From 6e379d62aca9ac38e7bca2e5404288c2a94a8354 Mon Sep 17 00:00:00 2001 From: hy001 Date: Wed, 22 Apr 2026 19:57:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=B8=B8=E6=88=8F=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/api/main.go | 8 + internal/config/config.go | 31 +- internal/integration/gateways.go | 10 + internal/integration/java.go | 13 + internal/model/baishun_models.go | 19 ++ internal/model/game_open_models.go | 25 ++ internal/router/baishun_routes.go | 66 +++- internal/router/game_open_routes.go | 79 +++++ internal/router/game_provider_routes.go | 195 +++++++++++ internal/router/router.go | 8 +- internal/service/baishun/admin.go | 42 ++- internal/service/baishun/app_provider.go | 167 ++++++++++ internal/service/baishun/callback.go | 49 +-- internal/service/baishun/catalog.go | 71 +++- internal/service/baishun/catalog_admin.go | 136 ++++++++ internal/service/baishun/extra_json.go | 127 ++++++++ internal/service/baishun/extra_json_test.go | 69 ++++ internal/service/baishun/game_row.go | 12 + internal/service/baishun/launch.go | 28 +- internal/service/baishun/provider_config.go | 230 +++++++++++++ internal/service/baishun/room.go | 139 ++++---- internal/service/baishun/types.go | 197 +++++++---- internal/service/gameopen/aliases.go | 7 + internal/service/gameopen/convert.go | 58 ++++ internal/service/gameopen/service.go | 306 ++++++++++++++++++ internal/service/gameopen/service_test.go | 133 ++++++++ internal/service/gameopen/types.go | 126 ++++++++ internal/service/gameprovider/registry.go | 229 +++++++++++++ .../service/gameprovider/registry_test.go | 161 +++++++++ internal/service/gameprovider/types.go | 112 +++++++ internal/service/luckygift/draw.go | 7 +- internal/service/luckygift/reward_event.go | 96 ++++++ .../service/luckygift/reward_event_test.go | 109 +++++++ migrations/007_baishun_provider_config.sql | 16 + migrations/008_game_open.sql | 20 ++ 35 files changed, 2885 insertions(+), 216 deletions(-) create mode 100644 internal/model/game_open_models.go create mode 100644 internal/router/game_open_routes.go create mode 100644 internal/router/game_provider_routes.go create mode 100644 internal/service/baishun/app_provider.go create mode 100644 internal/service/baishun/catalog_admin.go create mode 100644 internal/service/baishun/extra_json.go create mode 100644 internal/service/baishun/extra_json_test.go create mode 100644 internal/service/baishun/provider_config.go create mode 100644 internal/service/gameopen/aliases.go create mode 100644 internal/service/gameopen/convert.go create mode 100644 internal/service/gameopen/service.go create mode 100644 internal/service/gameopen/service_test.go create mode 100644 internal/service/gameopen/types.go create mode 100644 internal/service/gameprovider/registry.go create mode 100644 internal/service/gameprovider/registry_test.go create mode 100644 internal/service/gameprovider/types.go create mode 100644 internal/service/luckygift/reward_event.go create mode 100644 internal/service/luckygift/reward_event_test.go create mode 100644 migrations/007_baishun_provider_config.sql create mode 100644 migrations/008_game_open.sql diff --git a/cmd/api/main.go b/cmd/api/main.go index e6903d3..47bb5bb 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -4,6 +4,8 @@ import ( "chatapp3-golang/internal/bootstrap" "chatapp3-golang/internal/router" "chatapp3-golang/internal/service/baishun" + "chatapp3-golang/internal/service/gameopen" + "chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/invite" "chatapp3-golang/internal/service/luckygift" "chatapp3-golang/internal/service/registerreward" @@ -30,13 +32,19 @@ func main() { inviteService := invite.NewInviteService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) baishunService := baishun.NewBaishunService(app.Config, app.Repository.DB, app.Repository.Redis, &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) registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) + gameProviders := gameprovider.NewRegistry( + baishun.NewAppProvider(baishunService), + ) engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{ Invite: inviteService, Baishun: baishunService, + GameOpen: gameOpenService, + GameProviders: gameProviders, LuckyGift: luckyGiftService, RegisterReward: registerRewardService, SignInReward: signInRewardService, diff --git a/internal/config/config.go b/internal/config/config.go index 795eec9..3c650b7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ type Config struct { Invite InviteConfig WeekStar WeekStarConfig Baishun BaishunConfig + GameOpen GameOpenConfig LuckyGift LuckyGiftConfig } @@ -101,11 +102,21 @@ type BaishunConfig struct { SSTokenTTLSeconds int } +// GameOpenConfig 保存统一三方游戏回调配置。 +type GameOpenConfig struct { + PublicBaseURL string + AppKey string + TestUserID int64 + TestAccount string + TestPassword string +} + // LuckyGiftConfig 保存幸运礼物模块配置。 type LuckyGiftConfig struct { - Enabled bool - Timeout time.Duration - Providers []LuckyGiftProviderConfig + Enabled bool + Timeout time.Duration + RewardStreamKey string + Providers []LuckyGiftProviderConfig } type LuckyGiftProviderConfig struct { @@ -182,10 +193,18 @@ func Load() Config { LaunchCodeTTLSeconds: getEnvIntAny([]string{"CHATAPP_BAISHUN_LAUNCH_CODE_TTL_SECONDS", "BAISHUN_LAUNCH_CODE_TTL_SECONDS"}, 300), SSTokenTTLSeconds: getEnvIntAny([]string{"CHATAPP_BAISHUN_SS_TOKEN_TTL_SECONDS", "BAISHUN_SS_TOKEN_TTL_SECONDS"}, 86400), }, + 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"), + TestUserID: getEnvInt64Any([]string{"CHATAPP_GAME_OPEN_TEST_USER_ID", "GAME_OPEN_TEST_USER_ID"}, 1234567), + TestAccount: getEnvAny([]string{"CHATAPP_GAME_OPEN_TEST_ACCOUNT", "GAME_OPEN_TEST_ACCOUNT"}, "1234567"), + TestPassword: getEnvAny([]string{"CHATAPP_GAME_OPEN_TEST_PASSWORD", "GAME_OPEN_TEST_PASSWORD"}, "1234567"), + }, LuckyGift: LuckyGiftConfig{ - Enabled: getEnvBoolAny([]string{"CHATAPP_LUCKY_GIFT_ENABLED", "LUCKY_GIFT_ENABLED", "LUCKY_GIFT_GO_ENABLED"}, false), - Timeout: time.Duration(getEnvIntAny([]string{"CHATAPP_LUCKY_GIFT_TIMEOUT_MILLIS", "LUCKY_GIFT_TIMEOUT_MILLIS", "LUCKY_GIFT_GO_TIMEOUT_MILLIS"}, 8000)) * time.Millisecond, - Providers: loadLuckyGiftConfigs(), + Enabled: getEnvBoolAny([]string{"CHATAPP_LUCKY_GIFT_ENABLED", "LUCKY_GIFT_ENABLED", "LUCKY_GIFT_GO_ENABLED"}, false), + Timeout: time.Duration(getEnvIntAny([]string{"CHATAPP_LUCKY_GIFT_TIMEOUT_MILLIS", "LUCKY_GIFT_TIMEOUT_MILLIS", "LUCKY_GIFT_GO_TIMEOUT_MILLIS"}, 8000)) * time.Millisecond, + RewardStreamKey: getEnvAny([]string{"CHATAPP_LUCKY_GIFT_REWARD_STREAM_KEY", "LUCKY_GIFT_REWARD_STREAM_KEY"}, "lucky-gift:reward"), + Providers: loadLuckyGiftConfigs(), }, } } diff --git a/internal/integration/gateways.go b/internal/integration/gateways.go index 56f4e3a..f09225b 100644 --- a/internal/integration/gateways.go +++ b/internal/integration/gateways.go @@ -38,6 +38,11 @@ func (g *Gateways) AuthenticateToken(ctx context.Context, token string) (UserCre return g.Auth.AuthenticateToken(ctx, token) } +// CreateIfAbsentUserCredentialToken 透传到认证网关。 +func (g *Gateways) CreateIfAbsentUserCredentialToken(ctx context.Context, userID int64) (string, error) { + return g.Auth.CreateIfAbsentUserCredentialToken(ctx, userID) +} + // AuthenticateConsoleToken 透传到后台认证网关。 func (g *Gateways) AuthenticateConsoleToken(ctx context.Context, authorization string) (ConsoleAccount, error) { return g.Auth.AuthenticateConsoleToken(ctx, authorization) @@ -113,6 +118,11 @@ func (g *AuthGateway) AuthenticateToken(ctx context.Context, token string) (User return g.client.AuthenticateToken(ctx, token) } +// CreateIfAbsentUserCredentialToken 创建或返回用户 token。 +func (g *AuthGateway) CreateIfAbsentUserCredentialToken(ctx context.Context, userID int64) (string, error) { + return g.client.CreateIfAbsentUserCredentialToken(ctx, userID) +} + // AuthenticateConsoleToken 校验后台 console token。 func (g *AuthGateway) AuthenticateConsoleToken(ctx context.Context, authorization string) (ConsoleAccount, error) { return g.client.AuthenticateConsoleToken(ctx, authorization) diff --git a/internal/integration/java.go b/internal/integration/java.go index 19f54cb..a910580 100644 --- a/internal/integration/java.go +++ b/internal/integration/java.go @@ -192,6 +192,19 @@ func (c *Client) AuthenticateToken(ctx context.Context, token string) (UserCrede return tokenCredential, nil } +func (c *Client) CreateIfAbsentUserCredentialToken(ctx context.Context, userID int64) (string, error) { + endpoint := c.cfg.Java.AuthBaseURL + "/auth/client/createIfAbsentUserCredentialToken?userId=" + + url.QueryEscape(strconv.FormatInt(userID, 10)) + var resp resultResponse[string] + if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { + return "", err + } + if strings.TrimSpace(resp.Body) == "" { + return "", fmt.Errorf("empty token response") + } + return strings.TrimSpace(resp.Body), nil +} + // AuthenticateConsoleToken 校验后台 console Bearer token,并返回管理员基础信息。 func (c *Client) AuthenticateConsoleToken(ctx context.Context, authorization string) (ConsoleAccount, error) { authorization = strings.TrimSpace(authorization) diff --git a/internal/model/baishun_models.go b/internal/model/baishun_models.go index 05e032a..35084fc 100644 --- a/internal/model/baishun_models.go +++ b/internal/model/baishun_models.go @@ -52,6 +52,25 @@ type SysGameListVendorExt struct { // TableName 返回游戏厂商扩展表名。 func (SysGameListVendorExt) TableName() string { return "sys_game_list_vendor_ext" } +// BaishunProviderConfig 保存每个系统的百顺接入配置。 +type BaishunProviderConfig struct { + ID int64 `gorm:"column:id;primaryKey"` // 主键 + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_baishun_provider_sys_origin"` // 系统标识 + PlatformBaseURL string `gorm:"column:platform_base_url;size:1024"` // 百顺平台基础地址 + AppID int64 `gorm:"column:app_id;index:idx_baishun_provider_app,priority:1"` // 百顺应用 ID + AppName string `gorm:"column:app_name;size:128"` // 百顺应用名 + AppChannel string `gorm:"column:app_channel;size:64;index:idx_baishun_provider_app,priority:2"` // 百顺渠道 + AppKey string `gorm:"column:app_key;size:255"` // 百顺密钥 + GSP int `gorm:"column:gsp"` // 默认 GSP + LaunchCodeTTLSeconds int `gorm:"column:launch_code_ttl_seconds"` // 启动码过期秒数 + SSTokenTTLSeconds int `gorm:"column:ss_token_ttl_seconds"` // ss_token 过期秒数 + CreateTime time.Time `gorm:"column:create_time"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 +} + +// TableName 返回百顺配置表名。 +func (BaishunProviderConfig) TableName() string { return "baishun_provider_config" } + // BaishunGameCatalog 是百顺平台同步到本地的游戏目录。 type BaishunGameCatalog struct { ID int64 `gorm:"column:id;primaryKey"` // 主键 diff --git a/internal/model/game_open_models.go b/internal/model/game_open_models.go new file mode 100644 index 0000000..eac0bb4 --- /dev/null +++ b/internal/model/game_open_models.go @@ -0,0 +1,25 @@ +package model + +import "time" + +// GameOpenCallbackLog 保存统一三方游戏回调日志。 +type GameOpenCallbackLog struct { + ID int64 `gorm:"column:id;primaryKey"` // 主键 + RequestType string `gorm:"column:request_type;size:32;index:idx_game_open_log_type_time,priority:1"` // 回调类型 + SysOrigin string `gorm:"column:sys_origin;size:32"` // 系统标识 + OrderID string `gorm:"column:order_id;size:128;index:idx_game_open_log_order"` // 订单号 + GameID string `gorm:"column:game_id;size:64"` // 游戏 ID + RoundID string `gorm:"column:round_id;size:128"` // 局号 + RoomID string `gorm:"column:room_id;size:64"` // 房间 ID + UserID *int64 `gorm:"column:user_id;index:idx_game_open_log_user_time,priority:1"` // 用户 ID + RequestJSON string `gorm:"column:request_json;type:longtext"` // 请求报文 + ResponseJSON string `gorm:"column:response_json;type:longtext"` // 响应报文 + BizCode int `gorm:"column:biz_code"` // 业务状态码 + BizMessage string `gorm:"column:biz_message;type:text"` // 业务说明 + Status string `gorm:"column:status;size:32"` // 处理状态 + CreateTime time.Time `gorm:"column:create_time;index:idx_game_open_log_type_time,priority:2;index:idx_game_open_log_user_time,priority:2"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 +} + +// TableName 返回统一游戏开放接口日志表名。 +func (GameOpenCallbackLog) TableName() string { return "game_open_callback_log" } diff --git a/internal/router/baishun_routes.go b/internal/router/baishun_routes.go index cf6a91a..5db5c38 100644 --- a/internal/router/baishun_routes.go +++ b/internal/router/baishun_routes.go @@ -26,22 +26,6 @@ func registerBaishunRoutes( appGroup := engine.Group("/app/game") appGroup.Use(authMiddleware(javaClient)) - appGroup.GET("/room/shortcut", func(c *gin.Context) { - resp, err := baishunService.ListShortcutGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId")) - if err != nil { - writeError(c, err) - return - } - writeOK(c, resp) - }) - appGroup.GET("/room/list", func(c *gin.Context) { - resp, err := baishunService.ListRoomGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"), c.Query("category")) - if err != nil { - writeError(c, err) - return - } - writeOK(c, resp) - }) appGroup.GET("/baishun/state", func(c *gin.Context) { resp, err := baishunService.GetRoomState(c.Request.Context(), mustAuthUser(c), c.Query("roomId")) if err != nil { @@ -111,6 +95,27 @@ func registerBaishunRoutes( consoleGroup := engine.Group("/operate/baishun-game") consoleGroup.Use(consoleAuthMiddleware(javaClient)) + consoleGroup.GET("/config", func(c *gin.Context) { + resp, err := baishunService.GetProviderConfig(c.Request.Context(), c.Query("sysOrigin")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + consoleGroup.POST("/config", func(c *gin.Context) { + var req baishun.SaveBaishunProviderConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, baishun.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := baishunService.SaveProviderConfig(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) consoleGroup.GET("/page", func(c *gin.Context) { cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1"))) limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) @@ -128,6 +133,22 @@ func registerBaishunRoutes( } 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 := baishunService.PageCatalog( + c.Request.Context(), + c.Query("sysOrigin"), + c.Query("keyword"), + cursor, + limit, + ) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) consoleGroup.POST("/save", func(c *gin.Context) { var req baishun.SaveAdminBaishunGameRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -149,6 +170,19 @@ func registerBaishunRoutes( } writeOK(c, gin.H{"deleted": true}) }) + consoleGroup.POST("/catalog/fetch", func(c *gin.Context) { + var req baishun.SyncCatalogRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, baishun.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := baishunService.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 baishun.SyncCatalogRequest if err := c.ShouldBindJSON(&req); err != nil { diff --git a/internal/router/game_open_routes.go b/internal/router/game_open_routes.go new file mode 100644 index 0000000..5de676f --- /dev/null +++ b/internal/router/game_open_routes.go @@ -0,0 +1,79 @@ +package router + +import ( + "encoding/json" + "net/http" + "strings" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/service/gameopen" + + "github.com/gin-gonic/gin" +) + +// registerGameOpenRoutes 注册统一三方游戏回调接口。 +func registerGameOpenRoutes(engine *gin.Engine, cfg config.Config, service *gameopen.GameOpenService) { + if service == nil { + return + } + + callbackGroup := engine.Group("/game/open") + callbackGroup.POST("/user-info", func(c *gin.Context) { + raw, _ := c.GetRawData() + var req gameopen.QueryUserRequest + 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.HandleQueryUser(c.Request.Context(), req, string(raw))) + }) + callbackGroup.POST("/update-coin", func(c *gin.Context) { + raw, _ := c.GetRawData() + var req gameopen.UpdateCoinRequest + 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.HandleUpdateCoin(c.Request.Context(), req, string(raw))) + }) + callbackGroup.POST("/supplement", func(c *gin.Context) { + raw, _ := c.GetRawData() + var req gameopen.SupplementRequest + 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.HandleSupplement(c.Request.Context(), req, string(raw))) + }) + + internalGroup := engine.Group("/internal/game/open") + internalGroup.Use(internalSecretMiddleware(cfg.HTTP.InternalCallbackSecret)) + internalGroup.GET("/info", func(c *gin.Context) { + resp, err := service.GetIntegrationInfo(c.Request.Context(), resolvePublicBaseURL(c, cfg)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) +} + +func resolvePublicBaseURL(c *gin.Context, cfg config.Config) string { + if value := strings.TrimRight(strings.TrimSpace(cfg.GameOpen.PublicBaseURL), "/"); value != "" { + return value + } + scheme := "http" + if forwarded := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")); forwarded != "" { + scheme = forwarded + } else if c.Request.TLS != nil { + scheme = "https" + } + host := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")) + if host == "" { + host = strings.TrimSpace(c.Request.Host) + } + if host == "" { + return "" + } + return scheme + "://" + host +} diff --git a/internal/router/game_provider_routes.go b/internal/router/game_provider_routes.go new file mode 100644 index 0000000..aa72ebc --- /dev/null +++ b/internal/router/game_provider_routes.go @@ -0,0 +1,195 @@ +package router + +import ( + "net/http" + + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/service/gameprovider" + + "github.com/gin-gonic/gin" +) + +// registerGameProviderRoutes 注册统一游戏厂商 app 路由。 +func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, registry *gameprovider.Registry) { + if registry == nil { + return + } + + appGroup := engine.Group("/app/game") + appGroup.Use(authMiddleware(javaClient)) + appGroup.GET("/room/shortcut", func(c *gin.Context) { + resp, err := registry.ListShortcutGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, stripProvidersFromRoomGameItems(resp)) + }) + appGroup.GET("/room/list", func(c *gin.Context) { + resp, err := registry.ListRoomGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"), c.Query("category")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, stripProvidersFromRoomGameListResponse(resp)) + }) + appGroup.GET("/state", func(c *gin.Context) { + resp, err := registry.GetRoomState(c.Request.Context(), mustAuthUser(c), c.Query("roomId")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, stripProviderFromRoomState(resp)) + }) + appGroup.POST("/launch", func(c *gin.Context) { + var req gameprovider.LaunchRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := registry.LaunchGame(c.Request.Context(), mustAuthUser(c), req, resolveClientIP(c)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, stripProviderFromLaunch(resp)) + }) + appGroup.POST("/close", func(c *gin.Context) { + var req gameprovider.CloseRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := registry.CloseGame(c.Request.Context(), mustAuthUser(c), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, stripProviderFromRoomState(resp)) + }) + + providerGroup := engine.Group("/app/game/providers") + providerGroup.Use(authMiddleware(javaClient)) + providerGroup.GET("", func(c *gin.Context) { + writeOK(c, registry.List()) + }) + providerGroup.GET("/:provider/room/shortcut", func(c *gin.Context) { + provider, ok := resolveGameProvider(c, registry) + if !ok { + return + } + resp, err := provider.ListShortcutGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, gin.H{"items": resp}) + }) + providerGroup.GET("/:provider/room/list", func(c *gin.Context) { + provider, ok := resolveGameProvider(c, registry) + if !ok { + return + } + resp, err := provider.ListRoomGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"), c.Query("category")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + providerGroup.GET("/:provider/state", func(c *gin.Context) { + provider, ok := resolveGameProvider(c, registry) + if !ok { + return + } + resp, err := provider.GetRoomState(c.Request.Context(), mustAuthUser(c), c.Query("roomId")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + providerGroup.POST("/:provider/launch", func(c *gin.Context) { + provider, ok := resolveGameProvider(c, registry) + if !ok { + return + } + var req gameprovider.LaunchRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := provider.LaunchGame(c.Request.Context(), mustAuthUser(c), req, resolveClientIP(c)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + providerGroup.POST("/:provider/close", func(c *gin.Context) { + provider, ok := resolveGameProvider(c, registry) + if !ok { + return + } + var req gameprovider.CloseRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := provider.CloseGame(c.Request.Context(), mustAuthUser(c), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) +} + +func resolveGameProvider(c *gin.Context, registry *gameprovider.Registry) (gameprovider.Provider, bool) { + provider, err := registry.Resolve(c.Param("provider")) + if err != nil { + writeError(c, err) + return nil, false + } + return provider, true +} + +func stripProvidersFromRoomGameItems(items []gameprovider.RoomGameListItem) []gameprovider.RoomGameListItem { + if len(items) == 0 { + return []gameprovider.RoomGameListItem{} + } + result := make([]gameprovider.RoomGameListItem, 0, len(items)) + for _, item := range items { + item.Provider = "" + result = append(result, item) + } + return result +} + +func stripProvidersFromRoomGameListResponse(resp *gameprovider.RoomGameListResponse) *gameprovider.RoomGameListResponse { + if resp == nil { + return &gameprovider.RoomGameListResponse{Items: []gameprovider.RoomGameListItem{}} + } + return &gameprovider.RoomGameListResponse{ + Items: stripProvidersFromRoomGameItems(resp.Items), + } +} + +func stripProviderFromRoomState(resp *gameprovider.RoomStateResponse) *gameprovider.RoomStateResponse { + if resp == nil { + return nil + } + copied := *resp + copied.Provider = "" + return &copied +} + +func stripProviderFromLaunch(resp *gameprovider.LaunchResponse) *gameprovider.LaunchResponse { + if resp == nil { + return nil + } + copied := *resp + copied.Provider = "" + copied.RoomState = *stripProviderFromRoomState(&resp.RoomState) + return &copied +} diff --git a/internal/router/router.go b/internal/router/router.go index 3265a1c..6f7ff40 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -7,6 +7,8 @@ import ( "chatapp3-golang/internal/config" "chatapp3-golang/internal/repo" "chatapp3-golang/internal/service/baishun" + "chatapp3-golang/internal/service/gameopen" + "chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/invite" "chatapp3-golang/internal/service/luckygift" "chatapp3-golang/internal/service/registerreward" @@ -20,6 +22,8 @@ import ( type Services struct { Invite *invite.InviteService Baishun *baishun.BaishunService + GameOpen *gameopen.GameOpenService + GameProviders *gameprovider.Registry LuckyGift *luckygift.LuckyGiftService RegisterReward *registerreward.RegisterRewardService SignInReward *signinreward.SignInRewardService @@ -41,6 +45,8 @@ func NewRouter( registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward) registerSignInRewardRoutes(engine, javaClient, services.SignInReward) registerWeekStarRoutes(engine, javaClient, services.WeekStar) + registerGameOpenRoutes(engine, cfg, services.GameOpen) + registerGameProviderRoutes(engine, javaClient, services.GameProviders) registerBaishunRoutes(engine, cfg, javaClient, services.Baishun) registerLuckyGiftRoutes(engine, cfg, services.LuckyGift) @@ -61,6 +67,6 @@ func registerHealthRoutes(engine *gin.Engine, cfg config.Config, repository *rep return } - c.JSON(http.StatusOK, gin.H{"code": "ok", "message": "success3"}) + c.JSON(http.StatusOK, gin.H{"code": "ok", "message": "success4"}) }) } diff --git a/internal/service/baishun/admin.go b/internal/service/baishun/admin.go index 4ac6f3a..96d808e 100644 --- a/internal/service/baishun/admin.go +++ b/internal/service/baishun/admin.go @@ -43,6 +43,7 @@ type adminGameRow struct { ExtSafeHeight int ExtGSP string CurrencyIcon string + ExtExtraJSON string CatalogStatus string CatalogPreviewURL string CatalogDownloadURL string @@ -99,11 +100,12 @@ func (s *BaishunService) PageAdminGames( 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.gsp AS ext_gsp, - ext.currency_icon AS currency_icon, - cat.status AS catalog_status, + ext.orientation AS ext_orientation, + ext.safe_height AS ext_safe_height, + ext.gsp AS ext_gsp, + ext.currency_icon AS currency_icon, + ext.extra_json AS ext_extra_json, + cat.status AS catalog_status, cat.preview_url AS catalog_preview_url, cat.download_url AS catalog_download_url, cat.package_version AS catalog_package, @@ -136,6 +138,10 @@ func (s *BaishunService) PageAdminGames( // SaveAdminGame 保存后台百顺游戏配置。 func (s *BaishunService) SaveAdminGame(ctx context.Context, req SaveAdminBaishunGameRequest) (*AdminBaishunGameItem, error) { sysOrigin := normalizeAdminSysOrigin(req.SysOrigin) + runtimeCfg, err := s.resolveRuntimeConfig(ctx, sysOrigin) + if err != nil { + return nil, err + } catalog, err := s.findCatalogForAdmin(ctx, sysOrigin, req.VendorGameID, req.GameID) if err != nil { @@ -168,7 +174,7 @@ func (s *BaishunService) SaveAdminGame(ctx context.Context, req SaveAdminBaishun packageVersion := defaultIfBlank(strings.TrimSpace(req.PackageVersion), catalog.PackageVersion) previewURL := defaultIfBlank(strings.TrimSpace(req.PreviewURL), catalog.PreviewURL) packageURL := defaultIfBlank(strings.TrimSpace(req.PackageURL), catalog.DownloadURL) - gsp := defaultIfBlank(strings.TrimSpace(req.GSP), defaultAdminGSP(catalog.GSP, s.cfg.Baishun.GSP)) + gsp := defaultIfBlank(strings.TrimSpace(req.GSP), defaultAdminGSP(catalog.GSP, runtimeCfg.gsp())) safeHeight := req.SafeHeight if safeHeight <= 0 { safeHeight = catalog.SafeHeight @@ -261,6 +267,8 @@ func (s *BaishunService) SaveAdminGame(ctx context.Context, req SaveAdminBaishun return nil, err } + now := time.Now() + extraJSON := updateExtExtraJSONWithGameType(ext.ExtraJSON, req.LaunchParams, baishunVendorType, ext.ID != 0) if ext.ID == 0 { nextID, idErr := utils.NextID() if idErr != nil { @@ -281,7 +289,10 @@ func (s *BaishunService) SaveAdminGame(ctx context.Context, req SaveAdminBaishun SafeHeight: safeHeight, GSP: gsp, CurrencyIcon: strings.TrimSpace(req.CurrencyIcon), + ExtraJSON: extraJSON, Enabled: true, + CreateTime: now, + UpdateTime: now, } if err := tx.Create(&ext).Error; err != nil { tx.Rollback() @@ -301,8 +312,10 @@ func (s *BaishunService) SaveAdminGame(ctx context.Context, req SaveAdminBaishun "safe_height": safeHeight, "gsp": gsp, "currency_icon": strings.TrimSpace(req.CurrencyIcon), + "extra_json": extraJSON, "enabled": true, "bridge_schema_version": defaultIfBlank(ext.BridgeSchemaVersion, "1.0"), + "update_time": now, }).Error; err != nil { tx.Rollback() return nil, err @@ -436,6 +449,7 @@ func (s *BaishunService) getAdminGame(ctx context.Context, sysOrigin string, id ext.safe_height AS ext_safe_height, ext.gsp AS ext_gsp, ext.currency_icon AS currency_icon, + ext.extra_json AS ext_extra_json, cat.status AS catalog_status, cat.preview_url AS catalog_preview_url, cat.download_url AS catalog_download_url, @@ -515,6 +529,14 @@ func (s *BaishunService) findAdminConfigForSave(tx *gorm.DB, sysOrigin string, i } func (s *BaishunService) importCatalogGamesTx(tx *gorm.DB, sysOrigin string, filter map[int]struct{}) (*ImportCatalogResponse, error) { + ctx := tx.Statement.Context + if ctx == nil { + ctx = context.Background() + } + runtimeCfg, err := s.resolveRuntimeConfig(ctx, sysOrigin) + if err != nil { + return nil, err + } query := tx.Where("sys_origin = ? AND status = ?", sysOrigin, baishunCatalogEnabled) if len(filter) > 0 { ids := make([]int, 0, len(filter)) @@ -532,6 +554,7 @@ func (s *BaishunService) importCatalogGamesTx(tx *gorm.DB, sysOrigin string, fil resp := &ImportCatalogResponse{} for _, catalog := range catalogs { vendorGameID := strconv.Itoa(catalog.VendorGameID) + now := time.Now() var ext model.SysGameListVendorExt err := tx.Where("sys_origin = ? AND vendor_type = ? AND vendor_game_id = ?", sysOrigin, baishunVendorType, vendorGameID).First(&ext).Error @@ -589,9 +612,12 @@ func (s *BaishunService) importCatalogGamesTx(tx *gorm.DB, sysOrigin string, fil PreviewURL: catalog.PreviewURL, Orientation: catalog.Orientation, SafeHeight: catalog.SafeHeight, - GSP: defaultAdminGSP(catalog.GSP, s.cfg.Baishun.GSP), + GSP: defaultAdminGSP(catalog.GSP, runtimeCfg.gsp()), BridgeSchemaVersion: "1.0", + ExtraJSON: updateExtExtraJSONWithGameType("", nil, baishunVendorType, false), Enabled: true, + CreateTime: now, + UpdateTime: now, } if err := tx.Create(&newExt).Error; err != nil { return nil, err @@ -606,6 +632,7 @@ func (r adminGameRow) toAdminItem() AdminBaishunGameItem { ID: r.ID, SysOrigin: r.SysOrigin, GameID: r.InternalGameID, + GameType: gameTypeFromExtraJSON(r.ExtExtraJSON, baishunVendorType), VendorGameID: parseAdminVendorGameID(r.VendorGameID, r.InternalGameID), Name: r.Name, Category: r.Category, @@ -629,6 +656,7 @@ func (r adminGameRow) toAdminItem() AdminBaishunGameItem { GSP: r.ExtGSP, CurrencyIcon: r.CurrencyIcon, CatalogStatus: r.CatalogStatus, + LaunchParams: launchParamsWithGameType(r.ExtExtraJSON, baishunVendorType), CreateTime: formatAdminTime(r.CreateTime), UpdateTime: formatAdminTime(r.UpdateTime), } diff --git a/internal/service/baishun/app_provider.go b/internal/service/baishun/app_provider.go new file mode 100644 index 0000000..b3e2931 --- /dev/null +++ b/internal/service/baishun/app_provider.go @@ -0,0 +1,167 @@ +package baishun + +import ( + "chatapp3-golang/internal/service/gameprovider" + "context" + "errors" + "strconv" +) + +var _ gameprovider.Provider = (*AppProvider)(nil) + +// AppProvider 把百顺服务适配成统一厂商接口。 +type AppProvider struct { + service *BaishunService +} + +// NewAppProvider 创建百顺统一厂商适配器。 +func NewAppProvider(service *BaishunService) *AppProvider { + if service == nil { + return nil + } + return &AppProvider{service: service} +} + +// Key 返回统一厂商标识。 +func (p *AppProvider) Key() string { + return baishunVendorType +} + +// DisplayName 返回厂商展示名。 +func (p *AppProvider) DisplayName() string { + return "百顺" +} + +// SupportsLaunch 判断启动请求是否属于百顺。 +func (p *AppProvider) SupportsLaunch(ctx context.Context, user gameprovider.AuthUser, req gameprovider.LaunchRequest) (bool, error) { + _, err := p.service.resolveLaunchGameRow(ctx, user.SysOrigin, BaishunLaunchAppRequest{ + ID: req.ID, + GameID: req.GameID, + }) + if err == nil { + return true, nil + } + var appErr *AppError + if errors.As(err, &appErr) && appErr.Code == "game_not_found" { + return false, nil + } + return false, err +} + +// ListShortcutGames 返回百顺房间快捷游戏。 +func (p *AppProvider) ListShortcutGames(ctx context.Context, user gameprovider.AuthUser, roomID string) ([]gameprovider.RoomGameListItem, error) { + items, err := p.service.ListShortcutGames(ctx, user, roomID) + if err != nil { + return nil, err + } + return mapRoomGameListItems(items), nil +} + +// ListRoomGames 返回百顺房间游戏列表。 +func (p *AppProvider) ListRoomGames(ctx context.Context, user gameprovider.AuthUser, roomID, category string) (*gameprovider.RoomGameListResponse, error) { + resp, err := p.service.ListRoomGames(ctx, user, roomID, category) + if err != nil { + return nil, err + } + return &gameprovider.RoomGameListResponse{Items: mapRoomGameListItems(resp.Items)}, nil +} + +// GetRoomState 返回百顺房间状态。 +func (p *AppProvider) GetRoomState(ctx context.Context, user gameprovider.AuthUser, roomID string) (*gameprovider.RoomStateResponse, error) { + resp, err := p.service.GetRoomState(ctx, user, roomID) + if err != nil { + return nil, err + } + return mapRoomStateResponse(resp), nil +} + +// LaunchGame 启动百顺游戏。 +func (p *AppProvider) LaunchGame(ctx context.Context, user gameprovider.AuthUser, req gameprovider.LaunchRequest, clientIP string) (*gameprovider.LaunchResponse, error) { + resp, err := p.service.LaunchGame(ctx, user, BaishunLaunchAppRequest{ + ID: req.ID, + RoomID: req.RoomID, + GameID: req.GameID, + SceneMode: req.SceneMode, + ClientOrigin: req.ClientOrigin, + }, clientIP) + if err != nil { + return nil, err + } + return &gameprovider.LaunchResponse{ + ID: resp.ID, + GameSessionID: resp.GameSessionID, + Provider: baishunVendorType, + GameID: resp.GameID, + ProviderGameID: strconv.Itoa(resp.VendorGameID), + Entry: gameprovider.LaunchEntry{ + LaunchMode: resp.Entry.LaunchMode, + EntryURL: resp.Entry.EntryURL, + PreviewURL: resp.Entry.PreviewURL, + DownloadURL: resp.Entry.DownloadURL, + PackageVersion: resp.Entry.PackageVersion, + Orientation: resp.Entry.Orientation, + SafeHeight: resp.Entry.SafeHeight, + }, + LaunchConfig: resp.BridgeConfig, + RoomState: *mapRoomStateResponse(&resp.RoomState), + }, nil +} + +// CloseGame 关闭百顺游戏。 +func (p *AppProvider) CloseGame(ctx context.Context, user gameprovider.AuthUser, req gameprovider.CloseRequest) (*gameprovider.RoomStateResponse, error) { + resp, err := p.service.CloseGame(ctx, user, BaishunCloseAppRequest{ + RoomID: req.RoomID, + GameSessionID: req.GameSessionID, + Reason: req.Reason, + }) + if err != nil { + return nil, err + } + return mapRoomStateResponse(resp), nil +} + +func mapRoomGameListItems(items []RoomGameListItem) []gameprovider.RoomGameListItem { + result := make([]gameprovider.RoomGameListItem, 0, len(items)) + for _, item := range items { + result = append(result, gameprovider.RoomGameListItem{ + ID: item.ID, + GameID: item.GameID, + GameType: item.GameType, + Provider: baishunVendorType, + ProviderGameID: strconv.Itoa(item.VendorGameID), + Name: item.Name, + Cover: item.Cover, + Category: item.Category, + Sort: item.Sort, + LaunchMode: item.LaunchMode, + FullScreen: item.FullScreen, + GameMode: item.GameMode, + SafeHeight: item.SafeHeight, + Orientation: item.Orientation, + PackageVersion: item.PackageVersion, + Status: item.Status, + LaunchParams: item.LaunchParams, + }) + } + return result +} + +func mapRoomStateResponse(resp *BaishunRoomStateResponse) *gameprovider.RoomStateResponse { + if resp == nil { + return nil + } + state := &gameprovider.RoomStateResponse{ + RoomID: resp.RoomID, + State: resp.State, + Provider: baishunVendorType, + GameSessionID: resp.GameSessionID, + CurrentGameID: resp.CurrentGameID, + CurrentGameName: resp.CurrentGameName, + CurrentGameCover: resp.CurrentGameCover, + HostUserID: resp.HostUserID, + } + if resp.CurrentVendorGameID > 0 { + state.CurrentProviderGameID = strconv.Itoa(resp.CurrentVendorGameID) + } + return state +} diff --git a/internal/service/baishun/callback.go b/internal/service/baishun/callback.go index 7f5287f..9d88672 100644 --- a/internal/service/baishun/callback.go +++ b/internal/service/baishun/callback.go @@ -19,7 +19,8 @@ import ( // HandleToken 处理百顺获取启动 code/token 的回调。 func (s *BaishunService) HandleToken(ctx context.Context, req BaishunTokenRequest, rawJSON string) baishunStandardResponse { - if !s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp) { + runtimeCfg, ok := s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp) + if !ok { return s.failStandard(baishunCodeSignatureError, "signature error") } var session model.BaishunLaunchSession @@ -47,7 +48,7 @@ func (s *BaishunService) HandleToken(ctx context.Context, req BaishunTokenReques s.saveCallbackLog(ctx, "token", rawJSON, utils.MustJSONString(resp, ""), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) return resp } - expire := time.Now().Add(time.Duration(s.cfg.Baishun.SSTokenTTLSeconds) * time.Second) + expire := time.Now().Add(time.Duration(runtimeCfg.ssTokenTTLSeconds()) * time.Second) if err := s.repo.DB.WithContext(ctx). Model(&model.BaishunLaunchSession{}). Where("id = ?", session.ID). @@ -85,7 +86,7 @@ func (s *BaishunService) HandleToken(ctx context.Context, req BaishunTokenReques // HandleProfile 处理百顺获取用户资料的回调。 func (s *BaishunService) HandleProfile(ctx context.Context, req BaishunProfileRequest, rawJSON string) baishunStandardResponse { - if !s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp) { + if _, ok := s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp); !ok { return s.failStandard(baishunCodeSignatureError, "signature error") } session, ok := s.findSessionByToken(ctx, req.SSToken, req.UserID) @@ -119,7 +120,8 @@ func (s *BaishunService) HandleProfile(ctx context.Context, req BaishunProfileRe // HandleUpdateToken 处理百顺刷新 ss_token 的回调。 func (s *BaishunService) HandleUpdateToken(ctx context.Context, req BaishunUpdateTokenRequest, rawJSON string) baishunStandardResponse { - if !s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp) { + runtimeCfg, ok := s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp) + if !ok { return s.failStandard(baishunCodeSignatureError, "signature error") } session, ok := s.findSessionByToken(ctx, req.SSToken, req.UserID) @@ -134,7 +136,7 @@ func (s *BaishunService) HandleUpdateToken(ctx context.Context, req BaishunUpdat s.saveCallbackLog(ctx, "update-token", rawJSON, utils.MustJSONString(resp, ""), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) return resp } - expire := time.Now().Add(time.Duration(s.cfg.Baishun.SSTokenTTLSeconds) * time.Second) + expire := time.Now().Add(time.Duration(runtimeCfg.ssTokenTTLSeconds()) * time.Second) if err := s.repo.DB.WithContext(ctx). Model(&model.BaishunLaunchSession{}). Where("id = ?", session.ID). @@ -163,7 +165,7 @@ func (s *BaishunService) HandleUpdateToken(ctx context.Context, req BaishunUpdat // HandleChangeBalance 处理百顺扣增币回调,并驱动钱包变更。 func (s *BaishunService) HandleChangeBalance(ctx context.Context, req BaishunChangeBalanceRequest, rawJSON string) baishunStandardResponse { - if !s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp) { + if _, ok := s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp); !ok { return s.failStandard(baishunCodeSignatureError, "signature error") } session, ok := s.findSessionByToken(ctx, req.SSToken, req.UserID) @@ -255,7 +257,7 @@ func (s *BaishunService) HandleReport(ctx context.Context, payload map[string]an // HandleBalanceInfo 处理百顺查询余额回调。 func (s *BaishunService) HandleBalanceInfo(ctx context.Context, req BaishunBalanceInfoRequest, rawJSON string) baishunBalanceInfoResponse { - if !s.validateCommonSignature(ctx, req.AppID, req.AppChannel, req.Signature, req.SignatureNonce, req.Timestamp) { + if _, ok := s.validateCommonSignature(ctx, req.AppID, req.AppChannel, req.Signature, req.SignatureNonce, req.Timestamp); !ok { return baishunBalanceInfoResponse{Code: baishunCodeSignatureError, Msg: "signature error"} } userID, _ := strconv.ParseInt(req.UserID, 10, 64) @@ -270,24 +272,31 @@ func (s *BaishunService) HandleBalanceInfo(ctx context.Context, req BaishunBalan } // validateCommonSignature 校验百顺回调签名。 -func (s *BaishunService) validateCommonSignature(ctx context.Context, appID int64, appChannel, signature, nonce string, timestamp int64) bool { - if s.cfg.Baishun.AppID != 0 && appID != 0 && appID != s.cfg.Baishun.AppID { - return false +func (s *BaishunService) validateCommonSignature(ctx context.Context, appID int64, appChannel, signature, nonce string, timestamp int64) (baishunRuntimeConfig, bool) { + runtimeCfg, err := s.resolveRuntimeConfigByApp(ctx, appID, appChannel) + if err != nil { + return baishunRuntimeConfig{}, false } - if strings.TrimSpace(appChannel) != "" && s.cfg.Baishun.AppChannel != "" && appChannel != s.cfg.Baishun.AppChannel { - return false + if runtimeCfg.AppID != 0 && appID != 0 && appID != runtimeCfg.AppID { + return baishunRuntimeConfig{}, false } - if strings.TrimSpace(signature) == "" || strings.TrimSpace(nonce) == "" || timestamp == 0 || strings.TrimSpace(s.cfg.Baishun.AppKey) == "" { - return false + if strings.TrimSpace(appChannel) != "" && runtimeCfg.appChannel() != "" && appChannel != runtimeCfg.appChannel() { + return baishunRuntimeConfig{}, false + } + if strings.TrimSpace(signature) == "" || strings.TrimSpace(nonce) == "" || timestamp == 0 || strings.TrimSpace(runtimeCfg.AppKey) == "" { + return baishunRuntimeConfig{}, false } if abs64(time.Now().Unix()-timestamp) > 15 { - return false + return baishunRuntimeConfig{}, false } - expected := s.signBAISHUN(timestamp, nonce) + expected := signBAISHUN(runtimeCfg.AppKey, timestamp, nonce) if !strings.EqualFold(expected, signature) { - return false + return baishunRuntimeConfig{}, false } - return s.rememberNonce(ctx, nonce) + if !s.rememberNonce(ctx, nonce) { + return baishunRuntimeConfig{}, false + } + return runtimeCfg, true } // rememberNonce 记录已用 nonce,防止回调重放。 @@ -297,8 +306,8 @@ func (s *BaishunService) rememberNonce(ctx context.Context, nonce string) bool { } // signBAISHUN 按百顺约定生成签名。 -func (s *BaishunService) signBAISHUN(timestamp int64, nonce string) string { - sum := md5.Sum([]byte(nonce + s.cfg.Baishun.AppKey + strconv.FormatInt(timestamp, 10))) +func signBAISHUN(appKey string, timestamp int64, nonce string) string { + sum := md5.Sum([]byte(nonce + appKey + strconv.FormatInt(timestamp, 10))) return hex.EncodeToString(sum[:]) } diff --git a/internal/service/baishun/catalog.go b/internal/service/baishun/catalog.go index 34bceae..6870f9a 100644 --- a/internal/service/baishun/catalog.go +++ b/internal/service/baishun/catalog.go @@ -18,15 +18,16 @@ import ( // SyncCatalog 从百顺平台拉取游戏目录并同步到本地表。 func (s *BaishunService) SyncCatalog(ctx context.Context, req SyncCatalogRequest) (*SyncCatalogResponse, error) { - if strings.TrimSpace(s.cfg.Baishun.PlatformBaseURL) == "" || s.cfg.Baishun.AppID == 0 { - return nil, NewAppError(http.StatusBadRequest, "baishun_config_missing", "baishun platform config is missing") - } sysOrigin := defaultIfBlank(req.SysOrigin, "LIKEI") if strings.TrimSpace(sysOrigin) == "" { sysOrigin = "LIKEI" } + runtimeCfg, err := s.requireProviderConfig(ctx, sysOrigin) + if err != nil { + return nil, err + } - games, err := s.fetchPlatformGames(ctx) + games, err := s.fetchPlatformGames(ctx, runtimeCfg) if err != nil { return nil, err } @@ -61,7 +62,7 @@ func (s *BaishunService) SyncCatalog(ctx context.Context, req SyncCatalogRequest Orientation: intPtr(game.GameOrientation), SafeHeight: game.SafeHeight, VenueLevelJSON: utils.MustJSONString(game.VenueLevel, ""), - GSP: strconv.Itoa(s.cfg.Baishun.GSP), + GSP: strconv.Itoa(runtimeCfg.gsp()), RawJSON: utils.MustJSONString(game, ""), Status: baishunCatalogEnabled, CreateTime: time.Now(), @@ -88,22 +89,23 @@ func (s *BaishunService) SyncCatalog(ctx context.Context, req SyncCatalogRequest } // fetchPlatformGames 从百顺平台拉取游戏目录。 -func (s *BaishunService) fetchPlatformGames(ctx context.Context) ([]baishunPlatformGame, error) { +func (s *BaishunService) fetchPlatformGames(ctx context.Context, runtimeCfg baishunRuntimeConfig) ([]baishunPlatformGame, error) { timestamp := time.Now().Unix() nonce := s.randomNonce() payload := map[string]any{ "game_list_type": baishunGameListTypeGame, - "app_channel": s.cfg.Baishun.AppChannel, - "app_id": s.cfg.Baishun.AppID, - "signature": s.signBAISHUN(timestamp, nonce), + "app_channel": runtimeCfg.appChannel(), + "app_id": runtimeCfg.AppID, + "signature": signBAISHUN(runtimeCfg.AppKey, timestamp, nonce), "signature_nonce": nonce, "timestamp": timestamp, } - if strings.TrimSpace(s.cfg.Baishun.AppName) != "" { - payload["app_name"] = s.cfg.Baishun.AppName + if strings.TrimSpace(runtimeCfg.AppName) != "" { + payload["app_name"] = runtimeCfg.AppName } body, _ := json.Marshal(payload) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(s.cfg.Baishun.PlatformBaseURL, "/")+"/v1/api/gamelist", bytes.NewReader(body)) + endpoint := strings.TrimRight(runtimeCfg.PlatformBaseURL, "/") + "/v1/api/gamelist" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return nil, err } @@ -117,15 +119,56 @@ func (s *BaishunService) fetchPlatformGames(ctx context.Context) ([]baishunPlatf if err != nil { return nil, err } + contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) if resp.StatusCode >= http.StatusBadRequest { - return nil, fmt.Errorf("baishun platform failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(raw))) + return nil, fmt.Errorf( + "baishun platform failed: url=%s status=%d contentType=%s body=%s", + endpoint, + resp.StatusCode, + contentType, + baishunShortBody(raw), + ) + } + if baishunLooksLikeHTML(raw, contentType) { + return nil, fmt.Errorf( + "baishun platform returned non-json html: url=%s contentType=%s body=%s", + endpoint, + contentType, + baishunShortBody(raw), + ) } var result baishunPlatformResponse if err := json.Unmarshal(raw, &result); err != nil { - return nil, err + return nil, fmt.Errorf( + "baishun platform returned invalid json: url=%s contentType=%s error=%v body=%s", + endpoint, + contentType, + err, + baishunShortBody(raw), + ) } if result.Code != 0 { return nil, fmt.Errorf("baishun platform failed: code=%d message=%s", result.Code, result.Message) } return result.Data, nil } + +func baishunLooksLikeHTML(raw []byte, contentType string) bool { + contentType = strings.ToLower(strings.TrimSpace(contentType)) + if strings.Contains(contentType, "text/html") { + return true + } + body := strings.ToLower(strings.TrimSpace(string(raw))) + return strings.HasPrefix(body, " 0, + Showcase: row.Showcase != nil && *row.Showcase, + UpdateTime: row.UpdateTime, + }) + } + + return &AdminBaishunCatalogPageResponse{ + Cursor: cursor, + Limit: limit, + Records: items, + Total: total, + }, nil +} + +func (s *BaishunService) FetchAdminCatalog(ctx context.Context, req SyncCatalogRequest) (*SyncCatalogResponse, error) { + return s.SyncCatalog(ctx, req) +} + +func (s *BaishunService) buildAdminCatalogQuery(ctx context.Context, sysOrigin string, keyword string) *gorm.DB { + query := s.repo.DB.WithContext(ctx). + Table("baishun_game_catalog AS cat"). + Joins(` + LEFT JOIN sys_game_list_vendor_ext ext + ON ext.sys_origin = cat.sys_origin + AND ext.vendor_type = ? + AND ext.vendor_game_id = CAST(cat.vendor_game_id AS CHAR) + AND ext.enabled = 1 + `, baishunVendorType). + Joins("LEFT JOIN sys_game_list_config cfg ON cfg.id = ext.game_list_config_id"). + Where("cat.sys_origin = ?", sysOrigin) + keyword = strings.TrimSpace(keyword) + if keyword != "" { + like := "%" + keyword + "%" + query = query.Where(` + cat.name LIKE ? + OR cat.internal_game_id LIKE ? + OR CAST(cat.vendor_game_id AS CHAR) LIKE ? + `, like, like, like) + } + return query +} + +func (s *BaishunService) requireProviderConfig(ctx context.Context, sysOrigin string) (baishunRuntimeConfig, error) { + runtimeCfg, err := s.resolveRuntimeConfig(ctx, sysOrigin) + if err != nil { + return baishunRuntimeConfig{}, err + } + if strings.TrimSpace(runtimeCfg.PlatformBaseURL) == "" || runtimeCfg.AppID <= 0 || strings.TrimSpace(runtimeCfg.AppKey) == "" { + return baishunRuntimeConfig{}, NewAppError(http.StatusBadRequest, "baishun_config_missing", "baishun provider config is missing") + } + return runtimeCfg, nil +} diff --git a/internal/service/baishun/extra_json.go b/internal/service/baishun/extra_json.go new file mode 100644 index 0000000..c7583a1 --- /dev/null +++ b/internal/service/baishun/extra_json.go @@ -0,0 +1,127 @@ +package baishun + +import ( + "encoding/json" + "strings" +) + +const extExtraLaunchParamsKey = "launchParams" +const extLaunchParamGameTypeKey = "gameType" + +func launchParamsFromExtraJSON(raw string) map[string]any { + extra := parseExtExtraJSON(raw) + value, ok := extra[extExtraLaunchParamsKey] + if !ok || value == nil { + return nil + } + if params, ok := value.(map[string]any); ok { + if len(params) == 0 { + return nil + } + return params + } + bytes, err := json.Marshal(value) + if err != nil { + return nil + } + var params map[string]any + if err := json.Unmarshal(bytes, ¶ms); err != nil || len(params) == 0 { + return nil + } + return params +} + +func launchParamsWithGameType(raw string, gameType string) map[string]any { + params := cloneMapAny(launchParamsFromExtraJSON(raw)) + gameType = normalizeGameType(gameType) + if gameType == "" { + if len(params) == 0 { + return nil + } + return params + } + if params == nil { + params = map[string]any{} + } + params[extLaunchParamGameTypeKey] = gameType + return params +} + +func gameTypeFromExtraJSON(raw string, fallback string) string { + params := launchParamsFromExtraJSON(raw) + if value, ok := params[extLaunchParamGameTypeKey].(string); ok { + if normalized := normalizeGameType(value); normalized != "" { + return normalized + } + } + return normalizeGameType(fallback) +} + +func updateExtExtraJSON(existing string, launchParams map[string]any, preserveExisting bool) string { + if launchParams == nil { + if preserveExisting { + return strings.TrimSpace(existing) + } + return "" + } + extra := parseExtExtraJSON(existing) + if len(launchParams) == 0 { + delete(extra, extExtraLaunchParamsKey) + } else { + extra[extExtraLaunchParamsKey] = launchParams + } + if len(extra) == 0 { + return "" + } + bytes, err := json.Marshal(extra) + if err != nil { + return strings.TrimSpace(existing) + } + return string(bytes) +} + +func updateExtExtraJSONWithGameType(existing string, launchParams map[string]any, gameType string, preserveExisting bool) string { + params := cloneMapAny(launchParams) + if params == nil && preserveExisting { + params = cloneMapAny(launchParamsFromExtraJSON(existing)) + } + if params == nil { + params = map[string]any{} + } + gameType = normalizeGameType(gameType) + if gameType != "" { + params[extLaunchParamGameTypeKey] = gameType + } + return updateExtExtraJSON(existing, params, preserveExisting) +} + +func parseExtExtraJSON(raw string) map[string]any { + raw = strings.TrimSpace(raw) + if raw == "" { + return map[string]any{} + } + var extra map[string]any + if err := json.Unmarshal([]byte(raw), &extra); err != nil || extra == nil { + return map[string]any{} + } + return extra +} + +func cloneMapAny(source map[string]any) map[string]any { + if len(source) == 0 { + return nil + } + target := make(map[string]any, len(source)) + for key, value := range source { + target[key] = value + } + return target +} + +func normalizeGameType(value string) string { + value = strings.ToUpper(strings.TrimSpace(value)) + if value == "" { + return "" + } + return value +} diff --git a/internal/service/baishun/extra_json_test.go b/internal/service/baishun/extra_json_test.go new file mode 100644 index 0000000..3769a00 --- /dev/null +++ b/internal/service/baishun/extra_json_test.go @@ -0,0 +1,69 @@ +package baishun + +import "testing" + +func TestUpdateExtExtraJSON(t *testing.T) { + raw := updateExtExtraJSON(`{"keep":"value"}`, map[string]any{ + "sdkMode": "H5", + }, true) + + if got := parseExtExtraJSON(raw)["keep"]; got != "value" { + t.Fatalf("extra keep = %v, want value", got) + } + + cleared := updateExtExtraJSON(raw, map[string]any{}, true) + if params := launchParamsFromExtraJSON(cleared); params != nil { + t.Fatalf("launchParams after clear = %#v, want nil", params) + } + if got := parseExtExtraJSON(cleared)["keep"]; got != "value" { + t.Fatalf("extra keep after clear = %v, want value", got) + } +} + +func TestUpdateExtExtraJSONWithGameType(t *testing.T) { + raw := updateExtExtraJSONWithGameType(`{"keep":"value"}`, map[string]any{ + "sdkMode": "H5", + }, baishunVendorType, true) + + params := launchParamsFromExtraJSON(raw) + if got := params["gameType"]; got != baishunVendorType { + t.Fatalf("launchParams[gameType] = %v, want %s", got, baishunVendorType) + } + if got := params["sdkMode"]; got != "H5" { + t.Fatalf("launchParams[sdkMode] = %v, want H5", got) + } +} + +func TestLaunchParamsExposeToListAndAdmin(t *testing.T) { + raw := `{"launchParams":{"sdkMode":"H5"}}` + + listItem := gameListRow{ + ConfigID: 9527, + InternalGameID: "g-1", + VendorType: baishunVendorType, + VendorGameID: "1001", + LaunchMode: baishunLaunchModeRemote, + ExtExtraJSON: raw, + }.toListItem() + if got := listItem.ID; got != 9527 { + t.Fatalf("list id = %v, want 9527", got) + } + if got := listItem.GameType; got != baishunVendorType { + t.Fatalf("list gameType = %v, want %s", got, baishunVendorType) + } + if got := listItem.LaunchParams["gameType"]; got != baishunVendorType { + t.Fatalf("list launchParams[gameType] = %v, want %s", got, baishunVendorType) + } + + adminItem := adminGameRow{ + InternalGameID: "g-1", + VendorGameID: "1001", + ExtExtraJSON: raw, + }.toAdminItem() + if got := adminItem.GameType; got != baishunVendorType { + t.Fatalf("admin gameType = %v, want %s", got, baishunVendorType) + } + if got := adminItem.LaunchParams["sdkMode"]; got != "H5" { + t.Fatalf("admin launchParams[sdkMode] = %v, want H5", got) + } +} diff --git a/internal/service/baishun/game_row.go b/internal/service/baishun/game_row.go index 3ee1bd0..253720c 100644 --- a/internal/service/baishun/game_row.go +++ b/internal/service/baishun/game_row.go @@ -8,7 +8,9 @@ import ( // toListItem 把联表行模型转换成游戏列表项。 func (r gameListRow) toListItem() RoomGameListItem { return RoomGameListItem{ + ID: r.ConfigID, GameID: r.InternalGameID, + GameType: r.gameTypeName(), VendorType: defaultIfBlank(r.VendorType, baishunVendorType), VendorGameID: r.vendorGameIDInt(), Name: r.name(), @@ -22,6 +24,7 @@ func (r gameListRow) toListItem() RoomGameListItem { Orientation: r.orientation(), PackageVersion: r.packageVersion(), Status: defaultIfBlank(r.CatalogStatus, baishunCatalogEnabled), + LaunchParams: r.launchParams(), } } @@ -100,3 +103,12 @@ func (r gameListRow) vendorGameIDInt() int { func (r gameListRow) currencyIcon() string { return r.CurrencyIcon } + +// launchParams 返回 Flutter 统一启动接口使用的扩展参数。 +func (r gameListRow) launchParams() map[string]any { + return launchParamsWithGameType(r.ExtExtraJSON, r.gameTypeName()) +} + +func (r gameListRow) gameTypeName() string { + return gameTypeFromExtraJSON(r.ExtExtraJSON, defaultIfBlank(r.VendorType, baishunVendorType)) +} diff --git a/internal/service/baishun/launch.go b/internal/service/baishun/launch.go index 19ffafe..c8fea7b 100644 --- a/internal/service/baishun/launch.go +++ b/internal/service/baishun/launch.go @@ -20,11 +20,15 @@ func (s *BaishunService) LaunchGame(ctx context.Context, user AuthUser, req Bais if strings.TrimSpace(req.RoomID) == "" { return nil, NewAppError(http.StatusBadRequest, "missing_room_id", "roomId is required") } - if strings.TrimSpace(req.GameID) == "" { - return nil, NewAppError(http.StatusBadRequest, "missing_game_id", "gameId is required") + if req.ID <= 0 && strings.TrimSpace(req.GameID) == "" { + return nil, NewAppError(http.StatusBadRequest, "missing_game_id", "id or gameId is required") } - row, err := s.findGameRow(ctx, user.SysOrigin, req.GameID) + row, err := s.resolveLaunchGameRow(ctx, user.SysOrigin, req) + if err != nil { + return nil, err + } + runtimeCfg, err := s.requireProviderConfig(ctx, user.SysOrigin) if err != nil { return nil, err } @@ -56,9 +60,9 @@ func (s *BaishunService) LaunchGame(ctx context.Context, user AuthUser, req Bais VendorGameID: row.vendorGameIDInt(), GameSessionID: fmt.Sprintf("bs_%s_%d", sanitizeRoomID(req.RoomID), sessionID), LaunchCode: launchCode, - LaunchCodeExpireTime: now.Add(time.Duration(s.cfg.Baishun.LaunchCodeTTLSeconds) * time.Second), + LaunchCodeExpireTime: now.Add(time.Duration(runtimeCfg.launchCodeTTLSeconds()) * time.Second), Language: s.resolveLanguage(ctx, user.UserID), - GSP: s.resolveGSP(row), + GSP: s.resolveGSP(row, runtimeCfg), CurrencyType: 0, CurrencyIcon: row.currencyIcon(), SceneMode: req.SceneMode, @@ -106,6 +110,7 @@ func (s *BaishunService) LaunchGame(ctx context.Context, user AuthUser, req Bais } return &BaishunLaunchAppResponse{ + ID: row.ConfigID, GameSessionID: session.GameSessionID, VendorType: baishunVendorType, GameID: row.InternalGameID, @@ -120,9 +125,9 @@ func (s *BaishunService) LaunchGame(ctx context.Context, user AuthUser, req Bais SafeHeight: row.safeHeight(), }, BridgeConfig: BaishunBridgeConfig{ - AppName: s.cfg.Baishun.AppName, - AppChannel: defaultIfBlank(s.cfg.Baishun.AppChannel, "skychat"), - AppID: s.cfg.Baishun.AppID, + AppName: runtimeCfg.AppName, + AppChannel: runtimeCfg.appChannel(), + AppID: runtimeCfg.AppID, UserID: strconv.FormatInt(user.UserID, 10), Code: launchCode, RoomID: req.RoomID, @@ -147,6 +152,13 @@ func (s *BaishunService) LaunchGame(ctx context.Context, user AuthUser, req Bais }, nil } +func (s *BaishunService) resolveLaunchGameRow(ctx context.Context, sysOrigin string, req BaishunLaunchAppRequest) (gameListRow, error) { + if req.ID > 0 { + return s.findGameRowByConfigID(ctx, sysOrigin, req.ID) + } + return s.findGameRow(ctx, sysOrigin, req.GameID) +} + // CloseGame 关闭房间内当前游戏会话并回写房间状态。 func (s *BaishunService) CloseGame(ctx context.Context, user AuthUser, req BaishunCloseAppRequest) (*BaishunRoomStateResponse, error) { if strings.TrimSpace(req.RoomID) == "" { diff --git a/internal/service/baishun/provider_config.go b/internal/service/baishun/provider_config.go new file mode 100644 index 0000000..adaddf5 --- /dev/null +++ b/internal/service/baishun/provider_config.go @@ -0,0 +1,230 @@ +package baishun + +import ( + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + "context" + "net/http" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type baishunRuntimeConfig struct { + SysOrigin string + PlatformBaseURL string + AppID int64 + AppName string + AppChannel string + AppKey string + GSP int + LaunchCodeTTLSeconds int + SSTokenTTLSeconds int +} + +func (c baishunRuntimeConfig) appChannel() string { + return defaultIfBlank(strings.TrimSpace(c.AppChannel), "skychat") +} + +func (c baishunRuntimeConfig) gsp() int { + if c.GSP > 0 { + return c.GSP + } + return 101 +} + +func (c baishunRuntimeConfig) launchCodeTTLSeconds() int { + if c.LaunchCodeTTLSeconds > 0 { + return c.LaunchCodeTTLSeconds + } + return 300 +} + +func (c baishunRuntimeConfig) ssTokenTTLSeconds() int { + if c.SSTokenTTLSeconds > 0 { + return c.SSTokenTTLSeconds + } + return 86400 +} + +func (s *BaishunService) defaultRuntimeConfig(sysOrigin string) baishunRuntimeConfig { + return baishunRuntimeConfig{ + SysOrigin: normalizeAdminSysOrigin(sysOrigin), + PlatformBaseURL: strings.TrimSpace(s.cfg.Baishun.PlatformBaseURL), + AppID: s.cfg.Baishun.AppID, + AppName: strings.TrimSpace(s.cfg.Baishun.AppName), + AppChannel: strings.TrimSpace(s.cfg.Baishun.AppChannel), + AppKey: strings.TrimSpace(s.cfg.Baishun.AppKey), + GSP: s.cfg.Baishun.GSP, + LaunchCodeTTLSeconds: s.cfg.Baishun.LaunchCodeTTLSeconds, + SSTokenTTLSeconds: s.cfg.Baishun.SSTokenTTLSeconds, + } +} + +func applyProviderRow(base baishunRuntimeConfig, row model.BaishunProviderConfig) baishunRuntimeConfig { + base.SysOrigin = normalizeAdminSysOrigin(defaultIfBlank(row.SysOrigin, base.SysOrigin)) + if strings.TrimSpace(row.PlatformBaseURL) != "" { + base.PlatformBaseURL = strings.TrimSpace(row.PlatformBaseURL) + } + if row.AppID > 0 { + base.AppID = row.AppID + } + if strings.TrimSpace(row.AppName) != "" { + base.AppName = strings.TrimSpace(row.AppName) + } + if strings.TrimSpace(row.AppChannel) != "" { + base.AppChannel = strings.TrimSpace(row.AppChannel) + } + if strings.TrimSpace(row.AppKey) != "" { + base.AppKey = strings.TrimSpace(row.AppKey) + } + if row.GSP > 0 { + base.GSP = row.GSP + } + if row.LaunchCodeTTLSeconds > 0 { + base.LaunchCodeTTLSeconds = row.LaunchCodeTTLSeconds + } + if row.SSTokenTTLSeconds > 0 { + base.SSTokenTTLSeconds = row.SSTokenTTLSeconds + } + return base +} + +func (s *BaishunService) resolveRuntimeConfig(ctx context.Context, sysOrigin string) (baishunRuntimeConfig, error) { + sysOrigin = normalizeAdminSysOrigin(sysOrigin) + base := s.defaultRuntimeConfig(sysOrigin) + + var row model.BaishunProviderConfig + err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ?", sysOrigin). + Limit(1). + First(&row).Error + if err == nil { + return applyProviderRow(base, row), nil + } + if err == gorm.ErrRecordNotFound { + return base, nil + } + return baishunRuntimeConfig{}, err +} + +func (s *BaishunService) resolveRuntimeConfigByApp(ctx context.Context, appID int64, appChannel string) (baishunRuntimeConfig, error) { + base := s.defaultRuntimeConfig("LIKEI") + appChannel = strings.TrimSpace(appChannel) + + queries := make([]func(*gorm.DB) *gorm.DB, 0, 3) + if appID > 0 && appChannel != "" { + queries = append(queries, func(db *gorm.DB) *gorm.DB { + return db.Where("app_id = ? AND app_channel = ?", appID, appChannel) + }) + } + if appID > 0 { + queries = append(queries, func(db *gorm.DB) *gorm.DB { + return db.Where("app_id = ?", appID) + }) + } + if appChannel != "" { + queries = append(queries, func(db *gorm.DB) *gorm.DB { + return db.Where("app_channel = ?", appChannel) + }) + } + + for _, build := range queries { + var row model.BaishunProviderConfig + err := build(s.repo.DB.WithContext(ctx)).Limit(1).First(&row).Error + if err == nil { + return applyProviderRow(s.defaultRuntimeConfig(row.SysOrigin), row), nil + } + if err != gorm.ErrRecordNotFound { + return baishunRuntimeConfig{}, err + } + } + return base, nil +} + +func (s *BaishunService) GetProviderConfig(ctx context.Context, sysOrigin string) (*BaishunProviderConfigItem, error) { + runtimeCfg, err := s.resolveRuntimeConfig(ctx, sysOrigin) + if err != nil { + return nil, err + } + + var row model.BaishunProviderConfig + _ = s.repo.DB.WithContext(ctx). + Where("sys_origin = ?", normalizeAdminSysOrigin(sysOrigin)). + Limit(1). + First(&row).Error + + return &BaishunProviderConfigItem{ + ID: row.ID, + SysOrigin: runtimeCfg.SysOrigin, + PlatformBaseURL: runtimeCfg.PlatformBaseURL, + AppID: runtimeCfg.AppID, + AppName: runtimeCfg.AppName, + AppChannel: runtimeCfg.appChannel(), + AppKey: runtimeCfg.AppKey, + GSP: runtimeCfg.gsp(), + LaunchCodeTTLSeconds: runtimeCfg.launchCodeTTLSeconds(), + SSTokenTTLSeconds: runtimeCfg.ssTokenTTLSeconds(), + UpdateTime: formatAdminTime(row.UpdateTime), + }, nil +} + +func (s *BaishunService) SaveProviderConfig(ctx context.Context, req SaveBaishunProviderConfigRequest) (*BaishunProviderConfigItem, error) { + sysOrigin := normalizeAdminSysOrigin(req.SysOrigin) + platformBaseURL := strings.TrimSpace(req.PlatformBaseURL) + appChannel := strings.TrimSpace(req.AppChannel) + appKey := strings.TrimSpace(req.AppKey) + + switch { + case platformBaseURL == "": + return nil, NewAppError(http.StatusBadRequest, "platform_base_url_required", "platformBaseUrl is required") + case req.AppID <= 0: + return nil, NewAppError(http.StatusBadRequest, "app_id_required", "appId is required") + case appChannel == "": + return nil, NewAppError(http.StatusBadRequest, "app_channel_required", "appChannel is required") + case appKey == "": + return nil, NewAppError(http.StatusBadRequest, "app_key_required", "appKey is required") + } + + id, err := utils.NextID() + if err != nil { + return nil, err + } + now := time.Now() + row := model.BaishunProviderConfig{ + ID: id, + SysOrigin: sysOrigin, + PlatformBaseURL: platformBaseURL, + AppID: req.AppID, + AppName: strings.TrimSpace(req.AppName), + AppChannel: appChannel, + AppKey: appKey, + GSP: req.GSP, + LaunchCodeTTLSeconds: req.LaunchCodeTTLSeconds, + SSTokenTTLSeconds: req.SSTokenTTLSeconds, + CreateTime: now, + UpdateTime: now, + } + if row.GSP <= 0 { + row.GSP = 101 + } + if row.LaunchCodeTTLSeconds <= 0 { + row.LaunchCodeTTLSeconds = 300 + } + if row.SSTokenTTLSeconds <= 0 { + row.SSTokenTTLSeconds = 86400 + } + + if err := s.repo.DB.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "sys_origin"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "platform_base_url", "app_id", "app_name", "app_channel", "app_key", + "gsp", "launch_code_ttl_seconds", "ss_token_ttl_seconds", "update_time", + }), + }).Create(&row).Error; err != nil { + return nil, err + } + return s.GetProviderConfig(ctx, sysOrigin) +} diff --git a/internal/service/baishun/room.go b/internal/service/baishun/room.go index ab3fdf3..f276dd6 100644 --- a/internal/service/baishun/room.go +++ b/internal/service/baishun/room.go @@ -3,9 +3,7 @@ package baishun import ( "chatapp3-golang/internal/model" "context" - "fmt" "net/http" - "sort" "strconv" "strings" @@ -80,12 +78,13 @@ func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, roomID, c 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.currency_icon AS currency_icon, - ext.gsp AS ext_gsp, - cat.name AS catalog_name, - cat.cover AS catalog_cover, + ext.orientation AS ext_orientation, + ext.safe_height AS ext_safe_height, + ext.currency_icon AS currency_icon, + ext.gsp AS ext_gsp, + 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, @@ -107,42 +106,6 @@ func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, roomID, c for _, row := range rows { items = append(items, row.toListItem()) } - if len(items) > 0 { - return items, nil - } - - var catalogs []model.BaishunGameCatalog - if err := s.repo.DB.WithContext(ctx). - Where("sys_origin = ? AND status = ?", sysOrigin, baishunCatalogEnabled). - Order("vendor_game_id ASC"). - Find(&catalogs).Error; err != nil { - return nil, err - } - for _, catalog := range catalogs { - gameID := defaultIfBlank(catalog.InternalGameID, fmt.Sprintf("bs_%d", catalog.VendorGameID)) - items = append(items, RoomGameListItem{ - GameID: gameID, - VendorType: baishunVendorType, - VendorGameID: catalog.VendorGameID, - Name: catalog.Name, - Cover: defaultIfBlank(catalog.Cover, catalog.PreviewURL), - Category: "CHAT_ROOM", - Sort: int64(catalog.VendorGameID), - LaunchMode: baishunLaunchModeRemote, - FullScreen: true, - GameMode: baishunFixedGameMode, - SafeHeight: catalog.SafeHeight, - Orientation: derefInt(catalog.Orientation), - PackageVersion: catalog.PackageVersion, - Status: catalog.Status, - }) - } - sort.Slice(items, func(i, j int) bool { - if items[i].Sort == items[j].Sort { - return items[i].VendorGameID < items[j].VendorGameID - } - return items[i].Sort > items[j].Sort - }) return items, nil } @@ -167,12 +130,13 @@ func (s *BaishunService) findGameRow(ctx context.Context, sysOrigin, gameID stri 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.currency_icon AS currency_icon, - ext.gsp AS ext_gsp, - cat.name AS catalog_name, - cat.cover AS catalog_cover, + ext.orientation AS ext_orientation, + ext.safe_height AS ext_safe_height, + ext.currency_icon AS currency_icon, + ext.gsp AS ext_gsp, + 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, @@ -191,39 +155,62 @@ func (s *BaishunService) findGameRow(ctx context.Context, sysOrigin, gameID stri if strings.TrimSpace(row.InternalGameID) != "" { return row, nil } + return gameListRow{}, NewAppError(http.StatusNotFound, "game_not_found", "room game config not found") +} - var catalog model.BaishunGameCatalog - if err := s.repo.DB.WithContext(ctx). - Where("sys_origin = ? AND internal_game_id = ?", sysOrigin, gameID). - First(&catalog).Error; err == nil { - return gameListRow{ - SysOrigin: sysOrigin, - InternalGameID: catalog.InternalGameID, - Name: catalog.Name, - Cover: catalog.Cover, - VendorType: baishunVendorType, - VendorGameID: strconv.Itoa(catalog.VendorGameID), - LaunchMode: baishunLaunchModeRemote, - CatalogCover: catalog.Cover, - CatalogPreviewURL: catalog.PreviewURL, - CatalogDownloadURL: catalog.DownloadURL, - CatalogPackageVersion: catalog.PackageVersion, - CatalogSafeHeight: catalog.SafeHeight, - CatalogOrientation: catalog.Orientation, - CatalogStatus: catalog.Status, - GameModeRaw: formatAdminGameModeRaw(baishunFixedGameMode), - }, nil +// findGameRowByConfigID 按系统游戏配置主键查询游戏聚合信息。 +func (s *BaishunService) findGameRowByConfigID(ctx context.Context, sysOrigin string, configID int64) (gameListRow, error) { + var row gameListRow + err := s.repo.DB.WithContext(ctx). + Table("sys_game_list_config AS cfg"). + Select(` + cfg.id AS config_id, + cfg.sys_origin AS sys_origin, + 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, + cfg.game_mode AS game_mode_raw, + ext.vendor_type AS vendor_type, + 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.currency_icon AS currency_icon, + ext.gsp AS ext_gsp, + 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.orientation AS catalog_orientation, + cat.safe_height AS catalog_safe_height, + 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"). + Joins("LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id"). + Where("cfg.sys_origin = ? AND cfg.id = ? AND ext.vendor_type = ?", sysOrigin, configID, baishunVendorType). + Limit(1). + Scan(&row).Error + if err != nil { + return gameListRow{}, err + } + if row.ConfigID > 0 { + return row, nil } return gameListRow{}, NewAppError(http.StatusNotFound, "game_not_found", "room game config not found") } // resolveGSP 解析行模型中的 GSP 值。 -func (s *BaishunService) resolveGSP(row gameListRow) int { +func (s *BaishunService) resolveGSP(row gameListRow, runtimeCfg baishunRuntimeConfig) int { if parsed, err := strconv.Atoi(strings.TrimSpace(row.ExtGSP)); err == nil && parsed > 0 { return parsed } - if s.cfg.Baishun.GSP > 0 { - return s.cfg.Baishun.GSP - } - return 101 + return runtimeCfg.gsp() } diff --git a/internal/service/baishun/types.go b/internal/service/baishun/types.go index 2dcfacc..53e320e 100644 --- a/internal/service/baishun/types.go +++ b/internal/service/baishun/types.go @@ -77,20 +77,23 @@ func NewBaishunService(cfg config.Config, db baishunDB, cache redis.Cmdable, jav // RoomGameListItem 是房间游戏列表中的单个游戏项。 type RoomGameListItem struct { - GameID string `json:"gameId"` - VendorType string `json:"vendorType"` - VendorGameID int `json:"vendorGameId"` - Name string `json:"name"` - Cover string `json:"cover"` - Category string `json:"category,omitempty"` - Sort int64 `json:"sort,omitempty"` - LaunchMode string `json:"launchMode"` - FullScreen bool `json:"fullScreen"` - GameMode int `json:"gameMode"` - SafeHeight int `json:"safeHeight"` - Orientation int `json:"orientation"` - PackageVersion string `json:"packageVersion,omitempty"` - Status string `json:"status,omitempty"` + ID int64 `json:"id"` + GameID string `json:"gameId"` + GameType string `json:"gameType,omitempty"` + VendorType string `json:"vendorType"` + VendorGameID int `json:"vendorGameId"` + Name string `json:"name"` + Cover string `json:"cover"` + Category string `json:"category,omitempty"` + Sort int64 `json:"sort,omitempty"` + LaunchMode string `json:"launchMode"` + FullScreen bool `json:"fullScreen"` + GameMode int `json:"gameMode"` + SafeHeight int `json:"safeHeight"` + Orientation int `json:"orientation"` + PackageVersion string `json:"packageVersion,omitempty"` + Status string `json:"status,omitempty"` + LaunchParams map[string]any `json:"launchParams,omitempty"` } // RoomGameListResponse 是房间游戏列表响应。 @@ -112,6 +115,7 @@ type BaishunRoomStateResponse struct { // BaishunLaunchAppRequest 是启动百顺游戏入参。 type BaishunLaunchAppRequest struct { + ID int64 `json:"id,omitempty"` RoomID string `json:"roomId"` GameID string `json:"gameId"` SceneMode int `json:"sceneMode"` @@ -158,6 +162,7 @@ type BaishunLaunchEntry struct { // BaishunLaunchAppResponse 是启动游戏接口返回体。 type BaishunLaunchAppResponse struct { + ID int64 `json:"id,omitempty"` GameSessionID string `json:"gameSessionId"` VendorType string `json:"vendorType"` GameID string `json:"gameId"` @@ -183,34 +188,36 @@ type SyncCatalogResponse struct { // AdminBaishunGameItem 是后台管理页展示的百顺游戏配置行。 type AdminBaishunGameItem struct { - ID int64 `json:"id"` - SysOrigin string `json:"sysOrigin"` - GameID string `json:"gameId"` - VendorGameID int `json:"vendorGameId"` - Name string `json:"name"` - Category string `json:"category"` - GameCode string `json:"gameCode"` - Cover string `json:"cover"` - Amounts string `json:"amounts"` - Showcase bool `json:"showcase"` - Sort int64 `json:"sort"` - Height float64 `json:"height"` - Width float64 `json:"width"` - FullScreen bool `json:"fullScreen"` - ClientOrigin string `json:"clientOrigin"` - Regions string `json:"regions"` - GameMode int `json:"gameMode"` - LaunchMode string `json:"launchMode"` - PackageVersion string `json:"packageVersion"` - PreviewURL string `json:"previewUrl"` - PackageURL string `json:"packageUrl"` - Orientation int `json:"orientation"` - SafeHeight int `json:"safeHeight"` - GSP string `json:"gsp"` - CurrencyIcon string `json:"currencyIcon"` - CatalogStatus string `json:"catalogStatus"` - CreateTime string `json:"createTime"` - UpdateTime string `json:"updateTime"` + ID int64 `json:"id"` + SysOrigin string `json:"sysOrigin"` + GameID string `json:"gameId"` + GameType string `json:"gameType,omitempty"` + VendorGameID int `json:"vendorGameId"` + Name string `json:"name"` + Category string `json:"category"` + GameCode string `json:"gameCode"` + Cover string `json:"cover"` + Amounts string `json:"amounts"` + Showcase bool `json:"showcase"` + Sort int64 `json:"sort"` + Height float64 `json:"height"` + Width float64 `json:"width"` + FullScreen bool `json:"fullScreen"` + ClientOrigin string `json:"clientOrigin"` + Regions string `json:"regions"` + GameMode int `json:"gameMode"` + LaunchMode string `json:"launchMode"` + PackageVersion string `json:"packageVersion"` + PreviewURL string `json:"previewUrl"` + PackageURL string `json:"packageUrl"` + Orientation int `json:"orientation"` + SafeHeight int `json:"safeHeight"` + GSP string `json:"gsp"` + CurrencyIcon string `json:"currencyIcon"` + CatalogStatus string `json:"catalogStatus"` + LaunchParams map[string]any `json:"launchParams,omitempty"` + CreateTime string `json:"createTime"` + UpdateTime string `json:"updateTime"` } // AdminBaishunGamePageResponse 是后台页分页响应。 @@ -223,31 +230,32 @@ type AdminBaishunGamePageResponse struct { // SaveAdminBaishunGameRequest 是后台保存百顺游戏配置的入参。 type SaveAdminBaishunGameRequest struct { - ID int64 `json:"id"` - SysOrigin string `json:"sysOrigin"` - GameID string `json:"gameId"` - VendorGameID int `json:"vendorGameId"` - Name string `json:"name"` - Category string `json:"category"` - GameCode string `json:"gameCode"` - Cover string `json:"cover"` - Amounts string `json:"amounts"` - Showcase bool `json:"showcase"` - Sort int64 `json:"sort"` - Height float64 `json:"height"` - Width float64 `json:"width"` - FullScreen bool `json:"fullScreen"` - ClientOrigin string `json:"clientOrigin"` - Regions string `json:"regions"` - GameMode int `json:"gameMode"` - LaunchMode string `json:"launchMode"` - PackageVersion string `json:"packageVersion"` - PreviewURL string `json:"previewUrl"` - PackageURL string `json:"packageUrl"` - Orientation *int `json:"orientation"` - SafeHeight int `json:"safeHeight"` - GSP string `json:"gsp"` - CurrencyIcon string `json:"currencyIcon"` + ID int64 `json:"id"` + SysOrigin string `json:"sysOrigin"` + GameID string `json:"gameId"` + VendorGameID int `json:"vendorGameId"` + Name string `json:"name"` + Category string `json:"category"` + GameCode string `json:"gameCode"` + Cover string `json:"cover"` + Amounts string `json:"amounts"` + Showcase bool `json:"showcase"` + Sort int64 `json:"sort"` + Height float64 `json:"height"` + Width float64 `json:"width"` + FullScreen bool `json:"fullScreen"` + ClientOrigin string `json:"clientOrigin"` + Regions string `json:"regions"` + GameMode int `json:"gameMode"` + LaunchMode string `json:"launchMode"` + PackageVersion string `json:"packageVersion"` + PreviewURL string `json:"previewUrl"` + PackageURL string `json:"packageUrl"` + Orientation *int `json:"orientation"` + SafeHeight int `json:"safeHeight"` + GSP string `json:"gsp"` + CurrencyIcon string `json:"currencyIcon"` + LaunchParams map[string]any `json:"launchParams,omitempty"` } // ImportCatalogResponse 描述从本地目录补齐后台配置的结果。 @@ -265,6 +273,60 @@ type SyncAdminCatalogResponse struct { Imported int `json:"imported"` } +// BaishunProviderConfigItem 是后台页展示的百顺配置。 +type BaishunProviderConfigItem struct { + ID int64 `json:"id"` + SysOrigin string `json:"sysOrigin"` + PlatformBaseURL string `json:"platformBaseUrl"` + AppID int64 `json:"appId"` + AppName string `json:"appName"` + AppChannel string `json:"appChannel"` + AppKey string `json:"appKey"` + GSP int `json:"gsp"` + LaunchCodeTTLSeconds int `json:"launchCodeTtlSeconds"` + SSTokenTTLSeconds int `json:"ssTokenTtlSeconds"` + UpdateTime string `json:"updateTime"` +} + +// SaveBaishunProviderConfigRequest 是后台保存百顺配置的入参。 +type SaveBaishunProviderConfigRequest struct { + SysOrigin string `json:"sysOrigin"` + PlatformBaseURL string `json:"platformBaseUrl"` + AppID int64 `json:"appId"` + AppName string `json:"appName"` + AppChannel string `json:"appChannel"` + AppKey string `json:"appKey"` + GSP int `json:"gsp"` + LaunchCodeTTLSeconds int `json:"launchCodeTtlSeconds"` + SSTokenTTLSeconds int `json:"ssTokenTtlSeconds"` +} + +// AdminBaishunCatalogItem 是后台目录页展示的单个百顺目录项。 +type AdminBaishunCatalogItem struct { + VendorGameID int `json:"vendorGameId"` + GameID string `json:"gameId"` + Name string `json:"name"` + Cover string `json:"cover"` + PreviewURL string `json:"previewUrl"` + DownloadURL string `json:"downloadUrl"` + PackageVersion string `json:"packageVersion"` + GSP string `json:"gsp"` + Status string `json:"status"` + SafeHeight int `json:"safeHeight"` + Orientation int `json:"orientation"` + Added bool `json:"added"` + Showcase bool `json:"showcase"` + UpdateTime string `json:"updateTime"` +} + +// AdminBaishunCatalogPageResponse 是后台目录页分页响应。 +type AdminBaishunCatalogPageResponse struct { + Cursor int `json:"cursor"` + Limit int `json:"limit"` + Records []AdminBaishunCatalogItem `json:"records"` + Total int64 `json:"total"` +} + // BaishunTokenRequest 是百顺拉取 token/code 回调入参。 type BaishunTokenRequest struct { AppID int64 `json:"app_id"` @@ -365,6 +427,7 @@ type gameListRow struct { ExtSafeHeight int CurrencyIcon string ExtGSP string + ExtExtraJSON string CatalogName string CatalogCover string CatalogPreviewURL string diff --git a/internal/service/gameopen/aliases.go b/internal/service/gameopen/aliases.go new file mode 100644 index 0000000..51f960c --- /dev/null +++ b/internal/service/gameopen/aliases.go @@ -0,0 +1,7 @@ +package gameopen + +import "chatapp3-golang/internal/common" + +type AppError = common.AppError + +var NewAppError = common.NewAppError diff --git a/internal/service/gameopen/convert.go b/internal/service/gameopen/convert.go new file mode 100644 index 0000000..9d32395 --- /dev/null +++ b/internal/service/gameopen/convert.go @@ -0,0 +1,58 @@ +package gameopen + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "strings" +) + +func buildQueryUserSign(req QueryUserRequest, key string) string { + return md5Hex(req.GameID + req.UID + req.Token + req.RoomID + key) +} + +func buildUpdateCoinSign(req UpdateCoinRequest, key string) string { + return md5Hex( + req.OrderID + + req.GameID + + req.RoundID + + req.UID + + fmt.Sprintf("%d", req.Coin) + + fmt.Sprintf("%d", req.Type) + + fmt.Sprintf("%d", req.RewardType) + + req.Token + + req.WinID + + req.RoomID + + key, + ) +} + +func buildSupplementSign(req SupplementRequest, key string) string { + return md5Hex( + req.OrderID + + req.GameID + + req.RoundID + + req.UID + + fmt.Sprintf("%d", req.Coin) + + fmt.Sprintf("%d", req.RewardType) + + req.WinID + + req.RoomID + + key, + ) +} + +func verifySign(actual, expected string) bool { + return strings.EqualFold(strings.TrimSpace(actual), strings.TrimSpace(expected)) +} + +func receiptTypeFromType(typed int) string { + if typed == 2 { + return "INCOME" + } + return "EXPENDITURE" +} + +func md5Hex(value string) string { + sum := md5.Sum([]byte(value)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/service/gameopen/service.go b/internal/service/gameopen/service.go new file mode 100644 index 0000000..7610bea --- /dev/null +++ b/internal/service/gameopen/service.go @@ -0,0 +1,306 @@ +package gameopen + +import ( + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + "context" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +// HandleQueryUser 处理统一查询用户信息回调。 +func (s *GameOpenService) HandleQueryUser(ctx context.Context, req QueryUserRequest, rawJSON string) CallbackResponse { + if err := s.validateQueryUserRequest(req); err != nil { + resp := failResponse(gameOpenCodeBadRequest, err.Error()) + s.saveCallbackLog(ctx, "query-user", rawJSON, resp, "", req, nil, gameOpenLogStatusFailed) + return resp + } + if !verifySign(req.Sign, buildQueryUserSign(req, s.cfg.GameOpen.AppKey)) { + resp := failResponse(gameOpenCodeSignatureError, "signature error") + s.saveCallbackLog(ctx, "query-user", rawJSON, resp, "", req, nil, gameOpenLogStatusFailed) + return resp + } + + credential, err := s.java.AuthenticateToken(ctx, req.Token) + if err != nil { + resp := failResponse(gameOpenCodeTokenInvalid, "token invalid") + s.saveCallbackLog(ctx, "query-user", rawJSON, resp, "", req, nil, gameOpenLogStatusFailed) + return resp + } + userID := int64(credential.UserID) + if req.UID != strconv.FormatInt(userID, 10) { + resp := failResponse(gameOpenCodeTokenInvalid, "uid token mismatch") + s.saveCallbackLog(ctx, "query-user", rawJSON, resp, credential.SysOrigin, req, &userID, gameOpenLogStatusFailed) + return resp + } + + profile, err := s.java.GetUserProfile(ctx, userID) + if err != nil { + resp := failResponse(gameOpenCodeServerError, "profile load failed") + s.saveCallbackLog(ctx, "query-user", rawJSON, resp, credential.SysOrigin, req, &userID, gameOpenLogStatusFailed) + return resp + } + balance, err := s.readUserBalance(ctx, userID) + if err != nil { + resp := failResponse(gameOpenCodeServerError, "balance load failed") + s.saveCallbackLog(ctx, "query-user", rawJSON, resp, credential.SysOrigin, req, &userID, gameOpenLogStatusFailed) + return resp + } + + resp := successResponse(map[string]any{ + "uid": req.UID, + "nickname": profile.UserNickname, + "avatar": profile.UserAvatar, + "coin": balance, + }) + s.saveCallbackLog(ctx, "query-user", rawJSON, resp, credential.SysOrigin, req, &userID, gameOpenLogStatusSuccess) + return resp +} + +// HandleUpdateCoin 处理统一游戏币变更回调。 +func (s *GameOpenService) HandleUpdateCoin(ctx context.Context, req UpdateCoinRequest, rawJSON string) CallbackResponse { + if err := s.validateUpdateCoinRequest(req); err != nil { + resp := failResponse(gameOpenCodeBadRequest, err.Error()) + s.saveCallbackLog(ctx, "update-coin", rawJSON, resp, "", req, nil, gameOpenLogStatusFailed) + return resp + } + if !verifySign(req.Sign, buildUpdateCoinSign(req, s.cfg.GameOpen.AppKey)) { + resp := failResponse(gameOpenCodeSignatureError, "signature error") + s.saveCallbackLog(ctx, "update-coin", rawJSON, resp, "", req, nil, gameOpenLogStatusFailed) + return resp + } + + credential, err := s.java.AuthenticateToken(ctx, req.Token) + if err != nil { + resp := failResponse(gameOpenCodeTokenInvalid, "token invalid") + s.saveCallbackLog(ctx, "update-coin", rawJSON, resp, "", req, nil, gameOpenLogStatusFailed) + return resp + } + userID := int64(credential.UserID) + if req.UID != strconv.FormatInt(userID, 10) { + resp := failResponse(gameOpenCodeTokenInvalid, "uid token mismatch") + s.saveCallbackLog(ctx, "update-coin", rawJSON, resp, credential.SysOrigin, req, &userID, gameOpenLogStatusFailed) + return resp + } + + eventID := "GAME_OPEN:" + strings.TrimSpace(req.OrderID) + exists, err := s.java.ExistsGoldEvent(ctx, eventID) + if err != nil { + resp := failResponse(gameOpenCodeServerError, "event check failed") + s.saveCallbackLog(ctx, "update-coin", rawJSON, resp, credential.SysOrigin, req, &userID, gameOpenLogStatusFailed) + return resp + } + if !exists { + if err := s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ + ReceiptType: receiptTypeFromType(req.Type), + UserID: userID, + SysOrigin: credential.SysOrigin, + EventID: eventID, + Remark: fmt.Sprintf("game=%s rewardType=%d round=%s", req.GameID, req.RewardType, req.RoundID), + Amount: integration.NewPennyAmountPayloadFromDollar(req.Coin), + CloseDelayAsset: false, + OpUserType: "APP", + CustomizeOrigin: "GAME_OPEN_" + strings.TrimSpace(req.GameID), + CustomizeOriginDesc: "GAME OPEN[" + strings.TrimSpace(req.GameID) + "]", + }); err != nil { + resp := failResponse(gameOpenCodeServerError, err.Error()) + s.saveCallbackLog(ctx, "update-coin", rawJSON, resp, credential.SysOrigin, req, &userID, gameOpenLogStatusFailed) + return resp + } + } + + balance, err := s.readUserBalance(ctx, userID) + if err != nil { + resp := failResponse(gameOpenCodeServerError, "balance load failed") + s.saveCallbackLog(ctx, "update-coin", rawJSON, resp, credential.SysOrigin, req, &userID, gameOpenLogStatusFailed) + return resp + } + + resp := successResponse(map[string]any{"coin": balance}) + s.saveCallbackLog(ctx, "update-coin", rawJSON, resp, credential.SysOrigin, req, &userID, gameOpenLogStatusSuccess) + return resp +} + +// HandleSupplement 处理统一补单回调,当前只做签名校验和日志落库。 +func (s *GameOpenService) HandleSupplement(ctx context.Context, req SupplementRequest, rawJSON string) CallbackResponse { + if err := s.validateSupplementRequest(req); err != nil { + resp := failResponse(gameOpenCodeBadRequest, err.Error()) + s.saveCallbackLog(ctx, "supplement", rawJSON, resp, "", req, nil, gameOpenLogStatusFailed) + return resp + } + if !verifySign(req.Sign, buildSupplementSign(req, s.cfg.GameOpen.AppKey)) { + resp := failResponse(gameOpenCodeSignatureError, "signature error") + s.saveCallbackLog(ctx, "supplement", rawJSON, resp, "", req, nil, gameOpenLogStatusFailed) + return resp + } + + userID := parseInt64Default(req.UID) + balance, err := s.readUserBalance(ctx, userID) + if err != nil { + resp := failResponse(gameOpenCodeServerError, "balance load failed") + s.saveCallbackLog(ctx, "supplement", rawJSON, resp, "", req, &userID, gameOpenLogStatusFailed) + return resp + } + + resp := successResponse(map[string]any{"coin": balance}) + s.saveCallbackLog(ctx, "supplement", rawJSON, resp, "", req, &userID, gameOpenLogStatusPending) + return resp +} + +// GetIntegrationInfo 返回当前统一三方游戏联调信息。 +func (s *GameOpenService) GetIntegrationInfo(ctx context.Context, publicBaseURL string) (*IntegrationInfoResponse, error) { + baseURL := strings.TrimRight(strings.TrimSpace(publicBaseURL), "/") + if baseURL == "" { + baseURL = strings.TrimRight(strings.TrimSpace(s.cfg.GameOpen.PublicBaseURL), "/") + } + if baseURL == "" { + return nil, NewAppError(http.StatusServiceUnavailable, "public_base_url_required", "public base url is required") + } + if s.cfg.GameOpen.TestUserID <= 0 { + return nil, NewAppError(http.StatusServiceUnavailable, "test_user_required", "test user is required") + } + token, err := s.java.CreateIfAbsentUserCredentialToken(ctx, s.cfg.GameOpen.TestUserID) + if err != nil { + return nil, err + } + return &IntegrationInfoResponse{ + Domain: baseURL, + QueryUser: baseURL + "/game/open/user-info", + UpdateCoin: baseURL + "/game/open/update-coin", + Supplement: baseURL + "/game/open/supplement", + UID: defaultIfBlank(s.cfg.GameOpen.TestAccount, strconv.FormatInt(s.cfg.GameOpen.TestUserID, 10)), + Password: s.cfg.GameOpen.TestPassword, + Token: strings.TrimSpace(token), + Key: s.cfg.GameOpen.AppKey, + }, nil +} + +func (s *GameOpenService) readUserBalance(ctx context.Context, userID int64) (int64, error) { + balances, err := s.java.MapGoldBalance(ctx, []int64{userID}) + if err != nil { + return 0, err + } + return balances[userID], nil +} + +func (s *GameOpenService) saveCallbackLog(ctx context.Context, requestType, rawJSON string, resp CallbackResponse, sysOrigin string, req any, userID *int64, status string) { + if s == nil || s.repo.DB == nil { + return + } + id, err := utils.NextID() + if err != nil { + return + } + + record := model.GameOpenCallbackLog{ + ID: id, + RequestType: requestType, + SysOrigin: strings.TrimSpace(sysOrigin), + RequestJSON: rawJSON, + ResponseJSON: utils.MustJSONString(resp, ""), + BizCode: resp.ErrorCode, + BizMessage: resp.ErrorMsg, + Status: status, + CreateTime: time.Now(), + UpdateTime: time.Now(), + } + if userID != nil && *userID > 0 { + value := *userID + record.UserID = &value + } + switch typed := req.(type) { + case QueryUserRequest: + record.GameID = typed.GameID + record.RoomID = typed.RoomID + case UpdateCoinRequest: + record.OrderID = typed.OrderID + record.GameID = typed.GameID + record.RoundID = typed.RoundID + record.RoomID = typed.RoomID + case SupplementRequest: + record.OrderID = typed.OrderID + record.GameID = typed.GameID + record.RoundID = typed.RoundID + record.RoomID = typed.RoomID + } + _ = s.repo.DB.WithContext(ctx).Create(&record).Error +} + +func (s *GameOpenService) validateQueryUserRequest(req QueryUserRequest) 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") + case strings.TrimSpace(s.cfg.GameOpen.AppKey) == "": + return newBadRequest("app key is blank") + default: + return nil + } +} + +func (s *GameOpenService) validateUpdateCoinRequest(req UpdateCoinRequest) 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(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") + case strings.TrimSpace(s.cfg.GameOpen.AppKey) == "": + return newBadRequest("app key is blank") + default: + return nil + } +} + +func (s *GameOpenService) validateSupplementRequest(req SupplementRequest) 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(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 strings.TrimSpace(req.Sign) == "": + return newBadRequest("sign is required") + case strings.TrimSpace(s.cfg.GameOpen.AppKey) == "": + return newBadRequest("app key is blank") + default: + return nil + } +} + +func defaultIfBlank(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return strings.TrimSpace(value) +} + +func parseInt64Default(value string) int64 { + parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + return parsed +} diff --git a/internal/service/gameopen/service_test.go b/internal/service/gameopen/service_test.go new file mode 100644 index 0000000..39cdfde --- /dev/null +++ b/internal/service/gameopen/service_test.go @@ -0,0 +1,133 @@ +package gameopen + +import ( + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "context" + "testing" +) + +type stubGateway struct { + credential integration.UserCredential + token string + profile integration.UserProfile + balance int64 + exists bool + cmd integration.GoldReceiptCommand +} + +func (s *stubGateway) AuthenticateToken(context.Context, string) (integration.UserCredential, error) { + return s.credential, nil +} + +func (s *stubGateway) CreateIfAbsentUserCredentialToken(context.Context, int64) (string, error) { + return s.token, nil +} + +func (s *stubGateway) GetUserProfile(context.Context, int64) (integration.UserProfile, error) { + return s.profile, nil +} + +func (s *stubGateway) MapGoldBalance(context.Context, []int64) (map[int64]int64, error) { + return map[int64]int64{int64(s.credential.UserID): s.balance}, nil +} + +func (s *stubGateway) ExistsGoldEvent(context.Context, string) (bool, error) { + return s.exists, nil +} + +func (s *stubGateway) ChangeGoldBalance(_ context.Context, cmd integration.GoldReceiptCommand) error { + s.cmd = cmd + return nil +} + +func TestHandleQueryUser(t *testing.T) { + gateway := &stubGateway{ + credential: integration.UserCredential{UserID: 1234567, SysOrigin: "LIKEI"}, + profile: integration.UserProfile{ + ID: 1234567, + UserNickname: "tester", + UserAvatar: "https://example.com/avatar.png", + }, + balance: 88, + } + service := NewGameOpenService(config.Config{ + GameOpen: config.GameOpenConfig{AppKey: "game-open-test-key"}, + }, nil, gateway) + req := QueryUserRequest{ + GameID: "101", + UID: "1234567", + Token: "token", + RoomID: "room-1", + } + req.Sign = buildQueryUserSign(req, "game-open-test-key") + + resp := service.HandleQueryUser(context.Background(), req, "{}") + if resp.ErrorCode != 0 { + t.Fatalf("HandleQueryUser() error = %+v", resp) + } + data, ok := resp.Data.(map[string]any) + if !ok { + t.Fatalf("HandleQueryUser() data type = %T", resp.Data) + } + if got := data["coin"]; got != int64(88) { + t.Fatalf("HandleQueryUser() coin = %v, want 88", got) + } +} + +func TestHandleUpdateCoin(t *testing.T) { + gateway := &stubGateway{ + credential: integration.UserCredential{UserID: 1234567, SysOrigin: "LIKEI"}, + balance: 99, + } + service := NewGameOpenService(config.Config{ + GameOpen: config.GameOpenConfig{AppKey: "game-open-test-key"}, + }, nil, gateway) + req := UpdateCoinRequest{ + OrderID: "order-1", + GameID: "101", + RoundID: "round-1", + UID: "1234567", + Coin: 15, + Type: 2, + RewardType: 2, + Token: "token", + WinID: "win-1", + RoomID: "room-1", + } + req.Sign = buildUpdateCoinSign(req, "game-open-test-key") + + resp := service.HandleUpdateCoin(context.Background(), req, "{}") + if resp.ErrorCode != 0 { + t.Fatalf("HandleUpdateCoin() error = %+v", resp) + } + if gateway.cmd.EventID != "GAME_OPEN:order-1" { + t.Fatalf("HandleUpdateCoin() event id = %q", gateway.cmd.EventID) + } + if gateway.cmd.ReceiptType != "INCOME" { + t.Fatalf("HandleUpdateCoin() receipt type = %q", gateway.cmd.ReceiptType) + } +} + +func TestGetIntegrationInfo(t *testing.T) { + gateway := &stubGateway{token: "token-123"} + service := NewGameOpenService(config.Config{ + GameOpen: config.GameOpenConfig{ + AppKey: "game-open-test-key", + TestUserID: 1234567, + TestAccount: "1234567", + TestPassword: "1234567", + }, + }, nil, gateway) + + info, err := service.GetIntegrationInfo(context.Background(), "https://jvapi.haiyihy.com") + if err != nil { + t.Fatalf("GetIntegrationInfo() error = %v", err) + } + if info.QueryUser != "https://jvapi.haiyihy.com/game/open/user-info" { + t.Fatalf("GetIntegrationInfo() query url = %q", info.QueryUser) + } + if info.Token != "token-123" { + t.Fatalf("GetIntegrationInfo() token = %q", info.Token) + } +} diff --git a/internal/service/gameopen/types.go b/internal/service/gameopen/types.go new file mode 100644 index 0000000..14ecc58 --- /dev/null +++ b/internal/service/gameopen/types.go @@ -0,0 +1,126 @@ +package gameopen + +import ( + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "context" + "net/http" + + "gorm.io/gorm" +) + +const ( + gameOpenCodeSuccess = 0 + gameOpenCodeBadRequest = 4001 + gameOpenCodeSignatureError = 4002 + gameOpenCodeTokenInvalid = 4003 + gameOpenCodeServerError = 5000 + + gameOpenLogStatusSuccess = "SUCCESS" + gameOpenLogStatusFailed = "FAILED" + gameOpenLogStatusPending = "PENDING" +) + +type gameOpenDB interface { + WithContext(ctx context.Context) *gorm.DB +} + +type gameOpenGateway interface { + AuthenticateToken(ctx context.Context, token string) (integration.UserCredential, error) + CreateIfAbsentUserCredentialToken(ctx context.Context, userID int64) (string, error) + GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error) + MapGoldBalance(ctx context.Context, userIDs []int64) (map[int64]int64, error) + ExistsGoldEvent(ctx context.Context, eventID string) (bool, error) + ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error +} + +type gameOpenPorts struct { + DB gameOpenDB +} + +// GameOpenService 负责统一三方游戏回调接口。 +type GameOpenService struct { + cfg config.Config + repo gameOpenPorts + java gameOpenGateway +} + +// NewGameOpenService 创建统一三方游戏回调服务。 +func NewGameOpenService(cfg config.Config, db gameOpenDB, javaGateway gameOpenGateway) *GameOpenService { + if javaGateway == nil { + return nil + } + return &GameOpenService{ + cfg: cfg, + repo: gameOpenPorts{DB: db}, + java: javaGateway, + } +} + +// QueryUserRequest 是统一查询用户信息入参。 +type QueryUserRequest struct { + GameID string `json:"gameId"` + UID string `json:"uid"` + Token string `json:"token"` + RoomID string `json:"roomId"` + Sign string `json:"sign"` +} + +// UpdateCoinRequest 是统一更新游戏币入参。 +type UpdateCoinRequest struct { + OrderID string `json:"orderId"` + GameID string `json:"gameId"` + RoundID string `json:"roundId"` + UID string `json:"uid"` + Coin int64 `json:"coin"` + Type int `json:"type"` + RewardType int `json:"rewardType"` + Token string `json:"token"` + WinID string `json:"winId"` + RoomID string `json:"roomId"` + Sign string `json:"sign"` +} + +// SupplementRequest 是统一补单入参。 +type SupplementRequest struct { + OrderID string `json:"orderId"` + GameID string `json:"gameId"` + RoundID string `json:"roundId"` + UID string `json:"uid"` + Coin int64 `json:"coin"` + RewardType int `json:"rewardType"` + WinID string `json:"winId"` + RoomID string `json:"roomId"` + Sign string `json:"sign"` +} + +// CallbackResponse 是统一三方回调响应。 +type CallbackResponse struct { + ErrorCode int `json:"errorCode"` + ErrorMsg string `json:"errorMsg,omitempty"` + Data any `json:"data,omitempty"` +} + +// IntegrationInfoResponse 描述要提供给三方的联调信息。 +type IntegrationInfoResponse struct { + Domain string `json:"domain"` + QueryUser string `json:"queryUser"` + UpdateCoin string `json:"updateCoin"` + Supplement string `json:"supplement"` + UID string `json:"uid"` + Password string `json:"password,omitempty"` + Token string `json:"token"` + Key string `json:"key"` +} + +func successResponse(data any) CallbackResponse { + return CallbackResponse{ErrorCode: gameOpenCodeSuccess, Data: data} +} + +func failResponse(code int, message string) CallbackResponse { + return CallbackResponse{ErrorCode: code, ErrorMsg: message} +} + +func newBadRequest(message string) error { + return NewAppError(http.StatusBadRequest, "bad_request", message) +} diff --git a/internal/service/gameprovider/registry.go b/internal/service/gameprovider/registry.go new file mode 100644 index 0000000..58b9aa8 --- /dev/null +++ b/internal/service/gameprovider/registry.go @@ -0,0 +1,229 @@ +package gameprovider + +import ( + "chatapp3-golang/internal/common" + "context" + "net/http" + "sort" + "strings" +) + +// Registry 保存所有已注册的游戏厂商。 +type Registry struct { + providers map[string]Provider + ordered []Provider +} + +// NewRegistry 创建厂商注册表。 +func NewRegistry(providers ...Provider) *Registry { + r := &Registry{ + providers: make(map[string]Provider, len(providers)), + ordered: make([]Provider, 0, len(providers)), + } + for _, provider := range providers { + r.Register(provider) + } + return r +} + +// Register 注册一个游戏厂商。 +func (r *Registry) Register(provider Provider) { + if provider == nil { + return + } + key := normalizeKey(provider.Key()) + if key == "" { + panic("gameprovider: empty provider key") + } + if _, exists := r.providers[key]; exists { + panic("gameprovider: duplicate provider key " + key) + } + r.providers[key] = provider + r.ordered = append(r.ordered, provider) +} + +// Resolve 根据名称解析厂商,名称大小写不敏感。 +func (r *Registry) Resolve(key string) (Provider, error) { + if r == nil { + return nil, common.NewAppError(http.StatusNotFound, "provider_not_found", "game provider not found") + } + provider, ok := r.providers[normalizeKey(key)] + if !ok { + return nil, common.NewAppError(http.StatusNotFound, "provider_not_found", "game provider not found") + } + return provider, nil +} + +// List 返回已注册厂商列表。 +func (r *Registry) List() ProviderListResponse { + if r == nil || len(r.ordered) == 0 { + return ProviderListResponse{Items: []ProviderSummary{}} + } + items := make([]ProviderSummary, 0, len(r.ordered)) + for _, provider := range r.ordered { + items = append(items, ProviderSummary{ + Key: provider.Key(), + DisplayName: provider.DisplayName(), + }) + } + return ProviderListResponse{Items: items} +} + +// ListShortcutGames 返回聚合后的快捷游戏列表。 +func (r *Registry) ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) { + if r == nil || len(r.ordered) == 0 { + return []RoomGameListItem{}, nil + } + + items := make([]RoomGameListItem, 0, len(r.ordered)) + for _, provider := range r.ordered { + resp, err := provider.ListShortcutGames(ctx, user, roomID) + if err != nil { + return nil, err + } + items = append(items, resp...) + } + sortRoomGameListItems(items) + if len(items) > 5 { + items = items[:5] + } + return items, nil +} + +// ListRoomGames 返回聚合后的房间游戏列表。 +func (r *Registry) ListRoomGames(ctx context.Context, user AuthUser, roomID, category string) (*RoomGameListResponse, error) { + if r == nil || len(r.ordered) == 0 { + return &RoomGameListResponse{Items: []RoomGameListItem{}}, nil + } + + items := make([]RoomGameListItem, 0, len(r.ordered)) + for _, provider := range r.ordered { + resp, err := provider.ListRoomGames(ctx, user, roomID, category) + if err != nil { + return nil, err + } + if resp != nil { + items = append(items, resp.Items...) + } + } + sortRoomGameListItems(items) + return &RoomGameListResponse{Items: items}, nil +} + +// ResolveByLaunchRequest 根据启动请求解析内部对应的厂商。 +func (r *Registry) ResolveByLaunchRequest(ctx context.Context, user AuthUser, req LaunchRequest) (Provider, error) { + if r == nil { + return nil, common.NewAppError(http.StatusNotFound, "provider_not_found", "game provider not found") + } + if req.ID <= 0 && strings.TrimSpace(req.GameID) == "" { + return nil, common.NewAppError(http.StatusBadRequest, "missing_game_id", "id or gameId is required") + } + for _, provider := range r.ordered { + ok, err := provider.SupportsLaunch(ctx, user, req) + if err != nil { + return nil, err + } + if ok { + return provider, nil + } + } + return nil, common.NewAppError(http.StatusNotFound, "game_not_found", "game provider not found for gameId") +} + +// GetRoomState 返回聚合后的房间状态。 +func (r *Registry) GetRoomState(ctx context.Context, user AuthUser, roomID string) (*RoomStateResponse, error) { + if strings.TrimSpace(roomID) == "" { + return nil, common.NewAppError(http.StatusBadRequest, "missing_room_id", "roomId is required") + } + state, _, err := r.resolveProviderByRoomState(ctx, user, roomID, "") + if err != nil { + return nil, err + } + if state != nil { + return state, nil + } + return &RoomStateResponse{RoomID: roomID, State: roomStateIdle}, nil +} + +// LaunchGame 按 gameId 自动分发到内部对应的厂商实现。 +func (r *Registry) LaunchGame(ctx context.Context, user AuthUser, req LaunchRequest, clientIP string) (*LaunchResponse, error) { + provider, err := r.ResolveByLaunchRequest(ctx, user, req) + if err != nil { + return nil, err + } + return provider.LaunchGame(ctx, user, req, clientIP) +} + +// CloseGame 根据当前房间状态自动路由到真实厂商。 +func (r *Registry) CloseGame(ctx context.Context, user AuthUser, req CloseRequest) (*RoomStateResponse, error) { + if strings.TrimSpace(req.RoomID) == "" { + return nil, common.NewAppError(http.StatusBadRequest, "missing_room_id", "roomId is required") + } + _, provider, err := r.resolveProviderByRoomState(ctx, user, req.RoomID, req.GameSessionID) + if err != nil { + return nil, err + } + if provider == nil { + return &RoomStateResponse{RoomID: req.RoomID, State: roomStateIdle}, nil + } + return provider.CloseGame(ctx, user, req) +} + +const roomStateIdle = "IDLE" + +func (r *Registry) resolveProviderByRoomState(ctx context.Context, user AuthUser, roomID, gameSessionID string) (*RoomStateResponse, Provider, error) { + if r == nil { + return nil, nil, common.NewAppError(http.StatusNotFound, "provider_not_found", "game provider not found") + } + + sessionID := strings.TrimSpace(gameSessionID) + var activeState *RoomStateResponse + var activeProvider Provider + for _, provider := range r.ordered { + state, err := provider.GetRoomState(ctx, user, roomID) + if err != nil { + return nil, nil, err + } + if state == nil { + continue + } + if sessionID != "" && strings.EqualFold(strings.TrimSpace(state.GameSessionID), sessionID) { + return state, provider, nil + } + if !isIdleRoomState(state) && activeProvider == nil { + activeState = state + activeProvider = provider + } + } + return activeState, activeProvider, nil +} + +func isIdleRoomState(state *RoomStateResponse) bool { + if state == nil { + return true + } + if strings.TrimSpace(state.GameSessionID) != "" { + return false + } + value := strings.ToUpper(strings.TrimSpace(state.State)) + if value == "" { + value = roomStateIdle + } + return value == roomStateIdle +} + +func sortRoomGameListItems(items []RoomGameListItem) { + sort.SliceStable(items, func(i, j int) bool { + if items[i].Sort != items[j].Sort { + return items[i].Sort > items[j].Sort + } + if items[i].GameID != items[j].GameID { + return items[i].GameID < items[j].GameID + } + return strings.ToUpper(strings.TrimSpace(items[i].GameType)) < strings.ToUpper(strings.TrimSpace(items[j].GameType)) + }) +} + +func normalizeKey(key string) string { + return strings.ToUpper(strings.TrimSpace(key)) +} diff --git a/internal/service/gameprovider/registry_test.go b/internal/service/gameprovider/registry_test.go new file mode 100644 index 0000000..88f4ecc --- /dev/null +++ b/internal/service/gameprovider/registry_test.go @@ -0,0 +1,161 @@ +package gameprovider + +import ( + "context" + "testing" +) + +type stubProvider struct { + key string + name string + matchIDs map[int64]bool + matchIDsByGame map[string]bool + shortcut []RoomGameListItem + room []RoomGameListItem + state *RoomStateResponse +} + +func (s *stubProvider) Key() string { return s.key } + +func (s *stubProvider) DisplayName() string { return s.name } + +func (s *stubProvider) SupportsLaunch(_ context.Context, _ AuthUser, req LaunchRequest) (bool, error) { + if req.ID > 0 { + if s.matchIDs == nil { + return false, nil + } + return s.matchIDs[req.ID], nil + } + if s.matchIDsByGame == nil { + return false, nil + } + return s.matchIDsByGame[req.GameID], nil +} + +func (s *stubProvider) ListShortcutGames(context.Context, AuthUser, string) ([]RoomGameListItem, error) { + return append([]RoomGameListItem(nil), s.shortcut...), nil +} + +func (s *stubProvider) ListRoomGames(context.Context, AuthUser, string, string) (*RoomGameListResponse, error) { + return &RoomGameListResponse{Items: append([]RoomGameListItem(nil), s.room...)}, nil +} + +func (s *stubProvider) GetRoomState(context.Context, AuthUser, string) (*RoomStateResponse, error) { + if s.state == nil { + return &RoomStateResponse{State: roomStateIdle}, nil + } + copied := *s.state + return &copied, nil +} + +func (s *stubProvider) LaunchGame(context.Context, AuthUser, LaunchRequest, string) (*LaunchResponse, error) { + return &LaunchResponse{}, nil +} + +func (s *stubProvider) CloseGame(context.Context, AuthUser, CloseRequest) (*RoomStateResponse, error) { + return &RoomStateResponse{}, nil +} + +func TestRegistryResolveIsCaseInsensitive(t *testing.T) { + registry := NewRegistry(&stubProvider{key: "BAISHUN", name: "百顺"}) + + provider, err := registry.Resolve("baishun") + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if provider.Key() != "BAISHUN" { + t.Fatalf("Resolve() key = %q, want %q", provider.Key(), "BAISHUN") + } +} + +func TestRegistryListKeepsRegistrationOrder(t *testing.T) { + registry := NewRegistry( + &stubProvider{key: "BAISHUN", name: "百顺"}, + &stubProvider{key: "LINGXIAN", name: "灵仙"}, + ) + + list := registry.List() + if len(list.Items) != 2 { + t.Fatalf("List() items = %d, want 2", len(list.Items)) + } + if list.Items[0].Key != "BAISHUN" || list.Items[1].Key != "LINGXIAN" { + t.Fatalf("List() order = %#v", list.Items) + } +} + +func TestRegistryListRoomGamesAggregatesAndSorts(t *testing.T) { + registry := NewRegistry( + &stubProvider{ + key: "BAISHUN", + name: "百顺", + room: []RoomGameListItem{{GameID: "b", Sort: 10}, {GameID: "d", Sort: 10}}, + }, + &stubProvider{ + key: "LINGXIAN", + name: "灵仙", + room: []RoomGameListItem{{GameID: "a", Sort: 30}, {GameID: "c", Sort: 10}}, + }, + ) + + resp, err := registry.ListRoomGames(context.Background(), AuthUser{}, "room-1", "") + if err != nil { + t.Fatalf("ListRoomGames() error = %v", err) + } + if len(resp.Items) != 4 { + t.Fatalf("ListRoomGames() items = %d, want 4", len(resp.Items)) + } + got := []string{resp.Items[0].GameID, resp.Items[1].GameID, resp.Items[2].GameID, resp.Items[3].GameID} + want := []string{"a", "b", "c", "d"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("ListRoomGames() order = %#v, want %#v", got, want) + } + } +} + +func TestRegistryLaunchGameResolvesProviderByGameID(t *testing.T) { + baishunProvider := &stubProvider{ + key: "BAISHUN", + name: "百顺", + matchIDs: map[int64]bool{101: true}, + } + lingxianProvider := &stubProvider{ + key: "LINGXIAN", + name: "灵仙", + matchIDs: map[int64]bool{202: true}, + } + registry := NewRegistry(baishunProvider, lingxianProvider) + + provider, err := registry.ResolveByLaunchRequest(context.Background(), AuthUser{}, LaunchRequest{ID: 202}) + if err != nil { + t.Fatalf("ResolveByLaunchRequest() error = %v", err) + } + if provider.Key() != "LINGXIAN" { + t.Fatalf("ResolveByLaunchRequest() key = %q, want %q", provider.Key(), "LINGXIAN") + } +} + +func TestRegistryCloseGameUsesMatchingSessionProvider(t *testing.T) { + baishunProvider := &stubProvider{ + key: "BAISHUN", + name: "百顺", + state: &RoomStateResponse{RoomID: "room-1", State: "PLAYING", GameSessionID: "bs-session", Provider: "BAISHUN"}, + } + lingxianProvider := &stubProvider{ + key: "LINGXIAN", + name: "灵仙", + state: &RoomStateResponse{RoomID: "room-1", State: roomStateIdle, Provider: "LINGXIAN"}, + } + registry := NewRegistry(baishunProvider, lingxianProvider) + + state, provider, err := registry.resolveProviderByRoomState(context.Background(), AuthUser{}, "room-1", "bs-session") + if err != nil { + t.Fatalf("resolveProviderByRoomState() error = %v", err) + } + if provider == nil || provider.Key() != "BAISHUN" { + t.Fatalf("resolveProviderByRoomState() provider = %#v", provider) + } + if state == nil || state.GameSessionID != "bs-session" { + t.Fatalf("resolveProviderByRoomState() state = %#v", state) + } +} diff --git a/internal/service/gameprovider/types.go b/internal/service/gameprovider/types.go new file mode 100644 index 0000000..455df11 --- /dev/null +++ b/internal/service/gameprovider/types.go @@ -0,0 +1,112 @@ +package gameprovider + +import ( + "chatapp3-golang/internal/common" + "context" +) + +// AuthUser 表示已经通过平台鉴权的用户。 +type AuthUser = common.AuthUser + +// Provider 抽象出房间游戏类厂商在 app 侧通用的查询、启动和关闭能力。 +type Provider interface { + Key() string + DisplayName() string + SupportsLaunch(ctx context.Context, user AuthUser, req LaunchRequest) (bool, error) + ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) + ListRoomGames(ctx context.Context, user AuthUser, roomID, category string) (*RoomGameListResponse, error) + GetRoomState(ctx context.Context, user AuthUser, roomID string) (*RoomStateResponse, error) + LaunchGame(ctx context.Context, user AuthUser, req LaunchRequest, clientIP string) (*LaunchResponse, error) + CloseGame(ctx context.Context, user AuthUser, req CloseRequest) (*RoomStateResponse, error) +} + +// ProviderSummary 描述一个已注册的游戏厂商。 +type ProviderSummary struct { + Key string `json:"key"` + DisplayName string `json:"displayName"` +} + +// ProviderListResponse 是统一厂商列表返回结构。 +type ProviderListResponse struct { + Items []ProviderSummary `json:"items"` +} + +// RoomGameListItem 是统一房间游戏列表项。 +type RoomGameListItem struct { + ID int64 `json:"id"` + GameID string `json:"gameId"` + GameType string `json:"gameType,omitempty"` + Provider string `json:"provider,omitempty"` + ProviderGameID string `json:"providerGameId,omitempty"` + Name string `json:"name"` + Cover string `json:"cover"` + Category string `json:"category,omitempty"` + Sort int64 `json:"sort,omitempty"` + LaunchMode string `json:"launchMode,omitempty"` + FullScreen bool `json:"fullScreen"` + GameMode int `json:"gameMode,omitempty"` + SafeHeight int `json:"safeHeight,omitempty"` + Orientation int `json:"orientation,omitempty"` + PackageVersion string `json:"packageVersion,omitempty"` + Status string `json:"status,omitempty"` + LaunchParams map[string]any `json:"launchParams,omitempty"` +} + +// RoomGameListResponse 是统一房间游戏列表响应。 +type RoomGameListResponse struct { + Items []RoomGameListItem `json:"items"` +} + +// RoomStateResponse 是统一房间状态响应。 +type RoomStateResponse struct { + RoomID string `json:"roomId"` + State string `json:"state"` + Provider string `json:"provider,omitempty"` + GameSessionID string `json:"gameSessionId,omitempty"` + CurrentGameID string `json:"currentGameId,omitempty"` + CurrentProviderGameID string `json:"currentProviderGameId,omitempty"` + CurrentGameName string `json:"currentGameName,omitempty"` + CurrentGameCover string `json:"currentGameCover,omitempty"` + HostUserID int64 `json:"hostUserId,omitempty"` +} + +// LaunchRequest 是统一启动入参。 +type LaunchRequest struct { + ID int64 `json:"id,omitempty"` + RoomID string `json:"roomId"` + GameID string `json:"gameId"` + SceneMode int `json:"sceneMode"` + ClientOrigin string `json:"clientOrigin"` + Params map[string]any `json:"params,omitempty"` +} + +// CloseRequest 是统一关闭入参。 +type CloseRequest struct { + RoomID string `json:"roomId"` + GameSessionID string `json:"gameSessionId"` + Reason string `json:"reason"` + Params map[string]any `json:"params,omitempty"` +} + +// LaunchEntry 描述游戏真正的加载入口。 +type LaunchEntry struct { + LaunchMode string `json:"launchMode"` + EntryURL string `json:"entryUrl"` + PreviewURL string `json:"previewUrl"` + DownloadURL string `json:"downloadUrl"` + PackageVersion string `json:"packageVersion"` + Orientation int `json:"orientation"` + SafeHeight int `json:"safeHeight"` +} + +// LaunchResponse 是统一启动结果。 +type LaunchResponse struct { + ID int64 `json:"id,omitempty"` + GameSessionID string `json:"gameSessionId"` + Provider string `json:"provider,omitempty"` + GameID string `json:"gameId"` + ProviderGameID string `json:"providerGameId,omitempty"` + Entry LaunchEntry `json:"entry"` + LaunchConfig any `json:"launchConfig,omitempty"` + RoomState RoomStateResponse `json:"roomState"` +} diff --git a/internal/service/luckygift/draw.go b/internal/service/luckygift/draw.go index 9a1220d..7c90de3 100644 --- a/internal/service/luckygift/draw.go +++ b/internal/service/luckygift/draw.go @@ -75,7 +75,12 @@ func (s *LuckyGiftService) Draw(ctx context.Context, req LuckyGiftDrawRequest) ( return nil, err } - // 三方有中奖收益时,再调用钱包做一次金币入账,并用 walletEventID 保证幂等。 + if err := s.publishRewardEvent(ctx, req, results, rewardTotal); err != nil { + _ = s.markRequestFailed(ctx, req.BusinessID, providerCode, rawProviderResponse, err.Error()) + return nil, err + } + + // 三方有中奖收益时,再调用钱包给送礼人做金币返奖,并用 walletEventID 保证幂等。 if rewardTotal > 0 { exists, err := s.java.ExistsGoldEvent(ctx, walletEventID) if err != nil { diff --git a/internal/service/luckygift/reward_event.go b/internal/service/luckygift/reward_event.go new file mode 100644 index 0000000..6440dbf --- /dev/null +++ b/internal/service/luckygift/reward_event.go @@ -0,0 +1,96 @@ +package luckygift + +import ( + "chatapp3-golang/internal/utils" + "context" + "net/http" + "strings" + "time" + + "github.com/redis/go-redis/v9" +) + +// LuckyGiftRewardDispatchEvent 是发送给 Java 房间侧统计消费者的中奖事件。 +type LuckyGiftRewardDispatchEvent struct { + EventID string `json:"eventId"` + BusinessID string `json:"businessId"` + SysOrigin string `json:"sysOrigin"` + RoomID int64 `json:"roomId"` + SendUserID int64 `json:"sendUserId"` + GiftID int64 `json:"giftId"` + GiftPrice int64 `json:"giftPrice"` + GiftNum int64 `json:"giftNum"` + RewardTotal int64 `json:"rewardTotal"` + Results []LuckyGiftRewardDispatchResult `json:"results"` + PublishedAt int64 `json:"publishedAt"` +} + +// LuckyGiftRewardDispatchResult 是单个中奖子单的房间侧结算载体。 +type LuckyGiftRewardDispatchResult struct { + OrderSeed string `json:"orderSeed"` + AcceptUserID int64 `json:"acceptUserId"` + RewardNum int64 `json:"rewardNum"` +} + +func (s *LuckyGiftService) publishRewardEvent( + ctx context.Context, + req LuckyGiftDrawRequest, + results []LuckyGiftDrawResult, + rewardTotal int64, +) error { + if rewardTotal <= 0 { + return nil + } + + streamKey := strings.TrimSpace(s.cfg.LuckyGift.RewardStreamKey) + if streamKey == "" { + return NewAppError(http.StatusInternalServerError, "lucky_gift_reward_stream_not_configured", "lucky gift reward stream is not configured") + } + + winResults := make([]LuckyGiftRewardDispatchResult, 0, len(results)) + for _, item := range results { + if item.RewardNum <= 0 { + continue + } + winResults = append(winResults, LuckyGiftRewardDispatchResult{ + OrderSeed: item.ID, + AcceptUserID: item.AcceptUserID, + RewardNum: item.RewardNum, + }) + } + if len(winResults) == 0 { + return nil + } + + event := LuckyGiftRewardDispatchEvent{ + EventID: luckyGiftRewardEventID(req.BusinessID), + BusinessID: req.BusinessID, + SysOrigin: req.SysOrigin, + RoomID: req.RoomID, + SendUserID: req.SendUserID, + GiftID: req.GiftID, + GiftPrice: req.GiftPrice, + GiftNum: req.GiftNum, + RewardTotal: rewardTotal, + Results: winResults, + PublishedAt: time.Now().UnixMilli(), + } + + _, err := s.repo.Redis.XAdd(ctx, &redis.XAddArgs{ + Stream: streamKey, + Values: map[string]any{ + "eventId": event.EventID, + "businessId": event.BusinessID, + "payload": utils.MustJSONString(event, "{}"), + "publishedAt": event.PublishedAt, + }, + }).Result() + if err != nil { + return NewAppError(http.StatusBadGateway, "lucky_gift_reward_stream_publish_failed", err.Error()) + } + return nil +} + +func luckyGiftRewardEventID(businessID string) string { + return "LUCKY_GIFT_REWARD:" + strings.TrimSpace(businessID) +} diff --git a/internal/service/luckygift/reward_event_test.go b/internal/service/luckygift/reward_event_test.go new file mode 100644 index 0000000..cf011a0 --- /dev/null +++ b/internal/service/luckygift/reward_event_test.go @@ -0,0 +1,109 @@ +package luckygift + +import ( + "chatapp3-golang/internal/config" + "context" + "encoding/json" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" +) + +func TestPublishRewardEventWritesWinningResultsToStream(t *testing.T) { + mr, err := miniredis.Run() + if err != nil { + t.Fatalf("miniredis run failed: %v", err) + } + defer mr.Close() + + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + defer client.Close() + + service := &LuckyGiftService{ + cfg: config.Config{ + LuckyGift: config.LuckyGiftConfig{ + RewardStreamKey: "lucky-gift:reward", + }, + }, + repo: luckyGiftPorts{Redis: client}, + } + + req := LuckyGiftDrawRequest{ + BusinessID: "biz-1001", + SysOrigin: "LIKEI", + RoomID: 2001, + SendUserID: 3001, + GiftID: 4001, + GiftPrice: 500, + GiftNum: 2, + } + results := []LuckyGiftDrawResult{ + {ID: "order-a", AcceptUserID: 9001, RewardNum: 88}, + {ID: "order-b", AcceptUserID: 9002, RewardNum: 0}, + } + + if err := service.publishRewardEvent(context.Background(), req, results, 88); err != nil { + t.Fatalf("publishRewardEvent returned error: %v", err) + } + + streams, err := client.XRange(context.Background(), "lucky-gift:reward", "-", "+").Result() + if err != nil { + t.Fatalf("xrange returned error: %v", err) + } + if len(streams) != 1 { + t.Fatalf("expected 1 stream record, got %d", len(streams)) + } + + payloadRaw, ok := streams[0].Values["payload"].(string) + if !ok || payloadRaw == "" { + t.Fatalf("expected payload field, got %#v", streams[0].Values["payload"]) + } + + var event LuckyGiftRewardDispatchEvent + if err := json.Unmarshal([]byte(payloadRaw), &event); err != nil { + t.Fatalf("unmarshal payload failed: %v", err) + } + + if event.EventID != "LUCKY_GIFT_REWARD:biz-1001" { + t.Fatalf("unexpected event id: %s", event.EventID) + } + if len(event.Results) != 1 { + t.Fatalf("expected only winning results to be published, got %d", len(event.Results)) + } + if event.Results[0].OrderSeed != "order-a" || event.Results[0].AcceptUserID != 9001 || event.Results[0].RewardNum != 88 { + t.Fatalf("unexpected winning result payload: %+v", event.Results[0]) + } +} + +func TestPublishRewardEventSkipsZeroRewardTotal(t *testing.T) { + mr, err := miniredis.Run() + if err != nil { + t.Fatalf("miniredis run failed: %v", err) + } + defer mr.Close() + + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + defer client.Close() + + service := &LuckyGiftService{ + cfg: config.Config{ + LuckyGift: config.LuckyGiftConfig{ + RewardStreamKey: "lucky-gift:reward", + }, + }, + repo: luckyGiftPorts{Redis: client}, + } + + if err := service.publishRewardEvent(context.Background(), LuckyGiftDrawRequest{BusinessID: "biz-1002"}, nil, 0); err != nil { + t.Fatalf("publishRewardEvent returned error: %v", err) + } + + streams, err := client.XRange(context.Background(), "lucky-gift:reward", "-", "+").Result() + if err != nil { + t.Fatalf("xrange returned error: %v", err) + } + if len(streams) != 0 { + t.Fatalf("expected no stream records for zero reward total, got %d", len(streams)) + } +} diff --git a/migrations/007_baishun_provider_config.sql b/migrations/007_baishun_provider_config.sql new file mode 100644 index 0000000..caac4d1 --- /dev/null +++ b/migrations/007_baishun_provider_config.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS baishun_provider_config ( + id BIGINT NOT NULL PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL, + platform_base_url VARCHAR(1024) NOT NULL, + app_id BIGINT NOT NULL, + app_name VARCHAR(128) DEFAULT NULL, + app_channel VARCHAR(64) NOT NULL, + app_key VARCHAR(255) NOT NULL, + gsp INT NOT NULL DEFAULT 101, + launch_code_ttl_seconds INT NOT NULL DEFAULT 300, + ss_token_ttl_seconds INT NOT NULL DEFAULT 86400, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_baishun_provider_sys_origin (sys_origin), + KEY idx_baishun_provider_app (app_id, app_channel) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/migrations/008_game_open.sql b/migrations/008_game_open.sql new file mode 100644 index 0000000..fd998bc --- /dev/null +++ b/migrations/008_game_open.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS game_open_callback_log ( + id BIGINT NOT NULL PRIMARY KEY, + request_type VARCHAR(32) NOT NULL, + sys_origin VARCHAR(32) DEFAULT NULL, + order_id VARCHAR(128) DEFAULT NULL, + game_id VARCHAR(64) DEFAULT NULL, + round_id VARCHAR(128) DEFAULT NULL, + room_id VARCHAR(64) DEFAULT NULL, + user_id BIGINT DEFAULT NULL, + request_json LONGTEXT NOT NULL, + response_json LONGTEXT NOT NULL, + biz_code INT NOT NULL DEFAULT 0, + biz_message TEXT, + status VARCHAR(32) NOT NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_game_open_log_type_time (request_type, create_time), + KEY idx_game_open_log_order (order_id), + KEY idx_game_open_log_user_time (user_id, create_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;