bindbox-game/internal/api/activity/matching_game_helper.go
邹方成 a7a0f639e1 feat: 新增取消发货功能并优化任务中心
fix: 修复微信通知字段截断导致的编码错误
feat: 添加有效邀请相关字段和任务中心常量
refactor: 重构一番赏奖品格位逻辑
perf: 优化道具卡列表聚合显示
docs: 更新项目说明文档和API文档
test: 添加字符串截断工具测试
2025-12-23 22:26:07 +08:00

522 lines
15 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/pkg/logger"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
usersvc "bindbox-game/internal/service/user"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// 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 使用数据库配置创建游戏
// position: 用户选择的位置(可选),用于增加随机熵值
// masterSeed: 活动的主承诺种子(可选),如果提供则用作随机源的基础
func NewMatchingGameWithConfig(configs []CardTypeConfig, position string, masterSeed []byte) *MatchingGame {
g := &MatchingGame{
CardConfigs: configs,
RoundHistory: []MatchingRoundResult{},
Board: [9]*MatchingCard{},
LastActivity: time.Now(),
}
// 生成服务器种子
if len(masterSeed) > 0 {
// 使用主承诺种子作为基础
// ServerSeed = HMAC(MasterSeed, Position + Timestamp)
// 这样保证了基于主承诺的确定性派生
h := hmac.New(sha256.New, masterSeed)
h.Write([]byte(position))
h.Write([]byte(fmt.Sprintf("%d", time.Now().UnixNano())))
g.ServerSeed = h.Sum(nil) // 32 bytes
} else {
// Fallback to random if no master seed (compatibility)
g.ServerSeed = make([]byte, 32)
rand.Read(g.ServerSeed)
// 如果有 position 参数,将其混入种子逻辑
if position != "" {
h := sha256.New()
h.Write(g.ServerSeed)
h.Write([]byte(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 {
pairs := int64(cfg.Quantity / 2)
theoreticalMax += pairs // 向下取整每2张算1对
fmt.Printf("[对对碰生成] 卡牌类型:%s(%s) 数量:%d 可组成对数:%d\n", cfg.Code, cfg.Name, cfg.Quantity, pairs)
}
g.MaxPossiblePairs = theoreticalMax
fmt.Printf("[对对碰生成] 总卡牌数:%d 理论最大对数:%d\n", totalCards, theoreticalMax)
return g
}
// 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
} else {
g.Board[i] = nil
}
}
}
// 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,
}
}
// 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
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
}
// 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, quantity int, remark string) error {
// 1. Grant to Order (Delegating stock check to user service)
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: quantity,
ActivityID: &actID,
RewardID: &rid,
Remark: remark,
})
if err != nil {
h.logger.Error("Failed to grant reward to order", zap.Int64("order_id", orderID), zap.Error(err))
return err
}
// 2. 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,
})
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
}