bindbox-game/internal/api/activity/matching_game_app.go
邹方成 16e2ede037 feat: 新增订单列表筛选条件与活动信息展示
refactor(orders): 重构订单列表查询逻辑,支持按消耗状态筛选
feat(orders): 订单列表返回新增活动分类与玩法类型信息
fix(orders): 修复订单支付时间空指针问题
docs(swagger): 更新订单相关接口文档
test(matching): 添加对对碰奖励匹配测试用例
chore: 清理无用脚本文件
2025-12-22 15:15:18 +08:00

1003 lines
34 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/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
}