315 lines
10 KiB
Go
315 lines
10 KiB
Go
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 != "<nil>" {
|
||
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
|
||
}
|
||
}
|