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" "encoding/json" "fmt" "net/http" "sort" "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"` UseGamePass bool `json:"use_game_pass"` // 新增:是否使用次数卡 } 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 } // 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: activity.PriceDraw, Status: 2, // 已支付 Remark: fmt.Sprintf("activity:%d|game_pass:%d|matching_game:issue:%d", activity.ID, validPass.ID, req.IssueID), 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 } // 1. Concurrency Lock: Prevent multiple check requests for the same game 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 // 【幂等性检查】在发奖前检查该订单是否已经获得过奖励 existingLog, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Where( h.readDB.ActivityDrawLogs.OrderID.Eq(game.OrderID), h.readDB.ActivityDrawLogs.IsWinner.Eq(1), ).First() if existingLog != nil { h.logger.Warn("对对碰Check: 订单已获得过奖励,拒绝重复发放", zap.Int64("order_id", game.OrderID), zap.Int64("existing_log_id", existingLog.ID)) // 返回已有的奖励信息而不是重复发放 if existingLog.RewardID > 0 { rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where( h.readDB.ActivityRewardSettings.ID.Eq(existingLog.RewardID)).First() if rw != nil { prodName := "" prodImage := "" if p, _ := h.readDB.Products.WithContext(ctx.RequestContext()).Where(h.readDB.Products.ID.Eq(rw.ProductID)).First(); p != nil { prodName = p.Name prodImage = getFirstImage(p.ImagesJSON) } rewardInfo = &MatchingRewardInfo{ RewardID: rw.ID, Name: prodName, ProductName: prodName, ProductImage: prodImage, Level: rw.Level, } } } ctx.Payload(&matchingGameCheckResponse{ GameID: req.GameID, TotalPairs: req.TotalPairs, Finished: true, Reward: rewardInfo, }) return } // 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 actualPairs == r.MinScore { candidate = r break // 找到精确匹配,直接使用 } } if candidate != nil { // 3. Prepare Grant Params // Fetch real product name for remark productName := "" 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) h.logger.Debug("CheckMatchingGame: 道具卡检查", zap.String("order_no", order.OrderNo), zap.String("remark", order.Remark), zap.Int64("icID", 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 { h.logger.Warn("CheckMatchingGame: 用户道具卡未找到", zap.Int64("icID", icID), zap.Int64("user_id", game.UserID)) } else if uic.Status != 1 { h.logger.Warn("CheckMatchingGame: 用户道具卡状态无效", zap.Int32("status", 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) h.logger.Debug("道具卡-CheckMatchingGame: 范围检查", zap.Int32("scope_type", ic.ScopeType), zap.Int64("activity_id", game.ActivityID), zap.Int64("issue_id", game.IssueID), zap.Bool("is_ok", scopeOK)) if scopeOK { cardToVoid = icID if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 { // Double reward h.logger.Info("道具卡-CheckMatchingGame: 应用双倍奖励", zap.Int32("multiplier", ic.RewardMultiplierX1000)) finalQuantity = 2 finalRemark += "(倍数)" } else if ic.EffectType == 2 && ic.BoostRateX1000 > 0 { // Probability boost - try to upgrade to better reward h.logger.Debug("道具卡-CheckMatchingGame: 应用概率提升", zap.Int32("boost_rate", 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) h.logger.Debug("道具卡-CheckMatchingGame: 概率检定", zap.Int32("rand", randVal), zap.Int32("threshold", ic.BoostRateX1000)) if randVal < ic.BoostRateX1000 { // 获取升级后的商品名称 betterProdName := "" if better.ProductID > 0 { if bp, _ := h.readDB.Products.WithContext(ctx.RequestContext()).Where(h.readDB.Products.ID.Eq(better.ProductID)).First(); bp != nil { betterProdName = bp.Name } } h.logger.Info("道具卡-CheckMatchingGame: 概率提升成功", zap.Int64("new_reward_id", better.ID), zap.String("product_name", betterProdName)) finalReward = better finalRemark = betterProdName + "(升级)" } else { h.logger.Debug("道具卡-CheckMatchingGame: 概率提升失败") } } else { h.logger.Debug("道具卡-CheckMatchingGame: 未找到更好的奖品可升级", zap.Int64("current_score", candidate.MinScore)) } } } else { h.logger.Debug("道具卡-CheckMatchingGame: 范围校验失败") } } else { h.logger.Debug("道具卡-CheckMatchingGame: 时间或系统卡状态无效", zap.Bool("has_ic", ic != nil), zap.Time("start", uic.ValidStart), zap.Time("end", uic.ValidEnd), zap.Time("now", 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 { prodImage := "" if p, _ := h.readDB.Products.WithContext(ctx.RequestContext()).Where(h.readDB.Products.ID.Eq(candidate.ProductID)).First(); p != nil { productName = p.Name prodImage = getFirstImage(p.ImagesJSON) } rewardInfo = &MatchingRewardInfo{ RewardID: finalReward.ID, Name: productName, ProductName: productName, ProductImage: prodImage, Level: finalReward.Level, } // 6. Void Item Card (if used) if cardToVoid > 0 { h.logger.Info("道具卡-CheckMatchingGame: 核销道具卡", zap.Int64("uic_id", 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(), 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 "" }