feat(game): sync LeaderCC catalog

This commit is contained in:
zhx 2026-07-15 15:13:29 +08:00
parent f3570384f2
commit 0cdbd28d46
5 changed files with 198 additions and 3 deletions

View File

@ -25,6 +25,7 @@ import (
const ( const (
adapterBaishunV1 = "baishun_v1" adapterBaishunV1 = "baishun_v1"
adapterLeaderCCV1 = "leadercc_v1"
adapterZeeOneV1 = "zeeone_v1" adapterZeeOneV1 = "zeeone_v1"
adapterVivaGamesV1 = "vivagames_v1" adapterVivaGamesV1 = "vivagames_v1"
adapterReyouV1 = "reyou_v1" adapterReyouV1 = "reyou_v1"
@ -134,6 +135,8 @@ func fetchProviderGameSyncPlan(ctx context.Context, platform *gamev1.GamePlatfor
switch strings.ToLower(strings.TrimSpace(platform.GetAdapterType())) { switch strings.ToLower(strings.TrimSpace(platform.GetAdapterType())) {
case adapterBaishunV1: case adapterBaishunV1:
return fetchBaishunGameSyncPlan(ctx, platform, req) return fetchBaishunGameSyncPlan(ctx, platform, req)
case adapterLeaderCCV1:
return fetchLeaderCCGameSyncPlan(ctx, platform, req)
case adapterZeeOneV1: case adapterZeeOneV1:
return fetchZeeOneGameSyncPlan(platform, req) return fetchZeeOneGameSyncPlan(platform, req)
case adapterVivaGamesV1: case adapterVivaGamesV1:
@ -184,6 +187,21 @@ type baishunSyncConfig struct {
GameURLs map[string]string `json:"game_urls"` 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 { type zeeoneSyncConfig struct {
// ZeeOne 测试资料给的是逐游戏 H5 地址,不是列表 API后台“获取游戏列表”直接把这个映射转换为候选目录。 // ZeeOne 测试资料给的是逐游戏 H5 地址,不是列表 API后台“获取游戏列表”直接把这个映射转换为候选目录。
GameURLs map[string]string `json:"game_urls"` GameURLs map[string]string `json:"game_urls"`
@ -232,6 +250,91 @@ type yomiSyncConfig struct {
GameCovers map[string]string `json:"game_covers"` 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) { func fetchZeeOneGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) {
config, err := decodeZeeOneSyncConfig(platform.GetAdapterConfigJson()) config, err := decodeZeeOneSyncConfig(platform.GetAdapterConfigJson())
if err != nil { if err != nil {

View File

@ -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) { func TestFetchZeeOneGameSyncPlanReadsConfiguredGameURLs(t *testing.T) {
plan, err := fetchProviderGameSyncPlan(t.Context(), &gamev1.GamePlatform{ plan, err := fetchProviderGameSyncPlan(t.Context(), &gamev1.GamePlatform{
PlatformCode: "zeeone", PlatformCode: "zeeone",

View File

@ -35,6 +35,8 @@ type leaderccAdapterConfig struct {
UIDMode string `json:"uid_mode"` UIDMode string `json:"uid_mode"`
DefaultLang string `json:"default_lang"` DefaultLang string `json:"default_lang"`
TokenTTLSeconds int64 `json:"token_ttl_seconds"` TokenTTLSeconds int64 `json:"token_ttl_seconds"`
// game_urls 由后台灵仙列表同步写入,解决同一平台下每款游戏 H5 地址不同的问题。
GameURLs map[string]string `json:"game_urls"`
} }
func leaderccConfigFromPlatform(value any) leaderccAdapterConfig { func leaderccConfigFromPlatform(value any) leaderccAdapterConfig {
@ -50,9 +52,19 @@ func leaderccConfigFromPlatform(value any) leaderccAdapterConfig {
_ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config) _ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config)
config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode)) config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode))
config.DefaultLang = strings.TrimSpace(config.DefaultLang) config.DefaultLang = strings.TrimSpace(config.DefaultLang)
config.GameURLs = normalizeStringMap(config.GameURLs)
return config 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 { func (c leaderccAdapterConfig) TokenTTL() time.Duration {
// 灵仙启动后会持续用 token 调后端接口,默认给 24 小时,后台可通过 JSON 覆盖。 // 灵仙启动后会持续用 token 调后端接口,默认给 24 小时,后台可通过 JSON 覆盖。
if c.TokenTTLSeconds > 0 { if c.TokenTTLSeconds > 0 {

View File

@ -827,9 +827,14 @@ func normalizeRecentGamesPageSize(pageSize int32) int32 {
func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSession, token string) (string, error) { func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSession, token string) (string, error) {
base := strings.TrimSpace(game.APIBaseURL) base := strings.TrimSpace(game.APIBaseURL)
if strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) && base == "" { if strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) {
// 灵仙当前只需要我们提供回调域名和接口,不要求服务端拼 H5/Auth URL。 // 灵仙不同游戏使用独立 H5 地址;保留平台 URL 作为旧配置兜底。
return "", nil if configuredBase := leaderccLaunchBaseURL(game, leaderccConfigFromPlatform(game)); configuredBase != "" {
base = configuredBase
}
if base == "" {
return "", nil
}
} }
if strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) { if strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) {
// ZeeOne 的测试/正式游戏经常是同商户多条 H5 地址,优先读后台 JSON 的按游戏覆盖配置。 // ZeeOne 的测试/正式游戏经常是同商户多条 H5 地址,优先读后台 JSON 的按游戏覆盖配置。

View File

@ -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) { func TestLaunchGameBuildsZeeOneURL(t *testing.T) {
repo := &fakeRepository{ repo := &fakeRepository{
launchable: gamedomain.LaunchableGame{ launchable: gamedomain.LaunchableGame{