bindbox-game/internal/service/activity/lottery_process.go

377 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package activity
import (
"context"
"fmt"
"strings"
"time"
"bindbox-game/configs"
"bindbox-game/internal/pkg/notify"
"bindbox-game/internal/pkg/util/remark"
"bindbox-game/internal/pkg/wechat"
"bindbox-game/internal/repository/mysql/model"
strat "bindbox-game/internal/service/activity/strategy"
usersvc "bindbox-game/internal/service/user"
"go.uber.org/zap"
)
// ProcessOrderLottery 处理订单开奖(统原子化高性能幂等逻辑)
func (s *service) ProcessOrderLottery(ctx context.Context, orderID int64) error {
s.logger.Info("开始原子化处理订单开奖", zap.Int64("order_id", orderID))
// 1. Redis 分布式锁:强制同一个订单串行处理,防止并发竞态引起的超发/漏发
lockKey := fmt.Sprintf("lock:lottery:order:%d", orderID)
locked, err := s.redis.SetNX(ctx, lockKey, "1", 120*time.Second).Result()
if err != nil {
return fmt.Errorf("分布式锁获取异常: %w", err)
}
if !locked {
s.logger.Info("订单开奖锁已存在,跳过本次处理", zap.Int64("order_id", orderID))
return nil
}
defer s.redis.Del(ctx, lockKey)
// 2. 批量预加载快照:将后续循环所需的查询合并为一次性加载
order, err := s.readDB.Orders.WithContext(ctx).Where(s.readDB.Orders.ID.Eq(orderID)).First()
if err != nil || order == nil {
return fmt.Errorf("订单查询失败或不存在")
}
// 状态前校验:仅处理已支付且未取消的抽奖订单
// SourceType: 2=Common Lottery (WeChat/Points mixed), 4=Game Pass (Pure)
if order.Status != 2 || (order.SourceType != 2 && order.SourceType != 4) {
return nil
}
rmk := remark.Parse(order.Remark)
aid := rmk.ActivityID
iss := rmk.IssueID
dc := rmk.Count
icID := rmk.ItemCardID
if aid <= 0 || iss <= 0 {
return fmt.Errorf("订单备注关键信息缺失")
}
orderNo := order.OrderNo
userID := order.UserID
// 2.1 批量加载配置
act, _ := s.readDB.Activities.WithContext(ctx).Where(s.readDB.Activities.ID.Eq(aid)).First()
actName := "活动"
playType := "default"
if act != nil {
actName = act.Name
playType = act.PlayType
}
rewardSettings, _ := s.readDB.ActivityRewardSettings.WithContext(ctx).Where(s.readDB.ActivityRewardSettings.IssueID.Eq(iss)).Find()
rewardMap := make(map[int64]*model.ActivityRewardSettings)
productIDs := make([]int64, 0, len(rewardSettings))
for _, r := range rewardSettings {
rewardMap[r.ID] = r
if r.ProductID > 0 {
productIDs = append(productIDs, r.ProductID)
}
}
// 批量预加载商品名称
productNameMap := make(map[int64]string)
if len(productIDs) > 0 {
products, _ := s.readDB.Products.WithContext(ctx).Where(s.readDB.Products.ID.In(productIDs...)).Find()
for _, p := range products {
productNameMap[p.ID] = p.Name
}
}
// 2.2 批量加载已有记录
existingLogs, _ := s.readDB.ActivityDrawLogs.WithContext(ctx).Where(s.readDB.ActivityDrawLogs.OrderID.Eq(orderID)).Order(s.readDB.ActivityDrawLogs.DrawIndex).Find()
logMap := make(map[int64]*model.ActivityDrawLogs)
for _, l := range existingLogs {
logMap[l.DrawIndex] = l
}
existingInventory, _ := s.readDB.UserInventory.WithContext(ctx).Where(s.readDB.UserInventory.OrderID.Eq(orderID)).Find()
invCountMap := make(map[int64]int64)
invTotalCount := int64(len(existingInventory)) // 总发放数量(用于无限赏模式检测)
for _, inv := range existingInventory {
invCountMap[inv.RewardID]++
}
// 2.3 策略准备
var sel strat.ActivityDrawStrategy
if act != nil && act.PlayType == "ichiban" {
sel = strat.NewIchiban(s.readDB, s.writeDB)
} else {
sel = strat.NewDefault(s.readDB, s.writeDB)
}
// 3. 核心循环:基于内存快照进行原子发放
// 开始开奖循环
for i := int64(0); i < dc; i++ {
log, exists := logMap[i]
if !exists {
// 选品:如果日志不存在,则根据策略选出一个奖品 ID
var rid int64
var proof map[string]any
var selErr error
if act != nil && act.PlayType == "ichiban" {
slot := rmk.GetSlotAtIndex(i)
if slot < 0 {
s.logger.Warn("一番赏格位无效,跳过", zap.Int64("draw_index", i), zap.Int64("slot", slot))
continue
}
rid, proof, selErr = sel.SelectItemBySlot(ctx, aid, iss, slot)
} else {
// 使用预加载的数据进行选品,避免每次循环都查询数据库
if defSel, ok := sel.(*strat.DefaultStrategy); ok {
rid, proof, selErr = defSel.SelectItemFromCache(rewardSettings, act.CommitmentSeedMaster, iss, order.UserID)
} else {
rid, proof, selErr = sel.SelectItem(ctx, aid, iss, order.UserID)
}
}
if selErr != nil || rid <= 0 {
s.logger.Error("选品失败", zap.Int64("order_id", orderID), zap.Int64("draw_index", i), zap.Int64("rid", rid), zap.Error(selErr))
continue
}
rw := rewardMap[rid]
if rw == nil {
s.logger.Warn("奖品配置不存在,跳过", zap.Int64("draw_index", i), zap.Int64("reward_id", rid))
continue
}
// 写入开奖日志 (依靠数据库 (order_id, draw_index) 唯一键保证绝对幂等)
log = &model.ActivityDrawLogs{
UserID: order.UserID, IssueID: iss, OrderID: order.ID,
RewardID: rid, IsWinner: 1, Level: rw.Level, CurrentLevel: 1, DrawIndex: i,
}
if errLog := s.writeDB.ActivityDrawLogs.WithContext(ctx).Create(log); errLog != nil {
// 并发情况:回退并尝试复用已有记录
log, _ = s.readDB.ActivityDrawLogs.WithContext(ctx).Where(s.readDB.ActivityDrawLogs.OrderID.Eq(orderID), s.readDB.ActivityDrawLogs.DrawIndex.Eq(i)).First()
if log == nil {
s.logger.Error("创建开奖日志失败且无法复用", zap.Int64("draw_index", i), zap.Error(errLog))
continue
}
}
_ = strat.SaveDrawReceipt(ctx, s.writeDB, log.ID, iss, order.UserID, proof)
logMap[i] = log
}
}
// 4. 批量发放奖励(单事务处理,大幅提升性能)
// 收集所有需要发放的奖励项
var batchItems []usersvc.BatchRewardItem
var effectLogs []*model.ActivityDrawLogs // 需要处理道具卡效果的日志
// 无限赏模式下使用总数检测因为inventory.RewardID=0
// 如果已发放总数已达到开奖数量,说明已完成发放,跳过后续逻辑
if invTotalCount >= dc {
s.logger.Info("奖励已全部发放,跳过重复发放", zap.Int64("order_id", orderID), zap.Int64("dc", dc), zap.Int64("invTotalCount", invTotalCount))
} else {
for i := int64(0); i < dc; i++ {
log, ok := logMap[i]
if !ok || log == nil {
continue
}
// 统计该 RewardID 应得数量
needed := int64(0)
for j := int64(0); j <= i; j++ {
if l, ok := logMap[j]; ok && l.RewardID == log.RewardID {
needed++
}
}
// 检查是否需要发放
if invCountMap[log.RewardID] < needed {
rw := rewardMap[log.RewardID]
if rw != nil {
var rewardIDRef *int64
if act != nil && act.PlayType == "ichiban" {
rewardIDRef = &log.RewardID
}
batchItems = append(batchItems, usersvc.BatchRewardItem{
ProductID: rw.ProductID,
RewardID: rewardIDRef,
ActivityID: aid,
Remark: productNameMap[rw.ProductID],
})
invCountMap[log.RewardID]++ // 内存计数同步
// 记录需要处理道具卡效果的日志
if act != nil && act.AllowItemCards && icID > 0 {
effectLogs = append(effectLogs, log)
}
}
}
}
}
// 批量发放奖励(单个事务)
if len(batchItems) > 0 {
_, errGrant := s.user.BatchGrantRewardsToOrder(ctx, order.UserID, order.ID, batchItems)
if errGrant != nil {
s.logger.Error("批量发放奖励失败", zap.Int64("order_id", orderID), zap.Int("count", len(batchItems)), zap.Error(errGrant))
} else {
// 处理道具卡效果 - 只对第一发生效(翻倍卡只翻倍一次)
if len(effectLogs) > 0 {
s.applyItemCardEffect(ctx, icID, aid, iss, effectLogs[0])
}
}
}
s.logger.Info("开奖完成", zap.Int64("order_id", orderID), zap.Int("completed", len(logMap)))
// 5. 异步触发外部同步逻辑 (微信虚拟发货/通知)
if order.IsConsumed == 0 {
go s.TriggerVirtualShipping(context.Background(), orderID, orderNo, userID, aid, actName, playType)
}
return nil
}
// TriggerVirtualShipping 触发虚拟发货同步到微信
func (s *service) TriggerVirtualShipping(ctx context.Context, orderID int64, orderNo string, userID int64, aid int64, actName string, playType string) {
drawLogs, _ := s.readDB.ActivityDrawLogs.WithContext(ctx).Where(s.readDB.ActivityDrawLogs.OrderID.Eq(orderID)).Find()
if len(drawLogs) == 0 {
return
}
// 批量获取 reward 信息
rewardIDs := make([]int64, 0, len(drawLogs))
for _, lg := range drawLogs {
rewardIDs = append(rewardIDs, lg.RewardID)
}
rewards, _ := s.readDB.ActivityRewardSettings.WithContext(ctx).Where(s.readDB.ActivityRewardSettings.ID.In(rewardIDs...)).Find()
rewardMap := make(map[int64]*model.ActivityRewardSettings)
productIDs := make([]int64, 0, len(rewards))
for _, r := range rewards {
rewardMap[r.ID] = r
if r.ProductID > 0 {
productIDs = append(productIDs, r.ProductID)
}
}
// 批量获取商品信息,通过 ProductID 关联查询名称
productMap := make(map[int64]*model.Products)
if len(productIDs) > 0 {
products, _ := s.readDB.Products.WithContext(ctx).Where(s.readDB.Products.ID.In(productIDs...)).Find()
for _, p := range products {
productMap[p.ID] = p
}
}
// 构建奖品名称列表,使用商品名称
var rewardNames []string
for _, lg := range drawLogs {
if rw, ok := rewardMap[lg.RewardID]; ok && rw != nil {
name := ""
// 使用商品名称
if rw.ProductID > 0 {
if p, ok := productMap[rw.ProductID]; ok && p != nil && p.Name != "" {
name = p.Name
}
}
if name != "" {
rewardNames = append(rewardNames, name)
}
}
}
itemsDesc := actName + " " + orderNo + " 盲盒赏品: " + strings.Join(rewardNames, ", ")
itemsDesc = utf8SafeTruncate(itemsDesc, 110) // 微信限制 128 字节,我们保守一点截断到 110
tx, _ := s.readDB.PaymentTransactions.WithContext(ctx).Where(s.readDB.PaymentTransactions.OrderNo.Eq(orderNo)).First()
if tx == nil || tx.TransactionID == "" {
return
}
u, _ := s.readDB.Users.WithContext(ctx).Where(s.readDB.Users.ID.Eq(userID)).First()
payerOpenid := ""
if u != nil {
payerOpenid = u.Openid
}
c := configs.Get()
cfg := &wechat.WechatConfig{AppID: c.Wechat.AppID, AppSecret: c.Wechat.AppSecret}
errUpload := wechat.UploadVirtualShippingForBackground(ctx, cfg, tx.TransactionID, orderNo, payerOpenid, itemsDesc)
// 如果发货成功,或者微信提示已经发过货了(10060003),则标记本地订单为已履约
if errUpload == nil || strings.Contains(errUpload.Error(), "10060003") {
_, _ = s.writeDB.Orders.WithContext(ctx).Where(s.readDB.Orders.ID.Eq(orderID)).Update(s.readDB.Orders.IsConsumed, 1)
if errUpload != nil {
s.logger.Info("[虚拟发货] 微信反馈已处理,更新本地标记", zap.String("order_no", orderNo))
}
} else if errUpload != nil {
s.logger.Error("[虚拟发货] 上传失败", zap.Error(errUpload), zap.String("order_no", orderNo))
}
if playType == "ichiban" {
notifyCfg := &notify.WechatNotifyConfig{
AppID: c.Wechat.AppID,
AppSecret: c.Wechat.AppSecret,
LotteryResultTemplateID: c.Wechat.LotteryResultTemplateID,
}
_ = notify.SendLotteryResultNotification(ctx, notifyCfg, payerOpenid, actName, rewardNames, orderNo, time.Now())
}
}
func (s *service) applyItemCardEffect(ctx context.Context, icID int64, aid int64, iss int64, log *model.ActivityDrawLogs) {
uic, _ := s.readDB.UserItemCards.WithContext(ctx).Where(s.readDB.UserItemCards.ID.Eq(icID), s.readDB.UserItemCards.UserID.Eq(log.UserID), s.readDB.UserItemCards.Status.Eq(1)).First()
if uic == nil {
return
}
ic, _ := s.readDB.SystemItemCards.WithContext(ctx).Where(s.readDB.SystemItemCards.ID.Eq(uic.CardID), s.readDB.SystemItemCards.Status.Eq(1)).First()
now := time.Now()
if ic != nil && !uic.ValidStart.After(now) && !uic.ValidEnd.Before(now) {
scopeOK := (ic.ScopeType == 1) || (ic.ScopeType == 3 && ic.ActivityID == aid) || (ic.ScopeType == 4 && ic.IssueID == iss)
if scopeOK {
if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 {
rw, _ := s.readDB.ActivityRewardSettings.WithContext(ctx).Where(s.readDB.ActivityRewardSettings.ID.Eq(log.RewardID)).First()
if rw != nil {
// 获取商品名称
prodName := ""
if prod, _ := s.readDB.Products.WithContext(ctx).Where(s.readDB.Products.ID.Eq(rw.ProductID)).First(); prod != nil {
prodName = prod.Name
}
_, _ = s.user.GrantRewardToOrder(ctx, log.UserID, usersvc.GrantRewardToOrderRequest{OrderID: log.OrderID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &aid, RewardID: &log.RewardID, Remark: prodName + "(倍数)"})
}
}
_, _ = s.writeDB.UserItemCards.WithContext(ctx).Where(s.readDB.UserItemCards.ID.Eq(icID)).Updates(map[string]any{
"status": 2,
"used_draw_log_id": log.ID,
"used_activity_id": aid,
"used_issue_id": iss,
"used_at": now,
})
}
}
}
func parseInt64(s string) int64 {
var n int64
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
break
}
n = n*10 + int64(s[i]-'0')
}
return n
}
// utf8SafeTruncate 按字节长度截断,并过滤掉无效/损坏的 UTF-8 字符
func utf8SafeTruncate(s string, n int) string {
r := []rune(s)
var res []rune
var byteLen int
for _, val := range r {
// 过滤掉无效字符标识 \ufffd防止微信 API 报错
if val == '\uFFFD' {
continue
}
l := len(string(val))
if byteLen+l > n {
break
}
res = append(res, val)
byteLen += l
}
return string(res)
}