package activity import ( "context" "fmt" "strings" "time" "bindbox-game/configs" "bindbox-game/internal/pkg/notify" "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", 30*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("订单查询失败或不存在") } // 状态前校验:仅处理已支付且未取消的抽奖订单 if order.Status != 2 || order.SourceType != 2 { return nil } aid := extractActivityID(order.Remark) iss := extractIssueID(order.Remark) dc := extractCount(order.Remark) icID := extractItemCardID(order.Remark) 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) for _, r := range rewardSettings { rewardMap[r.ID] = r } // 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) 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) } idxs, cnts := parseSlotsCountsFromRemark(order.Remark) // 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 := getSlotForIndex(i, idxs, cnts) if slot < 0 { continue } rid, proof, selErr = sel.SelectItemBySlot(ctx, aid, iss, slot) } else { rid, proof, selErr = sel.SelectItem(ctx, aid, iss, order.UserID) } if selErr != nil || rid <= 0 { s.logger.Error("选品失败", zap.Int64("draw_index", i), zap.Error(selErr)) continue } rw := rewardMap[rid] if rw == nil { 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 { continue } } _ = strat.SaveDrawReceipt(ctx, s.writeDB, log.ID, iss, order.UserID, proof) logMap[i] = log } // 补发奖励校验:统计该 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 { _, errGrant := s.user.GrantRewardToOrder(ctx, order.UserID, usersvc.GrantRewardToOrderRequest{ OrderID: order.ID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &aid, RewardID: &log.RewardID, Remark: rw.Name, }) if errGrant == nil { invCountMap[log.RewardID]++ // 内存计数同步 // 处理道具卡 if act != nil && act.AllowItemCards && icID > 0 { s.applyItemCardEffect(ctx, icID, aid, iss, log) } } else { s.logger.Error("奖励原子发放失败", zap.Int64("draw_index", i), zap.Error(errGrant)) } } } } // 4. 异步触发外部同步逻辑 (微信虚拟发货/通知) 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 } var rewardNames []string for _, lg := range drawLogs { if rw, _ := s.readDB.ActivityRewardSettings.WithContext(ctx).Where(s.readDB.ActivityRewardSettings.ID.Eq(lg.RewardID)).First(); rw != nil { rewardNames = append(rewardNames, rw.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 { _, _ = s.user.GrantRewardToOrder(ctx, log.UserID, usersvc.GrantRewardToOrderRequest{OrderID: log.OrderID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &aid, RewardID: &log.RewardID, Remark: rw.Name + "(倍数)"}) } } _, _ = 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 extractActivityID(remark string) int64 { return parseActivityIDFromRemark(remark) } func extractIssueID(remark string) int64 { return parseIssueIDFromRemark(remark) } func extractCount(remark string) int64 { return parseCountFromRemark(remark) } func extractItemCardID(remark string) int64 { return parseItemCardIDFromRemark(remark) } func parseActivityIDFromRemark(remark string) int64 { parts := strings.Split(remark, "|") for _, p := range parts { if strings.HasPrefix(p, "lottery:activity:") { return parseInt64(p[17:]) } if strings.HasPrefix(p, "activity:") { return parseInt64(p[9:]) } } return 0 } func parseIssueIDFromRemark(remark string) int64 { parts := strings.Split(remark, "|") for _, p := range parts { if strings.HasPrefix(p, "issue:") { return parseInt64(p[6:]) } } return 0 } func parseCountFromRemark(remark string) int64 { parts := strings.Split(remark, "|") for _, p := range parts { if strings.HasPrefix(p, "count:") { n := parseInt64(p[6:]) if n <= 0 { return 1 } return n } } return 1 } func parseItemCardIDFromRemark(remark string) int64 { parts := strings.Split(remark, "|") for _, seg := range parts { if len(seg) > 9 && seg[:9] == "itemcard:" { return parseInt64(seg[9:]) } } return 0 } 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) } func parseSlotsCountsFromRemark(remark string) ([]int64, []int64) { parts := strings.Split(remark, "|") for _, p := range parts { if strings.HasPrefix(p, "slots:") { pairs := p[6:] var idxs []int64 var cnts []int64 start := 0 for start < len(pairs) { end := start for end < len(pairs) && pairs[end] != ',' { end++ } if end > start { a := pairs[start:end] var x, y int64 j := 0 for j < len(a) && a[j] >= '0' && a[j] <= '9' { x = x*10 + int64(a[j]-'0') j++ } if j < len(a) && a[j] == ':' { j++ for j < len(a) && a[j] >= '0' && a[j] <= '9' { y = y*10 + int64(a[j]-'0') j++ } } if y > 0 { idxs = append(idxs, x+1) cnts = append(cnts, y) } } start = end + 1 } return idxs, cnts } } return nil, nil } func getSlotForIndex(drawIndex int64, idxs []int64, cnts []int64) int64 { var current int64 for i := 0; i < len(idxs); i++ { if drawIndex < current+cnts[i] { return idxs[i] - 1 } current += cnts[i] } return -1 }