feat(game): sync LeaderCC catalog
This commit is contained in:
parent
f3570384f2
commit
0cdbd28d46
@ -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 {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -827,10 +827,15 @@ 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 作为旧配置兜底。
|
||||||
|
if configuredBase := leaderccLaunchBaseURL(game, leaderccConfigFromPlatform(game)); configuredBase != "" {
|
||||||
|
base = configuredBase
|
||||||
|
}
|
||||||
|
if base == "" {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) {
|
if strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) {
|
||||||
// ZeeOne 的测试/正式游戏经常是同商户多条 H5 地址,优先读后台 JSON 的按游戏覆盖配置。
|
// ZeeOne 的测试/正式游戏经常是同商户多条 H5 地址,优先读后台 JSON 的按游戏覆盖配置。
|
||||||
if configuredBase := zeeoneLaunchBaseURL(game, zeeoneConfigFromPlatform(game)); configuredBase != "" {
|
if configuredBase := zeeoneLaunchBaseURL(game, zeeoneConfigFromPlatform(game)); configuredBase != "" {
|
||||||
|
|||||||
@ -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{
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user