Compare commits

..

No commits in common. "main" and "release/redis-memory-fix-20260521" have entirely different histories.

100 changed files with 307 additions and 10856 deletions

View File

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

View File

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

View File

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

View File

@ -8,34 +8,25 @@ import (
"time" "time"
) )
const (
cpRelationBroadcastMQConsumerGroup = "cp-relation-broadcast"
cpRelationBroadcastMQTopic = "RC_DEFAULT_APP_ORDINARY"
cpRelationBroadcastMQTag = "cp_relation_broadcast"
)
// Config 是按运行层和业务模块分段后的应用配置。 // Config 是按运行层和业务模块分段后的应用配置。
type Config struct { type Config struct {
HTTP HTTPConfig HTTP HTTPConfig
Store StoreConfig Store StoreConfig
Java JavaConfig Java JavaConfig
Worker WorkerConfig Worker WorkerConfig
Invite InviteConfig Invite InviteConfig
RechargeReward RechargeRewardConfig RechargeReward RechargeRewardConfig
FirstRechargeReward FirstRechargeRewardConfig TaskCenter TaskCenterConfig
TaskCenter TaskCenterConfig WeekStar WeekStarConfig
WeekStar WeekStarConfig RoomTurnoverReward RoomTurnoverRewardConfig
RoomTurnoverReward RoomTurnoverRewardConfig VoiceRoomRedPacket VoiceRoomRedPacketConfig
VoiceRoomRedPacket VoiceRoomRedPacketConfig VoiceRoomRocket VoiceRoomRocketConfig
VoiceRoomRocket VoiceRoomRocketConfig TencentIM TencentIMConfig
CPRelationBroadcast CPRelationBroadcastConfig Baishun BaishunConfig
TencentIM TencentIMConfig Lingxian LingxianConfig
Baishun BaishunConfig GameOpen GameOpenConfig
Lingxian LingxianConfig LuckyGift LuckyGiftConfig
Hotgame HotgameConfig BinanceRecharge BinanceRechargeConfig
GameOpen GameOpenConfig
LuckyGift LuckyGiftConfig
BinanceRecharge BinanceRechargeConfig
} }
// HTTPConfig 保存 HTTP 服务运行配置。 // HTTPConfig 保存 HTTP 服务运行配置。
@ -97,25 +88,6 @@ type RechargeRewardConfig struct {
CoinSellerGoldPerUSD int64 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 保存任务中心模块配置。 // TaskCenterConfig 保存任务中心模块配置。
type TaskCenterConfig struct { type TaskCenterConfig struct {
DefaultSysOrigin string DefaultSysOrigin string
@ -220,25 +192,6 @@ type VoiceRoomRocketMQConfig struct {
Tag string 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 配置。 // TencentIMConfig 保存腾讯 IM REST API 配置。
type TencentIMConfig struct { type TencentIMConfig struct {
AppID int64 AppID int64
@ -269,12 +222,6 @@ type LingxianConfig struct {
AppKey string AppKey string
} }
// HotgameConfig 保存热游模块的环境兜底配置。
type HotgameConfig struct {
AppKey string
CallbackBaseURL string
}
// GameOpenConfig 保存统一三方游戏回调配置。 // GameOpenConfig 保存统一三方游戏回调配置。
type GameOpenConfig struct { type GameOpenConfig struct {
PublicBaseURL string PublicBaseURL string
@ -318,18 +265,9 @@ func Load() Config {
taskCenterMQEndpoint := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ENDPOINT", "TASK_CENTER_EVENT_MQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT"}, "") 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"}, "") 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"}, "") 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"}, "") 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"}, "") 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"}, "") 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{ return Config{
HTTP: HTTPConfig{ HTTP: HTTPConfig{
@ -391,23 +329,6 @@ func Load() Config {
100000, 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{ TaskCenter: TaskCenterConfig{
DefaultSysOrigin: strings.ToUpper(getEnvAny( 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"}, []string{"CHATAPP_TASK_CENTER_DEFAULT_SYS_ORIGIN", "TASK_CENTER_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"},
@ -439,7 +360,7 @@ func Load() Config {
SecretID: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_SECRET_ID", "TASK_CENTER_ARCHIVE_COS_SECRET_ID", "LIKEI_TENCENT_COS_SECRET_ID"}, ""), SecretID: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_SECRET_ID", "TASK_CENTER_ARCHIVE_COS_SECRET_ID", "LIKEI_TENCENT_COS_SECRET_ID"}, ""),
SecretKey: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_SECRET_KEY", "TASK_CENTER_ARCHIVE_COS_SECRET_KEY", "LIKEI_TENCENT_COS_SECRET_KEY"}, ""), SecretKey: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_SECRET_KEY", "TASK_CENTER_ARCHIVE_COS_SECRET_KEY", "LIKEI_TENCENT_COS_SECRET_KEY"}, ""),
Prefix: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_PREFIX", "TASK_CENTER_ARCHIVE_COS_PREFIX"}, "task-center-events/v1"), Prefix: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_PREFIX", "TASK_CENTER_ARCHIVE_COS_PREFIX"}, "task-center-events/v1"),
BatchSize: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_BATCH_SIZE", "TASK_CENTER_ARCHIVE_BATCH_SIZE"}, 500), BatchSize: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_BATCH_SIZE", "TASK_CENTER_ARCHIVE_BATCH_SIZE"}, 10000),
FlushSeconds: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_FLUSH_SECONDS", "TASK_CENTER_ARCHIVE_FLUSH_SECONDS"}, 60), FlushSeconds: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_FLUSH_SECONDS", "TASK_CENTER_ARCHIVE_FLUSH_SECONDS"}, 60),
Workers: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_WORKERS", "TASK_CENTER_ARCHIVE_WORKERS"}, 2), Workers: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_WORKERS", "TASK_CENTER_ARCHIVE_WORKERS"}, 2),
}, },
@ -565,23 +486,6 @@ func Load() Config {
Tag: getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_TAG", "VOICE_ROOM_ROCKET_MQ_TAG"}, "give_gift_v3"), 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{ TencentIM: TencentIMConfig{
AppID: getEnvInt64Any( AppID: getEnvInt64Any(
[]string{"CHATAPP_TENCENT_IM_APP_ID", "TENCENT_IM_APP_ID", "LIKEI_IM_APP_ID"}, []string{"CHATAPP_TENCENT_IM_APP_ID", "TENCENT_IM_APP_ID", "LIKEI_IM_APP_ID"},
@ -624,10 +528,6 @@ func Load() Config {
GameListURL: getEnvAny([]string{"CHATAPP_LINGXIAN_GAME_LIST_URL", "LINGXIAN_GAME_LIST_URL"}, "https://sg-test.leadercc.com/yumichat_games/test_game_list.json"), GameListURL: getEnvAny([]string{"CHATAPP_LINGXIAN_GAME_LIST_URL", "LINGXIAN_GAME_LIST_URL"}, "https://sg-test.leadercc.com/yumichat_games/test_game_list.json"),
AppKey: getEnvAny([]string{"CHATAPP_LINGXIAN_APP_KEY", "LINGXIAN_APP_KEY", "GAME_HKYS_SIGN_KEY", "LIKEI_GAME_HKYS_SIGN_KEY"}, ""), AppKey: getEnvAny([]string{"CHATAPP_LINGXIAN_APP_KEY", "LINGXIAN_APP_KEY", "GAME_HKYS_SIGN_KEY", "LIKEI_GAME_HKYS_SIGN_KEY"}, ""),
}, },
Hotgame: HotgameConfig{
AppKey: getEnvAny([]string{"CHATAPP_HOTGAME_APP_KEY", "HOTGAME_APP_KEY", "CHATAPP_REYOU_APP_KEY", "REYOU_APP_KEY"}, ""),
CallbackBaseURL: getEnvAny([]string{"CHATAPP_HOTGAME_CALLBACK_BASE_URL", "HOTGAME_CALLBACK_BASE_URL", "CHATAPP_REYOU_CALLBACK_BASE_URL", "REYOU_CALLBACK_BASE_URL"}, ""),
},
GameOpen: GameOpenConfig{ GameOpen: GameOpenConfig{
PublicBaseURL: getEnvAny([]string{"CHATAPP_GAME_OPEN_PUBLIC_BASE_URL", "GAME_OPEN_PUBLIC_BASE_URL"}, ""), PublicBaseURL: getEnvAny([]string{"CHATAPP_GAME_OPEN_PUBLIC_BASE_URL", "GAME_OPEN_PUBLIC_BASE_URL"}, ""),
AppKey: getEnvAny([]string{"CHATAPP_GAME_OPEN_APP_KEY", "GAME_OPEN_APP_KEY"}, "game-open-test-key"), AppKey: getEnvAny([]string{"CHATAPP_GAME_OPEN_APP_KEY", "GAME_OPEN_APP_KEY"}, "game-open-test-key"),

View File

@ -102,29 +102,6 @@ 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) { func TestLoadTaskCenterArchiveUsesSharedCOSCredentials(t *testing.T) {
t.Setenv("CHATAPP_TASK_CENTER_ARCHIVE_ENABLED", "true") t.Setenv("CHATAPP_TASK_CENTER_ARCHIVE_ENABLED", "true")
t.Setenv("LIKEI_TENCENT_COS_BUCKET", "bucket-123") t.Setenv("LIKEI_TENCENT_COS_BUCKET", "bucket-123")

View File

@ -131,31 +131,16 @@ func (g *Gateways) GetRewardGroupDetail(ctx context.Context, groupID int64) (Rew
return g.RewardQuery.GetRewardGroupDetail(ctx, groupID) return g.RewardQuery.GetRewardGroupDetail(ctx, groupID)
} }
// FindRewardGroupDetail 透传到奖励查询网关,按名称查找奖励组详情。
func (g *Gateways) FindRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (RewardGroupDetail, error) {
return g.RewardQuery.FindRewardGroupDetail(ctx, sysOrigin, name)
}
// GetUserProfile 透传到资料网关。 // GetUserProfile 透传到资料网关。
func (g *Gateways) GetUserProfile(ctx context.Context, userID int64) (UserProfile, error) { func (g *Gateways) GetUserProfile(ctx context.Context, userID int64) (UserProfile, error) {
return g.Profile.GetUserProfile(ctx, userID) return g.Profile.GetUserProfile(ctx, userID)
} }
// UpdateUserCountry 透传到 Java 用户国家更新接口。
func (g *Gateways) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error {
return g.Profile.UpdateUserCountry(ctx, userID, countryID)
}
// GetUserRegion 透传到用户地区网关。 // GetUserRegion 透传到用户地区网关。
func (g *Gateways) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) { func (g *Gateways) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) {
return g.Profile.GetUserRegion(ctx, userID, authorization) return g.Profile.GetUserRegion(ctx, userID, authorization)
} }
// GetUserLevel 透传到用户等级网关。
func (g *Gateways) GetUserLevel(ctx context.Context, sysOrigin string, userID int64) (UserLevel, error) {
return g.Profile.GetUserLevel(ctx, sysOrigin, userID)
}
// ResolveRegionCodeByCountryCode 透传到 Java 区域配置网关。 // ResolveRegionCodeByCountryCode 透传到 Java 区域配置网关。
func (g *Gateways) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) { func (g *Gateways) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
return g.Profile.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode) return g.Profile.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode)
@ -380,11 +365,6 @@ func (g *RewardQueryGateway) GetRewardGroupDetail(ctx context.Context, groupID i
return g.client.GetRewardGroupDetail(ctx, groupID) return g.client.GetRewardGroupDetail(ctx, groupID)
} }
// FindRewardGroupDetail 按名称查询奖励组详情。
func (g *RewardQueryGateway) FindRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (RewardGroupDetail, error) {
return g.client.FindRewardGroupDetail(ctx, sysOrigin, name)
}
// GetNobleVIPAbility 查询贵族 VIP 能力配置。 // GetNobleVIPAbility 查询贵族 VIP 能力配置。
func (g *RewardQueryGateway) GetNobleVIPAbility(ctx context.Context, sourceID int64) (PropsNobleVIPAbility, error) { func (g *RewardQueryGateway) GetNobleVIPAbility(ctx context.Context, sourceID int64) (PropsNobleVIPAbility, error) {
return g.client.GetNobleVIPAbility(ctx, sourceID) return g.client.GetNobleVIPAbility(ctx, sourceID)
@ -405,21 +385,11 @@ func (g *ProfileGateway) GetUserProfile(ctx context.Context, userID int64) (User
return g.client.GetUserProfile(ctx, userID) return g.client.GetUserProfile(ctx, userID)
} }
// UpdateUserCountry 通过 Java other 更新用户国家。
func (g *ProfileGateway) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error {
return g.client.UpdateUserCountry(ctx, userID, countryID)
}
// GetUserRegion 查询用户当前地区。 // GetUserRegion 查询用户当前地区。
func (g *ProfileGateway) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) { func (g *ProfileGateway) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) {
return g.client.GetUserRegion(ctx, userID, authorization) return g.client.GetUserRegion(ctx, userID, authorization)
} }
// GetUserLevel 查询用户财富/魅力等级。
func (g *ProfileGateway) GetUserLevel(ctx context.Context, sysOrigin string, userID int64) (UserLevel, error) {
return g.client.GetUserLevel(ctx, sysOrigin, userID)
}
// ResolveRegionCodeByCountryCode 根据 Java 区域配置把国家码解析成区域编码。 // ResolveRegionCodeByCountryCode 根据 Java 区域配置把国家码解析成区域编码。
func (g *ProfileGateway) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) { func (g *ProfileGateway) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
return g.client.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode) return g.client.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode)

View File

@ -59,12 +59,6 @@ type UserRegion struct {
RegionCode string `json:"regionCode"` RegionCode string `json:"regionCode"`
} }
type UserLevel struct {
UserID Int64Value `json:"userId"`
WealthLevel int `json:"wealthLevel"`
CharmLevel int `json:"charmLevel"`
}
type RegionConfig struct { type RegionConfig struct {
RegionCode string `json:"regionCode"` RegionCode string `json:"regionCode"`
SysOrigin string `json:"sysOrigin"` SysOrigin string `json:"sysOrigin"`
@ -241,15 +235,13 @@ type OfficialNoticeCustomizeRequest struct {
} }
type RewardGroupItem struct { type RewardGroupItem struct {
ID Int64Value `json:"id"` ID Int64Value `json:"id"`
Type string `json:"type"` Type string `json:"type"`
DetailType string `json:"detailType"` Name string `json:"name"`
Name string `json:"name"` Content string `json:"content"`
Content string `json:"content"` Quantity Int64Value `json:"quantity"`
Quantity Int64Value `json:"quantity"` Cover string `json:"cover"`
Cover string `json:"cover"` Remark string `json:"remark"`
SourceURL string `json:"sourceUrl"`
Remark string `json:"remark"`
} }
type RewardGroupDetail struct { type RewardGroupDetail struct {
@ -259,13 +251,6 @@ type RewardGroupDetail struct {
RewardConfigList []RewardGroupItem `json:"rewardConfigList"` RewardConfigList []RewardGroupItem `json:"rewardConfigList"`
} }
type rewardGroupPage struct {
Records []RewardGroupDetail `json:"records"`
Total int64 `json:"total"`
Current int `json:"current"`
Size int `json:"size"`
}
type UserTotalRecharge struct { type UserTotalRecharge struct {
ID Int64Value `json:"id"` ID Int64Value `json:"id"`
UserID Int64Value `json:"userId"` UserID Int64Value `json:"userId"`
@ -349,7 +334,6 @@ type resultResponse[T any] struct {
Code int `json:"code"` Code int `json:"code"`
Message string `json:"message"` Message string `json:"message"`
Body T `json:"body"` Body T `json:"body"`
Data T `json:"data"`
Success *bool `json:"success"` Success *bool `json:"success"`
} }
@ -712,154 +696,15 @@ func (c *Client) SendOfficialNoticeCustomize(ctx context.Context, req OfficialNo
} }
func (c *Client) GetRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) { func (c *Client) GetRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) {
detail, err := c.getInnerRewardGroupDetail(ctx, groupID) endpoint := c.cfg.Java.OtherBaseURL + "/props-activity/reward/group/client/getByGroupId?id=" + url.QueryEscape(strconv.FormatInt(groupID, 10))
if err == nil && rewardGroupDetailHasContent(detail) {
return detail, nil
}
if fallback, fallbackErr := c.getConsoleRewardGroupDetail(ctx, groupID); fallbackErr == nil && rewardGroupDetailHasContent(fallback) {
return fallback, nil
} else if err == nil && fallbackErr != nil {
return detail, nil
}
return detail, err
}
func (c *Client) getInnerRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) {
endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/props-activity/reward/group/client/getByGroupId?id=" + url.QueryEscape(strconv.FormatInt(groupID, 10))
var resp resultResponse[RewardGroupDetail] var resp resultResponse[RewardGroupDetail]
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
return RewardGroupDetail{}, err return RewardGroupDetail{}, err
} }
detail := resp.Body if int64(resp.Body.ID) == 0 {
if int64(detail.ID) == 0 {
detail = resp.Data
}
if int64(detail.ID) == 0 {
return RewardGroupDetail{}, fmt.Errorf("empty reward group response") return RewardGroupDetail{}, fmt.Errorf("empty reward group response")
} }
return detail, nil return resp.Body, nil
}
func (c *Client) getConsoleRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) {
baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.Java.ConsoleBaseURL), "/")
if baseURL == "" {
return RewardGroupDetail{}, fmt.Errorf("console base url is empty")
}
endpoint := baseURL + "/props/activity/reward/group/" + url.PathEscape(strconv.FormatInt(groupID, 10))
var detail RewardGroupDetail
if err := c.getJSON(ctx, endpoint, nil, &detail); err != nil {
return RewardGroupDetail{}, err
}
if int64(detail.ID) == 0 {
return RewardGroupDetail{}, fmt.Errorf("empty reward group response")
}
return detail, nil
}
func rewardGroupDetailHasContent(detail RewardGroupDetail) bool {
return int64(detail.ID) > 0 && len(detail.RewardConfigList) > 0
}
func (c *Client) FindRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (RewardGroupDetail, error) {
groupName := strings.TrimSpace(name)
if groupName == "" {
return RewardGroupDetail{}, fmt.Errorf("reward group name is empty")
}
if detail, err := c.findInnerRewardGroupDetail(ctx, sysOrigin, groupName); err == nil {
if hydrated, hydrateErr := c.hydrateRewardGroupDetail(ctx, detail); hydrateErr == nil && rewardGroupDetailHasContent(hydrated) {
return hydrated, nil
}
if rewardGroupDetailHasContent(detail) {
return detail, nil
}
}
detail, err := c.findConsoleRewardGroupDetail(ctx, sysOrigin, groupName)
if err != nil {
return RewardGroupDetail{}, err
}
if hydrated, hydrateErr := c.hydrateRewardGroupDetail(ctx, detail); hydrateErr == nil && rewardGroupDetailHasContent(hydrated) {
return hydrated, nil
}
return detail, nil
}
func (c *Client) hydrateRewardGroupDetail(ctx context.Context, detail RewardGroupDetail) (RewardGroupDetail, error) {
if rewardGroupDetailHasContent(detail) {
return detail, nil
}
groupID := int64(detail.ID)
if groupID <= 0 {
return detail, fmt.Errorf("empty reward group id")
}
if hydrated, err := c.getInnerRewardGroupDetail(ctx, groupID); err == nil && rewardGroupDetailHasContent(hydrated) {
return hydrated, nil
}
return c.getConsoleRewardGroupDetail(ctx, groupID)
}
func (c *Client) findInnerRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (RewardGroupDetail, error) {
endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/props-activity/reward/group/client/page"
payload := map[string]any{
"current": 1,
"cursor": 1,
"limit": 10,
"name": name,
"shelfStatus": true,
"size": 10,
}
if origin := strings.TrimSpace(sysOrigin); origin != "" {
payload["sysOrigin"] = origin
}
var resp resultResponse[rewardGroupPage]
if err := c.postJSON(ctx, endpoint, payload, nil, &resp); err != nil {
return RewardGroupDetail{}, err
}
page := resp.Body
if len(page.Records) == 0 {
page = resp.Data
}
return pickRewardGroupDetailByName(page.Records, name)
}
func (c *Client) findConsoleRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (RewardGroupDetail, error) {
baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.Java.ConsoleBaseURL), "/")
if baseURL == "" {
return RewardGroupDetail{}, fmt.Errorf("console base url is empty")
}
values := url.Values{}
values.Set("current", "1")
values.Set("cursor", "1")
values.Set("limit", "10")
values.Set("name", name)
values.Set("shelfStatus", "true")
values.Set("size", "10")
if origin := strings.TrimSpace(sysOrigin); origin != "" {
values.Set("sysOrigin", origin)
}
endpoint := baseURL + "/props/activity/reward/group/page?" + values.Encode()
var page rewardGroupPage
if err := c.getJSON(ctx, endpoint, nil, &page); err != nil {
return RewardGroupDetail{}, err
}
return pickRewardGroupDetailByName(page.Records, name)
}
func pickRewardGroupDetailByName(records []RewardGroupDetail, name string) (RewardGroupDetail, error) {
if len(records) == 0 {
return RewardGroupDetail{}, fmt.Errorf("empty reward group page response")
}
target := strings.TrimSpace(name)
for _, record := range records {
if strings.TrimSpace(record.Name) == target {
return record, nil
}
}
for _, record := range records {
if rewardGroupDetailHasContent(record) {
return record, nil
}
}
return records[0], nil
} }
func (c *Client) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]UserTotalRecharge, error) { func (c *Client) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]UserTotalRecharge, error) {
@ -1034,36 +879,6 @@ func (c *Client) GetUserProfile(ctx context.Context, userID int64) (UserProfile,
return resp.Body, nil return resp.Body, nil
} }
// UpdateUserCountry 只提交稳定的用户 ID 和国家 ID国家码、名称以及并发锁统一由 Java other 处理。
func (c *Client) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error {
if userID <= 0 {
return fmt.Errorf("userId is required")
}
if countryID <= 0 {
return fmt.Errorf("countryId is required")
}
endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/user-profile/client/updateSelectiveById"
payload := struct {
ID int64 `json:"id"`
CountryID int64 `json:"countryId"`
}{
ID: userID,
CountryID: countryID,
}
var resp resultResponse[any]
if err := c.postJSON(ctx, endpoint, payload, nil, &resp); err != nil {
return err
}
if resp.Success != nil && !*resp.Success {
message := strings.TrimSpace(resp.Message)
if message == "" {
message = "user country update failed"
}
return errors.New(message)
}
return nil
}
func (c *Client) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) { func (c *Client) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) {
if userID <= 0 { if userID <= 0 {
return UserRegion{}, fmt.Errorf("userId is required") return UserRegion{}, fmt.Errorf("userId is required")
@ -1080,21 +895,6 @@ func (c *Client) GetUserRegion(ctx context.Context, userID int64, authorization
return resp.Body, nil return resp.Body, nil
} }
func (c *Client) GetUserLevel(ctx context.Context, sysOrigin string, userID int64) (UserLevel, error) {
if userID <= 0 {
return UserLevel{}, fmt.Errorf("userId is required")
}
values := url.Values{}
values.Set("sysOrigin", strings.TrimSpace(sysOrigin))
values.Set("userId", strconv.FormatInt(userID, 10))
endpoint := c.cfg.Java.OtherBaseURL + "/user-level/client/getLevelByUserId?" + values.Encode()
var resp resultResponse[UserLevel]
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
return UserLevel{}, err
}
return resp.Body, nil
}
func (c *Client) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) { func (c *Client) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
countryCode = strings.ToUpper(strings.TrimSpace(countryCode)) countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
if countryCode == "" { if countryCode == "" {

View File

@ -77,131 +77,3 @@ func TestResolveRegionCodeByCountryCodeUsesRegionConfig(t *testing.T) {
t.Fatalf("region config calls = %d, want cached 1", calls) t.Fatalf("region config calls = %d, want cached 1", calls)
} }
} }
func TestUpdateUserCountryPostsOnlyStableIDs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/user-profile/client/updateSelectiveById" {
t.Fatalf("request = %s %s, want POST /user-profile/client/updateSelectiveById", r.Method, r.URL.Path)
}
var payload map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
if len(payload) != 2 || string(payload["id"]) != "2" || string(payload["countryId"]) != "11" {
t.Fatalf("payload = %s, want only id and countryId", payload)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true}`))
}))
defer server.Close()
client := New(config.Config{
HTTP: config.HTTPConfig{Timeout: time.Second},
Java: config.JavaConfig{OtherBaseURL: server.URL},
})
if err := client.UpdateUserCountry(context.Background(), 2, 11); err != nil {
t.Fatalf("UpdateUserCountry() error = %v", err)
}
}
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

@ -1,68 +0,0 @@
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

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

View File

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

View File

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

View File

@ -248,7 +248,7 @@ func registerBaishunRoutes(
}) })
callbackGroup.POST("/report", func(c *gin.Context) { callbackGroup.POST("/report", func(c *gin.Context) {
raw, payload := readRawPayload(c) raw, payload := readRawPayload(c)
writeBaishunJSON(c, http.StatusOK, baishunService.HandleReport(c.Request.Context(), mapToReportRequest(payload), raw)) writeBaishunJSON(c, http.StatusOK, baishunService.HandleReport(c.Request.Context(), payload, raw))
}) })
callbackGroup.POST("/balance-info", func(c *gin.Context) { callbackGroup.POST("/balance-info", func(c *gin.Context) {
raw, payload := readRawPayload(c) raw, payload := readRawPayload(c)
@ -338,16 +338,6 @@ 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。 // mapToBalanceInfoRequest 把百顺 balance-info 回调报文映射成服务层 DTO。
func mapToBalanceInfoRequest(payload map[string]any) baishun.BaishunBalanceInfoRequest { func mapToBalanceInfoRequest(payload map[string]any) baishun.BaishunBalanceInfoRequest {
return baishun.BaishunBalanceInfoRequest{ return baishun.BaishunBalanceInfoRequest{
@ -360,26 +350,6 @@ 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 宽松读取字符串字段。 // asString 宽松读取字符串字段。
func asString(value any) string { func asString(value any) string {
if value == nil { if value == nil {

View File

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

View File

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

View File

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

View File

@ -1,115 +0,0 @@
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,15 +37,6 @@ func registerManagerCenterRoutes(engine *gin.Engine, javaClient authGateway, ser
writeOK(c, resp) writeOK(c, resp)
}) })
group.GET("/countries", func(c *gin.Context) {
resp, err := service.ListCountries(c.Request.Context(), mustAuthUser(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.GET("/props", func(c *gin.Context) { group.GET("/props", func(c *gin.Context) {
resp, err := service.ListProps(c.Request.Context(), mustAuthUser(c), c.Query("type")) resp, err := service.ListProps(c.Request.Context(), mustAuthUser(c), c.Query("type"))
if err != nil { if err != nil {
@ -69,29 +60,6 @@ func registerManagerCenterRoutes(engine *gin.Engine, javaClient authGateway, ser
writeOK(c, resp) 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) { group.POST("/users/ban", func(c *gin.Context) {
var req managercenter.BanUserRequest var req managercenter.BanUserRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@ -119,18 +87,4 @@ func registerManagerCenterRoutes(engine *gin.Engine, javaClient authGateway, ser
} }
writeOK(c, resp) writeOK(c, resp)
}) })
group.POST("/users/country", func(c *gin.Context) {
var req managercenter.ChangeUserCountryRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, managercenter.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.ChangeUserCountry(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
} }

View File

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

View File

@ -4,8 +4,6 @@ import (
"net/http" "net/http"
"chatapp3-golang/internal/config" "chatapp3-golang/internal/config"
"chatapp3-golang/internal/repo"
"chatapp3-golang/internal/service/highwin"
"chatapp3-golang/internal/service/regionimgroup" "chatapp3-golang/internal/service/regionimgroup"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -15,7 +13,6 @@ import (
func registerRegionIMGroupRoutes( func registerRegionIMGroupRoutes(
engine *gin.Engine, engine *gin.Engine,
cfg config.Config, cfg config.Config,
repository *repo.Repository,
javaClient authGateway, javaClient authGateway,
service *regionimgroup.Service, service *regionimgroup.Service,
) { ) {
@ -25,7 +22,7 @@ func registerRegionIMGroupRoutes(
registerRegionIMGroupAppRoutes(engine, javaClient, service) registerRegionIMGroupAppRoutes(engine, javaClient, service)
registerRegionIMGroupInternalRoutes(engine, cfg, service) registerRegionIMGroupInternalRoutes(engine, cfg, service)
registerRegionIMGroupAdminRoutes(engine, repository, javaClient, service) registerRegionIMGroupAdminRoutes(engine, javaClient, service)
} }
func registerRegionIMGroupAppRoutes(engine *gin.Engine, javaClient authGateway, service *regionimgroup.Service) { func registerRegionIMGroupAppRoutes(engine *gin.Engine, javaClient authGateway, service *regionimgroup.Service) {
@ -65,14 +62,9 @@ func registerRegionIMGroupInternalRoutes(engine *gin.Engine, cfg config.Config,
}) })
} }
func registerRegionIMGroupAdminRoutes(engine *gin.Engine, repository *repo.Repository, javaClient authGateway, service *regionimgroup.Service) { func registerRegionIMGroupAdminRoutes(engine *gin.Engine, javaClient authGateway, service *regionimgroup.Service) {
registerRegionIMGroupAdminGroup(engine.Group("/region/config"), javaClient, service) registerRegionIMGroupAdminGroup(engine.Group("/region/config"), javaClient, service)
registerRegionIMGroupAdminGroup(engine.Group("/resident-activity/voice-room-red-packet"), 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) { func registerRegionIMGroupAdminGroup(group *gin.RouterGroup, javaClient authGateway, service *regionimgroup.Service) {
@ -112,39 +104,3 @@ func registerRegionIMGroupAdminGroup(group *gin.RouterGroup, javaClient authGate
writeOK(c, resp) 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,11 +10,9 @@ import (
"chatapp3-golang/internal/service/baishun" "chatapp3-golang/internal/service/baishun"
"chatapp3-golang/internal/service/binancerecharge" "chatapp3-golang/internal/service/binancerecharge"
"chatapp3-golang/internal/service/errorlog" "chatapp3-golang/internal/service/errorlog"
"chatapp3-golang/internal/service/firstrechargereward"
"chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameopen"
"chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/gameprovider"
"chatapp3-golang/internal/service/hostcenter" "chatapp3-golang/internal/service/hostcenter"
"chatapp3-golang/internal/service/hotgame"
"chatapp3-golang/internal/service/invite" "chatapp3-golang/internal/service/invite"
"chatapp3-golang/internal/service/lingxian" "chatapp3-golang/internal/service/lingxian"
"chatapp3-golang/internal/service/luckygift" "chatapp3-golang/internal/service/luckygift"
@ -40,35 +38,32 @@ import (
// Services 聚合所有业务模块服务,供路由层统一装配。 // Services 聚合所有业务模块服务,供路由层统一装配。
type Services struct { type Services struct {
AppPopup *apppopup.Service AppPopup *apppopup.Service
ErrorLog *errorlog.Service ErrorLog *errorlog.Service
Invite *invite.InviteService Invite *invite.InviteService
Baishun *baishun.BaishunService Baishun *baishun.BaishunService
BinanceRecharge *binancerecharge.Service BinanceRecharge *binancerecharge.Service
FirstRechargeReward *firstrechargereward.Service Lingxian *lingxian.Service
Lingxian *lingxian.Service GameOpen *gameopen.GameOpenService
GameOpen *gameopen.GameOpenService GameProviders *gameprovider.Registry
GameProviders *gameprovider.Registry LuckyGift *luckygift.LuckyGiftService
GameVisibility *gameprovider.VisibilityPolicy ManagerCenter *managercenter.Service
LuckyGift *luckygift.LuckyGiftService PropsStore *propsstore.Service
ManagerCenter *managercenter.Service HostCenter *hostcenter.Service
PropsStore *propsstore.Service RechargeAgency *rechargeagency.Service
HostCenter *hostcenter.Service RechargeReward *rechargereward.Service
Hotgame *hotgame.Service RegionIMGroup *regionimgroup.Service
RechargeAgency *rechargeagency.Service RegisterReward *registerreward.RegisterRewardService
RechargeReward *rechargereward.Service RoomTurnoverReward *roomturnoverreward.Service
RegionIMGroup *regionimgroup.Service SignInReward *signinreward.SignInRewardService
RegisterReward *registerreward.RegisterRewardService SmashEgg *smashegg.Service
RoomTurnoverReward *roomturnoverreward.Service TaskCenter *taskcenter.Service
SignInReward *signinreward.SignInRewardService UserBadge *userbadge.Service
SmashEgg *smashegg.Service VIP *vip.Service
TaskCenter *taskcenter.Service Wheel *wheel.Service
UserBadge *userbadge.Service VoiceRoomRedPacket *voiceroomredpacket.Service
VIP *vip.Service VoiceRoomRocket *voiceroomrocket.Service
Wheel *wheel.Service WeekStar *weekstar.WeekStarService
VoiceRoomRedPacket *voiceroomredpacket.Service
VoiceRoomRocket *voiceroomrocket.Service
WeekStar *weekstar.WeekStarService
} }
// NewRouter 创建 Gin 路由,并按模块注册所有接口。 // NewRouter 创建 Gin 路由,并按模块注册所有接口。
@ -87,7 +82,6 @@ func NewRouter(
registerInviteRoutes(engine, javaClient, services.Invite) registerInviteRoutes(engine, javaClient, services.Invite)
registerRechargeAgencyRoutes(engine, javaClient, services.RechargeAgency) registerRechargeAgencyRoutes(engine, javaClient, services.RechargeAgency)
registerRechargeRewardRoutes(engine, javaClient, services.RechargeReward) registerRechargeRewardRoutes(engine, javaClient, services.RechargeReward)
registerFirstRechargeRewardRoutes(engine, javaClient, services.FirstRechargeReward)
registerBinanceRechargeRoutes(engine, javaClient, services.BinanceRecharge) registerBinanceRechargeRoutes(engine, javaClient, services.BinanceRecharge)
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward) registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
registerSignInRewardRoutes(engine, javaClient, services.SignInReward) registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
@ -96,17 +90,16 @@ func NewRouter(
registerVipRoutes(engine, javaClient, services.VIP) registerVipRoutes(engine, javaClient, services.VIP)
registerWheelRoutes(engine, cfg, javaClient, services.Wheel) registerWheelRoutes(engine, cfg, javaClient, services.Wheel)
registerSmashEggRoutes(engine, javaClient, services.SmashEgg) registerSmashEggRoutes(engine, javaClient, services.SmashEgg)
registerRegionIMGroupRoutes(engine, cfg, repository, javaClient, services.RegionIMGroup) registerRegionIMGroupRoutes(engine, cfg, javaClient, services.RegionIMGroup)
registerVoiceRoomRedPacketRoutes(engine, javaClient, services.VoiceRoomRedPacket) registerVoiceRoomRedPacketRoutes(engine, javaClient, services.VoiceRoomRedPacket)
registerVoiceRoomRocketRoutes(engine, cfg, javaClient, services.VoiceRoomRocket) registerVoiceRoomRocketRoutes(engine, cfg, javaClient, services.VoiceRoomRocket)
registerWeekStarRoutes(engine, javaClient, services.WeekStar) registerWeekStarRoutes(engine, javaClient, services.WeekStar)
registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward) registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward)
registerHostCenterRoutes(engine, services.HostCenter) registerHostCenterRoutes(engine, services.HostCenter)
registerGameOpenRoutes(engine, cfg, services.GameOpen) registerGameOpenRoutes(engine, cfg, services.GameOpen)
registerGameProviderRoutes(engine, javaClient, services.GameProviders, services.GameVisibility) registerGameProviderRoutes(engine, javaClient, services.GameProviders)
registerBaishunRoutes(engine, cfg, javaClient, services.Baishun) registerBaishunRoutes(engine, cfg, javaClient, services.Baishun)
registerLingxianRoutes(engine, javaClient, services.Lingxian) registerLingxianRoutes(engine, javaClient, services.Lingxian)
registerHotgameRoutes(engine, javaClient, services.Hotgame)
registerLuckyGiftRoutes(engine, cfg, services.LuckyGift) registerLuckyGiftRoutes(engine, cfg, services.LuckyGift)
registerManagerCenterRoutes(engine, javaClient, services.ManagerCenter) registerManagerCenterRoutes(engine, javaClient, services.ManagerCenter)
registerPropsStoreRoutes(engine, javaClient, services.PropsStore) registerPropsStoreRoutes(engine, javaClient, services.PropsStore)

View File

@ -243,6 +243,21 @@ func (s *BaishunService) HandleChangeBalance(ctx context.Context, req BaishunCha
return resp 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) { func (s *BaishunService) reportGameConsumeTaskEvent(ctx context.Context, session model.BaishunLaunchSession, req BaishunChangeBalanceRequest) {
if s.taskReporter == nil || req.CurrencyDiff >= 0 { if s.taskReporter == nil || req.CurrencyDiff >= 0 {
return return

View File

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

View File

@ -1,291 +0,0 @@
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

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

View File

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

View File

@ -1,327 +0,0 @@
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

@ -1,142 +0,0 @@
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

@ -1,139 +0,0 @@
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

@ -1,199 +0,0 @@
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

@ -1,249 +0,0 @@
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

@ -1,456 +0,0 @@
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

@ -1,112 +0,0 @@
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

@ -1,139 +0,0 @@
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

@ -1,180 +0,0 @@
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

@ -1,31 +0,0 @@
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

@ -1,109 +0,0 @@
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

@ -1,567 +0,0 @@
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

@ -1,372 +0,0 @@
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

@ -1,79 +0,0 @@
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

@ -1,387 +0,0 @@
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,7 +219,20 @@ func (s *GameOpenService) GetIntegrationInfo(ctx context.Context, publicBaseURL
} }
func (s *GameOpenService) resolveCallbackAppKey(ctx context.Context, gameID, token string) string { func (s *GameOpenService) resolveCallbackAppKey(ctx context.Context, gameID, token string) string {
return s.resolveCallbackAppKeyForVendor(ctx, "LINGXIAN", gameID, token) 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)
} }
func (s *GameOpenService) resolveLingxianAppKeyByGame(ctx context.Context, gameID string) string { func (s *GameOpenService) resolveLingxianAppKeyByGame(ctx context.Context, gameID string) string {

View File

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

View File

@ -14,10 +14,6 @@ type Registry struct {
ordered []Provider ordered []Provider
} }
var emptyGameListUserIDs = map[int64]struct{}{
2061394478798794754: {},
}
// NewRegistry 创建厂商注册表。 // NewRegistry 创建厂商注册表。
func NewRegistry(providers ...Provider) *Registry { func NewRegistry(providers ...Provider) *Registry {
r := &Registry{ r := &Registry{
@ -79,9 +75,6 @@ func (r *Registry) List() ProviderListResponse {
// ListShortcutGames 返回聚合后的快捷游戏列表。 // ListShortcutGames 返回聚合后的快捷游戏列表。
func (r *Registry) ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) { func (r *Registry) ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) {
if IsEmptyGameListUser(user.UserID) {
return []RoomGameListItem{}, nil
}
if r == nil || len(r.ordered) == 0 { if r == nil || len(r.ordered) == 0 {
return []RoomGameListItem{}, nil return []RoomGameListItem{}, nil
} }
@ -103,9 +96,6 @@ func (r *Registry) ListShortcutGames(ctx context.Context, user AuthUser, roomID
// ListRoomGames 返回聚合后的房间游戏列表。 // ListRoomGames 返回聚合后的房间游戏列表。
func (r *Registry) ListRoomGames(ctx context.Context, user AuthUser, roomID, category string) (*RoomGameListResponse, error) { func (r *Registry) ListRoomGames(ctx context.Context, user AuthUser, roomID, category string) (*RoomGameListResponse, error) {
if IsEmptyGameListUser(user.UserID) {
return &RoomGameListResponse{Items: []RoomGameListItem{}}, nil
}
if r == nil || len(r.ordered) == 0 { if r == nil || len(r.ordered) == 0 {
return &RoomGameListResponse{Items: []RoomGameListItem{}}, nil return &RoomGameListResponse{Items: []RoomGameListItem{}}, nil
} }
@ -124,11 +114,6 @@ func (r *Registry) ListRoomGames(ctx context.Context, user AuthUser, roomID, cat
return &RoomGameListResponse{Items: items}, nil return &RoomGameListResponse{Items: items}, nil
} }
func IsEmptyGameListUser(userID int64) bool {
_, ok := emptyGameListUserIDs[userID]
return ok
}
// ResolveByLaunchRequest 根据启动请求解析内部对应的厂商。 // ResolveByLaunchRequest 根据启动请求解析内部对应的厂商。
func (r *Registry) ResolveByLaunchRequest(ctx context.Context, user AuthUser, req LaunchRequest) (Provider, error) { func (r *Registry) ResolveByLaunchRequest(ctx context.Context, user AuthUser, req LaunchRequest) (Provider, error) {
if r == nil { if r == nil {
@ -251,8 +236,6 @@ func normalizeProviderAlias(key string) string {
switch normalizeKey(key) { switch normalizeKey(key) {
case "LEADER": case "LEADER":
return "LINGXIAN" return "LINGXIAN"
case "HOTGAME", "HOT_GAME", "REYOU", "LALU":
return "HOTGAME"
default: default:
return normalizeKey(key) return normalizeKey(key)
} }

View File

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

View File

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

View File

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

View File

@ -1,676 +0,0 @@
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

@ -1,150 +0,0 @@
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

@ -1,71 +0,0 @@
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

@ -1,315 +0,0 @@
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

@ -1,446 +0,0 @@
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

@ -1,135 +0,0 @@
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

@ -1,203 +0,0 @@
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

@ -1,235 +0,0 @@
package hotgame
import (
"context"
"strings"
"time"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/service/gameprovider"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
const (
vendorType = "HOTGAME"
displayName = "热游"
profileProd = "PROD"
profileTest = "TEST"
launchModeH5 = "H5_REMOTE"
catalogEnabled = "ENABLED"
roomCategory = "CHAT_ROOM"
defaultLaunchLang = "en"
)
type dbHandle interface {
WithContext(ctx context.Context) *gorm.DB
}
// Service 负责热游后台配置、目录导入、app 列表和启动 URL 生成。
type Service struct {
cfg config.Config
db dbHandle
cache redis.Cmdable
}
// NewService 创建热游服务db 为空时路由仍可安全跳过。
func NewService(cfg config.Config, db dbHandle, cache redis.Cmdable) *Service {
return &Service{cfg: cfg, db: db, cache: cache}
}
// runtimeConfig 是某个系统、某个环境下实际用于启动和回调验签的配置快照。
type runtimeConfig struct {
SysOrigin string
Profile string
AppKey string
CallbackBaseURL string
TestUID string
TestToken string
}
// ProviderConfigItem 是后台热游配置页读取到的配置。
type ProviderConfigItem struct {
ID int64 `json:"id"`
SysOrigin string `json:"sysOrigin"`
Profile string `json:"profile"`
ActiveProfile string `json:"activeProfile"`
Active bool `json:"active"`
AppKey string `json:"appKey"`
CallbackBaseURL string `json:"callbackBaseUrl"`
TestUID string `json:"testUid"`
TestToken string `json:"testToken"`
UpdateTime string `json:"updateTime"`
}
// SaveProviderConfigRequest 是后台保存热游接入配置的入参。
type SaveProviderConfigRequest struct {
SysOrigin string `json:"sysOrigin"`
Profile string `json:"profile"`
AppKey string `json:"appKey"`
CallbackBaseURL string `json:"callbackBaseUrl"`
TestUID string `json:"testUid"`
TestToken string `json:"testToken"`
}
// ActivateProviderProfileRequest 是后台切换当前启用热游环境的入参。
type ActivateProviderProfileRequest struct {
SysOrigin string `json:"sysOrigin"`
Profile string `json:"profile"`
}
// SyncCatalogRequest 是后台同步或导入热游目录的入参。
type SyncCatalogRequest struct {
SysOrigin string `json:"sysOrigin"`
Profile string `json:"profile"`
VendorGameIDs []string `json:"vendorGameIds"`
Force bool `json:"force"`
DefaultShowcase *bool `json:"defaultShowcase"`
}
// SyncCatalogResponse 是热游目录同步结果。
type SyncCatalogResponse struct {
Inserted int `json:"inserted"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
}
// ImportCatalogResponse 是热游目录导入统一游戏列表的结果。
type ImportCatalogResponse struct {
Existing int `json:"existing"`
Imported int `json:"imported"`
}
// SyncAdminCatalogResponse 是“同步目录并导入游戏”的合并结果。
type SyncAdminCatalogResponse struct {
Inserted int `json:"inserted"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
Existing int `json:"existing"`
Imported int `json:"imported"`
}
// CatalogItem 是后台热游目录表格行。
type CatalogItem struct {
VendorGameID string `json:"vendorGameId"`
Profile string `json:"profile"`
GameID string `json:"gameId"`
Name string `json:"name"`
Cover string `json:"cover"`
PreviewURL string `json:"previewUrl"`
DownloadURL string `json:"downloadUrl"`
PackageVersion string `json:"packageVersion"`
Status string `json:"status"`
Added bool `json:"added"`
Showcase bool `json:"showcase"`
UpdateTime string `json:"updateTime"`
}
// CatalogPageResponse 是热游目录分页响应。
type CatalogPageResponse struct {
Cursor int `json:"cursor"`
Limit int `json:"limit"`
Records []CatalogItem `json:"records"`
Total int64 `json:"total"`
}
type defaultCatalogGame struct {
GameID string
Name string
TestURL string
ProdURL string
}
type gameRow struct {
ConfigID int64
SysOrigin string
Profile string
InternalGameID string
Name string
Category string
Cover string
Sort int64
FullScreen bool
VendorGameID string
LaunchMode string
ExtPackageVersion string
ExtPreviewURL string
PackageURL string
ExtOrientation *int
ExtSafeHeight int
ExtExtraJSON string
CatalogName string
CatalogCover string
CatalogPreviewURL string
CatalogDownloadURL string
CatalogPackageVersion string
CatalogStatus string
}
var _ gameprovider.Provider = (*AppProvider)(nil)
// AppProvider 把热游服务适配到统一游戏厂商注册表。
type AppProvider struct {
service *Service
}
// NewAppProvider 创建热游 app 侧 provider。
func NewAppProvider(service *Service) *AppProvider {
if service == nil {
return nil
}
return &AppProvider{service: service}
}
func normalizeSysOrigin(sysOrigin string) string {
sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin))
if sysOrigin == "" {
return "LIKEI"
}
return sysOrigin
}
func normalizeProfile(profile string) string {
switch strings.ToUpper(strings.TrimSpace(profile)) {
case "", "PROD", "PRODUCT", "PRODUCTION", "ONLINE", "OFFICIAL", "FORMAL":
return profileProd
case "TEST", "TESTING", "SANDBOX", "DEV":
return profileTest
default:
return strings.ToUpper(strings.TrimSpace(profile))
}
}
func defaultIfBlank(value, fallback string) string {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
return strings.TrimSpace(fallback)
}
func formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("2006-01-02 15:04:05")
}
func configToRuntime(base runtimeConfig, row model.HotgameProviderConfig) runtimeConfig {
base.SysOrigin = normalizeSysOrigin(defaultIfBlank(row.SysOrigin, base.SysOrigin))
base.Profile = normalizeProfile(defaultIfBlank(row.Profile, base.Profile))
base.AppKey = defaultIfBlank(row.AppKey, base.AppKey)
base.CallbackBaseURL = defaultIfBlank(row.CallbackBaseURL, base.CallbackBaseURL)
base.TestUID = strings.TrimSpace(row.TestUID)
base.TestToken = strings.TrimSpace(row.TestToken)
return base
}
func internalGameID(vendorGameID string) string {
vendorGameID = strings.TrimSpace(vendorGameID)
if vendorGameID == "" {
return ""
}
return "hg_" + vendorGameID
}

View File

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

View File

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

View File

@ -1,51 +0,0 @@
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

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

View File

@ -1,367 +0,0 @@
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,7 +6,6 @@ import (
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"time"
"chatapp3-golang/internal/integration" "chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model" "chatapp3-golang/internal/model"
@ -15,18 +14,9 @@ import (
) )
type teamManagerInfoRow struct { type teamManagerInfoRow struct {
ID int64 `gorm:"column:id;primaryKey"` ID int64 `gorm:"column:id;primaryKey"`
UserID int64 `gorm:"column:user_id"` UserID int64 `gorm:"column:user_id"`
Origin string `gorm:"column:sys_origin"` Origin string `gorm:"column:sys_origin"`
Contact string `gorm:"column:contact"`
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 { func (teamManagerInfoRow) TableName() string {
@ -34,38 +24,21 @@ func (teamManagerInfoRow) TableName() string {
} }
type userBaseInfoRow struct { type userBaseInfoRow struct {
ID int64 `gorm:"column:id;primaryKey"` ID int64 `gorm:"column:id;primaryKey"`
Account string `gorm:"column:account"` Account string `gorm:"column:account"`
UserAvatar string `gorm:"column:user_avatar"` UserAvatar string `gorm:"column:user_avatar"`
UserNickname string `gorm:"column:user_nickname"` UserNickname string `gorm:"column:user_nickname"`
CountryID int64 `gorm:"column:country_id"` CountryCode string `gorm:"column:country_code"`
CountryCode string `gorm:"column:country_code"` CountryName string `gorm:"column:country_name"`
CountryName string `gorm:"column:country_name"` OriginSys string `gorm:"column:origin_sys"`
OriginSys string `gorm:"column:origin_sys"` AccountStatus string `gorm:"column:account_status"`
AccountStatus string `gorm:"column:account_status"` IsDel bool `gorm:"column:is_del"`
IsDel bool `gorm:"column:is_del"`
UpdateTime time.Time `gorm:"column:update_time"`
UpdateUser int64 `gorm:"column:update_user"`
} }
func (userBaseInfoRow) TableName() string { func (userBaseInfoRow) TableName() string {
return "user_base_info" return "user_base_info"
} }
type sysCountryCodeRow struct {
ID int64 `gorm:"column:id;primaryKey"`
AlphaTwo string `gorm:"column:alpha_two"`
CountryName string `gorm:"column:country_name"`
NationalFlag string `gorm:"column:national_flag"`
PhonePrefix int `gorm:"column:phone_prefix"`
Open bool `gorm:"column:is_open"`
Sort int `gorm:"column:sort"`
}
func (sysCountryCodeRow) TableName() string {
return "sys_country_code"
}
type propsSourceRecordRow struct { type propsSourceRecordRow struct {
ID int64 `gorm:"column:id;primaryKey"` ID int64 `gorm:"column:id;primaryKey"`
Type string `gorm:"column:type"` Type string `gorm:"column:type"`
@ -96,8 +69,7 @@ func (propsNobleVIPAbilityRow) TableName() string {
} }
func (s *Service) GetProfile(ctx context.Context, user AuthUser) (*ProfileResponse, error) { func (s *Service) GetProfile(ctx context.Context, user AuthUser) (*ProfileResponse, error) {
manager, err := s.requireManagerInfo(ctx, user) if err := s.ensureManager(ctx, user); err != nil {
if err != nil {
return nil, err return nil, err
} }
@ -105,7 +77,7 @@ func (s *Service) GetProfile(ctx context.Context, user AuthUser) (*ProfileRespon
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &ProfileResponse{Manager: view, Features: managerFeaturesFromRow(manager)}, nil return &ProfileResponse{Manager: view}, nil
} }
func (s *Service) SearchUser(ctx context.Context, user AuthUser, account string) (*UserSearchResponse, error) { func (s *Service) SearchUser(ctx context.Context, user AuthUser, account string) (*UserSearchResponse, error) {
@ -125,34 +97,6 @@ func (s *Service) SearchUser(ctx context.Context, user AuthUser, account string)
return &UserSearchResponse{User: view}, nil return &UserSearchResponse{User: view}, nil
} }
// ListCountries 给经理中心返回当前开放国家H5 只展示可绑定目标,避免经理手输无效国家码。
func (s *Service) ListCountries(ctx context.Context, user AuthUser) (*CountryListResponse, error) {
if err := s.ensureManager(ctx, user); err != nil {
return nil, err
}
if s.db == nil {
return nil, serviceUnavailable("database_unavailable", "database is unavailable")
}
var rows []sysCountryCodeRow
if err := s.db.WithContext(ctx).
Table("sys_country_code").
Select("id, alpha_two, country_name, national_flag, phone_prefix, is_open, sort").
Where("is_open = ?", true).
Order("sort ASC, country_name ASC, id ASC").
Find(&rows).Error; err != nil {
return nil, serverError("country_query_failed", err.Error())
}
records := make([]CountryView, 0, len(rows))
for _, row := range rows {
if view, ok := countryRowToView(row); ok {
records = append(records, view)
}
}
return &CountryListResponse{Records: records}, nil
}
func (s *Service) ListProps(ctx context.Context, user AuthUser, propsType string) (*PropsListResponse, error) { func (s *Service) ListProps(ctx context.Context, user AuthUser, propsType string) (*PropsListResponse, error) {
if err := s.ensureManager(ctx, user); err != nil { if err := s.ensureManager(ctx, user); err != nil {
return nil, err return nil, err
@ -200,7 +144,7 @@ func (s *Service) ListProps(ctx context.Context, user AuthUser, propsType string
} }
func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsRequest) (*SendPropsResponse, error) { func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsRequest) (*SendPropsResponse, error) {
if _, err := s.requireManagerFeature(ctx, user, managerFeatureGiftProps); err != nil { if err := s.ensureManager(ctx, user); err != nil {
return nil, err return nil, err
} }
if req.AcceptUserID.Int64() <= 0 { if req.AcceptUserID.Int64() <= 0 {
@ -209,10 +153,6 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
if req.PropsID.Int64() <= 0 { if req.PropsID.Int64() <= 0 {
return nil, badRequest("props_required", "propsId is required") return nil, badRequest("props_required", "propsId is required")
} }
days, err := normalizeGiftDays(req.Days)
if err != nil {
return nil, err
}
if s.java == nil { if s.java == nil {
return nil, serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable") return nil, serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable")
} }
@ -228,7 +168,7 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
prop, err := s.loadGiftableProps(ctx, req.PropsID.Int64()) prop, err := s.loadGiftableProps(ctx, req.PropsID.Int64())
if err != nil { if err != nil {
if isAppErrorCode(err, "prop_not_giftable") { if isAppErrorCode(err, "prop_not_giftable") {
if resp, vipErr := s.sendVIPGift(ctx, user, target, req.PropsID.Int64(), days); vipErr != nil { if resp, vipErr := s.sendVIPGift(ctx, user, target, req.PropsID.Int64()); vipErr != nil {
return nil, vipErr return nil, vipErr
} else if resp != nil { } else if resp != nil {
return resp, nil return resp, nil
@ -237,6 +177,7 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
return nil, err return nil, err
} }
days := propsGiftDays(prop.Type)
if prop.Type == propsTypeBadge { if prop.Type == propsTypeBadge {
if err := s.java.ActivateTemporaryBadge(ctx, req.AcceptUserID.Int64(), prop.ID, days); err != nil { if err := s.java.ActivateTemporaryBadge(ctx, req.AcceptUserID.Int64(), prop.ID, days); err != nil {
return nil, serverError("send_badge_failed", err.Error()) return nil, serverError("send_badge_failed", err.Error())
@ -263,7 +204,7 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
if err != nil { if err != nil {
return nil, serverError("vip_ability_query_failed", err.Error()) return nil, serverError("vip_ability_query_failed", err.Error())
} }
grants = append(grants, nobleVIPAccessoryGrants(ability, days)...) grants = append(grants, nobleVIPAccessoryGrants(ability)...)
} }
for _, grant := range grants { for _, grant := range grants {
@ -298,7 +239,7 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
} }
func (s *Service) BanUser(ctx context.Context, user AuthUser, req BanUserRequest) (*BanUserResponse, error) { func (s *Service) BanUser(ctx context.Context, user AuthUser, req BanUserRequest) (*BanUserResponse, error) {
if _, err := s.requireManagerFeature(ctx, user, managerFeatureBanUser); err != nil { if err := s.ensureManager(ctx, user); err != nil {
return nil, err return nil, err
} }
if req.UserID.Int64() <= 0 { if req.UserID.Int64() <= 0 {
@ -345,7 +286,7 @@ func (s *Service) BanUser(ctx context.Context, user AuthUser, req BanUserRequest
} }
func (s *Service) UnbanUser(ctx context.Context, user AuthUser, req UnbanUserRequest) (*UnbanUserResponse, error) { func (s *Service) UnbanUser(ctx context.Context, user AuthUser, req UnbanUserRequest) (*UnbanUserResponse, error) {
if _, err := s.requireManagerFeature(ctx, user, managerFeatureUnbanUser); err != nil { if err := s.ensureManager(ctx, user); err != nil {
return nil, err return nil, err
} }
if req.UserID.Int64() <= 0 { if req.UserID.Int64() <= 0 {
@ -382,105 +323,26 @@ func (s *Service) UnbanUser(ctx context.Context, user AuthUser, req UnbanUserReq
}, nil }, nil
} }
// ChangeUserCountry 只允许 admin 开启改国家权限的经理移动普通用户国家;目标用户已有业务身份时拒绝,避免跨国家后团队、币商和主播归属数据不一致。 func (s *Service) ensureManager(ctx context.Context, user AuthUser) error {
func (s *Service) ChangeUserCountry(ctx context.Context, user AuthUser, req ChangeUserCountryRequest) (*ChangeUserCountryResponse, error) { if user.UserID <= 0 {
if _, err := s.requireManagerFeature(ctx, user, managerFeatureChangeCountry); err != nil { return unauthorized("invalid_user", "authenticated user is required")
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 { if s.db == nil {
return nil, serviceUnavailable("database_unavailable", "database is unavailable") return serviceUnavailable("database_unavailable", "database is unavailable")
}
if s.java == nil {
return nil, serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable")
} }
target, err := s.loadUserRowByID(ctx, req.UserID.Int64()) var count int64
err := s.db.WithContext(ctx).
Table("team_manager_base_info").
Where("user_id = ?", user.UserID).
Count(&count).Error
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { return serverError("manager_query_failed", err.Error())
return nil, notFound("user_not_found", "user not found")
}
return nil, serverError("user_query_failed", err.Error())
} }
country, err := s.loadOpenCountryByID(ctx, req.CountryID.Int64()) if count == 0 {
if err != nil { return forbidden("not_manager", "user is not manager")
return nil, err
}
if err := s.ensureUserCountryMovable(ctx, target.ID); err != nil {
return nil, err
}
// 国家写入统一交给 Java复用 country -> user 锁顺序,避免与后台国家批量同步并发时漏数据。
if err := s.java.UpdateUserCountry(ctx, target.ID, country.ID); err != nil {
return nil, serverError("user_country_update_failed", err.Error())
}
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) { func (s *Service) findUserView(ctx context.Context, account string) (UserView, error) {
@ -548,65 +410,6 @@ func (s *Service) loadUserRowByAccount(ctx context.Context, account string) (use
return row, err return row, err
} }
func (s *Service) loadOpenCountryByID(ctx context.Context, countryID int64) (sysCountryCodeRow, error) {
var row sysCountryCodeRow
err := s.db.WithContext(ctx).
Table("sys_country_code").
Select("id, alpha_two, country_name, national_flag, phone_prefix, is_open, sort").
Where("id = ? AND is_open = ?", countryID, true).
First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return sysCountryCodeRow{}, notFound("country_not_found", "country not found")
}
if err != nil {
return sysCountryCodeRow{}, serverError("country_query_failed", err.Error())
}
if strings.TrimSpace(row.AlphaTwo) == "" {
return sysCountryCodeRow{}, badRequest("country_code_required", "country code is required")
}
return row, nil
}
// ensureUserCountryMovable 串行检查所有会绑定归属关系的身份;任一命中都返回同一个业务错误,前端显示统一提示。
func (s *Service) ensureUserCountryMovable(ctx context.Context, userID int64) error {
locked, err := s.userHasLockedHistoryIdentity(ctx, userID)
if err != nil {
return err
}
if locked {
return badRequest("user_identity_locked", "user has host, agency, BD, BD leader or coin seller identity and cannot be moved")
}
locked, err = s.userIsBDLeader(ctx, userID)
if err != nil {
return err
}
if locked {
return badRequest("user_identity_locked", "user has host, agency, BD, BD leader or coin seller identity and cannot be moved")
}
if s.java == nil {
return serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable")
}
locked, err = s.java.ExistsFreightSeller(ctx, userID)
if err != nil {
return serverError("coin_seller_query_failed", err.Error())
}
if locked {
return badRequest("user_identity_locked", "user has host, agency, BD, BD leader or coin seller identity and cannot be moved")
}
return nil
}
func (s *Service) userHasLockedHistoryIdentity(ctx context.Context, userID int64) (bool, error) {
var count int64
if err := s.db.WithContext(ctx).
Table("user_history_identity").
Where("user_id = ? AND (is_host = ? OR is_agent = ? OR is_bd = ?)", userID, true, true, true).
Count(&count).Error; err != nil {
return false, serverError("history_identity_query_failed", err.Error())
}
return count > 0, nil
}
func (s *Service) userRowToView(ctx context.Context, row userBaseInfoRow) (UserView, error) { func (s *Service) userRowToView(ctx context.Context, row userBaseInfoRow) (UserView, error) {
hasRecharge, canBan := s.userRechargeEligibility(ctx, row.ID) hasRecharge, canBan := s.userRechargeEligibility(ctx, row.ID)
canBan = canBan && !isUnbannableAccountStatus(row.AccountStatus) canBan = canBan && !isUnbannableAccountStatus(row.AccountStatus)
@ -615,7 +418,6 @@ func (s *Service) userRowToView(ctx context.Context, row userBaseInfoRow) (UserV
Account: row.Account, Account: row.Account,
UserAvatar: row.UserAvatar, UserAvatar: row.UserAvatar,
UserNickname: row.UserNickname, UserNickname: row.UserNickname,
CountryID: ID(row.CountryID),
CountryCode: row.CountryCode, CountryCode: row.CountryCode,
CountryName: row.CountryName, CountryName: row.CountryName,
OriginSys: row.OriginSys, OriginSys: row.OriginSys,
@ -633,7 +435,6 @@ func (s *Service) javaProfileToView(ctx context.Context, profile integration.Use
Account: profile.Account, Account: profile.Account,
UserAvatar: profile.UserAvatar, UserAvatar: profile.UserAvatar,
UserNickname: profile.UserNickname, UserNickname: profile.UserNickname,
CountryID: ID(profile.CountryID),
CountryCode: profile.CountryCode, CountryCode: profile.CountryCode,
CountryName: profile.CountryName, CountryName: profile.CountryName,
OriginSys: profile.OriginSys, OriginSys: profile.OriginSys,
@ -659,9 +460,6 @@ func overlayJavaProfile(view *UserView, profile integration.UserProfile) {
if profile.CountryName != "" { if profile.CountryName != "" {
view.CountryName = profile.CountryName view.CountryName = profile.CountryName
} }
if int64(profile.CountryID) > 0 {
view.CountryID = ID(profile.CountryID)
}
if profile.OriginSys != "" { if profile.OriginSys != "" {
view.OriginSys = profile.OriginSys view.OriginSys = profile.OriginSys
} }
@ -673,21 +471,6 @@ func overlayJavaProfile(view *UserView, profile integration.UserProfile) {
} }
} }
func countryRowToView(row sysCountryCodeRow) (CountryView, bool) {
code := strings.ToUpper(strings.TrimSpace(row.AlphaTwo))
name := strings.TrimSpace(row.CountryName)
if row.ID <= 0 || code == "" || name == "" {
return CountryView{}, false
}
return CountryView{
ID: ID(row.ID),
CountryCode: code,
CountryName: name,
NationalFlag: strings.TrimSpace(row.NationalFlag),
PhonePrefix: row.PhonePrefix,
}, true
}
func (s *Service) userHasRecharge(ctx context.Context, userID int64) (bool, error) { func (s *Service) userHasRecharge(ctx context.Context, userID int64) (bool, error) {
if userID <= 0 { if userID <= 0 {
return false, nil return false, nil
@ -753,7 +536,7 @@ func (s *Service) loadGiftableNobleVIPAbility(ctx context.Context, propsID int64
return propsNobleVIPAbilityRow{}, serverError("vip_ability_query_failed", err.Error()) return propsNobleVIPAbilityRow{}, serverError("vip_ability_query_failed", err.Error())
} }
if row.VIPLevel > maxGiftableVIPLevel { if row.VIPLevel > maxGiftableVIPLevel {
return propsNobleVIPAbilityRow{}, badRequest("vip_not_giftable", "only noble VIP level 1-5 can be gifted") return propsNobleVIPAbilityRow{}, badRequest("vip_not_giftable", "only noble VIP level 1-3 can be gifted")
} }
return row, nil return row, nil
} }
@ -791,13 +574,13 @@ type propsGrant struct {
days int days int
} }
func nobleVIPAccessoryGrants(ability integration.PropsNobleVIPAbility, days int) []propsGrant { func nobleVIPAccessoryGrants(ability integration.PropsNobleVIPAbility) []propsGrant {
grants := make([]propsGrant, 0, 4) grants := make([]propsGrant, 0, 4)
appendIfPositive := func(propsID int64, typ string) { appendIfPositive := func(propsID int64, typ string) {
if propsID <= 0 { if propsID <= 0 {
return return
} }
grants = append(grants, propsGrant{propsID: propsID, typ: typ, days: days}) grants = append(grants, propsGrant{propsID: propsID, typ: typ, days: defaultVIPGiftDays})
} }
appendIfPositive(int64(ability.CarID), propsTypeRide) appendIfPositive(int64(ability.CarID), propsTypeRide)
appendIfPositive(int64(ability.AvatarFrameID), propsTypeAvatarFrame) appendIfPositive(int64(ability.AvatarFrameID), propsTypeAvatarFrame)
@ -851,7 +634,7 @@ func propsRowToView(row propsSourceRecordRow, vipLevels map[int64]int) (PropsVie
if row.Type == propsTypeNobleVIP { if row.Type == propsTypeNobleVIP {
var exists bool var exists bool
level, exists = vipLevels[row.ID] level, exists = vipLevels[row.ID]
if !exists || level > maxGiftableVIPLevel { if !exists || level > 3 {
return PropsView{}, false return PropsView{}, false
} }
} }
@ -875,19 +658,6 @@ func propsGiftDays(propsType string) int {
return defaultGiftDays return defaultGiftDays
} }
func normalizeGiftDays(days int) (int, error) {
// days=0 兼容尚未升级的旧 H5并按新规则默认赠送 30 天。
if days == 0 {
return defaultGiftDays, nil
}
switch days {
case giftDays30, giftDays60, giftDays90:
return days, nil
default:
return 0, badRequest("invalid_gift_days", "days must be one of 30, 60, 90")
}
}
func normalizePropsType(propsType string) string { func normalizePropsType(propsType string) string {
return strings.ToUpper(strings.TrimSpace(propsType)) return strings.ToUpper(strings.TrimSpace(propsType))
} }

View File

@ -17,8 +17,6 @@ import (
type fakeManagerGateway struct { type fakeManagerGateway struct {
recharges map[int64][]integration.UserTotalRecharge recharges map[int64][]integration.UserTotalRecharge
rechargeErr error rechargeErr error
freightSellers map[int64]bool
freightSellerErr error
abilities map[int64]integration.PropsNobleVIPAbility abilities map[int64]integration.PropsNobleVIPAbility
maxAbility integration.PropsNobleVIPAbility maxAbility integration.PropsNobleVIPAbility
giveRequests []integration.GivePropsBackpackRequest giveRequests []integration.GivePropsBackpackRequest
@ -26,16 +24,9 @@ type fakeManagerGateway struct {
unblockRequests []integration.UnblockAccountRequest unblockRequests []integration.UnblockAccountRequest
switchIDs []int64 switchIDs []int64
cacheCleared []int64 cacheCleared []int64
countryUpdates []fakeCountryUpdate
countryUpdateErr error
badgeActivations []fakeBadgeActivation badgeActivations []fakeBadgeActivation
} }
type fakeCountryUpdate struct {
userID int64
countryID int64
}
type fakeBadgeActivation struct { type fakeBadgeActivation struct {
userID int64 userID int64
badgeID int64 badgeID int64
@ -46,14 +37,6 @@ func (f *fakeManagerGateway) GetUserProfile(context.Context, int64) (integration
return integration.UserProfile{}, nil return integration.UserProfile{}, nil
} }
func (f *fakeManagerGateway) UpdateUserCountry(_ context.Context, userID int64, countryID int64) error {
if f.countryUpdateErr != nil {
return f.countryUpdateErr
}
f.countryUpdates = append(f.countryUpdates, fakeCountryUpdate{userID: userID, countryID: countryID})
return nil
}
func (f *fakeManagerGateway) GetUserProfileByAccountOne(context.Context, string) (integration.UserProfile, error) { func (f *fakeManagerGateway) GetUserProfileByAccountOne(context.Context, string) (integration.UserProfile, error) {
return integration.UserProfile{}, nil return integration.UserProfile{}, nil
} }
@ -69,13 +52,6 @@ func (f *fakeManagerGateway) MapUserTotalRecharge(_ context.Context, userIDs []i
return result, nil return result, nil
} }
func (f *fakeManagerGateway) ExistsFreightSeller(_ context.Context, userID int64) (bool, error) {
if f.freightSellerErr != nil {
return false, f.freightSellerErr
}
return f.freightSellers[userID], nil
}
func (f *fakeManagerGateway) GivePropsBackpack(_ context.Context, req integration.GivePropsBackpackRequest) error { func (f *fakeManagerGateway) GivePropsBackpack(_ context.Context, req integration.GivePropsBackpackRequest) error {
f.giveRequests = append(f.giveRequests, req) f.giveRequests = append(f.giveRequests, req)
return nil return nil
@ -122,14 +98,10 @@ func TestListPropsRequiresAdminFreeAndFiltersVIPLevels(t *testing.T) {
propsSourceRecordRow{ID: 102, Type: propsTypeRide, Name: "Paid ride", AdminFree: false}, propsSourceRecordRow{ID: 102, Type: propsTypeRide, Name: "Paid ride", AdminFree: false},
propsSourceRecordRow{ID: 201, Type: propsTypeNobleVIP, Name: "VIP 3", AdminFree: true}, propsSourceRecordRow{ID: 201, Type: propsTypeNobleVIP, Name: "VIP 3", AdminFree: true},
propsSourceRecordRow{ID: 204, Type: propsTypeNobleVIP, Name: "VIP 4", AdminFree: true}, propsSourceRecordRow{ID: 204, Type: propsTypeNobleVIP, Name: "VIP 4", AdminFree: true},
propsSourceRecordRow{ID: 205, Type: propsTypeNobleVIP, Name: "VIP 5", AdminFree: true},
propsSourceRecordRow{ID: 206, Type: propsTypeNobleVIP, Name: "VIP 6", AdminFree: true},
) )
seedAbilities(t, service.db.(*gorm.DB), seedAbilities(t, service.db.(*gorm.DB),
propsNobleVIPAbilityRow{ID: 201, VIPLevel: 3}, propsNobleVIPAbilityRow{ID: 201, VIPLevel: 3},
propsNobleVIPAbilityRow{ID: 204, VIPLevel: 4}, propsNobleVIPAbilityRow{ID: 204, VIPLevel: 4},
propsNobleVIPAbilityRow{ID: 205, VIPLevel: 5},
propsNobleVIPAbilityRow{ID: 206, VIPLevel: 6},
) )
resp, err := service.ListProps(context.Background(), AuthUser{UserID: 1}, "") resp, err := service.ListProps(context.Background(), AuthUser{UserID: 1}, "")
@ -148,16 +120,10 @@ func TestListPropsRequiresAdminFreeAndFiltersVIPLevels(t *testing.T) {
t.Fatalf("records = %+v, non-admin-free props must be hidden", resp.Records) t.Fatalf("records = %+v, non-admin-free props must be hidden", resp.Records)
} }
if item, ok := got[201]; !ok || item.Days != defaultVIPGiftDays || item.VIPLevel != 3 { if item, ok := got[201]; !ok || item.Days != defaultVIPGiftDays || item.VIPLevel != 3 {
t.Fatalf("vip 201 = %+v, want level 3 with default VIP days", item) t.Fatalf("vip 201 = %+v, want level 3 with 3 days", item)
} }
if item, ok := got[204]; !ok || item.VIPLevel != 4 { if _, ok := got[204]; ok {
t.Fatalf("vip 204 = %+v, want level 4 visible", item) t.Fatalf("records = %+v, VIP level 4 must be hidden", resp.Records)
}
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)
} }
} }
@ -170,32 +136,18 @@ func TestListPropsReturnsVIPLevelConfigs(t *testing.T) {
model.VipLevelConfig{ID: 302, SysOrigin: "LIKEI", Level: 2, LevelCode: "VIP2", DisplayName: "VIP 2", Enabled: false, DurationDays: 30}, model.VipLevelConfig{ID: 302, SysOrigin: "LIKEI", Level: 2, LevelCode: "VIP2", DisplayName: "VIP 2", Enabled: false, DurationDays: 30},
model.VipLevelConfig{ID: 303, SysOrigin: "ATYOU", Level: 1, LevelCode: "VIP1", DisplayName: "ATYOU VIP 1", Enabled: true, DurationDays: 30}, model.VipLevelConfig{ID: 303, SysOrigin: "ATYOU", Level: 1, LevelCode: "VIP1", DisplayName: "ATYOU VIP 1", Enabled: true, DurationDays: 30},
model.VipLevelConfig{ID: 304, SysOrigin: "LIKEI", Level: 4, LevelCode: "VIP4", DisplayName: "VIP 4", Enabled: true, DurationDays: 30}, model.VipLevelConfig{ID: 304, SysOrigin: "LIKEI", Level: 4, LevelCode: "VIP4", DisplayName: "VIP 4", Enabled: true, DurationDays: 30},
model.VipLevelConfig{ID: 305, SysOrigin: "LIKEI", Level: 5, LevelCode: "VIP5", DisplayName: "VIP 5", Enabled: true, DurationDays: 30},
model.VipLevelConfig{ID: 306, SysOrigin: "LIKEI", Level: 6, LevelCode: "VIP6", DisplayName: "VIP 6", Enabled: true, DurationDays: 30},
) )
resp, err := service.ListProps(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, propsTypeNobleVIP) resp, err := service.ListProps(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, propsTypeNobleVIP)
if err != nil { if err != nil {
t.Fatalf("ListProps() error = %v", err) t.Fatalf("ListProps() error = %v", err)
} }
if len(resp.Records) != 3 { if len(resp.Records) != 1 {
t.Fatalf("records = %+v, want enabled LIKEI vip configs up to level 5", resp.Records) t.Fatalf("records = %+v, want only one enabled LIKEI vip config", resp.Records)
} }
got := map[int64]PropsView{} got := resp.Records[0]
for _, item := range resp.Records { if got.ID.Int64() != 301 || got.Type != propsTypeNobleVIP || got.VIPLevel != 1 || got.Days != 30 {
got[item.ID.Int64()] = item t.Fatalf("vip props = %+v, want config 301 level 1", got)
}
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)
} }
} }
@ -215,112 +167,6 @@ 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) { func TestSendPropsRejectsNonAdminFree(t *testing.T) {
service, fake := newManagerCenterTestService(t) service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB) db := service.db.(*gorm.DB)
@ -381,23 +227,6 @@ func TestSendNobleVIPGivesAccessoriesAndSwitchesMax(t *testing.T) {
} }
} }
func TestSendPropsRejectsDisabledFeature(t *testing.T) {
service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManagerWithFeatures(t, db, 1, ManagerFeatures{AddBDLeader: true, BanUser: true, UnbanUser: true})
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2"})
seedProps(t, db, propsSourceRecordRow{ID: 101, Type: propsTypeRide, Name: "Giftable ride", AdminFree: true})
_, err := service.SendProps(context.Background(), AuthUser{UserID: 1}, SendPropsRequest{
AcceptUserID: ID(2),
PropsID: ID(101),
})
assertAppErrorCode(t, err, "manager_feature_disabled")
if len(fake.giveRequests) != 0 {
t.Fatalf("give requests = %+v, want none", fake.giveRequests)
}
}
func TestSendBadgeActivatesBadgeBackpack(t *testing.T) { func TestSendBadgeActivatesBadgeBackpack(t *testing.T) {
service, fake := newManagerCenterTestService(t) service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB) db := service.db.(*gorm.DB)
@ -437,12 +266,12 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
seedVIPConfigs(t, db, model.VipLevelConfig{ seedVIPConfigs(t, db, model.VipLevelConfig{
ID: 301, ID: 301,
SysOrigin: "LIKEI", SysOrigin: "LIKEI",
Level: 5, Level: 3,
LevelCode: "VIP5", LevelCode: "VIP3",
DisplayName: "VIP 5", DisplayName: "VIP 3",
Enabled: true, Enabled: true,
DurationDays: 30, DurationDays: 30,
PriceGold: 5000, PriceGold: 3000,
}) })
resp, err := service.SendProps(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, SendPropsRequest{ resp, err := service.SendProps(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, SendPropsRequest{
@ -452,7 +281,7 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("SendProps() error = %v", err) t.Fatalf("SendProps() error = %v", err)
} }
if !resp.Success || resp.Type != propsTypeNobleVIP || resp.VIPLevel != 5 || resp.Days != 30 { if !resp.Success || resp.Type != propsTypeNobleVIP || resp.VIPLevel != 3 || resp.Days != 30 {
t.Fatalf("resp = %+v, want vip gift success", resp) t.Fatalf("resp = %+v, want vip gift success", resp)
} }
@ -460,8 +289,8 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
if err := db.Where("sys_origin = ? AND user_id = ?", "LIKEI", int64(2)).First(&state).Error; err != nil { if err := db.Where("sys_origin = ? AND user_id = ?", "LIKEI", int64(2)).First(&state).Error; err != nil {
t.Fatalf("load state: %v", err) t.Fatalf("load state: %v", err)
} }
if state.Level != 5 || state.Status != vipStatusActive { if state.Level != 3 || state.Status != vipStatusActive {
t.Fatalf("state = %+v, want vip5 active", state) t.Fatalf("state = %+v, want vip3 active", state)
} }
var orders int64 var orders int64
if err := db.Model(&model.VipOrderRecord{}).Where("user_id = ? AND order_type = ?", int64(2), vipOrderTypeGMGift).Count(&orders).Error; err != nil { if err := db.Model(&model.VipOrderRecord{}).Where("user_id = ? AND order_type = ?", int64(2), vipOrderTypeGMGift).Count(&orders).Error; err != nil {
@ -471,7 +300,7 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
t.Fatalf("orders = %d, want 1", orders) t.Fatalf("orders = %d, want 1", orders)
} }
var records int64 var records int64
if err := db.Model(&gmVIPGiftRecordRow{}).Where("user_id = ? AND vip_level = ?", int64(2), 5).Count(&records).Error; err != nil { if err := db.Model(&gmVIPGiftRecordRow{}).Where("user_id = ? AND vip_level = ?", int64(2), 3).Count(&records).Error; err != nil {
t.Fatalf("count gift records: %v", err) t.Fatalf("count gift records: %v", err)
} }
if records != 1 { if records != 1 {
@ -479,48 +308,6 @@ 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) { func TestSendVIPRejectsLevelAboveGiftLimit(t *testing.T) {
service, _ := newManagerCenterTestService(t) service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB) db := service.db.(*gorm.DB)
@ -532,9 +319,9 @@ func TestSendVIPRejectsLevelAboveGiftLimit(t *testing.T) {
seedVIPConfigs(t, db, model.VipLevelConfig{ seedVIPConfigs(t, db, model.VipLevelConfig{
ID: 304, ID: 304,
SysOrigin: "LIKEI", SysOrigin: "LIKEI",
Level: 6, Level: 4,
LevelCode: "VIP6", LevelCode: "VIP4",
DisplayName: "VIP 6", DisplayName: "VIP 4",
Enabled: true, Enabled: true,
DurationDays: 30, DurationDays: 30,
}) })
@ -546,90 +333,6 @@ func TestSendVIPRejectsLevelAboveGiftLimit(t *testing.T) {
assertAppErrorCode(t, err, "vip_not_giftable") 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) { func TestBanUserRejectsRechargedUser(t *testing.T) {
service, fake := newManagerCenterTestService(t) service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB) db := service.db.(*gorm.DB)
@ -709,19 +412,6 @@ func TestBanUserWithoutRechargeCallsPunishment(t *testing.T) {
} }
} }
func TestUnbanUserRejectsDisabledFeature(t *testing.T) {
service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManagerWithFeatures(t, db, 1, ManagerFeatures{AddBDLeader: true, GiftProps: true, BanUser: true})
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", AccountStatus: accountStatusArchive})
_, err := service.UnbanUser(context.Background(), AuthUser{UserID: 1}, UnbanUserRequest{UserID: ID(2)})
assertAppErrorCode(t, err, "manager_feature_disabled")
if len(fake.unblockRequests) != 0 {
t.Fatalf("unblock requests = %+v, want none", fake.unblockRequests)
}
}
func TestUnbanUserRejectsNormalUser(t *testing.T) { func TestUnbanUserRejectsNormalUser(t *testing.T) {
service, fake := newManagerCenterTestService(t) service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB) db := service.db.(*gorm.DB)
@ -781,120 +471,6 @@ 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 TestChangeUserCountryDelegatesToJavaAndClearsCache(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)
}
if len(fake.countryUpdates) != 1 || fake.countryUpdates[0] != (fakeCountryUpdate{userID: 2, countryID: 11}) {
t.Fatalf("countryUpdates = %+v, want user 2 country 11", fake.countryUpdates)
}
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" || row.CountryName != "Saudi Arabia" {
t.Fatalf("user row = %+v, want Go service not to write country directly", row)
}
if !equalInt64Slice(fake.cacheCleared, []int64{2}) {
t.Fatalf("cacheCleared = %+v, want [2]", fake.cacheCleared)
}
}
func TestChangeUserCountryRejectsDisabledFeature(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManagerWithFeatures(t, db, 1, ManagerFeatures{AddBDLeader: true, GiftProps: true, BanUser: true, UnbanUser: true})
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA", CountryName: "Saudi Arabia"})
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
_, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{UserID: ID(2), CountryID: ID(11)})
assertAppErrorCode(t, err, "manager_feature_disabled")
var row userBaseInfoRow
if err := db.Where("id = ?", int64(2)).First(&row).Error; err != nil {
t.Fatalf("load user: %v", err)
}
if row.CountryID != 10 || row.CountryCode != "SA" {
t.Fatalf("user row = %+v, want unchanged country when feature disabled", row)
}
}
func TestChangeUserCountryRejectsHistoryIdentity(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA"})
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
if err := db.Create(&userHistoryIdentityRow{UserID: 2, IsHost: true}).Error; err != nil {
t.Fatalf("seed identity: %v", err)
}
_, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{UserID: ID(2), CountryID: ID(11)})
assertAppErrorCode(t, err, "user_identity_locked")
}
func TestChangeUserCountryRejectsBDLeader(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA"})
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
if err := db.Create(&bdLeaderRow{ID: 20, UserID: 2, Origin: "LIKEI", RegionID: "SA"}).Error; err != nil {
t.Fatalf("seed bd leader: %v", err)
}
_, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{UserID: ID(2), CountryID: ID(11)})
assertAppErrorCode(t, err, "user_identity_locked")
}
func TestChangeUserCountryRejectsCoinSeller(t *testing.T) {
service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA"})
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
fake.freightSellers[2] = true
_, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{UserID: ID(2), CountryID: ID(11)})
assertAppErrorCode(t, err, "user_identity_locked")
}
func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) { func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
t.Helper() t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
@ -904,11 +480,6 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
if err := db.AutoMigrate( if err := db.AutoMigrate(
&teamManagerInfoRow{}, &teamManagerInfoRow{},
&userBaseInfoRow{}, &userBaseInfoRow{},
&sysCountryCodeRow{},
&bdLeaderRow{},
&businessDevelopmentBaseInfoRow{},
&userHistoryIdentityRow{},
&administratorRow{},
&propsSourceRecordRow{}, &propsSourceRecordRow{},
&propsNobleVIPAbilityRow{}, &propsNobleVIPAbilityRow{},
&model.VipLevelConfig{}, &model.VipLevelConfig{},
@ -919,45 +490,19 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
t.Fatalf("auto migrate: %v", err) t.Fatalf("auto migrate: %v", err)
} }
fake := &fakeManagerGateway{ fake := &fakeManagerGateway{
recharges: map[int64][]integration.UserTotalRecharge{}, recharges: map[int64][]integration.UserTotalRecharge{},
freightSellers: map[int64]bool{}, abilities: map[int64]integration.PropsNobleVIPAbility{},
abilities: map[int64]integration.PropsNobleVIPAbility{},
} }
return NewService(db, fake), fake return NewService(db, fake), fake
} }
func seedManager(t *testing.T, db *gorm.DB, userID int64) { func seedManager(t *testing.T, db *gorm.DB, userID int64) {
t.Helper() t.Helper()
seedManagerWithFeatures(t, db, userID, ManagerFeatures{ if err := db.Create(&teamManagerInfoRow{ID: userID, UserID: userID, Origin: "LIKEI"}).Error; err != nil {
AddBDLeader: true,
GiftProps: true,
BanUser: true,
UnbanUser: true,
ChangeCountry: true,
})
}
func seedManagerWithFeatures(t *testing.T, db *gorm.DB, userID int64, features ManagerFeatures) {
t.Helper()
if err := db.Create(&teamManagerInfoRow{
ID: userID,
UserID: userID,
Origin: "LIKEI",
RegionID: "SA",
CanAddBDLeader: boolPtr(features.AddBDLeader),
CanGiftProps: boolPtr(features.GiftProps),
CanBanUser: boolPtr(features.BanUser),
CanUnbanUser: boolPtr(features.UnbanUser),
CanChangeCountry: boolPtr(features.ChangeCountry),
}).Error; err != nil {
t.Fatalf("seed manager: %v", err) t.Fatalf("seed manager: %v", err)
} }
} }
func boolPtr(value bool) *bool {
return &value
}
func seedUsers(t *testing.T, db *gorm.DB, rows ...userBaseInfoRow) { func seedUsers(t *testing.T, db *gorm.DB, rows ...userBaseInfoRow) {
t.Helper() t.Helper()
for _, row := range rows { for _, row := range rows {
@ -970,15 +515,6 @@ func seedUsers(t *testing.T, db *gorm.DB, rows ...userBaseInfoRow) {
} }
} }
func seedCountries(t *testing.T, db *gorm.DB, rows ...sysCountryCodeRow) {
t.Helper()
for _, row := range rows {
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seed country: %v", err)
}
}
}
func seedProps(t *testing.T, db *gorm.DB, rows ...propsSourceRecordRow) { func seedProps(t *testing.T, db *gorm.DB, rows ...propsSourceRecordRow) {
t.Helper() t.Helper()
for _, row := range rows { for _, row := range rows {

View File

@ -6,7 +6,6 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
"chatapp3-golang/internal/common" "chatapp3-golang/internal/common"
"chatapp3-golang/internal/integration" "chatapp3-golang/internal/integration"
@ -28,14 +27,9 @@ const (
vipGiftOrigin = "VIP_PURCHASE" vipGiftOrigin = "VIP_PURCHASE"
vipGiftOriginDesc = "VIP purchase" vipGiftOriginDesc = "VIP purchase"
defaultGiftDays = 30 defaultGiftDays = 7
defaultVIPGiftDays = 30 defaultVIPGiftDays = 30
// 经理赠送统一限制为运营确认的三个有效期,避免客户端提交任意天数。 maxGiftableVIPLevel = 3
giftDays30 = 30
giftDays60 = 60
giftDays90 = 90
// 经理中心 VIP 赠送按当前运营范围放开 VIP1-VIP5列表和发放校验共用该上限避免展示可选但提交失败。
maxGiftableVIPLevel = 5
accountStatusNormal = "NORMAL" accountStatusNormal = "NORMAL"
accountStatusFreeze = "FREEZE" accountStatusFreeze = "FREEZE"
@ -43,16 +37,6 @@ const (
accountStatusArchiveDevice = "ARCHIVE_DEVICE" accountStatusArchiveDevice = "ARCHIVE_DEVICE"
) )
type managerFeature string
const (
managerFeatureAddBDLeader managerFeature = "add_bd_leader"
managerFeatureGiftProps managerFeature = "gift_props"
managerFeatureBanUser managerFeature = "ban_user"
managerFeatureUnbanUser managerFeature = "unban_user"
managerFeatureChangeCountry managerFeature = "change_country"
)
type AppError = common.AppError type AppError = common.AppError
type AuthUser = common.AuthUser type AuthUser = common.AuthUser
@ -64,10 +48,8 @@ type managerDB interface {
type managerGateway interface { type managerGateway interface {
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error) GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error)
UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error
GetUserProfileByAccountOne(ctx context.Context, account string) (integration.UserProfile, error) GetUserProfileByAccountOne(ctx context.Context, account string) (integration.UserProfile, error)
MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error)
ExistsFreightSeller(ctx context.Context, userID int64) (bool, error)
GivePropsBackpack(ctx context.Context, req integration.GivePropsBackpackRequest) error GivePropsBackpack(ctx context.Context, req integration.GivePropsBackpackRequest) error
GetNobleVIPAbility(ctx context.Context, sourceID int64) (integration.PropsNobleVIPAbility, error) GetNobleVIPAbility(ctx context.Context, sourceID int64) (integration.PropsNobleVIPAbility, error)
GetUserMaxNobleVIPAbility(ctx context.Context, userID int64) (integration.PropsNobleVIPAbility, error) GetUserMaxNobleVIPAbility(ctx context.Context, userID int64) (integration.PropsNobleVIPAbility, error)
@ -123,40 +105,18 @@ func (id *ID) UnmarshalJSON(data []byte) error {
} }
type ProfileResponse struct { type ProfileResponse struct {
Manager UserView `json:"manager"` Manager UserView `json:"manager"`
Features ManagerFeatures `json:"features"`
}
type ManagerFeatures struct {
AddBDLeader bool `json:"addBdLeader"`
GiftProps bool `json:"giftProps"`
BanUser bool `json:"banUser"`
UnbanUser bool `json:"unbanUser"`
ChangeCountry bool `json:"changeCountry"`
} }
type UserSearchResponse struct { type UserSearchResponse struct {
User UserView `json:"user"` User UserView `json:"user"`
} }
type CountryListResponse struct {
Records []CountryView `json:"records"`
}
type CountryView struct {
ID ID `json:"id"`
CountryCode string `json:"countryCode"`
CountryName string `json:"countryName"`
NationalFlag string `json:"nationalFlag,omitempty"`
PhonePrefix int `json:"phonePrefix,omitempty"`
}
type UserView struct { type UserView struct {
UserID ID `json:"userId"` UserID ID `json:"userId"`
Account string `json:"account"` Account string `json:"account"`
UserAvatar string `json:"userAvatar"` UserAvatar string `json:"userAvatar"`
UserNickname string `json:"userNickname"` UserNickname string `json:"userNickname"`
CountryID ID `json:"countryId,omitempty"`
CountryCode string `json:"countryCode,omitempty"` CountryCode string `json:"countryCode,omitempty"`
CountryName string `json:"countryName,omitempty"` CountryName string `json:"countryName,omitempty"`
OriginSys string `json:"originSys,omitempty"` OriginSys string `json:"originSys,omitempty"`
@ -183,9 +143,8 @@ type PropsView struct {
} }
type SendPropsRequest struct { type SendPropsRequest struct {
AcceptUserID ID `json:"acceptUserId"` AcceptUserID ID `json:"acceptUserId"`
PropsID ID `json:"propsId"` PropsID ID `json:"propsId"`
Days int `json:"days"`
} }
type SendPropsResponse struct { type SendPropsResponse struct {
@ -219,45 +178,6 @@ type UnbanUserResponse struct {
AccountStatus string `json:"accountStatus"` AccountStatus string `json:"accountStatus"`
} }
type ChangeUserCountryRequest struct {
UserID ID `json:"userId"`
CountryID ID `json:"countryId"`
}
type ChangeUserCountryResponse struct {
Success bool `json:"success"`
User UserView `json:"user"`
}
type BDLeaderListResponse struct {
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 { func badRequest(code, message string) error {
return NewAppError(http.StatusBadRequest, code, message) return NewAppError(http.StatusBadRequest, code, message)
} }

View File

@ -79,7 +79,7 @@ func (s *Service) listVIPProps(ctx context.Context, user AuthUser) (*PropsListRe
}, nil }, nil
} }
func (s *Service) sendVIPGift(ctx context.Context, manager AuthUser, target userBaseInfoRow, configID int64, days int) (*SendPropsResponse, error) { func (s *Service) sendVIPGift(ctx context.Context, manager AuthUser, target userBaseInfoRow, configID int64) (*SendPropsResponse, error) {
sysOrigin := normalizeSysOrigin(target.OriginSys) sysOrigin := normalizeSysOrigin(target.OriginSys)
config, found, err := s.loadVIPConfigByID(ctx, sysOrigin, configID) config, found, err := s.loadVIPConfigByID(ctx, sysOrigin, configID)
if err != nil || !found { if err != nil || !found {
@ -91,8 +91,9 @@ func (s *Service) sendVIPGift(ctx context.Context, manager AuthUser, target user
if config.Level < 1 || config.Level > maxGiftableVIPLevel { if config.Level < 1 || config.Level > maxGiftableVIPLevel {
return nil, badRequest("vip_not_giftable", "vip level is invalid") return nil, badRequest("vip_not_giftable", "vip level is invalid")
} }
// 经理选择的赠送天数覆盖 VIP 售卖配置时长,订单、状态和附属权益保持同一到期口径。 if config.DurationDays <= 0 {
config.DurationDays = days config.DurationDays = defaultVIPGiftDays
}
now := time.Now() now := time.Now()
orderID, err := utils.NextID() orderID, err := utils.NextID()
@ -170,7 +171,6 @@ func (s *Service) sendVIPGift(ctx context.Context, manager AuthUser, target user
CreateTime: now, CreateTime: now,
} }
} }
previousResources := vipGiftResourcesFromState(state)
applyVIPGiftConfigToState(state, config, startAt, expireAt, now) applyVIPGiftConfigToState(state, config, startAt, expireAt, now)
if err := tx.Save(state).Error; err != nil { if err := tx.Save(state).Error; err != nil {
return nil, serverError("vip_state_save_failed", err.Error()) return nil, serverError("vip_state_save_failed", err.Error())
@ -209,7 +209,7 @@ func (s *Service) sendVIPGift(ctx context.Context, manager AuthUser, target user
} }
committed = true committed = true
if err := s.grantVIPGiftResources(ctx, sysOrigin, target.ID, manager.UserID, config, expireAt, previousResources, now); err != nil { if err := s.grantVIPGiftResources(ctx, target.ID, manager.UserID, config, expireAt, now); err != nil {
return nil, serverError("vip_resource_grant_failed", err.Error()) return nil, serverError("vip_resource_grant_failed", err.Error())
} }
@ -301,8 +301,11 @@ func applyVIPGiftConfigToState(state *model.UserVipState, config model.VipLevelC
} }
} }
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 { func (s *Service) grantVIPGiftResources(ctx context.Context, userID int64, managerID int64, config model.VipLevelConfig, expireAt time.Time, now time.Time) error {
resources := vipGiftResourcesFromConfig(config) resources := vipGiftResourcesFromConfig(config)
if len(resources) == 0 {
return nil
}
days := config.DurationDays days := config.DurationDays
if days <= 0 { if days <= 0 {
days = defaultVIPGiftDays days = defaultVIPGiftDays
@ -334,14 +337,6 @@ func (s *Service) grantVIPGiftResources(ctx context.Context, sysOrigin string, u
return err 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 { if err := s.java.RemoveUserProfileCacheAll(ctx, userID); err != nil {
return fmt.Errorf("remove vip user profile cache: %w", err) return fmt.Errorf("remove vip user profile cache: %w", err)
} }
@ -379,7 +374,6 @@ func vipGiftResourcesFromConfig(config model.VipLevelConfig) []vipGiftResource {
{resourceID: config.EntryEffectResourceID, propsType: propsTypeRide}, {resourceID: config.EntryEffectResourceID, propsType: propsTypeRide},
{resourceID: config.ChatBubbleResourceID, propsType: propsTypeChatBubble}, {resourceID: config.ChatBubbleResourceID, propsType: propsTypeChatBubble},
{resourceID: config.FloatPictureResourceID, propsType: propsTypeFloatPicture}, {resourceID: config.FloatPictureResourceID, propsType: propsTypeFloatPicture},
{resourceID: config.BackgroundCardResourceID, propsType: propsTypeDataCard},
} }
out := make([]vipGiftResource, 0, len(resources)) out := make([]vipGiftResource, 0, len(resources))
for _, resource := range resources { for _, resource := range resources {
@ -390,104 +384,6 @@ func vipGiftResourcesFromConfig(config model.VipLevelConfig) []vipGiftResource {
return out 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 { func (s *Service) managerApplicantName(ctx context.Context, managerID int64) string {
row, err := s.loadUserRowByID(ctx, managerID) row, err := s.loadUserRowByID(ctx, managerID)
if err != nil { if err != nil {

View File

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

View File

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

View File

@ -2,9 +2,7 @@ package rechargereward
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"strings"
"testing" "testing"
"time" "time"
@ -115,7 +113,7 @@ func TestSaveConfigUpsertsBySysOrigin(t *testing.T) {
} }
func TestGetHomeUsesJavaRechargeAndMarksLevels(t *testing.T) { func TestGetHomeUsesJavaRechargeAndMarksLevels(t *testing.T) {
groupID := int64(2056981527522242600) groupID := int64(9001)
java := rechargeRewardTestGateway{ java := rechargeRewardTestGateway{
total: map[int64][]integration.UserTotalRecharge{ total: map[int64][]integration.UserTotalRecharge{
42: { 42: {
@ -134,14 +132,7 @@ func TestGetHomeUsesJavaRechargeAndMarksLevels(t *testing.T) {
ID: integration.Int64Value(groupID), ID: integration.Int64Value(groupID),
Name: "Gift Box", Name: "Gift Box",
RewardConfigList: []integration.RewardGroupItem{ 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",
},
}, },
}, },
}, },
@ -205,63 +196,6 @@ 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" { if len(resp.LevelConfigs[0].RewardItems) != 1 || resp.LevelConfigs[0].RewardItems[0].Name != "Frame" {
t.Fatalf("reward items = %+v", resp.LevelConfigs[0].RewardItems) 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) { func TestPageClaimRecordsUsesActualReceiveRecords(t *testing.T) {
@ -448,15 +382,6 @@ func (g rechargeRewardTestGateway) GetRewardGroupDetail(_ context.Context, group
return integration.RewardGroupDetail{}, fmt.Errorf("group not found") return integration.RewardGroupDetail{}, fmt.Errorf("group not found")
} }
func (g rechargeRewardTestGateway) FindRewardGroupDetail(_ context.Context, _ string, name string) (integration.RewardGroupDetail, error) {
for _, detail := range g.groups {
if detail.Name == name {
return detail, nil
}
}
return integration.RewardGroupDetail{}, fmt.Errorf("group not found")
}
func (g rechargeRewardTestGateway) MapUserTotalRecharge(context.Context, []int64) (map[int64][]integration.UserTotalRecharge, error) { func (g rechargeRewardTestGateway) MapUserTotalRecharge(context.Context, []int64) (map[int64][]integration.UserTotalRecharge, error) {
return g.total, nil return g.total, nil
} }

View File

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

View File

@ -47,9 +47,6 @@ func buildLevelPayloads(
if level.RewardGroupID != nil { if level.RewardGroupID != nil {
items = rewardItems[*level.RewardGroupID] items = rewardItems[*level.RewardGroupID]
} }
if items == nil {
items = []RechargeRewardRewardItem{}
}
payloads = append(payloads, RechargeRewardLevelPayload{ payloads = append(payloads, RechargeRewardLevelPayload{
ID: level.ID, ID: level.ID,
Level: level.Level, Level: level.Level,
@ -77,9 +74,6 @@ func buildAdminLevelPayloads(
if level.RewardGroupID != nil { if level.RewardGroupID != nil {
items = rewardItems[*level.RewardGroupID] items = rewardItems[*level.RewardGroupID]
} }
if items == nil {
items = []RechargeRewardRewardItem{}
}
payloads = append(payloads, RechargeRewardLevelPayload{ payloads = append(payloads, RechargeRewardLevelPayload{
ID: level.ID, ID: level.ID,
Level: level.Level, Level: level.Level,
@ -131,7 +125,6 @@ func sortLevels(levels []rechargeRewardLevelSnapshot) {
func (s *Service) loadRewardItemsMap( func (s *Service) loadRewardItemsMap(
ctx context.Context, ctx context.Context,
sysOrigin string,
levels []rechargeRewardLevelSnapshot, levels []rechargeRewardLevelSnapshot,
) map[int64][]RechargeRewardRewardItem { ) map[int64][]RechargeRewardRewardItem {
if s.java == nil { if s.java == nil {
@ -139,14 +132,10 @@ func (s *Service) loadRewardItemsMap(
} }
groupIDs := make([]int64, 0) groupIDs := make([]int64, 0)
seen := make(map[int64]struct{}) seen := make(map[int64]struct{})
groupNames := make(map[int64]string)
for _, level := range levels { for _, level := range levels {
if level.RewardGroupID == nil || *level.RewardGroupID <= 0 { if level.RewardGroupID == nil || *level.RewardGroupID <= 0 {
continue continue
} }
if strings.TrimSpace(level.RewardGroupName) != "" {
groupNames[*level.RewardGroupID] = level.RewardGroupName
}
if _, exists := seen[*level.RewardGroupID]; exists { if _, exists := seen[*level.RewardGroupID]; exists {
continue continue
} }
@ -158,71 +147,26 @@ func (s *Service) loadRewardItemsMap(
result := make(map[int64][]RechargeRewardRewardItem, len(groupIDs)) result := make(map[int64][]RechargeRewardRewardItem, len(groupIDs))
for _, groupID := range groupIDs { for _, groupID := range groupIDs {
detail, err := s.java.GetRewardGroupDetail(ctx, groupID) detail, err := s.java.GetRewardGroupDetail(ctx, groupID)
if err != nil || !rewardGroupDetailHasItems(detail) { if err != nil {
if fallback, fallbackErr := s.java.FindRewardGroupDetail(ctx, sysOrigin, groupNames[groupID]); fallbackErr == nil && rewardGroupDetailHasItems(fallback) { continue
detail = fallback
} else {
result[groupID] = []RechargeRewardRewardItem{}
continue
}
} }
result[groupID] = rewardItemsFromGroup(detail) 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
} }
return result 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 { func totalRechargeCents(entries []integration.UserTotalRecharge) int64 {
var total int64 var total int64
for _, entry := range entries { for _, entry := range entries {

View File

@ -35,7 +35,6 @@ type rechargeRewardDB interface {
type rechargeRewardJavaGateway interface { type rechargeRewardJavaGateway interface {
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error) GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error)
GetRewardGroupDetail(ctx context.Context, groupID int64) (integration.RewardGroupDetail, error) GetRewardGroupDetail(ctx context.Context, groupID int64) (integration.RewardGroupDetail, error)
FindRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (integration.RewardGroupDetail, error)
MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error)
GetThisMonthTotalPersonalRecharge(ctx context.Context, userID int64) (integration.DecimalString, error) GetThisMonthTotalPersonalRecharge(ctx context.Context, userID int64) (integration.DecimalString, error)
} }
@ -76,7 +75,7 @@ type RechargeRewardLevelPayload struct {
RechargeAmount string `json:"rechargeAmount"` RechargeAmount string `json:"rechargeAmount"`
RechargeAmountCents int64 `json:"rechargeAmountCents"` RechargeAmountCents int64 `json:"rechargeAmountCents"`
RewardGold int64 `json:"rewardGold"` RewardGold int64 `json:"rewardGold"`
RewardGroupID *int64 `json:"rewardGroupId,string,omitempty"` RewardGroupID *int64 `json:"rewardGroupId,omitempty"`
RewardGroupName string `json:"rewardGroupName,omitempty"` RewardGroupName string `json:"rewardGroupName,omitempty"`
RewardItems []RechargeRewardRewardItem `json:"rewardItems"` RewardItems []RechargeRewardRewardItem `json:"rewardItems"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
@ -176,7 +175,7 @@ type RechargeRewardClaimedRewardPayload struct {
RechargeAmount string `json:"rechargeAmount"` RechargeAmount string `json:"rechargeAmount"`
RechargeAmountCents int64 `json:"rechargeAmountCents"` RechargeAmountCents int64 `json:"rechargeAmountCents"`
RewardGold int64 `json:"rewardGold"` RewardGold int64 `json:"rewardGold"`
RewardGroupID *int64 `json:"rewardGroupId,string,omitempty"` RewardGroupID *int64 `json:"rewardGroupId,omitempty"`
RewardGroupName string `json:"rewardGroupName,omitempty"` RewardGroupName string `json:"rewardGroupName,omitempty"`
RewardItems []RechargeRewardRewardItem `json:"rewardItems"` RewardItems []RechargeRewardRewardItem `json:"rewardItems"`
} }

View File

@ -66,8 +66,6 @@ func (s *Service) SendRegionBroadcast(ctx context.Context, req RegionBroadcastRe
return nil, err return nil, err
} }
} }
refreshHighWinMessage(data, messageType)
refreshCPRelationMessage(data, messageType)
body := map[string]any{ body := map[string]any{
"type": messageType, "type": messageType,
@ -184,60 +182,6 @@ func firstInt64Value(values ...any) int64 {
return 0 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 { func firstInt64FromList(value any) int64 {
switch typed := value.(type) { switch typed := value.(type) {
case []any: case []any:

View File

@ -133,97 +133,6 @@ 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) { func TestSendRegionBroadcastPrefersJavaRegionResolver(t *testing.T) {
service, db := newRegionIMGroupTestService(t) service, db := newRegionIMGroupTestService(t)
service.regionResolver = fakeRegionResolver{ service.regionResolver = fakeRegionResolver{

View File

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

View File

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

View File

@ -170,7 +170,7 @@ func (s *Service) Purchase(ctx context.Context, user AuthUser, req PurchaseReque
if stateErr != nil { if stateErr != nil {
return nil, stateErr return nil, stateErr
} }
if err := s.ensureStateResourcesGranted(ctx, sysOrigin, user.UserID, state, now); err != nil { if err := s.ensureStateResourcesGranted(ctx, user.UserID, state, now); err != nil {
return nil, NewAppError(http.StatusBadGateway, "vip_resource_grant_failed", err.Error()) return nil, NewAppError(http.StatusBadGateway, "vip_resource_grant_failed", err.Error())
} }
resources, resourceErr := s.loadResourceLookup(ctx, appendResourceIDsFromState(nil, state)) resources, resourceErr := s.loadResourceLookup(ctx, appendResourceIDsFromState(nil, state))
@ -274,7 +274,6 @@ func (s *Service) Purchase(ctx context.Context, user AuthUser, req PurchaseReque
} }
} }
previousResources := vipGrantResourcesFromState(state)
applyPlanToState(state, plan, now) applyPlanToState(state, plan, now)
order.Status = orderStatusSuccess order.Status = orderStatusSuccess
order.ErrorMessage = "" order.ErrorMessage = ""
@ -290,7 +289,7 @@ func (s *Service) Purchase(ctx context.Context, user AuthUser, req PurchaseReque
} }
committed = true committed = true
if err := s.ensurePlanResourcesGranted(ctx, sysOrigin, user.UserID, plan, configs, previousResources, now); err != nil { if err := s.ensurePlanResourcesGranted(ctx, user.UserID, plan, now); err != nil {
return nil, NewAppError(http.StatusBadGateway, "vip_resource_grant_failed", err.Error()) return nil, NewAppError(http.StatusBadGateway, "vip_resource_grant_failed", err.Error())
} }
@ -537,28 +536,15 @@ func expireState(state *model.UserVipState, now time.Time) {
state.UpdateTime = now state.UpdateTime = 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 { func (s *Service) ensurePlanResourcesGranted(ctx context.Context, userID int64, plan purchasePlan, now time.Time) error {
targetResources := vipGrantResourcesFromConfig(plan.TargetConfig) return s.ensureVIPResourcesGranted(ctx, userID, vipGrantResourcesFromConfig(plan.TargetConfig), plan.ExpireAt, plan.DurationDays, now)
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, sysOrigin string, userID int64, state *model.UserVipState, now time.Time) error { func (s *Service) ensureStateResourcesGranted(ctx context.Context, userID int64, state *model.UserVipState, now time.Time) error {
if !isActiveState(state, now) { if !isActiveState(state, now) {
return nil return nil
} }
targetResources := vipGrantResourcesFromState(state) return s.ensureVIPResourcesGranted(ctx, userID, vipGrantResourcesFromState(state), state.ExpireAt, vipGrantDaysUntil(now, state.ExpireAt), now)
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 { func (s *Service) ensureVIPResourcesGranted(ctx context.Context, userID int64, resources []vipGrantResource, expireAt time.Time, days int, now time.Time) error {
@ -699,17 +685,6 @@ 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 { func compactVIPGrantResources(resources []vipGrantResource) []vipGrantResource {
compacted := make([]vipGrantResource, 0, len(resources)) compacted := make([]vipGrantResource, 0, len(resources))
for _, resource := range resources { for _, resource := range resources {
@ -720,65 +695,6 @@ func compactVIPGrantResources(resources []vipGrantResource) []vipGrantResource {
return compacted 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 { func vipGrantDaysUntil(now time.Time, expireAt time.Time) int {
if !expireAt.After(now) { if !expireAt.After(now) {
return 1 return 1

View File

@ -1,18 +1,13 @@
package vip package vip
import ( import (
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
"strings"
"testing" "testing"
"time" "time"
"chatapp3-golang/internal/model" "chatapp3-golang/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
) )
func TestBuildPurchasePlanNewPurchase(t *testing.T) { func TestBuildPurchasePlanNewPurchase(t *testing.T) {
@ -134,33 +129,6 @@ 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) { func TestResourcePayloadUsesResourceGroupDetails(t *testing.T) {
row := model.VipLevelConfig{ row := model.VipLevelConfig{
Level: 2, Level: 2,
@ -326,47 +294,6 @@ func TestVIPGrantResourcesFromConfigMapsBackpackTypes(t *testing.T) {
assertGrant(6, 16, vipPropsTypeDataCard, false) 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) { func TestVIPGrantDaysUntilCeilsPartialDay(t *testing.T) {
now := time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC) now := time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC)
expireAt := now.Add((24 * time.Hour) + time.Second) expireAt := now.Add((24 * time.Hour) + time.Second)
@ -379,89 +306,6 @@ 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 { func vipTestConfigs() map[int]model.VipLevelConfig {
configs := make(map[int]model.VipLevelConfig, levelCount) configs := make(map[int]model.VipLevelConfig, levelCount)
for level := 1; level <= levelCount; level++ { for level := 1; level <= levelCount; level++ {

View File

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

View File

@ -182,12 +182,6 @@ func (s *Service) completeClaimWallet(ctx context.Context, claim model.VoiceRoom
} }
now := time.Now() now := time.Now()
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { 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{}). if err := tx.Model(&model.VoiceRoomRedPacketClaim{}).
Where("id = ?", claim.ID). Where("id = ?", claim.ID).
Updates(map[string]any{ Updates(map[string]any{
@ -206,7 +200,7 @@ func (s *Service) completeClaimWallet(ctx context.Context, claim model.VoiceRoom
return err return err
} }
return tx.Model(&model.VoiceRoomRedPacket{}). return tx.Model(&model.VoiceRoomRedPacket{}).
Where("id = ? AND remain_count = 0 AND status = ?", lockedPacket.ID, packetStatusActive). Where("id = ? AND remain_count = 0 AND status = ?", claim.PacketID, packetStatusActive).
Updates(map[string]any{ Updates(map[string]any{
"status": packetStatusFinished, "status": packetStatusFinished,
"update_time": now, "update_time": now,

View File

@ -399,9 +399,6 @@ func TestInRoomRewardWorkerSnapshotsSelectsAndGrants(t *testing.T) {
if err := rdb.SAdd(ctx, onlineRoomKey("LIKEI", roomID), userID).Err(); err != nil { if err := rdb.SAdd(ctx, onlineRoomKey("LIKEI", roomID), userID).Err(); err != nil {
t.Fatalf("sadd online: %v", err) 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 { 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) t.Fatalf("set seen: %v", err)
} }
@ -474,92 +471,6 @@ 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) { func TestProcessPendingRewardsGrantsBadgeThroughBadgeGateway(t *testing.T) {
gateway := newFakeRocketGateway() gateway := newFakeRocketGateway()
service, db := newTestService(t) service, db := newTestService(t)

View File

@ -73,16 +73,16 @@ func pointerTimeValue(value *time.Time) time.Time {
return *value return *value
} }
func decimalPercent(current, need int64) (string, float64) { func decimalPercent(current, need int64) (string, int) {
if need <= 0 || current <= 0 { if need <= 0 || current <= 0 {
return "0.0000", 0 return "0.0000", 0
} }
if current >= need { percent := float64(current) * 100 / float64(need)
return "1.0000", 1 display := int(math.Floor(percent))
if display > 100 {
display = 100
} }
// App 端按 0-1 比例换算百分比;截断而非四舍五入,避免未充满时提前显示 100%。 return fmt.Sprintf("%.4f", percent), display
ratio := math.Floor(float64(current)/float64(need)*10000) / 10000
return fmt.Sprintf("%.4f", ratio), ratio
} }
func parsePercent(value string) float64 { func parsePercent(value string) float64 {

View File

@ -33,14 +33,9 @@ func (s *Service) listOnlineRoomUsers(ctx context.Context, sysOrigin string, roo
continue continue
} }
seen[userID] = struct{}{} 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{ users = append(users, roomAudienceUser{
UserID: userID, UserID: userID,
LastSeenTime: seenAt, LastSeenTime: s.loadUserSeenTime(ctx, sysOrigin, userID, now),
}) })
} }
sort.Slice(users, func(i, j int) bool { sort.Slice(users, func(i, j int) bool {
@ -49,43 +44,6 @@ func (s *Service) listOnlineRoomUsers(ctx context.Context, sysOrigin string, roo
return users, nil 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 { func (s *Service) loadUserSeenTime(ctx context.Context, sysOrigin string, userID int64, fallback time.Time) time.Time {
if s.repo.Redis == nil { if s.repo.Redis == nil {
return fallback return fallback
@ -101,22 +59,10 @@ func (s *Service) loadUserSeenTime(ctx context.Context, sysOrigin string, userID
return resolveMillisTime(millis, fallback) 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 { func onlineRoomKey(sysOrigin string, roomID int64) string {
return fmt.Sprintf("voice_room:online:%s:%d", strings.ToUpper(strings.TrimSpace(sysOrigin)), roomID) 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 { func userSeenKey(sysOrigin string, userID int64) string {
return fmt.Sprintf("voice_room:user_seen:%s:%d", strings.ToUpper(strings.TrimSpace(sysOrigin)), userID) return fmt.Sprintf("voice_room:user_seen:%s:%d", strings.ToUpper(strings.TrimSpace(sysOrigin)), userID)
} }

View File

@ -32,7 +32,6 @@ const (
defaultWorkerBatchSize = 100 defaultWorkerBatchSize = 100
defaultWorkerInterval = time.Second defaultWorkerInterval = time.Second
defaultRoomLockTTL = 10 * time.Second defaultRoomLockTTL = 10 * time.Second
defaultPresenceTTLSeconds = 60
statusCharging = "CHARGING" statusCharging = "CHARGING"
statusLaunching = "LAUNCHING" statusLaunching = "LAUNCHING"
@ -422,7 +421,7 @@ type StatusResponse struct {
CurrentEnergy int64 `json:"currentEnergy"` CurrentEnergy int64 `json:"currentEnergy"`
NeedEnergy int64 `json:"needEnergy"` NeedEnergy int64 `json:"needEnergy"`
EnergyPercent string `json:"energyPercent"` EnergyPercent string `json:"energyPercent"`
DisplayPercent float64 `json:"displayPercent"` DisplayPercent int `json:"displayPercent"`
LaunchCountdownSeconds int `json:"launchCountdownSeconds,omitempty"` LaunchCountdownSeconds int `json:"launchCountdownSeconds,omitempty"`
Shake bool `json:"shake"` Shake bool `json:"shake"`
PreviewRocketURL string `json:"previewRocketUrl,omitempty"` PreviewRocketURL string `json:"previewRocketUrl,omitempty"`

View File

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

View File

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

View File

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

View File

@ -1,67 +0,0 @@
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

@ -1,71 +0,0 @@
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

@ -1,29 +0,0 @@
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

@ -1,17 +0,0 @@
-- 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

@ -1,106 +0,0 @@
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

@ -1,17 +0,0 @@
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

@ -1,61 +0,0 @@
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

@ -1,16 +0,0 @@
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

@ -1,29 +0,0 @@
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

@ -1,20 +0,0 @@
-- 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,7 +429,6 @@ func seedPresence(ctx context.Context, rdb *redis.Client, roomID int64, users []
key := fmt.Sprintf("voice_room:online:LIKEI:%d", roomID) key := fmt.Sprintf("voice_room:online:LIKEI:%d", roomID)
for _, userID := range users { for _, userID := range users {
must(rdb.SAdd(ctx, key, userID).Err(), "seed online room") 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.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") must(rdb.Expire(ctx, key, time.Minute).Err(), "expire online room")

View File

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