增加热游游戏
This commit is contained in:
parent
a71dd0fbae
commit
4219117721
@ -21,6 +21,7 @@ import (
|
|||||||
"chatapp3-golang/internal/service/gameopen"
|
"chatapp3-golang/internal/service/gameopen"
|
||||||
"chatapp3-golang/internal/service/gameprovider"
|
"chatapp3-golang/internal/service/gameprovider"
|
||||||
"chatapp3-golang/internal/service/hostcenter"
|
"chatapp3-golang/internal/service/hostcenter"
|
||||||
|
"chatapp3-golang/internal/service/hotgame"
|
||||||
"chatapp3-golang/internal/service/invite"
|
"chatapp3-golang/internal/service/invite"
|
||||||
"chatapp3-golang/internal/service/lingxian"
|
"chatapp3-golang/internal/service/lingxian"
|
||||||
"chatapp3-golang/internal/service/luckygift"
|
"chatapp3-golang/internal/service/luckygift"
|
||||||
@ -57,6 +58,7 @@ func main() {
|
|||||||
baishunService := baishun.NewBaishunService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
baishunService := baishun.NewBaishunService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||||
binanceRechargeService := binancerecharge.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
binanceRechargeService := binancerecharge.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
lingxianService := lingxian.NewService(app.Config, app.Repository.DB, app.Repository.Redis)
|
lingxianService := lingxian.NewService(app.Config, app.Repository.DB, app.Repository.Redis)
|
||||||
|
hotgameService := hotgame.NewService(app.Config, app.Repository.DB, app.Repository.Redis)
|
||||||
gameOpenService := gameopen.NewGameOpenService(app.Config, app.Repository.DB, &app.Gateways)
|
gameOpenService := gameopen.NewGameOpenService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet)
|
luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet)
|
||||||
rechargeAgencyService := rechargeagency.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
rechargeAgencyService := rechargeagency.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
@ -115,6 +117,7 @@ func main() {
|
|||||||
gameProviders := gameprovider.NewRegistry(
|
gameProviders := gameprovider.NewRegistry(
|
||||||
baishun.NewAppProvider(baishunService),
|
baishun.NewAppProvider(baishunService),
|
||||||
lingxian.NewAppProvider(lingxianService),
|
lingxian.NewAppProvider(lingxianService),
|
||||||
|
hotgame.NewAppProvider(hotgameService),
|
||||||
)
|
)
|
||||||
gameVisibility := gameprovider.NewVisibilityPolicy(
|
gameVisibility := gameprovider.NewVisibilityPolicy(
|
||||||
&app.Gateways,
|
&app.Gateways,
|
||||||
@ -135,6 +138,7 @@ func main() {
|
|||||||
ManagerCenter: managerCenterService,
|
ManagerCenter: managerCenterService,
|
||||||
PropsStore: propsStoreService,
|
PropsStore: propsStoreService,
|
||||||
HostCenter: hostCenterService,
|
HostCenter: hostCenterService,
|
||||||
|
Hotgame: hotgameService,
|
||||||
RechargeAgency: rechargeAgencyService,
|
RechargeAgency: rechargeAgencyService,
|
||||||
RechargeReward: rechargeRewardService,
|
RechargeReward: rechargeRewardService,
|
||||||
RegionIMGroup: regionIMGroupService,
|
RegionIMGroup: regionIMGroupService,
|
||||||
|
|||||||
@ -32,6 +32,7 @@ type Config struct {
|
|||||||
TencentIM TencentIMConfig
|
TencentIM TencentIMConfig
|
||||||
Baishun BaishunConfig
|
Baishun BaishunConfig
|
||||||
Lingxian LingxianConfig
|
Lingxian LingxianConfig
|
||||||
|
Hotgame HotgameConfig
|
||||||
GameOpen GameOpenConfig
|
GameOpen GameOpenConfig
|
||||||
LuckyGift LuckyGiftConfig
|
LuckyGift LuckyGiftConfig
|
||||||
BinanceRecharge BinanceRechargeConfig
|
BinanceRecharge BinanceRechargeConfig
|
||||||
@ -268,6 +269,12 @@ type LingxianConfig struct {
|
|||||||
AppKey string
|
AppKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HotgameConfig 保存热游模块的环境兜底配置。
|
||||||
|
type HotgameConfig struct {
|
||||||
|
AppKey string
|
||||||
|
CallbackBaseURL string
|
||||||
|
}
|
||||||
|
|
||||||
// GameOpenConfig 保存统一三方游戏回调配置。
|
// GameOpenConfig 保存统一三方游戏回调配置。
|
||||||
type GameOpenConfig struct {
|
type GameOpenConfig struct {
|
||||||
PublicBaseURL string
|
PublicBaseURL string
|
||||||
@ -617,6 +624,10 @@ func Load() Config {
|
|||||||
GameListURL: getEnvAny([]string{"CHATAPP_LINGXIAN_GAME_LIST_URL", "LINGXIAN_GAME_LIST_URL"}, "https://sg-test.leadercc.com/yumichat_games/test_game_list.json"),
|
GameListURL: getEnvAny([]string{"CHATAPP_LINGXIAN_GAME_LIST_URL", "LINGXIAN_GAME_LIST_URL"}, "https://sg-test.leadercc.com/yumichat_games/test_game_list.json"),
|
||||||
AppKey: getEnvAny([]string{"CHATAPP_LINGXIAN_APP_KEY", "LINGXIAN_APP_KEY", "GAME_HKYS_SIGN_KEY", "LIKEI_GAME_HKYS_SIGN_KEY"}, ""),
|
AppKey: getEnvAny([]string{"CHATAPP_LINGXIAN_APP_KEY", "LINGXIAN_APP_KEY", "GAME_HKYS_SIGN_KEY", "LIKEI_GAME_HKYS_SIGN_KEY"}, ""),
|
||||||
},
|
},
|
||||||
|
Hotgame: HotgameConfig{
|
||||||
|
AppKey: getEnvAny([]string{"CHATAPP_HOTGAME_APP_KEY", "HOTGAME_APP_KEY", "CHATAPP_REYOU_APP_KEY", "REYOU_APP_KEY"}, ""),
|
||||||
|
CallbackBaseURL: getEnvAny([]string{"CHATAPP_HOTGAME_CALLBACK_BASE_URL", "HOTGAME_CALLBACK_BASE_URL", "CHATAPP_REYOU_CALLBACK_BASE_URL", "REYOU_CALLBACK_BASE_URL"}, ""),
|
||||||
|
},
|
||||||
GameOpen: GameOpenConfig{
|
GameOpen: GameOpenConfig{
|
||||||
PublicBaseURL: getEnvAny([]string{"CHATAPP_GAME_OPEN_PUBLIC_BASE_URL", "GAME_OPEN_PUBLIC_BASE_URL"}, ""),
|
PublicBaseURL: getEnvAny([]string{"CHATAPP_GAME_OPEN_PUBLIC_BASE_URL", "GAME_OPEN_PUBLIC_BASE_URL"}, ""),
|
||||||
AppKey: getEnvAny([]string{"CHATAPP_GAME_OPEN_APP_KEY", "GAME_OPEN_APP_KEY"}, "game-open-test-key"),
|
AppKey: getEnvAny([]string{"CHATAPP_GAME_OPEN_APP_KEY", "GAME_OPEN_APP_KEY"}, "game-open-test-key"),
|
||||||
|
|||||||
41
internal/model/hotgame_models.go
Normal file
41
internal/model/hotgame_models.go
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// HotgameProviderConfig 保存每个系统、每个环境的热游回调验签配置。
|
||||||
|
type HotgameProviderConfig struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey"` // 主键
|
||||||
|
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_hotgame_provider_sys_origin_profile,priority:1;index:idx_hotgame_provider_active,priority:1"` // 系统标识
|
||||||
|
Profile string `gorm:"column:profile;size:32;default:PROD;uniqueIndex:uk_hotgame_provider_sys_origin_profile,priority:2"` // 配置环境:PROD/TEST
|
||||||
|
Active bool `gorm:"column:active;index:idx_hotgame_provider_active,priority:2"` // app 当前启用环境
|
||||||
|
AppKey string `gorm:"column:app_key;size:255"` // 热游回调 MD5 key
|
||||||
|
CallbackBaseURL string `gorm:"column:callback_base_url;size:1024"` // 提供给热游配置的回调域名
|
||||||
|
TestUID string `gorm:"column:test_uid;size:64"` // 联调用 UID 覆盖
|
||||||
|
TestToken string `gorm:"column:test_token;size:1024"` // 联调用 token 覆盖
|
||||||
|
CreateTime time.Time `gorm:"column:create_time"` // 创建时间
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time;index:idx_hotgame_provider_active,priority:3"` // 更新时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 返回热游接入配置表名。
|
||||||
|
func (HotgameProviderConfig) TableName() string { return "hotgame_provider_config" }
|
||||||
|
|
||||||
|
// HotgameGameCatalog 保存从热游对接表导入的游戏目录。
|
||||||
|
type HotgameGameCatalog struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey"` // 主键
|
||||||
|
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_hotgame_catalog_sys_profile_vendor_game,priority:1;index:idx_hotgame_catalog_profile_internal_game,priority:1"` // 系统标识
|
||||||
|
Profile string `gorm:"column:profile;size:32;default:PROD;uniqueIndex:uk_hotgame_catalog_sys_profile_vendor_game,priority:2;index:idx_hotgame_catalog_profile_internal_game,priority:2"` // 配置环境:PROD/TEST
|
||||||
|
InternalGameID string `gorm:"column:internal_game_id;size:64;index:idx_hotgame_catalog_profile_internal_game,priority:3"` // app 内部游戏 ID
|
||||||
|
VendorGameID string `gorm:"column:vendor_game_id;size:64;uniqueIndex:uk_hotgame_catalog_sys_profile_vendor_game,priority:3"` // 热游游戏 ID
|
||||||
|
Name string `gorm:"column:name;size:128"` // 游戏名称
|
||||||
|
Cover string `gorm:"column:cover;size:1024"` // 封面地址
|
||||||
|
PreviewURL string `gorm:"column:preview_url;size:1024"` // 七分屏/预览入口
|
||||||
|
DownloadURL string `gorm:"column:download_url;size:1024"` // 实际启动入口
|
||||||
|
PackageVersion string `gorm:"column:package_version;size:32"` // 版本号
|
||||||
|
RawJSON string `gorm:"column:raw_json;type:longtext"` // 原始目录 JSON
|
||||||
|
Status string `gorm:"column:status;size:32;index:idx_hotgame_catalog_profile_internal_game,priority:4"` // 目录状态
|
||||||
|
CreateTime time.Time `gorm:"column:create_time"` // 创建时间
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time"` // 更新时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 返回热游目录表名。
|
||||||
|
func (HotgameGameCatalog) TableName() string { return "hotgame_game_catalog" }
|
||||||
@ -46,6 +46,8 @@ func registerGameOpenRoutes(engine *gin.Engine, cfg config.Config, service *game
|
|||||||
c.JSON(http.StatusOK, service.HandleSupplement(c.Request.Context(), req, string(raw)))
|
c.JSON(http.StatusOK, service.HandleSupplement(c.Request.Context(), req, string(raw)))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
registerHotgameCallbackRoutes(engine, service)
|
||||||
|
|
||||||
internalGroup := engine.Group("/internal/game/open")
|
internalGroup := engine.Group("/internal/game/open")
|
||||||
internalGroup.Use(internalSecretMiddleware(cfg.HTTP.InternalCallbackSecret))
|
internalGroup.Use(internalSecretMiddleware(cfg.HTTP.InternalCallbackSecret))
|
||||||
internalGroup.GET("/info", func(c *gin.Context) {
|
internalGroup.GET("/info", func(c *gin.Context) {
|
||||||
@ -58,6 +60,34 @@ func registerGameOpenRoutes(engine *gin.Engine, cfg config.Config, service *game
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func registerHotgameCallbackRoutes(engine *gin.Engine, service *gameopen.GameOpenService) {
|
||||||
|
handleUserInfo := func(c *gin.Context) {
|
||||||
|
raw, _ := c.GetRawData()
|
||||||
|
var req gameopen.HotgameGetUserInfoRequest
|
||||||
|
if err := json.Unmarshal(raw, &req); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gameopen.CallbackResponse{ErrorCode: 4001, ErrorMsg: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, service.HandleHotgameGetUserInfo(c.Request.Context(), req, string(raw)))
|
||||||
|
}
|
||||||
|
handleUpdateBalance := func(c *gin.Context) {
|
||||||
|
raw, _ := c.GetRawData()
|
||||||
|
var req gameopen.HotgameUpdateBalanceRequest
|
||||||
|
if err := json.Unmarshal(raw, &req); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gameopen.CallbackResponse{ErrorCode: 4001, ErrorMsg: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, service.HandleHotgameUpdateBalance(c.Request.Context(), req, string(raw)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 热游文档要求平台直接提供 /getUserInfo 和 /updateBalance,同时保留命名空间路径便于网关灰度。
|
||||||
|
engine.POST("/getUserInfo", handleUserInfo)
|
||||||
|
engine.POST("/updateBalance", handleUpdateBalance)
|
||||||
|
hotgameGroup := engine.Group("/game/hotgame")
|
||||||
|
hotgameGroup.POST("/getUserInfo", handleUserInfo)
|
||||||
|
hotgameGroup.POST("/updateBalance", handleUpdateBalance)
|
||||||
|
}
|
||||||
|
|
||||||
func resolvePublicBaseURL(c *gin.Context, cfg config.Config) string {
|
func resolvePublicBaseURL(c *gin.Context, cfg config.Config) string {
|
||||||
if value := strings.TrimRight(strings.TrimSpace(cfg.GameOpen.PublicBaseURL), "/"); value != "" {
|
if value := strings.TrimRight(strings.TrimSpace(cfg.GameOpen.PublicBaseURL), "/"); value != "" {
|
||||||
return value
|
return value
|
||||||
|
|||||||
115
internal/router/hotgame_routes.go
Normal file
115
internal/router/hotgame_routes.go
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/common"
|
||||||
|
"chatapp3-golang/internal/service/hotgame"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerHotgameRoutes 注册热游后台管理类 Go 接口。
|
||||||
|
func registerHotgameRoutes(engine *gin.Engine, javaClient authGateway, service *hotgame.Service) {
|
||||||
|
if service == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
consoleGroup := engine.Group("/operate/hotgame-game")
|
||||||
|
consoleGroup.Use(consoleAuthMiddleware(javaClient))
|
||||||
|
consoleGroup.GET("/config", func(c *gin.Context) {
|
||||||
|
resp, err := service.GetProviderConfig(c.Request.Context(), c.Query("sysOrigin"), c.Query("profile"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, resp)
|
||||||
|
})
|
||||||
|
consoleGroup.POST("/config", func(c *gin.Context) {
|
||||||
|
var req hotgame.SaveProviderConfigRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := service.SaveProviderConfig(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, resp)
|
||||||
|
})
|
||||||
|
consoleGroup.POST("/config/activate", func(c *gin.Context) {
|
||||||
|
var req hotgame.ActivateProviderProfileRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := service.ActivateProviderProfile(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, resp)
|
||||||
|
})
|
||||||
|
consoleGroup.GET("/catalog/page", func(c *gin.Context) {
|
||||||
|
cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1")))
|
||||||
|
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
|
||||||
|
resp, err := service.PageCatalog(
|
||||||
|
c.Request.Context(),
|
||||||
|
c.Query("sysOrigin"),
|
||||||
|
c.Query("profile"),
|
||||||
|
c.Query("keyword"),
|
||||||
|
cursor,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, resp)
|
||||||
|
})
|
||||||
|
consoleGroup.POST("/catalog/fetch", func(c *gin.Context) {
|
||||||
|
var req hotgame.SyncCatalogRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := service.FetchAdminCatalog(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, resp)
|
||||||
|
})
|
||||||
|
consoleGroup.POST("/import-catalog", func(c *gin.Context) {
|
||||||
|
var req hotgame.SyncCatalogRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defaultShowcase := true
|
||||||
|
if req.DefaultShowcase != nil {
|
||||||
|
defaultShowcase = *req.DefaultShowcase
|
||||||
|
}
|
||||||
|
resp, err := service.ImportCatalogGames(c.Request.Context(), req.SysOrigin, req.Profile, req.VendorGameIDs, defaultShowcase)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, resp)
|
||||||
|
})
|
||||||
|
consoleGroup.POST("/sync", func(c *gin.Context) {
|
||||||
|
var req hotgame.SyncCatalogRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := service.SyncAdminGames(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, resp)
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@ import (
|
|||||||
"chatapp3-golang/internal/service/gameopen"
|
"chatapp3-golang/internal/service/gameopen"
|
||||||
"chatapp3-golang/internal/service/gameprovider"
|
"chatapp3-golang/internal/service/gameprovider"
|
||||||
"chatapp3-golang/internal/service/hostcenter"
|
"chatapp3-golang/internal/service/hostcenter"
|
||||||
|
"chatapp3-golang/internal/service/hotgame"
|
||||||
"chatapp3-golang/internal/service/invite"
|
"chatapp3-golang/internal/service/invite"
|
||||||
"chatapp3-golang/internal/service/lingxian"
|
"chatapp3-golang/internal/service/lingxian"
|
||||||
"chatapp3-golang/internal/service/luckygift"
|
"chatapp3-golang/internal/service/luckygift"
|
||||||
@ -53,6 +54,7 @@ type Services struct {
|
|||||||
ManagerCenter *managercenter.Service
|
ManagerCenter *managercenter.Service
|
||||||
PropsStore *propsstore.Service
|
PropsStore *propsstore.Service
|
||||||
HostCenter *hostcenter.Service
|
HostCenter *hostcenter.Service
|
||||||
|
Hotgame *hotgame.Service
|
||||||
RechargeAgency *rechargeagency.Service
|
RechargeAgency *rechargeagency.Service
|
||||||
RechargeReward *rechargereward.Service
|
RechargeReward *rechargereward.Service
|
||||||
RegionIMGroup *regionimgroup.Service
|
RegionIMGroup *regionimgroup.Service
|
||||||
@ -104,6 +106,7 @@ func NewRouter(
|
|||||||
registerGameProviderRoutes(engine, javaClient, services.GameProviders, services.GameVisibility)
|
registerGameProviderRoutes(engine, javaClient, services.GameProviders, services.GameVisibility)
|
||||||
registerBaishunRoutes(engine, cfg, javaClient, services.Baishun)
|
registerBaishunRoutes(engine, cfg, javaClient, services.Baishun)
|
||||||
registerLingxianRoutes(engine, javaClient, services.Lingxian)
|
registerLingxianRoutes(engine, javaClient, services.Lingxian)
|
||||||
|
registerHotgameRoutes(engine, javaClient, services.Hotgame)
|
||||||
registerLuckyGiftRoutes(engine, cfg, services.LuckyGift)
|
registerLuckyGiftRoutes(engine, cfg, services.LuckyGift)
|
||||||
registerManagerCenterRoutes(engine, javaClient, services.ManagerCenter)
|
registerManagerCenterRoutes(engine, javaClient, services.ManagerCenter)
|
||||||
registerPropsStoreRoutes(engine, javaClient, services.PropsStore)
|
registerPropsStoreRoutes(engine, javaClient, services.PropsStore)
|
||||||
|
|||||||
387
internal/service/gameopen/hotgame.go
Normal file
387
internal/service/gameopen/hotgame.go
Normal file
@ -0,0 +1,387 @@
|
|||||||
|
package gameopen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/integration"
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
"chatapp3-golang/internal/service/taskcenter"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
hotgameVendorType = "HOTGAME"
|
||||||
|
hotgameCodeTokenInvalid = 1001
|
||||||
|
hotgameCodeInsufficientBalance = 2001
|
||||||
|
hotgameCodeDuplicateOrder = 3001
|
||||||
|
hotgameCodeBadRequest = 4001
|
||||||
|
hotgameCodeSignatureError = 4002
|
||||||
|
hotgameCodeServerError = 5000
|
||||||
|
)
|
||||||
|
|
||||||
|
// HotgameGetUserInfoRequest 是热游 /getUserInfo 回调入参。
|
||||||
|
type HotgameGetUserInfoRequest struct {
|
||||||
|
GameID string `json:"gameId"`
|
||||||
|
UID string `json:"uid"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Sign string `json:"sign"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HotgameUpdateBalanceRequest 是热游 /updateBalance 回调入参;roundId 文档是 int,所以这里兼容数字和字符串。
|
||||||
|
type HotgameUpdateBalanceRequest struct {
|
||||||
|
OrderID string `json:"orderId"`
|
||||||
|
GameID string `json:"gameId"`
|
||||||
|
RoundID any `json:"roundId"`
|
||||||
|
UID string `json:"uid"`
|
||||||
|
Coin int64 `json:"coin"`
|
||||||
|
Type int `json:"type"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Sign string `json:"sign"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleHotgameGetUserInfo 按热游协议查询玩家资料和余额。
|
||||||
|
func (s *GameOpenService) HandleHotgameGetUserInfo(ctx context.Context, req HotgameGetUserInfoRequest, rawJSON string) CallbackResponse {
|
||||||
|
if err := s.validateHotgameGetUserInfoRequest(req); err != nil {
|
||||||
|
resp := failResponse(hotgameCodeBadRequest, err.Error())
|
||||||
|
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, "", req.toQueryUserRequest(), nil, gameOpenLogStatusFailed)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
appKey := s.resolveCallbackAppKeyForVendor(ctx, hotgameVendorType, req.GameID, req.Token)
|
||||||
|
if strings.TrimSpace(appKey) == "" {
|
||||||
|
resp := failResponse(hotgameCodeBadRequest, "app key is blank")
|
||||||
|
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, "", req.toQueryUserRequest(), nil, gameOpenLogStatusFailed)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
if !verifySign(req.Sign, buildHotgameGetUserInfoSign(req, appKey)) {
|
||||||
|
resp := failResponse(hotgameCodeSignatureError, "signature error")
|
||||||
|
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, "", req.toQueryUserRequest(), nil, gameOpenLogStatusFailed)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Token = normalizeCallbackToken(req.Token)
|
||||||
|
credential, err := s.java.AuthenticateToken(ctx, req.Token)
|
||||||
|
if err != nil {
|
||||||
|
resp := failResponse(hotgameCodeTokenInvalid, "token invalid")
|
||||||
|
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, "", req.toQueryUserRequest(), nil, gameOpenLogStatusFailed)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
resolved, err := s.resolveUserWithToken(ctx, credential, req.UID)
|
||||||
|
if err != nil {
|
||||||
|
resp := failResponse(hotgameCodeTokenInvalid, "uid token mismatch")
|
||||||
|
userID := int64(credential.UserID)
|
||||||
|
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, credential.SysOrigin, req.toQueryUserRequest(), &userID, gameOpenLogStatusFailed)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
userID := resolved.userID
|
||||||
|
|
||||||
|
profile := resolved.profile
|
||||||
|
if int64(profile.ID) == 0 {
|
||||||
|
profile, err = s.java.GetUserProfile(ctx, userID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
resp := failResponse(hotgameCodeServerError, "profile load failed")
|
||||||
|
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, credential.SysOrigin, req.toQueryUserRequest(), &userID, gameOpenLogStatusFailed)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
balance, err := s.readUserBalance(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
resp := failResponse(hotgameCodeServerError, "balance load failed")
|
||||||
|
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, credential.SysOrigin, req.toQueryUserRequest(), &userID, gameOpenLogStatusFailed)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := successResponse(map[string]any{
|
||||||
|
"uid": req.UID,
|
||||||
|
"nickname": profile.UserNickname,
|
||||||
|
"avatar": profile.UserAvatar,
|
||||||
|
"coin": balance,
|
||||||
|
"vipLevel": 0,
|
||||||
|
})
|
||||||
|
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, credential.SysOrigin, req.toQueryUserRequest(), &userID, gameOpenLogStatusSuccess)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleHotgameUpdateBalance 按热游协议处理实时扣加币,并用 orderId 做钱包事件幂等。
|
||||||
|
func (s *GameOpenService) HandleHotgameUpdateBalance(ctx context.Context, req HotgameUpdateBalanceRequest, rawJSON string) CallbackResponse {
|
||||||
|
updateReq := req.toUpdateCoinRequest()
|
||||||
|
if err := s.validateHotgameUpdateBalanceRequest(req); err != nil {
|
||||||
|
resp := failResponse(hotgameCodeBadRequest, err.Error())
|
||||||
|
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, "", updateReq, nil, gameOpenLogStatusFailed)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
appKey := s.resolveCallbackAppKeyForVendor(ctx, hotgameVendorType, req.GameID, req.Token)
|
||||||
|
if strings.TrimSpace(appKey) == "" {
|
||||||
|
resp := failResponse(hotgameCodeBadRequest, "app key is blank")
|
||||||
|
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, "", updateReq, nil, gameOpenLogStatusFailed)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
if !verifySign(req.Sign, buildHotgameUpdateBalanceSign(req, appKey)) {
|
||||||
|
resp := failResponse(hotgameCodeSignatureError, "signature error")
|
||||||
|
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, "", updateReq, nil, gameOpenLogStatusFailed)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Token = normalizeCallbackToken(req.Token)
|
||||||
|
updateReq.Token = req.Token
|
||||||
|
credential, err := s.java.AuthenticateToken(ctx, req.Token)
|
||||||
|
if err != nil {
|
||||||
|
resp := failResponse(hotgameCodeTokenInvalid, "token invalid")
|
||||||
|
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, "", updateReq, nil, gameOpenLogStatusFailed)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
resolved, err := s.resolveUserWithToken(ctx, credential, req.UID)
|
||||||
|
if err != nil {
|
||||||
|
resp := failResponse(hotgameCodeTokenInvalid, "uid token mismatch")
|
||||||
|
userID := int64(credential.UserID)
|
||||||
|
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
userID := resolved.userID
|
||||||
|
|
||||||
|
eventID := "GAME_HOTGAME:" + strings.TrimSpace(req.OrderID)
|
||||||
|
exists, err := s.java.ExistsGoldEvent(ctx, eventID)
|
||||||
|
if err != nil {
|
||||||
|
resp := failResponse(hotgameCodeServerError, "event check failed")
|
||||||
|
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
resp := failResponse(hotgameCodeDuplicateOrder, "duplicate orderId")
|
||||||
|
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{
|
||||||
|
ReceiptType: receiptTypeFromType(req.Type),
|
||||||
|
UserID: userID,
|
||||||
|
SysOrigin: credential.SysOrigin,
|
||||||
|
EventID: eventID,
|
||||||
|
Remark: fmt.Sprintf("hotgame=%s type=%d round=%s", req.GameID, req.Type, updateReq.RoundID),
|
||||||
|
Amount: integration.NewPennyAmountPayloadFromDollar(req.Coin),
|
||||||
|
CloseDelayAsset: false,
|
||||||
|
OpUserType: "APP",
|
||||||
|
CustomizeOrigin: "GAME_HOTGAME_" + strings.TrimSpace(req.GameID),
|
||||||
|
CustomizeOriginDesc: "HOTGAME[" + strings.TrimSpace(req.GameID) + "]",
|
||||||
|
}); err != nil {
|
||||||
|
// 热游文档要求扣款失败必须返回失败码;支出失败统一归到余额不足类,防止游戏继续开奖。
|
||||||
|
code := hotgameCodeServerError
|
||||||
|
if req.Type == 1 || looksLikeInsufficientBalance(err) {
|
||||||
|
code = hotgameCodeInsufficientBalance
|
||||||
|
}
|
||||||
|
resp := failResponse(code, err.Error())
|
||||||
|
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
balance, err := s.readUserBalance(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
resp := failResponse(hotgameCodeServerError, "balance load failed")
|
||||||
|
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := successResponse(map[string]any{"coin": balance, "responseId": eventID})
|
||||||
|
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusSuccess)
|
||||||
|
s.reportHotgameConsumeTaskEvent(ctx, credential.SysOrigin, userID, updateReq)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (req HotgameGetUserInfoRequest) toQueryUserRequest() QueryUserRequest {
|
||||||
|
return QueryUserRequest{
|
||||||
|
GameID: req.GameID,
|
||||||
|
UID: req.UID,
|
||||||
|
Token: req.Token,
|
||||||
|
Sign: req.Sign,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (req HotgameUpdateBalanceRequest) toUpdateCoinRequest() UpdateCoinRequest {
|
||||||
|
return UpdateCoinRequest{
|
||||||
|
OrderID: req.OrderID,
|
||||||
|
GameID: req.GameID,
|
||||||
|
RoundID: hotgameRoundIDString(req.RoundID),
|
||||||
|
UID: req.UID,
|
||||||
|
Coin: req.Coin,
|
||||||
|
Type: req.Type,
|
||||||
|
Token: req.Token,
|
||||||
|
Sign: req.Sign,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GameOpenService) validateHotgameGetUserInfoRequest(req HotgameGetUserInfoRequest) error {
|
||||||
|
switch {
|
||||||
|
case strings.TrimSpace(req.GameID) == "":
|
||||||
|
return newBadRequest("gameId is required")
|
||||||
|
case strings.TrimSpace(req.UID) == "":
|
||||||
|
return newBadRequest("uid is required")
|
||||||
|
case strings.TrimSpace(req.Token) == "":
|
||||||
|
return newBadRequest("token is required")
|
||||||
|
case strings.TrimSpace(req.Sign) == "":
|
||||||
|
return newBadRequest("sign is required")
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GameOpenService) validateHotgameUpdateBalanceRequest(req HotgameUpdateBalanceRequest) error {
|
||||||
|
switch {
|
||||||
|
case strings.TrimSpace(req.OrderID) == "":
|
||||||
|
return newBadRequest("orderId is required")
|
||||||
|
case strings.TrimSpace(req.GameID) == "":
|
||||||
|
return newBadRequest("gameId is required")
|
||||||
|
case strings.TrimSpace(hotgameRoundIDString(req.RoundID)) == "":
|
||||||
|
return newBadRequest("roundId is required")
|
||||||
|
case strings.TrimSpace(req.UID) == "":
|
||||||
|
return newBadRequest("uid is required")
|
||||||
|
case req.Coin <= 0:
|
||||||
|
return newBadRequest("coin is required")
|
||||||
|
case req.Type != 1 && req.Type != 2:
|
||||||
|
return newBadRequest("type must be 1 or 2")
|
||||||
|
case strings.TrimSpace(req.Token) == "":
|
||||||
|
return newBadRequest("token is required")
|
||||||
|
case strings.TrimSpace(req.Sign) == "":
|
||||||
|
return newBadRequest("sign is required")
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildHotgameGetUserInfoSign(req HotgameGetUserInfoRequest, key string) string {
|
||||||
|
return md5Hex(req.GameID + req.UID + req.Token + key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildHotgameUpdateBalanceSign(req HotgameUpdateBalanceRequest, key string) string {
|
||||||
|
return md5Hex(
|
||||||
|
req.OrderID +
|
||||||
|
req.GameID +
|
||||||
|
hotgameRoundIDString(req.RoundID) +
|
||||||
|
req.UID +
|
||||||
|
fmt.Sprintf("%d", req.Coin) +
|
||||||
|
fmt.Sprintf("%d", req.Type) +
|
||||||
|
req.Token +
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hotgameRoundIDString(value any) string {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case nil:
|
||||||
|
return ""
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(typed)
|
||||||
|
case json.Number:
|
||||||
|
return strings.TrimSpace(typed.String())
|
||||||
|
case float64:
|
||||||
|
if typed == float64(int64(typed)) {
|
||||||
|
return fmt.Sprintf("%d", int64(typed))
|
||||||
|
}
|
||||||
|
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%f", typed), "0"), ".")
|
||||||
|
default:
|
||||||
|
return strings.TrimSpace(fmt.Sprint(typed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GameOpenService) resolveCallbackAppKeyForVendor(ctx context.Context, vendorType string, gameID, token string) string {
|
||||||
|
if s != nil && s.repo.DB != nil {
|
||||||
|
if sysOrigin := callbackTokenSysOrigin(token); sysOrigin != "" {
|
||||||
|
if appKey := s.resolveActiveProviderAppKey(ctx, vendorType, sysOrigin); appKey != "" {
|
||||||
|
return appKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if appKey := s.resolveProviderAppKeyByGame(ctx, vendorType, gameID); appKey != "" {
|
||||||
|
return appKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(s.cfg.GameOpen.AppKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GameOpenService) resolveProviderAppKeyByGame(ctx context.Context, vendorType string, gameID string) string {
|
||||||
|
gameID = strings.TrimSpace(gameID)
|
||||||
|
if gameID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(vendorType)) {
|
||||||
|
case "LINGXIAN":
|
||||||
|
return s.resolveLingxianAppKeyByGame(ctx, gameID)
|
||||||
|
case hotgameVendorType:
|
||||||
|
var row struct {
|
||||||
|
AppKey string
|
||||||
|
}
|
||||||
|
err := s.repo.DB.WithContext(ctx).
|
||||||
|
Table("hotgame_provider_config AS cfg").
|
||||||
|
Select("cfg.app_key AS app_key").
|
||||||
|
Joins("JOIN sys_game_list_vendor_ext ext ON ext.sys_origin = cfg.sys_origin AND ext.profile = cfg.profile AND ext.vendor_type = ? AND ext.enabled = ?", hotgameVendorType, true).
|
||||||
|
Where("cfg.active = ? AND ext.vendor_game_id = ?", true, gameID).
|
||||||
|
Order("cfg.update_time DESC").
|
||||||
|
Limit(1).
|
||||||
|
Scan(&row).Error
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(row.AppKey)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GameOpenService) resolveActiveProviderAppKey(ctx context.Context, vendorType string, sysOrigin string) string {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(vendorType)) {
|
||||||
|
case "LINGXIAN":
|
||||||
|
return s.resolveActiveLingxianAppKey(ctx, sysOrigin)
|
||||||
|
case hotgameVendorType:
|
||||||
|
sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin))
|
||||||
|
if sysOrigin == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var row model.HotgameProviderConfig
|
||||||
|
err := s.repo.DB.WithContext(ctx).
|
||||||
|
Where("sys_origin = ? AND active = ?", sysOrigin, true).
|
||||||
|
Order("update_time DESC").
|
||||||
|
Limit(1).
|
||||||
|
First(&row).Error
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(row.AppKey)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeInsufficientBalance(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
message := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(message, "insufficient") ||
|
||||||
|
strings.Contains(message, "balance not made") ||
|
||||||
|
strings.Contains(message, "余额不足")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GameOpenService) reportHotgameConsumeTaskEvent(ctx context.Context, sysOrigin string, userID int64, req UpdateCoinRequest) {
|
||||||
|
if s.taskReporter == nil || receiptTypeFromType(req.Type) != "EXPENDITURE" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deltaValue := req.Coin
|
||||||
|
if deltaValue < 0 {
|
||||||
|
deltaValue = -deltaValue
|
||||||
|
}
|
||||||
|
if deltaValue <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"provider": hotgameVendorType,
|
||||||
|
"orderId": strings.TrimSpace(req.OrderID),
|
||||||
|
"gameId": strings.TrimSpace(req.GameID),
|
||||||
|
"roundId": strings.TrimSpace(req.RoundID),
|
||||||
|
}
|
||||||
|
if err := s.taskReporter.ReportSimpleEvent(ctx, sysOrigin, "GAME_HOTGAME_CONSUME:"+strings.TrimSpace(req.OrderID), taskcenter.EventTypeGameConsumeGold, userID, deltaValue, payload); err != nil {
|
||||||
|
log.Printf("report hotgame task event failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -219,20 +219,7 @@ func (s *GameOpenService) GetIntegrationInfo(ctx context.Context, publicBaseURL
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *GameOpenService) resolveCallbackAppKey(ctx context.Context, gameID, token string) string {
|
func (s *GameOpenService) resolveCallbackAppKey(ctx context.Context, gameID, token string) string {
|
||||||
if s != nil && s.repo.DB != nil {
|
return s.resolveCallbackAppKeyForVendor(ctx, "LINGXIAN", gameID, token)
|
||||||
if sysOrigin := callbackTokenSysOrigin(token); sysOrigin != "" {
|
|
||||||
if appKey := s.resolveActiveLingxianAppKey(ctx, sysOrigin); appKey != "" {
|
|
||||||
return appKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if appKey := s.resolveLingxianAppKeyByGame(ctx, gameID); appKey != "" {
|
|
||||||
return appKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(s.cfg.GameOpen.AppKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *GameOpenService) resolveLingxianAppKeyByGame(ctx context.Context, gameID string) string {
|
func (s *GameOpenService) resolveLingxianAppKeyByGame(ctx context.Context, gameID string) string {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"chatapp3-golang/internal/integration"
|
"chatapp3-golang/internal/integration"
|
||||||
"chatapp3-golang/internal/model"
|
"chatapp3-golang/internal/model"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -20,6 +21,7 @@ type stubGateway struct {
|
|||||||
accountMap map[string]integration.UserProfile
|
accountMap map[string]integration.UserProfile
|
||||||
balance int64
|
balance int64
|
||||||
exists bool
|
exists bool
|
||||||
|
changeErr error
|
||||||
cmd integration.GoldReceiptCommand
|
cmd integration.GoldReceiptCommand
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,7 +74,7 @@ func (s *stubGateway) ExistsGoldEvent(context.Context, string) (bool, error) {
|
|||||||
|
|
||||||
func (s *stubGateway) ChangeGoldBalance(_ context.Context, cmd integration.GoldReceiptCommand) error {
|
func (s *stubGateway) ChangeGoldBalance(_ context.Context, cmd integration.GoldReceiptCommand) error {
|
||||||
s.cmd = cmd
|
s.cmd = cmd
|
||||||
return nil
|
return s.changeErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleQueryUser(t *testing.T) {
|
func TestHandleQueryUser(t *testing.T) {
|
||||||
@ -351,6 +353,97 @@ func TestCallbacksUseLingxianProviderConfigAppKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHotgameGetUserInfoUsesHotgameSign(t *testing.T) {
|
||||||
|
gateway := &stubGateway{
|
||||||
|
credential: integration.UserCredential{UserID: 1234567, SysOrigin: "LIKEI"},
|
||||||
|
profile: integration.UserProfile{
|
||||||
|
ID: 1234567,
|
||||||
|
Account: "1234567",
|
||||||
|
UserNickname: "tester",
|
||||||
|
UserAvatar: "https://example.com/avatar.png",
|
||||||
|
},
|
||||||
|
balance: 88,
|
||||||
|
}
|
||||||
|
service := NewGameOpenService(config.Config{
|
||||||
|
GameOpen: config.GameOpenConfig{AppKey: "hotgame-key"},
|
||||||
|
}, nil, gateway)
|
||||||
|
req := HotgameGetUserInfoRequest{
|
||||||
|
GameID: "1",
|
||||||
|
UID: "1234567",
|
||||||
|
Token: "token",
|
||||||
|
}
|
||||||
|
req.Sign = buildHotgameGetUserInfoSign(req, "hotgame-key")
|
||||||
|
|
||||||
|
resp := service.HandleHotgameGetUserInfo(context.Background(), req, "{}")
|
||||||
|
if resp.ErrorCode != 0 {
|
||||||
|
t.Fatalf("HandleHotgameGetUserInfo() error = %+v", resp)
|
||||||
|
}
|
||||||
|
data, ok := resp.Data.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("HandleHotgameGetUserInfo() data type = %T", resp.Data)
|
||||||
|
}
|
||||||
|
if got := data["vipLevel"]; got != 0 {
|
||||||
|
t.Fatalf("HandleHotgameGetUserInfo() vipLevel = %v, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHotgameUpdateBalanceAcceptsNumericRoundIDAndDuplicateReturns3001(t *testing.T) {
|
||||||
|
gateway := &stubGateway{
|
||||||
|
credential: integration.UserCredential{UserID: 1234567, SysOrigin: "LIKEI"},
|
||||||
|
profile: integration.UserProfile{ID: 1234567, Account: "1234567"},
|
||||||
|
balance: 99,
|
||||||
|
exists: true,
|
||||||
|
}
|
||||||
|
service := NewGameOpenService(config.Config{
|
||||||
|
GameOpen: config.GameOpenConfig{AppKey: "hotgame-key"},
|
||||||
|
}, nil, gateway)
|
||||||
|
req := HotgameUpdateBalanceRequest{
|
||||||
|
OrderID: "order-1",
|
||||||
|
GameID: "1",
|
||||||
|
RoundID: float64(3199),
|
||||||
|
UID: "1234567",
|
||||||
|
Coin: 15,
|
||||||
|
Type: 1,
|
||||||
|
Token: "token",
|
||||||
|
}
|
||||||
|
req.Sign = buildHotgameUpdateBalanceSign(req, "hotgame-key")
|
||||||
|
|
||||||
|
resp := service.HandleHotgameUpdateBalance(context.Background(), req, "{}")
|
||||||
|
if resp.ErrorCode != hotgameCodeDuplicateOrder {
|
||||||
|
t.Fatalf("HandleHotgameUpdateBalance() errorCode = %d, want %d", resp.ErrorCode, hotgameCodeDuplicateOrder)
|
||||||
|
}
|
||||||
|
if gateway.cmd.EventID != "" {
|
||||||
|
t.Fatalf("HandleHotgameUpdateBalance() should not change duplicate order, got %q", gateway.cmd.EventID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHotgameUpdateBalanceDeductFailureReturns2001(t *testing.T) {
|
||||||
|
gateway := &stubGateway{
|
||||||
|
credential: integration.UserCredential{UserID: 1234567, SysOrigin: "LIKEI"},
|
||||||
|
profile: integration.UserProfile{ID: 1234567, Account: "1234567"},
|
||||||
|
balance: 99,
|
||||||
|
changeErr: errors.New("insufficient_balance"),
|
||||||
|
}
|
||||||
|
service := NewGameOpenService(config.Config{
|
||||||
|
GameOpen: config.GameOpenConfig{AppKey: "hotgame-key"},
|
||||||
|
}, nil, gateway)
|
||||||
|
req := HotgameUpdateBalanceRequest{
|
||||||
|
OrderID: "order-1",
|
||||||
|
GameID: "1",
|
||||||
|
RoundID: float64(3199),
|
||||||
|
UID: "1234567",
|
||||||
|
Coin: 15,
|
||||||
|
Type: 1,
|
||||||
|
Token: "token",
|
||||||
|
}
|
||||||
|
req.Sign = buildHotgameUpdateBalanceSign(req, "hotgame-key")
|
||||||
|
|
||||||
|
resp := service.HandleHotgameUpdateBalance(context.Background(), req, "{}")
|
||||||
|
if resp.ErrorCode != hotgameCodeInsufficientBalance {
|
||||||
|
t.Fatalf("HandleHotgameUpdateBalance() errorCode = %d, want %d", resp.ErrorCode, hotgameCodeInsufficientBalance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newGameOpenTestDB(t *testing.T) *gorm.DB {
|
func newGameOpenTestDB(t *testing.T) *gorm.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||||
@ -359,6 +452,7 @@ func newGameOpenTestDB(t *testing.T) *gorm.DB {
|
|||||||
}
|
}
|
||||||
if err := db.AutoMigrate(
|
if err := db.AutoMigrate(
|
||||||
&model.LingxianProviderConfig{},
|
&model.LingxianProviderConfig{},
|
||||||
|
&model.HotgameProviderConfig{},
|
||||||
&model.SysGameListVendorExt{},
|
&model.SysGameListVendorExt{},
|
||||||
&model.GameOpenCallbackLog{},
|
&model.GameOpenCallbackLog{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|||||||
314
internal/service/hotgame/app_provider.go
Normal file
314
internal/service/hotgame/app_provider.go
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
440
internal/service/hotgame/catalog.go
Normal file
440
internal/service/hotgame/catalog.go
Normal file
@ -0,0 +1,440 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
116
internal/service/hotgame/catalog_test.go
Normal file
116
internal/service/hotgame/catalog_test.go
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
package hotgame
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/config"
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
"chatapp3-golang/internal/service/gameprovider"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSyncImportAndListRoomGames(t *testing.T) {
|
||||||
|
db := newHotgameTestDB(t)
|
||||||
|
service := NewService(config.Config{}, db, nil)
|
||||||
|
|
||||||
|
resp, err := service.SyncCatalog(context.Background(), SyncCatalogRequest{
|
||||||
|
SysOrigin: "LIKEI",
|
||||||
|
Profile: "PROD",
|
||||||
|
VendorGameIDs: []string{"1"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SyncCatalog() error = %v", err)
|
||||||
|
}
|
||||||
|
if resp.Inserted != 1 {
|
||||||
|
t.Fatalf("SyncCatalog() inserted = %d, want 1", resp.Inserted)
|
||||||
|
}
|
||||||
|
|
||||||
|
importResp, err := service.ImportCatalogGames(context.Background(), "LIKEI", "PROD", []string{"1"}, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ImportCatalogGames() error = %v", err)
|
||||||
|
}
|
||||||
|
if importResp.Imported != 1 {
|
||||||
|
t.Fatalf("ImportCatalogGames() imported = %d, want 1", importResp.Imported)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := NewAppProvider(service)
|
||||||
|
list, err := provider.ListRoomGames(context.Background(), gameprovider.AuthUser{UserID: 1234567, SysOrigin: "LIKEI"}, "room-1", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListRoomGames() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(list.Items) != 1 {
|
||||||
|
t.Fatalf("ListRoomGames() items = %d, want 1", len(list.Items))
|
||||||
|
}
|
||||||
|
item := list.Items[0]
|
||||||
|
if item.Provider != vendorType || item.ProviderGameID != "1" || item.GameID != "hg_1" {
|
||||||
|
t.Fatalf("ListRoomGames() item = %+v", item)
|
||||||
|
}
|
||||||
|
if item.LaunchParams["screenMode"] != "seven" {
|
||||||
|
t.Fatalf("ListRoomGames() screenMode = %v, want seven", item.LaunchParams["screenMode"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchGameReplacesHotgameURLParams(t *testing.T) {
|
||||||
|
db := newHotgameTestDB(t)
|
||||||
|
service := NewService(config.Config{}, db, nil)
|
||||||
|
if _, err := service.SyncAdminGames(context.Background(), SyncCatalogRequest{
|
||||||
|
SysOrigin: "LIKEI",
|
||||||
|
Profile: "PROD",
|
||||||
|
VendorGameIDs: []string{"1"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SyncAdminGames() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := NewAppProvider(service)
|
||||||
|
resp, err := provider.LaunchGame(
|
||||||
|
context.Background(),
|
||||||
|
gameprovider.AuthUser{UserID: 1234567, SysOrigin: "LIKEI", Token: "token=="},
|
||||||
|
gameprovider.LaunchRequest{RoomID: "room-1", GameID: "hg_1", Params: map[string]any{"lang": "tr-TR"}},
|
||||||
|
"127.0.0.1",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LaunchGame() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Entry.EntryURL, "uid=1234567") {
|
||||||
|
t.Fatalf("LaunchGame() url missing uid: %s", resp.Entry.EntryURL)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Entry.EntryURL, "token=token%3D%3D") {
|
||||||
|
t.Fatalf("LaunchGame() url missing escaped token: %s", resp.Entry.EntryURL)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Entry.EntryURL, "lang=tr") {
|
||||||
|
t.Fatalf("LaunchGame() url missing lang: %s", resp.Entry.EntryURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHotgameTestDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(
|
||||||
|
&model.HotgameProviderConfig{},
|
||||||
|
&model.HotgameGameCatalog{},
|
||||||
|
&model.SysGameListConfig{},
|
||||||
|
&model.SysGameListVendorExt{},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("migrate sqlite: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&model.HotgameProviderConfig{
|
||||||
|
ID: 1,
|
||||||
|
SysOrigin: "LIKEI",
|
||||||
|
Profile: "PROD",
|
||||||
|
Active: true,
|
||||||
|
AppKey: "hotgame-key",
|
||||||
|
CreateTime: time.Now(),
|
||||||
|
UpdateTime: time.Now(),
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create provider config: %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
203
internal/service/hotgame/config.go
Normal file
203
internal/service/hotgame/config.go
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
package hotgame
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/common"
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
"chatapp3-golang/internal/utils"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) defaultRuntimeConfig(sysOrigin string) runtimeConfig {
|
||||||
|
return runtimeConfig{
|
||||||
|
SysOrigin: normalizeSysOrigin(sysOrigin),
|
||||||
|
Profile: profileProd,
|
||||||
|
AppKey: strings.TrimSpace(s.cfg.Hotgame.AppKey),
|
||||||
|
CallbackBaseURL: strings.TrimSpace(s.cfg.Hotgame.CallbackBaseURL),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) activeProfile(ctx context.Context, sysOrigin string) (string, error) {
|
||||||
|
sysOrigin = normalizeSysOrigin(sysOrigin)
|
||||||
|
var row model.HotgameProviderConfig
|
||||||
|
err := s.db.WithContext(ctx).
|
||||||
|
Where("sys_origin = ? AND active = ?", sysOrigin, true).
|
||||||
|
Order("update_time DESC").
|
||||||
|
Limit(1).
|
||||||
|
First(&row).Error
|
||||||
|
if err == nil {
|
||||||
|
return normalizeProfile(row.Profile), nil
|
||||||
|
}
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return profileProd, nil
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) requestProfile(ctx context.Context, sysOrigin, profile string) (string, error) {
|
||||||
|
if strings.TrimSpace(profile) != "" {
|
||||||
|
return normalizeProfile(profile), nil
|
||||||
|
}
|
||||||
|
return s.activeProfile(ctx, sysOrigin)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolveRuntimeConfigForProfile(ctx context.Context, sysOrigin, profile string) (runtimeConfig, error) {
|
||||||
|
sysOrigin = normalizeSysOrigin(sysOrigin)
|
||||||
|
profile = normalizeProfile(profile)
|
||||||
|
base := s.defaultRuntimeConfig(sysOrigin)
|
||||||
|
base.Profile = profile
|
||||||
|
|
||||||
|
var row model.HotgameProviderConfig
|
||||||
|
err := s.db.WithContext(ctx).
|
||||||
|
Where("sys_origin = ? AND profile = ?", sysOrigin, profile).
|
||||||
|
Limit(1).
|
||||||
|
First(&row).Error
|
||||||
|
if err == nil {
|
||||||
|
return configToRuntime(base, row), nil
|
||||||
|
}
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return base, nil
|
||||||
|
}
|
||||||
|
return runtimeConfig{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) requireRuntimeConfigForProfile(ctx context.Context, sysOrigin, profile string) (runtimeConfig, error) {
|
||||||
|
cfg, err := s.resolveRuntimeConfigForProfile(ctx, sysOrigin, profile)
|
||||||
|
if err != nil {
|
||||||
|
return runtimeConfig{}, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.AppKey) == "" {
|
||||||
|
return runtimeConfig{}, common.NewAppError(http.StatusBadRequest, "hotgame_config_missing", "hotgame appKey is required")
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProviderConfig 读取某个系统、某个环境的热游配置,并附带当前启用环境。
|
||||||
|
func (s *Service) GetProviderConfig(ctx context.Context, sysOrigin, profile string) (*ProviderConfigItem, error) {
|
||||||
|
sysOrigin = normalizeSysOrigin(sysOrigin)
|
||||||
|
activeProfile, err := s.activeProfile(ctx, sysOrigin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
profile, err = s.requestProfile(ctx, sysOrigin, profile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
runtimeCfg, err := s.resolveRuntimeConfigForProfile(ctx, sysOrigin, profile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var row model.HotgameProviderConfig
|
||||||
|
_ = s.db.WithContext(ctx).
|
||||||
|
Where("sys_origin = ? AND profile = ?", sysOrigin, profile).
|
||||||
|
Limit(1).
|
||||||
|
First(&row).Error
|
||||||
|
|
||||||
|
return &ProviderConfigItem{
|
||||||
|
ID: row.ID,
|
||||||
|
SysOrigin: runtimeCfg.SysOrigin,
|
||||||
|
Profile: runtimeCfg.Profile,
|
||||||
|
ActiveProfile: activeProfile,
|
||||||
|
Active: runtimeCfg.Profile == activeProfile,
|
||||||
|
AppKey: runtimeCfg.AppKey,
|
||||||
|
CallbackBaseURL: runtimeCfg.CallbackBaseURL,
|
||||||
|
TestUID: runtimeCfg.TestUID,
|
||||||
|
TestToken: runtimeCfg.TestToken,
|
||||||
|
UpdateTime: formatTime(row.UpdateTime),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveProviderConfig 保存热游配置;首个配置自动设为当前启用环境。
|
||||||
|
func (s *Service) SaveProviderConfig(ctx context.Context, req SaveProviderConfigRequest) (*ProviderConfigItem, error) {
|
||||||
|
sysOrigin := normalizeSysOrigin(req.SysOrigin)
|
||||||
|
profile := normalizeProfile(req.Profile)
|
||||||
|
appKey := strings.TrimSpace(req.AppKey)
|
||||||
|
if appKey == "" {
|
||||||
|
return nil, common.NewAppError(http.StatusBadRequest, "app_key_required", "appKey is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := utils.NextID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
row := model.HotgameProviderConfig{
|
||||||
|
ID: id,
|
||||||
|
SysOrigin: sysOrigin,
|
||||||
|
Profile: profile,
|
||||||
|
AppKey: appKey,
|
||||||
|
CallbackBaseURL: strings.TrimSpace(req.CallbackBaseURL),
|
||||||
|
TestUID: strings.TrimSpace(req.TestUID),
|
||||||
|
TestToken: strings.TrimSpace(req.TestToken),
|
||||||
|
CreateTime: now,
|
||||||
|
UpdateTime: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeCount int64
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Model(&model.HotgameProviderConfig{}).
|
||||||
|
Where("sys_origin = ? AND active = ?", sysOrigin, true).
|
||||||
|
Count(&activeCount).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
row.Active = activeCount == 0
|
||||||
|
|
||||||
|
if err := s.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "sys_origin"}, {Name: "profile"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{
|
||||||
|
"app_key", "callback_base_url", "test_uid", "test_token", "update_time",
|
||||||
|
}),
|
||||||
|
}).Create(&row).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if row.Active {
|
||||||
|
s.clearJavaGameListCache(ctx)
|
||||||
|
}
|
||||||
|
return s.GetProviderConfig(ctx, sysOrigin, profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivateProviderProfile 切换 app 侧读取的热游环境,并清理 Java 老游戏列表缓存。
|
||||||
|
func (s *Service) ActivateProviderProfile(ctx context.Context, req ActivateProviderProfileRequest) (*ProviderConfigItem, error) {
|
||||||
|
sysOrigin := normalizeSysOrigin(req.SysOrigin)
|
||||||
|
profile := normalizeProfile(req.Profile)
|
||||||
|
var row model.HotgameProviderConfig
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Where("sys_origin = ? AND profile = ?", sysOrigin, profile).
|
||||||
|
Limit(1).
|
||||||
|
First(&row).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, common.NewAppError(http.StatusBadRequest, "profile_config_missing", "profile config is missing")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Model(&model.HotgameProviderConfig{}).
|
||||||
|
Where("sys_origin = ?", sysOrigin).
|
||||||
|
Updates(map[string]any{"active": false, "update_time": now}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Model(&model.HotgameProviderConfig{}).
|
||||||
|
Where("sys_origin = ? AND profile = ?", sysOrigin, profile).
|
||||||
|
Updates(map[string]any{"active": true, "update_time": now}).Error
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.clearJavaGameListCache(ctx)
|
||||||
|
return s.GetProviderConfig(ctx, sysOrigin, profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) clearJavaGameListCache(ctx context.Context) {
|
||||||
|
if s.cache == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.cache.Del(ctx, "GAME_LIST").Err()
|
||||||
|
}
|
||||||
235
internal/service/hotgame/types.go
Normal file
235
internal/service/hotgame/types.go
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
package hotgame
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/config"
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
"chatapp3-golang/internal/service/gameprovider"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
vendorType = "HOTGAME"
|
||||||
|
displayName = "热游"
|
||||||
|
profileProd = "PROD"
|
||||||
|
profileTest = "TEST"
|
||||||
|
launchModeH5 = "H5_REMOTE"
|
||||||
|
catalogEnabled = "ENABLED"
|
||||||
|
roomCategory = "CHAT_ROOM"
|
||||||
|
defaultLaunchLang = "en"
|
||||||
|
)
|
||||||
|
|
||||||
|
type dbHandle interface {
|
||||||
|
WithContext(ctx context.Context) *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service 负责热游后台配置、目录导入、app 列表和启动 URL 生成。
|
||||||
|
type Service struct {
|
||||||
|
cfg config.Config
|
||||||
|
db dbHandle
|
||||||
|
cache redis.Cmdable
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService 创建热游服务;db 为空时路由仍可安全跳过。
|
||||||
|
func NewService(cfg config.Config, db dbHandle, cache redis.Cmdable) *Service {
|
||||||
|
return &Service{cfg: cfg, db: db, cache: cache}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runtimeConfig 是某个系统、某个环境下实际用于启动和回调验签的配置快照。
|
||||||
|
type runtimeConfig struct {
|
||||||
|
SysOrigin string
|
||||||
|
Profile string
|
||||||
|
AppKey string
|
||||||
|
CallbackBaseURL string
|
||||||
|
TestUID string
|
||||||
|
TestToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderConfigItem 是后台热游配置页读取到的配置。
|
||||||
|
type ProviderConfigItem struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
SysOrigin string `json:"sysOrigin"`
|
||||||
|
Profile string `json:"profile"`
|
||||||
|
ActiveProfile string `json:"activeProfile"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
AppKey string `json:"appKey"`
|
||||||
|
CallbackBaseURL string `json:"callbackBaseUrl"`
|
||||||
|
TestUID string `json:"testUid"`
|
||||||
|
TestToken string `json:"testToken"`
|
||||||
|
UpdateTime string `json:"updateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveProviderConfigRequest 是后台保存热游接入配置的入参。
|
||||||
|
type SaveProviderConfigRequest struct {
|
||||||
|
SysOrigin string `json:"sysOrigin"`
|
||||||
|
Profile string `json:"profile"`
|
||||||
|
AppKey string `json:"appKey"`
|
||||||
|
CallbackBaseURL string `json:"callbackBaseUrl"`
|
||||||
|
TestUID string `json:"testUid"`
|
||||||
|
TestToken string `json:"testToken"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivateProviderProfileRequest 是后台切换当前启用热游环境的入参。
|
||||||
|
type ActivateProviderProfileRequest struct {
|
||||||
|
SysOrigin string `json:"sysOrigin"`
|
||||||
|
Profile string `json:"profile"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncCatalogRequest 是后台同步或导入热游目录的入参。
|
||||||
|
type SyncCatalogRequest struct {
|
||||||
|
SysOrigin string `json:"sysOrigin"`
|
||||||
|
Profile string `json:"profile"`
|
||||||
|
VendorGameIDs []string `json:"vendorGameIds"`
|
||||||
|
Force bool `json:"force"`
|
||||||
|
DefaultShowcase *bool `json:"defaultShowcase"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncCatalogResponse 是热游目录同步结果。
|
||||||
|
type SyncCatalogResponse struct {
|
||||||
|
Inserted int `json:"inserted"`
|
||||||
|
Updated int `json:"updated"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportCatalogResponse 是热游目录导入统一游戏列表的结果。
|
||||||
|
type ImportCatalogResponse struct {
|
||||||
|
Existing int `json:"existing"`
|
||||||
|
Imported int `json:"imported"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncAdminCatalogResponse 是“同步目录并导入游戏”的合并结果。
|
||||||
|
type SyncAdminCatalogResponse struct {
|
||||||
|
Inserted int `json:"inserted"`
|
||||||
|
Updated int `json:"updated"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Existing int `json:"existing"`
|
||||||
|
Imported int `json:"imported"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatalogItem 是后台热游目录表格行。
|
||||||
|
type CatalogItem struct {
|
||||||
|
VendorGameID string `json:"vendorGameId"`
|
||||||
|
Profile string `json:"profile"`
|
||||||
|
GameID string `json:"gameId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Cover string `json:"cover"`
|
||||||
|
PreviewURL string `json:"previewUrl"`
|
||||||
|
DownloadURL string `json:"downloadUrl"`
|
||||||
|
PackageVersion string `json:"packageVersion"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Added bool `json:"added"`
|
||||||
|
Showcase bool `json:"showcase"`
|
||||||
|
UpdateTime string `json:"updateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatalogPageResponse 是热游目录分页响应。
|
||||||
|
type CatalogPageResponse struct {
|
||||||
|
Cursor int `json:"cursor"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Records []CatalogItem `json:"records"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type defaultCatalogGame struct {
|
||||||
|
GameID string
|
||||||
|
Name string
|
||||||
|
TestURL string
|
||||||
|
ProdURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type gameRow struct {
|
||||||
|
ConfigID int64
|
||||||
|
SysOrigin string
|
||||||
|
Profile string
|
||||||
|
InternalGameID string
|
||||||
|
Name string
|
||||||
|
Category string
|
||||||
|
Cover string
|
||||||
|
Sort int64
|
||||||
|
FullScreen bool
|
||||||
|
VendorGameID string
|
||||||
|
LaunchMode string
|
||||||
|
ExtPackageVersion string
|
||||||
|
ExtPreviewURL string
|
||||||
|
PackageURL string
|
||||||
|
ExtOrientation *int
|
||||||
|
ExtSafeHeight int
|
||||||
|
ExtExtraJSON string
|
||||||
|
CatalogName string
|
||||||
|
CatalogCover string
|
||||||
|
CatalogPreviewURL string
|
||||||
|
CatalogDownloadURL string
|
||||||
|
CatalogPackageVersion string
|
||||||
|
CatalogStatus string
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ gameprovider.Provider = (*AppProvider)(nil)
|
||||||
|
|
||||||
|
// AppProvider 把热游服务适配到统一游戏厂商注册表。
|
||||||
|
type AppProvider struct {
|
||||||
|
service *Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAppProvider 创建热游 app 侧 provider。
|
||||||
|
func NewAppProvider(service *Service) *AppProvider {
|
||||||
|
if service == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &AppProvider{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSysOrigin(sysOrigin string) string {
|
||||||
|
sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin))
|
||||||
|
if sysOrigin == "" {
|
||||||
|
return "LIKEI"
|
||||||
|
}
|
||||||
|
return sysOrigin
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeProfile(profile string) string {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(profile)) {
|
||||||
|
case "", "PROD", "PRODUCT", "PRODUCTION", "ONLINE", "OFFICIAL", "FORMAL":
|
||||||
|
return profileProd
|
||||||
|
case "TEST", "TESTING", "SANDBOX", "DEV":
|
||||||
|
return profileTest
|
||||||
|
default:
|
||||||
|
return strings.ToUpper(strings.TrimSpace(profile))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultIfBlank(value, fallback string) string {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTime(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
|
func configToRuntime(base runtimeConfig, row model.HotgameProviderConfig) runtimeConfig {
|
||||||
|
base.SysOrigin = normalizeSysOrigin(defaultIfBlank(row.SysOrigin, base.SysOrigin))
|
||||||
|
base.Profile = normalizeProfile(defaultIfBlank(row.Profile, base.Profile))
|
||||||
|
base.AppKey = defaultIfBlank(row.AppKey, base.AppKey)
|
||||||
|
base.CallbackBaseURL = defaultIfBlank(row.CallbackBaseURL, base.CallbackBaseURL)
|
||||||
|
base.TestUID = strings.TrimSpace(row.TestUID)
|
||||||
|
base.TestToken = strings.TrimSpace(row.TestToken)
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
func internalGameID(vendorGameID string) string {
|
||||||
|
vendorGameID = strings.TrimSpace(vendorGameID)
|
||||||
|
if vendorGameID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "hg_" + vendorGameID
|
||||||
|
}
|
||||||
106
migrations/052_hotgame_provider_config.sql
Normal file
106
migrations/052_hotgame_provider_config.sql
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS hotgame_provider_config (
|
||||||
|
id BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
sys_origin VARCHAR(32) NOT NULL,
|
||||||
|
profile VARCHAR(32) NOT NULL DEFAULT 'PROD',
|
||||||
|
active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
app_key VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
callback_base_url VARCHAR(1024) NOT NULL DEFAULT '',
|
||||||
|
test_uid VARCHAR(64) DEFAULT NULL,
|
||||||
|
test_token VARCHAR(1024) DEFAULT NULL,
|
||||||
|
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_hotgame_provider_sys_origin_profile (sys_origin, profile),
|
||||||
|
KEY idx_hotgame_provider_active (sys_origin, active, update_time)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS hotgame_game_catalog (
|
||||||
|
id BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
sys_origin VARCHAR(32) NOT NULL,
|
||||||
|
profile VARCHAR(32) NOT NULL DEFAULT 'PROD',
|
||||||
|
internal_game_id VARCHAR(64) NOT NULL,
|
||||||
|
vendor_game_id VARCHAR(64) NOT NULL,
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
cover VARCHAR(1024) DEFAULT NULL,
|
||||||
|
preview_url VARCHAR(1024) DEFAULT NULL,
|
||||||
|
download_url VARCHAR(1024) DEFAULT NULL,
|
||||||
|
package_version VARCHAR(32) DEFAULT NULL,
|
||||||
|
raw_json LONGTEXT DEFAULT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'ENABLED',
|
||||||
|
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_hotgame_catalog_sys_profile_vendor_game (sys_origin, profile, vendor_game_id),
|
||||||
|
KEY idx_hotgame_catalog_profile_internal_game (sys_origin, profile, internal_game_id, status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
INSERT INTO hotgame_provider_config (
|
||||||
|
id, sys_origin, profile, active, app_key, callback_base_url, create_time, update_time
|
||||||
|
) VALUES
|
||||||
|
(202606100520001, 'LIKEI', 'PROD', 1, 'laluprodappkey', 'https://api.global-interaction.com/', NOW(), NOW()),
|
||||||
|
(202606100520002, 'LIKEI', 'TEST', 0, 'lalutestappkey', 'https://api-test.global-interaction.com/', NOW(), NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
app_key = IF(TRIM(app_key) = '', VALUES(app_key), app_key),
|
||||||
|
callback_base_url = IF(TRIM(callback_base_url) = '', VALUES(callback_base_url), callback_base_url),
|
||||||
|
update_time = NOW();
|
||||||
|
|
||||||
|
INSERT INTO hotgame_game_catalog (
|
||||||
|
id, sys_origin, profile, internal_game_id, vendor_game_id, name, cover,
|
||||||
|
preview_url, download_url, package_version, raw_json, status, create_time, update_time
|
||||||
|
) VALUES
|
||||||
|
(202606100520101, 'LIKEI', 'TEST', 'hg_1', '1', '幸运77(Lucky77)', '',
|
||||||
|
'https://hk.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}',
|
||||||
|
'https://hk.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}',
|
||||||
|
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
||||||
|
(202606100520102, 'LIKEI', 'TEST', 'hg_13', '13', '海盗捕鱼(PirateFishing)', '',
|
||||||
|
'https://hk.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||||
|
'https://hk.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||||
|
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
||||||
|
(202606100520103, 'LIKEI', 'TEST', 'hg_19', '19', 'FortuneSlot (宝石solt)', '',
|
||||||
|
'https://hk.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||||
|
'https://hk.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||||
|
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
||||||
|
(202606100520104, 'LIKEI', 'TEST', 'hg_15', '15', '美味摩天轮(Delicious)', '',
|
||||||
|
'https://hk.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||||
|
'https://hk.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||||
|
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
||||||
|
(202606100520105, 'LIKEI', 'TEST', 'hg_20', '20', '水果派对(FruitParty)', '',
|
||||||
|
'https://hk.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||||
|
'https://hk.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||||
|
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
||||||
|
(202606100520106, 'LIKEI', 'TEST', 'hg_16', '16', '太空摩天轮(GreedyStar)', '',
|
||||||
|
'https://hk.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||||
|
'https://hk.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||||
|
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
||||||
|
(202606100520201, 'LIKEI', 'PROD', 'hg_1', '1', '幸运77(Lucky77)', '',
|
||||||
|
'https://cdn-res.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}',
|
||||||
|
'https://cdn-res.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}',
|
||||||
|
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
||||||
|
(202606100520202, 'LIKEI', 'PROD', 'hg_13', '13', '海盗捕鱼(PirateFishing)', '',
|
||||||
|
'https://cdn-res.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||||
|
'https://cdn-res.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||||
|
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
||||||
|
(202606100520203, 'LIKEI', 'PROD', 'hg_19', '19', 'FortuneSlot (宝石solt)', '',
|
||||||
|
'https://cdn-res.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||||
|
'https://cdn-res.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||||
|
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
||||||
|
(202606100520204, 'LIKEI', 'PROD', 'hg_15', '15', '美味摩天轮(Delicious)', '',
|
||||||
|
'https://cdn-res.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||||
|
'https://cdn-res.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||||
|
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
||||||
|
(202606100520205, 'LIKEI', 'PROD', 'hg_20', '20', '水果派对(FruitParty)', '',
|
||||||
|
'https://cdn-res.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||||
|
'https://cdn-res.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||||
|
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
||||||
|
(202606100520206, 'LIKEI', 'PROD', 'hg_16', '16', '太空摩天轮(GreedyStar)', '',
|
||||||
|
'https://cdn-res.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||||
|
'https://cdn-res.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||||
|
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
internal_game_id = VALUES(internal_game_id),
|
||||||
|
name = VALUES(name),
|
||||||
|
cover = VALUES(cover),
|
||||||
|
preview_url = VALUES(preview_url),
|
||||||
|
download_url = VALUES(download_url),
|
||||||
|
package_version = VALUES(package_version),
|
||||||
|
raw_json = VALUES(raw_json),
|
||||||
|
status = VALUES(status),
|
||||||
|
update_time = NOW();
|
||||||
Loading…
x
Reference in New Issue
Block a user