162 lines
6.0 KiB
Go
Executable File
Raw Permalink 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 game
import (
"bindbox-game/configs"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/model"
"context"
"fmt"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// GameTokenClaims contains the claims for a game token
type GameTokenClaims struct {
UserID int64 `json:"user_id"`
Username string `json:"username"`
Avatar string `json:"avatar"`
GameType string `json:"game_type"`
Ticket string `json:"ticket"`
jwt.RegisteredClaims
}
// GameTokenService handles game token generation and validation
type GameTokenService interface {
GenerateToken(ctx context.Context, userID int64, username, avatar, gameType string) (token string, ticket string, expiresAt time.Time, err error)
ValidateToken(ctx context.Context, tokenString string) (*GameTokenClaims, error)
InvalidateTicket(ctx context.Context, ticket string) error
}
type gameTokenService struct {
logger logger.CustomLogger
db mysql.Repo
redis *redis.Client
secret string
}
// NewGameTokenService creates a new game token service
func NewGameTokenService(l logger.CustomLogger, db mysql.Repo, rdb *redis.Client) GameTokenService {
// Use a dedicated secret for game tokens
secret := configs.Get().Random.CommitMasterKey + "_game_token"
return &gameTokenService{
logger: l,
db: db,
redis: rdb,
secret: secret,
}
}
// GenerateToken creates a new game token for a user (does NOT consume ticket, only validates)
func (s *gameTokenService) GenerateToken(ctx context.Context, userID int64, username, avatar, gameType string) (token string, ticket string, expiresAt time.Time, err error) {
// 1. Check if user has game tickets (do NOT deduct - will be done on match success)
// For free mode, we skip this check
if gameType != "minesweeper_free" {
var userTicket model.UserGameTickets
if err = s.db.GetDbR().Where("user_id = ? AND game_code = ? AND available > 0", userID, gameType).First(&userTicket).Error; err != nil {
return "", "", time.Time{}, fmt.Errorf("no available game tickets")
}
}
// 2. Generate unique ticket ID
ticket = fmt.Sprintf("GT%d%d", userID, time.Now().UnixNano())
// 3. Store ticket in Redis (for single-use validation)
// Value format: "{userID}:{gameType}" (to allow game type verification in settlement)
ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket)
// Check for error when setting Redis key - CRITICAL FIX
redisValue := fmt.Sprintf("%d:%s", userID, gameType)
if err := s.redis.Set(ctx, ticketKey, redisValue, 30*time.Minute).Err(); err != nil {
s.logger.Error("Failed to store ticket in Redis", zap.Error(err), zap.String("ticket", ticket), zap.Int64("user_id", userID))
return "", "", time.Time{}, fmt.Errorf("failed to generate ticket: %w", err)
}
s.logger.Info("DEBUG: Generated ticket and stored in Redis", zap.String("ticket", ticket), zap.String("key", ticketKey), zap.Int64("user_id", userID))
// 4. Generate JWT token (30分钟有效期确保匹配等待时间充足)
expiresAt = time.Now().Add(30 * time.Minute)
claims := GameTokenClaims{
UserID: userID,
Username: username,
Avatar: avatar,
GameType: gameType,
Ticket: ticket,
RegisteredClaims: jwt.RegisteredClaims{
Subject: fmt.Sprintf("%d", userID),
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(expiresAt),
NotBefore: jwt.NewNumericDate(time.Now()),
},
}
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
token, err = jwtToken.SignedString([]byte(s.secret))
if err != nil {
return "", "", time.Time{}, fmt.Errorf("failed to sign token: %w", err)
}
s.logger.Info("Generated game token", zap.Int64("user_id", userID), zap.String("game_type", gameType), zap.String("ticket", ticket))
return token, ticket, expiresAt, nil
}
// ValidateToken validates a game token and returns the claims
func (s *gameTokenService) ValidateToken(ctx context.Context, tokenString string) (*GameTokenClaims, error) {
// 1. Parse and validate JWT
token, err := jwt.ParseWithClaims(tokenString, &GameTokenClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(s.secret), nil
})
if err != nil {
s.logger.Warn("Token JWT validation failed", zap.Error(err))
return nil, fmt.Errorf("invalid token: %w", err)
}
claims, ok := token.Claims.(*GameTokenClaims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token claims")
}
// 2. Check if ticket is still valid (not used)
// TODO: 临时跳过 Redis 验证,仅记录日志用于排查
ticketKey := fmt.Sprintf("game:token:ticket:%s", claims.Ticket)
storedValue, err := s.redis.Get(ctx, ticketKey).Result()
if err != nil {
s.logger.Warn("DEBUG: Ticket not found in Redis (SKIPPING validation temporarily)",
zap.String("ticket", claims.Ticket),
zap.String("key", ticketKey),
zap.Error(err))
// 临时跳过验证,允许游戏继续
// return nil, fmt.Errorf("ticket not found or expired")
} else {
// Parse stored value "userID:gameType"
parts := strings.Split(storedValue, ":")
if len(parts) < 2 {
s.logger.Warn("DEBUG: Invalid ticket format in Redis", zap.String("value", storedValue))
return nil, fmt.Errorf("invalid ticket format")
}
storedUserID := parts[0]
if storedUserID != fmt.Sprintf("%d", claims.UserID) {
s.logger.Warn("DEBUG: Ticket user mismatch", zap.String("stored", storedUserID), zap.Int64("claim_user", claims.UserID))
return nil, fmt.Errorf("ticket user mismatch")
}
}
s.logger.Info("DEBUG: Token validated successfully", zap.String("ticket", claims.Ticket))
return claims, nil
}
// InvalidateTicket marks a ticket as used
func (s *gameTokenService) InvalidateTicket(ctx context.Context, ticket string) error {
ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket)
s.logger.Info("DEBUG: Invalidating ticket", zap.String("ticket", ticket), zap.String("key", ticketKey))
return s.redis.Del(ctx, ticketKey).Err()
}