feat(game): sync LeaderCC catalog
This commit is contained in:
parent
f3570384f2
commit
0cdbd28d46
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 的按游戏覆盖配置。
|
||||
|
||||
@ -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{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user