From a9ab2b85897f3642c914a8a36f6b83f8c74f91f1 Mon Sep 17 00:00:00 2001 From: hy001 Date: Tue, 28 Apr 2026 18:05:13 +0800 Subject: [PATCH] =?UTF-8?q?=E7=81=B5=E4=BB=99=E6=B8=B8=E6=88=8F=E5=92=8C?= =?UTF-8?q?=E7=99=BE=E9=A1=BA=E7=9A=84=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/config.go | 2 +- internal/config/config_test.go | 9 + internal/model/baishun_models.go | 1 + internal/router/baishun_routes.go | 6 +- internal/router/game_provider_routes.go | 53 +++- internal/router/game_provider_routes_test.go | 63 +++++ internal/router/lingxian_routes.go | 6 +- internal/router/week_star_routes.go | 10 + internal/service/baishun/admin.go | 24 +- internal/service/baishun/admin_test.go | 54 ++++ internal/service/baishun/types.go | 9 +- internal/service/gameopen/convert.go | 26 ++ internal/service/gameopen/service.go | 2 + internal/service/gameopen/service_test.go | 81 +++++- internal/service/gameprovider/registry.go | 15 +- .../service/gameprovider/registry_test.go | 12 + internal/service/lingxian/app_provider.go | 72 ++++- internal/service/lingxian/catalog.go | 24 +- internal/service/lingxian/catalog_test.go | 258 ++++++++++++++++++ internal/service/lingxian/config.go | 4 +- internal/service/lingxian/types.go | 30 +- internal/service/weekstar/page.go | 46 ++++ internal/service/weekstar/page_test.go | 102 +++++++ internal/service/weekstar/types.go | 13 + migrations/014_lingxian_provider_config.sql | 1 + migrations/018_lingxian_test_room_id.sql | 17 ++ 26 files changed, 889 insertions(+), 51 deletions(-) create mode 100644 internal/service/weekstar/page_test.go create mode 100644 migrations/018_lingxian_test_room_id.sql diff --git a/internal/config/config.go b/internal/config/config.go index d910fd4..a0f8fb8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -230,7 +230,7 @@ func Load() Config { }, Lingxian: LingxianConfig{ GameListURL: getEnvAny([]string{"CHATAPP_LINGXIAN_GAME_LIST_URL", "LINGXIAN_GAME_LIST_URL"}, "https://sg-test.leadercc.com/yumichat_games/test_game_list.json"), - AppKey: getEnvAny([]string{"CHATAPP_LINGXIAN_APP_KEY", "LINGXIAN_APP_KEY", "GAME_HKYS_SIGN_KEY"}, ""), + AppKey: getEnvAny([]string{"CHATAPP_LINGXIAN_APP_KEY", "LINGXIAN_APP_KEY", "GAME_HKYS_SIGN_KEY", "LIKEI_GAME_HKYS_SIGN_KEY"}, ""), }, GameOpen: GameOpenConfig{ PublicBaseURL: getEnvAny([]string{"CHATAPP_GAME_OPEN_PUBLIC_BASE_URL", "GAME_OPEN_PUBLIC_BASE_URL"}, ""), diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3ee8913..c9d5de9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -57,6 +57,15 @@ func TestGetEnvBoolAliasSupportsLegacyLuckyGiftKey(t *testing.T) { } } +func TestLoadLingxianConfigSupportsLikeiHkysSignKey(t *testing.T) { + t.Setenv("LIKEI_GAME_HKYS_SIGN_KEY", "likei-hkys-key") + + cfg := Load() + if got := cfg.Lingxian.AppKey; got != "likei-hkys-key" { + t.Fatalf("expected legacy likei hkys sign key, got %q", got) + } +} + func TestLoadLuckyGiftConfigsSupportsLegacyDefaultEnv(t *testing.T) { oldJSON, hadJSON := os.LookupEnv("LUCKY_GIFT_CONFIGS_JSON") oldList, hadList := os.LookupEnv("LUCKY_GIFT_CONFIGS") diff --git a/internal/model/baishun_models.go b/internal/model/baishun_models.go index 52361f4..45376c6 100644 --- a/internal/model/baishun_models.go +++ b/internal/model/baishun_models.go @@ -112,6 +112,7 @@ type LingxianProviderConfig struct { AppKey string `gorm:"column:app_key;size:255"` TestUID string `gorm:"column:test_uid;size:64"` TestToken string `gorm:"column:test_token;size:1024"` + TestRoomID string `gorm:"column:test_room_id;size:64"` CreateTime time.Time `gorm:"column:create_time"` UpdateTime time.Time `gorm:"column:update_time;index:idx_lingxian_provider_active,priority:3"` } diff --git a/internal/router/baishun_routes.go b/internal/router/baishun_routes.go index 342e41e..96f8277 100644 --- a/internal/router/baishun_routes.go +++ b/internal/router/baishun_routes.go @@ -204,7 +204,11 @@ func registerBaishunRoutes( writeError(c, baishun.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) return } - resp, err := baishunService.ImportCatalogGames(c.Request.Context(), req.SysOrigin, req.Profile, req.VendorGameIDs) + defaultShowcase := true + if req.DefaultShowcase != nil { + defaultShowcase = *req.DefaultShowcase + } + resp, err := baishunService.ImportCatalogGames(c.Request.Context(), req.SysOrigin, req.Profile, req.VendorGameIDs, defaultShowcase) if err != nil { writeError(c, err) return diff --git a/internal/router/game_provider_routes.go b/internal/router/game_provider_routes.go index 447b8cc..f048ee3 100644 --- a/internal/router/game_provider_routes.go +++ b/internal/router/game_provider_routes.go @@ -2,6 +2,7 @@ package router import ( "net/http" + "strings" "chatapp3-golang/internal/common" "chatapp3-golang/internal/service/gameprovider" @@ -83,7 +84,7 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi writeError(c, err) return } - writeOK(c, gin.H{"items": resp}) + writeOK(c, gin.H{"items": publicRoomGameItems(resp)}) }) providerGroup.GET("/:provider/room/list", func(c *gin.Context) { provider, ok := resolveGameProvider(c, registry) @@ -95,7 +96,7 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi writeError(c, err) return } - writeOK(c, resp) + writeOK(c, publicRoomGameListResponse(resp)) }) providerGroup.GET("/:provider/state", func(c *gin.Context) { provider, ok := resolveGameProvider(c, registry) @@ -107,7 +108,7 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi writeError(c, err) return } - writeOK(c, resp) + writeOK(c, publicRoomState(resp)) }) providerGroup.POST("/:provider/launch", func(c *gin.Context) { provider, ok := resolveGameProvider(c, registry) @@ -124,7 +125,7 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi writeError(c, err) return } - writeOK(c, resp) + writeOK(c, publicLaunch(resp)) }) providerGroup.POST("/:provider/close", func(c *gin.Context) { provider, ok := resolveGameProvider(c, registry) @@ -141,7 +142,7 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi writeError(c, err) return } - writeOK(c, resp) + writeOK(c, publicRoomState(resp)) }) } @@ -214,12 +215,14 @@ func publicRoomGameItems(items []gameprovider.RoomGameListItem) []publicRoomGame } result := make([]publicRoomGameItem, 0, len(items)) for _, item := range items { + provider := appCompatProviderName(item.Provider) + gameType := appCompatProviderName(item.GameType) result = append(result, publicRoomGameItem{ ID: item.ID, GameID: item.GameID, - GameType: item.GameType, - Provider: item.Provider, - VendorType: item.Provider, + GameType: gameType, + Provider: provider, + VendorType: provider, ProviderGameID: item.ProviderGameID, VendorGameID: item.ProviderGameID, Name: item.Name, @@ -233,12 +236,29 @@ func publicRoomGameItems(items []gameprovider.RoomGameListItem) []publicRoomGame Orientation: item.Orientation, PackageVersion: item.PackageVersion, Status: item.Status, - LaunchParams: item.LaunchParams, + LaunchParams: publicLaunchParams(item.LaunchParams), }) } return result } +func publicLaunchParams(params map[string]any) map[string]any { + if len(params) == 0 { + return nil + } + result := make(map[string]any, len(params)) + for key, value := range params { + if strings.EqualFold(strings.TrimSpace(key), "gameType") { + if text, ok := value.(string); ok { + result[key] = appCompatProviderName(text) + continue + } + } + result[key] = value + } + return result +} + func publicRoomGameListResponse(resp *gameprovider.RoomGameListResponse) *publicRoomGameList { if resp == nil { return &publicRoomGameList{Items: []publicRoomGameItem{}} @@ -255,8 +275,8 @@ func publicRoomState(resp *gameprovider.RoomStateResponse) *publicRoomStateRespo return &publicRoomStateResponse{ RoomID: resp.RoomID, State: resp.State, - Provider: resp.Provider, - VendorType: resp.Provider, + Provider: appCompatProviderName(resp.Provider), + VendorType: appCompatProviderName(resp.Provider), GameSessionID: resp.GameSessionID, CurrentGameID: resp.CurrentGameID, CurrentProviderGameID: resp.CurrentProviderGameID, @@ -278,8 +298,8 @@ func publicLaunch(resp *gameprovider.LaunchResponse) *publicLaunchResponse { return &publicLaunchResponse{ ID: resp.ID, GameSessionID: resp.GameSessionID, - Provider: resp.Provider, - VendorType: resp.Provider, + Provider: appCompatProviderName(resp.Provider), + VendorType: appCompatProviderName(resp.Provider), GameID: resp.GameID, ProviderGameID: resp.ProviderGameID, VendorGameID: resp.ProviderGameID, @@ -289,3 +309,10 @@ func publicLaunch(resp *gameprovider.LaunchResponse) *publicLaunchResponse { RoomState: *roomState, } } + +func appCompatProviderName(provider string) string { + if strings.EqualFold(strings.TrimSpace(provider), "LINGXIAN") { + return "LEADER" + } + return provider +} diff --git a/internal/router/game_provider_routes_test.go b/internal/router/game_provider_routes_test.go index 6ccdd58..d861425 100644 --- a/internal/router/game_provider_routes_test.go +++ b/internal/router/game_provider_routes_test.go @@ -38,6 +38,42 @@ func TestPublicRoomGameListResponseKeepsLegacyProviderFields(t *testing.T) { } } +func TestPublicRoomGameListResponseMapsLingxianToLeaderForOldClients(t *testing.T) { + resp := publicRoomGameListResponse(&gameprovider.RoomGameListResponse{ + Items: []gameprovider.RoomGameListItem{ + { + ID: 9528, + GameID: "1090", + GameType: "LINGXIAN", + Provider: "LINGXIAN", + ProviderGameID: "1090", + Name: "Lingxian Game", + LaunchParams: map[string]any{"gameType": "LINGXIAN", "screenMode": "half"}, + }, + }, + }) + + if len(resp.Items) != 1 { + t.Fatalf("items = %d, want 1", len(resp.Items)) + } + item := resp.Items[0] + if item.Provider != "LEADER" { + t.Fatalf("provider = %q, want LEADER", item.Provider) + } + if item.GameType != "LEADER" { + t.Fatalf("gameType = %q, want LEADER", item.GameType) + } + if item.VendorType != "LEADER" { + t.Fatalf("vendorType = %q, want LEADER", item.VendorType) + } + if got := item.LaunchParams["gameType"]; got != "LEADER" { + t.Fatalf("launchParams.gameType = %v, want LEADER", got) + } + if got := item.LaunchParams["screenMode"]; got != "half" { + t.Fatalf("launchParams.screenMode = %v, want half", got) + } +} + func TestPublicLaunchKeepsLegacyBridgeAndProviderFields(t *testing.T) { launchConfig := map[string]any{"code": "launch-code"} resp := publicLaunch(&gameprovider.LaunchResponse{ @@ -78,3 +114,30 @@ func TestPublicLaunchKeepsLegacyBridgeAndProviderFields(t *testing.T) { t.Fatalf("roomState.currentVendorGameId = %q, want 1046", resp.RoomState.CurrentVendorGameID) } } + +func TestPublicLaunchMapsLingxianToLeaderForOldClients(t *testing.T) { + resp := publicLaunch(&gameprovider.LaunchResponse{ + ID: 9528, + GameSessionID: "lx_1_1090", + Provider: "LINGXIAN", + GameID: "1090", + ProviderGameID: "1090", + LaunchConfig: map[string]any{"uid": "user-1"}, + RoomState: gameprovider.RoomStateResponse{ + RoomID: "room-1", + State: "PLAYING", + Provider: "LINGXIAN", + CurrentProviderGameID: "1090", + }, + }) + + if resp.Provider != "LEADER" { + t.Fatalf("provider = %q, want LEADER", resp.Provider) + } + if resp.VendorType != "LEADER" { + t.Fatalf("vendorType = %q, want LEADER", resp.VendorType) + } + if resp.RoomState.Provider != "LEADER" { + t.Fatalf("roomState.provider = %q, want LEADER", resp.RoomState.Provider) + } +} diff --git a/internal/router/lingxian_routes.go b/internal/router/lingxian_routes.go index 887ca64..d4562bd 100644 --- a/internal/router/lingxian_routes.go +++ b/internal/router/lingxian_routes.go @@ -87,7 +87,11 @@ func registerLingxianRoutes(engine *gin.Engine, javaClient authGateway, service writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) return } - resp, err := service.ImportCatalogGames(c.Request.Context(), req.SysOrigin, req.Profile, req.VendorGameIDs) + defaultShowcase := true + if req.DefaultShowcase != nil { + defaultShowcase = *req.DefaultShowcase + } + resp, err := service.ImportCatalogGames(c.Request.Context(), req.SysOrigin, req.Profile, req.VendorGameIDs, defaultShowcase) if err != nil { writeError(c, err) return diff --git a/internal/router/week_star_routes.go b/internal/router/week_star_routes.go index cd54def..ed1ba9b 100644 --- a/internal/router/week_star_routes.go +++ b/internal/router/week_star_routes.go @@ -68,6 +68,16 @@ func registerSpecifiedGiftWeeklyRankRoutes(group *gin.RouterGroup, javaClient au writeOK(c, resp) }) + group.GET("/current-rank", func(c *gin.Context) { + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) + resp, err := weekStarService.CurrentRanking(c.Request.Context(), c.Query("sysOrigin"), limit) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + group.GET("/snapshot/page", func(c *gin.Context) { cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1"))) limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) diff --git a/internal/service/baishun/admin.go b/internal/service/baishun/admin.go index 2ff0980..04f981d 100644 --- a/internal/service/baishun/admin.go +++ b/internal/service/baishun/admin.go @@ -386,7 +386,7 @@ func (s *BaishunService) DeleteAdminGame(ctx context.Context, sysOrigin string, } // ImportCatalogGames 把本地目录中尚未进入后台列表的百顺游戏补齐成可配置记录。 -func (s *BaishunService) ImportCatalogGames(ctx context.Context, sysOrigin string, profile string, vendorGameIDs []int) (*ImportCatalogResponse, error) { +func (s *BaishunService) ImportCatalogGames(ctx context.Context, sysOrigin string, profile string, vendorGameIDs []int, defaultShowcase ...bool) (*ImportCatalogResponse, error) { sysOrigin = normalizeAdminSysOrigin(sysOrigin) profile, err := s.requestProfile(ctx, sysOrigin, profile) if err != nil { @@ -396,7 +396,7 @@ func (s *BaishunService) ImportCatalogGames(ctx context.Context, sysOrigin strin if tx.Error != nil { return nil, tx.Error } - resp, err := s.importCatalogGamesTx(tx, sysOrigin, profile, buildVendorGameIDFilter(vendorGameIDs)) + resp, err := s.importCatalogGamesTx(tx, sysOrigin, profile, buildVendorGameIDFilter(vendorGameIDs), resolveImportDefaultShowcase(defaultShowcase...)) if err != nil { tx.Rollback() return nil, err @@ -414,7 +414,7 @@ func (s *BaishunService) SyncAdminGames(ctx context.Context, req SyncCatalogRequ if err != nil { return nil, err } - importResp, err := s.ImportCatalogGames(ctx, req.SysOrigin, req.Profile, req.VendorGameIDs) + importResp, err := s.ImportCatalogGames(ctx, req.SysOrigin, req.Profile, req.VendorGameIDs, resolveBoolPtrDefault(req.DefaultShowcase, true)) if err != nil { return nil, err } @@ -589,7 +589,7 @@ func (s *BaishunService) findAdminConfigForSave(tx *gorm.DB, sysOrigin string, p return cfg, nil } -func (s *BaishunService) importCatalogGamesTx(tx *gorm.DB, sysOrigin string, profile string, filter map[int]struct{}) (*ImportCatalogResponse, error) { +func (s *BaishunService) importCatalogGamesTx(tx *gorm.DB, sysOrigin string, profile string, filter map[int]struct{}, defaultShowcase bool) (*ImportCatalogResponse, error) { ctx := tx.Statement.Context if ctx == nil { ctx = context.Background() @@ -647,7 +647,7 @@ func (s *BaishunService) importCatalogGamesTx(tx *gorm.DB, sysOrigin string, pro Category: baishunRoomCategory, GameCode: vendorGameID, Cover: defaultIfBlank(catalog.Cover, catalog.PreviewURL), - Showcase: true, + Showcase: defaultShowcase, Sort: int64(catalog.VendorGameID), FullScreen: true, ClientOrigin: "COMMON", @@ -716,6 +716,20 @@ func (s *BaishunService) importCatalogGamesTx(tx *gorm.DB, sysOrigin string, pro return resp, nil } +func resolveImportDefaultShowcase(values ...bool) bool { + if len(values) == 0 { + return true + } + return values[0] +} + +func resolveBoolPtrDefault(value *bool, fallback bool) bool { + if value == nil { + return fallback + } + return *value +} + func (r adminGameRow) toAdminItem() AdminBaishunGameItem { return AdminBaishunGameItem{ ID: r.ID, diff --git a/internal/service/baishun/admin_test.go b/internal/service/baishun/admin_test.go index 597784e..f166ad2 100644 --- a/internal/service/baishun/admin_test.go +++ b/internal/service/baishun/admin_test.go @@ -141,6 +141,9 @@ func TestImportAndListRoomGamesUseActiveProfile(t *testing.T) { if prodConfig.Category != baishunRoomCategory { t.Fatalf("imported prod category = %q, want %q", prodConfig.Category, baishunRoomCategory) } + if !prodConfig.Showcase { + t.Fatalf("imported prod showcase = false, want true by default") + } if resp, err := service.ImportCatalogGames(ctx, "LIKEI", baishunProfileTest, []int{9001}); err != nil { t.Fatalf("import test catalog: %v", err) } else if resp.Imported != 1 { @@ -171,6 +174,57 @@ func TestImportAndListRoomGamesUseActiveProfile(t *testing.T) { } } +func TestImportCatalogGamesCanDefaultToHidden(t *testing.T) { + service, db := newTestBaishunService(t) + ctx := context.Background() + now := time.Now() + + if err := db.Create(&model.BaishunProviderConfig{ + ID: 1101, + SysOrigin: "LIKEI", + Profile: baishunProfileProd, + Active: true, + PlatformBaseURL: "https://prod.example.com", + AppID: 111, + AppChannel: "prod", + AppKey: "prod-key", + GSP: 101, + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("create provider config: %v", err) + } + if err := db.Create(&model.BaishunGameCatalog{ + ID: 2101, + SysOrigin: "LIKEI", + Profile: baishunProfileProd, + InternalGameID: "bs_9101", + VendorGameID: 9101, + Name: "Hidden Game", + Status: baishunCatalogEnabled, + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("create catalog: %v", err) + } + + resp, err := service.ImportCatalogGames(ctx, "LIKEI", baishunProfileProd, []int{9101}, false) + if err != nil { + t.Fatalf("import hidden catalog: %v", err) + } + if resp.Imported != 1 { + t.Fatalf("imported = %d, want 1", resp.Imported) + } + + var cfg model.SysGameListConfig + if err := db.Where("sys_origin = ? AND game_origin = ? AND game_id = ?", "LIKEI", baishunVendorType, "bs_9101").First(&cfg).Error; err != nil { + t.Fatalf("load imported config: %v", err) + } + if cfg.Showcase { + t.Fatalf("imported showcase = true, want false") + } +} + func TestCatalogAddedRequiresExistingGameConfigAndImportRepairsDanglingExt(t *testing.T) { service, db := newTestBaishunService(t) ctx := context.Background() diff --git a/internal/service/baishun/types.go b/internal/service/baishun/types.go index 0d76fe5..a33fc29 100644 --- a/internal/service/baishun/types.go +++ b/internal/service/baishun/types.go @@ -174,10 +174,11 @@ type BaishunLaunchAppResponse struct { // SyncCatalogRequest 是目录同步入参。 type SyncCatalogRequest struct { - SysOrigin string `json:"sysOrigin"` - Profile string `json:"profile"` - VendorGameIDs []int `json:"vendorGameIds"` - Force bool `json:"force"` + SysOrigin string `json:"sysOrigin"` + Profile string `json:"profile"` + VendorGameIDs []int `json:"vendorGameIds"` + Force bool `json:"force"` + DefaultShowcase *bool `json:"defaultShowcase"` } // SyncCatalogResponse 是目录同步结果统计。 diff --git a/internal/service/gameopen/convert.go b/internal/service/gameopen/convert.go index 9d32395..5c334d8 100644 --- a/internal/service/gameopen/convert.go +++ b/internal/service/gameopen/convert.go @@ -4,6 +4,7 @@ import ( "crypto/md5" "encoding/hex" "fmt" + "net/url" "strings" ) @@ -45,6 +46,31 @@ func verifySign(actual, expected string) bool { return strings.EqualFold(strings.TrimSpace(actual), strings.TrimSpace(expected)) } +func normalizeCallbackToken(token string) string { + token = strings.TrimSpace(token) + if token == "" { + return "" + } + if decoded, err := url.QueryUnescape(token); err == nil { + token = strings.TrimSpace(decoded) + } + if strings.HasPrefix(strings.ToLower(token), "bearer ") { + token = strings.TrimSpace(token[len("bearer "):]) + } + + parts := strings.SplitN(token, ".", 2) + if len(parts) != 2 || parts[1] == "" { + return token + } + switch len(parts[1]) % 4 { + case 2: + parts[1] += "==" + case 3: + parts[1] += "=" + } + return parts[0] + "." + parts[1] +} + func receiptTypeFromType(typed int) string { if typed == 2 { return "INCOME" diff --git a/internal/service/gameopen/service.go b/internal/service/gameopen/service.go index f75bc82..412aa29 100644 --- a/internal/service/gameopen/service.go +++ b/internal/service/gameopen/service.go @@ -25,6 +25,7 @@ func (s *GameOpenService) HandleQueryUser(ctx context.Context, req QueryUserRequ return resp } + req.Token = normalizeCallbackToken(req.Token) credential, err := s.java.AuthenticateToken(ctx, req.Token) if err != nil { resp := failResponse(gameOpenCodeTokenInvalid, "token invalid") @@ -79,6 +80,7 @@ func (s *GameOpenService) HandleUpdateCoin(ctx context.Context, req UpdateCoinRe return resp } + req.Token = normalizeCallbackToken(req.Token) credential, err := s.java.AuthenticateToken(ctx, req.Token) if err != nil { resp := failResponse(gameOpenCodeTokenInvalid, "token invalid") diff --git a/internal/service/gameopen/service_test.go b/internal/service/gameopen/service_test.go index e615d12..d6fc34a 100644 --- a/internal/service/gameopen/service_test.go +++ b/internal/service/gameopen/service_test.go @@ -10,6 +10,7 @@ import ( type stubGateway struct { credential integration.UserCredential token string + authToken string profile integration.UserProfile accountMap map[string]integration.UserProfile balance int64 @@ -17,7 +18,8 @@ type stubGateway struct { cmd integration.GoldReceiptCommand } -func (s *stubGateway) AuthenticateToken(context.Context, string) (integration.UserCredential, error) { +func (s *stubGateway) AuthenticateToken(_ context.Context, token string) (integration.UserCredential, error) { + s.authToken = token return s.credential, nil } @@ -103,6 +105,48 @@ func TestHandleQueryUser(t *testing.T) { } } +func TestHandleQueryUserNormalizesCallbackTokenAfterSignVerify(t *testing.T) { + tests := []struct { + name string + token string + want string + }{ + {name: "missing_padding", token: "token-sign.YWJjZA", want: "token-sign.YWJjZA=="}, + {name: "encoded_padding", token: "token-sign.YWJjZA%3D%3D", want: "token-sign.YWJjZA=="}, + {name: "encoded_bearer_missing_padding", token: "Bearer%20token-sign.YWJjZA", want: "token-sign.YWJjZA=="}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gateway := &stubGateway{ + credential: integration.UserCredential{UserID: 1234567, SysOrigin: "LIKEI"}, + profile: integration.UserProfile{ + ID: 1234567, + Account: "1234567", + }, + balance: 88, + } + service := NewGameOpenService(config.Config{ + GameOpen: config.GameOpenConfig{AppKey: "game-open-test-key"}, + }, nil, gateway) + req := QueryUserRequest{ + GameID: "101", + UID: "1234567", + Token: tt.token, + RoomID: "room-1", + } + req.Sign = buildQueryUserSign(req, "game-open-test-key") + + resp := service.HandleQueryUser(context.Background(), req, "{}") + if resp.ErrorCode != 0 { + t.Fatalf("HandleQueryUser() error = %+v", resp) + } + if gateway.authToken != tt.want { + t.Fatalf("AuthenticateToken() token = %q, want %q", gateway.authToken, tt.want) + } + }) + } +} + func TestHandleUpdateCoin(t *testing.T) { gateway := &stubGateway{ credential: integration.UserCredential{UserID: 1234567, SysOrigin: "LIKEI"}, @@ -141,6 +185,41 @@ func TestHandleUpdateCoin(t *testing.T) { } } +func TestHandleUpdateCoinNormalizesCallbackTokenAfterSignVerify(t *testing.T) { + gateway := &stubGateway{ + credential: integration.UserCredential{UserID: 1234567, SysOrigin: "LIKEI"}, + profile: integration.UserProfile{ + ID: 1234567, + Account: "1234567", + }, + balance: 99, + } + service := NewGameOpenService(config.Config{ + GameOpen: config.GameOpenConfig{AppKey: "game-open-test-key"}, + }, nil, gateway) + req := UpdateCoinRequest{ + OrderID: "order-1", + GameID: "101", + RoundID: "round-1", + UID: "1234567", + Coin: 15, + Type: 2, + RewardType: 2, + Token: "Bearer%20token-sign.YWJjZA", + WinID: "win-1", + RoomID: "room-1", + } + req.Sign = buildUpdateCoinSign(req, "game-open-test-key") + + resp := service.HandleUpdateCoin(context.Background(), req, "{}") + if resp.ErrorCode != 0 { + t.Fatalf("HandleUpdateCoin() error = %+v", resp) + } + if gateway.authToken != "token-sign.YWJjZA==" { + t.Fatalf("AuthenticateToken() token = %q, want token-sign.YWJjZA==", gateway.authToken) + } +} + func TestHandleUpdateCoinDuplicateOrderReturns10003(t *testing.T) { gateway := &stubGateway{ credential: integration.UserCredential{UserID: 1234567, SysOrigin: "LIKEI"}, diff --git a/internal/service/gameprovider/registry.go b/internal/service/gameprovider/registry.go index 58b9aa8..326b3c3 100644 --- a/internal/service/gameprovider/registry.go +++ b/internal/service/gameprovider/registry.go @@ -47,7 +47,11 @@ func (r *Registry) Resolve(key string) (Provider, error) { if r == nil { return nil, common.NewAppError(http.StatusNotFound, "provider_not_found", "game provider not found") } - provider, ok := r.providers[normalizeKey(key)] + normalizedKey := normalizeKey(key) + provider, ok := r.providers[normalizedKey] + if !ok { + provider, ok = r.providers[normalizeProviderAlias(normalizedKey)] + } if !ok { return nil, common.NewAppError(http.StatusNotFound, "provider_not_found", "game provider not found") } @@ -227,3 +231,12 @@ func sortRoomGameListItems(items []RoomGameListItem) { func normalizeKey(key string) string { return strings.ToUpper(strings.TrimSpace(key)) } + +func normalizeProviderAlias(key string) string { + switch normalizeKey(key) { + case "LEADER": + return "LINGXIAN" + default: + return normalizeKey(key) + } +} diff --git a/internal/service/gameprovider/registry_test.go b/internal/service/gameprovider/registry_test.go index 88f4ecc..fb10b0a 100644 --- a/internal/service/gameprovider/registry_test.go +++ b/internal/service/gameprovider/registry_test.go @@ -68,6 +68,18 @@ func TestRegistryResolveIsCaseInsensitive(t *testing.T) { } } +func TestRegistryResolveMapsLeaderAliasToLingxian(t *testing.T) { + registry := NewRegistry(&stubProvider{key: "LINGXIAN", name: "灵仙"}) + + provider, err := registry.Resolve("LEADER") + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if provider.Key() != "LINGXIAN" { + t.Fatalf("Resolve() key = %q, want %q", provider.Key(), "LINGXIAN") + } +} + func TestRegistryListKeepsRegistrationOrder(t *testing.T) { registry := NewRegistry( &stubProvider{key: "BAISHUN", name: "百顺"}, diff --git a/internal/service/lingxian/app_provider.go b/internal/service/lingxian/app_provider.go index 5631325..c9fc42d 100644 --- a/internal/service/lingxian/app_provider.go +++ b/internal/service/lingxian/app_provider.go @@ -67,6 +67,11 @@ func (p *AppProvider) LaunchGame(ctx context.Context, user gameprovider.AuthUser if err != nil { return nil, err } + uid := launchUID(runtimeCfg, user) + token := launchToken(runtimeCfg, user) + roomID := launchRoomID(runtimeCfg, req) + lang := launchLang(req) + sign := launchSign(runtimeCfg, row.VendorGameID, uid, token, roomID) entryURL := buildLaunchURL(row.entryURL(), runtimeCfg, user, req, row.VendorGameID) sessionID := fmt.Sprintf("lx_%d_%s", user.UserID, row.VendorGameID) return &gameprovider.LaunchResponse{ @@ -81,13 +86,17 @@ func (p *AppProvider) LaunchGame(ctx context.Context, user gameprovider.AuthUser PreviewURL: row.previewURL(), DownloadURL: row.entryURL(), PackageVersion: row.packageVersion(), + Orientation: row.orientation(), + SafeHeight: row.safeHeight(), }, LaunchConfig: map[string]any{ - "uid": launchUID(runtimeCfg, user), - "token": launchToken(runtimeCfg, user), + "uid": uid, + "token": token, + "lang": lang, "gameId": row.VendorGameID, - "roomId": req.RoomID, - "sign": launchSign(runtimeCfg, row.VendorGameID, launchUID(runtimeCfg, user), launchToken(runtimeCfg, user), req.RoomID), + "roomId": roomID, + "roomid": roomID, + "sign": sign, }, RoomState: gameprovider.RoomStateResponse{ RoomID: req.RoomID, @@ -170,6 +179,8 @@ func (s *Service) baseGameQuery(ctx context.Context, sysOrigin, profile string) ext.package_version AS ext_package_version, ext.preview_url AS ext_preview_url, ext.package_url AS package_url, + ext.orientation AS ext_orientation, + ext.safe_height AS ext_safe_height, ext.extra_json AS ext_extra_json, cat.name AS catalog_name, cat.cover AS catalog_cover, @@ -196,10 +207,14 @@ func (r gameRow) toListItem() gameprovider.RoomGameListItem { LaunchMode: defaultIfBlank(r.LaunchMode, launchModeH5), FullScreen: r.FullScreen, GameMode: 3, + SafeHeight: r.safeHeight(), + Orientation: r.orientation(), PackageVersion: r.packageVersion(), Status: defaultIfBlank(r.CatalogStatus, catalogEnabled), LaunchParams: map[string]any{ - "gameType": vendorType, + "gameType": vendorType, + "lang": defaultLaunchLang, + "screenMode": r.screenMode(), }, } } @@ -217,6 +232,11 @@ func (r gameRow) previewURL() string { } func (r gameRow) entryURL() string { + if !r.FullScreen { + if halfURL := r.previewURL(); halfURL != "" { + return halfURL + } + } return defaultIfBlank(r.PackageURL, defaultIfBlank(r.CatalogDownloadURL, r.ExtPreviewURL)) } @@ -224,6 +244,24 @@ func (r gameRow) packageVersion() string { return defaultIfBlank(r.ExtPackageVersion, r.CatalogPackageVersion) } +func (r gameRow) orientation() int { + if r.ExtOrientation != nil { + return *r.ExtOrientation + } + return 0 +} + +func (r gameRow) safeHeight() int { + return r.ExtSafeHeight +} + +func (r gameRow) screenMode() string { + if r.FullScreen { + return "full" + } + return "half" +} + func buildLaunchURL(entry string, cfg runtimeConfig, user gameprovider.AuthUser, req gameprovider.LaunchRequest, gameID string) string { parsed, err := url.Parse(strings.TrimSpace(entry)) if err != nil { @@ -231,12 +269,16 @@ func buildLaunchURL(entry string, cfg runtimeConfig, user gameprovider.AuthUser, } uid := launchUID(cfg, user) token := launchToken(cfg, user) + roomID := launchRoomID(cfg, req) + lang := launchLang(req) query := parsed.Query() query.Set("uid", uid) query.Set("token", token) + query.Set("lang", lang) query.Set("gameId", gameID) - query.Set("roomId", req.RoomID) - query.Set("sign", launchSign(cfg, gameID, uid, token, req.RoomID)) + query.Set("roomId", roomID) + query.Set("roomid", roomID) + query.Set("sign", launchSign(cfg, gameID, uid, token, roomID)) parsed.RawQuery = query.Encode() return parsed.String() } @@ -249,6 +291,22 @@ func launchToken(cfg runtimeConfig, user gameprovider.AuthUser) string { return defaultIfBlank(cfg.TestToken, user.Token) } +func launchRoomID(cfg runtimeConfig, req gameprovider.LaunchRequest) string { + return defaultIfBlank(cfg.TestRoomID, req.RoomID) +} + +func launchLang(req gameprovider.LaunchRequest) string { + for _, key := range []string{"lang", "language", "locale"} { + if req.Params == nil { + break + } + if value := strings.TrimSpace(fmt.Sprint(req.Params[key])); value != "" && value != "" { + return value + } + } + return defaultLaunchLang +} + func launchSign(cfg runtimeConfig, gameID, uid, token, roomID string) string { sum := md5.Sum([]byte(gameID + uid + token + roomID + strings.TrimSpace(cfg.AppKey))) return hex.EncodeToString(sum[:]) diff --git a/internal/service/lingxian/catalog.go b/internal/service/lingxian/catalog.go index b0f2ad2..e3399ac 100644 --- a/internal/service/lingxian/catalog.go +++ b/internal/service/lingxian/catalog.go @@ -207,7 +207,7 @@ func (s *Service) buildCatalogQuery(ctx context.Context, sysOrigin, profile, key return query } -func (s *Service) ImportCatalogGames(ctx context.Context, sysOrigin, profile string, vendorGameIDs []string) (*ImportCatalogResponse, error) { +func (s *Service) ImportCatalogGames(ctx context.Context, sysOrigin, profile string, vendorGameIDs []string, defaultShowcase ...bool) (*ImportCatalogResponse, error) { sysOrigin = normalizeSysOrigin(sysOrigin) profile, err := s.requestProfile(ctx, sysOrigin, profile) if err != nil { @@ -217,7 +217,7 @@ func (s *Service) ImportCatalogGames(ctx context.Context, sysOrigin, profile str if tx.Error != nil { return nil, tx.Error } - resp, err := s.importCatalogGamesTx(tx, sysOrigin, profile, buildVendorGameIDFilter(vendorGameIDs)) + resp, err := s.importCatalogGamesTx(tx, sysOrigin, profile, buildVendorGameIDFilter(vendorGameIDs), resolveImportDefaultShowcase(defaultShowcase...)) if err != nil { tx.Rollback() return nil, err @@ -234,7 +234,7 @@ func (s *Service) SyncAdminGames(ctx context.Context, req SyncCatalogRequest) (* if err != nil { return nil, err } - importResp, err := s.ImportCatalogGames(ctx, req.SysOrigin, req.Profile, req.VendorGameIDs) + importResp, err := s.ImportCatalogGames(ctx, req.SysOrigin, req.Profile, req.VendorGameIDs, resolveBoolPtrDefault(req.DefaultShowcase, true)) if err != nil { return nil, err } @@ -247,7 +247,7 @@ func (s *Service) SyncAdminGames(ctx context.Context, req SyncCatalogRequest) (* }, nil } -func (s *Service) importCatalogGamesTx(tx *gorm.DB, sysOrigin, profile string, filter map[string]struct{}) (*ImportCatalogResponse, error) { +func (s *Service) importCatalogGamesTx(tx *gorm.DB, sysOrigin, profile string, filter map[string]struct{}, defaultShowcase bool) (*ImportCatalogResponse, error) { query := tx.Where("sys_origin = ? AND profile = ? AND status = ?", sysOrigin, profile, catalogEnabled) if len(filter) > 0 { ids := make([]string, 0, len(filter)) @@ -300,7 +300,7 @@ func (s *Service) importCatalogGamesTx(tx *gorm.DB, sysOrigin, profile string, f Category: roomCategory, GameCode: vendorGameID, Cover: defaultIfBlank(catalog.Cover, catalog.PreviewURL), - Showcase: true, + Showcase: defaultShowcase, Sort: parseSort(vendorGameID), FullScreen: true, ClientOrigin: "COMMON", @@ -339,6 +339,20 @@ func (s *Service) importCatalogGamesTx(tx *gorm.DB, sysOrigin, profile string, f return resp, nil } +func resolveImportDefaultShowcase(values ...bool) bool { + if len(values) == 0 { + return true + } + return values[0] +} + +func resolveBoolPtrDefault(value *bool, fallback bool) bool { + if value == nil { + return fallback + } + return *value +} + func buildVendorGameIDFilter(vendorGameIDs []string) map[string]struct{} { filter := make(map[string]struct{}, len(vendorGameIDs)) for _, id := range vendorGameIDs { diff --git a/internal/service/lingxian/catalog_test.go b/internal/service/lingxian/catalog_test.go index 5263726..5bdba5c 100644 --- a/internal/service/lingxian/catalog_test.go +++ b/internal/service/lingxian/catalog_test.go @@ -3,9 +3,12 @@ package lingxian import ( "chatapp3-golang/internal/config" "chatapp3-golang/internal/model" + "chatapp3-golang/internal/service/gameprovider" "context" "net/http" "net/http/httptest" + "net/url" + "strings" "testing" "time" @@ -68,4 +71,259 @@ func TestSyncAdminGamesFetchesCatalogAndImportsUnifiedList(t *testing.T) { if ext.VendorGameID != "1" || ext.PackageURL != "https://example.test/full.html" { t.Fatalf("vendor ext = %#v", ext) } + if !cfg.Showcase { + t.Fatalf("showcase = false, want true by default") + } +} + +func TestImportCatalogGamesCanDefaultToHidden(t *testing.T) { + ctx := context.Background() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate( + &model.LingxianProviderConfig{}, + &model.LingxianGameCatalog{}, + &model.SysGameListConfig{}, + &model.SysGameListVendorExt{}, + ); err != nil { + t.Fatalf("migrate: %v", err) + } + + now := time.Now() + if err := db.Create(&model.LingxianProviderConfig{ + ID: 11, + SysOrigin: "LIKEI", + Profile: profileProd, + Active: true, + AppKey: "test-key", + TestUID: "test-uid", + TestToken: "test-token", + TestRoomID: "test-room", + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed provider: %v", err) + } + if err := db.Create(&model.LingxianGameCatalog{ + ID: 12, + SysOrigin: "LIKEI", + Profile: profileProd, + InternalGameID: "lx_2", + VendorGameID: "2", + Name: "Hidden Lingxian", + Status: catalogEnabled, + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed catalog: %v", err) + } + + service := NewService(config.Config{HTTP: config.HTTPConfig{Timeout: time.Second}}, db, nil) + resp, err := service.ImportCatalogGames(ctx, "LIKEI", profileProd, []string{"2"}, false) + if err != nil { + t.Fatalf("ImportCatalogGames: %v", err) + } + if resp.Imported != 1 { + t.Fatalf("imported = %d, want 1", resp.Imported) + } + + var cfg model.SysGameListConfig + if err := db.Where("sys_origin = ? AND game_origin = ? AND game_id = ?", "LIKEI", vendorType, "lx_2").First(&cfg).Error; err != nil { + t.Fatalf("unified config missing: %v", err) + } + if cfg.Showcase { + t.Fatalf("showcase = true, want false") + } +} + +func TestSaveProviderConfigPersistsTestRoomID(t *testing.T) { + ctx := context.Background() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.LingxianProviderConfig{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + service := NewService(config.Config{HTTP: config.HTTPConfig{Timeout: time.Second}}, db, nil) + saved, err := service.SaveProviderConfig(ctx, SaveProviderConfigRequest{ + SysOrigin: "LIKEI", + Profile: profileProd, + GameListURL: "https://example.test/list.json", + AppKey: "test-key", + TestUID: "test-uid", + TestToken: "test-token", + TestRoomID: "test-room", + }) + if err != nil { + t.Fatalf("SaveProviderConfig: %v", err) + } + if saved.TestRoomID != "test-room" { + t.Fatalf("saved testRoomId = %q, want test-room", saved.TestRoomID) + } + + runtimeCfg, err := service.resolveRuntimeConfigForProfile(ctx, "LIKEI", profileProd) + if err != nil { + t.Fatalf("resolveRuntimeConfigForProfile: %v", err) + } + if runtimeCfg.TestRoomID != "test-room" { + t.Fatalf("runtime testRoomId = %q, want test-room", runtimeCfg.TestRoomID) + } +} + +func TestAppProviderUsesHalfURLForHalfScreenGames(t *testing.T) { + ctx := context.Background() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate( + &model.LingxianProviderConfig{}, + &model.LingxianGameCatalog{}, + &model.SysGameListConfig{}, + &model.SysGameListVendorExt{}, + ); err != nil { + t.Fatalf("migrate: %v", err) + } + + now := time.Now() + if err := db.Create(&model.LingxianProviderConfig{ + ID: 21, + SysOrigin: "LIKEI", + Profile: profileProd, + Active: true, + AppKey: "test-key", + TestUID: "test-uid", + TestToken: "test-token", + TestRoomID: "test-room", + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed provider: %v", err) + } + if err := db.Create(&model.LingxianGameCatalog{ + ID: 22, + SysOrigin: "LIKEI", + Profile: profileProd, + InternalGameID: "1", + VendorGameID: "1", + Name: "Half Lingxian", + PreviewURL: "https://example.test/half.html", + DownloadURL: "https://example.test/full.html", + PackageVersion: "11", + Status: catalogEnabled, + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed catalog: %v", err) + } + if err := db.Create(&model.SysGameListConfig{ + ID: 23, + SysOrigin: "LIKEI", + GameOrigin: vendorType, + GameID: "1", + Name: "Half Lingxian", + Category: roomCategory, + GameCode: "1", + Showcase: true, + Sort: 1, + FullScreen: false, + ClientOrigin: "COMMON", + GameMode: "[3]", + }).Error; err != nil { + t.Fatalf("seed config: %v", err) + } + orientation := 1 + if err := db.Create(&model.SysGameListVendorExt{ + ID: 24, + GameListConfigID: 23, + SysOrigin: "LIKEI", + Profile: profileProd, + VendorType: vendorType, + VendorGameID: "1", + LaunchMode: launchModeH5, + PackageVersion: "11", + PackageURL: "https://example.test/full.html", + PreviewURL: "https://example.test/half.html", + Orientation: &orientation, + SafeHeight: 640, + BridgeSchemaVersion: "1.0", + ExtraJSON: `{"gameType":"LINGXIAN"}`, + Enabled: true, + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed vendor ext: %v", err) + } + + service := NewService(config.Config{HTTP: config.HTTPConfig{Timeout: time.Second}}, db, nil) + provider := NewAppProvider(service) + user := gameprovider.AuthUser{UserID: 1001, SysOrigin: "LIKEI", Token: "user-token"} + + list, err := provider.ListRoomGames(ctx, user, "room-1", roomCategory) + if err != nil { + t.Fatalf("ListRoomGames: %v", err) + } + if len(list.Items) != 1 { + t.Fatalf("items = %d, want 1", len(list.Items)) + } + item := list.Items[0] + if item.FullScreen { + t.Fatalf("fullScreen = true, want false") + } + if item.SafeHeight != 640 { + t.Fatalf("safeHeight = %d, want 640", item.SafeHeight) + } + if item.Orientation != 1 { + t.Fatalf("orientation = %d, want 1", item.Orientation) + } + if got := item.LaunchParams["screenMode"]; got != "half" { + t.Fatalf("screenMode = %v, want half", got) + } + + launch, err := provider.LaunchGame(ctx, user, gameprovider.LaunchRequest{RoomID: "room-1", GameID: "1"}, "") + if err != nil { + t.Fatalf("LaunchGame: %v", err) + } + if !strings.HasPrefix(launch.Entry.EntryURL, "https://example.test/half.html?") { + t.Fatalf("entryUrl = %q, want half url with query", launch.Entry.EntryURL) + } + if strings.Contains(launch.Entry.EntryURL, "full.html") { + t.Fatalf("entryUrl = %q, should not use full url", launch.Entry.EntryURL) + } + if launch.Entry.SafeHeight != 640 { + t.Fatalf("launch safeHeight = %d, want 640", launch.Entry.SafeHeight) + } + parsed, err := url.Parse(launch.Entry.EntryURL) + if err != nil { + t.Fatalf("parse entry url: %v", err) + } + query := parsed.Query() + if got := query.Get("uid"); got != "test-uid" { + t.Fatalf("query uid = %q, want test-uid", got) + } + if got := query.Get("token"); got != "test-token" { + t.Fatalf("query token = %q, want test-token", got) + } + if got := query.Get("lang"); got != defaultLaunchLang { + t.Fatalf("query lang = %q, want %s", got, defaultLaunchLang) + } + if got := query.Get("roomid"); got != "test-room" { + t.Fatalf("query roomid = %q, want test-room", got) + } + if got := query.Get("roomId"); got != "test-room" { + t.Fatalf("query roomId = %q, want test-room", got) + } + if got := launch.LaunchConfig.(map[string]any)["roomid"]; got != "test-room" { + t.Fatalf("launchConfig.roomid = %v, want test-room", got) + } + if got := launch.LaunchConfig.(map[string]any)["lang"]; got != defaultLaunchLang { + t.Fatalf("launchConfig.lang = %v, want %s", got, defaultLaunchLang) + } + if got := launch.RoomState.RoomID; got != "room-1" { + t.Fatalf("roomState.roomId = %q, want real room room-1", got) + } } diff --git a/internal/service/lingxian/config.go b/internal/service/lingxian/config.go index 9c00502..a85531f 100644 --- a/internal/service/lingxian/config.go +++ b/internal/service/lingxian/config.go @@ -108,6 +108,7 @@ func (s *Service) GetProviderConfig(ctx context.Context, sysOrigin, profile stri AppKey: runtimeCfg.AppKey, TestUID: runtimeCfg.TestUID, TestToken: runtimeCfg.TestToken, + TestRoomID: runtimeCfg.TestRoomID, UpdateTime: formatTime(row.UpdateTime), }, nil } @@ -137,6 +138,7 @@ func (s *Service) SaveProviderConfig(ctx context.Context, req SaveProviderConfig AppKey: appKey, TestUID: strings.TrimSpace(req.TestUID), TestToken: strings.TrimSpace(req.TestToken), + TestRoomID: strings.TrimSpace(req.TestRoomID), CreateTime: now, UpdateTime: now, } @@ -153,7 +155,7 @@ func (s *Service) SaveProviderConfig(ctx context.Context, req SaveProviderConfig if err := s.db.WithContext(ctx).Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "sys_origin"}, {Name: "profile"}}, DoUpdates: clause.AssignmentColumns([]string{ - "game_list_url", "app_key", "test_uid", "test_token", "update_time", + "game_list_url", "app_key", "test_uid", "test_token", "test_room_id", "update_time", }), }).Create(&row).Error; err != nil { return nil, err diff --git a/internal/service/lingxian/types.go b/internal/service/lingxian/types.go index 5913021..405d465 100644 --- a/internal/service/lingxian/types.go +++ b/internal/service/lingxian/types.go @@ -14,13 +14,14 @@ import ( ) const ( - vendorType = "LINGXIAN" - profileProd = "PROD" - profileTest = "TEST" - launchModeH5 = "H5_REMOTE" - catalogEnabled = "ENABLED" - roomCategory = "CHAT_ROOM" - defaultListURL = "https://sg-test.leadercc.com/yumichat_games/test_game_list.json" + vendorType = "LINGXIAN" + profileProd = "PROD" + profileTest = "TEST" + launchModeH5 = "H5_REMOTE" + catalogEnabled = "ENABLED" + roomCategory = "CHAT_ROOM" + defaultLaunchLang = "en-US" + defaultListURL = "https://sg-test.leadercc.com/yumichat_games/test_game_list.json" ) type dbHandle interface { @@ -52,6 +53,7 @@ type runtimeConfig struct { AppKey string TestUID string TestToken string + TestRoomID string } type ProviderConfigItem struct { @@ -64,6 +66,7 @@ type ProviderConfigItem struct { AppKey string `json:"appKey"` TestUID string `json:"testUid"` TestToken string `json:"testToken"` + TestRoomID string `json:"testRoomId"` UpdateTime string `json:"updateTime"` } @@ -74,6 +77,7 @@ type SaveProviderConfigRequest struct { AppKey string `json:"appKey"` TestUID string `json:"testUid"` TestToken string `json:"testToken"` + TestRoomID string `json:"testRoomId"` } type ActivateProviderProfileRequest struct { @@ -82,10 +86,11 @@ type ActivateProviderProfileRequest struct { } type SyncCatalogRequest struct { - SysOrigin string `json:"sysOrigin"` - Profile string `json:"profile"` - VendorGameIDs []string `json:"vendorGameIds"` - Force bool `json:"force"` + SysOrigin string `json:"sysOrigin"` + Profile string `json:"profile"` + VendorGameIDs []string `json:"vendorGameIds"` + Force bool `json:"force"` + DefaultShowcase *bool `json:"defaultShowcase"` } type SyncCatalogResponse struct { @@ -153,6 +158,8 @@ type gameRow struct { ExtPackageVersion string ExtPreviewURL string PackageURL string + ExtOrientation *int + ExtSafeHeight int ExtExtraJSON string CatalogName string CatalogCover string @@ -223,5 +230,6 @@ func configToRuntime(base runtimeConfig, row model.LingxianProviderConfig) runti base.AppKey = defaultIfBlank(row.AppKey, base.AppKey) base.TestUID = strings.TrimSpace(row.TestUID) base.TestToken = strings.TrimSpace(row.TestToken) + base.TestRoomID = strings.TrimSpace(row.TestRoomID) return base } diff --git a/internal/service/weekstar/page.go b/internal/service/weekstar/page.go index c520427..7339e96 100644 --- a/internal/service/weekstar/page.go +++ b/internal/service/weekstar/page.go @@ -107,6 +107,52 @@ func (s *WeekStarService) GetPublicPage(ctx context.Context, sysOrigin string) ( }, nil } +// CurrentRanking 返回后台查看用的当前实时榜,实时榜只在当前有生效配置时读取。 +func (s *WeekStarService) CurrentRanking(ctx context.Context, sysOrigin string, limit int) (*WeekStarCurrentRankResponse, error) { + sysOrigin = s.normalizeSysOrigin(sysOrigin) + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + + now := time.Now().In(s.location) + configRow, giftRows, _, err := s.loadActiveConfigBundle(ctx, sysOrigin, now) + if err != nil { + return nil, err + } + if configRow == nil { + return &WeekStarCurrentRankResponse{ + SysOrigin: sysOrigin, + ActivityStatus: weekStarActivityStatusDisabled, + Timezone: s.cfg.WeekStar.Timezone, + GiftConfigs: []WeekStarGiftConfigPayload{}, + Records: []WeekStarRankingUserView{}, + }, nil + } + + cycleStart, cycleEnd := s.cycleBounds(now) + periodStart := maxTime(cycleStart, s.fromStorageWallClock(configRow.StartAt)) + periodEnd := minTime(cycleEnd, s.fromStorageWallClock(configRow.EndAt)) + records, err := s.loadLiveRanking(ctx, configRow.ID, s.cycleKey(cycleStart), int64(limit)) + if err != nil { + return nil, err + } + + return &WeekStarCurrentRankResponse{ + ConfigID: configRow.ID, + SysOrigin: sysOrigin, + ActivityStatus: weekStarActivityStatusOngoing, + CycleKey: s.cycleKey(cycleStart), + PeriodStartAt: formatDateTime(periodStart), + PeriodEndAt: formatDateTime(periodEnd), + Timezone: configRow.Timezone, + GiftConfigs: buildGiftPayloads(giftRows), + Records: records, + }, nil +} + // PageSnapshots 按系统或配置分页查询历史快照,支持跨多周活动查看冻结榜单。 func (s *WeekStarService) PageSnapshots(ctx context.Context, sysOrigin string, configID int64, cycleKey string, cursor, limit int) (map[string]any, error) { page, size := normalizePage(cursor, limit) diff --git a/internal/service/weekstar/page_test.go b/internal/service/weekstar/page_test.go new file mode 100644 index 0000000..6e5aa28 --- /dev/null +++ b/internal/service/weekstar/page_test.go @@ -0,0 +1,102 @@ +package weekstar + +import ( + "context" + "testing" + "time" + + "chatapp3-golang/internal/model" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" +) + +func TestCurrentRankingReturnsLiveRank(t *testing.T) { + ctx := context.Background() + service, db := newTestWeekStarService(t) + if err := db.AutoMigrate(&model.UserBaseInfo{}); err != nil { + t.Fatalf("auto migrate user_base_info: %v", err) + } + redisServer := miniredis.RunT(t) + redisClient := redis.NewClient(&redis.Options{Addr: redisServer.Addr()}) + defer redisClient.Close() + service.repo.Redis = redisClient + + now := time.Now().In(service.location) + cycleStart, cycleEnd := service.cycleBounds(now) + configRow := model.WeekStarActivityConfig{ + ID: 101, + SysOrigin: "LIKEI", + Enabled: true, + StartAt: service.toStorageWallClock(cycleStart.Add(-time.Hour)), + EndAt: service.toStorageWallClock(cycleEnd.Add(time.Hour)), + Timezone: "Asia/Riyadh", + CreateTime: now, + UpdateTime: now, + } + if err := db.Create(&configRow).Error; err != nil { + t.Fatalf("create config: %v", err) + } + if err := db.Create(&model.WeekStarActivityGiftConfig{ + ConfigID: configRow.ID, + GiftID: 2001, + GiftName: "Gift A", + GiftCandy: 100, + Sort: 1, + }).Error; err != nil { + t.Fatalf("create gift config: %v", err) + } + if err := db.Create([]model.UserBaseInfo{ + {ID: 3001, Account: "3001", UserNickname: "Alice", CountryCode: "SA", CountryName: "Saudi Arabia"}, + {ID: 3002, Account: "3002", UserNickname: "Bob", CountryCode: "AE", CountryName: "UAE"}, + }).Error; err != nil { + t.Fatalf("create users: %v", err) + } + if err := redisClient.ZAdd(ctx, service.rankRedisKey(configRow.ID, service.cycleKey(cycleStart)), + redis.Z{Member: "3001", Score: 250}, + redis.Z{Member: "3002", Score: 500}, + ).Err(); err != nil { + t.Fatalf("zadd rank: %v", err) + } + + resp, err := service.CurrentRanking(ctx, "likei", 20) + if err != nil { + t.Fatalf("CurrentRanking() error = %v", err) + } + if resp.ActivityStatus != weekStarActivityStatusOngoing { + t.Fatalf("ActivityStatus = %s, want %s", resp.ActivityStatus, weekStarActivityStatusOngoing) + } + if resp.ConfigID != configRow.ID { + t.Fatalf("ConfigID = %d, want %d", resp.ConfigID, configRow.ID) + } + if resp.CycleKey != service.cycleKey(cycleStart) { + t.Fatalf("CycleKey = %s, want %s", resp.CycleKey, service.cycleKey(cycleStart)) + } + if len(resp.GiftConfigs) != 1 || resp.GiftConfigs[0].GiftID != 2001 { + t.Fatalf("GiftConfigs = %#v", resp.GiftConfigs) + } + if len(resp.Records) != 2 { + t.Fatalf("Records length = %d, want 2", len(resp.Records)) + } + if resp.Records[0].UserID != 3002 || resp.Records[0].Rank != 1 || resp.Records[0].ScoreGold != 500 { + t.Fatalf("top record = %#v", resp.Records[0]) + } + if resp.Records[1].UserID != 3001 || resp.Records[1].Rank != 2 || resp.Records[1].ScoreGold != 250 { + t.Fatalf("second record = %#v", resp.Records[1]) + } +} + +func TestCurrentRankingReturnsDisabledWhenNoActiveConfig(t *testing.T) { + service, _ := newTestWeekStarService(t) + + resp, err := service.CurrentRanking(context.Background(), "LIKEI", 20) + if err != nil { + t.Fatalf("CurrentRanking() error = %v", err) + } + if resp.ActivityStatus != weekStarActivityStatusDisabled { + t.Fatalf("ActivityStatus = %s, want %s", resp.ActivityStatus, weekStarActivityStatusDisabled) + } + if len(resp.Records) != 0 { + t.Fatalf("Records length = %d, want 0", len(resp.Records)) + } +} diff --git a/internal/service/weekstar/types.go b/internal/service/weekstar/types.go index 437ed40..5696017 100644 --- a/internal/service/weekstar/types.go +++ b/internal/service/weekstar/types.go @@ -197,6 +197,19 @@ type WeekStarPageResponse struct { RewardPreview []WeekStarRewardConfigPayload `json:"rewardPreview"` } +// WeekStarCurrentRankResponse 是后台当前实时榜展示对象。 +type WeekStarCurrentRankResponse struct { + ConfigID int64 `json:"configId,string"` + SysOrigin string `json:"sysOrigin"` + ActivityStatus string `json:"activityStatus"` + CycleKey string `json:"cycleKey"` + PeriodStartAt string `json:"periodStartAt"` + PeriodEndAt string `json:"periodEndAt"` + Timezone string `json:"timezone"` + GiftConfigs []WeekStarGiftConfigPayload `json:"giftConfigs"` + Records []WeekStarRankingUserView `json:"records"` +} + // WeekStarSnapshotView 是后台快照列表的展示对象。 type WeekStarSnapshotView struct { ID int64 `json:"id,string"` diff --git a/migrations/014_lingxian_provider_config.sql b/migrations/014_lingxian_provider_config.sql index 5fa8720..9855b0c 100644 --- a/migrations/014_lingxian_provider_config.sql +++ b/migrations/014_lingxian_provider_config.sql @@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS lingxian_provider_config ( app_key VARCHAR(255) NOT NULL DEFAULT '', test_uid VARCHAR(64) DEFAULT NULL, test_token VARCHAR(1024) DEFAULT NULL, + test_room_id VARCHAR(64) DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_lingxian_provider_sys_origin_profile (sys_origin, profile), diff --git a/migrations/018_lingxian_test_room_id.sql b/migrations/018_lingxian_test_room_id.sql new file mode 100644 index 0000000..d42cb69 --- /dev/null +++ b/migrations/018_lingxian_test_room_id.sql @@ -0,0 +1,17 @@ +SET @column_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'lingxian_provider_config' + AND COLUMN_NAME = 'test_room_id' +); + +SET @ddl := IF( + @column_exists = 0, + 'ALTER TABLE lingxian_provider_config ADD COLUMN test_room_id VARCHAR(64) DEFAULT NULL AFTER test_token', + 'SELECT 1' +); + +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt;