From 0cdbd28d46372ffc71f13e5e28988cc259e7a289 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 15:13:29 +0800 Subject: [PATCH] feat(game): sync LeaderCC catalog --- .../modules/gamemanagement/sync_games.go | 103 ++++++++++++++++++ .../modules/gamemanagement/sync_games_test.go | 36 ++++++ .../internal/service/game/leadercc_adapter.go | 12 ++ .../internal/service/game/service.go | 11 +- .../internal/service/game/service_test.go | 39 +++++++ 5 files changed, 198 insertions(+), 3 deletions(-) diff --git a/server/admin/internal/modules/gamemanagement/sync_games.go b/server/admin/internal/modules/gamemanagement/sync_games.go index e9c12a77..3c7c0dec 100644 --- a/server/admin/internal/modules/gamemanagement/sync_games.go +++ b/server/admin/internal/modules/gamemanagement/sync_games.go @@ -25,6 +25,7 @@ import ( const ( adapterBaishunV1 = "baishun_v1" + adapterLeaderCCV1 = "leadercc_v1" adapterZeeOneV1 = "zeeone_v1" adapterVivaGamesV1 = "vivagames_v1" adapterReyouV1 = "reyou_v1" @@ -134,6 +135,8 @@ func fetchProviderGameSyncPlan(ctx context.Context, platform *gamev1.GamePlatfor switch strings.ToLower(strings.TrimSpace(platform.GetAdapterType())) { case adapterBaishunV1: return fetchBaishunGameSyncPlan(ctx, platform, req) + case adapterLeaderCCV1: + return fetchLeaderCCGameSyncPlan(ctx, platform, req) case adapterZeeOneV1: return fetchZeeOneGameSyncPlan(platform, req) case adapterVivaGamesV1: @@ -184,6 +187,21 @@ type baishunSyncConfig struct { GameURLs map[string]string `json:"game_urls"` } +type leaderCCSyncConfig struct { + // game_list_url 是灵仙按环境提供的公开 JSON 清单,game_urls 由同步操作自动回写。 + GameListURL string `json:"game_list_url"` + GameListURLCamel string `json:"gameListUrl"` +} + +type leaderCCGameListItem struct { + GameID any `json:"gameId"` + Name string `json:"name"` + Title string `json:"title"` + Version any `json:"ver"` + FullURL string `json:"full_url"` + HalfURL string `json:"half_url"` +} + type zeeoneSyncConfig struct { // ZeeOne 测试资料给的是逐游戏 H5 地址,不是列表 API;后台“获取游戏列表”直接把这个映射转换为候选目录。 GameURLs map[string]string `json:"game_urls"` @@ -232,6 +250,91 @@ type yomiSyncConfig struct { GameCovers map[string]string `json:"game_covers"` } +func fetchLeaderCCGameSyncPlan(ctx context.Context, platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) { + config, err := decodeLeaderCCSyncConfig(platform.GetAdapterConfigJson()) + if err != nil { + return providerGameSyncPlan{}, err + } + endpoint := strings.TrimSpace(firstNonEmpty(config.GameListURL, config.GameListURLCamel)) + if endpoint == "" { + return providerGameSyncPlan{}, fmt.Errorf("灵仙列表同步缺少配置: adapterConfigJson.game_list_url") + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return providerGameSyncPlan{}, fmt.Errorf("灵仙游戏列表 URL 不合法: %w", err) + } + resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(httpReq) + if err != nil { + return providerGameSyncPlan{}, fmt.Errorf("请求灵仙游戏列表失败: %w", err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return providerGameSyncPlan{}, fmt.Errorf("读取灵仙游戏列表失败: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return providerGameSyncPlan{}, fmt.Errorf("灵仙游戏列表 HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) + } + var providerItems []leaderCCGameListItem + if err := json.Unmarshal(raw, &providerItems); err != nil { + return providerGameSyncPlan{}, fmt.Errorf("解析灵仙游戏列表失败: %w", err) + } + games, gameURLs := leaderCCCatalogItems(platform.GetPlatformCode(), providerItems, req) + if len(games) == 0 { + return providerGameSyncPlan{}, fmt.Errorf("灵仙游戏列表为空") + } + return providerGameSyncPlan{Games: games, GameURLs: gameURLs}, nil +} + +func decodeLeaderCCSyncConfig(raw string) (leaderCCSyncConfig, error) { + if strings.TrimSpace(raw) == "" { + return leaderCCSyncConfig{}, nil + } + var config leaderCCSyncConfig + if err := json.Unmarshal([]byte(raw), &config); err != nil { + return leaderCCSyncConfig{}, fmt.Errorf("灵仙适配器配置 JSON 不合法") + } + return config, nil +} + +func leaderCCCatalogItems(platformCode string, providerItems []leaderCCGameListItem, req syncGamesRequest) ([]catalogRequest, map[string]string) { + items := make([]catalogRequest, 0, len(providerItems)) + gameURLs := make(map[string]string, len(providerItems)) + for index, providerItem := range providerItems { + providerGameID := stringFromJSONValue(providerItem.GameID) + if providerGameID == "" { + continue + } + // 半屏地址优先匹配语音房内嵌场景;厂商未提供时才回退全屏地址。 + launchURL := firstNonEmpty(providerItem.HalfURL, providerItem.FullURL) + if launchURL == "" { + continue + } + launchMode := strings.TrimSpace(req.LaunchMode) + if launchMode == "" { + launchMode = "half_screen" + if strings.TrimSpace(providerItem.HalfURL) == "" { + launchMode = defaultLaunchMode + } + } + gameURLs[providerGameID] = launchURL + items = append(items, catalogRequest{ + GameID: stableGameID(platformCode, providerGameID), + PlatformCode: strings.TrimSpace(platformCode), + ProviderGameID: providerGameID, + GameName: firstNonEmpty(providerItem.Name, providerItem.Title, providerGameID), + Category: defaulted(req.Category, defaultGameCategory), + LaunchMode: launchMode, + Orientation: defaultOrientation, + MinCoin: req.MinCoin, + Status: defaulted(req.Status, defaultGameStatus), + SortOrder: int32((index + 1) * 10), + Tags: compactTags(append([]string{strings.TrimSpace(platformCode), adapterLeaderCCV1}, req.Tags...)), + }) + } + return items, gameURLs +} + func fetchZeeOneGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) { config, err := decodeZeeOneSyncConfig(platform.GetAdapterConfigJson()) if err != nil { diff --git a/server/admin/internal/modules/gamemanagement/sync_games_test.go b/server/admin/internal/modules/gamemanagement/sync_games_test.go index 4f3c2339..d098e85b 100644 --- a/server/admin/internal/modules/gamemanagement/sync_games_test.go +++ b/server/admin/internal/modules/gamemanagement/sync_games_test.go @@ -72,6 +72,42 @@ func TestFetchBaishunGameSyncPlanFetchesSignsAndFormatsCatalog(t *testing.T) { } } +func TestFetchLeaderCCGameSyncPlanReadsRemoteJSONList(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/test_game_list.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"gameId":"9","name":"GREEDY BOX","title":"过关斩将","ver":11,"half_url":"https://games.test/greedy_half/index.html"}, + {"gameId":"7","name":"SuperRichGod","title":"超级财神","ver":11,"full_url":"https://games.test/rich/index.html"} + ]`)) + })) + defer server.Close() + + plan, err := fetchProviderGameSyncPlan(t.Context(), &gamev1.GamePlatform{ + PlatformCode: "lingxian", + AdapterType: adapterLeaderCCV1, + AdapterConfigJson: `{"game_list_url":` + strconv.Quote(server.URL+"/test_game_list.json") + `}`, + }, syncGamesRequest{}) + if err != nil { + t.Fatalf("fetchProviderGameSyncPlan failed: %v", err) + } + if len(plan.Games) != 2 { + t.Fatalf("games len = %d, want 2: %+v", len(plan.Games), plan.Games) + } + first, second := plan.Games[0], plan.Games[1] + if first.GameID != "lingxian_9" || first.GameName != "GREEDY BOX" || first.LaunchMode != "half_screen" || first.Status != "disabled" { + t.Fatalf("first game mismatch: %+v", first) + } + if second.GameID != "lingxian_7" || second.LaunchMode != "full_screen" { + t.Fatalf("full URL fallback mismatch: %+v", second) + } + if plan.GameURLs["9"] != "https://games.test/greedy_half/index.html" || plan.GameURLs["7"] != "https://games.test/rich/index.html" { + t.Fatalf("game URLs mismatch: %+v", plan.GameURLs) + } +} + func TestFetchZeeOneGameSyncPlanReadsConfiguredGameURLs(t *testing.T) { plan, err := fetchProviderGameSyncPlan(t.Context(), &gamev1.GamePlatform{ PlatformCode: "zeeone", diff --git a/services/game-service/internal/service/game/leadercc_adapter.go b/services/game-service/internal/service/game/leadercc_adapter.go index 8c58e2aa..fa987d74 100644 --- a/services/game-service/internal/service/game/leadercc_adapter.go +++ b/services/game-service/internal/service/game/leadercc_adapter.go @@ -35,6 +35,8 @@ type leaderccAdapterConfig struct { UIDMode string `json:"uid_mode"` DefaultLang string `json:"default_lang"` TokenTTLSeconds int64 `json:"token_ttl_seconds"` + // game_urls 由后台灵仙列表同步写入,解决同一平台下每款游戏 H5 地址不同的问题。 + GameURLs map[string]string `json:"game_urls"` } func leaderccConfigFromPlatform(value any) leaderccAdapterConfig { @@ -50,9 +52,19 @@ func leaderccConfigFromPlatform(value any) leaderccAdapterConfig { _ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config) config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode)) config.DefaultLang = strings.TrimSpace(config.DefaultLang) + config.GameURLs = normalizeStringMap(config.GameURLs) return config } +func leaderccLaunchBaseURL(game gamedomain.LaunchableGame, config leaderccAdapterConfig) string { + for _, key := range []string{game.ProviderGameID, game.GameID, strings.ToLower(game.GameID)} { + if raw := strings.TrimSpace(config.GameURLs[strings.TrimSpace(key)]); raw != "" { + return raw + } + } + return "" +} + func (c leaderccAdapterConfig) TokenTTL() time.Duration { // 灵仙启动后会持续用 token 调后端接口,默认给 24 小时,后台可通过 JSON 覆盖。 if c.TokenTTLSeconds > 0 { diff --git a/services/game-service/internal/service/game/service.go b/services/game-service/internal/service/game/service.go index 768d0b75..36118398 100644 --- a/services/game-service/internal/service/game/service.go +++ b/services/game-service/internal/service/game/service.go @@ -827,9 +827,14 @@ func normalizeRecentGamesPageSize(pageSize int32) int32 { func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSession, token string) (string, error) { base := strings.TrimSpace(game.APIBaseURL) - if strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) && base == "" { - // 灵仙当前只需要我们提供回调域名和接口,不要求服务端拼 H5/Auth URL。 - return "", nil + if strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) { + // 灵仙不同游戏使用独立 H5 地址;保留平台 URL 作为旧配置兜底。 + if configuredBase := leaderccLaunchBaseURL(game, leaderccConfigFromPlatform(game)); configuredBase != "" { + base = configuredBase + } + if base == "" { + return "", nil + } } if strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) { // ZeeOne 的测试/正式游戏经常是同商户多条 H5 地址,优先读后台 JSON 的按游戏覆盖配置。 diff --git a/services/game-service/internal/service/game/service_test.go b/services/game-service/internal/service/game/service_test.go index 6151ca49..c66e87e0 100644 --- a/services/game-service/internal/service/game/service_test.go +++ b/services/game-service/internal/service/game/service_test.go @@ -277,6 +277,45 @@ func TestLaunchGameBuildsLeaderCCURL(t *testing.T) { } } +func TestLaunchGameUsesLeaderCCPerGameURL(t *testing.T) { + repo := &fakeRepository{ + launchable: gamedomain.LaunchableGame{ + CatalogItem: gamedomain.CatalogItem{ + AppCode: "lalu", GameID: "lingxian_9", PlatformCode: "lingxian", + ProviderGameID: "9", GameName: "GREEDY BOX", Status: gamedomain.StatusActive, + }, + PlatformStatus: gamedomain.StatusActive, + AdapterType: gamedomain.AdapterLeaderCCV1, + AdapterConfigJSON: `{ + "default_lang":"en","uid_mode":"display_user_id", + "game_urls":{"9":"https://gztest.leadercc.com/lalu_games/greedy_box_half/index.html?pl=lalu"} + }`, + }, + } + svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{}) + svc.now = func() time.Time { return time.UnixMilli(1700000000000) } + + result, err := svc.LaunchGame(context.Background(), LaunchCommand{ + AppCode: "lalu", UserID: 42, DisplayUserID: "420001", GameID: "lingxian_9", + RoomID: "room_1", AccessToken: "app_access_token_lingxian", + }) + if err != nil { + t.Fatalf("LaunchGame failed: %v", err) + } + parsed, err := url.Parse(result.LaunchURL) + if err != nil { + t.Fatalf("parse launch URL: %v", err) + } + if parsed.Host != "gztest.leadercc.com" || parsed.Path != "/lalu_games/greedy_box_half/index.html" { + t.Fatalf("launch base mismatch: %s", result.LaunchURL) + } + for key, want := range map[string]string{"pl": "lalu", "uid": "420001", "token": "app_access_token_lingxian", "lang": "en", "roomid": "room_1"} { + if got := parsed.Query().Get(key); got != want { + t.Fatalf("query %s = %q, want %q URL=%s", key, got, want, result.LaunchURL) + } + } +} + func TestLaunchGameBuildsZeeOneURL(t *testing.T) { repo := &fakeRepository{ launchable: gamedomain.LaunchableGame{