package gamemanagement import ( "bytes" "context" "crypto/md5" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "net/url" "sort" "strconv" "strings" "time" "hyapp-admin-server/internal/response" gamev1 "hyapp.local/api/proto/game/v1" "github.com/gin-gonic/gin" ) const ( adapterBaishunV1 = "baishun_v1" adapterZeeOneV1 = "zeeone_v1" adapterVivaGamesV1 = "vivagames_v1" adapterReyouV1 = "reyou_v1" adapterYomiV4 = "yomi_v4" defaultGameStatus = "disabled" defaultGameCategory = "casino" defaultLaunchMode = "full_screen" defaultOrientation = "portrait" ) type syncGamesRequest struct { DryRun bool `json:"dryRun"` Status string `json:"status"` Category string `json:"category"` LaunchMode string `json:"launchMode"` MinCoin int64 `json:"minCoin"` Tags []string `json:"tags"` GameListType int `json:"gameListType"` ProviderGameIDs []string `json:"providerGameIds"` } type providerGameSyncPlan struct { Games []catalogRequest GameURLs map[string]string } // SyncPlatformGames 从厂商后台 API 拉取游戏清单,格式化成本地 game_catalog,再由 game-service 落库并补默认展示规则。 func (h *Handler) SyncPlatformGames(c *gin.Context) { platformCode := strings.TrimSpace(c.Param("platform_code")) if platformCode == "" { response.BadRequest(c, "平台 Code 参数不正确") return } var req syncGamesRequest if c.Request.ContentLength > 0 { if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "同步参数不正确") return } } platform, err := h.findGamePlatform(c.Request.Context(), c, platformCode) if err != nil { response.BadRequest(c, err.Error()) return } plan, err := fetchProviderGameSyncPlan(c.Request.Context(), platform, req) if err != nil { response.BadRequest(c, err.Error()) return } games := selectProviderGames(plan.Games, req.ProviderGameIDs) items := make([]catalogDTO, 0, len(games)) if req.DryRun { for _, game := range games { item := catalogFromProto(game.toProto("")) item.LaunchURL = strings.TrimSpace(plan.GameURLs[game.ProviderGameID]) items = append(items, item) } response.OK(c, gin.H{"platformCode": platformCode, "synced": len(items), "dryRun": true, "items": items}) return } if len(games) == 0 { response.BadRequest(c, "请选择要添加的游戏") return } adapterConfigJSON := mergeAdapterGameURLs(platform.GetAdapterConfigJson(), selectedGameURLs(plan.GameURLs, games)) if strings.TrimSpace(adapterConfigJSON) != "" && strings.TrimSpace(adapterConfigJSON) != strings.TrimSpace(platform.GetAdapterConfigJson()) { if _, err := h.game.UpsertPlatform(c.Request.Context(), &gamev1.UpsertPlatformRequest{ Meta: requestMeta(c), Platform: platformWithAdapterConfig(platform, adapterConfigJSON), }); err != nil { response.BadRequest(c, "同步游戏平台配置失败: "+err.Error()) return } } for _, game := range games { resp, err := h.game.UpsertCatalog(c.Request.Context(), &gamev1.UpsertCatalogRequest{ Meta: requestMeta(c), Game: game.toProto(""), }) if err != nil { response.BadRequest(c, "同步游戏目录失败: "+err.Error()) return } items = append(items, catalogFromProto(resp.GetGame())) } h.auditLog(c, "sync-provider-games", "game_platforms", platformCode, fmt.Sprintf("synced=%d", len(items))) response.OK(c, gin.H{"platformCode": platformCode, "synced": len(items), "dryRun": false, "items": items}) } func (h *Handler) findGamePlatform(ctx context.Context, c *gin.Context, platformCode string) (*gamev1.GamePlatform, error) { resp, err := h.game.ListPlatforms(ctx, &gamev1.ListPlatformsRequest{Meta: requestMeta(c)}) if err != nil { return nil, fmt.Errorf("获取游戏平台失败") } for _, platform := range resp.GetPlatforms() { if strings.EqualFold(strings.TrimSpace(platform.GetPlatformCode()), platformCode) { return platform, nil } } return nil, fmt.Errorf("游戏平台不存在") } func fetchProviderGameSyncPlan(ctx context.Context, platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) { switch strings.ToLower(strings.TrimSpace(platform.GetAdapterType())) { case adapterBaishunV1: return fetchBaishunGameSyncPlan(ctx, platform, req) case adapterZeeOneV1: return fetchZeeOneGameSyncPlan(platform, req) case adapterVivaGamesV1: return fetchVivaGamesGameSyncPlan(platform, req) case adapterReyouV1: return fetchReyouGameSyncPlan(platform, req) case adapterYomiV4: return fetchYomiGameSyncPlan(platform, req) default: // 不在配置文件写死游戏:每家厂商新增“列表 API”时只加一个 adapter fetcher,后台入口保持不变。 return providerGameSyncPlan{}, fmt.Errorf("当前适配器暂未接入厂商游戏列表 API: %s", platform.GetAdapterType()) } } func platformWithAdapterConfig(platform *gamev1.GamePlatform, adapterConfigJSON string) *gamev1.GamePlatform { return &gamev1.GamePlatform{ AppCode: platform.GetAppCode(), PlatformCode: platform.GetPlatformCode(), PlatformName: platform.GetPlatformName(), Status: platform.GetStatus(), ApiBaseUrl: platform.GetApiBaseUrl(), AdapterType: platform.GetAdapterType(), CallbackSecret: platform.GetCallbackSecret(), CallbackSecretSet: platform.GetCallbackSecretSet(), CallbackIpWhitelist: platform.GetCallbackIpWhitelist(), AdapterConfigJson: adapterConfigJSON, SortOrder: platform.GetSortOrder(), CreatedAtMs: platform.GetCreatedAtMs(), UpdatedAtMs: platform.GetUpdatedAtMs(), } } type baishunSyncConfig struct { AppID int64 `json:"app_id"` AppIDCamel int64 `json:"appId"` AppChannel string `json:"app_channel"` AppChannelCamel string `json:"appChannel"` GameListType int `json:"game_list_type"` GameListTypeCamel int `json:"gameListType"` ProviderAPIBaseURL string `json:"provider_api_base_url"` ProviderAPIBaseURLAlt string `json:"providerApiBaseUrl"` GameListURL string `json:"game_list_url"` GameListURLCamel string `json:"gameListUrl"` GameURLs map[string]string `json:"game_urls"` } type zeeoneSyncConfig struct { // ZeeOne 测试资料给的是逐游戏 H5 地址,不是列表 API;后台“获取游戏列表”直接把这个映射转换为候选目录。 GameURLs map[string]string `json:"game_urls"` GameNames map[string]string `json:"game_names"` GameIcons map[string]string `json:"game_icons"` GameCovers map[string]string `json:"game_covers"` } type vivaGamesSyncConfig struct { // VIVAGAMES 文档说明游戏 URL 由厂商提供,后台用这个映射把 URL 转成可勾选的本地目录候选项。 GameURLs map[string]string `json:"game_urls"` GameNames map[string]string `json:"game_names"` GameIcons map[string]string `json:"game_icons"` GameCovers map[string]string `json:"game_covers"` } type reyouSyncConfig struct { // 热游文档没有游戏列表 API;后台以 game_urls 作为唯一清单来源,和服务端启动配置保持同一份 JSON。 GameURLs map[string]string `json:"game_urls"` GameNames map[string]string `json:"game_names"` GameIcons map[string]string `json:"game_icons"` GameCovers map[string]string `json:"game_covers"` } type yomiSyncConfig struct { // Yomi V4 文档只提供启动鉴权地址,没有游戏列表 API;后台通过 game_names 维护截图里的 gameUid 清单。 GameNames map[string]string `json:"game_names"` GameURLs map[string]string `json:"game_urls"` GameIcons map[string]string `json:"game_icons"` GameCovers map[string]string `json:"game_covers"` } func fetchZeeOneGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) { config, err := decodeZeeOneSyncConfig(platform.GetAdapterConfigJson()) if err != nil { return providerGameSyncPlan{}, err } games, gameURLs := zeeoneCatalogItems(platform.GetPlatformCode(), config, req) if len(games) == 0 { return providerGameSyncPlan{}, fmt.Errorf("ZeeOne 游戏列表为空:请在 adapterConfigJson.game_urls 配置游戏 ID 到 H5 URL 的映射") } return providerGameSyncPlan{ Games: games, GameURLs: gameURLs, }, nil } func decodeZeeOneSyncConfig(raw string) (zeeoneSyncConfig, error) { if strings.TrimSpace(raw) == "" { return zeeoneSyncConfig{}, nil } var config zeeoneSyncConfig if err := json.Unmarshal([]byte(raw), &config); err != nil { return zeeoneSyncConfig{}, fmt.Errorf("ZeeOne 适配器配置 JSON 不合法") } return config, nil } func zeeoneCatalogItems(platformCode string, config zeeoneSyncConfig, req syncGamesRequest) ([]catalogRequest, map[string]string) { keys := make([]string, 0, len(config.GameURLs)) for providerGameID, launchURL := range config.GameURLs { if strings.TrimSpace(providerGameID) != "" && strings.TrimSpace(launchURL) != "" { keys = append(keys, strings.TrimSpace(providerGameID)) } } sort.Strings(keys) items := make([]catalogRequest, 0, len(keys)) gameURLs := make(map[string]string, len(keys)) for index, providerGameID := range keys { launchURL := strings.TrimSpace(config.GameURLs[providerGameID]) gameURLs[providerGameID] = launchURL name := firstNonEmpty(config.GameNames[providerGameID], zeeoneGameNameFromURL(launchURL), providerGameID) iconURL := strings.TrimSpace(config.GameIcons[providerGameID]) coverURL := firstNonEmpty(config.GameCovers[providerGameID], iconURL) items = append(items, catalogRequest{ GameID: stableGameID(platformCode, providerGameID), PlatformCode: strings.TrimSpace(platformCode), ProviderGameID: providerGameID, GameName: name, Category: defaulted(req.Category, defaultGameCategory), IconURL: iconURL, CoverURL: coverURL, LaunchMode: defaulted(req.LaunchMode, defaultLaunchMode), Orientation: defaultOrientation, MinCoin: req.MinCoin, Status: defaulted(req.Status, defaultGameStatus), SortOrder: int32((index + 1) * 10), Tags: compactTags(append([]string{strings.TrimSpace(platformCode), adapterZeeOneV1}, req.Tags...)), }) } return items, gameURLs } func zeeoneGameNameFromURL(raw string) string { parsed, err := url.Parse(strings.TrimSpace(raw)) pathValue := strings.TrimSpace(raw) if err == nil && parsed.Path != "" { pathValue = parsed.Path } parts := strings.Split(strings.Trim(pathValue, "/"), "/") candidate := "" for index := len(parts) - 1; index >= 0; index-- { part := strings.TrimSpace(parts[index]) if part == "" || strings.EqualFold(part, "index.html") || strings.EqualFold(part, "index.htm") { continue } candidate = strings.TrimSuffix(strings.TrimSuffix(part, ".html"), ".htm") break } return titleGameName(candidate) } func fetchVivaGamesGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) { config, err := decodeVivaGamesSyncConfig(platform.GetAdapterConfigJson()) if err != nil { return providerGameSyncPlan{}, err } games, gameURLs := vivaGamesCatalogItems(platform.GetPlatformCode(), config, req) if len(games) == 0 { return providerGameSyncPlan{}, fmt.Errorf("VIVAGAMES 游戏列表为空:请在 adapterConfigJson.game_urls 配置游戏 ID 到 H5 URL 的映射") } return providerGameSyncPlan{ Games: games, GameURLs: gameURLs, }, nil } func decodeVivaGamesSyncConfig(raw string) (vivaGamesSyncConfig, error) { if strings.TrimSpace(raw) == "" { return vivaGamesSyncConfig{}, nil } var config vivaGamesSyncConfig if err := json.Unmarshal([]byte(raw), &config); err != nil { return vivaGamesSyncConfig{}, fmt.Errorf("VIVAGAMES 适配器配置 JSON 不合法") } return config, nil } func vivaGamesCatalogItems(platformCode string, config vivaGamesSyncConfig, req syncGamesRequest) ([]catalogRequest, map[string]string) { keys := make([]string, 0, len(config.GameURLs)) for providerGameID, launchURL := range config.GameURLs { if strings.TrimSpace(providerGameID) != "" && strings.TrimSpace(launchURL) != "" { keys = append(keys, strings.TrimSpace(providerGameID)) } } sort.Strings(keys) items := make([]catalogRequest, 0, len(keys)) gameURLs := make(map[string]string, len(keys)) for index, providerGameID := range keys { launchURL := strings.TrimSpace(config.GameURLs[providerGameID]) gameURLs[providerGameID] = launchURL name := firstNonEmpty(config.GameNames[providerGameID], zeeoneGameNameFromURL(launchURL), providerGameID) iconURL := strings.TrimSpace(config.GameIcons[providerGameID]) coverURL := firstNonEmpty(config.GameCovers[providerGameID], iconURL) items = append(items, catalogRequest{ GameID: stableGameID(platformCode, providerGameID), PlatformCode: strings.TrimSpace(platformCode), ProviderGameID: providerGameID, GameName: name, Category: defaulted(req.Category, defaultGameCategory), IconURL: iconURL, CoverURL: coverURL, LaunchMode: defaulted(req.LaunchMode, defaultLaunchMode), Orientation: defaultOrientation, MinCoin: req.MinCoin, Status: defaulted(req.Status, defaultGameStatus), SortOrder: int32((index + 1) * 10), Tags: compactTags(append([]string{strings.TrimSpace(platformCode), adapterVivaGamesV1}, req.Tags...)), }) } return items, gameURLs } func fetchReyouGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) { config, err := decodeReyouSyncConfig(platform.GetAdapterConfigJson()) if err != nil { return providerGameSyncPlan{}, err } games, gameURLs := reyouCatalogItems(platform.GetPlatformCode(), config, req) if len(games) == 0 { return providerGameSyncPlan{}, fmt.Errorf("热游游戏列表为空:请在 adapterConfigJson.game_urls 配置游戏 ID 到 H5 URL 的映射") } return providerGameSyncPlan{ Games: games, GameURLs: gameURLs, }, nil } func fetchYomiGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) { config, err := decodeYomiSyncConfig(platform.GetAdapterConfigJson()) if err != nil { return providerGameSyncPlan{}, err } games, gameURLs := yomiCatalogItems(platform.GetPlatformCode(), config, req) if len(games) == 0 { return providerGameSyncPlan{}, fmt.Errorf("小游游戏列表为空:请在 adapterConfigJson.game_names 配置游戏 ID 到名称的映射") } return providerGameSyncPlan{ Games: games, GameURLs: gameURLs, }, nil } func decodeReyouSyncConfig(raw string) (reyouSyncConfig, error) { if strings.TrimSpace(raw) == "" { return reyouSyncConfig{}, nil } var config reyouSyncConfig if err := json.Unmarshal([]byte(raw), &config); err != nil { return reyouSyncConfig{}, fmt.Errorf("热游适配器配置 JSON 不合法") } return config, nil } func decodeYomiSyncConfig(raw string) (yomiSyncConfig, error) { if strings.TrimSpace(raw) == "" { return yomiSyncConfig{}, nil } var config yomiSyncConfig if err := json.Unmarshal([]byte(raw), &config); err != nil { return yomiSyncConfig{}, fmt.Errorf("小游适配器配置 JSON 不合法") } return config, nil } func reyouCatalogItems(platformCode string, config reyouSyncConfig, req syncGamesRequest) ([]catalogRequest, map[string]string) { keys := make([]string, 0, len(config.GameURLs)) for providerGameID, launchURL := range config.GameURLs { if strings.TrimSpace(providerGameID) != "" && strings.TrimSpace(launchURL) != "" { keys = append(keys, strings.TrimSpace(providerGameID)) } } sort.Strings(keys) items := make([]catalogRequest, 0, len(keys)) gameURLs := make(map[string]string, len(keys)) for index, providerGameID := range keys { launchURL := strings.TrimSpace(config.GameURLs[providerGameID]) gameURLs[providerGameID] = launchURL name := firstNonEmpty(config.GameNames[providerGameID], zeeoneGameNameFromURL(launchURL), providerGameID) iconURL := strings.TrimSpace(config.GameIcons[providerGameID]) coverURL := firstNonEmpty(config.GameCovers[providerGameID], iconURL) items = append(items, catalogRequest{ GameID: stableGameID(platformCode, providerGameID), PlatformCode: strings.TrimSpace(platformCode), ProviderGameID: providerGameID, GameName: name, Category: defaulted(req.Category, defaultGameCategory), IconURL: iconURL, CoverURL: coverURL, LaunchMode: defaulted(req.LaunchMode, defaultLaunchMode), Orientation: defaultOrientation, MinCoin: req.MinCoin, Status: defaulted(req.Status, defaultGameStatus), SortOrder: int32((index + 1) * 10), Tags: compactTags(append([]string{strings.TrimSpace(platformCode), adapterReyouV1}, req.Tags...)), }) } return items, gameURLs } func yomiCatalogItems(platformCode string, config yomiSyncConfig, req syncGamesRequest) ([]catalogRequest, map[string]string) { keys := make([]string, 0, len(config.GameNames)+len(config.GameURLs)) seen := make(map[string]struct{}, len(config.GameNames)+len(config.GameURLs)) // 小游没有列表 API,game_names 是本地候选游戏的事实来源;game_urls 只用于后台预览,不参与启动拼参。 for providerGameID := range config.GameNames { key := strings.TrimSpace(providerGameID) if key == "" { continue } seen[key] = struct{}{} keys = append(keys, key) } for providerGameID, launchURL := range config.GameURLs { key := strings.TrimSpace(providerGameID) if key == "" || strings.TrimSpace(launchURL) == "" { continue } if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} keys = append(keys, key) } sort.Strings(keys) items := make([]catalogRequest, 0, len(keys)) gameURLs := make(map[string]string, len(keys)) for index, providerGameID := range keys { launchURL := strings.TrimSpace(config.GameURLs[providerGameID]) if launchURL != "" { gameURLs[providerGameID] = launchURL } name := firstNonEmpty(config.GameNames[providerGameID], zeeoneGameNameFromURL(launchURL), providerGameID) iconURL := strings.TrimSpace(config.GameIcons[providerGameID]) coverURL := firstNonEmpty(config.GameCovers[providerGameID], iconURL) items = append(items, catalogRequest{ GameID: stableGameID(platformCode, providerGameID), PlatformCode: strings.TrimSpace(platformCode), ProviderGameID: providerGameID, GameName: name, Category: defaulted(req.Category, defaultGameCategory), IconURL: iconURL, CoverURL: coverURL, LaunchMode: defaulted(req.LaunchMode, defaultLaunchMode), Orientation: defaultOrientation, MinCoin: req.MinCoin, Status: defaulted(req.Status, defaultGameStatus), SortOrder: int32((index + 1) * 10), Tags: compactTags(append([]string{strings.TrimSpace(platformCode), adapterYomiV4}, req.Tags...)), }) } return items, gameURLs } func titleGameName(value string) string { tokens := splitGameNameTokens(value) if len(tokens) == 0 { return "" } smallWords := map[string]struct{}{"and": {}, "of": {}, "the": {}} words := make([]string, 0, len(tokens)) for _, token := range tokens { lower := strings.ToLower(token) if _, err := strconv.Atoi(lower); err == nil { continue } if len(words) > 0 { if _, ok := smallWords[lower]; ok { words = append(words, lower) continue } } words = append(words, strings.ToUpper(lower[:1])+lower[1:]) } return strings.Join(words, " ") } func splitGameNameTokens(value string) []string { var tokens []string var builder strings.Builder var previous rune flush := func() { if builder.Len() > 0 { tokens = append(tokens, builder.String()) builder.Reset() } } for _, r := range strings.TrimSpace(value) { if !asciiAlphaNumeric(r) { flush() previous = 0 continue } if builder.Len() > 0 && asciiUpper(r) && (asciiLower(previous) || asciiDigit(previous)) { flush() } builder.WriteRune(r) previous = r } flush() return tokens } func asciiAlphaNumeric(r rune) bool { return asciiLower(r) || asciiUpper(r) || asciiDigit(r) } func asciiLower(r rune) bool { return r >= 'a' && r <= 'z' } func asciiUpper(r rune) bool { return r >= 'A' && r <= 'Z' } func asciiDigit(r rune) bool { return r >= '0' && r <= '9' } func fetchBaishunGameSyncPlan(ctx context.Context, platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) { config, err := decodeBaishunSyncConfig(platform.GetAdapterConfigJson()) if err != nil { return providerGameSyncPlan{}, err } appID := config.appID() appChannel := config.appChannel() appKey := strings.TrimSpace(platform.GetCallbackSecret()) endpoint := baishunGameListEndpoint(platform.GetApiBaseUrl(), config) if missing := missingBaishunSyncFields(appID, appChannel, appKey, endpoint); len(missing) > 0 { return providerGameSyncPlan{}, fmt.Errorf("百顺列表同步缺少配置: %s", strings.Join(missing, "、")) } body := baishunSignedListRequest(appID, appChannel, baishunGameListType(config, req), appKey) raw, err := json.Marshal(body) if err != nil { return providerGameSyncPlan{}, err } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(raw)) if err != nil { return providerGameSyncPlan{}, err } httpReq.Header.Set("Content-Type", "application/json") resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(httpReq) if err != nil { return providerGameSyncPlan{}, fmt.Errorf("请求百顺游戏列表失败: %w", err) } defer resp.Body.Close() respRaw, 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(respRaw))) } var list baishunGameListResponse if err := json.Unmarshal(respRaw, &list); err != nil { return providerGameSyncPlan{}, fmt.Errorf("解析百顺游戏列表失败: %w", err) } if list.Code != 0 { return providerGameSyncPlan{}, fmt.Errorf("百顺游戏列表返回失败: code=%d message=%s", list.Code, firstNonEmpty(list.Message, list.Msg)) } games, gameURLs := baishunCatalogItems(platform.GetPlatformCode(), list.Data, req) if len(games) == 0 { return providerGameSyncPlan{}, fmt.Errorf("百顺游戏列表为空") } return providerGameSyncPlan{ Games: games, GameURLs: gameURLs, }, nil } func decodeBaishunSyncConfig(raw string) (baishunSyncConfig, error) { if strings.TrimSpace(raw) == "" { return baishunSyncConfig{}, nil } var config baishunSyncConfig if err := json.Unmarshal([]byte(raw), &config); err != nil { return baishunSyncConfig{}, fmt.Errorf("百顺适配器配置 JSON 不合法") } return config, nil } func missingBaishunSyncFields(appID int64, appChannel string, appKey string, endpoint string) []string { missing := make([]string, 0, 4) if appID == 0 { missing = append(missing, "adapterConfigJson.app_id") } if strings.TrimSpace(appChannel) == "" { missing = append(missing, "adapterConfigJson.app_channel") } if strings.TrimSpace(appKey) == "" { missing = append(missing, "回调密钥") } if strings.TrimSpace(endpoint) == "" { missing = append(missing, "adapterConfigJson.game_list_url 或 provider_api_base_url") } return missing } func (c baishunSyncConfig) appID() int64 { if c.AppID != 0 { return c.AppID } return c.AppIDCamel } func (c baishunSyncConfig) appChannel() string { return firstNonEmpty(c.AppChannel, c.AppChannelCamel) } func baishunGameListType(config baishunSyncConfig, req syncGamesRequest) int { if req.GameListType != 0 { return req.GameListType } if config.GameListType != 0 { return config.GameListType } if config.GameListTypeCamel != 0 { return config.GameListTypeCamel } return 1 } func baishunGameListEndpoint(apiBaseURL string, config baishunSyncConfig) string { if endpoint := strings.TrimSpace(firstNonEmpty(config.GameListURL, config.GameListURLCamel)); endpoint != "" { return endpoint } base := strings.TrimSpace(firstNonEmpty(config.ProviderAPIBaseURL, config.ProviderAPIBaseURLAlt, apiBaseURL)) if base == "" { return "" } if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") { base = "https://" + base } if strings.Contains(base, "/v1/api/gamelist") { return base } parsed, err := url.Parse(base) if err != nil || parsed.Host == "" { return "" } parsed.Path = strings.TrimRight(parsed.Path, "/") + "/v1/api/gamelist" parsed.RawQuery = "" return parsed.String() } func baishunSignedListRequest(appID int64, appChannel string, gameListType int, appKey string) map[string]any { timestamp := time.Now().Unix() nonce := randomHex(8) signature := baishunMD5(nonce + appKey + strconv.FormatInt(timestamp, 10)) return map[string]any{ "game_list_type": gameListType, "app_channel": appChannel, "app_id": appID, "signature": signature, "signature_nonce": nonce, "timestamp": timestamp, } } type baishunGameListResponse struct { Code int `json:"code"` Message string `json:"message"` Msg string `json:"msg"` Data []baishunGameListItem `json:"data"` } type baishunGameListItem struct { GameID any `json:"game_id"` Name string `json:"name"` GameName string `json:"game_name"` PreviewURL string `json:"preview_url"` IconURL string `json:"icon_url"` CoverURL string `json:"cover_url"` DownloadURL string `json:"download_url"` GameOrientation any `json:"game_orientation"` SafeHeight any `json:"safe_height"` } func baishunCatalogItems(platformCode string, providerItems []baishunGameListItem, 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 } name := firstNonEmpty(providerItem.Name, providerItem.GameName, providerGameID) iconURL := firstNonEmpty(providerItem.IconURL, providerItem.PreviewURL) coverURL := firstNonEmpty(providerItem.CoverURL, providerItem.PreviewURL, iconURL) gameURLs[providerGameID] = strings.TrimSpace(providerItem.DownloadURL) items = append(items, catalogRequest{ GameID: stableGameID(platformCode, providerGameID), PlatformCode: strings.TrimSpace(platformCode), ProviderGameID: providerGameID, GameName: name, Category: defaulted(req.Category, defaultGameCategory), IconURL: iconURL, CoverURL: coverURL, LaunchMode: defaulted(req.LaunchMode, defaultLaunchMode), Orientation: baishunOrientation(providerItem.GameOrientation), SafeHeight: int32FromJSONValue(providerItem.SafeHeight), MinCoin: req.MinCoin, Status: defaulted(req.Status, defaultGameStatus), SortOrder: int32((index + 1) * 10), Tags: compactTags(append([]string{strings.TrimSpace(platformCode), adapterBaishunV1}, req.Tags...)), }) } return items, gameURLs } func selectProviderGames(games []catalogRequest, providerGameIDs []string) []catalogRequest { if len(providerGameIDs) == 0 { return games } allowed := make(map[string]struct{}, len(providerGameIDs)) for _, id := range providerGameIDs { if strings.TrimSpace(id) != "" { allowed[strings.TrimSpace(id)] = struct{}{} } } out := make([]catalogRequest, 0, len(games)) for _, game := range games { if _, ok := allowed[game.ProviderGameID]; ok { out = append(out, game) } } return out } func selectedGameURLs(all map[string]string, games []catalogRequest) map[string]string { out := make(map[string]string, len(games)) for _, game := range games { if launchURL := strings.TrimSpace(all[game.ProviderGameID]); launchURL != "" { out[game.ProviderGameID] = launchURL } } return out } func mergeAdapterGameURLs(raw string, gameURLs map[string]string) string { config := map[string]any{} if strings.TrimSpace(raw) != "" { _ = json.Unmarshal([]byte(raw), &config) } current := map[string]any{} if existing, ok := config["game_urls"].(map[string]any); ok { for key, value := range existing { current[key] = value } } for providerGameID, launchURL := range gameURLs { if strings.TrimSpace(providerGameID) != "" && strings.TrimSpace(launchURL) != "" { current[providerGameID] = strings.TrimSpace(launchURL) } } config["game_urls"] = current out, err := json.Marshal(config) if err != nil { return strings.TrimSpace(raw) } return string(out) } func stableGameID(platformCode string, providerGameID string) string { prefix := asciiIDPart(platformCode) suffix := asciiIDPart(providerGameID) if prefix == "" { prefix = "game" } if suffix == "" { suffix = "unknown" } value := prefix + "_" + suffix if len(value) <= 96 { return value } sum := sha256.Sum256([]byte(platformCode + ":" + providerGameID)) return prefix[:min(len(prefix), 32)] + "_" + hex.EncodeToString(sum[:16]) } func asciiIDPart(value string) string { var builder strings.Builder lastUnderscore := false for _, r := range strings.ToLower(strings.TrimSpace(value)) { keep := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') if keep { builder.WriteRune(r) lastUnderscore = false continue } if !lastUnderscore { builder.WriteByte('_') lastUnderscore = true } } return strings.Trim(builder.String(), "_") } func baishunOrientation(value any) string { switch strings.TrimSpace(stringFromJSONValue(value)) { case "2", "landscape": return "landscape" default: return defaultOrientation } } func stringFromJSONValue(value any) string { switch typed := value.(type) { case nil: return "" case string: return strings.TrimSpace(typed) case float64: if typed == float64(int64(typed)) { return strconv.FormatInt(int64(typed), 10) } return strconv.FormatFloat(typed, 'f', -1, 64) case json.Number: return typed.String() default: return strings.TrimSpace(fmt.Sprint(typed)) } } func int32FromJSONValue(value any) int32 { switch typed := value.(type) { case nil: return 0 case int: if typed > 0 { return int32(typed) } case int32: if typed > 0 { return typed } case int64: if typed > 0 { return int32(typed) } case float64: if typed > 0 { return int32(typed) } case json.Number: parsed, err := strconv.ParseInt(typed.String(), 10, 32) if err == nil && parsed > 0 { return int32(parsed) } case string: parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 32) if err == nil && parsed > 0 { return int32(parsed) } default: parsed, err := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(typed)), 10, 32) if err == nil && parsed > 0 { return int32(parsed) } } return 0 } func baishunMD5(value string) string { sum := md5.Sum([]byte(value)) return hex.EncodeToString(sum[:]) } func randomHex(length int) string { buf := make([]byte, length) if _, err := rand.Read(buf); err != nil { return strconv.FormatInt(time.Now().UnixNano(), 16) } return hex.EncodeToString(buf) } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" } func defaulted(value string, fallback string) string { if strings.TrimSpace(value) == "" { return fallback } return strings.TrimSpace(value) }