Compare commits

...

3 Commits

Author SHA1 Message Date
hy001
8f8ae40947 fix: preserve baishun provider response fields 2026-04-22 20:39:41 +08:00
hy001
6e379d62ac 增加游戏类型 2026-04-22 19:57:28 +08:00
hy001
0701bd0795 feat: update baishun game mode rollout 2026-04-21 20:21:28 +08:00
40 changed files with 4099 additions and 157 deletions

View File

@ -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,

View File

@ -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(),
},
}
}

View File

@ -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)

View File

@ -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)

View File

@ -4,20 +4,23 @@ import "time"
// SysGameListConfig 是系统游戏列表主表,描述平台可展示的游戏。
type SysGameListConfig struct {
ID int64 `gorm:"column:id;primaryKey"` // 主键
SysOrigin string `gorm:"column:sys_origin;size:32"` // 系统标识
GameOrigin string `gorm:"column:game_origin;size:32"` // 游戏来源
GameID string `gorm:"column:game_id;size:64"` // 内部游戏 ID
Name string `gorm:"column:name;size:128"` // 游戏名称
Category string `gorm:"column:category;size:64"` // 分类
GameCode string `gorm:"column:game_code;size:512"` // 游戏码或入口标识
Cover string `gorm:"column:cover;size:1024"` // 封面图
Showcase bool `gorm:"column:is_showcase"` // 是否首页推荐
Sort int64 `gorm:"column:sort"` // 排序值
FullScreen bool `gorm:"column:full_screen"` // 是否全屏展示
ClientOrigin string `gorm:"column:client_origin;size:32"` // 客户端来源
Regions string `gorm:"column:regions;size:255"` // 可投放地区
GameMode string `gorm:"column:game_mode;size:255"` // 游戏模式配置
ID int64 `gorm:"column:id;primaryKey"` // 主键
SysOrigin string `gorm:"column:sys_origin;size:32"` // 系统标识
GameOrigin string `gorm:"column:game_origin;size:32"` // 游戏来源
GameID string `gorm:"column:game_id;size:64"` // 内部游戏 ID
Name string `gorm:"column:name;size:128"` // 游戏名称
Category string `gorm:"column:category;size:64"` // 分类
GameCode string `gorm:"column:game_code;size:512"` // 游戏码或入口标识
Cover string `gorm:"column:cover;size:1024"` // 封面图
Amounts string `gorm:"column:amounts;size:512"` // 金额配置
Showcase bool `gorm:"column:is_showcase"` // 是否首页推荐
Sort int64 `gorm:"column:sort"` // 排序值
Height float64 `gorm:"column:height"` // 高度
Width float64 `gorm:"column:width"` // 宽度
FullScreen bool `gorm:"column:full_screen"` // 是否全屏展示
ClientOrigin string `gorm:"column:client_origin;size:32"` // 客户端来源
Regions string `gorm:"column:regions;size:255"` // 可投放地区
GameMode string `gorm:"column:game_mode;size:255"` // 游戏模式配置
}
// TableName 返回系统游戏列表表名。
@ -49,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"` // 主键

View File

@ -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" }

View File

@ -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 {
@ -109,6 +93,123 @@ func registerBaishunRoutes(
writeOK(c, resp)
})
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")))
resp, err := baishunService.PageAdminGames(
c.Request.Context(),
c.Query("sysOrigin"),
c.Query("keyword"),
parseOptionalBool(c.Query("showcase")),
cursor,
limit,
)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
consoleGroup.GET("/catalog/page", func(c *gin.Context) {
cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1")))
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
resp, err := 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 {
writeError(c, baishun.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := baishunService.SaveAdminGame(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
consoleGroup.DELETE("", func(c *gin.Context) {
id, _ := strconv.ParseInt(strings.TrimSpace(c.Query("id")), 10, 64)
if err := baishunService.DeleteAdminGame(c.Request.Context(), c.Query("sysOrigin"), id); err != nil {
writeError(c, err)
return
}
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 {
writeError(c, baishun.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := baishunService.ImportCatalogGames(c.Request.Context(), req.SysOrigin, req.VendorGameIDs)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
consoleGroup.POST("/sync", 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.SyncAdminGames(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
callbackGroup := engine.Group("/game/baishun")
callbackGroup.POST("/token", func(c *gin.Context) {
raw, payload := readRawPayload(c)
@ -268,3 +369,16 @@ func asInt64(value any) int64 {
return parsed
}
}
func parseOptionalBool(raw string) *bool {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "0", "false":
value := false
return &value
case "1", "true":
value := true
return &value
default:
return nil
}
}

View File

@ -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
}

View File

@ -0,0 +1,291 @@
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, publicRoomGameItems(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, publicRoomGameListResponse(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, publicRoomState(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, publicLaunch(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, publicRoomState(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
}
type publicRoomGameItem struct {
ID int64 `json:"id"`
GameID string `json:"gameId"`
GameType string `json:"gameType,omitempty"`
Provider string `json:"provider,omitempty"`
VendorType string `json:"vendorType,omitempty"`
ProviderGameID string `json:"providerGameId,omitempty"`
VendorGameID string `json:"vendorGameId,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"`
}
type publicRoomGameList struct {
Items []publicRoomGameItem `json:"items"`
}
type publicRoomStateResponse struct {
RoomID string `json:"roomId"`
State string `json:"state"`
Provider string `json:"provider,omitempty"`
VendorType string `json:"vendorType,omitempty"`
GameSessionID string `json:"gameSessionId,omitempty"`
CurrentGameID string `json:"currentGameId,omitempty"`
CurrentProviderGameID string `json:"currentProviderGameId,omitempty"`
CurrentVendorGameID string `json:"currentVendorGameId,omitempty"`
CurrentGameName string `json:"currentGameName,omitempty"`
CurrentGameCover string `json:"currentGameCover,omitempty"`
HostUserID int64 `json:"hostUserId,omitempty"`
}
type publicLaunchResponse struct {
ID int64 `json:"id,omitempty"`
GameSessionID string `json:"gameSessionId"`
Provider string `json:"provider,omitempty"`
VendorType string `json:"vendorType,omitempty"`
GameID string `json:"gameId"`
ProviderGameID string `json:"providerGameId,omitempty"`
VendorGameID string `json:"vendorGameId,omitempty"`
Entry gameprovider.LaunchEntry `json:"entry"`
LaunchConfig any `json:"launchConfig,omitempty"`
BridgeConfig any `json:"bridgeConfig,omitempty"`
RoomState publicRoomStateResponse `json:"roomState"`
}
func publicRoomGameItems(items []gameprovider.RoomGameListItem) []publicRoomGameItem {
if len(items) == 0 {
return []publicRoomGameItem{}
}
result := make([]publicRoomGameItem, 0, len(items))
for _, item := range items {
result = append(result, publicRoomGameItem{
ID: item.ID,
GameID: item.GameID,
GameType: item.GameType,
Provider: item.Provider,
VendorType: item.Provider,
ProviderGameID: item.ProviderGameID,
VendorGameID: item.ProviderGameID,
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 publicRoomGameListResponse(resp *gameprovider.RoomGameListResponse) *publicRoomGameList {
if resp == nil {
return &publicRoomGameList{Items: []publicRoomGameItem{}}
}
return &publicRoomGameList{
Items: publicRoomGameItems(resp.Items),
}
}
func publicRoomState(resp *gameprovider.RoomStateResponse) *publicRoomStateResponse {
if resp == nil {
return nil
}
return &publicRoomStateResponse{
RoomID: resp.RoomID,
State: resp.State,
Provider: resp.Provider,
VendorType: resp.Provider,
GameSessionID: resp.GameSessionID,
CurrentGameID: resp.CurrentGameID,
CurrentProviderGameID: resp.CurrentProviderGameID,
CurrentVendorGameID: resp.CurrentProviderGameID,
CurrentGameName: resp.CurrentGameName,
CurrentGameCover: resp.CurrentGameCover,
HostUserID: resp.HostUserID,
}
}
func publicLaunch(resp *gameprovider.LaunchResponse) *publicLaunchResponse {
if resp == nil {
return nil
}
roomState := publicRoomState(&resp.RoomState)
if roomState == nil {
roomState = &publicRoomStateResponse{}
}
return &publicLaunchResponse{
ID: resp.ID,
GameSessionID: resp.GameSessionID,
Provider: resp.Provider,
VendorType: resp.Provider,
GameID: resp.GameID,
ProviderGameID: resp.ProviderGameID,
VendorGameID: resp.ProviderGameID,
Entry: resp.Entry,
LaunchConfig: resp.LaunchConfig,
BridgeConfig: resp.LaunchConfig,
RoomState: *roomState,
}
}

View File

@ -0,0 +1,80 @@
package router
import (
"testing"
"chatapp3-golang/internal/service/gameprovider"
)
func TestPublicRoomGameListResponseKeepsLegacyProviderFields(t *testing.T) {
resp := publicRoomGameListResponse(&gameprovider.RoomGameListResponse{
Items: []gameprovider.RoomGameListItem{
{
ID: 9527,
GameID: "bs_1046",
GameType: "BAISHUN",
Provider: "BAISHUN",
ProviderGameID: "1046",
Name: "Lucky77Pro",
},
},
})
if len(resp.Items) != 1 {
t.Fatalf("items = %d, want 1", len(resp.Items))
}
item := resp.Items[0]
if item.Provider != "BAISHUN" {
t.Fatalf("provider = %q, want BAISHUN", item.Provider)
}
if item.VendorType != "BAISHUN" {
t.Fatalf("vendorType = %q, want BAISHUN", item.VendorType)
}
if item.ProviderGameID != "1046" {
t.Fatalf("providerGameId = %q, want 1046", item.ProviderGameID)
}
if item.VendorGameID != "1046" {
t.Fatalf("vendorGameId = %q, want 1046", item.VendorGameID)
}
}
func TestPublicLaunchKeepsLegacyBridgeAndProviderFields(t *testing.T) {
launchConfig := map[string]any{"code": "launch-code"}
resp := publicLaunch(&gameprovider.LaunchResponse{
ID: 9527,
GameSessionID: "bs_room_1",
Provider: "BAISHUN",
GameID: "bs_1046",
ProviderGameID: "1046",
LaunchConfig: launchConfig,
RoomState: gameprovider.RoomStateResponse{
RoomID: "room-1",
State: "PLAYING",
Provider: "BAISHUN",
CurrentProviderGameID: "1046",
},
})
if resp.Provider != "BAISHUN" {
t.Fatalf("provider = %q, want BAISHUN", resp.Provider)
}
if resp.VendorType != "BAISHUN" {
t.Fatalf("vendorType = %q, want BAISHUN", resp.VendorType)
}
if resp.VendorGameID != "1046" {
t.Fatalf("vendorGameId = %q, want 1046", resp.VendorGameID)
}
bridgeConfig, ok := resp.BridgeConfig.(map[string]any)
if !ok {
t.Fatalf("bridgeConfig type = %T, want map[string]any", resp.BridgeConfig)
}
if got := bridgeConfig["code"]; got != "launch-code" {
t.Fatalf("bridgeConfig[code] = %v, want launch-code", got)
}
if resp.RoomState.Provider != "BAISHUN" {
t.Fatalf("roomState.provider = %q, want BAISHUN", resp.RoomState.Provider)
}
if resp.RoomState.CurrentVendorGameID != "1046" {
t.Fatalf("roomState.currentVendorGameId = %q, want 1046", resp.RoomState.CurrentVendorGameID)
}
}

View File

@ -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"})
})
}

View File

@ -0,0 +1,759 @@
package baishun
import (
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"context"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"gorm.io/gorm"
)
const adminTimeLayout = "2006-01-02 15:04:05"
type adminGameRow struct {
ID int64
SysOrigin string
InternalGameID string
Name string
Category string
GameCode string
Cover string
Amounts string
Showcase bool
Sort int64
Height float64
Width float64
FullScreen bool
ClientOrigin string
Regions string
GameModeRaw string
CreateTime time.Time
UpdateTime time.Time
VendorGameID string
LaunchMode string
ExtPackageVersion string
ExtPreviewURL string
PackageURL string
ExtOrientation *int
ExtSafeHeight int
ExtGSP string
CurrencyIcon string
ExtExtraJSON string
CatalogStatus string
CatalogPreviewURL string
CatalogDownloadURL string
CatalogPackage string
CatalogOrientation *int
CatalogSafeHeight int
CatalogGameModeJSON string
}
// PageAdminGames 返回后台百顺游戏管理分页数据。
func (s *BaishunService) PageAdminGames(
ctx context.Context,
sysOrigin string,
keyword string,
showcase *bool,
cursor int,
limit int,
) (*AdminBaishunGamePageResponse, error) {
sysOrigin = normalizeAdminSysOrigin(sysOrigin)
cursor = normalizePageCursor(cursor)
limit = normalizePageLimit(limit)
countQuery := s.buildAdminGameQuery(ctx, sysOrigin, keyword, showcase)
var total int64
if err := countQuery.Count(&total).Error; err != nil {
return nil, err
}
var rows []adminGameRow
listQuery := s.buildAdminGameQuery(ctx, sysOrigin, keyword, showcase)
if err := listQuery.
Select(`
cfg.id AS id,
cfg.sys_origin AS sys_origin,
cfg.game_id AS internal_game_id,
cfg.name AS name,
cfg.category AS category,
cfg.game_code AS game_code,
cfg.cover AS cover,
cfg.amounts AS amounts,
cfg.is_showcase AS showcase,
cfg.sort AS sort,
cfg.height AS height,
cfg.width AS width,
cfg.full_screen AS full_screen,
cfg.client_origin AS client_origin,
cfg.regions AS regions,
cfg.game_mode AS game_mode_raw,
cfg.create_time AS create_time,
cfg.update_time AS update_time,
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.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,
cat.orientation AS catalog_orientation,
cat.safe_height AS catalog_safe_height,
cat.game_mode_json AS catalog_game_mode_json
`).
Order("cfg.is_showcase DESC").
Order("cfg.sort DESC").
Order("cfg.update_time DESC").
Limit(limit).
Offset((cursor - 1) * limit).
Scan(&rows).Error; err != nil {
return nil, err
}
items := make([]AdminBaishunGameItem, 0, len(rows))
for _, row := range rows {
items = append(items, row.toAdminItem())
}
return &AdminBaishunGamePageResponse{
Cursor: cursor,
Limit: limit,
Records: items,
Total: total,
}, nil
}
// 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 {
return nil, err
}
if req.VendorGameID <= 0 {
req.VendorGameID = catalog.VendorGameID
}
if req.VendorGameID <= 0 {
return nil, NewAppError(http.StatusBadRequest, "vendor_game_id_required", "vendorGameId is required")
}
gameID := strings.TrimSpace(req.GameID)
if gameID == "" {
gameID = defaultIfBlank(catalog.InternalGameID, fmt.Sprintf("bs_%d", req.VendorGameID))
}
if gameID == "" {
return nil, NewAppError(http.StatusBadRequest, "game_id_required", "gameId is required")
}
name := defaultIfBlank(strings.TrimSpace(req.Name), defaultIfBlank(catalog.Name, gameID))
if name == "" {
return nil, NewAppError(http.StatusBadRequest, "name_required", "name is required")
}
category := defaultIfBlank(strings.TrimSpace(req.Category), "OTHER")
cover := defaultIfBlank(strings.TrimSpace(req.Cover), defaultIfBlank(catalog.Cover, catalog.PreviewURL))
clientOrigin := defaultIfBlank(strings.TrimSpace(req.ClientOrigin), "COMMON")
gameCode := defaultIfBlank(strings.TrimSpace(req.GameCode), strconv.Itoa(req.VendorGameID))
launchMode := defaultIfBlank(strings.TrimSpace(req.LaunchMode), baishunLaunchModeRemote)
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, runtimeCfg.gsp()))
safeHeight := req.SafeHeight
if safeHeight <= 0 {
safeHeight = catalog.SafeHeight
}
orientation := req.Orientation
if orientation == nil && catalog.Orientation != nil {
value := *catalog.Orientation
orientation = &value
}
gameMode := baishunFixedGameMode
tx := s.repo.DB.WithContext(ctx).Begin()
if tx.Error != nil {
return nil, tx.Error
}
defer func() {
if r := recover(); r != nil {
tx.Rollback()
panic(r)
}
}()
cfg, err := s.findAdminConfigForSave(tx, sysOrigin, req.ID, gameID, req.VendorGameID)
if err != nil {
tx.Rollback()
return nil, err
}
showcase := resolveAdminShowcase(req.Showcase, cfg.Showcase, cfg.ID == 0)
cfgValues := map[string]any{
"sys_origin": sysOrigin,
"game_origin": baishunVendorType,
"game_id": gameID,
"name": name,
"category": category,
"game_code": gameCode,
"cover": cover,
"amounts": strings.TrimSpace(req.Amounts),
"is_showcase": showcase,
"sort": req.Sort,
"height": req.Height,
"width": req.Width,
"full_screen": req.FullScreen,
"client_origin": clientOrigin,
"regions": strings.TrimSpace(req.Regions),
"game_mode": formatAdminGameModeRaw(gameMode),
}
if cfg.ID == 0 {
nextID, idErr := utils.NextID()
if idErr != nil {
tx.Rollback()
return nil, idErr
}
cfg = model.SysGameListConfig{
ID: nextID,
SysOrigin: sysOrigin,
GameOrigin: baishunVendorType,
GameID: gameID,
Name: name,
Category: category,
GameCode: gameCode,
Cover: cover,
Amounts: strings.TrimSpace(req.Amounts),
Showcase: showcase,
Sort: req.Sort,
Height: req.Height,
Width: req.Width,
FullScreen: req.FullScreen,
ClientOrigin: clientOrigin,
Regions: strings.TrimSpace(req.Regions),
GameMode: formatAdminGameModeRaw(gameMode),
}
if err := tx.Create(&cfg).Error; err != nil {
tx.Rollback()
return nil, err
}
} else {
if err := tx.Model(&model.SysGameListConfig{}).
Where("id = ?", cfg.ID).
Updates(cfgValues).Error; err != nil {
tx.Rollback()
return nil, err
}
}
var ext model.SysGameListVendorExt
err = tx.Where("game_list_config_id = ? AND vendor_type = ?", cfg.ID, baishunVendorType).First(&ext).Error
if err != nil && err != gorm.ErrRecordNotFound {
tx.Rollback()
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 {
tx.Rollback()
return nil, idErr
}
ext = model.SysGameListVendorExt{
ID: nextID,
GameListConfigID: cfg.ID,
SysOrigin: sysOrigin,
VendorType: baishunVendorType,
VendorGameID: strconv.Itoa(req.VendorGameID),
LaunchMode: launchMode,
PackageVersion: packageVersion,
PackageURL: packageURL,
PreviewURL: previewURL,
Orientation: orientation,
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()
return nil, err
}
} else {
if err := tx.Model(&model.SysGameListVendorExt{}).
Where("id = ?", ext.ID).
Updates(map[string]any{
"sys_origin": sysOrigin,
"vendor_game_id": strconv.Itoa(req.VendorGameID),
"launch_mode": launchMode,
"package_version": packageVersion,
"package_url": packageURL,
"preview_url": previewURL,
"orientation": orientation,
"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
}
}
if err := tx.Commit().Error; err != nil {
return nil, err
}
return s.getAdminGame(ctx, sysOrigin, cfg.ID)
}
// DeleteAdminGame 删除后台百顺游戏配置,不影响目录表。
func (s *BaishunService) DeleteAdminGame(ctx context.Context, sysOrigin string, id int64) error {
if id <= 0 {
return NewAppError(http.StatusBadRequest, "id_required", "id is required")
}
sysOrigin = normalizeAdminSysOrigin(sysOrigin)
tx := s.repo.DB.WithContext(ctx).Begin()
if tx.Error != nil {
return tx.Error
}
if err := tx.Where("game_list_config_id = ? AND vendor_type = ?", id, baishunVendorType).
Delete(&model.SysGameListVendorExt{}).Error; err != nil {
tx.Rollback()
return err
}
if err := tx.Where("id = ? AND sys_origin = ? AND game_origin = ?", id, sysOrigin, baishunVendorType).
Delete(&model.SysGameListConfig{}).Error; err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
}
// ImportCatalogGames 把本地目录中尚未进入后台列表的百顺游戏补齐成可配置记录。
func (s *BaishunService) ImportCatalogGames(ctx context.Context, sysOrigin string, vendorGameIDs []int) (*ImportCatalogResponse, error) {
tx := s.repo.DB.WithContext(ctx).Begin()
if tx.Error != nil {
return nil, tx.Error
}
resp, err := s.importCatalogGamesTx(tx, normalizeAdminSysOrigin(sysOrigin), buildVendorGameIDFilter(vendorGameIDs))
if err != nil {
tx.Rollback()
return nil, err
}
if err := tx.Commit().Error; err != nil {
return nil, err
}
return resp, nil
}
// SyncAdminGames 先同步百顺平台目录,再把缺失配置导入后台列表。
func (s *BaishunService) SyncAdminGames(ctx context.Context, req SyncCatalogRequest) (*SyncAdminCatalogResponse, error) {
syncResp, err := s.SyncCatalog(ctx, req)
if err != nil {
return nil, err
}
importResp, err := s.ImportCatalogGames(ctx, req.SysOrigin, req.VendorGameIDs)
if err != nil {
return nil, err
}
return &SyncAdminCatalogResponse{
Inserted: syncResp.Inserted,
Updated: syncResp.Updated,
Skipped: syncResp.Skipped,
Existing: importResp.Existing,
Imported: importResp.Imported,
}, nil
}
func (s *BaishunService) buildAdminGameQuery(ctx context.Context, sysOrigin, keyword string, showcase *bool) *gorm.DB {
query := s.repo.DB.WithContext(ctx).
Table("sys_game_list_config AS cfg").
Joins("LEFT JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.vendor_type = ?", baishunVendorType).
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
OR cat.internal_game_id = cfg.game_id
)
`).
Where("cfg.sys_origin = ? AND cfg.game_origin = ?", sysOrigin, baishunVendorType)
if showcase != nil {
query = query.Where("cfg.is_showcase = ?", *showcase)
}
keyword = strings.TrimSpace(keyword)
if keyword != "" {
like := "%" + keyword + "%"
query = query.Where(`
cfg.name LIKE ?
OR cfg.game_id LIKE ?
OR cfg.game_code LIKE ?
OR ext.vendor_game_id LIKE ?
`, like, like, like, like)
}
return query
}
func (s *BaishunService) getAdminGame(ctx context.Context, sysOrigin string, id int64) (*AdminBaishunGameItem, error) {
var rows []adminGameRow
err := s.buildAdminGameQuery(ctx, sysOrigin, "", nil).
Where("cfg.id = ?", id).
Select(`
cfg.id AS id,
cfg.sys_origin AS sys_origin,
cfg.game_id AS internal_game_id,
cfg.name AS name,
cfg.category AS category,
cfg.game_code AS game_code,
cfg.cover AS cover,
cfg.amounts AS amounts,
cfg.is_showcase AS showcase,
cfg.sort AS sort,
cfg.height AS height,
cfg.width AS width,
cfg.full_screen AS full_screen,
cfg.client_origin AS client_origin,
cfg.regions AS regions,
cfg.game_mode AS game_mode_raw,
cfg.create_time AS create_time,
cfg.update_time AS update_time,
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.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,
cat.orientation AS catalog_orientation,
cat.safe_height AS catalog_safe_height,
cat.game_mode_json AS catalog_game_mode_json
`).
Limit(1).
Scan(&rows).Error
if err != nil {
return nil, err
}
if len(rows) == 1 {
item := rows[0].toAdminItem()
return &item, nil
}
return nil, NewAppError(http.StatusNotFound, "game_not_found", "admin baishun game not found")
}
func (s *BaishunService) findCatalogForAdmin(ctx context.Context, sysOrigin string, vendorGameID int, gameID string) (model.BaishunGameCatalog, error) {
query := s.repo.DB.WithContext(ctx).Where("sys_origin = ?", sysOrigin)
switch {
case vendorGameID > 0:
query = query.Where("vendor_game_id = ?", vendorGameID)
case strings.TrimSpace(gameID) != "":
query = query.Where("internal_game_id = ?", strings.TrimSpace(gameID))
default:
return model.BaishunGameCatalog{}, nil
}
var catalog model.BaishunGameCatalog
if err := query.First(&catalog).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return model.BaishunGameCatalog{}, nil
}
return model.BaishunGameCatalog{}, err
}
return catalog, nil
}
func (s *BaishunService) findAdminConfigForSave(tx *gorm.DB, sysOrigin string, id int64, gameID string, vendorGameID int) (model.SysGameListConfig, error) {
var cfg model.SysGameListConfig
if id > 0 {
err := tx.Where("id = ? AND sys_origin = ? AND game_origin = ?", id, sysOrigin, baishunVendorType).First(&cfg).Error
if err == gorm.ErrRecordNotFound {
return model.SysGameListConfig{}, NewAppError(http.StatusNotFound, "game_not_found", "admin baishun game not found")
}
return cfg, err
}
if vendorGameID > 0 {
var ext model.SysGameListVendorExt
err := tx.Where("sys_origin = ? AND vendor_type = ? AND vendor_game_id = ?", sysOrigin, baishunVendorType, strconv.Itoa(vendorGameID)).First(&ext).Error
if err != nil && err != gorm.ErrRecordNotFound {
return model.SysGameListConfig{}, err
}
if ext.GameListConfigID > 0 {
err = tx.Where("id = ? AND sys_origin = ? AND game_origin = ?", ext.GameListConfigID, sysOrigin, baishunVendorType).First(&cfg).Error
if err == nil {
return cfg, nil
}
if err != gorm.ErrRecordNotFound {
return model.SysGameListConfig{}, err
}
}
}
if strings.TrimSpace(gameID) == "" {
return model.SysGameListConfig{}, nil
}
err := tx.Where("sys_origin = ? AND game_origin = ? AND game_id = ?", sysOrigin, baishunVendorType, strings.TrimSpace(gameID)).First(&cfg).Error
if err == gorm.ErrRecordNotFound {
return model.SysGameListConfig{}, nil
}
return cfg, err
}
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))
for id := range filter {
ids = append(ids, id)
}
query = query.Where("vendor_game_id IN ?", ids)
}
var catalogs []model.BaishunGameCatalog
if err := query.Order("vendor_game_id ASC").Find(&catalogs).Error; err != nil {
return nil, err
}
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
if err != nil && err != gorm.ErrRecordNotFound {
return nil, err
}
if err == nil && ext.GameListConfigID > 0 {
resp.Existing++
continue
}
var cfg model.SysGameListConfig
err = tx.Where("sys_origin = ? AND game_origin = ? AND game_id = ?", sysOrigin, baishunVendorType, catalog.InternalGameID).First(&cfg).Error
if err != nil && err != gorm.ErrRecordNotFound {
return nil, err
}
if err == gorm.ErrRecordNotFound {
nextID, idErr := utils.NextID()
if idErr != nil {
return nil, idErr
}
cfg = model.SysGameListConfig{
ID: nextID,
SysOrigin: sysOrigin,
GameOrigin: baishunVendorType,
GameID: defaultIfBlank(catalog.InternalGameID, fmt.Sprintf("bs_%d", catalog.VendorGameID)),
Name: catalog.Name,
Category: "OTHER",
GameCode: vendorGameID,
Cover: defaultIfBlank(catalog.Cover, catalog.PreviewURL),
Showcase: true,
Sort: int64(catalog.VendorGameID),
FullScreen: true,
ClientOrigin: "COMMON",
GameMode: formatAdminGameModeRaw(baishunFixedGameMode),
}
if err := tx.Create(&cfg).Error; err != nil {
return nil, err
}
}
nextID, idErr := utils.NextID()
if idErr != nil {
return nil, idErr
}
newExt := model.SysGameListVendorExt{
ID: nextID,
GameListConfigID: cfg.ID,
SysOrigin: sysOrigin,
VendorType: baishunVendorType,
VendorGameID: vendorGameID,
LaunchMode: baishunLaunchModeRemote,
PackageVersion: catalog.PackageVersion,
PackageURL: catalog.DownloadURL,
PreviewURL: catalog.PreviewURL,
Orientation: catalog.Orientation,
SafeHeight: catalog.SafeHeight,
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
}
resp.Imported++
}
return resp, nil
}
func (r adminGameRow) toAdminItem() AdminBaishunGameItem {
return 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,
GameCode: r.GameCode,
Cover: r.Cover,
Amounts: r.Amounts,
Showcase: r.Showcase,
Sort: r.Sort,
Height: r.Height,
Width: r.Width,
FullScreen: r.FullScreen,
ClientOrigin: r.ClientOrigin,
Regions: r.Regions,
GameMode: baishunFixedGameMode,
LaunchMode: defaultIfBlank(r.LaunchMode, baishunLaunchModeRemote),
PackageVersion: defaultIfBlank(r.ExtPackageVersion, r.CatalogPackage),
PreviewURL: defaultIfBlank(r.ExtPreviewURL, r.CatalogPreviewURL),
PackageURL: defaultIfBlank(r.PackageURL, r.CatalogDownloadURL),
Orientation: chooseAdminOrientation(r.ExtOrientation, r.CatalogOrientation),
SafeHeight: chooseAdminSafeHeight(r.ExtSafeHeight, r.CatalogSafeHeight),
GSP: r.ExtGSP,
CurrencyIcon: r.CurrencyIcon,
CatalogStatus: r.CatalogStatus,
LaunchParams: launchParamsWithGameType(r.ExtExtraJSON, baishunVendorType),
CreateTime: formatAdminTime(r.CreateTime),
UpdateTime: formatAdminTime(r.UpdateTime),
}
}
func normalizeAdminSysOrigin(sysOrigin string) string {
sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin))
if sysOrigin == "" {
return "LIKEI"
}
return sysOrigin
}
func normalizePageCursor(cursor int) int {
if cursor <= 0 {
return 1
}
return cursor
}
func normalizePageLimit(limit int) int {
if limit <= 0 {
return 20
}
if limit > 200 {
return 200
}
return limit
}
func resolveAdminShowcase(showcase *bool, current bool, isCreate bool) bool {
if showcase != nil {
return *showcase
}
if isCreate {
return true
}
return current
}
func defaultAdminGSP(catalogGSP string, fallback int) string {
if strings.TrimSpace(catalogGSP) != "" {
return strings.TrimSpace(catalogGSP)
}
if fallback > 0 {
return strconv.Itoa(fallback)
}
return "101"
}
func formatAdminGameModeRaw(gameMode int) string {
return fmt.Sprintf("[%d]", baishunFixedGameMode)
}
func parseAdminVendorGameID(raw string, gameID string) int {
if parsed, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil {
return parsed
}
if strings.HasPrefix(strings.TrimSpace(gameID), "bs_") {
if parsed, err := strconv.Atoi(strings.TrimPrefix(strings.TrimSpace(gameID), "bs_")); err == nil {
return parsed
}
}
return 0
}
func chooseAdminOrientation(extOrientation, catalogOrientation *int) int {
if extOrientation != nil {
return *extOrientation
}
if catalogOrientation != nil {
return *catalogOrientation
}
return 0
}
func chooseAdminSafeHeight(extSafeHeight, catalogSafeHeight int) int {
if extSafeHeight > 0 {
return extSafeHeight
}
return catalogSafeHeight
}
func formatAdminTime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.Format(adminTimeLayout)
}
func buildVendorGameIDFilter(vendorGameIDs []int) map[int]struct{} {
filter := make(map[int]struct{}, len(vendorGameIDs))
for _, id := range vendorGameIDs {
if id > 0 {
filter[id] = struct{}{}
}
}
return filter
}

View File

@ -0,0 +1,24 @@
package baishun
import "testing"
func TestResolveAdminShowcase(t *testing.T) {
t.Run("create defaults to enabled", func(t *testing.T) {
if got := resolveAdminShowcase(nil, false, true); !got {
t.Fatalf("resolveAdminShowcase(nil, false, true) = %v, want true", got)
}
})
t.Run("update keeps current when omitted", func(t *testing.T) {
if got := resolveAdminShowcase(nil, false, false); got {
t.Fatalf("resolveAdminShowcase(nil, false, false) = %v, want false", got)
}
})
t.Run("explicit false remains false", func(t *testing.T) {
showcase := false
if got := resolveAdminShowcase(&showcase, true, true); got {
t.Fatalf("resolveAdminShowcase(false, true, true) = %v, want false", got)
}
})
}

View File

@ -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
}

View File

@ -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[:])
}

View File

@ -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, "<!doctype html") ||
strings.HasPrefix(body, "<!doctypehtml") ||
strings.HasPrefix(body, "<html")
}
func baishunShortBody(raw []byte) string {
body := strings.TrimSpace(string(raw))
const max = 512
if len(body) <= max {
return body
}
return body[:max] + "..."
}

View File

@ -0,0 +1,136 @@
package baishun
import (
"context"
"net/http"
"strings"
"gorm.io/gorm"
)
type adminCatalogRow struct {
VendorGameID int
InternalGameID string
Name string
Cover string
PreviewURL string
DownloadURL string
PackageVersion string
GSP string
Status string
SafeHeight int
Orientation *int
AddedConfigID *int64
Showcase *bool
UpdateTime string
}
func (s *BaishunService) PageCatalog(
ctx context.Context,
sysOrigin string,
keyword string,
cursor int,
limit int,
) (*AdminBaishunCatalogPageResponse, error) {
sysOrigin = normalizeAdminSysOrigin(sysOrigin)
cursor = normalizePageCursor(cursor)
limit = normalizePageLimit(limit)
countQuery := s.buildAdminCatalogQuery(ctx, sysOrigin, keyword)
var total int64
if err := countQuery.Count(&total).Error; err != nil {
return nil, err
}
var rows []adminCatalogRow
err := s.buildAdminCatalogQuery(ctx, sysOrigin, keyword).
Select(`
cat.vendor_game_id AS vendor_game_id,
cat.internal_game_id AS internal_game_id,
cat.name AS name,
cat.cover AS cover,
cat.preview_url AS preview_url,
cat.download_url AS download_url,
cat.package_version AS package_version,
cat.gsp AS gsp,
cat.status AS status,
cat.safe_height AS safe_height,
cat.orientation AS orientation,
ext.game_list_config_id AS added_config_id,
cfg.is_showcase AS showcase,
DATE_FORMAT(cat.update_time, '%Y-%m-%d %H:%i:%s') AS update_time
`).
Order("cat.vendor_game_id ASC").
Limit(limit).
Offset((cursor - 1) * limit).
Scan(&rows).Error
if err != nil {
return nil, err
}
items := make([]AdminBaishunCatalogItem, 0, len(rows))
for _, row := range rows {
items = append(items, AdminBaishunCatalogItem{
VendorGameID: row.VendorGameID,
GameID: row.InternalGameID,
Name: row.Name,
Cover: row.Cover,
PreviewURL: row.PreviewURL,
DownloadURL: row.DownloadURL,
PackageVersion: row.PackageVersion,
GSP: row.GSP,
Status: row.Status,
SafeHeight: row.SafeHeight,
Orientation: derefInt(row.Orientation),
Added: row.AddedConfigID != nil && *row.AddedConfigID > 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
}

View File

@ -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, &params); 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
}

View File

@ -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)
}
}

View File

@ -0,0 +1,3 @@
package baishun
const baishunFixedGameMode = 2

View File

@ -0,0 +1,32 @@
package baishun
import "testing"
func TestGameListRowGameModeAlwaysFixed(t *testing.T) {
testCases := []string{"", "[1]", "[2]", "[3]", "CHAT_ROOM"}
for _, raw := range testCases {
row := gameListRow{GameModeRaw: raw}
if got := row.gameMode(); got != baishunFixedGameMode {
t.Fatalf("gameMode() = %d, want %d for raw %q", got, baishunFixedGameMode, raw)
}
if got := row.toListItem().GameMode; got != baishunFixedGameMode {
t.Fatalf("toListItem().GameMode = %d, want %d for raw %q", got, baishunFixedGameMode, raw)
}
}
}
func TestAdminGameModeAlwaysFixed(t *testing.T) {
row := adminGameRow{
GameModeRaw: "[3]",
CatalogGameModeJSON: "[1]",
}
if got := row.toAdminItem().GameMode; got != baishunFixedGameMode {
t.Fatalf("toAdminItem().GameMode = %d, want %d", got, baishunFixedGameMode)
}
if got := formatAdminGameModeRaw(0); got != "[2]" {
t.Fatalf("formatAdminGameModeRaw(0) = %q, want [2]", got)
}
if got := formatAdminGameModeRaw(99); got != "[2]" {
t.Fatalf("formatAdminGameModeRaw(99) = %q, want [2]", got)
}
}

View File

@ -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(),
}
}
@ -87,10 +90,7 @@ func (r gameListRow) launchMode() string {
// gameMode 返回游戏模式。
func (r gameListRow) gameMode() int {
if parsed := pickFirstInt(r.GameModeRaw, 3); parsed > 0 {
return parsed
}
return 3
return baishunFixedGameMode
}
// vendorGameIDInt 返回厂商游戏 ID 的整数值。
@ -103,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))
}

View File

@ -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) == "" {

View File

@ -0,0 +1,118 @@
package baishun
import (
"context"
"testing"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type stubGateway struct{}
func (stubGateway) GetUserProfile(context.Context, int64) (integration.UserProfile, error) {
return integration.UserProfile{}, nil
}
func (stubGateway) MapGoldBalance(context.Context, []int64) (map[int64]int64, error) {
return map[int64]int64{}, nil
}
func (stubGateway) GetUserLanguage(context.Context, int64) (string, error) {
return "en", nil
}
func (stubGateway) ExistsGoldEvent(context.Context, string) (bool, error) {
return false, nil
}
func (stubGateway) ChangeGoldBalance(context.Context, integration.GoldReceiptCommand) error {
return nil
}
func newTestBaishunService(t *testing.T) (*BaishunService, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(
&model.SysGameListConfig{},
&model.SysGameListVendorExt{},
&model.BaishunProviderConfig{},
&model.BaishunGameCatalog{},
&model.BaishunLaunchSession{},
&model.BaishunRoomState{},
); err != nil {
t.Fatalf("auto migrate: %v", err)
}
service := NewBaishunService(config.Config{
Baishun: config.BaishunConfig{
PlatformBaseURL: "https://mesh-channels-online.jieyou.shop",
AppID: 7485318192,
AppName: "yumiparty_app",
AppChannel: "yumiparty",
AppKey: "test-app-key",
GSP: 111,
LaunchCodeTTLSeconds: 300,
SSTokenTTLSeconds: 86400,
},
}, db, nil, stubGateway{})
return service, db
}
func TestLaunchGameFallsBackToCatalogWhenConfigMissing(t *testing.T) {
service, db := newTestBaishunService(t)
if err := db.Create(&model.BaishunGameCatalog{
ID: 1,
SysOrigin: "LIKEI",
InternalGameID: "bs_1146",
VendorGameID: 1146,
Name: "LordOfOlympus",
Cover: "https://cdn.example.com/cover.png",
PreviewURL: "https://cdn.example.com/preview.png",
DownloadURL: "https://cdn.example.com/game/index.html",
PackageVersion: "1.0.0",
SafeHeight: 24,
GSP: "222",
Status: baishunCatalogEnabled,
}).Error; err != nil {
t.Fatalf("create catalog: %v", err)
}
resp, err := service.LaunchGame(context.Background(), AuthUser{
UserID: 1234567,
SysOrigin: "LIKEI",
}, BaishunLaunchAppRequest{
RoomID: "room-1",
GameID: "bs_1146",
SceneMode: 1,
ClientOrigin: "COMMON",
}, "127.0.0.1")
if err != nil {
t.Fatalf("LaunchGame() error = %v", err)
}
if resp.GameID != "bs_1146" {
t.Fatalf("gameId = %q, want bs_1146", resp.GameID)
}
if resp.VendorGameID != 1146 {
t.Fatalf("vendorGameId = %d, want 1146", resp.VendorGameID)
}
if resp.Entry.EntryURL != "https://cdn.example.com/game/index.html" {
t.Fatalf("entryUrl = %q, want catalog download url", resp.Entry.EntryURL)
}
if resp.BridgeConfig.GSP != 222 {
t.Fatalf("gsp = %d, want 222", resp.BridgeConfig.GSP)
}
if resp.RoomState.State != baishunRoomStatePlaying {
t.Fatalf("roomState.state = %q, want %q", resp.RoomState.State, baishunRoomStatePlaying)
}
}

View File

@ -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)
}

View File

@ -5,7 +5,6 @@ import (
"context"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
@ -80,12 +79,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,
@ -99,9 +99,6 @@ func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, roomID, c
if strings.TrimSpace(category) != "" && !strings.EqualFold(strings.TrimSpace(category), "CHAT_ROOM") {
query = query.Where("cfg.category = ?", category)
}
if strings.TrimSpace(roomID) != "" {
query = query.Where("cfg.game_mode LIKE ?", "%CHAT_ROOM%")
}
if err := query.Order("cfg.sort DESC").Scan(&rows).Error; err != nil {
return nil, err
}
@ -110,42 +107,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: pickFirstInt(catalog.GameModeJSON, 3),
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
}
@ -170,12 +131,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,
@ -201,12 +163,13 @@ func (s *BaishunService) findGameRow(ctx context.Context, sysOrigin, gameID stri
First(&catalog).Error; err == nil {
return gameListRow{
SysOrigin: sysOrigin,
InternalGameID: catalog.InternalGameID,
InternalGameID: defaultIfBlank(catalog.InternalGameID, fmt.Sprintf("bs_%d", catalog.VendorGameID)),
Name: catalog.Name,
Cover: catalog.Cover,
VendorType: baishunVendorType,
VendorGameID: strconv.Itoa(catalog.VendorGameID),
LaunchMode: baishunLaunchModeRemote,
ExtGSP: catalog.GSP,
CatalogCover: catalog.Cover,
CatalogPreviewURL: catalog.PreviewURL,
CatalogDownloadURL: catalog.DownloadURL,
@ -214,19 +177,68 @@ func (s *BaishunService) findGameRow(ctx context.Context, sysOrigin, gameID stri
CatalogSafeHeight: catalog.SafeHeight,
CatalogOrientation: catalog.Orientation,
CatalogStatus: catalog.Status,
GameModeRaw: catalog.GameModeJSON,
GameModeRaw: formatAdminGameModeRaw(baishunFixedGameMode),
}, nil
} else if err != gorm.ErrRecordNotFound {
return gameListRow{}, err
}
return gameListRow{}, NewAppError(http.StatusNotFound, "game_not_found", "room game config not found")
}
// 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()
}

View File

@ -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"`
@ -181,6 +186,147 @@ type SyncCatalogResponse struct {
Skipped int `json:"skipped"`
}
// AdminBaishunGameItem 是后台管理页展示的百顺游戏配置行。
type AdminBaishunGameItem struct {
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 是后台页分页响应。
type AdminBaishunGamePageResponse struct {
Cursor int `json:"cursor"`
Limit int `json:"limit"`
Records []AdminBaishunGameItem `json:"records"`
Total int64 `json:"total"`
}
// 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"`
LaunchParams map[string]any `json:"launchParams,omitempty"`
}
// ImportCatalogResponse 描述从本地目录补齐后台配置的结果。
type ImportCatalogResponse struct {
Existing int `json:"existing"`
Imported int `json:"imported"`
}
// SyncAdminCatalogResponse 描述同步平台目录并导入后台配置的结果。
type SyncAdminCatalogResponse struct {
Inserted int `json:"inserted"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
Existing int `json:"existing"`
Imported int `json:"imported"`
}
// 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"`
@ -281,6 +427,7 @@ type gameListRow struct {
ExtSafeHeight int
CurrencyIcon string
ExtGSP string
ExtExtraJSON string
CatalogName string
CatalogCover string
CatalogPreviewURL string

View File

@ -0,0 +1,7 @@
package gameopen
import "chatapp3-golang/internal/common"
type AppError = common.AppError
var NewAppError = common.NewAppError

View File

@ -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[:])
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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))
}

View File

@ -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)
}
}

View File

@ -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"`
}

View File

@ -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 {

View File

@ -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)
}

View File

@ -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))
}
}

View File

@ -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;

View File

@ -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;