Compare commits

...

37 Commits

Author SHA1 Message Date
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
ecb2fcba4e 自动结算 2026-06-01 19:46:21 +08:00
hy001
a9ee549e37 Hardcode empty game list for test user 2026-06-01 18:31:19 +08:00
hy001
c7c40fce77 Hardcode empty game list for test user 2026-06-01 18:30:52 +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
hy001
675f45bf44 修复充值奖励 2026-05-26 18:45:10 +08:00
hy001
f1b781e3be 增加范围以及首充版本识别 2026-05-25 23:20:32 +08:00
hy001
ee5b4f5eaf 1 2026-05-25 22:55:28 +08:00
hy001
72c0d1d633 修复区域飘屏 2026-05-25 22:11:02 +08:00
hy001
5b92fef113 模拟飘屏修复 2026-05-25 21:48:29 +08:00
hy001
9aecdf34a3 持续模拟 2026-05-25 21:19:09 +08:00
hy001
4cf4eba5ff 模拟飘屏 2026-05-25 21:02:28 +08:00
hy001
adcc722284 火箭结算不正确问题 2026-05-23 18:38:09 +08:00
hy001
3ec7cc6a62 修复vip类型 2026-05-23 14:54:28 +08:00
hy001
3639cad484 资源组vip 2026-05-23 12:04:14 +08:00
hy001
0a11372d96 修复最后一份红包死锁 2026-05-23 11:10:52 +08:00
hy001
e4911e7732 通用CP接口 2026-05-22 18:00:12 +08:00
hy001
747d4700df CP 2026-05-22 16:05:12 +08:00
hy001
8e8878a038 通用im 中奖消息 2026-05-22 14:22:27 +08:00
hy001
a02e22ac74 首冲奖励 2026-05-22 10:10:04 +08:00
99 changed files with 11021 additions and 288 deletions

View File

@ -15,10 +15,13 @@ import (
"chatapp3-golang/internal/service/apppopup"
"chatapp3-golang/internal/service/baishun"
"chatapp3-golang/internal/service/binancerecharge"
"chatapp3-golang/internal/service/cprelationbroadcast"
"chatapp3-golang/internal/service/errorlog"
"chatapp3-golang/internal/service/firstrechargereward"
"chatapp3-golang/internal/service/gameopen"
"chatapp3-golang/internal/service/gameprovider"
"chatapp3-golang/internal/service/hostcenter"
"chatapp3-golang/internal/service/hotgame"
"chatapp3-golang/internal/service/invite"
"chatapp3-golang/internal/service/lingxian"
"chatapp3-golang/internal/service/luckygift"
@ -55,11 +58,13 @@ func main() {
baishunService := baishun.NewBaishunService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
binanceRechargeService := binancerecharge.NewService(app.Config, app.Repository.DB, &app.Gateways)
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)
luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet)
rechargeAgencyService := rechargeagency.NewService(app.Config, app.Repository.DB, &app.Gateways)
registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
rechargeRewardService := rechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways)
firstRechargeRewardService := firstrechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways)
signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
userBadgeService := userbadge.NewService(app.Repository.DB)
@ -67,6 +72,9 @@ func main() {
wheelService := wheel.NewService(app.Config, app.Repository.DB, &app.Gateways)
smashEggService := smashegg.NewService(app.Config, app.Repository.DB, &app.Gateways)
regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB, &app.Gateways)
baishunService.SetHighWinBroadcaster(regionIMGroupService)
luckyGiftService.SetHighWinBroadcaster(regionIMGroupService)
cpRelationBroadcastService := cprelationbroadcast.NewService(app.Config, app.Repository.DB, regionIMGroupService)
voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room)
voiceRoomRedPacketService.SetRegionResolver(&app.Gateways)
voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
@ -92,6 +100,12 @@ func main() {
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start task center message consumer failed: %v", err)
}
if err := firstRechargeRewardService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start first recharge reward message consumer failed: %v", err)
}
if err := cpRelationBroadcastService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start cp relation broadcast message consumer failed: %v", err)
}
if err := voiceRoomRocketService.Start(workerCtx); err != nil {
log.Fatalf("start voice room rocket workers failed: %v", err)
}
@ -103,34 +117,42 @@ func main() {
gameProviders := gameprovider.NewRegistry(
baishun.NewAppProvider(baishunService),
lingxian.NewAppProvider(lingxianService),
hotgame.NewAppProvider(hotgameService),
)
gameVisibility := gameprovider.NewVisibilityPolicy(
&app.Gateways,
gameprovider.NewCachedIPCountryResolver(app.Repository.Redis, app.Config.HTTP.Timeout),
)
engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{
AppPopup: appPopupService,
ErrorLog: errorLogService,
Invite: inviteService,
Baishun: baishunService,
BinanceRecharge: binanceRechargeService,
Lingxian: lingxianService,
GameOpen: gameOpenService,
GameProviders: gameProviders,
LuckyGift: luckyGiftService,
ManagerCenter: managerCenterService,
PropsStore: propsStoreService,
HostCenter: hostCenterService,
RechargeAgency: rechargeAgencyService,
RechargeReward: rechargeRewardService,
RegionIMGroup: regionIMGroupService,
RegisterReward: registerRewardService,
RoomTurnoverReward: roomTurnoverRewardService,
SignInReward: signInRewardService,
SmashEgg: smashEggService,
TaskCenter: taskCenterService,
UserBadge: userBadgeService,
VIP: vipService,
Wheel: wheelService,
VoiceRoomRedPacket: voiceRoomRedPacketService,
VoiceRoomRocket: voiceRoomRocketService,
WeekStar: weekStarService,
AppPopup: appPopupService,
ErrorLog: errorLogService,
Invite: inviteService,
Baishun: baishunService,
BinanceRecharge: binanceRechargeService,
FirstRechargeReward: firstRechargeRewardService,
Lingxian: lingxianService,
GameOpen: gameOpenService,
GameProviders: gameProviders,
GameVisibility: gameVisibility,
LuckyGift: luckyGiftService,
ManagerCenter: managerCenterService,
PropsStore: propsStoreService,
HostCenter: hostCenterService,
Hotgame: hotgameService,
RechargeAgency: rechargeAgencyService,
RechargeReward: rechargeRewardService,
RegionIMGroup: regionIMGroupService,
RegisterReward: registerRewardService,
RoomTurnoverReward: roomTurnoverRewardService,
SignInReward: signInRewardService,
SmashEgg: smashEggService,
TaskCenter: taskCenterService,
UserBadge: userBadgeService,
VIP: vipService,
Wheel: wheelService,
VoiceRoomRedPacket: voiceRoomRedPacketService,
VoiceRoomRocket: voiceRoomRocketService,
WeekStar: weekStarService,
})
server := &http.Server{
Addr: app.Config.HTTP.ListenAddr,

View File

@ -2,6 +2,8 @@ package main
import (
"chatapp3-golang/internal/bootstrap"
"chatapp3-golang/internal/service/cprelationbroadcast"
"chatapp3-golang/internal/service/firstrechargereward"
"chatapp3-golang/internal/service/regionimgroup"
"chatapp3-golang/internal/service/registerreward"
"chatapp3-golang/internal/service/taskcenter"
@ -27,7 +29,9 @@ func main() {
registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
firstRechargeRewardService := firstrechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways)
regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB, &app.Gateways)
cpRelationBroadcastService := cprelationbroadcast.NewService(app.Config, app.Repository.DB, regionIMGroupService)
voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room)
voiceRoomRedPacketService.SetRegionResolver(&app.Gateways)
voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
@ -45,6 +49,12 @@ func main() {
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start task center message consumer failed: %v", err)
}
if err := firstRechargeRewardService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start first recharge reward message consumer failed: %v", err)
}
if err := cpRelationBroadcastService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start cp relation broadcast message consumer failed: %v", err)
}
if err := voiceRoomRocketService.Start(workerCtx); err != nil {
log.Fatalf("start voice room rocket workers failed: %v", err)
}

View File

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

View File

@ -8,25 +8,34 @@ import (
"time"
)
const (
cpRelationBroadcastMQConsumerGroup = "cp-relation-broadcast"
cpRelationBroadcastMQTopic = "RC_DEFAULT_APP_ORDINARY"
cpRelationBroadcastMQTag = "cp_relation_broadcast"
)
// Config 是按运行层和业务模块分段后的应用配置。
type Config struct {
HTTP HTTPConfig
Store StoreConfig
Java JavaConfig
Worker WorkerConfig
Invite InviteConfig
RechargeReward RechargeRewardConfig
TaskCenter TaskCenterConfig
WeekStar WeekStarConfig
RoomTurnoverReward RoomTurnoverRewardConfig
VoiceRoomRedPacket VoiceRoomRedPacketConfig
VoiceRoomRocket VoiceRoomRocketConfig
TencentIM TencentIMConfig
Baishun BaishunConfig
Lingxian LingxianConfig
GameOpen GameOpenConfig
LuckyGift LuckyGiftConfig
BinanceRecharge BinanceRechargeConfig
HTTP HTTPConfig
Store StoreConfig
Java JavaConfig
Worker WorkerConfig
Invite InviteConfig
RechargeReward RechargeRewardConfig
FirstRechargeReward FirstRechargeRewardConfig
TaskCenter TaskCenterConfig
WeekStar WeekStarConfig
RoomTurnoverReward RoomTurnoverRewardConfig
VoiceRoomRedPacket VoiceRoomRedPacketConfig
VoiceRoomRocket VoiceRoomRocketConfig
CPRelationBroadcast CPRelationBroadcastConfig
TencentIM TencentIMConfig
Baishun BaishunConfig
Lingxian LingxianConfig
Hotgame HotgameConfig
GameOpen GameOpenConfig
LuckyGift LuckyGiftConfig
BinanceRecharge BinanceRechargeConfig
}
// HTTPConfig 保存 HTTP 服务运行配置。
@ -88,6 +97,25 @@ type RechargeRewardConfig struct {
CoinSellerGoldPerUSD int64
}
// FirstRechargeRewardConfig 保存首冲奖励模块配置。
type FirstRechargeRewardConfig struct {
DefaultSysOrigin string
MQ FirstRechargeRewardMQConfig
}
// FirstRechargeRewardMQConfig 保存首冲奖励订阅通用充值成功 MQ 的消费配置。
type FirstRechargeRewardMQConfig struct {
Enabled bool
Endpoint string
Namespace string
AccessKey string
AccessSecret string
SecurityToken string
ConsumerGroup string
Topic string
Tag string
}
// TaskCenterConfig 保存任务中心模块配置。
type TaskCenterConfig struct {
DefaultSysOrigin string
@ -192,6 +220,25 @@ type VoiceRoomRocketMQConfig struct {
Tag string
}
// CPRelationBroadcastConfig 保存 CP/兄弟/姐妹关系区域飘屏配置。
type CPRelationBroadcastConfig struct {
DefaultSysOrigin string
MQ CPRelationBroadcastMQConfig
}
// CPRelationBroadcastMQConfig 保存 CP 关系成功 MQ 消费配置。
type CPRelationBroadcastMQConfig struct {
Enabled bool
Endpoint string
Namespace string
AccessKey string
AccessSecret string
SecurityToken string
ConsumerGroup string
Topic string
Tag string
}
// TencentIMConfig 保存腾讯 IM REST API 配置。
type TencentIMConfig struct {
AppID int64
@ -222,6 +269,12 @@ type LingxianConfig struct {
AppKey string
}
// HotgameConfig 保存热游模块的环境兜底配置。
type HotgameConfig struct {
AppKey string
CallbackBaseURL string
}
// GameOpenConfig 保存统一三方游戏回调配置。
type GameOpenConfig struct {
PublicBaseURL string
@ -265,9 +318,18 @@ func Load() Config {
taskCenterMQEndpoint := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ENDPOINT", "TASK_CENTER_EVENT_MQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT"}, "")
taskCenterMQAccessKey := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ACCESS_KEY", "TASK_CENTER_EVENT_MQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY"}, "")
taskCenterMQAccessSecret := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ACCESS_SECRET", "TASK_CENTER_EVENT_MQ_SECRET_KEY", "LIKEI_ROCKETMQ_SECRET_KEY"}, "")
firstRechargeRewardMQEndpoint := getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_ENDPOINT", "FIRST_RECHARGE_REWARD_MQ_ENDPOINT", "CHATAPP_RECHARGE_SUCCESS_MQ_ENDPOINT", "RECHARGE_SUCCESS_MQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT"}, "")
firstRechargeRewardMQAccessKey := getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_ACCESS_KEY", "FIRST_RECHARGE_REWARD_MQ_ACCESS_KEY", "CHATAPP_RECHARGE_SUCCESS_MQ_ACCESS_KEY", "RECHARGE_SUCCESS_MQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY"}, "")
firstRechargeRewardMQAccessSecret := getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_ACCESS_SECRET", "FIRST_RECHARGE_REWARD_MQ_SECRET_KEY", "CHATAPP_RECHARGE_SUCCESS_MQ_ACCESS_SECRET", "RECHARGE_SUCCESS_MQ_SECRET_KEY", "LIKEI_ROCKETMQ_SECRET_KEY"}, "")
voiceRoomRocketMQEndpoint := getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT"}, "")
voiceRoomRocketMQAccessKey := getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY"}, "")
voiceRoomRocketMQAccessSecret := getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_ACCESS_SECRET", "LIKEI_ROCKETMQ_SECRET_KEY"}, "")
cpRelationBroadcastMQEndpoint := getEnvAny([]string{"LIKEI_ROCKETMQ_ENDPOINT", "CHATAPP_TASK_CENTER_MQ_ENDPOINT", "TASK_CENTER_EVENT_MQ_ENDPOINT"}, "")
cpRelationBroadcastMQAccessKey := getEnvAny([]string{"LIKEI_ROCKETMQ_ACCESS_KEY", "CHATAPP_TASK_CENTER_MQ_ACCESS_KEY", "TASK_CENTER_EVENT_MQ_ACCESS_KEY"}, "")
cpRelationBroadcastMQAccessSecret := getEnvAny([]string{"LIKEI_ROCKETMQ_SECRET_KEY", "CHATAPP_TASK_CENTER_MQ_ACCESS_SECRET", "TASK_CENTER_EVENT_MQ_SECRET_KEY"}, "")
cpRelationBroadcastMQEnabled := strings.TrimSpace(cpRelationBroadcastMQEndpoint) != "" &&
strings.TrimSpace(cpRelationBroadcastMQAccessKey) != "" &&
strings.TrimSpace(cpRelationBroadcastMQAccessSecret) != ""
return Config{
HTTP: HTTPConfig{
@ -329,6 +391,23 @@ func Load() Config {
100000,
)),
},
FirstRechargeReward: FirstRechargeRewardConfig{
DefaultSysOrigin: strings.ToUpper(getEnvAny(
[]string{"CHATAPP_FIRST_RECHARGE_REWARD_DEFAULT_SYS_ORIGIN", "FIRST_RECHARGE_REWARD_DEFAULT_SYS_ORIGIN", "CHATAPP_RECHARGE_REWARD_DEFAULT_SYS_ORIGIN", "RECHARGE_REWARD_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"},
"LIKEI",
)),
MQ: FirstRechargeRewardMQConfig{
Enabled: getEnvBoolAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_ENABLED", "FIRST_RECHARGE_REWARD_MQ_ENABLED", "CHATAPP_RECHARGE_SUCCESS_MQ_ENABLED", "RECHARGE_SUCCESS_MQ_ENABLED"}, false),
Endpoint: firstRechargeRewardMQEndpoint,
Namespace: getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_NAMESPACE", "FIRST_RECHARGE_REWARD_MQ_NAMESPACE", "CHATAPP_RECHARGE_SUCCESS_MQ_NAMESPACE", "RECHARGE_SUCCESS_MQ_NAMESPACE"}, ""),
AccessKey: firstRechargeRewardMQAccessKey,
AccessSecret: firstRechargeRewardMQAccessSecret,
SecurityToken: getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_SECURITY_TOKEN", "FIRST_RECHARGE_REWARD_MQ_SECURITY_TOKEN", "CHATAPP_RECHARGE_SUCCESS_MQ_SECURITY_TOKEN", "RECHARGE_SUCCESS_MQ_SECURITY_TOKEN"}, ""),
ConsumerGroup: getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_CONSUMER_GROUP", "FIRST_RECHARGE_REWARD_MQ_CONSUMER_GROUP"}, "first-recharge-reward"),
Topic: getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_TOPIC", "FIRST_RECHARGE_REWARD_MQ_TOPIC", "CHATAPP_RECHARGE_SUCCESS_MQ_TOPIC", "RECHARGE_SUCCESS_MQ_TOPIC"}, "RC_DEFAULT_APP_ORDINARY"),
Tag: getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_TAG", "FIRST_RECHARGE_REWARD_MQ_TAG", "CHATAPP_RECHARGE_SUCCESS_MQ_TAG", "RECHARGE_SUCCESS_MQ_TAG"}, "recharge_success_v1"),
},
},
TaskCenter: TaskCenterConfig{
DefaultSysOrigin: strings.ToUpper(getEnvAny(
[]string{"CHATAPP_TASK_CENTER_DEFAULT_SYS_ORIGIN", "TASK_CENTER_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"},
@ -360,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"}, ""),
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"),
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),
Workers: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_WORKERS", "TASK_CENTER_ARCHIVE_WORKERS"}, 2),
},
@ -486,6 +565,23 @@ func Load() Config {
Tag: getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_TAG", "VOICE_ROOM_ROCKET_MQ_TAG"}, "give_gift_v3"),
},
},
CPRelationBroadcast: CPRelationBroadcastConfig{
DefaultSysOrigin: strings.ToUpper(getEnvAny(
[]string{"CHATAPP_CP_RELATION_BROADCAST_DEFAULT_SYS_ORIGIN", "CP_RELATION_BROADCAST_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"},
"LIKEI",
)),
MQ: CPRelationBroadcastMQConfig{
Enabled: cpRelationBroadcastMQEnabled,
Endpoint: cpRelationBroadcastMQEndpoint,
Namespace: "",
AccessKey: cpRelationBroadcastMQAccessKey,
AccessSecret: cpRelationBroadcastMQAccessSecret,
SecurityToken: "",
ConsumerGroup: cpRelationBroadcastMQConsumerGroup,
Topic: cpRelationBroadcastMQTopic,
Tag: cpRelationBroadcastMQTag,
},
},
TencentIM: TencentIMConfig{
AppID: getEnvInt64Any(
[]string{"CHATAPP_TENCENT_IM_APP_ID", "TENCENT_IM_APP_ID", "LIKEI_IM_APP_ID"},
@ -528,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"),
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{
PublicBaseURL: getEnvAny([]string{"CHATAPP_GAME_OPEN_PUBLIC_BASE_URL", "GAME_OPEN_PUBLIC_BASE_URL"}, ""),
AppKey: getEnvAny([]string{"CHATAPP_GAME_OPEN_APP_KEY", "GAME_OPEN_APP_KEY"}, "game-open-test-key"),

View File

@ -102,6 +102,29 @@ func TestLoadTaskCenterMQUsesSharedRocketMQCredentials(t *testing.T) {
}
}
func TestLoadCPRelationBroadcastMQUsesSharedRocketMQCredentials(t *testing.T) {
t.Setenv("LIKEI_ROCKETMQ_ENDPOINT", "rmq.example.com:8081")
t.Setenv("LIKEI_ROCKETMQ_ACCESS_KEY", "access-key")
t.Setenv("LIKEI_ROCKETMQ_SECRET_KEY", "access-secret")
t.Setenv("CHATAPP_CP_RELATION_BROADCAST_MQ_ENDPOINT", "should-not-use:8081")
t.Setenv("CHATAPP_CP_RELATION_BROADCAST_MQ_TOPIC", "should-not-use")
t.Setenv("CHATAPP_CP_RELATION_BROADCAST_MQ_TAG", "should-not-use")
cfg := Load()
if !cfg.CPRelationBroadcast.MQ.Enabled {
t.Fatalf("expected cp relation broadcast mq enabled")
}
if got := cfg.CPRelationBroadcast.MQ.Endpoint; got != "rmq.example.com:8081" {
t.Fatalf("cp relation broadcast mq endpoint = %q", got)
}
if got := cfg.CPRelationBroadcast.MQ.Topic; got != "RC_DEFAULT_APP_ORDINARY" {
t.Fatalf("cp relation broadcast mq topic = %q", got)
}
if got := cfg.CPRelationBroadcast.MQ.Tag; got != "cp_relation_broadcast" {
t.Fatalf("cp relation broadcast mq tag = %q", got)
}
}
func TestLoadTaskCenterArchiveUsesSharedCOSCredentials(t *testing.T) {
t.Setenv("CHATAPP_TASK_CENTER_ARCHIVE_ENABLED", "true")
t.Setenv("LIKEI_TENCENT_COS_BUCKET", "bucket-123")

View File

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

View File

@ -59,6 +59,12 @@ type UserRegion struct {
RegionCode string `json:"regionCode"`
}
type UserLevel struct {
UserID Int64Value `json:"userId"`
WealthLevel int `json:"wealthLevel"`
CharmLevel int `json:"charmLevel"`
}
type RegionConfig struct {
RegionCode string `json:"regionCode"`
SysOrigin string `json:"sysOrigin"`
@ -235,13 +241,15 @@ type OfficialNoticeCustomizeRequest struct {
}
type RewardGroupItem struct {
ID Int64Value `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
Quantity Int64Value `json:"quantity"`
Cover string `json:"cover"`
Remark string `json:"remark"`
ID Int64Value `json:"id"`
Type string `json:"type"`
DetailType string `json:"detailType"`
Name string `json:"name"`
Content string `json:"content"`
Quantity Int64Value `json:"quantity"`
Cover string `json:"cover"`
SourceURL string `json:"sourceUrl"`
Remark string `json:"remark"`
}
type RewardGroupDetail struct {
@ -251,6 +259,13 @@ type RewardGroupDetail struct {
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 {
ID Int64Value `json:"id"`
UserID Int64Value `json:"userId"`
@ -334,6 +349,7 @@ type resultResponse[T any] struct {
Code int `json:"code"`
Message string `json:"message"`
Body T `json:"body"`
Data T `json:"data"`
Success *bool `json:"success"`
}
@ -696,15 +712,154 @@ func (c *Client) SendOfficialNoticeCustomize(ctx context.Context, req OfficialNo
}
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]
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
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 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) {
@ -895,6 +1050,21 @@ func (c *Client) GetUserRegion(ctx context.Context, userID int64, authorization
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) {
countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
if countryCode == "" {

View File

@ -77,3 +77,105 @@ func TestResolveRegionCodeByCountryCodeUsesRegionConfig(t *testing.T) {
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

@ -0,0 +1,68 @@
package model
import "time"
// FirstRechargeRewardConfig stores first recharge reward config per system.
type FirstRechargeRewardConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
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"`
MinAppVersion string `gorm:"column:min_app_version;size:32"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (FirstRechargeRewardConfig) TableName() string { return "first_recharge_reward_config" }
// FirstRechargeRewardLevel stores a recharge threshold and reward resource group.
type FirstRechargeRewardLevel struct {
ID int64 `gorm:"column:id;primaryKey"`
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"`
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"`
RewardGroupName string `gorm:"column:reward_group_name;size:255"`
Enabled bool `gorm:"column:enabled"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (FirstRechargeRewardLevel) TableName() string { return "first_recharge_reward_level" }
// FirstRechargeRewardGrantRecord stores the actual first recharge reward grant result.
type FirstRechargeRewardGrantRecord struct {
ID int64 `gorm:"column:id;primaryKey"`
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_first_recharge_reward_event"`
ConfigID int64 `gorm:"column:config_id"`
LevelID int64 `gorm:"column:level_id"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_first_recharge_reward_user,priority:1;index:idx_first_recharge_reward_status,priority:1"`
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_first_recharge_reward_user,priority:2;index:idx_first_recharge_reward_status,priority:2"`
Account string `gorm:"column:account;size:64"`
UserAvatar string `gorm:"column:user_avatar;size:1024"`
UserNickname string `gorm:"column:user_nickname;size:255"`
CountryCode string `gorm:"column:country_code;size:32"`
CountryName string `gorm:"column:country_name;size:128"`
RechargeAmountCents int64 `gorm:"column:recharge_amount_cents"`
RechargeThresholdCents int64 `gorm:"column:recharge_threshold_cents"`
Level int `gorm:"column:level"`
PayPlatform string `gorm:"column:pay_platform;size:64"`
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"`
RewardGroupID int64 `gorm:"column:reward_group_id"`
RewardGroupName string `gorm:"column:reward_group_name;size:255"`
RewardGroupTrackID int64 `gorm:"column:reward_group_track_id"`
Status string `gorm:"column:status;size:32;index:idx_first_recharge_reward_status,priority:3"`
RewardGroupStatus string `gorm:"column:reward_group_status;size:32"`
RetryCount int `gorm:"column:retry_count"`
LastError string `gorm:"column:last_error;size:1024"`
EventTime time.Time `gorm:"column:event_time"`
SentAt *time.Time `gorm:"column:sent_at"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (FirstRechargeRewardGrantRecord) TableName() string {
return "first_recharge_reward_grant_record"
}

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 的原始事件。
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"`
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_task_center_archive_event,priority:2"`
EventType string `gorm:"column:event_type;size:64"`
UserID int64 `gorm:"column:user_id"`
OccurredAt time.Time `gorm:"column:occurred_at"`
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"`
COSKey string `gorm:"column:cos_key;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"`
UpdateTime time.Time `gorm:"column:update_time"`
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;index:idx_task_center_archive_claim_stale,priority:2"`
ArchivedAt *time.Time `gorm:"column:archived_at"`
}

View File

@ -76,12 +76,12 @@ func (VoiceRoomRedPacket) TableName() string { return "voice_room_red_packet" }
// VoiceRoomRedPacketSlice 保存红包预拆分份额。
type VoiceRoomRedPacketSlice struct {
ID int64 `gorm:"column:id;primaryKey"`
PacketID int64 `gorm:"column:packet_id;uniqueIndex:uk_voice_room_red_packet_slice,priority:1;index:idx_voice_room_red_packet_slice_claim,priority:1"`
ID int64 `gorm:"column:id;primaryKey;index:idx_voice_room_red_packet_slice_claim_order,priority:4"`
PacketID int64 `gorm:"column:packet_id;uniqueIndex:uk_voice_room_red_packet_slice,priority:1;index:idx_voice_room_red_packet_slice_claim,priority:1;index:idx_voice_room_red_packet_slice_claim_order,priority:1"`
PacketNo string `gorm:"column:packet_no;size:64"`
SortNo int `gorm:"column:sort_no;uniqueIndex:uk_voice_room_red_packet_slice,priority:2"`
SortNo int `gorm:"column:sort_no;uniqueIndex:uk_voice_room_red_packet_slice,priority:2;index:idx_voice_room_red_packet_slice_claim_order,priority:3"`
Amount int64 `gorm:"column:amount"`
Status string `gorm:"column:status;size:32;index:idx_voice_room_red_packet_slice_claim,priority:2"`
Status string `gorm:"column:status;size:32;index:idx_voice_room_red_packet_slice_claim,priority:2;index:idx_voice_room_red_packet_slice_claim_order,priority:2"`
ClaimUserID *int64 `gorm:"column:claim_user_id"`
ClaimRecordID *int64 `gorm:"column:claim_record_id"`
CreateTime time.Time `gorm:"column:create_time"`

View File

@ -248,7 +248,7 @@ func registerBaishunRoutes(
})
callbackGroup.POST("/report", func(c *gin.Context) {
raw, payload := readRawPayload(c)
writeBaishunJSON(c, http.StatusOK, baishunService.HandleReport(c.Request.Context(), payload, raw))
writeBaishunJSON(c, http.StatusOK, baishunService.HandleReport(c.Request.Context(), mapToReportRequest(payload), raw))
})
callbackGroup.POST("/balance-info", func(c *gin.Context) {
raw, payload := readRawPayload(c)
@ -338,6 +338,16 @@ func mapToChangeBalanceRequest(payload map[string]any) baishun.BaishunChangeBala
}
}
// mapToReportRequest 把百顺 report 回调报文映射成服务层 DTO。
func mapToReportRequest(payload map[string]any) baishun.BaishunReportRequest {
return baishun.BaishunReportRequest{
ReportType: asString(payload["report_type"]),
ReportMsg: asMap(payload["report_msg"]),
UserID: asString(payload["user_id"]),
SSToken: asString(payload["ss_token"]),
}
}
// mapToBalanceInfoRequest 把百顺 balance-info 回调报文映射成服务层 DTO。
func mapToBalanceInfoRequest(payload map[string]any) baishun.BaishunBalanceInfoRequest {
return baishun.BaishunBalanceInfoRequest{
@ -350,6 +360,26 @@ func mapToBalanceInfoRequest(payload map[string]any) baishun.BaishunBalanceInfoR
}
}
// asMap 宽松读取对象字段。
func asMap(value any) map[string]any {
switch typed := value.(type) {
case map[string]any:
return typed
case string:
result := map[string]any{}
_ = json.Unmarshal([]byte(typed), &result)
return result
default:
body, err := json.Marshal(typed)
if err != nil {
return map[string]any{}
}
result := map[string]any{}
_ = json.Unmarshal(body, &result)
return result
}
}
// asString 宽松读取字符串字段。
func asString(value any) string {
if value == nil {

View File

@ -0,0 +1,101 @@
package router
import (
"net/http"
"strings"
"chatapp3-golang/internal/service/firstrechargereward"
"github.com/gin-gonic/gin"
)
// registerFirstRechargeRewardRoutes registers app and admin first recharge reward APIs.
func registerFirstRechargeRewardRoutes(engine *gin.Engine, javaClient authGateway, service *firstrechargereward.Service) {
if service == nil {
return
}
registerFirstRechargeRewardAppGroup(engine.Group("/app/first-recharge-reward"), javaClient, service)
registerFirstRechargeRewardAppGroup(engine.Group("/app/h5/first-recharge-reward"), javaClient, service)
group := engine.Group("/resident-activity/first-recharge-reward")
group.Use(consoleAuthMiddleware(javaClient))
group.GET("/config", func(c *gin.Context) {
resp, err := service.GetConfig(c.Request.Context(), c.Query("sysOrigin"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.POST("/config/save", func(c *gin.Context) {
var req firstrechargereward.SaveConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, firstrechargereward.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.SaveConfig(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.GET("/grant-record/page", func(c *gin.Context) {
resp, err := service.PageGrantRecords(
c.Request.Context(),
c.Query("sysOrigin"),
c.Query("status"),
firstrechargereward.ParseInt64(c.Query("userId")),
int(firstrechargereward.ParseInt64(c.Query("cursor"))),
int(firstrechargereward.ParseInt64(c.Query("limit"))),
)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
}
func registerFirstRechargeRewardAppGroup(group *gin.RouterGroup, javaClient authGateway, service *firstrechargereward.Service) {
group.Use(authMiddleware(javaClient))
group.GET("/home", func(c *gin.Context) {
resp, err := service.GetHome(c.Request.Context(), mustAuthUser(c), firstRechargeRewardRequestVersion(c))
if err != nil {
writeError(c, err)
return
}
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)))
})
registerHotgameCallbackRoutes(engine, service)
internalGroup := engine.Group("/internal/game/open")
internalGroup.Use(internalSecretMiddleware(cfg.HTTP.InternalCallbackSecret))
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 {
if value := strings.TrimRight(strings.TrimSpace(cfg.GameOpen.PublicBaseURL), "/"); value != "" {
return value

View File

@ -1,6 +1,7 @@
package router
import (
"errors"
"net/http"
"strings"
@ -11,7 +12,7 @@ import (
)
// 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 {
return
}
@ -19,7 +20,15 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
appGroup := engine.Group("/app/game")
appGroup.Use(authMiddleware(javaClient))
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 {
writeError(c, err)
return
@ -27,7 +36,15 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
writeOK(c, publicRoomGameItems(resp))
})
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 {
writeError(c, err)
return
@ -72,14 +89,30 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
providerGroup := engine.Group("/app/game/providers")
providerGroup.Use(authMiddleware(javaClient))
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())
})
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)
if !ok {
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 {
writeError(c, err)
return
@ -87,11 +120,19 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
writeOK(c, gin.H{"items": publicRoomGameItems(resp)})
})
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)
if !ok {
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 {
writeError(c, err)
return
@ -120,7 +161,7 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := provider.LaunchGame(c.Request.Context(), mustAuthUser(c), req, resolveClientIP(c))
resp, err := launchProviderGame(c, registry, provider, req)
if err != nil {
writeError(c, err)
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, resolveClientIP(c))
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) {
provider, err := registry.Resolve(c.Param("provider"))
if err != nil {
@ -155,6 +223,25 @@ func resolveGameProvider(c *gin.Context, registry *gameprovider.Registry) (gamep
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 {
ID int64 `json:"id"`
GameID string `json:"gameId"`
@ -311,8 +398,14 @@ func publicLaunch(resp *gameprovider.LaunchResponse) *publicLaunchResponse {
}
func appCompatProviderName(provider string) string {
// 客户端历史上只认识 LEADER不认识内部厂商名 LINGXIAN所以出参继续保持旧字段值。
if strings.EqualFold(strings.TrimSpace(provider), "LINGXIAN") {
return "LEADER"
}
// 旧客户端没有 HOTGAME 页面;热游先按 LEADER 展示并进入已有 WebViewlaunch 路由再按 hg_* 分发回真实热游 provider。
switch strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(provider), "_", "")) {
case "HOTGAME", "REYOU", "LALU":
return "LEADER"
}
return provider
}

View File

@ -1,9 +1,18 @@
package router
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/service/gameprovider"
"github.com/gin-gonic/gin"
)
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) {
launchConfig := map[string]any{"code": "launch-code"}
resp := publicLaunch(&gameprovider.LaunchResponse{
@ -141,3 +185,349 @@ func TestPublicLaunchMapsLingxianToLeaderForOldClients(t *testing.T) {
t.Fatalf("roomState.provider = %q, want LEADER", resp.RoomState.Provider)
}
}
func TestGameProviderRoutesReturnEmptyWhenVisibilityPolicyDenies(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: "土耳其"},
level: integration.UserLevel{WealthLevel: 2},
targetRegionCode: "土耳其",
},
routeStubIPCountryResolver{countryCode: "TR"},
),
)
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) != 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 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: "土耳其"},
level: integration.UserLevel{WealthLevel: 3},
targetRegionCode: "土耳其",
},
routeStubIPCountryResolver{countryCode: "ZA"},
),
)
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
level integration.UserLevel
targetRegionCode string
}
func (s routeStubVisibilityGateway) GetUserRegion(context.Context, int64, string) (integration.UserRegion, error) {
return s.region, nil
}
func (s routeStubVisibilityGateway) GetUserLevel(context.Context, string, int64) (integration.UserLevel, error) {
return s.level, nil
}
func (s routeStubVisibilityGateway) ResolveRegionCodeByCountryCode(context.Context, string, string) (string, error) {
return s.targetRegionCode, nil
}
type routeStubIPCountryResolver struct {
countryCode string
}
func (s routeStubIPCountryResolver) CountryCode(context.Context, string) (string, error) {
return s.countryCode, 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)
})
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) {
resp, err := service.ListProps(c.Request.Context(), mustAuthUser(c), c.Query("type"))
if err != nil {
@ -60,6 +69,29 @@ func registerManagerCenterRoutes(engine *gin.Engine, javaClient authGateway, ser
writeOK(c, resp)
})
group.GET("/bd-leaders", func(c *gin.Context) {
resp, err := service.ListBDLeaders(c.Request.Context(), mustAuthUser(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.POST("/bd-leaders", func(c *gin.Context) {
var req managercenter.AddBDLeaderRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, managercenter.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.AddBDLeader(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.POST("/users/ban", func(c *gin.Context) {
var req managercenter.BanUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
@ -87,4 +119,18 @@ func registerManagerCenterRoutes(engine *gin.Engine, javaClient authGateway, ser
}
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。
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 != "" {
parts := strings.Split(forwarded, ",")
if len(parts) > 0 {

View File

@ -4,6 +4,8 @@ import (
"net/http"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/repo"
"chatapp3-golang/internal/service/highwin"
"chatapp3-golang/internal/service/regionimgroup"
"github.com/gin-gonic/gin"
@ -13,6 +15,7 @@ import (
func registerRegionIMGroupRoutes(
engine *gin.Engine,
cfg config.Config,
repository *repo.Repository,
javaClient authGateway,
service *regionimgroup.Service,
) {
@ -22,7 +25,7 @@ func registerRegionIMGroupRoutes(
registerRegionIMGroupAppRoutes(engine, javaClient, service)
registerRegionIMGroupInternalRoutes(engine, cfg, service)
registerRegionIMGroupAdminRoutes(engine, javaClient, service)
registerRegionIMGroupAdminRoutes(engine, repository, javaClient, service)
}
func registerRegionIMGroupAppRoutes(engine *gin.Engine, javaClient authGateway, service *regionimgroup.Service) {
@ -62,9 +65,14 @@ func registerRegionIMGroupInternalRoutes(engine *gin.Engine, cfg config.Config,
})
}
func registerRegionIMGroupAdminRoutes(engine *gin.Engine, javaClient authGateway, service *regionimgroup.Service) {
func registerRegionIMGroupAdminRoutes(engine *gin.Engine, repository *repo.Repository, javaClient authGateway, service *regionimgroup.Service) {
registerRegionIMGroupAdminGroup(engine.Group("/region/config"), javaClient, service)
registerRegionIMGroupAdminGroup(engine.Group("/resident-activity/voice-room-red-packet"), javaClient, service)
var worker *highwin.BroadcastSimulationWorker
if repository != nil {
worker = highwin.NewBroadcastSimulationWorker(repository.DB, repository.Redis, service)
}
registerRegionBroadcastSimulationAdminGroup(engine.Group("/operate/region-broadcast"), javaClient, service, worker)
}
func registerRegionIMGroupAdminGroup(group *gin.RouterGroup, javaClient authGateway, service *regionimgroup.Service) {
@ -104,3 +112,39 @@ func registerRegionIMGroupAdminGroup(group *gin.RouterGroup, javaClient authGate
writeOK(c, resp)
})
}
func registerRegionBroadcastSimulationAdminGroup(group *gin.RouterGroup, javaClient authGateway, service *regionimgroup.Service, worker *highwin.BroadcastSimulationWorker) {
group.Use(consoleAuthMiddleware(javaClient))
group.POST("/simulate", func(c *gin.Context) {
var req highwin.BroadcastSimulationRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, regionimgroup.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := highwin.SendSimulationBroadcast(c.Request.Context(), service, req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.GET("/worker/status", func(c *gin.Context) {
writeOK(c, worker.Status())
})
group.POST("/worker/start", func(c *gin.Context) {
var req highwin.ContinuousSimulationRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, regionimgroup.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := worker.Start(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.POST("/worker/stop", func(c *gin.Context) {
writeOK(c, worker.Stop())
})
}

View File

@ -10,9 +10,11 @@ import (
"chatapp3-golang/internal/service/baishun"
"chatapp3-golang/internal/service/binancerecharge"
"chatapp3-golang/internal/service/errorlog"
"chatapp3-golang/internal/service/firstrechargereward"
"chatapp3-golang/internal/service/gameopen"
"chatapp3-golang/internal/service/gameprovider"
"chatapp3-golang/internal/service/hostcenter"
"chatapp3-golang/internal/service/hotgame"
"chatapp3-golang/internal/service/invite"
"chatapp3-golang/internal/service/lingxian"
"chatapp3-golang/internal/service/luckygift"
@ -38,32 +40,35 @@ import (
// Services 聚合所有业务模块服务,供路由层统一装配。
type Services struct {
AppPopup *apppopup.Service
ErrorLog *errorlog.Service
Invite *invite.InviteService
Baishun *baishun.BaishunService
BinanceRecharge *binancerecharge.Service
Lingxian *lingxian.Service
GameOpen *gameopen.GameOpenService
GameProviders *gameprovider.Registry
LuckyGift *luckygift.LuckyGiftService
ManagerCenter *managercenter.Service
PropsStore *propsstore.Service
HostCenter *hostcenter.Service
RechargeAgency *rechargeagency.Service
RechargeReward *rechargereward.Service
RegionIMGroup *regionimgroup.Service
RegisterReward *registerreward.RegisterRewardService
RoomTurnoverReward *roomturnoverreward.Service
SignInReward *signinreward.SignInRewardService
SmashEgg *smashegg.Service
TaskCenter *taskcenter.Service
UserBadge *userbadge.Service
VIP *vip.Service
Wheel *wheel.Service
VoiceRoomRedPacket *voiceroomredpacket.Service
VoiceRoomRocket *voiceroomrocket.Service
WeekStar *weekstar.WeekStarService
AppPopup *apppopup.Service
ErrorLog *errorlog.Service
Invite *invite.InviteService
Baishun *baishun.BaishunService
BinanceRecharge *binancerecharge.Service
FirstRechargeReward *firstrechargereward.Service
Lingxian *lingxian.Service
GameOpen *gameopen.GameOpenService
GameProviders *gameprovider.Registry
GameVisibility *gameprovider.VisibilityPolicy
LuckyGift *luckygift.LuckyGiftService
ManagerCenter *managercenter.Service
PropsStore *propsstore.Service
HostCenter *hostcenter.Service
Hotgame *hotgame.Service
RechargeAgency *rechargeagency.Service
RechargeReward *rechargereward.Service
RegionIMGroup *regionimgroup.Service
RegisterReward *registerreward.RegisterRewardService
RoomTurnoverReward *roomturnoverreward.Service
SignInReward *signinreward.SignInRewardService
SmashEgg *smashegg.Service
TaskCenter *taskcenter.Service
UserBadge *userbadge.Service
VIP *vip.Service
Wheel *wheel.Service
VoiceRoomRedPacket *voiceroomredpacket.Service
VoiceRoomRocket *voiceroomrocket.Service
WeekStar *weekstar.WeekStarService
}
// NewRouter 创建 Gin 路由,并按模块注册所有接口。
@ -82,6 +87,7 @@ func NewRouter(
registerInviteRoutes(engine, javaClient, services.Invite)
registerRechargeAgencyRoutes(engine, javaClient, services.RechargeAgency)
registerRechargeRewardRoutes(engine, javaClient, services.RechargeReward)
registerFirstRechargeRewardRoutes(engine, javaClient, services.FirstRechargeReward)
registerBinanceRechargeRoutes(engine, javaClient, services.BinanceRecharge)
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
@ -90,16 +96,17 @@ func NewRouter(
registerVipRoutes(engine, javaClient, services.VIP)
registerWheelRoutes(engine, cfg, javaClient, services.Wheel)
registerSmashEggRoutes(engine, javaClient, services.SmashEgg)
registerRegionIMGroupRoutes(engine, cfg, javaClient, services.RegionIMGroup)
registerRegionIMGroupRoutes(engine, cfg, repository, javaClient, services.RegionIMGroup)
registerVoiceRoomRedPacketRoutes(engine, javaClient, services.VoiceRoomRedPacket)
registerVoiceRoomRocketRoutes(engine, cfg, javaClient, services.VoiceRoomRocket)
registerWeekStarRoutes(engine, javaClient, services.WeekStar)
registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward)
registerHostCenterRoutes(engine, services.HostCenter)
registerGameOpenRoutes(engine, cfg, services.GameOpen)
registerGameProviderRoutes(engine, javaClient, services.GameProviders)
registerGameProviderRoutes(engine, javaClient, services.GameProviders, services.GameVisibility)
registerBaishunRoutes(engine, cfg, javaClient, services.Baishun)
registerLingxianRoutes(engine, javaClient, services.Lingxian)
registerHotgameRoutes(engine, javaClient, services.Hotgame)
registerLuckyGiftRoutes(engine, cfg, services.LuckyGift)
registerManagerCenterRoutes(engine, javaClient, services.ManagerCenter)
registerPropsStoreRoutes(engine, javaClient, services.PropsStore)

View File

@ -243,21 +243,6 @@ func (s *BaishunService) HandleChangeBalance(ctx context.Context, req BaishunCha
return resp
}
// HandleReport 处理百顺上报类回调,目前仅记录日志并返回成功。
func (s *BaishunService) HandleReport(ctx context.Context, payload map[string]any, rawJSON string) baishunStandardResponse {
userID := toString(payload["user_id"])
orderID := toString(payload["order_id"])
gameRoundID := toString(payload["game_round_id"])
resp := baishunStandardResponse{
Code: 0,
Message: "succeed",
UniqueID: s.uniqueID(),
Data: map[string]any{},
}
s.saveCallbackLog(ctx, "report", rawJSON, utils.MustJSONString(resp, ""), "", userID, stringPtr(orderID), stringPtr(gameRoundID), baishunCallbackStatusSuccess, 0, resp.Message)
return resp
}
func (s *BaishunService) reportGameConsumeTaskEvent(ctx context.Context, session model.BaishunLaunchSession, req BaishunChangeBalanceRequest) {
if s.taskReporter == nil || req.CurrencyDiff >= 0 {
return

View File

@ -49,6 +49,7 @@ func newTestBaishunService(t *testing.T) (*BaishunService, *gorm.DB) {
&model.BaishunProviderConfig{},
&model.BaishunGameCatalog{},
&model.BaishunLaunchSession{},
&model.BaishunCallbackLog{},
&model.BaishunRoomState{},
); err != nil {
t.Fatalf("auto migrate: %v", err)

View File

@ -0,0 +1,291 @@
package baishun
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/service/highwin"
"chatapp3-golang/internal/utils"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
const (
baishunReportTypeStart = "game_start"
baishunReportTypeSettle = "game_settle"
baishunReportBetTTL = 24 * time.Hour
baishunReportBetRedisPrefix = "baishun:report:bet"
)
type baishunGameBetRecord struct {
SysOrigin string `json:"sysOrigin"`
RoomID string `json:"roomId"`
GameID int64 `json:"gameId"`
GameRoundID string `json:"gameRoundId"`
UserID int64 `json:"userId"`
Bet int64 `json:"bet"`
}
func (s *BaishunService) HandleReport(ctx context.Context, req BaishunReportRequest, rawJSON string) baishunStandardResponse {
resp := baishunStandardResponse{
Code: 0,
Message: "succeed",
UniqueID: s.uniqueID(),
Data: map[string]any{},
}
sysOrigin := ""
if session, ok := s.findSessionByToken(ctx, req.SSToken, req.UserID); ok {
sysOrigin = session.SysOrigin
if err := s.handleReportHighWins(ctx, session.SysOrigin, req); err != nil {
log.Printf("handle baishun report high win failed userId=%s reportType=%s: %v", req.UserID, req.ReportType, err)
}
}
s.saveCallbackLog(
ctx,
"report",
rawJSON,
utils.MustJSONString(resp, ""),
sysOrigin,
req.UserID,
nil,
stringPtr(reportGameRoundID(req.ReportMsg)),
baishunCallbackStatusSuccess,
0,
resp.Message,
)
return resp
}
func (s *BaishunService) handleReportHighWins(ctx context.Context, sysOrigin string, req BaishunReportRequest) error {
switch strings.ToLower(strings.TrimSpace(req.ReportType)) {
case baishunReportTypeStart:
return s.rememberBaishunGameBets(ctx, sysOrigin, req.ReportMsg)
case baishunReportTypeSettle:
return s.publishBaishunGameHighWins(ctx, sysOrigin, req.ReportMsg)
default:
return nil
}
}
func (s *BaishunService) rememberBaishunGameBets(ctx context.Context, sysOrigin string, payload map[string]any) error {
if s == nil || s.repo.Redis == nil {
return nil
}
roomID := reportText(payload, "room_id", "roomId")
gameRoundID := reportGameRoundID(payload)
if strings.TrimSpace(roomID) == "" || strings.TrimSpace(gameRoundID) == "" {
return nil
}
gameID := reportInt64(payload, "game_id", "gameId")
for _, player := range reportPlayers(payload) {
if reportInt64(player, "is_ai", "isAi") != 0 {
continue
}
userID := reportInt64(player, "user_id", "userId")
bet := reportInt64(player, "bet")
if userID <= 0 || bet <= 0 {
continue
}
record := baishunGameBetRecord{
SysOrigin: normalizeAdminSysOrigin(sysOrigin),
RoomID: roomID,
GameID: gameID,
GameRoundID: gameRoundID,
UserID: userID,
Bet: bet,
}
body, err := json.Marshal(record)
if err != nil {
return err
}
if err := s.repo.Redis.Set(ctx, baishunGameBetKey(record.SysOrigin, gameRoundID, userID), string(body), baishunReportBetTTL).Err(); err != nil {
return err
}
}
return nil
}
func (s *BaishunService) publishBaishunGameHighWins(ctx context.Context, sysOrigin string, payload map[string]any) error {
if s == nil || s.highWinBroadcaster == nil || s.repo.Redis == nil {
return nil
}
sysOrigin = normalizeAdminSysOrigin(sysOrigin)
roomID := reportText(payload, "room_id", "roomId")
gameRoundID := reportGameRoundID(payload)
if strings.TrimSpace(roomID) == "" || strings.TrimSpace(gameRoundID) == "" {
return nil
}
gameID := reportInt64(payload, "game_id", "gameId")
gameCover := s.resolveBaishunReportGameCover(ctx, sysOrigin, roomID, gameID)
for _, player := range reportPlayers(payload) {
if reportInt64(player, "is_ai", "isAi") != 0 {
continue
}
userID := reportInt64(player, "user_id", "userId")
reward := reportInt64(player, "reward")
if userID <= 0 || reward <= 0 {
continue
}
betRecord, ok, err := s.loadBaishunGameBet(ctx, sysOrigin, gameRoundID, userID)
if err != nil {
return err
}
if !ok || betRecord.Bet <= 0 {
continue
}
multiple := highwin.Multiple(reward, betRecord.Bet)
if !highwin.ShouldBroadcast(multiple, reward) {
continue
}
data := map[string]any{
"gameId": gameID,
"gameRoundId": gameRoundID,
"gameUrl": gameCover,
"betAmount": betRecord.Bet,
"currencyDiff": reward,
"rank": reportInt64(player, "rank"),
"score": reportInt64(player, "score"),
}
if err := highwin.SendRegionBroadcast(ctx, s.highWinBroadcaster, highwin.BroadcastEvent{
SysOrigin: sysOrigin,
Type: highwin.MessageTypeBaishunWin,
UserID: userID,
RoomID: roomID,
Multiple: multiple,
Amount: reward,
Data: data,
}); err != nil {
log.Printf("send baishun game high win region broadcast failed roundId=%s userId=%d: %v", gameRoundID, userID, err)
}
_ = s.repo.Redis.Del(ctx, baishunGameBetKey(sysOrigin, gameRoundID, userID)).Err()
}
return nil
}
func (s *BaishunService) loadBaishunGameBet(ctx context.Context, sysOrigin, gameRoundID string, userID int64) (baishunGameBetRecord, bool, error) {
raw, err := s.repo.Redis.Get(ctx, baishunGameBetKey(sysOrigin, gameRoundID, userID)).Result()
if errors.Is(err, redis.Nil) {
return baishunGameBetRecord{}, false, nil
}
if err != nil {
return baishunGameBetRecord{}, false, err
}
var record baishunGameBetRecord
if err := json.Unmarshal([]byte(raw), &record); err != nil {
return baishunGameBetRecord{}, false, err
}
return record, true, nil
}
func (s *BaishunService) resolveBaishunReportGameCover(ctx context.Context, sysOrigin, roomID string, gameID int64) string {
var state model.BaishunRoomState
if strings.TrimSpace(roomID) != "" {
err := s.repo.DB.WithContext(ctx).
Where("sys_origin = ? AND room_id = ?", normalizeAdminSysOrigin(sysOrigin), roomID).
First(&state).Error
if err == nil && strings.TrimSpace(state.CurrentGameCover) != "" {
return strings.TrimSpace(state.CurrentGameCover)
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return ""
}
}
if gameID <= 0 {
return ""
}
profile, err := s.activeProfile(ctx, sysOrigin)
if err != nil {
return ""
}
var catalog model.BaishunGameCatalog
if err := s.repo.DB.WithContext(ctx).
Where("sys_origin = ? AND profile = ? AND vendor_game_id = ?", normalizeAdminSysOrigin(sysOrigin), profile, gameID).
First(&catalog).Error; err != nil {
return ""
}
return strings.TrimSpace(catalog.Cover)
}
func reportGameRoundID(payload map[string]any) string {
return reportText(payload, "game_round_id", "gameRoundId")
}
func reportPlayers(payload map[string]any) []map[string]any {
raw, exists := payload["players"]
if !exists {
return nil
}
switch typed := raw.(type) {
case []map[string]any:
return typed
case []any:
players := make([]map[string]any, 0, len(typed))
for _, item := range typed {
if player, ok := item.(map[string]any); ok {
players = append(players, player)
}
}
return players
default:
return nil
}
}
func reportText(payload map[string]any, keys ...string) string {
for _, key := range keys {
if value, exists := payload[key]; exists {
text := strings.TrimSpace(toString(value))
if text != "" {
return text
}
}
}
return ""
}
func reportInt64(payload map[string]any, keys ...string) int64 {
for _, key := range keys {
value, exists := payload[key]
if !exists {
continue
}
switch typed := value.(type) {
case int:
return int64(typed)
case int64:
return typed
case float64:
return int64(typed)
case json.Number:
parsed, _ := typed.Int64()
return parsed
case string:
parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
return parsed
default:
parsed, _ := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(typed)), 10, 64)
return parsed
}
}
return 0
}
func baishunGameBetKey(sysOrigin, gameRoundID string, userID int64) string {
return strings.Join([]string{
baishunReportBetRedisPrefix,
normalizeAdminSysOrigin(sysOrigin),
strings.TrimSpace(gameRoundID),
strconv.FormatInt(userID, 10),
}, ":")
}

View File

@ -0,0 +1,115 @@
package baishun
import (
"context"
"testing"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/service/regionimgroup"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
func TestHandleReportPublishesRegionHighWinAfterStartAndSettle(t *testing.T) {
service, db := newTestBaishunService(t)
mr := miniredis.RunT(t)
redisClient := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = redisClient.Close() })
service.repo.Redis = redisClient
broadcaster := &fakeBaishunRegionBroadcaster{}
service.SetHighWinBroadcaster(broadcaster)
expireAt := time.Now().Add(time.Hour)
token := "ss-token-1001"
if err := db.Create(&model.BaishunLaunchSession{
ID: 1,
SysOrigin: "LIKEI",
RoomID: "9001",
UserID: 1001,
InternalGameID: "bs_1146",
VendorGameID: 1146,
GameSessionID: "session-1",
SSToken: &token,
SSTokenExpireTime: &expireAt,
Status: baishunSessionActive,
CreateTime: time.Now(),
UpdateTime: time.Now(),
}).Error; err != nil {
t.Fatalf("seed session: %v", err)
}
if err := db.Create(&model.BaishunRoomState{
ID: 2,
SysOrigin: "LIKEI",
RoomID: "9001",
HostUserID: 1001,
CurrentGameID: "bs_1146",
CurrentVendorGameID: intPtr(1146),
CurrentGameName: "LordOfOlympus",
CurrentGameCover: "https://cdn.example.com/game.png",
GameSessionID: "session-1",
State: baishunRoomStatePlaying,
CreateTime: time.Now(),
UpdateTime: time.Now(),
}).Error; err != nil {
t.Fatalf("seed room state: %v", err)
}
startResp := service.HandleReport(context.Background(), BaishunReportRequest{
ReportType: baishunReportTypeStart,
UserID: "1001",
SSToken: token,
ReportMsg: map[string]any{
"game_id": 1146,
"room_id": "9001",
"game_round_id": "round-1",
"players": []any{
map[string]any{"user_id": "1001", "is_ai": 0, "bet": 100},
},
},
}, `{"report_type":"game_start"}`)
if startResp.Code != 0 {
t.Fatalf("start response = %+v", startResp)
}
settleResp := service.HandleReport(context.Background(), BaishunReportRequest{
ReportType: baishunReportTypeSettle,
UserID: "1001",
SSToken: token,
ReportMsg: map[string]any{
"game_id": 1146,
"room_id": "9001",
"game_round_id": "round-1",
"players": []any{
map[string]any{"user_id": "1001", "is_ai": 0, "reward": 1000, "rank": 1, "score": 88},
},
},
}, `{"report_type":"game_settle"}`)
if settleResp.Code != 0 {
t.Fatalf("settle response = %+v", settleResp)
}
if len(broadcaster.requests) != 1 {
t.Fatalf("requests = %d, want 1", len(broadcaster.requests))
}
req := broadcaster.requests[0]
if req.Type != "GAME_BAISHUN_WIN" {
t.Fatalf("type = %q, want GAME_BAISHUN_WIN", req.Type)
}
if req.Data["userId"] != "1001" || req.Data["roomId"] != "9001" ||
req.Data["multiple"] != int64(10) || req.Data["winAmount"] != int64(1000) ||
req.Data["currencyDiff"] != int64(1000) ||
req.Data["gameUrl"] != "https://cdn.example.com/game.png" {
t.Fatalf("payload = %+v", req.Data)
}
}
type fakeBaishunRegionBroadcaster struct {
requests []regionimgroup.RegionBroadcastRequest
}
func (f *fakeBaishunRegionBroadcaster) SendRegionBroadcast(_ context.Context, req regionimgroup.RegionBroadcastRequest) (*regionimgroup.RegionBroadcastResponse, error) {
f.requests = append(f.requests, req)
return &regionimgroup.RegionBroadcastResponse{Type: req.Type}, nil
}

View File

@ -13,7 +13,7 @@ import (
// ListShortcutGames 返回房间里的快捷游戏列表,数量上限为 5 个。
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 {
return nil, err
}
@ -25,7 +25,7 @@ func (s *BaishunService) ListShortcutGames(ctx context.Context, user AuthUser, r
// ListRoomGames 返回房间可启动的完整游戏列表。
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 {
return nil, err
}
@ -59,7 +59,7 @@ func (s *BaishunService) GetRoomState(ctx context.Context, user AuthUser, roomID
}
// 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)
profile, err := s.activeProfile(ctx, sysOrigin)
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("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)
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") {
query = query.Where("cfg.category = ?", category)
}

View File

@ -3,6 +3,7 @@ package baishun
import (
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/service/highwin"
"context"
"net/http"
@ -61,11 +62,12 @@ type baishunPorts struct {
// BaishunService 负责百顺目录同步、游戏启动、房间状态以及回调处理。
type BaishunService struct {
cfg config.Config
repo baishunPorts
java baishunGateway
httpClient *http.Client
taskReporter taskEventReporter
cfg config.Config
repo baishunPorts
java baishunGateway
httpClient *http.Client
taskReporter taskEventReporter
highWinBroadcaster highwin.RegionBroadcaster
}
// NewBaishunService 创建百顺接入服务实例。
@ -84,6 +86,10 @@ func (s *BaishunService) SetTaskEventReporter(reporter taskEventReporter) {
s.taskReporter = reporter
}
func (s *BaishunService) SetHighWinBroadcaster(broadcaster highwin.RegionBroadcaster) {
s.highWinBroadcaster = broadcaster
}
// RoomGameListItem 是房间游戏列表中的单个游戏项。
type RoomGameListItem struct {
ID int64 `json:"id"`
@ -418,6 +424,13 @@ type BaishunBalanceInfoRequest struct {
Timestamp int64 `json:"timestamp"`
}
type BaishunReportRequest struct {
ReportType string `json:"report_type"`
ReportMsg map[string]any `json:"report_msg"`
UserID string `json:"user_id"`
SSToken string `json:"ss_token"`
}
// baishunStandardResponse 是百顺标准回包结构。
type baishunStandardResponse struct {
Code int `json:"code"`

View File

@ -0,0 +1,327 @@
package cprelationbroadcast
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/service/regionimgroup"
"gorm.io/gorm"
)
// ProcessPayload 解码 Java MQ 消息并发送区域飘屏。
func (s *Service) ProcessPayload(ctx context.Context, payload string) (*regionimgroup.RegionBroadcastResponse, error) {
req, err := decodeEventPayload(payload, s.cfg.CPRelationBroadcast.MQ.Tag)
if err != nil {
return nil, err
}
if req.isEmpty() {
return nil, nil
}
return s.ProcessEvent(ctx, req)
}
// ProcessEvent 处理标准化后的 CP 关系事件。
func (s *Service) ProcessEvent(ctx context.Context, req EventRequest) (*regionimgroup.RegionBroadcastResponse, error) {
if s == nil || s.broadcaster == nil {
return nil, common.NewAppError(http.StatusInternalServerError, "cp_relation_broadcaster_missing", "cp relation broadcaster is missing")
}
if s.db == nil {
return nil, common.NewAppError(http.StatusInternalServerError, "cp_relation_db_missing", "cp relation db is missing")
}
req = normalizeEventRequest(req)
userID := req.UserID.Int64()
cpUserID := req.CpUserID.Int64()
if userID <= 0 || cpUserID <= 0 {
return nil, common.NewAppError(http.StatusBadRequest, "invalid_cp_relation_users", "userId and cpUserId are required")
}
relationType, err := normalizeRelationType(req.RelationType)
if err != nil {
return nil, err
}
sysOrigin := normalizeSysOrigin(req.SysOrigin, s.cfg.CPRelationBroadcast.DefaultSysOrigin)
user, err := s.loadEventUser(ctx, userID, req.User)
if err != nil {
return nil, err
}
cpUser, err := s.loadEventUser(ctx, cpUserID, req.CpUser)
if err != nil {
return nil, err
}
msg := relationMessage(relationType, user.displayName(), cpUser.displayName())
data := buildBroadcastData(req, sysOrigin, relationType, user, cpUser, msg)
return s.broadcaster.SendRegionBroadcast(ctx, regionimgroup.RegionBroadcastRequest{
SysOrigin: sysOrigin,
Type: MessageTypeCPRelationBroadcast,
Data: data,
})
}
func (s *Service) loadEventUser(ctx context.Context, userID int64, provided *EventUser) (EventUser, error) {
var user model.UserBaseInfo
err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
account := strconv.FormatInt(userID, 10)
user = model.UserBaseInfo{
ID: userID,
Account: account,
UserNickname: account,
}
} else if err != nil {
return EventUser{}, err
}
if strings.TrimSpace(user.Account) == "" {
user.Account = strconv.FormatInt(userID, 10)
}
if strings.TrimSpace(user.UserNickname) == "" {
user.UserNickname = user.Account
}
return mergeEventUser(userID, user, provided), nil
}
func buildBroadcastData(req EventRequest, sysOrigin, relationType string, user EventUser, cpUser EventUser, msg string) map[string]any {
userID := user.normalizedID()
cpUserID := cpUser.normalizedID()
userIDText := strconv.FormatInt(userID, 10)
cpUserIDText := strconv.FormatInt(cpUserID, 10)
data := map[string]any{
"sysOrigin": sysOrigin,
"type": MessageTypeCPRelationBroadcast,
"relationType": relationType,
"relationLabel": relationLabel(relationType),
"msg": msg,
"userId": userIDText,
"sendUserId": userIDText,
"senderUserId": userIDText,
"cpUserId": cpUserIDText,
"acceptUserId": cpUserIDText,
"toUserId": cpUserIDText,
"user": user.toMap(),
"cpUser": cpUser.toMap(),
"account": user.Account,
"actualAccount": user.Account,
"userNickname": user.displayName(),
"nickname": user.displayName(),
"userAvatar": firstNonBlank(user.UserAvatar, user.Avatar),
"countryCode": user.CountryCode,
"countryName": user.CountryName,
"cpUserAccount": cpUser.Account,
"cpUserNickname": cpUser.displayName(),
"cpUserAvatar": firstNonBlank(cpUser.UserAvatar, cpUser.Avatar),
"cpUserCountryCode": cpUser.CountryCode,
"cpUserCountryName": cpUser.CountryName,
"acceptAccount": cpUser.Account,
"acceptNickname": cpUser.displayName(),
"acceptUserAvatar": firstNonBlank(cpUser.UserAvatar, cpUser.Avatar),
"toUserName": cpUser.displayName(),
"toUserAvatarUrl": firstNonBlank(cpUser.UserAvatar, cpUser.Avatar),
}
if eventID := strings.TrimSpace(req.EventID); eventID != "" {
data["eventId"] = eventID
}
if applyID := req.ApplyID.Int64(); applyID > 0 {
data["applyId"] = strconv.FormatInt(applyID, 10)
}
if occurredAt := strings.TrimSpace(req.OccurredAt); occurredAt != "" {
data["occurredAt"] = occurredAt
}
if len(req.Payload) > 0 {
data["payload"] = req.Payload
}
return data
}
func normalizeEventRequest(req EventRequest) EventRequest {
if req.UserID.Int64() <= 0 {
req.UserID = req.SendUserID
}
if req.CpUserID.Int64() <= 0 {
req.CpUserID = req.AcceptUserID
}
if req.UserID.Int64() <= 0 && req.User != nil {
req.UserID = flexibleInt64(req.User.normalizedID())
}
if req.CpUserID.Int64() <= 0 && req.CpUser != nil {
req.CpUserID = flexibleInt64(req.CpUser.normalizedID())
}
return req
}
func (req EventRequest) isEmpty() bool {
return strings.TrimSpace(req.EventID) == "" &&
strings.TrimSpace(req.RelationType) == "" &&
req.UserID.Int64() <= 0 &&
req.CpUserID.Int64() <= 0 &&
req.SendUserID.Int64() <= 0 &&
req.AcceptUserID.Int64() <= 0
}
func normalizeRelationType(value string) (string, error) {
normalized := strings.ToUpper(strings.TrimSpace(value))
if normalized == "" {
return RelationTypeCP, nil
}
switch normalized {
case RelationTypeCP:
return RelationTypeCP, nil
case RelationTypeBrother, "BROTHERS":
return RelationTypeBrother, nil
case RelationTypeSisters, "SISTER":
return RelationTypeSisters, nil
default:
return "", common.NewAppError(http.StatusBadRequest, "invalid_cp_relation_type", fmt.Sprintf("invalid cp relationType: %s", value))
}
}
func relationLabel(relationType string) string {
switch relationType {
case RelationTypeBrother:
return "brothers"
case RelationTypeSisters:
return "sisters"
default:
return "cp_relation"
}
}
func relationMessage(relationType, userName, cpUserName string) string {
switch relationType {
case RelationTypeBrother:
return fmt.Sprintf("%s与%s结为兄弟", userName, cpUserName)
case RelationTypeSisters:
return fmt.Sprintf("%s与%s结为姐妹", userName, cpUserName)
default:
return fmt.Sprintf("%s与%s get cp relation", userName, cpUserName)
}
}
func normalizeSysOrigin(values ...string) string {
for _, value := range values {
if text := strings.ToUpper(strings.TrimSpace(value)); text != "" {
return text
}
}
return defaultSysOrigin
}
func decodeEventPayload(payload string, expectedTag string) (EventRequest, error) {
payload = strings.TrimSpace(payload)
if payload == "" {
return EventRequest{}, nil
}
req, err := decodeEventObject([]byte(payload))
if err != nil {
return EventRequest{}, err
}
if !req.isEmpty() {
return req, nil
}
var envelope messageEventEnvelope
if err := json.Unmarshal([]byte(payload), &envelope); err != nil {
return EventRequest{}, err
}
if shouldSkipEnvelope(envelope.Tag, expectedTag) {
return EventRequest{}, nil
}
bodyPayload, ok, err := envelope.bodyPayload()
if err != nil || !ok {
return EventRequest{}, err
}
return decodeEventObject([]byte(bodyPayload))
}
type rawEventRequest struct {
EventRequest
SysOriginSnake string `json:"sys_origin"`
EventIDSnake string `json:"event_id"`
ApplyIDSnake flexibleInt64 `json:"apply_id"`
RelationTypeSnake string `json:"relation_type"`
UserIDSnake flexibleInt64 `json:"user_id"`
CpUserIDSnake flexibleInt64 `json:"cp_user_id"`
SendUserIDSnake flexibleInt64 `json:"send_user_id"`
AcceptUserIDSnake flexibleInt64 `json:"accept_user_id"`
OccurredAtSnake string `json:"occurred_at"`
TargetUserID flexibleInt64 `json:"targetUserId"`
TargetUserIDSnake flexibleInt64 `json:"target_user_id"`
ToUserIDSnake flexibleInt64 `json:"to_user_id"`
}
func decodeEventObject(data []byte) (EventRequest, error) {
var raw rawEventRequest
if err := json.Unmarshal(data, &raw); err != nil {
return EventRequest{}, err
}
req := raw.EventRequest
req.SysOrigin = firstNonBlank(req.SysOrigin, raw.SysOriginSnake)
req.EventID = firstNonBlank(req.EventID, raw.EventIDSnake)
req.RelationType = firstNonBlank(req.RelationType, raw.RelationTypeSnake)
req.OccurredAt = firstNonBlank(req.OccurredAt, raw.OccurredAtSnake)
if req.ApplyID.Int64() <= 0 {
req.ApplyID = raw.ApplyIDSnake
}
if req.UserID.Int64() <= 0 {
req.UserID = firstFlexible(raw.UserIDSnake, req.SendUserID, raw.SendUserIDSnake)
}
if req.CpUserID.Int64() <= 0 {
req.CpUserID = firstFlexible(raw.CpUserIDSnake, req.AcceptUserID, raw.AcceptUserIDSnake, raw.TargetUserID, raw.TargetUserIDSnake, raw.ToUserIDSnake)
}
if req.SendUserID.Int64() <= 0 {
req.SendUserID = firstFlexible(req.UserID, raw.SendUserIDSnake)
}
if req.AcceptUserID.Int64() <= 0 {
req.AcceptUserID = firstFlexible(req.CpUserID, raw.AcceptUserIDSnake, raw.TargetUserID, raw.TargetUserIDSnake, raw.ToUserIDSnake)
}
return req, nil
}
func firstFlexible(values ...flexibleInt64) flexibleInt64 {
for _, value := range values {
if value.Int64() > 0 {
return value
}
}
return 0
}
type messageEventEnvelope struct {
Tag string `json:"tag"`
Body json.RawMessage `json:"body"`
}
func (e messageEventEnvelope) bodyPayload() (string, bool, error) {
raw := strings.TrimSpace(string(e.Body))
if raw == "" || raw == "null" {
return "", false, nil
}
if strings.HasPrefix(raw, "\"") {
var body string
if err := json.Unmarshal(e.Body, &body); err != nil {
return "", false, err
}
body = strings.TrimSpace(body)
return body, body != "", nil
}
return raw, true, nil
}
func shouldSkipEnvelope(actualTag, expectedTag string) bool {
actualTag = strings.TrimSpace(actualTag)
expectedTag = strings.TrimSpace(expectedTag)
if actualTag == "" || expectedTag == "" || expectedTag == "*" {
return false
}
return actualTag != expectedTag
}

View File

@ -0,0 +1,142 @@
package cprelationbroadcast
import (
"context"
"testing"
"time"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/service/regionimgroup"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestProcessEventBuildsCPRelationBroadcastPayload(t *testing.T) {
service, broadcaster := newCPRelationBroadcastTestService(t)
_, err := service.ProcessEvent(context.Background(), EventRequest{
SysOrigin: "LIKEI",
EventID: "CP_RELATION:9001",
ApplyID: flexibleInt64(9001),
RelationType: RelationTypeCP,
UserID: flexibleInt64(1001),
CpUserID: flexibleInt64(1002),
OccurredAt: "2026-05-22T12:00:00Z",
})
if err != nil {
t.Fatalf("ProcessEvent() error = %v", err)
}
if len(broadcaster.requests) != 1 {
t.Fatalf("requests = %d, want 1", len(broadcaster.requests))
}
req := broadcaster.requests[0]
if req.Type != MessageTypeCPRelationBroadcast {
t.Fatalf("type = %q", req.Type)
}
data := req.Data
if data["msg"] != "Alice与Bob get cp relation" ||
data["relationType"] != RelationTypeCP ||
data["userId"] != "1001" ||
data["cpUserId"] != "1002" {
t.Fatalf("payload = %+v", data)
}
user, ok := data["user"].(map[string]any)
if !ok || user["userNickname"] != "Alice" || user["account"] != "alice" {
t.Fatalf("user payload = %+v", data["user"])
}
cpUser, ok := data["cpUser"].(map[string]any)
if !ok || cpUser["userNickname"] != "Bob" || cpUser["account"] != "bob" {
t.Fatalf("cpUser payload = %+v", data["cpUser"])
}
}
func TestProcessEventBuildsBrotherAndSistersMessages(t *testing.T) {
service, broadcaster := newCPRelationBroadcastTestService(t)
cases := []struct {
relationType string
wantMsg string
}{
{RelationTypeBrother, "Alice与Bob结为兄弟"},
{RelationTypeSisters, "Alice与Bob结为姐妹"},
}
for _, tc := range cases {
broadcaster.requests = nil
_, err := service.ProcessEvent(context.Background(), EventRequest{
RelationType: tc.relationType,
UserID: flexibleInt64(1001),
CpUserID: flexibleInt64(1002),
})
if err != nil {
t.Fatalf("ProcessEvent(%s) error = %v", tc.relationType, err)
}
if len(broadcaster.requests) != 1 {
t.Fatalf("requests = %d, want 1", len(broadcaster.requests))
}
if got := broadcaster.requests[0].Data["msg"]; got != tc.wantMsg {
t.Fatalf("msg = %v, want %s", got, tc.wantMsg)
}
}
}
func TestProcessPayloadDecodesMessageEventEnvelopeAndAliases(t *testing.T) {
service, broadcaster := newCPRelationBroadcastTestService(t)
service.cfg.CPRelationBroadcast.MQ.Tag = "cp_relation_broadcast"
payload := `{"tag":"cp_relation_broadcast","body":"{\"sys_origin\":\"likei\",\"event_id\":\"CP_RELATION:9002\",\"apply_id\":\"9002\",\"relation_type\":\"BROTHERS\",\"send_user_id\":\"1001\",\"accept_user_id\":\"1002\"}"}`
_, err := service.ProcessPayload(context.Background(), payload)
if err != nil {
t.Fatalf("ProcessPayload() error = %v", err)
}
if len(broadcaster.requests) != 1 {
t.Fatalf("requests = %d, want 1", len(broadcaster.requests))
}
req := broadcaster.requests[0]
if req.SysOrigin != "LIKEI" {
t.Fatalf("sysOrigin = %q", req.SysOrigin)
}
if req.Data["eventId"] != "CP_RELATION:9002" ||
req.Data["applyId"] != "9002" ||
req.Data["relationType"] != RelationTypeBrother ||
req.Data["msg"] != "Alice与Bob结为兄弟" {
t.Fatalf("payload = %+v", req.Data)
}
}
func newCPRelationBroadcastTestService(t *testing.T) (*Service, *fakeCPRegionBroadcaster) {
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.UserBaseInfo{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
now := time.Now()
if err := db.Create(&[]model.UserBaseInfo{
{ID: 1001, Account: "alice", UserNickname: "Alice", UserAvatar: "alice.png", OriginSys: "LIKEI", CountryCode: "SA", CountryName: "Saudi Arabia", CreateTime: now},
{ID: 1002, Account: "bob", UserNickname: "Bob", UserAvatar: "bob.png", OriginSys: "LIKEI", CountryCode: "SA", CountryName: "Saudi Arabia", CreateTime: now},
}).Error; err != nil {
t.Fatalf("seed users: %v", err)
}
broadcaster := &fakeCPRegionBroadcaster{}
return NewService(config.Config{
CPRelationBroadcast: config.CPRelationBroadcastConfig{DefaultSysOrigin: "LIKEI"},
}, db, broadcaster), broadcaster
}
type fakeCPRegionBroadcaster struct {
requests []regionimgroup.RegionBroadcastRequest
}
func (f *fakeCPRegionBroadcaster) SendRegionBroadcast(_ context.Context, req regionimgroup.RegionBroadcastRequest) (*regionimgroup.RegionBroadcastResponse, error) {
f.requests = append(f.requests, req)
return &regionimgroup.RegionBroadcastResponse{
SysOrigin: req.SysOrigin,
RegionCode: "AR",
GroupID: "@region-ar",
Type: req.Type,
}, nil
}

View File

@ -0,0 +1,139 @@
package cprelationbroadcast
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/common"
rmq "github.com/apache/rocketmq-clients/golang/v5"
"github.com/apache/rocketmq-clients/golang/v5/credentials"
)
// StartMessageConsumer 启动 CP 关系成功 MQ 消费链路。
func (s *Service) StartMessageConsumer(ctx context.Context) error {
if !s.cfg.CPRelationBroadcast.MQ.Enabled {
log.Printf("cp relation broadcast rocketmq consumer disabled")
return nil
}
if strings.TrimSpace(s.cfg.CPRelationBroadcast.MQ.Endpoint) == "" ||
strings.TrimSpace(s.cfg.CPRelationBroadcast.MQ.AccessKey) == "" ||
strings.TrimSpace(s.cfg.CPRelationBroadcast.MQ.AccessSecret) == "" ||
strings.TrimSpace(s.cfg.CPRelationBroadcast.MQ.Topic) == "" ||
strings.TrimSpace(s.cfg.CPRelationBroadcast.MQ.ConsumerGroup) == "" {
log.Printf("cp relation broadcast rocketmq consumer skipped: endpoint, credentials, topic or consumer group missing")
return nil
}
go s.startRocketMQConsumerWithRetry(ctx)
return nil
}
func (s *Service) startRocketMQConsumerWithRetry(ctx context.Context) {
retryDelay := 5 * time.Second
for {
if ctx.Err() != nil {
return
}
if err := s.startRocketMQConsumer(ctx); err != nil {
log.Printf("start cp relation broadcast rocketmq consumer failed: %v; retry in %s", err, retryDelay)
timer := time.NewTimer(retryDelay)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
if retryDelay < time.Minute {
retryDelay *= 2
if retryDelay > time.Minute {
retryDelay = time.Minute
}
}
continue
}
return
}
}
func (s *Service) startRocketMQConsumer(ctx context.Context) error {
filter := rmq.SUB_ALL
if tag := strings.TrimSpace(s.cfg.CPRelationBroadcast.MQ.Tag); tag != "" && tag != "*" {
filter = rmq.NewFilterExpression(tag)
}
consumer, err := rmq.NewPushConsumer(&rmq.Config{
Endpoint: s.cfg.CPRelationBroadcast.MQ.Endpoint,
NameSpace: s.cfg.CPRelationBroadcast.MQ.Namespace,
ConsumerGroup: s.cfg.CPRelationBroadcast.MQ.ConsumerGroup,
Credentials: &credentials.SessionCredentials{
AccessKey: s.cfg.CPRelationBroadcast.MQ.AccessKey,
AccessSecret: s.cfg.CPRelationBroadcast.MQ.AccessSecret,
SecurityToken: s.cfg.CPRelationBroadcast.MQ.SecurityToken,
},
},
rmq.WithPushSubscriptionExpressions(map[string]*rmq.FilterExpression{
s.cfg.CPRelationBroadcast.MQ.Topic: filter,
}),
rmq.WithPushConsumptionThreadCount(4),
rmq.WithPushMaxCacheMessageCount(256),
rmq.WithPushMessageListener(&rmq.FuncMessageListener{
Consume: func(messageView *rmq.MessageView) rmq.ConsumerResult {
if messageView == nil {
return rmq.SUCCESS
}
if err := s.processRocketMQMessage(context.Background(), messageView); err != nil {
log.Printf("cp relation broadcast mq process failed. messageId=%s err=%v", messageView.GetMessageId(), err)
return rmq.FAILURE
}
return rmq.SUCCESS
},
}),
)
if err != nil {
return err
}
if err := consumer.Start(); err != nil {
return err
}
s.rocketConsumer = consumer
log.Printf("cp relation broadcast rocketmq consumer started. topic=%s group=%s tag=%s",
s.cfg.CPRelationBroadcast.MQ.Topic,
s.cfg.CPRelationBroadcast.MQ.ConsumerGroup,
s.cfg.CPRelationBroadcast.MQ.Tag,
)
go func() {
<-ctx.Done()
if s.rocketConsumer != nil {
_ = s.rocketConsumer.GracefulStop()
}
}()
return nil
}
func (s *Service) processRocketMQMessage(ctx context.Context, messageView *rmq.MessageView) error {
payload := strings.TrimSpace(string(messageView.GetBody()))
if payload == "" {
return nil
}
if _, err := s.ProcessPayload(ctx, payload); err != nil {
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
if errors.As(err, &syntaxErr) || errors.As(err, &typeErr) {
log.Printf("drop malformed cp relation broadcast message. messageId=%s err=%v", messageView.GetMessageId(), err)
return nil
}
var appErr *common.AppError
if errors.As(err, &appErr) && appErr.Status >= http.StatusBadRequest && appErr.Status < http.StatusInternalServerError {
log.Printf("drop invalid cp relation broadcast message. messageId=%s code=%s err=%v", messageView.GetMessageId(), appErr.Code, err)
return nil
}
return err
}
return nil
}

View File

@ -0,0 +1,199 @@
package cprelationbroadcast
import (
"context"
"fmt"
"strconv"
"strings"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/service/regionimgroup"
rmq "github.com/apache/rocketmq-clients/golang/v5"
"gorm.io/gorm"
)
const (
MessageTypeCPRelationBroadcast = "CP_RELATION_BROADCAST"
RelationTypeCP = "CP"
RelationTypeBrother = "BROTHER"
RelationTypeSisters = "SISTERS"
defaultSysOrigin = "LIKEI"
)
type dbHandle interface {
WithContext(ctx context.Context) *gorm.DB
}
type regionBroadcaster interface {
SendRegionBroadcast(ctx context.Context, req regionimgroup.RegionBroadcastRequest) (*regionimgroup.RegionBroadcastResponse, error)
}
// Service 消费 Java CP 关系成功 MQ并复用区域 IM 广播发送飘屏。
type Service struct {
cfg config.Config
db dbHandle
broadcaster regionBroadcaster
rocketConsumer rmq.PushConsumer
}
func NewService(cfg config.Config, db dbHandle, broadcaster regionBroadcaster) *Service {
return &Service{cfg: cfg, db: db, broadcaster: broadcaster}
}
// EventRequest 是 Java 在 CP/兄弟/姐妹关系达成后投递给 Go 的 MQ 事件。
type EventRequest struct {
SysOrigin string `json:"sysOrigin"`
EventID string `json:"eventId"`
ApplyID flexibleInt64 `json:"applyId"`
RelationType string `json:"relationType"`
UserID flexibleInt64 `json:"userId"`
CpUserID flexibleInt64 `json:"cpUserId"`
SendUserID flexibleInt64 `json:"sendUserId"`
AcceptUserID flexibleInt64 `json:"acceptUserId"`
OccurredAt string `json:"occurredAt"`
User *EventUser `json:"user"`
CpUser *EventUser `json:"cpUser"`
Payload map[string]any `json:"payload,omitempty"`
}
type EventUser struct {
ID flexibleInt64 `json:"id"`
UserID flexibleInt64 `json:"userId"`
Account string `json:"account"`
Nickname string `json:"nickname"`
UserNickname string `json:"userNickname"`
Avatar string `json:"avatar"`
UserAvatar string `json:"userAvatar"`
CountryCode string `json:"countryCode"`
CountryName string `json:"countryName"`
OriginSys string `json:"originSys"`
}
func (u EventUser) normalizedID() int64 {
if id := u.UserID.Int64(); id > 0 {
return id
}
return u.ID.Int64()
}
func (u EventUser) displayName() string {
for _, value := range []string{u.UserNickname, u.Nickname, u.Account} {
if text := strings.TrimSpace(value); text != "" {
return text
}
}
if id := u.normalizedID(); id > 0 {
return strconv.FormatInt(id, 10)
}
return ""
}
func (u EventUser) toMap() map[string]any {
id := u.normalizedID()
data := map[string]any{}
if id > 0 {
data["userId"] = strconv.FormatInt(id, 10)
data["id"] = strconv.FormatInt(id, 10)
}
if account := strings.TrimSpace(u.Account); account != "" {
data["account"] = account
}
if nickname := strings.TrimSpace(firstNonBlank(u.UserNickname, u.Nickname)); nickname != "" {
data["userNickname"] = nickname
data["nickname"] = nickname
}
if avatar := strings.TrimSpace(firstNonBlank(u.UserAvatar, u.Avatar)); avatar != "" {
data["userAvatar"] = avatar
data["avatar"] = avatar
}
if countryCode := strings.TrimSpace(u.CountryCode); countryCode != "" {
data["countryCode"] = countryCode
}
if countryName := strings.TrimSpace(u.CountryName); countryName != "" {
data["countryName"] = countryName
}
if originSys := strings.TrimSpace(u.OriginSys); originSys != "" {
data["originSys"] = originSys
}
return data
}
func eventUserFromModel(user model.UserBaseInfo) EventUser {
return EventUser{
ID: flexibleInt64(user.ID),
UserID: flexibleInt64(user.ID),
Account: user.Account,
Nickname: user.UserNickname,
UserNickname: user.UserNickname,
Avatar: user.UserAvatar,
UserAvatar: user.UserAvatar,
CountryCode: user.CountryCode,
CountryName: user.CountryName,
OriginSys: user.OriginSys,
}
}
func mergeEventUser(id int64, dbUser model.UserBaseInfo, provided *EventUser) EventUser {
merged := eventUserFromModel(dbUser)
if id > 0 {
merged.ID = flexibleInt64(id)
merged.UserID = flexibleInt64(id)
}
if provided == nil {
return merged
}
if provided.normalizedID() > 0 {
merged.ID = flexibleInt64(provided.normalizedID())
merged.UserID = flexibleInt64(provided.normalizedID())
}
merged.Account = firstNonBlank(provided.Account, merged.Account)
merged.UserNickname = firstNonBlank(provided.UserNickname, provided.Nickname, merged.UserNickname)
merged.Nickname = merged.UserNickname
merged.UserAvatar = firstNonBlank(provided.UserAvatar, provided.Avatar, merged.UserAvatar)
merged.Avatar = merged.UserAvatar
merged.CountryCode = firstNonBlank(provided.CountryCode, merged.CountryCode)
merged.CountryName = firstNonBlank(provided.CountryName, merged.CountryName)
merged.OriginSys = firstNonBlank(provided.OriginSys, merged.OriginSys)
return merged
}
type flexibleInt64 int64
func (v *flexibleInt64) UnmarshalJSON(data []byte) error {
raw := strings.TrimSpace(string(data))
switch raw {
case "", "null", `""`:
*v = 0
return nil
}
if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) {
unquoted, err := strconv.Unquote(raw)
if err != nil {
return err
}
raw = strings.TrimSpace(unquoted)
}
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return fmt.Errorf("invalid int64 value: %w", err)
}
*v = flexibleInt64(parsed)
return nil
}
func (v flexibleInt64) Int64() int64 {
return int64(v)
}
func firstNonBlank(values ...string) string {
for _, value := range values {
if text := strings.TrimSpace(value); text != "" {
return text
}
}
return ""
}

View File

@ -0,0 +1,249 @@
package firstrechargereward
import (
"context"
"errors"
"net/http"
"sort"
"strings"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
)
func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*ConfigResponse, error) {
sysOrigin = s.normalizeSysOrigin(sysOrigin)
var configRow model.FirstRechargeRewardConfig
err := s.db.WithContext(ctx).
Where("sys_origin = ?", sysOrigin).
First(&configRow).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return &ConfigResponse{
Configured: false,
SysOrigin: sysOrigin,
Enabled: false,
LevelConfigs: []LevelPayload{},
}, nil
}
if err != nil {
return nil, err
}
levels, err := s.loadLevels(ctx, configRow.ID)
if err != nil {
return nil, err
}
rewardItems := s.loadRewardItemsMap(ctx, levels)
return &ConfigResponse{
Configured: true,
ID: configRow.ID,
SysOrigin: configRow.SysOrigin,
Enabled: configRow.Enabled,
MinAppVersion: strings.TrimSpace(configRow.MinAppVersion),
UpdateTime: formatDateTime(configRow.UpdateTime),
LevelConfigs: buildLevelPayloads(levels, rewardItems, 0, 0, true),
}, nil
}
func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*ConfigResponse, error) {
requestID := req.ID.Int64()
sysOrigin := s.normalizeSysOrigin(req.SysOrigin)
levels, err := s.normalizeLevelInputs(req.LevelConfigs)
if err != nil {
return nil, err
}
if req.Enabled && !hasEnabledLevel(levels) {
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
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
now := time.Now()
var configRow model.FirstRechargeRewardConfig
if requestID > 0 {
err := tx.Where("id = ?", requestID).First(&configRow).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return NewAppError(http.StatusNotFound, "first_recharge_reward_config_not_found", "config not found")
}
if err != nil {
return err
}
if strings.TrimSpace(req.SysOrigin) == "" {
sysOrigin = configRow.SysOrigin
}
} else {
err := tx.Where("sys_origin = ?", sysOrigin).First(&configRow).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
configID, idErr := utils.NextID()
if idErr != nil {
return idErr
}
configRow = model.FirstRechargeRewardConfig{
ID: configID,
SysOrigin: sysOrigin,
CreateTime: now,
}
} else if err != nil {
return err
}
}
configRow.SysOrigin = sysOrigin
configRow.Enabled = req.Enabled
configRow.MinAppVersion = minAppVersion
configRow.UpdateTime = now
if configRow.CreateTime.IsZero() {
configRow.CreateTime = now
}
if err := tx.Save(&configRow).Error; err != nil {
return err
}
if err := tx.Where("config_id = ?", configRow.ID).Delete(&model.FirstRechargeRewardLevel{}).Error; err != nil {
return err
}
for index := range levels {
levels[index].ConfigID = configRow.ID
levels[index].CreateTime = now
levels[index].UpdateTime = now
}
if len(levels) > 0 {
if err := tx.Create(&levels).Error; err != nil {
return err
}
}
savedConfigID = configRow.ID
return nil
}); err != nil {
return nil, err
}
return s.GetConfig(ctx, savedSysOrigin(ctx, s.db, savedConfigID, sysOrigin))
}
func (s *Service) normalizeLevelInputs(inputs []LevelInput) ([]model.FirstRechargeRewardLevel, error) {
if len(inputs) == 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_level_configs", "levelConfigs is required")
}
seenLevels := make(map[int]struct{}, len(inputs))
seenAmounts := make(map[int64]struct{}, len(inputs))
seenGoogleProductIDs := make(map[string]struct{}, len(inputs))
rows := make([]model.FirstRechargeRewardLevel, 0, len(inputs))
for _, item := range inputs {
if item.Level <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_level", "level must be greater than 0")
}
amountCents := item.RechargeAmount.Cents()
if amountCents == 0 && item.RechargeAmountCents > 0 {
amountCents = item.RechargeAmountCents
}
if amountCents <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_recharge_amount", "rechargeAmount must be greater than 0")
}
rewardGroupID := item.RewardGroupID.Int64()
if rewardGroupID <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_reward_group", "rewardGroupId is required")
}
if _, exists := seenLevels[item.Level]; exists {
return nil, NewAppError(http.StatusBadRequest, "duplicate_level", "levelConfigs must not contain duplicate levels")
}
if _, exists := seenAmounts[amountCents]; exists {
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{}{}
seenAmounts[amountCents] = struct{}{}
id, err := utils.NextID()
if err != nil {
return nil, err
}
rows = append(rows, model.FirstRechargeRewardLevel{
ID: id,
Level: item.Level,
RechargeAmountCents: amountCents,
GoogleProductID: googleProductID,
RewardGroupID: rewardGroupID,
RewardGroupName: strings.TrimSpace(item.RewardGroupName),
Enabled: item.Enabled,
})
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].RechargeAmountCents == rows[j].RechargeAmountCents {
return rows[i].Level < rows[j].Level
}
return rows[i].RechargeAmountCents < rows[j].RechargeAmountCents
})
return rows, nil
}
func (s *Service) loadConfigBundle(ctx context.Context, sysOrigin string) (*configBundle, error) {
sysOrigin = s.normalizeSysOrigin(sysOrigin)
var configRow model.FirstRechargeRewardConfig
err := s.db.WithContext(ctx).
Where("sys_origin = ?", sysOrigin).
First(&configRow).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return &configBundle{}, nil
}
if err != nil {
return nil, err
}
levels, err := s.loadLevels(ctx, configRow.ID)
if err != nil {
return nil, err
}
return &configBundle{
Config: refConfigSnapshot(configSnapshotFromModel(configRow)),
Levels: levels,
}, nil
}
func (s *Service) loadLevels(ctx context.Context, configID int64) ([]levelSnapshot, error) {
if configID <= 0 {
return []levelSnapshot{}, nil
}
var rows []model.FirstRechargeRewardLevel
if err := s.db.WithContext(ctx).
Where("config_id = ?", configID).
Order("recharge_amount_cents asc").
Order("level asc").
Find(&rows).Error; err != nil {
return nil, err
}
levels := make([]levelSnapshot, 0, len(rows))
for _, row := range rows {
levels = append(levels, levelSnapshotFromModel(row))
}
sortLevels(levels)
return levels, nil
}
func hasEnabledLevel(levels []model.FirstRechargeRewardLevel) bool {
for _, level := range levels {
if level.Enabled {
return true
}
}
return false
}
func savedSysOrigin(ctx context.Context, db dbHandle, id int64, fallback string) string {
var row model.FirstRechargeRewardConfig
if id > 0 && db != nil {
if err := db.WithContext(ctx).Where("id = ?", id).First(&row).Error; err == nil {
return row.SysOrigin
}
}
return fallback
}

View File

@ -0,0 +1,456 @@
package firstrechargereward
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
)
func (s *Service) ProcessRechargePayload(ctx context.Context, payload string) (*ProcessRechargeResponse, error) {
payload = strings.TrimSpace(payload)
if payload == "" {
return &ProcessRechargeResponse{Processed: false, Reason: "empty_payload"}, nil
}
event, skip, err := decodeRechargeEventPayload(payload, s.cfg.FirstRechargeReward.MQ.Tag)
if err != nil {
return nil, err
}
if skip {
return &ProcessRechargeResponse{Processed: false, Reason: "tag_skipped"}, nil
}
return s.ProcessRechargeEvent(ctx, event)
}
func (s *Service) ProcessRechargeEvent(ctx context.Context, event RechargeEvent) (*ProcessRechargeResponse, error) {
normalized, err := s.normalizeRechargeEvent(event)
if err != nil {
var appErr *AppError
if errors.As(err, &appErr) && appErr.Status >= http.StatusBadRequest && appErr.Status < http.StatusInternalServerError {
return &ProcessRechargeResponse{Processed: false, Reason: appErr.Code}, nil
}
return nil, err
}
if !isAcceptedPaymentMethod(normalized.PaymentMethod, normalized.PayPlatform) {
return &ProcessRechargeResponse{Processed: false, Reason: "unsupported_payment_method"}, nil
}
if s.java == nil {
return nil, NewAppError(http.StatusServiceUnavailable, "java_gateway_unavailable", "java gateway is unavailable")
}
bundle, err := s.loadConfigBundle(ctx, normalized.SysOrigin)
if err != nil {
return nil, err
}
if bundle.Config == nil {
return &ProcessRechargeResponse{Processed: false, Reason: "config_missing"}, nil
}
if !bundle.Config.Enabled {
return &ProcessRechargeResponse{Processed: false, Reason: "config_disabled"}, nil
}
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 {
return &ProcessRechargeResponse{Processed: false, Reason: "level_not_matched"}, nil
}
record, terminal, err := s.getOrCreateGrantRecord(ctx, normalized, bundle.Config, matched)
if err != nil {
return nil, err
}
if terminal {
return &ProcessRechargeResponse{
Processed: true,
RecordID: record.ID,
Status: record.Status,
Level: record.Level,
}, nil
}
if err := s.dispatchRewardGroup(ctx, record); err != nil {
_ = s.updateGrantRecordFailed(ctx, record.ID, err)
return nil, err
}
if err := s.updateGrantRecordSuccess(ctx, record.ID); err != nil {
return nil, err
}
record.Status = statusSuccess
now := time.Now()
record.SentAt = &now
if err := s.pushRewardNotice(ctx, record); err != nil {
log.Printf("push first recharge reward notice failed. recordId=%d userId=%d eventId=%s err=%v",
record.ID, record.UserID, record.EventID, err)
}
_ = s.java.RemoveUserProfileCacheAll(ctx, record.UserID)
return &ProcessRechargeResponse{
Processed: true,
RecordID: record.ID,
Status: statusSuccess,
Level: record.Level,
}, nil
}
func (s *Service) normalizeRechargeEvent(event RechargeEvent) (normalizedRechargeEvent, error) {
sysOrigin := s.normalizeSysOrigin(event.SysOrigin)
userID := event.UserID.Int64()
if userID <= 0 {
return normalizedRechargeEvent{}, NewAppError(http.StatusBadRequest, "invalid_user_id", "userId is required")
}
amountCents := event.AmountCents.Int64()
if amountCents <= 0 {
amountCents = firstPositiveAmountCents(event.Amount.String(), event.AmountUSD.String(), event.USDAmount.String())
}
payPlatform := strings.ToUpper(strings.TrimSpace(event.PayPlatform))
paymentMethod := strings.ToUpper(strings.TrimSpace(event.PaymentMethod))
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())
eventID := strings.TrimSpace(event.EventID)
if eventID == "" && sourceOrderID != "" {
eventID = "RECHARGE_SUCCESS:" + firstNonEmpty(paymentMethod, payPlatform, "UNKNOWN") + ":" + sourceOrderID
}
if eventID == "" {
return normalizedRechargeEvent{}, NewAppError(http.StatusBadRequest, "invalid_event_id", "eventId is required")
}
occurredAt := parseEventTime(event.OccurredAt.String())
if occurredAt.IsZero() {
occurredAt = time.Now()
}
return normalizedRechargeEvent{
EventID: eventID,
SysOrigin: sysOrigin,
UserID: userID,
AmountCents: amountCents,
Currency: firstNonEmpty(strings.ToUpper(strings.TrimSpace(event.Currency)), "USD"),
PayPlatform: payPlatform,
PaymentMethod: paymentMethod,
AppVersion: appVersion,
GoogleProductID: googleProductID,
SourceOrderID: sourceOrderID,
OccurredAt: occurredAt,
}, nil
}
func (s *Service) getOrCreateGrantRecord(
ctx context.Context,
event normalizedRechargeEvent,
config *configSnapshot,
level levelSnapshot,
) (*model.FirstRechargeRewardGrantRecord, bool, error) {
var existing model.FirstRechargeRewardGrantRecord
err := s.db.WithContext(ctx).Where("event_id = ?", event.EventID).First(&existing).Error
if err == nil {
return &existing, existing.Status == statusSuccess, nil
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, err
}
err = s.db.WithContext(ctx).
Where("sys_origin = ? AND user_id = ?", event.SysOrigin, event.UserID).
First(&existing).Error
if err == nil {
return &existing, existing.Status == statusSuccess, nil
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, err
}
id, err := utils.NextID()
if err != nil {
return nil, false, err
}
now := time.Now()
record := model.FirstRechargeRewardGrantRecord{
ID: id,
EventID: event.EventID,
ConfigID: config.ID,
LevelID: level.ID,
SysOrigin: config.SysOrigin,
UserID: event.UserID,
RechargeAmountCents: event.AmountCents,
RechargeThresholdCents: level.RechargeAmountCents,
Level: level.Level,
PayPlatform: event.PayPlatform,
PaymentMethod: event.PaymentMethod,
GoogleProductID: event.GoogleProductID,
SourceOrderID: event.SourceOrderID,
RewardGroupID: level.RewardGroupID,
RewardGroupName: level.RewardGroupName,
RewardGroupTrackID: id,
Status: statusPending,
RewardGroupStatus: statusPending,
EventTime: event.OccurredAt,
CreateTime: now,
UpdateTime: now,
}
if profile, err := s.java.GetUserProfile(ctx, event.UserID); err == nil {
record.Account = strings.TrimSpace(profile.Account)
record.UserAvatar = strings.TrimSpace(profile.UserAvatar)
record.UserNickname = strings.TrimSpace(profile.UserNickname)
record.CountryCode = strings.TrimSpace(profile.CountryCode)
record.CountryName = strings.TrimSpace(profile.CountryName)
}
if err := s.db.WithContext(ctx).Create(&record).Error; err != nil {
if isDuplicateError(err) {
if reload, ok, reloadErr := s.loadExistingGrantRecord(ctx, event); reloadErr != nil {
return nil, false, reloadErr
} else if ok {
return &reload, reload.Status == statusSuccess, nil
}
}
return nil, false, err
}
return &record, false, nil
}
func (s *Service) loadExistingGrantRecord(ctx context.Context, event normalizedRechargeEvent) (model.FirstRechargeRewardGrantRecord, bool, error) {
var existing model.FirstRechargeRewardGrantRecord
err := s.db.WithContext(ctx).
Where("event_id = ? OR (sys_origin = ? AND user_id = ?)", event.EventID, event.SysOrigin, event.UserID).
First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.FirstRechargeRewardGrantRecord{}, false, nil
}
if err != nil {
return model.FirstRechargeRewardGrantRecord{}, false, err
}
return existing, true, nil
}
func (s *Service) dispatchRewardGroup(ctx context.Context, record *model.FirstRechargeRewardGrantRecord) error {
if record.RewardGroupID <= 0 {
return NewAppError(http.StatusBadRequest, "reward_group_missing", "reward group is missing")
}
trackID := record.RewardGroupTrackID
if trackID <= 0 {
trackID = record.ID
}
return s.java.SendActivityReward(ctx, integration.SendActivityRewardRequest{
TrackID: trackID,
Origin: firstRechargeRewardOrigin,
SysOrigin: record.SysOrigin,
SourceGroupID: record.RewardGroupID,
AcceptUserID: record.UserID,
})
}
func (s *Service) updateGrantRecordSuccess(ctx context.Context, recordID int64) error {
now := time.Now()
return s.db.WithContext(ctx).
Model(&model.FirstRechargeRewardGrantRecord{}).
Where("id = ?", recordID).
Updates(map[string]any{
"status": statusSuccess,
"reward_group_status": statusSuccess,
"last_error": "",
"sent_at": now,
"update_time": now,
}).Error
}
func (s *Service) updateGrantRecordFailed(ctx context.Context, recordID int64, cause error) error {
return s.db.WithContext(ctx).
Model(&model.FirstRechargeRewardGrantRecord{}).
Where("id = ?", recordID).
Updates(map[string]any{
"status": statusFailed,
"reward_group_status": statusFailed,
"retry_count": gorm.Expr("retry_count + 1"),
"last_error": truncateMessage(cause.Error(), 1024),
"update_time": time.Now(),
}).Error
}
func (s *Service) pushRewardNotice(ctx context.Context, record *model.FirstRechargeRewardGrantRecord) error {
expand := map[string]any{
"scene": "FIRST_RECHARGE_REWARD",
"showRewardPopup": true,
"userId": record.UserID,
"sysOrigin": record.SysOrigin,
"eventId": record.EventID,
"recordId": fmt.Sprintf("%d", record.ID),
"level": record.Level,
"rechargeAmount": formatAmountCents(record.RechargeAmountCents),
"rechargeAmountCents": record.RechargeAmountCents,
"rewardGroupId": fmt.Sprintf("%d", record.RewardGroupID),
"rewardGroupName": record.RewardGroupName,
"payPlatform": record.PayPlatform,
"paymentMethod": record.PaymentMethod,
"googleProductId": record.GoogleProductID,
"noticeType": firstRechargeRewardNoticeType,
}
return s.java.SendOfficialNoticeCustomize(ctx, integration.OfficialNoticeCustomizeRequest{
ToAccounts: []int64{record.UserID},
NoticeType: firstRechargeRewardNoticeType,
Title: firstRechargeRewardNoticeTitle,
Content: firstRechargeRewardNoticeContent,
Expand: expand,
})
}
func decodeRechargeEventPayload(payload string, expectedTag string) (RechargeEvent, bool, error) {
var event RechargeEvent
if err := json.Unmarshal([]byte(payload), &event); err != nil {
return event, false, err
}
if strings.TrimSpace(event.EventID) != "" || event.UserID.Int64() > 0 {
return event, false, nil
}
var envelope rechargeMessageEnvelope
if err := json.Unmarshal([]byte(payload), &envelope); err != nil {
return RechargeEvent{}, false, err
}
if shouldSkipEnvelope(envelope.Tag, expectedTag) {
return RechargeEvent{}, true, nil
}
bodyPayload, ok, err := envelope.bodyPayload()
if err != nil || !ok {
return RechargeEvent{}, false, err
}
if err := json.Unmarshal([]byte(bodyPayload), &event); err != nil {
return RechargeEvent{}, false, err
}
return event, false, nil
}
type rechargeMessageEnvelope struct {
Tag string `json:"tag"`
Body json.RawMessage `json:"body"`
}
func (e rechargeMessageEnvelope) bodyPayload() (string, bool, error) {
raw := strings.TrimSpace(string(e.Body))
if raw == "" || raw == "null" {
return "", false, nil
}
if strings.HasPrefix(raw, "\"") {
var body string
if err := json.Unmarshal(e.Body, &body); err != nil {
return "", false, err
}
body = strings.TrimSpace(body)
return body, body != "", nil
}
return raw, true, nil
}
func shouldSkipEnvelope(actualTag, expectedTag string) bool {
actualTag = strings.TrimSpace(actualTag)
expectedTag = strings.TrimSpace(expectedTag)
if actualTag == "" || expectedTag == "" || expectedTag == "*" {
return false
}
return actualTag != expectedTag
}
func isAcceptedPaymentMethod(paymentMethod string, _ string) bool {
paymentMethod = strings.ToUpper(strings.TrimSpace(paymentMethod))
return paymentMethod == paymentMethodGoogle || paymentMethod == paymentMethodThirdParty
}
func normalizeRechargeAppVersion(event RechargeEvent) string {
return firstNonEmpty(
event.AppVersion.String(),
event.ClientVersion.String(),
event.Version.String(),
event.ReqVersion.String(),
rechargePayloadString(event.Payload, "appVersion", "clientVersion", "version", "reqVersion"),
)
}
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 {
if len(payload) == 0 {
return ""
}
var values map[string]any
if err := json.Unmarshal(payload, &values); err != nil {
return ""
}
for _, key := range keys {
if value, ok := values[key]; ok {
if text := strings.TrimSpace(fmt.Sprint(value)); text != "" && text != "<nil>" {
return text
}
}
}
return ""
}
func firstPositiveAmountCents(values ...string) int64 {
for _, value := range values {
cents, err := parseAmountCents(value)
if err == nil && cents > 0 {
return cents
}
}
return 0
}
func parseEventTime(raw string) time.Time {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}
}
if t, err := time.Parse(time.RFC3339Nano, raw); err == nil {
return t
}
if t, err := time.Parse("2006-01-02 15:04:05", raw); err == nil {
return t
}
return time.Time{}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func isDuplicateError(err error) bool {
if err == nil {
return false
}
text := strings.ToLower(err.Error())
return strings.Contains(text, "duplicate") || strings.Contains(text, "unique constraint")
}
func truncateMessage(message string, limit int) string {
message = strings.TrimSpace(message)
if limit <= 0 || len(message) <= limit {
return message
}
return message[:limit]
}

View File

@ -0,0 +1,112 @@
package firstrechargereward
import (
"context"
"errors"
"net/http"
"chatapp3-golang/internal/model"
"gorm.io/gorm"
)
const (
activityStatusNotConfigured = "NOT_CONFIGURED"
activityStatusDisabled = "DISABLED"
activityStatusOngoing = "ONGOING"
activityStatusPending = "PENDING"
activityStatusRewarded = "REWARDED"
activityStatusFailed = "FAILED"
)
// GetHome returns app-facing first recharge reward config and the user's grant state.
func (s *Service) GetHome(ctx context.Context, user AuthUser, appVersion string) (*HomeResponse, error) {
sysOrigin, err := requireUser(user)
if err != nil {
return nil, err
}
if s.java == nil {
return nil, NewAppError(http.StatusServiceUnavailable, "java_gateway_unavailable", "java gateway is unavailable")
}
bundle, err := s.loadConfigBundle(ctx, sysOrigin)
if err != nil {
return nil, err
}
if bundle.Config == nil {
return &HomeResponse{
Configured: false,
Enabled: false,
ActivityStatus: activityStatusNotConfigured,
UserID: user.UserID,
SysOrigin: sysOrigin,
LevelConfigs: []LevelPayload{},
HasRewardRecord: false,
}, nil
}
rewardItems := s.loadRewardItemsMap(ctx, bundle.Levels)
resp := &HomeResponse{
Configured: true,
Enabled: bundle.Config.Enabled,
ActivityStatus: activityStatusOngoing,
UserID: user.UserID,
SysOrigin: bundle.Config.SysOrigin,
MinAppVersion: bundle.Config.MinAppVersion,
LevelConfigs: buildLevelPayloads(bundle.Levels, rewardItems, 0, 0, false),
HasRewardRecord: false,
}
if !bundle.Config.Enabled {
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)
if err != nil {
return nil, err
}
if !ok {
return resp, nil
}
resp.HasRewardRecord = true
resp.RewardStatus = record.Status
resp.Rewarded = record.Status == statusSuccess
resp.MatchedLevel = record.Level
resp.RechargeAmount = formatAmountCents(record.RechargeAmountCents)
resp.RechargeAmountCents = record.RechargeAmountCents
resp.RewardGroupID = record.RewardGroupID
resp.RewardGroupName = record.RewardGroupName
resp.RewardItems = rewardItems[record.RewardGroupID]
resp.LevelConfigs = buildLevelPayloads(bundle.Levels, rewardItems, record.RechargeAmountCents, record.Level, false)
switch record.Status {
case statusSuccess:
resp.ActivityStatus = activityStatusRewarded
case statusPending:
resp.ActivityStatus = activityStatusPending
case statusFailed:
resp.ActivityStatus = activityStatusFailed
}
return resp, nil
}
func (s *Service) loadUserGrantRecord(ctx context.Context, sysOrigin string, userID int64) (model.FirstRechargeRewardGrantRecord, bool, error) {
if userID <= 0 {
return model.FirstRechargeRewardGrantRecord{}, false, nil
}
var record model.FirstRechargeRewardGrantRecord
err := s.db.WithContext(ctx).
Where("sys_origin = ? AND user_id = ?", s.normalizeSysOrigin(sysOrigin), userID).
First(&record).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.FirstRechargeRewardGrantRecord{}, false, nil
}
if err != nil {
return model.FirstRechargeRewardGrantRecord{}, false, err
}
return record, true, nil
}

View File

@ -0,0 +1,139 @@
package firstrechargereward
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/common"
rmq "github.com/apache/rocketmq-clients/golang/v5"
"github.com/apache/rocketmq-clients/golang/v5/credentials"
)
// StartMessageConsumer starts the first recharge reward RocketMQ consumer.
func (s *Service) StartMessageConsumer(ctx context.Context) error {
if !s.cfg.FirstRechargeReward.MQ.Enabled {
log.Printf("first recharge reward rocketmq consumer disabled")
return nil
}
if strings.TrimSpace(s.cfg.FirstRechargeReward.MQ.Endpoint) == "" ||
strings.TrimSpace(s.cfg.FirstRechargeReward.MQ.AccessKey) == "" ||
strings.TrimSpace(s.cfg.FirstRechargeReward.MQ.AccessSecret) == "" ||
strings.TrimSpace(s.cfg.FirstRechargeReward.MQ.Topic) == "" ||
strings.TrimSpace(s.cfg.FirstRechargeReward.MQ.ConsumerGroup) == "" {
log.Printf("first recharge reward rocketmq consumer skipped: endpoint, credentials, topic or consumer group missing")
return nil
}
go s.startRocketMQConsumerWithRetry(ctx)
return nil
}
func (s *Service) startRocketMQConsumerWithRetry(ctx context.Context) {
retryDelay := 5 * time.Second
for {
if ctx.Err() != nil {
return
}
if err := s.startRocketMQConsumer(ctx); err != nil {
log.Printf("start first recharge reward rocketmq consumer failed: %v; retry in %s", err, retryDelay)
timer := time.NewTimer(retryDelay)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
if retryDelay < time.Minute {
retryDelay *= 2
if retryDelay > time.Minute {
retryDelay = time.Minute
}
}
continue
}
return
}
}
func (s *Service) startRocketMQConsumer(ctx context.Context) error {
filter := rmq.SUB_ALL
if tag := strings.TrimSpace(s.cfg.FirstRechargeReward.MQ.Tag); tag != "" && tag != "*" {
filter = rmq.NewFilterExpression(tag)
}
consumer, err := rmq.NewPushConsumer(&rmq.Config{
Endpoint: s.cfg.FirstRechargeReward.MQ.Endpoint,
NameSpace: s.cfg.FirstRechargeReward.MQ.Namespace,
ConsumerGroup: s.cfg.FirstRechargeReward.MQ.ConsumerGroup,
Credentials: &credentials.SessionCredentials{
AccessKey: s.cfg.FirstRechargeReward.MQ.AccessKey,
AccessSecret: s.cfg.FirstRechargeReward.MQ.AccessSecret,
SecurityToken: s.cfg.FirstRechargeReward.MQ.SecurityToken,
},
},
rmq.WithPushSubscriptionExpressions(map[string]*rmq.FilterExpression{
s.cfg.FirstRechargeReward.MQ.Topic: filter,
}),
rmq.WithPushConsumptionThreadCount(4),
rmq.WithPushMaxCacheMessageCount(256),
rmq.WithPushMessageListener(&rmq.FuncMessageListener{
Consume: func(messageView *rmq.MessageView) rmq.ConsumerResult {
if messageView == nil {
return rmq.SUCCESS
}
if err := s.processRocketMQMessage(context.Background(), messageView); err != nil {
log.Printf("first recharge reward mq process failed. messageId=%s err=%v", messageView.GetMessageId(), err)
return rmq.FAILURE
}
return rmq.SUCCESS
},
}),
)
if err != nil {
return err
}
if err := consumer.Start(); err != nil {
return err
}
s.rocketConsumer = consumer
log.Printf("first recharge reward rocketmq consumer started. topic=%s group=%s tag=%s",
s.cfg.FirstRechargeReward.MQ.Topic,
s.cfg.FirstRechargeReward.MQ.ConsumerGroup,
s.cfg.FirstRechargeReward.MQ.Tag,
)
go func() {
<-ctx.Done()
if s.rocketConsumer != nil {
_ = s.rocketConsumer.GracefulStop()
}
}()
return nil
}
func (s *Service) processRocketMQMessage(ctx context.Context, messageView *rmq.MessageView) error {
payload := strings.TrimSpace(string(messageView.GetBody()))
if payload == "" {
return nil
}
if _, err := s.ProcessRechargePayload(ctx, payload); err != nil {
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
if errors.As(err, &syntaxErr) || errors.As(err, &typeErr) {
log.Printf("drop malformed first recharge reward message. messageId=%s err=%v", messageView.GetMessageId(), err)
return nil
}
var appErr *common.AppError
if errors.As(err, &appErr) && appErr.Status >= http.StatusBadRequest && appErr.Status < http.StatusInternalServerError {
log.Printf("drop invalid first recharge reward message. messageId=%s code=%s err=%v", messageView.GetMessageId(), appErr.Code, err)
return nil
}
return err
}
return nil
}

View File

@ -0,0 +1,180 @@
package firstrechargereward
import (
"context"
"sort"
"strings"
"time"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
)
func configSnapshotFromModel(row model.FirstRechargeRewardConfig) configSnapshot {
return configSnapshot{
ID: row.ID,
SysOrigin: strings.ToUpper(strings.TrimSpace(row.SysOrigin)),
Enabled: row.Enabled,
MinAppVersion: strings.TrimSpace(row.MinAppVersion),
UpdateTime: row.UpdateTime,
}
}
func levelSnapshotFromModel(row model.FirstRechargeRewardLevel) levelSnapshot {
return levelSnapshot{
ID: row.ID,
Level: row.Level,
RechargeAmountCents: row.RechargeAmountCents,
GoogleProductID: normalizeGoogleProductID(row.GoogleProductID),
RewardGroupID: row.RewardGroupID,
RewardGroupName: strings.TrimSpace(row.RewardGroupName),
Enabled: row.Enabled,
}
}
func refConfigSnapshot(value configSnapshot) *configSnapshot {
return &value
}
func buildLevelPayloads(
levels []levelSnapshot,
rewardItems map[int64][]RewardItem,
currentRechargeCents int64,
matchedLevel int,
admin bool,
) []LevelPayload {
payloads := make([]LevelPayload, 0, len(levels))
for _, level := range levels {
if !admin && !level.Enabled {
continue
}
payloads = append(payloads, LevelPayload{
ID: level.ID,
Level: level.Level,
RechargeAmount: formatAmountCents(level.RechargeAmountCents),
RechargeAmountCents: level.RechargeAmountCents,
GoogleProductID: level.GoogleProductID,
RewardGroupID: level.RewardGroupID,
RewardGroupName: level.RewardGroupName,
RewardItems: rewardItems[level.RewardGroupID],
Enabled: level.Enabled,
Reached: currentRechargeCents >= level.RechargeAmountCents,
Current: matchedLevel > 0 && matchedLevel == level.Level,
})
}
return payloads
}
func pickMatchedLevel(levels []levelSnapshot, rechargeAmountCents int64, googleProductID string) (levelSnapshot, bool) {
googleProductID = normalizeGoogleProductID(googleProductID)
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 {
if !level.Enabled || level.RechargeAmountCents <= 0 || level.RewardGroupID <= 0 {
continue
}
if rechargeAmountCents == level.RechargeAmountCents {
return level, true
}
}
return levelSnapshot{}, false
}
func sortLevels(levels []levelSnapshot) {
sort.Slice(levels, func(i, j int) bool {
if levels[i].RechargeAmountCents == levels[j].RechargeAmountCents {
return levels[i].Level < levels[j].Level
}
return levels[i].RechargeAmountCents < levels[j].RechargeAmountCents
})
}
func (s *Service) loadRewardItemsMap(ctx context.Context, levels []levelSnapshot) map[int64][]RewardItem {
if s.java == nil {
return map[int64][]RewardItem{}
}
groupIDs := make([]int64, 0)
seen := make(map[int64]struct{})
for _, level := range levels {
if level.RewardGroupID <= 0 {
continue
}
if _, exists := seen[level.RewardGroupID]; exists {
continue
}
seen[level.RewardGroupID] = struct{}{}
groupIDs = append(groupIDs, level.RewardGroupID)
}
sort.Slice(groupIDs, func(i, j int) bool { return groupIDs[i] < groupIDs[j] })
result := make(map[int64][]RewardItem, len(groupIDs))
for _, groupID := range groupIDs {
detail, err := s.java.GetRewardGroupDetail(ctx, groupID)
if err != nil {
continue
}
result[groupID] = rewardItemsFromGroup(detail)
}
return result
}
func rewardItemsFromGroup(detail integration.RewardGroupDetail) []RewardItem {
items := make([]RewardItem, 0, len(detail.RewardConfigList))
for _, item := range detail.RewardConfigList {
items = append(items, RewardItem{
ID: int64(item.ID),
Type: rewardItemDisplayType(item),
Name: strings.TrimSpace(item.Name),
Content: strings.TrimSpace(item.Content),
Quantity: int64(item.Quantity),
Cover: firstNonEmpty(item.Cover, item.SourceURL),
Remark: strings.TrimSpace(item.Remark),
})
}
return items
}
func rewardItemDisplayType(item integration.RewardGroupItem) string {
itemType := strings.ToUpper(strings.TrimSpace(item.Type))
detailType := strings.ToUpper(strings.TrimSpace(item.DetailType))
if itemType == "PROPS" {
if detailType != "" && detailType != "PROPS" {
return detailType
}
if isVipRewardItem(item) {
return "NOBLE_VIP"
}
}
return itemType
}
func isVipRewardItem(item integration.RewardGroupItem) bool {
name := strings.ToUpper(strings.TrimSpace(item.Name))
remark := strings.ToUpper(strings.TrimSpace(item.Remark))
return strings.Contains(name, "VIP") ||
strings.Contains(name, "NOBLE") ||
strings.Contains(remark, "VIP") ||
strings.Contains(remark, "NOBLE")
}
func formatDateTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("2006-01-02 15:04:05")
}
func formatPtrDateTime(t *time.Time) string {
if t == nil {
return ""
}
return formatDateTime(*t)
}

View File

@ -0,0 +1,31 @@
package firstrechargereward
import (
"fmt"
"math"
"strconv"
"strings"
)
func parseAmountCents(raw string) (int64, error) {
value := strings.TrimSpace(raw)
if value == "" {
return 0, nil
}
value = strings.TrimPrefix(value, "$")
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0, err
}
if parsed < 0 {
return 0, fmt.Errorf("amount must not be negative")
}
return int64(math.Round(parsed * 100)), nil
}
func formatAmountCents(cents int64) string {
if cents <= 0 {
return "0.00"
}
return fmt.Sprintf("%.2f", float64(cents)/100)
}

View File

@ -0,0 +1,109 @@
package firstrechargereward
import (
"context"
"strings"
"chatapp3-golang/internal/model"
)
// PageGrantRecords returns admin-facing first recharge reward grant records.
func (s *Service) PageGrantRecords(
ctx context.Context,
sysOrigin string,
status string,
userID int64,
cursor int,
limit int,
) (*GrantRecordPageResponse, error) {
sysOrigin = s.normalizeSysOrigin(sysOrigin)
status = strings.ToUpper(strings.TrimSpace(status))
cursor, limit = NormalizePage(cursor, limit)
query := s.db.WithContext(ctx).Model(&model.FirstRechargeRewardGrantRecord{}).
Where("sys_origin = ?", sysOrigin)
if status != "" {
query = query.Where("status = ?", status)
}
if userID > 0 {
query = query.Where("user_id = ?", userID)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, err
}
var rows []model.FirstRechargeRewardGrantRecord
if err := query.
Order("create_time desc").
Offset((cursor - 1) * limit).
Limit(limit).
Find(&rows).Error; err != nil {
return nil, err
}
rewardItems := s.loadRewardItemsForRecords(ctx, rows)
views := make([]GrantRecordView, 0, len(rows))
for _, row := range rows {
views = append(views, grantRecordViewFromModel(row, rewardItems[row.RewardGroupID]))
}
return &GrantRecordPageResponse{
Records: views,
Total: total,
Current: cursor,
Size: limit,
}, nil
}
func (s *Service) loadRewardItemsForRecords(ctx context.Context, rows []model.FirstRechargeRewardGrantRecord) map[int64][]RewardItem {
levels := make([]levelSnapshot, 0, len(rows))
seen := make(map[int64]struct{})
for _, row := range rows {
if row.RewardGroupID <= 0 {
continue
}
if _, exists := seen[row.RewardGroupID]; exists {
continue
}
seen[row.RewardGroupID] = struct{}{}
levels = append(levels, levelSnapshot{
RewardGroupID: row.RewardGroupID,
})
}
return s.loadRewardItemsMap(ctx, levels)
}
func grantRecordViewFromModel(row model.FirstRechargeRewardGrantRecord, rewardItems []RewardItem) GrantRecordView {
return GrantRecordView{
ID: row.ID,
EventID: row.EventID,
UserID: row.UserID,
Account: row.Account,
UserAvatar: row.UserAvatar,
UserNickname: row.UserNickname,
CountryCode: row.CountryCode,
CountryName: row.CountryName,
SysOrigin: row.SysOrigin,
RechargeAmount: formatAmountCents(row.RechargeAmountCents),
RechargeAmountCents: row.RechargeAmountCents,
RechargeThreshold: formatAmountCents(row.RechargeThresholdCents),
RechargeThresholdCents: row.RechargeThresholdCents,
Level: row.Level,
PayPlatform: row.PayPlatform,
PaymentMethod: row.PaymentMethod,
GoogleProductID: row.GoogleProductID,
SourceOrderID: row.SourceOrderID,
RewardGroupID: row.RewardGroupID,
RewardGroupName: row.RewardGroupName,
RewardItems: rewardItems,
Status: row.Status,
RewardGroupStatus: row.RewardGroupStatus,
RetryCount: row.RetryCount,
LastError: row.LastError,
EventTime: formatDateTime(row.EventTime),
SentAt: formatPtrDateTime(row.SentAt),
CreateTime: formatDateTime(row.CreateTime),
UpdateTime: formatDateTime(row.UpdateTime),
}
}

View File

@ -0,0 +1,567 @@
package firstrechargereward
import (
"context"
"encoding/json"
"testing"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestSaveConfigReturnsRewardItems(t *testing.T) {
service, gateway, _ := newTestService(t)
gateway.groups[1001] = testRewardGroup(1001, "首冲礼包")
req := mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI",
"enabled": true,
"minAppVersion": "1.5.0",
"levelConfigs": [
{"level": 1, "rechargeAmount": "9.99", "googleProductId": "gold_999", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
]
}`)
resp, err := service.SaveConfig(context.Background(), req)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
if !resp.Configured || !resp.Enabled {
t.Fatalf("SaveConfig() configured/enabled = %v/%v", resp.Configured, resp.Enabled)
}
if len(resp.LevelConfigs) != 1 {
t.Fatalf("level count = %d", len(resp.LevelConfigs))
}
level := resp.LevelConfigs[0]
if level.RechargeAmountCents != 999 || level.RewardGroupID != 1001 {
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 != "首冲礼包奖励" {
t.Fatalf("reward items = %+v", level.RewardItems)
}
}
func TestSaveConfigReturnsVipCoverFromSourceURL(t *testing.T) {
service, gateway, _ := newTestService(t)
gateway.groups[2001] = integration.RewardGroupDetail{
ID: integration.Int64Value(2001),
Name: "VIP礼包",
RewardConfigList: []integration.RewardGroupItem{
{
ID: integration.Int64Value(11),
Type: "PROPS",
DetailType: "NOBLE_VIP",
Name: "VIP 1",
Content: "2049402425177018400",
Quantity: integration.Int64Value(30),
Cover: "",
SourceURL: "https://example.com/vip-short-badge.png",
Remark: "30 天",
},
},
}
resp, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI",
"enabled": true,
"minAppVersion": "1.5.0",
"levelConfigs": [
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "2001", "rewardGroupName": "VIP礼包", "enabled": true}
]
}`))
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
if len(resp.LevelConfigs) != 1 || len(resp.LevelConfigs[0].RewardItems) != 1 {
t.Fatalf("reward items = %+v", resp.LevelConfigs)
}
if got := resp.LevelConfigs[0].RewardItems[0].Cover; got != "https://example.com/vip-short-badge.png" {
t.Fatalf("vip cover = %q", got)
}
if got := resp.LevelConfigs[0].RewardItems[0].Type; got != "NOBLE_VIP" {
t.Fatalf("vip type = %q", got)
}
}
func TestRewardItemsReturnConcretePropsType(t *testing.T) {
service, gateway, _ := newTestService(t)
gateway.groups[3001] = integration.RewardGroupDetail{
ID: integration.Int64Value(3001),
Name: "道具礼包",
RewardConfigList: []integration.RewardGroupItem{
{
ID: integration.Int64Value(21),
Type: "PROPS",
DetailType: "RIDE",
Name: "Luxury sports car",
Content: "2044620024357908482",
Quantity: integration.Int64Value(7),
Cover: "https://example.com/ride.png",
Remark: "Luxury sports car",
},
{
ID: integration.Int64Value(22),
Type: "PROPS",
Name: "VIP 3",
Content: "2050200000000000003",
Quantity: integration.Int64Value(3),
Cover: "https://example.com/vip3.png",
Remark: "VIP 3",
},
},
}
resp, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI",
"enabled": true,
"minAppVersion": "1.5.0",
"levelConfigs": [
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "3001", "rewardGroupName": "道具礼包", "enabled": true}
]
}`))
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
items := resp.LevelConfigs[0].RewardItems
if len(items) != 2 {
t.Fatalf("reward items = %+v", items)
}
if items[0].Type != "RIDE" {
t.Fatalf("ride type = %q", items[0].Type)
}
if items[1].Type != "NOBLE_VIP" {
t.Fatalf("vip fallback type = %q", items[1].Type)
}
}
func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
service, gateway, db := newTestService(t)
gateway.groups[1001] = testRewardGroup(1001, "首冲礼包")
gateway.profiles[123] = integration.UserProfile{
ID: integration.Int64Value(123),
Account: "100123",
UserNickname: "tester",
CountryCode: "SA",
}
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI",
"enabled": true,
"minAppVersion": "1.5.0",
"levelConfigs": [
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
]
}`))
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
unsupported, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:APPLE:1",
"sysOrigin": "LIKEI",
"userId": "123",
"amountCents": "999",
"payPlatform": "APPLE",
"paymentMethod": "APPLE",
"appVersion": "1.5.0",
"sourceOrderId": "1"
}`))
if err != nil {
t.Fatalf("unsupported ProcessRechargeEvent() error = %v", err)
}
if unsupported.Processed || unsupported.Reason != "unsupported_payment_method" {
t.Fatalf("unsupported response = %+v", unsupported)
}
lowAmount, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:GOOGLE:LOW",
"sysOrigin": "LIKEI",
"userId": "123",
"amountCents": "499",
"payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE",
"appVersion": "1.5.0",
"sourceOrderId": "LOW"
}`))
if err != nil {
t.Fatalf("low amount ProcessRechargeEvent() error = %v", err)
}
if lowAmount.Processed || lowAmount.Reason != "level_not_matched" {
t.Fatalf("low amount response = %+v", lowAmount)
}
granted, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:GOOGLE:OK",
"sysOrigin": "LIKEI",
"userId": "123",
"amountCents": "999",
"payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE",
"appVersion": "1.5.0",
"sourceOrderId": "OK",
"occurredAt": "2026-05-21T12:00:00Z"
}`))
if err != nil {
t.Fatalf("grant ProcessRechargeEvent() error = %v", err)
}
if !granted.Processed || granted.Status != statusSuccess || granted.Level != 1 {
t.Fatalf("grant response = %+v", granted)
}
if len(gateway.rewardRequests) != 1 {
t.Fatalf("reward request count = %d", len(gateway.rewardRequests))
}
if got := gateway.rewardRequests[0].Origin; got != firstRechargeRewardOrigin {
t.Fatalf("reward origin = %s", got)
}
if len(gateway.notices) != 1 || gateway.notices[0].NoticeType != firstRechargeRewardNoticeType {
t.Fatalf("notices = %+v", gateway.notices)
}
duplicate, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:GOOGLE:OK",
"sysOrigin": "LIKEI",
"userId": "123",
"amountCents": "999",
"payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE",
"appVersion": "1.5.0",
"sourceOrderId": "OK"
}`))
if err != nil {
t.Fatalf("duplicate ProcessRechargeEvent() error = %v", err)
}
if !duplicate.Processed || duplicate.Status != statusSuccess {
t.Fatalf("duplicate response = %+v", duplicate)
}
if len(gateway.rewardRequests) != 1 {
t.Fatalf("duplicate reward request count = %d", len(gateway.rewardRequests))
}
thirdPartyUnknownPlatform, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:THIRD_PARTY:CUSTOM",
"sysOrigin": "LIKEI",
"userId": "456",
"amountCents": "999",
"payPlatform": "CUSTOM_PAY",
"paymentMethod": "THIRD_PARTY",
"appVersion": "1.5.1",
"sourceOrderId": "CUSTOM"
}`))
if err != nil {
t.Fatalf("third party ProcessRechargeEvent() error = %v", err)
}
if !thirdPartyUnknownPlatform.Processed || thirdPartyUnknownPlatform.Status != statusSuccess {
t.Fatalf("third party response = %+v", thirdPartyUnknownPlatform)
}
if len(gateway.rewardRequests) != 2 {
t.Fatalf("third party reward request count = %d", len(gateway.rewardRequests))
}
googleDifferentPlatform, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:GOOGLE:CUSTOM",
"sysOrigin": "LIKEI",
"userId": "789",
"amountCents": "999",
"payPlatform": "CUSTOM_GOOGLE_GATEWAY",
"paymentMethod": "GOOGLE",
"appVersion": "1.5.1",
"sourceOrderId": "CUSTOM_GOOGLE"
}`))
if err != nil {
t.Fatalf("google ProcessRechargeEvent() error = %v", err)
}
if !googleDifferentPlatform.Processed || googleDifferentPlatform.Status != statusSuccess {
t.Fatalf("google response = %+v", googleDifferentPlatform)
}
if len(gateway.rewardRequests) != 3 {
t.Fatalf("google reward request count = %d", len(gateway.rewardRequests))
}
var count int64
if err := db.Model(&model.FirstRechargeRewardGrantRecord{}).Count(&count).Error; err != nil {
t.Fatalf("count grant records: %v", err)
}
if count != 3 {
t.Fatalf("grant record count = %d", count)
}
}
func TestProcessRechargeEventStartsCountingAtSupportedAppVersion(t *testing.T) {
service, gateway, db := newTestService(t)
gateway.groups[1001] = testRewardGroup(1001, "首冲礼包")
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI",
"enabled": true,
"minAppVersion": "1.5.0",
"levelConfigs": [
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
]
}`))
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
oldVersion, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:GOOGLE:OLD_VERSION",
"sysOrigin": "LIKEI",
"userId": "123",
"amountCents": "999",
"payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE",
"appVersion": "1.4.9",
"sourceOrderId": "OLD_VERSION"
}`))
if err != nil {
t.Fatalf("old version ProcessRechargeEvent() error = %v", err)
}
if oldVersion.Processed || oldVersion.Reason != "app_version_not_supported" {
t.Fatalf("old version response = %+v", oldVersion)
}
missingVersion, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:GOOGLE:MISSING_VERSION",
"sysOrigin": "LIKEI",
"userId": "123",
"amountCents": "999",
"payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE",
"sourceOrderId": "MISSING_VERSION"
}`))
if err != nil {
t.Fatalf("missing version ProcessRechargeEvent() error = %v", err)
}
if missingVersion.Processed || missingVersion.Reason != "app_version_not_supported" {
t.Fatalf("missing version response = %+v", missingVersion)
}
var count int64
if err := db.Model(&model.FirstRechargeRewardGrantRecord{}).Count(&count).Error; err != nil {
t.Fatalf("count grant records after unsupported versions: %v", err)
}
if count != 0 {
t.Fatalf("grant record count after unsupported versions = %d", count)
}
supported, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:GOOGLE:SUPPORTED_VERSION",
"sysOrigin": "LIKEI",
"userId": "123",
"amountCents": "999",
"payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE",
"appVersion": "1.5.0",
"sourceOrderId": "SUPPORTED_VERSION"
}`))
if err != nil {
t.Fatalf("supported version ProcessRechargeEvent() error = %v", err)
}
if !supported.Processed || supported.Status != statusSuccess {
t.Fatalf("supported version response = %+v", supported)
}
if err := db.Model(&model.FirstRechargeRewardGrantRecord{}).Count(&count).Error; err != nil {
t.Fatalf("count grant records after supported version: %v", err)
}
if count != 1 {
t.Fatalf("grant record count after supported version = %d", count)
}
}
func TestFirstRechargeRewardAppVersionSupported(t *testing.T) {
tests := []struct {
name string
appVersion string
want bool
}{
{name: "missing", appVersion: "", want: false},
{name: "below", appVersion: "1.4.9", want: false},
{name: "equal", appVersion: "1.5.0", want: true},
{name: "short equal", appVersion: "1.5", want: true},
{name: "above", appVersion: "1.5.1", want: true},
{name: "suffix", appVersion: "1.5.0+100", want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isFirstRechargeRewardAppVersionSupported(tt.appVersion, "1.5.0"); 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) {
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.FirstRechargeRewardConfig{},
&model.FirstRechargeRewardLevel{},
&model.FirstRechargeRewardGrantRecord{},
); err != nil {
t.Fatalf("migrate sqlite: %v", err)
}
gateway := &fakeGateway{
groups: map[int64]integration.RewardGroupDetail{},
profiles: map[int64]integration.UserProfile{},
}
service := NewService(config.Config{
FirstRechargeReward: config.FirstRechargeRewardConfig{
DefaultSysOrigin: "LIKEI",
},
}, db, gateway)
return service, gateway, db
}
func mustDecodeSaveConfigRequest(t *testing.T, raw string) SaveConfigRequest {
t.Helper()
var req SaveConfigRequest
if err := json.Unmarshal([]byte(raw), &req); err != nil {
t.Fatalf("decode SaveConfigRequest: %v", err)
}
return req
}
func mustDecodeRechargeEvent(t *testing.T, raw string) RechargeEvent {
t.Helper()
var event RechargeEvent
if err := json.Unmarshal([]byte(raw), &event); err != nil {
t.Fatalf("decode RechargeEvent: %v", err)
}
return event
}
func testRewardGroup(id int64, name string) integration.RewardGroupDetail {
return integration.RewardGroupDetail{
ID: integration.Int64Value(id),
Name: name,
RewardConfigList: []integration.RewardGroupItem{
{
ID: integration.Int64Value(11),
Type: "PROPS",
DetailType: "RIDE",
Name: name + "奖励",
Content: "10001",
Quantity: integration.Int64Value(1),
Cover: "https://example.com/reward.png",
},
},
}
}
type fakeGateway struct {
groups map[int64]integration.RewardGroupDetail
profiles map[int64]integration.UserProfile
rewardRequests []integration.SendActivityRewardRequest
notices []integration.OfficialNoticeCustomizeRequest
cacheRemoved []int64
}
func (g *fakeGateway) GetRewardGroupDetail(_ context.Context, groupID int64) (integration.RewardGroupDetail, error) {
if group, ok := g.groups[groupID]; ok {
return group, nil
}
return testRewardGroup(groupID, "默认奖励组"), nil
}
func (g *fakeGateway) GetUserProfile(_ context.Context, userID int64) (integration.UserProfile, error) {
if profile, ok := g.profiles[userID]; ok {
return profile, nil
}
return integration.UserProfile{ID: integration.Int64Value(userID)}, nil
}
func (g *fakeGateway) SendActivityReward(_ context.Context, req integration.SendActivityRewardRequest) error {
g.rewardRequests = append(g.rewardRequests, req)
return nil
}
func (g *fakeGateway) SendOfficialNoticeCustomize(_ context.Context, req integration.OfficialNoticeCustomizeRequest) error {
g.notices = append(g.notices, req)
return nil
}
func (g *fakeGateway) RemoveUserProfileCacheAll(_ context.Context, userID int64) error {
g.cacheRemoved = append(g.cacheRemoved, userID)
return nil
}

View File

@ -0,0 +1,372 @@
package firstrechargereward
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
rmq "github.com/apache/rocketmq-clients/golang/v5"
"gorm.io/gorm"
)
const (
statusPending = "PENDING"
statusSuccess = "SUCCESS"
statusFailed = "FAILED"
paymentMethodGoogle = "GOOGLE"
paymentMethodThirdParty = "THIRD_PARTY"
firstRechargeRewardOrigin = "FIRST_CHARGE_REWARD"
firstRechargeRewardNoticeType = "FIRST_RECHARGE_REWARD_GRANTED"
firstRechargeRewardNoticeTitle = "First recharge reward"
firstRechargeRewardNoticeContent = "Reward granted"
)
type AppError = common.AppError
type AuthUser = common.AuthUser
var NewAppError = common.NewAppError
type dbHandle interface {
WithContext(ctx context.Context) *gorm.DB
}
type gateway interface {
GetRewardGroupDetail(ctx context.Context, groupID int64) (integration.RewardGroupDetail, error)
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error)
SendActivityReward(ctx context.Context, req integration.SendActivityRewardRequest) error
SendOfficialNoticeCustomize(ctx context.Context, req integration.OfficialNoticeCustomizeRequest) error
RemoveUserProfileCacheAll(ctx context.Context, userID int64) error
}
// Service owns first recharge reward config, MQ consumption, and reward dispatch.
type Service struct {
cfg config.Config
db dbHandle
java gateway
rocketConsumer rmq.PushConsumer
}
func NewService(cfg config.Config, db dbHandle, java gateway) *Service {
return &Service{cfg: cfg, db: db, java: java}
}
type LevelPayload struct {
ID int64 `json:"id,string"`
Level int `json:"level"`
RechargeAmount string `json:"rechargeAmount"`
RechargeAmountCents int64 `json:"rechargeAmountCents"`
GoogleProductID string `json:"googleProductId,omitempty"`
RewardGroupID int64 `json:"rewardGroupId,string"`
RewardGroupName string `json:"rewardGroupName,omitempty"`
RewardItems []RewardItem `json:"rewardItems"`
Enabled bool `json:"enabled"`
Reached bool `json:"reached"`
Current bool `json:"current"`
}
type LevelInput struct {
ID flexibleInt64 `json:"id"`
Level int `json:"level"`
RechargeAmount flexibleAmount `json:"rechargeAmount"`
RechargeAmountCents int64 `json:"rechargeAmountCents"`
GoogleProductID string `json:"googleProductId"`
RewardGroupID flexibleInt64 `json:"rewardGroupId"`
RewardGroupName string `json:"rewardGroupName"`
Enabled bool `json:"enabled"`
}
type ConfigResponse struct {
Configured bool `json:"configured"`
ID int64 `json:"id,string"`
SysOrigin string `json:"sysOrigin"`
Enabled bool `json:"enabled"`
MinAppVersion string `json:"minAppVersion,omitempty"`
UpdateTime string `json:"updateTime,omitempty"`
LevelConfigs []LevelPayload `json:"levelConfigs"`
}
type SaveConfigRequest struct {
ID flexibleInt64 `json:"id"`
SysOrigin string `json:"sysOrigin"`
Enabled bool `json:"enabled"`
MinAppVersion string `json:"minAppVersion"`
LevelConfigs []LevelInput `json:"levelConfigs"`
}
type HomeResponse struct {
Configured bool `json:"configured"`
Enabled bool `json:"enabled"`
ActivityStatus string `json:"activityStatus"`
UserID int64 `json:"userId,string"`
SysOrigin string `json:"sysOrigin"`
HasRewardRecord bool `json:"hasRewardRecord"`
RewardStatus string `json:"rewardStatus,omitempty"`
Rewarded bool `json:"rewarded"`
MinAppVersion string `json:"minAppVersion,omitempty"`
MatchedLevel int `json:"matchedLevel,omitempty"`
RechargeAmount string `json:"rechargeAmount,omitempty"`
RechargeAmountCents int64 `json:"rechargeAmountCents,omitempty"`
RewardGroupID int64 `json:"rewardGroupId,string,omitempty"`
RewardGroupName string `json:"rewardGroupName,omitempty"`
RewardItems []RewardItem `json:"rewardItems,omitempty"`
LevelConfigs []LevelPayload `json:"levelConfigs"`
}
type GrantRecordPageResponse struct {
Records []GrantRecordView `json:"records"`
Total int64 `json:"total"`
Current int `json:"current"`
Size int `json:"size"`
}
type GrantRecordView struct {
ID int64 `json:"id,string"`
EventID string `json:"eventId"`
UserID int64 `json:"userId,string"`
Account string `json:"account,omitempty"`
UserAvatar string `json:"userAvatar,omitempty"`
UserNickname string `json:"userNickname,omitempty"`
CountryCode string `json:"countryCode,omitempty"`
CountryName string `json:"countryName,omitempty"`
SysOrigin string `json:"sysOrigin"`
RechargeAmount string `json:"rechargeAmount"`
RechargeAmountCents int64 `json:"rechargeAmountCents"`
RechargeThreshold string `json:"rechargeThreshold"`
RechargeThresholdCents int64 `json:"rechargeThresholdCents"`
Level int `json:"level"`
PayPlatform string `json:"payPlatform"`
PaymentMethod string `json:"paymentMethod"`
GoogleProductID string `json:"googleProductId,omitempty"`
SourceOrderID string `json:"sourceOrderId,omitempty"`
RewardGroupID int64 `json:"rewardGroupId,string"`
RewardGroupName string `json:"rewardGroupName,omitempty"`
RewardItems []RewardItem `json:"rewardItems"`
Status string `json:"status"`
RewardGroupStatus string `json:"rewardGroupStatus"`
RetryCount int `json:"retryCount"`
LastError string `json:"lastError,omitempty"`
EventTime string `json:"eventTime,omitempty"`
SentAt string `json:"sentAt,omitempty"`
CreateTime string `json:"createTime,omitempty"`
UpdateTime string `json:"updateTime,omitempty"`
}
type RewardItem struct {
ID int64 `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
Quantity int64 `json:"quantity"`
Cover string `json:"cover"`
Remark string `json:"remark,omitempty"`
}
type RechargeEvent struct {
EventID string `json:"eventId"`
SysOrigin string `json:"sysOrigin"`
UserID flexibleInt64 `json:"userId"`
AmountCents flexibleInt64 `json:"amountCents"`
Amount flexibleString `json:"amount"`
AmountUSD flexibleString `json:"amountUsd"`
USDAmount flexibleString `json:"usdAmount"`
Currency string `json:"currency"`
PayPlatform string `json:"payPlatform"`
PaymentMethod string `json:"paymentMethod"`
GoogleProductID string `json:"googleProductId"`
ProductID flexibleString `json:"productId"`
AppVersion flexibleString `json:"appVersion"`
ClientVersion flexibleString `json:"clientVersion"`
Version flexibleString `json:"version"`
ReqVersion flexibleString `json:"reqVersion"`
SourceOrderID flexibleString `json:"sourceOrderId"`
OrderID flexibleString `json:"orderId"`
OccurredAt flexibleString `json:"occurredAt"`
Payload json.RawMessage `json:"payload,omitempty"`
}
type ProcessRechargeResponse struct {
Processed bool `json:"processed"`
Reason string `json:"reason,omitempty"`
RecordID int64 `json:"recordId,string,omitempty"`
Status string `json:"status,omitempty"`
Level int `json:"level,omitempty"`
}
type configBundle struct {
Config *configSnapshot
Levels []levelSnapshot
}
type configSnapshot struct {
ID int64
SysOrigin string
Enabled bool
MinAppVersion string
UpdateTime time.Time
}
type levelSnapshot struct {
ID int64
Level int
RechargeAmountCents int64
GoogleProductID string
RewardGroupID int64
RewardGroupName string
Enabled bool
}
type normalizedRechargeEvent struct {
EventID string
SysOrigin string
UserID int64
AmountCents int64
Currency string
PayPlatform string
PaymentMethod string
AppVersion string
GoogleProductID string
SourceOrderID string
OccurredAt time.Time
}
type flexibleInt64 int64
func (v *flexibleInt64) UnmarshalJSON(data []byte) error {
raw := strings.TrimSpace(string(data))
switch raw {
case "", "null", `""`:
*v = 0
return nil
}
if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) {
unquoted, err := strconv.Unquote(raw)
if err != nil {
return err
}
raw = strings.TrimSpace(unquoted)
if raw == "" {
*v = 0
return nil
}
}
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return fmt.Errorf("invalid int64 value: %w", err)
}
*v = flexibleInt64(parsed)
return nil
}
func (v flexibleInt64) Int64() int64 { return int64(v) }
func (v flexibleInt64) MarshalJSON() ([]byte, error) {
return json.Marshal(strconv.FormatInt(int64(v), 10))
}
type flexibleString string
func (v *flexibleString) UnmarshalJSON(data []byte) error {
raw := strings.TrimSpace(string(data))
if raw == "" || raw == "null" {
*v = ""
return nil
}
if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) {
unquoted, err := strconv.Unquote(raw)
if err != nil {
return err
}
*v = flexibleString(strings.TrimSpace(unquoted))
return nil
}
*v = flexibleString(raw)
return nil
}
func (v flexibleString) String() string {
return strings.TrimSpace(string(v))
}
type flexibleAmount int64
func (v *flexibleAmount) UnmarshalJSON(data []byte) error {
raw := strings.TrimSpace(string(data))
switch raw {
case "", "null", `""`:
*v = 0
return nil
}
if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) {
unquoted, err := strconv.Unquote(raw)
if err != nil {
return err
}
raw = strings.TrimSpace(unquoted)
}
cents, err := parseAmountCents(raw)
if err != nil {
return err
}
*v = flexibleAmount(cents)
return nil
}
func (v flexibleAmount) Cents() int64 {
return int64(v)
}
func ParseInt64(raw string) int64 {
value, _ := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
return value
}
func NormalizePage(cursor int, limit int) (int, int) {
if cursor <= 0 {
cursor = 1
}
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
return cursor, limit
}
func (s *Service) normalizeSysOrigin(sysOrigin string) string {
sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin))
if sysOrigin != "" {
return sysOrigin
}
if fallback := strings.ToUpper(strings.TrimSpace(s.cfg.FirstRechargeReward.DefaultSysOrigin)); fallback != "" {
return fallback
}
if fallback := strings.ToUpper(strings.TrimSpace(s.cfg.RechargeReward.DefaultSysOrigin)); fallback != "" {
return fallback
}
if fallback := strings.ToUpper(strings.TrimSpace(s.cfg.WeekStar.DefaultSysOrigin)); fallback != "" {
return fallback
}
return "LIKEI"
}
func requireUser(user AuthUser) (string, error) {
if user.UserID <= 0 {
return "", NewAppError(http.StatusUnauthorized, "invalid_user", "user is required")
}
sysOrigin := strings.ToUpper(strings.TrimSpace(user.SysOrigin))
if sysOrigin == "" {
return "", NewAppError(http.StatusUnauthorized, "invalid_sys_origin", "authenticated sysOrigin is required")
}
return sysOrigin, nil
}

View File

@ -0,0 +1,79 @@
package firstrechargereward
import (
"strconv"
"strings"
)
const defaultFirstRechargeRewardMinAppVersion = "1.5.0"
func isFirstRechargeRewardAppVersionSupported(appVersion string, minAppVersion string) bool {
appVersion = strings.TrimSpace(appVersion)
minAppVersion = strings.TrimSpace(minAppVersion)
if minAppVersion == "" {
minAppVersion = defaultFirstRechargeRewardMinAppVersion
}
if appVersion == "" {
return false
}
return compareAppVersion(appVersion, minAppVersion) >= 0
}
func compareAppVersion(left, right string) int {
leftParts := parseAppVersion(left)
rightParts := parseAppVersion(right)
maxLen := len(leftParts)
if len(rightParts) > maxLen {
maxLen = len(rightParts)
}
for i := 0; i < maxLen; i++ {
var lv, rv int
if i < len(leftParts) {
lv = leftParts[i]
}
if i < len(rightParts) {
rv = rightParts[i]
}
if lv > rv {
return 1
}
if lv < rv {
return -1
}
}
return 0
}
func parseAppVersion(version string) []int {
version = strings.TrimSpace(version)
if version == "" {
return []int{0}
}
parts := strings.Split(version, ".")
result := make([]int, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
result = append(result, 0)
continue
}
digits := strings.Builder{}
for _, r := range part {
if r < '0' || r > '9' {
break
}
digits.WriteRune(r)
}
if digits.Len() == 0 {
result = append(result, 0)
continue
}
value, err := strconv.Atoi(digits.String())
if err != nil {
result = append(result, 0)
continue
}
result = append(result, value)
}
return result
}

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 {
if s != nil && s.repo.DB != nil {
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)
return s.resolveCallbackAppKeyForVendor(ctx, "LINGXIAN", gameID, token)
}
func (s *GameOpenService) resolveLingxianAppKeyByGame(ctx context.Context, gameID string) string {

View File

@ -5,6 +5,7 @@ import (
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"context"
"errors"
"testing"
"time"
@ -20,6 +21,7 @@ type stubGateway struct {
accountMap map[string]integration.UserProfile
balance int64
exists bool
changeErr error
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 {
s.cmd = cmd
return nil
return s.changeErr
}
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 {
t.Helper()
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(
&model.LingxianProviderConfig{},
&model.HotgameProviderConfig{},
&model.SysGameListVendorExt{},
&model.GameOpenCallbackLog{},
); err != nil {

View File

@ -14,6 +14,10 @@ type Registry struct {
ordered []Provider
}
var emptyGameListUserIDs = map[int64]struct{}{
2061394478798794754: {},
}
// NewRegistry 创建厂商注册表。
func NewRegistry(providers ...Provider) *Registry {
r := &Registry{
@ -75,6 +79,9 @@ func (r *Registry) List() ProviderListResponse {
// ListShortcutGames 返回聚合后的快捷游戏列表。
func (r *Registry) ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) {
if IsEmptyGameListUser(user.UserID) {
return []RoomGameListItem{}, nil
}
if r == nil || len(r.ordered) == 0 {
return []RoomGameListItem{}, nil
}
@ -96,6 +103,9 @@ func (r *Registry) ListShortcutGames(ctx context.Context, user AuthUser, roomID
// ListRoomGames 返回聚合后的房间游戏列表。
func (r *Registry) ListRoomGames(ctx context.Context, user AuthUser, roomID, category string) (*RoomGameListResponse, error) {
if IsEmptyGameListUser(user.UserID) {
return &RoomGameListResponse{Items: []RoomGameListItem{}}, nil
}
if r == nil || len(r.ordered) == 0 {
return &RoomGameListResponse{Items: []RoomGameListItem{}}, nil
}
@ -114,6 +124,11 @@ func (r *Registry) ListRoomGames(ctx context.Context, user AuthUser, roomID, cat
return &RoomGameListResponse{Items: items}, nil
}
func IsEmptyGameListUser(userID int64) bool {
_, ok := emptyGameListUserIDs[userID]
return ok
}
// ResolveByLaunchRequest 根据启动请求解析内部对应的厂商。
func (r *Registry) ResolveByLaunchRequest(ctx context.Context, user AuthUser, req LaunchRequest) (Provider, error) {
if r == nil {
@ -236,6 +251,8 @@ func normalizeProviderAlias(key string) string {
switch normalizeKey(key) {
case "LEADER":
return "LINGXIAN"
case "HOTGAME", "HOT_GAME", "REYOU", "LALU":
return "HOTGAME"
default:
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) {
registry := NewRegistry(
&stubProvider{key: "BAISHUN", name: "百顺"},
@ -125,6 +139,47 @@ func TestRegistryListRoomGamesAggregatesAndSorts(t *testing.T) {
}
}
func TestRegistryListRoomGamesReturnsEmptyForHardcodedTestUser(t *testing.T) {
registry := NewRegistry(&stubProvider{
key: "BAISHUN",
name: "百顺",
room: []RoomGameListItem{{GameID: "a", Sort: 30}},
})
resp, err := registry.ListRoomGames(
context.Background(),
AuthUser{UserID: 2061394478798794754},
"room-1",
"",
)
if err != nil {
t.Fatalf("ListRoomGames() error = %v", err)
}
if len(resp.Items) != 0 {
t.Fatalf("ListRoomGames() items = %d, want 0", len(resp.Items))
}
}
func TestRegistryListShortcutGamesReturnsEmptyForHardcodedTestUser(t *testing.T) {
registry := NewRegistry(&stubProvider{
key: "BAISHUN",
name: "百顺",
shortcut: []RoomGameListItem{{GameID: "a", Sort: 30}},
})
items, err := registry.ListShortcutGames(
context.Background(),
AuthUser{UserID: 2061394478798794754},
"room-1",
)
if err != nil {
t.Fatalf("ListShortcutGames() error = %v", err)
}
if len(items) != 0 {
t.Fatalf("ListShortcutGames() items = %d, want 0", len(items))
}
}
func TestRegistryLaunchGameResolvesProviderByGameID(t *testing.T) {
baishunProvider := &stubProvider{
key: "BAISHUN",

View File

@ -0,0 +1,251 @@
package gameprovider
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"chatapp3-golang/internal/integration"
"github.com/redis/go-redis/v9"
)
const (
gameListTargetSysOrigin = "LIKEI"
gameListTurkeyCountryCode = "TR"
gameListTurkeyBlockLevel = 2
ipCountryCachePrefix = "GAME_LIST:IP_COUNTRY:"
ipCountryCacheTTL = 7 * 24 * time.Hour
)
// VisibilityGateway 聚合游戏列表门禁需要的 Java 用户区域和等级能力。
type VisibilityGateway interface {
GetUserRegion(ctx context.Context, userID int64, authorization string) (integration.UserRegion, error)
GetUserLevel(ctx context.Context, sysOrigin string, userID int64) (integration.UserLevel, error)
ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error)
}
// IPCountryResolver 把请求 IP 解析成国家二字码。
type IPCountryResolver interface {
CountryCode(ctx context.Context, ip string) (string, error)
}
// VisibilityResult 返回门禁结果,并把已经查到的区域带回路由继续参与 SQL 过滤。
type VisibilityResult struct {
Allow bool
RegionID string
RegionCode string
}
// VisibilityPolicy 判断当前用户是否可以看到游戏列表。
type VisibilityPolicy struct {
java VisibilityGateway
ipResolver IPCountryResolver
targetCountry string
targetSys string
blockLevel int
}
// NewVisibilityPolicy 创建 LIKEI 游戏列表门禁:土耳其区域或土耳其 IP 的用户财富等级必须大于 2其他用户直接展示。
func NewVisibilityPolicy(java VisibilityGateway, ipResolver IPCountryResolver) *VisibilityPolicy {
return &VisibilityPolicy{
java: java,
ipResolver: ipResolver,
targetCountry: gameListTurkeyCountryCode,
targetSys: gameListTargetSysOrigin,
blockLevel: gameListTurkeyBlockLevel,
}
}
// Evaluate 只拦截土耳其区域或土耳其 IP 的低财富等级用户;非土耳其区域且非土耳其 IP 的用户直接展示游戏列表。
func (p *VisibilityPolicy) Evaluate(ctx context.Context, user AuthUser, clientIP string) (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
}
result := VisibilityResult{
RegionID: strings.TrimSpace(region.RegionID),
RegionCode: strings.TrimSpace(region.RegionCode),
}
// 区域配置是运营可变数据,所以先从 Java 区域配置解析 TR 对应的 regionCode再判断用户是否属于土耳其区域。
targetRegionCode, err := p.java.ResolveRegionCodeByCountryCode(ctx, user.SysOrigin, p.targetCountry)
if err != nil {
return result, err
}
turkeyRegion := matchesTargetRegion(result.RegionCode, targetRegionCode, p.targetCountry)
turkeyIP := false
if p.ipResolver != nil {
countryCode, err := p.ipResolver.CountryCode(ctx, clientIP)
if err != nil {
return result, err
}
turkeyIP = strings.EqualFold(strings.TrimSpace(countryCode), p.targetCountry)
}
// 用户区域和当前 IP 都不是土耳其时,直接放行;只有命中土耳其门禁时才需要读取财富等级。
if !turkeyRegion && !turkeyIP {
result.Allow = true
return result, nil
}
level, err := p.java.GetUserLevel(ctx, user.SysOrigin, user.UserID)
if err != nil {
return result, err
}
result.Allow = level.WealthLevel > p.blockLevel
return result, nil
}
func matchesTargetRegion(userRegionCode, targetRegionCode, targetCountry string) bool {
userRegionCode = strings.TrimSpace(userRegionCode)
targetRegionCode = strings.TrimSpace(targetRegionCode)
if targetRegionCode != "" {
return strings.EqualFold(userRegionCode, targetRegionCode)
}
return strings.EqualFold(userRegionCode, strings.TrimSpace(targetCountry))
}
// CachedIPCountryResolver 使用 Redis 缓存 IP 国家码,未命中时按顺序调用几个公开 geoip provider。
type CachedIPCountryResolver struct {
cache redis.Cmdable
httpClient *http.Client
}
// NewCachedIPCountryResolver 创建可复用的 IP 国家解析器。
func NewCachedIPCountryResolver(cache redis.Cmdable, timeout time.Duration) *CachedIPCountryResolver {
if timeout <= 0 {
timeout = 5 * time.Second
}
return &CachedIPCountryResolver{
cache: cache,
httpClient: &http.Client{
Timeout: timeout,
},
}
}
// CountryCode 返回 IP 对应国家二字码;解析不到时返回空字符串,调用方负责按不可见处理。
func (r *CachedIPCountryResolver) CountryCode(ctx context.Context, ip string) (string, error) {
ip = strings.TrimSpace(ip)
if ip == "" || ip == "-" {
return "", nil
}
if cached := r.cachedCountryCode(ctx, ip); cached != "" {
return cached, nil
}
for _, provider := range ipGeoProviders {
countryCode := r.requestCountryCode(ctx, provider, ip)
if countryCode == "" {
continue
}
r.cacheCountryCode(ctx, ip, countryCode)
return countryCode, nil
}
return "", nil
}
func (r *CachedIPCountryResolver) cachedCountryCode(ctx context.Context, ip string) string {
if r == nil || r.cache == nil {
return ""
}
value, err := r.cache.Get(ctx, ipCountryCachePrefix+ip).Result()
if err != nil && !errors.Is(err, redis.Nil) {
return ""
}
return strings.ToUpper(strings.TrimSpace(value))
}
func (r *CachedIPCountryResolver) cacheCountryCode(ctx context.Context, ip string, countryCode string) {
if r == nil || r.cache == nil || strings.TrimSpace(countryCode) == "" {
return
}
_ = r.cache.Set(ctx, ipCountryCachePrefix+ip, strings.ToUpper(strings.TrimSpace(countryCode)), ipCountryCacheTTL).Err()
}
type ipGeoProvider struct {
name string
urlFormat string
}
var ipGeoProviders = []ipGeoProvider{
{name: "ip.sb", urlFormat: "https://api.ip.sb/geoip/%s"},
{name: "freeipapi", urlFormat: "https://freeipapi.com/api/json/%s"},
{name: "ipwhois", urlFormat: "https://ipwhois.app/json/%s?format=json"},
{name: "ip.nc.gy", urlFormat: "https://ip.nc.gy/json?ip=%s"},
}
func (r *CachedIPCountryResolver) requestCountryCode(ctx context.Context, provider ipGeoProvider, ip string) string {
if r == nil || r.httpClient == nil {
return ""
}
endpoint := fmt.Sprintf(provider.urlFormat, url.QueryEscape(ip))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return ""
}
resp, err := r.httpClient.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return ""
}
body, err := io.ReadAll(resp.Body)
if err != nil || len(body) == 0 {
return ""
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return ""
}
return countryCodeFromProvider(provider.name, payload)
}
func countryCodeFromProvider(provider string, payload map[string]any) string {
switch provider {
case "ip.sb":
return upperString(payload["country_code"])
case "freeipapi":
return upperString(payload["countryCode"])
case "ipwhois":
if success, ok := payload["success"].(bool); ok && !success {
return ""
}
return upperString(payload["country_code"])
case "ip.nc.gy":
if code := countryObjectCode(payload["country"]); code != "" {
return code
}
return countryObjectCode(payload["registered_country"])
default:
return ""
}
}
func countryObjectCode(value any) string {
object, ok := value.(map[string]any)
if !ok {
return ""
}
return upperString(object["iso_code"])
}
func upperString(value any) string {
text, _ := value.(string)
return strings.ToUpper(strings.TrimSpace(text))
}

View File

@ -0,0 +1,139 @@
package gameprovider
import (
"context"
"testing"
"chatapp3-golang/internal/integration"
)
type stubVisibilityGateway struct {
region integration.UserRegion
level integration.UserLevel
targetRegionCode string
}
func (s stubVisibilityGateway) GetUserRegion(context.Context, int64, string) (integration.UserRegion, error) {
return s.region, nil
}
func (s stubVisibilityGateway) GetUserLevel(context.Context, string, int64) (integration.UserLevel, error) {
return s.level, nil
}
func (s stubVisibilityGateway) ResolveRegionCodeByCountryCode(context.Context, string, string) (string, error) {
return s.targetRegionCode, nil
}
type stubIPCountryResolver struct {
countryCode string
}
func (s stubIPCountryResolver) CountryCode(context.Context, string) (string, error) {
return s.countryCode, nil
}
const account1007500UserID int64 = 2054163533368717314
func TestVisibilityPolicyAppliesTurkeyRegionOrIPWealthGate(t *testing.T) {
tests := []struct {
name string
region integration.UserRegion
ipCountry string
wealth int
wantAllow bool
wantRegion integration.UserRegion
}{
{
name: "turkey region level 2 is blocked",
region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
ipCountry: "ZA",
wealth: 2,
wantAllow: false,
wantRegion: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
},
{
name: "turkey region level 3 is visible even when ip is not turkey",
region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
ipCountry: "ZA",
wealth: 3,
wantAllow: true,
wantRegion: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
},
{
name: "turkey ip level 2 is blocked",
region: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
ipCountry: "TR",
wealth: 2,
wantAllow: false,
wantRegion: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
},
{
name: "turkey ip level 3 is visible",
region: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
ipCountry: "TR",
wealth: 3,
wantAllow: true,
wantRegion: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
},
{
name: "non turkey region and non turkey ip are visible even at level 1",
region: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
ipCountry: "ZA",
wealth: 1,
wantAllow: true,
wantRegion: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
},
{
name: "account 1007500 current region and ip are visible",
region: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
ipCountry: "ZA",
wealth: 35,
wantAllow: true,
wantRegion: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
policy := NewVisibilityPolicy(
stubVisibilityGateway{
region: tt.region,
level: integration.UserLevel{WealthLevel: tt.wealth},
targetRegionCode: "土耳其",
},
stubIPCountryResolver{countryCode: tt.ipCountry},
)
result, err := policy.Evaluate(context.Background(), AuthUser{UserID: account1007500UserID, SysOrigin: "LIKEI"}, "1.1.1.1")
if err != nil {
t.Fatalf("Evaluate() error = %v", err)
}
if result.Allow != tt.wantAllow {
t.Fatalf("Evaluate() allow = %v, want %v", result.Allow, tt.wantAllow)
}
if result.RegionID != tt.wantRegion.RegionID || result.RegionCode != tt.wantRegion.RegionCode {
t.Fatalf("Evaluate() region = %#v, want %#v", result, tt.wantRegion)
}
})
}
}
func TestVisibilityPolicySkipsOtherSysOrigin(t *testing.T) {
policy := NewVisibilityPolicy(
stubVisibilityGateway{
region: integration.UserRegion{RegionID: "2046067036517363714", RegionCode: "AR"},
level: integration.UserLevel{WealthLevel: 0},
targetRegionCode: "TR",
},
stubIPCountryResolver{countryCode: "SA"},
)
result, err := policy.Evaluate(context.Background(), AuthUser{UserID: 1001, SysOrigin: "ASWAT"}, "1.1.1.1")
if err != nil {
t.Fatalf("Evaluate() error = %v", err)
}
if !result.Allow {
t.Fatalf("Evaluate() allow = false, want true")
}
}

View File

@ -0,0 +1,676 @@
package highwin
import (
"context"
"fmt"
"math/rand"
"net/http"
"strconv"
"strings"
"sync"
"time"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/service/regionimgroup"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
const (
SimulationKindLuckyGift = "LUCKY_GIFT"
SimulationKindGameWin = "GAME_WIN"
adminTimeLayout = "2006-01-02 15:04:05"
)
type BroadcastSimulationRequest struct {
SysOrigin string `json:"sysOrigin"`
RegionCode string `json:"regionCode"`
Kind string `json:"kind"`
SenderUserID string `json:"senderUserId"`
RoomID string `json:"roomId"`
Multiple string `json:"multiple"`
WinAmount string `json:"winAmount"`
AcceptUserID string `json:"acceptUserId"`
GiftID string `json:"giftId"`
GiftCandy string `json:"giftCandy"`
GiftQuantity string `json:"giftQuantity"`
GameID string `json:"gameId"`
GameRoundID string `json:"gameRoundId"`
GameURL string `json:"gameUrl"`
BetAmount string `json:"betAmount"`
ProviderOrder string `json:"providerOrderId"`
}
type BroadcastSimulationResponse struct {
SysOrigin string `json:"sysOrigin"`
RegionCode string `json:"regionCode"`
GroupID string `json:"groupId"`
Type string `json:"type"`
Data map[string]any `json:"data"`
}
type BroadcastSimulationWorker struct {
db *gorm.DB
redis *redis.Client
broadcaster RegionBroadcaster
mu sync.Mutex
cancel context.CancelFunc
config ContinuousSimulationRequest
status ContinuousSimulationStatus
}
type ContinuousSimulationRequest struct {
SysOrigin string `json:"sysOrigin"`
RegionCodes []string `json:"regionCodes"`
EnableLuckyGift bool `json:"enableLuckyGift"`
EnableGameWin bool `json:"enableGameWin"`
MinDelaySeconds int `json:"minDelaySeconds"`
MaxDelaySeconds int `json:"maxDelaySeconds"`
BetAmounts []int64 `json:"betAmounts"`
Multiples []float64 `json:"multiples"`
GameWinAmounts []int64 `json:"gameWinAmounts"`
LuckyGiftWinAmounts []int64 `json:"luckyGiftWinAmounts"`
GiftCandyOptions []int64 `json:"giftCandyOptions"`
GiftQuantityOptions []int64 `json:"giftQuantityOptions"`
}
type ContinuousSimulationStatus struct {
Running bool `json:"running"`
SysOrigin string `json:"sysOrigin"`
RegionCodes []string `json:"regionCodes"`
StartedAt string `json:"startedAt,omitempty"`
LastRunAt string `json:"lastRunAt,omitempty"`
LastError string `json:"lastError,omitempty"`
NextDelaySec int `json:"nextDelaySec"`
LuckyGiftCount int64 `json:"luckyGiftCount"`
GameWinCount int64 `json:"gameWinCount"`
TotalCount int64 `json:"totalCount"`
Config any `json:"config,omitempty"`
UpdateTime time.Time `json:"-"`
}
type continuousCandidateUser struct {
ID int64
}
type continuousCandidateGame struct {
GameID string
Cover string
}
type continuousCandidateRoom struct {
RoomID int64
}
func NewBroadcastSimulationWorker(db *gorm.DB, redisClient *redis.Client, broadcaster RegionBroadcaster) *BroadcastSimulationWorker {
return &BroadcastSimulationWorker{
db: db,
redis: redisClient,
broadcaster: broadcaster,
}
}
func (w *BroadcastSimulationWorker) Start(ctx context.Context, req ContinuousSimulationRequest) (*ContinuousSimulationStatus, error) {
req = normalizeContinuousRequest(req)
if err := validateContinuousRequest(req); err != nil {
return nil, err
}
if w == nil || w.db == nil || w.redis == nil || w.broadcaster == nil {
return nil, common.NewAppError(http.StatusInternalServerError, "simulation_worker_not_configured", "simulation worker is not configured")
}
w.mu.Lock()
if w.cancel != nil {
w.cancel()
}
workerCtx, cancel := context.WithCancel(context.Background())
w.cancel = cancel
w.config = req
w.status = ContinuousSimulationStatus{
Running: true,
SysOrigin: req.SysOrigin,
RegionCodes: append([]string(nil), req.RegionCodes...),
StartedAt: time.Now().Format(adminTimeLayout),
Config: req,
}
status := w.status
w.mu.Unlock()
go w.run(workerCtx, req)
return &status, nil
}
func (w *BroadcastSimulationWorker) Stop() *ContinuousSimulationStatus {
if w == nil {
return &ContinuousSimulationStatus{}
}
w.mu.Lock()
defer w.mu.Unlock()
if w.cancel != nil {
w.cancel()
w.cancel = nil
}
w.status.Running = false
w.status.NextDelaySec = 0
w.status.UpdateTime = time.Now()
status := w.status
status.Config = w.config
return &status
}
func (w *BroadcastSimulationWorker) Status() *ContinuousSimulationStatus {
if w == nil {
return &ContinuousSimulationStatus{}
}
w.mu.Lock()
defer w.mu.Unlock()
status := w.status
status.Config = w.config
return &status
}
func (w *BroadcastSimulationWorker) run(ctx context.Context, req ContinuousSimulationRequest) {
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
for {
delay := randomDelaySeconds(rng, req.MinDelaySeconds, req.MaxDelaySeconds)
w.updateStatus(func(status *ContinuousSimulationStatus) {
status.NextDelaySec = delay
})
timer := time.NewTimer(time.Duration(delay) * time.Second)
select {
case <-ctx.Done():
timer.Stop()
w.updateStatus(func(status *ContinuousSimulationStatus) {
status.Running = false
status.NextDelaySec = 0
})
return
case <-timer.C:
}
w.runOnce(ctx, req, rng)
}
}
func (w *BroadcastSimulationWorker) runOnce(ctx context.Context, req ContinuousSimulationRequest, rng *rand.Rand) {
regionCode := req.RegionCodes[rng.Intn(len(req.RegionCodes))]
userID, err := w.randomOfflineUser(ctx, req.SysOrigin, rng)
if err != nil {
w.setLastError(err)
return
}
roomID, err := w.randomActiveRoom(ctx, req.SysOrigin, rng)
if err != nil {
w.setLastError(err)
return
}
if req.EnableLuckyGift {
winAmount := pickInt64(rng, req.LuckyGiftWinAmounts)
if winAmount <= 0 {
winAmount = pickInt64(rng, req.GameWinAmounts)
}
err := w.sendContinuous(ctx, BroadcastSimulationRequest{
SysOrigin: req.SysOrigin,
RegionCode: regionCode,
Kind: SimulationKindLuckyGift,
SenderUserID: strconv.FormatInt(userID, 10),
RoomID: strconv.FormatInt(roomID, 10),
Multiple: formatFloat(pickFloat64(rng, req.Multiples)),
WinAmount: strconv.FormatInt(winAmount, 10),
GiftCandy: strconv.FormatInt(pickInt64(rng, req.GiftCandyOptions), 10),
GiftQuantity: strconv.FormatInt(pickInt64(rng, req.GiftQuantityOptions), 10),
ProviderOrder: fmt.Sprintf("worker-lucky-%d", time.Now().UnixNano()),
})
if err != nil {
w.setLastError(err)
} else {
w.incrementCount(true)
}
}
if req.EnableGameWin {
game, err := w.randomGame(ctx, req.SysOrigin)
if err != nil {
w.setLastError(err)
return
}
err = w.sendContinuous(ctx, BroadcastSimulationRequest{
SysOrigin: req.SysOrigin,
RegionCode: regionCode,
Kind: SimulationKindGameWin,
SenderUserID: strconv.FormatInt(userID, 10),
RoomID: strconv.FormatInt(roomID, 10),
Multiple: formatFloat(pickFloat64(rng, req.Multiples)),
WinAmount: strconv.FormatInt(pickInt64(rng, req.GameWinAmounts), 10),
GameID: game.GameID,
GameURL: game.Cover,
GameRoundID: fmt.Sprintf("worker-game-%d", time.Now().UnixNano()),
BetAmount: strconv.FormatInt(pickInt64(rng, req.BetAmounts), 10),
})
if err != nil {
w.setLastError(err)
} else {
w.incrementCount(false)
}
}
}
func SendSimulationBroadcast(
ctx context.Context,
broadcaster RegionBroadcaster,
req BroadcastSimulationRequest,
) (*BroadcastSimulationResponse, error) {
if broadcaster == nil {
return nil, common.NewAppError(http.StatusInternalServerError, "region_broadcaster_missing", "region broadcaster is missing")
}
messageType, err := simulationMessageType(req.Kind)
if err != nil {
return nil, err
}
userID, err := parseRequiredInt64(req.SenderUserID, "senderUserId")
if err != nil {
return nil, err
}
roomID := strings.TrimSpace(req.RoomID)
if roomID == "" {
return nil, common.NewAppError(http.StatusBadRequest, "missing_room_id", "roomId is required")
}
amount, err := parseRequiredInt64(req.WinAmount, "winAmount")
if err != nil {
return nil, err
}
multiple, err := parseRequiredFloat64(req.Multiple, "multiple")
if err != nil {
return nil, err
}
if !ShouldBroadcast(multiple, amount) {
return nil, common.NewAppError(http.StatusBadRequest, "broadcast_threshold_not_met", "multiple must be >= 10 and winAmount must be > 0")
}
data := simulationData(messageType, req, amount)
event := BroadcastEvent{
SysOrigin: req.SysOrigin,
RegionCode: req.RegionCode,
Type: messageType,
UserID: userID,
RoomID: roomID,
Multiple: multiple,
Amount: amount,
Data: data,
}
if err := SendRegionBroadcast(ctx, broadcaster, event); err != nil {
return nil, err
}
previewData := cloneData(data)
setRequired(previewData, "userId", strconv.FormatInt(userID, 10))
setRequired(previewData, "sendUserId", strconv.FormatInt(userID, 10))
setRequired(previewData, "senderUserId", strconv.FormatInt(userID, 10))
setRequired(previewData, "roomId", roomID)
setRequired(previewData, "multiple", normalizeMultiple(multiple))
setRequired(previewData, "winMultiple", normalizeMultiple(multiple))
setRequired(previewData, "winAmount", amount)
setRequired(previewData, "awardAmount", amount)
setRequired(previewData, "msg", highWinMessage(previewData, messageType, strconv.FormatInt(userID, 10), multiple, amount))
return &BroadcastSimulationResponse{
SysOrigin: strings.ToUpper(strings.TrimSpace(req.SysOrigin)),
RegionCode: strings.TrimSpace(req.RegionCode),
Type: messageType,
Data: previewData,
}, nil
}
func (w *BroadcastSimulationWorker) sendContinuous(ctx context.Context, req BroadcastSimulationRequest) error {
_, err := SendSimulationBroadcast(ctx, w.broadcaster, req)
return err
}
func (w *BroadcastSimulationWorker) randomOfflineUser(ctx context.Context, sysOrigin string, rng *rand.Rand) (int64, error) {
onlineIDs := w.onlineUserIDs(ctx, sysOrigin)
query := w.db.WithContext(ctx).
Table("user_base_info AS user").
Select("user.id AS id").
Where("user.origin_sys = ?", sysOrigin).
Where("user.id > 0")
if len(onlineIDs) > 0 && len(onlineIDs) <= 1000 {
query = query.Where("user.id NOT IN ?", onlineIDs)
}
var users []continuousCandidateUser
if err := query.Order("RAND()").Limit(50).Scan(&users).Error; err != nil {
return 0, err
}
filtered := make([]continuousCandidateUser, 0, len(users))
onlineSet := make(map[int64]struct{}, len(onlineIDs))
for _, id := range onlineIDs {
onlineSet[id] = struct{}{}
}
for _, user := range users {
if user.ID <= 0 {
continue
}
if _, online := onlineSet[user.ID]; online {
continue
}
filtered = append(filtered, user)
}
if len(filtered) == 0 {
return 0, common.NewAppError(http.StatusNotFound, "simulation_offline_user_not_found", "offline user not found")
}
return filtered[rng.Intn(len(filtered))].ID, nil
}
func (w *BroadcastSimulationWorker) randomActiveRoom(ctx context.Context, sysOrigin string, rng *rand.Rand) (int64, error) {
rooms, err := w.activeRooms(ctx, sysOrigin, 10)
if err != nil {
return 0, err
}
if len(rooms) == 0 {
return 0, nil
}
return rooms[rng.Intn(len(rooms))].RoomID, nil
}
func (w *BroadcastSimulationWorker) randomGame(ctx context.Context, sysOrigin string) (continuousCandidateGame, error) {
var game continuousCandidateGame
err := w.db.WithContext(ctx).
Table("sys_game_list_config").
Select("game_id AS game_id, cover AS cover").
Where("sys_origin = ? AND cover <> ''", sysOrigin).
Order("RAND()").
Limit(1).
Scan(&game).Error
if err != nil {
return continuousCandidateGame{}, err
}
if strings.TrimSpace(game.GameID) == "" || strings.TrimSpace(game.Cover) == "" {
return continuousCandidateGame{}, common.NewAppError(http.StatusNotFound, "simulation_game_not_found", "game cover not found")
}
return game, nil
}
func (w *BroadcastSimulationWorker) activeRooms(ctx context.Context, sysOrigin string, minUsers int64) ([]continuousCandidateRoom, error) {
var cursor uint64
var rooms []continuousCandidateRoom
prefix := fmt.Sprintf("voice_room:online:%s:", strings.ToUpper(strings.TrimSpace(sysOrigin)))
for {
keys, next, err := w.redis.Scan(ctx, cursor, prefix+"*", 100).Result()
if err != nil {
return nil, err
}
for _, key := range keys {
count, err := w.redis.SCard(ctx, key).Result()
if err != nil || count <= minUsers {
continue
}
roomID, err := strconv.ParseInt(strings.TrimPrefix(key, prefix), 10, 64)
if err != nil || roomID <= 0 {
continue
}
rooms = append(rooms, continuousCandidateRoom{RoomID: roomID})
}
cursor = next
if cursor == 0 {
break
}
}
return rooms, nil
}
func (w *BroadcastSimulationWorker) onlineUserIDs(ctx context.Context, sysOrigin string) []int64 {
var cursor uint64
prefix := fmt.Sprintf("voice_room:online:%s:", strings.ToUpper(strings.TrimSpace(sysOrigin)))
seen := map[int64]struct{}{}
for {
keys, next, err := w.redis.Scan(ctx, cursor, prefix+"*", 100).Result()
if err != nil {
return nil
}
for _, key := range keys {
members, err := w.redis.SMembers(ctx, key).Result()
if err != nil {
continue
}
for _, raw := range members {
id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err == nil && id > 0 {
seen[id] = struct{}{}
}
}
}
cursor = next
if cursor == 0 {
break
}
}
ids := make([]int64, 0, len(seen))
for id := range seen {
ids = append(ids, id)
}
return ids
}
func (w *BroadcastSimulationWorker) updateStatus(fn func(*ContinuousSimulationStatus)) {
w.mu.Lock()
defer w.mu.Unlock()
fn(&w.status)
w.status.UpdateTime = time.Now()
}
func (w *BroadcastSimulationWorker) setLastError(err error) {
if err == nil {
return
}
w.updateStatus(func(status *ContinuousSimulationStatus) {
status.LastError = err.Error()
status.LastRunAt = time.Now().Format(adminTimeLayout)
})
}
func (w *BroadcastSimulationWorker) incrementCount(luckyGift bool) {
w.updateStatus(func(status *ContinuousSimulationStatus) {
status.LastError = ""
status.LastRunAt = time.Now().Format(adminTimeLayout)
status.TotalCount++
if luckyGift {
status.LuckyGiftCount++
} else {
status.GameWinCount++
}
})
}
func normalizeContinuousRequest(req ContinuousSimulationRequest) ContinuousSimulationRequest {
req.SysOrigin = strings.ToUpper(strings.TrimSpace(req.SysOrigin))
req.RegionCodes = normalizeStringList(req.RegionCodes)
if req.MinDelaySeconds < 0 {
req.MinDelaySeconds = 0
}
if req.MaxDelaySeconds < req.MinDelaySeconds {
req.MaxDelaySeconds = req.MinDelaySeconds
}
req.BetAmounts = positiveInt64List(req.BetAmounts, []int64{100, 200, 500, 1000})
req.Multiples = positiveFloat64List(req.Multiples, []float64{10, 20, 50})
req.GameWinAmounts = positiveInt64List(req.GameWinAmounts, []int64{1000, 2000, 5000})
req.LuckyGiftWinAmounts = positiveInt64List(req.LuckyGiftWinAmounts, []int64{1000, 2000, 5000})
req.GiftCandyOptions = positiveInt64List(req.GiftCandyOptions, []int64{100, 200, 500})
req.GiftQuantityOptions = positiveInt64List(req.GiftQuantityOptions, []int64{1, 5, 10})
return req
}
func validateContinuousRequest(req ContinuousSimulationRequest) error {
if req.SysOrigin == "" {
return common.NewAppError(http.StatusBadRequest, "missing_sys_origin", "sysOrigin is required")
}
if len(req.RegionCodes) == 0 {
return common.NewAppError(http.StatusBadRequest, "missing_region_codes", "regionCodes is required")
}
if !req.EnableLuckyGift && !req.EnableGameWin {
return common.NewAppError(http.StatusBadRequest, "missing_broadcast_type", "enable at least one broadcast type")
}
return nil
}
func normalizeStringList(values []string) []string {
seen := map[string]struct{}{}
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.ToUpper(strings.TrimSpace(value))
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func positiveInt64List(values []int64, fallback []int64) []int64 {
result := make([]int64, 0, len(values))
for _, value := range values {
if value > 0 {
result = append(result, value)
}
}
if len(result) == 0 {
return append([]int64(nil), fallback...)
}
return result
}
func positiveFloat64List(values []float64, fallback []float64) []float64 {
result := make([]float64, 0, len(values))
for _, value := range values {
if value >= ThresholdMultiple {
result = append(result, value)
}
}
if len(result) == 0 {
return append([]float64(nil), fallback...)
}
return result
}
func randomDelaySeconds(rng *rand.Rand, min, max int) int {
if max <= min {
return min
}
return min + rng.Intn(max-min+1)
}
func pickInt64(rng *rand.Rand, values []int64) int64 {
if len(values) == 0 {
return 0
}
return values[rng.Intn(len(values))]
}
func pickFloat64(rng *rand.Rand, values []float64) float64 {
if len(values) == 0 {
return 0
}
return values[rng.Intn(len(values))]
}
func formatFloat(value float64) string {
return strconv.FormatFloat(value, 'f', -1, 64)
}
func simulationMessageType(kind string) (string, error) {
switch strings.ToUpper(strings.TrimSpace(kind)) {
case SimulationKindLuckyGift:
return MessageTypeLuckyGift, nil
case SimulationKindGameWin:
return MessageTypeBaishunWin, nil
default:
return "", common.NewAppError(http.StatusBadRequest, "invalid_broadcast_kind", "kind must be LUCKY_GIFT or GAME_WIN")
}
}
func simulationData(messageType string, req BroadcastSimulationRequest, amount int64) map[string]any {
now := strconv.FormatInt(time.Now().UnixMilli(), 10)
switch messageType {
case MessageTypeLuckyGift:
data := map[string]any{
"businessId": firstNonBlank(req.ProviderOrder, "admin-sim-lucky-gift-"+now),
"orderId": firstNonBlank(req.ProviderOrder, "admin-sim-order-"+now),
"globalNews": true,
"multipleType": "WIN",
}
setOptional(data, "acceptUserId", req.AcceptUserID)
setOptional(data, "giftId", req.GiftID)
setOptionalInt64(data, "giftCandy", req.GiftCandy)
setOptionalInt64(data, "giftQuantity", req.GiftQuantity)
return data
default:
data := map[string]any{
"gameRoundId": firstNonBlank(req.GameRoundID, "admin-sim-game-round-"+now),
"currencyDiff": amount,
}
setOptional(data, "gameId", req.GameID)
setOptional(data, "gameUrl", req.GameURL)
setOptionalInt64(data, "betAmount", req.BetAmount)
return data
}
}
func setOptional(data map[string]any, key string, value string) {
if value = strings.TrimSpace(value); value != "" {
data[key] = value
}
}
func setOptionalInt64(data map[string]any, key string, value string) {
parsed, err := parseOptionalInt64(value)
if err == nil && parsed > 0 {
data[key] = parsed
}
}
func firstNonBlank(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func parseRequiredInt64(value string, field string) (int64, error) {
parsed, err := parseOptionalInt64(value)
if err != nil || parsed <= 0 {
return 0, common.NewAppError(http.StatusBadRequest, "invalid_"+field, fmt.Sprintf("%s must be a positive integer", field))
}
return parsed, nil
}
func parseOptionalInt64(value string) (int64, error) {
raw := strings.TrimSpace(value)
if raw == "" {
return 0, nil
}
return strconv.ParseInt(raw, 10, 64)
}
func parseRequiredFloat64(value string, field string) (float64, error) {
raw := strings.TrimSpace(value)
if raw == "" {
return 0, common.NewAppError(http.StatusBadRequest, "invalid_"+field, fmt.Sprintf("%s must be a positive number", field))
}
parsed, err := strconv.ParseFloat(raw, 64)
if err != nil || parsed <= 0 {
return 0, common.NewAppError(http.StatusBadRequest, "invalid_"+field, fmt.Sprintf("%s must be a positive number", field))
}
return parsed, nil
}
var _ RegionBroadcaster = (*regionimgroup.Service)(nil)

View File

@ -0,0 +1,150 @@
package highwin
import (
"context"
"fmt"
"math"
"strconv"
"strings"
"chatapp3-golang/internal/service/regionimgroup"
)
const (
ThresholdMultiple = 10
MessageTypeLuckyGift = "GAME_LUCKY_GIFT"
MessageTypeBaishunWin = "GAME_BAISHUN_WIN"
)
type RegionBroadcaster interface {
SendRegionBroadcast(ctx context.Context, req regionimgroup.RegionBroadcastRequest) (*regionimgroup.RegionBroadcastResponse, error)
}
type BroadcastEvent struct {
SysOrigin string
RegionCode string
Type string
UserID int64
RoomID string
Multiple float64
Amount int64
Data map[string]any
}
func IntegralMultiple(amount, base int64) int64 {
if amount <= 0 || base <= 0 {
return 0
}
return amount / base
}
func Multiple(amount, base int64) float64 {
if amount <= 0 || base <= 0 {
return 0
}
return float64(amount) / float64(base)
}
func ShouldBroadcast(multiple float64, amount int64) bool {
return amount > 0 && multiple >= ThresholdMultiple
}
func SendRegionBroadcast(ctx context.Context, broadcaster RegionBroadcaster, event BroadcastEvent) error {
if broadcaster == nil || !ShouldBroadcast(event.Multiple, event.Amount) {
return nil
}
messageType := strings.TrimSpace(event.Type)
if messageType == "" {
return fmt.Errorf("high win broadcast type is required")
}
if event.UserID <= 0 {
return fmt.Errorf("high win broadcast user id is required")
}
roomID := strings.TrimSpace(event.RoomID)
if roomID == "" {
return fmt.Errorf("high win broadcast room id is required")
}
data := cloneData(event.Data)
userIDText := strconv.FormatInt(event.UserID, 10)
setRequired(data, "userId", userIDText)
setRequired(data, "sendUserId", userIDText)
setRequired(data, "senderUserId", userIDText)
setRequired(data, "roomId", roomID)
setRequired(data, "multiple", normalizeMultiple(event.Multiple))
setRequired(data, "winMultiple", normalizeMultiple(event.Multiple))
setRequired(data, "winAmount", event.Amount)
setRequired(data, "awardAmount", event.Amount)
setRequired(data, "msg", highWinMessage(data, messageType, userIDText, event.Multiple, event.Amount))
_, err := broadcaster.SendRegionBroadcast(ctx, regionimgroup.RegionBroadcastRequest{
SysOrigin: strings.TrimSpace(event.SysOrigin),
RegionCode: strings.TrimSpace(event.RegionCode),
Type: messageType,
Data: data,
})
return err
}
func cloneData(data map[string]any) map[string]any {
clone := map[string]any{}
for key, value := range data {
clone[key] = value
}
return clone
}
func setRequired(data map[string]any, key string, value any) {
data[key] = value
}
func normalizeMultiple(value float64) any {
if value <= 0 {
return 0
}
if math.Trunc(value) == value {
return int64(value)
}
return math.Round(value*100) / 100
}
func highWinMessage(data map[string]any, messageType, fallbackUser string, multiple float64, amount int64) string {
user := firstTextValue(data, "userNickname", "nickname", "account", "actualAccount", "sendUserId", "senderUserId", "userId")
if user == "" {
user = fallbackUser
}
return fmt.Sprintf("%s play %s get x %s win %d coins", user, highWinPlayTarget(messageType), formatMultipleText(multiple), amount)
}
func highWinPlayTarget(messageType string) string {
switch strings.TrimSpace(messageType) {
case MessageTypeLuckyGift:
return "lucky_gift"
case MessageTypeBaishunWin:
return "game"
default:
return "game"
}
}
func firstTextValue(data map[string]any, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(fmt.Sprint(data[key])); value != "" && value != "<nil>" {
return value
}
}
return ""
}
func formatMultipleText(value float64) string {
normalized := normalizeMultiple(value)
switch typed := normalized.(type) {
case int64:
return strconv.FormatInt(typed, 10)
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
default:
return strings.TrimSpace(fmt.Sprint(typed))
}
}

View File

@ -0,0 +1,71 @@
package highwin
import (
"context"
"testing"
"chatapp3-golang/internal/service/regionimgroup"
)
func TestSendRegionBroadcastBuildsRequiredPayload(t *testing.T) {
broadcaster := &fakeRegionBroadcaster{}
err := SendRegionBroadcast(context.Background(), broadcaster, BroadcastEvent{
SysOrigin: "LIKEI",
Type: MessageTypeBaishunWin,
UserID: 1001,
RoomID: "9001",
Multiple: 10.5,
Amount: 2100,
Data: map[string]any{
"gameId": 1146,
},
})
if err != nil {
t.Fatalf("SendRegionBroadcast() error = %v", err)
}
if len(broadcaster.requests) != 1 {
t.Fatalf("requests = %d, want 1", len(broadcaster.requests))
}
req := broadcaster.requests[0]
if req.Type != MessageTypeBaishunWin {
t.Fatalf("type = %q, want %q", req.Type, MessageTypeBaishunWin)
}
if req.Data["userId"] != "1001" || req.Data["roomId"] != "9001" ||
req.Data["winAmount"] != int64(2100) || req.Data["multiple"] != 10.5 ||
req.Data["msg"] != "1001 play game get x 10.5 win 2100 coins" {
t.Fatalf("payload = %+v", req.Data)
}
}
func TestSendRegionBroadcastSkipsBelowThreshold(t *testing.T) {
broadcaster := &fakeRegionBroadcaster{}
err := SendRegionBroadcast(context.Background(), broadcaster, BroadcastEvent{
Type: MessageTypeLuckyGift,
UserID: 1001,
RoomID: "9001",
Multiple: 9,
Amount: 900,
})
if err != nil {
t.Fatalf("SendRegionBroadcast() error = %v", err)
}
if len(broadcaster.requests) != 0 {
t.Fatalf("requests = %d, want 0", len(broadcaster.requests))
}
}
type fakeRegionBroadcaster struct {
requests []regionimgroup.RegionBroadcastRequest
}
func (f *fakeRegionBroadcaster) SendRegionBroadcast(_ context.Context, req regionimgroup.RegionBroadcastRequest) (*regionimgroup.RegionBroadcastResponse, error) {
f.requests = append(f.requests, req)
return &regionimgroup.RegionBroadcastResponse{
SysOrigin: req.SysOrigin,
RegionCode: "AR",
GroupID: "@region-ar",
Type: req.Type,
}, nil
}

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) {
items, err := p.service.listRoomGames(ctx, user.SysOrigin, category)
items, err := p.service.listRoomGames(ctx, user.SysOrigin, user.RegionID, category)
if err != nil {
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
}
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)
profile, err := s.activeProfile(ctx, sysOrigin)
if err != nil {
@ -125,6 +125,10 @@ func (s *Service) listRoomGames(ctx context.Context, sysOrigin, category string)
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) != "" {
// 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) {
query = query.Where("cfg.category = ?", category)
}

View File

@ -140,6 +140,8 @@ func (s *LuckyGiftService) Draw(ctx context.Context, req LuckyGiftDrawRequest) (
return nil, err
}
s.publishHighWinRegionBroadcasts(ctx, req, results, balanceAfter)
return resp, nil
}

View File

@ -0,0 +1,51 @@
package luckygift
import (
"context"
"log"
"strconv"
"chatapp3-golang/internal/service/highwin"
)
func (s *LuckyGiftService) publishHighWinRegionBroadcasts(ctx context.Context, req LuckyGiftDrawRequest, results []LuckyGiftDrawResult, balanceAfter int64) {
if s == nil || s.highWinBroadcaster == nil || req.GiftPrice <= 0 {
return
}
for _, result := range results {
if result.RewardNum <= 0 {
continue
}
multiple := highwin.IntegralMultiple(result.RewardNum, req.GiftPrice)
if !highwin.ShouldBroadcast(float64(multiple), result.RewardNum) {
continue
}
data := map[string]any{
"businessId": req.BusinessID,
"orderId": result.ID,
"providerOrderId": result.OrderID,
"acceptUserId": strconv.FormatInt(result.AcceptUserID, 10),
"giftId": strconv.FormatInt(req.GiftID, 10),
"giftCandy": req.GiftPrice,
"giftQuantity": req.GiftNum,
"multipleType": luckyGiftMultipleType(multiple),
"balance": balanceAfter,
"globalNews": true,
}
if err := highwin.SendRegionBroadcast(ctx, s.highWinBroadcaster, highwin.BroadcastEvent{
SysOrigin: req.SysOrigin,
Type: highwin.MessageTypeLuckyGift,
UserID: req.SendUserID,
RoomID: strconv.FormatInt(req.RoomID, 10),
Multiple: float64(multiple),
Amount: result.RewardNum,
Data: data,
}); err != nil {
log.Printf("send lucky gift high win region broadcast failed businessId=%s orderId=%s: %v", req.BusinessID, result.ID, err)
}
}
}
func luckyGiftMultipleType(_ int64) string {
return "WIN"
}

View File

@ -0,0 +1,48 @@
package luckygift
import (
"context"
"testing"
"chatapp3-golang/internal/service/regionimgroup"
)
func TestPublishHighWinRegionBroadcastsSendsOnlyTenTimesAndAbove(t *testing.T) {
broadcaster := &fakeLuckyGiftRegionBroadcaster{}
service := &LuckyGiftService{highWinBroadcaster: broadcaster}
service.publishHighWinRegionBroadcasts(context.Background(), LuckyGiftDrawRequest{
BusinessID: "biz-1",
SysOrigin: "LIKEI",
RoomID: 9001,
SendUserID: 1001,
GiftID: 5001,
GiftPrice: 100,
GiftNum: 1,
}, []LuckyGiftDrawResult{
{ID: "low", AcceptUserID: 2001, RewardNum: 900},
{ID: "high", OrderID: "LG_high", AcceptUserID: 2002, RewardNum: 1000},
}, 90000)
if len(broadcaster.requests) != 1 {
t.Fatalf("requests = %d, want 1", len(broadcaster.requests))
}
req := broadcaster.requests[0]
if req.Type != "GAME_LUCKY_GIFT" {
t.Fatalf("type = %q, want GAME_LUCKY_GIFT", req.Type)
}
if req.Data["sendUserId"] != "1001" || req.Data["roomId"] != "9001" ||
req.Data["multiple"] != int64(10) || req.Data["awardAmount"] != int64(1000) ||
req.Data["acceptUserId"] != "2002" {
t.Fatalf("payload = %+v", req.Data)
}
}
type fakeLuckyGiftRegionBroadcaster struct {
requests []regionimgroup.RegionBroadcastRequest
}
func (f *fakeLuckyGiftRegionBroadcaster) SendRegionBroadcast(_ context.Context, req regionimgroup.RegionBroadcastRequest) (*regionimgroup.RegionBroadcastResponse, error) {
f.requests = append(f.requests, req)
return &regionimgroup.RegionBroadcastResponse{Type: req.Type}, nil
}

View File

@ -3,6 +3,7 @@ package luckygift
import (
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/service/highwin"
"context"
"net/http"
"time"
@ -39,10 +40,11 @@ type luckyGiftPorts struct {
// LuckyGiftService 负责幸运礼物抽奖、三方调用和钱包返奖。
type LuckyGiftService struct {
cfg config.Config
repo luckyGiftPorts
java luckyGiftGateway
httpClient *http.Client
cfg config.Config
repo luckyGiftPorts
java luckyGiftGateway
httpClient *http.Client
highWinBroadcaster highwin.RegionBroadcaster
}
// LuckyGiftDrawRequest 是幸运礼物抽奖入参。
@ -60,6 +62,10 @@ type LuckyGiftDrawRequest struct {
Orders []LuckyGiftDrawOrderInput `json:"orders"`
}
func (s *LuckyGiftService) SetHighWinBroadcaster(broadcaster highwin.RegionBroadcaster) {
s.highWinBroadcaster = broadcaster
}
// LuckyGiftDrawOrderInput 表示单个收礼人的抽奖子单。
type LuckyGiftDrawOrderInput struct {
ID string `json:"id"`

View File

@ -0,0 +1,367 @@
package managercenter
import (
"context"
"errors"
"strings"
"time"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
)
type bdLeaderRow struct {
ID int64 `gorm:"column:id;primaryKey"`
Origin string `gorm:"column:sys_origin"`
UserID int64 `gorm:"column:user_id"`
Contact string `gorm:"column:contact"`
RegionID string `gorm:"column:region_id"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
CreateUser int64 `gorm:"column:create_user"`
UpdateUser int64 `gorm:"column:update_user"`
}
func (bdLeaderRow) TableName() string {
return "room_bd_lead_base_info"
}
type businessDevelopmentBaseInfoRow struct {
ID int64 `gorm:"column:id;primaryKey"`
Origin string `gorm:"column:sys_origin"`
BDLeadUserID *int64 `gorm:"column:bd_lead_user_id"`
UserID int64 `gorm:"column:user_id"`
Contact string `gorm:"column:contact"`
Region string `gorm:"column:region"`
MemberQuantity int64 `gorm:"column:member_quantity"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
CreateUser int64 `gorm:"column:create_user"`
UpdateUser int64 `gorm:"column:update_user"`
}
func (businessDevelopmentBaseInfoRow) TableName() string {
return "room_business_development_base_info"
}
type userHistoryIdentityRow struct {
UserID int64 `gorm:"column:user_id;primaryKey"`
IsBD bool `gorm:"column:is_bd"`
IsAgent bool `gorm:"column:is_agent"`
IsHost bool `gorm:"column:is_host"`
IsManager bool `gorm:"column:is_manager"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
CreateUser int64 `gorm:"column:create_user"`
UpdateUser int64 `gorm:"column:update_user"`
}
func (userHistoryIdentityRow) TableName() string {
return "user_history_identity"
}
type administratorRow struct {
ID int64 `gorm:"column:id;primaryKey"`
UserID int64 `gorm:"column:user_id"`
Status bool `gorm:"column:status"`
}
func (administratorRow) TableName() string {
return "sys_administrator"
}
type bdLeaderListRow struct {
ID int64 `gorm:"column:id"`
UserID int64 `gorm:"column:user_id"`
Origin string `gorm:"column:sys_origin"`
Contact string `gorm:"column:contact"`
RegionID string `gorm:"column:region_id"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
Account string `gorm:"column:account"`
UserAvatar string `gorm:"column:user_avatar"`
UserNickname string `gorm:"column:user_nickname"`
}
func (s *Service) ListBDLeaders(ctx context.Context, user AuthUser) (*BDLeaderListResponse, error) {
manager, err := s.requireManagerInfo(ctx, user)
if err != nil {
return nil, err
}
query := s.db.WithContext(ctx).
Table("room_bd_lead_base_info AS leader").
Select(strings.Join([]string{
"leader.id",
"leader.user_id",
"leader.sys_origin",
"leader.contact",
"leader.region_id",
"leader.create_time",
"leader.update_time",
"user_info.account",
"user_info.user_avatar",
"user_info.user_nickname",
}, ", ")).
Joins("LEFT JOIN user_base_info AS user_info ON user_info.id = leader.user_id AND user_info.is_del = ?", false).
Where("leader.create_user = ?", user.UserID)
if origin := managerSysOrigin(manager, user); origin != "" {
query = query.Where("leader.sys_origin = ?", origin)
}
var rows []bdLeaderListRow
if err := query.Order("leader.create_time DESC, leader.id DESC").Find(&rows).Error; err != nil {
return nil, serverError("bd_leader_query_failed", err.Error())
}
records := make([]BDLeaderView, 0, len(rows))
for _, row := range rows {
records = append(records, bdLeaderListRowToView(row))
}
return &BDLeaderListResponse{Count: len(records), Records: records}, nil
}
func (s *Service) AddBDLeader(ctx context.Context, user AuthUser, req AddBDLeaderRequest) (*AddBDLeaderResponse, error) {
manager, err := s.requireManagerFeature(ctx, user, managerFeatureAddBDLeader)
if err != nil {
return nil, err
}
if req.UserID.Int64() <= 0 {
return nil, badRequest("user_required", "userId is required")
}
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())
}
sysOrigin := managerSysOrigin(manager, user)
if sysOrigin == "" {
sysOrigin = normalizeSysOrigin(target.OriginSys)
}
if target.OriginSys != "" && sysOrigin != "" && !strings.EqualFold(target.OriginSys, sysOrigin) {
return nil, badRequest("bd_leader_origin_mismatch", "target user sysOrigin does not match manager")
}
regionID := strings.TrimSpace(req.RegionID)
if regionID == "" {
regionID = strings.TrimSpace(manager.RegionID)
}
if regionID == "" {
return nil, badRequest("manager_region_required", "manager region is required")
}
if exists, err := s.userIsAdministrator(ctx, target.ID); err != nil {
return nil, err
} else if exists {
return nil, badRequest("user_is_admin", "administrator cannot be added as BD leader")
}
if exists, err := s.userIsBDLeader(ctx, target.ID); err != nil {
return nil, err
} else if exists {
return nil, badRequest("user_already_bd_leader", "user is already a BD leader")
}
leaderID, err := utils.NextID()
if err != nil {
return nil, serverError("id_generate_failed", err.Error())
}
now := time.Now()
contact := strings.TrimSpace(req.Contact)
leader := bdLeaderRow{
ID: leaderID,
Origin: sysOrigin,
UserID: target.ID,
Contact: contact,
RegionID: regionID,
CreateTime: now,
UpdateTime: now,
CreateUser: user.UserID,
UpdateUser: user.UserID,
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&leader).Error; err != nil {
return serverError("bd_leader_create_failed", err.Error())
}
if err := upsertBDBaseInfo(tx, leader, user.UserID, now); err != nil {
return err
}
if err := upsertUserHistoryBD(tx, target.ID, user.UserID, now); err != nil {
return err
}
return nil
}); err != nil {
return nil, err
}
return &AddBDLeaderResponse{
Success: true,
Leader: bdLeaderRowToView(leader, target),
}, nil
}
func (s *Service) requireManagerInfo(ctx context.Context, user AuthUser) (teamManagerInfoRow, error) {
if user.UserID <= 0 {
return teamManagerInfoRow{}, unauthorized("invalid_user", "authenticated user is required")
}
if s.db == nil {
return teamManagerInfoRow{}, serviceUnavailable("database_unavailable", "database is unavailable")
}
var row teamManagerInfoRow
err := s.db.WithContext(ctx).
Table("team_manager_base_info").
Where("user_id = ?", user.UserID).
First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return teamManagerInfoRow{}, forbidden("not_manager", "user is not manager")
}
if err != nil {
return teamManagerInfoRow{}, serverError("manager_query_failed", err.Error())
}
return row, nil
}
func managerSysOrigin(manager teamManagerInfoRow, user AuthUser) string {
if origin := strings.TrimSpace(manager.Origin); origin != "" {
return origin
}
return strings.TrimSpace(user.SysOrigin)
}
func (s *Service) userIsAdministrator(ctx context.Context, userID int64) (bool, error) {
var count int64
if err := s.db.WithContext(ctx).
Table("sys_administrator").
Where("user_id = ? AND status = ?", userID, true).
Count(&count).Error; err != nil {
return false, serverError("administrator_query_failed", err.Error())
}
return count > 0, nil
}
func (s *Service) userIsBDLeader(ctx context.Context, userID int64) (bool, error) {
var count int64
if err := s.db.WithContext(ctx).
Table("room_bd_lead_base_info").
Where("user_id = ?", userID).
Count(&count).Error; err != nil {
return false, serverError("bd_leader_query_failed", err.Error())
}
return count > 0, nil
}
func upsertBDBaseInfo(tx *gorm.DB, leader bdLeaderRow, operatorID int64, now time.Time) error {
var row businessDevelopmentBaseInfoRow
err := tx.Where("user_id = ?", leader.UserID).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
id, idErr := utils.NextID()
if idErr != nil {
return serverError("id_generate_failed", idErr.Error())
}
leadUserID := leader.UserID
return tx.Create(&businessDevelopmentBaseInfoRow{
ID: id,
Origin: leader.Origin,
BDLeadUserID: &leadUserID,
UserID: leader.UserID,
Contact: leader.Contact,
Region: leader.RegionID,
MemberQuantity: 0,
CreateTime: now,
UpdateTime: now,
CreateUser: operatorID,
UpdateUser: operatorID,
}).Error
}
if err != nil {
return serverError("bd_info_query_failed", err.Error())
}
if row.BDLeadUserID != nil && *row.BDLeadUserID > 0 {
return badRequest("user_already_bound_bd_leader", "user already has a BD leader")
}
updates := map[string]any{
"bd_lead_user_id": leader.UserID,
"region": leader.RegionID,
"update_time": now,
"update_user": operatorID,
}
if leader.Contact != "" {
updates["contact"] = leader.Contact
}
if leader.Origin != "" {
updates["sys_origin"] = leader.Origin
}
if err := tx.Model(&businessDevelopmentBaseInfoRow{}).
Where("id = ?", row.ID).
Updates(updates).Error; err != nil {
return serverError("bd_info_update_failed", err.Error())
}
return nil
}
func upsertUserHistoryBD(tx *gorm.DB, userID int64, operatorID int64, now time.Time) error {
var row userHistoryIdentityRow
err := tx.Where("user_id = ?", userID).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return tx.Create(&userHistoryIdentityRow{
UserID: userID,
IsBD: true,
CreateTime: now,
UpdateTime: now,
CreateUser: operatorID,
UpdateUser: operatorID,
}).Error
}
if err != nil {
return serverError("history_identity_query_failed", err.Error())
}
if err := tx.Model(&userHistoryIdentityRow{}).
Where("user_id = ?", userID).
Updates(map[string]any{
"is_bd": true,
"update_time": now,
"update_user": operatorID,
}).Error; err != nil {
return serverError("history_identity_update_failed", err.Error())
}
return nil
}
func bdLeaderRowToView(leader bdLeaderRow, user userBaseInfoRow) BDLeaderView {
return BDLeaderView{
ID: ID(leader.ID),
UserID: ID(leader.UserID),
Account: user.Account,
UserAvatar: user.UserAvatar,
UserNickname: user.UserNickname,
Contact: leader.Contact,
RegionID: leader.RegionID,
OriginSys: leader.Origin,
CreateTime: leader.CreateTime,
UpdateTime: leader.UpdateTime,
}
}
func bdLeaderListRowToView(row bdLeaderListRow) BDLeaderView {
return BDLeaderView{
ID: ID(row.ID),
UserID: ID(row.UserID),
Account: row.Account,
UserAvatar: row.UserAvatar,
UserNickname: row.UserNickname,
Contact: row.Contact,
RegionID: row.RegionID,
OriginSys: row.Origin,
CreateTime: row.CreateTime,
UpdateTime: row.UpdateTime,
}
}

View File

@ -6,6 +6,7 @@ import (
"fmt"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
@ -14,9 +15,18 @@ import (
)
type teamManagerInfoRow struct {
ID int64 `gorm:"column:id;primaryKey"`
UserID int64 `gorm:"column:user_id"`
Origin string `gorm:"column:sys_origin"`
ID int64 `gorm:"column:id;primaryKey"`
UserID int64 `gorm:"column:user_id"`
Origin string `gorm:"column:sys_origin"`
Contact string `gorm:"column:contact"`
RegionID string `gorm:"column:region_id"`
CanAddBDLeader *bool `gorm:"column:can_add_bd_leader"`
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 {
@ -24,21 +34,38 @@ func (teamManagerInfoRow) TableName() string {
}
type userBaseInfoRow struct {
ID int64 `gorm:"column:id;primaryKey"`
Account string `gorm:"column:account"`
UserAvatar string `gorm:"column:user_avatar"`
UserNickname string `gorm:"column:user_nickname"`
CountryCode string `gorm:"column:country_code"`
CountryName string `gorm:"column:country_name"`
OriginSys string `gorm:"column:origin_sys"`
AccountStatus string `gorm:"column:account_status"`
IsDel bool `gorm:"column:is_del"`
ID int64 `gorm:"column:id;primaryKey"`
Account string `gorm:"column:account"`
UserAvatar string `gorm:"column:user_avatar"`
UserNickname string `gorm:"column:user_nickname"`
CountryID int64 `gorm:"column:country_id"`
CountryCode string `gorm:"column:country_code"`
CountryName string `gorm:"column:country_name"`
OriginSys string `gorm:"column:origin_sys"`
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 {
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 {
ID int64 `gorm:"column:id;primaryKey"`
Type string `gorm:"column:type"`
@ -69,7 +96,8 @@ func (propsNobleVIPAbilityRow) TableName() string {
}
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
}
@ -77,7 +105,7 @@ func (s *Service) GetProfile(ctx context.Context, user AuthUser) (*ProfileRespon
if err != nil {
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) {
@ -97,6 +125,34 @@ func (s *Service) SearchUser(ctx context.Context, user AuthUser, account string)
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) {
if err := s.ensureManager(ctx, user); err != nil {
return nil, err
@ -144,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) {
if err := s.ensureManager(ctx, user); err != nil {
if _, err := s.requireManagerFeature(ctx, user, managerFeatureGiftProps); err != nil {
return nil, err
}
if req.AcceptUserID.Int64() <= 0 {
@ -239,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) {
if err := s.ensureManager(ctx, user); err != nil {
if _, err := s.requireManagerFeature(ctx, user, managerFeatureBanUser); err != nil {
return nil, err
}
if req.UserID.Int64() <= 0 {
@ -286,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) {
if err := s.ensureManager(ctx, user); err != nil {
if _, err := s.requireManagerFeature(ctx, user, managerFeatureUnbanUser); err != nil {
return nil, err
}
if req.UserID.Int64() <= 0 {
@ -323,26 +379,114 @@ func (s *Service) UnbanUser(ctx context.Context, user AuthUser, req UnbanUserReq
}, nil
}
func (s *Service) ensureManager(ctx context.Context, user AuthUser) error {
if user.UserID <= 0 {
return unauthorized("invalid_user", "authenticated user is required")
// 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 serviceUnavailable("database_unavailable", "database is unavailable")
return nil, serviceUnavailable("database_unavailable", "database is unavailable")
}
var count int64
err := s.db.WithContext(ctx).
Table("team_manager_base_info").
Where("user_id = ?", user.UserID).
Count(&count).Error
target, err := s.loadUserRowByID(ctx, req.UserID.Int64())
if err != nil {
return serverError("manager_query_failed", err.Error())
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, notFound("user_not_found", "user not found")
}
return nil, serverError("user_query_failed", err.Error())
}
if count == 0 {
return forbidden("not_manager", "user is not manager")
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 {
_, err := s.requireManagerInfo(ctx, user)
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
}
return nil
}
func (s *Service) findUserView(ctx context.Context, account string) (UserView, error) {
@ -410,6 +554,65 @@ func (s *Service) loadUserRowByAccount(ctx context.Context, account string) (use
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) {
hasRecharge, canBan := s.userRechargeEligibility(ctx, row.ID)
canBan = canBan && !isUnbannableAccountStatus(row.AccountStatus)
@ -418,6 +621,7 @@ func (s *Service) userRowToView(ctx context.Context, row userBaseInfoRow) (UserV
Account: row.Account,
UserAvatar: row.UserAvatar,
UserNickname: row.UserNickname,
CountryID: ID(row.CountryID),
CountryCode: row.CountryCode,
CountryName: row.CountryName,
OriginSys: row.OriginSys,
@ -435,6 +639,7 @@ func (s *Service) javaProfileToView(ctx context.Context, profile integration.Use
Account: profile.Account,
UserAvatar: profile.UserAvatar,
UserNickname: profile.UserNickname,
CountryID: ID(profile.CountryID),
CountryCode: profile.CountryCode,
CountryName: profile.CountryName,
OriginSys: profile.OriginSys,
@ -460,6 +665,9 @@ func overlayJavaProfile(view *UserView, profile integration.UserProfile) {
if profile.CountryName != "" {
view.CountryName = profile.CountryName
}
if int64(profile.CountryID) > 0 {
view.CountryID = ID(profile.CountryID)
}
if profile.OriginSys != "" {
view.OriginSys = profile.OriginSys
}
@ -471,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) {
if userID <= 0 {
return false, nil
@ -536,7 +759,7 @@ func (s *Service) loadGiftableNobleVIPAbility(ctx context.Context, propsID int64
return propsNobleVIPAbilityRow{}, serverError("vip_ability_query_failed", err.Error())
}
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
}
@ -634,7 +857,7 @@ func propsRowToView(row propsSourceRecordRow, vipLevels map[int64]int) (PropsVie
if row.Type == propsTypeNobleVIP {
var exists bool
level, exists = vipLevels[row.ID]
if !exists || level > 3 {
if !exists || level > maxGiftableVIPLevel {
return PropsView{}, false
}
}

View File

@ -17,6 +17,8 @@ import (
type fakeManagerGateway struct {
recharges map[int64][]integration.UserTotalRecharge
rechargeErr error
freightSellers map[int64]bool
freightSellerErr error
abilities map[int64]integration.PropsNobleVIPAbility
maxAbility integration.PropsNobleVIPAbility
giveRequests []integration.GivePropsBackpackRequest
@ -52,6 +54,13 @@ func (f *fakeManagerGateway) MapUserTotalRecharge(_ context.Context, userIDs []i
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 {
f.giveRequests = append(f.giveRequests, req)
return nil
@ -98,10 +107,14 @@ func TestListPropsRequiresAdminFreeAndFiltersVIPLevels(t *testing.T) {
propsSourceRecordRow{ID: 102, Type: propsTypeRide, Name: "Paid ride", AdminFree: false},
propsSourceRecordRow{ID: 201, Type: propsTypeNobleVIP, Name: "VIP 3", 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),
propsNobleVIPAbilityRow{ID: 201, VIPLevel: 3},
propsNobleVIPAbilityRow{ID: 204, VIPLevel: 4},
propsNobleVIPAbilityRow{ID: 205, VIPLevel: 5},
propsNobleVIPAbilityRow{ID: 206, VIPLevel: 6},
)
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)
}
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 {
t.Fatalf("records = %+v, VIP level 4 must be hidden", resp.Records)
if item, ok := got[204]; !ok || item.VIPLevel != 4 {
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: 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: 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)
if err != nil {
t.Fatalf("ListProps() error = %v", err)
}
if len(resp.Records) != 1 {
t.Fatalf("records = %+v, want only one enabled LIKEI vip config", resp.Records)
if len(resp.Records) != 3 {
t.Fatalf("records = %+v, want enabled LIKEI vip configs up to level 5", resp.Records)
}
got := resp.Records[0]
if got.ID.Int64() != 301 || got.Type != propsTypeNobleVIP || got.VIPLevel != 1 || got.Days != 30 {
t.Fatalf("vip props = %+v, want config 301 level 1", got)
got := map[int64]PropsView{}
for _, item := range resp.Records {
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,112 @@ 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) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedUsers(t, db,
userBaseInfoRow{ID: 2, Account: "2002", UserNickname: "leader", OriginSys: "LIKEI"},
)
resp, err := service.AddBDLeader(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, AddBDLeaderRequest{
UserID: ID(2),
Contact: "whatsapp",
})
if err != nil {
t.Fatalf("AddBDLeader() error = %v", err)
}
if !resp.Success || resp.Leader.UserID.Int64() != 2 || resp.Leader.RegionID != "SA" {
t.Fatalf("resp = %+v, want created leader for user 2 in manager region", resp)
}
var bdInfo businessDevelopmentBaseInfoRow
if err := db.Where("user_id = ?", int64(2)).First(&bdInfo).Error; err != nil {
t.Fatalf("load bd info: %v", err)
}
if bdInfo.BDLeadUserID == nil || *bdInfo.BDLeadUserID != 2 || bdInfo.Region != "SA" {
t.Fatalf("bd info = %+v, want self-linked BD leader info", bdInfo)
}
var identity userHistoryIdentityRow
if err := db.Where("user_id = ?", int64(2)).First(&identity).Error; err != nil {
t.Fatalf("load identity: %v", err)
}
if !identity.IsBD {
t.Fatalf("identity = %+v, want is_bd=true", identity)
}
list, err := service.ListBDLeaders(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"})
if err != nil {
t.Fatalf("ListBDLeaders() error = %v", err)
}
if list.Count != 1 || list.Records[0].UserID.Int64() != 2 || list.Records[0].Account != "2002" {
t.Fatalf("list = %+v, want one created leader", list)
}
}
func TestAddBDLeaderRejectsExistingLeader(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2002", OriginSys: "LIKEI"})
if err := db.Create(&bdLeaderRow{ID: 10, UserID: 2, Origin: "LIKEI", RegionID: "SA", CreateUser: 1}).Error; err != nil {
t.Fatalf("seed bd leader: %v", err)
}
_, err := service.AddBDLeader(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, AddBDLeaderRequest{UserID: ID(2)})
assertAppErrorCode(t, err, "user_already_bd_leader")
}
func TestAddBDLeaderRejectsTargetFromOtherOrigin(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2002", OriginSys: "ATYOU"})
_, err := service.AddBDLeader(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, AddBDLeaderRequest{UserID: ID(2)})
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) {
service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
@ -227,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) {
service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
@ -266,12 +422,12 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
seedVIPConfigs(t, db, model.VipLevelConfig{
ID: 301,
SysOrigin: "LIKEI",
Level: 3,
LevelCode: "VIP3",
DisplayName: "VIP 3",
Level: 5,
LevelCode: "VIP5",
DisplayName: "VIP 5",
Enabled: true,
DurationDays: 30,
PriceGold: 3000,
PriceGold: 5000,
})
resp, err := service.SendProps(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, SendPropsRequest{
@ -281,7 +437,7 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
if err != nil {
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)
}
@ -289,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 {
t.Fatalf("load state: %v", err)
}
if state.Level != 3 || state.Status != vipStatusActive {
t.Fatalf("state = %+v, want vip3 active", state)
if state.Level != 5 || state.Status != vipStatusActive {
t.Fatalf("state = %+v, want vip5 active", state)
}
var orders int64
if err := db.Model(&model.VipOrderRecord{}).Where("user_id = ? AND order_type = ?", int64(2), vipOrderTypeGMGift).Count(&orders).Error; err != nil {
@ -300,7 +456,7 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
t.Fatalf("orders = %d, want 1", orders)
}
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)
}
if records != 1 {
@ -308,6 +464,48 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
}
}
func TestVIPGiftCleanupExpiresOldVIPResourcesOnly(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
if err := db.AutoMigrate(&managerVIPCleanupPropsRow{}, &managerVIPCleanupBadgeRow{}); err != nil {
t.Fatalf("auto migrate cleanup tables: %v", err)
}
now := time.Date(2026, 5, 30, 10, 0, 0, 0, time.UTC)
future := now.AddDate(0, 0, 20)
userID := int64(2)
seedManagerVIPCleanupProps(t, db,
managerVIPCleanupPropsRow{ID: 1, UserID: userID, PropsID: 101, Type: propsTypeAvatarFrame, ExpireTime: future, UseProps: true, AllowGive: true},
managerVIPCleanupPropsRow{ID: 2, UserID: userID, PropsID: 201, Type: propsTypeAvatarFrame, ExpireTime: future, UseProps: true, AllowGive: true},
managerVIPCleanupPropsRow{ID: 3, UserID: userID, PropsID: 999, Type: propsTypeAvatarFrame, ExpireTime: future, UseProps: true, AllowGive: true},
managerVIPCleanupPropsRow{ID: 4, UserID: userID, PropsID: 103, Type: propsTypeDataCard, ExpireTime: future, UseProps: true, AllowGive: true},
)
seedManagerVIPCleanupBadges(t, db,
managerVIPCleanupBadgeRow{ID: 1, UserID: userID, BadgeID: 100, ExpireTime: future, UseProps: true},
managerVIPCleanupBadgeRow{ID: 2, UserID: userID, BadgeID: 200, ExpireTime: future, UseProps: true},
)
target := vipGiftResourcesFromConfig(model.VipLevelConfig{
BadgeResourceID: 200,
AvatarFrameResourceID: 201,
})
all := append(vipGiftResourcesFromConfig(model.VipLevelConfig{
BadgeResourceID: 100,
AvatarFrameResourceID: 101,
BackgroundCardResourceID: 103,
}), target...)
if err := service.cleanupSupersededVIPGiftResources(context.Background(), userID, target, all, now); err != nil {
t.Fatalf("cleanupSupersededVIPGiftResources() error = %v", err)
}
assertManagerVIPCleanupProps(t, db, 101, now, false, false)
assertManagerVIPCleanupProps(t, db, 103, now, false, false)
assertManagerVIPCleanupProps(t, db, 201, future, true, true)
assertManagerVIPCleanupProps(t, db, 999, future, true, true)
assertManagerVIPCleanupBadge(t, db, 100, now, false)
assertManagerVIPCleanupBadge(t, db, 200, future, true)
}
func TestSendVIPRejectsLevelAboveGiftLimit(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
@ -319,9 +517,9 @@ func TestSendVIPRejectsLevelAboveGiftLimit(t *testing.T) {
seedVIPConfigs(t, db, model.VipLevelConfig{
ID: 304,
SysOrigin: "LIKEI",
Level: 4,
LevelCode: "VIP4",
DisplayName: "VIP 4",
Level: 6,
LevelCode: "VIP6",
DisplayName: "VIP 6",
Enabled: true,
DurationDays: 30,
})
@ -333,6 +531,90 @@ func TestSendVIPRejectsLevelAboveGiftLimit(t *testing.T) {
assertAppErrorCode(t, err, "vip_not_giftable")
}
type managerVIPCleanupPropsRow struct {
ID int64 `gorm:"column:id;primaryKey"`
UserID int64 `gorm:"column:user_id"`
PropsID int64 `gorm:"column:props_id"`
Type string `gorm:"column:type"`
ExpireTime time.Time `gorm:"column:expire_time"`
UseProps bool `gorm:"column:is_use_props"`
AllowGive bool `gorm:"column:allow_give"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (managerVIPCleanupPropsRow) TableName() string {
return "user_props_backpack"
}
type managerVIPCleanupBadgeRow struct {
ID int64 `gorm:"column:id;primaryKey"`
UserID int64 `gorm:"column:user_id"`
BadgeID int64 `gorm:"column:badge_id"`
ExpireType string `gorm:"column:expire_type"`
ExpireTime time.Time `gorm:"column:expire_time"`
UseProps bool `gorm:"column:is_use_props"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (managerVIPCleanupBadgeRow) TableName() string {
return "user_badge_backpack"
}
func seedManagerVIPCleanupProps(t *testing.T, db *gorm.DB, rows ...managerVIPCleanupPropsRow) {
t.Helper()
for _, row := range rows {
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seed manager cleanup props: %v", err)
}
}
}
func seedManagerVIPCleanupBadges(t *testing.T, db *gorm.DB, rows ...managerVIPCleanupBadgeRow) {
t.Helper()
for _, row := range rows {
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seed manager cleanup badge: %v", err)
}
}
}
func assertManagerVIPCleanupProps(t *testing.T, db *gorm.DB, propsID int64, expireTime time.Time, useProps bool, allowGive bool) {
t.Helper()
var row managerVIPCleanupPropsRow
if err := db.Where("props_id = ?", propsID).First(&row).Error; err != nil {
t.Fatalf("load manager cleanup props %d: %v", propsID, err)
}
if !row.ExpireTime.Equal(expireTime) || row.UseProps != useProps || row.AllowGive != allowGive {
t.Fatalf("props %d = expire:%s use:%v allow:%v, want expire:%s use:%v allow:%v",
propsID, row.ExpireTime, row.UseProps, row.AllowGive, expireTime, useProps, allowGive)
}
}
func assertManagerVIPCleanupBadge(t *testing.T, db *gorm.DB, badgeID int64, expireTime time.Time, useProps bool) {
t.Helper()
var row managerVIPCleanupBadgeRow
if err := db.Where("badge_id = ?", badgeID).First(&row).Error; err != nil {
t.Fatalf("load manager cleanup badge %d: %v", badgeID, err)
}
if !row.ExpireTime.Equal(expireTime) || row.UseProps != useProps {
t.Fatalf("badge %d = expire:%s use:%v, want expire:%s use:%v",
badgeID, row.ExpireTime, row.UseProps, expireTime, useProps)
}
}
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) {
service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
@ -412,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) {
service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
@ -471,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) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
@ -480,6 +886,11 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
if err := db.AutoMigrate(
&teamManagerInfoRow{},
&userBaseInfoRow{},
&sysCountryCodeRow{},
&bdLeaderRow{},
&businessDevelopmentBaseInfoRow{},
&userHistoryIdentityRow{},
&administratorRow{},
&propsSourceRecordRow{},
&propsNobleVIPAbilityRow{},
&model.VipLevelConfig{},
@ -490,19 +901,45 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
t.Fatalf("auto migrate: %v", err)
}
fake := &fakeManagerGateway{
recharges: map[int64][]integration.UserTotalRecharge{},
abilities: map[int64]integration.PropsNobleVIPAbility{},
recharges: map[int64][]integration.UserTotalRecharge{},
freightSellers: map[int64]bool{},
abilities: map[int64]integration.PropsNobleVIPAbility{},
}
return NewService(db, fake), fake
}
func seedManager(t *testing.T, db *gorm.DB, userID int64) {
t.Helper()
if err := db.Create(&teamManagerInfoRow{ID: userID, UserID: userID, Origin: "LIKEI"}).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)
}
}
func boolPtr(value bool) *bool {
return &value
}
func seedUsers(t *testing.T, db *gorm.DB, rows ...userBaseInfoRow) {
t.Helper()
for _, row := range rows {
@ -515,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) {
t.Helper()
for _, row := range rows {

View File

@ -6,6 +6,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/integration"
@ -27,9 +28,10 @@ const (
vipGiftOrigin = "VIP_PURCHASE"
vipGiftOriginDesc = "VIP purchase"
defaultGiftDays = 7
defaultVIPGiftDays = 30
maxGiftableVIPLevel = 3
defaultGiftDays = 7
defaultVIPGiftDays = 30
// 经理中心 VIP 赠送按当前运营范围放开 VIP1-VIP5列表和发放校验共用该上限避免展示可选但提交失败。
maxGiftableVIPLevel = 5
accountStatusNormal = "NORMAL"
accountStatusFreeze = "FREEZE"
@ -37,6 +39,16 @@ const (
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 AuthUser = common.AuthUser
@ -50,6 +62,7 @@ type managerGateway interface {
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error)
GetUserProfileByAccountOne(ctx context.Context, account string) (integration.UserProfile, 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
GetNobleVIPAbility(ctx context.Context, sourceID int64) (integration.PropsNobleVIPAbility, error)
GetUserMaxNobleVIPAbility(ctx context.Context, userID int64) (integration.PropsNobleVIPAbility, error)
@ -105,18 +118,40 @@ func (id *ID) UnmarshalJSON(data []byte) error {
}
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 {
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 {
UserID ID `json:"userId"`
Account string `json:"account"`
UserAvatar string `json:"userAvatar"`
UserNickname string `json:"userNickname"`
CountryID ID `json:"countryId,omitempty"`
CountryCode string `json:"countryCode,omitempty"`
CountryName string `json:"countryName,omitempty"`
OriginSys string `json:"originSys,omitempty"`
@ -178,6 +213,45 @@ type UnbanUserResponse struct {
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 {
Count int `json:"count"`
Records []BDLeaderView `json:"records"`
}
type BDLeaderView struct {
ID ID `json:"id"`
UserID ID `json:"userId"`
Account string `json:"account"`
UserAvatar string `json:"userAvatar"`
UserNickname string `json:"userNickname"`
Contact string `json:"contact,omitempty"`
RegionID string `json:"regionId,omitempty"`
OriginSys string `json:"originSys,omitempty"`
CreateTime time.Time `json:"createTime,omitempty"`
UpdateTime time.Time `json:"updateTime,omitempty"`
}
type AddBDLeaderRequest struct {
UserID ID `json:"userId"`
Contact string `json:"contact"`
RegionID string `json:"regionId"`
}
type AddBDLeaderResponse struct {
Success bool `json:"success"`
Leader BDLeaderView `json:"leader"`
}
func badRequest(code, message string) error {
return NewAppError(http.StatusBadRequest, code, message)
}

View File

@ -171,6 +171,7 @@ func (s *Service) sendVIPGift(ctx context.Context, manager AuthUser, target user
CreateTime: now,
}
}
previousResources := vipGiftResourcesFromState(state)
applyVIPGiftConfigToState(state, config, startAt, expireAt, now)
if err := tx.Save(state).Error; err != nil {
return nil, serverError("vip_state_save_failed", err.Error())
@ -209,7 +210,7 @@ func (s *Service) sendVIPGift(ctx context.Context, manager AuthUser, target user
}
committed = true
if err := s.grantVIPGiftResources(ctx, target.ID, manager.UserID, config, expireAt, now); err != nil {
if err := s.grantVIPGiftResources(ctx, sysOrigin, target.ID, manager.UserID, config, expireAt, previousResources, now); err != nil {
return nil, serverError("vip_resource_grant_failed", err.Error())
}
@ -301,11 +302,8 @@ func applyVIPGiftConfigToState(state *model.UserVipState, config model.VipLevelC
}
}
func (s *Service) grantVIPGiftResources(ctx context.Context, userID int64, managerID int64, config model.VipLevelConfig, expireAt time.Time, now time.Time) error {
func (s *Service) grantVIPGiftResources(ctx context.Context, sysOrigin string, userID int64, managerID int64, config model.VipLevelConfig, expireAt time.Time, additionalOldResources []vipGiftResource, now time.Time) error {
resources := vipGiftResourcesFromConfig(config)
if len(resources) == 0 {
return nil
}
days := config.DurationDays
if days <= 0 {
days = defaultVIPGiftDays
@ -337,6 +335,14 @@ func (s *Service) grantVIPGiftResources(ctx context.Context, userID int64, manag
return err
}
}
allResources, err := s.vipGiftResourcesFromSysOrigin(ctx, sysOrigin)
if err != nil {
return err
}
allResources = append(allResources, additionalOldResources...)
if err := s.cleanupSupersededVIPGiftResources(ctx, userID, resources, allResources, now); err != nil {
return err
}
if err := s.java.RemoveUserProfileCacheAll(ctx, userID); err != nil {
return fmt.Errorf("remove vip user profile cache: %w", err)
}
@ -374,6 +380,7 @@ func vipGiftResourcesFromConfig(config model.VipLevelConfig) []vipGiftResource {
{resourceID: config.EntryEffectResourceID, propsType: propsTypeRide},
{resourceID: config.ChatBubbleResourceID, propsType: propsTypeChatBubble},
{resourceID: config.FloatPictureResourceID, propsType: propsTypeFloatPicture},
{resourceID: config.BackgroundCardResourceID, propsType: propsTypeDataCard},
}
out := make([]vipGiftResource, 0, len(resources))
for _, resource := range resources {
@ -384,6 +391,104 @@ func vipGiftResourcesFromConfig(config model.VipLevelConfig) []vipGiftResource {
return out
}
func vipGiftResourcesFromState(state *model.UserVipState) []vipGiftResource {
if state == nil {
return nil
}
return compactVIPGiftResources([]vipGiftResource{
{resourceID: state.BadgeResourceID, propsType: propsTypeBadge, badge: true},
{resourceID: state.ShortBadgeResourceID, propsType: propsTypeBadge, badge: true},
{resourceID: state.AvatarFrameResourceID, propsType: propsTypeAvatarFrame},
{resourceID: state.EntryEffectResourceID, propsType: propsTypeRide},
{resourceID: state.ChatBubbleResourceID, propsType: propsTypeChatBubble},
{resourceID: state.FloatPictureResourceID, propsType: propsTypeFloatPicture},
{resourceID: state.BackgroundCardResourceID, propsType: propsTypeDataCard},
})
}
func compactVIPGiftResources(resources []vipGiftResource) []vipGiftResource {
out := make([]vipGiftResource, 0, len(resources))
for _, resource := range resources {
if resource.resourceID > 0 {
out = append(out, resource)
}
}
return out
}
func (s *Service) vipGiftResourcesFromSysOrigin(ctx context.Context, sysOrigin string) ([]vipGiftResource, error) {
var configs []model.VipLevelConfig
if err := s.db.WithContext(ctx).
Where("sys_origin = ?", normalizeSysOrigin(sysOrigin)).
Find(&configs).Error; err != nil {
return nil, err
}
resources := make([]vipGiftResource, 0, len(configs)*7)
for _, config := range configs {
resources = append(resources, vipGiftResourcesFromConfig(config)...)
}
return resources, nil
}
func (s *Service) cleanupSupersededVIPGiftResources(ctx context.Context, userID int64, targetResources []vipGiftResource, allVIPResources []vipGiftResource, now time.Time) error {
targets := make(map[string]struct{}, len(targetResources))
for _, resource := range targetResources {
targets[vipGiftResourceKey(resource)] = struct{}{}
}
badgeIDs := make([]int64, 0)
propsIDsByType := map[string][]int64{}
seen := make(map[string]struct{}, len(allVIPResources))
for _, resource := range allVIPResources {
if resource.resourceID <= 0 {
continue
}
key := vipGiftResourceKey(resource)
if _, exists := targets[key]; exists {
continue
}
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
if resource.badge {
badgeIDs = append(badgeIDs, resource.resourceID)
continue
}
if strings.TrimSpace(resource.propsType) != "" {
propsIDsByType[resource.propsType] = append(propsIDsByType[resource.propsType], resource.resourceID)
}
}
if len(badgeIDs) > 0 {
if err := s.db.WithContext(ctx).Exec(
"UPDATE user_badge_backpack SET expire_type = ?, expire_time = ?, is_use_props = ?, update_time = ? WHERE user_id = ? AND badge_id IN ? AND expire_time > ?",
"TEMPORARY", now, false, now, userID, badgeIDs, now,
).Error; err != nil {
return err
}
}
for propsType, propsIDs := range propsIDsByType {
if len(propsIDs) == 0 {
continue
}
if err := s.db.WithContext(ctx).Exec(
"UPDATE user_props_backpack SET expire_time = ?, is_use_props = ?, allow_give = ?, update_time = ? WHERE user_id = ? AND props_id IN ? AND type = ? AND expire_time > ?",
now, false, false, now, userID, propsIDs, propsType, now,
).Error; err != nil {
return err
}
}
return nil
}
func vipGiftResourceKey(resource vipGiftResource) string {
if resource.badge {
return fmt.Sprintf("BADGE:%d", resource.resourceID)
}
return fmt.Sprintf("%s:%d", resource.propsType, resource.resourceID)
}
func (s *Service) managerApplicantName(ctx context.Context, managerID int64) string {
row, err := s.loadUserRowByID(ctx, managerID)
if err != nil {

View File

@ -117,7 +117,7 @@ ORDER BY claims.last_claim_time DESC, claims.user_id ASC`
if err != nil {
return nil, err
}
rewardItems := s.loadRewardItemsMap(ctx, claimDetailLevels(details))
rewardItems := s.loadRewardItemsMap(ctx, sysOrigin, claimDetailLevels(details))
records = make([]RechargeRewardClaimRecordView, 0, len(rows))
for _, row := range rows {
records = append(records, rechargeRewardClaimRecordView(row, displayMonth, rechargeDate, details[row.UserID], rewardItems))
@ -283,6 +283,9 @@ func claimDetailPayloads(
if detail.ResourceGroupID != nil {
items = rewardItems[*detail.ResourceGroupID]
}
if items == nil {
items = []RechargeRewardRewardItem{}
}
result = append(result, RechargeRewardLevelPayload{
ID: detail.ActivityID,
Level: level,
@ -313,6 +316,9 @@ func reachedRechargeRewardLevels(
if level.RewardGroupID != nil {
items = rewardItems[*level.RewardGroupID]
}
if items == nil {
items = []RechargeRewardRewardItem{}
}
result = append(result, RechargeRewardLevelPayload{
ID: level.ID,
Level: level.Level,
@ -332,6 +338,10 @@ func reachedRechargeRewardLevels(
func rechargeRewardClaimedRewards(levels []RechargeRewardLevelPayload) []RechargeRewardClaimedRewardPayload {
result := make([]RechargeRewardClaimedRewardPayload, 0, len(levels))
for _, level := range levels {
items := level.RewardItems
if items == nil {
items = []RechargeRewardRewardItem{}
}
result = append(result, RechargeRewardClaimedRewardPayload{
Level: level.Level,
RechargeAmount: level.RechargeAmount,
@ -339,7 +349,7 @@ func rechargeRewardClaimedRewards(levels []RechargeRewardLevelPayload) []Recharg
RewardGold: level.RewardGold,
RewardGroupID: level.RewardGroupID,
RewardGroupName: level.RewardGroupName,
RewardItems: level.RewardItems,
RewardItems: items,
})
}
return result

View File

@ -39,7 +39,7 @@ func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*RechargeRew
if err != nil {
return nil, err
}
rewardItems := s.loadRewardItemsMap(ctx, levels)
rewardItems := s.loadRewardItemsMap(ctx, configRow.SysOrigin, levels)
return &RechargeRewardConfigResponse{
Configured: true,
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())
}
levels, err := s.normalizeLevelInputs(req.LevelConfigs)
levels, err := s.normalizeLevelInputs(ctx, sysOrigin, req.LevelConfigs)
if err != nil {
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))
}
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 {
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 {
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) {
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
}
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) {
sysOrigin = s.normalizeSysOrigin(sysOrigin)

View File

@ -2,7 +2,9 @@ package rechargereward
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
@ -113,7 +115,7 @@ func TestSaveConfigUpsertsBySysOrigin(t *testing.T) {
}
func TestGetHomeUsesJavaRechargeAndMarksLevels(t *testing.T) {
groupID := int64(9001)
groupID := int64(2056981527522242600)
java := rechargeRewardTestGateway{
total: map[int64][]integration.UserTotalRecharge{
42: {
@ -132,7 +134,14 @@ func TestGetHomeUsesJavaRechargeAndMarksLevels(t *testing.T) {
ID: integration.Int64Value(groupID),
Name: "Gift Box",
RewardConfigList: []integration.RewardGroupItem{
{ID: integration.Int64Value(1), Type: "PROP", Name: "Frame", Quantity: integration.Int64Value(1), Cover: "https://example.com/frame.png"},
{
ID: integration.Int64Value(1),
Type: "PROPS",
DetailType: "AVATAR_FRAME",
Name: "Frame",
Quantity: integration.Int64Value(1),
SourceURL: "https://example.com/frame.svga",
},
},
},
},
@ -196,6 +205,63 @@ INSERT INTO likei_wallet.user_freight_balance_running_water (accept_user_id, sys
if len(resp.LevelConfigs[0].RewardItems) != 1 || resp.LevelConfigs[0].RewardItems[0].Name != "Frame" {
t.Fatalf("reward items = %+v", resp.LevelConfigs[0].RewardItems)
}
if got := resp.LevelConfigs[0].RewardItems[0]; got.Type != "AVATAR_FRAME" || got.Cover != "https://example.com/frame.svga" {
t.Fatalf("reward item display fields = %+v", got)
}
if resp.LevelConfigs[1].RewardItems == nil {
t.Fatalf("gold-only level RewardItems = nil, want empty slice")
}
encoded, err := json.Marshal(resp)
if err != nil {
t.Fatalf("marshal response: %v", err)
}
body := string(encoded)
if !strings.Contains(body, `"rewardGroupId":"2056981527522242600"`) {
t.Fatalf("encoded rewardGroupId is not a string: %s", body)
}
if strings.Contains(body, `"rewardItems":null`) {
t.Fatalf("encoded rewardItems contains null: %s", body)
}
}
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) {
@ -382,6 +448,15 @@ func (g rechargeRewardTestGateway) GetRewardGroupDetail(_ context.Context, group
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) {
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.NextRechargeAmountCents = next.RechargeAmountCents
}
rewardItems := s.loadRewardItemsMap(ctx, bundle.Levels)
rewardItems := s.loadRewardItemsMap(ctx, resp.SysOrigin, bundle.Levels)
matchedLevel := 0
if matchedOK {
matchedLevel = matched.Level

View File

@ -47,6 +47,9 @@ func buildLevelPayloads(
if level.RewardGroupID != nil {
items = rewardItems[*level.RewardGroupID]
}
if items == nil {
items = []RechargeRewardRewardItem{}
}
payloads = append(payloads, RechargeRewardLevelPayload{
ID: level.ID,
Level: level.Level,
@ -74,6 +77,9 @@ func buildAdminLevelPayloads(
if level.RewardGroupID != nil {
items = rewardItems[*level.RewardGroupID]
}
if items == nil {
items = []RechargeRewardRewardItem{}
}
payloads = append(payloads, RechargeRewardLevelPayload{
ID: level.ID,
Level: level.Level,
@ -125,6 +131,7 @@ func sortLevels(levels []rechargeRewardLevelSnapshot) {
func (s *Service) loadRewardItemsMap(
ctx context.Context,
sysOrigin string,
levels []rechargeRewardLevelSnapshot,
) map[int64][]RechargeRewardRewardItem {
if s.java == nil {
@ -132,10 +139,14 @@ func (s *Service) loadRewardItemsMap(
}
groupIDs := make([]int64, 0)
seen := make(map[int64]struct{})
groupNames := make(map[int64]string)
for _, level := range levels {
if level.RewardGroupID == nil || *level.RewardGroupID <= 0 {
continue
}
if strings.TrimSpace(level.RewardGroupName) != "" {
groupNames[*level.RewardGroupID] = level.RewardGroupName
}
if _, exists := seen[*level.RewardGroupID]; exists {
continue
}
@ -147,26 +158,71 @@ func (s *Service) loadRewardItemsMap(
result := make(map[int64][]RechargeRewardRewardItem, len(groupIDs))
for _, groupID := range groupIDs {
detail, err := s.java.GetRewardGroupDetail(ctx, groupID)
if err != nil {
continue
if err != nil || !rewardGroupDetailHasItems(detail) {
if fallback, fallbackErr := s.java.FindRewardGroupDetail(ctx, sysOrigin, groupNames[groupID]); fallbackErr == nil && rewardGroupDetailHasItems(fallback) {
detail = fallback
} else {
result[groupID] = []RechargeRewardRewardItem{}
continue
}
}
items := make([]RechargeRewardRewardItem, 0, len(detail.RewardConfigList))
for _, item := range detail.RewardConfigList {
items = append(items, RechargeRewardRewardItem{
ID: int64(item.ID),
Type: strings.TrimSpace(item.Type),
Name: strings.TrimSpace(item.Name),
Content: strings.TrimSpace(item.Content),
Quantity: int64(item.Quantity),
Cover: strings.TrimSpace(item.Cover),
Remark: strings.TrimSpace(item.Remark),
})
}
result[groupID] = items
result[groupID] = rewardItemsFromGroup(detail)
}
return result
}
func rewardGroupDetailHasItems(detail integration.RewardGroupDetail) bool {
return len(detail.RewardConfigList) > 0
}
func rewardItemsFromGroup(detail integration.RewardGroupDetail) []RechargeRewardRewardItem {
items := make([]RechargeRewardRewardItem, 0, len(detail.RewardConfigList))
for _, item := range detail.RewardConfigList {
items = append(items, RechargeRewardRewardItem{
ID: int64(item.ID),
Type: rewardItemDisplayType(item),
Name: strings.TrimSpace(item.Name),
Content: strings.TrimSpace(item.Content),
Quantity: int64(item.Quantity),
Cover: firstNonEmpty(item.Cover, item.SourceURL),
Remark: strings.TrimSpace(item.Remark),
})
}
return items
}
func rewardItemDisplayType(item integration.RewardGroupItem) string {
itemType := strings.ToUpper(strings.TrimSpace(item.Type))
detailType := strings.ToUpper(strings.TrimSpace(item.DetailType))
if itemType == "PROPS" {
if detailType != "" && detailType != "PROPS" {
return detailType
}
if isVipRewardItem(item) {
return "NOBLE_VIP"
}
}
return itemType
}
func isVipRewardItem(item integration.RewardGroupItem) bool {
name := strings.ToUpper(strings.TrimSpace(item.Name))
remark := strings.ToUpper(strings.TrimSpace(item.Remark))
return strings.Contains(name, "VIP") ||
strings.Contains(name, "NOBLE") ||
strings.Contains(remark, "VIP") ||
strings.Contains(remark, "NOBLE")
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func totalRechargeCents(entries []integration.UserTotalRecharge) int64 {
var total int64
for _, entry := range entries {

View File

@ -35,6 +35,7 @@ type rechargeRewardDB interface {
type rechargeRewardJavaGateway interface {
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, 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)
GetThisMonthTotalPersonalRecharge(ctx context.Context, userID int64) (integration.DecimalString, error)
}
@ -75,7 +76,7 @@ type RechargeRewardLevelPayload struct {
RechargeAmount string `json:"rechargeAmount"`
RechargeAmountCents int64 `json:"rechargeAmountCents"`
RewardGold int64 `json:"rewardGold"`
RewardGroupID *int64 `json:"rewardGroupId,omitempty"`
RewardGroupID *int64 `json:"rewardGroupId,string,omitempty"`
RewardGroupName string `json:"rewardGroupName,omitempty"`
RewardItems []RechargeRewardRewardItem `json:"rewardItems"`
Enabled bool `json:"enabled"`
@ -175,7 +176,7 @@ type RechargeRewardClaimedRewardPayload struct {
RechargeAmount string `json:"rechargeAmount"`
RechargeAmountCents int64 `json:"rechargeAmountCents"`
RewardGold int64 `json:"rewardGold"`
RewardGroupID *int64 `json:"rewardGroupId,omitempty"`
RewardGroupID *int64 `json:"rewardGroupId,string,omitempty"`
RewardGroupName string `json:"rewardGroupName,omitempty"`
RewardItems []RechargeRewardRewardItem `json:"rewardItems"`
}

View File

@ -66,6 +66,8 @@ func (s *Service) SendRegionBroadcast(ctx context.Context, req RegionBroadcastRe
return nil, err
}
}
refreshHighWinMessage(data, messageType)
refreshCPRelationMessage(data, messageType)
body := map[string]any{
"type": messageType,
@ -182,6 +184,60 @@ func firstInt64Value(values ...any) int64 {
return 0
}
func refreshHighWinMessage(data map[string]any, messageType string) {
target := highWinPlayTarget(messageType)
if target == "" {
return
}
user := firstTextValue(data["userNickname"], data["nickname"], data["account"], data["actualAccount"], data["userId"])
multiple := firstTextValue(data["multiple"], data["winMultiple"])
amount := firstTextValue(data["winAmount"], data["awardAmount"])
if user == "" || multiple == "" || amount == "" {
return
}
data["msg"] = fmt.Sprintf("%s play %s get x %s win %s coins", user, target, multiple, amount)
}
func highWinPlayTarget(messageType string) string {
switch strings.TrimSpace(messageType) {
case "GAME_LUCKY_GIFT":
return "lucky_gift"
case "GAME_BAISHUN_WIN":
return "game"
default:
return ""
}
}
func refreshCPRelationMessage(data map[string]any, messageType string) {
if strings.TrimSpace(messageType) != "CP_RELATION_BROADCAST" {
return
}
user := firstTextValue(data["userNickname"], data["nickname"], data["account"], data["actualAccount"], data["userId"])
cpUser := firstTextValue(data["cpUserNickname"], data["acceptNickname"], data["toUserName"], data["cpUserAccount"], data["acceptAccount"], data["cpUserId"], data["acceptUserId"])
if user == "" || cpUser == "" {
return
}
switch strings.ToUpper(strings.TrimSpace(firstTextValue(data["relationType"]))) {
case "BROTHER", "BROTHERS":
data["msg"] = fmt.Sprintf("%s与%s结为兄弟", user, cpUser)
case "SISTERS", "SISTER":
data["msg"] = fmt.Sprintf("%s与%s结为姐妹", user, cpUser)
default:
data["msg"] = fmt.Sprintf("%s与%s get cp relation", user, cpUser)
}
}
func firstTextValue(values ...any) string {
for _, value := range values {
text := strings.TrimSpace(fmt.Sprint(value))
if text != "" && text != "<nil>" {
return text
}
}
return ""
}
func firstInt64FromList(value any) int64 {
switch typed := value.(type) {
case []any:

View File

@ -133,6 +133,97 @@ func TestSendRegionBroadcastResolvesRegionAndEnrichesGiftPayload(t *testing.T) {
}
}
func TestSendRegionBroadcastRefreshesHighWinMessageWithProfile(t *testing.T) {
service, db := newRegionIMGroupTestService(t)
fakeIM := &fakeIMGateway{}
service.im = fakeIM
now := time.Now()
if err := db.Create(&model.VoiceRoomRegionIMGroup{
ID: 3010,
SysOrigin: "LIKEI",
RegionCode: "AR",
RegionName: "Arab",
GroupID: "@region-ar",
GroupName: "region-ar",
Status: imGroupStatusActive,
CreateTime: now,
UpdateTime: now,
}).Error; err != nil {
t.Fatalf("seed im group: %v", err)
}
_, err := service.SendRegionBroadcast(context.Background(), RegionBroadcastRequest{
SysOrigin: "LIKEI",
Type: "GAME_BAISHUN_WIN",
Data: map[string]any{
"userId": "1001",
"roomId": "9001",
"multiple": 10,
"winAmount": 1000,
"msg": "1001 play game get x 10 win 1000 coins",
},
})
if err != nil {
t.Fatalf("SendRegionBroadcast() error = %v", err)
}
body, ok := fakeIM.sent[0].body.(map[string]any)
if !ok {
t.Fatalf("sent body type = %T", fakeIM.sent[0].body)
}
data, ok := body["data"].(map[string]any)
if !ok {
t.Fatalf("sent data type = %T", body["data"])
}
if data["msg"] != "sender play game get x 10 win 1000 coins" {
t.Fatalf("msg = %v", data["msg"])
}
}
func TestSendRegionBroadcastRefreshesCPRelationMessageWithProfiles(t *testing.T) {
service, db := newRegionIMGroupTestService(t)
fakeIM := &fakeIMGateway{}
service.im = fakeIM
now := time.Now()
if err := db.Create(&model.VoiceRoomRegionIMGroup{
ID: 3011,
SysOrigin: "LIKEI",
RegionCode: "AR",
RegionName: "Arab",
GroupID: "@region-ar",
GroupName: "region-ar",
Status: imGroupStatusActive,
CreateTime: now,
UpdateTime: now,
}).Error; err != nil {
t.Fatalf("seed im group: %v", err)
}
_, err := service.SendRegionBroadcast(context.Background(), RegionBroadcastRequest{
SysOrigin: "LIKEI",
Type: "CP_RELATION_BROADCAST",
Data: map[string]any{
"userId": "1001",
"acceptUserId": "1002",
"relationType": "SISTERS",
"msg": "1001与1002结为姐妹",
},
})
if err != nil {
t.Fatalf("SendRegionBroadcast() error = %v", err)
}
body, ok := fakeIM.sent[0].body.(map[string]any)
if !ok {
t.Fatalf("sent body type = %T", fakeIM.sent[0].body)
}
data, ok := body["data"].(map[string]any)
if !ok {
t.Fatalf("sent data type = %T", body["data"])
}
if data["msg"] != "sender与claimer结为姐妹" {
t.Fatalf("msg = %v", data["msg"])
}
}
func TestSendRegionBroadcastPrefersJavaRegionResolver(t *testing.T) {
service, db := newRegionIMGroupTestService(t)
service.regionResolver = fakeRegionResolver{

View File

@ -20,6 +20,11 @@ import (
"gorm.io/gorm/clause"
)
const (
defaultTaskCenterArchiveBatchSize = 500
maxTaskCenterArchiveBatchSize = 1000
)
// StartArchiveWorker 启动任务中心事件 COS 归档 worker。
func (s *Service) StartArchiveWorker(ctx context.Context) error {
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 {
batchSize := s.cfg.TaskCenter.Archive.BatchSize
if batchSize <= 0 {
batchSize = 10000
}
batchSize := normalizeTaskCenterArchiveBatchSize(s.cfg.TaskCenter.Archive.BatchSize)
rows, err := s.claimArchiveRows(ctx, batchSize)
if err != nil {
return err
@ -97,16 +99,46 @@ func (s *Service) claimArchiveRows(ctx context.Context, batchSize int) ([]model.
var rows []model.TaskCenterEventArchiveOutbox
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
staleProcessingBefore := time.Now().Add(-10 * time.Minute)
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
Where("status IN ? OR (status = ? AND update_time < ?)",
[]string{ArchiveStatusPending, ArchiveStatusFailed},
ArchiveStatusProcessing,
staleProcessingBefore,
).
Order("create_time asc, id asc").
Limit(batchSize).
Find(&rows).Error; err != nil {
return err
staleLimit, _ := taskCenterArchiveClaimLimits(batchSize)
if staleLimit > 0 {
var staleRows []model.TaskCenterEventArchiveOutbox
// PROCESSING 超时行保留固定接管额度,避免 PENDING/FAILED 持续堆积时旧锁永久饥饿。
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
Where("status = ? AND update_time < ?", ArchiveStatusProcessing, staleProcessingBefore).
Order("update_time asc, id asc").
Limit(staleLimit).
Find(&staleRows).Error; err != nil {
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 {
return nil
@ -124,6 +156,44 @@ func (s *Service) claimArchiveRows(ctx context.Context, batchSize int) ([]model.
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) {
bucketURL, err := url.Parse(fmt.Sprintf("https://%s.cos.%s.myqcloud.com",
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
}
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) {
java := &taskCenterTestGateway{events: map[string]bool{}}
service, db := newTestService(t, java)

View File

@ -170,7 +170,7 @@ func (s *Service) Purchase(ctx context.Context, user AuthUser, req PurchaseReque
if stateErr != nil {
return nil, stateErr
}
if err := s.ensureStateResourcesGranted(ctx, user.UserID, state, now); err != nil {
if err := s.ensureStateResourcesGranted(ctx, sysOrigin, user.UserID, state, now); err != nil {
return nil, NewAppError(http.StatusBadGateway, "vip_resource_grant_failed", err.Error())
}
resources, resourceErr := s.loadResourceLookup(ctx, appendResourceIDsFromState(nil, state))
@ -274,6 +274,7 @@ func (s *Service) Purchase(ctx context.Context, user AuthUser, req PurchaseReque
}
}
previousResources := vipGrantResourcesFromState(state)
applyPlanToState(state, plan, now)
order.Status = orderStatusSuccess
order.ErrorMessage = ""
@ -289,7 +290,7 @@ func (s *Service) Purchase(ctx context.Context, user AuthUser, req PurchaseReque
}
committed = true
if err := s.ensurePlanResourcesGranted(ctx, user.UserID, plan, now); err != nil {
if err := s.ensurePlanResourcesGranted(ctx, sysOrigin, user.UserID, plan, configs, previousResources, now); err != nil {
return nil, NewAppError(http.StatusBadGateway, "vip_resource_grant_failed", err.Error())
}
@ -536,15 +537,28 @@ func expireState(state *model.UserVipState, now time.Time) {
state.UpdateTime = now
}
func (s *Service) ensurePlanResourcesGranted(ctx context.Context, userID int64, plan purchasePlan, now time.Time) error {
return s.ensureVIPResourcesGranted(ctx, userID, vipGrantResourcesFromConfig(plan.TargetConfig), plan.ExpireAt, plan.DurationDays, now)
func (s *Service) ensurePlanResourcesGranted(ctx context.Context, sysOrigin string, userID int64, plan purchasePlan, configs map[int]model.VipLevelConfig, additionalOldResources []vipGrantResource, now time.Time) error {
targetResources := vipGrantResourcesFromConfig(plan.TargetConfig)
if err := s.ensureVIPResourcesGranted(ctx, userID, targetResources, plan.ExpireAt, plan.DurationDays, now); err != nil {
return err
}
allVIPResources := append(vipGrantResourcesFromConfigs(configs), additionalOldResources...)
return s.cleanupSupersededVIPResources(ctx, userID, targetResources, allVIPResources, now)
}
func (s *Service) ensureStateResourcesGranted(ctx context.Context, userID int64, state *model.UserVipState, now time.Time) error {
func (s *Service) ensureStateResourcesGranted(ctx context.Context, sysOrigin string, userID int64, state *model.UserVipState, now time.Time) error {
if !isActiveState(state, now) {
return nil
}
return s.ensureVIPResourcesGranted(ctx, userID, vipGrantResourcesFromState(state), state.ExpireAt, vipGrantDaysUntil(now, state.ExpireAt), now)
targetResources := vipGrantResourcesFromState(state)
if err := s.ensureVIPResourcesGranted(ctx, userID, targetResources, state.ExpireAt, vipGrantDaysUntil(now, state.ExpireAt), now); err != nil {
return err
}
configs, err := s.loadConfigMap(ctx, sysOrigin)
if err != nil {
return err
}
return s.cleanupSupersededVIPResources(ctx, userID, targetResources, vipGrantResourcesFromConfigs(configs), now)
}
func (s *Service) ensureVIPResourcesGranted(ctx context.Context, userID int64, resources []vipGrantResource, expireAt time.Time, days int, now time.Time) error {
@ -685,6 +699,17 @@ func vipGrantResourcesFromState(state *model.UserVipState) []vipGrantResource {
})
}
func vipGrantResourcesFromConfigs(configs map[int]model.VipLevelConfig) []vipGrantResource {
if len(configs) == 0 {
return nil
}
resources := make([]vipGrantResource, 0, len(configs)*7)
for _, config := range configs {
resources = append(resources, vipGrantResourcesFromConfig(config)...)
}
return compactVIPGrantResources(resources)
}
func compactVIPGrantResources(resources []vipGrantResource) []vipGrantResource {
compacted := make([]vipGrantResource, 0, len(resources))
for _, resource := range resources {
@ -695,6 +720,65 @@ func compactVIPGrantResources(resources []vipGrantResource) []vipGrantResource {
return compacted
}
func (s *Service) cleanupSupersededVIPResources(ctx context.Context, userID int64, targetResources []vipGrantResource, allVIPResources []vipGrantResource, now time.Time) error {
targets := make(map[string]struct{}, len(targetResources))
for _, resource := range targetResources {
targets[vipGrantResourceKey(resource)] = struct{}{}
}
badgeIDs := make([]int64, 0)
propsIDsByType := map[string][]int64{}
seen := make(map[string]struct{}, len(allVIPResources))
for _, resource := range allVIPResources {
if resource.ResourceID <= 0 {
continue
}
key := vipGrantResourceKey(resource)
if _, exists := targets[key]; exists {
continue
}
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
if resource.Badge {
badgeIDs = append(badgeIDs, resource.ResourceID)
continue
}
if strings.TrimSpace(resource.PropsType) != "" {
propsIDsByType[resource.PropsType] = append(propsIDsByType[resource.PropsType], resource.ResourceID)
}
}
if len(badgeIDs) > 0 {
if err := s.db.WithContext(ctx).Exec(
"UPDATE user_badge_backpack SET expire_type = ?, expire_time = ?, is_use_props = ?, update_time = ? WHERE user_id = ? AND badge_id IN ? AND expire_time > ?",
"TEMPORARY", now, false, now, userID, badgeIDs, now,
).Error; err != nil {
return err
}
}
for propsType, propsIDs := range propsIDsByType {
if len(propsIDs) == 0 {
continue
}
if err := s.db.WithContext(ctx).Exec(
"UPDATE user_props_backpack SET expire_time = ?, is_use_props = ?, allow_give = ?, update_time = ? WHERE user_id = ? AND props_id IN ? AND type = ? AND expire_time > ?",
now, false, false, now, userID, propsIDs, propsType, now,
).Error; err != nil {
return err
}
}
return nil
}
func vipGrantResourceKey(resource vipGrantResource) string {
if resource.Badge {
return fmt.Sprintf("BADGE:%d", resource.ResourceID)
}
return fmt.Sprintf("%s:%d", resource.PropsType, resource.ResourceID)
}
func vipGrantDaysUntil(now time.Time, expireAt time.Time) int {
if !expireAt.After(now) {
return 1

View File

@ -1,13 +1,18 @@
package vip
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"time"
"chatapp3-golang/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestBuildPurchasePlanNewPurchase(t *testing.T) {
@ -129,6 +134,33 @@ func TestVipResourcePayloadAcceptsStringResourceID(t *testing.T) {
}
}
func TestVipLevelPayloadMarshalsIDAsString(t *testing.T) {
payload := payloadFromConfig(model.VipLevelConfig{
ID: 2049402425177018368,
SysOrigin: "LIKEI",
Level: 1,
LevelCode: "VIP1",
DisplayName: "VIP 1",
Enabled: true,
})
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal vip level payload: %v", err)
}
if !strings.Contains(string(body), `"id":"2049402425177018368"`) {
t.Fatalf("vip level id was not emitted as string: %s", body)
}
var decoded VipLevelPayload
if err := json.Unmarshal(body, &decoded); err != nil {
t.Fatalf("unmarshal vip level payload: %v", err)
}
if decoded.ID.Int64() != 2049402425177018368 {
t.Fatalf("decoded id = %d, want 2049402425177018368", decoded.ID.Int64())
}
}
func TestResourcePayloadUsesResourceGroupDetails(t *testing.T) {
row := model.VipLevelConfig{
Level: 2,
@ -294,6 +326,47 @@ func TestVIPGrantResourcesFromConfigMapsBackpackTypes(t *testing.T) {
assertGrant(6, 16, vipPropsTypeDataCard, false)
}
func TestCleanupSupersededVIPResourcesExpiresOnlyOldVIPResources(t *testing.T) {
db := openVIPCleanupTestDB(t)
service := &Service{db: db}
now := time.Date(2026, 5, 30, 10, 0, 0, 0, time.UTC)
future := now.AddDate(0, 0, 20)
oldTime := now.AddDate(0, 0, -1)
userID := int64(1001)
seedVIPCleanupRows(t, db,
vipCleanupPropsRow{ID: 1, UserID: userID, PropsID: 101, Type: vipPropsTypeAvatarFrame, ExpireTime: future, UseProps: true, AllowGive: true, UpdateTime: oldTime},
vipCleanupPropsRow{ID: 2, UserID: userID, PropsID: 201, Type: vipPropsTypeAvatarFrame, ExpireTime: future, UseProps: true, AllowGive: true, UpdateTime: oldTime},
vipCleanupPropsRow{ID: 3, UserID: userID, PropsID: 999, Type: vipPropsTypeAvatarFrame, ExpireTime: future, UseProps: true, AllowGive: true, UpdateTime: oldTime},
vipCleanupPropsRow{ID: 4, UserID: userID, PropsID: 102, Type: vipPropsTypeRide, ExpireTime: future, UseProps: true, AllowGive: true, UpdateTime: oldTime},
)
seedVIPCleanupBadges(t, db,
vipCleanupBadgeRow{ID: 1, UserID: userID, BadgeID: 100, ExpireTime: future, UseProps: true, UpdateTime: oldTime},
vipCleanupBadgeRow{ID: 2, UserID: userID, BadgeID: 200, ExpireTime: future, UseProps: true, UpdateTime: oldTime},
)
target := vipGrantResourcesFromConfig(model.VipLevelConfig{
BadgeResourceID: 200,
AvatarFrameResourceID: 201,
})
all := append(vipGrantResourcesFromConfig(model.VipLevelConfig{
BadgeResourceID: 100,
AvatarFrameResourceID: 101,
EntryEffectResourceID: 102,
}), target...)
if err := service.cleanupSupersededVIPResources(context.Background(), userID, target, all, now); err != nil {
t.Fatalf("cleanupSupersededVIPResources() error = %v", err)
}
assertVIPCleanupProps(t, db, 101, now, false, false)
assertVIPCleanupProps(t, db, 102, now, false, false)
assertVIPCleanupProps(t, db, 201, future, true, true)
assertVIPCleanupProps(t, db, 999, future, true, true)
assertVIPCleanupBadge(t, db, 100, now, false)
assertVIPCleanupBadge(t, db, 200, future, true)
}
func TestVIPGrantDaysUntilCeilsPartialDay(t *testing.T) {
now := time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC)
expireAt := now.Add((24 * time.Hour) + time.Second)
@ -306,6 +379,89 @@ func TestVIPGrantDaysUntilCeilsPartialDay(t *testing.T) {
}
}
type vipCleanupPropsRow struct {
ID int64 `gorm:"column:id;primaryKey"`
UserID int64 `gorm:"column:user_id"`
PropsID int64 `gorm:"column:props_id"`
Type string `gorm:"column:type"`
ExpireTime time.Time `gorm:"column:expire_time"`
UseProps bool `gorm:"column:is_use_props"`
AllowGive bool `gorm:"column:allow_give"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (vipCleanupPropsRow) TableName() string {
return "user_props_backpack"
}
type vipCleanupBadgeRow struct {
ID int64 `gorm:"column:id;primaryKey"`
UserID int64 `gorm:"column:user_id"`
BadgeID int64 `gorm:"column:badge_id"`
ExpireType string `gorm:"column:expire_type"`
ExpireTime time.Time `gorm:"column:expire_time"`
UseProps bool `gorm:"column:is_use_props"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (vipCleanupBadgeRow) TableName() string {
return "user_badge_backpack"
}
func openVIPCleanupTestDB(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(&vipCleanupPropsRow{}, &vipCleanupBadgeRow{}); err != nil {
t.Fatalf("auto migrate cleanup tables: %v", err)
}
return db
}
func seedVIPCleanupRows(t *testing.T, db *gorm.DB, rows ...vipCleanupPropsRow) {
t.Helper()
for _, row := range rows {
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seed props row: %v", err)
}
}
}
func seedVIPCleanupBadges(t *testing.T, db *gorm.DB, rows ...vipCleanupBadgeRow) {
t.Helper()
for _, row := range rows {
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seed badge row: %v", err)
}
}
}
func assertVIPCleanupProps(t *testing.T, db *gorm.DB, propsID int64, expireTime time.Time, useProps bool, allowGive bool) {
t.Helper()
var row vipCleanupPropsRow
if err := db.Where("props_id = ?", propsID).First(&row).Error; err != nil {
t.Fatalf("load props %d: %v", propsID, err)
}
if !row.ExpireTime.Equal(expireTime) || row.UseProps != useProps || row.AllowGive != allowGive {
t.Fatalf("props %d = expire:%s use:%v allow:%v, want expire:%s use:%v allow:%v",
propsID, row.ExpireTime, row.UseProps, row.AllowGive, expireTime, useProps, allowGive)
}
}
func assertVIPCleanupBadge(t *testing.T, db *gorm.DB, badgeID int64, expireTime time.Time, useProps bool) {
t.Helper()
var row vipCleanupBadgeRow
if err := db.Where("badge_id = ?", badgeID).First(&row).Error; err != nil {
t.Fatalf("load badge %d: %v", badgeID, err)
}
if !row.ExpireTime.Equal(expireTime) || row.UseProps != useProps {
t.Fatalf("badge %d = expire:%s use:%v, want expire:%s use:%v",
badgeID, row.ExpireTime, row.UseProps, expireTime, useProps)
}
}
func vipTestConfigs() map[int]model.VipLevelConfig {
configs := make(map[int]model.VipLevelConfig, levelCount)
for level := 1; level <= levelCount; level++ {

View File

@ -83,7 +83,7 @@ type ConfigResponse struct {
}
type VipLevelPayload struct {
ID int64 `json:"id"`
ID ResourceID `json:"id"`
Level int `json:"level"`
LevelCode string `json:"levelCode"`
DisplayName string `json:"displayName"`
@ -365,7 +365,7 @@ func payloadFromConfigWithResources(row model.VipLevelConfig, resources vipResou
code = levelCode(row.Level)
}
return VipLevelPayload{
ID: row.ID,
ID: ResourceID(row.ID),
Level: row.Level,
LevelCode: code,
DisplayName: displayName,

View File

@ -182,6 +182,12 @@ func (s *Service) completeClaimWallet(ctx context.Context, claim model.VoiceRoom
}
now := time.Now()
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var lockedPacket model.VoiceRoomRedPacket
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", claim.PacketID).
First(&lockedPacket).Error; err != nil {
return err
}
if err := tx.Model(&model.VoiceRoomRedPacketClaim{}).
Where("id = ?", claim.ID).
Updates(map[string]any{
@ -200,7 +206,7 @@ func (s *Service) completeClaimWallet(ctx context.Context, claim model.VoiceRoom
return err
}
return tx.Model(&model.VoiceRoomRedPacket{}).
Where("id = ? AND remain_count = 0 AND status = ?", claim.PacketID, packetStatusActive).
Where("id = ? AND remain_count = 0 AND status = ?", lockedPacket.ID, packetStatusActive).
Updates(map[string]any{
"status": packetStatusFinished,
"update_time": now,

View File

@ -399,6 +399,9 @@ func TestInRoomRewardWorkerSnapshotsSelectsAndGrants(t *testing.T) {
if err := rdb.SAdd(ctx, onlineRoomKey("LIKEI", roomID), userID).Err(); err != nil {
t.Fatalf("sadd online: %v", err)
}
if err := rdb.Set(ctx, userRoomKey("LIKEI", userID), strconv.FormatInt(roomID, 10), time.Minute).Err(); err != nil {
t.Fatalf("set user room: %v", err)
}
if err := rdb.Set(ctx, userSeenKey("LIKEI", userID), strconv.FormatInt(time.Now().UnixMilli(), 10), time.Minute).Err(); err != nil {
t.Fatalf("set seen: %v", err)
}
@ -471,6 +474,92 @@ func TestInRoomRewardWorkerSnapshotsSelectsAndGrants(t *testing.T) {
}
}
func TestInRoomRewardWorkerSkipsUsersNoLongerInRoom(t *testing.T) {
gateway := newFakeRocketGateway()
service, db, rdb, mr := newTestServiceWithRedis(t, gateway)
defer mr.Close()
ctx := context.Background()
now := time.Now()
seedRocketConfig(t, db)
seedLevelConfigs(t, db, []int64{100, 200, 300, 400, 500, 600})
seedRewardConfig(t, db, 1, rewardSceneTop1, rewardTypeGold, 10)
seedRewardConfig(t, db, 1, rewardSceneIgnite, rewardTypeGold, 5)
seedRewardConfigWithCopies(t, db, 1, rewardSceneInRoom, rewardTypeGold, 1, 4)
roomID := int64(9001)
for _, userID := range []int64{1001, 1002} {
if err := rdb.SAdd(ctx, onlineRoomKey("LIKEI", roomID), userID).Err(); err != nil {
t.Fatalf("sadd active online: %v", err)
}
if err := rdb.Set(ctx, userRoomKey("LIKEI", userID), strconv.FormatInt(roomID, 10), time.Minute).Err(); err != nil {
t.Fatalf("set active room: %v", err)
}
if err := rdb.Set(ctx, userSeenKey("LIKEI", userID), strconv.FormatInt(now.UnixMilli(), 10), time.Minute).Err(); err != nil {
t.Fatalf("set active seen: %v", err)
}
}
staleUsers := []int64{1003, 1004}
for _, userID := range staleUsers {
if err := rdb.SAdd(ctx, onlineRoomKey("LIKEI", roomID), userID).Err(); err != nil {
t.Fatalf("sadd stale online: %v", err)
}
if err := rdb.Set(ctx, userSeenKey("LIKEI", userID), strconv.FormatInt(now.UnixMilli(), 10), time.Minute).Err(); err != nil {
t.Fatalf("set stale seen: %v", err)
}
}
if err := rdb.Set(ctx, userRoomKey("LIKEI", staleUsers[1]), strconv.FormatInt(roomID+1, 10), time.Minute).Err(); err != nil {
t.Fatalf("set stale room: %v", err)
}
payload := `{
"trackId":"track-launch-in-room-stale",
"sysOrigin":"LIKEI",
"sendUserId":"1001",
"roomId":"9001",
"quantity":1,
"giftConfig":{"id":"11","giftCandy":100,"type":"GOLD","giftTab":"NORMAL"},
"createTime":` + strconvFormatMillis(now) + `
}`
resp, err := service.ProcessGiftPayload(ctx, payload)
if err != nil {
t.Fatalf("ProcessGiftPayload() error = %v", err)
}
if !resp.Processed {
t.Fatalf("response = %+v, want processed", resp)
}
if _, err := service.ProcessDueLaunches(ctx, now.Add(11*time.Second), 10); err != nil {
t.Fatalf("ProcessDueLaunches() error = %v", err)
}
if _, err := service.ProcessDueInRoomRewards(ctx, now.Add(30*time.Second), 10); err != nil {
t.Fatalf("ProcessDueInRoomRewards() error = %v", err)
}
var snapshotCount int64
if err := db.Model(&model.VoiceRoomRocketAudienceSnapshot{}).Count(&snapshotCount).Error; err != nil {
t.Fatalf("count snapshot: %v", err)
}
if snapshotCount != 2 {
t.Fatalf("snapshot count = %d, want 2 active users only", snapshotCount)
}
for _, userID := range staleUsers {
var userSnapshotCount int64
if err := db.Model(&model.VoiceRoomRocketAudienceSnapshot{}).Where("user_id = ?", userID).Count(&userSnapshotCount).Error; err != nil {
t.Fatalf("count stale snapshot: %v", err)
}
if userSnapshotCount != 0 {
t.Fatalf("stale user %d snapshot count = %d, want 0", userID, userSnapshotCount)
}
var rewardCount int64
if err := db.Model(&model.VoiceRoomRocketRewardRecord{}).Where("user_id = ? AND reward_scene = ?", userID, rewardSceneInRoom).Count(&rewardCount).Error; err != nil {
t.Fatalf("count stale reward: %v", err)
}
if rewardCount != 0 {
t.Fatalf("stale user %d in-room reward count = %d, want 0", userID, rewardCount)
}
}
}
func TestProcessPendingRewardsGrantsBadgeThroughBadgeGateway(t *testing.T) {
gateway := newFakeRocketGateway()
service, db := newTestService(t)

View File

@ -33,9 +33,14 @@ func (s *Service) listOnlineRoomUsers(ctx context.Context, sysOrigin string, roo
continue
}
seen[userID] = struct{}{}
seenAt, active := s.loadActiveRoomSeenTime(ctx, sysOrigin, roomID, userID, now)
if !active {
_ = s.repo.Redis.SRem(ctx, onlineRoomKey(sysOrigin, roomID), userID).Err()
continue
}
users = append(users, roomAudienceUser{
UserID: userID,
LastSeenTime: s.loadUserSeenTime(ctx, sysOrigin, userID, now),
LastSeenTime: seenAt,
})
}
sort.Slice(users, func(i, j int) bool {
@ -44,6 +49,43 @@ func (s *Service) listOnlineRoomUsers(ctx context.Context, sysOrigin string, roo
return users, nil
}
func (s *Service) loadActiveRoomSeenTime(ctx context.Context, sysOrigin string, roomID, userID int64, now time.Time) (time.Time, bool) {
if s.repo.Redis == nil {
return time.Time{}, false
}
rawRoomID, err := s.repo.Redis.Get(ctx, userRoomKey(sysOrigin, userID)).Result()
if err != nil {
return time.Time{}, false
}
currentRoomID, err := strconv.ParseInt(strings.TrimSpace(rawRoomID), 10, 64)
if err != nil || currentRoomID != roomID {
return time.Time{}, false
}
seenAt, ok := s.loadUserSeenTimeStrict(ctx, sysOrigin, userID, now)
if !ok {
return time.Time{}, false
}
if ttl := s.roomPresenceTTL(); ttl > 0 && now.Sub(seenAt) > ttl {
return time.Time{}, false
}
return seenAt, true
}
func (s *Service) loadUserSeenTimeStrict(ctx context.Context, sysOrigin string, userID int64, fallback time.Time) (time.Time, bool) {
if s.repo.Redis == nil {
return time.Time{}, false
}
raw, err := s.repo.Redis.Get(ctx, userSeenKey(sysOrigin, userID)).Result()
if err != nil {
return time.Time{}, false
}
millis, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil || millis <= 0 {
return time.Time{}, false
}
return resolveMillisTime(millis, fallback), true
}
func (s *Service) loadUserSeenTime(ctx context.Context, sysOrigin string, userID int64, fallback time.Time) time.Time {
if s.repo.Redis == nil {
return fallback
@ -59,10 +101,22 @@ func (s *Service) loadUserSeenTime(ctx context.Context, sysOrigin string, userID
return resolveMillisTime(millis, fallback)
}
func (s *Service) roomPresenceTTL() time.Duration {
seconds := s.cfg.VoiceRoomRedPacket.PresenceTTLSeconds
if seconds <= 0 {
seconds = defaultPresenceTTLSeconds
}
return time.Duration(seconds) * time.Second
}
func onlineRoomKey(sysOrigin string, roomID int64) string {
return fmt.Sprintf("voice_room:online:%s:%d", strings.ToUpper(strings.TrimSpace(sysOrigin)), roomID)
}
func userRoomKey(sysOrigin string, userID int64) string {
return fmt.Sprintf("voice_room:user_room:%s:%d", strings.ToUpper(strings.TrimSpace(sysOrigin)), userID)
}
func userSeenKey(sysOrigin string, userID int64) string {
return fmt.Sprintf("voice_room:user_seen:%s:%d", strings.ToUpper(strings.TrimSpace(sysOrigin)), userID)
}

View File

@ -32,6 +32,7 @@ const (
defaultWorkerBatchSize = 100
defaultWorkerInterval = time.Second
defaultRoomLockTTL = 10 * time.Second
defaultPresenceTTLSeconds = 60
statusCharging = "CHARGING"
statusLaunching = "LAUNCHING"

View File

@ -126,16 +126,18 @@ func (s *Service) PageAdminRecords(
records = append(records, payload)
}
return &RecordPageResponse{
Records: records,
Total: total,
Current: cursor,
Size: limit,
TotalPaidGold: summary.TotalPaidGold,
TotalRewardGold: summary.TotalRewardGold,
TotalDrawTimes: summary.TotalDrawTimes,
ParticipantCount: summary.ParticipantCount,
StartTime: formatDateTime(filter.StartTime),
EndTime: formatDateTime(filter.EndTime),
Records: records,
Total: total,
Current: cursor,
Size: limit,
TotalPaidGold: summary.TotalPaidGold,
TotalRewardGold: summary.TotalRewardGold,
TotalGiftRewardGold: summary.TotalGiftRewardGold,
TotalDecorationRewardGold: summary.TotalDecorationRewardGold,
TotalDrawTimes: summary.TotalDrawTimes,
ParticipantCount: summary.ParticipantCount,
StartTime: formatDateTime(filter.StartTime),
EndTime: formatDateTime(filter.EndTime),
}, nil
}
@ -250,10 +252,12 @@ type wheelAdminRecordFilter struct {
}
type wheelAdminRecordSummary struct {
TotalPaidGold int64
TotalRewardGold int64
TotalDrawTimes int64
ParticipantCount int64
TotalPaidGold int64
TotalRewardGold int64
TotalGiftRewardGold int64
TotalDecorationRewardGold int64
TotalDrawTimes int64
ParticipantCount int64
}
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) {
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).
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).
Scan(&summary.TotalRewardGold).Error; err != nil {
Select(`
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
}
if err := s.adminRecordQuery(ctx, filter).

View File

@ -104,8 +104,8 @@ func TestPageAdminRecordsIncludesUserInfoAndRangeSummary(t *testing.T) {
PaidGold: 200,
RewardType: rewardTypeResource,
ResourceID: &giftID,
ResourceType: resourceTypeGift,
ResourceName: "Gift B",
ResourceType: "AVATAR_FRAME",
ResourceName: "Frame B",
DisplayGoldAmount: 30,
Probability: 500000,
Status: drawStatusSuccess,
@ -149,6 +149,12 @@ func TestPageAdminRecordsIncludesUserInfoAndRangeSummary(t *testing.T) {
if resp.TotalRewardGold != 150 {
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 {
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.
type RecordPageResponse struct {
Records []DrawRecordPayload `json:"records"`
Total int64 `json:"total"`
Current int `json:"current"`
Size int `json:"size"`
TotalPaidGold int64 `json:"totalPaidGold,omitempty"`
TotalRewardGold int64 `json:"totalRewardGold,omitempty"`
TotalDrawTimes int64 `json:"totalDrawTimes,omitempty"`
ParticipantCount int64 `json:"participantCount,omitempty"`
StartTime string `json:"startTime,omitempty"`
EndTime string `json:"endTime,omitempty"`
Records []DrawRecordPayload `json:"records"`
Total int64 `json:"total"`
Current int `json:"current"`
Size int `json:"size"`
TotalPaidGold int64 `json:"totalPaidGold,omitempty"`
TotalRewardGold int64 `json:"totalRewardGold,omitempty"`
TotalGiftRewardGold int64 `json:"totalGiftRewardGold,omitempty"`
TotalDecorationRewardGold int64 `json:"totalDecorationRewardGold,omitempty"`
TotalDrawTimes int64 `json:"totalDrawTimes,omitempty"`
ParticipantCount int64 `json:"participantCount,omitempty"`
StartTime string `json:"startTime,omitempty"`
EndTime string `json:"endTime,omitempty"`
}
func normalizeSysOrigin(sysOrigin string) string {

View File

@ -0,0 +1,67 @@
CREATE TABLE IF NOT EXISTS `first_recharge_reward_config` (
`id` bigint NOT NULL COMMENT '主键ID',
`sys_origin` varchar(32) NOT NULL 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 '创建时间',
`update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_first_recharge_reward_sys_origin` (`sys_origin`),
KEY `idx_first_recharge_reward_enabled` (`enabled`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励配置';
CREATE TABLE IF NOT EXISTS `first_recharge_reward_level` (
`id` bigint NOT NULL COMMENT '主键ID',
`config_id` bigint NOT NULL COMMENT '首冲奖励主配置ID',
`level` int NOT NULL 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_name` varchar(255) NOT NULL DEFAULT '' COMMENT '奖励资源组名称快照',
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用',
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
`update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_first_recharge_reward_level` (`config_id`, `level`),
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`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励充值档位';
CREATE TABLE IF NOT EXISTS `first_recharge_reward_grant_record` (
`id` bigint NOT NULL COMMENT '主键ID',
`event_id` varchar(128) NOT NULL COMMENT '充值事件幂等ID',
`config_id` bigint NOT NULL COMMENT '首冲奖励主配置ID',
`level_id` bigint NOT NULL COMMENT '首冲奖励档位ID',
`sys_origin` varchar(32) NOT NULL COMMENT '来源系统',
`user_id` bigint NOT NULL COMMENT '用户ID',
`account` varchar(64) NOT NULL DEFAULT '' COMMENT '用户账号快照',
`user_avatar` varchar(1024) NOT NULL DEFAULT '' COMMENT '用户头像快照',
`user_nickname` varchar(255) NOT NULL DEFAULT '' COMMENT '用户昵称快照',
`country_code` varchar(32) NOT NULL DEFAULT '' COMMENT '国家编码快照',
`country_name` varchar(128) NOT NULL DEFAULT '' COMMENT '国家名称快照',
`recharge_amount_cents` bigint NOT NULL DEFAULT '0' COMMENT '本次充值金额,单位为分',
`recharge_threshold_cents` bigint NOT NULL DEFAULT '0' COMMENT '命中档位门槛,单位为分',
`level` int NOT NULL COMMENT '命中档位',
`pay_platform` varchar(64) NOT NULL DEFAULT '' COMMENT '支付平台GOOGLE/MIFA_PAY/PAYER_MAX等',
`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',
`reward_group_id` bigint NOT NULL DEFAULT '0' COMMENT '奖励资源组ID',
`reward_group_name` varchar(255) NOT NULL DEFAULT '' COMMENT '奖励资源组名称快照',
`reward_group_track_id` bigint NOT NULL DEFAULT '0' COMMENT '奖励资源组发放幂等追踪ID',
`status` varchar(32) NOT NULL DEFAULT 'PENDING' COMMENT '整体发放状态PENDING/SUCCESS/FAILED',
`reward_group_status` varchar(32) NOT NULL DEFAULT 'PENDING' COMMENT '奖励组发放状态PENDING/SUCCESS/FAILED',
`retry_count` int NOT NULL DEFAULT '0' COMMENT '重试次数',
`last_error` varchar(1024) NOT NULL DEFAULT '' COMMENT '最近一次失败原因',
`event_time` datetime(3) DEFAULT NULL COMMENT '充值事件时间',
`sent_at` datetime(3) DEFAULT NULL COMMENT '发放成功时间',
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
`update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_first_recharge_reward_event` (`event_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_grant_google_product` (`google_product_id`),
KEY `idx_first_recharge_reward_order` (`source_order_id`)
) 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,29 @@
SET @current_schema := DATABASE();
SET @voice_room_red_packet_slice_claim_order_idx_columns := (
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index)
FROM information_schema.statistics
WHERE table_schema = @current_schema
AND table_name = 'voice_room_red_packet_slice'
AND index_name = 'idx_voice_room_red_packet_slice_claim_order'
);
SET @drop_voice_room_red_packet_slice_claim_order_idx_sql := IF(
@voice_room_red_packet_slice_claim_order_idx_columns IS NOT NULL
AND @voice_room_red_packet_slice_claim_order_idx_columns <> 'packet_id,status,sort_no,id',
'ALTER TABLE `voice_room_red_packet_slice` DROP INDEX `idx_voice_room_red_packet_slice_claim_order`',
'SELECT 1'
);
PREPARE drop_voice_room_red_packet_slice_claim_order_idx_stmt FROM @drop_voice_room_red_packet_slice_claim_order_idx_sql;
EXECUTE drop_voice_room_red_packet_slice_claim_order_idx_stmt;
DEALLOCATE PREPARE drop_voice_room_red_packet_slice_claim_order_idx_stmt;
SET @add_voice_room_red_packet_slice_claim_order_idx_sql := IF(
@voice_room_red_packet_slice_claim_order_idx_columns IS NULL
OR @voice_room_red_packet_slice_claim_order_idx_columns <> 'packet_id,status,sort_no,id',
'ALTER TABLE `voice_room_red_packet_slice` ADD INDEX `idx_voice_room_red_packet_slice_claim_order` (`packet_id`, `status`, `sort_no`, `id`)',
'SELECT 1'
);
PREPARE add_voice_room_red_packet_slice_claim_order_idx_stmt FROM @add_voice_room_red_packet_slice_claim_order_idx_sql;
EXECUTE add_voice_room_red_packet_slice_claim_order_idx_stmt;
DEALLOCATE PREPARE add_voice_room_red_packet_slice_claim_order_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;

View File

@ -429,6 +429,7 @@ func seedPresence(ctx context.Context, rdb *redis.Client, roomID int64, users []
key := fmt.Sprintf("voice_room:online:LIKEI:%d", roomID)
for _, userID := range users {
must(rdb.SAdd(ctx, key, userID).Err(), "seed online room")
must(rdb.Set(ctx, fmt.Sprintf("voice_room:user_room:LIKEI:%d", userID), strconv.FormatInt(roomID, 10), time.Minute).Err(), "seed user room")
must(rdb.Set(ctx, fmt.Sprintf("voice_room:user_seen:LIKEI:%d", userID), strconv.FormatInt(time.Now().UnixMilli(), 10), time.Minute).Err(), "seed user seen")
}
must(rdb.Expire(ctx, key, time.Minute).Err(), "expire online room")

View File

@ -109,6 +109,9 @@ type verifySummary struct {
Top1PopupBeforeAck int `json:"top1PopupBeforeAck"`
Top1PopupAfterAck int `json:"top1PopupAfterAck"`
IgnitePopupCount int `json:"ignitePopupCount"`
LeftUserID int64 `json:"leftUserId,string"`
LeftUserSnapshotCount int64 `json:"leftUserSnapshotCount"`
LeftUserRewardCount int64 `json:"leftUserRewardCount"`
RewardRecordCount int64 `json:"rewardRecordCount"`
SnapshotCount int64 `json:"snapshotCount"`
SelectedAudienceCount int64 `json:"selectedAudienceCount"`
@ -162,6 +165,11 @@ func main() {
cleanup(ctx, db, rdb, room.RoomID, day)
seedConfig(ctx, service, db)
seedPresence(ctx, rdb, room.RoomID, append([]int64{room.UserID, igniteUserID}, otherUsers[1:]...))
leftUserID := otherUsers[2]
must(rdb.Del(ctx,
fmt.Sprintf("voice_room:user_room:LIKEI:%d", leftUserID),
fmt.Sprintf("voice_room:user_seen:LIKEI:%d", leftUserID),
).Err(), "simulate left user presence")
initial, err := service.GetStatus(ctx, user, room.RoomID)
must(err, "initial status")
@ -179,7 +187,17 @@ func main() {
secondPayload := mqEnvelope(giftPayload("LOCAL_VERIFY_TRACK_2", igniteUserID, room.RoomID, 80, now.Add(time.Second)))
second, err := service.ProcessGiftPayload(ctx, secondPayload)
must(err, "process second gift")
assert(second.Processed && second.AcceptedEnergy == 40 && second.Launched && second.LaunchNo != "", "second gift result mismatch")
assert(second.Processed && second.AcceptedEnergy == 40 && !second.Launched, "second gift result mismatch")
launched, err := service.ProcessDueLaunches(ctx, now.Add(11*time.Second), 10)
must(err, "process due launches")
assert(launched == 1, "due launch count mismatch")
var launch model.VoiceRoomRocketLaunch
must(db.WithContext(ctx).
Where("room_id = ? AND day_key = ?", room.RoomID, day).
Order("create_time DESC").
First(&launch).Error, "load launch")
assert(strings.TrimSpace(launch.LaunchNo) != "", "launch no missing")
finalStatus, err := service.GetStatus(ctx, user, room.RoomID)
must(err, "final status")
@ -213,7 +231,7 @@ func main() {
must(err, "top1 reward records")
assert(len(rewardRecords.Records) >= 1, "top1 reward record count mismatch")
dueProcessed, err := service.ProcessDueInRoomRewards(ctx, time.Now().Add(2*time.Second), 10)
dueProcessed, err := service.ProcessDueInRoomRewards(ctx, now.Add(30*time.Second), 10)
must(err, "process due in-room rewards")
assert(dueProcessed == 1, "due in-room reward count mismatch")
@ -233,10 +251,22 @@ func main() {
must(db.WithContext(ctx).Model(&model.VoiceRoomRocketAudienceSnapshot{}).
Where("room_id = ?", room.RoomID).
Count(&snapshotCount).Error, "count snapshots")
assert(snapshotCount == 3, "snapshot count mismatch")
var selectedCount int64
must(db.WithContext(ctx).Model(&model.VoiceRoomRocketAudienceSnapshot{}).
Where("room_id = ? AND selected = ?", room.RoomID, true).
Count(&selectedCount).Error, "count selected snapshots")
assert(selectedCount == 3, "selected snapshot count mismatch")
var leftSnapshotCount int64
must(db.WithContext(ctx).Model(&model.VoiceRoomRocketAudienceSnapshot{}).
Where("room_id = ? AND user_id = ?", room.RoomID, leftUserID).
Count(&leftSnapshotCount).Error, "count left user snapshots")
assert(leftSnapshotCount == 0, "left user snapshot mismatch")
var leftRewardCount int64
must(db.WithContext(ctx).Model(&model.VoiceRoomRocketRewardRecord{}).
Where("room_id = ? AND day_key = ? AND user_id = ?", room.RoomID, day, leftUserID).
Count(&leftRewardCount).Error, "count left user rewards")
assert(leftRewardCount == 0, "left user reward mismatch")
var successCount int64
must(db.WithContext(ctx).Model(&model.VoiceRoomRocketRewardRecord{}).
Where("room_id = ? AND day_key = ? AND grant_status = ?", room.RoomID, day, "SUCCESS").
@ -258,13 +288,16 @@ func main() {
FirstAcceptedEnergy: first.AcceptedEnergy,
SecondRawEnergy: 80,
SecondAcceptedEnergy: second.AcceptedEnergy,
LaunchNo: second.LaunchNo,
LaunchNo: launch.LaunchNo,
StatusLevel: finalStatus.CurrentLevel,
StatusEnergy: finalStatus.CurrentEnergy,
KingRecords: len(king.Records),
Top1PopupBeforeAck: len(top1Popups.Records),
Top1PopupAfterAck: len(top1AfterAck.Records),
IgnitePopupCount: len(ignitePopups.Records),
LeftUserID: leftUserID,
LeftUserSnapshotCount: leftSnapshotCount,
LeftUserRewardCount: leftRewardCount,
RewardRecordCount: rewardCount,
SnapshotCount: snapshotCount,
SelectedAudienceCount: selectedCount,
@ -312,6 +345,7 @@ func seedPresence(ctx context.Context, rdb *redis.Client, roomID int64, users []
key := fmt.Sprintf("voice_room:online:LIKEI:%d", roomID)
for _, userID := range users {
must(rdb.SAdd(ctx, key, userID).Err(), "seed online room")
must(rdb.Set(ctx, fmt.Sprintf("voice_room:user_room:LIKEI:%d", userID), strconv.FormatInt(roomID, 10), time.Minute).Err(), "seed user room")
must(rdb.Set(ctx, fmt.Sprintf("voice_room:user_seen:LIKEI:%d", userID), strconv.FormatInt(time.Now().UnixMilli(), 10), time.Minute).Err(), "seed user seen")
}
must(rdb.Expire(ctx, key, time.Minute).Err(), "expire online room")
@ -373,7 +407,7 @@ func seedConfig(ctx context.Context, service *voiceroomrocket.Service, db *gorm.
Rewards: []voiceroomrocket.RewardConfigPayload{
{Level: 1, RewardScene: "TOP1", RewardType: "GOLD", RewardName: "Local Verify Top1 Gold", RewardAmount: 10, Enabled: true, Sort: 1},
{Level: 1, RewardScene: "IGNITE", RewardType: "GOLD", RewardName: "Local Verify Ignite Gold", RewardAmount: 5, Enabled: true, Sort: 1},
{Level: 1, RewardScene: "IN_ROOM", RewardType: "GOLD", RewardName: "Local Verify In Room Gold", RewardAmount: 1, Enabled: true, Sort: 1},
{Level: 1, RewardScene: "IN_ROOM", RewardType: "GOLD", RewardName: "Local Verify In Room Gold", RewardAmount: 1, RewardCopies: 3, Enabled: true, Sort: 1},
},
})
must(err, "save rewards")