灵仙游戏和百顺的兼容

This commit is contained in:
hy001 2026-04-28 18:05:13 +08:00
parent 24525bb4f8
commit a9ab2b8589
26 changed files with 889 additions and 51 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 是目录同步结果统计。

View File

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

View File

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

View File

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

View File

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

View File

@ -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: "百顺"},

View File

@ -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 != "<nil>" {
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[:])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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