refactor(utils): 修复密码哈希比较逻辑错误 feat(user): 新增按状态筛选优惠券接口 docs: 添加虚拟发货与任务中心相关文档 fix(wechat): 修正Code2Session上下文传递问题 test: 补充订单折扣与积分转换测试用例 build: 更新配置文件与构建脚本 style: 清理多余的空行与注释
427 lines
12 KiB
Go
427 lines
12 KiB
Go
package app
|
||
|
||
import (
|
||
"bindbox-game/internal/code"
|
||
"bindbox-game/internal/pkg/core"
|
||
"bindbox-game/internal/repository/mysql/dao"
|
||
"context"
|
||
"crypto/hmac"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/binary"
|
||
"fmt"
|
||
"net/http"
|
||
"time"
|
||
)
|
||
|
||
// CardType 卡牌类型
|
||
type CardType string
|
||
|
||
// CardTypeConfig 卡牌类型配置(从数据库加载)
|
||
type CardTypeConfig struct {
|
||
Code CardType
|
||
Name string
|
||
ImageURL string
|
||
Quantity int32
|
||
}
|
||
|
||
// MatchingGame 对对碰游戏结构
|
||
type MatchingGame struct {
|
||
serverSeed []byte
|
||
serverSeedHash string
|
||
nonce int64
|
||
cardConfigs []CardTypeConfig
|
||
cards []CardType
|
||
hand []CardType
|
||
deck []CardType
|
||
totalPairs int64
|
||
round int64
|
||
roundHistory []MatchingRoundResult
|
||
}
|
||
|
||
type MatchingCard struct {
|
||
ID int `json:"id"`
|
||
Type CardType `json:"type"`
|
||
Name string `json:"name,omitempty"`
|
||
ImageURL string `json:"image_url,omitempty"`
|
||
}
|
||
|
||
type MatchingRoundResult struct {
|
||
Round int64 `json:"round"`
|
||
HandBefore []CardType `json:"hand_before"`
|
||
Pairs []MatchingPair `json:"pairs"`
|
||
PairsCount int64 `json:"pairs_count"`
|
||
DrawnCards []CardType `json:"drawn_cards"`
|
||
HandAfter []CardType `json:"hand_after"`
|
||
CanContinue bool `json:"can_continue"`
|
||
}
|
||
|
||
type MatchingPair struct {
|
||
CardType CardType `json:"card_type"`
|
||
Count int64 `json:"count"`
|
||
}
|
||
|
||
// 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 使用数据库配置创建游戏
|
||
func NewMatchingGameWithConfig(configs []CardTypeConfig) *MatchingGame {
|
||
g := &MatchingGame{
|
||
cardConfigs: configs,
|
||
roundHistory: []MatchingRoundResult{},
|
||
}
|
||
|
||
// 生成服务器种子
|
||
g.serverSeed = make([]byte, 32)
|
||
rand.Read(g.serverSeed)
|
||
hash := sha256.Sum256(g.serverSeed)
|
||
g.serverSeedHash = fmt.Sprintf("%x", hash)
|
||
|
||
// 根据配置生成卡牌
|
||
totalCards := 0
|
||
for _, cfg := range configs {
|
||
totalCards += int(cfg.Quantity)
|
||
}
|
||
g.cards = make([]CardType, 0, totalCards)
|
||
for _, cfg := range configs {
|
||
for i := int32(0); i < cfg.Quantity; i++ {
|
||
g.cards = append(g.cards, cfg.Code)
|
||
}
|
||
}
|
||
|
||
// 安全洗牌
|
||
g.secureShuffle()
|
||
|
||
// 分配手牌和牌堆(手牌9张)
|
||
handSize := 9
|
||
if len(g.cards) < handSize {
|
||
handSize = len(g.cards)
|
||
}
|
||
g.hand = g.cards[:handSize]
|
||
g.deck = g.cards[handSize:]
|
||
|
||
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
|
||
}
|
||
|
||
// 保留原有函数用于向后兼容(使用默认配置)
|
||
func NewMatchingGame() *MatchingGame {
|
||
defaultConfigs := []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},
|
||
}
|
||
return NewMatchingGameWithConfig(defaultConfigs)
|
||
}
|
||
|
||
// secureShuffle 使用 HMAC-SHA256 的 Fisher-Yates 洗牌
|
||
func (g *MatchingGame) secureShuffle() {
|
||
n := len(g.cards)
|
||
for i := n - 1; i > 0; i-- {
|
||
j := g.secureRandInt(i+1, fmt.Sprintf("shuffle:%d", i))
|
||
g.cards[i], g.cards[j] = g.cards[j], g.cards[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))
|
||
}
|
||
|
||
// PlayRound 执行一轮游戏
|
||
func (g *MatchingGame) PlayRound(isFirst bool) MatchingRoundResult {
|
||
g.round++
|
||
handBefore := make([]CardType, len(g.hand))
|
||
copy(handBefore, g.hand)
|
||
result := MatchingRoundResult{
|
||
Round: g.round,
|
||
HandBefore: handBefore,
|
||
}
|
||
|
||
// 第一轮:如果有类型A的卡牌,额外抽牌
|
||
if isFirst {
|
||
appleCount := 0
|
||
for _, c := range g.hand {
|
||
if c == "A" {
|
||
appleCount++
|
||
}
|
||
}
|
||
if appleCount > 0 && len(g.deck) >= appleCount {
|
||
extra := g.deck[:appleCount]
|
||
g.hand = append(g.hand, extra...)
|
||
g.deck = g.deck[appleCount:]
|
||
result.DrawnCards = append(result.DrawnCards, extra...)
|
||
}
|
||
}
|
||
|
||
// 统计每种牌的数量
|
||
counter := make(map[CardType]int)
|
||
for _, c := range g.hand {
|
||
counter[c]++
|
||
}
|
||
|
||
// 找出配对
|
||
pairsCount := int64(0)
|
||
remaining := []CardType{}
|
||
for cardType, count := range counter {
|
||
pairs := count / 2
|
||
if pairs > 0 {
|
||
pairsCount += int64(pairs)
|
||
result.Pairs = append(result.Pairs, MatchingPair{CardType: cardType, Count: int64(pairs * 2)})
|
||
}
|
||
// 剩余单张
|
||
if count%2 == 1 {
|
||
remaining = append(remaining, cardType)
|
||
}
|
||
}
|
||
|
||
result.PairsCount = pairsCount
|
||
g.totalPairs += pairsCount
|
||
|
||
// 抽取新牌
|
||
if pairsCount > 0 {
|
||
drawCount := int(pairsCount)
|
||
if drawCount > len(g.deck) {
|
||
drawCount = len(g.deck)
|
||
}
|
||
if drawCount > 0 {
|
||
newCards := g.deck[:drawCount]
|
||
g.deck = g.deck[drawCount:]
|
||
remaining = append(remaining, newCards...)
|
||
result.DrawnCards = append(result.DrawnCards, newCards...)
|
||
}
|
||
}
|
||
|
||
g.hand = remaining
|
||
handAfter := make([]CardType, len(g.hand))
|
||
copy(handAfter, g.hand)
|
||
result.HandAfter = handAfter
|
||
result.CanContinue = pairsCount > 0
|
||
|
||
g.roundHistory = append(g.roundHistory, result)
|
||
|
||
return result
|
||
}
|
||
|
||
// GetGameState 获取游戏状态
|
||
func (g *MatchingGame) GetGameState() map[string]any {
|
||
return map[string]any{
|
||
"hand": g.hand,
|
||
"hand_count": len(g.hand),
|
||
"deck_count": len(g.deck),
|
||
"total_pairs": g.totalPairs,
|
||
"round": g.round,
|
||
"server_seed_hash": g.serverSeedHash,
|
||
}
|
||
}
|
||
|
||
// ========== API Handlers ==========
|
||
|
||
type matchingGameStartRequest struct {
|
||
IssueID int64 `json:"issue_id"`
|
||
}
|
||
|
||
type matchingGameStartResponse struct {
|
||
GameID string `json:"game_id"`
|
||
Hand []MatchingCard `json:"hand"`
|
||
DeckCount int `json:"deck_count"`
|
||
ServerSeedHash string `json:"server_seed_hash"`
|
||
FirstRound MatchingRoundResult `json:"first_round"`
|
||
}
|
||
|
||
type matchingGamePlayRequest struct {
|
||
GameID string `json:"game_id"`
|
||
}
|
||
|
||
type matchingGamePlayResponse struct {
|
||
Round MatchingRoundResult `json:"round"`
|
||
TotalPairs int64 `json:"total_pairs"`
|
||
GameOver bool `json:"game_over"`
|
||
FinalState map[string]any `json:"final_state,omitempty"`
|
||
}
|
||
|
||
// 游戏会话存储(生产环境应使用 Redis)
|
||
var gameSessionsV2 = make(map[string]*MatchingGame)
|
||
|
||
// StartMatchingGame 开始对对碰游戏
|
||
// @Summary 开始对对碰游戏
|
||
// @Description 创建新的对对碰游戏会话,返回初始手牌和第一轮结果
|
||
// @Tags APP端.活动
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security LoginVerifyToken
|
||
// @Param RequestBody body matchingGameStartRequest true "请求参数"
|
||
// @Success 200 {object} matchingGameStartResponse
|
||
// @Failure 400 {object} code.Failure
|
||
// @Router /api/app/matching/start [post]
|
||
func (h *handler) StartMatchingGame() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
userID := int64(ctx.SessionUserInfo().Id)
|
||
|
||
// 从数据库加载卡牌类型配置
|
||
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},
|
||
}
|
||
}
|
||
|
||
// 创建新游戏
|
||
game := NewMatchingGameWithConfig(configs)
|
||
|
||
// 生成游戏ID
|
||
gameID := fmt.Sprintf("MG%d%d", userID, time.Now().UnixNano())
|
||
|
||
// 存储游戏会话
|
||
gameSessionsV2[gameID] = game
|
||
|
||
// 执行第一轮
|
||
firstRound := game.PlayRound(true)
|
||
|
||
// 构建手牌展示(包含卡牌名称和图片)
|
||
cards := make([]MatchingCard, len(game.hand))
|
||
for i, c := range game.hand {
|
||
mc := MatchingCard{ID: i, Type: c}
|
||
if cfg := game.getCardConfig(c); cfg != nil {
|
||
mc.Name = cfg.Name
|
||
mc.ImageURL = cfg.ImageURL
|
||
}
|
||
cards[i] = mc
|
||
}
|
||
|
||
rsp := &matchingGameStartResponse{
|
||
GameID: gameID,
|
||
Hand: cards,
|
||
DeckCount: len(game.deck),
|
||
ServerSeedHash: game.serverSeedHash,
|
||
FirstRound: firstRound,
|
||
}
|
||
|
||
ctx.Payload(rsp)
|
||
}
|
||
}
|
||
|
||
// PlayMatchingGame 执行一轮对对碰游戏
|
||
// @Summary 执行一轮对对碰游戏
|
||
// @Description 执行一轮配对,返回配对结果和新抽取的牌
|
||
// @Tags APP端.活动
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security LoginVerifyToken
|
||
// @Param RequestBody body matchingGamePlayRequest true "请求参数"
|
||
// @Success 200 {object} matchingGamePlayResponse
|
||
// @Failure 400 {object} code.Failure
|
||
// @Router /api/app/matching/play [post]
|
||
func (h *handler) PlayMatchingGame() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
req := new(matchingGamePlayRequest)
|
||
if err := ctx.ShouldBindJSON(req); err != nil || req.GameID == "" {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "game_id required"))
|
||
return
|
||
}
|
||
|
||
// 获取游戏会话
|
||
game, ok := gameSessionsV2[req.GameID]
|
||
if !ok {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "game session not found"))
|
||
return
|
||
}
|
||
|
||
// 执行一轮
|
||
round := game.PlayRound(false)
|
||
|
||
rsp := &matchingGamePlayResponse{
|
||
Round: round,
|
||
TotalPairs: game.totalPairs,
|
||
GameOver: !round.CanContinue,
|
||
}
|
||
|
||
// 如果游戏结束,返回最终状态
|
||
if !round.CanContinue {
|
||
rsp.FinalState = game.GetGameState()
|
||
// 清理会话
|
||
delete(gameSessionsV2, 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, ok := gameSessionsV2[gameID]
|
||
if !ok {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "game session not found"))
|
||
return
|
||
}
|
||
|
||
ctx.Payload(game.GetGameState())
|
||
}
|
||
}
|