diff --git a/services/game-service/internal/service/game/service.go b/services/game-service/internal/service/game/service.go index f476b0dc..129e6de9 100644 --- a/services/game-service/internal/service/game/service.go +++ b/services/game-service/internal/service/game/service.go @@ -564,6 +564,9 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes } } if base == "" { + if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) { + return "", xerr.New(xerr.InvalidArgument, "yomi auth url is required") + } if strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) { return "", xerr.New(xerr.InvalidArgument, "zeeone launch url is required") } @@ -587,11 +590,14 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes query.Set("platUserId", externalUserID(session, config.UIDMode)) query.Set("lang", lang) // Yomi gameLevelUid 语义类似房间/场次,没有后台配置时必须显式传 0。 - if config.GameLevelUID > 0 { - query.Set("gameLevelUid", strconv.FormatInt(config.GameLevelUID, 10)) + if gameLevelUID := config.GameLevelUIDFor(game); gameLevelUID > 0 { + query.Set("gameLevelUid", strconv.FormatInt(gameLevelUID, 10)) } else { query.Set("gameLevelUid", "0") } + if config.PlatPayload != "" { + query.Set("platPayload", config.PlatPayload) + } if session.RoomID != "" { query.Set("platRoomId", session.RoomID) } diff --git a/services/game-service/internal/service/game/service_test.go b/services/game-service/internal/service/game/service_test.go index 81d5279c..ca558401 100644 --- a/services/game-service/internal/service/game/service_test.go +++ b/services/game-service/internal/service/game/service_test.go @@ -8,6 +8,7 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "net/url" "strconv" "strings" "testing" @@ -82,7 +83,7 @@ func TestLaunchGameBuildsYomiAuthURL(t *testing.T) { PlatformStatus: gamedomain.StatusActive, APIBaseURL: "https://api-testv2.yomigame.games/gate/gateway/auth", AdapterType: gamedomain.AdapterYomiV4, - AdapterConfigJSON: `{"default_lang":"en","token_ttl_seconds":86400,"uid_mode":"user_id"}`, + AdapterConfigJSON: `{"default_lang":"en","token_ttl_seconds":86400,"uid_mode":"user_id","game_level_uids":{"33":17},"plat_payload":"{\"env\":\"test\"}"}`, }, } svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{}) @@ -102,13 +103,57 @@ func TestLaunchGameBuildsYomiAuthURL(t *testing.T) { if result.ExpiresAtMS != 1700086400000 { t.Fatalf("yomi launch must use adapter token ttl, got %+v", result) } - for _, part := range []string{"gameUid=33", "platUserId=42", "lang=en", "gameLevelUid=0", "platRoomId=room_1", "platAuthCode=app_access_token_yomi"} { - if !strings.Contains(result.LaunchURL, part) { - t.Fatalf("yomi launch url missing %s: %s", part, result.LaunchURL) + parsed, err := url.Parse(result.LaunchURL) + if err != nil { + t.Fatalf("parse yomi launch url failed: %v", err) + } + query := parsed.Query() + expected := map[string]string{ + "gameUid": "33", + "platUserId": "42", + "lang": "en", + "gameLevelUid": "17", + "platRoomId": "room_1", + "platAuthCode": "app_access_token_yomi", + "platPayload": `{"env":"test"}`, + } + for key, want := range expected { + if got := query.Get(key); got != want { + t.Fatalf("yomi launch query %s mismatch: got %q want %q url=%s", key, got, want, result.LaunchURL) } } } +func TestLaunchGameRequiresYomiAuthURL(t *testing.T) { + repo := &fakeRepository{ + launchable: gamedomain.LaunchableGame{ + CatalogItem: gamedomain.CatalogItem{ + AppCode: "lalu", + GameID: "yomi_fish_001", + PlatformCode: "yomi", + ProviderGameID: "33", + GameName: "Fish", + Status: gamedomain.StatusActive, + }, + PlatformStatus: gamedomain.StatusActive, + AdapterType: gamedomain.AdapterYomiV4, + AdapterConfigJSON: `{"default_lang":"en"}`, + }, + } + svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{}) + svc.now = func() time.Time { return time.UnixMilli(1700000000000) } + + _, err := svc.LaunchGame(context.Background(), LaunchCommand{ + AppCode: "lalu", + UserID: 42, + GameID: "yomi_fish_001", + AccessToken: "app_access_token_yomi", + }) + if err == nil || !strings.Contains(xerr.MessageOf(err), "yomi auth url is required") { + t.Fatalf("LaunchGame must reject missing yomi auth url, got %v", err) + } +} + func TestLaunchGameBuildsLeaderCCURL(t *testing.T) { repo := &fakeRepository{ launchable: gamedomain.LaunchableGame{ @@ -464,6 +509,69 @@ func TestHandleLeaderCCChangeCoinAcceptsAppAccessTokenAndTokenOnlyProbe(t *testi } } +func TestHandleYomiGetTokenAndUserInfo(t *testing.T) { + secret := "kEdOjdxgOUCJjG1L6kl3fYEIgMFxQOza" + token := "app_access_token_yomi" + repo := &fakeRepository{ + platform: gamedomain.Platform{ + PlatformCode: "yomi", + AdapterType: gamedomain.AdapterYomiV4, + CallbackSecretCiphertext: secret, + AdapterConfigJSON: `{"uid_mode":"display_user_id"}`, + }, + session: gamedomain.LaunchSession{ + AppCode: "lalu", + SessionID: "game_sess_yomi", + UserID: 42, + DisplayUserID: "420001", + RoomID: "room_1", + PlatformCode: "yomi", + GameID: "yomi_fish_001", + ProviderGameID: "33", + LaunchTokenHash: stableHash(token), + Status: gamedomain.SessionActive, + ExpiresAtMS: 1700086400000, + }, + } + wallet := &fakeWallet{balanceAfter: 1880} + user := &fakeUser{} + svc := New(Config{}, repo, wallet, user) + svc.now = func() time.Time { return time.UnixMilli(1700000000000) } + + getTokenBody := encryptYomiTestBody(t, secret, `{"gameUid":33,"lang":"en","platAuthCode":"app_access_token_yomi","platUserId":"420001","platPayload":"","platRoomId":"room_1"}`) + raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ + Meta: &gamev1.RequestMeta{RequestId: "req-yomi-gettoken", AppCode: "lalu"}, + PlatformCode: "yomi", + Operation: "gettoken", + RawBody: getTokenBody, + RemoteAddr: "3.1.174.194:443", + }) + if err != nil { + t.Fatalf("HandleCallback gettoken failed: %v", err) + } + if contentType != "application/json" || !strings.Contains(string(raw), `"code":200`) || !strings.Contains(string(raw), `"platToken":"app_access_token_yomi"`) || !strings.Contains(string(raw), `"balance":1880`) { + t.Fatalf("yomi gettoken response mismatch: %s", raw) + } + if user.lastGet.GetUserId() != 42 { + t.Fatalf("yomi gettoken must query token user, got %+v", user.lastGet) + } + + userInfoBody := encryptYomiTestBody(t, secret, `{"platUserId":"420001","platToken":"app_access_token_yomi","platPayload":"","platRoomId":"room_1"}`) + raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ + Meta: &gamev1.RequestMeta{RequestId: "req-yomi-userinfo", AppCode: "lalu"}, + PlatformCode: "yomi", + Operation: "userinfo", + RawBody: userInfoBody, + RemoteAddr: "3.1.174.194:443", + }) + if err != nil { + t.Fatalf("HandleCallback userinfo failed: %v", err) + } + if !strings.Contains(string(raw), `"code":200`) || !strings.Contains(string(raw), `"platUserId":"420001"`) || !strings.Contains(string(raw), `"avatar":"https://cdn.example/avatar.png"`) { + t.Fatalf("yomi userinfo response mismatch: %s", raw) + } +} + func TestHandleYomiChangeBalanceDecryptsAndAppliesWallet(t *testing.T) { secret := "12345678901234567890123456789012" token := "gt_test_token" diff --git a/services/game-service/internal/service/game/yomi_adapter.go b/services/game-service/internal/service/game/yomi_adapter.go index fa27cbbc..47d5fb92 100644 --- a/services/game-service/internal/service/game/yomi_adapter.go +++ b/services/game-service/internal/service/game/yomi_adapter.go @@ -43,6 +43,10 @@ type yomiAdapterConfig struct { TokenTTLSeconds int64 `json:"token_ttl_seconds"` // game_level_uid 对应 Yomi 房间/场次,没有配置时按文档传 0。 GameLevelUID int64 `json:"game_level_uid"` + // game_level_uids 支持同一平台下不同游戏/房间使用不同场次 ID,key 可填 providerGameId 或内部 gameId。 + GameLevelUIDs map[string]int64 `json:"game_level_uids"` + // plat_payload 透传给 Yomi,适合联调时放环境、渠道或房间附加上下文;不参与服务端业务判断。 + PlatPayload string `json:"plat_payload"` } func yomiConfigFromPlatform(value any) yomiAdapterConfig { @@ -58,6 +62,8 @@ func yomiConfigFromPlatform(value any) yomiAdapterConfig { _ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config) config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode)) config.DefaultLang = strings.TrimSpace(config.DefaultLang) + config.PlatPayload = strings.TrimSpace(config.PlatPayload) + config.GameLevelUIDs = normalizeInt64Map(config.GameLevelUIDs) return config } @@ -69,6 +75,32 @@ func (c yomiAdapterConfig) TokenTTL() time.Duration { return yomiDefaultTokenTTL } +func (c yomiAdapterConfig) GameLevelUIDFor(game gamedomain.LaunchableGame) int64 { + for _, key := range []string{game.ProviderGameID, game.GameID, strings.ToLower(game.GameID)} { + if value := c.GameLevelUIDs[strings.TrimSpace(key)]; value > 0 { + return value + } + } + return c.GameLevelUID +} + +func normalizeInt64Map(input map[string]int64) map[string]int64 { + if len(input) == 0 { + return nil + } + output := make(map[string]int64, len(input)) + for key, value := range input { + key = strings.TrimSpace(key) + if key != "" && value > 0 { + output[key] = value + } + } + if len(output) == 0 { + return nil + } + return output +} + func externalUserID(session gamedomain.LaunchSession, uidMode string) string { // 三方通常会把 uid 展示给玩家或写入订单,切换 uid_mode 后必须保持启动和回调校验一致。 if strings.EqualFold(uidMode, "display_user_id") && strings.TrimSpace(session.DisplayUserID) != "" {