2026-06-10 15:58:52 +08:00

441 lines
15 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 hotgame
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var defaultCatalogGames = []defaultCatalogGame{
{
GameID: "1",
Name: "幸运77(Lucky77)",
TestURL: "https://hk.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}",
ProdURL: "https://cdn-res.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}",
},
{
GameID: "13",
Name: "海盗捕鱼(PirateFishing)",
TestURL: "https://hk.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
ProdURL: "https://cdn-res.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
},
{
GameID: "19",
Name: "FortuneSlot (宝石solt)",
TestURL: "https://hk.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
ProdURL: "https://cdn-res.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
},
{
GameID: "15",
Name: "美味摩天轮(Delicious)",
TestURL: "https://hk.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
ProdURL: "https://cdn-res.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
},
{
GameID: "20",
Name: "水果派对(FruitParty)",
TestURL: "https://hk.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
ProdURL: "https://cdn-res.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
},
{
GameID: "16",
Name: "太空摩天轮(GreedyStar)",
TestURL: "https://hk.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
ProdURL: "https://cdn-res.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
},
}
// SyncCatalog 把表格里确认过的热游可用游戏同步到本地目录表。
func (s *Service) SyncCatalog(ctx context.Context, req SyncCatalogRequest) (*SyncCatalogResponse, error) {
sysOrigin := normalizeSysOrigin(req.SysOrigin)
profile, err := s.requestProfile(ctx, sysOrigin, req.Profile)
if err != nil {
return nil, err
}
if _, err := s.requireRuntimeConfigForProfile(ctx, sysOrigin, profile); err != nil {
return nil, err
}
filter := buildVendorGameIDFilter(req.VendorGameIDs)
resp := &SyncCatalogResponse{}
for _, game := range defaultCatalogGames {
vendorGameID := strings.TrimSpace(game.GameID)
if vendorGameID == "" {
resp.Skipped++
continue
}
if len(filter) > 0 {
if _, ok := filter[vendorGameID]; !ok {
resp.Skipped++
continue
}
}
id, err := utils.NextID()
if err != nil {
return nil, err
}
now := time.Now()
launchURL := hotgameLaunchURLForProfile(game, profile)
record := model.HotgameGameCatalog{
ID: id,
SysOrigin: sysOrigin,
Profile: profile,
InternalGameID: internalGameID(vendorGameID),
VendorGameID: vendorGameID,
Name: game.Name,
Cover: "",
PreviewURL: launchURL,
DownloadURL: launchURL,
PackageVersion: "2026",
RawJSON: utils.MustJSONString(game, ""),
Status: catalogEnabled,
CreateTime: now,
UpdateTime: now,
}
if err := s.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "sys_origin"}, {Name: "profile"}, {Name: "vendor_game_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"internal_game_id", "name", "cover", "preview_url", "download_url",
"package_version", "raw_json", "status", "update_time",
}),
}).Create(&record).Error; err != nil {
return nil, err
}
if req.Force {
resp.Updated++
} else {
resp.Inserted++
}
}
return resp, nil
}
// PageCatalog 返回热游目录,并标记每个目录游戏是否已经进入统一游戏列表。
func (s *Service) PageCatalog(ctx context.Context, sysOrigin, profile, keyword string, cursor, limit int) (*CatalogPageResponse, error) {
sysOrigin = normalizeSysOrigin(sysOrigin)
profile, err := s.requestProfile(ctx, sysOrigin, profile)
if err != nil {
return nil, err
}
cursor = normalizePageCursor(cursor)
limit = normalizePageLimit(limit)
countQuery := s.buildCatalogQuery(ctx, sysOrigin, profile, keyword)
var total int64
if err := countQuery.Count(&total).Error; err != nil {
return nil, err
}
type row struct {
VendorGameID string
Profile string
InternalGameID string
Name string
Cover string
PreviewURL string
DownloadURL string
PackageVersion string
Status string
AddedConfigID *int64
Showcase *bool
UpdateTime string
}
var rows []row
if err := s.buildCatalogQuery(ctx, sysOrigin, profile, keyword).
Select(`
cat.vendor_game_id AS vendor_game_id,
cat.profile AS profile,
cat.internal_game_id AS internal_game_id,
cat.name AS name,
cat.cover AS cover,
cat.preview_url AS preview_url,
cat.download_url AS download_url,
cat.package_version AS package_version,
cat.status AS status,
cfg.id AS added_config_id,
cfg.is_showcase AS showcase,
DATE_FORMAT(cat.update_time, '%Y-%m-%d %H:%i:%s') AS update_time
`).
Order("CAST(cat.vendor_game_id AS UNSIGNED) ASC").
Limit(limit).
Offset((cursor - 1) * limit).
Scan(&rows).Error; err != nil {
return nil, err
}
items := make([]CatalogItem, 0, len(rows))
for _, row := range rows {
items = append(items, CatalogItem{
VendorGameID: row.VendorGameID,
Profile: normalizeProfile(row.Profile),
GameID: row.InternalGameID,
Name: row.Name,
Cover: row.Cover,
PreviewURL: row.PreviewURL,
DownloadURL: row.DownloadURL,
PackageVersion: row.PackageVersion,
Status: row.Status,
Added: row.AddedConfigID != nil && *row.AddedConfigID > 0,
Showcase: row.Showcase != nil && *row.Showcase,
UpdateTime: row.UpdateTime,
})
}
return &CatalogPageResponse{Cursor: cursor, Limit: limit, Records: items, Total: total}, nil
}
func (s *Service) buildCatalogQuery(ctx context.Context, sysOrigin, profile, keyword string) *gorm.DB {
query := s.db.WithContext(ctx).
Table("hotgame_game_catalog AS cat").
Joins(`
LEFT JOIN sys_game_list_vendor_ext ext
ON ext.sys_origin = cat.sys_origin
AND ext.profile = cat.profile
AND ext.vendor_type = ?
AND ext.vendor_game_id = cat.vendor_game_id
AND ext.enabled = 1
`, vendorType).
Joins(`
LEFT JOIN sys_game_list_config cfg
ON cfg.id = ext.game_list_config_id
AND cfg.sys_origin = cat.sys_origin
AND cfg.game_origin = ?
`, vendorType).
Where("cat.sys_origin = ? AND cat.profile = ?", sysOrigin, profile)
keyword = strings.TrimSpace(keyword)
if keyword != "" {
like := "%" + keyword + "%"
query = query.Where("cat.name LIKE ? OR cat.internal_game_id LIKE ? OR cat.vendor_game_id LIKE ?", like, like, like)
}
return query
}
// FetchAdminCatalog 供后台按钮调用,热游没有远程目录接口,因此读取内置对接表目录。
func (s *Service) FetchAdminCatalog(ctx context.Context, req SyncCatalogRequest) (*SyncCatalogResponse, error) {
return s.SyncCatalog(ctx, req)
}
// ImportCatalogGames 把热游目录导入统一游戏列表app 侧列表随后会通过统一 provider 返回。
func (s *Service) ImportCatalogGames(ctx context.Context, sysOrigin, profile string, vendorGameIDs []string, defaultShowcase ...bool) (*ImportCatalogResponse, error) {
sysOrigin = normalizeSysOrigin(sysOrigin)
profile, err := s.requestProfile(ctx, sysOrigin, profile)
if err != nil {
return nil, err
}
tx := s.db.WithContext(ctx).Begin()
if tx.Error != nil {
return nil, tx.Error
}
resp, err := s.importCatalogGamesTx(tx, sysOrigin, profile, buildVendorGameIDFilter(vendorGameIDs), resolveImportDefaultShowcase(defaultShowcase...))
if err != nil {
tx.Rollback()
return nil, err
}
if err := tx.Commit().Error; err != nil {
return nil, err
}
s.clearJavaGameListCache(ctx)
return resp, nil
}
// SyncAdminGames 一次完成目录同步和统一游戏列表导入,便于后台首次配置。
func (s *Service) SyncAdminGames(ctx context.Context, req SyncCatalogRequest) (*SyncAdminCatalogResponse, error) {
syncResp, err := s.SyncCatalog(ctx, req)
if err != nil {
return nil, err
}
importResp, err := s.ImportCatalogGames(ctx, req.SysOrigin, req.Profile, req.VendorGameIDs, resolveBoolPtrDefault(req.DefaultShowcase, true))
if err != nil {
return nil, err
}
return &SyncAdminCatalogResponse{
Inserted: syncResp.Inserted,
Updated: syncResp.Updated,
Skipped: syncResp.Skipped,
Existing: importResp.Existing,
Imported: importResp.Imported,
}, nil
}
func (s *Service) importCatalogGamesTx(tx *gorm.DB, sysOrigin, profile string, filter map[string]struct{}, defaultShowcase bool) (*ImportCatalogResponse, error) {
query := tx.Where("sys_origin = ? AND profile = ? AND status = ?", sysOrigin, profile, catalogEnabled)
if len(filter) > 0 {
ids := make([]string, 0, len(filter))
for id := range filter {
ids = append(ids, id)
}
sort.Strings(ids)
query = query.Where("vendor_game_id IN ?", ids)
}
var catalogs []model.HotgameGameCatalog
if err := query.Order("CAST(vendor_game_id AS UNSIGNED) ASC").Find(&catalogs).Error; err != nil {
return nil, err
}
resp := &ImportCatalogResponse{}
for _, catalog := range catalogs {
vendorGameID := strings.TrimSpace(catalog.VendorGameID)
if vendorGameID == "" {
continue
}
now := time.Now()
fullScreen := false
var ext model.SysGameListVendorExt
err := tx.Where("sys_origin = ? AND profile = ? AND vendor_type = ? AND vendor_game_id = ?", sysOrigin, profile, vendorType, vendorGameID).First(&ext).Error
if err != nil && err != gorm.ErrRecordNotFound {
return nil, err
}
if err == nil && ext.GameListConfigID > 0 {
if err := s.refreshExistingGame(tx, ext.GameListConfigID, catalog, fullScreen); err != nil {
return nil, err
}
resp.Existing++
continue
}
nextCfgID, idErr := utils.NextID()
if idErr != nil {
return nil, idErr
}
cfg := model.SysGameListConfig{
ID: nextCfgID,
SysOrigin: sysOrigin,
GameOrigin: vendorType,
GameID: defaultIfBlank(catalog.InternalGameID, vendorGameID),
Name: catalog.Name,
Category: roomCategory,
GameCode: vendorGameID,
Cover: defaultIfBlank(catalog.Cover, catalog.PreviewURL),
Showcase: defaultShowcase,
Sort: parseSort(vendorGameID),
FullScreen: fullScreen,
ClientOrigin: "COMMON",
GameMode: "[3]",
}
if err := tx.Create(&cfg).Error; err != nil {
return nil, err
}
nextID, idErr := utils.NextID()
if idErr != nil {
return nil, idErr
}
newExt := model.SysGameListVendorExt{
ID: nextID,
GameListConfigID: cfg.ID,
SysOrigin: sysOrigin,
Profile: profile,
VendorType: vendorType,
VendorGameID: vendorGameID,
LaunchMode: launchModeH5,
PackageVersion: catalog.PackageVersion,
PackageURL: catalog.DownloadURL,
PreviewURL: catalog.PreviewURL,
BridgeSchemaVersion: "1.0",
ExtraJSON: `{"launchParams":{"gameType":"HOTGAME","screenMode":"seven"}}`,
Enabled: true,
CreateTime: now,
UpdateTime: now,
}
if err := tx.Create(&newExt).Error; err != nil {
return nil, err
}
resp.Imported++
}
return resp, nil
}
func (s *Service) refreshExistingGame(tx *gorm.DB, configID int64, catalog model.HotgameGameCatalog, fullScreen bool) error {
// 目录链接来自热游正式表格,重新同步时要同时刷新主表展示信息和扩展表启动入口,避免旧 URL 继续下发给 app。
if err := tx.Model(&model.SysGameListConfig{}).
Where("id = ? AND game_origin = ?", configID, vendorType).
Updates(map[string]any{
"name": catalog.Name,
"cover": defaultIfBlank(catalog.Cover, catalog.PreviewURL),
"full_screen": fullScreen,
}).Error; err != nil {
return err
}
return tx.Model(&model.SysGameListVendorExt{}).
Where("game_list_config_id = ? AND vendor_type = ?", configID, vendorType).
Updates(map[string]any{
"package_version": catalog.PackageVersion,
"package_url": catalog.DownloadURL,
"preview_url": catalog.PreviewURL,
"extra_json": `{"launchParams":{"gameType":"HOTGAME","screenMode":"seven"}}`,
"update_time": time.Now(),
}).Error
}
func hotgameLaunchURLForProfile(game defaultCatalogGame, profile string) string {
if normalizeProfile(profile) == profileTest {
return strings.TrimSpace(game.TestURL)
}
return strings.TrimSpace(game.ProdURL)
}
func resolveImportDefaultShowcase(values ...bool) bool {
if len(values) == 0 {
return true
}
return values[0]
}
func resolveBoolPtrDefault(value *bool, fallback bool) bool {
if value == nil {
return fallback
}
return *value
}
func buildVendorGameIDFilter(vendorGameIDs []string) map[string]struct{} {
filter := make(map[string]struct{}, len(vendorGameIDs))
for _, id := range vendorGameIDs {
id = strings.TrimSpace(id)
if id != "" {
filter[id] = struct{}{}
}
}
return filter
}
func normalizePageCursor(cursor int) int {
if cursor <= 0 {
return 1
}
return cursor
}
func normalizePageLimit(limit int) int {
if limit <= 0 {
return 20
}
if limit > 200 {
return 200
}
return limit
}
func parseSort(value string) int64 {
var sortValue int64
_, _ = fmt.Sscanf(strings.TrimSpace(value), "%d", &sortValue)
return sortValue
}
func catalogRawMode(rawJSON string) string {
var raw struct {
Mode string `json:"mode"`
}
_ = json.Unmarshal([]byte(strings.TrimSpace(rawJSON)), &raw)
return strings.TrimSpace(raw.Mode)
}