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" "gorm.io/gorm" ) // 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 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 and deduct one err = s.db.GetDbW().Transaction(func(tx *gorm.DB) error { // Check available tickets var userTicket model.UserGameTickets if err := tx.Where("user_id = ? AND game_code = ? AND available > 0", userID, gameType).First(&userTicket).Error; err != nil { return fmt.Errorf("no available game tickets") } // Deduct one ticket result := tx.Model(&model.UserGameTickets{}). Where("user_id = ? AND game_code = ? AND available > 0", userID, gameType). Updates(map[string]interface{}{ "available": gorm.Expr("available - 1"), "total_used": gorm.Expr("total_used + 1"), "updated_at": time.Now(), }) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return fmt.Errorf("failed to deduct ticket") } // Get new balance for logging var balance int32 tx.Model(&model.UserGameTickets{}). Where("user_id = ? AND game_code = ?", userID, gameType). Pluck("available", &balance) // Log the ticket usage log := &model.GameTicketLogs{ UserID: userID, GameCode: gameType, ChangeType: 2, // 使用 Amount: 1, Balance: balance, Source: "game_token", Remark: "生成游戏Token", } return tx.Create(log).Error }) if err != nil { return "", "", time.Time{}, err } // 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) 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)) } // 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 { 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 { return nil, fmt.Errorf("ticket not found or expired") } if storedUserID != fmt.Sprintf("%d", claims.UserID) { return nil, fmt.Errorf("ticket user mismatch") } 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) return s.redis.Del(ctx, ticketKey).Err() }