2026-06-09 11:04:31 +08:00

842 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"
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)
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"`
}
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 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 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 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)
}