842 lines
28 KiB
Go
842 lines
28 KiB
Go
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)
|
||
}
|