fix(game): scope yomi launch session lookup

This commit is contained in:
zhx 2026-06-10 11:25:28 +08:00
parent 709fd00ab2
commit 59a609a45d
4 changed files with 143 additions and 3 deletions

View File

@ -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

View File

@ -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 {

View File

@ -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) {

View File

@ -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 (