package app import ( "bindbox-game/internal/code" "bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/logger" "bindbox-game/internal/pkg/validation" "bindbox-game/internal/repository/mysql/dao" "bindbox-game/internal/repository/mysql/model" "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/binary" "encoding/hex" "encoding/json" "fmt" "net/http" "sync" "time" "github.com/redis/go-redis/v9" "go.uber.org/zap" activitysvc "bindbox-game/internal/service/activity" usersvc "bindbox-game/internal/service/user" ) // CardType 卡牌类型 type CardType string // CardTypeConfig 卡牌类型配置(从数据库加载) type CardTypeConfig struct { Code CardType `json:"code"` Name string `json:"name"` ImageURL string `json:"image_url"` Quantity int32 `json:"quantity"` } // MatchingCard 游戏中的卡牌实例 type MatchingCard struct { ID string `json:"id"` Type CardType `json:"type"` } // MatchingGame 对对碰游戏结构 type MatchingGame struct { Mu sync.Mutex `json:"-"` // 互斥锁保护并发访问 ServerSeed []byte `json:"server_seed"` ServerSeedHash string `json:"server_seed_hash"` Nonce int64 `json:"nonce"` CardConfigs []CardTypeConfig `json:"card_configs"` Deck []*MatchingCard `json:"deck"` // 牌堆 (预生成的卡牌对象) Board [9]*MatchingCard `json:"board"` // 固定9格棋盘 CardIDCounter int64 `json:"card_id_counter"` // 用于生成唯一ID TotalPairs int64 `json:"total_pairs"` MaxPossiblePairs int64 `json:"max_possible_pairs"` // 最大可能消除对数 (安全校验) Round int64 `json:"round"` RoundHistory []MatchingRoundResult `json:"round_history"` LastActivity time.Time `json:"last_activity"` // Context info for reward granting ActivityID int64 `json:"activity_id"` IssueID int64 `json:"issue_id"` OrderID int64 `json:"order_id"` UserID int64 `json:"user_id"` } type MatchingRoundResult struct { Round int64 `json:"round"` Board [9]*MatchingCard `json:"board"` Pairs []MatchingPair `json:"pairs"` PairsCount int64 `json:"pairs_count"` DrawnCards []DrawnCardInfo `json:"drawn_cards"` // 优化:包含位置信息 Reshuffled bool `json:"reshuffled"` CanContinue bool `json:"can_continue"` } type DrawnCardInfo struct { SlotIndex int `json:"slot_index"` Card MatchingCard `json:"card"` } type MatchingPair struct { CardType CardType `json:"card_type"` Count int64 `json:"count"` CardIDs []string `json:"card_ids"` SlotIndices []int `json:"slot_indices"` // 新增:消除的格子索引 } // loadCardTypesFromDB 从数据库加载启用的卡牌类型配置 func loadCardTypesFromDB(ctx context.Context, readDB *dao.Query) ([]CardTypeConfig, error) { items, err := readDB.MatchingCardTypes.WithContext(ctx).Where(readDB.MatchingCardTypes.Status.Eq(1)).Order(readDB.MatchingCardTypes.Sort.Asc()).Find() if err != nil { return nil, err } configs := make([]CardTypeConfig, len(items)) for i, item := range items { configs[i] = CardTypeConfig{ Code: CardType(item.Code), Name: item.Name, ImageURL: item.ImageURL, Quantity: item.Quantity, } } return configs, nil } // NewMatchingGameWithConfig 使用数据库配置创建游戏 // NewMatchingGameWithConfig 使用数据库配置创建游戏 // position: 用户选择的位置(可选),用于增加随机熵值 func NewMatchingGameWithConfig(configs []CardTypeConfig, position string) *MatchingGame { g := &MatchingGame{ CardConfigs: configs, RoundHistory: []MatchingRoundResult{}, Board: [9]*MatchingCard{}, LastActivity: time.Now(), } // 生成服务器种子 g.ServerSeed = make([]byte, 32) rand.Read(g.ServerSeed) // 如果有 position 参数,将其混入种子逻辑 if position != "" { // 使用 SHA256 (seed + position + timestamp) 生成新的混合种子 h := sha256.New() h.Write(g.ServerSeed) h.Write([]byte(position)) // 还可以加个时间戳确保不仅仅依赖 position h.Write([]byte(fmt.Sprintf("%d", time.Now().UnixNano()))) g.ServerSeed = h.Sum(nil) } hash := sha256.Sum256(g.ServerSeed) g.ServerSeedHash = fmt.Sprintf("%x", hash) // 根据配置生成所有卡牌 (99张) totalCards := 0 for _, cfg := range configs { totalCards += int(cfg.Quantity) } // 创建所有卡牌对象 g.CardIDCounter = 0 allCards := make([]*MatchingCard, 0, totalCards) for _, cfg := range configs { for i := int32(0); i < cfg.Quantity; i++ { // 创建卡牌对象 g.CardIDCounter++ id := fmt.Sprintf("c%d", g.CardIDCounter) mc := &MatchingCard{ ID: id, Type: cfg.Code, } allCards = append(allCards, mc) } } g.Deck = allCards // 安全洗牌 g.secureShuffle() // 初始填充棋盘 for i := 0; i < 9; i++ { if len(g.Deck) > 0 { // 从牌堆顶取一张 card := g.Deck[0] g.Deck = g.Deck[1:] g.Board[i] = card } else { g.Board[i] = nil } } // 计算理论最大对数 (Sanity Check) // 遍历所有生成的卡牌配置 var theoreticalMax int64 for _, cfg := range configs { theoreticalMax += int64(cfg.Quantity / 2) // 向下取整,每2张算1对 } g.MaxPossiblePairs = theoreticalMax return g } // createMatchingCard (已废弃,改为预生成) - 但为了兼容 PlayRound 里可能的动态生成(如有),保留作为 helper? // 不,PlayRound 现在应该直接从 deck 取对象。 // 只需要保留 getCardConfig 即可。 // getCardConfig 获取指定卡牌类型的配置 func (g *MatchingGame) getCardConfig(cardType CardType) *CardTypeConfig { for i := range g.CardConfigs { if g.CardConfigs[i].Code == cardType { return &g.CardConfigs[i] } } return nil } // secureShuffle 使用 HMAC-SHA256 的 Fisher-Yates 洗牌 func (g *MatchingGame) secureShuffle() { n := len(g.Deck) for i := n - 1; i > 0; i-- { j := g.secureRandInt(i+1, fmt.Sprintf("shuffle:%d", i)) g.Deck[i], g.Deck[j] = g.Deck[j], g.Deck[i] } } // secureRandInt 使用 HMAC-SHA256 生成安全随机数 func (g *MatchingGame) secureRandInt(max int, context string) int { g.Nonce++ message := fmt.Sprintf("%s|nonce:%d", context, g.Nonce) mac := hmac.New(sha256.New, g.ServerSeed) mac.Write([]byte(message)) sum := mac.Sum(nil) val := binary.BigEndian.Uint64(sum[:8]) return int(val % uint64(max)) } // reshuffleBoard 重洗棋盘和牌堆 func (g *MatchingGame) reshuffleBoard() { // 1. 回收所有卡牌(板上 + 牌堆) tempDeck := make([]*MatchingCard, 0, len(g.Deck)+9) tempDeck = append(tempDeck, g.Deck...) for i := 0; i < 9; i++ { if g.Board[i] != nil { tempDeck = append(tempDeck, g.Board[i]) g.Board[i] = nil } } // 2. 循环尝试洗牌,直到开局有解(或者尝试一定次数) // 尝试最多 10 次,寻找一个起手就有解的局面 bestDeck := make([]*MatchingCard, len(tempDeck)) copy(bestDeck, tempDeck) for retry := 0; retry < 10; retry++ { // 复制一份进行尝试 currentDeck := make([]*MatchingCard, len(tempDeck)) copy(currentDeck, tempDeck) g.Deck = currentDeck g.secureShuffle() // 检查前9张(或更少)是否有对子 checkCount := 9 if len(g.Deck) < 9 { checkCount = len(g.Deck) } counts := make(map[CardType]int) hasPair := false for k := 0; k < checkCount; k++ { t := g.Deck[k].Type counts[t]++ if counts[t] >= 2 { hasPair = true break } } if hasPair { // 找到有解的洗牌结果,采用之 // g.deck 已经是洗好的状态 break } } // 3. 重新填满棋盘 for i := 0; i < 9; i++ { if len(g.Deck) > 0 { card := g.Deck[0] g.Deck = g.Deck[1:] g.Board[i] = card } } } // GetGameState 获取游戏状态 func (g *MatchingGame) GetGameState() map[string]any { return map[string]any{ "board": g.Board, "deck_count": len(g.Deck), "total_pairs": g.TotalPairs, "round": g.Round, "server_seed_hash": g.ServerSeedHash, } } // ========== 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 AllCards []MatchingCard `json:"all_cards"` // 全量99张卡牌(乱序) ServerSeedHash string `json:"server_seed_hash"` } 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"` } // Redis Key Prefix const matchingGameKeyPrefix = "bindbox:matching_game:" // saveGameToRedis 保存游戏状态到 Redis func (h *handler) saveGameToRedis(ctx context.Context, gameID string, game *MatchingGame) error { data, err := json.Marshal(game) if err != nil { return err } // TTL: 30 minutes return h.redis.Set(ctx, matchingGameKeyPrefix+gameID, data, 30*time.Minute).Err() } // loadGameFromRedis 从 Redis 加载游戏状态 // 如果 Redis 中没有找到,则尝试从数据库恢复 func (h *handler) loadGameFromRedis(ctx context.Context, gameID string) (*MatchingGame, error) { data, err := h.redis.Get(ctx, matchingGameKeyPrefix+gameID).Bytes() if err == nil { var game MatchingGame if err := json.Unmarshal(data, &game); err != nil { return nil, err } return &game, nil } // Redis miss - try to recover from DB if err == redis.Nil { game, recoverErr := h.recoverGameFromDB(ctx, gameID) if recoverErr != nil { return nil, redis.Nil // Return original error to indicate session not found } // Cache the recovered game back to Redis _ = h.saveGameToRedis(ctx, gameID, game) return game, nil } return nil, err } // recoverGameFromDB 从数据库恢复游戏状态 // 通过 game_id 解析 user_id,然后查找对应的 activity_draw_receipts 记录 // 使用 ServerSubSeed 重建游戏状态 func (h *handler) recoverGameFromDB(ctx context.Context, gameID string) (*MatchingGame, error) { // Parse user_id from game_id (format: MG{userID}{timestamp}) // Example: MG121766299471192637903 if len(gameID) < 3 || gameID[:2] != "MG" { return nil, fmt.Errorf("invalid game_id format") } // Extract user_id: find the first digit sequence after "MG" // The user_id is typically short (1-5 digits), timestamp is long (19 digits) numPart := gameID[2:] var userID int64 if len(numPart) > 19 { // User ID is everything before the last 19 chars (nanosecond timestamp) userIDStr := numPart[:len(numPart)-19] userID = parseInt64(userIDStr) } else { return nil, fmt.Errorf("cannot parse user_id from game_id") } if userID <= 0 { return nil, fmt.Errorf("invalid user_id in game_id") } // Find the most recent matching game receipt for this user receipt, err := h.readDB.ActivityDrawReceipts.WithContext(ctx). Where(h.readDB.ActivityDrawReceipts.ClientID.Eq(userID)). Where(h.readDB.ActivityDrawReceipts.AlgoVersion.Eq("HMAC-SHA256-v1")). Order(h.readDB.ActivityDrawReceipts.ID.Desc()). First() if err != nil || receipt == nil { return nil, fmt.Errorf("no matching game receipt found for user %d", userID) } // Decode ServerSubSeed (hex -> bytes) serverSeed, err := hex.DecodeString(receipt.ServerSubSeed) if err != nil || len(serverSeed) == 0 { return nil, fmt.Errorf("invalid server seed in receipt") } // Get DrawLog to find IssueID and OrderID drawLog, err := h.readDB.ActivityDrawLogs.WithContext(ctx). Where(h.readDB.ActivityDrawLogs.ID.Eq(receipt.DrawLogID)). First() if err != nil || drawLog == nil { return nil, fmt.Errorf("draw log not found") } // Load card configs configs, err := loadCardTypesFromDB(ctx, h.readDB) if err != nil || len(configs) == 0 { // Fallback to default configs 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}, } } // Reconstruct game with the same seed game := &MatchingGame{ CardConfigs: configs, RoundHistory: []MatchingRoundResult{}, Board: [9]*MatchingCard{}, LastActivity: time.Now(), ServerSeed: serverSeed, ServerSeedHash: receipt.ServerSeedHash, Nonce: 0, // Reset nonce for reconstruction ActivityID: drawLog.IssueID, // Note: IssueID is stored in DrawLog IssueID: drawLog.IssueID, OrderID: drawLog.OrderID, UserID: userID, } // Get ActivityID from Issue if issue, _ := h.readDB.ActivityIssues.WithContext(ctx).Where(h.readDB.ActivityIssues.ID.Eq(drawLog.IssueID)).First(); issue != nil { game.ActivityID = issue.ActivityID } // Generate all cards totalCards := 0 for _, cfg := range configs { totalCards += int(cfg.Quantity) } game.CardIDCounter = 0 allCards := make([]*MatchingCard, 0, totalCards) for _, cfg := range configs { for i := int32(0); i < cfg.Quantity; i++ { game.CardIDCounter++ id := fmt.Sprintf("c%d", game.CardIDCounter) mc := &MatchingCard{ ID: id, Type: cfg.Code, } allCards = append(allCards, mc) } } game.Deck = allCards // Shuffle with the same seed (deterministic) game.secureShuffle() // Fill board for i := 0; i < 9; i++ { if len(game.Deck) > 0 { card := game.Deck[0] game.Deck = game.Deck[1:] game.Board[i] = card } else { game.Board[i] = nil } } // Calculate max possible pairs var theoreticalMax int64 for _, cfg := range configs { theoreticalMax += int64(cfg.Quantity / 2) } game.MaxPossiblePairs = theoreticalMax fmt.Printf("[会话恢复] 成功从数据库恢复游戏 game_id=%s user_id=%d issue_id=%d\n", gameID, userID, drawLog.IssueID) return game, nil } // 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 } // 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. 创建游戏并洗牌 game := NewMatchingGameWithConfig(configs, req.Position) 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. 返回数据 rsp := &matchingGamePreOrderResponse{ GameID: gameID, OrderNo: order.OrderNo, PayStatus: order.Status, AllCards: allCards, 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 } // 校验:不能超过理论最大对数 if req.TotalPairs > game.MaxPossiblePairs { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, fmt.Sprintf("invalid total pairs: %d > %d", req.TotalPairs, game.MaxPossiblePairs))) return } 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 } // 精确匹配:用户消除的对子数 == 奖品设置的 MinScore if int64(req.TotalPairs) == r.MinScore { candidate = r break // 精确匹配,直接使用 } } // 3. Grant Reward if found if candidate != nil { if err := h.grantRewardHelper(ctx.RequestContext(), game.UserID, game.OrderID, candidate); err != nil { h.logger.Error("Failed to grant matching reward", zap.Int64("order_id", game.OrderID), zap.Error(err)) } else { rewardInfo = &MatchingRewardInfo{ RewardID: candidate.ID, Name: candidate.Name, Level: candidate.Level, } // 4. Apply Item Card Effects (if any) ord, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).Where(h.readDB.Orders.ID.Eq(game.OrderID)).First() if ord != nil { icID := parseItemCardIDFromRemark(ord.Remark) fmt.Printf("[道具卡-CheckMatchingGame] 从订单备注解析道具卡ID icID=%d 订单备注=%s\n", icID, ord.Remark) if icID > 0 { uic, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).Where( h.readDB.UserItemCards.ID.Eq(icID), h.readDB.UserItemCards.UserID.Eq(game.UserID), h.readDB.UserItemCards.Status.Eq(1), ).First() if uic != nil { 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 { // Apply effect based on type if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 { // Double reward fmt.Printf("[道具卡-CheckMatchingGame] ✅ 应用双倍奖励 倍数=%d 奖品ID=%d 奖品名=%s\n", ic.RewardMultiplierX1000, candidate.ID, candidate.Name) rid := candidate.ID _, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), game.UserID, usersvc.GrantRewardToOrderRequest{ OrderID: game.OrderID, ProductID: candidate.ProductID, Quantity: 1, ActivityID: &game.ActivityID, RewardID: &rid, Remark: candidate.Name + "(倍数)", }) } 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) if randVal < ic.BoostRateX1000 { fmt.Printf("[道具卡-CheckMatchingGame] ✅ 概率提升成功 升级到奖品ID=%d 奖品名=%s\n", better.ID, better.Name) rid := better.ID _, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), game.UserID, usersvc.GrantRewardToOrderRequest{ OrderID: game.OrderID, ProductID: better.ProductID, Quantity: 1, ActivityID: &game.ActivityID, RewardID: &rid, Remark: better.Name + "(升级)", }) } } } // Void the item card fmt.Printf("[道具卡-CheckMatchingGame] 核销道具卡 用户道具卡ID=%d\n", icID) // 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(icID), 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, } // 结算完成,清理会话 (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) 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) } } // startMatchingGameCleanup ... (Deprecated since we use Redis TTL) func (h *handler) startMatchingGameCleanup() { // No-op } // cleanupExpiredMatchingGames ... (Deprecated) func cleanupExpiredMatchingGames(logger logger.CustomLogger) { // No-op } // grantRewardHelper 发放奖励辅助函数 func (h *handler) grantRewardHelper(ctx context.Context, userID, orderID int64, r *model.ActivityRewardSettings) error { // 1. 扣减库存 res, err := h.writeDB.ActivityRewardSettings.WithContext(ctx).Where( h.writeDB.ActivityRewardSettings.ID.Eq(r.ID), h.writeDB.ActivityRewardSettings.Quantity.Gt(0), ).UpdateSimple(h.writeDB.ActivityRewardSettings.Quantity.Add(-1)) if err != nil { return err } if res.RowsAffected == 0 { return fmt.Errorf("reward out of stock") } // 2. Grant to Order 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: 1, // 1 prize ActivityID: &actID, RewardID: &rid, Remark: "Matching Game Reward", }) if err != nil { // Use h.logger.Error if available, else fmt.Printf or zap.L().Error // h.logger is likely type definition interface. // Let's use generic logger if h.logger doesn't support structured. // But usually it does. // h.logger.Error(msg, fields...) h.logger.Error("Failed to grant reward to order", zap.Int64("order_id", orderID), zap.Error(err)) return err } // 3. 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, // RewardName: r.Name, // Removed // ProductPrice: 0, // Removed // UpdatedAt: time.Now(), // Removed }) return err } // parseInt64 将字符串转换为int64 func parseInt64(s string) int64 { var n int64 for i := 0; i < len(s); i++ { c := s[i] if c < '0' || c > '9' { break } n = n*10 + int64(c-'0') } return n }