diff --git a/cmd/api/main.go b/cmd/api/main.go index 3051220..fee4d7f 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -7,6 +7,7 @@ import ( "chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/invite" + "chatapp3-golang/internal/service/lingxian" "chatapp3-golang/internal/service/luckygift" "chatapp3-golang/internal/service/registerreward" "chatapp3-golang/internal/service/signinreward" @@ -32,6 +33,7 @@ func main() { inviteService := invite.NewInviteService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) baishunService := baishun.NewBaishunService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) + lingxianService := lingxian.NewService(app.Config, app.Repository.DB, app.Repository.Redis) gameOpenService := gameopen.NewGameOpenService(app.Config, app.Repository.DB, &app.Gateways) luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet) registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) @@ -46,10 +48,12 @@ func main() { gameProviders := gameprovider.NewRegistry( baishun.NewAppProvider(baishunService), + lingxian.NewAppProvider(lingxianService), ) engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{ Invite: inviteService, Baishun: baishunService, + Lingxian: lingxianService, GameOpen: gameOpenService, GameProviders: gameProviders, LuckyGift: luckyGiftService, diff --git a/internal/config/config.go b/internal/config/config.go index 479d8ed..7fd2aa2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ type Config struct { Invite InviteConfig WeekStar WeekStarConfig Baishun BaishunConfig + Lingxian LingxianConfig GameOpen GameOpenConfig LuckyGift LuckyGiftConfig } @@ -104,6 +105,12 @@ type BaishunConfig struct { SSTokenTTLSeconds int } +// LingxianConfig 保存灵仙模块配置。 +type LingxianConfig struct { + GameListURL string + AppKey string +} + // GameOpenConfig 保存统一三方游戏回调配置。 type GameOpenConfig struct { PublicBaseURL string @@ -197,6 +204,10 @@ func Load() Config { LaunchCodeTTLSeconds: getEnvIntAny([]string{"CHATAPP_BAISHUN_LAUNCH_CODE_TTL_SECONDS", "BAISHUN_LAUNCH_CODE_TTL_SECONDS"}, 300), SSTokenTTLSeconds: getEnvIntAny([]string{"CHATAPP_BAISHUN_SS_TOKEN_TTL_SECONDS", "BAISHUN_SS_TOKEN_TTL_SECONDS"}, 86400), }, + 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"}, ""), + }, GameOpen: GameOpenConfig{ PublicBaseURL: getEnvAny([]string{"CHATAPP_GAME_OPEN_PUBLIC_BASE_URL", "GAME_OPEN_PUBLIC_BASE_URL"}, ""), AppKey: getEnvAny([]string{"CHATAPP_GAME_OPEN_APP_KEY", "GAME_OPEN_APP_KEY"}, "game-open-test-key"), diff --git a/internal/integration/java.go b/internal/integration/java.go index df65ea3..2214d19 100644 --- a/internal/integration/java.go +++ b/internal/integration/java.go @@ -295,18 +295,51 @@ func (c *Client) GetDeviceFingerprint(ctx context.Context, userID int64) (string func (c *Client) EnsureInviteCode(ctx context.Context, authorization string) (InviteCode, error) { endpoint := c.cfg.Java.AppBaseURL + "/activity/invite/user/my/invite/code" - headers := http.Header{} + headers := javaAppHeadersFromAuthorization(authorization) if authorization != "" { headers.Set("Authorization", authorization) } - var resp InviteCode + var resp resultResponse[InviteCode] if err := c.getJSON(ctx, endpoint, headers, &resp); err != nil { return InviteCode{}, err } - if strings.TrimSpace(resp.InviteCode) == "" { + if strings.TrimSpace(resp.Body.InviteCode) == "" { return InviteCode{}, fmt.Errorf("invite code response is empty") } - return resp, nil + return resp.Body, nil +} + +func javaAppHeadersFromAuthorization(authorization string) http.Header { + headers := http.Header{} + token := trimBearerToken(authorization) + sysOrigin := "LIKEI" + var userID int64 + if credential, err := parseUserCredentialToken(token); err == nil { + userID = int64(credential.UserID) + if strings.TrimSpace(credential.SysOrigin) != "" { + sysOrigin = strings.TrimSpace(credential.SysOrigin) + } + } + + headers.Set("Req-Sys-Origin", "origin="+sysOrigin+";originChild="+sysOrigin) + headers.Set("Req-Client", "Android") + headers.Set("Req-App-Intel", "version=1.0.0;build=100;channel=local;model=go-service;sysVersion=go") + headers.Set("Req-Zone", "Asia/Shanghai") + headers.Set("Req-Imei", "go-service") + if userID > 0 { + headers.Set("Req-Inner-Internal", fmt.Sprintf("time=%d;uid=%d;", time.Now().UnixMilli(), userID)) + } else { + headers.Set("Req-Inner-Internal", fmt.Sprintf("time=%d;", time.Now().UnixMilli())) + } + return headers +} + +func trimBearerToken(authorization string) string { + token := strings.TrimSpace(authorization) + if len(token) >= 7 && strings.EqualFold(token[:7], "Bearer ") { + return strings.TrimSpace(token[7:]) + } + return token } func (c *Client) GrantGold(ctx context.Context, req GrantGoldRequest) error { diff --git a/internal/model/baishun_models.go b/internal/model/baishun_models.go index decb4c7..52361f4 100644 --- a/internal/model/baishun_models.go +++ b/internal/model/baishun_models.go @@ -102,6 +102,44 @@ type BaishunGameCatalog struct { // TableName 返回百顺目录表名。 func (BaishunGameCatalog) TableName() string { return "baishun_game_catalog" } +// LingxianProviderConfig 保存每个系统的灵仙接入配置。 +type LingxianProviderConfig struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_lingxian_provider_sys_origin_profile,priority:1;index:idx_lingxian_provider_active,priority:1"` + Profile string `gorm:"column:profile;size:32;default:PROD;uniqueIndex:uk_lingxian_provider_sys_origin_profile,priority:2"` + Active bool `gorm:"column:active;index:idx_lingxian_provider_active,priority:2"` + GameListURL string `gorm:"column:game_list_url;size:1024"` + AppKey string `gorm:"column:app_key;size:255"` + TestUID string `gorm:"column:test_uid;size:64"` + TestToken string `gorm:"column:test_token;size:1024"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time;index:idx_lingxian_provider_active,priority:3"` +} + +// TableName 返回灵仙配置表名。 +func (LingxianProviderConfig) TableName() string { return "lingxian_provider_config" } + +// LingxianGameCatalog 是灵仙平台同步到本地的游戏目录。 +type LingxianGameCatalog struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_lingxian_catalog_sys_profile_vendor_game,priority:1;index:idx_lingxian_catalog_profile_internal_game,priority:1"` + Profile string `gorm:"column:profile;size:32;default:PROD;uniqueIndex:uk_lingxian_catalog_sys_profile_vendor_game,priority:2;index:idx_lingxian_catalog_profile_internal_game,priority:2"` + InternalGameID string `gorm:"column:internal_game_id;size:64;index:idx_lingxian_catalog_profile_internal_game,priority:3"` + VendorGameID string `gorm:"column:vendor_game_id;size:64;uniqueIndex:uk_lingxian_catalog_sys_profile_vendor_game,priority:3"` + Name string `gorm:"column:name;size:128"` + Cover string `gorm:"column:cover;size:1024"` + PreviewURL string `gorm:"column:preview_url;size:1024"` + DownloadURL string `gorm:"column:download_url;size:1024"` + PackageVersion string `gorm:"column:package_version;size:32"` + RawJSON string `gorm:"column:raw_json;type:longtext"` + Status string `gorm:"column:status;size:32;index:idx_lingxian_catalog_profile_internal_game,priority:4"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +// TableName 返回灵仙目录表名。 +func (LingxianGameCatalog) TableName() string { return "lingxian_game_catalog" } + // BaishunLaunchSession 保存一次房间内游戏启动会话。 type BaishunLaunchSession struct { ID int64 `gorm:"column:id;primaryKey"` // 会话主键 diff --git a/internal/router/invite_routes.go b/internal/router/invite_routes.go index 71769e6..920f924 100644 --- a/internal/router/invite_routes.go +++ b/internal/router/invite_routes.go @@ -38,6 +38,19 @@ func registerInviteRoutes(engine *gin.Engine, javaClient authGateway, inviteServ } writeOK(c, resp) }) + internalGroup.POST("/register-bind-code", func(c *gin.Context) { + var req invite.RegisterBindCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, invite.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := inviteService.BindRegisteredCode(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) appGroup := engine.Group("/app/h5/invite-campaign") appGroup.Use(authMiddleware(javaClient)) diff --git a/internal/router/lingxian_routes.go b/internal/router/lingxian_routes.go new file mode 100644 index 0000000..887ca64 --- /dev/null +++ b/internal/router/lingxian_routes.go @@ -0,0 +1,110 @@ +package router + +import ( + "net/http" + "strconv" + "strings" + + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/service/lingxian" + + "github.com/gin-gonic/gin" +) + +func registerLingxianRoutes(engine *gin.Engine, javaClient authGateway, service *lingxian.Service) { + if service == nil { + return + } + consoleGroup := engine.Group("/operate/lingxian-game") + consoleGroup.Use(consoleAuthMiddleware(javaClient)) + consoleGroup.GET("/config", func(c *gin.Context) { + resp, err := service.GetProviderConfig(c.Request.Context(), c.Query("sysOrigin"), c.Query("profile")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + consoleGroup.POST("/config", func(c *gin.Context) { + var req lingxian.SaveProviderConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.SaveProviderConfig(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + consoleGroup.POST("/config/activate", func(c *gin.Context) { + var req lingxian.ActivateProviderProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.ActivateProviderProfile(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + consoleGroup.GET("/catalog/page", func(c *gin.Context) { + cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1"))) + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) + resp, err := service.PageCatalog( + c.Request.Context(), + c.Query("sysOrigin"), + c.Query("profile"), + c.Query("keyword"), + cursor, + limit, + ) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + consoleGroup.POST("/catalog/fetch", func(c *gin.Context) { + var req lingxian.SyncCatalogRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.FetchAdminCatalog(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + consoleGroup.POST("/import-catalog", func(c *gin.Context) { + var req lingxian.SyncCatalogRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.ImportCatalogGames(c.Request.Context(), req.SysOrigin, req.Profile, req.VendorGameIDs) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + consoleGroup.POST("/sync", func(c *gin.Context) { + var req lingxian.SyncCatalogRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.SyncAdminGames(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) +} diff --git a/internal/router/router.go b/internal/router/router.go index 6f7ff40..e617443 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -10,6 +10,7 @@ import ( "chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/invite" + "chatapp3-golang/internal/service/lingxian" "chatapp3-golang/internal/service/luckygift" "chatapp3-golang/internal/service/registerreward" "chatapp3-golang/internal/service/signinreward" @@ -22,6 +23,7 @@ import ( type Services struct { Invite *invite.InviteService Baishun *baishun.BaishunService + Lingxian *lingxian.Service GameOpen *gameopen.GameOpenService GameProviders *gameprovider.Registry LuckyGift *luckygift.LuckyGiftService @@ -48,6 +50,7 @@ func NewRouter( registerGameOpenRoutes(engine, cfg, services.GameOpen) registerGameProviderRoutes(engine, javaClient, services.GameProviders) registerBaishunRoutes(engine, cfg, javaClient, services.Baishun) + registerLingxianRoutes(engine, javaClient, services.Lingxian) registerLuckyGiftRoutes(engine, cfg, services.LuckyGift) return engine diff --git a/internal/service/invite/bind.go b/internal/service/invite/bind.go index 7282c7e..08e18a1 100644 --- a/internal/service/invite/bind.go +++ b/internal/service/invite/bind.go @@ -15,6 +15,25 @@ import ( // BindCode 处理邀请码绑定,并写入邀请关系、风控状态和双方即时奖励。 func (s *InviteService) BindCode(ctx context.Context, user AuthUser, clientIP string, req BindCodeRequest) (*BindCodeResponse, error) { + return s.bindCode(ctx, user, clientIP, req, "") +} + +// BindRegisteredCode 处理 Java 注册链路传入的邀请码,复用 H5 绑码口径。 +func (s *InviteService) BindRegisteredCode(ctx context.Context, req RegisterBindCodeRequest) (*BindCodeResponse, error) { + sysOrigin := normalizeSysOrigin(req.SysOrigin) + if sysOrigin == "" { + return nil, NewAppError(400, "invalid_sys_origin", "sysOrigin is required") + } + if req.InviteeUserID <= 0 { + return nil, NewAppError(400, "invalid_invitee_user_id", "inviteeUserId is required") + } + return s.bindCode(ctx, AuthUser{ + UserID: req.InviteeUserID, + SysOrigin: sysOrigin, + }, req.ClientIP, BindCodeRequest{InviteCode: req.InviteCode}, req.DeviceFingerprint) +} + +func (s *InviteService) bindCode(ctx context.Context, user AuthUser, clientIP string, req BindCodeRequest, deviceFingerprint string) (*BindCodeResponse, error) { inviteCode := strings.TrimSpace(req.InviteCode) if inviteCode == "" { return nil, NewAppError(400, "invalid_invite_code", "inviteCode is required") @@ -46,9 +65,11 @@ func (s *InviteService) BindCode(ctx context.Context, user AuthUser, clientIP st return nil, NewAppError(400, "sys_origin_mismatch", "invite code does not belong to the current system") } - fingerprint := "" - if value, fpErr := s.java.GetDeviceFingerprint(ctx, user.UserID); fpErr == nil { - fingerprint = strings.TrimSpace(value) + fingerprint := strings.TrimSpace(deviceFingerprint) + if fingerprint == "" { + if value, fpErr := s.java.GetDeviceFingerprint(ctx, user.UserID); fpErr == nil { + fingerprint = strings.TrimSpace(value) + } } // 绑码链路同时锁住用户以及可选的 IP/设备维度,避免并发穿透风控。 lockKeys := []string{ diff --git a/internal/service/invite/invite_test.go b/internal/service/invite/invite_test.go index 09e22d6..509563c 100644 --- a/internal/service/invite/invite_test.go +++ b/internal/service/invite/invite_test.go @@ -205,6 +205,67 @@ func TestClaimTaskUsesInviteCountForInviteCountTasks(t *testing.T) { } } +func TestBindRegisteredCodeUsesRequestDeviceFingerprint(t *testing.T) { + service, db, cleanup := newTestInviteService(t, nil) + defer cleanup() + + now := time.Now() + if err := db.Create(&model.InviteCampaignConfig{ + ID: 101, + SysOrigin: "LIKEI", + Enabled: true, + Timezone: "Asia/Shanghai", + AntiCheatSameIPOnce: false, + AntiCheatSameDeviceOnce: true, + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed config: %v", err) + } + if err := db.Create(&model.InviteCodeMapping{UserID: 9001, InviteCode: "INV9001", Account: "u9001"}).Error; err != nil { + t.Fatalf("seed invite code: %v", err) + } + if err := db.Create(&model.UserBaseInfo{ID: 9001, Account: "u9001", UserNickname: "Inviter", OriginSys: "LIKEI", CreateTime: now}).Error; err != nil { + t.Fatalf("seed inviter profile: %v", err) + } + + resp, err := service.BindRegisteredCode(context.Background(), RegisterBindCodeRequest{ + InviteCode: "INV9001", + SysOrigin: "LIKEI", + InviteeUserID: 9101, + ClientIP: "10.0.0.1", + DeviceFingerprint: "device-a", + }) + if err != nil { + t.Fatalf("BindRegisteredCode() error = %v", err) + } + if !resp.Eligible || resp.InviterUserID != 9001 { + t.Fatalf("BindRegisteredCode() response = %+v, want eligible inviter 9001", resp) + } + + var relation model.InviteCampaignRelation + if err := db.Where("invitee_user_id = ?", 9101).First(&relation).Error; err != nil { + t.Fatalf("load relation: %v", err) + } + if relation.DeviceFingerprint != "device-a" { + t.Fatalf("relation device fingerprint = %q, want device-a", relation.DeviceFingerprint) + } + + duplicateResp, err := service.BindRegisteredCode(context.Background(), RegisterBindCodeRequest{ + InviteCode: "INV9001", + SysOrigin: "LIKEI", + InviteeUserID: 9102, + ClientIP: "10.0.0.2", + DeviceFingerprint: "device-a", + }) + if err != nil { + t.Fatalf("BindRegisteredCode() duplicate device error = %v", err) + } + if duplicateResp.Eligible || duplicateResp.BlockReason != blockReasonDuplicateDevice { + t.Fatalf("duplicate response = %+v, want blocked duplicate device", duplicateResp) + } +} + func TestGetRankUsesLifetimeInviteAndRewardStats(t *testing.T) { service, db, cleanup := newTestInviteService(t, nil) defer cleanup() diff --git a/internal/service/invite/types.go b/internal/service/invite/types.go index fd59442..1462e60 100644 --- a/internal/service/invite/types.go +++ b/internal/service/invite/types.go @@ -136,6 +136,15 @@ type BindCodeRequest struct { InviteCode string `json:"inviteCode"` } +// RegisterBindCodeRequest 是 Java 注册成功后同步邀请活动关系的内部入参。 +type RegisterBindCodeRequest struct { + InviteCode string `json:"inviteCode"` + SysOrigin string `json:"sysOrigin"` + InviteeUserID int64 `json:"inviteeUserId"` + ClientIP string `json:"clientIp"` + DeviceFingerprint string `json:"deviceFingerprint"` +} + // BindCodeResponse 是绑定邀请码后的返回结果。 type BindCodeResponse struct { RelationID int64 `json:"relationId"` diff --git a/internal/service/lingxian/app_provider.go b/internal/service/lingxian/app_provider.go new file mode 100644 index 0000000..5631325 --- /dev/null +++ b/internal/service/lingxian/app_provider.go @@ -0,0 +1,255 @@ +package lingxian + +import ( + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/service/gameprovider" + "context" + "crypto/md5" + "encoding/hex" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "gorm.io/gorm" +) + +func (p *AppProvider) Key() string { + return vendorType +} + +func (p *AppProvider) DisplayName() string { + return "灵仙" +} + +func (p *AppProvider) SupportsLaunch(ctx context.Context, user gameprovider.AuthUser, req gameprovider.LaunchRequest) (bool, error) { + _, err := p.service.findGameRow(ctx, user.SysOrigin, req) + if err == nil { + return true, nil + } + var appErr *common.AppError + if errors.As(err, &appErr) && appErr.Code == "game_not_found" { + return false, nil + } + return false, err +} + +func (p *AppProvider) ListShortcutGames(ctx context.Context, user gameprovider.AuthUser, roomID string) ([]gameprovider.RoomGameListItem, error) { + resp, err := p.ListRoomGames(ctx, user, roomID, "") + if err != nil { + return nil, err + } + if len(resp.Items) > 5 { + resp.Items = resp.Items[:5] + } + return resp.Items, nil +} + +func (p *AppProvider) ListRoomGames(ctx context.Context, user gameprovider.AuthUser, roomID, category string) (*gameprovider.RoomGameListResponse, error) { + items, err := p.service.listRoomGames(ctx, user.SysOrigin, category) + if err != nil { + return nil, err + } + return &gameprovider.RoomGameListResponse{Items: items}, nil +} + +func (p *AppProvider) GetRoomState(ctx context.Context, user gameprovider.AuthUser, roomID string) (*gameprovider.RoomStateResponse, error) { + return &gameprovider.RoomStateResponse{RoomID: roomID, State: "IDLE", Provider: vendorType}, nil +} + +func (p *AppProvider) LaunchGame(ctx context.Context, user gameprovider.AuthUser, req gameprovider.LaunchRequest, clientIP string) (*gameprovider.LaunchResponse, error) { + row, err := p.service.findGameRow(ctx, user.SysOrigin, req) + if err != nil { + return nil, err + } + runtimeCfg, err := p.service.resolveRuntimeConfigForProfile(ctx, normalizeSysOrigin(user.SysOrigin), row.Profile) + if err != nil { + return nil, err + } + entryURL := buildLaunchURL(row.entryURL(), runtimeCfg, user, req, row.VendorGameID) + sessionID := fmt.Sprintf("lx_%d_%s", user.UserID, row.VendorGameID) + return &gameprovider.LaunchResponse{ + ID: row.ConfigID, + GameSessionID: sessionID, + Provider: vendorType, + GameID: row.InternalGameID, + ProviderGameID: row.VendorGameID, + Entry: gameprovider.LaunchEntry{ + LaunchMode: defaultIfBlank(row.LaunchMode, launchModeH5), + EntryURL: entryURL, + PreviewURL: row.previewURL(), + DownloadURL: row.entryURL(), + PackageVersion: row.packageVersion(), + }, + LaunchConfig: map[string]any{ + "uid": launchUID(runtimeCfg, user), + "token": launchToken(runtimeCfg, user), + "gameId": row.VendorGameID, + "roomId": req.RoomID, + "sign": launchSign(runtimeCfg, row.VendorGameID, launchUID(runtimeCfg, user), launchToken(runtimeCfg, user), req.RoomID), + }, + RoomState: gameprovider.RoomStateResponse{ + RoomID: req.RoomID, + State: "PLAYING", + Provider: vendorType, + GameSessionID: sessionID, + CurrentGameID: row.InternalGameID, + CurrentProviderGameID: row.VendorGameID, + CurrentGameName: row.name(), + CurrentGameCover: row.cover(), + HostUserID: user.UserID, + }, + }, nil +} + +func (p *AppProvider) CloseGame(ctx context.Context, user gameprovider.AuthUser, req gameprovider.CloseRequest) (*gameprovider.RoomStateResponse, error) { + return &gameprovider.RoomStateResponse{RoomID: req.RoomID, State: "IDLE", Provider: vendorType}, nil +} + +func (s *Service) listRoomGames(ctx context.Context, sysOrigin, category string) ([]gameprovider.RoomGameListItem, error) { + sysOrigin = normalizeSysOrigin(sysOrigin) + profile, err := s.activeProfile(ctx, sysOrigin) + if err != nil { + return nil, err + } + var rows []gameRow + query := s.baseGameQuery(ctx, sysOrigin, profile). + Where("cfg.sys_origin = ? AND cfg.is_showcase = 1 AND ext.vendor_type = ?", sysOrigin, vendorType) + if strings.TrimSpace(category) != "" && !strings.EqualFold(strings.TrimSpace(category), roomCategory) { + query = query.Where("cfg.category = ?", category) + } + if err := query.Order("cfg.sort DESC").Scan(&rows).Error; err != nil { + return nil, err + } + items := make([]gameprovider.RoomGameListItem, 0, len(rows)) + for _, row := range rows { + items = append(items, row.toListItem()) + } + return items, nil +} + +func (s *Service) findGameRow(ctx context.Context, sysOrigin string, req gameprovider.LaunchRequest) (gameRow, error) { + sysOrigin = normalizeSysOrigin(sysOrigin) + profile, err := s.activeProfile(ctx, sysOrigin) + if err != nil { + return gameRow{}, err + } + var row gameRow + query := s.baseGameQuery(ctx, sysOrigin, profile). + Where("cfg.sys_origin = ? AND ext.vendor_type = ?", sysOrigin, vendorType) + if req.ID > 0 { + query = query.Where("cfg.id = ?", req.ID) + } else { + query = query.Where("cfg.game_id = ? OR ext.vendor_game_id = ?", strings.TrimSpace(req.GameID), strings.TrimSpace(req.GameID)) + } + if err := query.Limit(1).Scan(&row).Error; err != nil { + return gameRow{}, err + } + if row.ConfigID > 0 { + return row, nil + } + return gameRow{}, common.NewAppError(http.StatusNotFound, "game_not_found", "lingxian game config not found") +} + +func (s *Service) baseGameQuery(ctx context.Context, sysOrigin, profile string) *gorm.DB { + return s.db.WithContext(ctx). + Table("sys_game_list_config AS cfg"). + Select(` + cfg.id AS config_id, + cfg.sys_origin AS sys_origin, + ext.profile AS profile, + cfg.game_id AS internal_game_id, + cfg.name AS name, + cfg.category AS category, + cfg.cover AS cover, + cfg.sort AS sort, + cfg.full_screen AS full_screen, + ext.vendor_game_id AS vendor_game_id, + ext.launch_mode AS launch_mode, + ext.package_version AS ext_package_version, + ext.preview_url AS ext_preview_url, + ext.package_url AS package_url, + ext.extra_json AS ext_extra_json, + cat.name AS catalog_name, + cat.cover AS catalog_cover, + cat.preview_url AS catalog_preview_url, + cat.download_url AS catalog_download_url, + cat.package_version AS catalog_package_version, + cat.status AS catalog_status + `). + Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1 AND ext.profile = ?", profile). + Joins("LEFT JOIN lingxian_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND cat.profile = ext.profile AND cat.vendor_game_id = ext.vendor_game_id") +} + +func (r gameRow) toListItem() gameprovider.RoomGameListItem { + return gameprovider.RoomGameListItem{ + ID: r.ConfigID, + GameID: r.InternalGameID, + GameType: vendorType, + Provider: vendorType, + ProviderGameID: r.VendorGameID, + Name: r.name(), + Cover: r.cover(), + Category: r.Category, + Sort: r.Sort, + LaunchMode: defaultIfBlank(r.LaunchMode, launchModeH5), + FullScreen: r.FullScreen, + GameMode: 3, + PackageVersion: r.packageVersion(), + Status: defaultIfBlank(r.CatalogStatus, catalogEnabled), + LaunchParams: map[string]any{ + "gameType": vendorType, + }, + } +} + +func (r gameRow) name() string { + return defaultIfBlank(r.Name, r.CatalogName) +} + +func (r gameRow) cover() string { + return defaultIfBlank(r.Cover, defaultIfBlank(r.CatalogCover, r.previewURL())) +} + +func (r gameRow) previewURL() string { + return defaultIfBlank(r.ExtPreviewURL, r.CatalogPreviewURL) +} + +func (r gameRow) entryURL() string { + return defaultIfBlank(r.PackageURL, defaultIfBlank(r.CatalogDownloadURL, r.ExtPreviewURL)) +} + +func (r gameRow) packageVersion() string { + return defaultIfBlank(r.ExtPackageVersion, r.CatalogPackageVersion) +} + +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 { + return strings.TrimSpace(entry) + } + uid := launchUID(cfg, user) + token := launchToken(cfg, user) + query := parsed.Query() + query.Set("uid", uid) + query.Set("token", token) + query.Set("gameId", gameID) + query.Set("roomId", req.RoomID) + query.Set("sign", launchSign(cfg, gameID, uid, token, req.RoomID)) + parsed.RawQuery = query.Encode() + return parsed.String() +} + +func launchUID(cfg runtimeConfig, user gameprovider.AuthUser) string { + return defaultIfBlank(cfg.TestUID, fmt.Sprintf("%d", user.UserID)) +} + +func launchToken(cfg runtimeConfig, user gameprovider.AuthUser) string { + return defaultIfBlank(cfg.TestToken, user.Token) +} + +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 new file mode 100644 index 0000000..b0f2ad2 --- /dev/null +++ b/internal/service/lingxian/catalog.go @@ -0,0 +1,391 @@ +package lingxian + +import ( + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func (s *Service) SyncCatalog(ctx context.Context, req SyncCatalogRequest) (*SyncCatalogResponse, error) { + sysOrigin := normalizeSysOrigin(req.SysOrigin) + profile, err := s.requestProfile(ctx, sysOrigin, req.Profile) + if err != nil { + return nil, err + } + runtimeCfg, err := s.requireRuntimeConfigForProfile(ctx, sysOrigin, profile) + if err != nil { + return nil, err + } + games, err := s.fetchPlatformGames(ctx, runtimeCfg) + if err != nil { + return nil, err + } + filter := buildVendorGameIDFilter(req.VendorGameIDs) + resp := &SyncCatalogResponse{} + for _, game := range games { + vendorGameID := strings.TrimSpace(game.GameID) + if vendorGameID == "" { + resp.Skipped++ + continue + } + if len(filter) > 0 { + if _, ok := filter[vendorGameID]; !ok { + resp.Skipped++ + continue + } + } + id, err := utils.NextID() + if err != nil { + return nil, err + } + now := time.Now() + record := model.LingxianGameCatalog{ + ID: id, + SysOrigin: sysOrigin, + Profile: profile, + InternalGameID: internalGameID(vendorGameID), + VendorGameID: vendorGameID, + Name: catalogName(game), + Cover: "", + PreviewURL: strings.TrimSpace(game.HalfURL), + DownloadURL: defaultIfBlank(game.FullURL, game.HalfURL), + PackageVersion: fmt.Sprintf("%d", game.Version), + RawJSON: utils.MustJSONString(game, ""), + Status: catalogEnabled, + CreateTime: now, + UpdateTime: now, + } + if err := s.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "sys_origin"}, {Name: "profile"}, {Name: "vendor_game_id"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "internal_game_id", "name", "cover", "preview_url", "download_url", + "package_version", "raw_json", "status", "update_time", + }), + }).Create(&record).Error; err != nil { + return nil, err + } + if req.Force { + resp.Updated++ + } else { + resp.Inserted++ + } + } + return resp, nil +} + +func (s *Service) fetchPlatformGames(ctx context.Context, runtimeCfg runtimeConfig) ([]platformGame, error) { + endpoint := strings.TrimSpace(runtimeCfg.GameListURL) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= http.StatusBadRequest { + return nil, fmt.Errorf("lingxian platform failed: url=%s status=%d body=%s", endpoint, resp.StatusCode, shortBody(raw)) + } + var games []platformGame + if err := json.Unmarshal(raw, &games); err != nil { + return nil, fmt.Errorf("lingxian platform returned invalid json: url=%s error=%v body=%s", endpoint, err, shortBody(raw)) + } + return games, nil +} + +func (s *Service) PageCatalog(ctx context.Context, sysOrigin, profile, keyword string, cursor, limit int) (*CatalogPageResponse, error) { + sysOrigin = normalizeSysOrigin(sysOrigin) + profile, err := s.requestProfile(ctx, sysOrigin, profile) + if err != nil { + return nil, err + } + cursor = normalizePageCursor(cursor) + limit = normalizePageLimit(limit) + + countQuery := s.buildCatalogQuery(ctx, sysOrigin, profile, keyword) + var total int64 + if err := countQuery.Count(&total).Error; err != nil { + return nil, err + } + + type row struct { + VendorGameID string + Profile string + InternalGameID string + Name string + Cover string + PreviewURL string + DownloadURL string + PackageVersion string + Status string + AddedConfigID *int64 + Showcase *bool + UpdateTime string + } + var rows []row + if err := s.buildCatalogQuery(ctx, sysOrigin, profile, keyword). + Select(` + cat.vendor_game_id AS vendor_game_id, + cat.profile AS profile, + cat.internal_game_id AS internal_game_id, + cat.name AS name, + cat.cover AS cover, + cat.preview_url AS preview_url, + cat.download_url AS download_url, + cat.package_version AS package_version, + cat.status AS status, + cfg.id AS added_config_id, + cfg.is_showcase AS showcase, + DATE_FORMAT(cat.update_time, '%Y-%m-%d %H:%i:%s') AS update_time + `). + Order("cat.vendor_game_id ASC"). + Limit(limit). + Offset((cursor - 1) * limit). + Scan(&rows).Error; err != nil { + return nil, err + } + + items := make([]CatalogItem, 0, len(rows)) + for _, row := range rows { + items = append(items, CatalogItem{ + VendorGameID: row.VendorGameID, + Profile: normalizeProfile(row.Profile), + GameID: row.InternalGameID, + Name: row.Name, + Cover: row.Cover, + PreviewURL: row.PreviewURL, + DownloadURL: row.DownloadURL, + PackageVersion: row.PackageVersion, + Status: row.Status, + Added: row.AddedConfigID != nil && *row.AddedConfigID > 0, + Showcase: row.Showcase != nil && *row.Showcase, + UpdateTime: row.UpdateTime, + }) + } + return &CatalogPageResponse{Cursor: cursor, Limit: limit, Records: items, Total: total}, nil +} + +func (s *Service) buildCatalogQuery(ctx context.Context, sysOrigin, profile, keyword string) *gorm.DB { + query := s.db.WithContext(ctx). + Table("lingxian_game_catalog AS cat"). + Joins(` + LEFT JOIN sys_game_list_vendor_ext ext + ON ext.sys_origin = cat.sys_origin + AND ext.profile = cat.profile + AND ext.vendor_type = ? + AND ext.vendor_game_id = cat.vendor_game_id + AND ext.enabled = 1 + `, vendorType). + Joins(` + LEFT JOIN sys_game_list_config cfg + ON cfg.id = ext.game_list_config_id + AND cfg.sys_origin = cat.sys_origin + AND cfg.game_origin = ? + `, vendorType). + Where("cat.sys_origin = ? AND cat.profile = ?", sysOrigin, profile) + keyword = strings.TrimSpace(keyword) + if keyword != "" { + like := "%" + keyword + "%" + query = query.Where("cat.name LIKE ? OR cat.internal_game_id LIKE ? OR cat.vendor_game_id LIKE ?", like, like, like) + } + return query +} + +func (s *Service) ImportCatalogGames(ctx context.Context, sysOrigin, profile string, vendorGameIDs []string) (*ImportCatalogResponse, error) { + sysOrigin = normalizeSysOrigin(sysOrigin) + profile, err := s.requestProfile(ctx, sysOrigin, profile) + if err != nil { + return nil, err + } + tx := s.db.WithContext(ctx).Begin() + if tx.Error != nil { + return nil, tx.Error + } + resp, err := s.importCatalogGamesTx(tx, sysOrigin, profile, buildVendorGameIDFilter(vendorGameIDs)) + if err != nil { + tx.Rollback() + return nil, err + } + if err := tx.Commit().Error; err != nil { + return nil, err + } + s.clearJavaGameListCache(ctx) + return resp, nil +} + +func (s *Service) SyncAdminGames(ctx context.Context, req SyncCatalogRequest) (*SyncAdminCatalogResponse, error) { + syncResp, err := s.SyncCatalog(ctx, req) + if err != nil { + return nil, err + } + importResp, err := s.ImportCatalogGames(ctx, req.SysOrigin, req.Profile, req.VendorGameIDs) + if err != nil { + return nil, err + } + return &SyncAdminCatalogResponse{ + Inserted: syncResp.Inserted, + Updated: syncResp.Updated, + Skipped: syncResp.Skipped, + Existing: importResp.Existing, + Imported: importResp.Imported, + }, nil +} + +func (s *Service) importCatalogGamesTx(tx *gorm.DB, sysOrigin, profile string, filter map[string]struct{}) (*ImportCatalogResponse, error) { + query := tx.Where("sys_origin = ? AND profile = ? AND status = ?", sysOrigin, profile, catalogEnabled) + if len(filter) > 0 { + ids := make([]string, 0, len(filter)) + for id := range filter { + ids = append(ids, id) + } + sort.Strings(ids) + query = query.Where("vendor_game_id IN ?", ids) + } + + var catalogs []model.LingxianGameCatalog + if err := query.Order("vendor_game_id ASC").Find(&catalogs).Error; err != nil { + return nil, err + } + + resp := &ImportCatalogResponse{} + for _, catalog := range catalogs { + vendorGameID := strings.TrimSpace(catalog.VendorGameID) + if vendorGameID == "" { + continue + } + now := time.Now() + var ext model.SysGameListVendorExt + err := tx.Where("sys_origin = ? AND profile = ? AND vendor_type = ? AND vendor_game_id = ?", sysOrigin, profile, vendorType, vendorGameID).First(&ext).Error + if err != nil && err != gorm.ErrRecordNotFound { + return nil, err + } + if err == nil && ext.GameListConfigID > 0 { + var cfg model.SysGameListConfig + cfgErr := tx.Where("id = ? AND sys_origin = ? AND game_origin = ?", ext.GameListConfigID, sysOrigin, vendorType).First(&cfg).Error + if cfgErr == nil { + resp.Existing++ + continue + } + if cfgErr != gorm.ErrRecordNotFound { + return nil, cfgErr + } + } + + nextCfgID, idErr := utils.NextID() + if idErr != nil { + return nil, idErr + } + cfg := model.SysGameListConfig{ + ID: nextCfgID, + SysOrigin: sysOrigin, + GameOrigin: vendorType, + GameID: defaultIfBlank(catalog.InternalGameID, vendorGameID), + Name: catalog.Name, + Category: roomCategory, + GameCode: vendorGameID, + Cover: defaultIfBlank(catalog.Cover, catalog.PreviewURL), + Showcase: true, + Sort: parseSort(vendorGameID), + FullScreen: true, + ClientOrigin: "COMMON", + GameMode: "[3]", + } + if err := tx.Create(&cfg).Error; err != nil { + return nil, err + } + + nextID, idErr := utils.NextID() + if idErr != nil { + return nil, idErr + } + newExt := model.SysGameListVendorExt{ + ID: nextID, + GameListConfigID: cfg.ID, + SysOrigin: sysOrigin, + Profile: profile, + VendorType: vendorType, + VendorGameID: vendorGameID, + LaunchMode: launchModeH5, + PackageVersion: catalog.PackageVersion, + PackageURL: catalog.DownloadURL, + PreviewURL: catalog.PreviewURL, + BridgeSchemaVersion: "1.0", + ExtraJSON: `{"gameType":"LINGXIAN"}`, + Enabled: true, + CreateTime: now, + UpdateTime: now, + } + if err := tx.Create(&newExt).Error; err != nil { + return nil, err + } + resp.Imported++ + } + return resp, nil +} + +func buildVendorGameIDFilter(vendorGameIDs []string) map[string]struct{} { + filter := make(map[string]struct{}, len(vendorGameIDs)) + for _, id := range vendorGameIDs { + id = strings.TrimSpace(id) + if id != "" { + filter[id] = struct{}{} + } + } + return filter +} + +func normalizePageCursor(cursor int) int { + if cursor <= 0 { + return 1 + } + return cursor +} + +func normalizePageLimit(limit int) int { + if limit <= 0 { + return 20 + } + if limit > 200 { + return 200 + } + return limit +} + +func parseSort(value string) int64 { + var sortValue int64 + _, _ = fmt.Sscanf(strings.TrimSpace(value), "%d", &sortValue) + return sortValue +} + +func shortBody(raw []byte) string { + body := strings.TrimSpace(string(raw)) + const max = 512 + if len(body) <= max { + return body + } + return body[:max] + "..." +} + +func (s *Service) FetchAdminCatalog(ctx context.Context, req SyncCatalogRequest) (*SyncCatalogResponse, error) { + return s.SyncCatalog(ctx, req) +} + +func badRequest(code, message string) error { + return common.NewAppError(http.StatusBadRequest, code, message) +} diff --git a/internal/service/lingxian/catalog_test.go b/internal/service/lingxian/catalog_test.go new file mode 100644 index 0000000..5263726 --- /dev/null +++ b/internal/service/lingxian/catalog_test.go @@ -0,0 +1,71 @@ +package lingxian + +import ( + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/model" + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestSyncAdminGamesFetchesCatalogAndImportsUnifiedList(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) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"gameId":"1","name":"Fruit Party","title":"水果奇缘","ver":11,"full_url":"https://example.test/full.html","half_url":"https://example.test/half.html"}]`)) + })) + defer server.Close() + + now := time.Now() + if err := db.Create(&model.LingxianProviderConfig{ + ID: 1, + SysOrigin: "LIKEI", + Profile: profileProd, + Active: true, + GameListURL: server.URL, + AppKey: "test-key", + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed provider: %v", err) + } + + service := NewService(config.Config{HTTP: config.HTTPConfig{Timeout: time.Second}}, db, nil) + resp, err := service.SyncAdminGames(ctx, SyncCatalogRequest{SysOrigin: "LIKEI", Profile: profileProd}) + if err != nil { + t.Fatalf("SyncAdminGames: %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, "1").First(&cfg).Error; err != nil { + t.Fatalf("unified config missing: %v", err) + } + var ext model.SysGameListVendorExt + if err := db.Where("game_list_config_id = ? AND vendor_type = ? AND profile = ?", cfg.ID, vendorType, profileProd).First(&ext).Error; err != nil { + t.Fatalf("vendor ext missing: %v", err) + } + if ext.VendorGameID != "1" || ext.PackageURL != "https://example.test/full.html" { + t.Fatalf("vendor ext = %#v", ext) + } +} diff --git a/internal/service/lingxian/config.go b/internal/service/lingxian/config.go new file mode 100644 index 0000000..9c00502 --- /dev/null +++ b/internal/service/lingxian/config.go @@ -0,0 +1,203 @@ +package lingxian + +import ( + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + "context" + "net/http" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func (s *Service) defaultRuntimeConfig(sysOrigin string) runtimeConfig { + return runtimeConfig{ + SysOrigin: normalizeSysOrigin(sysOrigin), + Profile: profileProd, + GameListURL: defaultIfBlank(s.cfg.Lingxian.GameListURL, defaultListURL), + AppKey: strings.TrimSpace(s.cfg.Lingxian.AppKey), + } +} + +func (s *Service) activeProfile(ctx context.Context, sysOrigin string) (string, error) { + sysOrigin = normalizeSysOrigin(sysOrigin) + var row model.LingxianProviderConfig + err := s.db.WithContext(ctx). + Where("sys_origin = ? AND active = ?", sysOrigin, true). + Order("update_time DESC"). + Limit(1). + First(&row).Error + if err == nil { + return normalizeProfile(row.Profile), nil + } + if err == gorm.ErrRecordNotFound { + return profileProd, nil + } + return "", err +} + +func (s *Service) requestProfile(ctx context.Context, sysOrigin, profile string) (string, error) { + if strings.TrimSpace(profile) != "" { + return normalizeProfile(profile), nil + } + return s.activeProfile(ctx, sysOrigin) +} + +func (s *Service) resolveRuntimeConfigForProfile(ctx context.Context, sysOrigin, profile string) (runtimeConfig, error) { + sysOrigin = normalizeSysOrigin(sysOrigin) + profile = normalizeProfile(profile) + base := s.defaultRuntimeConfig(sysOrigin) + base.Profile = profile + + var row model.LingxianProviderConfig + err := s.db.WithContext(ctx). + Where("sys_origin = ? AND profile = ?", sysOrigin, profile). + Limit(1). + First(&row).Error + if err == nil { + return configToRuntime(base, row), nil + } + if err == gorm.ErrRecordNotFound { + return base, nil + } + return runtimeConfig{}, err +} + +func (s *Service) requireRuntimeConfigForProfile(ctx context.Context, sysOrigin, profile string) (runtimeConfig, error) { + cfg, err := s.resolveRuntimeConfigForProfile(ctx, sysOrigin, profile) + if err != nil { + return runtimeConfig{}, err + } + if strings.TrimSpace(cfg.GameListURL) == "" || strings.TrimSpace(cfg.AppKey) == "" { + return runtimeConfig{}, common.NewAppError(http.StatusBadRequest, "lingxian_config_missing", "lingxian config is missing") + } + return cfg, nil +} + +func (s *Service) GetProviderConfig(ctx context.Context, sysOrigin, profile string) (*ProviderConfigItem, error) { + sysOrigin = normalizeSysOrigin(sysOrigin) + activeProfile, err := s.activeProfile(ctx, sysOrigin) + if err != nil { + return nil, err + } + profile, err = s.requestProfile(ctx, sysOrigin, profile) + if err != nil { + return nil, err + } + runtimeCfg, err := s.resolveRuntimeConfigForProfile(ctx, sysOrigin, profile) + if err != nil { + return nil, err + } + + var row model.LingxianProviderConfig + _ = s.db.WithContext(ctx). + Where("sys_origin = ? AND profile = ?", sysOrigin, profile). + Limit(1). + First(&row).Error + + return &ProviderConfigItem{ + ID: row.ID, + SysOrigin: runtimeCfg.SysOrigin, + Profile: runtimeCfg.Profile, + ActiveProfile: activeProfile, + Active: runtimeCfg.Profile == activeProfile, + GameListURL: defaultIfBlank(runtimeCfg.GameListURL, defaultListURL), + AppKey: runtimeCfg.AppKey, + TestUID: runtimeCfg.TestUID, + TestToken: runtimeCfg.TestToken, + UpdateTime: formatTime(row.UpdateTime), + }, nil +} + +func (s *Service) SaveProviderConfig(ctx context.Context, req SaveProviderConfigRequest) (*ProviderConfigItem, error) { + sysOrigin := normalizeSysOrigin(req.SysOrigin) + profile := normalizeProfile(req.Profile) + gameListURL := defaultIfBlank(req.GameListURL, defaultListURL) + appKey := strings.TrimSpace(req.AppKey) + if gameListURL == "" { + return nil, common.NewAppError(http.StatusBadRequest, "game_list_url_required", "gameListUrl is required") + } + if appKey == "" { + return nil, common.NewAppError(http.StatusBadRequest, "app_key_required", "appKey is required") + } + + id, err := utils.NextID() + if err != nil { + return nil, err + } + now := time.Now() + row := model.LingxianProviderConfig{ + ID: id, + SysOrigin: sysOrigin, + Profile: profile, + GameListURL: gameListURL, + AppKey: appKey, + TestUID: strings.TrimSpace(req.TestUID), + TestToken: strings.TrimSpace(req.TestToken), + CreateTime: now, + UpdateTime: now, + } + + var activeCount int64 + if err := s.db.WithContext(ctx). + Model(&model.LingxianProviderConfig{}). + Where("sys_origin = ? AND active = ?", sysOrigin, true). + Count(&activeCount).Error; err != nil { + return nil, err + } + row.Active = activeCount == 0 + + 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", + }), + }).Create(&row).Error; err != nil { + return nil, err + } + if row.Active { + s.clearJavaGameListCache(ctx) + } + return s.GetProviderConfig(ctx, sysOrigin, profile) +} + +func (s *Service) ActivateProviderProfile(ctx context.Context, req ActivateProviderProfileRequest) (*ProviderConfigItem, error) { + sysOrigin := normalizeSysOrigin(req.SysOrigin) + profile := normalizeProfile(req.Profile) + var row model.LingxianProviderConfig + if err := s.db.WithContext(ctx). + Where("sys_origin = ? AND profile = ?", sysOrigin, profile). + Limit(1). + First(&row).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, common.NewAppError(http.StatusBadRequest, "profile_config_missing", "profile config is missing") + } + return nil, err + } + + now := time.Now() + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.LingxianProviderConfig{}). + Where("sys_origin = ?", sysOrigin). + Updates(map[string]any{"active": false, "update_time": now}).Error; err != nil { + return err + } + return tx.Model(&model.LingxianProviderConfig{}). + Where("sys_origin = ? AND profile = ?", sysOrigin, profile). + Updates(map[string]any{"active": true, "update_time": now}).Error + }); err != nil { + return nil, err + } + s.clearJavaGameListCache(ctx) + return s.GetProviderConfig(ctx, sysOrigin, profile) +} + +func (s *Service) clearJavaGameListCache(ctx context.Context) { + if s.cache == nil { + return + } + _ = s.cache.Del(ctx, "GAME_LIST").Err() +} diff --git a/internal/service/lingxian/types.go b/internal/service/lingxian/types.go new file mode 100644 index 0000000..5913021 --- /dev/null +++ b/internal/service/lingxian/types.go @@ -0,0 +1,227 @@ +package lingxian + +import ( + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/service/gameprovider" + "context" + "net/http" + "strings" + "time" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +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" +) + +type dbHandle interface { + WithContext(ctx context.Context) *gorm.DB +} + +type Service struct { + cfg config.Config + db dbHandle + cache redis.Cmdable + httpClient *http.Client +} + +func NewService(cfg config.Config, db dbHandle, cache redis.Cmdable) *Service { + return &Service{ + cfg: cfg, + db: db, + cache: cache, + httpClient: &http.Client{ + Timeout: cfg.HTTP.Timeout, + }, + } +} + +type runtimeConfig struct { + SysOrigin string + Profile string + GameListURL string + AppKey string + TestUID string + TestToken string +} + +type ProviderConfigItem struct { + ID int64 `json:"id"` + SysOrigin string `json:"sysOrigin"` + Profile string `json:"profile"` + ActiveProfile string `json:"activeProfile"` + Active bool `json:"active"` + GameListURL string `json:"gameListUrl"` + AppKey string `json:"appKey"` + TestUID string `json:"testUid"` + TestToken string `json:"testToken"` + UpdateTime string `json:"updateTime"` +} + +type SaveProviderConfigRequest struct { + SysOrigin string `json:"sysOrigin"` + Profile string `json:"profile"` + GameListURL string `json:"gameListUrl"` + AppKey string `json:"appKey"` + TestUID string `json:"testUid"` + TestToken string `json:"testToken"` +} + +type ActivateProviderProfileRequest struct { + SysOrigin string `json:"sysOrigin"` + Profile string `json:"profile"` +} + +type SyncCatalogRequest struct { + SysOrigin string `json:"sysOrigin"` + Profile string `json:"profile"` + VendorGameIDs []string `json:"vendorGameIds"` + Force bool `json:"force"` +} + +type SyncCatalogResponse struct { + Inserted int `json:"inserted"` + Updated int `json:"updated"` + Skipped int `json:"skipped"` +} + +type ImportCatalogResponse struct { + Existing int `json:"existing"` + Imported int `json:"imported"` +} + +type SyncAdminCatalogResponse struct { + Inserted int `json:"inserted"` + Updated int `json:"updated"` + Skipped int `json:"skipped"` + Existing int `json:"existing"` + Imported int `json:"imported"` +} + +type CatalogItem struct { + VendorGameID string `json:"vendorGameId"` + Profile string `json:"profile"` + GameID string `json:"gameId"` + Name string `json:"name"` + Cover string `json:"cover"` + PreviewURL string `json:"previewUrl"` + DownloadURL string `json:"downloadUrl"` + PackageVersion string `json:"packageVersion"` + Status string `json:"status"` + Added bool `json:"added"` + Showcase bool `json:"showcase"` + UpdateTime string `json:"updateTime"` +} + +type CatalogPageResponse struct { + Cursor int `json:"cursor"` + Limit int `json:"limit"` + Records []CatalogItem `json:"records"` + Total int64 `json:"total"` +} + +type platformGame struct { + GameID string `json:"gameId"` + Name string `json:"name"` + Title string `json:"title"` + Version int `json:"ver"` + FullURL string `json:"full_url"` + HalfURL string `json:"half_url"` +} + +type gameRow struct { + ConfigID int64 + SysOrigin string + Profile string + InternalGameID string + Name string + Category string + Cover string + Sort int64 + FullScreen bool + VendorGameID string + LaunchMode string + ExtPackageVersion string + ExtPreviewURL string + PackageURL string + ExtExtraJSON string + CatalogName string + CatalogCover string + CatalogPreviewURL string + CatalogDownloadURL string + CatalogPackageVersion string + CatalogStatus string +} + +var _ gameprovider.Provider = (*AppProvider)(nil) + +type AppProvider struct { + service *Service +} + +func NewAppProvider(service *Service) *AppProvider { + if service == nil { + return nil + } + return &AppProvider{service: service} +} + +func normalizeSysOrigin(sysOrigin string) string { + sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin)) + if sysOrigin == "" { + return "LIKEI" + } + return sysOrigin +} + +func normalizeProfile(profile string) string { + switch strings.ToUpper(strings.TrimSpace(profile)) { + case "", "PROD", "PRODUCT", "PRODUCTION", "ONLINE", "OFFICIAL", "FORMAL": + return profileProd + case "TEST", "TESTING", "SANDBOX", "DEV": + return profileTest + default: + return strings.ToUpper(strings.TrimSpace(profile)) + } +} + +func formatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format("2006-01-02 15:04:05") +} + +func defaultIfBlank(value, fallback string) string { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + return strings.TrimSpace(fallback) +} + +func catalogName(game platformGame) string { + return defaultIfBlank(game.Title, game.Name) +} + +func internalGameID(vendorGameID string) string { + return strings.TrimSpace(vendorGameID) +} + +func configToRuntime(base runtimeConfig, row model.LingxianProviderConfig) runtimeConfig { + base.SysOrigin = normalizeSysOrigin(defaultIfBlank(row.SysOrigin, base.SysOrigin)) + base.Profile = normalizeProfile(defaultIfBlank(row.Profile, base.Profile)) + base.GameListURL = defaultIfBlank(row.GameListURL, base.GameListURL) + base.AppKey = defaultIfBlank(row.AppKey, base.AppKey) + base.TestUID = strings.TrimSpace(row.TestUID) + base.TestToken = strings.TrimSpace(row.TestToken) + return base +} diff --git a/migrations/014_lingxian_provider_config.sql b/migrations/014_lingxian_provider_config.sql new file mode 100644 index 0000000..5fa8720 --- /dev/null +++ b/migrations/014_lingxian_provider_config.sql @@ -0,0 +1,33 @@ +CREATE TABLE IF NOT EXISTS lingxian_provider_config ( + id BIGINT NOT NULL PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL, + profile VARCHAR(32) NOT NULL DEFAULT 'PROD', + active TINYINT(1) NOT NULL DEFAULT 1, + game_list_url VARCHAR(1024) NOT NULL DEFAULT '', + app_key VARCHAR(255) NOT NULL DEFAULT '', + test_uid VARCHAR(64) DEFAULT NULL, + test_token VARCHAR(1024) 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), + KEY idx_lingxian_provider_active (sys_origin, active, update_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS lingxian_game_catalog ( + id BIGINT NOT NULL PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL, + profile VARCHAR(32) NOT NULL DEFAULT 'PROD', + internal_game_id VARCHAR(64) NOT NULL, + vendor_game_id VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + cover VARCHAR(1024) DEFAULT NULL, + preview_url VARCHAR(1024) DEFAULT NULL, + download_url VARCHAR(1024) DEFAULT NULL, + package_version VARCHAR(32) DEFAULT NULL, + raw_json LONGTEXT DEFAULT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_lingxian_catalog_sys_profile_vendor_game (sys_origin, profile, vendor_game_id), + KEY idx_lingxian_catalog_profile_internal_game (sys_origin, profile, internal_game_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;