bindbox-game/internal/api/activity/matching_game_app.go
2025-12-26 12:22:32 +08:00

654 lines
24 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/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/pkg/wechat"
"bindbox-game/internal/repository/mysql/model"
activitysvc "bindbox-game/internal/service/activity"
"context"
"crypto/rand"
"encoding/binary"
"encoding/hex"
"fmt"
"net/http"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// ========== API Handlers ==========
type matchingGamePreOrderRequest struct {
IssueID int64 `json:"issue_id"`
Position string `json:"position"`
CouponID *int64 `json:"coupon_id"`
ItemCardID *int64 `json:"item_card_id"`
}
type matchingGamePreOrderResponse struct {
GameID string `json:"game_id"`
OrderNo string `json:"order_no"`
PayStatus int32 `json:"pay_status"` // 1=Pending, 2=Paid
ServerSeedHash string `json:"server_seed_hash"`
// AllCards 已移除:游戏数据需通过 GetMatchingGameCards 接口在支付成功后获取
}
type matchingGameCheckRequest struct {
GameID string `json:"game_id" binding:"required"`
TotalPairs int64 `json:"total_pairs"` // 客户端上报的消除总对数
}
type MatchingRewardInfo struct {
RewardID int64 `json:"reward_id"`
Name string `json:"name"`
Level int32 `json:"level"`
}
type matchingGameCheckResponse struct {
GameID string `json:"game_id"`
TotalPairs int64 `json:"total_pairs"`
Finished bool `json:"finished"`
Reward *MatchingRewardInfo `json:"reward,omitempty"`
}
// PreOrderMatchingGame 下单并预生成对对碰游戏数据
// @Summary 下单并获取对对碰全量数据
// @Description 用户下单服务器扣费并返回全量99张乱序卡牌前端自行负责游戏流程
// @Tags APP端.活动
// @Accept json
// @Produce json
// @Security LoginVerifyToken
// @Param RequestBody body matchingGamePreOrderRequest true "请求参数"
// @Success 200 {object} matchingGamePreOrderResponse
// @Failure 400 {object} code.Failure
// @Router /api/app/matching/preorder [post]
func (h *handler) PreOrderMatchingGame() core.HandlerFunc {
// 启动清理协程(Lazy Init)
h.startMatchingGameCleanup()
return func(ctx core.Context) {
userID := int64(ctx.SessionUserInfo().Id)
req := new(matchingGamePreOrderRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
// 1. Get Activity/Issue Info (Mocking price for now or fetching if available)
// Assuming price is fixed or fetched. Let's fetch basic activity info if possible, or assume config.
// Since Request has IssueID, let's fetch Issue to get ActivityID and Price.
// Note: The current handler doesn't have easy access to Issue struct helper without exporting or duplicating.
// We will assume `req.IssueID` is valid and fetch price via `h.activity.GetActivity` if we had ActivityID.
// But req only has IssueID. Let's look up Issue first.
issue, err := h.writeDB.ActivityIssues.WithContext(ctx.RequestContext()).Where(h.writeDB.ActivityIssues.ID.Eq(req.IssueID)).First()
if err != nil || issue == nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170001, "issue not found"))
return
}
activity, err := h.activity.GetActivity(ctx.RequestContext(), issue.ActivityID)
if err != nil || activity == nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170001, "activity not found"))
return
}
if activity.Status != 1 {
ctx.AbortWithError(core.Error(http.StatusNotFound, 170001, "该活动已下线"))
return
}
// Validation
if !activity.AllowCoupons && req.CouponID != nil && *req.CouponID > 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170009, "本活动不支持优惠券"))
return
}
if !activity.AllowItemCards && req.ItemCardID != nil && *req.ItemCardID > 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170010, "本活动不支持道具卡"))
return
}
// 2. Create Order using ActivityOrderService
var couponID *int64
if activity.AllowCoupons && req.CouponID != nil && *req.CouponID > 0 {
couponID = req.CouponID
}
var itemCardID *int64
if activity.AllowItemCards && req.ItemCardID != nil && *req.ItemCardID > 0 {
itemCardID = req.ItemCardID
}
orderResult, err := h.activityOrder.CreateActivityOrder(ctx, activitysvc.CreateActivityOrderRequest{
UserID: userID,
ActivityID: issue.ActivityID,
IssueID: req.IssueID,
Count: 1,
UnitPrice: activity.PriceDraw,
SourceType: 3, // 对对碰
CouponID: couponID,
ItemCardID: itemCardID,
ExtraRemark: fmt.Sprintf("matching_game:issue:%d", req.IssueID),
})
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 170002, err.Error()))
return
}
order := orderResult.Order
// 2. 加载配置
configs, err := loadCardTypesFromDB(ctx.RequestContext(), h.readDB)
if err != nil || len(configs) == 0 {
configs = []CardTypeConfig{
{Code: "A", Name: "类型A", Quantity: 9},
{Code: "B", Name: "类型B", Quantity: 9},
{Code: "C", Name: "类型C", Quantity: 9},
{Code: "D", Name: "类型D", Quantity: 9},
{Code: "E", Name: "类型E", Quantity: 9},
{Code: "F", Name: "类型F", Quantity: 9},
{Code: "G", Name: "类型G", Quantity: 9},
{Code: "H", Name: "类型H", Quantity: 9},
{Code: "I", Name: "类型I", Quantity: 9},
{Code: "J", Name: "类型J", Quantity: 9},
{Code: "K", Name: "类型K", Quantity: 9},
}
}
// 3. 创建游戏并洗牌
// 使用 Activity Commitment 作为随机源(必须存在)
if len(activity.CommitmentSeedMaster) == 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170011, "活动尚未生成承诺,无法开始游戏"))
return
}
game := NewMatchingGameWithConfig(configs, req.Position, activity.CommitmentSeedMaster)
if game == nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170011, "活动尚未生成承诺,无法开始游戏"))
return
}
game.ActivityID = issue.ActivityID
game.IssueID = req.IssueID
game.OrderID = order.ID
game.UserID = userID
// 4. 构造 AllCards (仅需返回 Flat List)
// game.deck 包含了所有的牌(已洗好,且包含了 board[0..8] 因为 NewMatchingGameWithConfig 中我们是从 deck 发到 board 的)
// 但 NewMatchingGameWithConfig 目前的逻辑是:生成 -> 洗牌 -> 发前9张到 board -> deck只剩剩下的。
// 所以我们需要把 board 和 deck 拼起来。
allCards := make([]MatchingCard, 0, 99)
for _, c := range game.Board {
if c != nil {
allCards = append(allCards, *c)
}
}
for _, c := range game.Deck {
allCards = append(allCards, *c)
}
// 5. 生成GameID并存储 (主要用于 Check 时校验存在性,或者验签)
gameID := fmt.Sprintf("MG%d%d", userID, time.Now().UnixNano())
// Save to Redis
if err := h.saveGameToRedis(ctx.RequestContext(), gameID, game); err != nil {
h.logger.Error("Failed to save matching game session", zap.Error(err))
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, "failed to create game session"))
return
}
// 6. Save Verification Data (ActivityDrawLogs + ActivityDrawReceipts)
// This is required for the "Verification" feature in App/Admin to work.
// A "Matching Game" session is treated as one "Draw".
// 6.1 Create DrawLog
drawLog := &model.ActivityDrawLogs{
UserID: userID,
IssueID: req.IssueID,
OrderID: order.ID,
CreatedAt: time.Now(),
IsWinner: 0, // Will be updated if they win prizes at `Check`? Or just 0 for participation.
Level: 0,
}
_ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(drawLog)
// 6.2 Create DrawReceipt
if drawLog.ID > 0 {
receipt := &model.ActivityDrawReceipts{
CreatedAt: time.Now(),
DrawLogID: drawLog.ID,
AlgoVersion: "HMAC-SHA256-v1",
RoundID: req.IssueID,
DrawID: time.Now().UnixNano(), // Use timestamp to ensure uniqueness as we don't have real DrawID
ClientID: userID,
Timestamp: time.Now().UnixMilli(),
ServerSeedHash: game.ServerSeedHash,
ServerSubSeed: "", // Matching game generic seed
ClientSeed: req.Position, // Use Position as ClientSeed
Nonce: 0,
ItemsRoot: "", // Could enable if we hashed the deck
WeightsTotal: 0,
SelectedIndex: 0,
RandProof: "",
Signature: "",
}
// Hex encode server seed
receipt.ServerSubSeed = hex.EncodeToString(game.ServerSeed)
_ = h.writeDB.ActivityDrawReceipts.WithContext(ctx.RequestContext()).Create(receipt)
}
// 7. 返回数据(不返回 all_cards需支付成功后通过 GetMatchingGameCards 获取)
rsp := &matchingGamePreOrderResponse{
GameID: gameID,
OrderNo: order.OrderNo,
PayStatus: order.Status,
ServerSeedHash: game.ServerSeedHash,
}
ctx.Payload(rsp)
}
}
// CheckMatchingGame 游戏结束结算校验
// @Summary 游戏结束结算校验
// @Description 前端游戏结束后上报结果,服务器发放奖励
// @Tags APP端.活动
// @Accept json
// @Produce json
// @Security LoginVerifyToken
// @Param RequestBody body matchingGameCheckRequest true "请求参数"
// @Success 200 {object} matchingGameCheckResponse
// @Failure 400 {object} code.Failure
// @Router /api/app/matching/check [post]
func (h *handler) CheckMatchingGame() core.HandlerFunc {
return func(ctx core.Context) {
req := new(matchingGameCheckRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
game, err := h.loadGameFromRedis(ctx.RequestContext(), req.GameID)
if err != nil {
if err == redis.Nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "game session not found or expired"))
} else {
h.logger.Error("Failed to load matching game session", zap.Error(err))
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, "internal server error"))
}
return
}
// 校验:不能超过理论最大对数
// 【关键校验】检查订单是否已支付
// 对对碰游戏必须先支付才能结算和发奖
order, err := h.readDB.Orders.WithContext(ctx.RequestContext()).Where(h.readDB.Orders.ID.Eq(game.OrderID)).First()
if err != nil || order == nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170003, "订单不存在"))
return
}
if order.Status != 2 {
fmt.Printf("[对对碰Check] ⏳ 订单支付确认中 order_id=%d status=%d等待回调完成\n", order.ID, order.Status)
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170004, "支付确认中,请稍后重试"))
return
}
// 检查活动状态
activity, err := h.activity.GetActivity(ctx.RequestContext(), game.ActivityID)
if err != nil || activity == nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170001, "活动不存在"))
return
}
if activity.Status != 1 {
ctx.AbortWithError(core.Error(http.StatusNotFound, 170001, "该活动已下线"))
return
}
// 校验:不能超过理论最大对数
fmt.Printf("[对对碰Check] 校验对子数量: 客户端提交=%d 服务端计算最大值=%d GameID=%s\n", req.TotalPairs, game.MaxPossiblePairs, req.GameID)
if req.TotalPairs > game.MaxPossiblePairs {
fmt.Printf("[对对碰Check] ❌ 校验失败: 提交数量超过理论最大值\n")
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, fmt.Sprintf("invalid total pairs: %d > %d", req.TotalPairs, game.MaxPossiblePairs)))
return
}
fmt.Printf("[对对碰Check] ✅ 校验通过\n")
game.TotalPairs = req.TotalPairs // 记录一下
var rewardInfo *MatchingRewardInfo
// 1. Fetch Rewards
rewards, err := h.activity.ListIssueRewards(ctx.RequestContext(), game.IssueID)
if err == nil && len(rewards) > 0 {
// 2. Filter & Sort
var candidate *model.ActivityRewardSettings
for _, r := range rewards {
if r.Quantity <= 0 {
continue
}
// 精确匹配:用户消除的对子数 == 奖品设置的对子数
if int64(req.TotalPairs) == r.MinScore {
candidate = r
break // 找到精确匹配,直接使用
}
}
if candidate != nil {
// 3. Prepare Grant Params
// Fetch real product name for remark
productName := candidate.Name
if p, _ := h.readDB.Products.WithContext(ctx.RequestContext()).Where(h.readDB.Products.ID.Eq(candidate.ProductID)).First(); p != nil {
productName = p.Name
}
finalReward := candidate
finalQuantity := 1
finalRemark := fmt.Sprintf("%s %s", order.OrderNo, productName)
var cardToVoid int64 = 0
// 4. Apply Item Card Effects (Determine final reward and quantity)
icID := parseItemCardIDFromRemark(order.Remark)
fmt.Printf("[CheckMatchingGame] Debug: OrderNo=%s Remark=%s icID=%d\n", order.OrderNo, order.Remark, icID)
if icID > 0 {
uic, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).Where(
h.readDB.UserItemCards.ID.Eq(icID),
h.readDB.UserItemCards.UserID.Eq(game.UserID),
).First()
if uic == nil {
fmt.Printf("[CheckMatchingGame] ❌ UserItemCard not found: icID=%d userID=%d\n", icID, game.UserID)
} else if uic.Status != 1 {
fmt.Printf("[CheckMatchingGame] ❌ UserItemCard invalid status: status=%d\n", uic.Status)
} else { // Status == 1
ic, _ := h.readDB.SystemItemCards.WithContext(ctx.RequestContext()).Where(
h.readDB.SystemItemCards.ID.Eq(uic.CardID),
h.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 == game.ActivityID) || (ic.ScopeType == 4 && ic.IssueID == game.IssueID)
fmt.Printf("[道具卡-CheckMatchingGame] 范围检查 ScopeType=%d ActivityID=%d IssueID=%d scopeOK=%t\n", ic.ScopeType, game.ActivityID, game.IssueID, scopeOK)
if scopeOK {
cardToVoid = icID
if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 {
// Double reward
fmt.Printf("[道具卡-CheckMatchingGame] ✅ 应用双倍奖励 倍数=%d\n", ic.RewardMultiplierX1000)
finalQuantity = 2
finalRemark += "(倍数)"
} else if ic.EffectType == 2 && ic.BoostRateX1000 > 0 {
// Probability boost - try to upgrade to better reward
fmt.Printf("[道具卡-CheckMatchingGame] 应用概率提升 BoostRateX1000=%d\n", ic.BoostRateX1000)
allRewards, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(
h.readDB.ActivityRewardSettings.IssueID.Eq(game.IssueID),
).Find()
var better *model.ActivityRewardSettings
for _, r := range allRewards {
if r.MinScore > candidate.MinScore && r.Quantity > 0 {
if better == nil || r.MinScore < better.MinScore {
better = r
}
}
}
if better != nil {
// Use crypto/rand for secure random
randBytes := make([]byte, 4)
rand.Read(randBytes)
randVal := int32(binary.BigEndian.Uint32(randBytes) % 1000)
fmt.Printf("[道具卡-CheckMatchingGame] 概率检定: rand=%d threshold=%d\n", randVal, ic.BoostRateX1000)
if randVal < ic.BoostRateX1000 {
fmt.Printf("[道具卡-CheckMatchingGame] ✅ 概率提升成功 升级到奖品ID=%d 奖品名=%s\n", better.ID, better.Name)
finalReward = better
finalRemark = better.Name + "(升级)"
} else {
fmt.Printf("[道具卡-CheckMatchingGame] ❌ 概率提升失败\n")
}
} else {
fmt.Printf("[道具卡-CheckMatchingGame] ⚠️ 未找到更好的奖品可升级 (currentMinScore=%d)\n", candidate.MinScore)
}
}
} else {
fmt.Printf("[道具卡-CheckMatchingGame] ❌ 范围校验失败\n")
}
} else {
fmt.Printf("[道具卡-CheckMatchingGame] ❌ 时间或系统卡状态无效: IC=%v ValidStart=%v ValidEnd=%v Now=%v\n", ic != nil, uic.ValidStart, uic.ValidEnd, now)
}
}
}
// 5. Grant Reward
if err := h.grantRewardHelper(ctx.RequestContext(), game.UserID, game.OrderID, finalReward, finalQuantity, finalRemark); err != nil {
h.logger.Error("Failed to grant matching reward", zap.Int64("order_id", game.OrderID), zap.Error(err))
} else {
rewardInfo = &MatchingRewardInfo{
RewardID: finalReward.ID,
Name: productName,
Level: finalReward.Level,
}
// 6. Void Item Card (if used)
if cardToVoid > 0 {
fmt.Printf("[道具卡-CheckMatchingGame] 核销道具卡 用户道具卡ID=%d\n", cardToVoid)
now := time.Now()
// Get DrawLog ID for the order
drawLog, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Where(
h.readDB.ActivityDrawLogs.OrderID.Eq(game.OrderID),
).First()
var drawLogID int64
if drawLog != nil {
drawLogID = drawLog.ID
}
_, _ = h.writeDB.UserItemCards.WithContext(ctx.RequestContext()).Where(
h.writeDB.UserItemCards.ID.Eq(cardToVoid),
h.writeDB.UserItemCards.UserID.Eq(game.UserID),
h.writeDB.UserItemCards.Status.Eq(1),
).Updates(map[string]any{
h.writeDB.UserItemCards.Status.ColumnName().String(): 2,
h.writeDB.UserItemCards.UsedDrawLogID.ColumnName().String(): drawLogID,
h.writeDB.UserItemCards.UsedActivityID.ColumnName().String(): game.ActivityID,
h.writeDB.UserItemCards.UsedIssueID.ColumnName().String(): game.IssueID,
h.writeDB.UserItemCards.UsedAt.ColumnName().String(): now,
})
}
}
}
}
rsp := &matchingGameCheckResponse{
GameID: req.GameID,
TotalPairs: req.TotalPairs,
Finished: true,
Reward: rewardInfo,
}
// 7. Virtual Shipping (Async)
// Upload shipping info to WeChat (similar to Ichiban Kuji) so user can see "Shipped" status and reward info.
rewardName := "无奖励"
if rewardInfo != nil {
rewardName = rewardInfo.Name
}
go func(orderID int64, orderNo string, userID int64, rName string) {
bgCtx := context.Background()
// 1. Get Payment Transaction
tx, _ := h.readDB.PaymentTransactions.WithContext(bgCtx).Where(h.readDB.PaymentTransactions.OrderNo.Eq(orderNo)).First()
if tx == nil || tx.TransactionID == "" {
h.logger.Warn("CheckMatchingGame: No payment transaction found for shipping", zap.String("order_no", orderNo))
return
}
// 2. Get User OpenID
u, _ := h.readDB.Users.WithContext(bgCtx).Where(h.readDB.Users.ID.Eq(userID)).First()
payerOpenid := ""
if u != nil {
payerOpenid = u.Openid
}
// 3. Construct Item Desc
itemsDesc := fmt.Sprintf("对对碰 %s 赏品: %s", orderNo, rName)
if len(itemsDesc) > 120 {
itemsDesc = itemsDesc[:120]
}
// 4. Upload
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("CheckMatchingGame: Failed to upload virtual shipping", zap.Error(err))
} else {
h.logger.Info("CheckMatchingGame: Virtual shipping uploaded", zap.String("order_no", orderNo), zap.String("items", itemsDesc))
}
}(game.OrderID, order.OrderNo, game.UserID, rewardName)
// 结算完成,清理会话 (Delete from Redis)
_ = h.redis.Del(ctx.RequestContext(), matchingGameKeyPrefix+req.GameID)
ctx.Payload(rsp)
}
}
// GetMatchingGameState 获取对对碰游戏状态
// @Summary 获取对对碰游戏状态
// @Description 获取当前游戏的完整状态
// @Tags APP端.活动
// @Accept json
// @Produce json
// @Security LoginVerifyToken
// @Param game_id query string true "游戏ID"
// @Success 200 {object} map[string]any
// @Failure 400 {object} code.Failure
// @Router /api/app/matching/state [get]
func (h *handler) GetMatchingGameState() core.HandlerFunc {
return func(ctx core.Context) {
gameID := ctx.RequestInputParams().Get("game_id")
if gameID == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "game_id required"))
return
}
game, err := h.loadGameFromRedis(ctx.RequestContext(), gameID)
if err != nil {
if err == redis.Nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "game session not found"))
} else {
h.logger.Error("Failed to load matching game", zap.Error(err))
}
return
}
// Keep-Alive: Refresh Redis TTL
h.redis.Expire(ctx.RequestContext(), matchingGameKeyPrefix+gameID, 30*time.Minute)
// 检查活动状态
activity, err := h.activity.GetActivity(ctx.RequestContext(), game.ActivityID)
if err != nil || activity == nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170001, "活动不存在"))
return
}
if activity.Status != 1 {
ctx.AbortWithError(core.Error(http.StatusNotFound, 170001, "该活动已下线"))
return
}
ctx.Payload(game.GetGameState())
}
}
// ListMatchingCardTypes 列出对对碰卡牌类型App端枚举
// @Summary 列出对对碰卡牌类型
// @Description 获取所有启用的卡牌类型配置用于App端预览或动画展示
// @Tags APP端.活动
// @Accept json
// @Produce json
// @Success 200 {array} CardTypeConfig
// @Failure 400 {object} code.Failure
// @Router /api/app/matching/card_types [get]
func (h *handler) ListMatchingCardTypes() core.HandlerFunc {
return func(ctx core.Context) {
configs, err := loadCardTypesFromDB(ctx.RequestContext(), h.readDB)
if err != nil {
// Try to serve default configs if DB fails? Or just error safely.
// Let's rely on DB being available.
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ParamBindError, err.Error()))
return
}
ctx.Payload(configs)
}
}
// matchingGameCardsResponse 游戏数据响应
type matchingGameCardsResponse struct {
GameID string `json:"game_id"`
AllCards []MatchingCard `json:"all_cards"`
}
// GetMatchingGameCards 支付成功后获取游戏数据
// @Summary 获取对对碰游戏数据
// @Description 只有支付成功后才能获取游戏牌组数据,防止未支付用户获取牌组信息
// @Tags APP端.活动
// @Accept json
// @Produce json
// @Security LoginVerifyToken
// @Param game_id query string true "游戏ID"
// @Success 200 {object} matchingGameCardsResponse
// @Failure 400 {object} code.Failure
// @Router /api/app/matching/cards [get]
func (h *handler) GetMatchingGameCards() core.HandlerFunc {
return func(ctx core.Context) {
gameID := ctx.RequestInputParams().Get("game_id")
if gameID == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "game_id required"))
return
}
// 1. 从 Redis 加载游戏数据
game, err := h.loadGameFromRedis(ctx.RequestContext(), gameID)
if err != nil {
if err == redis.Nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "game session not found or expired"))
} else {
h.logger.Error("Failed to load matching game", zap.Error(err))
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, "internal server error"))
}
return
}
// 2. 【关键校验】检查订单是否已支付
order, err := h.readDB.Orders.WithContext(ctx.RequestContext()).Where(h.readDB.Orders.ID.Eq(game.OrderID)).First()
if err != nil || order == nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170003, "订单不存在"))
return
}
if order.Status != 2 {
fmt.Printf("[GetMatchingGameCards] ⏳ 订单未支付 order_id=%d status=%d拒绝返回游戏数据\n", order.ID, order.Status)
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170004, "请先完成支付"))
return
}
// 3. 检查活动状态
activity, err := h.activity.GetActivity(ctx.RequestContext(), game.ActivityID)
if err != nil || activity == nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170001, "活动不存在"))
return
}
if activity.Status != 1 {
ctx.AbortWithError(core.Error(http.StatusNotFound, 170001, "该活动已下线"))
return
}
// 4. Keep-Alive: Refresh Redis TTL
h.redis.Expire(ctx.RequestContext(), matchingGameKeyPrefix+gameID, 30*time.Minute)
// 5. 构造并返回全量卡牌数据
allCards := make([]MatchingCard, 0, 99)
for _, c := range game.Board {
if c != nil {
allCards = append(allCards, *c)
}
}
for _, c := range game.Deck {
allCards = append(allCards, *c)
}
ctx.Payload(&matchingGameCardsResponse{
GameID: gameID,
AllCards: allCards,
})
}
}