增加邀请活动
This commit is contained in:
hy001 2026-04-24 17:11:13 +08:00
parent 42b3a4b595
commit c4f75b4846
16 changed files with 1490 additions and 7 deletions

View File

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

View File

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

View File

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

View File

@ -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"` // 会话主键

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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[:])
}

View File

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

View File

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

View File

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

View File

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

View File

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