diff --git a/cmd/api/main.go b/cmd/api/main.go index 0ce36bb..94f8fed 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -21,6 +21,7 @@ import ( "chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/hostcenter" + "chatapp3-golang/internal/service/hotgame" "chatapp3-golang/internal/service/invite" "chatapp3-golang/internal/service/lingxian" "chatapp3-golang/internal/service/luckygift" @@ -57,6 +58,7 @@ func main() { baishunService := baishun.NewBaishunService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) binanceRechargeService := binancerecharge.NewService(app.Config, app.Repository.DB, &app.Gateways) 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) luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet) rechargeAgencyService := rechargeagency.NewService(app.Config, app.Repository.DB, &app.Gateways) @@ -115,6 +117,7 @@ func main() { gameProviders := gameprovider.NewRegistry( baishun.NewAppProvider(baishunService), lingxian.NewAppProvider(lingxianService), + hotgame.NewAppProvider(hotgameService), ) gameVisibility := gameprovider.NewVisibilityPolicy( &app.Gateways, @@ -135,6 +138,7 @@ func main() { ManagerCenter: managerCenterService, PropsStore: propsStoreService, HostCenter: hostCenterService, + Hotgame: hotgameService, RechargeAgency: rechargeAgencyService, RechargeReward: rechargeRewardService, RegionIMGroup: regionIMGroupService, diff --git a/internal/config/config.go b/internal/config/config.go index faf3682..98a0949 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -32,6 +32,7 @@ type Config struct { TencentIM TencentIMConfig Baishun BaishunConfig Lingxian LingxianConfig + Hotgame HotgameConfig GameOpen GameOpenConfig LuckyGift LuckyGiftConfig BinanceRecharge BinanceRechargeConfig @@ -268,6 +269,12 @@ type LingxianConfig struct { AppKey string } +// HotgameConfig 保存热游模块的环境兜底配置。 +type HotgameConfig struct { + AppKey string + CallbackBaseURL string +} + // GameOpenConfig 保存统一三方游戏回调配置。 type GameOpenConfig struct { 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"), 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{ 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"), diff --git a/internal/model/hotgame_models.go b/internal/model/hotgame_models.go new file mode 100644 index 0000000..60978b1 --- /dev/null +++ b/internal/model/hotgame_models.go @@ -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" } diff --git a/internal/router/game_open_routes.go b/internal/router/game_open_routes.go index 5de676f..dc7321e 100644 --- a/internal/router/game_open_routes.go +++ b/internal/router/game_open_routes.go @@ -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))) }) + registerHotgameCallbackRoutes(engine, service) + internalGroup := engine.Group("/internal/game/open") internalGroup.Use(internalSecretMiddleware(cfg.HTTP.InternalCallbackSecret)) 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 { if value := strings.TrimRight(strings.TrimSpace(cfg.GameOpen.PublicBaseURL), "/"); value != "" { return value diff --git a/internal/router/hotgame_routes.go b/internal/router/hotgame_routes.go new file mode 100644 index 0000000..b4414b8 --- /dev/null +++ b/internal/router/hotgame_routes.go @@ -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) + }) +} diff --git a/internal/router/router.go b/internal/router/router.go index 80d8d28..7695e11 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -14,6 +14,7 @@ import ( "chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/hostcenter" + "chatapp3-golang/internal/service/hotgame" "chatapp3-golang/internal/service/invite" "chatapp3-golang/internal/service/lingxian" "chatapp3-golang/internal/service/luckygift" @@ -53,6 +54,7 @@ type Services struct { ManagerCenter *managercenter.Service PropsStore *propsstore.Service HostCenter *hostcenter.Service + Hotgame *hotgame.Service RechargeAgency *rechargeagency.Service RechargeReward *rechargereward.Service RegionIMGroup *regionimgroup.Service @@ -104,6 +106,7 @@ func NewRouter( registerGameProviderRoutes(engine, javaClient, services.GameProviders, services.GameVisibility) registerBaishunRoutes(engine, cfg, javaClient, services.Baishun) registerLingxianRoutes(engine, javaClient, services.Lingxian) + registerHotgameRoutes(engine, javaClient, services.Hotgame) registerLuckyGiftRoutes(engine, cfg, services.LuckyGift) registerManagerCenterRoutes(engine, javaClient, services.ManagerCenter) registerPropsStoreRoutes(engine, javaClient, services.PropsStore) diff --git a/internal/service/gameopen/hotgame.go b/internal/service/gameopen/hotgame.go new file mode 100644 index 0000000..5d63b2c --- /dev/null +++ b/internal/service/gameopen/hotgame.go @@ -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) + } +} diff --git a/internal/service/gameopen/service.go b/internal/service/gameopen/service.go index 9b09743..1d02d99 100644 --- a/internal/service/gameopen/service.go +++ b/internal/service/gameopen/service.go @@ -219,20 +219,7 @@ func (s *GameOpenService) GetIntegrationInfo(ctx context.Context, publicBaseURL } func (s *GameOpenService) resolveCallbackAppKey(ctx context.Context, gameID, token string) string { - if s != nil && s.repo.DB != nil { - 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) + return s.resolveCallbackAppKeyForVendor(ctx, "LINGXIAN", gameID, token) } func (s *GameOpenService) resolveLingxianAppKeyByGame(ctx context.Context, gameID string) string { diff --git a/internal/service/gameopen/service_test.go b/internal/service/gameopen/service_test.go index 6553a97..2ec379d 100644 --- a/internal/service/gameopen/service_test.go +++ b/internal/service/gameopen/service_test.go @@ -5,6 +5,7 @@ import ( "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" "context" + "errors" "testing" "time" @@ -20,6 +21,7 @@ type stubGateway struct { accountMap map[string]integration.UserProfile balance int64 exists bool + changeErr error 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 { s.cmd = cmd - return nil + return s.changeErr } 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 { t.Helper() 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( &model.LingxianProviderConfig{}, + &model.HotgameProviderConfig{}, &model.SysGameListVendorExt{}, &model.GameOpenCallbackLog{}, ); err != nil { diff --git a/internal/service/hotgame/app_provider.go b/internal/service/hotgame/app_provider.go new file mode 100644 index 0000000..2bcac78 --- /dev/null +++ b/internal/service/hotgame/app_provider.go @@ -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 != "" { + 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 + } +} diff --git a/internal/service/hotgame/catalog.go b/internal/service/hotgame/catalog.go new file mode 100644 index 0000000..d1ec152 --- /dev/null +++ b/internal/service/hotgame/catalog.go @@ -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) +} diff --git a/internal/service/hotgame/catalog_test.go b/internal/service/hotgame/catalog_test.go new file mode 100644 index 0000000..a2d20a6 --- /dev/null +++ b/internal/service/hotgame/catalog_test.go @@ -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 +} diff --git a/internal/service/hotgame/config.go b/internal/service/hotgame/config.go new file mode 100644 index 0000000..e66e724 --- /dev/null +++ b/internal/service/hotgame/config.go @@ -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() +} diff --git a/internal/service/hotgame/types.go b/internal/service/hotgame/types.go new file mode 100644 index 0000000..3eef262 --- /dev/null +++ b/internal/service/hotgame/types.go @@ -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 +} diff --git a/migrations/052_hotgame_provider_config.sql b/migrations/052_hotgame_provider_config.sql new file mode 100644 index 0000000..5382a79 --- /dev/null +++ b/migrations/052_hotgame_provider_config.sql @@ -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();