bindbox-game/internal/api/activity/matching_game_helper.go
2026-02-18 23:23:34 +08:00

316 lines
9.9 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 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()
}
}()
})
}
// autoCheckDatabaseFallback 数据库扫描兜底防止Redis缓存过期导致漏单
func (h *handler) autoCheckDatabaseFallback() {
ctx := context.Background()
// 1. 查询 30分钟前~24小时内 已支付 但 未开奖 的对对碰订单 (SourceType=3)
// 这个时间窗口是为了避开正常游戏中的订单 (Redis TTL 30m)
startTime := time.Now().Add(-24 * time.Hour)
endTime := time.Now().Add(-30 * time.Minute)
// 使用 left join 排除已有日志的订单
var orderNos []string
err := h.readDB.Orders.WithContext(ctx).UnderlyingDB().Raw(`
SELECT o.order_no
FROM orders o
LEFT JOIN activity_draw_logs l ON o.id = l.order_id
WHERE o.source_type = 3
AND o.status = 2
AND o.created_at BETWEEN ? AND ?
AND l.id IS NULL
`, startTime, endTime).Scan(&orderNos).Error
if err != nil {
h.logger.Error("对对碰兜底扫描: 查询失败", zap.Error(err))
return
}
if len(orderNos) == 0 {
return
}
h.logger.Info("对对碰兜底扫描: 发现异常订单", zap.Int("count", len(orderNos)))
for _, orderNo := range orderNos {
// 2. 加载订单详情
order, err := h.readDB.Orders.WithContext(ctx).Where(h.readDB.Orders.OrderNo.Eq(orderNo)).First()
if err != nil || order == nil {
continue
}
// 3. 重构游戏状态
// 我们需要从 Seed, Position 等信息重构 Memory Graph
game, err := h.activity.ReconstructMatchingGame(ctx, orderNo)
if err != nil {
h.logger.Error("对对碰兜底扫描: 游戏状态重构失败", zap.String("order_no", orderNo), zap.Error(err))
continue
}
// 4. 重构 GameID (模拟)
// 注意:原始 GameID 可能丢失,这里我们并不真的需要精确的 Request GameID
// 因为 doAutoCheck 主要依赖 game 对象和 OrderID。
// 但为了锁的唯一性,我们使用 MG_FALLBACK_{OrderID}
fakeGameID := fmt.Sprintf("FALLBACK_%d", order.ID)
h.logger.Info("对对碰兜底扫描: 触发补单", zap.String("order_no", orderNo))
h.doAutoCheck(ctx, fakeGameID, game, order)
}
}
// autoCheckExpiredGames 扫描超时未结算的对对碰游戏并自动开奖
func (h *handler) autoCheckExpiredGames() {
ctx := context.Background()
// 0. 执行数据库兜底扫描 (低频执行,例如每次 autoCheck 都跑,或者加计数器)
// 由于 autoCheckHelper 是每3分钟跑一次这里直接调用损耗可控
// 且查询走了索引 (created_at)
h.autoCheckDatabaseFallback()
// 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
}
// 优先使用支付时的 openid
payerOpenid := tx.PayerOpenid
if payerOpenid == "" {
u, _ := h.readDB.Users.WithContext(bgCtx).Where(h.readDB.Users.ID.Eq(userID)).First()
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))
}