fix(game): scope yomi launch session lookup
This commit is contained in:
parent
709fd00ab2
commit
59a609a45d
@ -35,6 +35,7 @@ type Repository interface {
|
|||||||
GetPlatform(ctx context.Context, appCode string, platformCode string) (gamedomain.Platform, error)
|
GetPlatform(ctx context.Context, appCode string, platformCode string) (gamedomain.Platform, error)
|
||||||
CreateLaunchSession(ctx context.Context, session gamedomain.LaunchSession) error
|
CreateLaunchSession(ctx context.Context, session gamedomain.LaunchSession) error
|
||||||
GetLaunchSessionByToken(ctx context.Context, appCode string, token string) (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
|
InsertCallbackLog(ctx context.Context, log gamedomain.CallbackLog) error
|
||||||
CreateGameOrder(ctx context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, 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
|
MarkOrderSucceeded(ctx context.Context, appCode string, orderID string, walletTransactionID string, balanceAfter int64, nowMs int64) error
|
||||||
|
|||||||
@ -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) {
|
func TestHandleYomiChangeBalanceDecryptsAndAppliesWallet(t *testing.T) {
|
||||||
secret := "12345678901234567890123456789012"
|
secret := "12345678901234567890123456789012"
|
||||||
token := "gt_test_token"
|
token := "gt_test_token"
|
||||||
@ -1581,6 +1654,7 @@ type fakeRepository struct {
|
|||||||
launchable gamedomain.LaunchableGame
|
launchable gamedomain.LaunchableGame
|
||||||
platform gamedomain.Platform
|
platform gamedomain.Platform
|
||||||
session gamedomain.LaunchSession
|
session gamedomain.LaunchSession
|
||||||
|
sessions []gamedomain.LaunchSession
|
||||||
order gamedomain.GameOrder
|
order gamedomain.GameOrder
|
||||||
orders []gamedomain.GameOrder
|
orders []gamedomain.GameOrder
|
||||||
repair gamedomain.RepairOrder
|
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 {
|
func (f *fakeRepository) CreateLaunchSession(_ context.Context, session gamedomain.LaunchSession) error {
|
||||||
f.session = session
|
f.session = session
|
||||||
|
f.sessions = append(f.sessions, session)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (f *fakeRepository) GetLaunchSessionByToken(_ context.Context, _ string, token string) (gamedomain.LaunchSession, error) {
|
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) {
|
if f.session.LaunchTokenHash == stableHash(token) {
|
||||||
return f.session, nil
|
return f.session, nil
|
||||||
}
|
}
|
||||||
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "session token invalid")
|
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) InsertCallbackLog(context.Context, gamedomain.CallbackLog) error { return nil }
|
||||||
func (f *fakeRepository) CreateGameOrder(_ context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) {
|
func (f *fakeRepository) CreateGameOrder(_ context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) {
|
||||||
for _, existing := range f.orders {
|
for _, existing := range f.orders {
|
||||||
|
|||||||
@ -153,7 +153,7 @@ func (s *Service) handleYomiGetToken(ctx context.Context, app string, platform g
|
|||||||
return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "")
|
return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "")
|
||||||
}
|
}
|
||||||
config := yomiConfigFromPlatform(platform)
|
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 游戏或别的用户。
|
// 同时校验 token、用户和游戏,避免 A 游戏拿到的 token 被挪到 B 游戏或别的用户。
|
||||||
if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, body.GameUID.String()) {
|
if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, body.GameUID.String()) {
|
||||||
return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, "")
|
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, "")
|
return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "")
|
||||||
}
|
}
|
||||||
config := yomiConfigFromPlatform(platform)
|
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。
|
// userinfo 没有 gameUid 时只校验用户;change_balance 会额外校验游戏 ID。
|
||||||
if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, "") {
|
if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, "") {
|
||||||
return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, "")
|
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())
|
providerOrderID := strings.TrimSpace(body.RequestID.String())
|
||||||
config := yomiConfigFromPlatform(platform)
|
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。
|
// 改账必须绑定 token、uid、gameUid 三个维度,防止跨用户、跨游戏复用合法 token。
|
||||||
if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, body.GameUID.String()) {
|
if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, body.GameUID.String()) {
|
||||||
return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, providerOrderID)
|
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
|
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 {
|
func yomiSessionMatches(session gamedomain.LaunchSession, config yomiAdapterConfig, platUserID string, gameUID string) bool {
|
||||||
// gameUID 允许为空是为了兼容 userinfo;只要厂商传了就必须和启动会话里的 provider_game_id 一致。
|
// gameUID 允许为空是为了兼容 userinfo;只要厂商传了就必须和启动会话里的 provider_game_id 一致。
|
||||||
if strings.TrimSpace(gameUID) != "" && strings.TrimSpace(gameUID) != strings.TrimSpace(session.ProviderGameID) {
|
if strings.TrimSpace(gameUID) != "" && strings.TrimSpace(gameUID) != strings.TrimSpace(session.ProviderGameID) {
|
||||||
|
|||||||
@ -544,6 +544,31 @@ func (r *Repository) GetLaunchSessionByToken(ctx context.Context, appCode string
|
|||||||
return item, nil
|
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 {
|
func (r *Repository) InsertCallbackLog(ctx context.Context, log gamedomain.CallbackLog) error {
|
||||||
_, err := r.db.ExecContext(ctx,
|
_, err := r.db.ExecContext(ctx,
|
||||||
`INSERT INTO game_callback_logs (
|
`INSERT INTO game_callback_logs (
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user