增加游戏类型

This commit is contained in:
hy001 2026-04-22 19:57:28 +08:00
parent 0701bd0795
commit 6e379d62ac
35 changed files with 2885 additions and 216 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

@ -52,6 +52,25 @@ type SysGameListVendorExt struct {
// TableName 返回游戏厂商扩展表名。
func (SysGameListVendorExt) TableName() string { return "sys_game_list_vendor_ext" }
// BaishunProviderConfig 保存每个系统的百顺接入配置。
type BaishunProviderConfig struct {
ID int64 `gorm:"column:id;primaryKey"` // 主键
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_baishun_provider_sys_origin"` // 系统标识
PlatformBaseURL string `gorm:"column:platform_base_url;size:1024"` // 百顺平台基础地址
AppID int64 `gorm:"column:app_id;index:idx_baishun_provider_app,priority:1"` // 百顺应用 ID
AppName string `gorm:"column:app_name;size:128"` // 百顺应用名
AppChannel string `gorm:"column:app_channel;size:64;index:idx_baishun_provider_app,priority:2"` // 百顺渠道
AppKey string `gorm:"column:app_key;size:255"` // 百顺密钥
GSP int `gorm:"column:gsp"` // 默认 GSP
LaunchCodeTTLSeconds int `gorm:"column:launch_code_ttl_seconds"` // 启动码过期秒数
SSTokenTTLSeconds int `gorm:"column:ss_token_ttl_seconds"` // ss_token 过期秒数
CreateTime time.Time `gorm:"column:create_time"` // 创建时间
UpdateTime time.Time `gorm:"column:update_time"` // 更新时间
}
// TableName 返回百顺配置表名。
func (BaishunProviderConfig) TableName() string { return "baishun_provider_config" }
// BaishunGameCatalog 是百顺平台同步到本地的游戏目录。
type BaishunGameCatalog struct {
ID int64 `gorm:"column:id;primaryKey"` // 主键

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 {
@ -111,6 +95,27 @@ func registerBaishunRoutes(
consoleGroup := engine.Group("/operate/baishun-game")
consoleGroup.Use(consoleAuthMiddleware(javaClient))
consoleGroup.GET("/config", func(c *gin.Context) {
resp, err := baishunService.GetProviderConfig(c.Request.Context(), c.Query("sysOrigin"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
consoleGroup.POST("/config", func(c *gin.Context) {
var req baishun.SaveBaishunProviderConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, baishun.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := baishunService.SaveProviderConfig(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
consoleGroup.GET("/page", func(c *gin.Context) {
cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1")))
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
@ -128,6 +133,22 @@ func registerBaishunRoutes(
}
writeOK(c, resp)
})
consoleGroup.GET("/catalog/page", func(c *gin.Context) {
cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1")))
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
resp, err := baishunService.PageCatalog(
c.Request.Context(),
c.Query("sysOrigin"),
c.Query("keyword"),
cursor,
limit,
)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
consoleGroup.POST("/save", func(c *gin.Context) {
var req baishun.SaveAdminBaishunGameRequest
if err := c.ShouldBindJSON(&req); err != nil {
@ -149,6 +170,19 @@ func registerBaishunRoutes(
}
writeOK(c, gin.H{"deleted": true})
})
consoleGroup.POST("/catalog/fetch", func(c *gin.Context) {
var req baishun.SyncCatalogRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, baishun.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := baishunService.FetchAdminCatalog(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
consoleGroup.POST("/import-catalog", func(c *gin.Context) {
var req baishun.SyncCatalogRequest
if err := c.ShouldBindJSON(&req); err != nil {

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,195 @@
package router
import (
"net/http"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/service/gameprovider"
"github.com/gin-gonic/gin"
)
// registerGameProviderRoutes 注册统一游戏厂商 app 路由。
func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, registry *gameprovider.Registry) {
if registry == nil {
return
}
appGroup := engine.Group("/app/game")
appGroup.Use(authMiddleware(javaClient))
appGroup.GET("/room/shortcut", func(c *gin.Context) {
resp, err := registry.ListShortcutGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, stripProvidersFromRoomGameItems(resp))
})
appGroup.GET("/room/list", func(c *gin.Context) {
resp, err := registry.ListRoomGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"), c.Query("category"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, stripProvidersFromRoomGameListResponse(resp))
})
appGroup.GET("/state", func(c *gin.Context) {
resp, err := registry.GetRoomState(c.Request.Context(), mustAuthUser(c), c.Query("roomId"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, stripProviderFromRoomState(resp))
})
appGroup.POST("/launch", func(c *gin.Context) {
var req gameprovider.LaunchRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := registry.LaunchGame(c.Request.Context(), mustAuthUser(c), req, resolveClientIP(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, stripProviderFromLaunch(resp))
})
appGroup.POST("/close", func(c *gin.Context) {
var req gameprovider.CloseRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := registry.CloseGame(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, stripProviderFromRoomState(resp))
})
providerGroup := engine.Group("/app/game/providers")
providerGroup.Use(authMiddleware(javaClient))
providerGroup.GET("", func(c *gin.Context) {
writeOK(c, registry.List())
})
providerGroup.GET("/:provider/room/shortcut", func(c *gin.Context) {
provider, ok := resolveGameProvider(c, registry)
if !ok {
return
}
resp, err := provider.ListShortcutGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, gin.H{"items": resp})
})
providerGroup.GET("/:provider/room/list", func(c *gin.Context) {
provider, ok := resolveGameProvider(c, registry)
if !ok {
return
}
resp, err := provider.ListRoomGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"), c.Query("category"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
providerGroup.GET("/:provider/state", func(c *gin.Context) {
provider, ok := resolveGameProvider(c, registry)
if !ok {
return
}
resp, err := provider.GetRoomState(c.Request.Context(), mustAuthUser(c), c.Query("roomId"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
providerGroup.POST("/:provider/launch", func(c *gin.Context) {
provider, ok := resolveGameProvider(c, registry)
if !ok {
return
}
var req gameprovider.LaunchRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := provider.LaunchGame(c.Request.Context(), mustAuthUser(c), req, resolveClientIP(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
providerGroup.POST("/:provider/close", func(c *gin.Context) {
provider, ok := resolveGameProvider(c, registry)
if !ok {
return
}
var req gameprovider.CloseRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := provider.CloseGame(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
}
func resolveGameProvider(c *gin.Context, registry *gameprovider.Registry) (gameprovider.Provider, bool) {
provider, err := registry.Resolve(c.Param("provider"))
if err != nil {
writeError(c, err)
return nil, false
}
return provider, true
}
func stripProvidersFromRoomGameItems(items []gameprovider.RoomGameListItem) []gameprovider.RoomGameListItem {
if len(items) == 0 {
return []gameprovider.RoomGameListItem{}
}
result := make([]gameprovider.RoomGameListItem, 0, len(items))
for _, item := range items {
item.Provider = ""
result = append(result, item)
}
return result
}
func stripProvidersFromRoomGameListResponse(resp *gameprovider.RoomGameListResponse) *gameprovider.RoomGameListResponse {
if resp == nil {
return &gameprovider.RoomGameListResponse{Items: []gameprovider.RoomGameListItem{}}
}
return &gameprovider.RoomGameListResponse{
Items: stripProvidersFromRoomGameItems(resp.Items),
}
}
func stripProviderFromRoomState(resp *gameprovider.RoomStateResponse) *gameprovider.RoomStateResponse {
if resp == nil {
return nil
}
copied := *resp
copied.Provider = ""
return &copied
}
func stripProviderFromLaunch(resp *gameprovider.LaunchResponse) *gameprovider.LaunchResponse {
if resp == nil {
return nil
}
copied := *resp
copied.Provider = ""
copied.RoomState = *stripProviderFromRoomState(&resp.RoomState)
return &copied
}

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

@ -43,6 +43,7 @@ type adminGameRow struct {
ExtSafeHeight int
ExtGSP string
CurrencyIcon string
ExtExtraJSON string
CatalogStatus string
CatalogPreviewURL string
CatalogDownloadURL string
@ -99,11 +100,12 @@ func (s *BaishunService) PageAdminGames(
ext.package_version AS ext_package_version,
ext.preview_url AS ext_preview_url,
ext.package_url AS package_url,
ext.orientation AS ext_orientation,
ext.safe_height AS ext_safe_height,
ext.gsp AS ext_gsp,
ext.currency_icon AS currency_icon,
cat.status AS catalog_status,
ext.orientation AS ext_orientation,
ext.safe_height AS ext_safe_height,
ext.gsp AS ext_gsp,
ext.currency_icon AS currency_icon,
ext.extra_json AS ext_extra_json,
cat.status AS catalog_status,
cat.preview_url AS catalog_preview_url,
cat.download_url AS catalog_download_url,
cat.package_version AS catalog_package,
@ -136,6 +138,10 @@ func (s *BaishunService) PageAdminGames(
// SaveAdminGame 保存后台百顺游戏配置。
func (s *BaishunService) SaveAdminGame(ctx context.Context, req SaveAdminBaishunGameRequest) (*AdminBaishunGameItem, error) {
sysOrigin := normalizeAdminSysOrigin(req.SysOrigin)
runtimeCfg, err := s.resolveRuntimeConfig(ctx, sysOrigin)
if err != nil {
return nil, err
}
catalog, err := s.findCatalogForAdmin(ctx, sysOrigin, req.VendorGameID, req.GameID)
if err != nil {
@ -168,7 +174,7 @@ func (s *BaishunService) SaveAdminGame(ctx context.Context, req SaveAdminBaishun
packageVersion := defaultIfBlank(strings.TrimSpace(req.PackageVersion), catalog.PackageVersion)
previewURL := defaultIfBlank(strings.TrimSpace(req.PreviewURL), catalog.PreviewURL)
packageURL := defaultIfBlank(strings.TrimSpace(req.PackageURL), catalog.DownloadURL)
gsp := defaultIfBlank(strings.TrimSpace(req.GSP), defaultAdminGSP(catalog.GSP, s.cfg.Baishun.GSP))
gsp := defaultIfBlank(strings.TrimSpace(req.GSP), defaultAdminGSP(catalog.GSP, runtimeCfg.gsp()))
safeHeight := req.SafeHeight
if safeHeight <= 0 {
safeHeight = catalog.SafeHeight
@ -261,6 +267,8 @@ func (s *BaishunService) SaveAdminGame(ctx context.Context, req SaveAdminBaishun
return nil, err
}
now := time.Now()
extraJSON := updateExtExtraJSONWithGameType(ext.ExtraJSON, req.LaunchParams, baishunVendorType, ext.ID != 0)
if ext.ID == 0 {
nextID, idErr := utils.NextID()
if idErr != nil {
@ -281,7 +289,10 @@ func (s *BaishunService) SaveAdminGame(ctx context.Context, req SaveAdminBaishun
SafeHeight: safeHeight,
GSP: gsp,
CurrencyIcon: strings.TrimSpace(req.CurrencyIcon),
ExtraJSON: extraJSON,
Enabled: true,
CreateTime: now,
UpdateTime: now,
}
if err := tx.Create(&ext).Error; err != nil {
tx.Rollback()
@ -301,8 +312,10 @@ func (s *BaishunService) SaveAdminGame(ctx context.Context, req SaveAdminBaishun
"safe_height": safeHeight,
"gsp": gsp,
"currency_icon": strings.TrimSpace(req.CurrencyIcon),
"extra_json": extraJSON,
"enabled": true,
"bridge_schema_version": defaultIfBlank(ext.BridgeSchemaVersion, "1.0"),
"update_time": now,
}).Error; err != nil {
tx.Rollback()
return nil, err
@ -436,6 +449,7 @@ func (s *BaishunService) getAdminGame(ctx context.Context, sysOrigin string, id
ext.safe_height AS ext_safe_height,
ext.gsp AS ext_gsp,
ext.currency_icon AS currency_icon,
ext.extra_json AS ext_extra_json,
cat.status AS catalog_status,
cat.preview_url AS catalog_preview_url,
cat.download_url AS catalog_download_url,
@ -515,6 +529,14 @@ func (s *BaishunService) findAdminConfigForSave(tx *gorm.DB, sysOrigin string, i
}
func (s *BaishunService) importCatalogGamesTx(tx *gorm.DB, sysOrigin string, filter map[int]struct{}) (*ImportCatalogResponse, error) {
ctx := tx.Statement.Context
if ctx == nil {
ctx = context.Background()
}
runtimeCfg, err := s.resolveRuntimeConfig(ctx, sysOrigin)
if err != nil {
return nil, err
}
query := tx.Where("sys_origin = ? AND status = ?", sysOrigin, baishunCatalogEnabled)
if len(filter) > 0 {
ids := make([]int, 0, len(filter))
@ -532,6 +554,7 @@ func (s *BaishunService) importCatalogGamesTx(tx *gorm.DB, sysOrigin string, fil
resp := &ImportCatalogResponse{}
for _, catalog := range catalogs {
vendorGameID := strconv.Itoa(catalog.VendorGameID)
now := time.Now()
var ext model.SysGameListVendorExt
err := tx.Where("sys_origin = ? AND vendor_type = ? AND vendor_game_id = ?", sysOrigin, baishunVendorType, vendorGameID).First(&ext).Error
@ -589,9 +612,12 @@ func (s *BaishunService) importCatalogGamesTx(tx *gorm.DB, sysOrigin string, fil
PreviewURL: catalog.PreviewURL,
Orientation: catalog.Orientation,
SafeHeight: catalog.SafeHeight,
GSP: defaultAdminGSP(catalog.GSP, s.cfg.Baishun.GSP),
GSP: defaultAdminGSP(catalog.GSP, runtimeCfg.gsp()),
BridgeSchemaVersion: "1.0",
ExtraJSON: updateExtExtraJSONWithGameType("", nil, baishunVendorType, false),
Enabled: true,
CreateTime: now,
UpdateTime: now,
}
if err := tx.Create(&newExt).Error; err != nil {
return nil, err
@ -606,6 +632,7 @@ func (r adminGameRow) toAdminItem() AdminBaishunGameItem {
ID: r.ID,
SysOrigin: r.SysOrigin,
GameID: r.InternalGameID,
GameType: gameTypeFromExtraJSON(r.ExtExtraJSON, baishunVendorType),
VendorGameID: parseAdminVendorGameID(r.VendorGameID, r.InternalGameID),
Name: r.Name,
Category: r.Category,
@ -629,6 +656,7 @@ func (r adminGameRow) toAdminItem() AdminBaishunGameItem {
GSP: r.ExtGSP,
CurrencyIcon: r.CurrencyIcon,
CatalogStatus: r.CatalogStatus,
LaunchParams: launchParamsWithGameType(r.ExtExtraJSON, baishunVendorType),
CreateTime: formatAdminTime(r.CreateTime),
UpdateTime: formatAdminTime(r.UpdateTime),
}

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

@ -8,7 +8,9 @@ import (
// toListItem 把联表行模型转换成游戏列表项。
func (r gameListRow) toListItem() RoomGameListItem {
return RoomGameListItem{
ID: r.ConfigID,
GameID: r.InternalGameID,
GameType: r.gameTypeName(),
VendorType: defaultIfBlank(r.VendorType, baishunVendorType),
VendorGameID: r.vendorGameIDInt(),
Name: r.name(),
@ -22,6 +24,7 @@ func (r gameListRow) toListItem() RoomGameListItem {
Orientation: r.orientation(),
PackageVersion: r.packageVersion(),
Status: defaultIfBlank(r.CatalogStatus, baishunCatalogEnabled),
LaunchParams: r.launchParams(),
}
}
@ -100,3 +103,12 @@ func (r gameListRow) vendorGameIDInt() int {
func (r gameListRow) currencyIcon() string {
return r.CurrencyIcon
}
// launchParams 返回 Flutter 统一启动接口使用的扩展参数。
func (r gameListRow) launchParams() map[string]any {
return launchParamsWithGameType(r.ExtExtraJSON, r.gameTypeName())
}
func (r gameListRow) gameTypeName() string {
return gameTypeFromExtraJSON(r.ExtExtraJSON, defaultIfBlank(r.VendorType, baishunVendorType))
}

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

@ -3,9 +3,7 @@ package baishun
import (
"chatapp3-golang/internal/model"
"context"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
@ -80,12 +78,13 @@ func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, roomID, c
ext.package_version AS ext_package_version,
ext.preview_url AS ext_preview_url,
ext.package_url AS package_url,
ext.orientation AS ext_orientation,
ext.safe_height AS ext_safe_height,
ext.currency_icon AS currency_icon,
ext.gsp AS ext_gsp,
cat.name AS catalog_name,
cat.cover AS catalog_cover,
ext.orientation AS ext_orientation,
ext.safe_height AS ext_safe_height,
ext.currency_icon AS currency_icon,
ext.gsp AS ext_gsp,
ext.extra_json AS ext_extra_json,
cat.name AS catalog_name,
cat.cover AS catalog_cover,
cat.preview_url AS catalog_preview_url,
cat.download_url AS catalog_download_url,
cat.package_version AS catalog_package_version,
@ -107,42 +106,6 @@ func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, roomID, c
for _, row := range rows {
items = append(items, row.toListItem())
}
if len(items) > 0 {
return items, nil
}
var catalogs []model.BaishunGameCatalog
if err := s.repo.DB.WithContext(ctx).
Where("sys_origin = ? AND status = ?", sysOrigin, baishunCatalogEnabled).
Order("vendor_game_id ASC").
Find(&catalogs).Error; err != nil {
return nil, err
}
for _, catalog := range catalogs {
gameID := defaultIfBlank(catalog.InternalGameID, fmt.Sprintf("bs_%d", catalog.VendorGameID))
items = append(items, RoomGameListItem{
GameID: gameID,
VendorType: baishunVendorType,
VendorGameID: catalog.VendorGameID,
Name: catalog.Name,
Cover: defaultIfBlank(catalog.Cover, catalog.PreviewURL),
Category: "CHAT_ROOM",
Sort: int64(catalog.VendorGameID),
LaunchMode: baishunLaunchModeRemote,
FullScreen: true,
GameMode: baishunFixedGameMode,
SafeHeight: catalog.SafeHeight,
Orientation: derefInt(catalog.Orientation),
PackageVersion: catalog.PackageVersion,
Status: catalog.Status,
})
}
sort.Slice(items, func(i, j int) bool {
if items[i].Sort == items[j].Sort {
return items[i].VendorGameID < items[j].VendorGameID
}
return items[i].Sort > items[j].Sort
})
return items, nil
}
@ -167,12 +130,13 @@ func (s *BaishunService) findGameRow(ctx context.Context, sysOrigin, gameID stri
ext.package_version AS ext_package_version,
ext.preview_url AS ext_preview_url,
ext.package_url AS package_url,
ext.orientation AS ext_orientation,
ext.safe_height AS ext_safe_height,
ext.currency_icon AS currency_icon,
ext.gsp AS ext_gsp,
cat.name AS catalog_name,
cat.cover AS catalog_cover,
ext.orientation AS ext_orientation,
ext.safe_height AS ext_safe_height,
ext.currency_icon AS currency_icon,
ext.gsp AS ext_gsp,
ext.extra_json AS ext_extra_json,
cat.name AS catalog_name,
cat.cover AS catalog_cover,
cat.preview_url AS catalog_preview_url,
cat.download_url AS catalog_download_url,
cat.package_version AS catalog_package_version,
@ -191,39 +155,62 @@ func (s *BaishunService) findGameRow(ctx context.Context, sysOrigin, gameID stri
if strings.TrimSpace(row.InternalGameID) != "" {
return row, nil
}
return gameListRow{}, NewAppError(http.StatusNotFound, "game_not_found", "room game config not found")
}
var catalog model.BaishunGameCatalog
if err := s.repo.DB.WithContext(ctx).
Where("sys_origin = ? AND internal_game_id = ?", sysOrigin, gameID).
First(&catalog).Error; err == nil {
return gameListRow{
SysOrigin: sysOrigin,
InternalGameID: catalog.InternalGameID,
Name: catalog.Name,
Cover: catalog.Cover,
VendorType: baishunVendorType,
VendorGameID: strconv.Itoa(catalog.VendorGameID),
LaunchMode: baishunLaunchModeRemote,
CatalogCover: catalog.Cover,
CatalogPreviewURL: catalog.PreviewURL,
CatalogDownloadURL: catalog.DownloadURL,
CatalogPackageVersion: catalog.PackageVersion,
CatalogSafeHeight: catalog.SafeHeight,
CatalogOrientation: catalog.Orientation,
CatalogStatus: catalog.Status,
GameModeRaw: formatAdminGameModeRaw(baishunFixedGameMode),
}, nil
// findGameRowByConfigID 按系统游戏配置主键查询游戏聚合信息。
func (s *BaishunService) findGameRowByConfigID(ctx context.Context, sysOrigin string, configID int64) (gameListRow, error) {
var row gameListRow
err := s.repo.DB.WithContext(ctx).
Table("sys_game_list_config AS cfg").
Select(`
cfg.id AS config_id,
cfg.sys_origin AS sys_origin,
cfg.game_id AS internal_game_id,
cfg.name AS name,
cfg.category AS category,
cfg.cover AS cover,
cfg.sort AS sort,
cfg.full_screen AS full_screen,
cfg.game_mode AS game_mode_raw,
ext.vendor_type AS vendor_type,
ext.vendor_game_id AS vendor_game_id,
ext.launch_mode AS launch_mode,
ext.package_version AS ext_package_version,
ext.preview_url AS ext_preview_url,
ext.package_url AS package_url,
ext.orientation AS ext_orientation,
ext.safe_height AS ext_safe_height,
ext.currency_icon AS currency_icon,
ext.gsp AS ext_gsp,
ext.extra_json AS ext_extra_json,
cat.name AS catalog_name,
cat.cover AS catalog_cover,
cat.preview_url AS catalog_preview_url,
cat.download_url AS catalog_download_url,
cat.package_version AS catalog_package_version,
cat.orientation AS catalog_orientation,
cat.safe_height AS catalog_safe_height,
cat.status AS catalog_status
`).
Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1").
Joins("LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id").
Where("cfg.sys_origin = ? AND cfg.id = ? AND ext.vendor_type = ?", sysOrigin, configID, baishunVendorType).
Limit(1).
Scan(&row).Error
if err != nil {
return gameListRow{}, err
}
if row.ConfigID > 0 {
return row, nil
}
return gameListRow{}, NewAppError(http.StatusNotFound, "game_not_found", "room game config not found")
}
// resolveGSP 解析行模型中的 GSP 值。
func (s *BaishunService) resolveGSP(row gameListRow) int {
func (s *BaishunService) resolveGSP(row gameListRow, runtimeCfg baishunRuntimeConfig) int {
if parsed, err := strconv.Atoi(strings.TrimSpace(row.ExtGSP)); err == nil && parsed > 0 {
return parsed
}
if s.cfg.Baishun.GSP > 0 {
return s.cfg.Baishun.GSP
}
return 101
return runtimeCfg.gsp()
}

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"`
@ -183,34 +188,36 @@ type SyncCatalogResponse struct {
// AdminBaishunGameItem 是后台管理页展示的百顺游戏配置行。
type AdminBaishunGameItem struct {
ID int64 `json:"id"`
SysOrigin string `json:"sysOrigin"`
GameID string `json:"gameId"`
VendorGameID int `json:"vendorGameId"`
Name string `json:"name"`
Category string `json:"category"`
GameCode string `json:"gameCode"`
Cover string `json:"cover"`
Amounts string `json:"amounts"`
Showcase bool `json:"showcase"`
Sort int64 `json:"sort"`
Height float64 `json:"height"`
Width float64 `json:"width"`
FullScreen bool `json:"fullScreen"`
ClientOrigin string `json:"clientOrigin"`
Regions string `json:"regions"`
GameMode int `json:"gameMode"`
LaunchMode string `json:"launchMode"`
PackageVersion string `json:"packageVersion"`
PreviewURL string `json:"previewUrl"`
PackageURL string `json:"packageUrl"`
Orientation int `json:"orientation"`
SafeHeight int `json:"safeHeight"`
GSP string `json:"gsp"`
CurrencyIcon string `json:"currencyIcon"`
CatalogStatus string `json:"catalogStatus"`
CreateTime string `json:"createTime"`
UpdateTime string `json:"updateTime"`
ID int64 `json:"id"`
SysOrigin string `json:"sysOrigin"`
GameID string `json:"gameId"`
GameType string `json:"gameType,omitempty"`
VendorGameID int `json:"vendorGameId"`
Name string `json:"name"`
Category string `json:"category"`
GameCode string `json:"gameCode"`
Cover string `json:"cover"`
Amounts string `json:"amounts"`
Showcase bool `json:"showcase"`
Sort int64 `json:"sort"`
Height float64 `json:"height"`
Width float64 `json:"width"`
FullScreen bool `json:"fullScreen"`
ClientOrigin string `json:"clientOrigin"`
Regions string `json:"regions"`
GameMode int `json:"gameMode"`
LaunchMode string `json:"launchMode"`
PackageVersion string `json:"packageVersion"`
PreviewURL string `json:"previewUrl"`
PackageURL string `json:"packageUrl"`
Orientation int `json:"orientation"`
SafeHeight int `json:"safeHeight"`
GSP string `json:"gsp"`
CurrencyIcon string `json:"currencyIcon"`
CatalogStatus string `json:"catalogStatus"`
LaunchParams map[string]any `json:"launchParams,omitempty"`
CreateTime string `json:"createTime"`
UpdateTime string `json:"updateTime"`
}
// AdminBaishunGamePageResponse 是后台页分页响应。
@ -223,31 +230,32 @@ type AdminBaishunGamePageResponse struct {
// SaveAdminBaishunGameRequest 是后台保存百顺游戏配置的入参。
type SaveAdminBaishunGameRequest struct {
ID int64 `json:"id"`
SysOrigin string `json:"sysOrigin"`
GameID string `json:"gameId"`
VendorGameID int `json:"vendorGameId"`
Name string `json:"name"`
Category string `json:"category"`
GameCode string `json:"gameCode"`
Cover string `json:"cover"`
Amounts string `json:"amounts"`
Showcase bool `json:"showcase"`
Sort int64 `json:"sort"`
Height float64 `json:"height"`
Width float64 `json:"width"`
FullScreen bool `json:"fullScreen"`
ClientOrigin string `json:"clientOrigin"`
Regions string `json:"regions"`
GameMode int `json:"gameMode"`
LaunchMode string `json:"launchMode"`
PackageVersion string `json:"packageVersion"`
PreviewURL string `json:"previewUrl"`
PackageURL string `json:"packageUrl"`
Orientation *int `json:"orientation"`
SafeHeight int `json:"safeHeight"`
GSP string `json:"gsp"`
CurrencyIcon string `json:"currencyIcon"`
ID int64 `json:"id"`
SysOrigin string `json:"sysOrigin"`
GameID string `json:"gameId"`
VendorGameID int `json:"vendorGameId"`
Name string `json:"name"`
Category string `json:"category"`
GameCode string `json:"gameCode"`
Cover string `json:"cover"`
Amounts string `json:"amounts"`
Showcase bool `json:"showcase"`
Sort int64 `json:"sort"`
Height float64 `json:"height"`
Width float64 `json:"width"`
FullScreen bool `json:"fullScreen"`
ClientOrigin string `json:"clientOrigin"`
Regions string `json:"regions"`
GameMode int `json:"gameMode"`
LaunchMode string `json:"launchMode"`
PackageVersion string `json:"packageVersion"`
PreviewURL string `json:"previewUrl"`
PackageURL string `json:"packageUrl"`
Orientation *int `json:"orientation"`
SafeHeight int `json:"safeHeight"`
GSP string `json:"gsp"`
CurrencyIcon string `json:"currencyIcon"`
LaunchParams map[string]any `json:"launchParams,omitempty"`
}
// ImportCatalogResponse 描述从本地目录补齐后台配置的结果。
@ -265,6 +273,60 @@ type SyncAdminCatalogResponse struct {
Imported int `json:"imported"`
}
// BaishunProviderConfigItem 是后台页展示的百顺配置。
type BaishunProviderConfigItem struct {
ID int64 `json:"id"`
SysOrigin string `json:"sysOrigin"`
PlatformBaseURL string `json:"platformBaseUrl"`
AppID int64 `json:"appId"`
AppName string `json:"appName"`
AppChannel string `json:"appChannel"`
AppKey string `json:"appKey"`
GSP int `json:"gsp"`
LaunchCodeTTLSeconds int `json:"launchCodeTtlSeconds"`
SSTokenTTLSeconds int `json:"ssTokenTtlSeconds"`
UpdateTime string `json:"updateTime"`
}
// SaveBaishunProviderConfigRequest 是后台保存百顺配置的入参。
type SaveBaishunProviderConfigRequest struct {
SysOrigin string `json:"sysOrigin"`
PlatformBaseURL string `json:"platformBaseUrl"`
AppID int64 `json:"appId"`
AppName string `json:"appName"`
AppChannel string `json:"appChannel"`
AppKey string `json:"appKey"`
GSP int `json:"gsp"`
LaunchCodeTTLSeconds int `json:"launchCodeTtlSeconds"`
SSTokenTTLSeconds int `json:"ssTokenTtlSeconds"`
}
// AdminBaishunCatalogItem 是后台目录页展示的单个百顺目录项。
type AdminBaishunCatalogItem struct {
VendorGameID int `json:"vendorGameId"`
GameID string `json:"gameId"`
Name string `json:"name"`
Cover string `json:"cover"`
PreviewURL string `json:"previewUrl"`
DownloadURL string `json:"downloadUrl"`
PackageVersion string `json:"packageVersion"`
GSP string `json:"gsp"`
Status string `json:"status"`
SafeHeight int `json:"safeHeight"`
Orientation int `json:"orientation"`
Added bool `json:"added"`
Showcase bool `json:"showcase"`
UpdateTime string `json:"updateTime"`
}
// AdminBaishunCatalogPageResponse 是后台目录页分页响应。
type AdminBaishunCatalogPageResponse struct {
Cursor int `json:"cursor"`
Limit int `json:"limit"`
Records []AdminBaishunCatalogItem `json:"records"`
Total int64 `json:"total"`
}
// BaishunTokenRequest 是百顺拉取 token/code 回调入参。
type BaishunTokenRequest struct {
AppID int64 `json:"app_id"`
@ -365,6 +427,7 @@ type gameListRow struct {
ExtSafeHeight int
CurrencyIcon string
ExtGSP string
ExtExtraJSON string
CatalogName string
CatalogCover string
CatalogPreviewURL string

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;