Compare commits

...

21 Commits
test ... main

Author SHA1 Message Date
hy001
ca72ed3f97 feat: 下线土耳其游戏列表财富等级门禁
土耳其区域/IP 用户不再按财富等级隐藏游戏列表;保留 Evaluate 的
区域查询给 provider SQL 做 regions 过滤,删除 IP 国家解析器。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 21:10:41 +08:00
hy001
a1114fbbf5 docs: 存档 game_lucky_gift_count 时间窗口索引迁移
dashboard-cdc-worker 的 24h 窗口查询缺 create_time 可用索引,
每 5 分钟 examined ~18 万行;(sys_origin, create_time) 索引
已于 2026-07-10 在生产在线应用。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:12:16 +08:00
hy001
523cfe961f optimize archive retry claim SQL 2026-07-09 23:02:09 +08:00
hy001
90ab87393e fix task center archive claim SQL 2026-07-08 20:03:52 +08:00
hy001
714c1c5c68 feat: allow manager center VIP4 and VIP5 gifts 2026-07-07 19:58:23 +08:00
hy001
4bb8613570 经理中心 2026-06-24 15:39:42 +08:00
hy001
53c6f361f9 经理中心配置 2026-06-22 17:34:52 +08:00
hy001
e370c3ca32 Hide game lists for iOS clients 2026-06-16 14:09:15 +08:00
hy001
995268fd70 经理中心 2026-06-12 19:37:04 +08:00
hy001
e086a518bc 修复后台 2026-06-10 19:26:15 +08:00
hy001
4219117721 增加热游游戏 2026-06-10 15:58:52 +08:00
hy001
a71dd0fbae test文件 2026-06-09 16:26:03 +08:00
hy001
81b1ff88bf 游戏列表 2026-06-09 16:18:19 +08:00
hy001
4e3c9d01aa 隐藏土耳其地区的游戏 2026-06-09 15:42:52 +08:00
hy001
cbe388b071 feat(wheel): split gift and decoration reward values 2026-06-03 21:02:14 +08:00
hy001
8147b5981f Hide provider game lists for test user 2026-06-02 20:49:58 +08:00
hy001
81cdba4096 Merge branch 'test' 2026-06-01 19:46:35 +08:00
hy001
a9ee549e37 Hardcode empty game list for test user 2026-06-01 18:31:19 +08:00
hy001
b53313f62d 增加首冲 2026-05-28 15:07:59 +08:00
hy001
e738fbc44a 修复充值奖励 2026-05-26 20:30:54 +08:00
hy001
c09433bf29 修复奖励 2026-05-26 20:06:23 +08:00
63 changed files with 4475 additions and 231 deletions

View File

@ -21,6 +21,7 @@ import (
"chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameopen"
"chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/gameprovider"
"chatapp3-golang/internal/service/hostcenter" "chatapp3-golang/internal/service/hostcenter"
"chatapp3-golang/internal/service/hotgame"
"chatapp3-golang/internal/service/invite" "chatapp3-golang/internal/service/invite"
"chatapp3-golang/internal/service/lingxian" "chatapp3-golang/internal/service/lingxian"
"chatapp3-golang/internal/service/luckygift" "chatapp3-golang/internal/service/luckygift"
@ -57,6 +58,7 @@ func main() {
baishunService := baishun.NewBaishunService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) baishunService := baishun.NewBaishunService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
binanceRechargeService := binancerecharge.NewService(app.Config, app.Repository.DB, &app.Gateways) binanceRechargeService := binancerecharge.NewService(app.Config, app.Repository.DB, &app.Gateways)
lingxianService := lingxian.NewService(app.Config, app.Repository.DB, app.Repository.Redis) lingxianService := lingxian.NewService(app.Config, app.Repository.DB, app.Repository.Redis)
hotgameService := hotgame.NewService(app.Config, app.Repository.DB, app.Repository.Redis)
gameOpenService := gameopen.NewGameOpenService(app.Config, app.Repository.DB, &app.Gateways) gameOpenService := gameopen.NewGameOpenService(app.Config, app.Repository.DB, &app.Gateways)
luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet) luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet)
rechargeAgencyService := rechargeagency.NewService(app.Config, app.Repository.DB, &app.Gateways) rechargeAgencyService := rechargeagency.NewService(app.Config, app.Repository.DB, &app.Gateways)
@ -115,7 +117,9 @@ func main() {
gameProviders := gameprovider.NewRegistry( gameProviders := gameprovider.NewRegistry(
baishun.NewAppProvider(baishunService), baishun.NewAppProvider(baishunService),
lingxian.NewAppProvider(lingxianService), lingxian.NewAppProvider(lingxianService),
hotgame.NewAppProvider(hotgameService),
) )
gameVisibility := gameprovider.NewVisibilityPolicy(&app.Gateways)
engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{ engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{
AppPopup: appPopupService, AppPopup: appPopupService,
ErrorLog: errorLogService, ErrorLog: errorLogService,
@ -126,10 +130,12 @@ func main() {
Lingxian: lingxianService, Lingxian: lingxianService,
GameOpen: gameOpenService, GameOpen: gameOpenService,
GameProviders: gameProviders, GameProviders: gameProviders,
GameVisibility: gameVisibility,
LuckyGift: luckyGiftService, LuckyGift: luckyGiftService,
ManagerCenter: managerCenterService, ManagerCenter: managerCenterService,
PropsStore: propsStoreService, PropsStore: propsStoreService,
HostCenter: hostCenterService, HostCenter: hostCenterService,
Hotgame: hotgameService,
RechargeAgency: rechargeAgencyService, RechargeAgency: rechargeAgencyService,
RechargeReward: rechargeRewardService, RechargeReward: rechargeRewardService,
RegionIMGroup: regionIMGroupService, RegionIMGroup: regionIMGroupService,

View File

@ -6,4 +6,6 @@ type AuthUser struct {
SysOrigin string SysOrigin string
Token string Token string
Authorization string Authorization string
RegionID string
RegionCode string
} }

View File

@ -32,6 +32,7 @@ type Config struct {
TencentIM TencentIMConfig TencentIM TencentIMConfig
Baishun BaishunConfig Baishun BaishunConfig
Lingxian LingxianConfig Lingxian LingxianConfig
Hotgame HotgameConfig
GameOpen GameOpenConfig GameOpen GameOpenConfig
LuckyGift LuckyGiftConfig LuckyGift LuckyGiftConfig
BinanceRecharge BinanceRechargeConfig BinanceRecharge BinanceRechargeConfig
@ -268,6 +269,12 @@ type LingxianConfig struct {
AppKey string AppKey string
} }
// HotgameConfig 保存热游模块的环境兜底配置。
type HotgameConfig struct {
AppKey string
CallbackBaseURL string
}
// GameOpenConfig 保存统一三方游戏回调配置。 // GameOpenConfig 保存统一三方游戏回调配置。
type GameOpenConfig struct { type GameOpenConfig struct {
PublicBaseURL string PublicBaseURL string
@ -432,7 +439,7 @@ func Load() Config {
SecretID: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_SECRET_ID", "TASK_CENTER_ARCHIVE_COS_SECRET_ID", "LIKEI_TENCENT_COS_SECRET_ID"}, ""), SecretID: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_SECRET_ID", "TASK_CENTER_ARCHIVE_COS_SECRET_ID", "LIKEI_TENCENT_COS_SECRET_ID"}, ""),
SecretKey: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_SECRET_KEY", "TASK_CENTER_ARCHIVE_COS_SECRET_KEY", "LIKEI_TENCENT_COS_SECRET_KEY"}, ""), SecretKey: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_SECRET_KEY", "TASK_CENTER_ARCHIVE_COS_SECRET_KEY", "LIKEI_TENCENT_COS_SECRET_KEY"}, ""),
Prefix: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_PREFIX", "TASK_CENTER_ARCHIVE_COS_PREFIX"}, "task-center-events/v1"), Prefix: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_PREFIX", "TASK_CENTER_ARCHIVE_COS_PREFIX"}, "task-center-events/v1"),
BatchSize: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_BATCH_SIZE", "TASK_CENTER_ARCHIVE_BATCH_SIZE"}, 10000), BatchSize: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_BATCH_SIZE", "TASK_CENTER_ARCHIVE_BATCH_SIZE"}, 500),
FlushSeconds: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_FLUSH_SECONDS", "TASK_CENTER_ARCHIVE_FLUSH_SECONDS"}, 60), FlushSeconds: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_FLUSH_SECONDS", "TASK_CENTER_ARCHIVE_FLUSH_SECONDS"}, 60),
Workers: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_WORKERS", "TASK_CENTER_ARCHIVE_WORKERS"}, 2), Workers: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_WORKERS", "TASK_CENTER_ARCHIVE_WORKERS"}, 2),
}, },
@ -617,6 +624,10 @@ func Load() Config {
GameListURL: getEnvAny([]string{"CHATAPP_LINGXIAN_GAME_LIST_URL", "LINGXIAN_GAME_LIST_URL"}, "https://sg-test.leadercc.com/yumichat_games/test_game_list.json"), 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", "LIKEI_GAME_HKYS_SIGN_KEY"}, ""), AppKey: getEnvAny([]string{"CHATAPP_LINGXIAN_APP_KEY", "LINGXIAN_APP_KEY", "GAME_HKYS_SIGN_KEY", "LIKEI_GAME_HKYS_SIGN_KEY"}, ""),
}, },
Hotgame: HotgameConfig{
AppKey: getEnvAny([]string{"CHATAPP_HOTGAME_APP_KEY", "HOTGAME_APP_KEY", "CHATAPP_REYOU_APP_KEY", "REYOU_APP_KEY"}, ""),
CallbackBaseURL: getEnvAny([]string{"CHATAPP_HOTGAME_CALLBACK_BASE_URL", "HOTGAME_CALLBACK_BASE_URL", "CHATAPP_REYOU_CALLBACK_BASE_URL", "REYOU_CALLBACK_BASE_URL"}, ""),
},
GameOpen: GameOpenConfig{ GameOpen: GameOpenConfig{
PublicBaseURL: getEnvAny([]string{"CHATAPP_GAME_OPEN_PUBLIC_BASE_URL", "GAME_OPEN_PUBLIC_BASE_URL"}, ""), 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"), AppKey: getEnvAny([]string{"CHATAPP_GAME_OPEN_APP_KEY", "GAME_OPEN_APP_KEY"}, "game-open-test-key"),

View File

@ -131,6 +131,11 @@ func (g *Gateways) GetRewardGroupDetail(ctx context.Context, groupID int64) (Rew
return g.RewardQuery.GetRewardGroupDetail(ctx, groupID) return g.RewardQuery.GetRewardGroupDetail(ctx, groupID)
} }
// FindRewardGroupDetail 透传到奖励查询网关,按名称查找奖励组详情。
func (g *Gateways) FindRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (RewardGroupDetail, error) {
return g.RewardQuery.FindRewardGroupDetail(ctx, sysOrigin, name)
}
// GetUserProfile 透传到资料网关。 // GetUserProfile 透传到资料网关。
func (g *Gateways) GetUserProfile(ctx context.Context, userID int64) (UserProfile, error) { func (g *Gateways) GetUserProfile(ctx context.Context, userID int64) (UserProfile, error) {
return g.Profile.GetUserProfile(ctx, userID) return g.Profile.GetUserProfile(ctx, userID)
@ -141,6 +146,11 @@ func (g *Gateways) GetUserRegion(ctx context.Context, userID int64, authorizatio
return g.Profile.GetUserRegion(ctx, userID, authorization) return g.Profile.GetUserRegion(ctx, userID, authorization)
} }
// GetUserLevel 透传到用户等级网关。
func (g *Gateways) GetUserLevel(ctx context.Context, sysOrigin string, userID int64) (UserLevel, error) {
return g.Profile.GetUserLevel(ctx, sysOrigin, userID)
}
// ResolveRegionCodeByCountryCode 透传到 Java 区域配置网关。 // ResolveRegionCodeByCountryCode 透传到 Java 区域配置网关。
func (g *Gateways) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) { func (g *Gateways) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
return g.Profile.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode) return g.Profile.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode)
@ -365,6 +375,11 @@ func (g *RewardQueryGateway) GetRewardGroupDetail(ctx context.Context, groupID i
return g.client.GetRewardGroupDetail(ctx, groupID) return g.client.GetRewardGroupDetail(ctx, groupID)
} }
// FindRewardGroupDetail 按名称查询奖励组详情。
func (g *RewardQueryGateway) FindRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (RewardGroupDetail, error) {
return g.client.FindRewardGroupDetail(ctx, sysOrigin, name)
}
// GetNobleVIPAbility 查询贵族 VIP 能力配置。 // GetNobleVIPAbility 查询贵族 VIP 能力配置。
func (g *RewardQueryGateway) GetNobleVIPAbility(ctx context.Context, sourceID int64) (PropsNobleVIPAbility, error) { func (g *RewardQueryGateway) GetNobleVIPAbility(ctx context.Context, sourceID int64) (PropsNobleVIPAbility, error) {
return g.client.GetNobleVIPAbility(ctx, sourceID) return g.client.GetNobleVIPAbility(ctx, sourceID)
@ -390,6 +405,11 @@ func (g *ProfileGateway) GetUserRegion(ctx context.Context, userID int64, author
return g.client.GetUserRegion(ctx, userID, authorization) return g.client.GetUserRegion(ctx, userID, authorization)
} }
// GetUserLevel 查询用户财富/魅力等级。
func (g *ProfileGateway) GetUserLevel(ctx context.Context, sysOrigin string, userID int64) (UserLevel, error) {
return g.client.GetUserLevel(ctx, sysOrigin, userID)
}
// ResolveRegionCodeByCountryCode 根据 Java 区域配置把国家码解析成区域编码。 // ResolveRegionCodeByCountryCode 根据 Java 区域配置把国家码解析成区域编码。
func (g *ProfileGateway) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) { func (g *ProfileGateway) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
return g.client.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode) return g.client.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode)

View File

@ -59,6 +59,12 @@ type UserRegion struct {
RegionCode string `json:"regionCode"` RegionCode string `json:"regionCode"`
} }
type UserLevel struct {
UserID Int64Value `json:"userId"`
WealthLevel int `json:"wealthLevel"`
CharmLevel int `json:"charmLevel"`
}
type RegionConfig struct { type RegionConfig struct {
RegionCode string `json:"regionCode"` RegionCode string `json:"regionCode"`
SysOrigin string `json:"sysOrigin"` SysOrigin string `json:"sysOrigin"`
@ -253,6 +259,13 @@ type RewardGroupDetail struct {
RewardConfigList []RewardGroupItem `json:"rewardConfigList"` RewardConfigList []RewardGroupItem `json:"rewardConfigList"`
} }
type rewardGroupPage struct {
Records []RewardGroupDetail `json:"records"`
Total int64 `json:"total"`
Current int `json:"current"`
Size int `json:"size"`
}
type UserTotalRecharge struct { type UserTotalRecharge struct {
ID Int64Value `json:"id"` ID Int64Value `json:"id"`
UserID Int64Value `json:"userId"` UserID Int64Value `json:"userId"`
@ -336,6 +349,7 @@ type resultResponse[T any] struct {
Code int `json:"code"` Code int `json:"code"`
Message string `json:"message"` Message string `json:"message"`
Body T `json:"body"` Body T `json:"body"`
Data T `json:"data"`
Success *bool `json:"success"` Success *bool `json:"success"`
} }
@ -698,15 +712,154 @@ func (c *Client) SendOfficialNoticeCustomize(ctx context.Context, req OfficialNo
} }
func (c *Client) GetRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) { func (c *Client) GetRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) {
endpoint := c.cfg.Java.OtherBaseURL + "/props-activity/reward/group/client/getByGroupId?id=" + url.QueryEscape(strconv.FormatInt(groupID, 10)) detail, err := c.getInnerRewardGroupDetail(ctx, groupID)
if err == nil && rewardGroupDetailHasContent(detail) {
return detail, nil
}
if fallback, fallbackErr := c.getConsoleRewardGroupDetail(ctx, groupID); fallbackErr == nil && rewardGroupDetailHasContent(fallback) {
return fallback, nil
} else if err == nil && fallbackErr != nil {
return detail, nil
}
return detail, err
}
func (c *Client) getInnerRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) {
endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/props-activity/reward/group/client/getByGroupId?id=" + url.QueryEscape(strconv.FormatInt(groupID, 10))
var resp resultResponse[RewardGroupDetail] var resp resultResponse[RewardGroupDetail]
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
return RewardGroupDetail{}, err return RewardGroupDetail{}, err
} }
if int64(resp.Body.ID) == 0 { detail := resp.Body
if int64(detail.ID) == 0 {
detail = resp.Data
}
if int64(detail.ID) == 0 {
return RewardGroupDetail{}, fmt.Errorf("empty reward group response") return RewardGroupDetail{}, fmt.Errorf("empty reward group response")
} }
return resp.Body, nil return detail, nil
}
func (c *Client) getConsoleRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) {
baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.Java.ConsoleBaseURL), "/")
if baseURL == "" {
return RewardGroupDetail{}, fmt.Errorf("console base url is empty")
}
endpoint := baseURL + "/props/activity/reward/group/" + url.PathEscape(strconv.FormatInt(groupID, 10))
var detail RewardGroupDetail
if err := c.getJSON(ctx, endpoint, nil, &detail); err != nil {
return RewardGroupDetail{}, err
}
if int64(detail.ID) == 0 {
return RewardGroupDetail{}, fmt.Errorf("empty reward group response")
}
return detail, nil
}
func rewardGroupDetailHasContent(detail RewardGroupDetail) bool {
return int64(detail.ID) > 0 && len(detail.RewardConfigList) > 0
}
func (c *Client) FindRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (RewardGroupDetail, error) {
groupName := strings.TrimSpace(name)
if groupName == "" {
return RewardGroupDetail{}, fmt.Errorf("reward group name is empty")
}
if detail, err := c.findInnerRewardGroupDetail(ctx, sysOrigin, groupName); err == nil {
if hydrated, hydrateErr := c.hydrateRewardGroupDetail(ctx, detail); hydrateErr == nil && rewardGroupDetailHasContent(hydrated) {
return hydrated, nil
}
if rewardGroupDetailHasContent(detail) {
return detail, nil
}
}
detail, err := c.findConsoleRewardGroupDetail(ctx, sysOrigin, groupName)
if err != nil {
return RewardGroupDetail{}, err
}
if hydrated, hydrateErr := c.hydrateRewardGroupDetail(ctx, detail); hydrateErr == nil && rewardGroupDetailHasContent(hydrated) {
return hydrated, nil
}
return detail, nil
}
func (c *Client) hydrateRewardGroupDetail(ctx context.Context, detail RewardGroupDetail) (RewardGroupDetail, error) {
if rewardGroupDetailHasContent(detail) {
return detail, nil
}
groupID := int64(detail.ID)
if groupID <= 0 {
return detail, fmt.Errorf("empty reward group id")
}
if hydrated, err := c.getInnerRewardGroupDetail(ctx, groupID); err == nil && rewardGroupDetailHasContent(hydrated) {
return hydrated, nil
}
return c.getConsoleRewardGroupDetail(ctx, groupID)
}
func (c *Client) findInnerRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (RewardGroupDetail, error) {
endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/props-activity/reward/group/client/page"
payload := map[string]any{
"current": 1,
"cursor": 1,
"limit": 10,
"name": name,
"shelfStatus": true,
"size": 10,
}
if origin := strings.TrimSpace(sysOrigin); origin != "" {
payload["sysOrigin"] = origin
}
var resp resultResponse[rewardGroupPage]
if err := c.postJSON(ctx, endpoint, payload, nil, &resp); err != nil {
return RewardGroupDetail{}, err
}
page := resp.Body
if len(page.Records) == 0 {
page = resp.Data
}
return pickRewardGroupDetailByName(page.Records, name)
}
func (c *Client) findConsoleRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (RewardGroupDetail, error) {
baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.Java.ConsoleBaseURL), "/")
if baseURL == "" {
return RewardGroupDetail{}, fmt.Errorf("console base url is empty")
}
values := url.Values{}
values.Set("current", "1")
values.Set("cursor", "1")
values.Set("limit", "10")
values.Set("name", name)
values.Set("shelfStatus", "true")
values.Set("size", "10")
if origin := strings.TrimSpace(sysOrigin); origin != "" {
values.Set("sysOrigin", origin)
}
endpoint := baseURL + "/props/activity/reward/group/page?" + values.Encode()
var page rewardGroupPage
if err := c.getJSON(ctx, endpoint, nil, &page); err != nil {
return RewardGroupDetail{}, err
}
return pickRewardGroupDetailByName(page.Records, name)
}
func pickRewardGroupDetailByName(records []RewardGroupDetail, name string) (RewardGroupDetail, error) {
if len(records) == 0 {
return RewardGroupDetail{}, fmt.Errorf("empty reward group page response")
}
target := strings.TrimSpace(name)
for _, record := range records {
if strings.TrimSpace(record.Name) == target {
return record, nil
}
}
for _, record := range records {
if rewardGroupDetailHasContent(record) {
return record, nil
}
}
return records[0], nil
} }
func (c *Client) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]UserTotalRecharge, error) { func (c *Client) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]UserTotalRecharge, error) {
@ -897,6 +1050,21 @@ func (c *Client) GetUserRegion(ctx context.Context, userID int64, authorization
return resp.Body, nil return resp.Body, nil
} }
func (c *Client) GetUserLevel(ctx context.Context, sysOrigin string, userID int64) (UserLevel, error) {
if userID <= 0 {
return UserLevel{}, fmt.Errorf("userId is required")
}
values := url.Values{}
values.Set("sysOrigin", strings.TrimSpace(sysOrigin))
values.Set("userId", strconv.FormatInt(userID, 10))
endpoint := c.cfg.Java.OtherBaseURL + "/user-level/client/getLevelByUserId?" + values.Encode()
var resp resultResponse[UserLevel]
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
return UserLevel{}, err
}
return resp.Body, nil
}
func (c *Client) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) { func (c *Client) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
countryCode = strings.ToUpper(strings.TrimSpace(countryCode)) countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
if countryCode == "" { if countryCode == "" {

View File

@ -77,3 +77,105 @@ func TestResolveRegionCodeByCountryCodeUsesRegionConfig(t *testing.T) {
t.Fatalf("region config calls = %d, want cached 1", calls) t.Fatalf("region config calls = %d, want cached 1", calls)
} }
} }
func TestGetRewardGroupDetailAcceptsDataWrapper(t *testing.T) {
groupID := int64(2056981527522242600)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/props-activity/reward/group/client/getByGroupId" {
t.Fatalf("request = %s %s, want GET /props-activity/reward/group/client/getByGroupId", r.Method, r.URL.Path)
}
if r.URL.Query().Get("id") != "2056981527522242600" {
t.Fatalf("id query = %q", r.URL.Query().Get("id"))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"id":"2056981527522242600","name":"累计充值10美金","rewardConfigList":[{"id":"1","type":"PROPS","detailType":"AVATAR_FRAME","name":"Frame","quantity":"1","sourceUrl":"https://example.com/frame.svga"}]}}`))
}))
defer server.Close()
client := New(config.Config{
HTTP: config.HTTPConfig{Timeout: time.Second},
Java: config.JavaConfig{OtherBaseURL: server.URL},
})
got, err := client.GetRewardGroupDetail(context.Background(), groupID)
if err != nil {
t.Fatalf("GetRewardGroupDetail() error = %v", err)
}
if int64(got.ID) != groupID || len(got.RewardConfigList) != 1 {
t.Fatalf("detail = %+v, want group with one reward item", got)
}
}
func TestGetRewardGroupDetailFallsBackToConsoleEndpoint(t *testing.T) {
groupID := int64(2056981527522242600)
var innerCalls, consoleCalls int
mux := http.NewServeMux()
mux.HandleFunc("/props-activity/reward/group/client/getByGroupId", func(w http.ResponseWriter, r *http.Request) {
innerCalls++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"body":{"id":"2056981527522242600","name":"累计充值10美金","rewardConfigList":[]}}`))
})
mux.HandleFunc("/console/props/activity/reward/group/2056981527522242600", func(w http.ResponseWriter, r *http.Request) {
consoleCalls++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"2056981527522242600","name":"累计充值10美金","rewardConfigList":[{"id":"1","type":"GIFT","name":"Gift","quantity":"1","cover":"https://example.com/gift.png"}]}`))
})
server := httptest.NewServer(mux)
defer server.Close()
client := New(config.Config{
HTTP: config.HTTPConfig{Timeout: time.Second},
Java: config.JavaConfig{
OtherBaseURL: server.URL,
ConsoleBaseURL: server.URL + "/console",
},
})
got, err := client.GetRewardGroupDetail(context.Background(), groupID)
if err != nil {
t.Fatalf("GetRewardGroupDetail() error = %v", err)
}
if innerCalls != 1 || consoleCalls != 1 {
t.Fatalf("calls inner=%d console=%d, want 1/1", innerCalls, consoleCalls)
}
if len(got.RewardConfigList) != 1 || got.RewardConfigList[0].Name != "Gift" {
t.Fatalf("detail = %+v, want fallback reward item", got)
}
}
func TestFindRewardGroupDetailHydratesPageRecord(t *testing.T) {
groupID := int64(2056980937278816399)
var pageCalls, detailCalls int
mux := http.NewServeMux()
mux.HandleFunc("/props-activity/reward/group/client/page", func(w http.ResponseWriter, r *http.Request) {
pageCalls++
if r.Method != http.MethodPost {
t.Fatalf("method = %s, want POST", r.Method)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"body":{"records":[{"id":"2056980937278816399","name":"累充活动$100","rewardConfigList":[]}]}}`))
})
mux.HandleFunc("/props-activity/reward/group/client/getByGroupId", func(w http.ResponseWriter, r *http.Request) {
detailCalls++
if r.URL.Query().Get("id") != "2056980937278816399" {
t.Fatalf("id query = %q", r.URL.Query().Get("id"))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"body":{"id":"2056980937278816399","name":"累充活动$100","rewardConfigList":[{"id":"1","type":"BADGE","name":"Badge","quantity":"30","cover":"https://example.com/badge.png"}]}}`))
})
server := httptest.NewServer(mux)
defer server.Close()
client := New(config.Config{
HTTP: config.HTTPConfig{Timeout: time.Second},
Java: config.JavaConfig{OtherBaseURL: server.URL},
})
got, err := client.FindRewardGroupDetail(context.Background(), "LIKEI", "累充活动$100")
if err != nil {
t.Fatalf("FindRewardGroupDetail() error = %v", err)
}
if pageCalls != 1 || detailCalls != 1 {
t.Fatalf("calls page=%d detail=%d, want 1/1", pageCalls, detailCalls)
}
if int64(got.ID) != groupID || len(got.RewardConfigList) != 1 {
t.Fatalf("detail = %+v, want hydrated reward group", got)
}
}

View File

@ -4,11 +4,12 @@ import "time"
// FirstRechargeRewardConfig stores first recharge reward config per system. // FirstRechargeRewardConfig stores first recharge reward config per system.
type FirstRechargeRewardConfig struct { type FirstRechargeRewardConfig struct {
ID int64 `gorm:"column:id;primaryKey"` ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_first_recharge_reward_sys_origin"` SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_first_recharge_reward_sys_origin"`
Enabled bool `gorm:"column:enabled;index:idx_first_recharge_reward_enabled"` Enabled bool `gorm:"column:enabled;index:idx_first_recharge_reward_enabled"`
CreateTime time.Time `gorm:"column:create_time"` MinAppVersion string `gorm:"column:min_app_version;size:32"`
UpdateTime time.Time `gorm:"column:update_time"` CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
} }
func (FirstRechargeRewardConfig) TableName() string { return "first_recharge_reward_config" } func (FirstRechargeRewardConfig) TableName() string { return "first_recharge_reward_config" }
@ -19,6 +20,7 @@ type FirstRechargeRewardLevel struct {
ConfigID int64 `gorm:"column:config_id;uniqueIndex:uk_first_recharge_reward_level,priority:1;uniqueIndex:uk_first_recharge_reward_amount,priority:1;index:idx_first_recharge_reward_level_config"` ConfigID int64 `gorm:"column:config_id;uniqueIndex:uk_first_recharge_reward_level,priority:1;uniqueIndex:uk_first_recharge_reward_amount,priority:1;index:idx_first_recharge_reward_level_config"`
Level int `gorm:"column:level;uniqueIndex:uk_first_recharge_reward_level,priority:2"` Level int `gorm:"column:level;uniqueIndex:uk_first_recharge_reward_level,priority:2"`
RechargeAmountCents int64 `gorm:"column:recharge_amount_cents;uniqueIndex:uk_first_recharge_reward_amount,priority:2"` RechargeAmountCents int64 `gorm:"column:recharge_amount_cents;uniqueIndex:uk_first_recharge_reward_amount,priority:2"`
GoogleProductID string `gorm:"column:google_product_id;size:128;index:idx_first_recharge_reward_level_google_product"`
RewardGroupID int64 `gorm:"column:reward_group_id"` RewardGroupID int64 `gorm:"column:reward_group_id"`
RewardGroupName string `gorm:"column:reward_group_name;size:255"` RewardGroupName string `gorm:"column:reward_group_name;size:255"`
Enabled bool `gorm:"column:enabled"` Enabled bool `gorm:"column:enabled"`
@ -46,6 +48,7 @@ type FirstRechargeRewardGrantRecord struct {
Level int `gorm:"column:level"` Level int `gorm:"column:level"`
PayPlatform string `gorm:"column:pay_platform;size:64"` PayPlatform string `gorm:"column:pay_platform;size:64"`
PaymentMethod string `gorm:"column:payment_method;size:32"` PaymentMethod string `gorm:"column:payment_method;size:32"`
GoogleProductID string `gorm:"column:google_product_id;size:128;index:idx_first_recharge_reward_grant_google_product"`
SourceOrderID string `gorm:"column:source_order_id;size:128;index:idx_first_recharge_reward_order"` SourceOrderID string `gorm:"column:source_order_id;size:128;index:idx_first_recharge_reward_order"`
RewardGroupID int64 `gorm:"column:reward_group_id"` RewardGroupID int64 `gorm:"column:reward_group_id"`
RewardGroupName string `gorm:"column:reward_group_name;size:255"` RewardGroupName string `gorm:"column:reward_group_name;size:255"`

View File

@ -0,0 +1,41 @@
package model
import "time"
// HotgameProviderConfig 保存每个系统、每个环境的热游回调验签配置。
type HotgameProviderConfig struct {
ID int64 `gorm:"column:id;primaryKey"` // 主键
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_hotgame_provider_sys_origin_profile,priority:1;index:idx_hotgame_provider_active,priority:1"` // 系统标识
Profile string `gorm:"column:profile;size:32;default:PROD;uniqueIndex:uk_hotgame_provider_sys_origin_profile,priority:2"` // 配置环境PROD/TEST
Active bool `gorm:"column:active;index:idx_hotgame_provider_active,priority:2"` // app 当前启用环境
AppKey string `gorm:"column:app_key;size:255"` // 热游回调 MD5 key
CallbackBaseURL string `gorm:"column:callback_base_url;size:1024"` // 提供给热游配置的回调域名
TestUID string `gorm:"column:test_uid;size:64"` // 联调用 UID 覆盖
TestToken string `gorm:"column:test_token;size:1024"` // 联调用 token 覆盖
CreateTime time.Time `gorm:"column:create_time"` // 创建时间
UpdateTime time.Time `gorm:"column:update_time;index:idx_hotgame_provider_active,priority:3"` // 更新时间
}
// TableName 返回热游接入配置表名。
func (HotgameProviderConfig) TableName() string { return "hotgame_provider_config" }
// HotgameGameCatalog 保存从热游对接表导入的游戏目录。
type HotgameGameCatalog struct {
ID int64 `gorm:"column:id;primaryKey"` // 主键
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_hotgame_catalog_sys_profile_vendor_game,priority:1;index:idx_hotgame_catalog_profile_internal_game,priority:1"` // 系统标识
Profile string `gorm:"column:profile;size:32;default:PROD;uniqueIndex:uk_hotgame_catalog_sys_profile_vendor_game,priority:2;index:idx_hotgame_catalog_profile_internal_game,priority:2"` // 配置环境PROD/TEST
InternalGameID string `gorm:"column:internal_game_id;size:64;index:idx_hotgame_catalog_profile_internal_game,priority:3"` // app 内部游戏 ID
VendorGameID string `gorm:"column:vendor_game_id;size:64;uniqueIndex:uk_hotgame_catalog_sys_profile_vendor_game,priority:3"` // 热游游戏 ID
Name string `gorm:"column:name;size:128"` // 游戏名称
Cover string `gorm:"column:cover;size:1024"` // 封面地址
PreviewURL string `gorm:"column:preview_url;size:1024"` // 七分屏/预览入口
DownloadURL string `gorm:"column:download_url;size:1024"` // 实际启动入口
PackageVersion string `gorm:"column:package_version;size:32"` // 版本号
RawJSON string `gorm:"column:raw_json;type:longtext"` // 原始目录 JSON
Status string `gorm:"column:status;size:32;index:idx_hotgame_catalog_profile_internal_game,priority:4"` // 目录状态
CreateTime time.Time `gorm:"column:create_time"` // 创建时间
UpdateTime time.Time `gorm:"column:update_time"` // 更新时间
}
// TableName 返回热游目录表名。
func (HotgameGameCatalog) TableName() string { return "hotgame_game_catalog" }

View File

@ -116,19 +116,19 @@ func (TaskCenterEventDedup) TableName() string { return "task_center_event_dedup
// TaskCenterEventArchiveOutbox 保存待归档到 COS 的原始事件。 // TaskCenterEventArchiveOutbox 保存待归档到 COS 的原始事件。
type TaskCenterEventArchiveOutbox struct { type TaskCenterEventArchiveOutbox struct {
ID int64 `gorm:"column:id;primaryKey"` ID int64 `gorm:"column:id;primaryKey;index:idx_task_center_archive_claim_create,priority:3;index:idx_task_center_archive_claim_stale,priority:3"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_task_center_archive_event,priority:1"` SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_task_center_archive_event,priority:1"`
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_task_center_archive_event,priority:2"` EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_task_center_archive_event,priority:2"`
EventType string `gorm:"column:event_type;size:64"` EventType string `gorm:"column:event_type;size:64"`
UserID int64 `gorm:"column:user_id"` UserID int64 `gorm:"column:user_id"`
OccurredAt time.Time `gorm:"column:occurred_at"` OccurredAt time.Time `gorm:"column:occurred_at"`
RawJSON string `gorm:"column:raw_json;type:text"` RawJSON string `gorm:"column:raw_json;type:text"`
Status string `gorm:"column:status;size:32;index:idx_task_center_archive_status_time,priority:1"` Status string `gorm:"column:status;size:32;index:idx_task_center_archive_status_time,priority:1;index:idx_task_center_archive_claim_create,priority:1;index:idx_task_center_archive_claim_stale,priority:1"`
RetryCount int `gorm:"column:retry_count"` RetryCount int `gorm:"column:retry_count"`
COSKey string `gorm:"column:cos_key;size:512"` COSKey string `gorm:"column:cos_key;size:512"`
FailureReason string `gorm:"column:failure_reason;size:512"` FailureReason string `gorm:"column:failure_reason;size:512"`
CreateTime time.Time `gorm:"column:create_time;index:idx_task_center_archive_status_time,priority:2"` CreateTime time.Time `gorm:"column:create_time;index:idx_task_center_archive_status_time,priority:2;index:idx_task_center_archive_claim_create,priority:2"`
UpdateTime time.Time `gorm:"column:update_time"` UpdateTime time.Time `gorm:"column:update_time;index:idx_task_center_archive_claim_stale,priority:2"`
ArchivedAt *time.Time `gorm:"column:archived_at"` ArchivedAt *time.Time `gorm:"column:archived_at"`
} }

View File

@ -2,6 +2,7 @@ package router
import ( import (
"net/http" "net/http"
"strings"
"chatapp3-golang/internal/service/firstrechargereward" "chatapp3-golang/internal/service/firstrechargereward"
@ -60,7 +61,7 @@ func registerFirstRechargeRewardRoutes(engine *gin.Engine, javaClient authGatewa
func registerFirstRechargeRewardAppGroup(group *gin.RouterGroup, javaClient authGateway, service *firstrechargereward.Service) { func registerFirstRechargeRewardAppGroup(group *gin.RouterGroup, javaClient authGateway, service *firstrechargereward.Service) {
group.Use(authMiddleware(javaClient)) group.Use(authMiddleware(javaClient))
group.GET("/home", func(c *gin.Context) { group.GET("/home", func(c *gin.Context) {
resp, err := service.GetHome(c.Request.Context(), mustAuthUser(c)) resp, err := service.GetHome(c.Request.Context(), mustAuthUser(c), firstRechargeRewardRequestVersion(c))
if err != nil { if err != nil {
writeError(c, err) writeError(c, err)
return return
@ -68,3 +69,33 @@ func registerFirstRechargeRewardAppGroup(group *gin.RouterGroup, javaClient auth
writeOK(c, resp) writeOK(c, resp)
}) })
} }
func firstRechargeRewardRequestVersion(c *gin.Context) string {
return firstNonEmptyString(
c.Query("appVersion"),
c.Query("version"),
c.GetHeader("req-version"),
c.GetHeader("Req-Version"),
versionFromReqAppIntel(c.GetHeader("req-app-intel")),
versionFromReqAppIntel(c.GetHeader("Req-App-Intel")),
)
}
func versionFromReqAppIntel(value string) string {
for _, item := range strings.Split(value, ";") {
parts := strings.SplitN(item, "=", 2)
if len(parts) == 2 && strings.EqualFold(strings.TrimSpace(parts[0]), "version") {
return strings.TrimSpace(parts[1])
}
}
return ""
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if text := strings.TrimSpace(value); text != "" {
return text
}
}
return ""
}

View File

@ -46,6 +46,8 @@ func registerGameOpenRoutes(engine *gin.Engine, cfg config.Config, service *game
c.JSON(http.StatusOK, service.HandleSupplement(c.Request.Context(), req, string(raw))) c.JSON(http.StatusOK, service.HandleSupplement(c.Request.Context(), req, string(raw)))
}) })
registerHotgameCallbackRoutes(engine, service)
internalGroup := engine.Group("/internal/game/open") internalGroup := engine.Group("/internal/game/open")
internalGroup.Use(internalSecretMiddleware(cfg.HTTP.InternalCallbackSecret)) internalGroup.Use(internalSecretMiddleware(cfg.HTTP.InternalCallbackSecret))
internalGroup.GET("/info", func(c *gin.Context) { internalGroup.GET("/info", func(c *gin.Context) {
@ -58,6 +60,34 @@ func registerGameOpenRoutes(engine *gin.Engine, cfg config.Config, service *game
}) })
} }
func registerHotgameCallbackRoutes(engine *gin.Engine, service *gameopen.GameOpenService) {
handleUserInfo := func(c *gin.Context) {
raw, _ := c.GetRawData()
var req gameopen.HotgameGetUserInfoRequest
if err := json.Unmarshal(raw, &req); err != nil {
c.JSON(http.StatusOK, gameopen.CallbackResponse{ErrorCode: 4001, ErrorMsg: err.Error()})
return
}
c.JSON(http.StatusOK, service.HandleHotgameGetUserInfo(c.Request.Context(), req, string(raw)))
}
handleUpdateBalance := func(c *gin.Context) {
raw, _ := c.GetRawData()
var req gameopen.HotgameUpdateBalanceRequest
if err := json.Unmarshal(raw, &req); err != nil {
c.JSON(http.StatusOK, gameopen.CallbackResponse{ErrorCode: 4001, ErrorMsg: err.Error()})
return
}
c.JSON(http.StatusOK, service.HandleHotgameUpdateBalance(c.Request.Context(), req, string(raw)))
}
// 热游文档要求平台直接提供 /getUserInfo 和 /updateBalance同时保留命名空间路径便于网关灰度。
engine.POST("/getUserInfo", handleUserInfo)
engine.POST("/updateBalance", handleUpdateBalance)
hotgameGroup := engine.Group("/game/hotgame")
hotgameGroup.POST("/getUserInfo", handleUserInfo)
hotgameGroup.POST("/updateBalance", handleUpdateBalance)
}
func resolvePublicBaseURL(c *gin.Context, cfg config.Config) string { func resolvePublicBaseURL(c *gin.Context, cfg config.Config) string {
if value := strings.TrimRight(strings.TrimSpace(cfg.GameOpen.PublicBaseURL), "/"); value != "" { if value := strings.TrimRight(strings.TrimSpace(cfg.GameOpen.PublicBaseURL), "/"); value != "" {
return value return value

View File

@ -1,6 +1,7 @@
package router package router
import ( import (
"errors"
"net/http" "net/http"
"strings" "strings"
@ -11,7 +12,7 @@ import (
) )
// registerGameProviderRoutes 注册统一游戏厂商 app 路由。 // registerGameProviderRoutes 注册统一游戏厂商 app 路由。
func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, registry *gameprovider.Registry) { func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, registry *gameprovider.Registry, visibility *gameprovider.VisibilityPolicy) {
if registry == nil { if registry == nil {
return return
} }
@ -19,7 +20,15 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
appGroup := engine.Group("/app/game") appGroup := engine.Group("/app/game")
appGroup.Use(authMiddleware(javaClient)) appGroup.Use(authMiddleware(javaClient))
appGroup.GET("/room/shortcut", func(c *gin.Context) { appGroup.GET("/room/shortcut", func(c *gin.Context) {
resp, err := registry.ListShortcutGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId")) user, visible, ok := gameListAccess(c, visibility)
if !ok {
return
}
if !visible {
writeOK(c, []publicRoomGameItem{})
return
}
resp, err := registry.ListShortcutGames(c.Request.Context(), user, c.Query("roomId"))
if err != nil { if err != nil {
writeError(c, err) writeError(c, err)
return return
@ -27,7 +36,15 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
writeOK(c, publicRoomGameItems(resp)) writeOK(c, publicRoomGameItems(resp))
}) })
appGroup.GET("/room/list", func(c *gin.Context) { appGroup.GET("/room/list", func(c *gin.Context) {
resp, err := registry.ListRoomGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"), c.Query("category")) user, visible, ok := gameListAccess(c, visibility)
if !ok {
return
}
if !visible {
writeOK(c, publicRoomGameListResponse(&gameprovider.RoomGameListResponse{Items: []gameprovider.RoomGameListItem{}}))
return
}
resp, err := registry.ListRoomGames(c.Request.Context(), user, c.Query("roomId"), c.Query("category"))
if err != nil { if err != nil {
writeError(c, err) writeError(c, err)
return return
@ -72,14 +89,30 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
providerGroup := engine.Group("/app/game/providers") providerGroup := engine.Group("/app/game/providers")
providerGroup.Use(authMiddleware(javaClient)) providerGroup.Use(authMiddleware(javaClient))
providerGroup.GET("", func(c *gin.Context) { providerGroup.GET("", func(c *gin.Context) {
_, visible, ok := gameListAccess(c, visibility)
if !ok {
return
}
if !visible {
writeOK(c, gameprovider.ProviderListResponse{Items: []gameprovider.ProviderSummary{}})
return
}
writeOK(c, registry.List()) writeOK(c, registry.List())
}) })
providerGroup.GET("/:provider/room/shortcut", func(c *gin.Context) { providerGroup.GET("/:provider/room/shortcut", func(c *gin.Context) {
user, visible, ok := gameListAccess(c, visibility)
if !ok {
return
}
if !visible {
writeOK(c, gin.H{"items": []publicRoomGameItem{}})
return
}
provider, ok := resolveGameProvider(c, registry) provider, ok := resolveGameProvider(c, registry)
if !ok { if !ok {
return return
} }
resp, err := provider.ListShortcutGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId")) resp, err := provider.ListShortcutGames(c.Request.Context(), user, c.Query("roomId"))
if err != nil { if err != nil {
writeError(c, err) writeError(c, err)
return return
@ -87,11 +120,19 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
writeOK(c, gin.H{"items": publicRoomGameItems(resp)}) writeOK(c, gin.H{"items": publicRoomGameItems(resp)})
}) })
providerGroup.GET("/:provider/room/list", func(c *gin.Context) { providerGroup.GET("/:provider/room/list", func(c *gin.Context) {
user, visible, ok := gameListAccess(c, visibility)
if !ok {
return
}
if !visible {
writeOK(c, publicRoomGameListResponse(&gameprovider.RoomGameListResponse{Items: []gameprovider.RoomGameListItem{}}))
return
}
provider, ok := resolveGameProvider(c, registry) provider, ok := resolveGameProvider(c, registry)
if !ok { if !ok {
return return
} }
resp, err := provider.ListRoomGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"), c.Query("category")) resp, err := provider.ListRoomGames(c.Request.Context(), user, c.Query("roomId"), c.Query("category"))
if err != nil { if err != nil {
writeError(c, err) writeError(c, err)
return return
@ -120,7 +161,7 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return return
} }
resp, err := provider.LaunchGame(c.Request.Context(), mustAuthUser(c), req, resolveClientIP(c)) resp, err := launchProviderGame(c, registry, provider, req)
if err != nil { if err != nil {
writeError(c, err) writeError(c, err)
return return
@ -146,6 +187,33 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
}) })
} }
func gameListAccess(c *gin.Context, visibility *gameprovider.VisibilityPolicy) (common.AuthUser, bool, bool) {
user := mustAuthUser(c)
// iOS 审核和上架场景需要隐藏游戏入口;这里放在统一列表门禁最前面,避免继续请求 Java 区域/等级接口或 provider SQL。
if isIOSGameListClient(c) {
return user, false, true
}
if gameprovider.IsEmptyGameListUser(user.UserID) {
return user, false, true
}
if visibility == nil {
return user, true, true
}
// 游戏列表只在列表入口做展示门禁,结果里的区域继续向下传给 provider SQL 做 regions 过滤。
result, err := visibility.Evaluate(c.Request.Context(), user)
if err != nil {
writeError(c, err)
return user, false, false
}
user.RegionID = result.RegionID
user.RegionCode = result.RegionCode
return user, result.Allow, true
}
func isIOSGameListClient(c *gin.Context) bool {
return strings.EqualFold(strings.TrimSpace(c.GetHeader("Req-Client")), "ios")
}
func resolveGameProvider(c *gin.Context, registry *gameprovider.Registry) (gameprovider.Provider, bool) { func resolveGameProvider(c *gin.Context, registry *gameprovider.Registry) (gameprovider.Provider, bool) {
provider, err := registry.Resolve(c.Param("provider")) provider, err := registry.Resolve(c.Param("provider"))
if err != nil { if err != nil {
@ -155,6 +223,25 @@ func resolveGameProvider(c *gin.Context, registry *gameprovider.Registry) (gamep
return provider, true return provider, true
} }
func launchProviderGame(c *gin.Context, registry *gameprovider.Registry, provider gameprovider.Provider, req gameprovider.LaunchRequest) (*gameprovider.LaunchResponse, error) {
user := mustAuthUser(c)
clientIP := resolveClientIP(c)
resp, err := provider.LaunchGame(c.Request.Context(), user, req, clientIP)
if err == nil {
return resp, nil
}
if !isGameNotFoundError(err) {
return nil, err
}
// 旧客户端只能把热游列表项伪装成 LEADER 进入已有 WebView这里再按 gameId 回到真实 provider避免 LEADER 路由找不到 hg_*。
return registry.LaunchGame(c.Request.Context(), user, req, clientIP)
}
func isGameNotFoundError(err error) bool {
var appErr *common.AppError
return errors.As(err, &appErr) && appErr.Code == "game_not_found"
}
type publicRoomGameItem struct { type publicRoomGameItem struct {
ID int64 `json:"id"` ID int64 `json:"id"`
GameID string `json:"gameId"` GameID string `json:"gameId"`
@ -311,8 +398,14 @@ func publicLaunch(resp *gameprovider.LaunchResponse) *publicLaunchResponse {
} }
func appCompatProviderName(provider string) string { func appCompatProviderName(provider string) string {
// 客户端历史上只认识 LEADER不认识内部厂商名 LINGXIAN所以出参继续保持旧字段值。
if strings.EqualFold(strings.TrimSpace(provider), "LINGXIAN") { if strings.EqualFold(strings.TrimSpace(provider), "LINGXIAN") {
return "LEADER" return "LEADER"
} }
// 旧客户端没有 HOTGAME 页面;热游先按 LEADER 展示并进入已有 WebViewlaunch 路由再按 hg_* 分发回真实热游 provider。
switch strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(provider), "_", "")) {
case "HOTGAME", "REYOU", "LALU":
return "LEADER"
}
return provider return provider
} }

View File

@ -1,9 +1,18 @@
package router package router
import ( import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing" "testing"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/gameprovider"
"github.com/gin-gonic/gin"
) )
func TestPublicRoomGameListResponseKeepsLegacyProviderFields(t *testing.T) { func TestPublicRoomGameListResponseKeepsLegacyProviderFields(t *testing.T) {
@ -74,6 +83,41 @@ func TestPublicRoomGameListResponseMapsLingxianToLeaderForOldClients(t *testing.
} }
} }
func TestPublicRoomGameListResponseMapsHotgameAliasesToLeaderForOldClients(t *testing.T) {
for _, alias := range []string{"HOTGAME", "HOT_GAME", "REYOU", "LALU"} {
resp := publicRoomGameListResponse(&gameprovider.RoomGameListResponse{
Items: []gameprovider.RoomGameListItem{
{
ID: 9530,
GameID: "hg_1",
GameType: alias,
Provider: alias,
ProviderGameID: "1",
Name: "Hotgame",
LaunchParams: map[string]any{"gameType": alias, "screenMode": "seven"},
},
},
})
if len(resp.Items) != 1 {
t.Fatalf("%s items = %d, want 1", alias, len(resp.Items))
}
item := resp.Items[0]
if item.Provider != "LEADER" {
t.Fatalf("%s provider = %q, want LEADER", alias, item.Provider)
}
if item.GameType != "LEADER" {
t.Fatalf("%s gameType = %q, want LEADER", alias, item.GameType)
}
if item.VendorType != "LEADER" {
t.Fatalf("%s vendorType = %q, want LEADER", alias, item.VendorType)
}
if got := item.LaunchParams["gameType"]; got != "LEADER" {
t.Fatalf("%s launchParams.gameType = %v, want LEADER", alias, got)
}
}
}
func TestPublicLaunchKeepsLegacyBridgeAndProviderFields(t *testing.T) { func TestPublicLaunchKeepsLegacyBridgeAndProviderFields(t *testing.T) {
launchConfig := map[string]any{"code": "launch-code"} launchConfig := map[string]any{"code": "launch-code"}
resp := publicLaunch(&gameprovider.LaunchResponse{ resp := publicLaunch(&gameprovider.LaunchResponse{
@ -141,3 +185,325 @@ func TestPublicLaunchMapsLingxianToLeaderForOldClients(t *testing.T) {
t.Fatalf("roomState.provider = %q, want LEADER", resp.RoomState.Provider) t.Fatalf("roomState.provider = %q, want LEADER", resp.RoomState.Provider)
} }
} }
func TestGameProviderRoutesReturnRoomListForTurkeyRegionUser(t *testing.T) {
gin.SetMode(gin.TestMode)
provider := &routeStubProvider{}
engine := gin.New()
registerGameProviderRoutes(
engine,
routeStubAuthGateway{},
gameprovider.NewRegistry(provider),
gameprovider.NewVisibilityPolicy(
routeStubVisibilityGateway{
region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
},
),
)
req := httptest.NewRequest(http.MethodGet, "/app/game/room/list?roomId=1", nil)
req.Header.Set("Authorization", "Bearer token")
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
}
var payload struct {
Body struct {
Items []publicRoomGameItem `json:"items"`
} `json:"body"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("response json error = %v: %s", err, rec.Body.String())
}
if len(payload.Body.Items) != 1 {
t.Fatalf("items = %d, want 1", len(payload.Body.Items))
}
if provider.listRoomCalls != 1 {
t.Fatalf("provider list calls = %d, want 1", provider.listRoomCalls)
}
}
func TestGameProviderRoutesReturnEmptyRoomListForIOSClient(t *testing.T) {
gin.SetMode(gin.TestMode)
provider := &routeStubProvider{}
engine := gin.New()
registerGameProviderRoutes(
engine,
routeStubAuthGateway{},
gameprovider.NewRegistry(provider),
nil,
)
req := httptest.NewRequest(http.MethodGet, "/app/game/room/list?roomId=1", nil)
req.Header.Set("Authorization", "Bearer token")
req.Header.Set("Req-Client", "ios")
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
}
var payload struct {
Body struct {
Items []publicRoomGameItem `json:"items"`
} `json:"body"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("response json error = %v: %s", err, rec.Body.String())
}
if len(payload.Body.Items) != 0 {
t.Fatalf("items = %d, want 0", len(payload.Body.Items))
}
if provider.listRoomCalls != 0 {
t.Fatalf("provider list calls = %d, want 0", provider.listRoomCalls)
}
}
func TestGameProviderRoutesReturnEmptyShortcutForIOSClient(t *testing.T) {
gin.SetMode(gin.TestMode)
provider := &routeStubProvider{}
engine := gin.New()
registerGameProviderRoutes(
engine,
routeStubAuthGateway{},
gameprovider.NewRegistry(provider),
nil,
)
req := httptest.NewRequest(http.MethodGet, "/app/game/room/shortcut?roomId=1", nil)
req.Header.Set("Authorization", "Bearer token")
req.Header.Set("Req-Client", " iOS ")
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
}
var payload struct {
Body []publicRoomGameItem `json:"body"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("response json error = %v: %s", err, rec.Body.String())
}
if len(payload.Body) != 0 {
t.Fatalf("items = %d, want 0", len(payload.Body))
}
if provider.shortcutCalls != 0 {
t.Fatalf("provider shortcut calls = %d, want 0", provider.shortcutCalls)
}
}
func TestGameProviderRoutesReturnEmptyProvidersForIOSClient(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
registerGameProviderRoutes(
engine,
routeStubAuthGateway{},
gameprovider.NewRegistry(&routeStubProvider{}),
nil,
)
req := httptest.NewRequest(http.MethodGet, "/app/game/providers", nil)
req.Header.Set("Authorization", "Bearer token")
req.Header.Set("Req-Client", "IOS")
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
}
var payload struct {
Body struct {
Items []gameprovider.ProviderSummary `json:"items"`
} `json:"body"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("response json error = %v: %s", err, rec.Body.String())
}
if len(payload.Body.Items) != 0 {
t.Fatalf("providers = %d, want 0", len(payload.Body.Items))
}
}
func TestGameProviderRoutesReturnProvidersWhenVisibilityPolicyAllows(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
registerGameProviderRoutes(
engine,
routeStubAuthGateway{},
gameprovider.NewRegistry(&routeStubProvider{}),
gameprovider.NewVisibilityPolicy(
routeStubVisibilityGateway{
region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
},
),
)
req := httptest.NewRequest(http.MethodGet, "/app/game/providers", nil)
req.Header.Set("Authorization", "Bearer token")
req.Header.Set("X-Real-IP", "88.255.1.1")
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
}
var payload struct {
Body struct {
Items []gameprovider.ProviderSummary `json:"items"`
} `json:"body"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("response json error = %v: %s", err, rec.Body.String())
}
if len(payload.Body.Items) != 1 {
t.Fatalf("providers = %d, want 1", len(payload.Body.Items))
}
if payload.Body.Items[0].Key != "TEST" {
t.Fatalf("provider key = %q, want TEST", payload.Body.Items[0].Key)
}
}
func TestProviderLaunchFallsBackToRegistryWhenLegacyProviderCannotFindGame(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
registerGameProviderRoutes(
engine,
routeStubAuthGateway{},
gameprovider.NewRegistry(
&routeLaunchStubProvider{key: "LINGXIAN"},
&routeLaunchStubProvider{key: "HOTGAME", matchGameID: "hg_1"},
),
nil,
)
req := httptest.NewRequest(http.MethodPost, "/app/game/providers/LEADER/launch", strings.NewReader(`{"roomId":"room-1","gameId":"hg_1"}`))
req.Header.Set("Authorization", "Bearer token")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
}
var payload struct {
Body publicLaunchResponse `json:"body"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("response json error = %v: %s", err, rec.Body.String())
}
if payload.Body.GameID != "hg_1" {
t.Fatalf("gameId = %q, want hg_1", payload.Body.GameID)
}
if payload.Body.Provider != "LEADER" {
t.Fatalf("provider = %q, want LEADER for old-client compat", payload.Body.Provider)
}
if payload.Body.GameSessionID != "hg-session" {
t.Fatalf("gameSessionId = %q, want hg-session", payload.Body.GameSessionID)
}
}
type routeStubAuthGateway struct{}
func (routeStubAuthGateway) AuthenticateToken(context.Context, string) (integration.UserCredential, error) {
return integration.UserCredential{UserID: integration.Int64Value(1001), SysOrigin: "LIKEI"}, nil
}
func (routeStubAuthGateway) AuthenticateConsoleToken(context.Context, string) (integration.ConsoleAccount, error) {
return integration.ConsoleAccount{}, nil
}
type routeStubVisibilityGateway struct {
region integration.UserRegion
}
func (s routeStubVisibilityGateway) GetUserRegion(context.Context, int64, string) (integration.UserRegion, error) {
return s.region, nil
}
type routeStubProvider struct {
listRoomCalls int
shortcutCalls int
}
func (p *routeStubProvider) Key() string { return "TEST" }
func (p *routeStubProvider) DisplayName() string { return "Test Provider" }
func (p *routeStubProvider) SupportsLaunch(context.Context, gameprovider.AuthUser, gameprovider.LaunchRequest) (bool, error) {
return false, nil
}
func (p *routeStubProvider) ListShortcutGames(context.Context, gameprovider.AuthUser, string) ([]gameprovider.RoomGameListItem, error) {
p.shortcutCalls++
return []gameprovider.RoomGameListItem{{ID: 1, GameID: "test_1", Provider: "TEST", ProviderGameID: "1", Name: "Test Game"}}, nil
}
func (p *routeStubProvider) ListRoomGames(context.Context, gameprovider.AuthUser, string, string) (*gameprovider.RoomGameListResponse, error) {
p.listRoomCalls++
return &gameprovider.RoomGameListResponse{
Items: []gameprovider.RoomGameListItem{{ID: 1, GameID: "test_1", Provider: "TEST", ProviderGameID: "1", Name: "Test Game"}},
}, nil
}
func (p *routeStubProvider) GetRoomState(context.Context, gameprovider.AuthUser, string) (*gameprovider.RoomStateResponse, error) {
return &gameprovider.RoomStateResponse{}, nil
}
func (p *routeStubProvider) LaunchGame(context.Context, gameprovider.AuthUser, gameprovider.LaunchRequest, string) (*gameprovider.LaunchResponse, error) {
return nil, common.NewAppError(http.StatusBadRequest, "unsupported", "unsupported")
}
func (p *routeStubProvider) CloseGame(context.Context, gameprovider.AuthUser, gameprovider.CloseRequest) (*gameprovider.RoomStateResponse, error) {
return &gameprovider.RoomStateResponse{}, nil
}
type routeLaunchStubProvider struct {
key string
matchGameID string
}
func (p *routeLaunchStubProvider) Key() string { return p.key }
func (p *routeLaunchStubProvider) DisplayName() string { return p.key }
func (p *routeLaunchStubProvider) SupportsLaunch(_ context.Context, _ gameprovider.AuthUser, req gameprovider.LaunchRequest) (bool, error) {
return p.matchGameID != "" && req.GameID == p.matchGameID, nil
}
func (p *routeLaunchStubProvider) ListShortcutGames(context.Context, gameprovider.AuthUser, string) ([]gameprovider.RoomGameListItem, error) {
return nil, nil
}
func (p *routeLaunchStubProvider) ListRoomGames(context.Context, gameprovider.AuthUser, string, string) (*gameprovider.RoomGameListResponse, error) {
return &gameprovider.RoomGameListResponse{}, nil
}
func (p *routeLaunchStubProvider) GetRoomState(context.Context, gameprovider.AuthUser, string) (*gameprovider.RoomStateResponse, error) {
return &gameprovider.RoomStateResponse{}, nil
}
func (p *routeLaunchStubProvider) LaunchGame(_ context.Context, _ gameprovider.AuthUser, req gameprovider.LaunchRequest, _ string) (*gameprovider.LaunchResponse, error) {
if p.matchGameID == "" || req.GameID != p.matchGameID {
return nil, common.NewAppError(http.StatusNotFound, "game_not_found", "game not found")
}
return &gameprovider.LaunchResponse{
GameSessionID: "hg-session",
Provider: p.key,
GameID: req.GameID,
ProviderGameID: "1",
Entry: gameprovider.LaunchEntry{LaunchMode: "H5_REMOTE"},
RoomState: gameprovider.RoomStateResponse{
RoomID: req.RoomID,
State: "PLAYING",
Provider: p.key,
GameSessionID: "hg-session",
},
}, nil
}
func (p *routeLaunchStubProvider) CloseGame(context.Context, gameprovider.AuthUser, gameprovider.CloseRequest) (*gameprovider.RoomStateResponse, error) {
return &gameprovider.RoomStateResponse{}, nil
}

View File

@ -0,0 +1,115 @@
package router
import (
"net/http"
"strconv"
"strings"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/service/hotgame"
"github.com/gin-gonic/gin"
)
// registerHotgameRoutes 注册热游后台管理类 Go 接口。
func registerHotgameRoutes(engine *gin.Engine, javaClient authGateway, service *hotgame.Service) {
if service == nil {
return
}
consoleGroup := engine.Group("/operate/hotgame-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 hotgame.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 hotgame.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 hotgame.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 hotgame.SyncCatalogRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
defaultShowcase := true
if req.DefaultShowcase != nil {
defaultShowcase = *req.DefaultShowcase
}
resp, err := service.ImportCatalogGames(c.Request.Context(), req.SysOrigin, req.Profile, req.VendorGameIDs, defaultShowcase)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
consoleGroup.POST("/sync", func(c *gin.Context) {
var req hotgame.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

@ -37,6 +37,15 @@ func registerManagerCenterRoutes(engine *gin.Engine, javaClient authGateway, ser
writeOK(c, resp) writeOK(c, resp)
}) })
group.GET("/countries", func(c *gin.Context) {
resp, err := service.ListCountries(c.Request.Context(), mustAuthUser(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.GET("/props", func(c *gin.Context) { group.GET("/props", func(c *gin.Context) {
resp, err := service.ListProps(c.Request.Context(), mustAuthUser(c), c.Query("type")) resp, err := service.ListProps(c.Request.Context(), mustAuthUser(c), c.Query("type"))
if err != nil { if err != nil {
@ -110,4 +119,18 @@ func registerManagerCenterRoutes(engine *gin.Engine, javaClient authGateway, ser
} }
writeOK(c, resp) writeOK(c, resp)
}) })
group.POST("/users/country", func(c *gin.Context) {
var req managercenter.ChangeUserCountryRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, managercenter.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.ChangeUserCountry(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
} }

View File

@ -112,6 +112,9 @@ func trimBearer(authorization string) string {
// resolveClientIP 解析请求来源 IP。 // resolveClientIP 解析请求来源 IP。
func resolveClientIP(c *gin.Context) string { func resolveClientIP(c *gin.Context) string {
if realIP := strings.TrimSpace(c.GetHeader("X-Real-IP")); realIP != "" {
return realIP
}
if forwarded := strings.TrimSpace(c.GetHeader("X-Forwarded-For")); forwarded != "" { if forwarded := strings.TrimSpace(c.GetHeader("X-Forwarded-For")); forwarded != "" {
parts := strings.Split(forwarded, ",") parts := strings.Split(forwarded, ",")
if len(parts) > 0 { if len(parts) > 0 {

View File

@ -14,6 +14,7 @@ import (
"chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameopen"
"chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/gameprovider"
"chatapp3-golang/internal/service/hostcenter" "chatapp3-golang/internal/service/hostcenter"
"chatapp3-golang/internal/service/hotgame"
"chatapp3-golang/internal/service/invite" "chatapp3-golang/internal/service/invite"
"chatapp3-golang/internal/service/lingxian" "chatapp3-golang/internal/service/lingxian"
"chatapp3-golang/internal/service/luckygift" "chatapp3-golang/internal/service/luckygift"
@ -48,10 +49,12 @@ type Services struct {
Lingxian *lingxian.Service Lingxian *lingxian.Service
GameOpen *gameopen.GameOpenService GameOpen *gameopen.GameOpenService
GameProviders *gameprovider.Registry GameProviders *gameprovider.Registry
GameVisibility *gameprovider.VisibilityPolicy
LuckyGift *luckygift.LuckyGiftService LuckyGift *luckygift.LuckyGiftService
ManagerCenter *managercenter.Service ManagerCenter *managercenter.Service
PropsStore *propsstore.Service PropsStore *propsstore.Service
HostCenter *hostcenter.Service HostCenter *hostcenter.Service
Hotgame *hotgame.Service
RechargeAgency *rechargeagency.Service RechargeAgency *rechargeagency.Service
RechargeReward *rechargereward.Service RechargeReward *rechargereward.Service
RegionIMGroup *regionimgroup.Service RegionIMGroup *regionimgroup.Service
@ -100,9 +103,10 @@ func NewRouter(
registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward) registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward)
registerHostCenterRoutes(engine, services.HostCenter) registerHostCenterRoutes(engine, services.HostCenter)
registerGameOpenRoutes(engine, cfg, services.GameOpen) registerGameOpenRoutes(engine, cfg, services.GameOpen)
registerGameProviderRoutes(engine, javaClient, services.GameProviders) registerGameProviderRoutes(engine, javaClient, services.GameProviders, services.GameVisibility)
registerBaishunRoutes(engine, cfg, javaClient, services.Baishun) registerBaishunRoutes(engine, cfg, javaClient, services.Baishun)
registerLingxianRoutes(engine, javaClient, services.Lingxian) registerLingxianRoutes(engine, javaClient, services.Lingxian)
registerHotgameRoutes(engine, javaClient, services.Hotgame)
registerLuckyGiftRoutes(engine, cfg, services.LuckyGift) registerLuckyGiftRoutes(engine, cfg, services.LuckyGift)
registerManagerCenterRoutes(engine, javaClient, services.ManagerCenter) registerManagerCenterRoutes(engine, javaClient, services.ManagerCenter)
registerPropsStoreRoutes(engine, javaClient, services.PropsStore) registerPropsStoreRoutes(engine, javaClient, services.PropsStore)

View File

@ -13,7 +13,7 @@ import (
// ListShortcutGames 返回房间里的快捷游戏列表,数量上限为 5 个。 // ListShortcutGames 返回房间里的快捷游戏列表,数量上限为 5 个。
func (s *BaishunService) ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) { func (s *BaishunService) ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) {
items, err := s.listRoomGames(ctx, user.SysOrigin, roomID, "") items, err := s.listRoomGames(ctx, user.SysOrigin, user.RegionID, roomID, "")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -25,7 +25,7 @@ func (s *BaishunService) ListShortcutGames(ctx context.Context, user AuthUser, r
// ListRoomGames 返回房间可启动的完整游戏列表。 // ListRoomGames 返回房间可启动的完整游戏列表。
func (s *BaishunService) ListRoomGames(ctx context.Context, user AuthUser, roomID string, category string) (*RoomGameListResponse, error) { func (s *BaishunService) ListRoomGames(ctx context.Context, user AuthUser, roomID string, category string) (*RoomGameListResponse, error) {
items, err := s.listRoomGames(ctx, user.SysOrigin, roomID, category) items, err := s.listRoomGames(ctx, user.SysOrigin, user.RegionID, roomID, category)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -59,7 +59,7 @@ func (s *BaishunService) GetRoomState(ctx context.Context, user AuthUser, roomID
} }
// listRoomGames 联表读取房间可启动游戏,并按展示规则排序。 // listRoomGames 联表读取房间可启动游戏,并按展示规则排序。
func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, roomID, category string) ([]RoomGameListItem, error) { func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, regionID, roomID, category string) ([]RoomGameListItem, error) {
sysOrigin = normalizeAdminSysOrigin(sysOrigin) sysOrigin = normalizeAdminSysOrigin(sysOrigin)
profile, err := s.activeProfile(ctx, sysOrigin) profile, err := s.activeProfile(ctx, sysOrigin)
if err != nil { if err != nil {
@ -102,6 +102,10 @@ func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, roomID, c
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("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1 AND ext.profile = ?", profile).
Joins("LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND cat.profile = ext.profile AND CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id"). Joins("LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND cat.profile = ext.profile AND CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id").
Where("cfg.sys_origin = ? AND cfg.is_showcase = 1 AND ext.vendor_type = ?", sysOrigin, baishunVendorType) Where("cfg.sys_origin = ? AND cfg.is_showcase = 1 AND ext.vendor_type = ?", sysOrigin, baishunVendorType)
if strings.TrimSpace(regionID) != "" {
// regions 为空表示全区域;有值时按用户区域 ID 精确匹配,避免土耳其用户看到其他区域游戏。
query = query.Where("(COALESCE(TRIM(cfg.regions), '') = '' OR FIND_IN_SET(?, REPLACE(COALESCE(cfg.regions, ''), ' ', '')) > 0)", strings.TrimSpace(regionID))
}
if strings.TrimSpace(category) != "" && !strings.EqualFold(strings.TrimSpace(category), "CHAT_ROOM") { if strings.TrimSpace(category) != "" && !strings.EqualFold(strings.TrimSpace(category), "CHAT_ROOM") {
query = query.Where("cfg.category = ?", category) query = query.Where("cfg.category = ?", category)
} }

View File

@ -37,12 +37,13 @@ func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*ConfigRespo
} }
rewardItems := s.loadRewardItemsMap(ctx, levels) rewardItems := s.loadRewardItemsMap(ctx, levels)
return &ConfigResponse{ return &ConfigResponse{
Configured: true, Configured: true,
ID: configRow.ID, ID: configRow.ID,
SysOrigin: configRow.SysOrigin, SysOrigin: configRow.SysOrigin,
Enabled: configRow.Enabled, Enabled: configRow.Enabled,
UpdateTime: formatDateTime(configRow.UpdateTime), MinAppVersion: strings.TrimSpace(configRow.MinAppVersion),
LevelConfigs: buildLevelPayloads(levels, rewardItems, 0, 0, true), UpdateTime: formatDateTime(configRow.UpdateTime),
LevelConfigs: buildLevelPayloads(levels, rewardItems, 0, 0, true),
}, nil }, nil
} }
@ -56,6 +57,10 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*Confi
if req.Enabled && !hasEnabledLevel(levels) { if req.Enabled && !hasEnabledLevel(levels) {
return nil, NewAppError(http.StatusBadRequest, "invalid_level_configs", "enabled config must contain at least one enabled level") return nil, NewAppError(http.StatusBadRequest, "invalid_level_configs", "enabled config must contain at least one enabled level")
} }
minAppVersion := strings.TrimSpace(req.MinAppVersion)
if req.Enabled && minAppVersion == "" {
return nil, NewAppError(http.StatusBadRequest, "invalid_min_app_version", "minAppVersion is required when enabled")
}
var savedConfigID int64 var savedConfigID int64
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
@ -91,6 +96,7 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*Confi
configRow.SysOrigin = sysOrigin configRow.SysOrigin = sysOrigin
configRow.Enabled = req.Enabled configRow.Enabled = req.Enabled
configRow.MinAppVersion = minAppVersion
configRow.UpdateTime = now configRow.UpdateTime = now
if configRow.CreateTime.IsZero() { if configRow.CreateTime.IsZero() {
configRow.CreateTime = now configRow.CreateTime = now
@ -126,6 +132,7 @@ func (s *Service) normalizeLevelInputs(inputs []LevelInput) ([]model.FirstRechar
} }
seenLevels := make(map[int]struct{}, len(inputs)) seenLevels := make(map[int]struct{}, len(inputs))
seenAmounts := make(map[int64]struct{}, len(inputs)) seenAmounts := make(map[int64]struct{}, len(inputs))
seenGoogleProductIDs := make(map[string]struct{}, len(inputs))
rows := make([]model.FirstRechargeRewardLevel, 0, len(inputs)) rows := make([]model.FirstRechargeRewardLevel, 0, len(inputs))
for _, item := range inputs { for _, item := range inputs {
if item.Level <= 0 { if item.Level <= 0 {
@ -148,6 +155,13 @@ func (s *Service) normalizeLevelInputs(inputs []LevelInput) ([]model.FirstRechar
if _, exists := seenAmounts[amountCents]; exists { if _, exists := seenAmounts[amountCents]; exists {
return nil, NewAppError(http.StatusBadRequest, "duplicate_recharge_amount", "levelConfigs must not contain duplicate rechargeAmount") return nil, NewAppError(http.StatusBadRequest, "duplicate_recharge_amount", "levelConfigs must not contain duplicate rechargeAmount")
} }
googleProductID := normalizeGoogleProductID(item.GoogleProductID)
if googleProductID != "" {
if _, exists := seenGoogleProductIDs[googleProductID]; exists {
return nil, NewAppError(http.StatusBadRequest, "duplicate_google_product_id", "levelConfigs must not contain duplicate googleProductId")
}
seenGoogleProductIDs[googleProductID] = struct{}{}
}
seenLevels[item.Level] = struct{}{} seenLevels[item.Level] = struct{}{}
seenAmounts[amountCents] = struct{}{} seenAmounts[amountCents] = struct{}{}
id, err := utils.NextID() id, err := utils.NextID()
@ -158,6 +172,7 @@ func (s *Service) normalizeLevelInputs(inputs []LevelInput) ([]model.FirstRechar
ID: id, ID: id,
Level: item.Level, Level: item.Level,
RechargeAmountCents: amountCents, RechargeAmountCents: amountCents,
GoogleProductID: googleProductID,
RewardGroupID: rewardGroupID, RewardGroupID: rewardGroupID,
RewardGroupName: strings.TrimSpace(item.RewardGroupName), RewardGroupName: strings.TrimSpace(item.RewardGroupName),
Enabled: item.Enabled, Enabled: item.Enabled,

View File

@ -41,9 +41,6 @@ func (s *Service) ProcessRechargeEvent(ctx context.Context, event RechargeEvent)
} }
return nil, err return nil, err
} }
if !isFirstRechargeRewardAppVersionSupported(normalized.AppVersion) {
return &ProcessRechargeResponse{Processed: false, Reason: "app_version_not_supported"}, nil
}
if !isAcceptedPaymentMethod(normalized.PaymentMethod, normalized.PayPlatform) { if !isAcceptedPaymentMethod(normalized.PaymentMethod, normalized.PayPlatform) {
return &ProcessRechargeResponse{Processed: false, Reason: "unsupported_payment_method"}, nil return &ProcessRechargeResponse{Processed: false, Reason: "unsupported_payment_method"}, nil
} }
@ -61,9 +58,12 @@ func (s *Service) ProcessRechargeEvent(ctx context.Context, event RechargeEvent)
if !bundle.Config.Enabled { if !bundle.Config.Enabled {
return &ProcessRechargeResponse{Processed: false, Reason: "config_disabled"}, nil return &ProcessRechargeResponse{Processed: false, Reason: "config_disabled"}, nil
} }
matched, ok := pickMatchedLevel(bundle.Levels, normalized.AmountCents) if !isFirstRechargeRewardAppVersionSupported(normalized.AppVersion, bundle.Config.MinAppVersion) {
return &ProcessRechargeResponse{Processed: false, Reason: "app_version_not_supported"}, nil
}
matched, ok := pickMatchedLevel(bundle.Levels, normalized.AmountCents, normalized.GoogleProductID)
if !ok { if !ok {
return &ProcessRechargeResponse{Processed: false, Reason: "amount_not_reached"}, nil return &ProcessRechargeResponse{Processed: false, Reason: "level_not_matched"}, nil
} }
record, terminal, err := s.getOrCreateGrantRecord(ctx, normalized, bundle.Config, matched) record, terminal, err := s.getOrCreateGrantRecord(ctx, normalized, bundle.Config, matched)
@ -112,13 +112,14 @@ func (s *Service) normalizeRechargeEvent(event RechargeEvent) (normalizedRecharg
if amountCents <= 0 { if amountCents <= 0 {
amountCents = firstPositiveAmountCents(event.Amount.String(), event.AmountUSD.String(), event.USDAmount.String()) amountCents = firstPositiveAmountCents(event.Amount.String(), event.AmountUSD.String(), event.USDAmount.String())
} }
if amountCents <= 0 {
return normalizedRechargeEvent{}, NewAppError(http.StatusBadRequest, "invalid_amount", "amountCents or amount is required")
}
payPlatform := strings.ToUpper(strings.TrimSpace(event.PayPlatform)) payPlatform := strings.ToUpper(strings.TrimSpace(event.PayPlatform))
paymentMethod := strings.ToUpper(strings.TrimSpace(event.PaymentMethod)) paymentMethod := strings.ToUpper(strings.TrimSpace(event.PaymentMethod))
appVersion := normalizeRechargeAppVersion(event) appVersion := normalizeRechargeAppVersion(event)
googleProductID := normalizeRechargeGoogleProductID(event)
if amountCents <= 0 && googleProductID == "" {
return normalizedRechargeEvent{}, NewAppError(http.StatusBadRequest, "invalid_recharge_identity", "amountCents, amount, or googleProductId is required")
}
sourceOrderID := firstNonEmpty(event.SourceOrderID.String(), event.OrderID.String()) sourceOrderID := firstNonEmpty(event.SourceOrderID.String(), event.OrderID.String())
eventID := strings.TrimSpace(event.EventID) eventID := strings.TrimSpace(event.EventID)
if eventID == "" && sourceOrderID != "" { if eventID == "" && sourceOrderID != "" {
@ -132,16 +133,17 @@ func (s *Service) normalizeRechargeEvent(event RechargeEvent) (normalizedRecharg
occurredAt = time.Now() occurredAt = time.Now()
} }
return normalizedRechargeEvent{ return normalizedRechargeEvent{
EventID: eventID, EventID: eventID,
SysOrigin: sysOrigin, SysOrigin: sysOrigin,
UserID: userID, UserID: userID,
AmountCents: amountCents, AmountCents: amountCents,
Currency: firstNonEmpty(strings.ToUpper(strings.TrimSpace(event.Currency)), "USD"), Currency: firstNonEmpty(strings.ToUpper(strings.TrimSpace(event.Currency)), "USD"),
PayPlatform: payPlatform, PayPlatform: payPlatform,
PaymentMethod: paymentMethod, PaymentMethod: paymentMethod,
AppVersion: appVersion, AppVersion: appVersion,
SourceOrderID: sourceOrderID, GoogleProductID: googleProductID,
OccurredAt: occurredAt, SourceOrderID: sourceOrderID,
OccurredAt: occurredAt,
}, nil }, nil
} }
@ -187,6 +189,7 @@ func (s *Service) getOrCreateGrantRecord(
Level: level.Level, Level: level.Level,
PayPlatform: event.PayPlatform, PayPlatform: event.PayPlatform,
PaymentMethod: event.PaymentMethod, PaymentMethod: event.PaymentMethod,
GoogleProductID: event.GoogleProductID,
SourceOrderID: event.SourceOrderID, SourceOrderID: event.SourceOrderID,
RewardGroupID: level.RewardGroupID, RewardGroupID: level.RewardGroupID,
RewardGroupName: level.RewardGroupName, RewardGroupName: level.RewardGroupName,
@ -290,6 +293,7 @@ func (s *Service) pushRewardNotice(ctx context.Context, record *model.FirstRecha
"rewardGroupName": record.RewardGroupName, "rewardGroupName": record.RewardGroupName,
"payPlatform": record.PayPlatform, "payPlatform": record.PayPlatform,
"paymentMethod": record.PaymentMethod, "paymentMethod": record.PaymentMethod,
"googleProductId": record.GoogleProductID,
"noticeType": firstRechargeRewardNoticeType, "noticeType": firstRechargeRewardNoticeType,
} }
return s.java.SendOfficialNoticeCustomize(ctx, integration.OfficialNoticeCustomizeRequest{ return s.java.SendOfficialNoticeCustomize(ctx, integration.OfficialNoticeCustomizeRequest{
@ -372,6 +376,18 @@ func normalizeRechargeAppVersion(event RechargeEvent) string {
) )
} }
func normalizeRechargeGoogleProductID(event RechargeEvent) string {
return normalizeGoogleProductID(firstNonEmpty(
event.GoogleProductID,
event.ProductID.String(),
rechargePayloadString(event.Payload, "googleProductId", "productId", "product_id"),
))
}
func normalizeGoogleProductID(value string) string {
return strings.TrimSpace(value)
}
func rechargePayloadString(payload json.RawMessage, keys ...string) string { func rechargePayloadString(payload json.RawMessage, keys ...string) string {
if len(payload) == 0 { if len(payload) == 0 {
return "" return ""

View File

@ -20,7 +20,7 @@ const (
) )
// GetHome returns app-facing first recharge reward config and the user's grant state. // GetHome returns app-facing first recharge reward config and the user's grant state.
func (s *Service) GetHome(ctx context.Context, user AuthUser) (*HomeResponse, error) { func (s *Service) GetHome(ctx context.Context, user AuthUser, appVersion string) (*HomeResponse, error) {
sysOrigin, err := requireUser(user) sysOrigin, err := requireUser(user)
if err != nil { if err != nil {
return nil, err return nil, err
@ -52,12 +52,17 @@ func (s *Service) GetHome(ctx context.Context, user AuthUser) (*HomeResponse, er
ActivityStatus: activityStatusOngoing, ActivityStatus: activityStatusOngoing,
UserID: user.UserID, UserID: user.UserID,
SysOrigin: bundle.Config.SysOrigin, SysOrigin: bundle.Config.SysOrigin,
MinAppVersion: bundle.Config.MinAppVersion,
LevelConfigs: buildLevelPayloads(bundle.Levels, rewardItems, 0, 0, false), LevelConfigs: buildLevelPayloads(bundle.Levels, rewardItems, 0, 0, false),
HasRewardRecord: false, HasRewardRecord: false,
} }
if !bundle.Config.Enabled { if !bundle.Config.Enabled {
resp.ActivityStatus = activityStatusDisabled resp.ActivityStatus = activityStatusDisabled
} }
if bundle.Config.Enabled && !isFirstRechargeRewardAppVersionSupported(appVersion, bundle.Config.MinAppVersion) {
resp.Enabled = false
resp.ActivityStatus = activityStatusDisabled
}
record, ok, err := s.loadUserGrantRecord(ctx, bundle.Config.SysOrigin, user.UserID) record, ok, err := s.loadUserGrantRecord(ctx, bundle.Config.SysOrigin, user.UserID)
if err != nil { if err != nil {

View File

@ -12,10 +12,11 @@ import (
func configSnapshotFromModel(row model.FirstRechargeRewardConfig) configSnapshot { func configSnapshotFromModel(row model.FirstRechargeRewardConfig) configSnapshot {
return configSnapshot{ return configSnapshot{
ID: row.ID, ID: row.ID,
SysOrigin: strings.ToUpper(strings.TrimSpace(row.SysOrigin)), SysOrigin: strings.ToUpper(strings.TrimSpace(row.SysOrigin)),
Enabled: row.Enabled, Enabled: row.Enabled,
UpdateTime: row.UpdateTime, MinAppVersion: strings.TrimSpace(row.MinAppVersion),
UpdateTime: row.UpdateTime,
} }
} }
@ -24,6 +25,7 @@ func levelSnapshotFromModel(row model.FirstRechargeRewardLevel) levelSnapshot {
ID: row.ID, ID: row.ID,
Level: row.Level, Level: row.Level,
RechargeAmountCents: row.RechargeAmountCents, RechargeAmountCents: row.RechargeAmountCents,
GoogleProductID: normalizeGoogleProductID(row.GoogleProductID),
RewardGroupID: row.RewardGroupID, RewardGroupID: row.RewardGroupID,
RewardGroupName: strings.TrimSpace(row.RewardGroupName), RewardGroupName: strings.TrimSpace(row.RewardGroupName),
Enabled: row.Enabled, Enabled: row.Enabled,
@ -51,6 +53,7 @@ func buildLevelPayloads(
Level: level.Level, Level: level.Level,
RechargeAmount: formatAmountCents(level.RechargeAmountCents), RechargeAmount: formatAmountCents(level.RechargeAmountCents),
RechargeAmountCents: level.RechargeAmountCents, RechargeAmountCents: level.RechargeAmountCents,
GoogleProductID: level.GoogleProductID,
RewardGroupID: level.RewardGroupID, RewardGroupID: level.RewardGroupID,
RewardGroupName: level.RewardGroupName, RewardGroupName: level.RewardGroupName,
RewardItems: rewardItems[level.RewardGroupID], RewardItems: rewardItems[level.RewardGroupID],
@ -62,19 +65,27 @@ func buildLevelPayloads(
return payloads return payloads
} }
func pickMatchedLevel(levels []levelSnapshot, rechargeAmountCents int64) (levelSnapshot, bool) { func pickMatchedLevel(levels []levelSnapshot, rechargeAmountCents int64, googleProductID string) (levelSnapshot, bool) {
var matched levelSnapshot googleProductID = normalizeGoogleProductID(googleProductID)
ok := false if googleProductID != "" {
for _, level := range levels {
if !level.Enabled || level.RewardGroupID <= 0 || level.GoogleProductID == "" {
continue
}
if level.GoogleProductID == googleProductID {
return level, true
}
}
}
for _, level := range levels { for _, level := range levels {
if !level.Enabled || level.RechargeAmountCents <= 0 || level.RewardGroupID <= 0 { if !level.Enabled || level.RechargeAmountCents <= 0 || level.RewardGroupID <= 0 {
continue continue
} }
if rechargeAmountCents >= level.RechargeAmountCents && (!ok || level.RechargeAmountCents > matched.RechargeAmountCents) { if rechargeAmountCents == level.RechargeAmountCents {
matched = level return level, true
ok = true
} }
} }
return matched, ok return levelSnapshot{}, false
} }
func sortLevels(levels []levelSnapshot) { func sortLevels(levels []levelSnapshot) {

View File

@ -92,6 +92,7 @@ func grantRecordViewFromModel(row model.FirstRechargeRewardGrantRecord, rewardIt
Level: row.Level, Level: row.Level,
PayPlatform: row.PayPlatform, PayPlatform: row.PayPlatform,
PaymentMethod: row.PaymentMethod, PaymentMethod: row.PaymentMethod,
GoogleProductID: row.GoogleProductID,
SourceOrderID: row.SourceOrderID, SourceOrderID: row.SourceOrderID,
RewardGroupID: row.RewardGroupID, RewardGroupID: row.RewardGroupID,
RewardGroupName: row.RewardGroupName, RewardGroupName: row.RewardGroupName,

View File

@ -20,8 +20,9 @@ func TestSaveConfigReturnsRewardItems(t *testing.T) {
req := mustDecodeSaveConfigRequest(t, `{ req := mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI", "sysOrigin": "LIKEI",
"enabled": true, "enabled": true,
"minAppVersion": "1.5.0",
"levelConfigs": [ "levelConfigs": [
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true} {"level": 1, "rechargeAmount": "9.99", "googleProductId": "gold_999", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
] ]
}`) }`)
resp, err := service.SaveConfig(context.Background(), req) resp, err := service.SaveConfig(context.Background(), req)
@ -38,6 +39,9 @@ func TestSaveConfigReturnsRewardItems(t *testing.T) {
if level.RechargeAmountCents != 999 || level.RewardGroupID != 1001 { if level.RechargeAmountCents != 999 || level.RewardGroupID != 1001 {
t.Fatalf("level payload = %+v", level) t.Fatalf("level payload = %+v", level)
} }
if level.GoogleProductID != "gold_999" {
t.Fatalf("google product id = %q", level.GoogleProductID)
}
if len(level.RewardItems) != 1 || level.RewardItems[0].Name != "首冲礼包奖励" { if len(level.RewardItems) != 1 || level.RewardItems[0].Name != "首冲礼包奖励" {
t.Fatalf("reward items = %+v", level.RewardItems) t.Fatalf("reward items = %+v", level.RewardItems)
} }
@ -66,6 +70,7 @@ func TestSaveConfigReturnsVipCoverFromSourceURL(t *testing.T) {
resp, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{ resp, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI", "sysOrigin": "LIKEI",
"enabled": true, "enabled": true,
"minAppVersion": "1.5.0",
"levelConfigs": [ "levelConfigs": [
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "2001", "rewardGroupName": "VIP礼包", "enabled": true} {"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "2001", "rewardGroupName": "VIP礼包", "enabled": true}
] ]
@ -115,6 +120,7 @@ func TestRewardItemsReturnConcretePropsType(t *testing.T) {
resp, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{ resp, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI", "sysOrigin": "LIKEI",
"enabled": true, "enabled": true,
"minAppVersion": "1.5.0",
"levelConfigs": [ "levelConfigs": [
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "3001", "rewardGroupName": "道具礼包", "enabled": true} {"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "3001", "rewardGroupName": "道具礼包", "enabled": true}
] ]
@ -147,8 +153,9 @@ func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{ _, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI", "sysOrigin": "LIKEI",
"enabled": true, "enabled": true,
"minAppVersion": "1.5.0",
"levelConfigs": [ "levelConfigs": [
{"level": 1, "rechargeAmount": "5.00", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true} {"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
] ]
}`)) }`))
if err != nil { if err != nil {
@ -185,7 +192,7 @@ func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("low amount ProcessRechargeEvent() error = %v", err) t.Fatalf("low amount ProcessRechargeEvent() error = %v", err)
} }
if lowAmount.Processed || lowAmount.Reason != "amount_not_reached" { if lowAmount.Processed || lowAmount.Reason != "level_not_matched" {
t.Fatalf("low amount response = %+v", lowAmount) t.Fatalf("low amount response = %+v", lowAmount)
} }
@ -292,8 +299,9 @@ func TestProcessRechargeEventStartsCountingAtSupportedAppVersion(t *testing.T) {
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{ _, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI", "sysOrigin": "LIKEI",
"enabled": true, "enabled": true,
"minAppVersion": "1.5.0",
"levelConfigs": [ "levelConfigs": [
{"level": 1, "rechargeAmount": "5.00", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true} {"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
] ]
}`)) }`))
if err != nil { if err != nil {
@ -380,13 +388,86 @@ func TestFirstRechargeRewardAppVersionSupported(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if got := isFirstRechargeRewardAppVersionSupported(tt.appVersion); got != tt.want { if got := isFirstRechargeRewardAppVersionSupported(tt.appVersion, "1.5.0"); got != tt.want {
t.Fatalf("isFirstRechargeRewardAppVersionSupported(%q) = %v, want %v", tt.appVersion, got, tt.want) t.Fatalf("isFirstRechargeRewardAppVersionSupported(%q) = %v, want %v", tt.appVersion, got, tt.want)
} }
}) })
} }
} }
func TestHomeRequiresConfiguredMinAppVersion(t *testing.T) {
service, gateway, _ := newTestService(t)
gateway.groups[1001] = testRewardGroup(1001, "首冲礼包")
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI",
"enabled": true,
"minAppVersion": "2.0.0",
"levelConfigs": [
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
]
}`))
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
oldVersion, err := service.GetHome(context.Background(), AuthUser{UserID: 123, SysOrigin: "LIKEI"}, "1.9.9")
if err != nil {
t.Fatalf("old version GetHome() error = %v", err)
}
if oldVersion.Enabled || oldVersion.ActivityStatus != activityStatusDisabled {
t.Fatalf("old version home = %+v", oldVersion)
}
supported, err := service.GetHome(context.Background(), AuthUser{UserID: 123, SysOrigin: "LIKEI"}, "2.0.0")
if err != nil {
t.Fatalf("supported GetHome() error = %v", err)
}
if !supported.Enabled || supported.ActivityStatus != activityStatusOngoing {
t.Fatalf("supported home = %+v", supported)
}
}
func TestProcessRechargeEventMatchesGoogleProductIDBeforeAmount(t *testing.T) {
service, gateway, _ := newTestService(t)
gateway.groups[1001] = testRewardGroup(1001, "首冲礼包")
gateway.groups[1002] = testRewardGroup(1002, "Google 首冲礼包")
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI",
"enabled": true,
"minAppVersion": "1.5.0",
"levelConfigs": [
{"level": 1, "rechargeAmount": "9.99", "googleProductId": "gold_999", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true},
{"level": 2, "rechargeAmount": "19.99", "googleProductId": "first_recharge_google_1999", "rewardGroupId": "1002", "rewardGroupName": "Google 首冲礼包", "enabled": true}
]
}`))
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
granted, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:GOOGLE:PRODUCT_ID",
"sysOrigin": "LIKEI",
"userId": "123",
"amountCents": "999",
"payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE",
"googleProductId": "first_recharge_google_1999",
"appVersion": "1.5.0",
"sourceOrderId": "PRODUCT_ID"
}`))
if err != nil {
t.Fatalf("product id ProcessRechargeEvent() error = %v", err)
}
if !granted.Processed || granted.Status != statusSuccess || granted.Level != 2 {
t.Fatalf("product id response = %+v", granted)
}
if len(gateway.rewardRequests) != 1 || gateway.rewardRequests[0].SourceGroupID != 1002 {
t.Fatalf("reward requests = %+v", gateway.rewardRequests)
}
}
func newTestService(t *testing.T) (*Service, *fakeGateway, *gorm.DB) { func newTestService(t *testing.T) (*Service, *fakeGateway, *gorm.DB) {
t.Helper() t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})

View File

@ -65,6 +65,7 @@ type LevelPayload struct {
Level int `json:"level"` Level int `json:"level"`
RechargeAmount string `json:"rechargeAmount"` RechargeAmount string `json:"rechargeAmount"`
RechargeAmountCents int64 `json:"rechargeAmountCents"` RechargeAmountCents int64 `json:"rechargeAmountCents"`
GoogleProductID string `json:"googleProductId,omitempty"`
RewardGroupID int64 `json:"rewardGroupId,string"` RewardGroupID int64 `json:"rewardGroupId,string"`
RewardGroupName string `json:"rewardGroupName,omitempty"` RewardGroupName string `json:"rewardGroupName,omitempty"`
RewardItems []RewardItem `json:"rewardItems"` RewardItems []RewardItem `json:"rewardItems"`
@ -78,25 +79,28 @@ type LevelInput struct {
Level int `json:"level"` Level int `json:"level"`
RechargeAmount flexibleAmount `json:"rechargeAmount"` RechargeAmount flexibleAmount `json:"rechargeAmount"`
RechargeAmountCents int64 `json:"rechargeAmountCents"` RechargeAmountCents int64 `json:"rechargeAmountCents"`
GoogleProductID string `json:"googleProductId"`
RewardGroupID flexibleInt64 `json:"rewardGroupId"` RewardGroupID flexibleInt64 `json:"rewardGroupId"`
RewardGroupName string `json:"rewardGroupName"` RewardGroupName string `json:"rewardGroupName"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
} }
type ConfigResponse struct { type ConfigResponse struct {
Configured bool `json:"configured"` Configured bool `json:"configured"`
ID int64 `json:"id,string"` ID int64 `json:"id,string"`
SysOrigin string `json:"sysOrigin"` SysOrigin string `json:"sysOrigin"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
UpdateTime string `json:"updateTime,omitempty"` MinAppVersion string `json:"minAppVersion,omitempty"`
LevelConfigs []LevelPayload `json:"levelConfigs"` UpdateTime string `json:"updateTime,omitempty"`
LevelConfigs []LevelPayload `json:"levelConfigs"`
} }
type SaveConfigRequest struct { type SaveConfigRequest struct {
ID flexibleInt64 `json:"id"` ID flexibleInt64 `json:"id"`
SysOrigin string `json:"sysOrigin"` SysOrigin string `json:"sysOrigin"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
LevelConfigs []LevelInput `json:"levelConfigs"` MinAppVersion string `json:"minAppVersion"`
LevelConfigs []LevelInput `json:"levelConfigs"`
} }
type HomeResponse struct { type HomeResponse struct {
@ -108,6 +112,7 @@ type HomeResponse struct {
HasRewardRecord bool `json:"hasRewardRecord"` HasRewardRecord bool `json:"hasRewardRecord"`
RewardStatus string `json:"rewardStatus,omitempty"` RewardStatus string `json:"rewardStatus,omitempty"`
Rewarded bool `json:"rewarded"` Rewarded bool `json:"rewarded"`
MinAppVersion string `json:"minAppVersion,omitempty"`
MatchedLevel int `json:"matchedLevel,omitempty"` MatchedLevel int `json:"matchedLevel,omitempty"`
RechargeAmount string `json:"rechargeAmount,omitempty"` RechargeAmount string `json:"rechargeAmount,omitempty"`
RechargeAmountCents int64 `json:"rechargeAmountCents,omitempty"` RechargeAmountCents int64 `json:"rechargeAmountCents,omitempty"`
@ -141,6 +146,7 @@ type GrantRecordView struct {
Level int `json:"level"` Level int `json:"level"`
PayPlatform string `json:"payPlatform"` PayPlatform string `json:"payPlatform"`
PaymentMethod string `json:"paymentMethod"` PaymentMethod string `json:"paymentMethod"`
GoogleProductID string `json:"googleProductId,omitempty"`
SourceOrderID string `json:"sourceOrderId,omitempty"` SourceOrderID string `json:"sourceOrderId,omitempty"`
RewardGroupID int64 `json:"rewardGroupId,string"` RewardGroupID int64 `json:"rewardGroupId,string"`
RewardGroupName string `json:"rewardGroupName,omitempty"` RewardGroupName string `json:"rewardGroupName,omitempty"`
@ -166,24 +172,26 @@ type RewardItem struct {
} }
type RechargeEvent struct { type RechargeEvent struct {
EventID string `json:"eventId"` EventID string `json:"eventId"`
SysOrigin string `json:"sysOrigin"` SysOrigin string `json:"sysOrigin"`
UserID flexibleInt64 `json:"userId"` UserID flexibleInt64 `json:"userId"`
AmountCents flexibleInt64 `json:"amountCents"` AmountCents flexibleInt64 `json:"amountCents"`
Amount flexibleString `json:"amount"` Amount flexibleString `json:"amount"`
AmountUSD flexibleString `json:"amountUsd"` AmountUSD flexibleString `json:"amountUsd"`
USDAmount flexibleString `json:"usdAmount"` USDAmount flexibleString `json:"usdAmount"`
Currency string `json:"currency"` Currency string `json:"currency"`
PayPlatform string `json:"payPlatform"` PayPlatform string `json:"payPlatform"`
PaymentMethod string `json:"paymentMethod"` PaymentMethod string `json:"paymentMethod"`
AppVersion flexibleString `json:"appVersion"` GoogleProductID string `json:"googleProductId"`
ClientVersion flexibleString `json:"clientVersion"` ProductID flexibleString `json:"productId"`
Version flexibleString `json:"version"` AppVersion flexibleString `json:"appVersion"`
ReqVersion flexibleString `json:"reqVersion"` ClientVersion flexibleString `json:"clientVersion"`
SourceOrderID flexibleString `json:"sourceOrderId"` Version flexibleString `json:"version"`
OrderID flexibleString `json:"orderId"` ReqVersion flexibleString `json:"reqVersion"`
OccurredAt flexibleString `json:"occurredAt"` SourceOrderID flexibleString `json:"sourceOrderId"`
Payload json.RawMessage `json:"payload,omitempty"` OrderID flexibleString `json:"orderId"`
OccurredAt flexibleString `json:"occurredAt"`
Payload json.RawMessage `json:"payload,omitempty"`
} }
type ProcessRechargeResponse struct { type ProcessRechargeResponse struct {
@ -200,32 +208,35 @@ type configBundle struct {
} }
type configSnapshot struct { type configSnapshot struct {
ID int64 ID int64
SysOrigin string SysOrigin string
Enabled bool Enabled bool
UpdateTime time.Time MinAppVersion string
UpdateTime time.Time
} }
type levelSnapshot struct { type levelSnapshot struct {
ID int64 ID int64
Level int Level int
RechargeAmountCents int64 RechargeAmountCents int64
GoogleProductID string
RewardGroupID int64 RewardGroupID int64
RewardGroupName string RewardGroupName string
Enabled bool Enabled bool
} }
type normalizedRechargeEvent struct { type normalizedRechargeEvent struct {
EventID string EventID string
SysOrigin string SysOrigin string
UserID int64 UserID int64
AmountCents int64 AmountCents int64
Currency string Currency string
PayPlatform string PayPlatform string
PaymentMethod string PaymentMethod string
AppVersion string AppVersion string
SourceOrderID string GoogleProductID string
OccurredAt time.Time SourceOrderID string
OccurredAt time.Time
} }
type flexibleInt64 int64 type flexibleInt64 int64

View File

@ -5,14 +5,18 @@ import (
"strings" "strings"
) )
const firstRechargeRewardMinAppVersion = "1.5.0" const defaultFirstRechargeRewardMinAppVersion = "1.5.0"
func isFirstRechargeRewardAppVersionSupported(appVersion string) bool { func isFirstRechargeRewardAppVersionSupported(appVersion string, minAppVersion string) bool {
appVersion = strings.TrimSpace(appVersion) appVersion = strings.TrimSpace(appVersion)
minAppVersion = strings.TrimSpace(minAppVersion)
if minAppVersion == "" {
minAppVersion = defaultFirstRechargeRewardMinAppVersion
}
if appVersion == "" { if appVersion == "" {
return false return false
} }
return compareAppVersion(appVersion, firstRechargeRewardMinAppVersion) >= 0 return compareAppVersion(appVersion, minAppVersion) >= 0
} }
func compareAppVersion(left, right string) int { func compareAppVersion(left, right string) int {

View File

@ -0,0 +1,387 @@
package gameopen
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/service/taskcenter"
)
const (
hotgameVendorType = "HOTGAME"
hotgameCodeTokenInvalid = 1001
hotgameCodeInsufficientBalance = 2001
hotgameCodeDuplicateOrder = 3001
hotgameCodeBadRequest = 4001
hotgameCodeSignatureError = 4002
hotgameCodeServerError = 5000
)
// HotgameGetUserInfoRequest 是热游 /getUserInfo 回调入参。
type HotgameGetUserInfoRequest struct {
GameID string `json:"gameId"`
UID string `json:"uid"`
Token string `json:"token"`
Sign string `json:"sign"`
}
// HotgameUpdateBalanceRequest 是热游 /updateBalance 回调入参roundId 文档是 int所以这里兼容数字和字符串。
type HotgameUpdateBalanceRequest struct {
OrderID string `json:"orderId"`
GameID string `json:"gameId"`
RoundID any `json:"roundId"`
UID string `json:"uid"`
Coin int64 `json:"coin"`
Type int `json:"type"`
Token string `json:"token"`
Sign string `json:"sign"`
}
// HandleHotgameGetUserInfo 按热游协议查询玩家资料和余额。
func (s *GameOpenService) HandleHotgameGetUserInfo(ctx context.Context, req HotgameGetUserInfoRequest, rawJSON string) CallbackResponse {
if err := s.validateHotgameGetUserInfoRequest(req); err != nil {
resp := failResponse(hotgameCodeBadRequest, err.Error())
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, "", req.toQueryUserRequest(), nil, gameOpenLogStatusFailed)
return resp
}
appKey := s.resolveCallbackAppKeyForVendor(ctx, hotgameVendorType, req.GameID, req.Token)
if strings.TrimSpace(appKey) == "" {
resp := failResponse(hotgameCodeBadRequest, "app key is blank")
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, "", req.toQueryUserRequest(), nil, gameOpenLogStatusFailed)
return resp
}
if !verifySign(req.Sign, buildHotgameGetUserInfoSign(req, appKey)) {
resp := failResponse(hotgameCodeSignatureError, "signature error")
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, "", req.toQueryUserRequest(), nil, gameOpenLogStatusFailed)
return resp
}
req.Token = normalizeCallbackToken(req.Token)
credential, err := s.java.AuthenticateToken(ctx, req.Token)
if err != nil {
resp := failResponse(hotgameCodeTokenInvalid, "token invalid")
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, "", req.toQueryUserRequest(), nil, gameOpenLogStatusFailed)
return resp
}
resolved, err := s.resolveUserWithToken(ctx, credential, req.UID)
if err != nil {
resp := failResponse(hotgameCodeTokenInvalid, "uid token mismatch")
userID := int64(credential.UserID)
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, credential.SysOrigin, req.toQueryUserRequest(), &userID, gameOpenLogStatusFailed)
return resp
}
userID := resolved.userID
profile := resolved.profile
if int64(profile.ID) == 0 {
profile, err = s.java.GetUserProfile(ctx, userID)
}
if err != nil {
resp := failResponse(hotgameCodeServerError, "profile load failed")
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, credential.SysOrigin, req.toQueryUserRequest(), &userID, gameOpenLogStatusFailed)
return resp
}
balance, err := s.readUserBalance(ctx, userID)
if err != nil {
resp := failResponse(hotgameCodeServerError, "balance load failed")
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, credential.SysOrigin, req.toQueryUserRequest(), &userID, gameOpenLogStatusFailed)
return resp
}
resp := successResponse(map[string]any{
"uid": req.UID,
"nickname": profile.UserNickname,
"avatar": profile.UserAvatar,
"coin": balance,
"vipLevel": 0,
})
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, credential.SysOrigin, req.toQueryUserRequest(), &userID, gameOpenLogStatusSuccess)
return resp
}
// HandleHotgameUpdateBalance 按热游协议处理实时扣加币,并用 orderId 做钱包事件幂等。
func (s *GameOpenService) HandleHotgameUpdateBalance(ctx context.Context, req HotgameUpdateBalanceRequest, rawJSON string) CallbackResponse {
updateReq := req.toUpdateCoinRequest()
if err := s.validateHotgameUpdateBalanceRequest(req); err != nil {
resp := failResponse(hotgameCodeBadRequest, err.Error())
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, "", updateReq, nil, gameOpenLogStatusFailed)
return resp
}
appKey := s.resolveCallbackAppKeyForVendor(ctx, hotgameVendorType, req.GameID, req.Token)
if strings.TrimSpace(appKey) == "" {
resp := failResponse(hotgameCodeBadRequest, "app key is blank")
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, "", updateReq, nil, gameOpenLogStatusFailed)
return resp
}
if !verifySign(req.Sign, buildHotgameUpdateBalanceSign(req, appKey)) {
resp := failResponse(hotgameCodeSignatureError, "signature error")
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, "", updateReq, nil, gameOpenLogStatusFailed)
return resp
}
req.Token = normalizeCallbackToken(req.Token)
updateReq.Token = req.Token
credential, err := s.java.AuthenticateToken(ctx, req.Token)
if err != nil {
resp := failResponse(hotgameCodeTokenInvalid, "token invalid")
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, "", updateReq, nil, gameOpenLogStatusFailed)
return resp
}
resolved, err := s.resolveUserWithToken(ctx, credential, req.UID)
if err != nil {
resp := failResponse(hotgameCodeTokenInvalid, "uid token mismatch")
userID := int64(credential.UserID)
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed)
return resp
}
userID := resolved.userID
eventID := "GAME_HOTGAME:" + strings.TrimSpace(req.OrderID)
exists, err := s.java.ExistsGoldEvent(ctx, eventID)
if err != nil {
resp := failResponse(hotgameCodeServerError, "event check failed")
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed)
return resp
}
if exists {
resp := failResponse(hotgameCodeDuplicateOrder, "duplicate orderId")
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed)
return resp
}
if err := s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{
ReceiptType: receiptTypeFromType(req.Type),
UserID: userID,
SysOrigin: credential.SysOrigin,
EventID: eventID,
Remark: fmt.Sprintf("hotgame=%s type=%d round=%s", req.GameID, req.Type, updateReq.RoundID),
Amount: integration.NewPennyAmountPayloadFromDollar(req.Coin),
CloseDelayAsset: false,
OpUserType: "APP",
CustomizeOrigin: "GAME_HOTGAME_" + strings.TrimSpace(req.GameID),
CustomizeOriginDesc: "HOTGAME[" + strings.TrimSpace(req.GameID) + "]",
}); err != nil {
// 热游文档要求扣款失败必须返回失败码;支出失败统一归到余额不足类,防止游戏继续开奖。
code := hotgameCodeServerError
if req.Type == 1 || looksLikeInsufficientBalance(err) {
code = hotgameCodeInsufficientBalance
}
resp := failResponse(code, err.Error())
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed)
return resp
}
balance, err := s.readUserBalance(ctx, userID)
if err != nil {
resp := failResponse(hotgameCodeServerError, "balance load failed")
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed)
return resp
}
resp := successResponse(map[string]any{"coin": balance, "responseId": eventID})
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusSuccess)
s.reportHotgameConsumeTaskEvent(ctx, credential.SysOrigin, userID, updateReq)
return resp
}
func (req HotgameGetUserInfoRequest) toQueryUserRequest() QueryUserRequest {
return QueryUserRequest{
GameID: req.GameID,
UID: req.UID,
Token: req.Token,
Sign: req.Sign,
}
}
func (req HotgameUpdateBalanceRequest) toUpdateCoinRequest() UpdateCoinRequest {
return UpdateCoinRequest{
OrderID: req.OrderID,
GameID: req.GameID,
RoundID: hotgameRoundIDString(req.RoundID),
UID: req.UID,
Coin: req.Coin,
Type: req.Type,
Token: req.Token,
Sign: req.Sign,
}
}
func (s *GameOpenService) validateHotgameGetUserInfoRequest(req HotgameGetUserInfoRequest) error {
switch {
case strings.TrimSpace(req.GameID) == "":
return newBadRequest("gameId is required")
case strings.TrimSpace(req.UID) == "":
return newBadRequest("uid is required")
case strings.TrimSpace(req.Token) == "":
return newBadRequest("token is required")
case strings.TrimSpace(req.Sign) == "":
return newBadRequest("sign is required")
default:
return nil
}
}
func (s *GameOpenService) validateHotgameUpdateBalanceRequest(req HotgameUpdateBalanceRequest) error {
switch {
case strings.TrimSpace(req.OrderID) == "":
return newBadRequest("orderId is required")
case strings.TrimSpace(req.GameID) == "":
return newBadRequest("gameId is required")
case strings.TrimSpace(hotgameRoundIDString(req.RoundID)) == "":
return newBadRequest("roundId is required")
case strings.TrimSpace(req.UID) == "":
return newBadRequest("uid is required")
case req.Coin <= 0:
return newBadRequest("coin is required")
case req.Type != 1 && req.Type != 2:
return newBadRequest("type must be 1 or 2")
case strings.TrimSpace(req.Token) == "":
return newBadRequest("token is required")
case strings.TrimSpace(req.Sign) == "":
return newBadRequest("sign is required")
default:
return nil
}
}
func buildHotgameGetUserInfoSign(req HotgameGetUserInfoRequest, key string) string {
return md5Hex(req.GameID + req.UID + req.Token + key)
}
func buildHotgameUpdateBalanceSign(req HotgameUpdateBalanceRequest, key string) string {
return md5Hex(
req.OrderID +
req.GameID +
hotgameRoundIDString(req.RoundID) +
req.UID +
fmt.Sprintf("%d", req.Coin) +
fmt.Sprintf("%d", req.Type) +
req.Token +
key,
)
}
func hotgameRoundIDString(value any) string {
switch typed := value.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(typed)
case json.Number:
return strings.TrimSpace(typed.String())
case float64:
if typed == float64(int64(typed)) {
return fmt.Sprintf("%d", int64(typed))
}
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%f", typed), "0"), ".")
default:
return strings.TrimSpace(fmt.Sprint(typed))
}
}
func (s *GameOpenService) resolveCallbackAppKeyForVendor(ctx context.Context, vendorType string, gameID, token string) string {
if s != nil && s.repo.DB != nil {
if sysOrigin := callbackTokenSysOrigin(token); sysOrigin != "" {
if appKey := s.resolveActiveProviderAppKey(ctx, vendorType, sysOrigin); appKey != "" {
return appKey
}
}
if appKey := s.resolveProviderAppKeyByGame(ctx, vendorType, gameID); appKey != "" {
return appKey
}
}
if s == nil {
return ""
}
return strings.TrimSpace(s.cfg.GameOpen.AppKey)
}
func (s *GameOpenService) resolveProviderAppKeyByGame(ctx context.Context, vendorType string, gameID string) string {
gameID = strings.TrimSpace(gameID)
if gameID == "" {
return ""
}
switch strings.ToUpper(strings.TrimSpace(vendorType)) {
case "LINGXIAN":
return s.resolveLingxianAppKeyByGame(ctx, gameID)
case hotgameVendorType:
var row struct {
AppKey string
}
err := s.repo.DB.WithContext(ctx).
Table("hotgame_provider_config AS cfg").
Select("cfg.app_key AS app_key").
Joins("JOIN sys_game_list_vendor_ext ext ON ext.sys_origin = cfg.sys_origin AND ext.profile = cfg.profile AND ext.vendor_type = ? AND ext.enabled = ?", hotgameVendorType, true).
Where("cfg.active = ? AND ext.vendor_game_id = ?", true, gameID).
Order("cfg.update_time DESC").
Limit(1).
Scan(&row).Error
if err != nil {
return ""
}
return strings.TrimSpace(row.AppKey)
default:
return ""
}
}
func (s *GameOpenService) resolveActiveProviderAppKey(ctx context.Context, vendorType string, sysOrigin string) string {
switch strings.ToUpper(strings.TrimSpace(vendorType)) {
case "LINGXIAN":
return s.resolveActiveLingxianAppKey(ctx, sysOrigin)
case hotgameVendorType:
sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin))
if sysOrigin == "" {
return ""
}
var row model.HotgameProviderConfig
err := s.repo.DB.WithContext(ctx).
Where("sys_origin = ? AND active = ?", sysOrigin, true).
Order("update_time DESC").
Limit(1).
First(&row).Error
if err != nil {
return ""
}
return strings.TrimSpace(row.AppKey)
default:
return ""
}
}
func looksLikeInsufficientBalance(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "insufficient") ||
strings.Contains(message, "balance not made") ||
strings.Contains(message, "余额不足")
}
func (s *GameOpenService) reportHotgameConsumeTaskEvent(ctx context.Context, sysOrigin string, userID int64, req UpdateCoinRequest) {
if s.taskReporter == nil || receiptTypeFromType(req.Type) != "EXPENDITURE" {
return
}
deltaValue := req.Coin
if deltaValue < 0 {
deltaValue = -deltaValue
}
if deltaValue <= 0 {
return
}
payload := map[string]any{
"provider": hotgameVendorType,
"orderId": strings.TrimSpace(req.OrderID),
"gameId": strings.TrimSpace(req.GameID),
"roundId": strings.TrimSpace(req.RoundID),
}
if err := s.taskReporter.ReportSimpleEvent(ctx, sysOrigin, "GAME_HOTGAME_CONSUME:"+strings.TrimSpace(req.OrderID), taskcenter.EventTypeGameConsumeGold, userID, deltaValue, payload); err != nil {
log.Printf("report hotgame task event failed: %v", err)
}
}

View File

@ -219,20 +219,7 @@ func (s *GameOpenService) GetIntegrationInfo(ctx context.Context, publicBaseURL
} }
func (s *GameOpenService) resolveCallbackAppKey(ctx context.Context, gameID, token string) string { func (s *GameOpenService) resolveCallbackAppKey(ctx context.Context, gameID, token string) string {
if s != nil && s.repo.DB != nil { return s.resolveCallbackAppKeyForVendor(ctx, "LINGXIAN", gameID, token)
if sysOrigin := callbackTokenSysOrigin(token); sysOrigin != "" {
if appKey := s.resolveActiveLingxianAppKey(ctx, sysOrigin); appKey != "" {
return appKey
}
}
if appKey := s.resolveLingxianAppKeyByGame(ctx, gameID); appKey != "" {
return appKey
}
}
if s == nil {
return ""
}
return strings.TrimSpace(s.cfg.GameOpen.AppKey)
} }
func (s *GameOpenService) resolveLingxianAppKeyByGame(ctx context.Context, gameID string) string { func (s *GameOpenService) resolveLingxianAppKeyByGame(ctx context.Context, gameID string) string {

View File

@ -5,6 +5,7 @@ import (
"chatapp3-golang/internal/integration" "chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model" "chatapp3-golang/internal/model"
"context" "context"
"errors"
"testing" "testing"
"time" "time"
@ -20,6 +21,7 @@ type stubGateway struct {
accountMap map[string]integration.UserProfile accountMap map[string]integration.UserProfile
balance int64 balance int64
exists bool exists bool
changeErr error
cmd integration.GoldReceiptCommand cmd integration.GoldReceiptCommand
} }
@ -72,7 +74,7 @@ func (s *stubGateway) ExistsGoldEvent(context.Context, string) (bool, error) {
func (s *stubGateway) ChangeGoldBalance(_ context.Context, cmd integration.GoldReceiptCommand) error { func (s *stubGateway) ChangeGoldBalance(_ context.Context, cmd integration.GoldReceiptCommand) error {
s.cmd = cmd s.cmd = cmd
return nil return s.changeErr
} }
func TestHandleQueryUser(t *testing.T) { func TestHandleQueryUser(t *testing.T) {
@ -351,6 +353,97 @@ func TestCallbacksUseLingxianProviderConfigAppKey(t *testing.T) {
} }
} }
func TestHotgameGetUserInfoUsesHotgameSign(t *testing.T) {
gateway := &stubGateway{
credential: integration.UserCredential{UserID: 1234567, SysOrigin: "LIKEI"},
profile: integration.UserProfile{
ID: 1234567,
Account: "1234567",
UserNickname: "tester",
UserAvatar: "https://example.com/avatar.png",
},
balance: 88,
}
service := NewGameOpenService(config.Config{
GameOpen: config.GameOpenConfig{AppKey: "hotgame-key"},
}, nil, gateway)
req := HotgameGetUserInfoRequest{
GameID: "1",
UID: "1234567",
Token: "token",
}
req.Sign = buildHotgameGetUserInfoSign(req, "hotgame-key")
resp := service.HandleHotgameGetUserInfo(context.Background(), req, "{}")
if resp.ErrorCode != 0 {
t.Fatalf("HandleHotgameGetUserInfo() error = %+v", resp)
}
data, ok := resp.Data.(map[string]any)
if !ok {
t.Fatalf("HandleHotgameGetUserInfo() data type = %T", resp.Data)
}
if got := data["vipLevel"]; got != 0 {
t.Fatalf("HandleHotgameGetUserInfo() vipLevel = %v, want 0", got)
}
}
func TestHotgameUpdateBalanceAcceptsNumericRoundIDAndDuplicateReturns3001(t *testing.T) {
gateway := &stubGateway{
credential: integration.UserCredential{UserID: 1234567, SysOrigin: "LIKEI"},
profile: integration.UserProfile{ID: 1234567, Account: "1234567"},
balance: 99,
exists: true,
}
service := NewGameOpenService(config.Config{
GameOpen: config.GameOpenConfig{AppKey: "hotgame-key"},
}, nil, gateway)
req := HotgameUpdateBalanceRequest{
OrderID: "order-1",
GameID: "1",
RoundID: float64(3199),
UID: "1234567",
Coin: 15,
Type: 1,
Token: "token",
}
req.Sign = buildHotgameUpdateBalanceSign(req, "hotgame-key")
resp := service.HandleHotgameUpdateBalance(context.Background(), req, "{}")
if resp.ErrorCode != hotgameCodeDuplicateOrder {
t.Fatalf("HandleHotgameUpdateBalance() errorCode = %d, want %d", resp.ErrorCode, hotgameCodeDuplicateOrder)
}
if gateway.cmd.EventID != "" {
t.Fatalf("HandleHotgameUpdateBalance() should not change duplicate order, got %q", gateway.cmd.EventID)
}
}
func TestHotgameUpdateBalanceDeductFailureReturns2001(t *testing.T) {
gateway := &stubGateway{
credential: integration.UserCredential{UserID: 1234567, SysOrigin: "LIKEI"},
profile: integration.UserProfile{ID: 1234567, Account: "1234567"},
balance: 99,
changeErr: errors.New("insufficient_balance"),
}
service := NewGameOpenService(config.Config{
GameOpen: config.GameOpenConfig{AppKey: "hotgame-key"},
}, nil, gateway)
req := HotgameUpdateBalanceRequest{
OrderID: "order-1",
GameID: "1",
RoundID: float64(3199),
UID: "1234567",
Coin: 15,
Type: 1,
Token: "token",
}
req.Sign = buildHotgameUpdateBalanceSign(req, "hotgame-key")
resp := service.HandleHotgameUpdateBalance(context.Background(), req, "{}")
if resp.ErrorCode != hotgameCodeInsufficientBalance {
t.Fatalf("HandleHotgameUpdateBalance() errorCode = %d, want %d", resp.ErrorCode, hotgameCodeInsufficientBalance)
}
}
func newGameOpenTestDB(t *testing.T) *gorm.DB { func newGameOpenTestDB(t *testing.T) *gorm.DB {
t.Helper() t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
@ -359,6 +452,7 @@ func newGameOpenTestDB(t *testing.T) *gorm.DB {
} }
if err := db.AutoMigrate( if err := db.AutoMigrate(
&model.LingxianProviderConfig{}, &model.LingxianProviderConfig{},
&model.HotgameProviderConfig{},
&model.SysGameListVendorExt{}, &model.SysGameListVendorExt{},
&model.GameOpenCallbackLog{}, &model.GameOpenCallbackLog{},
); err != nil { ); err != nil {

View File

@ -79,7 +79,7 @@ func (r *Registry) List() ProviderListResponse {
// ListShortcutGames 返回聚合后的快捷游戏列表。 // ListShortcutGames 返回聚合后的快捷游戏列表。
func (r *Registry) ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) { func (r *Registry) ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) {
if isEmptyGameListUser(user.UserID) { if IsEmptyGameListUser(user.UserID) {
return []RoomGameListItem{}, nil return []RoomGameListItem{}, nil
} }
if r == nil || len(r.ordered) == 0 { if r == nil || len(r.ordered) == 0 {
@ -103,7 +103,7 @@ func (r *Registry) ListShortcutGames(ctx context.Context, user AuthUser, roomID
// ListRoomGames 返回聚合后的房间游戏列表。 // ListRoomGames 返回聚合后的房间游戏列表。
func (r *Registry) ListRoomGames(ctx context.Context, user AuthUser, roomID, category string) (*RoomGameListResponse, error) { func (r *Registry) ListRoomGames(ctx context.Context, user AuthUser, roomID, category string) (*RoomGameListResponse, error) {
if isEmptyGameListUser(user.UserID) { if IsEmptyGameListUser(user.UserID) {
return &RoomGameListResponse{Items: []RoomGameListItem{}}, nil return &RoomGameListResponse{Items: []RoomGameListItem{}}, nil
} }
if r == nil || len(r.ordered) == 0 { if r == nil || len(r.ordered) == 0 {
@ -124,7 +124,7 @@ func (r *Registry) ListRoomGames(ctx context.Context, user AuthUser, roomID, cat
return &RoomGameListResponse{Items: items}, nil return &RoomGameListResponse{Items: items}, nil
} }
func isEmptyGameListUser(userID int64) bool { func IsEmptyGameListUser(userID int64) bool {
_, ok := emptyGameListUserIDs[userID] _, ok := emptyGameListUserIDs[userID]
return ok return ok
} }
@ -251,6 +251,8 @@ func normalizeProviderAlias(key string) string {
switch normalizeKey(key) { switch normalizeKey(key) {
case "LEADER": case "LEADER":
return "LINGXIAN" return "LINGXIAN"
case "HOTGAME", "HOT_GAME", "REYOU", "LALU":
return "HOTGAME"
default: default:
return normalizeKey(key) return normalizeKey(key)
} }

View File

@ -80,6 +80,20 @@ func TestRegistryResolveMapsLeaderAliasToLingxian(t *testing.T) {
} }
} }
func TestRegistryResolveMapsHotgameAliases(t *testing.T) {
registry := NewRegistry(&stubProvider{key: "HOTGAME", name: "热游"})
for _, alias := range []string{"HOTGAME", "hot_game", "REYOU", "lalu"} {
provider, err := registry.Resolve(alias)
if err != nil {
t.Fatalf("Resolve(%q) error = %v", alias, err)
}
if provider.Key() != "HOTGAME" {
t.Fatalf("Resolve(%q) key = %q, want HOTGAME", alias, provider.Key())
}
}
}
func TestRegistryListKeepsRegistrationOrder(t *testing.T) { func TestRegistryListKeepsRegistrationOrder(t *testing.T) {
registry := NewRegistry( registry := NewRegistry(
&stubProvider{key: "BAISHUN", name: "百顺"}, &stubProvider{key: "BAISHUN", name: "百顺"},

View File

@ -0,0 +1,57 @@
package gameprovider
import (
"context"
"strings"
"chatapp3-golang/internal/integration"
)
const gameListTargetSysOrigin = "LIKEI"
// VisibilityGateway 聚合游戏列表门禁需要的 Java 用户区域能力。
type VisibilityGateway interface {
GetUserRegion(ctx context.Context, userID int64, authorization string) (integration.UserRegion, error)
}
// VisibilityResult 返回门禁结果,并把已经查到的区域带回路由继续参与 SQL 过滤。
type VisibilityResult struct {
Allow bool
RegionID string
RegionCode string
}
// VisibilityPolicy 判断当前用户是否可以看到游戏列表。
type VisibilityPolicy struct {
java VisibilityGateway
targetSys string
}
// NewVisibilityPolicy 创建 LIKEI 游戏列表门禁:土耳其财富等级限制已下线,现在对所有区域直接展示,
// 保留 Evaluate 是因为路由仍依赖它查询用户区域给 provider SQL 做 regions 过滤。
func NewVisibilityPolicy(java VisibilityGateway) *VisibilityPolicy {
return &VisibilityPolicy{
java: java,
targetSys: gameListTargetSysOrigin,
}
}
// Evaluate 给 LIKEI 用户补齐区域信息并放行;非 LIKEI 用户沿用自带区域直接展示。
func (p *VisibilityPolicy) Evaluate(ctx context.Context, user AuthUser) (VisibilityResult, error) {
if p == nil || !strings.EqualFold(strings.TrimSpace(user.SysOrigin), p.targetSys) {
return VisibilityResult{Allow: true, RegionID: user.RegionID, RegionCode: user.RegionCode}, nil
}
if user.UserID <= 0 || p.java == nil {
return VisibilityResult{}, nil
}
region, err := p.java.GetUserRegion(ctx, user.UserID, user.Authorization)
if err != nil {
return VisibilityResult{}, err
}
return VisibilityResult{
Allow: true,
RegionID: strings.TrimSpace(region.RegionID),
RegionCode: strings.TrimSpace(region.RegionCode),
}, nil
}

View File

@ -0,0 +1,80 @@
package gameprovider
import (
"context"
"testing"
"chatapp3-golang/internal/integration"
)
type stubVisibilityGateway struct {
region integration.UserRegion
}
func (s stubVisibilityGateway) GetUserRegion(context.Context, int64, string) (integration.UserRegion, error) {
return s.region, nil
}
const account1007500UserID int64 = 2054163533368717314
func TestVisibilityPolicyAllowsAllRegions(t *testing.T) {
tests := []struct {
name string
region integration.UserRegion
}{
{
name: "turkey region is visible without wealth gate",
region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
},
{
name: "south asia region is visible",
region: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
policy := NewVisibilityPolicy(stubVisibilityGateway{region: tt.region})
result, err := policy.Evaluate(context.Background(), AuthUser{UserID: account1007500UserID, SysOrigin: "LIKEI"})
if err != nil {
t.Fatalf("Evaluate() error = %v", err)
}
if !result.Allow {
t.Fatalf("Evaluate() allow = false, want true")
}
if result.RegionID != tt.region.RegionID || result.RegionCode != tt.region.RegionCode {
t.Fatalf("Evaluate() region = %#v, want %#v", result, tt.region)
}
})
}
}
func TestVisibilityPolicySkipsOtherSysOrigin(t *testing.T) {
policy := NewVisibilityPolicy(stubVisibilityGateway{
region: integration.UserRegion{RegionID: "2046067036517363714", RegionCode: "AR"},
})
result, err := policy.Evaluate(context.Background(), AuthUser{UserID: 1001, SysOrigin: "ASWAT", RegionID: "r-1", RegionCode: "SA"})
if err != nil {
t.Fatalf("Evaluate() error = %v", err)
}
if !result.Allow {
t.Fatalf("Evaluate() allow = false, want true")
}
if result.RegionID != "r-1" || result.RegionCode != "SA" {
t.Fatalf("Evaluate() region = %#v, want user-provided region", result)
}
}
func TestVisibilityPolicyDeniesInvalidUser(t *testing.T) {
policy := NewVisibilityPolicy(stubVisibilityGateway{})
result, err := policy.Evaluate(context.Background(), AuthUser{UserID: 0, SysOrigin: "LIKEI"})
if err != nil {
t.Fatalf("Evaluate() error = %v", err)
}
if result.Allow {
t.Fatalf("Evaluate() allow = true, want false for invalid user")
}
}

View File

@ -0,0 +1,315 @@
package hotgame
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/service/gameprovider"
"gorm.io/gorm"
)
// Key 返回统一厂商标识,和后台游戏来源 HOTGAME 保持一致。
func (p *AppProvider) Key() string {
return vendorType
}
// DisplayName 返回后台和调试接口展示名。
func (p *AppProvider) DisplayName() string {
return displayName
}
// SupportsLaunch 判断统一启动请求是否命中热游配置。
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
}
// ListShortcutGames 返回统一快捷列表,热游沿用完整列表前 5 个。
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
}
// ListRoomGames 返回用户当前区域可见的热游房间游戏列表。
func (p *AppProvider) ListRoomGames(ctx context.Context, user gameprovider.AuthUser, roomID, category string) (*gameprovider.RoomGameListResponse, error) {
items, err := p.service.listRoomGames(ctx, user.SysOrigin, user.RegionID, category)
if err != nil {
return nil, err
}
return &gameprovider.RoomGameListResponse{Items: items}, nil
}
// GetRoomState 热游 H5 不需要 Go 持久化房间状态,统一入口只返回空闲态。
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
}
// LaunchGame 生成热游 H5 URLuid/token/lang 会被替换进热游提供的启动链接。
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)
sessionID := fmt.Sprintf("hg_%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(),
Orientation: row.orientation(),
SafeHeight: row.safeHeight(),
},
LaunchConfig: map[string]any{
"uid": launchUID(runtimeCfg, user),
"token": launchToken(runtimeCfg, user),
"lang": launchLang(req),
"gameId": row.VendorGameID,
},
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
}
// CloseGame 热游关闭只影响客户端 WebViewGo 侧不保留会话状态。
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, regionID, 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(regionID) != "" {
// 统一游戏列表按运营配置的区域 ID 过滤;空 regions 表示所有区域都可见。
query = query.Where("(COALESCE(TRIM(cfg.regions), '') = '' OR FIND_IN_SET(?, REPLACE(COALESCE(cfg.regions, ''), ' ', '')) > 0)", strings.TrimSpace(regionID))
}
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", "hotgame 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.orientation AS ext_orientation,
ext.safe_height AS ext_safe_height,
ext.extra_json AS ext_extra_json,
cat.name AS catalog_name,
cat.cover AS catalog_cover,
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 hotgame_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,
SafeHeight: r.safeHeight(),
Orientation: r.orientation(),
PackageVersion: r.packageVersion(),
Status: defaultIfBlank(r.CatalogStatus, catalogEnabled),
LaunchParams: map[string]any{
"gameType": vendorType,
"lang": defaultLaunchLang,
"screenMode": defaultIfBlank(catalogRawMode(r.ExtExtraJSON), "seven"),
},
}
}
func (r gameRow) name() string {
return defaultIfBlank(r.Name, r.CatalogName)
}
func (r gameRow) cover() string {
// 热游的 preview_url 是带用户占位符的 H5 启动地址,不是图片资源;封面为空时直接返回空值,避免客户端把启动页当图片加载。
return defaultIfBlank(r.Cover, r.CatalogCover)
}
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 (r gameRow) orientation() int {
if r.ExtOrientation != nil {
return *r.ExtOrientation
}
return 0
}
func (r gameRow) safeHeight() int {
return r.ExtSafeHeight
}
func buildLaunchURL(entry string, cfg runtimeConfig, user gameprovider.AuthUser, req gameprovider.LaunchRequest) string {
entry = strings.TrimSpace(entry)
uid := launchUID(cfg, user)
token := launchToken(cfg, user)
lang := launchLang(req)
replaced := strings.NewReplacer(
"{uid}", url.QueryEscape(uid),
"{token}", url.QueryEscape(token),
"{lang}", url.QueryEscape(lang),
).Replace(entry)
if replaced != entry {
return replaced
}
parsed, err := url.Parse(entry)
if err != nil {
return entry
}
query := parsed.Query()
query.Set("uid", uid)
query.Set("token", token)
query.Set("lang", lang)
parsed.RawQuery = query.Encode()
return parsed.String()
}
func launchUID(cfg runtimeConfig, user gameprovider.AuthUser) string {
return defaultIfBlank(cfg.TestUID, strconv.FormatInt(user.UserID, 10))
}
func launchToken(cfg runtimeConfig, user gameprovider.AuthUser) string {
return defaultIfBlank(cfg.TestToken, user.Token)
}
func launchLang(req gameprovider.LaunchRequest) string {
for _, key := range []string{"lang", "language", "locale"} {
if req.Params == nil {
break
}
if value := strings.TrimSpace(fmt.Sprint(req.Params[key])); value != "" && value != "<nil>" {
return normalizeLaunchLang(value)
}
}
return defaultLaunchLang
}
func normalizeLaunchLang(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if strings.Contains(value, "-") {
value = strings.SplitN(value, "-", 2)[0]
}
switch value {
case "en", "ar", "id", "tr", "ur", "vi":
return value
default:
return defaultLaunchLang
}
}

View File

@ -0,0 +1,446 @@
package hotgame
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var defaultCatalogGames = []defaultCatalogGame{
{
GameID: "1",
Name: "幸运77(Lucky77)",
TestURL: "https://hk.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}",
ProdURL: "https://cdn-res.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}",
},
{
GameID: "13",
Name: "海盗捕鱼(PirateFishing)",
TestURL: "https://hk.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
ProdURL: "https://cdn-res.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
},
{
GameID: "19",
Name: "FortuneSlot (宝石solt)",
TestURL: "https://hk.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
ProdURL: "https://cdn-res.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
},
{
GameID: "15",
Name: "美味摩天轮(Delicious)",
TestURL: "https://hk.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
ProdURL: "https://cdn-res.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
},
{
GameID: "20",
Name: "水果派对(FruitParty)",
TestURL: "https://hk.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
ProdURL: "https://cdn-res.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
},
{
GameID: "16",
Name: "太空摩天轮(GreedyStar)",
TestURL: "https://hk.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
ProdURL: "https://cdn-res.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
},
}
// SyncCatalog 把表格里确认过的热游可用游戏同步到本地目录表。
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
}
if _, err := s.requireRuntimeConfigForProfile(ctx, sysOrigin, profile); err != nil {
return nil, err
}
filter := buildVendorGameIDFilter(req.VendorGameIDs)
resp := &SyncCatalogResponse{}
for _, game := range defaultCatalogGames {
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()
launchURL := hotgameLaunchURLForProfile(game, profile)
record := model.HotgameGameCatalog{
ID: id,
SysOrigin: sysOrigin,
Profile: profile,
InternalGameID: internalGameID(vendorGameID),
VendorGameID: vendorGameID,
Name: game.Name,
Cover: "",
PreviewURL: launchURL,
DownloadURL: launchURL,
PackageVersion: "2026",
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
}
// PageCatalog 返回热游目录,并标记每个目录游戏是否已经进入统一游戏列表。
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("CAST(cat.vendor_game_id AS UNSIGNED) 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("hotgame_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
}
// FetchAdminCatalog 供后台按钮调用,热游没有远程目录接口,因此读取内置对接表目录。
func (s *Service) FetchAdminCatalog(ctx context.Context, req SyncCatalogRequest) (*SyncCatalogResponse, error) {
return s.SyncCatalog(ctx, req)
}
// ImportCatalogGames 把热游目录导入统一游戏列表app 侧列表随后会通过统一 provider 返回。
func (s *Service) ImportCatalogGames(ctx context.Context, sysOrigin, profile string, vendorGameIDs []string, defaultShowcase ...bool) (*ImportCatalogResponse, error) {
sysOrigin = normalizeSysOrigin(sysOrigin)
profile, err := s.requestProfile(ctx, sysOrigin, profile)
if err != nil {
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), resolveImportDefaultShowcase(defaultShowcase...))
if err != nil {
tx.Rollback()
return nil, err
}
if err := tx.Commit().Error; err != nil {
return nil, err
}
s.clearJavaGameListCache(ctx)
return resp, nil
}
// SyncAdminGames 一次完成目录同步和统一游戏列表导入,便于后台首次配置。
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, resolveBoolPtrDefault(req.DefaultShowcase, true))
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{}, defaultShowcase bool) (*ImportCatalogResponse, error) {
query := tx.Where("sys_origin = ? AND profile = ? AND status = ?", sysOrigin, profile, catalogEnabled)
if len(filter) > 0 {
ids := make([]string, 0, len(filter))
for id := range filter {
ids = append(ids, id)
}
sort.Strings(ids)
query = query.Where("vendor_game_id IN ?", ids)
}
var catalogs []model.HotgameGameCatalog
if err := query.Order("CAST(vendor_game_id AS UNSIGNED) 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()
fullScreen := false
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 {
if err := s.refreshExistingGame(tx, ext.GameListConfigID, catalog, fullScreen); err != nil {
return nil, err
}
resp.Existing++
continue
}
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,
// 热游启动地址包含 uid/token/lang 占位符且整体很长,不能在缺少封面时回填到 cover封面为空时交给客户端展示默认占位真正启动地址只放在扩展表里。
Cover: hotgameCatalogCover(catalog),
Showcase: defaultShowcase,
Sort: parseSort(vendorGameID),
FullScreen: fullScreen,
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: `{"launchParams":{"gameType":"HOTGAME","screenMode":"seven"}}`,
Enabled: true,
CreateTime: now,
UpdateTime: now,
}
if err := tx.Create(&newExt).Error; err != nil {
return nil, err
}
resp.Imported++
}
return resp, nil
}
func (s *Service) refreshExistingGame(tx *gorm.DB, configID int64, catalog model.HotgameGameCatalog, fullScreen bool) error {
// 目录链接来自热游正式表格,重新同步时要同时刷新主表展示信息和扩展表启动入口,避免旧 URL 继续下发给 app。
if err := tx.Model(&model.SysGameListConfig{}).
Where("id = ? AND game_origin = ?", configID, vendorType).
Updates(map[string]any{
"name": catalog.Name,
// 这里和新导入保持一致,只刷新真实封面,避免把 H5 启动链接写入封面列导致 MySQL 长度错误。
"cover": hotgameCatalogCover(catalog),
"full_screen": fullScreen,
}).Error; err != nil {
return err
}
return tx.Model(&model.SysGameListVendorExt{}).
Where("game_list_config_id = ? AND vendor_type = ?", configID, vendorType).
Updates(map[string]any{
"package_version": catalog.PackageVersion,
"package_url": catalog.DownloadURL,
"preview_url": catalog.PreviewURL,
"extra_json": `{"launchParams":{"gameType":"HOTGAME","screenMode":"seven"}}`,
"update_time": time.Now(),
}).Error
}
func hotgameLaunchURLForProfile(game defaultCatalogGame, profile string) string {
if normalizeProfile(profile) == profileTest {
return strings.TrimSpace(game.TestURL)
}
return strings.TrimSpace(game.ProdURL)
}
func hotgameCatalogCover(catalog model.HotgameGameCatalog) string {
return strings.TrimSpace(catalog.Cover)
}
func resolveImportDefaultShowcase(values ...bool) bool {
if len(values) == 0 {
return true
}
return values[0]
}
func resolveBoolPtrDefault(value *bool, fallback bool) bool {
if value == nil {
return fallback
}
return *value
}
func buildVendorGameIDFilter(vendorGameIDs []string) map[string]struct{} {
filter := make(map[string]struct{}, len(vendorGameIDs))
for _, id := range vendorGameIDs {
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 catalogRawMode(rawJSON string) string {
var raw struct {
Mode string `json:"mode"`
}
_ = json.Unmarshal([]byte(strings.TrimSpace(rawJSON)), &raw)
return strings.TrimSpace(raw.Mode)
}

View File

@ -0,0 +1,135 @@
package hotgame
import (
"context"
"strings"
"testing"
"time"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/service/gameprovider"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestSyncImportAndListRoomGames(t *testing.T) {
db := newHotgameTestDB(t)
service := NewService(config.Config{}, db, nil)
resp, err := service.SyncCatalog(context.Background(), SyncCatalogRequest{
SysOrigin: "LIKEI",
Profile: "PROD",
VendorGameIDs: []string{"1"},
})
if err != nil {
t.Fatalf("SyncCatalog() error = %v", err)
}
if resp.Inserted != 1 {
t.Fatalf("SyncCatalog() inserted = %d, want 1", resp.Inserted)
}
importResp, err := service.ImportCatalogGames(context.Background(), "LIKEI", "PROD", []string{"1"}, true)
if err != nil {
t.Fatalf("ImportCatalogGames() error = %v", err)
}
if importResp.Imported != 1 {
t.Fatalf("ImportCatalogGames() imported = %d, want 1", importResp.Imported)
}
var cfg model.SysGameListConfig
if err := db.Where("game_origin = ? AND game_id = ?", vendorType, "hg_1").First(&cfg).Error; err != nil {
t.Fatalf("load imported game config: %v", err)
}
if cfg.Cover != "" {
t.Fatalf("imported cover = %q, want blank because launch url must stay out of cover", cfg.Cover)
}
var ext model.SysGameListVendorExt
if err := db.Where("game_list_config_id = ? AND vendor_type = ?", cfg.ID, vendorType).First(&ext).Error; err != nil {
t.Fatalf("load imported vendor ext: %v", err)
}
if !strings.Contains(ext.PreviewURL, "uid={uid}") || !strings.Contains(ext.PackageURL, "token={token}") {
t.Fatalf("vendor ext lost launch url placeholders: preview=%q package=%q", ext.PreviewURL, ext.PackageURL)
}
provider := NewAppProvider(service)
list, err := provider.ListRoomGames(context.Background(), gameprovider.AuthUser{UserID: 1234567, SysOrigin: "LIKEI"}, "room-1", "")
if err != nil {
t.Fatalf("ListRoomGames() error = %v", err)
}
if len(list.Items) != 1 {
t.Fatalf("ListRoomGames() items = %d, want 1", len(list.Items))
}
item := list.Items[0]
if item.Provider != vendorType || item.ProviderGameID != "1" || item.GameID != "hg_1" {
t.Fatalf("ListRoomGames() item = %+v", item)
}
if item.Cover != "" {
t.Fatalf("ListRoomGames() cover = %q, want blank because hotgame preview url is not an image", item.Cover)
}
if item.LaunchParams["screenMode"] != "seven" {
t.Fatalf("ListRoomGames() screenMode = %v, want seven", item.LaunchParams["screenMode"])
}
}
func TestLaunchGameReplacesHotgameURLParams(t *testing.T) {
db := newHotgameTestDB(t)
service := NewService(config.Config{}, db, nil)
if _, err := service.SyncAdminGames(context.Background(), SyncCatalogRequest{
SysOrigin: "LIKEI",
Profile: "PROD",
VendorGameIDs: []string{"1"},
}); err != nil {
t.Fatalf("SyncAdminGames() error = %v", err)
}
provider := NewAppProvider(service)
resp, err := provider.LaunchGame(
context.Background(),
gameprovider.AuthUser{UserID: 1234567, SysOrigin: "LIKEI", Token: "token=="},
gameprovider.LaunchRequest{RoomID: "room-1", GameID: "hg_1", Params: map[string]any{"lang": "tr-TR"}},
"127.0.0.1",
)
if err != nil {
t.Fatalf("LaunchGame() error = %v", err)
}
if !strings.Contains(resp.Entry.EntryURL, "uid=1234567") {
t.Fatalf("LaunchGame() url missing uid: %s", resp.Entry.EntryURL)
}
if !strings.Contains(resp.Entry.EntryURL, "token=token%3D%3D") {
t.Fatalf("LaunchGame() url missing escaped token: %s", resp.Entry.EntryURL)
}
if !strings.Contains(resp.Entry.EntryURL, "lang=tr") {
t.Fatalf("LaunchGame() url missing lang: %s", resp.Entry.EntryURL)
}
}
func newHotgameTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(
&model.HotgameProviderConfig{},
&model.HotgameGameCatalog{},
&model.SysGameListConfig{},
&model.SysGameListVendorExt{},
); err != nil {
t.Fatalf("migrate sqlite: %v", err)
}
if err := db.Create(&model.HotgameProviderConfig{
ID: 1,
SysOrigin: "LIKEI",
Profile: "PROD",
Active: true,
AppKey: "hotgame-key",
CreateTime: time.Now(),
UpdateTime: time.Now(),
}).Error; err != nil {
t.Fatalf("create provider config: %v", err)
}
return db
}

View File

@ -0,0 +1,203 @@
package hotgame
import (
"context"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (s *Service) defaultRuntimeConfig(sysOrigin string) runtimeConfig {
return runtimeConfig{
SysOrigin: normalizeSysOrigin(sysOrigin),
Profile: profileProd,
AppKey: strings.TrimSpace(s.cfg.Hotgame.AppKey),
CallbackBaseURL: strings.TrimSpace(s.cfg.Hotgame.CallbackBaseURL),
}
}
func (s *Service) activeProfile(ctx context.Context, sysOrigin string) (string, error) {
sysOrigin = normalizeSysOrigin(sysOrigin)
var row model.HotgameProviderConfig
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.HotgameProviderConfig
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.AppKey) == "" {
return runtimeConfig{}, common.NewAppError(http.StatusBadRequest, "hotgame_config_missing", "hotgame appKey is required")
}
return cfg, nil
}
// GetProviderConfig 读取某个系统、某个环境的热游配置,并附带当前启用环境。
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.HotgameProviderConfig
_ = 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,
AppKey: runtimeCfg.AppKey,
CallbackBaseURL: runtimeCfg.CallbackBaseURL,
TestUID: runtimeCfg.TestUID,
TestToken: runtimeCfg.TestToken,
UpdateTime: formatTime(row.UpdateTime),
}, nil
}
// SaveProviderConfig 保存热游配置;首个配置自动设为当前启用环境。
func (s *Service) SaveProviderConfig(ctx context.Context, req SaveProviderConfigRequest) (*ProviderConfigItem, error) {
sysOrigin := normalizeSysOrigin(req.SysOrigin)
profile := normalizeProfile(req.Profile)
appKey := strings.TrimSpace(req.AppKey)
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.HotgameProviderConfig{
ID: id,
SysOrigin: sysOrigin,
Profile: profile,
AppKey: appKey,
CallbackBaseURL: strings.TrimSpace(req.CallbackBaseURL),
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.HotgameProviderConfig{}).
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{
"app_key", "callback_base_url", "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)
}
// ActivateProviderProfile 切换 app 侧读取的热游环境,并清理 Java 老游戏列表缓存。
func (s *Service) ActivateProviderProfile(ctx context.Context, req ActivateProviderProfileRequest) (*ProviderConfigItem, error) {
sysOrigin := normalizeSysOrigin(req.SysOrigin)
profile := normalizeProfile(req.Profile)
var row model.HotgameProviderConfig
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.HotgameProviderConfig{}).
Where("sys_origin = ?", sysOrigin).
Updates(map[string]any{"active": false, "update_time": now}).Error; err != nil {
return err
}
return tx.Model(&model.HotgameProviderConfig{}).
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,235 @@
package hotgame
import (
"context"
"strings"
"time"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/service/gameprovider"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
const (
vendorType = "HOTGAME"
displayName = "热游"
profileProd = "PROD"
profileTest = "TEST"
launchModeH5 = "H5_REMOTE"
catalogEnabled = "ENABLED"
roomCategory = "CHAT_ROOM"
defaultLaunchLang = "en"
)
type dbHandle interface {
WithContext(ctx context.Context) *gorm.DB
}
// Service 负责热游后台配置、目录导入、app 列表和启动 URL 生成。
type Service struct {
cfg config.Config
db dbHandle
cache redis.Cmdable
}
// NewService 创建热游服务db 为空时路由仍可安全跳过。
func NewService(cfg config.Config, db dbHandle, cache redis.Cmdable) *Service {
return &Service{cfg: cfg, db: db, cache: cache}
}
// runtimeConfig 是某个系统、某个环境下实际用于启动和回调验签的配置快照。
type runtimeConfig struct {
SysOrigin string
Profile string
AppKey string
CallbackBaseURL string
TestUID string
TestToken string
}
// ProviderConfigItem 是后台热游配置页读取到的配置。
type ProviderConfigItem struct {
ID int64 `json:"id"`
SysOrigin string `json:"sysOrigin"`
Profile string `json:"profile"`
ActiveProfile string `json:"activeProfile"`
Active bool `json:"active"`
AppKey string `json:"appKey"`
CallbackBaseURL string `json:"callbackBaseUrl"`
TestUID string `json:"testUid"`
TestToken string `json:"testToken"`
UpdateTime string `json:"updateTime"`
}
// SaveProviderConfigRequest 是后台保存热游接入配置的入参。
type SaveProviderConfigRequest struct {
SysOrigin string `json:"sysOrigin"`
Profile string `json:"profile"`
AppKey string `json:"appKey"`
CallbackBaseURL string `json:"callbackBaseUrl"`
TestUID string `json:"testUid"`
TestToken string `json:"testToken"`
}
// ActivateProviderProfileRequest 是后台切换当前启用热游环境的入参。
type ActivateProviderProfileRequest struct {
SysOrigin string `json:"sysOrigin"`
Profile string `json:"profile"`
}
// SyncCatalogRequest 是后台同步或导入热游目录的入参。
type SyncCatalogRequest struct {
SysOrigin string `json:"sysOrigin"`
Profile string `json:"profile"`
VendorGameIDs []string `json:"vendorGameIds"`
Force bool `json:"force"`
DefaultShowcase *bool `json:"defaultShowcase"`
}
// SyncCatalogResponse 是热游目录同步结果。
type SyncCatalogResponse struct {
Inserted int `json:"inserted"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
}
// ImportCatalogResponse 是热游目录导入统一游戏列表的结果。
type ImportCatalogResponse struct {
Existing int `json:"existing"`
Imported int `json:"imported"`
}
// SyncAdminCatalogResponse 是“同步目录并导入游戏”的合并结果。
type SyncAdminCatalogResponse struct {
Inserted int `json:"inserted"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
Existing int `json:"existing"`
Imported int `json:"imported"`
}
// CatalogItem 是后台热游目录表格行。
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"`
}
// CatalogPageResponse 是热游目录分页响应。
type CatalogPageResponse struct {
Cursor int `json:"cursor"`
Limit int `json:"limit"`
Records []CatalogItem `json:"records"`
Total int64 `json:"total"`
}
type defaultCatalogGame struct {
GameID string
Name string
TestURL string
ProdURL string
}
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
ExtOrientation *int
ExtSafeHeight int
ExtExtraJSON string
CatalogName string
CatalogCover string
CatalogPreviewURL string
CatalogDownloadURL string
CatalogPackageVersion string
CatalogStatus string
}
var _ gameprovider.Provider = (*AppProvider)(nil)
// AppProvider 把热游服务适配到统一游戏厂商注册表。
type AppProvider struct {
service *Service
}
// NewAppProvider 创建热游 app 侧 provider。
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 defaultIfBlank(value, fallback string) string {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
return strings.TrimSpace(fallback)
}
func formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("2006-01-02 15:04:05")
}
func configToRuntime(base runtimeConfig, row model.HotgameProviderConfig) runtimeConfig {
base.SysOrigin = normalizeSysOrigin(defaultIfBlank(row.SysOrigin, base.SysOrigin))
base.Profile = normalizeProfile(defaultIfBlank(row.Profile, base.Profile))
base.AppKey = defaultIfBlank(row.AppKey, base.AppKey)
base.CallbackBaseURL = defaultIfBlank(row.CallbackBaseURL, base.CallbackBaseURL)
base.TestUID = strings.TrimSpace(row.TestUID)
base.TestToken = strings.TrimSpace(row.TestToken)
return base
}
func internalGameID(vendorGameID string) string {
vendorGameID = strings.TrimSpace(vendorGameID)
if vendorGameID == "" {
return ""
}
return "hg_" + vendorGameID
}

View File

@ -47,7 +47,7 @@ func (p *AppProvider) ListShortcutGames(ctx context.Context, user gameprovider.A
} }
func (p *AppProvider) ListRoomGames(ctx context.Context, user gameprovider.AuthUser, roomID, category string) (*gameprovider.RoomGameListResponse, error) { 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) items, err := p.service.listRoomGames(ctx, user.SysOrigin, user.RegionID, category)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -116,7 +116,7 @@ func (p *AppProvider) CloseGame(ctx context.Context, user gameprovider.AuthUser,
return &gameprovider.RoomStateResponse{RoomID: req.RoomID, State: "IDLE", Provider: vendorType}, nil return &gameprovider.RoomStateResponse{RoomID: req.RoomID, State: "IDLE", Provider: vendorType}, nil
} }
func (s *Service) listRoomGames(ctx context.Context, sysOrigin, category string) ([]gameprovider.RoomGameListItem, error) { func (s *Service) listRoomGames(ctx context.Context, sysOrigin, regionID, category string) ([]gameprovider.RoomGameListItem, error) {
sysOrigin = normalizeSysOrigin(sysOrigin) sysOrigin = normalizeSysOrigin(sysOrigin)
profile, err := s.activeProfile(ctx, sysOrigin) profile, err := s.activeProfile(ctx, sysOrigin)
if err != nil { if err != nil {
@ -125,6 +125,10 @@ func (s *Service) listRoomGames(ctx context.Context, sysOrigin, category string)
var rows []gameRow var rows []gameRow
query := s.baseGameQuery(ctx, sysOrigin, profile). query := s.baseGameQuery(ctx, sysOrigin, profile).
Where("cfg.sys_origin = ? AND cfg.is_showcase = 1 AND ext.vendor_type = ?", sysOrigin, vendorType) Where("cfg.sys_origin = ? AND cfg.is_showcase = 1 AND ext.vendor_type = ?", sysOrigin, vendorType)
if strings.TrimSpace(regionID) != "" {
// regions 为空表示全区域;有值时按用户区域 ID 精确匹配,避免土耳其用户看到其他区域游戏。
query = query.Where("(COALESCE(TRIM(cfg.regions), '') = '' OR FIND_IN_SET(?, REPLACE(COALESCE(cfg.regions, ''), ' ', '')) > 0)", strings.TrimSpace(regionID))
}
if strings.TrimSpace(category) != "" && !strings.EqualFold(strings.TrimSpace(category), roomCategory) { if strings.TrimSpace(category) != "" && !strings.EqualFold(strings.TrimSpace(category), roomCategory) {
query = query.Where("cfg.category = ?", category) query = query.Where("cfg.category = ?", category)
} }

View File

@ -124,7 +124,7 @@ func (s *Service) ListBDLeaders(ctx context.Context, user AuthUser) (*BDLeaderLi
} }
func (s *Service) AddBDLeader(ctx context.Context, user AuthUser, req AddBDLeaderRequest) (*AddBDLeaderResponse, error) { func (s *Service) AddBDLeader(ctx context.Context, user AuthUser, req AddBDLeaderRequest) (*AddBDLeaderResponse, error) {
manager, err := s.requireManagerInfo(ctx, user) manager, err := s.requireManagerFeature(ctx, user, managerFeatureAddBDLeader)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"time"
"chatapp3-golang/internal/integration" "chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model" "chatapp3-golang/internal/model"
@ -14,13 +15,18 @@ import (
) )
type teamManagerInfoRow struct { type teamManagerInfoRow struct {
ID int64 `gorm:"column:id;primaryKey"` ID int64 `gorm:"column:id;primaryKey"`
UserID int64 `gorm:"column:user_id"` UserID int64 `gorm:"column:user_id"`
Origin string `gorm:"column:sys_origin"` Origin string `gorm:"column:sys_origin"`
Contact string `gorm:"column:contact"` Contact string `gorm:"column:contact"`
RegionID string `gorm:"column:region_id"` RegionID string `gorm:"column:region_id"`
CreateUser int64 `gorm:"column:create_user"` CanAddBDLeader *bool `gorm:"column:can_add_bd_leader"`
UpdateUser int64 `gorm:"column:update_user"` CanGiftProps *bool `gorm:"column:can_gift_props"`
CanBanUser *bool `gorm:"column:can_ban_user"`
CanUnbanUser *bool `gorm:"column:can_unban_user"`
CanChangeCountry *bool `gorm:"column:can_change_country"`
CreateUser int64 `gorm:"column:create_user"`
UpdateUser int64 `gorm:"column:update_user"`
} }
func (teamManagerInfoRow) TableName() string { func (teamManagerInfoRow) TableName() string {
@ -28,21 +34,38 @@ func (teamManagerInfoRow) TableName() string {
} }
type userBaseInfoRow struct { type userBaseInfoRow struct {
ID int64 `gorm:"column:id;primaryKey"` ID int64 `gorm:"column:id;primaryKey"`
Account string `gorm:"column:account"` Account string `gorm:"column:account"`
UserAvatar string `gorm:"column:user_avatar"` UserAvatar string `gorm:"column:user_avatar"`
UserNickname string `gorm:"column:user_nickname"` UserNickname string `gorm:"column:user_nickname"`
CountryCode string `gorm:"column:country_code"` CountryID int64 `gorm:"column:country_id"`
CountryName string `gorm:"column:country_name"` CountryCode string `gorm:"column:country_code"`
OriginSys string `gorm:"column:origin_sys"` CountryName string `gorm:"column:country_name"`
AccountStatus string `gorm:"column:account_status"` OriginSys string `gorm:"column:origin_sys"`
IsDel bool `gorm:"column:is_del"` AccountStatus string `gorm:"column:account_status"`
IsDel bool `gorm:"column:is_del"`
UpdateTime time.Time `gorm:"column:update_time"`
UpdateUser int64 `gorm:"column:update_user"`
} }
func (userBaseInfoRow) TableName() string { func (userBaseInfoRow) TableName() string {
return "user_base_info" return "user_base_info"
} }
type sysCountryCodeRow struct {
ID int64 `gorm:"column:id;primaryKey"`
AlphaTwo string `gorm:"column:alpha_two"`
CountryName string `gorm:"column:country_name"`
NationalFlag string `gorm:"column:national_flag"`
PhonePrefix int `gorm:"column:phone_prefix"`
Open bool `gorm:"column:is_open"`
Sort int `gorm:"column:sort"`
}
func (sysCountryCodeRow) TableName() string {
return "sys_country_code"
}
type propsSourceRecordRow struct { type propsSourceRecordRow struct {
ID int64 `gorm:"column:id;primaryKey"` ID int64 `gorm:"column:id;primaryKey"`
Type string `gorm:"column:type"` Type string `gorm:"column:type"`
@ -73,7 +96,8 @@ func (propsNobleVIPAbilityRow) TableName() string {
} }
func (s *Service) GetProfile(ctx context.Context, user AuthUser) (*ProfileResponse, error) { func (s *Service) GetProfile(ctx context.Context, user AuthUser) (*ProfileResponse, error) {
if err := s.ensureManager(ctx, user); err != nil { manager, err := s.requireManagerInfo(ctx, user)
if err != nil {
return nil, err return nil, err
} }
@ -81,7 +105,7 @@ func (s *Service) GetProfile(ctx context.Context, user AuthUser) (*ProfileRespon
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &ProfileResponse{Manager: view}, nil return &ProfileResponse{Manager: view, Features: managerFeaturesFromRow(manager)}, nil
} }
func (s *Service) SearchUser(ctx context.Context, user AuthUser, account string) (*UserSearchResponse, error) { func (s *Service) SearchUser(ctx context.Context, user AuthUser, account string) (*UserSearchResponse, error) {
@ -101,6 +125,34 @@ func (s *Service) SearchUser(ctx context.Context, user AuthUser, account string)
return &UserSearchResponse{User: view}, nil return &UserSearchResponse{User: view}, nil
} }
// ListCountries 给经理中心返回当前开放国家H5 只展示可绑定目标,避免经理手输无效国家码。
func (s *Service) ListCountries(ctx context.Context, user AuthUser) (*CountryListResponse, error) {
if err := s.ensureManager(ctx, user); err != nil {
return nil, err
}
if s.db == nil {
return nil, serviceUnavailable("database_unavailable", "database is unavailable")
}
var rows []sysCountryCodeRow
if err := s.db.WithContext(ctx).
Table("sys_country_code").
Select("id, alpha_two, country_name, national_flag, phone_prefix, is_open, sort").
Where("is_open = ?", true).
Order("sort ASC, country_name ASC, id ASC").
Find(&rows).Error; err != nil {
return nil, serverError("country_query_failed", err.Error())
}
records := make([]CountryView, 0, len(rows))
for _, row := range rows {
if view, ok := countryRowToView(row); ok {
records = append(records, view)
}
}
return &CountryListResponse{Records: records}, nil
}
func (s *Service) ListProps(ctx context.Context, user AuthUser, propsType string) (*PropsListResponse, error) { func (s *Service) ListProps(ctx context.Context, user AuthUser, propsType string) (*PropsListResponse, error) {
if err := s.ensureManager(ctx, user); err != nil { if err := s.ensureManager(ctx, user); err != nil {
return nil, err return nil, err
@ -148,7 +200,7 @@ func (s *Service) ListProps(ctx context.Context, user AuthUser, propsType string
} }
func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsRequest) (*SendPropsResponse, error) { func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsRequest) (*SendPropsResponse, error) {
if err := s.ensureManager(ctx, user); err != nil { if _, err := s.requireManagerFeature(ctx, user, managerFeatureGiftProps); err != nil {
return nil, err return nil, err
} }
if req.AcceptUserID.Int64() <= 0 { if req.AcceptUserID.Int64() <= 0 {
@ -243,7 +295,7 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
} }
func (s *Service) BanUser(ctx context.Context, user AuthUser, req BanUserRequest) (*BanUserResponse, error) { func (s *Service) BanUser(ctx context.Context, user AuthUser, req BanUserRequest) (*BanUserResponse, error) {
if err := s.ensureManager(ctx, user); err != nil { if _, err := s.requireManagerFeature(ctx, user, managerFeatureBanUser); err != nil {
return nil, err return nil, err
} }
if req.UserID.Int64() <= 0 { if req.UserID.Int64() <= 0 {
@ -290,7 +342,7 @@ func (s *Service) BanUser(ctx context.Context, user AuthUser, req BanUserRequest
} }
func (s *Service) UnbanUser(ctx context.Context, user AuthUser, req UnbanUserRequest) (*UnbanUserResponse, error) { func (s *Service) UnbanUser(ctx context.Context, user AuthUser, req UnbanUserRequest) (*UnbanUserResponse, error) {
if err := s.ensureManager(ctx, user); err != nil { if _, err := s.requireManagerFeature(ctx, user, managerFeatureUnbanUser); err != nil {
return nil, err return nil, err
} }
if req.UserID.Int64() <= 0 { if req.UserID.Int64() <= 0 {
@ -327,11 +379,116 @@ func (s *Service) UnbanUser(ctx context.Context, user AuthUser, req UnbanUserReq
}, nil }, nil
} }
// ChangeUserCountry 只允许 admin 开启改国家权限的经理移动普通用户国家;目标用户已有业务身份时拒绝,避免跨国家后团队、币商和主播归属数据不一致。
func (s *Service) ChangeUserCountry(ctx context.Context, user AuthUser, req ChangeUserCountryRequest) (*ChangeUserCountryResponse, error) {
if _, err := s.requireManagerFeature(ctx, user, managerFeatureChangeCountry); err != nil {
return nil, err
}
if req.UserID.Int64() <= 0 {
return nil, badRequest("user_required", "userId is required")
}
if req.CountryID.Int64() <= 0 {
return nil, badRequest("country_required", "countryId is required")
}
if s.db == nil {
return nil, serviceUnavailable("database_unavailable", "database is unavailable")
}
target, err := s.loadUserRowByID(ctx, req.UserID.Int64())
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, notFound("user_not_found", "user not found")
}
return nil, serverError("user_query_failed", err.Error())
}
country, err := s.loadOpenCountryByID(ctx, req.CountryID.Int64())
if err != nil {
return nil, err
}
if err := s.ensureUserCountryMovable(ctx, target.ID); err != nil {
return nil, err
}
// 只改国家三元组,不触碰昵称、头像等资料审核字段;清理 Java 资料缓存后 App/H5 会重新读到新国家。
updates := map[string]any{
"country_id": country.ID,
"country_code": strings.ToUpper(strings.TrimSpace(country.AlphaTwo)),
"country_name": strings.TrimSpace(country.CountryName),
"update_time": time.Now(),
"update_user": user.UserID,
}
if err := s.db.WithContext(ctx).
Table("user_base_info").
Where("id = ? AND is_del = ?", target.ID, false).
Updates(updates).Error; err != nil {
return nil, serverError("user_country_update_failed", err.Error())
}
if s.java != nil {
if err := s.java.RemoveUserProfileCacheAll(ctx, target.ID); err != nil {
return nil, serverError("remove_profile_cache_failed", err.Error())
}
}
target.CountryID = country.ID
target.CountryCode = strings.ToUpper(strings.TrimSpace(country.AlphaTwo))
target.CountryName = strings.TrimSpace(country.CountryName)
view, err := s.userRowToView(ctx, target)
if err != nil {
return nil, err
}
return &ChangeUserCountryResponse{Success: true, User: view}, nil
}
func (s *Service) ensureManager(ctx context.Context, user AuthUser) error { func (s *Service) ensureManager(ctx context.Context, user AuthUser) error {
_, err := s.requireManagerInfo(ctx, user) _, err := s.requireManagerInfo(ctx, user)
return err return err
} }
// requireManagerFeature 先确认调用人仍然是团队经理,再检查 admin 给这个经理打开的单项 H5 操作权限。
// 这样前端隐藏按钮和后端写操作使用同一张 team_manager_base_info 表做判断H5 被绕过时也会被服务层拦截。
func (s *Service) requireManagerFeature(ctx context.Context, user AuthUser, feature managerFeature) (teamManagerInfoRow, error) {
manager, err := s.requireManagerInfo(ctx, user)
if err != nil {
return teamManagerInfoRow{}, err
}
features := managerFeaturesFromRow(manager)
if !features.enabled(feature) {
return teamManagerInfoRow{}, forbidden("manager_feature_disabled", "manager feature is disabled")
}
return manager, nil
}
func managerFeaturesFromRow(row teamManagerInfoRow) ManagerFeatures {
return ManagerFeatures{
AddBDLeader: defaultEnabled(row.CanAddBDLeader),
GiftProps: defaultEnabled(row.CanGiftProps),
BanUser: defaultEnabled(row.CanBanUser),
UnbanUser: defaultEnabled(row.CanUnbanUser),
ChangeCountry: defaultEnabled(row.CanChangeCountry),
}
}
func defaultEnabled(value *bool) bool {
return value == nil || *value
}
func (features ManagerFeatures) enabled(feature managerFeature) bool {
switch feature {
case managerFeatureAddBDLeader:
return features.AddBDLeader
case managerFeatureGiftProps:
return features.GiftProps
case managerFeatureBanUser:
return features.BanUser
case managerFeatureUnbanUser:
return features.UnbanUser
case managerFeatureChangeCountry:
return features.ChangeCountry
default:
return false
}
}
func (s *Service) findUserView(ctx context.Context, account string) (UserView, error) { func (s *Service) findUserView(ctx context.Context, account string) (UserView, error) {
if userID, err := strconv.ParseInt(account, 10, 64); err == nil && userID > 0 { if userID, err := strconv.ParseInt(account, 10, 64); err == nil && userID > 0 {
if row, err := s.loadUserRowByID(ctx, userID); err == nil { if row, err := s.loadUserRowByID(ctx, userID); err == nil {
@ -397,6 +554,65 @@ func (s *Service) loadUserRowByAccount(ctx context.Context, account string) (use
return row, err return row, err
} }
func (s *Service) loadOpenCountryByID(ctx context.Context, countryID int64) (sysCountryCodeRow, error) {
var row sysCountryCodeRow
err := s.db.WithContext(ctx).
Table("sys_country_code").
Select("id, alpha_two, country_name, national_flag, phone_prefix, is_open, sort").
Where("id = ? AND is_open = ?", countryID, true).
First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return sysCountryCodeRow{}, notFound("country_not_found", "country not found")
}
if err != nil {
return sysCountryCodeRow{}, serverError("country_query_failed", err.Error())
}
if strings.TrimSpace(row.AlphaTwo) == "" {
return sysCountryCodeRow{}, badRequest("country_code_required", "country code is required")
}
return row, nil
}
// ensureUserCountryMovable 串行检查所有会绑定归属关系的身份;任一命中都返回同一个业务错误,前端显示统一提示。
func (s *Service) ensureUserCountryMovable(ctx context.Context, userID int64) error {
locked, err := s.userHasLockedHistoryIdentity(ctx, userID)
if err != nil {
return err
}
if locked {
return badRequest("user_identity_locked", "user has host, agency, BD, BD leader or coin seller identity and cannot be moved")
}
locked, err = s.userIsBDLeader(ctx, userID)
if err != nil {
return err
}
if locked {
return badRequest("user_identity_locked", "user has host, agency, BD, BD leader or coin seller identity and cannot be moved")
}
if s.java == nil {
return serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable")
}
locked, err = s.java.ExistsFreightSeller(ctx, userID)
if err != nil {
return serverError("coin_seller_query_failed", err.Error())
}
if locked {
return badRequest("user_identity_locked", "user has host, agency, BD, BD leader or coin seller identity and cannot be moved")
}
return nil
}
func (s *Service) userHasLockedHistoryIdentity(ctx context.Context, userID int64) (bool, error) {
var count int64
if err := s.db.WithContext(ctx).
Table("user_history_identity").
Where("user_id = ? AND (is_host = ? OR is_agent = ? OR is_bd = ?)", userID, true, true, true).
Count(&count).Error; err != nil {
return false, serverError("history_identity_query_failed", err.Error())
}
return count > 0, nil
}
func (s *Service) userRowToView(ctx context.Context, row userBaseInfoRow) (UserView, error) { func (s *Service) userRowToView(ctx context.Context, row userBaseInfoRow) (UserView, error) {
hasRecharge, canBan := s.userRechargeEligibility(ctx, row.ID) hasRecharge, canBan := s.userRechargeEligibility(ctx, row.ID)
canBan = canBan && !isUnbannableAccountStatus(row.AccountStatus) canBan = canBan && !isUnbannableAccountStatus(row.AccountStatus)
@ -405,6 +621,7 @@ func (s *Service) userRowToView(ctx context.Context, row userBaseInfoRow) (UserV
Account: row.Account, Account: row.Account,
UserAvatar: row.UserAvatar, UserAvatar: row.UserAvatar,
UserNickname: row.UserNickname, UserNickname: row.UserNickname,
CountryID: ID(row.CountryID),
CountryCode: row.CountryCode, CountryCode: row.CountryCode,
CountryName: row.CountryName, CountryName: row.CountryName,
OriginSys: row.OriginSys, OriginSys: row.OriginSys,
@ -422,6 +639,7 @@ func (s *Service) javaProfileToView(ctx context.Context, profile integration.Use
Account: profile.Account, Account: profile.Account,
UserAvatar: profile.UserAvatar, UserAvatar: profile.UserAvatar,
UserNickname: profile.UserNickname, UserNickname: profile.UserNickname,
CountryID: ID(profile.CountryID),
CountryCode: profile.CountryCode, CountryCode: profile.CountryCode,
CountryName: profile.CountryName, CountryName: profile.CountryName,
OriginSys: profile.OriginSys, OriginSys: profile.OriginSys,
@ -447,6 +665,9 @@ func overlayJavaProfile(view *UserView, profile integration.UserProfile) {
if profile.CountryName != "" { if profile.CountryName != "" {
view.CountryName = profile.CountryName view.CountryName = profile.CountryName
} }
if int64(profile.CountryID) > 0 {
view.CountryID = ID(profile.CountryID)
}
if profile.OriginSys != "" { if profile.OriginSys != "" {
view.OriginSys = profile.OriginSys view.OriginSys = profile.OriginSys
} }
@ -458,6 +679,21 @@ func overlayJavaProfile(view *UserView, profile integration.UserProfile) {
} }
} }
func countryRowToView(row sysCountryCodeRow) (CountryView, bool) {
code := strings.ToUpper(strings.TrimSpace(row.AlphaTwo))
name := strings.TrimSpace(row.CountryName)
if row.ID <= 0 || code == "" || name == "" {
return CountryView{}, false
}
return CountryView{
ID: ID(row.ID),
CountryCode: code,
CountryName: name,
NationalFlag: strings.TrimSpace(row.NationalFlag),
PhonePrefix: row.PhonePrefix,
}, true
}
func (s *Service) userHasRecharge(ctx context.Context, userID int64) (bool, error) { func (s *Service) userHasRecharge(ctx context.Context, userID int64) (bool, error) {
if userID <= 0 { if userID <= 0 {
return false, nil return false, nil
@ -523,7 +759,7 @@ func (s *Service) loadGiftableNobleVIPAbility(ctx context.Context, propsID int64
return propsNobleVIPAbilityRow{}, serverError("vip_ability_query_failed", err.Error()) return propsNobleVIPAbilityRow{}, serverError("vip_ability_query_failed", err.Error())
} }
if row.VIPLevel > maxGiftableVIPLevel { if row.VIPLevel > maxGiftableVIPLevel {
return propsNobleVIPAbilityRow{}, badRequest("vip_not_giftable", "only noble VIP level 1-3 can be gifted") return propsNobleVIPAbilityRow{}, badRequest("vip_not_giftable", "only noble VIP level 1-5 can be gifted")
} }
return row, nil return row, nil
} }
@ -621,7 +857,7 @@ func propsRowToView(row propsSourceRecordRow, vipLevels map[int64]int) (PropsVie
if row.Type == propsTypeNobleVIP { if row.Type == propsTypeNobleVIP {
var exists bool var exists bool
level, exists = vipLevels[row.ID] level, exists = vipLevels[row.ID]
if !exists || level > 3 { if !exists || level > maxGiftableVIPLevel {
return PropsView{}, false return PropsView{}, false
} }
} }

View File

@ -17,6 +17,8 @@ import (
type fakeManagerGateway struct { type fakeManagerGateway struct {
recharges map[int64][]integration.UserTotalRecharge recharges map[int64][]integration.UserTotalRecharge
rechargeErr error rechargeErr error
freightSellers map[int64]bool
freightSellerErr error
abilities map[int64]integration.PropsNobleVIPAbility abilities map[int64]integration.PropsNobleVIPAbility
maxAbility integration.PropsNobleVIPAbility maxAbility integration.PropsNobleVIPAbility
giveRequests []integration.GivePropsBackpackRequest giveRequests []integration.GivePropsBackpackRequest
@ -52,6 +54,13 @@ func (f *fakeManagerGateway) MapUserTotalRecharge(_ context.Context, userIDs []i
return result, nil return result, nil
} }
func (f *fakeManagerGateway) ExistsFreightSeller(_ context.Context, userID int64) (bool, error) {
if f.freightSellerErr != nil {
return false, f.freightSellerErr
}
return f.freightSellers[userID], nil
}
func (f *fakeManagerGateway) GivePropsBackpack(_ context.Context, req integration.GivePropsBackpackRequest) error { func (f *fakeManagerGateway) GivePropsBackpack(_ context.Context, req integration.GivePropsBackpackRequest) error {
f.giveRequests = append(f.giveRequests, req) f.giveRequests = append(f.giveRequests, req)
return nil return nil
@ -98,10 +107,14 @@ func TestListPropsRequiresAdminFreeAndFiltersVIPLevels(t *testing.T) {
propsSourceRecordRow{ID: 102, Type: propsTypeRide, Name: "Paid ride", AdminFree: false}, propsSourceRecordRow{ID: 102, Type: propsTypeRide, Name: "Paid ride", AdminFree: false},
propsSourceRecordRow{ID: 201, Type: propsTypeNobleVIP, Name: "VIP 3", AdminFree: true}, propsSourceRecordRow{ID: 201, Type: propsTypeNobleVIP, Name: "VIP 3", AdminFree: true},
propsSourceRecordRow{ID: 204, Type: propsTypeNobleVIP, Name: "VIP 4", AdminFree: true}, propsSourceRecordRow{ID: 204, Type: propsTypeNobleVIP, Name: "VIP 4", AdminFree: true},
propsSourceRecordRow{ID: 205, Type: propsTypeNobleVIP, Name: "VIP 5", AdminFree: true},
propsSourceRecordRow{ID: 206, Type: propsTypeNobleVIP, Name: "VIP 6", AdminFree: true},
) )
seedAbilities(t, service.db.(*gorm.DB), seedAbilities(t, service.db.(*gorm.DB),
propsNobleVIPAbilityRow{ID: 201, VIPLevel: 3}, propsNobleVIPAbilityRow{ID: 201, VIPLevel: 3},
propsNobleVIPAbilityRow{ID: 204, VIPLevel: 4}, propsNobleVIPAbilityRow{ID: 204, VIPLevel: 4},
propsNobleVIPAbilityRow{ID: 205, VIPLevel: 5},
propsNobleVIPAbilityRow{ID: 206, VIPLevel: 6},
) )
resp, err := service.ListProps(context.Background(), AuthUser{UserID: 1}, "") resp, err := service.ListProps(context.Background(), AuthUser{UserID: 1}, "")
@ -120,10 +133,16 @@ func TestListPropsRequiresAdminFreeAndFiltersVIPLevels(t *testing.T) {
t.Fatalf("records = %+v, non-admin-free props must be hidden", resp.Records) t.Fatalf("records = %+v, non-admin-free props must be hidden", resp.Records)
} }
if item, ok := got[201]; !ok || item.Days != defaultVIPGiftDays || item.VIPLevel != 3 { if item, ok := got[201]; !ok || item.Days != defaultVIPGiftDays || item.VIPLevel != 3 {
t.Fatalf("vip 201 = %+v, want level 3 with 3 days", item) t.Fatalf("vip 201 = %+v, want level 3 with default VIP days", item)
} }
if _, ok := got[204]; ok { if item, ok := got[204]; !ok || item.VIPLevel != 4 {
t.Fatalf("records = %+v, VIP level 4 must be hidden", resp.Records) t.Fatalf("vip 204 = %+v, want level 4 visible", item)
}
if item, ok := got[205]; !ok || item.VIPLevel != 5 {
t.Fatalf("vip 205 = %+v, want level 5 visible", item)
}
if _, ok := got[206]; ok {
t.Fatalf("records = %+v, VIP level 6 must be hidden", resp.Records)
} }
} }
@ -136,18 +155,32 @@ func TestListPropsReturnsVIPLevelConfigs(t *testing.T) {
model.VipLevelConfig{ID: 302, SysOrigin: "LIKEI", Level: 2, LevelCode: "VIP2", DisplayName: "VIP 2", Enabled: false, DurationDays: 30}, model.VipLevelConfig{ID: 302, SysOrigin: "LIKEI", Level: 2, LevelCode: "VIP2", DisplayName: "VIP 2", Enabled: false, DurationDays: 30},
model.VipLevelConfig{ID: 303, SysOrigin: "ATYOU", Level: 1, LevelCode: "VIP1", DisplayName: "ATYOU VIP 1", Enabled: true, DurationDays: 30}, model.VipLevelConfig{ID: 303, SysOrigin: "ATYOU", Level: 1, LevelCode: "VIP1", DisplayName: "ATYOU VIP 1", Enabled: true, DurationDays: 30},
model.VipLevelConfig{ID: 304, SysOrigin: "LIKEI", Level: 4, LevelCode: "VIP4", DisplayName: "VIP 4", Enabled: true, DurationDays: 30}, model.VipLevelConfig{ID: 304, SysOrigin: "LIKEI", Level: 4, LevelCode: "VIP4", DisplayName: "VIP 4", Enabled: true, DurationDays: 30},
model.VipLevelConfig{ID: 305, SysOrigin: "LIKEI", Level: 5, LevelCode: "VIP5", DisplayName: "VIP 5", Enabled: true, DurationDays: 30},
model.VipLevelConfig{ID: 306, SysOrigin: "LIKEI", Level: 6, LevelCode: "VIP6", DisplayName: "VIP 6", Enabled: true, DurationDays: 30},
) )
resp, err := service.ListProps(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, propsTypeNobleVIP) resp, err := service.ListProps(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, propsTypeNobleVIP)
if err != nil { if err != nil {
t.Fatalf("ListProps() error = %v", err) t.Fatalf("ListProps() error = %v", err)
} }
if len(resp.Records) != 1 { if len(resp.Records) != 3 {
t.Fatalf("records = %+v, want only one enabled LIKEI vip config", resp.Records) t.Fatalf("records = %+v, want enabled LIKEI vip configs up to level 5", resp.Records)
} }
got := resp.Records[0] got := map[int64]PropsView{}
if got.ID.Int64() != 301 || got.Type != propsTypeNobleVIP || got.VIPLevel != 1 || got.Days != 30 { for _, item := range resp.Records {
t.Fatalf("vip props = %+v, want config 301 level 1", got) got[item.ID.Int64()] = item
}
if item := got[301]; item.Type != propsTypeNobleVIP || item.VIPLevel != 1 || item.Days != 30 {
t.Fatalf("vip props 301 = %+v, want config 301 level 1", item)
}
if item := got[304]; item.Type != propsTypeNobleVIP || item.VIPLevel != 4 || item.Days != 30 {
t.Fatalf("vip props 304 = %+v, want config 304 level 4", item)
}
if item := got[305]; item.Type != propsTypeNobleVIP || item.VIPLevel != 5 || item.Days != 30 {
t.Fatalf("vip props 305 = %+v, want config 305 level 5", item)
}
if _, ok := got[306]; ok {
t.Fatalf("records = %+v, VIP level 6 must be hidden", resp.Records)
} }
} }
@ -167,6 +200,27 @@ func TestGetProfileToleratesRechargeQueryFailure(t *testing.T) {
} }
} }
func TestGetProfileReturnsManagerFeatures(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManagerWithFeatures(t, db, 1, ManagerFeatures{
AddBDLeader: true,
GiftProps: false,
BanUser: true,
UnbanUser: false,
ChangeCountry: true,
})
seedUsers(t, db, userBaseInfoRow{ID: 1, Account: "1"})
resp, err := service.GetProfile(context.Background(), AuthUser{UserID: 1})
if err != nil {
t.Fatalf("GetProfile() error = %v", err)
}
if !resp.Features.AddBDLeader || resp.Features.GiftProps || !resp.Features.BanUser || resp.Features.UnbanUser || !resp.Features.ChangeCountry {
t.Fatalf("features = %+v, want row switches returned", resp.Features)
}
}
func TestAddBDLeaderCreatesLeaderAndListEntry(t *testing.T) { func TestAddBDLeaderCreatesLeaderAndListEntry(t *testing.T) {
service, _ := newManagerCenterTestService(t) service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB) db := service.db.(*gorm.DB)
@ -234,6 +288,24 @@ func TestAddBDLeaderRejectsTargetFromOtherOrigin(t *testing.T) {
assertAppErrorCode(t, err, "bd_leader_origin_mismatch") assertAppErrorCode(t, err, "bd_leader_origin_mismatch")
} }
func TestAddBDLeaderRejectsDisabledFeature(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManagerWithFeatures(t, db, 1, ManagerFeatures{GiftProps: true, BanUser: true, UnbanUser: true})
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2002", OriginSys: "LIKEI"})
_, err := service.AddBDLeader(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, AddBDLeaderRequest{UserID: ID(2)})
assertAppErrorCode(t, err, "manager_feature_disabled")
var count int64
if err := db.Model(&bdLeaderRow{}).Where("user_id = ?", int64(2)).Count(&count).Error; err != nil {
t.Fatalf("count bd leaders: %v", err)
}
if count != 0 {
t.Fatalf("bd leader count = %d, want 0", count)
}
}
func TestSendPropsRejectsNonAdminFree(t *testing.T) { func TestSendPropsRejectsNonAdminFree(t *testing.T) {
service, fake := newManagerCenterTestService(t) service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB) db := service.db.(*gorm.DB)
@ -294,6 +366,23 @@ func TestSendNobleVIPGivesAccessoriesAndSwitchesMax(t *testing.T) {
} }
} }
func TestSendPropsRejectsDisabledFeature(t *testing.T) {
service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManagerWithFeatures(t, db, 1, ManagerFeatures{AddBDLeader: true, BanUser: true, UnbanUser: true})
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2"})
seedProps(t, db, propsSourceRecordRow{ID: 101, Type: propsTypeRide, Name: "Giftable ride", AdminFree: true})
_, err := service.SendProps(context.Background(), AuthUser{UserID: 1}, SendPropsRequest{
AcceptUserID: ID(2),
PropsID: ID(101),
})
assertAppErrorCode(t, err, "manager_feature_disabled")
if len(fake.giveRequests) != 0 {
t.Fatalf("give requests = %+v, want none", fake.giveRequests)
}
}
func TestSendBadgeActivatesBadgeBackpack(t *testing.T) { func TestSendBadgeActivatesBadgeBackpack(t *testing.T) {
service, fake := newManagerCenterTestService(t) service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB) db := service.db.(*gorm.DB)
@ -333,12 +422,12 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
seedVIPConfigs(t, db, model.VipLevelConfig{ seedVIPConfigs(t, db, model.VipLevelConfig{
ID: 301, ID: 301,
SysOrigin: "LIKEI", SysOrigin: "LIKEI",
Level: 3, Level: 5,
LevelCode: "VIP3", LevelCode: "VIP5",
DisplayName: "VIP 3", DisplayName: "VIP 5",
Enabled: true, Enabled: true,
DurationDays: 30, DurationDays: 30,
PriceGold: 3000, PriceGold: 5000,
}) })
resp, err := service.SendProps(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, SendPropsRequest{ resp, err := service.SendProps(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, SendPropsRequest{
@ -348,7 +437,7 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("SendProps() error = %v", err) t.Fatalf("SendProps() error = %v", err)
} }
if !resp.Success || resp.Type != propsTypeNobleVIP || resp.VIPLevel != 3 || resp.Days != 30 { if !resp.Success || resp.Type != propsTypeNobleVIP || resp.VIPLevel != 5 || resp.Days != 30 {
t.Fatalf("resp = %+v, want vip gift success", resp) t.Fatalf("resp = %+v, want vip gift success", resp)
} }
@ -356,8 +445,8 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
if err := db.Where("sys_origin = ? AND user_id = ?", "LIKEI", int64(2)).First(&state).Error; err != nil { if err := db.Where("sys_origin = ? AND user_id = ?", "LIKEI", int64(2)).First(&state).Error; err != nil {
t.Fatalf("load state: %v", err) t.Fatalf("load state: %v", err)
} }
if state.Level != 3 || state.Status != vipStatusActive { if state.Level != 5 || state.Status != vipStatusActive {
t.Fatalf("state = %+v, want vip3 active", state) t.Fatalf("state = %+v, want vip5 active", state)
} }
var orders int64 var orders int64
if err := db.Model(&model.VipOrderRecord{}).Where("user_id = ? AND order_type = ?", int64(2), vipOrderTypeGMGift).Count(&orders).Error; err != nil { if err := db.Model(&model.VipOrderRecord{}).Where("user_id = ? AND order_type = ?", int64(2), vipOrderTypeGMGift).Count(&orders).Error; err != nil {
@ -367,7 +456,7 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
t.Fatalf("orders = %d, want 1", orders) t.Fatalf("orders = %d, want 1", orders)
} }
var records int64 var records int64
if err := db.Model(&gmVIPGiftRecordRow{}).Where("user_id = ? AND vip_level = ?", int64(2), 3).Count(&records).Error; err != nil { if err := db.Model(&gmVIPGiftRecordRow{}).Where("user_id = ? AND vip_level = ?", int64(2), 5).Count(&records).Error; err != nil {
t.Fatalf("count gift records: %v", err) t.Fatalf("count gift records: %v", err)
} }
if records != 1 { if records != 1 {
@ -428,9 +517,9 @@ func TestSendVIPRejectsLevelAboveGiftLimit(t *testing.T) {
seedVIPConfigs(t, db, model.VipLevelConfig{ seedVIPConfigs(t, db, model.VipLevelConfig{
ID: 304, ID: 304,
SysOrigin: "LIKEI", SysOrigin: "LIKEI",
Level: 4, Level: 6,
LevelCode: "VIP4", LevelCode: "VIP6",
DisplayName: "VIP 4", DisplayName: "VIP 6",
Enabled: true, Enabled: true,
DurationDays: 30, DurationDays: 30,
}) })
@ -513,6 +602,19 @@ func assertManagerVIPCleanupBadge(t *testing.T, db *gorm.DB, badgeID int64, expi
} }
} }
func TestBanUserRejectsDisabledFeature(t *testing.T) {
service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManagerWithFeatures(t, db, 1, ManagerFeatures{AddBDLeader: true, GiftProps: true, UnbanUser: true})
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2"})
_, err := service.BanUser(context.Background(), AuthUser{UserID: 1}, BanUserRequest{UserID: ID(2)})
assertAppErrorCode(t, err, "manager_feature_disabled")
if len(fake.punishRequests) != 0 {
t.Fatalf("punish requests = %+v, want none", fake.punishRequests)
}
}
func TestBanUserRejectsRechargedUser(t *testing.T) { func TestBanUserRejectsRechargedUser(t *testing.T) {
service, fake := newManagerCenterTestService(t) service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB) db := service.db.(*gorm.DB)
@ -592,6 +694,19 @@ func TestBanUserWithoutRechargeCallsPunishment(t *testing.T) {
} }
} }
func TestUnbanUserRejectsDisabledFeature(t *testing.T) {
service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManagerWithFeatures(t, db, 1, ManagerFeatures{AddBDLeader: true, GiftProps: true, BanUser: true})
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", AccountStatus: accountStatusArchive})
_, err := service.UnbanUser(context.Background(), AuthUser{UserID: 1}, UnbanUserRequest{UserID: ID(2)})
assertAppErrorCode(t, err, "manager_feature_disabled")
if len(fake.unblockRequests) != 0 {
t.Fatalf("unblock requests = %+v, want none", fake.unblockRequests)
}
}
func TestUnbanUserRejectsNormalUser(t *testing.T) { func TestUnbanUserRejectsNormalUser(t *testing.T) {
service, fake := newManagerCenterTestService(t) service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB) db := service.db.(*gorm.DB)
@ -651,6 +766,117 @@ func TestUnbanUserAllowsFrozenUser(t *testing.T) {
} }
} }
func TestListCountriesReturnsOpenCountries(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedCountries(t, db,
sysCountryCodeRow{ID: 10, AlphaTwo: "SA", CountryName: "Saudi Arabia", Open: true, Sort: 2},
sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true, Sort: 1},
sysCountryCodeRow{ID: 12, AlphaTwo: "XX", CountryName: "Closed", Open: false, Sort: 3},
)
resp, err := service.ListCountries(context.Background(), AuthUser{UserID: 1})
if err != nil {
t.Fatalf("ListCountries() error = %v", err)
}
if len(resp.Records) != 2 {
t.Fatalf("countries = %+v, want two open rows", resp.Records)
}
if resp.Records[0].CountryCode != "AE" || resp.Records[1].CountryCode != "SA" {
t.Fatalf("countries = %+v, want sort order AE then SA", resp.Records)
}
}
func TestChangeUserCountryUpdatesCountryAndClearsCache(t *testing.T) {
service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA", CountryName: "Saudi Arabia"})
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
resp, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{
UserID: ID(2),
CountryID: ID(11),
})
if err != nil {
t.Fatalf("ChangeUserCountry() error = %v", err)
}
if !resp.Success || resp.User.CountryCode != "AE" || resp.User.CountryName != "United Arab Emirates" {
t.Fatalf("resp = %+v, want changed country", resp)
}
var row userBaseInfoRow
if err := db.Where("id = ?", int64(2)).First(&row).Error; err != nil {
t.Fatalf("load user: %v", err)
}
if row.CountryID != 11 || row.CountryCode != "AE" || row.CountryName != "United Arab Emirates" || row.UpdateUser != 1 {
t.Fatalf("user row = %+v, want AE country and manager updater", row)
}
if !equalInt64Slice(fake.cacheCleared, []int64{2}) {
t.Fatalf("cacheCleared = %+v, want [2]", fake.cacheCleared)
}
}
func TestChangeUserCountryRejectsDisabledFeature(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManagerWithFeatures(t, db, 1, ManagerFeatures{AddBDLeader: true, GiftProps: true, BanUser: true, UnbanUser: true})
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA", CountryName: "Saudi Arabia"})
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
_, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{UserID: ID(2), CountryID: ID(11)})
assertAppErrorCode(t, err, "manager_feature_disabled")
var row userBaseInfoRow
if err := db.Where("id = ?", int64(2)).First(&row).Error; err != nil {
t.Fatalf("load user: %v", err)
}
if row.CountryID != 10 || row.CountryCode != "SA" {
t.Fatalf("user row = %+v, want unchanged country when feature disabled", row)
}
}
func TestChangeUserCountryRejectsHistoryIdentity(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA"})
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
if err := db.Create(&userHistoryIdentityRow{UserID: 2, IsHost: true}).Error; err != nil {
t.Fatalf("seed identity: %v", err)
}
_, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{UserID: ID(2), CountryID: ID(11)})
assertAppErrorCode(t, err, "user_identity_locked")
}
func TestChangeUserCountryRejectsBDLeader(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA"})
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
if err := db.Create(&bdLeaderRow{ID: 20, UserID: 2, Origin: "LIKEI", RegionID: "SA"}).Error; err != nil {
t.Fatalf("seed bd leader: %v", err)
}
_, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{UserID: ID(2), CountryID: ID(11)})
assertAppErrorCode(t, err, "user_identity_locked")
}
func TestChangeUserCountryRejectsCoinSeller(t *testing.T) {
service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA"})
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
fake.freightSellers[2] = true
_, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{UserID: ID(2), CountryID: ID(11)})
assertAppErrorCode(t, err, "user_identity_locked")
}
func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) { func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
t.Helper() t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
@ -660,6 +886,7 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
if err := db.AutoMigrate( if err := db.AutoMigrate(
&teamManagerInfoRow{}, &teamManagerInfoRow{},
&userBaseInfoRow{}, &userBaseInfoRow{},
&sysCountryCodeRow{},
&bdLeaderRow{}, &bdLeaderRow{},
&businessDevelopmentBaseInfoRow{}, &businessDevelopmentBaseInfoRow{},
&userHistoryIdentityRow{}, &userHistoryIdentityRow{},
@ -674,19 +901,45 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
t.Fatalf("auto migrate: %v", err) t.Fatalf("auto migrate: %v", err)
} }
fake := &fakeManagerGateway{ fake := &fakeManagerGateway{
recharges: map[int64][]integration.UserTotalRecharge{}, recharges: map[int64][]integration.UserTotalRecharge{},
abilities: map[int64]integration.PropsNobleVIPAbility{}, freightSellers: map[int64]bool{},
abilities: map[int64]integration.PropsNobleVIPAbility{},
} }
return NewService(db, fake), fake return NewService(db, fake), fake
} }
func seedManager(t *testing.T, db *gorm.DB, userID int64) { func seedManager(t *testing.T, db *gorm.DB, userID int64) {
t.Helper() t.Helper()
if err := db.Create(&teamManagerInfoRow{ID: userID, UserID: userID, Origin: "LIKEI", RegionID: "SA"}).Error; err != nil { seedManagerWithFeatures(t, db, userID, ManagerFeatures{
AddBDLeader: true,
GiftProps: true,
BanUser: true,
UnbanUser: true,
ChangeCountry: true,
})
}
func seedManagerWithFeatures(t *testing.T, db *gorm.DB, userID int64, features ManagerFeatures) {
t.Helper()
if err := db.Create(&teamManagerInfoRow{
ID: userID,
UserID: userID,
Origin: "LIKEI",
RegionID: "SA",
CanAddBDLeader: boolPtr(features.AddBDLeader),
CanGiftProps: boolPtr(features.GiftProps),
CanBanUser: boolPtr(features.BanUser),
CanUnbanUser: boolPtr(features.UnbanUser),
CanChangeCountry: boolPtr(features.ChangeCountry),
}).Error; err != nil {
t.Fatalf("seed manager: %v", err) t.Fatalf("seed manager: %v", err)
} }
} }
func boolPtr(value bool) *bool {
return &value
}
func seedUsers(t *testing.T, db *gorm.DB, rows ...userBaseInfoRow) { func seedUsers(t *testing.T, db *gorm.DB, rows ...userBaseInfoRow) {
t.Helper() t.Helper()
for _, row := range rows { for _, row := range rows {
@ -699,6 +952,15 @@ func seedUsers(t *testing.T, db *gorm.DB, rows ...userBaseInfoRow) {
} }
} }
func seedCountries(t *testing.T, db *gorm.DB, rows ...sysCountryCodeRow) {
t.Helper()
for _, row := range rows {
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seed country: %v", err)
}
}
}
func seedProps(t *testing.T, db *gorm.DB, rows ...propsSourceRecordRow) { func seedProps(t *testing.T, db *gorm.DB, rows ...propsSourceRecordRow) {
t.Helper() t.Helper()
for _, row := range rows { for _, row := range rows {

View File

@ -28,9 +28,10 @@ const (
vipGiftOrigin = "VIP_PURCHASE" vipGiftOrigin = "VIP_PURCHASE"
vipGiftOriginDesc = "VIP purchase" vipGiftOriginDesc = "VIP purchase"
defaultGiftDays = 7 defaultGiftDays = 7
defaultVIPGiftDays = 30 defaultVIPGiftDays = 30
maxGiftableVIPLevel = 3 // 经理中心 VIP 赠送按当前运营范围放开 VIP1-VIP5列表和发放校验共用该上限避免展示可选但提交失败。
maxGiftableVIPLevel = 5
accountStatusNormal = "NORMAL" accountStatusNormal = "NORMAL"
accountStatusFreeze = "FREEZE" accountStatusFreeze = "FREEZE"
@ -38,6 +39,16 @@ const (
accountStatusArchiveDevice = "ARCHIVE_DEVICE" accountStatusArchiveDevice = "ARCHIVE_DEVICE"
) )
type managerFeature string
const (
managerFeatureAddBDLeader managerFeature = "add_bd_leader"
managerFeatureGiftProps managerFeature = "gift_props"
managerFeatureBanUser managerFeature = "ban_user"
managerFeatureUnbanUser managerFeature = "unban_user"
managerFeatureChangeCountry managerFeature = "change_country"
)
type AppError = common.AppError type AppError = common.AppError
type AuthUser = common.AuthUser type AuthUser = common.AuthUser
@ -51,6 +62,7 @@ type managerGateway interface {
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error) GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error)
GetUserProfileByAccountOne(ctx context.Context, account string) (integration.UserProfile, error) GetUserProfileByAccountOne(ctx context.Context, account string) (integration.UserProfile, error)
MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error)
ExistsFreightSeller(ctx context.Context, userID int64) (bool, error)
GivePropsBackpack(ctx context.Context, req integration.GivePropsBackpackRequest) error GivePropsBackpack(ctx context.Context, req integration.GivePropsBackpackRequest) error
GetNobleVIPAbility(ctx context.Context, sourceID int64) (integration.PropsNobleVIPAbility, error) GetNobleVIPAbility(ctx context.Context, sourceID int64) (integration.PropsNobleVIPAbility, error)
GetUserMaxNobleVIPAbility(ctx context.Context, userID int64) (integration.PropsNobleVIPAbility, error) GetUserMaxNobleVIPAbility(ctx context.Context, userID int64) (integration.PropsNobleVIPAbility, error)
@ -106,18 +118,40 @@ func (id *ID) UnmarshalJSON(data []byte) error {
} }
type ProfileResponse struct { type ProfileResponse struct {
Manager UserView `json:"manager"` Manager UserView `json:"manager"`
Features ManagerFeatures `json:"features"`
}
type ManagerFeatures struct {
AddBDLeader bool `json:"addBdLeader"`
GiftProps bool `json:"giftProps"`
BanUser bool `json:"banUser"`
UnbanUser bool `json:"unbanUser"`
ChangeCountry bool `json:"changeCountry"`
} }
type UserSearchResponse struct { type UserSearchResponse struct {
User UserView `json:"user"` User UserView `json:"user"`
} }
type CountryListResponse struct {
Records []CountryView `json:"records"`
}
type CountryView struct {
ID ID `json:"id"`
CountryCode string `json:"countryCode"`
CountryName string `json:"countryName"`
NationalFlag string `json:"nationalFlag,omitempty"`
PhonePrefix int `json:"phonePrefix,omitempty"`
}
type UserView struct { type UserView struct {
UserID ID `json:"userId"` UserID ID `json:"userId"`
Account string `json:"account"` Account string `json:"account"`
UserAvatar string `json:"userAvatar"` UserAvatar string `json:"userAvatar"`
UserNickname string `json:"userNickname"` UserNickname string `json:"userNickname"`
CountryID ID `json:"countryId,omitempty"`
CountryCode string `json:"countryCode,omitempty"` CountryCode string `json:"countryCode,omitempty"`
CountryName string `json:"countryName,omitempty"` CountryName string `json:"countryName,omitempty"`
OriginSys string `json:"originSys,omitempty"` OriginSys string `json:"originSys,omitempty"`
@ -179,6 +213,16 @@ type UnbanUserResponse struct {
AccountStatus string `json:"accountStatus"` AccountStatus string `json:"accountStatus"`
} }
type ChangeUserCountryRequest struct {
UserID ID `json:"userId"`
CountryID ID `json:"countryId"`
}
type ChangeUserCountryResponse struct {
Success bool `json:"success"`
User UserView `json:"user"`
}
type BDLeaderListResponse struct { type BDLeaderListResponse struct {
Count int `json:"count"` Count int `json:"count"`
Records []BDLeaderView `json:"records"` Records []BDLeaderView `json:"records"`

View File

@ -117,7 +117,7 @@ ORDER BY claims.last_claim_time DESC, claims.user_id ASC`
if err != nil { if err != nil {
return nil, err return nil, err
} }
rewardItems := s.loadRewardItemsMap(ctx, claimDetailLevels(details)) rewardItems := s.loadRewardItemsMap(ctx, sysOrigin, claimDetailLevels(details))
records = make([]RechargeRewardClaimRecordView, 0, len(rows)) records = make([]RechargeRewardClaimRecordView, 0, len(rows))
for _, row := range rows { for _, row := range rows {
records = append(records, rechargeRewardClaimRecordView(row, displayMonth, rechargeDate, details[row.UserID], rewardItems)) records = append(records, rechargeRewardClaimRecordView(row, displayMonth, rechargeDate, details[row.UserID], rewardItems))

View File

@ -39,7 +39,7 @@ func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*RechargeRew
if err != nil { if err != nil {
return nil, err return nil, err
} }
rewardItems := s.loadRewardItemsMap(ctx, levels) rewardItems := s.loadRewardItemsMap(ctx, configRow.SysOrigin, levels)
return &RechargeRewardConfigResponse{ return &RechargeRewardConfigResponse{
Configured: true, Configured: true,
ID: configRow.ID, ID: configRow.ID,
@ -60,7 +60,7 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveRechargeRewardConfigRe
return nil, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error()) return nil, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error())
} }
levels, err := s.normalizeLevelInputs(req.LevelConfigs) levels, err := s.normalizeLevelInputs(ctx, sysOrigin, req.LevelConfigs)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -133,7 +133,7 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveRechargeRewardConfigRe
return s.GetConfig(ctx, rechargeRewardSysOriginBySavedID(ctx, s.db, savedConfigID, sysOrigin)) return s.GetConfig(ctx, rechargeRewardSysOriginBySavedID(ctx, s.db, savedConfigID, sysOrigin))
} }
func (s *Service) normalizeLevelInputs(inputs []RechargeRewardLevelInput) ([]model.RechargeRewardLevel, error) { func (s *Service) normalizeLevelInputs(ctx context.Context, sysOrigin string, inputs []RechargeRewardLevelInput) ([]model.RechargeRewardLevel, error) {
if len(inputs) == 0 { if len(inputs) == 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_level_configs", "levelConfigs is required") return nil, NewAppError(http.StatusBadRequest, "invalid_level_configs", "levelConfigs is required")
} }
@ -152,7 +152,7 @@ func (s *Service) normalizeLevelInputs(inputs []RechargeRewardLevelInput) ([]mod
if amountCents <= 0 { if amountCents <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_recharge_amount", "rechargeAmount must be greater than 0") return nil, NewAppError(http.StatusBadRequest, "invalid_recharge_amount", "rechargeAmount must be greater than 0")
} }
rewardGroupID := item.RewardGroupID.Int64Ptr() rewardGroupID := s.resolveRewardGroupID(ctx, sysOrigin, item.RewardGroupID.Int64Ptr(), item.RewardGroupName)
if item.RewardGold <= 0 && (rewardGroupID == nil || *rewardGroupID <= 0) { if item.RewardGold <= 0 && (rewardGroupID == nil || *rewardGroupID <= 0) {
return nil, NewAppError(http.StatusBadRequest, "invalid_reward", "rewardGold or rewardGroupId is required") return nil, NewAppError(http.StatusBadRequest, "invalid_reward", "rewardGold or rewardGroupId is required")
} }
@ -188,6 +188,22 @@ func (s *Service) normalizeLevelInputs(inputs []RechargeRewardLevelInput) ([]mod
return rows, nil return rows, nil
} }
func (s *Service) resolveRewardGroupID(ctx context.Context, sysOrigin string, rewardGroupID *int64, rewardGroupName string) *int64 {
normalized := normalizeRewardGroupID(rewardGroupID)
if normalized == nil || s.java == nil || strings.TrimSpace(rewardGroupName) == "" {
return normalized
}
if detail, err := s.java.GetRewardGroupDetail(ctx, *normalized); err == nil && rewardGroupDetailHasItems(detail) {
return normalized
}
detail, err := s.java.FindRewardGroupDetail(ctx, sysOrigin, rewardGroupName)
if err != nil || int64(detail.ID) <= 0 {
return normalized
}
resolved := int64(detail.ID)
return &resolved
}
func (s *Service) loadConfigBundle(ctx context.Context, sysOrigin string) (*rechargeRewardConfigBundle, error) { func (s *Service) loadConfigBundle(ctx context.Context, sysOrigin string) (*rechargeRewardConfigBundle, error) {
sysOrigin = s.normalizeSysOrigin(sysOrigin) sysOrigin = s.normalizeSysOrigin(sysOrigin)

View File

@ -224,6 +224,46 @@ INSERT INTO likei_wallet.user_freight_balance_running_water (accept_user_id, sys
} }
} }
func TestGetHomeFallsBackToRewardGroupNameWhenStoredIDLostPrecision(t *testing.T) {
actualGroupID := int64(2056723577461338114)
roundedGroupID := int64(2056723577461338000)
service, _ := newTestService(t, rechargeRewardTestGateway{
groups: map[int64]integration.RewardGroupDetail{
actualGroupID: {
ID: integration.Int64Value(actualGroupID),
Name: "累充活动$50",
RewardConfigList: []integration.RewardGroupItem{
{ID: integration.Int64Value(1), Type: "BADGE", Name: "recharge50$", Quantity: integration.Int64Value(30), Cover: "https://example.com/badge.png"},
},
},
},
})
if _, err := service.SaveConfig(context.Background(), SaveRechargeRewardConfigRequest{
SysOrigin: "LIKEI",
Enabled: true,
Timezone: "Asia/Riyadh",
LevelConfigs: []RechargeRewardLevelInput{
{Level: 1, RechargeAmount: mustAmount(t, "50"), RewardGroupID: ptrFlexibleInt64(roundedGroupID), RewardGroupName: "累充活动$50", Enabled: true},
},
}); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
resp, err := service.GetConfig(context.Background(), "LIKEI")
if err != nil {
t.Fatalf("GetConfig() error = %v", err)
}
if len(resp.LevelConfigs) != 1 || len(resp.LevelConfigs[0].RewardItems) != 1 {
t.Fatalf("reward items = %+v, want fallback by reward group name", resp.LevelConfigs)
}
if resp.LevelConfigs[0].RewardGroupID == nil || *resp.LevelConfigs[0].RewardGroupID != actualGroupID {
t.Fatalf("rewardGroupId = %v, want resolved actual group id %d", resp.LevelConfigs[0].RewardGroupID, actualGroupID)
}
if got := resp.LevelConfigs[0].RewardItems[0].Name; got != "recharge50$" {
t.Fatalf("reward item name = %q, want recharge50$", got)
}
}
func TestPageClaimRecordsUsesActualReceiveRecords(t *testing.T) { func TestPageClaimRecordsUsesActualReceiveRecords(t *testing.T) {
groupID := int64(9001) groupID := int64(9001)
service, db := newTestService(t, rechargeRewardTestGateway{ service, db := newTestService(t, rechargeRewardTestGateway{
@ -408,6 +448,15 @@ func (g rechargeRewardTestGateway) GetRewardGroupDetail(_ context.Context, group
return integration.RewardGroupDetail{}, fmt.Errorf("group not found") return integration.RewardGroupDetail{}, fmt.Errorf("group not found")
} }
func (g rechargeRewardTestGateway) FindRewardGroupDetail(_ context.Context, _ string, name string) (integration.RewardGroupDetail, error) {
for _, detail := range g.groups {
if detail.Name == name {
return detail, nil
}
}
return integration.RewardGroupDetail{}, fmt.Errorf("group not found")
}
func (g rechargeRewardTestGateway) MapUserTotalRecharge(context.Context, []int64) (map[int64][]integration.UserTotalRecharge, error) { func (g rechargeRewardTestGateway) MapUserTotalRecharge(context.Context, []int64) (map[int64][]integration.UserTotalRecharge, error) {
return g.total, nil return g.total, nil
} }

View File

@ -83,7 +83,7 @@ func (s *Service) GetHome(ctx context.Context, user AuthUser) (*RechargeRewardHo
resp.NextRechargeAmount = formatAmountCents(next.RechargeAmountCents) resp.NextRechargeAmount = formatAmountCents(next.RechargeAmountCents)
resp.NextRechargeAmountCents = next.RechargeAmountCents resp.NextRechargeAmountCents = next.RechargeAmountCents
} }
rewardItems := s.loadRewardItemsMap(ctx, bundle.Levels) rewardItems := s.loadRewardItemsMap(ctx, resp.SysOrigin, bundle.Levels)
matchedLevel := 0 matchedLevel := 0
if matchedOK { if matchedOK {
matchedLevel = matched.Level matchedLevel = matched.Level

View File

@ -131,6 +131,7 @@ func sortLevels(levels []rechargeRewardLevelSnapshot) {
func (s *Service) loadRewardItemsMap( func (s *Service) loadRewardItemsMap(
ctx context.Context, ctx context.Context,
sysOrigin string,
levels []rechargeRewardLevelSnapshot, levels []rechargeRewardLevelSnapshot,
) map[int64][]RechargeRewardRewardItem { ) map[int64][]RechargeRewardRewardItem {
if s.java == nil { if s.java == nil {
@ -138,10 +139,14 @@ func (s *Service) loadRewardItemsMap(
} }
groupIDs := make([]int64, 0) groupIDs := make([]int64, 0)
seen := make(map[int64]struct{}) seen := make(map[int64]struct{})
groupNames := make(map[int64]string)
for _, level := range levels { for _, level := range levels {
if level.RewardGroupID == nil || *level.RewardGroupID <= 0 { if level.RewardGroupID == nil || *level.RewardGroupID <= 0 {
continue continue
} }
if strings.TrimSpace(level.RewardGroupName) != "" {
groupNames[*level.RewardGroupID] = level.RewardGroupName
}
if _, exists := seen[*level.RewardGroupID]; exists { if _, exists := seen[*level.RewardGroupID]; exists {
continue continue
} }
@ -153,15 +158,23 @@ func (s *Service) loadRewardItemsMap(
result := make(map[int64][]RechargeRewardRewardItem, len(groupIDs)) result := make(map[int64][]RechargeRewardRewardItem, len(groupIDs))
for _, groupID := range groupIDs { for _, groupID := range groupIDs {
detail, err := s.java.GetRewardGroupDetail(ctx, groupID) detail, err := s.java.GetRewardGroupDetail(ctx, groupID)
if err != nil { if err != nil || !rewardGroupDetailHasItems(detail) {
result[groupID] = []RechargeRewardRewardItem{} if fallback, fallbackErr := s.java.FindRewardGroupDetail(ctx, sysOrigin, groupNames[groupID]); fallbackErr == nil && rewardGroupDetailHasItems(fallback) {
continue detail = fallback
} else {
result[groupID] = []RechargeRewardRewardItem{}
continue
}
} }
result[groupID] = rewardItemsFromGroup(detail) result[groupID] = rewardItemsFromGroup(detail)
} }
return result return result
} }
func rewardGroupDetailHasItems(detail integration.RewardGroupDetail) bool {
return len(detail.RewardConfigList) > 0
}
func rewardItemsFromGroup(detail integration.RewardGroupDetail) []RechargeRewardRewardItem { func rewardItemsFromGroup(detail integration.RewardGroupDetail) []RechargeRewardRewardItem {
items := make([]RechargeRewardRewardItem, 0, len(detail.RewardConfigList)) items := make([]RechargeRewardRewardItem, 0, len(detail.RewardConfigList))
for _, item := range detail.RewardConfigList { for _, item := range detail.RewardConfigList {

View File

@ -35,6 +35,7 @@ type rechargeRewardDB interface {
type rechargeRewardJavaGateway interface { type rechargeRewardJavaGateway interface {
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error) GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error)
GetRewardGroupDetail(ctx context.Context, groupID int64) (integration.RewardGroupDetail, error) GetRewardGroupDetail(ctx context.Context, groupID int64) (integration.RewardGroupDetail, error)
FindRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (integration.RewardGroupDetail, error)
MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error)
GetThisMonthTotalPersonalRecharge(ctx context.Context, userID int64) (integration.DecimalString, error) GetThisMonthTotalPersonalRecharge(ctx context.Context, userID int64) (integration.DecimalString, error)
} }

View File

@ -20,6 +20,11 @@ import (
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
const (
defaultTaskCenterArchiveBatchSize = 500
maxTaskCenterArchiveBatchSize = 1000
)
// StartArchiveWorker 启动任务中心事件 COS 归档 worker。 // StartArchiveWorker 启动任务中心事件 COS 归档 worker。
func (s *Service) StartArchiveWorker(ctx context.Context) error { func (s *Service) StartArchiveWorker(ctx context.Context) error {
if !s.cfg.TaskCenter.Archive.Enabled { if !s.cfg.TaskCenter.Archive.Enabled {
@ -68,10 +73,7 @@ func (s *Service) runArchiveWorker(ctx context.Context, workerIndex int) {
} }
func (s *Service) archivePendingEvents(ctx context.Context) error { func (s *Service) archivePendingEvents(ctx context.Context) error {
batchSize := s.cfg.TaskCenter.Archive.BatchSize batchSize := normalizeTaskCenterArchiveBatchSize(s.cfg.TaskCenter.Archive.BatchSize)
if batchSize <= 0 {
batchSize = 10000
}
rows, err := s.claimArchiveRows(ctx, batchSize) rows, err := s.claimArchiveRows(ctx, batchSize)
if err != nil { if err != nil {
return err return err
@ -97,16 +99,46 @@ func (s *Service) claimArchiveRows(ctx context.Context, batchSize int) ([]model.
var rows []model.TaskCenterEventArchiveOutbox var rows []model.TaskCenterEventArchiveOutbox
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
staleProcessingBefore := time.Now().Add(-10 * time.Minute) staleProcessingBefore := time.Now().Add(-10 * time.Minute)
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}). staleLimit, _ := taskCenterArchiveClaimLimits(batchSize)
Where("status IN ? OR (status = ? AND update_time < ?)", if staleLimit > 0 {
[]string{ArchiveStatusPending, ArchiveStatusFailed}, var staleRows []model.TaskCenterEventArchiveOutbox
ArchiveStatusProcessing, // PROCESSING 超时行保留固定接管额度,避免 PENDING/FAILED 持续堆积时旧锁永久饥饿。
staleProcessingBefore, if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
). Where("status = ? AND update_time < ?", ArchiveStatusProcessing, staleProcessingBefore).
Order("create_time asc, id asc"). Order("update_time asc, id asc").
Limit(batchSize). Limit(staleLimit).
Find(&rows).Error; err != nil { Find(&staleRows).Error; err != nil {
return err return err
}
rows = append(rows, staleRows...)
}
if remaining := batchSize - len(rows); remaining > 0 {
pendingLimit := taskCenterArchivePendingClaimLimit(remaining)
for _, retryable := range []struct {
status string
limit int
}{
{status: ArchiveStatusPending, limit: pendingLimit},
{status: ArchiveStatusFailed, limit: remaining},
} {
if retryable.limit <= 0 || remaining <= 0 {
continue
}
if retryable.limit > remaining {
retryable.limit = remaining
}
var retryableRows []model.TaskCenterEventArchiveOutbox
// PENDING 和 FAILED 分开抢占,避免 IN 跨 status 排序触发 filesort同时给 FAILED 保留重试额度。
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
Where("status = ?", retryable.status).
Order("create_time asc, id asc").
Limit(retryable.limit).
Find(&retryableRows).Error; err != nil {
return err
}
rows = append(rows, retryableRows...)
remaining -= len(retryableRows)
}
} }
if len(rows) == 0 { if len(rows) == 0 {
return nil return nil
@ -124,6 +156,44 @@ func (s *Service) claimArchiveRows(ctx context.Context, batchSize int) ([]model.
return rows, nil return rows, nil
} }
func normalizeTaskCenterArchiveBatchSize(batchSize int) int {
if batchSize <= 0 {
return defaultTaskCenterArchiveBatchSize
}
if batchSize > maxTaskCenterArchiveBatchSize {
return maxTaskCenterArchiveBatchSize
}
return batchSize
}
func taskCenterArchiveClaimLimits(batchSize int) (int, int) {
if batchSize <= 1 {
return batchSize, 0
}
staleLimit := batchSize / 10
if staleLimit < 1 {
staleLimit = 1
}
if staleLimit > 100 {
staleLimit = 100
}
return staleLimit, batchSize - staleLimit
}
func taskCenterArchivePendingClaimLimit(retryableLimit int) int {
if retryableLimit <= 1 {
return retryableLimit
}
failedReserve := retryableLimit / 10
if failedReserve < 1 {
failedReserve = 1
}
if failedReserve > 100 {
failedReserve = 100
}
return retryableLimit - failedReserve
}
func (s *Service) newCOSClient() (*cos.Client, error) { func (s *Service) newCOSClient() (*cos.Client, error) {
bucketURL, err := url.Parse(fmt.Sprintf("https://%s.cos.%s.myqcloud.com", bucketURL, err := url.Parse(fmt.Sprintf("https://%s.cos.%s.myqcloud.com",
strings.TrimSpace(s.cfg.TaskCenter.Archive.Bucket), strings.TrimSpace(s.cfg.TaskCenter.Archive.Bucket),

View File

@ -42,6 +42,73 @@ func newTestService(t *testing.T, java taskCenterJavaGateway) (*Service, *gorm.D
return service, db return service, db
} }
func TestNormalizeTaskCenterArchiveBatchSize(t *testing.T) {
tests := []struct {
name string
input int
want int
}{
{name: "default", input: 0, want: defaultTaskCenterArchiveBatchSize},
{name: "negative default", input: -1, want: defaultTaskCenterArchiveBatchSize},
{name: "keeps configured value", input: 200, want: 200},
{name: "caps large batch", input: 10000, want: maxTaskCenterArchiveBatchSize},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeTaskCenterArchiveBatchSize(tt.input); got != tt.want {
t.Fatalf("normalizeTaskCenterArchiveBatchSize(%d) = %d, want %d", tt.input, got, tt.want)
}
})
}
}
func TestTaskCenterArchiveClaimLimitsReserveStaleRows(t *testing.T) {
tests := []struct {
name string
batchSize int
wantStale int
wantRetryable int
}{
{name: "single row", batchSize: 1, wantStale: 1, wantRetryable: 0},
{name: "small batch keeps one stale slot", batchSize: 5, wantStale: 1, wantRetryable: 4},
{name: "normal batch uses ten percent", batchSize: 500, wantStale: 50, wantRetryable: 450},
{name: "large batch caps stale slot", batchSize: 1000, wantStale: 100, wantRetryable: 900},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotStale, gotRetryable := taskCenterArchiveClaimLimits(tt.batchSize)
if gotStale != tt.wantStale || gotRetryable != tt.wantRetryable {
t.Fatalf("taskCenterArchiveClaimLimits(%d) = (%d, %d), want (%d, %d)",
tt.batchSize, gotStale, gotRetryable, tt.wantStale, tt.wantRetryable)
}
})
}
}
func TestTaskCenterArchivePendingClaimLimitReservesFailedRows(t *testing.T) {
tests := []struct {
name string
retryableLimit int
want int
}{
{name: "empty", retryableLimit: 0, want: 0},
{name: "single row stays pending first", retryableLimit: 1, want: 1},
{name: "small retry batch keeps one failed slot", retryableLimit: 4, want: 3},
{name: "normal retry batch reserves ten percent", retryableLimit: 450, want: 405},
{name: "large retry batch caps failed reserve", retryableLimit: 900, want: 810},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := taskCenterArchivePendingClaimLimit(tt.retryableLimit); got != tt.want {
t.Fatalf("taskCenterArchivePendingClaimLimit(%d) = %d, want %d", tt.retryableLimit, got, tt.want)
}
})
}
}
func TestDailyTaskConfigEventListAndClaim(t *testing.T) { func TestDailyTaskConfigEventListAndClaim(t *testing.T) {
java := &taskCenterTestGateway{events: map[string]bool{}} java := &taskCenterTestGateway{events: map[string]bool{}}
service, db := newTestService(t, java) service, db := newTestService(t, java)

View File

@ -126,16 +126,18 @@ func (s *Service) PageAdminRecords(
records = append(records, payload) records = append(records, payload)
} }
return &RecordPageResponse{ return &RecordPageResponse{
Records: records, Records: records,
Total: total, Total: total,
Current: cursor, Current: cursor,
Size: limit, Size: limit,
TotalPaidGold: summary.TotalPaidGold, TotalPaidGold: summary.TotalPaidGold,
TotalRewardGold: summary.TotalRewardGold, TotalRewardGold: summary.TotalRewardGold,
TotalDrawTimes: summary.TotalDrawTimes, TotalGiftRewardGold: summary.TotalGiftRewardGold,
ParticipantCount: summary.ParticipantCount, TotalDecorationRewardGold: summary.TotalDecorationRewardGold,
StartTime: formatDateTime(filter.StartTime), TotalDrawTimes: summary.TotalDrawTimes,
EndTime: formatDateTime(filter.EndTime), ParticipantCount: summary.ParticipantCount,
StartTime: formatDateTime(filter.StartTime),
EndTime: formatDateTime(filter.EndTime),
}, nil }, nil
} }
@ -250,10 +252,12 @@ type wheelAdminRecordFilter struct {
} }
type wheelAdminRecordSummary struct { type wheelAdminRecordSummary struct {
TotalPaidGold int64 TotalPaidGold int64
TotalRewardGold int64 TotalRewardGold int64
TotalDrawTimes int64 TotalGiftRewardGold int64
ParticipantCount int64 TotalDecorationRewardGold int64
TotalDrawTimes int64
ParticipantCount int64
} }
func newWheelAdminRecordFilter(sysOrigin string, category string, userID int64, status string, startRaw string, endRaw string) (wheelAdminRecordFilter, error) { func newWheelAdminRecordFilter(sysOrigin string, category string, userID int64, status string, startRaw string, endRaw string) (wheelAdminRecordFilter, error) {
@ -348,9 +352,16 @@ func (s *Service) adminRecordQuery(ctx context.Context, filter wheelAdminRecordF
func (s *Service) adminRecordSummary(ctx context.Context, filter wheelAdminRecordFilter) (wheelAdminRecordSummary, error) { func (s *Service) adminRecordSummary(ctx context.Context, filter wheelAdminRecordFilter) (wheelAdminRecordSummary, error) {
var summary wheelAdminRecordSummary var summary wheelAdminRecordSummary
// totalRewardGold keeps the historical dashboard number, including direct gold rewards.
// The two split fields only count RESOURCE rewards: GIFT is the gift value, every other
// resourceType is treated as decoration value for the admin cards.
if err := s.adminRecordQuery(ctx, filter). if err := s.adminRecordQuery(ctx, filter).
Select("COALESCE(SUM(CASE WHEN status = ? THEN CASE WHEN reward_type = ? THEN gold_amount ELSE display_gold_amount END ELSE 0 END), 0)", drawStatusSuccess, rewardTypeGold). Select(`
Scan(&summary.TotalRewardGold).Error; err != nil { COALESCE(SUM(CASE WHEN status = ? THEN CASE WHEN reward_type = ? THEN gold_amount ELSE display_gold_amount END ELSE 0 END), 0) AS total_reward_gold,
COALESCE(SUM(CASE WHEN status = ? AND reward_type = ? AND UPPER(resource_type) = ? THEN display_gold_amount ELSE 0 END), 0) AS total_gift_reward_gold,
COALESCE(SUM(CASE WHEN status = ? AND reward_type = ? AND UPPER(resource_type) <> ? THEN display_gold_amount ELSE 0 END), 0) AS total_decoration_reward_gold
`, drawStatusSuccess, rewardTypeGold, drawStatusSuccess, rewardTypeResource, resourceTypeGift, drawStatusSuccess, rewardTypeResource, resourceTypeGift).
Scan(&summary).Error; err != nil {
return summary, err return summary, err
} }
if err := s.adminRecordQuery(ctx, filter). if err := s.adminRecordQuery(ctx, filter).

View File

@ -104,8 +104,8 @@ func TestPageAdminRecordsIncludesUserInfoAndRangeSummary(t *testing.T) {
PaidGold: 200, PaidGold: 200,
RewardType: rewardTypeResource, RewardType: rewardTypeResource,
ResourceID: &giftID, ResourceID: &giftID,
ResourceType: resourceTypeGift, ResourceType: "AVATAR_FRAME",
ResourceName: "Gift B", ResourceName: "Frame B",
DisplayGoldAmount: 30, DisplayGoldAmount: 30,
Probability: 500000, Probability: 500000,
Status: drawStatusSuccess, Status: drawStatusSuccess,
@ -149,6 +149,12 @@ func TestPageAdminRecordsIncludesUserInfoAndRangeSummary(t *testing.T) {
if resp.TotalRewardGold != 150 { if resp.TotalRewardGold != 150 {
t.Fatalf("total reward = %d, want 150", resp.TotalRewardGold) t.Fatalf("total reward = %d, want 150", resp.TotalRewardGold)
} }
if resp.TotalGiftRewardGold != 50 {
t.Fatalf("total gift reward = %d, want 50", resp.TotalGiftRewardGold)
}
if resp.TotalDecorationRewardGold != 30 {
t.Fatalf("total decoration reward = %d, want 30", resp.TotalDecorationRewardGold)
}
if resp.TotalDrawTimes != 12 { if resp.TotalDrawTimes != 12 {
t.Fatalf("total draw times = %d, want 12", resp.TotalDrawTimes) t.Fatalf("total draw times = %d, want 12", resp.TotalDrawTimes)
} }

View File

@ -307,16 +307,18 @@ type DrawRecordPayload struct {
// RecordPageResponse is an admin record page. // RecordPageResponse is an admin record page.
type RecordPageResponse struct { type RecordPageResponse struct {
Records []DrawRecordPayload `json:"records"` Records []DrawRecordPayload `json:"records"`
Total int64 `json:"total"` Total int64 `json:"total"`
Current int `json:"current"` Current int `json:"current"`
Size int `json:"size"` Size int `json:"size"`
TotalPaidGold int64 `json:"totalPaidGold,omitempty"` TotalPaidGold int64 `json:"totalPaidGold,omitempty"`
TotalRewardGold int64 `json:"totalRewardGold,omitempty"` TotalRewardGold int64 `json:"totalRewardGold,omitempty"`
TotalDrawTimes int64 `json:"totalDrawTimes,omitempty"` TotalGiftRewardGold int64 `json:"totalGiftRewardGold,omitempty"`
ParticipantCount int64 `json:"participantCount,omitempty"` TotalDecorationRewardGold int64 `json:"totalDecorationRewardGold,omitempty"`
StartTime string `json:"startTime,omitempty"` TotalDrawTimes int64 `json:"totalDrawTimes,omitempty"`
EndTime string `json:"endTime,omitempty"` ParticipantCount int64 `json:"participantCount,omitempty"`
StartTime string `json:"startTime,omitempty"`
EndTime string `json:"endTime,omitempty"`
} }
func normalizeSysOrigin(sysOrigin string) string { func normalizeSysOrigin(sysOrigin string) string {

View File

@ -2,6 +2,7 @@ CREATE TABLE IF NOT EXISTS `first_recharge_reward_config` (
`id` bigint NOT NULL COMMENT '主键ID', `id` bigint NOT NULL COMMENT '主键ID',
`sys_origin` varchar(32) NOT NULL COMMENT '来源系统', `sys_origin` varchar(32) NOT NULL COMMENT '来源系统',
`enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用', `enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用',
`min_app_version` varchar(32) NOT NULL DEFAULT '1.5.0' COMMENT '最低生效App版本',
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
`update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
@ -14,6 +15,7 @@ CREATE TABLE IF NOT EXISTS `first_recharge_reward_level` (
`config_id` bigint NOT NULL COMMENT '首冲奖励主配置ID', `config_id` bigint NOT NULL COMMENT '首冲奖励主配置ID',
`level` int NOT NULL COMMENT '档位', `level` int NOT NULL COMMENT '档位',
`recharge_amount_cents` bigint NOT NULL DEFAULT '0' COMMENT '充值金额门槛,单位为分', `recharge_amount_cents` bigint NOT NULL DEFAULT '0' COMMENT '充值金额门槛,单位为分',
`google_product_id` varchar(128) NOT NULL DEFAULT '' COMMENT 'Google商品ID',
`reward_group_id` bigint NOT NULL DEFAULT '0' COMMENT '奖励资源组ID', `reward_group_id` bigint NOT NULL DEFAULT '0' COMMENT '奖励资源组ID',
`reward_group_name` varchar(255) NOT NULL DEFAULT '' COMMENT '奖励资源组名称快照', `reward_group_name` varchar(255) NOT NULL DEFAULT '' COMMENT '奖励资源组名称快照',
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用', `enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用',
@ -22,6 +24,7 @@ CREATE TABLE IF NOT EXISTS `first_recharge_reward_level` (
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `uk_first_recharge_reward_level` (`config_id`, `level`), UNIQUE KEY `uk_first_recharge_reward_level` (`config_id`, `level`),
UNIQUE KEY `uk_first_recharge_reward_amount` (`config_id`, `recharge_amount_cents`), UNIQUE KEY `uk_first_recharge_reward_amount` (`config_id`, `recharge_amount_cents`),
KEY `idx_first_recharge_reward_level_google_product` (`google_product_id`),
KEY `idx_first_recharge_reward_level_config` (`config_id`) KEY `idx_first_recharge_reward_level_config` (`config_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励充值档位'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励充值档位';
@ -42,6 +45,7 @@ CREATE TABLE IF NOT EXISTS `first_recharge_reward_grant_record` (
`level` int NOT NULL COMMENT '命中档位', `level` int NOT NULL COMMENT '命中档位',
`pay_platform` varchar(64) NOT NULL DEFAULT '' COMMENT '支付平台GOOGLE/MIFA_PAY/PAYER_MAX等', `pay_platform` varchar(64) NOT NULL DEFAULT '' COMMENT '支付平台GOOGLE/MIFA_PAY/PAYER_MAX等',
`payment_method` varchar(32) NOT NULL DEFAULT '' COMMENT '充值方式GOOGLE/THIRD_PARTY', `payment_method` varchar(32) NOT NULL DEFAULT '' COMMENT '充值方式GOOGLE/THIRD_PARTY',
`google_product_id` varchar(128) NOT NULL DEFAULT '' COMMENT 'Google商品ID',
`source_order_id` varchar(128) NOT NULL DEFAULT '' COMMENT '来源订单ID', `source_order_id` varchar(128) NOT NULL DEFAULT '' COMMENT '来源订单ID',
`reward_group_id` bigint NOT NULL DEFAULT '0' COMMENT '奖励资源组ID', `reward_group_id` bigint NOT NULL DEFAULT '0' COMMENT '奖励资源组ID',
`reward_group_name` varchar(255) NOT NULL DEFAULT '' COMMENT '奖励资源组名称快照', `reward_group_name` varchar(255) NOT NULL DEFAULT '' COMMENT '奖励资源组名称快照',
@ -58,5 +62,6 @@ CREATE TABLE IF NOT EXISTS `first_recharge_reward_grant_record` (
UNIQUE KEY `uk_first_recharge_reward_event` (`event_id`), UNIQUE KEY `uk_first_recharge_reward_event` (`event_id`),
UNIQUE KEY `uk_first_recharge_reward_user` (`sys_origin`, `user_id`), UNIQUE KEY `uk_first_recharge_reward_user` (`sys_origin`, `user_id`),
KEY `idx_first_recharge_reward_status` (`sys_origin`, `user_id`, `status`), KEY `idx_first_recharge_reward_status` (`sys_origin`, `user_id`, `status`),
KEY `idx_first_recharge_reward_grant_google_product` (`google_product_id`),
KEY `idx_first_recharge_reward_order` (`source_order_id`) KEY `idx_first_recharge_reward_order` (`source_order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励发放记录'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励发放记录';

View File

@ -0,0 +1,71 @@
SET @current_schema := DATABASE();
SET @add_first_recharge_reward_min_app_version_sql := IF(
NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'first_recharge_reward_config'
AND column_name = 'min_app_version'
),
'ALTER TABLE `first_recharge_reward_config` ADD COLUMN `min_app_version` varchar(32) NOT NULL DEFAULT ''1.5.0'' COMMENT ''最低生效App版本'' AFTER `enabled`',
'SELECT 1'
);
PREPARE add_first_recharge_reward_min_app_version_stmt FROM @add_first_recharge_reward_min_app_version_sql;
EXECUTE add_first_recharge_reward_min_app_version_stmt;
DEALLOCATE PREPARE add_first_recharge_reward_min_app_version_stmt;
SET @add_first_recharge_reward_level_google_product_sql := IF(
NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'first_recharge_reward_level'
AND column_name = 'google_product_id'
),
'ALTER TABLE `first_recharge_reward_level` ADD COLUMN `google_product_id` varchar(128) NOT NULL DEFAULT '''' COMMENT ''Google商品ID'' AFTER `recharge_amount_cents`',
'SELECT 1'
);
PREPARE add_first_recharge_reward_level_google_product_stmt FROM @add_first_recharge_reward_level_google_product_sql;
EXECUTE add_first_recharge_reward_level_google_product_stmt;
DEALLOCATE PREPARE add_first_recharge_reward_level_google_product_stmt;
SET @add_first_recharge_reward_record_google_product_sql := IF(
NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'first_recharge_reward_grant_record'
AND column_name = 'google_product_id'
),
'ALTER TABLE `first_recharge_reward_grant_record` ADD COLUMN `google_product_id` varchar(128) NOT NULL DEFAULT '''' COMMENT ''Google商品ID'' AFTER `payment_method`',
'SELECT 1'
);
PREPARE add_first_recharge_reward_record_google_product_stmt FROM @add_first_recharge_reward_record_google_product_sql;
EXECUTE add_first_recharge_reward_record_google_product_stmt;
DEALLOCATE PREPARE add_first_recharge_reward_record_google_product_stmt;
SET @add_first_recharge_reward_level_google_product_idx_sql := IF(
NOT EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = @current_schema
AND table_name = 'first_recharge_reward_level'
AND index_name = 'idx_first_recharge_reward_level_google_product'
),
'ALTER TABLE `first_recharge_reward_level` ADD INDEX `idx_first_recharge_reward_level_google_product` (`google_product_id`)',
'SELECT 1'
);
PREPARE add_first_recharge_reward_level_google_product_idx_stmt FROM @add_first_recharge_reward_level_google_product_idx_sql;
EXECUTE add_first_recharge_reward_level_google_product_idx_stmt;
DEALLOCATE PREPARE add_first_recharge_reward_level_google_product_idx_stmt;
SET @add_first_recharge_reward_record_google_product_idx_sql := IF(
NOT EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = @current_schema
AND table_name = 'first_recharge_reward_grant_record'
AND index_name = 'idx_first_recharge_reward_grant_google_product'
),
'ALTER TABLE `first_recharge_reward_grant_record` ADD INDEX `idx_first_recharge_reward_grant_google_product` (`google_product_id`)',
'SELECT 1'
);
PREPARE add_first_recharge_reward_record_google_product_idx_stmt FROM @add_first_recharge_reward_record_google_product_idx_sql;
EXECUTE add_first_recharge_reward_record_google_product_idx_stmt;
DEALLOCATE PREPARE add_first_recharge_reward_record_google_product_idx_stmt;

View File

@ -0,0 +1,17 @@
-- Limit LIKEI app game-list rows to the Turkey region.
-- Production Turkey Region observed in sys_region_config: 2046066409959649281.
-- Re-check sys_region_config before production execution if region data has been rebuilt.
-- After direct SQL execution, clear Redis GAME_LIST* cache or wait for its 1-hour TTL.
START TRANSACTION;
SET @sys_origin := 'LIKEI';
SET @turkey_region_id := '2046066409959649281';
UPDATE sys_game_list_config
SET regions = @turkey_region_id,
update_time = NOW(3)
WHERE sys_origin = @sys_origin
AND is_showcase = 1;
COMMIT;

View File

@ -0,0 +1,106 @@
CREATE TABLE IF NOT EXISTS hotgame_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,
app_key VARCHAR(255) NOT NULL DEFAULT '',
callback_base_url VARCHAR(1024) 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_hotgame_provider_sys_origin_profile (sys_origin, profile),
KEY idx_hotgame_provider_active (sys_origin, active, update_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS hotgame_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_hotgame_catalog_sys_profile_vendor_game (sys_origin, profile, vendor_game_id),
KEY idx_hotgame_catalog_profile_internal_game (sys_origin, profile, internal_game_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO hotgame_provider_config (
id, sys_origin, profile, active, app_key, callback_base_url, create_time, update_time
) VALUES
(202606100520001, 'LIKEI', 'PROD', 1, 'laluprodappkey', 'https://api.global-interaction.com/', NOW(), NOW()),
(202606100520002, 'LIKEI', 'TEST', 0, 'lalutestappkey', 'https://api-test.global-interaction.com/', NOW(), NOW())
ON DUPLICATE KEY UPDATE
app_key = IF(TRIM(app_key) = '', VALUES(app_key), app_key),
callback_base_url = IF(TRIM(callback_base_url) = '', VALUES(callback_base_url), callback_base_url),
update_time = NOW();
INSERT INTO hotgame_game_catalog (
id, sys_origin, profile, internal_game_id, vendor_game_id, name, cover,
preview_url, download_url, package_version, raw_json, status, create_time, update_time
) VALUES
(202606100520101, 'LIKEI', 'TEST', 'hg_1', '1', '幸运77(Lucky77)', '',
'https://hk.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}',
'https://hk.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}',
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
(202606100520102, 'LIKEI', 'TEST', 'hg_13', '13', '海盗捕鱼(PirateFishing)', '',
'https://hk.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'https://hk.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
(202606100520103, 'LIKEI', 'TEST', 'hg_19', '19', 'FortuneSlot (宝石solt)', '',
'https://hk.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'https://hk.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
(202606100520104, 'LIKEI', 'TEST', 'hg_15', '15', '美味摩天轮(Delicious)', '',
'https://hk.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'https://hk.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
(202606100520105, 'LIKEI', 'TEST', 'hg_20', '20', '水果派对(FruitParty)', '',
'https://hk.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'https://hk.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
(202606100520106, 'LIKEI', 'TEST', 'hg_16', '16', '太空摩天轮(GreedyStar)', '',
'https://hk.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'https://hk.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
(202606100520201, 'LIKEI', 'PROD', 'hg_1', '1', '幸运77(Lucky77)', '',
'https://cdn-res.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}',
'https://cdn-res.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}',
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
(202606100520202, 'LIKEI', 'PROD', 'hg_13', '13', '海盗捕鱼(PirateFishing)', '',
'https://cdn-res.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'https://cdn-res.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
(202606100520203, 'LIKEI', 'PROD', 'hg_19', '19', 'FortuneSlot (宝石solt)', '',
'https://cdn-res.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'https://cdn-res.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
(202606100520204, 'LIKEI', 'PROD', 'hg_15', '15', '美味摩天轮(Delicious)', '',
'https://cdn-res.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'https://cdn-res.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
(202606100520205, 'LIKEI', 'PROD', 'hg_20', '20', '水果派对(FruitParty)', '',
'https://cdn-res.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'https://cdn-res.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
(202606100520206, 'LIKEI', 'PROD', 'hg_16', '16', '太空摩天轮(GreedyStar)', '',
'https://cdn-res.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'https://cdn-res.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW())
ON DUPLICATE KEY UPDATE
internal_game_id = VALUES(internal_game_id),
name = VALUES(name),
cover = VALUES(cover),
preview_url = VALUES(preview_url),
download_url = VALUES(download_url),
package_version = VALUES(package_version),
raw_json = VALUES(raw_json),
status = VALUES(status),
update_time = NOW();

View File

@ -0,0 +1,17 @@
SET @current_schema := DATABASE();
SET @modify_sys_game_list_cover_sql := IF(
EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'sys_game_list_config'
AND column_name = 'cover'
),
'ALTER TABLE `sys_game_list_config` MODIFY COLUMN `cover` VARCHAR(1024) DEFAULT NULL COMMENT ''封面图''',
'SELECT 1'
);
PREPARE modify_sys_game_list_cover_stmt FROM @modify_sys_game_list_cover_sql;
EXECUTE modify_sys_game_list_cover_stmt;
DEALLOCATE PREPARE modify_sys_game_list_cover_stmt;

View File

@ -0,0 +1,61 @@
SET @table_schema = DATABASE();
SET @ddl = IF(
EXISTS(
SELECT 1
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @table_schema
AND TABLE_NAME = 'team_manager_base_info'
AND COLUMN_NAME = 'can_add_bd_leader'
),
'SELECT 1',
"ALTER TABLE `team_manager_base_info` ADD COLUMN `can_add_bd_leader` tinyint(1) NOT NULL DEFAULT 1 COMMENT '经理中心是否允许添加BD Leader' AFTER `region_id`"
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = IF(
EXISTS(
SELECT 1
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @table_schema
AND TABLE_NAME = 'team_manager_base_info'
AND COLUMN_NAME = 'can_gift_props'
),
'SELECT 1',
"ALTER TABLE `team_manager_base_info` ADD COLUMN `can_gift_props` tinyint(1) NOT NULL DEFAULT 1 COMMENT '经理中心是否允许赠送道具' AFTER `can_add_bd_leader`"
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = IF(
EXISTS(
SELECT 1
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @table_schema
AND TABLE_NAME = 'team_manager_base_info'
AND COLUMN_NAME = 'can_ban_user'
),
'SELECT 1',
"ALTER TABLE `team_manager_base_info` ADD COLUMN `can_ban_user` tinyint(1) NOT NULL DEFAULT 1 COMMENT '经理中心是否允许封禁用户' AFTER `can_gift_props`"
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = IF(
EXISTS(
SELECT 1
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @table_schema
AND TABLE_NAME = 'team_manager_base_info'
AND COLUMN_NAME = 'can_unban_user'
),
'SELECT 1',
"ALTER TABLE `team_manager_base_info` ADD COLUMN `can_unban_user` tinyint(1) NOT NULL DEFAULT 1 COMMENT '经理中心是否允许解封用户' AFTER `can_ban_user`"
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -0,0 +1,16 @@
SET @table_schema = DATABASE();
SET @ddl = IF(
EXISTS(
SELECT 1
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @table_schema
AND TABLE_NAME = 'team_manager_base_info'
AND COLUMN_NAME = 'can_change_country'
),
'SELECT 1',
"ALTER TABLE `team_manager_base_info` ADD COLUMN `can_change_country` tinyint(1) NOT NULL DEFAULT 1 COMMENT '经理中心是否允许修改用户国家' AFTER `can_unban_user`"
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -0,0 +1,29 @@
SET @current_schema := DATABASE();
SET @add_task_center_archive_claim_create_sql := IF(
NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE table_schema = @current_schema
AND table_name = 'task_center_event_archive_outbox'
AND index_name = 'idx_task_center_archive_claim_create'
),
'ALTER TABLE `task_center_event_archive_outbox` ADD INDEX `idx_task_center_archive_claim_create` (`status`, `create_time`, `id`)',
'SELECT 1'
);
PREPARE add_task_center_archive_claim_create_stmt FROM @add_task_center_archive_claim_create_sql;
EXECUTE add_task_center_archive_claim_create_stmt;
DEALLOCATE PREPARE add_task_center_archive_claim_create_stmt;
SET @add_task_center_archive_claim_stale_sql := IF(
NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE table_schema = @current_schema
AND table_name = 'task_center_event_archive_outbox'
AND index_name = 'idx_task_center_archive_claim_stale'
),
'ALTER TABLE `task_center_event_archive_outbox` ADD INDEX `idx_task_center_archive_claim_stale` (`status`, `update_time`, `id`)',
'SELECT 1'
);
PREPARE add_task_center_archive_claim_stale_stmt FROM @add_task_center_archive_claim_stale_sql;
EXECUTE add_task_center_archive_claim_stale_stmt;
DEALLOCATE PREPARE add_task_center_archive_claim_stale_stmt;

View File

@ -0,0 +1,20 @@
-- dashboard-cdc-worker 每 5 分钟按 create_time 24 小时窗口回读 game_lucky_gift_count1400 万行),
-- 原有索引均以 sys_origin,user_id 开头,无法按时间窗口定位,只能从 user_base_info 反向驱动全量探测
-- (单次 examined ~18 万行,是线上 MySQL CPU 波动来源之一)。
-- 本索引已于 2026-07-10 通过 ALGORITHM=INPLACE, LOCK=NONE 在生产手工应用(耗时 19s此文件用于存档与新环境初始化。
SET @current_schema := DATABASE();
SET @add_game_lucky_gift_count_sorigin_ctime_sql := IF(
NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE table_schema = @current_schema
AND table_name = 'game_lucky_gift_count'
AND index_name = 'idx_game_lucky_gift_count_sorigin_ctime'
),
'ALTER TABLE `game_lucky_gift_count` ADD INDEX `idx_game_lucky_gift_count_sorigin_ctime` (`sys_origin`, `create_time`), ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
);
PREPARE add_game_lucky_gift_count_sorigin_ctime_stmt FROM @add_game_lucky_gift_count_sorigin_ctime_sql;
EXECUTE add_game_lucky_gift_count_sorigin_ctime_stmt;
DEALLOCATE PREPARE add_game_lucky_gift_count_sorigin_ctime_stmt;