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