refactor(orders): 重构订单列表查询逻辑,支持按消耗状态筛选 feat(orders): 订单列表返回新增活动分类与玩法类型信息 fix(orders): 修复订单支付时间空指针问题 docs(swagger): 更新订单相关接口文档 test(matching): 添加对对碰奖励匹配测试用例 chore: 清理无用脚本文件
1003 lines
34 KiB
Go
1003 lines
34 KiB
Go
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
|
||
}
|