package app import ( "bindbox-game/configs" "bindbox-game/internal/pkg/wechat" "bindbox-game/internal/repository/mysql/model" activitysvc "bindbox-game/internal/service/activity" usersvc "bindbox-game/internal/service/user" "context" "fmt" "strings" "sync" "time" "go.uber.org/zap" ) // grantRewardHelper 发放奖励辅助函数 func (h *handler) grantRewardHelper(ctx context.Context, userID, orderID int64, r *model.ActivityRewardSettings, quantity int, remark string) error { // 1. Grant to Order (Delegating stock check to user service) issue, _ := h.readDB.ActivityIssues.WithContext(ctx).Where(h.readDB.ActivityIssues.ID.Eq(r.IssueID)).First() var actID int64 if issue != nil { actID = issue.ActivityID } rid := r.ID _, err := h.user.GrantRewardToOrder(ctx, userID, usersvc.GrantRewardToOrderRequest{ OrderID: orderID, ProductID: r.ProductID, Quantity: quantity, ActivityID: &actID, RewardID: &rid, Remark: remark, }) if err != nil { h.logger.Error("Failed to grant reward to order", zap.Int64("order_id", orderID), zap.Error(err)) return err } // 2. Update Draw Log (IsWinner = 1) _, err = h.writeDB.ActivityDrawLogs.WithContext(ctx).Where( h.writeDB.ActivityDrawLogs.OrderID.Eq(orderID), ).Updates(&model.ActivityDrawLogs{ IsWinner: 1, RewardID: r.ID, Level: r.Level, }) return err } var matchingCleanupOnce sync.Once func (h *handler) startMatchingGameCleanup() { matchingCleanupOnce.Do(func() { go func() { h.logger.Info("对对碰自动开奖: 后台任务已启动") ticker := time.NewTicker(3 * time.Minute) // 每 3 分钟扫描一次 defer ticker.Stop() for range ticker.C { h.autoCheckExpiredGames() } }() }) } // autoCheckExpiredGames 扫描超时未结算的对对碰游戏并自动开奖 func (h *handler) autoCheckExpiredGames() { ctx := context.Background() // 1. 扫描 Redis 中所有 matching_game key keys, err := h.redis.Keys(ctx, activitysvc.MatchingGameKeyPrefix+"*").Result() if err != nil { h.logger.Error("对对碰自动开奖: 扫描 Redis 失败", zap.Error(err)) return } if len(keys) == 0 { return } h.logger.Debug("对对碰自动开奖: 扫描到游戏会话", zap.Int("count", len(keys))) for _, key := range keys { gameID := strings.TrimPrefix(key, activitysvc.MatchingGameKeyPrefix) if gameID == "" { continue } game, err := h.activity.GetMatchingGameFromRedis(ctx, gameID) if err != nil { continue } // 2. 检查是否超过 5 分钟 if game.CreatedAt.IsZero() || time.Since(game.CreatedAt) < 5*time.Minute { continue } // 3. 检查用户是否还在活跃(最近 2 分钟内有操作则跳过) if !game.LastActivity.IsZero() && time.Since(game.LastActivity) < 2*time.Minute { h.logger.Debug("对对碰自动开奖: 用户仍在活跃,跳过", zap.String("game_id", gameID), zap.Time("last_activity", game.LastActivity)) continue } // 3. 检查订单是否已支付 order, err := h.readDB.Orders.WithContext(ctx).Where(h.readDB.Orders.ID.Eq(game.OrderID)).First() if err != nil || order == nil || order.Status != 2 { continue } // 4. 检查是否已结算(幂等) existingLog, _ := h.readDB.ActivityDrawLogs.WithContext(ctx).Where( h.readDB.ActivityDrawLogs.OrderID.Eq(game.OrderID), h.readDB.ActivityDrawLogs.IsWinner.Eq(1), ).First() if existingLog != nil { // 已结算,清理 Redis _ = h.redis.Del(ctx, key) continue } // 5. 执行自动结算 h.logger.Info("对对碰自动开奖: 触发超时自动结算", zap.String("game_id", gameID), zap.Int64("order_id", game.OrderID), zap.Int64("user_id", game.UserID), zap.Time("created_at", game.CreatedAt), zap.Duration("elapsed", time.Since(game.CreatedAt))) h.doAutoCheck(ctx, gameID, game, order) } } // doAutoCheck 执行自动开奖结算(复用 CheckMatchingGame 核心逻辑) func (h *handler) doAutoCheck(ctx context.Context, gameID string, game *activitysvc.MatchingGame, order *model.Orders) { // 1. 并发锁 lockKey := fmt.Sprintf("lock:matching_game:check:%s", gameID) locked, err := h.redis.SetNX(ctx, lockKey, "1", 10*time.Second).Result() if err != nil || !locked { return } defer h.redis.Del(ctx, lockKey) // 2. 检查活动状态 activity, err := h.activity.GetActivity(ctx, game.ActivityID) if err != nil || activity == nil || activity.Status != 1 { h.logger.Warn("对对碰自动开奖: 活动状态异常", zap.Int64("activity_id", game.ActivityID)) return } // 3. 服务端模拟计算对数 serverSimulatedPairs := game.SimulateMaxPairs() h.logger.Debug("对对碰自动开奖: 服务端模拟", zap.Int64("simulated_pairs", serverSimulatedPairs), zap.String("position", game.Position)) game.TotalPairs = serverSimulatedPairs // 4. 发放奖励 var rewardInfo *MatchingRewardInfo rewards, err := h.activity.ListIssueRewards(ctx, game.IssueID) if err == nil && len(rewards) > 0 { var candidate *model.ActivityRewardSettings for _, r := range rewards { if r.Quantity <= 0 { continue } if serverSimulatedPairs == r.MinScore { candidate = r break } } if candidate != nil { productName := "" if p, _ := h.readDB.Products.WithContext(ctx).Where(h.readDB.Products.ID.Eq(candidate.ProductID)).First(); p != nil { productName = p.Name } finalRemark := fmt.Sprintf("%s %s (自动开奖)", order.OrderNo, productName) if err := h.grantRewardHelper(ctx, game.UserID, game.OrderID, candidate, 1, finalRemark); err != nil { h.logger.Error("对对碰自动开奖: 发放奖励失败", zap.Int64("order_id", game.OrderID), zap.Error(err)) } else { prodImage := "" if p, _ := h.readDB.Products.WithContext(ctx).Where(h.readDB.Products.ID.Eq(candidate.ProductID)).First(); p != nil { prodImage = getFirstImage(p.ImagesJSON) } rewardInfo = &MatchingRewardInfo{ RewardID: candidate.ID, Name: productName, ProductName: productName, ProductImage: prodImage, Level: candidate.Level, } h.logger.Info("对对碰自动开奖: 奖励发放成功", zap.Int64("order_id", game.OrderID), zap.String("product_name", productName), zap.Int32("level", candidate.Level)) } } } // 5. 虚拟发货 rewardName := "无奖励" if rewardInfo != nil { rewardName = rewardInfo.Name } go func(orderID int64, orderNo string, userID int64, rName string) { bgCtx := context.Background() tx, _ := h.readDB.PaymentTransactions.WithContext(bgCtx).Where(h.readDB.PaymentTransactions.OrderNo.Eq(orderNo)).First() if tx == nil || tx.TransactionID == "" { return } u, _ := h.readDB.Users.WithContext(bgCtx).Where(h.readDB.Users.ID.Eq(userID)).First() payerOpenid := "" if u != nil { payerOpenid = u.Openid } itemsDesc := fmt.Sprintf("对对碰 %s 赏品: %s (自动开奖)", orderNo, rName) if len(itemsDesc) > 120 { itemsDesc = itemsDesc[:120] } c := configs.Get() if err := wechat.UploadVirtualShippingForBackground(bgCtx, &wechat.WechatConfig{AppID: c.Wechat.AppID, AppSecret: c.Wechat.AppSecret}, tx.TransactionID, orderNo, payerOpenid, itemsDesc); err != nil { h.logger.Error("对对碰自动开奖: 虚拟发货失败", zap.Error(err)) } else { h.logger.Info("对对碰自动开奖: 虚拟发货成功", zap.String("order_no", orderNo)) } }(game.OrderID, order.OrderNo, game.UserID, rewardName) // 6. 清理 Redis 会话 _ = h.redis.Del(ctx, activitysvc.MatchingGameKeyPrefix+gameID) h.logger.Info("对对碰自动开奖: 结算完成", zap.String("game_id", gameID), zap.Int64("order_id", game.OrderID), zap.Int64("total_pairs", serverSimulatedPairs)) }