package hotgame import ( "context" "errors" "fmt" "net/http" "net/url" "strconv" "strings" "chatapp3-golang/internal/common" "chatapp3-golang/internal/service/gameprovider" "gorm.io/gorm" ) // Key 返回统一厂商标识,和后台游戏来源 HOTGAME 保持一致。 func (p *AppProvider) Key() string { return vendorType } // DisplayName 返回后台和调试接口展示名。 func (p *AppProvider) DisplayName() string { return displayName } // SupportsLaunch 判断统一启动请求是否命中热游配置。 func (p *AppProvider) SupportsLaunch(ctx context.Context, user gameprovider.AuthUser, req gameprovider.LaunchRequest) (bool, error) { _, err := p.service.findGameRow(ctx, user.SysOrigin, req) if err == nil { return true, nil } var appErr *common.AppError if errors.As(err, &appErr) && appErr.Code == "game_not_found" { return false, nil } return false, err } // ListShortcutGames 返回统一快捷列表,热游沿用完整列表前 5 个。 func (p *AppProvider) ListShortcutGames(ctx context.Context, user gameprovider.AuthUser, roomID string) ([]gameprovider.RoomGameListItem, error) { resp, err := p.ListRoomGames(ctx, user, roomID, "") if err != nil { return nil, err } if len(resp.Items) > 5 { resp.Items = resp.Items[:5] } return resp.Items, nil } // ListRoomGames 返回用户当前区域可见的热游房间游戏列表。 func (p *AppProvider) ListRoomGames(ctx context.Context, user gameprovider.AuthUser, roomID, category string) (*gameprovider.RoomGameListResponse, error) { items, err := p.service.listRoomGames(ctx, user.SysOrigin, user.RegionID, category) if err != nil { return nil, err } return &gameprovider.RoomGameListResponse{Items: items}, nil } // GetRoomState 热游 H5 不需要 Go 持久化房间状态,统一入口只返回空闲态。 func (p *AppProvider) GetRoomState(ctx context.Context, user gameprovider.AuthUser, roomID string) (*gameprovider.RoomStateResponse, error) { return &gameprovider.RoomStateResponse{RoomID: roomID, State: "IDLE", Provider: vendorType}, nil } // LaunchGame 生成热游 H5 URL,uid/token/lang 会被替换进热游提供的启动链接。 func (p *AppProvider) LaunchGame(ctx context.Context, user gameprovider.AuthUser, req gameprovider.LaunchRequest, clientIP string) (*gameprovider.LaunchResponse, error) { row, err := p.service.findGameRow(ctx, user.SysOrigin, req) if err != nil { return nil, err } runtimeCfg, err := p.service.resolveRuntimeConfigForProfile(ctx, normalizeSysOrigin(user.SysOrigin), row.Profile) if err != nil { return nil, err } entryURL := buildLaunchURL(row.entryURL(), runtimeCfg, user, req) sessionID := fmt.Sprintf("hg_%d_%s", user.UserID, row.VendorGameID) return &gameprovider.LaunchResponse{ ID: row.ConfigID, GameSessionID: sessionID, Provider: vendorType, GameID: row.InternalGameID, ProviderGameID: row.VendorGameID, Entry: gameprovider.LaunchEntry{ LaunchMode: defaultIfBlank(row.LaunchMode, launchModeH5), EntryURL: entryURL, PreviewURL: row.previewURL(), DownloadURL: row.entryURL(), PackageVersion: row.packageVersion(), Orientation: row.orientation(), SafeHeight: row.safeHeight(), }, LaunchConfig: map[string]any{ "uid": launchUID(runtimeCfg, user), "token": launchToken(runtimeCfg, user), "lang": launchLang(req), "gameId": row.VendorGameID, }, RoomState: gameprovider.RoomStateResponse{ RoomID: req.RoomID, State: "PLAYING", Provider: vendorType, GameSessionID: sessionID, CurrentGameID: row.InternalGameID, CurrentProviderGameID: row.VendorGameID, CurrentGameName: row.name(), CurrentGameCover: row.cover(), HostUserID: user.UserID, }, }, nil } // CloseGame 热游关闭只影响客户端 WebView,Go 侧不保留会话状态。 func (p *AppProvider) CloseGame(ctx context.Context, user gameprovider.AuthUser, req gameprovider.CloseRequest) (*gameprovider.RoomStateResponse, error) { return &gameprovider.RoomStateResponse{RoomID: req.RoomID, State: "IDLE", Provider: vendorType}, nil } func (s *Service) listRoomGames(ctx context.Context, sysOrigin, regionID, category string) ([]gameprovider.RoomGameListItem, error) { sysOrigin = normalizeSysOrigin(sysOrigin) profile, err := s.activeProfile(ctx, sysOrigin) if err != nil { return nil, err } var rows []gameRow query := s.baseGameQuery(ctx, sysOrigin, profile). Where("cfg.sys_origin = ? AND cfg.is_showcase = 1 AND ext.vendor_type = ?", sysOrigin, vendorType) if strings.TrimSpace(regionID) != "" { // 统一游戏列表按运营配置的区域 ID 过滤;空 regions 表示所有区域都可见。 query = query.Where("(COALESCE(TRIM(cfg.regions), '') = '' OR FIND_IN_SET(?, REPLACE(COALESCE(cfg.regions, ''), ' ', '')) > 0)", strings.TrimSpace(regionID)) } if strings.TrimSpace(category) != "" && !strings.EqualFold(strings.TrimSpace(category), roomCategory) { query = query.Where("cfg.category = ?", category) } if err := query.Order("cfg.sort DESC").Scan(&rows).Error; err != nil { return nil, err } items := make([]gameprovider.RoomGameListItem, 0, len(rows)) for _, row := range rows { items = append(items, row.toListItem()) } return items, nil } func (s *Service) findGameRow(ctx context.Context, sysOrigin string, req gameprovider.LaunchRequest) (gameRow, error) { sysOrigin = normalizeSysOrigin(sysOrigin) profile, err := s.activeProfile(ctx, sysOrigin) if err != nil { return gameRow{}, err } var row gameRow query := s.baseGameQuery(ctx, sysOrigin, profile). Where("cfg.sys_origin = ? AND ext.vendor_type = ?", sysOrigin, vendorType) if req.ID > 0 { query = query.Where("cfg.id = ?", req.ID) } else { query = query.Where("cfg.game_id = ? OR ext.vendor_game_id = ?", strings.TrimSpace(req.GameID), strings.TrimSpace(req.GameID)) } if err := query.Limit(1).Scan(&row).Error; err != nil { return gameRow{}, err } if row.ConfigID > 0 { return row, nil } return gameRow{}, common.NewAppError(http.StatusNotFound, "game_not_found", "hotgame game config not found") } func (s *Service) baseGameQuery(ctx context.Context, sysOrigin, profile string) *gorm.DB { return s.db.WithContext(ctx). Table("sys_game_list_config AS cfg"). Select(` cfg.id AS config_id, cfg.sys_origin AS sys_origin, ext.profile AS profile, cfg.game_id AS internal_game_id, cfg.name AS name, cfg.category AS category, cfg.cover AS cover, cfg.sort AS sort, cfg.full_screen AS full_screen, ext.vendor_game_id AS vendor_game_id, ext.launch_mode AS launch_mode, ext.package_version AS ext_package_version, ext.preview_url AS ext_preview_url, ext.package_url AS package_url, ext.orientation AS ext_orientation, ext.safe_height AS ext_safe_height, ext.extra_json AS ext_extra_json, cat.name AS catalog_name, cat.cover AS catalog_cover, cat.preview_url AS catalog_preview_url, cat.download_url AS catalog_download_url, cat.package_version AS catalog_package_version, cat.status AS catalog_status `). Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1 AND ext.profile = ?", profile). Joins("LEFT JOIN hotgame_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND cat.profile = ext.profile AND cat.vendor_game_id = ext.vendor_game_id") } func (r gameRow) toListItem() gameprovider.RoomGameListItem { return gameprovider.RoomGameListItem{ ID: r.ConfigID, GameID: r.InternalGameID, GameType: vendorType, Provider: vendorType, ProviderGameID: r.VendorGameID, Name: r.name(), Cover: r.cover(), Category: r.Category, Sort: r.Sort, LaunchMode: defaultIfBlank(r.LaunchMode, launchModeH5), FullScreen: r.FullScreen, GameMode: 3, SafeHeight: r.safeHeight(), Orientation: r.orientation(), PackageVersion: r.packageVersion(), Status: defaultIfBlank(r.CatalogStatus, catalogEnabled), LaunchParams: map[string]any{ "gameType": vendorType, "lang": defaultLaunchLang, "screenMode": defaultIfBlank(catalogRawMode(r.ExtExtraJSON), "seven"), }, } } func (r gameRow) name() string { return defaultIfBlank(r.Name, r.CatalogName) } func (r gameRow) cover() string { return defaultIfBlank(r.Cover, defaultIfBlank(r.CatalogCover, r.previewURL())) } func (r gameRow) previewURL() string { return defaultIfBlank(r.ExtPreviewURL, r.CatalogPreviewURL) } func (r gameRow) entryURL() string { return defaultIfBlank(r.PackageURL, defaultIfBlank(r.CatalogDownloadURL, r.ExtPreviewURL)) } func (r gameRow) packageVersion() string { return defaultIfBlank(r.ExtPackageVersion, r.CatalogPackageVersion) } func (r gameRow) orientation() int { if r.ExtOrientation != nil { return *r.ExtOrientation } return 0 } func (r gameRow) safeHeight() int { return r.ExtSafeHeight } func buildLaunchURL(entry string, cfg runtimeConfig, user gameprovider.AuthUser, req gameprovider.LaunchRequest) string { entry = strings.TrimSpace(entry) uid := launchUID(cfg, user) token := launchToken(cfg, user) lang := launchLang(req) replaced := strings.NewReplacer( "{uid}", url.QueryEscape(uid), "{token}", url.QueryEscape(token), "{lang}", url.QueryEscape(lang), ).Replace(entry) if replaced != entry { return replaced } parsed, err := url.Parse(entry) if err != nil { return entry } query := parsed.Query() query.Set("uid", uid) query.Set("token", token) query.Set("lang", lang) parsed.RawQuery = query.Encode() return parsed.String() } func launchUID(cfg runtimeConfig, user gameprovider.AuthUser) string { return defaultIfBlank(cfg.TestUID, strconv.FormatInt(user.UserID, 10)) } func launchToken(cfg runtimeConfig, user gameprovider.AuthUser) string { return defaultIfBlank(cfg.TestToken, user.Token) } func launchLang(req gameprovider.LaunchRequest) string { for _, key := range []string{"lang", "language", "locale"} { if req.Params == nil { break } if value := strings.TrimSpace(fmt.Sprint(req.Params[key])); value != "" && value != "" { return normalizeLaunchLang(value) } } return defaultLaunchLang } func normalizeLaunchLang(value string) string { value = strings.ToLower(strings.TrimSpace(value)) if strings.Contains(value, "-") { value = strings.SplitN(value, "-", 2)[0] } switch value { case "en", "ar", "id", "tr", "ur", "vi": return value default: return defaultLaunchLang } }