package game import ( "bindbox-game/configs" "bindbox-game/internal/pkg/logger" "bindbox-game/internal/repository/mysql" "bindbox-game/internal/repository/mysql/model" "context" "fmt" "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) 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) ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket) // Check for error when setting Redis key - CRITICAL FIX if err := s.redis.Set(ctx, ticketKey, fmt.Sprintf("%d", userID), 15*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 expiresAt = time.Now().Add(10 * 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) ticketKey := fmt.Sprintf("game:token:ticket:%s", claims.Ticket) storedUserID, err := s.redis.Get(ctx, ticketKey).Result() if err != nil { s.logger.Warn("DEBUG: Ticket not found in Redis", zap.String("ticket", claims.Ticket), zap.String("key", ticketKey), zap.Error(err)) return nil, fmt.Errorf("ticket not found or expired") } 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() }