diff --git a/services/game-service/internal/service/game/service.go b/services/game-service/internal/service/game/service.go index 10852b04..e0947207 100644 --- a/services/game-service/internal/service/game/service.go +++ b/services/game-service/internal/service/game/service.go @@ -35,6 +35,7 @@ type Repository interface { GetPlatform(ctx context.Context, appCode string, platformCode string) (gamedomain.Platform, error) CreateLaunchSession(ctx context.Context, session gamedomain.LaunchSession) error GetLaunchSessionByToken(ctx context.Context, appCode string, token string) (gamedomain.LaunchSession, error) + GetLaunchSessionByTokenScope(ctx context.Context, appCode string, token string, platformCode string, providerGameID string) (gamedomain.LaunchSession, error) InsertCallbackLog(ctx context.Context, log gamedomain.CallbackLog) error CreateGameOrder(ctx context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) MarkOrderSucceeded(ctx context.Context, appCode string, orderID string, walletTransactionID string, balanceAfter int64, nowMs int64) error diff --git a/services/game-service/internal/service/game/service_test.go b/services/game-service/internal/service/game/service_test.go index f3e45d87..da07a565 100644 --- a/services/game-service/internal/service/game/service_test.go +++ b/services/game-service/internal/service/game/service_test.go @@ -876,6 +876,79 @@ func TestHandleYomiGetTokenAndUserInfo(t *testing.T) { } } +func TestHandleYomiGetTokenScopesSharedAccessTokenByPlatformAndGame(t *testing.T) { + secret := "12345678901234567890123456789012" + token := "shared_app_access_token" + repo := &fakeRepository{ + platform: gamedomain.Platform{ + PlatformCode: "yomi", + AdapterType: gamedomain.AdapterYomiV4, + CallbackSecretCiphertext: secret, + AdapterConfigJSON: `{"uid_mode":"display_user_id"}`, + }, + sessions: []gamedomain.LaunchSession{ + { + AppCode: "lalu", + SessionID: "game_sess_yomi_463", + UserID: 42, + DisplayUserID: "420001", + RoomID: "room_463", + PlatformCode: "yomi", + GameID: "yomi_463", + ProviderGameID: "463", + LaunchTokenHash: stableHash(token), + Status: gamedomain.SessionActive, + ExpiresAtMS: 1700086400000, + CreatedAtMS: 1700000000000, + }, + { + AppCode: "lalu", + SessionID: "game_sess_yomi_464", + UserID: 42, + DisplayUserID: "420001", + RoomID: "room_464", + PlatformCode: "yomi", + GameID: "yomi_464", + ProviderGameID: "464", + LaunchTokenHash: stableHash(token), + Status: gamedomain.SessionActive, + ExpiresAtMS: 1700086400000, + CreatedAtMS: 1700000001000, + }, + { + AppCode: "lalu", + SessionID: "game_sess_reyou_1", + UserID: 42, + DisplayUserID: "420001", + RoomID: "room_reyou", + PlatformCode: "reyou", + GameID: "reyou_1", + ProviderGameID: "1", + LaunchTokenHash: stableHash(token), + Status: gamedomain.SessionActive, + ExpiresAtMS: 1700086400000, + CreatedAtMS: 1700000002000, + }, + }, + } + svc := New(Config{}, repo, &fakeWallet{balanceAfter: 1880}, &fakeUser{}) + svc.now = func() time.Time { return time.UnixMilli(1700000000000) } + + raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ + Meta: &gamev1.RequestMeta{RequestId: "req-yomi-scoped-gettoken", AppCode: "lalu"}, + PlatformCode: "yomi", + Operation: "gettoken", + RawBody: encryptYomiTestBody(t, secret, `{"gameUid":463,"lang":"en","platAuthCode":"shared_app_access_token","platUserId":"420001","platPayload":"","platRoomId":"room_463"}`), + RemoteAddr: "3.1.174.194:443", + }) + if err != nil { + t.Fatalf("HandleCallback failed: %v", err) + } + if !strings.Contains(string(raw), `"code":200`) || !strings.Contains(string(raw), `"platToken":"shared_app_access_token"`) { + t.Fatalf("yomi scoped gettoken response mismatch: %s", raw) + } +} + func TestHandleYomiChangeBalanceDecryptsAndAppliesWallet(t *testing.T) { secret := "12345678901234567890123456789012" token := "gt_test_token" @@ -1581,6 +1654,7 @@ type fakeRepository struct { launchable gamedomain.LaunchableGame platform gamedomain.Platform session gamedomain.LaunchSession + sessions []gamedomain.LaunchSession order gamedomain.GameOrder orders []gamedomain.GameOrder repair gamedomain.RepairOrder @@ -1613,14 +1687,39 @@ func (f *fakeRepository) GetPlatform(context.Context, string, string) (gamedomai } func (f *fakeRepository) CreateLaunchSession(_ context.Context, session gamedomain.LaunchSession) error { f.session = session + f.sessions = append(f.sessions, session) return nil } func (f *fakeRepository) GetLaunchSessionByToken(_ context.Context, _ string, token string) (gamedomain.LaunchSession, error) { + for index := len(f.sessions) - 1; index >= 0; index-- { + if f.sessions[index].LaunchTokenHash == stableHash(token) { + return f.sessions[index], nil + } + } if f.session.LaunchTokenHash == stableHash(token) { return f.session, nil } return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "session token invalid") } +func (f *fakeRepository) GetLaunchSessionByTokenScope(_ context.Context, _ string, token string, platformCode string, providerGameID string) (gamedomain.LaunchSession, error) { + tokenHash := stableHash(token) + for index := len(f.sessions) - 1; index >= 0; index-- { + session := f.sessions[index] + if session.LaunchTokenHash != tokenHash || session.PlatformCode != strings.TrimSpace(platformCode) { + continue + } + if strings.TrimSpace(providerGameID) != "" && session.ProviderGameID != strings.TrimSpace(providerGameID) { + continue + } + return session, nil + } + if f.session.LaunchTokenHash == tokenHash && f.session.PlatformCode == strings.TrimSpace(platformCode) { + if strings.TrimSpace(providerGameID) == "" || f.session.ProviderGameID == strings.TrimSpace(providerGameID) { + return f.session, nil + } + } + return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "session token invalid") +} func (f *fakeRepository) InsertCallbackLog(context.Context, gamedomain.CallbackLog) error { return nil } func (f *fakeRepository) CreateGameOrder(_ context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) { for _, existing := range f.orders { diff --git a/services/game-service/internal/service/game/yomi_adapter.go b/services/game-service/internal/service/game/yomi_adapter.go index 37edfbb8..d5fa2326 100644 --- a/services/game-service/internal/service/game/yomi_adapter.go +++ b/services/game-service/internal/service/game/yomi_adapter.go @@ -153,7 +153,7 @@ func (s *Service) handleYomiGetToken(ctx context.Context, app string, platform g return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "") } config := yomiConfigFromPlatform(platform) - session, err := s.validYomiSession(ctx, app, body.PlatAuthCode) + session, err := s.validScopedYomiSession(ctx, app, platform.PlatformCode, body.PlatAuthCode, body.GameUID.String()) // 同时校验 token、用户和游戏,避免 A 游戏拿到的 token 被挪到 B 游戏或别的用户。 if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, body.GameUID.String()) { return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, "") @@ -188,7 +188,7 @@ func (s *Service) handleYomiUserInfo(ctx context.Context, app string, platform g return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "") } config := yomiConfigFromPlatform(platform) - session, err := s.validYomiSession(ctx, app, body.PlatToken) + session, err := s.validScopedYomiSession(ctx, app, platform.PlatformCode, body.PlatToken, "") // userinfo 没有 gameUid 时只校验用户;change_balance 会额外校验游戏 ID。 if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, "") { return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, "") @@ -229,7 +229,7 @@ func (s *Service) handleYomiChangeBalance(ctx context.Context, app string, platf } providerOrderID := strings.TrimSpace(body.RequestID.String()) config := yomiConfigFromPlatform(platform) - session, err := s.validYomiSession(ctx, app, body.PlatToken) + session, err := s.validScopedYomiSession(ctx, app, platform.PlatformCode, body.PlatToken, body.GameUID.String()) // 改账必须绑定 token、uid、gameUid 三个维度,防止跨用户、跨游戏复用合法 token。 if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, body.GameUID.String()) { return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, providerOrderID) @@ -432,6 +432,21 @@ func (s *Service) validYomiSession(ctx context.Context, app string, token string return session, nil } +func (s *Service) validScopedYomiSession(ctx context.Context, app string, platformCode string, token string, providerGameID string) (gamedomain.LaunchSession, error) { + // 同一个 App token 可能同时启动多个厂商游戏,所以 Yomi 回调必须带平台和可选 gameUid 查询,不能只按 token 命中任意旧 session。 + if strings.TrimSpace(token) == "" { + return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid") + } + session, err := s.repository.GetLaunchSessionByTokenScope(ctx, app, token, platformCode, providerGameID) + if err != nil { + return gamedomain.LaunchSession{}, err + } + if session.Status != gamedomain.SessionActive || session.ExpiresAtMS <= s.now().UnixMilli() { + return gamedomain.LaunchSession{}, xerr.New(xerr.SessionExpired, "token expired") + } + return session, nil +} + func yomiSessionMatches(session gamedomain.LaunchSession, config yomiAdapterConfig, platUserID string, gameUID string) bool { // gameUID 允许为空是为了兼容 userinfo;只要厂商传了就必须和启动会话里的 provider_game_id 一致。 if strings.TrimSpace(gameUID) != "" && strings.TrimSpace(gameUID) != strings.TrimSpace(session.ProviderGameID) { diff --git a/services/game-service/internal/storage/mysql/repository.go b/services/game-service/internal/storage/mysql/repository.go index fbd7f73e..961cadc2 100644 --- a/services/game-service/internal/storage/mysql/repository.go +++ b/services/game-service/internal/storage/mysql/repository.go @@ -544,6 +544,31 @@ func (r *Repository) GetLaunchSessionByToken(ctx context.Context, appCode string return item, nil } +func (r *Repository) GetLaunchSessionByTokenScope(ctx context.Context, appCode string, token string, platformCode string, providerGameID string) (gamedomain.LaunchSession, error) { + // 同一个 App access token 会被多个厂商和多个游戏复用,回调校验必须把 token、平台和可选游戏 ID 一起收窄。 + // 这里按 created_at_ms 倒序取最新一条,避免同用户重复打开同一游戏时拿到旧房间或旧 session。 + row := r.db.QueryRowContext(ctx, + `SELECT app_code, session_id, user_id, display_user_id, room_id, region_id, scene, platform_code, game_id, + provider_game_id, launch_token_hash, status, expires_at_ms, created_at_ms, updated_at_ms + FROM game_launch_sessions + WHERE app_code = ? + AND launch_token_hash = ? + AND platform_code = ? + AND (? = '' OR provider_game_id = ?) + ORDER BY created_at_ms DESC + LIMIT 1`, + appcode.Normalize(appCode), stableHash(strings.TrimSpace(token)), strings.TrimSpace(platformCode), strings.TrimSpace(providerGameID), strings.TrimSpace(providerGameID), + ) + var item gamedomain.LaunchSession + if err := row.Scan(&item.AppCode, &item.SessionID, &item.UserID, &item.DisplayUserID, &item.RoomID, &item.RegionID, &item.Scene, &item.PlatformCode, &item.GameID, &item.ProviderGameID, &item.LaunchTokenHash, &item.Status, &item.ExpiresAtMS, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "game session token invalid") + } + return gamedomain.LaunchSession{}, err + } + return item, nil +} + func (r *Repository) InsertCallbackLog(ctx context.Context, log gamedomain.CallbackLog) error { _, err := r.db.ExecContext(ctx, `INSERT INTO game_callback_logs (