bindbox-game/internal/api/activity/matching_game_app.go
Zuncle 566641a2e7 fix(activity): 移除对对碰候选奖励的库存前置过滤
对对碰结算时不再因 activity_reward_settings.quantity 小于等于 0 而提前跳过候选奖励,便于按实际商品库存进行排查与处理。
2026-05-18 20:34:23 +08:00

676 lines
24 KiB
Go
Executable File
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 (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"sort"
"time"
"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"
"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"`
UseGamePass bool `json:"use_game_pass"` // 新增:是否使用次数卡
Count int64 `json:"count"` // 新增:购买数量
}
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"`
ProductName string `json:"product_name"` // 商品原始名称
ProductImage string `json:"product_image"` // 商品图片
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
}
// 校验 Count对对碰只能单次购买
if req.Count > 1 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170013, "对对碰游戏暂不支持批量购买,请单次支付"))
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
}
var order *model.Orders
// ⭐ 次数卡支付分支
if req.UseGamePass {
// 查询用户可用的次数卡(全局或该活动的)
now := time.Now()
gamePasses, err := h.writeDB.UserGamePasses.WithContext(ctx.RequestContext()).
Where(h.writeDB.UserGamePasses.UserID.Eq(userID)).
Where(h.writeDB.UserGamePasses.Remaining.Gt(0)).
Where(h.writeDB.UserGamePasses.ActivityID.In(0, issue.ActivityID)).
Order(h.writeDB.UserGamePasses.ActivityID.Desc()). // 优先使用活动限定的
Find()
if err != nil || len(gamePasses) == 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170012, "无可用的次数卡"))
return
}
// 找到第一个未过期的次数卡
var validPass *model.UserGamePasses
for _, p := range gamePasses {
if p.ExpiredAt.IsZero() || p.ExpiredAt.After(now) {
validPass = p
break
}
}
if validPass == nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170012, "次数卡已过期"))
return
}
// 扣减次数
result, err := h.writeDB.UserGamePasses.WithContext(ctx.RequestContext()).
Where(h.writeDB.UserGamePasses.ID.Eq(validPass.ID)).
Where(h.writeDB.UserGamePasses.Remaining.Gt(0)).
Updates(map[string]any{
"remaining": validPass.Remaining - 1,
"total_used": validPass.TotalUsed + 1,
})
if err != nil || result.RowsAffected == 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170012, "次数卡扣减失败"))
return
}
// 直接创建"已支付”订单
orderNo := now.Format("20060102150405") + fmt.Sprintf("%04d", now.UnixNano()%10000)
newOrder := &model.Orders{
UserID: userID,
OrderNo: "GP" + orderNo,
SourceType: 3, // 对对碰
TotalAmount: activity.PriceDraw,
ActualAmount: 0, // 次数卡抵扣实付0元
DiscountAmount: 0, // 次数卡支付,无优惠券抵扣
Status: 2, // 已支付
Remark: func() string {
r := fmt.Sprintf("activity:%d|game_pass:%d|matching_game:issue:%d", activity.ID, validPass.ID, req.IssueID)
if activity.AllowItemCards && req.ItemCardID != nil && *req.ItemCardID > 0 {
r += fmt.Sprintf("|itemcard:%d", *req.ItemCardID)
}
return r
}(),
CreatedAt: now,
UpdatedAt: now,
PaidAt: now,
}
if err := h.writeDB.Orders.WithContext(ctx.RequestContext()).
Omit(h.writeDB.Orders.CancelledAt).
Create(newOrder); err != nil {
// 回滚次数卡
h.writeDB.UserGamePasses.WithContext(ctx.RequestContext()).
Where(h.writeDB.UserGamePasses.ID.Eq(validPass.ID)).
Updates(map[string]any{
"remaining": validPass.Remaining,
"total_used": validPass.TotalUsed,
})
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 170002, err.Error()))
return
}
order = newOrder
// 次数卡 0 元订单手动触发任务中心
go func() {
_ = h.task.OnOrderPaid(context.Background(), userID, order.ID)
}()
} else {
// 原有支付流程
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 := h.activity.ListMatchingCardTypes(ctx.RequestContext())
if err != nil || len(configs) == 0 {
configs = []activitysvc.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
}
// 🔍 【关键修复】对配置进行强制排序,保证洗牌前的初始数组顺序绝对固定
sort.Slice(configs, func(i, j int) bool {
return configs[i].Code < configs[j].Code
})
game := activitysvc.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
game.Position = req.Position // 保存用户选择的类型,用于服务端验证
game.CreatedAt = time.Now() // 设置游戏创建时间,用于自动开奖超时判断
// 4. 构造 AllCards (仅需返回 Flat List)
// game.deck 包含了所有的牌(已洗好,且包含了 board[0..8] 因为 NewMatchingGameWithConfig 中我们是从 deck 发到 board 的)
// 但 NewMatchingGameWithConfig 目前的逻辑是:生成 -> 洗牌 -> 发前9张到 board -> deck只剩剩下的。
// 所以我们需要把 board 和 deck 拼起来。
allCards := make([]activitysvc.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.activity.SaveMatchingGameToRedis(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
}
lockKey := fmt.Sprintf("lock:matching_game:check:%s", req.GameID)
locked, err := h.redis.SetNX(ctx.RequestContext(), lockKey, "1", 10*time.Second).Result()
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, "redis error"))
return
}
if !locked {
ctx.AbortWithError(core.Error(http.StatusConflict, 170005, "结算处理中,请勿重复提交"))
return
}
defer h.redis.Del(ctx.RequestContext(), lockKey)
game, err := h.activity.GetMatchingGameFromRedis(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 {
h.logger.Debug("对对碰Check: 订单支付确认中",
zap.Int64("order_id", order.ID),
zap.Int32("status", 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
}
serverSimulatedPairs := game.SimulateMaxPairs()
h.logger.Debug("对对碰Check: 服务端模拟验证",
zap.Int64("client_pairs", req.TotalPairs),
zap.Int64("server_simulated", serverSimulatedPairs),
zap.Int64("max_possible", game.MaxPossiblePairs),
zap.String("position", game.Position),
zap.String("game_id", req.GameID))
actualPairs := serverSimulatedPairs
if req.TotalPairs != serverSimulatedPairs {
h.logger.Warn("对对碰Check: 客户端提交数值与服务端模拟不一致",
zap.Int64("client_pairs", req.TotalPairs),
zap.Int64("server_simulated", serverSimulatedPairs),
zap.String("game_id", req.GameID))
}
game.TotalPairs = actualPairs
var rewardInfo *MatchingRewardInfo
rewards, err := h.activity.ListIssueRewards(ctx.RequestContext(), game.IssueID)
if err == nil && len(rewards) > 0 {
var candidate *model.ActivityRewardSettings
for _, r := range rewards {
if actualPairs == r.MinScore {
candidate = r
break
}
}
if candidate != nil {
plan, err := h.settleMatchingReward(ctx.RequestContext(), game, order, candidate, false)
if err != nil {
h.logger.Error("Failed to grant matching reward", zap.Int64("order_id", game.OrderID), zap.Error(err))
} else if plan != nil {
prodImage := ""
if p, _ := h.readDB.Products.WithContext(ctx.RequestContext()).Where(h.readDB.Products.ID.Eq(plan.reward.ProductID)).First(); p != nil {
prodImage = getFirstImage(p.ImagesJSON)
}
rewardInfo = &MatchingRewardInfo{
RewardID: plan.reward.ID,
Name: plan.rewardName,
ProductName: plan.productName,
ProductImage: prodImage,
Level: plan.reward.Level,
}
}
}
}
rsp := &matchingGameCheckResponse{
GameID: req.GameID,
TotalPairs: req.TotalPairs,
Finished: true,
Reward: rewardInfo,
}
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 == "" {
h.logger.Warn("CheckMatchingGame: No payment transaction found for shipping", zap.String("order_no", orderNo))
return
}
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("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)
_ = h.redis.Del(ctx.RequestContext(), activitysvc.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.activity.GetMatchingGameFromRedis(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(), activitysvc.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} activitysvc.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 := h.activity.ListMatchingCardTypes(ctx.RequestContext())
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 []activitysvc.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.activity.GetMatchingGameFromRedis(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 {
h.logger.Warn("GetMatchingGameCards: 订单未支付", zap.Int64("order_id", order.ID), zap.Int32("status", 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(), activitysvc.MatchingGameKeyPrefix+gameID, 30*time.Minute)
// 5. 构造并返回全量卡牌数据
allCards := make([]activitysvc.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,
})
}
}
func getFirstImage(imagesJSON string) string {
if imagesJSON == "" || imagesJSON == "[]" {
return ""
}
// 简单解析,假设是 ["url1", "url2"] 格式
var images []string
if err := json.Unmarshal([]byte(imagesJSON), &images); err == nil && len(images) > 0 {
return images[0]
}
return ""
}