diff --git a/README.md b/README.md index 705d636..d39e3ba 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ internal/utils ### 1. `cmd/api` - 只做启动和依赖装配。 - 不承担业务逻辑。 +- 当前线上只部署 `golang` API 进程,所以这里会同时启动 `registerreward` 的 Redis Stream consumer,确保注册成功事件能被消费并发奖。 - 不启动任何 `weekstar` 结算 goroutine。 ### 2. `cmd/consumer` @@ -36,6 +37,7 @@ internal/utils - 当前承接: - `registerreward` 的 Redis Stream 消费 - `weekstar` 的 RocketMQ 消费 +- 如果后续单独部署 consumer,`registerreward` 使用同一个 Redis consumer group,重复启动不会重复发奖。 ### 3. `internal/bootstrap` - 统一完成配置加载、存储初始化、依赖探活、Java 网关装配。 diff --git a/cmd/api/main.go b/cmd/api/main.go index 47bb5bb..3051220 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -37,6 +37,13 @@ func main() { 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) + + workerCtx, workerCancel := context.WithCancel(context.Background()) + defer workerCancel() + if err := registerRewardService.Start(workerCtx); err != nil { + log.Fatalf("start register reward consumer failed: %v", err) + } + gameProviders := gameprovider.NewRegistry( baishun.NewAppProvider(baishunService), ) @@ -76,6 +83,7 @@ func main() { return case <-shutdownSignal.Done(): log.Printf("shutdown signal received, draining http traffic") + workerCancel() } shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), app.Config.HTTP.ShutdownTimeout) diff --git a/internal/model/baishun_models.go b/internal/model/baishun_models.go index 35084fc..51616bb 100644 --- a/internal/model/baishun_models.go +++ b/internal/model/baishun_models.go @@ -28,25 +28,26 @@ func (SysGameListConfig) TableName() string { return "sys_game_list_config" } // SysGameListVendorExt 保存某个厂商在系统游戏表上的扩展信息。 type SysGameListVendorExt struct { - ID int64 `gorm:"column:id;primaryKey"` // 主键 - GameListConfigID int64 `gorm:"column:game_list_config_id;uniqueIndex:uk_game_vendor_ext_cfg_vendor,priority:1"` // 关联系统游戏配置 ID - SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_game_vendor_ext_sys_vendor,priority:1"` // 系统标识 - VendorType string `gorm:"column:vendor_type;size:32;uniqueIndex:uk_game_vendor_ext_cfg_vendor,priority:2;index:idx_game_vendor_ext_sys_vendor,priority:2"` // 厂商类型 - VendorGameID string `gorm:"column:vendor_game_id;size:64"` // 厂商游戏 ID - LaunchMode string `gorm:"column:launch_mode;size:32"` // 启动模式 - PackageVersion string `gorm:"column:package_version;size:32"` // 包版本号 - PackageURL string `gorm:"column:package_url;size:1024"` // 包下载地址 - PreviewURL string `gorm:"column:preview_url;size:1024"` // 预览地址 - Orientation *int `gorm:"column:orientation"` // 屏幕方向 - SafeHeight int `gorm:"column:safe_height"` // 安全区域高度 - GSP string `gorm:"column:gsp;size:64"` // GSP 配置 - BridgeSchemaVersion string `gorm:"column:bridge_schema_version;size:16"` // Bridge 协议版本 - CurrencyType *int `gorm:"column:currency_type"` // 货币类型 - CurrencyIcon string `gorm:"column:currency_icon;size:1024"` // 货币图标 - ExtraJSON string `gorm:"column:extra_json;type:text"` // 额外扩展配置 - Enabled bool `gorm:"column:enabled;index:idx_game_vendor_ext_sys_vendor,priority:3"` // 是否启用 - CreateTime time.Time `gorm:"column:create_time"` // 创建时间 - UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 + ID int64 `gorm:"column:id;primaryKey"` // 主键 + GameListConfigID int64 `gorm:"column:game_list_config_id;uniqueIndex:uk_game_vendor_ext_cfg_vendor,priority:1"` // 关联系统游戏配置 ID + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_game_vendor_ext_sys_vendor,priority:1;index:idx_game_vendor_ext_sys_profile_vendor,priority:1"` // 系统标识 + Profile string `gorm:"column:profile;size:32;default:PROD;index:idx_game_vendor_ext_sys_profile_vendor,priority:2"` // 配置档案:PROD/TEST + VendorType string `gorm:"column:vendor_type;size:32;uniqueIndex:uk_game_vendor_ext_cfg_vendor,priority:2;index:idx_game_vendor_ext_sys_vendor,priority:2;index:idx_game_vendor_ext_sys_profile_vendor,priority:3"` // 厂商类型 + VendorGameID string `gorm:"column:vendor_game_id;size:64;index:idx_game_vendor_ext_sys_profile_vendor,priority:4"` // 厂商游戏 ID + LaunchMode string `gorm:"column:launch_mode;size:32"` // 启动模式 + PackageVersion string `gorm:"column:package_version;size:32"` // 包版本号 + PackageURL string `gorm:"column:package_url;size:1024"` // 包下载地址 + PreviewURL string `gorm:"column:preview_url;size:1024"` // 预览地址 + Orientation *int `gorm:"column:orientation"` // 屏幕方向 + SafeHeight int `gorm:"column:safe_height"` // 安全区域高度 + GSP string `gorm:"column:gsp;size:64"` // GSP 配置 + BridgeSchemaVersion string `gorm:"column:bridge_schema_version;size:16"` // Bridge 协议版本 + CurrencyType *int `gorm:"column:currency_type"` // 货币类型 + CurrencyIcon string `gorm:"column:currency_icon;size:1024"` // 货币图标 + ExtraJSON string `gorm:"column:extra_json;type:text"` // 额外扩展配置 + Enabled bool `gorm:"column:enabled;index:idx_game_vendor_ext_sys_vendor,priority:3;index:idx_game_vendor_ext_sys_profile_vendor,priority:5"` // 是否启用 + CreateTime time.Time `gorm:"column:create_time"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 } // TableName 返回游戏厂商扩展表名。 @@ -54,18 +55,20 @@ func (SysGameListVendorExt) TableName() string { return "sys_game_list_vendor_ex // 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"` // 更新时间 + ID int64 `gorm:"column:id;primaryKey"` // 主键 + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_baishun_provider_sys_origin_profile,priority:1;index:idx_baishun_provider_active,priority:1"` // 系统标识 + Profile string `gorm:"column:profile;size:32;default:PROD;uniqueIndex:uk_baishun_provider_sys_origin_profile,priority:2"` // 配置档案:PROD/TEST + Active bool `gorm:"column:active;index:idx_baishun_provider_active,priority:2"` // 是否当前启用 + 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;index:idx_baishun_provider_active,priority:3"` // 更新时间 } // TableName 返回百顺配置表名。 @@ -73,24 +76,25 @@ func (BaishunProviderConfig) TableName() string { return "baishun_provider_confi // BaishunGameCatalog 是百顺平台同步到本地的游戏目录。 type BaishunGameCatalog struct { - ID int64 `gorm:"column:id;primaryKey"` // 主键 - SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_baishun_catalog_sys_vendor_game,priority:1;index:idx_baishun_catalog_internal_game,priority:1"` // 系统标识 - InternalGameID string `gorm:"column:internal_game_id;size:64;index:idx_baishun_catalog_internal_game,priority:2"` // 内部游戏 ID - VendorGameID int `gorm:"column:vendor_game_id;uniqueIndex:uk_baishun_catalog_sys_vendor_game,priority:2"` // 百顺游戏 ID - Name string `gorm:"column:name;size:128"` // 游戏名称 - Cover string `gorm:"column:cover;size:1024"` // 封面图 - PreviewURL string `gorm:"column:preview_url;size:1024"` // 预览地址 - DownloadURL string `gorm:"column:download_url;size:1024"` // 下载地址 - PackageVersion string `gorm:"column:package_version;size:32"` // 包版本 - GameModeJSON string `gorm:"column:game_mode_json;size:64"` // 游戏模式原始 JSON - Orientation *int `gorm:"column:orientation"` // 屏幕方向 - SafeHeight int `gorm:"column:safe_height"` // 安全区域高度 - VenueLevelJSON string `gorm:"column:venue_level_json;size:64"` // 场馆等级 JSON - GSP string `gorm:"column:gsp;size:64"` // GSP 配置 - RawJSON string `gorm:"column:raw_json;type:longtext"` // 原始目录数据 - Status string `gorm:"column:status;size:32;index:idx_baishun_catalog_internal_game,priority:3"` // 状态 - CreateTime time.Time `gorm:"column:create_time"` // 创建时间 - UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 + ID int64 `gorm:"column:id;primaryKey"` // 主键 + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_baishun_catalog_sys_profile_vendor_game,priority:1;index:idx_baishun_catalog_profile_internal_game,priority:1"` // 系统标识 + Profile string `gorm:"column:profile;size:32;default:PROD;uniqueIndex:uk_baishun_catalog_sys_profile_vendor_game,priority:2;index:idx_baishun_catalog_profile_internal_game,priority:2"` // 配置档案:PROD/TEST + InternalGameID string `gorm:"column:internal_game_id;size:64;index:idx_baishun_catalog_profile_internal_game,priority:3"` // 内部游戏 ID + VendorGameID int `gorm:"column:vendor_game_id;uniqueIndex:uk_baishun_catalog_sys_profile_vendor_game,priority:3"` // 百顺游戏 ID + Name string `gorm:"column:name;size:128"` // 游戏名称 + Cover string `gorm:"column:cover;size:1024"` // 封面图 + PreviewURL string `gorm:"column:preview_url;size:1024"` // 预览地址 + DownloadURL string `gorm:"column:download_url;size:1024"` // 下载地址 + PackageVersion string `gorm:"column:package_version;size:32"` // 包版本 + GameModeJSON string `gorm:"column:game_mode_json;size:64"` // 游戏模式原始 JSON + Orientation *int `gorm:"column:orientation"` // 屏幕方向 + SafeHeight int `gorm:"column:safe_height"` // 安全区域高度 + VenueLevelJSON string `gorm:"column:venue_level_json;size:64"` // 场馆等级 JSON + GSP string `gorm:"column:gsp;size:64"` // GSP 配置 + RawJSON string `gorm:"column:raw_json;type:longtext"` // 原始目录数据 + Status string `gorm:"column:status;size:32;index:idx_baishun_catalog_profile_internal_game,priority:4"` // 状态 + CreateTime time.Time `gorm:"column:create_time"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 } // TableName 返回百顺目录表名。 diff --git a/internal/router/baishun_routes.go b/internal/router/baishun_routes.go index 5db5c38..342e41e 100644 --- a/internal/router/baishun_routes.go +++ b/internal/router/baishun_routes.go @@ -96,7 +96,7 @@ 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")) + resp, err := baishunService.GetProviderConfig(c.Request.Context(), c.Query("sysOrigin"), c.Query("profile")) if err != nil { writeError(c, err) return @@ -116,12 +116,26 @@ func registerBaishunRoutes( } writeOK(c, resp) }) + consoleGroup.POST("/config/activate", func(c *gin.Context) { + var req baishun.ActivateBaishunProviderProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, baishun.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := baishunService.ActivateProviderProfile(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) consoleGroup.GET("/page", func(c *gin.Context) { cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1"))) limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) resp, err := baishunService.PageAdminGames( c.Request.Context(), c.Query("sysOrigin"), + c.Query("profile"), c.Query("keyword"), parseOptionalBool(c.Query("showcase")), cursor, @@ -139,6 +153,7 @@ func registerBaishunRoutes( resp, err := baishunService.PageCatalog( c.Request.Context(), c.Query("sysOrigin"), + c.Query("profile"), c.Query("keyword"), cursor, limit, @@ -164,7 +179,7 @@ func registerBaishunRoutes( }) consoleGroup.DELETE("", func(c *gin.Context) { id, _ := strconv.ParseInt(strings.TrimSpace(c.Query("id")), 10, 64) - if err := baishunService.DeleteAdminGame(c.Request.Context(), c.Query("sysOrigin"), id); err != nil { + if err := baishunService.DeleteAdminGame(c.Request.Context(), c.Query("sysOrigin"), c.Query("profile"), id); err != nil { writeError(c, err) return } @@ -189,7 +204,7 @@ func registerBaishunRoutes( writeError(c, baishun.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) return } - resp, err := baishunService.ImportCatalogGames(c.Request.Context(), req.SysOrigin, req.VendorGameIDs) + resp, err := baishunService.ImportCatalogGames(c.Request.Context(), req.SysOrigin, req.Profile, req.VendorGameIDs) if err != nil { writeError(c, err) return diff --git a/internal/router/invite_routes.go b/internal/router/invite_routes.go index a3484ee..71769e6 100644 --- a/internal/router/invite_routes.go +++ b/internal/router/invite_routes.go @@ -2,6 +2,7 @@ package router import ( "net/http" + "strconv" "chatapp3-golang/internal/service/invite" @@ -48,6 +49,15 @@ func registerInviteRoutes(engine *gin.Engine, javaClient authGateway, inviteServ } writeOK(c, resp) }) + appGroup.GET("/rank", func(c *gin.Context) { + limit, _ := strconv.Atoi(c.Query("limit")) + resp, err := inviteService.GetRank(c.Request.Context(), mustAuthUser(c), limit) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) appGroup.POST("/bind-code", func(c *gin.Context) { var req invite.BindCodeRequest if err := c.ShouldBindJSON(&req); err != nil { diff --git a/internal/service/baishun/admin.go b/internal/service/baishun/admin.go index 649c6ce..94a5ece 100644 --- a/internal/service/baishun/admin.go +++ b/internal/service/baishun/admin.go @@ -18,6 +18,7 @@ const adminTimeLayout = "2006-01-02 15:04:05" type adminGameRow struct { ID int64 SysOrigin string + Profile string InternalGameID string Name string Category string @@ -57,16 +58,21 @@ type adminGameRow struct { func (s *BaishunService) PageAdminGames( ctx context.Context, sysOrigin string, + profile string, keyword string, showcase *bool, cursor int, limit int, ) (*AdminBaishunGamePageResponse, error) { sysOrigin = normalizeAdminSysOrigin(sysOrigin) + profile, err := s.requestProfile(ctx, sysOrigin, profile) + if err != nil { + return nil, err + } cursor = normalizePageCursor(cursor) limit = normalizePageLimit(limit) - countQuery := s.buildAdminGameQuery(ctx, sysOrigin, keyword, showcase) + countQuery := s.buildAdminGameQuery(ctx, sysOrigin, profile, keyword, showcase) var total int64 if err := countQuery.Count(&total).Error; err != nil { @@ -74,11 +80,12 @@ func (s *BaishunService) PageAdminGames( } var rows []adminGameRow - listQuery := s.buildAdminGameQuery(ctx, sysOrigin, keyword, showcase) + listQuery := s.buildAdminGameQuery(ctx, sysOrigin, profile, keyword, showcase) if err := listQuery. Select(` cfg.id AS id, cfg.sys_origin AS sys_origin, + ext.profile AS profile, cfg.game_id AS internal_game_id, cfg.name AS name, cfg.category AS category, @@ -138,12 +145,16 @@ 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) + profile, err := s.requestProfile(ctx, sysOrigin, req.Profile) + if err != nil { + return nil, err + } + runtimeCfg, err := s.resolveRuntimeConfigForProfile(ctx, sysOrigin, profile) if err != nil { return nil, err } - catalog, err := s.findCatalogForAdmin(ctx, sysOrigin, req.VendorGameID, req.GameID) + catalog, err := s.findCatalogForAdmin(ctx, sysOrigin, profile, req.VendorGameID, req.GameID) if err != nil { return nil, err } @@ -197,7 +208,7 @@ func (s *BaishunService) SaveAdminGame(ctx context.Context, req SaveAdminBaishun } }() - cfg, err := s.findAdminConfigForSave(tx, sysOrigin, req.ID, gameID, req.VendorGameID) + cfg, err := s.findAdminConfigForSave(tx, sysOrigin, profile, req.ID, gameID, req.VendorGameID) if err != nil { tx.Rollback() return nil, err @@ -262,7 +273,7 @@ func (s *BaishunService) SaveAdminGame(ctx context.Context, req SaveAdminBaishun } var ext model.SysGameListVendorExt - err = tx.Where("game_list_config_id = ? AND vendor_type = ?", cfg.ID, baishunVendorType).First(&ext).Error + err = tx.Where("game_list_config_id = ? AND vendor_type = ? AND profile = ?", cfg.ID, baishunVendorType, profile).First(&ext).Error if err != nil && err != gorm.ErrRecordNotFound { tx.Rollback() return nil, err @@ -280,6 +291,7 @@ func (s *BaishunService) SaveAdminGame(ctx context.Context, req SaveAdminBaishun ID: nextID, GameListConfigID: cfg.ID, SysOrigin: sysOrigin, + Profile: profile, VendorType: baishunVendorType, VendorGameID: strconv.Itoa(req.VendorGameID), LaunchMode: launchMode, @@ -304,6 +316,7 @@ func (s *BaishunService) SaveAdminGame(ctx context.Context, req SaveAdminBaishun Where("id = ?", ext.ID). Updates(map[string]any{ "sys_origin": sysOrigin, + "profile": profile, "vendor_game_id": strconv.Itoa(req.VendorGameID), "launch_mode": launchMode, "package_version": packageVersion, @@ -326,40 +339,63 @@ func (s *BaishunService) SaveAdminGame(ctx context.Context, req SaveAdminBaishun if err := tx.Commit().Error; err != nil { return nil, err } - return s.getAdminGame(ctx, sysOrigin, cfg.ID) + s.clearJavaGameListCache(ctx) + return s.getAdminGame(ctx, sysOrigin, profile, cfg.ID) } // DeleteAdminGame 删除后台百顺游戏配置,不影响目录表。 -func (s *BaishunService) DeleteAdminGame(ctx context.Context, sysOrigin string, id int64) error { +func (s *BaishunService) DeleteAdminGame(ctx context.Context, sysOrigin string, profile string, id int64) error { if id <= 0 { return NewAppError(http.StatusBadRequest, "id_required", "id is required") } sysOrigin = normalizeAdminSysOrigin(sysOrigin) + profile, err := s.requestProfile(ctx, sysOrigin, profile) + if err != nil { + return err + } tx := s.repo.DB.WithContext(ctx).Begin() if tx.Error != nil { return tx.Error } - if err := tx.Where("game_list_config_id = ? AND vendor_type = ?", id, baishunVendorType). + if err := tx.Where("game_list_config_id = ? AND vendor_type = ? AND profile = ?", id, baishunVendorType, profile). Delete(&model.SysGameListVendorExt{}).Error; err != nil { tx.Rollback() return err } - if err := tx.Where("id = ? AND sys_origin = ? AND game_origin = ?", id, sysOrigin, baishunVendorType). - Delete(&model.SysGameListConfig{}).Error; err != nil { + var remaining int64 + if err := tx.Model(&model.SysGameListVendorExt{}). + Where("game_list_config_id = ? AND vendor_type = ?", id, baishunVendorType). + Count(&remaining).Error; err != nil { tx.Rollback() return err } - return tx.Commit().Error + if remaining == 0 { + if err := tx.Where("id = ? AND sys_origin = ? AND game_origin = ?", id, sysOrigin, baishunVendorType). + Delete(&model.SysGameListConfig{}).Error; err != nil { + tx.Rollback() + return err + } + } + if err := tx.Commit().Error; err != nil { + return err + } + s.clearJavaGameListCache(ctx) + return nil } // ImportCatalogGames 把本地目录中尚未进入后台列表的百顺游戏补齐成可配置记录。 -func (s *BaishunService) ImportCatalogGames(ctx context.Context, sysOrigin string, vendorGameIDs []int) (*ImportCatalogResponse, error) { +func (s *BaishunService) ImportCatalogGames(ctx context.Context, sysOrigin string, profile string, vendorGameIDs []int) (*ImportCatalogResponse, error) { + sysOrigin = normalizeAdminSysOrigin(sysOrigin) + profile, err := s.requestProfile(ctx, sysOrigin, profile) + if err != nil { + return nil, err + } tx := s.repo.DB.WithContext(ctx).Begin() if tx.Error != nil { return nil, tx.Error } - resp, err := s.importCatalogGamesTx(tx, normalizeAdminSysOrigin(sysOrigin), buildVendorGameIDFilter(vendorGameIDs)) + resp, err := s.importCatalogGamesTx(tx, sysOrigin, profile, buildVendorGameIDFilter(vendorGameIDs)) if err != nil { tx.Rollback() return nil, err @@ -367,6 +403,7 @@ func (s *BaishunService) ImportCatalogGames(ctx context.Context, sysOrigin strin if err := tx.Commit().Error; err != nil { return nil, err } + s.clearJavaGameListCache(ctx) return resp, nil } @@ -376,7 +413,7 @@ func (s *BaishunService) SyncAdminGames(ctx context.Context, req SyncCatalogRequ if err != nil { return nil, err } - importResp, err := s.ImportCatalogGames(ctx, req.SysOrigin, req.VendorGameIDs) + importResp, err := s.ImportCatalogGames(ctx, req.SysOrigin, req.Profile, req.VendorGameIDs) if err != nil { return nil, err } @@ -389,13 +426,14 @@ func (s *BaishunService) SyncAdminGames(ctx context.Context, req SyncCatalogRequ }, nil } -func (s *BaishunService) buildAdminGameQuery(ctx context.Context, sysOrigin, keyword string, showcase *bool) *gorm.DB { +func (s *BaishunService) buildAdminGameQuery(ctx context.Context, sysOrigin, profile, keyword string, showcase *bool) *gorm.DB { query := s.repo.DB.WithContext(ctx). Table("sys_game_list_config AS cfg"). - Joins("LEFT JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.vendor_type = ?", baishunVendorType). + Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.vendor_type = ? AND ext.profile = ?", baishunVendorType, profile). Joins(` LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin + AND cat.profile = ext.profile AND ( CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id OR cat.internal_game_id = cfg.game_id @@ -418,13 +456,14 @@ func (s *BaishunService) buildAdminGameQuery(ctx context.Context, sysOrigin, key return query } -func (s *BaishunService) getAdminGame(ctx context.Context, sysOrigin string, id int64) (*AdminBaishunGameItem, error) { +func (s *BaishunService) getAdminGame(ctx context.Context, sysOrigin string, profile string, id int64) (*AdminBaishunGameItem, error) { var rows []adminGameRow - err := s.buildAdminGameQuery(ctx, sysOrigin, "", nil). + err := s.buildAdminGameQuery(ctx, sysOrigin, profile, "", nil). Where("cfg.id = ?", id). Select(` cfg.id AS id, cfg.sys_origin AS sys_origin, + ext.profile AS profile, cfg.game_id AS internal_game_id, cfg.name AS name, cfg.category AS category, @@ -471,8 +510,8 @@ func (s *BaishunService) getAdminGame(ctx context.Context, sysOrigin string, id return nil, NewAppError(http.StatusNotFound, "game_not_found", "admin baishun game not found") } -func (s *BaishunService) findCatalogForAdmin(ctx context.Context, sysOrigin string, vendorGameID int, gameID string) (model.BaishunGameCatalog, error) { - query := s.repo.DB.WithContext(ctx).Where("sys_origin = ?", sysOrigin) +func (s *BaishunService) findCatalogForAdmin(ctx context.Context, sysOrigin string, profile string, vendorGameID int, gameID string) (model.BaishunGameCatalog, error) { + query := s.repo.DB.WithContext(ctx).Where("sys_origin = ? AND profile = ?", sysOrigin, profile) switch { case vendorGameID > 0: query = query.Where("vendor_game_id = ?", vendorGameID) @@ -492,19 +531,31 @@ func (s *BaishunService) findCatalogForAdmin(ctx context.Context, sysOrigin stri return catalog, nil } -func (s *BaishunService) findAdminConfigForSave(tx *gorm.DB, sysOrigin string, id int64, gameID string, vendorGameID int) (model.SysGameListConfig, error) { +func (s *BaishunService) findAdminConfigForSave(tx *gorm.DB, sysOrigin string, profile string, id int64, gameID string, vendorGameID int) (model.SysGameListConfig, error) { var cfg model.SysGameListConfig if id > 0 { err := tx.Where("id = ? AND sys_origin = ? AND game_origin = ?", id, sysOrigin, baishunVendorType).First(&cfg).Error if err == gorm.ErrRecordNotFound { return model.SysGameListConfig{}, NewAppError(http.StatusNotFound, "game_not_found", "admin baishun game not found") } + if err == nil { + var extCount int64 + err = tx.Model(&model.SysGameListVendorExt{}). + Where("game_list_config_id = ? AND vendor_type = ? AND profile = ?", cfg.ID, baishunVendorType, profile). + Count(&extCount).Error + if err != nil { + return model.SysGameListConfig{}, err + } + if extCount == 0 { + return model.SysGameListConfig{}, NewAppError(http.StatusNotFound, "game_not_found", "admin baishun game not found") + } + } return cfg, err } if vendorGameID > 0 { var ext model.SysGameListVendorExt - err := tx.Where("sys_origin = ? AND vendor_type = ? AND vendor_game_id = ?", sysOrigin, baishunVendorType, strconv.Itoa(vendorGameID)).First(&ext).Error + err := tx.Where("sys_origin = ? AND profile = ? AND vendor_type = ? AND vendor_game_id = ?", sysOrigin, profile, baishunVendorType, strconv.Itoa(vendorGameID)).First(&ext).Error if err != nil && err != gorm.ErrRecordNotFound { return model.SysGameListConfig{}, err } @@ -522,23 +573,31 @@ func (s *BaishunService) findAdminConfigForSave(tx *gorm.DB, sysOrigin string, i if strings.TrimSpace(gameID) == "" { return model.SysGameListConfig{}, nil } - err := tx.Where("sys_origin = ? AND game_origin = ? AND game_id = ?", sysOrigin, baishunVendorType, strings.TrimSpace(gameID)).First(&cfg).Error - if err == gorm.ErrRecordNotFound { + err := tx.Table("sys_game_list_config AS cfg"). + Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.vendor_type = ? AND ext.profile = ?", baishunVendorType, profile). + Where("cfg.sys_origin = ? AND cfg.game_origin = ? AND cfg.game_id = ?", sysOrigin, baishunVendorType, strings.TrimSpace(gameID)). + Select("cfg.*"). + Limit(1). + Scan(&cfg).Error + if err != nil { + return model.SysGameListConfig{}, err + } + if cfg.ID == 0 { return model.SysGameListConfig{}, nil } - return cfg, err + return cfg, nil } -func (s *BaishunService) importCatalogGamesTx(tx *gorm.DB, sysOrigin string, filter map[int]struct{}) (*ImportCatalogResponse, error) { +func (s *BaishunService) importCatalogGamesTx(tx *gorm.DB, sysOrigin string, profile string, filter map[int]struct{}) (*ImportCatalogResponse, error) { ctx := tx.Statement.Context if ctx == nil { ctx = context.Background() } - runtimeCfg, err := s.resolveRuntimeConfig(ctx, sysOrigin) + runtimeCfg, err := s.resolveRuntimeConfigForProfile(ctx, sysOrigin, profile) if err != nil { return nil, err } - query := tx.Where("sys_origin = ? AND status = ?", sysOrigin, baishunCatalogEnabled) + query := tx.Where("sys_origin = ? AND profile = ? AND status = ?", sysOrigin, profile, baishunCatalogEnabled) if len(filter) > 0 { ids := make([]int, 0, len(filter)) for id := range filter { @@ -558,70 +617,98 @@ func (s *BaishunService) importCatalogGamesTx(tx *gorm.DB, sysOrigin string, fil 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 + err := tx.Where("sys_origin = ? AND profile = ? AND vendor_type = ? AND vendor_game_id = ?", sysOrigin, profile, baishunVendorType, vendorGameID).First(&ext).Error if err != nil && err != gorm.ErrRecordNotFound { return nil, err } if err == nil && ext.GameListConfigID > 0 { - resp.Existing++ - continue + var cfg model.SysGameListConfig + cfgErr := tx.Where("id = ? AND sys_origin = ? AND game_origin = ?", ext.GameListConfigID, sysOrigin, baishunVendorType).First(&cfg).Error + if cfgErr == nil { + resp.Existing++ + continue + } + if cfgErr != gorm.ErrRecordNotFound { + return nil, cfgErr + } } - var cfg model.SysGameListConfig - err = tx.Where("sys_origin = ? AND game_origin = ? AND game_id = ?", sysOrigin, baishunVendorType, catalog.InternalGameID).First(&cfg).Error - if err != nil && err != gorm.ErrRecordNotFound { - return nil, err + nextCfgID, idErr := utils.NextID() + if idErr != nil { + return nil, idErr } - if err == gorm.ErrRecordNotFound { - nextID, idErr := utils.NextID() - if idErr != nil { - return nil, idErr - } - cfg = model.SysGameListConfig{ - ID: nextID, - SysOrigin: sysOrigin, - GameOrigin: baishunVendorType, - GameID: defaultIfBlank(catalog.InternalGameID, fmt.Sprintf("bs_%d", catalog.VendorGameID)), - Name: catalog.Name, - Category: "OTHER", - GameCode: vendorGameID, - Cover: defaultIfBlank(catalog.Cover, catalog.PreviewURL), - Showcase: true, - Sort: int64(catalog.VendorGameID), - FullScreen: true, - ClientOrigin: "COMMON", - GameMode: formatAdminGameModeRaw(baishunFixedGameMode), - } - if err := tx.Create(&cfg).Error; err != nil { - return nil, err - } + cfg := model.SysGameListConfig{ + ID: nextCfgID, + SysOrigin: sysOrigin, + GameOrigin: baishunVendorType, + GameID: defaultIfBlank(catalog.InternalGameID, fmt.Sprintf("bs_%d", catalog.VendorGameID)), + Name: catalog.Name, + Category: "OTHER", + GameCode: vendorGameID, + Cover: defaultIfBlank(catalog.Cover, catalog.PreviewURL), + Showcase: true, + Sort: int64(catalog.VendorGameID), + FullScreen: true, + ClientOrigin: "COMMON", + GameMode: formatAdminGameModeRaw(baishunFixedGameMode), + } + if err := tx.Create(&cfg).Error; err != nil { + return nil, err } nextID, idErr := utils.NextID() if idErr != nil { return nil, idErr } - newExt := model.SysGameListVendorExt{ - ID: nextID, - GameListConfigID: cfg.ID, - SysOrigin: sysOrigin, - VendorType: baishunVendorType, - VendorGameID: vendorGameID, - LaunchMode: baishunLaunchModeRemote, - PackageVersion: catalog.PackageVersion, - PackageURL: catalog.DownloadURL, - PreviewURL: catalog.PreviewURL, - Orientation: catalog.Orientation, - SafeHeight: catalog.SafeHeight, - GSP: defaultAdminGSP(catalog.GSP, runtimeCfg.gsp()), - BridgeSchemaVersion: "1.0", - ExtraJSON: updateExtExtraJSONWithGameType("", nil, baishunVendorType, false), - Enabled: true, - CreateTime: now, - UpdateTime: now, + extraJSON := updateExtExtraJSONWithGameType(ext.ExtraJSON, nil, baishunVendorType, ext.ID != 0) + extValues := map[string]any{ + "game_list_config_id": cfg.ID, + "sys_origin": sysOrigin, + "profile": profile, + "vendor_type": baishunVendorType, + "vendor_game_id": vendorGameID, + "launch_mode": baishunLaunchModeRemote, + "package_version": catalog.PackageVersion, + "package_url": catalog.DownloadURL, + "preview_url": catalog.PreviewURL, + "orientation": catalog.Orientation, + "safe_height": catalog.SafeHeight, + "gsp": defaultAdminGSP(catalog.GSP, runtimeCfg.gsp()), + "bridge_schema_version": "1.0", + "extra_json": extraJSON, + "enabled": true, + "update_time": now, } - if err := tx.Create(&newExt).Error; err != nil { - return nil, err + if ext.ID > 0 { + if err := tx.Model(&model.SysGameListVendorExt{}). + Where("id = ?", ext.ID). + Updates(extValues).Error; err != nil { + return nil, err + } + } else { + newExt := model.SysGameListVendorExt{ + ID: nextID, + GameListConfigID: cfg.ID, + SysOrigin: sysOrigin, + Profile: profile, + VendorType: baishunVendorType, + VendorGameID: vendorGameID, + LaunchMode: baishunLaunchModeRemote, + PackageVersion: catalog.PackageVersion, + PackageURL: catalog.DownloadURL, + PreviewURL: catalog.PreviewURL, + Orientation: catalog.Orientation, + SafeHeight: catalog.SafeHeight, + GSP: defaultAdminGSP(catalog.GSP, runtimeCfg.gsp()), + BridgeSchemaVersion: "1.0", + ExtraJSON: extraJSON, + Enabled: true, + CreateTime: now, + UpdateTime: now, + } + if err := tx.Create(&newExt).Error; err != nil { + return nil, err + } } resp.Imported++ } @@ -632,6 +719,7 @@ func (r adminGameRow) toAdminItem() AdminBaishunGameItem { return AdminBaishunGameItem{ ID: r.ID, SysOrigin: r.SysOrigin, + Profile: normalizeBaishunProfile(r.Profile), GameID: r.InternalGameID, GameType: gameTypeFromExtraJSON(r.ExtExtraJSON, baishunVendorType), VendorGameID: parseAdminVendorGameID(r.VendorGameID, r.InternalGameID), diff --git a/internal/service/baishun/admin_test.go b/internal/service/baishun/admin_test.go index 1718896..ffc4833 100644 --- a/internal/service/baishun/admin_test.go +++ b/internal/service/baishun/admin_test.go @@ -1,6 +1,12 @@ package baishun -import "testing" +import ( + "context" + "testing" + "time" + + "chatapp3-golang/internal/model" +) func TestResolveAdminShowcase(t *testing.T) { t.Run("create defaults to enabled", func(t *testing.T) { @@ -22,3 +28,192 @@ func TestResolveAdminShowcase(t *testing.T) { } }) } + +func TestImportAndListRoomGamesUseActiveProfile(t *testing.T) { + service, db := newTestBaishunService(t) + ctx := context.Background() + now := time.Now() + + if err := db.Create(&[]model.BaishunProviderConfig{ + { + ID: 1001, + SysOrigin: "LIKEI", + Profile: baishunProfileProd, + Active: true, + PlatformBaseURL: "https://prod.example.com", + AppID: 111, + AppChannel: "prod", + AppKey: "prod-key", + GSP: 101, + CreateTime: now, + UpdateTime: now, + }, + { + ID: 1002, + SysOrigin: "LIKEI", + Profile: baishunProfileTest, + Active: false, + PlatformBaseURL: "https://test.example.com", + AppID: 222, + AppChannel: "test", + AppKey: "test-key", + GSP: 102, + CreateTime: now, + UpdateTime: now, + }, + }).Error; err != nil { + t.Fatalf("create provider configs: %v", err) + } + + if err := db.Create(&[]model.BaishunGameCatalog{ + { + ID: 2001, + SysOrigin: "LIKEI", + Profile: baishunProfileProd, + InternalGameID: "bs_9001", + VendorGameID: 9001, + Name: "Prod Game", + Cover: "https://cdn.example.com/prod.png", + DownloadURL: "https://cdn.example.com/prod/index.html", + Status: baishunCatalogEnabled, + CreateTime: now, + UpdateTime: now, + }, + { + ID: 2002, + SysOrigin: "LIKEI", + Profile: baishunProfileTest, + InternalGameID: "bs_9001", + VendorGameID: 9001, + Name: "Test Game", + Cover: "https://cdn.example.com/test.png", + DownloadURL: "https://cdn.example.com/test/index.html", + Status: baishunCatalogEnabled, + CreateTime: now, + UpdateTime: now, + }, + }).Error; err != nil { + t.Fatalf("create catalogs: %v", err) + } + + if resp, err := service.ImportCatalogGames(ctx, "LIKEI", baishunProfileProd, []int{9001}); err != nil { + t.Fatalf("import prod catalog: %v", err) + } else if resp.Imported != 1 { + t.Fatalf("prod imported = %d, want 1", resp.Imported) + } + if resp, err := service.ImportCatalogGames(ctx, "LIKEI", baishunProfileTest, []int{9001}); err != nil { + t.Fatalf("import test catalog: %v", err) + } else if resp.Imported != 1 { + t.Fatalf("test imported = %d, want 1", resp.Imported) + } + + prodList, err := service.ListRoomGames(ctx, AuthUser{SysOrigin: "LIKEI"}, "room-1", "") + if err != nil { + t.Fatalf("list prod games: %v", err) + } + if len(prodList.Items) != 1 || prodList.Items[0].Name != "Prod Game" { + t.Fatalf("prod list = %+v, want only Prod Game", prodList.Items) + } + + if _, err := service.ActivateProviderProfile(ctx, ActivateBaishunProviderProfileRequest{ + SysOrigin: "LIKEI", + Profile: baishunProfileTest, + }); err != nil { + t.Fatalf("activate test profile: %v", err) + } + + testList, err := service.ListRoomGames(ctx, AuthUser{SysOrigin: "LIKEI"}, "room-1", "") + if err != nil { + t.Fatalf("list test games: %v", err) + } + if len(testList.Items) != 1 || testList.Items[0].Name != "Test Game" { + t.Fatalf("test list = %+v, want only Test Game", testList.Items) + } +} + +func TestCatalogAddedRequiresExistingGameConfigAndImportRepairsDanglingExt(t *testing.T) { + service, db := newTestBaishunService(t) + ctx := context.Background() + now := time.Now() + + if err := db.Create(&model.BaishunGameCatalog{ + ID: 3001, + SysOrigin: "LIKEI", + Profile: baishunProfileProd, + InternalGameID: "bs_1021", + VendorGameID: 1021, + Name: "LuckyGift", + Cover: "https://cdn.example.com/lucky.png", + DownloadURL: "https://cdn.example.com/lucky/index.html", + Status: baishunCatalogEnabled, + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("create catalog: %v", err) + } + + if err := db.Create(&model.SysGameListVendorExt{ + ID: 4001, + GameListConfigID: 999999, + SysOrigin: "LIKEI", + Profile: baishunProfileProd, + VendorType: baishunVendorType, + VendorGameID: "1021", + Enabled: true, + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("create dangling ext: %v", err) + } + + if catalogAddedForVendor(t, service, ctx, "LIKEI", baishunProfileProd, 1021) { + t.Fatalf("catalog added before import = true, want false for dangling ext") + } + + importResp, err := service.ImportCatalogGames(ctx, "LIKEI", baishunProfileProd, []int{1021}) + if err != nil { + t.Fatalf("import catalog with dangling ext: %v", err) + } + if importResp.Imported != 1 || importResp.Existing != 0 { + t.Fatalf("import response = %+v, want imported 1 existing 0", importResp) + } + + var ext model.SysGameListVendorExt + if err := db.Where("id = ?", int64(4001)).First(&ext).Error; err != nil { + t.Fatalf("load repaired ext: %v", err) + } + if ext.GameListConfigID == 999999 || ext.GameListConfigID == 0 { + t.Fatalf("ext game_list_config_id = %d, want repaired config id", ext.GameListConfigID) + } + + var cfg model.SysGameListConfig + if err := db.Where("id = ? AND sys_origin = ? AND game_origin = ?", ext.GameListConfigID, "LIKEI", baishunVendorType).First(&cfg).Error; err != nil { + t.Fatalf("load repaired config: %v", err) + } + if cfg.Name != "LuckyGift" { + t.Fatalf("repaired config name = %q, want LuckyGift", cfg.Name) + } + + if !catalogAddedForVendor(t, service, ctx, "LIKEI", baishunProfileProd, 1021) { + t.Fatalf("catalog added after import = false, want true") + } +} + +func catalogAddedForVendor(t *testing.T, service *BaishunService, ctx context.Context, sysOrigin string, profile string, vendorGameID int) bool { + t.Helper() + + var rows []struct { + AddedConfigID *int64 + } + err := service.buildAdminCatalogQuery(ctx, sysOrigin, profile, ""). + Where("cat.vendor_game_id = ?", vendorGameID). + Select("cfg.id AS added_config_id"). + Scan(&rows).Error + if err != nil { + t.Fatalf("query catalog added flag: %v", err) + } + if len(rows) != 1 { + t.Fatalf("catalog rows = %d, want 1", len(rows)) + } + return rows[0].AddedConfigID != nil && *rows[0].AddedConfigID > 0 +} diff --git a/internal/service/baishun/catalog.go b/internal/service/baishun/catalog.go index 6870f9a..e8deb8e 100644 --- a/internal/service/baishun/catalog.go +++ b/internal/service/baishun/catalog.go @@ -18,11 +18,12 @@ import ( // SyncCatalog 从百顺平台拉取游戏目录并同步到本地表。 func (s *BaishunService) SyncCatalog(ctx context.Context, req SyncCatalogRequest) (*SyncCatalogResponse, error) { - sysOrigin := defaultIfBlank(req.SysOrigin, "LIKEI") - if strings.TrimSpace(sysOrigin) == "" { - sysOrigin = "LIKEI" + sysOrigin := normalizeAdminSysOrigin(req.SysOrigin) + profile, err := s.requestProfile(ctx, sysOrigin, req.Profile) + if err != nil { + return nil, err } - runtimeCfg, err := s.requireProviderConfig(ctx, sysOrigin) + runtimeCfg, err := s.requireProviderConfigForProfile(ctx, sysOrigin, profile) if err != nil { return nil, err } @@ -51,6 +52,7 @@ func (s *BaishunService) SyncCatalog(ctx context.Context, req SyncCatalogRequest record := model.BaishunGameCatalog{ ID: id, SysOrigin: sysOrigin, + Profile: profile, InternalGameID: fmt.Sprintf("bs_%d", game.GameID), VendorGameID: game.GameID, Name: game.Name, @@ -69,7 +71,7 @@ func (s *BaishunService) SyncCatalog(ctx context.Context, req SyncCatalogRequest UpdateTime: time.Now(), } err = s.repo.DB.WithContext(ctx).Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "sys_origin"}, {Name: "vendor_game_id"}}, + Columns: []clause.Column{{Name: "sys_origin"}, {Name: "profile"}, {Name: "vendor_game_id"}}, DoUpdates: clause.AssignmentColumns([]string{ "internal_game_id", "name", "cover", "preview_url", "download_url", "package_version", "game_mode_json", "orientation", "safe_height", diff --git a/internal/service/baishun/catalog_admin.go b/internal/service/baishun/catalog_admin.go index ec27269..0f2bc9b 100644 --- a/internal/service/baishun/catalog_admin.go +++ b/internal/service/baishun/catalog_admin.go @@ -10,6 +10,7 @@ import ( type adminCatalogRow struct { VendorGameID int + Profile string InternalGameID string Name string Cover string @@ -28,24 +29,30 @@ type adminCatalogRow struct { func (s *BaishunService) PageCatalog( ctx context.Context, sysOrigin string, + profile string, keyword string, cursor int, limit int, ) (*AdminBaishunCatalogPageResponse, error) { sysOrigin = normalizeAdminSysOrigin(sysOrigin) + profile, err := s.requestProfile(ctx, sysOrigin, profile) + if err != nil { + return nil, err + } cursor = normalizePageCursor(cursor) limit = normalizePageLimit(limit) - countQuery := s.buildAdminCatalogQuery(ctx, sysOrigin, keyword) + countQuery := s.buildAdminCatalogQuery(ctx, sysOrigin, profile, keyword) var total int64 if err := countQuery.Count(&total).Error; err != nil { return nil, err } var rows []adminCatalogRow - err := s.buildAdminCatalogQuery(ctx, sysOrigin, keyword). + err = s.buildAdminCatalogQuery(ctx, sysOrigin, profile, keyword). Select(` cat.vendor_game_id AS vendor_game_id, + cat.profile AS profile, cat.internal_game_id AS internal_game_id, cat.name AS name, cat.cover AS cover, @@ -56,7 +63,7 @@ func (s *BaishunService) PageCatalog( cat.status AS status, cat.safe_height AS safe_height, cat.orientation AS orientation, - ext.game_list_config_id AS added_config_id, + cfg.id AS added_config_id, cfg.is_showcase AS showcase, DATE_FORMAT(cat.update_time, '%Y-%m-%d %H:%i:%s') AS update_time `). @@ -72,6 +79,7 @@ func (s *BaishunService) PageCatalog( for _, row := range rows { items = append(items, AdminBaishunCatalogItem{ VendorGameID: row.VendorGameID, + Profile: normalizeBaishunProfile(row.Profile), GameID: row.InternalGameID, Name: row.Name, Cover: row.Cover, @@ -100,18 +108,24 @@ func (s *BaishunService) FetchAdminCatalog(ctx context.Context, req SyncCatalogR return s.SyncCatalog(ctx, req) } -func (s *BaishunService) buildAdminCatalogQuery(ctx context.Context, sysOrigin string, keyword string) *gorm.DB { +func (s *BaishunService) buildAdminCatalogQuery(ctx context.Context, sysOrigin string, profile 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.profile = cat.profile 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) + Joins(` + LEFT JOIN sys_game_list_config cfg + ON cfg.id = ext.game_list_config_id + AND cfg.sys_origin = cat.sys_origin + AND cfg.game_origin = ? + `, baishunVendorType). + Where("cat.sys_origin = ? AND cat.profile = ?", sysOrigin, profile) keyword = strings.TrimSpace(keyword) if keyword != "" { like := "%" + keyword + "%" @@ -134,3 +148,14 @@ func (s *BaishunService) requireProviderConfig(ctx context.Context, sysOrigin st } return runtimeCfg, nil } + +func (s *BaishunService) requireProviderConfigForProfile(ctx context.Context, sysOrigin string, profile string) (baishunRuntimeConfig, error) { + runtimeCfg, err := s.resolveRuntimeConfigForProfile(ctx, sysOrigin, profile) + if err != nil { + return baishunRuntimeConfig{}, err + } + if strings.TrimSpace(runtimeCfg.PlatformBaseURL) == "" || runtimeCfg.AppID <= 0 || strings.TrimSpace(runtimeCfg.AppKey) == "" { + return baishunRuntimeConfig{}, NewAppError(http.StatusBadRequest, "baishun_config_missing", "baishun provider config is missing") + } + return runtimeCfg, nil +} diff --git a/internal/service/baishun/game_list_cache.go b/internal/service/baishun/game_list_cache.go new file mode 100644 index 0000000..a8cb2bf --- /dev/null +++ b/internal/service/baishun/game_list_cache.go @@ -0,0 +1,25 @@ +package baishun + +import "context" + +const javaGameListCachePattern = "GAME_LIST*" + +func (s *BaishunService) clearJavaGameListCache(ctx context.Context) { + if s.repo.Redis == nil { + return + } + var cursor uint64 + for { + keys, nextCursor, err := s.repo.Redis.Scan(ctx, cursor, javaGameListCachePattern, 100).Result() + if err != nil { + return + } + if len(keys) > 0 { + _ = s.repo.Redis.Del(ctx, keys...).Err() + } + if nextCursor == 0 { + return + } + cursor = nextCursor + } +} diff --git a/internal/service/baishun/launch_fallback_test.go b/internal/service/baishun/launch_fallback_test.go index b52e57f..69891c3 100644 --- a/internal/service/baishun/launch_fallback_test.go +++ b/internal/service/baishun/launch_fallback_test.go @@ -2,6 +2,7 @@ package baishun import ( "context" + "strings" "testing" "chatapp3-golang/internal/config" @@ -37,7 +38,8 @@ func (stubGateway) ChangeGoldBalance(context.Context, integration.GoldReceiptCom func newTestBaishunService(t *testing.T) (*BaishunService, *gorm.DB) { t.Helper() - db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{}) if err != nil { t.Fatalf("open sqlite: %v", err) } @@ -73,6 +75,7 @@ func TestLaunchGameFallsBackToCatalogWhenConfigMissing(t *testing.T) { if err := db.Create(&model.BaishunGameCatalog{ ID: 1, SysOrigin: "LIKEI", + Profile: baishunProfileProd, InternalGameID: "bs_1146", VendorGameID: 1146, Name: "LordOfOlympus", diff --git a/internal/service/baishun/profile.go b/internal/service/baishun/profile.go new file mode 100644 index 0000000..d714105 --- /dev/null +++ b/internal/service/baishun/profile.go @@ -0,0 +1,61 @@ +package baishun + +import ( + "chatapp3-golang/internal/model" + "context" + "net/http" + "strings" + + "gorm.io/gorm" +) + +const ( + baishunProfileProd = "PROD" + baishunProfileTest = "TEST" +) + +func normalizeBaishunProfile(profile string) string { + switch strings.ToUpper(strings.TrimSpace(profile)) { + case "", "PROD", "PRODUCT", "PRODUCTION", "ONLINE", "OFFICIAL", "FORMAL": + return baishunProfileProd + case "TEST", "TESTING", "SANDBOX", "DEV": + return baishunProfileTest + default: + return strings.ToUpper(strings.TrimSpace(profile)) + } +} + +func validateBaishunProfile(profile string) (string, error) { + profile = normalizeBaishunProfile(profile) + switch profile { + case baishunProfileProd, baishunProfileTest: + return profile, nil + default: + return "", NewAppError(http.StatusBadRequest, "profile_invalid", "profile must be PROD or TEST") + } +} + +func (s *BaishunService) activeProfile(ctx context.Context, sysOrigin string) (string, error) { + sysOrigin = normalizeAdminSysOrigin(sysOrigin) + + var row model.BaishunProviderConfig + err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND active = ?", sysOrigin, true). + Order("update_time DESC"). + Limit(1). + First(&row).Error + if err == nil { + return normalizeBaishunProfile(row.Profile), nil + } + if err == gorm.ErrRecordNotFound { + return baishunProfileProd, nil + } + return "", err +} + +func (s *BaishunService) requestProfile(ctx context.Context, sysOrigin string, profile string) (string, error) { + if strings.TrimSpace(profile) != "" { + return validateBaishunProfile(profile) + } + return s.activeProfile(ctx, sysOrigin) +} diff --git a/internal/service/baishun/provider_config.go b/internal/service/baishun/provider_config.go index adaddf5..c070d01 100644 --- a/internal/service/baishun/provider_config.go +++ b/internal/service/baishun/provider_config.go @@ -14,6 +14,7 @@ import ( type baishunRuntimeConfig struct { SysOrigin string + Profile string PlatformBaseURL string AppID int64 AppName string @@ -52,6 +53,7 @@ func (c baishunRuntimeConfig) ssTokenTTLSeconds() int { func (s *BaishunService) defaultRuntimeConfig(sysOrigin string) baishunRuntimeConfig { return baishunRuntimeConfig{ SysOrigin: normalizeAdminSysOrigin(sysOrigin), + Profile: baishunProfileProd, PlatformBaseURL: strings.TrimSpace(s.cfg.Baishun.PlatformBaseURL), AppID: s.cfg.Baishun.AppID, AppName: strings.TrimSpace(s.cfg.Baishun.AppName), @@ -65,6 +67,7 @@ func (s *BaishunService) defaultRuntimeConfig(sysOrigin string) baishunRuntimeCo func applyProviderRow(base baishunRuntimeConfig, row model.BaishunProviderConfig) baishunRuntimeConfig { base.SysOrigin = normalizeAdminSysOrigin(defaultIfBlank(row.SysOrigin, base.SysOrigin)) + base.Profile = normalizeBaishunProfile(defaultIfBlank(row.Profile, base.Profile)) if strings.TrimSpace(row.PlatformBaseURL) != "" { base.PlatformBaseURL = strings.TrimSpace(row.PlatformBaseURL) } @@ -94,11 +97,25 @@ func applyProviderRow(base baishunRuntimeConfig, row model.BaishunProviderConfig func (s *BaishunService) resolveRuntimeConfig(ctx context.Context, sysOrigin string) (baishunRuntimeConfig, error) { sysOrigin = normalizeAdminSysOrigin(sysOrigin) + profile, err := s.activeProfile(ctx, sysOrigin) + if err != nil { + return baishunRuntimeConfig{}, err + } + return s.resolveRuntimeConfigForProfile(ctx, sysOrigin, profile) +} + +func (s *BaishunService) resolveRuntimeConfigForProfile(ctx context.Context, sysOrigin string, profile string) (baishunRuntimeConfig, error) { + sysOrigin = normalizeAdminSysOrigin(sysOrigin) + profile, err := validateBaishunProfile(profile) + if err != nil { + return baishunRuntimeConfig{}, err + } base := s.defaultRuntimeConfig(sysOrigin) + base.Profile = profile var row model.BaishunProviderConfig - err := s.repo.DB.WithContext(ctx). - Where("sys_origin = ?", sysOrigin). + err = s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND profile = ?", sysOrigin, profile). Limit(1). First(&row).Error if err == nil { @@ -133,7 +150,11 @@ func (s *BaishunService) resolveRuntimeConfigByApp(ctx context.Context, appID in for _, build := range queries { var row model.BaishunProviderConfig - err := build(s.repo.DB.WithContext(ctx)).Limit(1).First(&row).Error + err := build(s.repo.DB.WithContext(ctx)). + Order("active DESC"). + Order("update_time DESC"). + Limit(1). + First(&row).Error if err == nil { return applyProviderRow(s.defaultRuntimeConfig(row.SysOrigin), row), nil } @@ -144,21 +165,33 @@ func (s *BaishunService) resolveRuntimeConfigByApp(ctx context.Context, appID in return base, nil } -func (s *BaishunService) GetProviderConfig(ctx context.Context, sysOrigin string) (*BaishunProviderConfigItem, error) { - runtimeCfg, err := s.resolveRuntimeConfig(ctx, sysOrigin) +func (s *BaishunService) GetProviderConfig(ctx context.Context, sysOrigin string, profile string) (*BaishunProviderConfigItem, error) { + sysOrigin = normalizeAdminSysOrigin(sysOrigin) + activeProfile, err := s.activeProfile(ctx, sysOrigin) + if err != nil { + return nil, err + } + profile, err = s.requestProfile(ctx, sysOrigin, profile) + if err != nil { + return nil, err + } + runtimeCfg, err := s.resolveRuntimeConfigForProfile(ctx, sysOrigin, profile) if err != nil { return nil, err } var row model.BaishunProviderConfig _ = s.repo.DB.WithContext(ctx). - Where("sys_origin = ?", normalizeAdminSysOrigin(sysOrigin)). + Where("sys_origin = ? AND profile = ?", sysOrigin, profile). Limit(1). First(&row).Error return &BaishunProviderConfigItem{ ID: row.ID, SysOrigin: runtimeCfg.SysOrigin, + Profile: runtimeCfg.Profile, + ActiveProfile: activeProfile, + Active: runtimeCfg.Profile == activeProfile, PlatformBaseURL: runtimeCfg.PlatformBaseURL, AppID: runtimeCfg.AppID, AppName: runtimeCfg.AppName, @@ -173,6 +206,10 @@ func (s *BaishunService) GetProviderConfig(ctx context.Context, sysOrigin string func (s *BaishunService) SaveProviderConfig(ctx context.Context, req SaveBaishunProviderConfigRequest) (*BaishunProviderConfigItem, error) { sysOrigin := normalizeAdminSysOrigin(req.SysOrigin) + profile, err := validateBaishunProfile(req.Profile) + if err != nil { + return nil, err + } platformBaseURL := strings.TrimSpace(req.PlatformBaseURL) appChannel := strings.TrimSpace(req.AppChannel) appKey := strings.TrimSpace(req.AppKey) @@ -196,6 +233,7 @@ func (s *BaishunService) SaveProviderConfig(ctx context.Context, req SaveBaishun row := model.BaishunProviderConfig{ ID: id, SysOrigin: sysOrigin, + Profile: profile, PlatformBaseURL: platformBaseURL, AppID: req.AppID, AppName: strings.TrimSpace(req.AppName), @@ -216,9 +254,17 @@ func (s *BaishunService) SaveProviderConfig(ctx context.Context, req SaveBaishun if row.SSTokenTTLSeconds <= 0 { row.SSTokenTTLSeconds = 86400 } + var activeCount int64 + if err := s.repo.DB.WithContext(ctx). + Model(&model.BaishunProviderConfig{}). + Where("sys_origin = ? AND active = ?", sysOrigin, true). + Count(&activeCount).Error; err != nil { + return nil, err + } + row.Active = activeCount == 0 if err := s.repo.DB.WithContext(ctx).Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "sys_origin"}}, + Columns: []clause.Column{{Name: "sys_origin"}, {Name: "profile"}}, 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", @@ -226,5 +272,49 @@ func (s *BaishunService) SaveProviderConfig(ctx context.Context, req SaveBaishun }).Create(&row).Error; err != nil { return nil, err } - return s.GetProviderConfig(ctx, sysOrigin) + if row.Active { + s.clearJavaGameListCache(ctx) + } + return s.GetProviderConfig(ctx, sysOrigin, profile) +} + +func (s *BaishunService) ActivateProviderProfile(ctx context.Context, req ActivateBaishunProviderProfileRequest) (*BaishunProviderConfigItem, error) { + sysOrigin := normalizeAdminSysOrigin(req.SysOrigin) + profile, err := validateBaishunProfile(req.Profile) + if err != nil { + return nil, err + } + + var row model.BaishunProviderConfig + if err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND profile = ?", sysOrigin, profile). + Limit(1). + First(&row).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, NewAppError(http.StatusBadRequest, "profile_config_missing", "profile config is missing") + } + return nil, err + } + + now := time.Now() + if err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.BaishunProviderConfig{}). + Where("sys_origin = ?", sysOrigin). + Updates(map[string]any{ + "active": false, + "update_time": now, + }).Error; err != nil { + return err + } + return tx.Model(&model.BaishunProviderConfig{}). + Where("sys_origin = ? AND profile = ?", sysOrigin, profile). + Updates(map[string]any{ + "active": true, + "update_time": now, + }).Error + }); err != nil { + return nil, err + } + s.clearJavaGameListCache(ctx) + return s.GetProviderConfig(ctx, sysOrigin, profile) } diff --git a/internal/service/baishun/room.go b/internal/service/baishun/room.go index 2034879..efeb3f4 100644 --- a/internal/service/baishun/room.go +++ b/internal/service/baishun/room.go @@ -60,12 +60,18 @@ func (s *BaishunService) GetRoomState(ctx context.Context, user AuthUser, roomID // listRoomGames 联表读取房间可启动游戏,并按展示规则排序。 func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, roomID, category string) ([]RoomGameListItem, error) { + sysOrigin = normalizeAdminSysOrigin(sysOrigin) + profile, err := s.activeProfile(ctx, sysOrigin) + if err != nil { + return nil, err + } var rows []gameListRow query := s.repo.DB.WithContext(ctx). Table("sys_game_list_config AS cfg"). Select(` cfg.id AS config_id, cfg.sys_origin AS sys_origin, + ext.profile AS profile, cfg.game_id AS internal_game_id, cfg.name AS name, cfg.category AS category, @@ -93,8 +99,8 @@ func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, roomID, c 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"). + Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1 AND ext.profile = ?", profile). + Joins("LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND cat.profile = ext.profile AND CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id"). Where("cfg.sys_origin = ? AND cfg.is_showcase = 1 AND ext.vendor_type = ?", sysOrigin, baishunVendorType) if strings.TrimSpace(category) != "" && !strings.EqualFold(strings.TrimSpace(category), "CHAT_ROOM") { query = query.Where("cfg.category = ?", category) @@ -112,12 +118,18 @@ func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, roomID, c // findGameRow 按内部游戏 ID 查询游戏聚合信息。 func (s *BaishunService) findGameRow(ctx context.Context, sysOrigin, gameID string) (gameListRow, error) { + sysOrigin = normalizeAdminSysOrigin(sysOrigin) + profile, err := s.activeProfile(ctx, sysOrigin) + if err != nil { + return gameListRow{}, err + } var row gameListRow - err := s.repo.DB.WithContext(ctx). + 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, + ext.profile AS profile, cfg.game_id AS internal_game_id, cfg.name AS name, cfg.category AS category, @@ -145,8 +157,8 @@ func (s *BaishunService) findGameRow(ctx context.Context, sysOrigin, gameID stri 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"). + Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1 AND ext.profile = ?", profile). + Joins("LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND cat.profile = ext.profile AND CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id"). Where("cfg.sys_origin = ? AND cfg.game_id = ? AND ext.vendor_type = ?", sysOrigin, gameID, baishunVendorType). Limit(1). Scan(&row).Error @@ -159,10 +171,11 @@ func (s *BaishunService) findGameRow(ctx context.Context, sysOrigin, gameID stri var catalog model.BaishunGameCatalog if err := s.repo.DB.WithContext(ctx). - Where("sys_origin = ? AND internal_game_id = ?", sysOrigin, gameID). + Where("sys_origin = ? AND profile = ? AND internal_game_id = ?", sysOrigin, profile, gameID). First(&catalog).Error; err == nil { return gameListRow{ SysOrigin: sysOrigin, + Profile: profile, InternalGameID: defaultIfBlank(catalog.InternalGameID, fmt.Sprintf("bs_%d", catalog.VendorGameID)), Name: catalog.Name, Cover: catalog.Cover, @@ -188,12 +201,18 @@ func (s *BaishunService) findGameRow(ctx context.Context, sysOrigin, gameID stri // findGameRowByConfigID 按系统游戏配置主键查询游戏聚合信息。 func (s *BaishunService) findGameRowByConfigID(ctx context.Context, sysOrigin string, configID int64) (gameListRow, error) { + sysOrigin = normalizeAdminSysOrigin(sysOrigin) + profile, err := s.activeProfile(ctx, sysOrigin) + if err != nil { + return gameListRow{}, err + } var row gameListRow - err := s.repo.DB.WithContext(ctx). + 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, + ext.profile AS profile, cfg.game_id AS internal_game_id, cfg.name AS name, cfg.category AS category, @@ -221,8 +240,8 @@ func (s *BaishunService) findGameRowByConfigID(ctx context.Context, sysOrigin st 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"). + Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1 AND ext.profile = ?", profile). + Joins("LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND cat.profile = ext.profile 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 diff --git a/internal/service/baishun/types.go b/internal/service/baishun/types.go index 2ffed1b..accc633 100644 --- a/internal/service/baishun/types.go +++ b/internal/service/baishun/types.go @@ -175,6 +175,7 @@ type BaishunLaunchAppResponse struct { // SyncCatalogRequest 是目录同步入参。 type SyncCatalogRequest struct { SysOrigin string `json:"sysOrigin"` + Profile string `json:"profile"` VendorGameIDs []int `json:"vendorGameIds"` Force bool `json:"force"` } @@ -190,6 +191,7 @@ type SyncCatalogResponse struct { type AdminBaishunGameItem struct { ID int64 `json:"id"` SysOrigin string `json:"sysOrigin"` + Profile string `json:"profile"` GameID string `json:"gameId"` GameType string `json:"gameType,omitempty"` VendorGameID int `json:"vendorGameId"` @@ -232,6 +234,7 @@ type AdminBaishunGamePageResponse struct { type SaveAdminBaishunGameRequest struct { ID int64 `json:"id"` SysOrigin string `json:"sysOrigin"` + Profile string `json:"profile"` GameID string `json:"gameId"` VendorGameID int `json:"vendorGameId"` Name string `json:"name"` @@ -277,6 +280,9 @@ type SyncAdminCatalogResponse struct { type BaishunProviderConfigItem struct { ID int64 `json:"id"` SysOrigin string `json:"sysOrigin"` + Profile string `json:"profile"` + ActiveProfile string `json:"activeProfile"` + Active bool `json:"active"` PlatformBaseURL string `json:"platformBaseUrl"` AppID int64 `json:"appId"` AppName string `json:"appName"` @@ -291,6 +297,7 @@ type BaishunProviderConfigItem struct { // SaveBaishunProviderConfigRequest 是后台保存百顺配置的入参。 type SaveBaishunProviderConfigRequest struct { SysOrigin string `json:"sysOrigin"` + Profile string `json:"profile"` PlatformBaseURL string `json:"platformBaseUrl"` AppID int64 `json:"appId"` AppName string `json:"appName"` @@ -301,9 +308,16 @@ type SaveBaishunProviderConfigRequest struct { SSTokenTTLSeconds int `json:"ssTokenTtlSeconds"` } +// ActivateBaishunProviderProfileRequest 是切换百顺正式/测试配置档案的入参。 +type ActivateBaishunProviderProfileRequest struct { + SysOrigin string `json:"sysOrigin"` + Profile string `json:"profile"` +} + // AdminBaishunCatalogItem 是后台目录页展示的单个百顺目录项。 type AdminBaishunCatalogItem struct { VendorGameID int `json:"vendorGameId"` + Profile string `json:"profile"` GameID string `json:"gameId"` Name string `json:"name"` Cover string `json:"cover"` @@ -410,6 +424,7 @@ type baishunBalanceInfoResponse struct { type gameListRow struct { ConfigID int64 SysOrigin string + Profile string InternalGameID string Name string Category string diff --git a/internal/service/invite/home.go b/internal/service/invite/home.go index 5abbb5a..84366ab 100644 --- a/internal/service/invite/home.go +++ b/internal/service/invite/home.go @@ -23,6 +23,7 @@ func (s *InviteService) GetHome(ctx context.Context, user AuthUser) (*HomeRespon now := time.Now() timezone := normalizeTimezone(bundle.Config.Timezone) monthKey := monthKeyAt(now, timezone) + periodEndAt, secondsToReset := monthResetInfoAt(now, timezone) monthly, err := s.findMonthlyProgress(ctx, user.SysOrigin, user.UserID, monthKey) if err != nil { return nil, err @@ -53,6 +54,9 @@ func (s *InviteService) GetHome(ctx context.Context, user AuthUser) (*HomeRespon ServiceContact: bundle.Config.ServiceContact, Timezone: timezone, MonthKey: monthKey, + ServerTimeMs: now.UnixMilli(), + PeriodEndAtMs: periodEndAt.UnixMilli(), + SecondsToReset: secondsToReset, ValidUserThreshold: effectiveValidThreshold(inviteeRechargeRules), InviterBindReward: rewardFromRule(inviterBindRule), InviteeBindReward: rewardFromRule(inviteeBindRule), @@ -63,7 +67,7 @@ func (s *InviteService) GetHome(ctx context.Context, user AuthUser) (*HomeRespon resp.MonthlyProgress.ValidUserCount = monthly.ValidUserCount resp.MonthlyProgress.TotalRecharge = monthly.TotalRecharge resp.MonthlyProgress.RewardGoldCoins = monthly.RewardGoldCoins - resp.ValidCountTasks = buildTaskViews(validCountRules, monthly.ValidUserCount, claims) + resp.ValidCountTasks = buildTaskViews(validCountRules, monthly.InviteCount, claims) resp.TotalRechargeTasks = buildTaskViews(totalRechargeRules, monthly.TotalRecharge, claims) return resp, nil } diff --git a/internal/service/invite/invite_test.go b/internal/service/invite/invite_test.go new file mode 100644 index 0000000..09e22d6 --- /dev/null +++ b/internal/service/invite/invite_test.go @@ -0,0 +1,272 @@ +package invite + +import ( + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type inviteTestGateway struct { + grantGoldErr error + grantPropsErr error + grantGoldCalls int +} + +func (g *inviteTestGateway) EnsureInviteCode(context.Context, string) (integration.InviteCode, error) { + return integration.InviteCode{InviteCode: "INV-TEST"}, nil +} + +func (g *inviteTestGateway) GetDeviceFingerprint(context.Context, int64) (string, error) { + return "", nil +} + +func (g *inviteTestGateway) GrantGold(context.Context, integration.GrantGoldRequest) error { + g.grantGoldCalls++ + return g.grantGoldErr +} + +func (g *inviteTestGateway) GrantProps(context.Context, integration.GrantPropsRequest) error { + return g.grantPropsErr +} + +func newTestInviteService(t *testing.T, gateway *inviteTestGateway) (*InviteService, *gorm.DB, func()) { + t.Helper() + + dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate( + &model.InviteCodeMapping{}, + &model.UserBaseInfo{}, + &model.InviteCampaignConfig{}, + &model.InviteCampaignRewardRule{}, + &model.InviteCampaignRelation{}, + &model.InviteCampaignInviteeProgress{}, + &model.InviteCampaignMonthlyProgress{}, + &model.InviteCampaignTaskClaim{}, + &model.InviteCampaignRewardLog{}, + &model.InviteCampaignRechargeEvent{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + + mr, err := miniredis.Run() + if err != nil { + t.Fatalf("miniredis run: %v", err) + } + redisClient := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + if gateway == nil { + gateway = &inviteTestGateway{} + } + service := NewInviteService(config.Config{ + Invite: config.InviteConfig{PublicBaseURL: "https://invite.example.com"}, + }, db, redisClient, gateway) + + return service, db, func() { + redisClient.Close() + mr.Close() + } +} + +func seedInviteCampaign(t *testing.T, db *gorm.DB) { + t.Helper() + now := time.Now() + if err := db.Create(&model.InviteCampaignConfig{ + ID: 1, + SysOrigin: "LIKEI", + Enabled: true, + Timezone: "Asia/Shanghai", + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed config: %v", err) + } +} + +func TestMonthResetInfoAtReturnsNextMonthStart(t *testing.T) { + now := time.Date(2026, 4, 30, 23, 59, 30, 0, time.FixedZone("CST", 8*3600)) + periodEnd, seconds := monthResetInfoAt(now, "Asia/Shanghai") + + if got := periodEnd.In(time.FixedZone("CST", 8*3600)).Format(time.RFC3339); got != "2026-05-01T00:00:00+08:00" { + t.Fatalf("periodEnd = %s, want 2026-05-01T00:00:00+08:00", got) + } + if seconds != 30 { + t.Fatalf("seconds = %d, want 30", seconds) + } +} + +func TestGetHomeUsesInviteCountForInviteCountTasks(t *testing.T) { + service, db, cleanup := newTestInviteService(t, nil) + defer cleanup() + seedInviteCampaign(t, db) + + now := time.Now() + monthKey := monthKeyAt(now, "Asia/Shanghai") + if err := db.Create(&model.InviteCodeMapping{UserID: 1001, InviteCode: "INV1001", Account: "u1001"}).Error; err != nil { + t.Fatalf("seed invite code: %v", err) + } + if err := db.Create(&model.InviteCampaignRewardRule{ + ID: 2001, + SysOrigin: "LIKEI", + RuleType: ruleTypeInviterValidCount, + ThresholdValue: 5, + GoldAmount: 100, + Enabled: true, + Sort: 1, + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed rule: %v", err) + } + if err := db.Create(&model.InviteCampaignMonthlyProgress{ + ID: 3001, + SysOrigin: "LIKEI", + InviterUserID: 1001, + MonthKey: monthKey, + InviteCount: 7, + ValidUserCount: 0, + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed monthly progress: %v", err) + } + + resp, err := service.GetHome(context.Background(), AuthUser{UserID: 1001, SysOrigin: "LIKEI"}) + if err != nil { + t.Fatalf("GetHome() error = %v", err) + } + if len(resp.ValidCountTasks) != 1 { + t.Fatalf("ValidCountTasks length = %d, want 1", len(resp.ValidCountTasks)) + } + task := resp.ValidCountTasks[0] + if task.Progress != 7 || !task.Achieved { + t.Fatalf("task progress = %d achieved = %v, want progress 7 achieved true", task.Progress, task.Achieved) + } + if resp.SecondsToReset <= 0 || resp.PeriodEndAtMs <= resp.ServerTimeMs { + t.Fatalf("invalid countdown fields: server=%d end=%d seconds=%d", resp.ServerTimeMs, resp.PeriodEndAtMs, resp.SecondsToReset) + } +} + +func TestClaimTaskUsesInviteCountForInviteCountTasks(t *testing.T) { + gateway := &inviteTestGateway{} + service, db, cleanup := newTestInviteService(t, gateway) + defer cleanup() + seedInviteCampaign(t, db) + + now := time.Now() + monthKey := monthKeyAt(now, "Asia/Shanghai") + if err := db.Create(&model.InviteCampaignRewardRule{ + ID: 2002, + SysOrigin: "LIKEI", + RuleType: ruleTypeInviterValidCount, + ThresholdValue: 5, + GoldAmount: 100, + Enabled: true, + Sort: 1, + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed rule: %v", err) + } + if err := db.Create(&model.InviteCampaignMonthlyProgress{ + ID: 3002, + SysOrigin: "LIKEI", + InviterUserID: 1002, + MonthKey: monthKey, + InviteCount: 5, + ValidUserCount: 0, + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed monthly progress: %v", err) + } + + resp, err := service.ClaimTask(context.Background(), AuthUser{UserID: 1002, SysOrigin: "LIKEI"}, TaskClaimRequest{RuleID: 2002}) + if err != nil { + t.Fatalf("ClaimTask() error = %v", err) + } + if !resp.Claimed || resp.Progress != 5 { + t.Fatalf("claim response = %+v, want claimed progress 5", resp) + } + if gateway.grantGoldCalls != 1 { + t.Fatalf("grantGoldCalls = %d, want 1", gateway.grantGoldCalls) + } +} + +func TestGetRankUsesLifetimeInviteAndRewardStats(t *testing.T) { + service, db, cleanup := newTestInviteService(t, nil) + defer cleanup() + + now := time.Now() + rows := []any{ + &model.InviteCampaignRelation{ID: 1, SysOrigin: "LIKEI", InviterUserID: 20, InviteeUserID: 201, EligibleStatus: relationStatusEligible, BindTime: now, CreateTime: now, UpdateTime: now}, + &model.InviteCampaignRelation{ID: 2, SysOrigin: "LIKEI", InviterUserID: 20, InviteeUserID: 202, EligibleStatus: relationStatusEligible, BindTime: now, CreateTime: now, UpdateTime: now}, + &model.InviteCampaignRelation{ID: 3, SysOrigin: "LIKEI", InviterUserID: 10, InviteeUserID: 101, EligibleStatus: relationStatusEligible, BindTime: now, CreateTime: now, UpdateTime: now}, + &model.InviteCampaignRelation{ID: 4, SysOrigin: "LIKEI", InviterUserID: 10, InviteeUserID: 102, EligibleStatus: relationStatusBlocked, BindTime: now, CreateTime: now, UpdateTime: now}, + &model.UserBaseInfo{ID: 20, Account: "user20", UserNickname: "Mina", UserAvatar: "avatar-20", OriginSys: "LIKEI", CreateTime: now}, + &model.UserBaseInfo{ID: 10, Account: "user10", UserNickname: "Lina", UserAvatar: "avatar-10", OriginSys: "LIKEI", CreateTime: now}, + &model.InviteCampaignRewardLog{ID: 11, SysOrigin: "LIKEI", InviterUserID: refInt64(20), TargetUserID: 20, BusinessKey: "r20-1", GoldAmount: 100, Status: rewardLogStatusSuccess, CreateTime: now, UpdateTime: now}, + &model.InviteCampaignRewardLog{ID: 12, SysOrigin: "LIKEI", InviterUserID: refInt64(20), TargetUserID: 20, BusinessKey: "r20-2", GoldAmount: 200, Status: rewardLogStatusSuccess, CreateTime: now, UpdateTime: now}, + &model.InviteCampaignRewardLog{ID: 13, SysOrigin: "LIKEI", InviterUserID: refInt64(10), TargetUserID: 10, BusinessKey: "r10-1", GoldAmount: 900, Status: rewardLogStatusSuccess, CreateTime: now, UpdateTime: now}, + &model.InviteCampaignRewardLog{ID: 14, SysOrigin: "LIKEI", InviterUserID: refInt64(20), TargetUserID: 201, BusinessKey: "invitee-reward", GoldAmount: 999, Status: rewardLogStatusSuccess, CreateTime: now, UpdateTime: now}, + &model.InviteCampaignRewardLog{ID: 15, SysOrigin: "LIKEI", InviterUserID: refInt64(20), TargetUserID: 20, BusinessKey: "failed-reward", GoldAmount: 999, Status: rewardLogStatusFailed, CreateTime: now, UpdateTime: now}, + } + for _, row := range rows { + if err := db.Create(row).Error; err != nil { + t.Fatalf("seed row %T: %v", row, err) + } + } + + resp, err := service.GetRank(context.Background(), AuthUser{SysOrigin: "LIKEI"}, 10) + if err != nil { + t.Fatalf("GetRank() error = %v", err) + } + if len(resp.Items) != 2 { + t.Fatalf("rank length = %d, want 2", len(resp.Items)) + } + if got := resp.Items[0]; got.Rank != 1 || got.UserID != 20 || got.Nickname != "Mina" || got.InviteCount != 2 || got.RewardGoldCoins != 300 { + t.Fatalf("top rank = %+v, want user 20 count 2 reward 300", got) + } + if got := resp.Items[1]; got.Rank != 2 || got.UserID != 10 || got.InviteCount != 1 || got.RewardGoldCoins != 900 { + t.Fatalf("second rank = %+v, want user 10 count 1 reward 900", got) + } +} + +func TestDispatchGoldReturnsGrantError(t *testing.T) { + grantErr := errors.New("wallet unavailable") + service, db, cleanup := newTestInviteService(t, &inviteTestGateway{grantGoldErr: grantErr}) + defer cleanup() + + _, err := service.dispatchReward(context.Background(), rewardDispatchInput{ + SysOrigin: "LIKEI", + TargetUserID: 1001, + Reward: RewardView{GoldAmount: 100}, + Scene: ruleTypeInviterValidCount, + BusinessKey: "claim:1001:202604:1", + GoldOrigin: "INVITE_USER_REWARDS", + Remark: "test reward", + }) + if !errors.Is(err, grantErr) { + t.Fatalf("dispatchReward() error = %v, want %v", err, grantErr) + } + + var log model.InviteCampaignRewardLog + if err := db.Where("business_key = ?", "claim:1001:202604:1:gold").First(&log).Error; err != nil { + t.Fatalf("load reward log: %v", err) + } + if log.Status != rewardLogStatusFailed { + t.Fatalf("reward log status = %s, want %s", log.Status, rewardLogStatusFailed) + } +} diff --git a/internal/service/invite/normalize.go b/internal/service/invite/normalize.go index 1fd77db..16677d3 100644 --- a/internal/service/invite/normalize.go +++ b/internal/service/invite/normalize.go @@ -36,6 +36,21 @@ func monthKeyAt(t time.Time, timezone string) string { return t.In(location).Format("200601") } +// monthResetInfoAt 返回指定时间所在活动月的结束时间和剩余秒数。 +func monthResetInfoAt(t time.Time, timezone string) (time.Time, int64) { + location, err := time.LoadLocation(normalizeTimezone(timezone)) + if err != nil { + location = time.FixedZone(defaultTimezone, 3*3600) + } + localTime := t.In(location) + periodEnd := time.Date(localTime.Year(), localTime.Month()+1, 1, 0, 0, 0, 0, location) + seconds := int64(periodEnd.Sub(localTime).Seconds()) + if seconds < 0 { + seconds = 0 + } + return periodEnd, seconds +} + // digitsOnly 判断字符串是否只包含数字。 func digitsOnly(value string) bool { for _, ch := range value { diff --git a/internal/service/invite/rank.go b/internal/service/invite/rank.go new file mode 100644 index 0000000..526093d --- /dev/null +++ b/internal/service/invite/rank.go @@ -0,0 +1,73 @@ +package invite + +import ( + "context" + "strings" +) + +type inviteRankRow struct { + UserID int64 + Nickname string + Avatar string + InviteCount int64 + RewardGoldCoins int64 +} + +// GetRank 返回累计邀请排行榜。排行榜不按月重置。 +func (s *InviteService) GetRank(ctx context.Context, user AuthUser, limit int) (*InviteRankResponse, error) { + limit = normalizeInviteRankLimit(limit) + rows := make([]inviteRankRow, 0, limit) + if err := s.repo.DB.WithContext(ctx).Raw(` + SELECT + invite_rank.user_id AS user_id, + COALESCE(NULLIF(user_info.user_nickname, ''), invite_code.account, '') AS nickname, + COALESCE(user_info.user_avatar, '') AS avatar, + invite_rank.invite_count AS invite_count, + COALESCE(reward_rank.reward_gold_coins, 0) AS reward_gold_coins + FROM ( + SELECT inviter_user_id AS user_id, COUNT(1) AS invite_count + FROM invite_campaign_relation + WHERE sys_origin = ? AND eligible_status = ? + GROUP BY inviter_user_id + ) invite_rank + LEFT JOIN ( + SELECT target_user_id AS user_id, COALESCE(SUM(gold_amount), 0) AS reward_gold_coins + FROM invite_campaign_reward_log + WHERE sys_origin = ? AND status = ? AND target_user_id = inviter_user_id + GROUP BY target_user_id + ) reward_rank ON reward_rank.user_id = invite_rank.user_id + LEFT JOIN user_base_info user_info ON user_info.id = invite_rank.user_id + LEFT JOIN invite_code_mapping invite_code ON invite_code.user_id = invite_rank.user_id + ORDER BY invite_rank.invite_count DESC, reward_gold_coins DESC, invite_rank.user_id ASC + LIMIT ? + `, user.SysOrigin, relationStatusEligible, user.SysOrigin, rewardLogStatusSuccess, limit).Scan(&rows).Error; err != nil { + return nil, err + } + + items := make([]InviteRankItem, 0, len(rows)) + for index, row := range rows { + items = append(items, InviteRankItem{ + Rank: index + 1, + UserID: row.UserID, + Nickname: strings.TrimSpace(row.Nickname), + Avatar: strings.TrimSpace(row.Avatar), + InviteCount: row.InviteCount, + RewardGoldCoins: row.RewardGoldCoins, + }) + } + return &InviteRankResponse{ + SysOrigin: user.SysOrigin, + Limit: limit, + Items: items, + }, nil +} + +func normalizeInviteRankLimit(limit int) int { + if limit <= 0 { + return defaultInviteRankLimit + } + if limit > maxInviteRankLimit { + return maxInviteRankLimit + } + return limit +} diff --git a/internal/service/invite/reward.go b/internal/service/invite/reward.go index 1fb17f0..dacada0 100644 --- a/internal/service/invite/reward.go +++ b/internal/service/invite/reward.go @@ -51,7 +51,10 @@ func (s *InviteService) dispatchGold(ctx context.Context, input rewardDispatchIn Remark: input.Remark, }) if err != nil { - return s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusFailed, err.Error()) + if logErr := s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusFailed, err.Error()); logErr != nil { + return logErr + } + return err } if err := s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusSuccess, ""); err != nil { return err @@ -87,7 +90,10 @@ func (s *InviteService) dispatchProps(ctx context.Context, input rewardDispatchI SourceGroupID: *input.Reward.RewardGroupID, }) if err != nil { - return s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusFailed, err.Error()) + if logErr := s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusFailed, err.Error()); logErr != nil { + return logErr + } + return err } return s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusSuccess, "") } diff --git a/internal/service/invite/task.go b/internal/service/invite/task.go index 0796520..1d28e29 100644 --- a/internal/service/invite/task.go +++ b/internal/service/invite/task.go @@ -40,7 +40,7 @@ func (s *InviteService) ClaimTask(ctx context.Context, user AuthUser, req TaskCl } progressValue := monthly.TotalRecharge if rule.RuleType == ruleTypeInviterValidCount { - progressValue = monthly.ValidUserCount + progressValue = monthly.InviteCount } if claimed { return &TaskClaimResponse{ diff --git a/internal/service/invite/types.go b/internal/service/invite/types.go index f95bcd9..fd59442 100644 --- a/internal/service/invite/types.go +++ b/internal/service/invite/types.go @@ -29,6 +29,8 @@ const ( ruleTypeInviteeRecharge = "INVITEE_RECHARGE" ruleTypeInviterValidCount = "INVITER_VALID_COUNT" ruleTypeInviterTotalRecharge = "INVITER_TOTAL_RECHARGE" + defaultInviteRankLimit = 50 + maxInviteRankLimit = 100 ) var errAlreadyProcessed = errors.New("invite recharge already processed") @@ -80,6 +82,9 @@ type HomeResponse struct { ServiceContact string `json:"serviceContact"` Timezone string `json:"timezone"` MonthKey string `json:"monthKey"` + ServerTimeMs int64 `json:"serverTimeMs"` + PeriodEndAtMs int64 `json:"periodEndAtMs"` + SecondsToReset int64 `json:"secondsToReset"` ValidUserThreshold int64 `json:"validUserThreshold"` InviterBindReward RewardView `json:"inviterBindReward"` InviteeBindReward RewardView `json:"inviteeBindReward"` @@ -95,6 +100,23 @@ type HomeResponse struct { Stats InviteStats `json:"stats"` } +// InviteRankItem 是邀请排行榜单行展示数据。 +type InviteRankItem struct { + Rank int `json:"rank"` + UserID int64 `json:"userId"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + InviteCount int64 `json:"inviteCount"` + RewardGoldCoins int64 `json:"rewardGoldCoins"` +} + +// InviteRankResponse 是邀请排行榜接口返回体。 +type InviteRankResponse struct { + SysOrigin string `json:"sysOrigin"` + Limit int `json:"limit"` + Items []InviteRankItem `json:"items"` +} + // LandingResponse 是邀请落地页接口返回体。 type LandingResponse struct { CampaignEnabled bool `json:"campaignEnabled"` diff --git a/internal/service/luckygift/runtime_config.go b/internal/service/luckygift/runtime_config.go index 5fc381e..39717d8 100644 --- a/internal/service/luckygift/runtime_config.go +++ b/internal/service/luckygift/runtime_config.go @@ -12,6 +12,7 @@ import ( ) type luckyGiftRuntimeAccount struct { + URL string AppID string AppChannel string AppKey string @@ -40,10 +41,18 @@ func (s *LuckyGiftService) resolveProviderConfig(ctx context.Context, req LuckyG func (s *LuckyGiftService) resolveRuntimeAccount(ctx context.Context, sysOrigin string) (luckyGiftRuntimeAccount, error) { account := defaultLuckyGiftRuntimeAccount(s.cfg) + if s.repo.DB == nil { + return account, nil + } + normalizedSysOrigin := normalizeLuckyGiftSysOrigin(sysOrigin) + profile, err := s.resolveActiveBaishunProfile(ctx, normalizedSysOrigin) + if err != nil { + return luckyGiftRuntimeAccount{}, err + } var row model.BaishunProviderConfig - err := s.repo.DB.WithContext(ctx). - Where("sys_origin = ?", normalizeLuckyGiftSysOrigin(sysOrigin)). + err = s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND profile = ?", normalizedSysOrigin, profile). Limit(1). First(&row).Error if err == nil { @@ -55,8 +64,25 @@ func (s *LuckyGiftService) resolveRuntimeAccount(ctx context.Context, sysOrigin return luckyGiftRuntimeAccount{}, err } +func (s *LuckyGiftService) resolveActiveBaishunProfile(ctx context.Context, sysOrigin string) (string, error) { + var row model.BaishunProviderConfig + err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND active = ?", normalizeLuckyGiftSysOrigin(sysOrigin), true). + Order("update_time DESC"). + Limit(1). + First(&row).Error + if err == nil { + return normalizeLuckyGiftBaishunProfile(row.Profile), nil + } + if err == gorm.ErrRecordNotFound { + return luckyGiftBaishunProfileProd, nil + } + return "", err +} + func defaultLuckyGiftRuntimeAccount(cfg config.Config) luckyGiftRuntimeAccount { account := luckyGiftRuntimeAccount{ + URL: strings.TrimSpace(cfg.Baishun.PlatformBaseURL), AppChannel: strings.TrimSpace(cfg.Baishun.AppChannel), AppKey: strings.TrimSpace(cfg.Baishun.AppKey), } @@ -67,6 +93,9 @@ func defaultLuckyGiftRuntimeAccount(cfg config.Config) luckyGiftRuntimeAccount { } func applyLuckyGiftRuntimeAccount(base luckyGiftRuntimeAccount, row model.BaishunProviderConfig) luckyGiftRuntimeAccount { + if strings.TrimSpace(row.PlatformBaseURL) != "" { + base.URL = strings.TrimSpace(row.PlatformBaseURL) + } if row.AppID > 0 { base.AppID = strconv.FormatInt(row.AppID, 10) } @@ -84,6 +113,9 @@ func mergeLuckyGiftProviderConfig( runtime luckyGiftRuntimeAccount, ) config.LuckyGiftProviderConfig { merged := base + if runtime.URL != "" { + merged.URL = runtime.URL + } if runtime.AppID != "" { merged.AppID = runtime.AppID } @@ -103,3 +135,16 @@ func normalizeLuckyGiftSysOrigin(sysOrigin string) string { } return sysOrigin } + +const luckyGiftBaishunProfileProd = "PROD" + +func normalizeLuckyGiftBaishunProfile(profile string) string { + switch strings.ToUpper(strings.TrimSpace(profile)) { + case "", "PROD", "PRODUCT", "PRODUCTION", "ONLINE", "OFFICIAL", "FORMAL": + return luckyGiftBaishunProfileProd + case "TEST", "TESTING", "SANDBOX", "DEV": + return "TEST" + default: + return strings.ToUpper(strings.TrimSpace(profile)) + } +} diff --git a/internal/service/luckygift/runtime_config_test.go b/internal/service/luckygift/runtime_config_test.go index d953b29..4975f45 100644 --- a/internal/service/luckygift/runtime_config_test.go +++ b/internal/service/luckygift/runtime_config_test.go @@ -3,7 +3,12 @@ package luckygift import ( "chatapp3-golang/internal/config" "chatapp3-golang/internal/model" + "context" "testing" + "time" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" ) func TestMergeLuckyGiftProviderConfigPrefersRuntimeAccount(t *testing.T) { @@ -16,13 +21,14 @@ func TestMergeLuckyGiftProviderConfigPrefersRuntimeAccount(t *testing.T) { } merged := mergeLuckyGiftProviderConfig(base, luckyGiftRuntimeAccount{ + URL: "https://runtime.example.com", AppID: "runtime-app-id", AppChannel: "runtime-app-channel", AppKey: "runtime-app-key", }) - if merged.URL != base.URL { - t.Fatalf("expected url to keep base value, got %q", merged.URL) + if merged.URL != "https://runtime.example.com" { + t.Fatalf("expected runtime url, got %q", merged.URL) } if merged.AppID != "runtime-app-id" { t.Fatalf("expected runtime app id, got %q", merged.AppID) @@ -37,15 +43,20 @@ func TestMergeLuckyGiftProviderConfigPrefersRuntimeAccount(t *testing.T) { func TestApplyLuckyGiftRuntimeAccountUsesProviderConfigRow(t *testing.T) { account := applyLuckyGiftRuntimeAccount(luckyGiftRuntimeAccount{ + URL: "https://fallback.example.com", AppID: "fallback-app-id", AppChannel: "fallback-app-channel", AppKey: "fallback-app-key", }, model.BaishunProviderConfig{ - AppID: 4581045902, - AppChannel: "yumiparty", - AppKey: "test-key", + PlatformBaseURL: "https://provider.example.com", + AppID: 4581045902, + AppChannel: "yumiparty", + AppKey: "test-key", }) + if account.URL != "https://provider.example.com" { + t.Fatalf("expected row url, got %q", account.URL) + } if account.AppID != "4581045902" { t.Fatalf("expected row app id, got %q", account.AppID) } @@ -60,12 +71,16 @@ func TestApplyLuckyGiftRuntimeAccountUsesProviderConfigRow(t *testing.T) { func TestDefaultLuckyGiftRuntimeAccountUsesBaishunConfig(t *testing.T) { account := defaultLuckyGiftRuntimeAccount(config.Config{ Baishun: config.BaishunConfig{ - AppID: 7485318192, - AppChannel: "yumiparty", - AppKey: "shared-key", + PlatformBaseURL: "https://provider.example.com", + AppID: 7485318192, + AppChannel: "yumiparty", + AppKey: "shared-key", }, }) + if account.URL != "https://provider.example.com" { + t.Fatalf("expected baishun platform base url, got %q", account.URL) + } if account.AppID != "7485318192" { t.Fatalf("expected baishun app id, got %q", account.AppID) } @@ -76,3 +91,85 @@ func TestDefaultLuckyGiftRuntimeAccountUsesBaishunConfig(t *testing.T) { t.Fatalf("expected baishun app key, got %q", account.AppKey) } } + +func TestResolveProviderConfigUsesActiveBaishunProfile(t *testing.T) { + service, db := newTestLuckyGiftService(t, config.Config{ + Baishun: config.BaishunConfig{ + PlatformBaseURL: "https://fallback.example.com", + AppID: 111, + AppChannel: "fallback", + AppKey: "fallback-key", + }, + LuckyGift: config.LuckyGiftConfig{ + Providers: []config.LuckyGiftProviderConfig{ + { + StandardID: 99, + URL: "https://legacy.example.com/lucky_gift/start_game", + AppID: "legacy", + AppChannel: "legacy", + AppKey: "legacy-key", + }, + }, + }, + }) + now := time.Now() + if err := db.Create(&[]model.BaishunProviderConfig{ + { + ID: 1001, + SysOrigin: "LIKEI", + Profile: "PROD", + Active: false, + PlatformBaseURL: "https://prod.example.com", + AppID: 222, + AppChannel: "prod", + AppKey: "prod-key", + CreateTime: now, + UpdateTime: now, + }, + { + ID: 1002, + SysOrigin: "LIKEI", + Profile: "TEST", + Active: true, + PlatformBaseURL: "https://test.example.com", + AppID: 333, + AppChannel: "test", + AppKey: "test-key", + CreateTime: now, + UpdateTime: now.Add(time.Second), + }, + }).Error; err != nil { + t.Fatalf("create provider configs: %v", err) + } + + resolved, err := service.resolveProviderConfig(context.Background(), LuckyGiftDrawRequest{ + SysOrigin: "LIKEI", + StandardID: 99, + }) + if err != nil { + t.Fatalf("resolve provider config: %v", err) + } + endpoint, err := resolveLuckyGiftProviderEndpoint(resolved.URL) + if err != nil { + t.Fatalf("resolve lucky gift endpoint: %v", err) + } + if endpoint != "https://test.example.com/lucky_gift/start_game" { + t.Fatalf("endpoint = %q, want active test endpoint", endpoint) + } + if resolved.AppID != "333" || resolved.AppChannel != "test" || resolved.AppKey != "test-key" { + t.Fatalf("resolved account = %+v, want active test account", resolved) + } +} + +func newTestLuckyGiftService(t *testing.T, cfg config.Config) (*LuckyGiftService, *gorm.DB) { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.BaishunProviderConfig{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + return NewLuckyGiftService(cfg, db, nil, nil), db +} diff --git a/migrations/002_baishun_game.sql b/migrations/002_baishun_game.sql index 9b2a89f..13dc894 100644 --- a/migrations/002_baishun_game.sql +++ b/migrations/002_baishun_game.sql @@ -2,6 +2,7 @@ CREATE TABLE IF NOT EXISTS sys_game_list_vendor_ext ( id BIGINT NOT NULL PRIMARY KEY, game_list_config_id BIGINT NOT NULL, sys_origin VARCHAR(32) NOT NULL, + profile VARCHAR(32) NOT NULL DEFAULT 'PROD', vendor_type VARCHAR(32) NOT NULL, vendor_game_id VARCHAR(64) NOT NULL, launch_mode VARCHAR(32) NOT NULL DEFAULT 'H5_REMOTE', @@ -19,12 +20,14 @@ CREATE TABLE IF NOT EXISTS sys_game_list_vendor_ext ( create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_game_vendor_ext_cfg_vendor (game_list_config_id, vendor_type), - KEY idx_game_vendor_ext_sys_vendor (sys_origin, vendor_type, enabled) + KEY idx_game_vendor_ext_sys_vendor (sys_origin, vendor_type, enabled), + KEY idx_game_vendor_ext_sys_profile_vendor (sys_origin, profile, vendor_type, vendor_game_id, enabled) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS baishun_game_catalog ( id BIGINT NOT NULL PRIMARY KEY, sys_origin VARCHAR(32) NOT NULL, + profile VARCHAR(32) NOT NULL DEFAULT 'PROD', internal_game_id VARCHAR(64) NOT NULL, vendor_game_id INT NOT NULL, name VARCHAR(128) NOT NULL, @@ -41,8 +44,8 @@ CREATE TABLE IF NOT EXISTS baishun_game_catalog ( status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - UNIQUE KEY uk_baishun_catalog_sys_vendor_game (sys_origin, vendor_game_id), - KEY idx_baishun_catalog_internal_game (sys_origin, internal_game_id, status) + UNIQUE KEY uk_baishun_catalog_sys_profile_vendor_game (sys_origin, profile, vendor_game_id), + KEY idx_baishun_catalog_profile_internal_game (sys_origin, profile, internal_game_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS baishun_launch_session ( diff --git a/migrations/007_baishun_provider_config.sql b/migrations/007_baishun_provider_config.sql index caac4d1..e1f7f22 100644 --- a/migrations/007_baishun_provider_config.sql +++ b/migrations/007_baishun_provider_config.sql @@ -1,6 +1,8 @@ CREATE TABLE IF NOT EXISTS baishun_provider_config ( id BIGINT NOT NULL PRIMARY KEY, sys_origin VARCHAR(32) NOT NULL, + profile VARCHAR(32) NOT NULL DEFAULT 'PROD', + active TINYINT(1) NOT NULL DEFAULT 1, platform_base_url VARCHAR(1024) NOT NULL, app_id BIGINT NOT NULL, app_name VARCHAR(128) DEFAULT NULL, @@ -11,6 +13,7 @@ CREATE TABLE IF NOT EXISTS baishun_provider_config ( 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) + UNIQUE KEY uk_baishun_provider_sys_origin_profile (sys_origin, profile), + KEY idx_baishun_provider_app (app_id, app_channel), + KEY idx_baishun_provider_active (sys_origin, active, update_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/migrations/009_baishun_profile_switch.sql b/migrations/009_baishun_profile_switch.sql new file mode 100644 index 0000000..e098d75 --- /dev/null +++ b/migrations/009_baishun_profile_switch.sql @@ -0,0 +1,155 @@ +SET @current_schema := DATABASE(); + +SET @add_provider_profile_sql := IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'baishun_provider_config' + AND column_name = 'profile' + ), + 'ALTER TABLE `baishun_provider_config` ADD COLUMN `profile` varchar(32) NOT NULL DEFAULT ''PROD'' AFTER `sys_origin`', + 'SELECT 1' +); +PREPARE add_provider_profile_stmt FROM @add_provider_profile_sql; +EXECUTE add_provider_profile_stmt; +DEALLOCATE PREPARE add_provider_profile_stmt; + +SET @add_provider_active_sql := IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'baishun_provider_config' + AND column_name = 'active' + ), + 'ALTER TABLE `baishun_provider_config` ADD COLUMN `active` tinyint(1) NOT NULL DEFAULT 1 AFTER `profile`', + 'SELECT 1' +); +PREPARE add_provider_active_stmt FROM @add_provider_active_sql; +EXECUTE add_provider_active_stmt; +DEALLOCATE PREPARE add_provider_active_stmt; + +SET @drop_provider_old_unique_sql := IF( + EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = @current_schema + AND table_name = 'baishun_provider_config' + AND index_name = 'uk_baishun_provider_sys_origin' + ), + 'ALTER TABLE `baishun_provider_config` DROP INDEX `uk_baishun_provider_sys_origin`', + 'SELECT 1' +); +PREPARE drop_provider_old_unique_stmt FROM @drop_provider_old_unique_sql; +EXECUTE drop_provider_old_unique_stmt; +DEALLOCATE PREPARE drop_provider_old_unique_stmt; + +SET @add_provider_profile_unique_sql := IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = @current_schema + AND table_name = 'baishun_provider_config' + AND index_name = 'uk_baishun_provider_sys_origin_profile' + ), + 'ALTER TABLE `baishun_provider_config` ADD UNIQUE KEY `uk_baishun_provider_sys_origin_profile` (`sys_origin`, `profile`)', + 'SELECT 1' +); +PREPARE add_provider_profile_unique_stmt FROM @add_provider_profile_unique_sql; +EXECUTE add_provider_profile_unique_stmt; +DEALLOCATE PREPARE add_provider_profile_unique_stmt; + +SET @add_provider_active_index_sql := IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = @current_schema + AND table_name = 'baishun_provider_config' + AND index_name = 'idx_baishun_provider_active' + ), + 'ALTER TABLE `baishun_provider_config` ADD KEY `idx_baishun_provider_active` (`sys_origin`, `active`, `update_time`)', + 'SELECT 1' +); +PREPARE add_provider_active_index_stmt FROM @add_provider_active_index_sql; +EXECUTE add_provider_active_index_stmt; +DEALLOCATE PREPARE add_provider_active_index_stmt; + +SET @add_catalog_profile_sql := IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'baishun_game_catalog' + AND column_name = 'profile' + ), + 'ALTER TABLE `baishun_game_catalog` ADD COLUMN `profile` varchar(32) NOT NULL DEFAULT ''PROD'' AFTER `sys_origin`', + 'SELECT 1' +); +PREPARE add_catalog_profile_stmt FROM @add_catalog_profile_sql; +EXECUTE add_catalog_profile_stmt; +DEALLOCATE PREPARE add_catalog_profile_stmt; + +SET @drop_catalog_old_unique_sql := IF( + EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = @current_schema + AND table_name = 'baishun_game_catalog' + AND index_name = 'uk_baishun_catalog_sys_vendor_game' + ), + 'ALTER TABLE `baishun_game_catalog` DROP INDEX `uk_baishun_catalog_sys_vendor_game`', + 'SELECT 1' +); +PREPARE drop_catalog_old_unique_stmt FROM @drop_catalog_old_unique_sql; +EXECUTE drop_catalog_old_unique_stmt; +DEALLOCATE PREPARE drop_catalog_old_unique_stmt; + +SET @add_catalog_profile_unique_sql := IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = @current_schema + AND table_name = 'baishun_game_catalog' + AND index_name = 'uk_baishun_catalog_sys_profile_vendor_game' + ), + 'ALTER TABLE `baishun_game_catalog` ADD UNIQUE KEY `uk_baishun_catalog_sys_profile_vendor_game` (`sys_origin`, `profile`, `vendor_game_id`)', + 'SELECT 1' +); +PREPARE add_catalog_profile_unique_stmt FROM @add_catalog_profile_unique_sql; +EXECUTE add_catalog_profile_unique_stmt; +DEALLOCATE PREPARE add_catalog_profile_unique_stmt; + +SET @add_catalog_profile_index_sql := IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = @current_schema + AND table_name = 'baishun_game_catalog' + AND index_name = 'idx_baishun_catalog_profile_internal_game' + ), + 'ALTER TABLE `baishun_game_catalog` ADD KEY `idx_baishun_catalog_profile_internal_game` (`sys_origin`, `profile`, `internal_game_id`, `status`)', + 'SELECT 1' +); +PREPARE add_catalog_profile_index_stmt FROM @add_catalog_profile_index_sql; +EXECUTE add_catalog_profile_index_stmt; +DEALLOCATE PREPARE add_catalog_profile_index_stmt; + +SET @add_vendor_ext_profile_sql := IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'sys_game_list_vendor_ext' + AND column_name = 'profile' + ), + 'ALTER TABLE `sys_game_list_vendor_ext` ADD COLUMN `profile` varchar(32) NOT NULL DEFAULT ''PROD'' AFTER `sys_origin`', + 'SELECT 1' +); +PREPARE add_vendor_ext_profile_stmt FROM @add_vendor_ext_profile_sql; +EXECUTE add_vendor_ext_profile_stmt; +DEALLOCATE PREPARE add_vendor_ext_profile_stmt; + +SET @add_vendor_ext_profile_index_sql := IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = @current_schema + AND table_name = 'sys_game_list_vendor_ext' + AND index_name = 'idx_game_vendor_ext_sys_profile_vendor' + ), + 'ALTER TABLE `sys_game_list_vendor_ext` ADD KEY `idx_game_vendor_ext_sys_profile_vendor` (`sys_origin`, `profile`, `vendor_type`, `vendor_game_id`, `enabled`)', + 'SELECT 1' +); +PREPARE add_vendor_ext_profile_index_stmt FROM @add_vendor_ext_profile_index_sql; +EXECUTE add_vendor_ext_profile_index_stmt; +DEALLOCATE PREPARE add_vendor_ext_profile_index_stmt;