316 lines
9.9 KiB
Go
316 lines
9.9 KiB
Go
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))
|
||
}
|