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 := ¬ify.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) }