Compare commits
37 Commits
release/re
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1114fbbf5 | ||
|
|
523cfe961f | ||
|
|
90ab87393e | ||
|
|
714c1c5c68 | ||
|
|
4bb8613570 | ||
|
|
53c6f361f9 | ||
|
|
e370c3ca32 | ||
|
|
995268fd70 | ||
|
|
e086a518bc | ||
|
|
4219117721 | ||
|
|
a71dd0fbae | ||
|
|
81b1ff88bf | ||
|
|
4e3c9d01aa | ||
|
|
cbe388b071 | ||
|
|
8147b5981f | ||
|
|
81cdba4096 | ||
|
|
ecb2fcba4e | ||
|
|
a9ee549e37 | ||
|
|
c7c40fce77 | ||
|
|
b53313f62d | ||
|
|
e738fbc44a | ||
|
|
c09433bf29 | ||
|
|
675f45bf44 | ||
|
|
f1b781e3be | ||
|
|
ee5b4f5eaf | ||
|
|
72c0d1d633 | ||
|
|
5b92fef113 | ||
|
|
9aecdf34a3 | ||
|
|
4cf4eba5ff | ||
|
|
adcc722284 | ||
|
|
3ec7cc6a62 | ||
|
|
3639cad484 | ||
|
|
0a11372d96 | ||
|
|
e4911e7732 | ||
|
|
747d4700df | ||
|
|
8e8878a038 | ||
|
|
a02e22ac74 |
@ -15,10 +15,13 @@ import (
|
||||
"chatapp3-golang/internal/service/apppopup"
|
||||
"chatapp3-golang/internal/service/baishun"
|
||||
"chatapp3-golang/internal/service/binancerecharge"
|
||||
"chatapp3-golang/internal/service/cprelationbroadcast"
|
||||
"chatapp3-golang/internal/service/errorlog"
|
||||
"chatapp3-golang/internal/service/firstrechargereward"
|
||||
"chatapp3-golang/internal/service/gameopen"
|
||||
"chatapp3-golang/internal/service/gameprovider"
|
||||
"chatapp3-golang/internal/service/hostcenter"
|
||||
"chatapp3-golang/internal/service/hotgame"
|
||||
"chatapp3-golang/internal/service/invite"
|
||||
"chatapp3-golang/internal/service/lingxian"
|
||||
"chatapp3-golang/internal/service/luckygift"
|
||||
@ -55,11 +58,13 @@ func main() {
|
||||
baishunService := baishun.NewBaishunService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||
binanceRechargeService := binancerecharge.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||
lingxianService := lingxian.NewService(app.Config, app.Repository.DB, app.Repository.Redis)
|
||||
hotgameService := hotgame.NewService(app.Config, app.Repository.DB, app.Repository.Redis)
|
||||
gameOpenService := gameopen.NewGameOpenService(app.Config, app.Repository.DB, &app.Gateways)
|
||||
luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet)
|
||||
rechargeAgencyService := rechargeagency.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||
registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||
rechargeRewardService := rechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||
firstRechargeRewardService := firstrechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||
signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||
userBadgeService := userbadge.NewService(app.Repository.DB)
|
||||
@ -67,6 +72,9 @@ func main() {
|
||||
wheelService := wheel.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||
smashEggService := smashegg.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||
regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||
baishunService.SetHighWinBroadcaster(regionIMGroupService)
|
||||
luckyGiftService.SetHighWinBroadcaster(regionIMGroupService)
|
||||
cpRelationBroadcastService := cprelationbroadcast.NewService(app.Config, app.Repository.DB, regionIMGroupService)
|
||||
voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room)
|
||||
voiceRoomRedPacketService.SetRegionResolver(&app.Gateways)
|
||||
voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||
@ -92,6 +100,12 @@ func main() {
|
||||
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
|
||||
log.Fatalf("start task center message consumer failed: %v", err)
|
||||
}
|
||||
if err := firstRechargeRewardService.StartMessageConsumer(workerCtx); err != nil {
|
||||
log.Fatalf("start first recharge reward message consumer failed: %v", err)
|
||||
}
|
||||
if err := cpRelationBroadcastService.StartMessageConsumer(workerCtx); err != nil {
|
||||
log.Fatalf("start cp relation broadcast message consumer failed: %v", err)
|
||||
}
|
||||
if err := voiceRoomRocketService.Start(workerCtx); err != nil {
|
||||
log.Fatalf("start voice room rocket workers failed: %v", err)
|
||||
}
|
||||
@ -103,34 +117,42 @@ func main() {
|
||||
gameProviders := gameprovider.NewRegistry(
|
||||
baishun.NewAppProvider(baishunService),
|
||||
lingxian.NewAppProvider(lingxianService),
|
||||
hotgame.NewAppProvider(hotgameService),
|
||||
)
|
||||
gameVisibility := gameprovider.NewVisibilityPolicy(
|
||||
&app.Gateways,
|
||||
gameprovider.NewCachedIPCountryResolver(app.Repository.Redis, app.Config.HTTP.Timeout),
|
||||
)
|
||||
engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{
|
||||
AppPopup: appPopupService,
|
||||
ErrorLog: errorLogService,
|
||||
Invite: inviteService,
|
||||
Baishun: baishunService,
|
||||
BinanceRecharge: binanceRechargeService,
|
||||
Lingxian: lingxianService,
|
||||
GameOpen: gameOpenService,
|
||||
GameProviders: gameProviders,
|
||||
LuckyGift: luckyGiftService,
|
||||
ManagerCenter: managerCenterService,
|
||||
PropsStore: propsStoreService,
|
||||
HostCenter: hostCenterService,
|
||||
RechargeAgency: rechargeAgencyService,
|
||||
RechargeReward: rechargeRewardService,
|
||||
RegionIMGroup: regionIMGroupService,
|
||||
RegisterReward: registerRewardService,
|
||||
RoomTurnoverReward: roomTurnoverRewardService,
|
||||
SignInReward: signInRewardService,
|
||||
SmashEgg: smashEggService,
|
||||
TaskCenter: taskCenterService,
|
||||
UserBadge: userBadgeService,
|
||||
VIP: vipService,
|
||||
Wheel: wheelService,
|
||||
VoiceRoomRedPacket: voiceRoomRedPacketService,
|
||||
VoiceRoomRocket: voiceRoomRocketService,
|
||||
WeekStar: weekStarService,
|
||||
AppPopup: appPopupService,
|
||||
ErrorLog: errorLogService,
|
||||
Invite: inviteService,
|
||||
Baishun: baishunService,
|
||||
BinanceRecharge: binanceRechargeService,
|
||||
FirstRechargeReward: firstRechargeRewardService,
|
||||
Lingxian: lingxianService,
|
||||
GameOpen: gameOpenService,
|
||||
GameProviders: gameProviders,
|
||||
GameVisibility: gameVisibility,
|
||||
LuckyGift: luckyGiftService,
|
||||
ManagerCenter: managerCenterService,
|
||||
PropsStore: propsStoreService,
|
||||
HostCenter: hostCenterService,
|
||||
Hotgame: hotgameService,
|
||||
RechargeAgency: rechargeAgencyService,
|
||||
RechargeReward: rechargeRewardService,
|
||||
RegionIMGroup: regionIMGroupService,
|
||||
RegisterReward: registerRewardService,
|
||||
RoomTurnoverReward: roomTurnoverRewardService,
|
||||
SignInReward: signInRewardService,
|
||||
SmashEgg: smashEggService,
|
||||
TaskCenter: taskCenterService,
|
||||
UserBadge: userBadgeService,
|
||||
VIP: vipService,
|
||||
Wheel: wheelService,
|
||||
VoiceRoomRedPacket: voiceRoomRedPacketService,
|
||||
VoiceRoomRocket: voiceRoomRocketService,
|
||||
WeekStar: weekStarService,
|
||||
})
|
||||
server := &http.Server{
|
||||
Addr: app.Config.HTTP.ListenAddr,
|
||||
|
||||
@ -2,6 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"chatapp3-golang/internal/bootstrap"
|
||||
"chatapp3-golang/internal/service/cprelationbroadcast"
|
||||
"chatapp3-golang/internal/service/firstrechargereward"
|
||||
"chatapp3-golang/internal/service/regionimgroup"
|
||||
"chatapp3-golang/internal/service/registerreward"
|
||||
"chatapp3-golang/internal/service/taskcenter"
|
||||
@ -27,7 +29,9 @@ func main() {
|
||||
registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||
firstRechargeRewardService := firstrechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||
regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||
cpRelationBroadcastService := cprelationbroadcast.NewService(app.Config, app.Repository.DB, regionIMGroupService)
|
||||
voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room)
|
||||
voiceRoomRedPacketService.SetRegionResolver(&app.Gateways)
|
||||
voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||
@ -45,6 +49,12 @@ func main() {
|
||||
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
|
||||
log.Fatalf("start task center message consumer failed: %v", err)
|
||||
}
|
||||
if err := firstRechargeRewardService.StartMessageConsumer(workerCtx); err != nil {
|
||||
log.Fatalf("start first recharge reward message consumer failed: %v", err)
|
||||
}
|
||||
if err := cpRelationBroadcastService.StartMessageConsumer(workerCtx); err != nil {
|
||||
log.Fatalf("start cp relation broadcast message consumer failed: %v", err)
|
||||
}
|
||||
if err := voiceRoomRocketService.Start(workerCtx); err != nil {
|
||||
log.Fatalf("start voice room rocket workers failed: %v", err)
|
||||
}
|
||||
|
||||
@ -6,4 +6,6 @@ type AuthUser struct {
|
||||
SysOrigin string
|
||||
Token string
|
||||
Authorization string
|
||||
RegionID string
|
||||
RegionCode string
|
||||
}
|
||||
|
||||
@ -8,25 +8,34 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
cpRelationBroadcastMQConsumerGroup = "cp-relation-broadcast"
|
||||
cpRelationBroadcastMQTopic = "RC_DEFAULT_APP_ORDINARY"
|
||||
cpRelationBroadcastMQTag = "cp_relation_broadcast"
|
||||
)
|
||||
|
||||
// Config 是按运行层和业务模块分段后的应用配置。
|
||||
type Config struct {
|
||||
HTTP HTTPConfig
|
||||
Store StoreConfig
|
||||
Java JavaConfig
|
||||
Worker WorkerConfig
|
||||
Invite InviteConfig
|
||||
RechargeReward RechargeRewardConfig
|
||||
TaskCenter TaskCenterConfig
|
||||
WeekStar WeekStarConfig
|
||||
RoomTurnoverReward RoomTurnoverRewardConfig
|
||||
VoiceRoomRedPacket VoiceRoomRedPacketConfig
|
||||
VoiceRoomRocket VoiceRoomRocketConfig
|
||||
TencentIM TencentIMConfig
|
||||
Baishun BaishunConfig
|
||||
Lingxian LingxianConfig
|
||||
GameOpen GameOpenConfig
|
||||
LuckyGift LuckyGiftConfig
|
||||
BinanceRecharge BinanceRechargeConfig
|
||||
HTTP HTTPConfig
|
||||
Store StoreConfig
|
||||
Java JavaConfig
|
||||
Worker WorkerConfig
|
||||
Invite InviteConfig
|
||||
RechargeReward RechargeRewardConfig
|
||||
FirstRechargeReward FirstRechargeRewardConfig
|
||||
TaskCenter TaskCenterConfig
|
||||
WeekStar WeekStarConfig
|
||||
RoomTurnoverReward RoomTurnoverRewardConfig
|
||||
VoiceRoomRedPacket VoiceRoomRedPacketConfig
|
||||
VoiceRoomRocket VoiceRoomRocketConfig
|
||||
CPRelationBroadcast CPRelationBroadcastConfig
|
||||
TencentIM TencentIMConfig
|
||||
Baishun BaishunConfig
|
||||
Lingxian LingxianConfig
|
||||
Hotgame HotgameConfig
|
||||
GameOpen GameOpenConfig
|
||||
LuckyGift LuckyGiftConfig
|
||||
BinanceRecharge BinanceRechargeConfig
|
||||
}
|
||||
|
||||
// HTTPConfig 保存 HTTP 服务运行配置。
|
||||
@ -88,6 +97,25 @@ type RechargeRewardConfig struct {
|
||||
CoinSellerGoldPerUSD int64
|
||||
}
|
||||
|
||||
// FirstRechargeRewardConfig 保存首冲奖励模块配置。
|
||||
type FirstRechargeRewardConfig struct {
|
||||
DefaultSysOrigin string
|
||||
MQ FirstRechargeRewardMQConfig
|
||||
}
|
||||
|
||||
// FirstRechargeRewardMQConfig 保存首冲奖励订阅通用充值成功 MQ 的消费配置。
|
||||
type FirstRechargeRewardMQConfig struct {
|
||||
Enabled bool
|
||||
Endpoint string
|
||||
Namespace string
|
||||
AccessKey string
|
||||
AccessSecret string
|
||||
SecurityToken string
|
||||
ConsumerGroup string
|
||||
Topic string
|
||||
Tag string
|
||||
}
|
||||
|
||||
// TaskCenterConfig 保存任务中心模块配置。
|
||||
type TaskCenterConfig struct {
|
||||
DefaultSysOrigin string
|
||||
@ -192,6 +220,25 @@ type VoiceRoomRocketMQConfig struct {
|
||||
Tag string
|
||||
}
|
||||
|
||||
// CPRelationBroadcastConfig 保存 CP/兄弟/姐妹关系区域飘屏配置。
|
||||
type CPRelationBroadcastConfig struct {
|
||||
DefaultSysOrigin string
|
||||
MQ CPRelationBroadcastMQConfig
|
||||
}
|
||||
|
||||
// CPRelationBroadcastMQConfig 保存 CP 关系成功 MQ 消费配置。
|
||||
type CPRelationBroadcastMQConfig struct {
|
||||
Enabled bool
|
||||
Endpoint string
|
||||
Namespace string
|
||||
AccessKey string
|
||||
AccessSecret string
|
||||
SecurityToken string
|
||||
ConsumerGroup string
|
||||
Topic string
|
||||
Tag string
|
||||
}
|
||||
|
||||
// TencentIMConfig 保存腾讯 IM REST API 配置。
|
||||
type TencentIMConfig struct {
|
||||
AppID int64
|
||||
@ -222,6 +269,12 @@ type LingxianConfig struct {
|
||||
AppKey string
|
||||
}
|
||||
|
||||
// HotgameConfig 保存热游模块的环境兜底配置。
|
||||
type HotgameConfig struct {
|
||||
AppKey string
|
||||
CallbackBaseURL string
|
||||
}
|
||||
|
||||
// GameOpenConfig 保存统一三方游戏回调配置。
|
||||
type GameOpenConfig struct {
|
||||
PublicBaseURL string
|
||||
@ -265,9 +318,18 @@ func Load() Config {
|
||||
taskCenterMQEndpoint := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ENDPOINT", "TASK_CENTER_EVENT_MQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT"}, "")
|
||||
taskCenterMQAccessKey := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ACCESS_KEY", "TASK_CENTER_EVENT_MQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY"}, "")
|
||||
taskCenterMQAccessSecret := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ACCESS_SECRET", "TASK_CENTER_EVENT_MQ_SECRET_KEY", "LIKEI_ROCKETMQ_SECRET_KEY"}, "")
|
||||
firstRechargeRewardMQEndpoint := getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_ENDPOINT", "FIRST_RECHARGE_REWARD_MQ_ENDPOINT", "CHATAPP_RECHARGE_SUCCESS_MQ_ENDPOINT", "RECHARGE_SUCCESS_MQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT"}, "")
|
||||
firstRechargeRewardMQAccessKey := getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_ACCESS_KEY", "FIRST_RECHARGE_REWARD_MQ_ACCESS_KEY", "CHATAPP_RECHARGE_SUCCESS_MQ_ACCESS_KEY", "RECHARGE_SUCCESS_MQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY"}, "")
|
||||
firstRechargeRewardMQAccessSecret := getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_ACCESS_SECRET", "FIRST_RECHARGE_REWARD_MQ_SECRET_KEY", "CHATAPP_RECHARGE_SUCCESS_MQ_ACCESS_SECRET", "RECHARGE_SUCCESS_MQ_SECRET_KEY", "LIKEI_ROCKETMQ_SECRET_KEY"}, "")
|
||||
voiceRoomRocketMQEndpoint := getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT"}, "")
|
||||
voiceRoomRocketMQAccessKey := getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY"}, "")
|
||||
voiceRoomRocketMQAccessSecret := getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_ACCESS_SECRET", "LIKEI_ROCKETMQ_SECRET_KEY"}, "")
|
||||
cpRelationBroadcastMQEndpoint := getEnvAny([]string{"LIKEI_ROCKETMQ_ENDPOINT", "CHATAPP_TASK_CENTER_MQ_ENDPOINT", "TASK_CENTER_EVENT_MQ_ENDPOINT"}, "")
|
||||
cpRelationBroadcastMQAccessKey := getEnvAny([]string{"LIKEI_ROCKETMQ_ACCESS_KEY", "CHATAPP_TASK_CENTER_MQ_ACCESS_KEY", "TASK_CENTER_EVENT_MQ_ACCESS_KEY"}, "")
|
||||
cpRelationBroadcastMQAccessSecret := getEnvAny([]string{"LIKEI_ROCKETMQ_SECRET_KEY", "CHATAPP_TASK_CENTER_MQ_ACCESS_SECRET", "TASK_CENTER_EVENT_MQ_SECRET_KEY"}, "")
|
||||
cpRelationBroadcastMQEnabled := strings.TrimSpace(cpRelationBroadcastMQEndpoint) != "" &&
|
||||
strings.TrimSpace(cpRelationBroadcastMQAccessKey) != "" &&
|
||||
strings.TrimSpace(cpRelationBroadcastMQAccessSecret) != ""
|
||||
|
||||
return Config{
|
||||
HTTP: HTTPConfig{
|
||||
@ -329,6 +391,23 @@ func Load() Config {
|
||||
100000,
|
||||
)),
|
||||
},
|
||||
FirstRechargeReward: FirstRechargeRewardConfig{
|
||||
DefaultSysOrigin: strings.ToUpper(getEnvAny(
|
||||
[]string{"CHATAPP_FIRST_RECHARGE_REWARD_DEFAULT_SYS_ORIGIN", "FIRST_RECHARGE_REWARD_DEFAULT_SYS_ORIGIN", "CHATAPP_RECHARGE_REWARD_DEFAULT_SYS_ORIGIN", "RECHARGE_REWARD_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"},
|
||||
"LIKEI",
|
||||
)),
|
||||
MQ: FirstRechargeRewardMQConfig{
|
||||
Enabled: getEnvBoolAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_ENABLED", "FIRST_RECHARGE_REWARD_MQ_ENABLED", "CHATAPP_RECHARGE_SUCCESS_MQ_ENABLED", "RECHARGE_SUCCESS_MQ_ENABLED"}, false),
|
||||
Endpoint: firstRechargeRewardMQEndpoint,
|
||||
Namespace: getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_NAMESPACE", "FIRST_RECHARGE_REWARD_MQ_NAMESPACE", "CHATAPP_RECHARGE_SUCCESS_MQ_NAMESPACE", "RECHARGE_SUCCESS_MQ_NAMESPACE"}, ""),
|
||||
AccessKey: firstRechargeRewardMQAccessKey,
|
||||
AccessSecret: firstRechargeRewardMQAccessSecret,
|
||||
SecurityToken: getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_SECURITY_TOKEN", "FIRST_RECHARGE_REWARD_MQ_SECURITY_TOKEN", "CHATAPP_RECHARGE_SUCCESS_MQ_SECURITY_TOKEN", "RECHARGE_SUCCESS_MQ_SECURITY_TOKEN"}, ""),
|
||||
ConsumerGroup: getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_CONSUMER_GROUP", "FIRST_RECHARGE_REWARD_MQ_CONSUMER_GROUP"}, "first-recharge-reward"),
|
||||
Topic: getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_TOPIC", "FIRST_RECHARGE_REWARD_MQ_TOPIC", "CHATAPP_RECHARGE_SUCCESS_MQ_TOPIC", "RECHARGE_SUCCESS_MQ_TOPIC"}, "RC_DEFAULT_APP_ORDINARY"),
|
||||
Tag: getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_TAG", "FIRST_RECHARGE_REWARD_MQ_TAG", "CHATAPP_RECHARGE_SUCCESS_MQ_TAG", "RECHARGE_SUCCESS_MQ_TAG"}, "recharge_success_v1"),
|
||||
},
|
||||
},
|
||||
TaskCenter: TaskCenterConfig{
|
||||
DefaultSysOrigin: strings.ToUpper(getEnvAny(
|
||||
[]string{"CHATAPP_TASK_CENTER_DEFAULT_SYS_ORIGIN", "TASK_CENTER_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"},
|
||||
@ -360,7 +439,7 @@ func Load() Config {
|
||||
SecretID: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_SECRET_ID", "TASK_CENTER_ARCHIVE_COS_SECRET_ID", "LIKEI_TENCENT_COS_SECRET_ID"}, ""),
|
||||
SecretKey: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_SECRET_KEY", "TASK_CENTER_ARCHIVE_COS_SECRET_KEY", "LIKEI_TENCENT_COS_SECRET_KEY"}, ""),
|
||||
Prefix: getEnvAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_COS_PREFIX", "TASK_CENTER_ARCHIVE_COS_PREFIX"}, "task-center-events/v1"),
|
||||
BatchSize: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_BATCH_SIZE", "TASK_CENTER_ARCHIVE_BATCH_SIZE"}, 10000),
|
||||
BatchSize: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_BATCH_SIZE", "TASK_CENTER_ARCHIVE_BATCH_SIZE"}, 500),
|
||||
FlushSeconds: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_FLUSH_SECONDS", "TASK_CENTER_ARCHIVE_FLUSH_SECONDS"}, 60),
|
||||
Workers: getEnvIntAny([]string{"CHATAPP_TASK_CENTER_ARCHIVE_WORKERS", "TASK_CENTER_ARCHIVE_WORKERS"}, 2),
|
||||
},
|
||||
@ -486,6 +565,23 @@ func Load() Config {
|
||||
Tag: getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_TAG", "VOICE_ROOM_ROCKET_MQ_TAG"}, "give_gift_v3"),
|
||||
},
|
||||
},
|
||||
CPRelationBroadcast: CPRelationBroadcastConfig{
|
||||
DefaultSysOrigin: strings.ToUpper(getEnvAny(
|
||||
[]string{"CHATAPP_CP_RELATION_BROADCAST_DEFAULT_SYS_ORIGIN", "CP_RELATION_BROADCAST_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"},
|
||||
"LIKEI",
|
||||
)),
|
||||
MQ: CPRelationBroadcastMQConfig{
|
||||
Enabled: cpRelationBroadcastMQEnabled,
|
||||
Endpoint: cpRelationBroadcastMQEndpoint,
|
||||
Namespace: "",
|
||||
AccessKey: cpRelationBroadcastMQAccessKey,
|
||||
AccessSecret: cpRelationBroadcastMQAccessSecret,
|
||||
SecurityToken: "",
|
||||
ConsumerGroup: cpRelationBroadcastMQConsumerGroup,
|
||||
Topic: cpRelationBroadcastMQTopic,
|
||||
Tag: cpRelationBroadcastMQTag,
|
||||
},
|
||||
},
|
||||
TencentIM: TencentIMConfig{
|
||||
AppID: getEnvInt64Any(
|
||||
[]string{"CHATAPP_TENCENT_IM_APP_ID", "TENCENT_IM_APP_ID", "LIKEI_IM_APP_ID"},
|
||||
@ -528,6 +624,10 @@ func Load() Config {
|
||||
GameListURL: getEnvAny([]string{"CHATAPP_LINGXIAN_GAME_LIST_URL", "LINGXIAN_GAME_LIST_URL"}, "https://sg-test.leadercc.com/yumichat_games/test_game_list.json"),
|
||||
AppKey: getEnvAny([]string{"CHATAPP_LINGXIAN_APP_KEY", "LINGXIAN_APP_KEY", "GAME_HKYS_SIGN_KEY", "LIKEI_GAME_HKYS_SIGN_KEY"}, ""),
|
||||
},
|
||||
Hotgame: HotgameConfig{
|
||||
AppKey: getEnvAny([]string{"CHATAPP_HOTGAME_APP_KEY", "HOTGAME_APP_KEY", "CHATAPP_REYOU_APP_KEY", "REYOU_APP_KEY"}, ""),
|
||||
CallbackBaseURL: getEnvAny([]string{"CHATAPP_HOTGAME_CALLBACK_BASE_URL", "HOTGAME_CALLBACK_BASE_URL", "CHATAPP_REYOU_CALLBACK_BASE_URL", "REYOU_CALLBACK_BASE_URL"}, ""),
|
||||
},
|
||||
GameOpen: GameOpenConfig{
|
||||
PublicBaseURL: getEnvAny([]string{"CHATAPP_GAME_OPEN_PUBLIC_BASE_URL", "GAME_OPEN_PUBLIC_BASE_URL"}, ""),
|
||||
AppKey: getEnvAny([]string{"CHATAPP_GAME_OPEN_APP_KEY", "GAME_OPEN_APP_KEY"}, "game-open-test-key"),
|
||||
|
||||
@ -102,6 +102,29 @@ func TestLoadTaskCenterMQUsesSharedRocketMQCredentials(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCPRelationBroadcastMQUsesSharedRocketMQCredentials(t *testing.T) {
|
||||
t.Setenv("LIKEI_ROCKETMQ_ENDPOINT", "rmq.example.com:8081")
|
||||
t.Setenv("LIKEI_ROCKETMQ_ACCESS_KEY", "access-key")
|
||||
t.Setenv("LIKEI_ROCKETMQ_SECRET_KEY", "access-secret")
|
||||
t.Setenv("CHATAPP_CP_RELATION_BROADCAST_MQ_ENDPOINT", "should-not-use:8081")
|
||||
t.Setenv("CHATAPP_CP_RELATION_BROADCAST_MQ_TOPIC", "should-not-use")
|
||||
t.Setenv("CHATAPP_CP_RELATION_BROADCAST_MQ_TAG", "should-not-use")
|
||||
|
||||
cfg := Load()
|
||||
if !cfg.CPRelationBroadcast.MQ.Enabled {
|
||||
t.Fatalf("expected cp relation broadcast mq enabled")
|
||||
}
|
||||
if got := cfg.CPRelationBroadcast.MQ.Endpoint; got != "rmq.example.com:8081" {
|
||||
t.Fatalf("cp relation broadcast mq endpoint = %q", got)
|
||||
}
|
||||
if got := cfg.CPRelationBroadcast.MQ.Topic; got != "RC_DEFAULT_APP_ORDINARY" {
|
||||
t.Fatalf("cp relation broadcast mq topic = %q", got)
|
||||
}
|
||||
if got := cfg.CPRelationBroadcast.MQ.Tag; got != "cp_relation_broadcast" {
|
||||
t.Fatalf("cp relation broadcast mq tag = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTaskCenterArchiveUsesSharedCOSCredentials(t *testing.T) {
|
||||
t.Setenv("CHATAPP_TASK_CENTER_ARCHIVE_ENABLED", "true")
|
||||
t.Setenv("LIKEI_TENCENT_COS_BUCKET", "bucket-123")
|
||||
|
||||
@ -131,6 +131,11 @@ func (g *Gateways) GetRewardGroupDetail(ctx context.Context, groupID int64) (Rew
|
||||
return g.RewardQuery.GetRewardGroupDetail(ctx, groupID)
|
||||
}
|
||||
|
||||
// FindRewardGroupDetail 透传到奖励查询网关,按名称查找奖励组详情。
|
||||
func (g *Gateways) FindRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (RewardGroupDetail, error) {
|
||||
return g.RewardQuery.FindRewardGroupDetail(ctx, sysOrigin, name)
|
||||
}
|
||||
|
||||
// GetUserProfile 透传到资料网关。
|
||||
func (g *Gateways) GetUserProfile(ctx context.Context, userID int64) (UserProfile, error) {
|
||||
return g.Profile.GetUserProfile(ctx, userID)
|
||||
@ -141,6 +146,11 @@ func (g *Gateways) GetUserRegion(ctx context.Context, userID int64, authorizatio
|
||||
return g.Profile.GetUserRegion(ctx, userID, authorization)
|
||||
}
|
||||
|
||||
// GetUserLevel 透传到用户等级网关。
|
||||
func (g *Gateways) GetUserLevel(ctx context.Context, sysOrigin string, userID int64) (UserLevel, error) {
|
||||
return g.Profile.GetUserLevel(ctx, sysOrigin, userID)
|
||||
}
|
||||
|
||||
// ResolveRegionCodeByCountryCode 透传到 Java 区域配置网关。
|
||||
func (g *Gateways) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
|
||||
return g.Profile.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode)
|
||||
@ -365,6 +375,11 @@ func (g *RewardQueryGateway) GetRewardGroupDetail(ctx context.Context, groupID i
|
||||
return g.client.GetRewardGroupDetail(ctx, groupID)
|
||||
}
|
||||
|
||||
// FindRewardGroupDetail 按名称查询奖励组详情。
|
||||
func (g *RewardQueryGateway) FindRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (RewardGroupDetail, error) {
|
||||
return g.client.FindRewardGroupDetail(ctx, sysOrigin, name)
|
||||
}
|
||||
|
||||
// GetNobleVIPAbility 查询贵族 VIP 能力配置。
|
||||
func (g *RewardQueryGateway) GetNobleVIPAbility(ctx context.Context, sourceID int64) (PropsNobleVIPAbility, error) {
|
||||
return g.client.GetNobleVIPAbility(ctx, sourceID)
|
||||
@ -390,6 +405,11 @@ func (g *ProfileGateway) GetUserRegion(ctx context.Context, userID int64, author
|
||||
return g.client.GetUserRegion(ctx, userID, authorization)
|
||||
}
|
||||
|
||||
// GetUserLevel 查询用户财富/魅力等级。
|
||||
func (g *ProfileGateway) GetUserLevel(ctx context.Context, sysOrigin string, userID int64) (UserLevel, error) {
|
||||
return g.client.GetUserLevel(ctx, sysOrigin, userID)
|
||||
}
|
||||
|
||||
// ResolveRegionCodeByCountryCode 根据 Java 区域配置把国家码解析成区域编码。
|
||||
func (g *ProfileGateway) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
|
||||
return g.client.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode)
|
||||
|
||||
@ -59,6 +59,12 @@ type UserRegion struct {
|
||||
RegionCode string `json:"regionCode"`
|
||||
}
|
||||
|
||||
type UserLevel struct {
|
||||
UserID Int64Value `json:"userId"`
|
||||
WealthLevel int `json:"wealthLevel"`
|
||||
CharmLevel int `json:"charmLevel"`
|
||||
}
|
||||
|
||||
type RegionConfig struct {
|
||||
RegionCode string `json:"regionCode"`
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
@ -235,13 +241,15 @@ type OfficialNoticeCustomizeRequest struct {
|
||||
}
|
||||
|
||||
type RewardGroupItem struct {
|
||||
ID Int64Value `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
Quantity Int64Value `json:"quantity"`
|
||||
Cover string `json:"cover"`
|
||||
Remark string `json:"remark"`
|
||||
ID Int64Value `json:"id"`
|
||||
Type string `json:"type"`
|
||||
DetailType string `json:"detailType"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
Quantity Int64Value `json:"quantity"`
|
||||
Cover string `json:"cover"`
|
||||
SourceURL string `json:"sourceUrl"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type RewardGroupDetail struct {
|
||||
@ -251,6 +259,13 @@ type RewardGroupDetail struct {
|
||||
RewardConfigList []RewardGroupItem `json:"rewardConfigList"`
|
||||
}
|
||||
|
||||
type rewardGroupPage struct {
|
||||
Records []RewardGroupDetail `json:"records"`
|
||||
Total int64 `json:"total"`
|
||||
Current int `json:"current"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
type UserTotalRecharge struct {
|
||||
ID Int64Value `json:"id"`
|
||||
UserID Int64Value `json:"userId"`
|
||||
@ -334,6 +349,7 @@ type resultResponse[T any] struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Body T `json:"body"`
|
||||
Data T `json:"data"`
|
||||
Success *bool `json:"success"`
|
||||
}
|
||||
|
||||
@ -696,15 +712,154 @@ func (c *Client) SendOfficialNoticeCustomize(ctx context.Context, req OfficialNo
|
||||
}
|
||||
|
||||
func (c *Client) GetRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) {
|
||||
endpoint := c.cfg.Java.OtherBaseURL + "/props-activity/reward/group/client/getByGroupId?id=" + url.QueryEscape(strconv.FormatInt(groupID, 10))
|
||||
detail, err := c.getInnerRewardGroupDetail(ctx, groupID)
|
||||
if err == nil && rewardGroupDetailHasContent(detail) {
|
||||
return detail, nil
|
||||
}
|
||||
if fallback, fallbackErr := c.getConsoleRewardGroupDetail(ctx, groupID); fallbackErr == nil && rewardGroupDetailHasContent(fallback) {
|
||||
return fallback, nil
|
||||
} else if err == nil && fallbackErr != nil {
|
||||
return detail, nil
|
||||
}
|
||||
return detail, err
|
||||
}
|
||||
|
||||
func (c *Client) getInnerRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) {
|
||||
endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/props-activity/reward/group/client/getByGroupId?id=" + url.QueryEscape(strconv.FormatInt(groupID, 10))
|
||||
var resp resultResponse[RewardGroupDetail]
|
||||
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
|
||||
return RewardGroupDetail{}, err
|
||||
}
|
||||
if int64(resp.Body.ID) == 0 {
|
||||
detail := resp.Body
|
||||
if int64(detail.ID) == 0 {
|
||||
detail = resp.Data
|
||||
}
|
||||
if int64(detail.ID) == 0 {
|
||||
return RewardGroupDetail{}, fmt.Errorf("empty reward group response")
|
||||
}
|
||||
return resp.Body, nil
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func (c *Client) getConsoleRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) {
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.Java.ConsoleBaseURL), "/")
|
||||
if baseURL == "" {
|
||||
return RewardGroupDetail{}, fmt.Errorf("console base url is empty")
|
||||
}
|
||||
endpoint := baseURL + "/props/activity/reward/group/" + url.PathEscape(strconv.FormatInt(groupID, 10))
|
||||
var detail RewardGroupDetail
|
||||
if err := c.getJSON(ctx, endpoint, nil, &detail); err != nil {
|
||||
return RewardGroupDetail{}, err
|
||||
}
|
||||
if int64(detail.ID) == 0 {
|
||||
return RewardGroupDetail{}, fmt.Errorf("empty reward group response")
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func rewardGroupDetailHasContent(detail RewardGroupDetail) bool {
|
||||
return int64(detail.ID) > 0 && len(detail.RewardConfigList) > 0
|
||||
}
|
||||
|
||||
func (c *Client) FindRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (RewardGroupDetail, error) {
|
||||
groupName := strings.TrimSpace(name)
|
||||
if groupName == "" {
|
||||
return RewardGroupDetail{}, fmt.Errorf("reward group name is empty")
|
||||
}
|
||||
if detail, err := c.findInnerRewardGroupDetail(ctx, sysOrigin, groupName); err == nil {
|
||||
if hydrated, hydrateErr := c.hydrateRewardGroupDetail(ctx, detail); hydrateErr == nil && rewardGroupDetailHasContent(hydrated) {
|
||||
return hydrated, nil
|
||||
}
|
||||
if rewardGroupDetailHasContent(detail) {
|
||||
return detail, nil
|
||||
}
|
||||
}
|
||||
detail, err := c.findConsoleRewardGroupDetail(ctx, sysOrigin, groupName)
|
||||
if err != nil {
|
||||
return RewardGroupDetail{}, err
|
||||
}
|
||||
if hydrated, hydrateErr := c.hydrateRewardGroupDetail(ctx, detail); hydrateErr == nil && rewardGroupDetailHasContent(hydrated) {
|
||||
return hydrated, nil
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func (c *Client) hydrateRewardGroupDetail(ctx context.Context, detail RewardGroupDetail) (RewardGroupDetail, error) {
|
||||
if rewardGroupDetailHasContent(detail) {
|
||||
return detail, nil
|
||||
}
|
||||
groupID := int64(detail.ID)
|
||||
if groupID <= 0 {
|
||||
return detail, fmt.Errorf("empty reward group id")
|
||||
}
|
||||
if hydrated, err := c.getInnerRewardGroupDetail(ctx, groupID); err == nil && rewardGroupDetailHasContent(hydrated) {
|
||||
return hydrated, nil
|
||||
}
|
||||
return c.getConsoleRewardGroupDetail(ctx, groupID)
|
||||
}
|
||||
|
||||
func (c *Client) findInnerRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (RewardGroupDetail, error) {
|
||||
endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/props-activity/reward/group/client/page"
|
||||
payload := map[string]any{
|
||||
"current": 1,
|
||||
"cursor": 1,
|
||||
"limit": 10,
|
||||
"name": name,
|
||||
"shelfStatus": true,
|
||||
"size": 10,
|
||||
}
|
||||
if origin := strings.TrimSpace(sysOrigin); origin != "" {
|
||||
payload["sysOrigin"] = origin
|
||||
}
|
||||
var resp resultResponse[rewardGroupPage]
|
||||
if err := c.postJSON(ctx, endpoint, payload, nil, &resp); err != nil {
|
||||
return RewardGroupDetail{}, err
|
||||
}
|
||||
page := resp.Body
|
||||
if len(page.Records) == 0 {
|
||||
page = resp.Data
|
||||
}
|
||||
return pickRewardGroupDetailByName(page.Records, name)
|
||||
}
|
||||
|
||||
func (c *Client) findConsoleRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (RewardGroupDetail, error) {
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.Java.ConsoleBaseURL), "/")
|
||||
if baseURL == "" {
|
||||
return RewardGroupDetail{}, fmt.Errorf("console base url is empty")
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("current", "1")
|
||||
values.Set("cursor", "1")
|
||||
values.Set("limit", "10")
|
||||
values.Set("name", name)
|
||||
values.Set("shelfStatus", "true")
|
||||
values.Set("size", "10")
|
||||
if origin := strings.TrimSpace(sysOrigin); origin != "" {
|
||||
values.Set("sysOrigin", origin)
|
||||
}
|
||||
endpoint := baseURL + "/props/activity/reward/group/page?" + values.Encode()
|
||||
var page rewardGroupPage
|
||||
if err := c.getJSON(ctx, endpoint, nil, &page); err != nil {
|
||||
return RewardGroupDetail{}, err
|
||||
}
|
||||
return pickRewardGroupDetailByName(page.Records, name)
|
||||
}
|
||||
|
||||
func pickRewardGroupDetailByName(records []RewardGroupDetail, name string) (RewardGroupDetail, error) {
|
||||
if len(records) == 0 {
|
||||
return RewardGroupDetail{}, fmt.Errorf("empty reward group page response")
|
||||
}
|
||||
target := strings.TrimSpace(name)
|
||||
for _, record := range records {
|
||||
if strings.TrimSpace(record.Name) == target {
|
||||
return record, nil
|
||||
}
|
||||
}
|
||||
for _, record := range records {
|
||||
if rewardGroupDetailHasContent(record) {
|
||||
return record, nil
|
||||
}
|
||||
}
|
||||
return records[0], nil
|
||||
}
|
||||
|
||||
func (c *Client) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]UserTotalRecharge, error) {
|
||||
@ -895,6 +1050,21 @@ func (c *Client) GetUserRegion(ctx context.Context, userID int64, authorization
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetUserLevel(ctx context.Context, sysOrigin string, userID int64) (UserLevel, error) {
|
||||
if userID <= 0 {
|
||||
return UserLevel{}, fmt.Errorf("userId is required")
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("sysOrigin", strings.TrimSpace(sysOrigin))
|
||||
values.Set("userId", strconv.FormatInt(userID, 10))
|
||||
endpoint := c.cfg.Java.OtherBaseURL + "/user-level/client/getLevelByUserId?" + values.Encode()
|
||||
var resp resultResponse[UserLevel]
|
||||
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
|
||||
return UserLevel{}, err
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
func (c *Client) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
|
||||
countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
|
||||
if countryCode == "" {
|
||||
|
||||
@ -77,3 +77,105 @@ func TestResolveRegionCodeByCountryCodeUsesRegionConfig(t *testing.T) {
|
||||
t.Fatalf("region config calls = %d, want cached 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRewardGroupDetailAcceptsDataWrapper(t *testing.T) {
|
||||
groupID := int64(2056981527522242600)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/props-activity/reward/group/client/getByGroupId" {
|
||||
t.Fatalf("request = %s %s, want GET /props-activity/reward/group/client/getByGroupId", r.Method, r.URL.Path)
|
||||
}
|
||||
if r.URL.Query().Get("id") != "2056981527522242600" {
|
||||
t.Fatalf("id query = %q", r.URL.Query().Get("id"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":{"id":"2056981527522242600","name":"累计充值10美金","rewardConfigList":[{"id":"1","type":"PROPS","detailType":"AVATAR_FRAME","name":"Frame","quantity":"1","sourceUrl":"https://example.com/frame.svga"}]}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := New(config.Config{
|
||||
HTTP: config.HTTPConfig{Timeout: time.Second},
|
||||
Java: config.JavaConfig{OtherBaseURL: server.URL},
|
||||
})
|
||||
got, err := client.GetRewardGroupDetail(context.Background(), groupID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRewardGroupDetail() error = %v", err)
|
||||
}
|
||||
if int64(got.ID) != groupID || len(got.RewardConfigList) != 1 {
|
||||
t.Fatalf("detail = %+v, want group with one reward item", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRewardGroupDetailFallsBackToConsoleEndpoint(t *testing.T) {
|
||||
groupID := int64(2056981527522242600)
|
||||
var innerCalls, consoleCalls int
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/props-activity/reward/group/client/getByGroupId", func(w http.ResponseWriter, r *http.Request) {
|
||||
innerCalls++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"body":{"id":"2056981527522242600","name":"累计充值10美金","rewardConfigList":[]}}`))
|
||||
})
|
||||
mux.HandleFunc("/console/props/activity/reward/group/2056981527522242600", func(w http.ResponseWriter, r *http.Request) {
|
||||
consoleCalls++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"id":"2056981527522242600","name":"累计充值10美金","rewardConfigList":[{"id":"1","type":"GIFT","name":"Gift","quantity":"1","cover":"https://example.com/gift.png"}]}`))
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
client := New(config.Config{
|
||||
HTTP: config.HTTPConfig{Timeout: time.Second},
|
||||
Java: config.JavaConfig{
|
||||
OtherBaseURL: server.URL,
|
||||
ConsoleBaseURL: server.URL + "/console",
|
||||
},
|
||||
})
|
||||
got, err := client.GetRewardGroupDetail(context.Background(), groupID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRewardGroupDetail() error = %v", err)
|
||||
}
|
||||
if innerCalls != 1 || consoleCalls != 1 {
|
||||
t.Fatalf("calls inner=%d console=%d, want 1/1", innerCalls, consoleCalls)
|
||||
}
|
||||
if len(got.RewardConfigList) != 1 || got.RewardConfigList[0].Name != "Gift" {
|
||||
t.Fatalf("detail = %+v, want fallback reward item", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRewardGroupDetailHydratesPageRecord(t *testing.T) {
|
||||
groupID := int64(2056980937278816399)
|
||||
var pageCalls, detailCalls int
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/props-activity/reward/group/client/page", func(w http.ResponseWriter, r *http.Request) {
|
||||
pageCalls++
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s, want POST", r.Method)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"body":{"records":[{"id":"2056980937278816399","name":"累充活动$100","rewardConfigList":[]}]}}`))
|
||||
})
|
||||
mux.HandleFunc("/props-activity/reward/group/client/getByGroupId", func(w http.ResponseWriter, r *http.Request) {
|
||||
detailCalls++
|
||||
if r.URL.Query().Get("id") != "2056980937278816399" {
|
||||
t.Fatalf("id query = %q", r.URL.Query().Get("id"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"body":{"id":"2056980937278816399","name":"累充活动$100","rewardConfigList":[{"id":"1","type":"BADGE","name":"Badge","quantity":"30","cover":"https://example.com/badge.png"}]}}`))
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
client := New(config.Config{
|
||||
HTTP: config.HTTPConfig{Timeout: time.Second},
|
||||
Java: config.JavaConfig{OtherBaseURL: server.URL},
|
||||
})
|
||||
got, err := client.FindRewardGroupDetail(context.Background(), "LIKEI", "累充活动$100")
|
||||
if err != nil {
|
||||
t.Fatalf("FindRewardGroupDetail() error = %v", err)
|
||||
}
|
||||
if pageCalls != 1 || detailCalls != 1 {
|
||||
t.Fatalf("calls page=%d detail=%d, want 1/1", pageCalls, detailCalls)
|
||||
}
|
||||
if int64(got.ID) != groupID || len(got.RewardConfigList) != 1 {
|
||||
t.Fatalf("detail = %+v, want hydrated reward group", got)
|
||||
}
|
||||
}
|
||||
|
||||
68
internal/model/first_recharge_reward_models.go
Normal file
68
internal/model/first_recharge_reward_models.go
Normal file
@ -0,0 +1,68 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// FirstRechargeRewardConfig stores first recharge reward config per system.
|
||||
type FirstRechargeRewardConfig struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_first_recharge_reward_sys_origin"`
|
||||
Enabled bool `gorm:"column:enabled;index:idx_first_recharge_reward_enabled"`
|
||||
MinAppVersion string `gorm:"column:min_app_version;size:32"`
|
||||
CreateTime time.Time `gorm:"column:create_time"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
}
|
||||
|
||||
func (FirstRechargeRewardConfig) TableName() string { return "first_recharge_reward_config" }
|
||||
|
||||
// FirstRechargeRewardLevel stores a recharge threshold and reward resource group.
|
||||
type FirstRechargeRewardLevel struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
ConfigID int64 `gorm:"column:config_id;uniqueIndex:uk_first_recharge_reward_level,priority:1;uniqueIndex:uk_first_recharge_reward_amount,priority:1;index:idx_first_recharge_reward_level_config"`
|
||||
Level int `gorm:"column:level;uniqueIndex:uk_first_recharge_reward_level,priority:2"`
|
||||
RechargeAmountCents int64 `gorm:"column:recharge_amount_cents;uniqueIndex:uk_first_recharge_reward_amount,priority:2"`
|
||||
GoogleProductID string `gorm:"column:google_product_id;size:128;index:idx_first_recharge_reward_level_google_product"`
|
||||
RewardGroupID int64 `gorm:"column:reward_group_id"`
|
||||
RewardGroupName string `gorm:"column:reward_group_name;size:255"`
|
||||
Enabled bool `gorm:"column:enabled"`
|
||||
CreateTime time.Time `gorm:"column:create_time"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
}
|
||||
|
||||
func (FirstRechargeRewardLevel) TableName() string { return "first_recharge_reward_level" }
|
||||
|
||||
// FirstRechargeRewardGrantRecord stores the actual first recharge reward grant result.
|
||||
type FirstRechargeRewardGrantRecord struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_first_recharge_reward_event"`
|
||||
ConfigID int64 `gorm:"column:config_id"`
|
||||
LevelID int64 `gorm:"column:level_id"`
|
||||
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_first_recharge_reward_user,priority:1;index:idx_first_recharge_reward_status,priority:1"`
|
||||
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_first_recharge_reward_user,priority:2;index:idx_first_recharge_reward_status,priority:2"`
|
||||
Account string `gorm:"column:account;size:64"`
|
||||
UserAvatar string `gorm:"column:user_avatar;size:1024"`
|
||||
UserNickname string `gorm:"column:user_nickname;size:255"`
|
||||
CountryCode string `gorm:"column:country_code;size:32"`
|
||||
CountryName string `gorm:"column:country_name;size:128"`
|
||||
RechargeAmountCents int64 `gorm:"column:recharge_amount_cents"`
|
||||
RechargeThresholdCents int64 `gorm:"column:recharge_threshold_cents"`
|
||||
Level int `gorm:"column:level"`
|
||||
PayPlatform string `gorm:"column:pay_platform;size:64"`
|
||||
PaymentMethod string `gorm:"column:payment_method;size:32"`
|
||||
GoogleProductID string `gorm:"column:google_product_id;size:128;index:idx_first_recharge_reward_grant_google_product"`
|
||||
SourceOrderID string `gorm:"column:source_order_id;size:128;index:idx_first_recharge_reward_order"`
|
||||
RewardGroupID int64 `gorm:"column:reward_group_id"`
|
||||
RewardGroupName string `gorm:"column:reward_group_name;size:255"`
|
||||
RewardGroupTrackID int64 `gorm:"column:reward_group_track_id"`
|
||||
Status string `gorm:"column:status;size:32;index:idx_first_recharge_reward_status,priority:3"`
|
||||
RewardGroupStatus string `gorm:"column:reward_group_status;size:32"`
|
||||
RetryCount int `gorm:"column:retry_count"`
|
||||
LastError string `gorm:"column:last_error;size:1024"`
|
||||
EventTime time.Time `gorm:"column:event_time"`
|
||||
SentAt *time.Time `gorm:"column:sent_at"`
|
||||
CreateTime time.Time `gorm:"column:create_time"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
}
|
||||
|
||||
func (FirstRechargeRewardGrantRecord) TableName() string {
|
||||
return "first_recharge_reward_grant_record"
|
||||
}
|
||||
41
internal/model/hotgame_models.go
Normal file
41
internal/model/hotgame_models.go
Normal file
@ -0,0 +1,41 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// HotgameProviderConfig 保存每个系统、每个环境的热游回调验签配置。
|
||||
type HotgameProviderConfig struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"` // 主键
|
||||
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_hotgame_provider_sys_origin_profile,priority:1;index:idx_hotgame_provider_active,priority:1"` // 系统标识
|
||||
Profile string `gorm:"column:profile;size:32;default:PROD;uniqueIndex:uk_hotgame_provider_sys_origin_profile,priority:2"` // 配置环境:PROD/TEST
|
||||
Active bool `gorm:"column:active;index:idx_hotgame_provider_active,priority:2"` // app 当前启用环境
|
||||
AppKey string `gorm:"column:app_key;size:255"` // 热游回调 MD5 key
|
||||
CallbackBaseURL string `gorm:"column:callback_base_url;size:1024"` // 提供给热游配置的回调域名
|
||||
TestUID string `gorm:"column:test_uid;size:64"` // 联调用 UID 覆盖
|
||||
TestToken string `gorm:"column:test_token;size:1024"` // 联调用 token 覆盖
|
||||
CreateTime time.Time `gorm:"column:create_time"` // 创建时间
|
||||
UpdateTime time.Time `gorm:"column:update_time;index:idx_hotgame_provider_active,priority:3"` // 更新时间
|
||||
}
|
||||
|
||||
// TableName 返回热游接入配置表名。
|
||||
func (HotgameProviderConfig) TableName() string { return "hotgame_provider_config" }
|
||||
|
||||
// HotgameGameCatalog 保存从热游对接表导入的游戏目录。
|
||||
type HotgameGameCatalog struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"` // 主键
|
||||
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_hotgame_catalog_sys_profile_vendor_game,priority:1;index:idx_hotgame_catalog_profile_internal_game,priority:1"` // 系统标识
|
||||
Profile string `gorm:"column:profile;size:32;default:PROD;uniqueIndex:uk_hotgame_catalog_sys_profile_vendor_game,priority:2;index:idx_hotgame_catalog_profile_internal_game,priority:2"` // 配置环境:PROD/TEST
|
||||
InternalGameID string `gorm:"column:internal_game_id;size:64;index:idx_hotgame_catalog_profile_internal_game,priority:3"` // app 内部游戏 ID
|
||||
VendorGameID string `gorm:"column:vendor_game_id;size:64;uniqueIndex:uk_hotgame_catalog_sys_profile_vendor_game,priority:3"` // 热游游戏 ID
|
||||
Name string `gorm:"column:name;size:128"` // 游戏名称
|
||||
Cover string `gorm:"column:cover;size:1024"` // 封面地址
|
||||
PreviewURL string `gorm:"column:preview_url;size:1024"` // 七分屏/预览入口
|
||||
DownloadURL string `gorm:"column:download_url;size:1024"` // 实际启动入口
|
||||
PackageVersion string `gorm:"column:package_version;size:32"` // 版本号
|
||||
RawJSON string `gorm:"column:raw_json;type:longtext"` // 原始目录 JSON
|
||||
Status string `gorm:"column:status;size:32;index:idx_hotgame_catalog_profile_internal_game,priority:4"` // 目录状态
|
||||
CreateTime time.Time `gorm:"column:create_time"` // 创建时间
|
||||
UpdateTime time.Time `gorm:"column:update_time"` // 更新时间
|
||||
}
|
||||
|
||||
// TableName 返回热游目录表名。
|
||||
func (HotgameGameCatalog) TableName() string { return "hotgame_game_catalog" }
|
||||
@ -116,19 +116,19 @@ func (TaskCenterEventDedup) TableName() string { return "task_center_event_dedup
|
||||
|
||||
// TaskCenterEventArchiveOutbox 保存待归档到 COS 的原始事件。
|
||||
type TaskCenterEventArchiveOutbox struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
ID int64 `gorm:"column:id;primaryKey;index:idx_task_center_archive_claim_create,priority:3;index:idx_task_center_archive_claim_stale,priority:3"`
|
||||
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_task_center_archive_event,priority:1"`
|
||||
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_task_center_archive_event,priority:2"`
|
||||
EventType string `gorm:"column:event_type;size:64"`
|
||||
UserID int64 `gorm:"column:user_id"`
|
||||
OccurredAt time.Time `gorm:"column:occurred_at"`
|
||||
RawJSON string `gorm:"column:raw_json;type:text"`
|
||||
Status string `gorm:"column:status;size:32;index:idx_task_center_archive_status_time,priority:1"`
|
||||
Status string `gorm:"column:status;size:32;index:idx_task_center_archive_status_time,priority:1;index:idx_task_center_archive_claim_create,priority:1;index:idx_task_center_archive_claim_stale,priority:1"`
|
||||
RetryCount int `gorm:"column:retry_count"`
|
||||
COSKey string `gorm:"column:cos_key;size:512"`
|
||||
FailureReason string `gorm:"column:failure_reason;size:512"`
|
||||
CreateTime time.Time `gorm:"column:create_time;index:idx_task_center_archive_status_time,priority:2"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
CreateTime time.Time `gorm:"column:create_time;index:idx_task_center_archive_status_time,priority:2;index:idx_task_center_archive_claim_create,priority:2"`
|
||||
UpdateTime time.Time `gorm:"column:update_time;index:idx_task_center_archive_claim_stale,priority:2"`
|
||||
ArchivedAt *time.Time `gorm:"column:archived_at"`
|
||||
}
|
||||
|
||||
|
||||
@ -76,12 +76,12 @@ func (VoiceRoomRedPacket) TableName() string { return "voice_room_red_packet" }
|
||||
|
||||
// VoiceRoomRedPacketSlice 保存红包预拆分份额。
|
||||
type VoiceRoomRedPacketSlice struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
PacketID int64 `gorm:"column:packet_id;uniqueIndex:uk_voice_room_red_packet_slice,priority:1;index:idx_voice_room_red_packet_slice_claim,priority:1"`
|
||||
ID int64 `gorm:"column:id;primaryKey;index:idx_voice_room_red_packet_slice_claim_order,priority:4"`
|
||||
PacketID int64 `gorm:"column:packet_id;uniqueIndex:uk_voice_room_red_packet_slice,priority:1;index:idx_voice_room_red_packet_slice_claim,priority:1;index:idx_voice_room_red_packet_slice_claim_order,priority:1"`
|
||||
PacketNo string `gorm:"column:packet_no;size:64"`
|
||||
SortNo int `gorm:"column:sort_no;uniqueIndex:uk_voice_room_red_packet_slice,priority:2"`
|
||||
SortNo int `gorm:"column:sort_no;uniqueIndex:uk_voice_room_red_packet_slice,priority:2;index:idx_voice_room_red_packet_slice_claim_order,priority:3"`
|
||||
Amount int64 `gorm:"column:amount"`
|
||||
Status string `gorm:"column:status;size:32;index:idx_voice_room_red_packet_slice_claim,priority:2"`
|
||||
Status string `gorm:"column:status;size:32;index:idx_voice_room_red_packet_slice_claim,priority:2;index:idx_voice_room_red_packet_slice_claim_order,priority:2"`
|
||||
ClaimUserID *int64 `gorm:"column:claim_user_id"`
|
||||
ClaimRecordID *int64 `gorm:"column:claim_record_id"`
|
||||
CreateTime time.Time `gorm:"column:create_time"`
|
||||
|
||||
@ -248,7 +248,7 @@ func registerBaishunRoutes(
|
||||
})
|
||||
callbackGroup.POST("/report", func(c *gin.Context) {
|
||||
raw, payload := readRawPayload(c)
|
||||
writeBaishunJSON(c, http.StatusOK, baishunService.HandleReport(c.Request.Context(), payload, raw))
|
||||
writeBaishunJSON(c, http.StatusOK, baishunService.HandleReport(c.Request.Context(), mapToReportRequest(payload), raw))
|
||||
})
|
||||
callbackGroup.POST("/balance-info", func(c *gin.Context) {
|
||||
raw, payload := readRawPayload(c)
|
||||
@ -338,6 +338,16 @@ func mapToChangeBalanceRequest(payload map[string]any) baishun.BaishunChangeBala
|
||||
}
|
||||
}
|
||||
|
||||
// mapToReportRequest 把百顺 report 回调报文映射成服务层 DTO。
|
||||
func mapToReportRequest(payload map[string]any) baishun.BaishunReportRequest {
|
||||
return baishun.BaishunReportRequest{
|
||||
ReportType: asString(payload["report_type"]),
|
||||
ReportMsg: asMap(payload["report_msg"]),
|
||||
UserID: asString(payload["user_id"]),
|
||||
SSToken: asString(payload["ss_token"]),
|
||||
}
|
||||
}
|
||||
|
||||
// mapToBalanceInfoRequest 把百顺 balance-info 回调报文映射成服务层 DTO。
|
||||
func mapToBalanceInfoRequest(payload map[string]any) baishun.BaishunBalanceInfoRequest {
|
||||
return baishun.BaishunBalanceInfoRequest{
|
||||
@ -350,6 +360,26 @@ func mapToBalanceInfoRequest(payload map[string]any) baishun.BaishunBalanceInfoR
|
||||
}
|
||||
}
|
||||
|
||||
// asMap 宽松读取对象字段。
|
||||
func asMap(value any) map[string]any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
return typed
|
||||
case string:
|
||||
result := map[string]any{}
|
||||
_ = json.Unmarshal([]byte(typed), &result)
|
||||
return result
|
||||
default:
|
||||
body, err := json.Marshal(typed)
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
result := map[string]any{}
|
||||
_ = json.Unmarshal(body, &result)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// asString 宽松读取字符串字段。
|
||||
func asString(value any) string {
|
||||
if value == nil {
|
||||
|
||||
101
internal/router/first_recharge_reward_routes.go
Normal file
101
internal/router/first_recharge_reward_routes.go
Normal file
@ -0,0 +1,101 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"chatapp3-golang/internal/service/firstrechargereward"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// registerFirstRechargeRewardRoutes registers app and admin first recharge reward APIs.
|
||||
func registerFirstRechargeRewardRoutes(engine *gin.Engine, javaClient authGateway, service *firstrechargereward.Service) {
|
||||
if service == nil {
|
||||
return
|
||||
}
|
||||
|
||||
registerFirstRechargeRewardAppGroup(engine.Group("/app/first-recharge-reward"), javaClient, service)
|
||||
registerFirstRechargeRewardAppGroup(engine.Group("/app/h5/first-recharge-reward"), javaClient, service)
|
||||
|
||||
group := engine.Group("/resident-activity/first-recharge-reward")
|
||||
group.Use(consoleAuthMiddleware(javaClient))
|
||||
group.GET("/config", func(c *gin.Context) {
|
||||
resp, err := service.GetConfig(c.Request.Context(), c.Query("sysOrigin"))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
group.POST("/config/save", func(c *gin.Context) {
|
||||
var req firstrechargereward.SaveConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeError(c, firstrechargereward.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||
return
|
||||
}
|
||||
resp, err := service.SaveConfig(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
group.GET("/grant-record/page", func(c *gin.Context) {
|
||||
resp, err := service.PageGrantRecords(
|
||||
c.Request.Context(),
|
||||
c.Query("sysOrigin"),
|
||||
c.Query("status"),
|
||||
firstrechargereward.ParseInt64(c.Query("userId")),
|
||||
int(firstrechargereward.ParseInt64(c.Query("cursor"))),
|
||||
int(firstrechargereward.ParseInt64(c.Query("limit"))),
|
||||
)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
}
|
||||
|
||||
func registerFirstRechargeRewardAppGroup(group *gin.RouterGroup, javaClient authGateway, service *firstrechargereward.Service) {
|
||||
group.Use(authMiddleware(javaClient))
|
||||
group.GET("/home", func(c *gin.Context) {
|
||||
resp, err := service.GetHome(c.Request.Context(), mustAuthUser(c), firstRechargeRewardRequestVersion(c))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
}
|
||||
|
||||
func firstRechargeRewardRequestVersion(c *gin.Context) string {
|
||||
return firstNonEmptyString(
|
||||
c.Query("appVersion"),
|
||||
c.Query("version"),
|
||||
c.GetHeader("req-version"),
|
||||
c.GetHeader("Req-Version"),
|
||||
versionFromReqAppIntel(c.GetHeader("req-app-intel")),
|
||||
versionFromReqAppIntel(c.GetHeader("Req-App-Intel")),
|
||||
)
|
||||
}
|
||||
|
||||
func versionFromReqAppIntel(value string) string {
|
||||
for _, item := range strings.Split(value, ";") {
|
||||
parts := strings.SplitN(item, "=", 2)
|
||||
if len(parts) == 2 && strings.EqualFold(strings.TrimSpace(parts[0]), "version") {
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if text := strings.TrimSpace(value); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@ -46,6 +46,8 @@ func registerGameOpenRoutes(engine *gin.Engine, cfg config.Config, service *game
|
||||
c.JSON(http.StatusOK, service.HandleSupplement(c.Request.Context(), req, string(raw)))
|
||||
})
|
||||
|
||||
registerHotgameCallbackRoutes(engine, service)
|
||||
|
||||
internalGroup := engine.Group("/internal/game/open")
|
||||
internalGroup.Use(internalSecretMiddleware(cfg.HTTP.InternalCallbackSecret))
|
||||
internalGroup.GET("/info", func(c *gin.Context) {
|
||||
@ -58,6 +60,34 @@ func registerGameOpenRoutes(engine *gin.Engine, cfg config.Config, service *game
|
||||
})
|
||||
}
|
||||
|
||||
func registerHotgameCallbackRoutes(engine *gin.Engine, service *gameopen.GameOpenService) {
|
||||
handleUserInfo := func(c *gin.Context) {
|
||||
raw, _ := c.GetRawData()
|
||||
var req gameopen.HotgameGetUserInfoRequest
|
||||
if err := json.Unmarshal(raw, &req); err != nil {
|
||||
c.JSON(http.StatusOK, gameopen.CallbackResponse{ErrorCode: 4001, ErrorMsg: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, service.HandleHotgameGetUserInfo(c.Request.Context(), req, string(raw)))
|
||||
}
|
||||
handleUpdateBalance := func(c *gin.Context) {
|
||||
raw, _ := c.GetRawData()
|
||||
var req gameopen.HotgameUpdateBalanceRequest
|
||||
if err := json.Unmarshal(raw, &req); err != nil {
|
||||
c.JSON(http.StatusOK, gameopen.CallbackResponse{ErrorCode: 4001, ErrorMsg: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, service.HandleHotgameUpdateBalance(c.Request.Context(), req, string(raw)))
|
||||
}
|
||||
|
||||
// 热游文档要求平台直接提供 /getUserInfo 和 /updateBalance,同时保留命名空间路径便于网关灰度。
|
||||
engine.POST("/getUserInfo", handleUserInfo)
|
||||
engine.POST("/updateBalance", handleUpdateBalance)
|
||||
hotgameGroup := engine.Group("/game/hotgame")
|
||||
hotgameGroup.POST("/getUserInfo", handleUserInfo)
|
||||
hotgameGroup.POST("/updateBalance", handleUpdateBalance)
|
||||
}
|
||||
|
||||
func resolvePublicBaseURL(c *gin.Context, cfg config.Config) string {
|
||||
if value := strings.TrimRight(strings.TrimSpace(cfg.GameOpen.PublicBaseURL), "/"); value != "" {
|
||||
return value
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@ -11,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
// registerGameProviderRoutes 注册统一游戏厂商 app 路由。
|
||||
func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, registry *gameprovider.Registry) {
|
||||
func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, registry *gameprovider.Registry, visibility *gameprovider.VisibilityPolicy) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
@ -19,7 +20,15 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
|
||||
appGroup := engine.Group("/app/game")
|
||||
appGroup.Use(authMiddleware(javaClient))
|
||||
appGroup.GET("/room/shortcut", func(c *gin.Context) {
|
||||
resp, err := registry.ListShortcutGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"))
|
||||
user, visible, ok := gameListAccess(c, visibility)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !visible {
|
||||
writeOK(c, []publicRoomGameItem{})
|
||||
return
|
||||
}
|
||||
resp, err := registry.ListShortcutGames(c.Request.Context(), user, c.Query("roomId"))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@ -27,7 +36,15 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
|
||||
writeOK(c, publicRoomGameItems(resp))
|
||||
})
|
||||
appGroup.GET("/room/list", func(c *gin.Context) {
|
||||
resp, err := registry.ListRoomGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"), c.Query("category"))
|
||||
user, visible, ok := gameListAccess(c, visibility)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !visible {
|
||||
writeOK(c, publicRoomGameListResponse(&gameprovider.RoomGameListResponse{Items: []gameprovider.RoomGameListItem{}}))
|
||||
return
|
||||
}
|
||||
resp, err := registry.ListRoomGames(c.Request.Context(), user, c.Query("roomId"), c.Query("category"))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@ -72,14 +89,30 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
|
||||
providerGroup := engine.Group("/app/game/providers")
|
||||
providerGroup.Use(authMiddleware(javaClient))
|
||||
providerGroup.GET("", func(c *gin.Context) {
|
||||
_, visible, ok := gameListAccess(c, visibility)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !visible {
|
||||
writeOK(c, gameprovider.ProviderListResponse{Items: []gameprovider.ProviderSummary{}})
|
||||
return
|
||||
}
|
||||
writeOK(c, registry.List())
|
||||
})
|
||||
providerGroup.GET("/:provider/room/shortcut", func(c *gin.Context) {
|
||||
user, visible, ok := gameListAccess(c, visibility)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !visible {
|
||||
writeOK(c, gin.H{"items": []publicRoomGameItem{}})
|
||||
return
|
||||
}
|
||||
provider, ok := resolveGameProvider(c, registry)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
resp, err := provider.ListShortcutGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"))
|
||||
resp, err := provider.ListShortcutGames(c.Request.Context(), user, c.Query("roomId"))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@ -87,11 +120,19 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
|
||||
writeOK(c, gin.H{"items": publicRoomGameItems(resp)})
|
||||
})
|
||||
providerGroup.GET("/:provider/room/list", func(c *gin.Context) {
|
||||
user, visible, ok := gameListAccess(c, visibility)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !visible {
|
||||
writeOK(c, publicRoomGameListResponse(&gameprovider.RoomGameListResponse{Items: []gameprovider.RoomGameListItem{}}))
|
||||
return
|
||||
}
|
||||
provider, ok := resolveGameProvider(c, registry)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
resp, err := provider.ListRoomGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"), c.Query("category"))
|
||||
resp, err := provider.ListRoomGames(c.Request.Context(), user, c.Query("roomId"), c.Query("category"))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@ -120,7 +161,7 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
|
||||
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||
return
|
||||
}
|
||||
resp, err := provider.LaunchGame(c.Request.Context(), mustAuthUser(c), req, resolveClientIP(c))
|
||||
resp, err := launchProviderGame(c, registry, provider, req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@ -146,6 +187,33 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
|
||||
})
|
||||
}
|
||||
|
||||
func gameListAccess(c *gin.Context, visibility *gameprovider.VisibilityPolicy) (common.AuthUser, bool, bool) {
|
||||
user := mustAuthUser(c)
|
||||
// iOS 审核和上架场景需要隐藏游戏入口;这里放在统一列表门禁最前面,避免继续请求 Java 区域/等级接口或 provider SQL。
|
||||
if isIOSGameListClient(c) {
|
||||
return user, false, true
|
||||
}
|
||||
if gameprovider.IsEmptyGameListUser(user.UserID) {
|
||||
return user, false, true
|
||||
}
|
||||
if visibility == nil {
|
||||
return user, true, true
|
||||
}
|
||||
// 游戏列表只在列表入口做展示门禁,结果里的区域继续向下传给 provider SQL 做 regions 过滤。
|
||||
result, err := visibility.Evaluate(c.Request.Context(), user, resolveClientIP(c))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return user, false, false
|
||||
}
|
||||
user.RegionID = result.RegionID
|
||||
user.RegionCode = result.RegionCode
|
||||
return user, result.Allow, true
|
||||
}
|
||||
|
||||
func isIOSGameListClient(c *gin.Context) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(c.GetHeader("Req-Client")), "ios")
|
||||
}
|
||||
|
||||
func resolveGameProvider(c *gin.Context, registry *gameprovider.Registry) (gameprovider.Provider, bool) {
|
||||
provider, err := registry.Resolve(c.Param("provider"))
|
||||
if err != nil {
|
||||
@ -155,6 +223,25 @@ func resolveGameProvider(c *gin.Context, registry *gameprovider.Registry) (gamep
|
||||
return provider, true
|
||||
}
|
||||
|
||||
func launchProviderGame(c *gin.Context, registry *gameprovider.Registry, provider gameprovider.Provider, req gameprovider.LaunchRequest) (*gameprovider.LaunchResponse, error) {
|
||||
user := mustAuthUser(c)
|
||||
clientIP := resolveClientIP(c)
|
||||
resp, err := provider.LaunchGame(c.Request.Context(), user, req, clientIP)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
if !isGameNotFoundError(err) {
|
||||
return nil, err
|
||||
}
|
||||
// 旧客户端只能把热游列表项伪装成 LEADER 进入已有 WebView;这里再按 gameId 回到真实 provider,避免 LEADER 路由找不到 hg_*。
|
||||
return registry.LaunchGame(c.Request.Context(), user, req, clientIP)
|
||||
}
|
||||
|
||||
func isGameNotFoundError(err error) bool {
|
||||
var appErr *common.AppError
|
||||
return errors.As(err, &appErr) && appErr.Code == "game_not_found"
|
||||
}
|
||||
|
||||
type publicRoomGameItem struct {
|
||||
ID int64 `json:"id"`
|
||||
GameID string `json:"gameId"`
|
||||
@ -311,8 +398,14 @@ func publicLaunch(resp *gameprovider.LaunchResponse) *publicLaunchResponse {
|
||||
}
|
||||
|
||||
func appCompatProviderName(provider string) string {
|
||||
// 客户端历史上只认识 LEADER,不认识内部厂商名 LINGXIAN,所以出参继续保持旧字段值。
|
||||
if strings.EqualFold(strings.TrimSpace(provider), "LINGXIAN") {
|
||||
return "LEADER"
|
||||
}
|
||||
// 旧客户端没有 HOTGAME 页面;热游先按 LEADER 展示并进入已有 WebView,launch 路由再按 hg_* 分发回真实热游 provider。
|
||||
switch strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(provider), "_", "")) {
|
||||
case "HOTGAME", "REYOU", "LALU":
|
||||
return "LEADER"
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
@ -1,9 +1,18 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"chatapp3-golang/internal/common"
|
||||
"chatapp3-golang/internal/integration"
|
||||
"chatapp3-golang/internal/service/gameprovider"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestPublicRoomGameListResponseKeepsLegacyProviderFields(t *testing.T) {
|
||||
@ -74,6 +83,41 @@ func TestPublicRoomGameListResponseMapsLingxianToLeaderForOldClients(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicRoomGameListResponseMapsHotgameAliasesToLeaderForOldClients(t *testing.T) {
|
||||
for _, alias := range []string{"HOTGAME", "HOT_GAME", "REYOU", "LALU"} {
|
||||
resp := publicRoomGameListResponse(&gameprovider.RoomGameListResponse{
|
||||
Items: []gameprovider.RoomGameListItem{
|
||||
{
|
||||
ID: 9530,
|
||||
GameID: "hg_1",
|
||||
GameType: alias,
|
||||
Provider: alias,
|
||||
ProviderGameID: "1",
|
||||
Name: "Hotgame",
|
||||
LaunchParams: map[string]any{"gameType": alias, "screenMode": "seven"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if len(resp.Items) != 1 {
|
||||
t.Fatalf("%s items = %d, want 1", alias, len(resp.Items))
|
||||
}
|
||||
item := resp.Items[0]
|
||||
if item.Provider != "LEADER" {
|
||||
t.Fatalf("%s provider = %q, want LEADER", alias, item.Provider)
|
||||
}
|
||||
if item.GameType != "LEADER" {
|
||||
t.Fatalf("%s gameType = %q, want LEADER", alias, item.GameType)
|
||||
}
|
||||
if item.VendorType != "LEADER" {
|
||||
t.Fatalf("%s vendorType = %q, want LEADER", alias, item.VendorType)
|
||||
}
|
||||
if got := item.LaunchParams["gameType"]; got != "LEADER" {
|
||||
t.Fatalf("%s launchParams.gameType = %v, want LEADER", alias, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicLaunchKeepsLegacyBridgeAndProviderFields(t *testing.T) {
|
||||
launchConfig := map[string]any{"code": "launch-code"}
|
||||
resp := publicLaunch(&gameprovider.LaunchResponse{
|
||||
@ -141,3 +185,349 @@ func TestPublicLaunchMapsLingxianToLeaderForOldClients(t *testing.T) {
|
||||
t.Fatalf("roomState.provider = %q, want LEADER", resp.RoomState.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameProviderRoutesReturnEmptyWhenVisibilityPolicyDenies(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
provider := &routeStubProvider{}
|
||||
engine := gin.New()
|
||||
registerGameProviderRoutes(
|
||||
engine,
|
||||
routeStubAuthGateway{},
|
||||
gameprovider.NewRegistry(provider),
|
||||
gameprovider.NewVisibilityPolicy(
|
||||
routeStubVisibilityGateway{
|
||||
region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
|
||||
level: integration.UserLevel{WealthLevel: 2},
|
||||
targetRegionCode: "土耳其",
|
||||
},
|
||||
routeStubIPCountryResolver{countryCode: "TR"},
|
||||
),
|
||||
)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/game/room/list?roomId=1", nil)
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Body struct {
|
||||
Items []publicRoomGameItem `json:"items"`
|
||||
} `json:"body"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("response json error = %v: %s", err, rec.Body.String())
|
||||
}
|
||||
if len(payload.Body.Items) != 0 {
|
||||
t.Fatalf("items = %d, want 0", len(payload.Body.Items))
|
||||
}
|
||||
if provider.listRoomCalls != 0 {
|
||||
t.Fatalf("provider list calls = %d, want 0", provider.listRoomCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameProviderRoutesReturnEmptyRoomListForIOSClient(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
provider := &routeStubProvider{}
|
||||
engine := gin.New()
|
||||
registerGameProviderRoutes(
|
||||
engine,
|
||||
routeStubAuthGateway{},
|
||||
gameprovider.NewRegistry(provider),
|
||||
nil,
|
||||
)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/game/room/list?roomId=1", nil)
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
req.Header.Set("Req-Client", "ios")
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Body struct {
|
||||
Items []publicRoomGameItem `json:"items"`
|
||||
} `json:"body"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("response json error = %v: %s", err, rec.Body.String())
|
||||
}
|
||||
if len(payload.Body.Items) != 0 {
|
||||
t.Fatalf("items = %d, want 0", len(payload.Body.Items))
|
||||
}
|
||||
if provider.listRoomCalls != 0 {
|
||||
t.Fatalf("provider list calls = %d, want 0", provider.listRoomCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameProviderRoutesReturnEmptyShortcutForIOSClient(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
provider := &routeStubProvider{}
|
||||
engine := gin.New()
|
||||
registerGameProviderRoutes(
|
||||
engine,
|
||||
routeStubAuthGateway{},
|
||||
gameprovider.NewRegistry(provider),
|
||||
nil,
|
||||
)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/game/room/shortcut?roomId=1", nil)
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
req.Header.Set("Req-Client", " iOS ")
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Body []publicRoomGameItem `json:"body"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("response json error = %v: %s", err, rec.Body.String())
|
||||
}
|
||||
if len(payload.Body) != 0 {
|
||||
t.Fatalf("items = %d, want 0", len(payload.Body))
|
||||
}
|
||||
if provider.shortcutCalls != 0 {
|
||||
t.Fatalf("provider shortcut calls = %d, want 0", provider.shortcutCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameProviderRoutesReturnEmptyProvidersForIOSClient(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
registerGameProviderRoutes(
|
||||
engine,
|
||||
routeStubAuthGateway{},
|
||||
gameprovider.NewRegistry(&routeStubProvider{}),
|
||||
nil,
|
||||
)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/game/providers", nil)
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
req.Header.Set("Req-Client", "IOS")
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Body struct {
|
||||
Items []gameprovider.ProviderSummary `json:"items"`
|
||||
} `json:"body"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("response json error = %v: %s", err, rec.Body.String())
|
||||
}
|
||||
if len(payload.Body.Items) != 0 {
|
||||
t.Fatalf("providers = %d, want 0", len(payload.Body.Items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameProviderRoutesReturnProvidersWhenVisibilityPolicyAllows(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
registerGameProviderRoutes(
|
||||
engine,
|
||||
routeStubAuthGateway{},
|
||||
gameprovider.NewRegistry(&routeStubProvider{}),
|
||||
gameprovider.NewVisibilityPolicy(
|
||||
routeStubVisibilityGateway{
|
||||
region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
|
||||
level: integration.UserLevel{WealthLevel: 3},
|
||||
targetRegionCode: "土耳其",
|
||||
},
|
||||
routeStubIPCountryResolver{countryCode: "ZA"},
|
||||
),
|
||||
)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/game/providers", nil)
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
req.Header.Set("X-Real-IP", "88.255.1.1")
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Body struct {
|
||||
Items []gameprovider.ProviderSummary `json:"items"`
|
||||
} `json:"body"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("response json error = %v: %s", err, rec.Body.String())
|
||||
}
|
||||
if len(payload.Body.Items) != 1 {
|
||||
t.Fatalf("providers = %d, want 1", len(payload.Body.Items))
|
||||
}
|
||||
if payload.Body.Items[0].Key != "TEST" {
|
||||
t.Fatalf("provider key = %q, want TEST", payload.Body.Items[0].Key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderLaunchFallsBackToRegistryWhenLegacyProviderCannotFindGame(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
registerGameProviderRoutes(
|
||||
engine,
|
||||
routeStubAuthGateway{},
|
||||
gameprovider.NewRegistry(
|
||||
&routeLaunchStubProvider{key: "LINGXIAN"},
|
||||
&routeLaunchStubProvider{key: "HOTGAME", matchGameID: "hg_1"},
|
||||
),
|
||||
nil,
|
||||
)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/app/game/providers/LEADER/launch", strings.NewReader(`{"roomId":"room-1","gameId":"hg_1"}`))
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Body publicLaunchResponse `json:"body"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("response json error = %v: %s", err, rec.Body.String())
|
||||
}
|
||||
if payload.Body.GameID != "hg_1" {
|
||||
t.Fatalf("gameId = %q, want hg_1", payload.Body.GameID)
|
||||
}
|
||||
if payload.Body.Provider != "LEADER" {
|
||||
t.Fatalf("provider = %q, want LEADER for old-client compat", payload.Body.Provider)
|
||||
}
|
||||
if payload.Body.GameSessionID != "hg-session" {
|
||||
t.Fatalf("gameSessionId = %q, want hg-session", payload.Body.GameSessionID)
|
||||
}
|
||||
}
|
||||
|
||||
type routeStubAuthGateway struct{}
|
||||
|
||||
func (routeStubAuthGateway) AuthenticateToken(context.Context, string) (integration.UserCredential, error) {
|
||||
return integration.UserCredential{UserID: integration.Int64Value(1001), SysOrigin: "LIKEI"}, nil
|
||||
}
|
||||
|
||||
func (routeStubAuthGateway) AuthenticateConsoleToken(context.Context, string) (integration.ConsoleAccount, error) {
|
||||
return integration.ConsoleAccount{}, nil
|
||||
}
|
||||
|
||||
type routeStubVisibilityGateway struct {
|
||||
region integration.UserRegion
|
||||
level integration.UserLevel
|
||||
targetRegionCode string
|
||||
}
|
||||
|
||||
func (s routeStubVisibilityGateway) GetUserRegion(context.Context, int64, string) (integration.UserRegion, error) {
|
||||
return s.region, nil
|
||||
}
|
||||
|
||||
func (s routeStubVisibilityGateway) GetUserLevel(context.Context, string, int64) (integration.UserLevel, error) {
|
||||
return s.level, nil
|
||||
}
|
||||
|
||||
func (s routeStubVisibilityGateway) ResolveRegionCodeByCountryCode(context.Context, string, string) (string, error) {
|
||||
return s.targetRegionCode, nil
|
||||
}
|
||||
|
||||
type routeStubIPCountryResolver struct {
|
||||
countryCode string
|
||||
}
|
||||
|
||||
func (s routeStubIPCountryResolver) CountryCode(context.Context, string) (string, error) {
|
||||
return s.countryCode, nil
|
||||
}
|
||||
|
||||
type routeStubProvider struct {
|
||||
listRoomCalls int
|
||||
shortcutCalls int
|
||||
}
|
||||
|
||||
func (p *routeStubProvider) Key() string { return "TEST" }
|
||||
|
||||
func (p *routeStubProvider) DisplayName() string { return "Test Provider" }
|
||||
|
||||
func (p *routeStubProvider) SupportsLaunch(context.Context, gameprovider.AuthUser, gameprovider.LaunchRequest) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (p *routeStubProvider) ListShortcutGames(context.Context, gameprovider.AuthUser, string) ([]gameprovider.RoomGameListItem, error) {
|
||||
p.shortcutCalls++
|
||||
return []gameprovider.RoomGameListItem{{ID: 1, GameID: "test_1", Provider: "TEST", ProviderGameID: "1", Name: "Test Game"}}, nil
|
||||
}
|
||||
|
||||
func (p *routeStubProvider) ListRoomGames(context.Context, gameprovider.AuthUser, string, string) (*gameprovider.RoomGameListResponse, error) {
|
||||
p.listRoomCalls++
|
||||
return &gameprovider.RoomGameListResponse{
|
||||
Items: []gameprovider.RoomGameListItem{{ID: 1, GameID: "test_1", Provider: "TEST", ProviderGameID: "1", Name: "Test Game"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *routeStubProvider) GetRoomState(context.Context, gameprovider.AuthUser, string) (*gameprovider.RoomStateResponse, error) {
|
||||
return &gameprovider.RoomStateResponse{}, nil
|
||||
}
|
||||
|
||||
func (p *routeStubProvider) LaunchGame(context.Context, gameprovider.AuthUser, gameprovider.LaunchRequest, string) (*gameprovider.LaunchResponse, error) {
|
||||
return nil, common.NewAppError(http.StatusBadRequest, "unsupported", "unsupported")
|
||||
}
|
||||
|
||||
func (p *routeStubProvider) CloseGame(context.Context, gameprovider.AuthUser, gameprovider.CloseRequest) (*gameprovider.RoomStateResponse, error) {
|
||||
return &gameprovider.RoomStateResponse{}, nil
|
||||
}
|
||||
|
||||
type routeLaunchStubProvider struct {
|
||||
key string
|
||||
matchGameID string
|
||||
}
|
||||
|
||||
func (p *routeLaunchStubProvider) Key() string { return p.key }
|
||||
|
||||
func (p *routeLaunchStubProvider) DisplayName() string { return p.key }
|
||||
|
||||
func (p *routeLaunchStubProvider) SupportsLaunch(_ context.Context, _ gameprovider.AuthUser, req gameprovider.LaunchRequest) (bool, error) {
|
||||
return p.matchGameID != "" && req.GameID == p.matchGameID, nil
|
||||
}
|
||||
|
||||
func (p *routeLaunchStubProvider) ListShortcutGames(context.Context, gameprovider.AuthUser, string) ([]gameprovider.RoomGameListItem, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *routeLaunchStubProvider) ListRoomGames(context.Context, gameprovider.AuthUser, string, string) (*gameprovider.RoomGameListResponse, error) {
|
||||
return &gameprovider.RoomGameListResponse{}, nil
|
||||
}
|
||||
|
||||
func (p *routeLaunchStubProvider) GetRoomState(context.Context, gameprovider.AuthUser, string) (*gameprovider.RoomStateResponse, error) {
|
||||
return &gameprovider.RoomStateResponse{}, nil
|
||||
}
|
||||
|
||||
func (p *routeLaunchStubProvider) LaunchGame(_ context.Context, _ gameprovider.AuthUser, req gameprovider.LaunchRequest, _ string) (*gameprovider.LaunchResponse, error) {
|
||||
if p.matchGameID == "" || req.GameID != p.matchGameID {
|
||||
return nil, common.NewAppError(http.StatusNotFound, "game_not_found", "game not found")
|
||||
}
|
||||
return &gameprovider.LaunchResponse{
|
||||
GameSessionID: "hg-session",
|
||||
Provider: p.key,
|
||||
GameID: req.GameID,
|
||||
ProviderGameID: "1",
|
||||
Entry: gameprovider.LaunchEntry{LaunchMode: "H5_REMOTE"},
|
||||
RoomState: gameprovider.RoomStateResponse{
|
||||
RoomID: req.RoomID,
|
||||
State: "PLAYING",
|
||||
Provider: p.key,
|
||||
GameSessionID: "hg-session",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *routeLaunchStubProvider) CloseGame(context.Context, gameprovider.AuthUser, gameprovider.CloseRequest) (*gameprovider.RoomStateResponse, error) {
|
||||
return &gameprovider.RoomStateResponse{}, nil
|
||||
}
|
||||
|
||||
115
internal/router/hotgame_routes.go
Normal file
115
internal/router/hotgame_routes.go
Normal file
@ -0,0 +1,115 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"chatapp3-golang/internal/common"
|
||||
"chatapp3-golang/internal/service/hotgame"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// registerHotgameRoutes 注册热游后台管理类 Go 接口。
|
||||
func registerHotgameRoutes(engine *gin.Engine, javaClient authGateway, service *hotgame.Service) {
|
||||
if service == nil {
|
||||
return
|
||||
}
|
||||
consoleGroup := engine.Group("/operate/hotgame-game")
|
||||
consoleGroup.Use(consoleAuthMiddleware(javaClient))
|
||||
consoleGroup.GET("/config", func(c *gin.Context) {
|
||||
resp, err := service.GetProviderConfig(c.Request.Context(), c.Query("sysOrigin"), c.Query("profile"))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
consoleGroup.POST("/config", func(c *gin.Context) {
|
||||
var req hotgame.SaveProviderConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||
return
|
||||
}
|
||||
resp, err := service.SaveProviderConfig(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
consoleGroup.POST("/config/activate", func(c *gin.Context) {
|
||||
var req hotgame.ActivateProviderProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||
return
|
||||
}
|
||||
resp, err := service.ActivateProviderProfile(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
consoleGroup.GET("/catalog/page", func(c *gin.Context) {
|
||||
cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1")))
|
||||
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
|
||||
resp, err := service.PageCatalog(
|
||||
c.Request.Context(),
|
||||
c.Query("sysOrigin"),
|
||||
c.Query("profile"),
|
||||
c.Query("keyword"),
|
||||
cursor,
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
consoleGroup.POST("/catalog/fetch", func(c *gin.Context) {
|
||||
var req hotgame.SyncCatalogRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||
return
|
||||
}
|
||||
resp, err := service.FetchAdminCatalog(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
consoleGroup.POST("/import-catalog", func(c *gin.Context) {
|
||||
var req hotgame.SyncCatalogRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||
return
|
||||
}
|
||||
defaultShowcase := true
|
||||
if req.DefaultShowcase != nil {
|
||||
defaultShowcase = *req.DefaultShowcase
|
||||
}
|
||||
resp, err := service.ImportCatalogGames(c.Request.Context(), req.SysOrigin, req.Profile, req.VendorGameIDs, defaultShowcase)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
consoleGroup.POST("/sync", func(c *gin.Context) {
|
||||
var req hotgame.SyncCatalogRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeError(c, common.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||
return
|
||||
}
|
||||
resp, err := service.SyncAdminGames(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
}
|
||||
@ -37,6 +37,15 @@ func registerManagerCenterRoutes(engine *gin.Engine, javaClient authGateway, ser
|
||||
writeOK(c, resp)
|
||||
})
|
||||
|
||||
group.GET("/countries", func(c *gin.Context) {
|
||||
resp, err := service.ListCountries(c.Request.Context(), mustAuthUser(c))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
|
||||
group.GET("/props", func(c *gin.Context) {
|
||||
resp, err := service.ListProps(c.Request.Context(), mustAuthUser(c), c.Query("type"))
|
||||
if err != nil {
|
||||
@ -60,6 +69,29 @@ func registerManagerCenterRoutes(engine *gin.Engine, javaClient authGateway, ser
|
||||
writeOK(c, resp)
|
||||
})
|
||||
|
||||
group.GET("/bd-leaders", func(c *gin.Context) {
|
||||
resp, err := service.ListBDLeaders(c.Request.Context(), mustAuthUser(c))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
|
||||
group.POST("/bd-leaders", func(c *gin.Context) {
|
||||
var req managercenter.AddBDLeaderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeError(c, managercenter.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||
return
|
||||
}
|
||||
resp, err := service.AddBDLeader(c.Request.Context(), mustAuthUser(c), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
|
||||
group.POST("/users/ban", func(c *gin.Context) {
|
||||
var req managercenter.BanUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@ -87,4 +119,18 @@ func registerManagerCenterRoutes(engine *gin.Engine, javaClient authGateway, ser
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
|
||||
group.POST("/users/country", func(c *gin.Context) {
|
||||
var req managercenter.ChangeUserCountryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeError(c, managercenter.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||
return
|
||||
}
|
||||
resp, err := service.ChangeUserCountry(c.Request.Context(), mustAuthUser(c), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
}
|
||||
|
||||
@ -112,6 +112,9 @@ func trimBearer(authorization string) string {
|
||||
|
||||
// resolveClientIP 解析请求来源 IP。
|
||||
func resolveClientIP(c *gin.Context) string {
|
||||
if realIP := strings.TrimSpace(c.GetHeader("X-Real-IP")); realIP != "" {
|
||||
return realIP
|
||||
}
|
||||
if forwarded := strings.TrimSpace(c.GetHeader("X-Forwarded-For")); forwarded != "" {
|
||||
parts := strings.Split(forwarded, ",")
|
||||
if len(parts) > 0 {
|
||||
|
||||
@ -4,6 +4,8 @@ import (
|
||||
"net/http"
|
||||
|
||||
"chatapp3-golang/internal/config"
|
||||
"chatapp3-golang/internal/repo"
|
||||
"chatapp3-golang/internal/service/highwin"
|
||||
"chatapp3-golang/internal/service/regionimgroup"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@ -13,6 +15,7 @@ import (
|
||||
func registerRegionIMGroupRoutes(
|
||||
engine *gin.Engine,
|
||||
cfg config.Config,
|
||||
repository *repo.Repository,
|
||||
javaClient authGateway,
|
||||
service *regionimgroup.Service,
|
||||
) {
|
||||
@ -22,7 +25,7 @@ func registerRegionIMGroupRoutes(
|
||||
|
||||
registerRegionIMGroupAppRoutes(engine, javaClient, service)
|
||||
registerRegionIMGroupInternalRoutes(engine, cfg, service)
|
||||
registerRegionIMGroupAdminRoutes(engine, javaClient, service)
|
||||
registerRegionIMGroupAdminRoutes(engine, repository, javaClient, service)
|
||||
}
|
||||
|
||||
func registerRegionIMGroupAppRoutes(engine *gin.Engine, javaClient authGateway, service *regionimgroup.Service) {
|
||||
@ -62,9 +65,14 @@ func registerRegionIMGroupInternalRoutes(engine *gin.Engine, cfg config.Config,
|
||||
})
|
||||
}
|
||||
|
||||
func registerRegionIMGroupAdminRoutes(engine *gin.Engine, javaClient authGateway, service *regionimgroup.Service) {
|
||||
func registerRegionIMGroupAdminRoutes(engine *gin.Engine, repository *repo.Repository, javaClient authGateway, service *regionimgroup.Service) {
|
||||
registerRegionIMGroupAdminGroup(engine.Group("/region/config"), javaClient, service)
|
||||
registerRegionIMGroupAdminGroup(engine.Group("/resident-activity/voice-room-red-packet"), javaClient, service)
|
||||
var worker *highwin.BroadcastSimulationWorker
|
||||
if repository != nil {
|
||||
worker = highwin.NewBroadcastSimulationWorker(repository.DB, repository.Redis, service)
|
||||
}
|
||||
registerRegionBroadcastSimulationAdminGroup(engine.Group("/operate/region-broadcast"), javaClient, service, worker)
|
||||
}
|
||||
|
||||
func registerRegionIMGroupAdminGroup(group *gin.RouterGroup, javaClient authGateway, service *regionimgroup.Service) {
|
||||
@ -104,3 +112,39 @@ func registerRegionIMGroupAdminGroup(group *gin.RouterGroup, javaClient authGate
|
||||
writeOK(c, resp)
|
||||
})
|
||||
}
|
||||
|
||||
func registerRegionBroadcastSimulationAdminGroup(group *gin.RouterGroup, javaClient authGateway, service *regionimgroup.Service, worker *highwin.BroadcastSimulationWorker) {
|
||||
group.Use(consoleAuthMiddleware(javaClient))
|
||||
group.POST("/simulate", func(c *gin.Context) {
|
||||
var req highwin.BroadcastSimulationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeError(c, regionimgroup.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||
return
|
||||
}
|
||||
resp, err := highwin.SendSimulationBroadcast(c.Request.Context(), service, req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
group.GET("/worker/status", func(c *gin.Context) {
|
||||
writeOK(c, worker.Status())
|
||||
})
|
||||
group.POST("/worker/start", func(c *gin.Context) {
|
||||
var req highwin.ContinuousSimulationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeError(c, regionimgroup.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||
return
|
||||
}
|
||||
resp, err := worker.Start(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
group.POST("/worker/stop", func(c *gin.Context) {
|
||||
writeOK(c, worker.Stop())
|
||||
})
|
||||
}
|
||||
|
||||
@ -10,9 +10,11 @@ import (
|
||||
"chatapp3-golang/internal/service/baishun"
|
||||
"chatapp3-golang/internal/service/binancerecharge"
|
||||
"chatapp3-golang/internal/service/errorlog"
|
||||
"chatapp3-golang/internal/service/firstrechargereward"
|
||||
"chatapp3-golang/internal/service/gameopen"
|
||||
"chatapp3-golang/internal/service/gameprovider"
|
||||
"chatapp3-golang/internal/service/hostcenter"
|
||||
"chatapp3-golang/internal/service/hotgame"
|
||||
"chatapp3-golang/internal/service/invite"
|
||||
"chatapp3-golang/internal/service/lingxian"
|
||||
"chatapp3-golang/internal/service/luckygift"
|
||||
@ -38,32 +40,35 @@ import (
|
||||
|
||||
// Services 聚合所有业务模块服务,供路由层统一装配。
|
||||
type Services struct {
|
||||
AppPopup *apppopup.Service
|
||||
ErrorLog *errorlog.Service
|
||||
Invite *invite.InviteService
|
||||
Baishun *baishun.BaishunService
|
||||
BinanceRecharge *binancerecharge.Service
|
||||
Lingxian *lingxian.Service
|
||||
GameOpen *gameopen.GameOpenService
|
||||
GameProviders *gameprovider.Registry
|
||||
LuckyGift *luckygift.LuckyGiftService
|
||||
ManagerCenter *managercenter.Service
|
||||
PropsStore *propsstore.Service
|
||||
HostCenter *hostcenter.Service
|
||||
RechargeAgency *rechargeagency.Service
|
||||
RechargeReward *rechargereward.Service
|
||||
RegionIMGroup *regionimgroup.Service
|
||||
RegisterReward *registerreward.RegisterRewardService
|
||||
RoomTurnoverReward *roomturnoverreward.Service
|
||||
SignInReward *signinreward.SignInRewardService
|
||||
SmashEgg *smashegg.Service
|
||||
TaskCenter *taskcenter.Service
|
||||
UserBadge *userbadge.Service
|
||||
VIP *vip.Service
|
||||
Wheel *wheel.Service
|
||||
VoiceRoomRedPacket *voiceroomredpacket.Service
|
||||
VoiceRoomRocket *voiceroomrocket.Service
|
||||
WeekStar *weekstar.WeekStarService
|
||||
AppPopup *apppopup.Service
|
||||
ErrorLog *errorlog.Service
|
||||
Invite *invite.InviteService
|
||||
Baishun *baishun.BaishunService
|
||||
BinanceRecharge *binancerecharge.Service
|
||||
FirstRechargeReward *firstrechargereward.Service
|
||||
Lingxian *lingxian.Service
|
||||
GameOpen *gameopen.GameOpenService
|
||||
GameProviders *gameprovider.Registry
|
||||
GameVisibility *gameprovider.VisibilityPolicy
|
||||
LuckyGift *luckygift.LuckyGiftService
|
||||
ManagerCenter *managercenter.Service
|
||||
PropsStore *propsstore.Service
|
||||
HostCenter *hostcenter.Service
|
||||
Hotgame *hotgame.Service
|
||||
RechargeAgency *rechargeagency.Service
|
||||
RechargeReward *rechargereward.Service
|
||||
RegionIMGroup *regionimgroup.Service
|
||||
RegisterReward *registerreward.RegisterRewardService
|
||||
RoomTurnoverReward *roomturnoverreward.Service
|
||||
SignInReward *signinreward.SignInRewardService
|
||||
SmashEgg *smashegg.Service
|
||||
TaskCenter *taskcenter.Service
|
||||
UserBadge *userbadge.Service
|
||||
VIP *vip.Service
|
||||
Wheel *wheel.Service
|
||||
VoiceRoomRedPacket *voiceroomredpacket.Service
|
||||
VoiceRoomRocket *voiceroomrocket.Service
|
||||
WeekStar *weekstar.WeekStarService
|
||||
}
|
||||
|
||||
// NewRouter 创建 Gin 路由,并按模块注册所有接口。
|
||||
@ -82,6 +87,7 @@ func NewRouter(
|
||||
registerInviteRoutes(engine, javaClient, services.Invite)
|
||||
registerRechargeAgencyRoutes(engine, javaClient, services.RechargeAgency)
|
||||
registerRechargeRewardRoutes(engine, javaClient, services.RechargeReward)
|
||||
registerFirstRechargeRewardRoutes(engine, javaClient, services.FirstRechargeReward)
|
||||
registerBinanceRechargeRoutes(engine, javaClient, services.BinanceRecharge)
|
||||
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
|
||||
registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
|
||||
@ -90,16 +96,17 @@ func NewRouter(
|
||||
registerVipRoutes(engine, javaClient, services.VIP)
|
||||
registerWheelRoutes(engine, cfg, javaClient, services.Wheel)
|
||||
registerSmashEggRoutes(engine, javaClient, services.SmashEgg)
|
||||
registerRegionIMGroupRoutes(engine, cfg, javaClient, services.RegionIMGroup)
|
||||
registerRegionIMGroupRoutes(engine, cfg, repository, javaClient, services.RegionIMGroup)
|
||||
registerVoiceRoomRedPacketRoutes(engine, javaClient, services.VoiceRoomRedPacket)
|
||||
registerVoiceRoomRocketRoutes(engine, cfg, javaClient, services.VoiceRoomRocket)
|
||||
registerWeekStarRoutes(engine, javaClient, services.WeekStar)
|
||||
registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward)
|
||||
registerHostCenterRoutes(engine, services.HostCenter)
|
||||
registerGameOpenRoutes(engine, cfg, services.GameOpen)
|
||||
registerGameProviderRoutes(engine, javaClient, services.GameProviders)
|
||||
registerGameProviderRoutes(engine, javaClient, services.GameProviders, services.GameVisibility)
|
||||
registerBaishunRoutes(engine, cfg, javaClient, services.Baishun)
|
||||
registerLingxianRoutes(engine, javaClient, services.Lingxian)
|
||||
registerHotgameRoutes(engine, javaClient, services.Hotgame)
|
||||
registerLuckyGiftRoutes(engine, cfg, services.LuckyGift)
|
||||
registerManagerCenterRoutes(engine, javaClient, services.ManagerCenter)
|
||||
registerPropsStoreRoutes(engine, javaClient, services.PropsStore)
|
||||
|
||||
@ -243,21 +243,6 @@ func (s *BaishunService) HandleChangeBalance(ctx context.Context, req BaishunCha
|
||||
return resp
|
||||
}
|
||||
|
||||
// HandleReport 处理百顺上报类回调,目前仅记录日志并返回成功。
|
||||
func (s *BaishunService) HandleReport(ctx context.Context, payload map[string]any, rawJSON string) baishunStandardResponse {
|
||||
userID := toString(payload["user_id"])
|
||||
orderID := toString(payload["order_id"])
|
||||
gameRoundID := toString(payload["game_round_id"])
|
||||
resp := baishunStandardResponse{
|
||||
Code: 0,
|
||||
Message: "succeed",
|
||||
UniqueID: s.uniqueID(),
|
||||
Data: map[string]any{},
|
||||
}
|
||||
s.saveCallbackLog(ctx, "report", rawJSON, utils.MustJSONString(resp, ""), "", userID, stringPtr(orderID), stringPtr(gameRoundID), baishunCallbackStatusSuccess, 0, resp.Message)
|
||||
return resp
|
||||
}
|
||||
|
||||
func (s *BaishunService) reportGameConsumeTaskEvent(ctx context.Context, session model.BaishunLaunchSession, req BaishunChangeBalanceRequest) {
|
||||
if s.taskReporter == nil || req.CurrencyDiff >= 0 {
|
||||
return
|
||||
|
||||
@ -49,6 +49,7 @@ func newTestBaishunService(t *testing.T) (*BaishunService, *gorm.DB) {
|
||||
&model.BaishunProviderConfig{},
|
||||
&model.BaishunGameCatalog{},
|
||||
&model.BaishunLaunchSession{},
|
||||
&model.BaishunCallbackLog{},
|
||||
&model.BaishunRoomState{},
|
||||
); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
|
||||
291
internal/service/baishun/report.go
Normal file
291
internal/service/baishun/report.go
Normal file
@ -0,0 +1,291 @@
|
||||
package baishun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/model"
|
||||
"chatapp3-golang/internal/service/highwin"
|
||||
"chatapp3-golang/internal/utils"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
baishunReportTypeStart = "game_start"
|
||||
baishunReportTypeSettle = "game_settle"
|
||||
baishunReportBetTTL = 24 * time.Hour
|
||||
baishunReportBetRedisPrefix = "baishun:report:bet"
|
||||
)
|
||||
|
||||
type baishunGameBetRecord struct {
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
RoomID string `json:"roomId"`
|
||||
GameID int64 `json:"gameId"`
|
||||
GameRoundID string `json:"gameRoundId"`
|
||||
UserID int64 `json:"userId"`
|
||||
Bet int64 `json:"bet"`
|
||||
}
|
||||
|
||||
func (s *BaishunService) HandleReport(ctx context.Context, req BaishunReportRequest, rawJSON string) baishunStandardResponse {
|
||||
resp := baishunStandardResponse{
|
||||
Code: 0,
|
||||
Message: "succeed",
|
||||
UniqueID: s.uniqueID(),
|
||||
Data: map[string]any{},
|
||||
}
|
||||
|
||||
sysOrigin := ""
|
||||
if session, ok := s.findSessionByToken(ctx, req.SSToken, req.UserID); ok {
|
||||
sysOrigin = session.SysOrigin
|
||||
if err := s.handleReportHighWins(ctx, session.SysOrigin, req); err != nil {
|
||||
log.Printf("handle baishun report high win failed userId=%s reportType=%s: %v", req.UserID, req.ReportType, err)
|
||||
}
|
||||
}
|
||||
|
||||
s.saveCallbackLog(
|
||||
ctx,
|
||||
"report",
|
||||
rawJSON,
|
||||
utils.MustJSONString(resp, ""),
|
||||
sysOrigin,
|
||||
req.UserID,
|
||||
nil,
|
||||
stringPtr(reportGameRoundID(req.ReportMsg)),
|
||||
baishunCallbackStatusSuccess,
|
||||
0,
|
||||
resp.Message,
|
||||
)
|
||||
return resp
|
||||
}
|
||||
|
||||
func (s *BaishunService) handleReportHighWins(ctx context.Context, sysOrigin string, req BaishunReportRequest) error {
|
||||
switch strings.ToLower(strings.TrimSpace(req.ReportType)) {
|
||||
case baishunReportTypeStart:
|
||||
return s.rememberBaishunGameBets(ctx, sysOrigin, req.ReportMsg)
|
||||
case baishunReportTypeSettle:
|
||||
return s.publishBaishunGameHighWins(ctx, sysOrigin, req.ReportMsg)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BaishunService) rememberBaishunGameBets(ctx context.Context, sysOrigin string, payload map[string]any) error {
|
||||
if s == nil || s.repo.Redis == nil {
|
||||
return nil
|
||||
}
|
||||
roomID := reportText(payload, "room_id", "roomId")
|
||||
gameRoundID := reportGameRoundID(payload)
|
||||
if strings.TrimSpace(roomID) == "" || strings.TrimSpace(gameRoundID) == "" {
|
||||
return nil
|
||||
}
|
||||
gameID := reportInt64(payload, "game_id", "gameId")
|
||||
for _, player := range reportPlayers(payload) {
|
||||
if reportInt64(player, "is_ai", "isAi") != 0 {
|
||||
continue
|
||||
}
|
||||
userID := reportInt64(player, "user_id", "userId")
|
||||
bet := reportInt64(player, "bet")
|
||||
if userID <= 0 || bet <= 0 {
|
||||
continue
|
||||
}
|
||||
record := baishunGameBetRecord{
|
||||
SysOrigin: normalizeAdminSysOrigin(sysOrigin),
|
||||
RoomID: roomID,
|
||||
GameID: gameID,
|
||||
GameRoundID: gameRoundID,
|
||||
UserID: userID,
|
||||
Bet: bet,
|
||||
}
|
||||
body, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.Redis.Set(ctx, baishunGameBetKey(record.SysOrigin, gameRoundID, userID), string(body), baishunReportBetTTL).Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BaishunService) publishBaishunGameHighWins(ctx context.Context, sysOrigin string, payload map[string]any) error {
|
||||
if s == nil || s.highWinBroadcaster == nil || s.repo.Redis == nil {
|
||||
return nil
|
||||
}
|
||||
sysOrigin = normalizeAdminSysOrigin(sysOrigin)
|
||||
roomID := reportText(payload, "room_id", "roomId")
|
||||
gameRoundID := reportGameRoundID(payload)
|
||||
if strings.TrimSpace(roomID) == "" || strings.TrimSpace(gameRoundID) == "" {
|
||||
return nil
|
||||
}
|
||||
gameID := reportInt64(payload, "game_id", "gameId")
|
||||
gameCover := s.resolveBaishunReportGameCover(ctx, sysOrigin, roomID, gameID)
|
||||
|
||||
for _, player := range reportPlayers(payload) {
|
||||
if reportInt64(player, "is_ai", "isAi") != 0 {
|
||||
continue
|
||||
}
|
||||
userID := reportInt64(player, "user_id", "userId")
|
||||
reward := reportInt64(player, "reward")
|
||||
if userID <= 0 || reward <= 0 {
|
||||
continue
|
||||
}
|
||||
betRecord, ok, err := s.loadBaishunGameBet(ctx, sysOrigin, gameRoundID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok || betRecord.Bet <= 0 {
|
||||
continue
|
||||
}
|
||||
multiple := highwin.Multiple(reward, betRecord.Bet)
|
||||
if !highwin.ShouldBroadcast(multiple, reward) {
|
||||
continue
|
||||
}
|
||||
data := map[string]any{
|
||||
"gameId": gameID,
|
||||
"gameRoundId": gameRoundID,
|
||||
"gameUrl": gameCover,
|
||||
"betAmount": betRecord.Bet,
|
||||
"currencyDiff": reward,
|
||||
"rank": reportInt64(player, "rank"),
|
||||
"score": reportInt64(player, "score"),
|
||||
}
|
||||
if err := highwin.SendRegionBroadcast(ctx, s.highWinBroadcaster, highwin.BroadcastEvent{
|
||||
SysOrigin: sysOrigin,
|
||||
Type: highwin.MessageTypeBaishunWin,
|
||||
UserID: userID,
|
||||
RoomID: roomID,
|
||||
Multiple: multiple,
|
||||
Amount: reward,
|
||||
Data: data,
|
||||
}); err != nil {
|
||||
log.Printf("send baishun game high win region broadcast failed roundId=%s userId=%d: %v", gameRoundID, userID, err)
|
||||
}
|
||||
_ = s.repo.Redis.Del(ctx, baishunGameBetKey(sysOrigin, gameRoundID, userID)).Err()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BaishunService) loadBaishunGameBet(ctx context.Context, sysOrigin, gameRoundID string, userID int64) (baishunGameBetRecord, bool, error) {
|
||||
raw, err := s.repo.Redis.Get(ctx, baishunGameBetKey(sysOrigin, gameRoundID, userID)).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return baishunGameBetRecord{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return baishunGameBetRecord{}, false, err
|
||||
}
|
||||
var record baishunGameBetRecord
|
||||
if err := json.Unmarshal([]byte(raw), &record); err != nil {
|
||||
return baishunGameBetRecord{}, false, err
|
||||
}
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
func (s *BaishunService) resolveBaishunReportGameCover(ctx context.Context, sysOrigin, roomID string, gameID int64) string {
|
||||
var state model.BaishunRoomState
|
||||
if strings.TrimSpace(roomID) != "" {
|
||||
err := s.repo.DB.WithContext(ctx).
|
||||
Where("sys_origin = ? AND room_id = ?", normalizeAdminSysOrigin(sysOrigin), roomID).
|
||||
First(&state).Error
|
||||
if err == nil && strings.TrimSpace(state.CurrentGameCover) != "" {
|
||||
return strings.TrimSpace(state.CurrentGameCover)
|
||||
}
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
if gameID <= 0 {
|
||||
return ""
|
||||
}
|
||||
profile, err := s.activeProfile(ctx, sysOrigin)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var catalog model.BaishunGameCatalog
|
||||
if err := s.repo.DB.WithContext(ctx).
|
||||
Where("sys_origin = ? AND profile = ? AND vendor_game_id = ?", normalizeAdminSysOrigin(sysOrigin), profile, gameID).
|
||||
First(&catalog).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(catalog.Cover)
|
||||
}
|
||||
|
||||
func reportGameRoundID(payload map[string]any) string {
|
||||
return reportText(payload, "game_round_id", "gameRoundId")
|
||||
}
|
||||
|
||||
func reportPlayers(payload map[string]any) []map[string]any {
|
||||
raw, exists := payload["players"]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
switch typed := raw.(type) {
|
||||
case []map[string]any:
|
||||
return typed
|
||||
case []any:
|
||||
players := make([]map[string]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if player, ok := item.(map[string]any); ok {
|
||||
players = append(players, player)
|
||||
}
|
||||
}
|
||||
return players
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func reportText(payload map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, exists := payload[key]; exists {
|
||||
text := strings.TrimSpace(toString(value))
|
||||
if text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func reportInt64(payload map[string]any, keys ...string) int64 {
|
||||
for _, key := range keys {
|
||||
value, exists := payload[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return int64(typed)
|
||||
case int64:
|
||||
return typed
|
||||
case float64:
|
||||
return int64(typed)
|
||||
case json.Number:
|
||||
parsed, _ := typed.Int64()
|
||||
return parsed
|
||||
case string:
|
||||
parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||||
return parsed
|
||||
default:
|
||||
parsed, _ := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(typed)), 10, 64)
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func baishunGameBetKey(sysOrigin, gameRoundID string, userID int64) string {
|
||||
return strings.Join([]string{
|
||||
baishunReportBetRedisPrefix,
|
||||
normalizeAdminSysOrigin(sysOrigin),
|
||||
strings.TrimSpace(gameRoundID),
|
||||
strconv.FormatInt(userID, 10),
|
||||
}, ":")
|
||||
}
|
||||
115
internal/service/baishun/report_test.go
Normal file
115
internal/service/baishun/report_test.go
Normal file
@ -0,0 +1,115 @@
|
||||
package baishun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/model"
|
||||
"chatapp3-golang/internal/service/regionimgroup"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func TestHandleReportPublishesRegionHighWinAfterStartAndSettle(t *testing.T) {
|
||||
service, db := newTestBaishunService(t)
|
||||
mr := miniredis.RunT(t)
|
||||
redisClient := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = redisClient.Close() })
|
||||
service.repo.Redis = redisClient
|
||||
broadcaster := &fakeBaishunRegionBroadcaster{}
|
||||
service.SetHighWinBroadcaster(broadcaster)
|
||||
|
||||
expireAt := time.Now().Add(time.Hour)
|
||||
token := "ss-token-1001"
|
||||
if err := db.Create(&model.BaishunLaunchSession{
|
||||
ID: 1,
|
||||
SysOrigin: "LIKEI",
|
||||
RoomID: "9001",
|
||||
UserID: 1001,
|
||||
InternalGameID: "bs_1146",
|
||||
VendorGameID: 1146,
|
||||
GameSessionID: "session-1",
|
||||
SSToken: &token,
|
||||
SSTokenExpireTime: &expireAt,
|
||||
Status: baishunSessionActive,
|
||||
CreateTime: time.Now(),
|
||||
UpdateTime: time.Now(),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed session: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.BaishunRoomState{
|
||||
ID: 2,
|
||||
SysOrigin: "LIKEI",
|
||||
RoomID: "9001",
|
||||
HostUserID: 1001,
|
||||
CurrentGameID: "bs_1146",
|
||||
CurrentVendorGameID: intPtr(1146),
|
||||
CurrentGameName: "LordOfOlympus",
|
||||
CurrentGameCover: "https://cdn.example.com/game.png",
|
||||
GameSessionID: "session-1",
|
||||
State: baishunRoomStatePlaying,
|
||||
CreateTime: time.Now(),
|
||||
UpdateTime: time.Now(),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed room state: %v", err)
|
||||
}
|
||||
|
||||
startResp := service.HandleReport(context.Background(), BaishunReportRequest{
|
||||
ReportType: baishunReportTypeStart,
|
||||
UserID: "1001",
|
||||
SSToken: token,
|
||||
ReportMsg: map[string]any{
|
||||
"game_id": 1146,
|
||||
"room_id": "9001",
|
||||
"game_round_id": "round-1",
|
||||
"players": []any{
|
||||
map[string]any{"user_id": "1001", "is_ai": 0, "bet": 100},
|
||||
},
|
||||
},
|
||||
}, `{"report_type":"game_start"}`)
|
||||
if startResp.Code != 0 {
|
||||
t.Fatalf("start response = %+v", startResp)
|
||||
}
|
||||
|
||||
settleResp := service.HandleReport(context.Background(), BaishunReportRequest{
|
||||
ReportType: baishunReportTypeSettle,
|
||||
UserID: "1001",
|
||||
SSToken: token,
|
||||
ReportMsg: map[string]any{
|
||||
"game_id": 1146,
|
||||
"room_id": "9001",
|
||||
"game_round_id": "round-1",
|
||||
"players": []any{
|
||||
map[string]any{"user_id": "1001", "is_ai": 0, "reward": 1000, "rank": 1, "score": 88},
|
||||
},
|
||||
},
|
||||
}, `{"report_type":"game_settle"}`)
|
||||
if settleResp.Code != 0 {
|
||||
t.Fatalf("settle response = %+v", settleResp)
|
||||
}
|
||||
|
||||
if len(broadcaster.requests) != 1 {
|
||||
t.Fatalf("requests = %d, want 1", len(broadcaster.requests))
|
||||
}
|
||||
req := broadcaster.requests[0]
|
||||
if req.Type != "GAME_BAISHUN_WIN" {
|
||||
t.Fatalf("type = %q, want GAME_BAISHUN_WIN", req.Type)
|
||||
}
|
||||
if req.Data["userId"] != "1001" || req.Data["roomId"] != "9001" ||
|
||||
req.Data["multiple"] != int64(10) || req.Data["winAmount"] != int64(1000) ||
|
||||
req.Data["currencyDiff"] != int64(1000) ||
|
||||
req.Data["gameUrl"] != "https://cdn.example.com/game.png" {
|
||||
t.Fatalf("payload = %+v", req.Data)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeBaishunRegionBroadcaster struct {
|
||||
requests []regionimgroup.RegionBroadcastRequest
|
||||
}
|
||||
|
||||
func (f *fakeBaishunRegionBroadcaster) SendRegionBroadcast(_ context.Context, req regionimgroup.RegionBroadcastRequest) (*regionimgroup.RegionBroadcastResponse, error) {
|
||||
f.requests = append(f.requests, req)
|
||||
return ®ionimgroup.RegionBroadcastResponse{Type: req.Type}, nil
|
||||
}
|
||||
@ -13,7 +13,7 @@ import (
|
||||
|
||||
// ListShortcutGames 返回房间里的快捷游戏列表,数量上限为 5 个。
|
||||
func (s *BaishunService) ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) {
|
||||
items, err := s.listRoomGames(ctx, user.SysOrigin, roomID, "")
|
||||
items, err := s.listRoomGames(ctx, user.SysOrigin, user.RegionID, roomID, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -25,7 +25,7 @@ func (s *BaishunService) ListShortcutGames(ctx context.Context, user AuthUser, r
|
||||
|
||||
// ListRoomGames 返回房间可启动的完整游戏列表。
|
||||
func (s *BaishunService) ListRoomGames(ctx context.Context, user AuthUser, roomID string, category string) (*RoomGameListResponse, error) {
|
||||
items, err := s.listRoomGames(ctx, user.SysOrigin, roomID, category)
|
||||
items, err := s.listRoomGames(ctx, user.SysOrigin, user.RegionID, roomID, category)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -59,7 +59,7 @@ func (s *BaishunService) GetRoomState(ctx context.Context, user AuthUser, roomID
|
||||
}
|
||||
|
||||
// listRoomGames 联表读取房间可启动游戏,并按展示规则排序。
|
||||
func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, roomID, category string) ([]RoomGameListItem, error) {
|
||||
func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, regionID, roomID, category string) ([]RoomGameListItem, error) {
|
||||
sysOrigin = normalizeAdminSysOrigin(sysOrigin)
|
||||
profile, err := s.activeProfile(ctx, sysOrigin)
|
||||
if err != nil {
|
||||
@ -102,6 +102,10 @@ func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, roomID, c
|
||||
Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1 AND ext.profile = ?", profile).
|
||||
Joins("LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND cat.profile = ext.profile AND CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id").
|
||||
Where("cfg.sys_origin = ? AND cfg.is_showcase = 1 AND ext.vendor_type = ?", sysOrigin, baishunVendorType)
|
||||
if strings.TrimSpace(regionID) != "" {
|
||||
// regions 为空表示全区域;有值时按用户区域 ID 精确匹配,避免土耳其用户看到其他区域游戏。
|
||||
query = query.Where("(COALESCE(TRIM(cfg.regions), '') = '' OR FIND_IN_SET(?, REPLACE(COALESCE(cfg.regions, ''), ' ', '')) > 0)", strings.TrimSpace(regionID))
|
||||
}
|
||||
if strings.TrimSpace(category) != "" && !strings.EqualFold(strings.TrimSpace(category), "CHAT_ROOM") {
|
||||
query = query.Where("cfg.category = ?", category)
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package baishun
|
||||
import (
|
||||
"chatapp3-golang/internal/config"
|
||||
"chatapp3-golang/internal/integration"
|
||||
"chatapp3-golang/internal/service/highwin"
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
@ -61,11 +62,12 @@ type baishunPorts struct {
|
||||
|
||||
// BaishunService 负责百顺目录同步、游戏启动、房间状态以及回调处理。
|
||||
type BaishunService struct {
|
||||
cfg config.Config
|
||||
repo baishunPorts
|
||||
java baishunGateway
|
||||
httpClient *http.Client
|
||||
taskReporter taskEventReporter
|
||||
cfg config.Config
|
||||
repo baishunPorts
|
||||
java baishunGateway
|
||||
httpClient *http.Client
|
||||
taskReporter taskEventReporter
|
||||
highWinBroadcaster highwin.RegionBroadcaster
|
||||
}
|
||||
|
||||
// NewBaishunService 创建百顺接入服务实例。
|
||||
@ -84,6 +86,10 @@ func (s *BaishunService) SetTaskEventReporter(reporter taskEventReporter) {
|
||||
s.taskReporter = reporter
|
||||
}
|
||||
|
||||
func (s *BaishunService) SetHighWinBroadcaster(broadcaster highwin.RegionBroadcaster) {
|
||||
s.highWinBroadcaster = broadcaster
|
||||
}
|
||||
|
||||
// RoomGameListItem 是房间游戏列表中的单个游戏项。
|
||||
type RoomGameListItem struct {
|
||||
ID int64 `json:"id"`
|
||||
@ -418,6 +424,13 @@ type BaishunBalanceInfoRequest struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
type BaishunReportRequest struct {
|
||||
ReportType string `json:"report_type"`
|
||||
ReportMsg map[string]any `json:"report_msg"`
|
||||
UserID string `json:"user_id"`
|
||||
SSToken string `json:"ss_token"`
|
||||
}
|
||||
|
||||
// baishunStandardResponse 是百顺标准回包结构。
|
||||
type baishunStandardResponse struct {
|
||||
Code int `json:"code"`
|
||||
|
||||
327
internal/service/cprelationbroadcast/event.go
Normal file
327
internal/service/cprelationbroadcast/event.go
Normal file
@ -0,0 +1,327 @@
|
||||
package cprelationbroadcast
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"chatapp3-golang/internal/common"
|
||||
"chatapp3-golang/internal/model"
|
||||
"chatapp3-golang/internal/service/regionimgroup"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ProcessPayload 解码 Java MQ 消息并发送区域飘屏。
|
||||
func (s *Service) ProcessPayload(ctx context.Context, payload string) (*regionimgroup.RegionBroadcastResponse, error) {
|
||||
req, err := decodeEventPayload(payload, s.cfg.CPRelationBroadcast.MQ.Tag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.isEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
return s.ProcessEvent(ctx, req)
|
||||
}
|
||||
|
||||
// ProcessEvent 处理标准化后的 CP 关系事件。
|
||||
func (s *Service) ProcessEvent(ctx context.Context, req EventRequest) (*regionimgroup.RegionBroadcastResponse, error) {
|
||||
if s == nil || s.broadcaster == nil {
|
||||
return nil, common.NewAppError(http.StatusInternalServerError, "cp_relation_broadcaster_missing", "cp relation broadcaster is missing")
|
||||
}
|
||||
if s.db == nil {
|
||||
return nil, common.NewAppError(http.StatusInternalServerError, "cp_relation_db_missing", "cp relation db is missing")
|
||||
}
|
||||
req = normalizeEventRequest(req)
|
||||
|
||||
userID := req.UserID.Int64()
|
||||
cpUserID := req.CpUserID.Int64()
|
||||
if userID <= 0 || cpUserID <= 0 {
|
||||
return nil, common.NewAppError(http.StatusBadRequest, "invalid_cp_relation_users", "userId and cpUserId are required")
|
||||
}
|
||||
relationType, err := normalizeRelationType(req.RelationType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sysOrigin := normalizeSysOrigin(req.SysOrigin, s.cfg.CPRelationBroadcast.DefaultSysOrigin)
|
||||
|
||||
user, err := s.loadEventUser(ctx, userID, req.User)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cpUser, err := s.loadEventUser(ctx, cpUserID, req.CpUser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
msg := relationMessage(relationType, user.displayName(), cpUser.displayName())
|
||||
data := buildBroadcastData(req, sysOrigin, relationType, user, cpUser, msg)
|
||||
return s.broadcaster.SendRegionBroadcast(ctx, regionimgroup.RegionBroadcastRequest{
|
||||
SysOrigin: sysOrigin,
|
||||
Type: MessageTypeCPRelationBroadcast,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) loadEventUser(ctx context.Context, userID int64, provided *EventUser) (EventUser, error) {
|
||||
var user model.UserBaseInfo
|
||||
err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
account := strconv.FormatInt(userID, 10)
|
||||
user = model.UserBaseInfo{
|
||||
ID: userID,
|
||||
Account: account,
|
||||
UserNickname: account,
|
||||
}
|
||||
} else if err != nil {
|
||||
return EventUser{}, err
|
||||
}
|
||||
if strings.TrimSpace(user.Account) == "" {
|
||||
user.Account = strconv.FormatInt(userID, 10)
|
||||
}
|
||||
if strings.TrimSpace(user.UserNickname) == "" {
|
||||
user.UserNickname = user.Account
|
||||
}
|
||||
return mergeEventUser(userID, user, provided), nil
|
||||
}
|
||||
|
||||
func buildBroadcastData(req EventRequest, sysOrigin, relationType string, user EventUser, cpUser EventUser, msg string) map[string]any {
|
||||
userID := user.normalizedID()
|
||||
cpUserID := cpUser.normalizedID()
|
||||
userIDText := strconv.FormatInt(userID, 10)
|
||||
cpUserIDText := strconv.FormatInt(cpUserID, 10)
|
||||
|
||||
data := map[string]any{
|
||||
"sysOrigin": sysOrigin,
|
||||
"type": MessageTypeCPRelationBroadcast,
|
||||
"relationType": relationType,
|
||||
"relationLabel": relationLabel(relationType),
|
||||
"msg": msg,
|
||||
"userId": userIDText,
|
||||
"sendUserId": userIDText,
|
||||
"senderUserId": userIDText,
|
||||
"cpUserId": cpUserIDText,
|
||||
"acceptUserId": cpUserIDText,
|
||||
"toUserId": cpUserIDText,
|
||||
"user": user.toMap(),
|
||||
"cpUser": cpUser.toMap(),
|
||||
|
||||
"account": user.Account,
|
||||
"actualAccount": user.Account,
|
||||
"userNickname": user.displayName(),
|
||||
"nickname": user.displayName(),
|
||||
"userAvatar": firstNonBlank(user.UserAvatar, user.Avatar),
|
||||
"countryCode": user.CountryCode,
|
||||
"countryName": user.CountryName,
|
||||
"cpUserAccount": cpUser.Account,
|
||||
"cpUserNickname": cpUser.displayName(),
|
||||
"cpUserAvatar": firstNonBlank(cpUser.UserAvatar, cpUser.Avatar),
|
||||
"cpUserCountryCode": cpUser.CountryCode,
|
||||
"cpUserCountryName": cpUser.CountryName,
|
||||
"acceptAccount": cpUser.Account,
|
||||
"acceptNickname": cpUser.displayName(),
|
||||
"acceptUserAvatar": firstNonBlank(cpUser.UserAvatar, cpUser.Avatar),
|
||||
"toUserName": cpUser.displayName(),
|
||||
"toUserAvatarUrl": firstNonBlank(cpUser.UserAvatar, cpUser.Avatar),
|
||||
}
|
||||
if eventID := strings.TrimSpace(req.EventID); eventID != "" {
|
||||
data["eventId"] = eventID
|
||||
}
|
||||
if applyID := req.ApplyID.Int64(); applyID > 0 {
|
||||
data["applyId"] = strconv.FormatInt(applyID, 10)
|
||||
}
|
||||
if occurredAt := strings.TrimSpace(req.OccurredAt); occurredAt != "" {
|
||||
data["occurredAt"] = occurredAt
|
||||
}
|
||||
if len(req.Payload) > 0 {
|
||||
data["payload"] = req.Payload
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func normalizeEventRequest(req EventRequest) EventRequest {
|
||||
if req.UserID.Int64() <= 0 {
|
||||
req.UserID = req.SendUserID
|
||||
}
|
||||
if req.CpUserID.Int64() <= 0 {
|
||||
req.CpUserID = req.AcceptUserID
|
||||
}
|
||||
if req.UserID.Int64() <= 0 && req.User != nil {
|
||||
req.UserID = flexibleInt64(req.User.normalizedID())
|
||||
}
|
||||
if req.CpUserID.Int64() <= 0 && req.CpUser != nil {
|
||||
req.CpUserID = flexibleInt64(req.CpUser.normalizedID())
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func (req EventRequest) isEmpty() bool {
|
||||
return strings.TrimSpace(req.EventID) == "" &&
|
||||
strings.TrimSpace(req.RelationType) == "" &&
|
||||
req.UserID.Int64() <= 0 &&
|
||||
req.CpUserID.Int64() <= 0 &&
|
||||
req.SendUserID.Int64() <= 0 &&
|
||||
req.AcceptUserID.Int64() <= 0
|
||||
}
|
||||
|
||||
func normalizeRelationType(value string) (string, error) {
|
||||
normalized := strings.ToUpper(strings.TrimSpace(value))
|
||||
if normalized == "" {
|
||||
return RelationTypeCP, nil
|
||||
}
|
||||
switch normalized {
|
||||
case RelationTypeCP:
|
||||
return RelationTypeCP, nil
|
||||
case RelationTypeBrother, "BROTHERS":
|
||||
return RelationTypeBrother, nil
|
||||
case RelationTypeSisters, "SISTER":
|
||||
return RelationTypeSisters, nil
|
||||
default:
|
||||
return "", common.NewAppError(http.StatusBadRequest, "invalid_cp_relation_type", fmt.Sprintf("invalid cp relationType: %s", value))
|
||||
}
|
||||
}
|
||||
|
||||
func relationLabel(relationType string) string {
|
||||
switch relationType {
|
||||
case RelationTypeBrother:
|
||||
return "brothers"
|
||||
case RelationTypeSisters:
|
||||
return "sisters"
|
||||
default:
|
||||
return "cp_relation"
|
||||
}
|
||||
}
|
||||
|
||||
func relationMessage(relationType, userName, cpUserName string) string {
|
||||
switch relationType {
|
||||
case RelationTypeBrother:
|
||||
return fmt.Sprintf("%s与%s结为兄弟", userName, cpUserName)
|
||||
case RelationTypeSisters:
|
||||
return fmt.Sprintf("%s与%s结为姐妹", userName, cpUserName)
|
||||
default:
|
||||
return fmt.Sprintf("%s与%s get cp relation", userName, cpUserName)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSysOrigin(values ...string) string {
|
||||
for _, value := range values {
|
||||
if text := strings.ToUpper(strings.TrimSpace(value)); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return defaultSysOrigin
|
||||
}
|
||||
|
||||
func decodeEventPayload(payload string, expectedTag string) (EventRequest, error) {
|
||||
payload = strings.TrimSpace(payload)
|
||||
if payload == "" {
|
||||
return EventRequest{}, nil
|
||||
}
|
||||
req, err := decodeEventObject([]byte(payload))
|
||||
if err != nil {
|
||||
return EventRequest{}, err
|
||||
}
|
||||
if !req.isEmpty() {
|
||||
return req, nil
|
||||
}
|
||||
|
||||
var envelope messageEventEnvelope
|
||||
if err := json.Unmarshal([]byte(payload), &envelope); err != nil {
|
||||
return EventRequest{}, err
|
||||
}
|
||||
if shouldSkipEnvelope(envelope.Tag, expectedTag) {
|
||||
return EventRequest{}, nil
|
||||
}
|
||||
bodyPayload, ok, err := envelope.bodyPayload()
|
||||
if err != nil || !ok {
|
||||
return EventRequest{}, err
|
||||
}
|
||||
return decodeEventObject([]byte(bodyPayload))
|
||||
}
|
||||
|
||||
type rawEventRequest struct {
|
||||
EventRequest
|
||||
SysOriginSnake string `json:"sys_origin"`
|
||||
EventIDSnake string `json:"event_id"`
|
||||
ApplyIDSnake flexibleInt64 `json:"apply_id"`
|
||||
RelationTypeSnake string `json:"relation_type"`
|
||||
UserIDSnake flexibleInt64 `json:"user_id"`
|
||||
CpUserIDSnake flexibleInt64 `json:"cp_user_id"`
|
||||
SendUserIDSnake flexibleInt64 `json:"send_user_id"`
|
||||
AcceptUserIDSnake flexibleInt64 `json:"accept_user_id"`
|
||||
OccurredAtSnake string `json:"occurred_at"`
|
||||
TargetUserID flexibleInt64 `json:"targetUserId"`
|
||||
TargetUserIDSnake flexibleInt64 `json:"target_user_id"`
|
||||
ToUserIDSnake flexibleInt64 `json:"to_user_id"`
|
||||
}
|
||||
|
||||
func decodeEventObject(data []byte) (EventRequest, error) {
|
||||
var raw rawEventRequest
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return EventRequest{}, err
|
||||
}
|
||||
req := raw.EventRequest
|
||||
req.SysOrigin = firstNonBlank(req.SysOrigin, raw.SysOriginSnake)
|
||||
req.EventID = firstNonBlank(req.EventID, raw.EventIDSnake)
|
||||
req.RelationType = firstNonBlank(req.RelationType, raw.RelationTypeSnake)
|
||||
req.OccurredAt = firstNonBlank(req.OccurredAt, raw.OccurredAtSnake)
|
||||
if req.ApplyID.Int64() <= 0 {
|
||||
req.ApplyID = raw.ApplyIDSnake
|
||||
}
|
||||
if req.UserID.Int64() <= 0 {
|
||||
req.UserID = firstFlexible(raw.UserIDSnake, req.SendUserID, raw.SendUserIDSnake)
|
||||
}
|
||||
if req.CpUserID.Int64() <= 0 {
|
||||
req.CpUserID = firstFlexible(raw.CpUserIDSnake, req.AcceptUserID, raw.AcceptUserIDSnake, raw.TargetUserID, raw.TargetUserIDSnake, raw.ToUserIDSnake)
|
||||
}
|
||||
if req.SendUserID.Int64() <= 0 {
|
||||
req.SendUserID = firstFlexible(req.UserID, raw.SendUserIDSnake)
|
||||
}
|
||||
if req.AcceptUserID.Int64() <= 0 {
|
||||
req.AcceptUserID = firstFlexible(req.CpUserID, raw.AcceptUserIDSnake, raw.TargetUserID, raw.TargetUserIDSnake, raw.ToUserIDSnake)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func firstFlexible(values ...flexibleInt64) flexibleInt64 {
|
||||
for _, value := range values {
|
||||
if value.Int64() > 0 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type messageEventEnvelope struct {
|
||||
Tag string `json:"tag"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
}
|
||||
|
||||
func (e messageEventEnvelope) bodyPayload() (string, bool, error) {
|
||||
raw := strings.TrimSpace(string(e.Body))
|
||||
if raw == "" || raw == "null" {
|
||||
return "", false, nil
|
||||
}
|
||||
if strings.HasPrefix(raw, "\"") {
|
||||
var body string
|
||||
if err := json.Unmarshal(e.Body, &body); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
body = strings.TrimSpace(body)
|
||||
return body, body != "", nil
|
||||
}
|
||||
return raw, true, nil
|
||||
}
|
||||
|
||||
func shouldSkipEnvelope(actualTag, expectedTag string) bool {
|
||||
actualTag = strings.TrimSpace(actualTag)
|
||||
expectedTag = strings.TrimSpace(expectedTag)
|
||||
if actualTag == "" || expectedTag == "" || expectedTag == "*" {
|
||||
return false
|
||||
}
|
||||
return actualTag != expectedTag
|
||||
}
|
||||
142
internal/service/cprelationbroadcast/event_test.go
Normal file
142
internal/service/cprelationbroadcast/event_test.go
Normal file
@ -0,0 +1,142 @@
|
||||
package cprelationbroadcast
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/config"
|
||||
"chatapp3-golang/internal/model"
|
||||
"chatapp3-golang/internal/service/regionimgroup"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestProcessEventBuildsCPRelationBroadcastPayload(t *testing.T) {
|
||||
service, broadcaster := newCPRelationBroadcastTestService(t)
|
||||
|
||||
_, err := service.ProcessEvent(context.Background(), EventRequest{
|
||||
SysOrigin: "LIKEI",
|
||||
EventID: "CP_RELATION:9001",
|
||||
ApplyID: flexibleInt64(9001),
|
||||
RelationType: RelationTypeCP,
|
||||
UserID: flexibleInt64(1001),
|
||||
CpUserID: flexibleInt64(1002),
|
||||
OccurredAt: "2026-05-22T12:00:00Z",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessEvent() error = %v", err)
|
||||
}
|
||||
if len(broadcaster.requests) != 1 {
|
||||
t.Fatalf("requests = %d, want 1", len(broadcaster.requests))
|
||||
}
|
||||
req := broadcaster.requests[0]
|
||||
if req.Type != MessageTypeCPRelationBroadcast {
|
||||
t.Fatalf("type = %q", req.Type)
|
||||
}
|
||||
data := req.Data
|
||||
if data["msg"] != "Alice与Bob get cp relation" ||
|
||||
data["relationType"] != RelationTypeCP ||
|
||||
data["userId"] != "1001" ||
|
||||
data["cpUserId"] != "1002" {
|
||||
t.Fatalf("payload = %+v", data)
|
||||
}
|
||||
user, ok := data["user"].(map[string]any)
|
||||
if !ok || user["userNickname"] != "Alice" || user["account"] != "alice" {
|
||||
t.Fatalf("user payload = %+v", data["user"])
|
||||
}
|
||||
cpUser, ok := data["cpUser"].(map[string]any)
|
||||
if !ok || cpUser["userNickname"] != "Bob" || cpUser["account"] != "bob" {
|
||||
t.Fatalf("cpUser payload = %+v", data["cpUser"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessEventBuildsBrotherAndSistersMessages(t *testing.T) {
|
||||
service, broadcaster := newCPRelationBroadcastTestService(t)
|
||||
cases := []struct {
|
||||
relationType string
|
||||
wantMsg string
|
||||
}{
|
||||
{RelationTypeBrother, "Alice与Bob结为兄弟"},
|
||||
{RelationTypeSisters, "Alice与Bob结为姐妹"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
broadcaster.requests = nil
|
||||
_, err := service.ProcessEvent(context.Background(), EventRequest{
|
||||
RelationType: tc.relationType,
|
||||
UserID: flexibleInt64(1001),
|
||||
CpUserID: flexibleInt64(1002),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessEvent(%s) error = %v", tc.relationType, err)
|
||||
}
|
||||
if len(broadcaster.requests) != 1 {
|
||||
t.Fatalf("requests = %d, want 1", len(broadcaster.requests))
|
||||
}
|
||||
if got := broadcaster.requests[0].Data["msg"]; got != tc.wantMsg {
|
||||
t.Fatalf("msg = %v, want %s", got, tc.wantMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessPayloadDecodesMessageEventEnvelopeAndAliases(t *testing.T) {
|
||||
service, broadcaster := newCPRelationBroadcastTestService(t)
|
||||
service.cfg.CPRelationBroadcast.MQ.Tag = "cp_relation_broadcast"
|
||||
payload := `{"tag":"cp_relation_broadcast","body":"{\"sys_origin\":\"likei\",\"event_id\":\"CP_RELATION:9002\",\"apply_id\":\"9002\",\"relation_type\":\"BROTHERS\",\"send_user_id\":\"1001\",\"accept_user_id\":\"1002\"}"}`
|
||||
|
||||
_, err := service.ProcessPayload(context.Background(), payload)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessPayload() error = %v", err)
|
||||
}
|
||||
if len(broadcaster.requests) != 1 {
|
||||
t.Fatalf("requests = %d, want 1", len(broadcaster.requests))
|
||||
}
|
||||
req := broadcaster.requests[0]
|
||||
if req.SysOrigin != "LIKEI" {
|
||||
t.Fatalf("sysOrigin = %q", req.SysOrigin)
|
||||
}
|
||||
if req.Data["eventId"] != "CP_RELATION:9002" ||
|
||||
req.Data["applyId"] != "9002" ||
|
||||
req.Data["relationType"] != RelationTypeBrother ||
|
||||
req.Data["msg"] != "Alice与Bob结为兄弟" {
|
||||
t.Fatalf("payload = %+v", req.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func newCPRelationBroadcastTestService(t *testing.T) (*Service, *fakeCPRegionBroadcaster) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.UserBaseInfo{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
now := time.Now()
|
||||
if err := db.Create(&[]model.UserBaseInfo{
|
||||
{ID: 1001, Account: "alice", UserNickname: "Alice", UserAvatar: "alice.png", OriginSys: "LIKEI", CountryCode: "SA", CountryName: "Saudi Arabia", CreateTime: now},
|
||||
{ID: 1002, Account: "bob", UserNickname: "Bob", UserAvatar: "bob.png", OriginSys: "LIKEI", CountryCode: "SA", CountryName: "Saudi Arabia", CreateTime: now},
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed users: %v", err)
|
||||
}
|
||||
broadcaster := &fakeCPRegionBroadcaster{}
|
||||
return NewService(config.Config{
|
||||
CPRelationBroadcast: config.CPRelationBroadcastConfig{DefaultSysOrigin: "LIKEI"},
|
||||
}, db, broadcaster), broadcaster
|
||||
}
|
||||
|
||||
type fakeCPRegionBroadcaster struct {
|
||||
requests []regionimgroup.RegionBroadcastRequest
|
||||
}
|
||||
|
||||
func (f *fakeCPRegionBroadcaster) SendRegionBroadcast(_ context.Context, req regionimgroup.RegionBroadcastRequest) (*regionimgroup.RegionBroadcastResponse, error) {
|
||||
f.requests = append(f.requests, req)
|
||||
return ®ionimgroup.RegionBroadcastResponse{
|
||||
SysOrigin: req.SysOrigin,
|
||||
RegionCode: "AR",
|
||||
GroupID: "@region-ar",
|
||||
Type: req.Type,
|
||||
}, nil
|
||||
}
|
||||
139
internal/service/cprelationbroadcast/lifecycle.go
Normal file
139
internal/service/cprelationbroadcast/lifecycle.go
Normal file
@ -0,0 +1,139 @@
|
||||
package cprelationbroadcast
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/common"
|
||||
|
||||
rmq "github.com/apache/rocketmq-clients/golang/v5"
|
||||
"github.com/apache/rocketmq-clients/golang/v5/credentials"
|
||||
)
|
||||
|
||||
// StartMessageConsumer 启动 CP 关系成功 MQ 消费链路。
|
||||
func (s *Service) StartMessageConsumer(ctx context.Context) error {
|
||||
if !s.cfg.CPRelationBroadcast.MQ.Enabled {
|
||||
log.Printf("cp relation broadcast rocketmq consumer disabled")
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(s.cfg.CPRelationBroadcast.MQ.Endpoint) == "" ||
|
||||
strings.TrimSpace(s.cfg.CPRelationBroadcast.MQ.AccessKey) == "" ||
|
||||
strings.TrimSpace(s.cfg.CPRelationBroadcast.MQ.AccessSecret) == "" ||
|
||||
strings.TrimSpace(s.cfg.CPRelationBroadcast.MQ.Topic) == "" ||
|
||||
strings.TrimSpace(s.cfg.CPRelationBroadcast.MQ.ConsumerGroup) == "" {
|
||||
log.Printf("cp relation broadcast rocketmq consumer skipped: endpoint, credentials, topic or consumer group missing")
|
||||
return nil
|
||||
}
|
||||
|
||||
go s.startRocketMQConsumerWithRetry(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) startRocketMQConsumerWithRetry(ctx context.Context) {
|
||||
retryDelay := 5 * time.Second
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := s.startRocketMQConsumer(ctx); err != nil {
|
||||
log.Printf("start cp relation broadcast rocketmq consumer failed: %v; retry in %s", err, retryDelay)
|
||||
timer := time.NewTimer(retryDelay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
if retryDelay < time.Minute {
|
||||
retryDelay *= 2
|
||||
if retryDelay > time.Minute {
|
||||
retryDelay = time.Minute
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) startRocketMQConsumer(ctx context.Context) error {
|
||||
filter := rmq.SUB_ALL
|
||||
if tag := strings.TrimSpace(s.cfg.CPRelationBroadcast.MQ.Tag); tag != "" && tag != "*" {
|
||||
filter = rmq.NewFilterExpression(tag)
|
||||
}
|
||||
|
||||
consumer, err := rmq.NewPushConsumer(&rmq.Config{
|
||||
Endpoint: s.cfg.CPRelationBroadcast.MQ.Endpoint,
|
||||
NameSpace: s.cfg.CPRelationBroadcast.MQ.Namespace,
|
||||
ConsumerGroup: s.cfg.CPRelationBroadcast.MQ.ConsumerGroup,
|
||||
Credentials: &credentials.SessionCredentials{
|
||||
AccessKey: s.cfg.CPRelationBroadcast.MQ.AccessKey,
|
||||
AccessSecret: s.cfg.CPRelationBroadcast.MQ.AccessSecret,
|
||||
SecurityToken: s.cfg.CPRelationBroadcast.MQ.SecurityToken,
|
||||
},
|
||||
},
|
||||
rmq.WithPushSubscriptionExpressions(map[string]*rmq.FilterExpression{
|
||||
s.cfg.CPRelationBroadcast.MQ.Topic: filter,
|
||||
}),
|
||||
rmq.WithPushConsumptionThreadCount(4),
|
||||
rmq.WithPushMaxCacheMessageCount(256),
|
||||
rmq.WithPushMessageListener(&rmq.FuncMessageListener{
|
||||
Consume: func(messageView *rmq.MessageView) rmq.ConsumerResult {
|
||||
if messageView == nil {
|
||||
return rmq.SUCCESS
|
||||
}
|
||||
if err := s.processRocketMQMessage(context.Background(), messageView); err != nil {
|
||||
log.Printf("cp relation broadcast mq process failed. messageId=%s err=%v", messageView.GetMessageId(), err)
|
||||
return rmq.FAILURE
|
||||
}
|
||||
return rmq.SUCCESS
|
||||
},
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := consumer.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.rocketConsumer = consumer
|
||||
log.Printf("cp relation broadcast rocketmq consumer started. topic=%s group=%s tag=%s",
|
||||
s.cfg.CPRelationBroadcast.MQ.Topic,
|
||||
s.cfg.CPRelationBroadcast.MQ.ConsumerGroup,
|
||||
s.cfg.CPRelationBroadcast.MQ.Tag,
|
||||
)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
if s.rocketConsumer != nil {
|
||||
_ = s.rocketConsumer.GracefulStop()
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) processRocketMQMessage(ctx context.Context, messageView *rmq.MessageView) error {
|
||||
payload := strings.TrimSpace(string(messageView.GetBody()))
|
||||
if payload == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.ProcessPayload(ctx, payload); err != nil {
|
||||
var syntaxErr *json.SyntaxError
|
||||
var typeErr *json.UnmarshalTypeError
|
||||
if errors.As(err, &syntaxErr) || errors.As(err, &typeErr) {
|
||||
log.Printf("drop malformed cp relation broadcast message. messageId=%s err=%v", messageView.GetMessageId(), err)
|
||||
return nil
|
||||
}
|
||||
var appErr *common.AppError
|
||||
if errors.As(err, &appErr) && appErr.Status >= http.StatusBadRequest && appErr.Status < http.StatusInternalServerError {
|
||||
log.Printf("drop invalid cp relation broadcast message. messageId=%s code=%s err=%v", messageView.GetMessageId(), appErr.Code, err)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
199
internal/service/cprelationbroadcast/types.go
Normal file
199
internal/service/cprelationbroadcast/types.go
Normal file
@ -0,0 +1,199 @@
|
||||
package cprelationbroadcast
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"chatapp3-golang/internal/config"
|
||||
"chatapp3-golang/internal/model"
|
||||
"chatapp3-golang/internal/service/regionimgroup"
|
||||
|
||||
rmq "github.com/apache/rocketmq-clients/golang/v5"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
MessageTypeCPRelationBroadcast = "CP_RELATION_BROADCAST"
|
||||
|
||||
RelationTypeCP = "CP"
|
||||
RelationTypeBrother = "BROTHER"
|
||||
RelationTypeSisters = "SISTERS"
|
||||
|
||||
defaultSysOrigin = "LIKEI"
|
||||
)
|
||||
|
||||
type dbHandle interface {
|
||||
WithContext(ctx context.Context) *gorm.DB
|
||||
}
|
||||
|
||||
type regionBroadcaster interface {
|
||||
SendRegionBroadcast(ctx context.Context, req regionimgroup.RegionBroadcastRequest) (*regionimgroup.RegionBroadcastResponse, error)
|
||||
}
|
||||
|
||||
// Service 消费 Java CP 关系成功 MQ,并复用区域 IM 广播发送飘屏。
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
db dbHandle
|
||||
broadcaster regionBroadcaster
|
||||
rocketConsumer rmq.PushConsumer
|
||||
}
|
||||
|
||||
func NewService(cfg config.Config, db dbHandle, broadcaster regionBroadcaster) *Service {
|
||||
return &Service{cfg: cfg, db: db, broadcaster: broadcaster}
|
||||
}
|
||||
|
||||
// EventRequest 是 Java 在 CP/兄弟/姐妹关系达成后投递给 Go 的 MQ 事件。
|
||||
type EventRequest struct {
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
EventID string `json:"eventId"`
|
||||
ApplyID flexibleInt64 `json:"applyId"`
|
||||
RelationType string `json:"relationType"`
|
||||
UserID flexibleInt64 `json:"userId"`
|
||||
CpUserID flexibleInt64 `json:"cpUserId"`
|
||||
SendUserID flexibleInt64 `json:"sendUserId"`
|
||||
AcceptUserID flexibleInt64 `json:"acceptUserId"`
|
||||
OccurredAt string `json:"occurredAt"`
|
||||
User *EventUser `json:"user"`
|
||||
CpUser *EventUser `json:"cpUser"`
|
||||
Payload map[string]any `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type EventUser struct {
|
||||
ID flexibleInt64 `json:"id"`
|
||||
UserID flexibleInt64 `json:"userId"`
|
||||
Account string `json:"account"`
|
||||
Nickname string `json:"nickname"`
|
||||
UserNickname string `json:"userNickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
UserAvatar string `json:"userAvatar"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
CountryName string `json:"countryName"`
|
||||
OriginSys string `json:"originSys"`
|
||||
}
|
||||
|
||||
func (u EventUser) normalizedID() int64 {
|
||||
if id := u.UserID.Int64(); id > 0 {
|
||||
return id
|
||||
}
|
||||
return u.ID.Int64()
|
||||
}
|
||||
|
||||
func (u EventUser) displayName() string {
|
||||
for _, value := range []string{u.UserNickname, u.Nickname, u.Account} {
|
||||
if text := strings.TrimSpace(value); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
if id := u.normalizedID(); id > 0 {
|
||||
return strconv.FormatInt(id, 10)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (u EventUser) toMap() map[string]any {
|
||||
id := u.normalizedID()
|
||||
data := map[string]any{}
|
||||
if id > 0 {
|
||||
data["userId"] = strconv.FormatInt(id, 10)
|
||||
data["id"] = strconv.FormatInt(id, 10)
|
||||
}
|
||||
if account := strings.TrimSpace(u.Account); account != "" {
|
||||
data["account"] = account
|
||||
}
|
||||
if nickname := strings.TrimSpace(firstNonBlank(u.UserNickname, u.Nickname)); nickname != "" {
|
||||
data["userNickname"] = nickname
|
||||
data["nickname"] = nickname
|
||||
}
|
||||
if avatar := strings.TrimSpace(firstNonBlank(u.UserAvatar, u.Avatar)); avatar != "" {
|
||||
data["userAvatar"] = avatar
|
||||
data["avatar"] = avatar
|
||||
}
|
||||
if countryCode := strings.TrimSpace(u.CountryCode); countryCode != "" {
|
||||
data["countryCode"] = countryCode
|
||||
}
|
||||
if countryName := strings.TrimSpace(u.CountryName); countryName != "" {
|
||||
data["countryName"] = countryName
|
||||
}
|
||||
if originSys := strings.TrimSpace(u.OriginSys); originSys != "" {
|
||||
data["originSys"] = originSys
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func eventUserFromModel(user model.UserBaseInfo) EventUser {
|
||||
return EventUser{
|
||||
ID: flexibleInt64(user.ID),
|
||||
UserID: flexibleInt64(user.ID),
|
||||
Account: user.Account,
|
||||
Nickname: user.UserNickname,
|
||||
UserNickname: user.UserNickname,
|
||||
Avatar: user.UserAvatar,
|
||||
UserAvatar: user.UserAvatar,
|
||||
CountryCode: user.CountryCode,
|
||||
CountryName: user.CountryName,
|
||||
OriginSys: user.OriginSys,
|
||||
}
|
||||
}
|
||||
|
||||
func mergeEventUser(id int64, dbUser model.UserBaseInfo, provided *EventUser) EventUser {
|
||||
merged := eventUserFromModel(dbUser)
|
||||
if id > 0 {
|
||||
merged.ID = flexibleInt64(id)
|
||||
merged.UserID = flexibleInt64(id)
|
||||
}
|
||||
if provided == nil {
|
||||
return merged
|
||||
}
|
||||
if provided.normalizedID() > 0 {
|
||||
merged.ID = flexibleInt64(provided.normalizedID())
|
||||
merged.UserID = flexibleInt64(provided.normalizedID())
|
||||
}
|
||||
merged.Account = firstNonBlank(provided.Account, merged.Account)
|
||||
merged.UserNickname = firstNonBlank(provided.UserNickname, provided.Nickname, merged.UserNickname)
|
||||
merged.Nickname = merged.UserNickname
|
||||
merged.UserAvatar = firstNonBlank(provided.UserAvatar, provided.Avatar, merged.UserAvatar)
|
||||
merged.Avatar = merged.UserAvatar
|
||||
merged.CountryCode = firstNonBlank(provided.CountryCode, merged.CountryCode)
|
||||
merged.CountryName = firstNonBlank(provided.CountryName, merged.CountryName)
|
||||
merged.OriginSys = firstNonBlank(provided.OriginSys, merged.OriginSys)
|
||||
return merged
|
||||
}
|
||||
|
||||
type flexibleInt64 int64
|
||||
|
||||
func (v *flexibleInt64) UnmarshalJSON(data []byte) error {
|
||||
raw := strings.TrimSpace(string(data))
|
||||
switch raw {
|
||||
case "", "null", `""`:
|
||||
*v = 0
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) {
|
||||
unquoted, err := strconv.Unquote(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = strings.TrimSpace(unquoted)
|
||||
}
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid int64 value: %w", err)
|
||||
}
|
||||
*v = flexibleInt64(parsed)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v flexibleInt64) Int64() int64 {
|
||||
return int64(v)
|
||||
}
|
||||
|
||||
func firstNonBlank(values ...string) string {
|
||||
for _, value := range values {
|
||||
if text := strings.TrimSpace(value); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
249
internal/service/firstrechargereward/config.go
Normal file
249
internal/service/firstrechargereward/config.go
Normal file
@ -0,0 +1,249 @@
|
||||
package firstrechargereward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/model"
|
||||
"chatapp3-golang/internal/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*ConfigResponse, error) {
|
||||
sysOrigin = s.normalizeSysOrigin(sysOrigin)
|
||||
var configRow model.FirstRechargeRewardConfig
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("sys_origin = ?", sysOrigin).
|
||||
First(&configRow).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &ConfigResponse{
|
||||
Configured: false,
|
||||
SysOrigin: sysOrigin,
|
||||
Enabled: false,
|
||||
LevelConfigs: []LevelPayload{},
|
||||
}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
levels, err := s.loadLevels(ctx, configRow.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rewardItems := s.loadRewardItemsMap(ctx, levels)
|
||||
return &ConfigResponse{
|
||||
Configured: true,
|
||||
ID: configRow.ID,
|
||||
SysOrigin: configRow.SysOrigin,
|
||||
Enabled: configRow.Enabled,
|
||||
MinAppVersion: strings.TrimSpace(configRow.MinAppVersion),
|
||||
UpdateTime: formatDateTime(configRow.UpdateTime),
|
||||
LevelConfigs: buildLevelPayloads(levels, rewardItems, 0, 0, true),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*ConfigResponse, error) {
|
||||
requestID := req.ID.Int64()
|
||||
sysOrigin := s.normalizeSysOrigin(req.SysOrigin)
|
||||
levels, err := s.normalizeLevelInputs(req.LevelConfigs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Enabled && !hasEnabledLevel(levels) {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_level_configs", "enabled config must contain at least one enabled level")
|
||||
}
|
||||
minAppVersion := strings.TrimSpace(req.MinAppVersion)
|
||||
if req.Enabled && minAppVersion == "" {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_min_app_version", "minAppVersion is required when enabled")
|
||||
}
|
||||
|
||||
var savedConfigID int64
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
now := time.Now()
|
||||
var configRow model.FirstRechargeRewardConfig
|
||||
if requestID > 0 {
|
||||
err := tx.Where("id = ?", requestID).First(&configRow).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return NewAppError(http.StatusNotFound, "first_recharge_reward_config_not_found", "config not found")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(req.SysOrigin) == "" {
|
||||
sysOrigin = configRow.SysOrigin
|
||||
}
|
||||
} else {
|
||||
err := tx.Where("sys_origin = ?", sysOrigin).First(&configRow).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
configID, idErr := utils.NextID()
|
||||
if idErr != nil {
|
||||
return idErr
|
||||
}
|
||||
configRow = model.FirstRechargeRewardConfig{
|
||||
ID: configID,
|
||||
SysOrigin: sysOrigin,
|
||||
CreateTime: now,
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
configRow.SysOrigin = sysOrigin
|
||||
configRow.Enabled = req.Enabled
|
||||
configRow.MinAppVersion = minAppVersion
|
||||
configRow.UpdateTime = now
|
||||
if configRow.CreateTime.IsZero() {
|
||||
configRow.CreateTime = now
|
||||
}
|
||||
if err := tx.Save(&configRow).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("config_id = ?", configRow.ID).Delete(&model.FirstRechargeRewardLevel{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for index := range levels {
|
||||
levels[index].ConfigID = configRow.ID
|
||||
levels[index].CreateTime = now
|
||||
levels[index].UpdateTime = now
|
||||
}
|
||||
if len(levels) > 0 {
|
||||
if err := tx.Create(&levels).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
savedConfigID = configRow.ID
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.GetConfig(ctx, savedSysOrigin(ctx, s.db, savedConfigID, sysOrigin))
|
||||
}
|
||||
|
||||
func (s *Service) normalizeLevelInputs(inputs []LevelInput) ([]model.FirstRechargeRewardLevel, error) {
|
||||
if len(inputs) == 0 {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_level_configs", "levelConfigs is required")
|
||||
}
|
||||
seenLevels := make(map[int]struct{}, len(inputs))
|
||||
seenAmounts := make(map[int64]struct{}, len(inputs))
|
||||
seenGoogleProductIDs := make(map[string]struct{}, len(inputs))
|
||||
rows := make([]model.FirstRechargeRewardLevel, 0, len(inputs))
|
||||
for _, item := range inputs {
|
||||
if item.Level <= 0 {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_level", "level must be greater than 0")
|
||||
}
|
||||
amountCents := item.RechargeAmount.Cents()
|
||||
if amountCents == 0 && item.RechargeAmountCents > 0 {
|
||||
amountCents = item.RechargeAmountCents
|
||||
}
|
||||
if amountCents <= 0 {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_recharge_amount", "rechargeAmount must be greater than 0")
|
||||
}
|
||||
rewardGroupID := item.RewardGroupID.Int64()
|
||||
if rewardGroupID <= 0 {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_reward_group", "rewardGroupId is required")
|
||||
}
|
||||
if _, exists := seenLevels[item.Level]; exists {
|
||||
return nil, NewAppError(http.StatusBadRequest, "duplicate_level", "levelConfigs must not contain duplicate levels")
|
||||
}
|
||||
if _, exists := seenAmounts[amountCents]; exists {
|
||||
return nil, NewAppError(http.StatusBadRequest, "duplicate_recharge_amount", "levelConfigs must not contain duplicate rechargeAmount")
|
||||
}
|
||||
googleProductID := normalizeGoogleProductID(item.GoogleProductID)
|
||||
if googleProductID != "" {
|
||||
if _, exists := seenGoogleProductIDs[googleProductID]; exists {
|
||||
return nil, NewAppError(http.StatusBadRequest, "duplicate_google_product_id", "levelConfigs must not contain duplicate googleProductId")
|
||||
}
|
||||
seenGoogleProductIDs[googleProductID] = struct{}{}
|
||||
}
|
||||
seenLevels[item.Level] = struct{}{}
|
||||
seenAmounts[amountCents] = struct{}{}
|
||||
id, err := utils.NextID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows = append(rows, model.FirstRechargeRewardLevel{
|
||||
ID: id,
|
||||
Level: item.Level,
|
||||
RechargeAmountCents: amountCents,
|
||||
GoogleProductID: googleProductID,
|
||||
RewardGroupID: rewardGroupID,
|
||||
RewardGroupName: strings.TrimSpace(item.RewardGroupName),
|
||||
Enabled: item.Enabled,
|
||||
})
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].RechargeAmountCents == rows[j].RechargeAmountCents {
|
||||
return rows[i].Level < rows[j].Level
|
||||
}
|
||||
return rows[i].RechargeAmountCents < rows[j].RechargeAmountCents
|
||||
})
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadConfigBundle(ctx context.Context, sysOrigin string) (*configBundle, error) {
|
||||
sysOrigin = s.normalizeSysOrigin(sysOrigin)
|
||||
var configRow model.FirstRechargeRewardConfig
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("sys_origin = ?", sysOrigin).
|
||||
First(&configRow).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &configBundle{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
levels, err := s.loadLevels(ctx, configRow.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &configBundle{
|
||||
Config: refConfigSnapshot(configSnapshotFromModel(configRow)),
|
||||
Levels: levels,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadLevels(ctx context.Context, configID int64) ([]levelSnapshot, error) {
|
||||
if configID <= 0 {
|
||||
return []levelSnapshot{}, nil
|
||||
}
|
||||
var rows []model.FirstRechargeRewardLevel
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("config_id = ?", configID).
|
||||
Order("recharge_amount_cents asc").
|
||||
Order("level asc").
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
levels := make([]levelSnapshot, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
levels = append(levels, levelSnapshotFromModel(row))
|
||||
}
|
||||
sortLevels(levels)
|
||||
return levels, nil
|
||||
}
|
||||
|
||||
func hasEnabledLevel(levels []model.FirstRechargeRewardLevel) bool {
|
||||
for _, level := range levels {
|
||||
if level.Enabled {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func savedSysOrigin(ctx context.Context, db dbHandle, id int64, fallback string) string {
|
||||
var row model.FirstRechargeRewardConfig
|
||||
if id > 0 && db != nil {
|
||||
if err := db.WithContext(ctx).Where("id = ?", id).First(&row).Error; err == nil {
|
||||
return row.SysOrigin
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
456
internal/service/firstrechargereward/event.go
Normal file
456
internal/service/firstrechargereward/event.go
Normal file
@ -0,0 +1,456 @@
|
||||
package firstrechargereward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/integration"
|
||||
"chatapp3-golang/internal/model"
|
||||
"chatapp3-golang/internal/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (s *Service) ProcessRechargePayload(ctx context.Context, payload string) (*ProcessRechargeResponse, error) {
|
||||
payload = strings.TrimSpace(payload)
|
||||
if payload == "" {
|
||||
return &ProcessRechargeResponse{Processed: false, Reason: "empty_payload"}, nil
|
||||
}
|
||||
event, skip, err := decodeRechargeEventPayload(payload, s.cfg.FirstRechargeReward.MQ.Tag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if skip {
|
||||
return &ProcessRechargeResponse{Processed: false, Reason: "tag_skipped"}, nil
|
||||
}
|
||||
return s.ProcessRechargeEvent(ctx, event)
|
||||
}
|
||||
|
||||
func (s *Service) ProcessRechargeEvent(ctx context.Context, event RechargeEvent) (*ProcessRechargeResponse, error) {
|
||||
normalized, err := s.normalizeRechargeEvent(event)
|
||||
if err != nil {
|
||||
var appErr *AppError
|
||||
if errors.As(err, &appErr) && appErr.Status >= http.StatusBadRequest && appErr.Status < http.StatusInternalServerError {
|
||||
return &ProcessRechargeResponse{Processed: false, Reason: appErr.Code}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !isAcceptedPaymentMethod(normalized.PaymentMethod, normalized.PayPlatform) {
|
||||
return &ProcessRechargeResponse{Processed: false, Reason: "unsupported_payment_method"}, nil
|
||||
}
|
||||
if s.java == nil {
|
||||
return nil, NewAppError(http.StatusServiceUnavailable, "java_gateway_unavailable", "java gateway is unavailable")
|
||||
}
|
||||
|
||||
bundle, err := s.loadConfigBundle(ctx, normalized.SysOrigin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if bundle.Config == nil {
|
||||
return &ProcessRechargeResponse{Processed: false, Reason: "config_missing"}, nil
|
||||
}
|
||||
if !bundle.Config.Enabled {
|
||||
return &ProcessRechargeResponse{Processed: false, Reason: "config_disabled"}, nil
|
||||
}
|
||||
if !isFirstRechargeRewardAppVersionSupported(normalized.AppVersion, bundle.Config.MinAppVersion) {
|
||||
return &ProcessRechargeResponse{Processed: false, Reason: "app_version_not_supported"}, nil
|
||||
}
|
||||
matched, ok := pickMatchedLevel(bundle.Levels, normalized.AmountCents, normalized.GoogleProductID)
|
||||
if !ok {
|
||||
return &ProcessRechargeResponse{Processed: false, Reason: "level_not_matched"}, nil
|
||||
}
|
||||
|
||||
record, terminal, err := s.getOrCreateGrantRecord(ctx, normalized, bundle.Config, matched)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if terminal {
|
||||
return &ProcessRechargeResponse{
|
||||
Processed: true,
|
||||
RecordID: record.ID,
|
||||
Status: record.Status,
|
||||
Level: record.Level,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if err := s.dispatchRewardGroup(ctx, record); err != nil {
|
||||
_ = s.updateGrantRecordFailed(ctx, record.ID, err)
|
||||
return nil, err
|
||||
}
|
||||
if err := s.updateGrantRecordSuccess(ctx, record.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record.Status = statusSuccess
|
||||
now := time.Now()
|
||||
record.SentAt = &now
|
||||
if err := s.pushRewardNotice(ctx, record); err != nil {
|
||||
log.Printf("push first recharge reward notice failed. recordId=%d userId=%d eventId=%s err=%v",
|
||||
record.ID, record.UserID, record.EventID, err)
|
||||
}
|
||||
_ = s.java.RemoveUserProfileCacheAll(ctx, record.UserID)
|
||||
return &ProcessRechargeResponse{
|
||||
Processed: true,
|
||||
RecordID: record.ID,
|
||||
Status: statusSuccess,
|
||||
Level: record.Level,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) normalizeRechargeEvent(event RechargeEvent) (normalizedRechargeEvent, error) {
|
||||
sysOrigin := s.normalizeSysOrigin(event.SysOrigin)
|
||||
userID := event.UserID.Int64()
|
||||
if userID <= 0 {
|
||||
return normalizedRechargeEvent{}, NewAppError(http.StatusBadRequest, "invalid_user_id", "userId is required")
|
||||
}
|
||||
amountCents := event.AmountCents.Int64()
|
||||
if amountCents <= 0 {
|
||||
amountCents = firstPositiveAmountCents(event.Amount.String(), event.AmountUSD.String(), event.USDAmount.String())
|
||||
}
|
||||
|
||||
payPlatform := strings.ToUpper(strings.TrimSpace(event.PayPlatform))
|
||||
paymentMethod := strings.ToUpper(strings.TrimSpace(event.PaymentMethod))
|
||||
appVersion := normalizeRechargeAppVersion(event)
|
||||
googleProductID := normalizeRechargeGoogleProductID(event)
|
||||
if amountCents <= 0 && googleProductID == "" {
|
||||
return normalizedRechargeEvent{}, NewAppError(http.StatusBadRequest, "invalid_recharge_identity", "amountCents, amount, or googleProductId is required")
|
||||
}
|
||||
sourceOrderID := firstNonEmpty(event.SourceOrderID.String(), event.OrderID.String())
|
||||
eventID := strings.TrimSpace(event.EventID)
|
||||
if eventID == "" && sourceOrderID != "" {
|
||||
eventID = "RECHARGE_SUCCESS:" + firstNonEmpty(paymentMethod, payPlatform, "UNKNOWN") + ":" + sourceOrderID
|
||||
}
|
||||
if eventID == "" {
|
||||
return normalizedRechargeEvent{}, NewAppError(http.StatusBadRequest, "invalid_event_id", "eventId is required")
|
||||
}
|
||||
occurredAt := parseEventTime(event.OccurredAt.String())
|
||||
if occurredAt.IsZero() {
|
||||
occurredAt = time.Now()
|
||||
}
|
||||
return normalizedRechargeEvent{
|
||||
EventID: eventID,
|
||||
SysOrigin: sysOrigin,
|
||||
UserID: userID,
|
||||
AmountCents: amountCents,
|
||||
Currency: firstNonEmpty(strings.ToUpper(strings.TrimSpace(event.Currency)), "USD"),
|
||||
PayPlatform: payPlatform,
|
||||
PaymentMethod: paymentMethod,
|
||||
AppVersion: appVersion,
|
||||
GoogleProductID: googleProductID,
|
||||
SourceOrderID: sourceOrderID,
|
||||
OccurredAt: occurredAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) getOrCreateGrantRecord(
|
||||
ctx context.Context,
|
||||
event normalizedRechargeEvent,
|
||||
config *configSnapshot,
|
||||
level levelSnapshot,
|
||||
) (*model.FirstRechargeRewardGrantRecord, bool, error) {
|
||||
var existing model.FirstRechargeRewardGrantRecord
|
||||
err := s.db.WithContext(ctx).Where("event_id = ?", event.EventID).First(&existing).Error
|
||||
if err == nil {
|
||||
return &existing, existing.Status == statusSuccess, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
err = s.db.WithContext(ctx).
|
||||
Where("sys_origin = ? AND user_id = ?", event.SysOrigin, event.UserID).
|
||||
First(&existing).Error
|
||||
if err == nil {
|
||||
return &existing, existing.Status == statusSuccess, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
id, err := utils.NextID()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
now := time.Now()
|
||||
record := model.FirstRechargeRewardGrantRecord{
|
||||
ID: id,
|
||||
EventID: event.EventID,
|
||||
ConfigID: config.ID,
|
||||
LevelID: level.ID,
|
||||
SysOrigin: config.SysOrigin,
|
||||
UserID: event.UserID,
|
||||
RechargeAmountCents: event.AmountCents,
|
||||
RechargeThresholdCents: level.RechargeAmountCents,
|
||||
Level: level.Level,
|
||||
PayPlatform: event.PayPlatform,
|
||||
PaymentMethod: event.PaymentMethod,
|
||||
GoogleProductID: event.GoogleProductID,
|
||||
SourceOrderID: event.SourceOrderID,
|
||||
RewardGroupID: level.RewardGroupID,
|
||||
RewardGroupName: level.RewardGroupName,
|
||||
RewardGroupTrackID: id,
|
||||
Status: statusPending,
|
||||
RewardGroupStatus: statusPending,
|
||||
EventTime: event.OccurredAt,
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
}
|
||||
if profile, err := s.java.GetUserProfile(ctx, event.UserID); err == nil {
|
||||
record.Account = strings.TrimSpace(profile.Account)
|
||||
record.UserAvatar = strings.TrimSpace(profile.UserAvatar)
|
||||
record.UserNickname = strings.TrimSpace(profile.UserNickname)
|
||||
record.CountryCode = strings.TrimSpace(profile.CountryCode)
|
||||
record.CountryName = strings.TrimSpace(profile.CountryName)
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Create(&record).Error; err != nil {
|
||||
if isDuplicateError(err) {
|
||||
if reload, ok, reloadErr := s.loadExistingGrantRecord(ctx, event); reloadErr != nil {
|
||||
return nil, false, reloadErr
|
||||
} else if ok {
|
||||
return &reload, reload.Status == statusSuccess, nil
|
||||
}
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
return &record, false, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadExistingGrantRecord(ctx context.Context, event normalizedRechargeEvent) (model.FirstRechargeRewardGrantRecord, bool, error) {
|
||||
var existing model.FirstRechargeRewardGrantRecord
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("event_id = ? OR (sys_origin = ? AND user_id = ?)", event.EventID, event.SysOrigin, event.UserID).
|
||||
First(&existing).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.FirstRechargeRewardGrantRecord{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return model.FirstRechargeRewardGrantRecord{}, false, err
|
||||
}
|
||||
return existing, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) dispatchRewardGroup(ctx context.Context, record *model.FirstRechargeRewardGrantRecord) error {
|
||||
if record.RewardGroupID <= 0 {
|
||||
return NewAppError(http.StatusBadRequest, "reward_group_missing", "reward group is missing")
|
||||
}
|
||||
trackID := record.RewardGroupTrackID
|
||||
if trackID <= 0 {
|
||||
trackID = record.ID
|
||||
}
|
||||
return s.java.SendActivityReward(ctx, integration.SendActivityRewardRequest{
|
||||
TrackID: trackID,
|
||||
Origin: firstRechargeRewardOrigin,
|
||||
SysOrigin: record.SysOrigin,
|
||||
SourceGroupID: record.RewardGroupID,
|
||||
AcceptUserID: record.UserID,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) updateGrantRecordSuccess(ctx context.Context, recordID int64) error {
|
||||
now := time.Now()
|
||||
return s.db.WithContext(ctx).
|
||||
Model(&model.FirstRechargeRewardGrantRecord{}).
|
||||
Where("id = ?", recordID).
|
||||
Updates(map[string]any{
|
||||
"status": statusSuccess,
|
||||
"reward_group_status": statusSuccess,
|
||||
"last_error": "",
|
||||
"sent_at": now,
|
||||
"update_time": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *Service) updateGrantRecordFailed(ctx context.Context, recordID int64, cause error) error {
|
||||
return s.db.WithContext(ctx).
|
||||
Model(&model.FirstRechargeRewardGrantRecord{}).
|
||||
Where("id = ?", recordID).
|
||||
Updates(map[string]any{
|
||||
"status": statusFailed,
|
||||
"reward_group_status": statusFailed,
|
||||
"retry_count": gorm.Expr("retry_count + 1"),
|
||||
"last_error": truncateMessage(cause.Error(), 1024),
|
||||
"update_time": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *Service) pushRewardNotice(ctx context.Context, record *model.FirstRechargeRewardGrantRecord) error {
|
||||
expand := map[string]any{
|
||||
"scene": "FIRST_RECHARGE_REWARD",
|
||||
"showRewardPopup": true,
|
||||
"userId": record.UserID,
|
||||
"sysOrigin": record.SysOrigin,
|
||||
"eventId": record.EventID,
|
||||
"recordId": fmt.Sprintf("%d", record.ID),
|
||||
"level": record.Level,
|
||||
"rechargeAmount": formatAmountCents(record.RechargeAmountCents),
|
||||
"rechargeAmountCents": record.RechargeAmountCents,
|
||||
"rewardGroupId": fmt.Sprintf("%d", record.RewardGroupID),
|
||||
"rewardGroupName": record.RewardGroupName,
|
||||
"payPlatform": record.PayPlatform,
|
||||
"paymentMethod": record.PaymentMethod,
|
||||
"googleProductId": record.GoogleProductID,
|
||||
"noticeType": firstRechargeRewardNoticeType,
|
||||
}
|
||||
return s.java.SendOfficialNoticeCustomize(ctx, integration.OfficialNoticeCustomizeRequest{
|
||||
ToAccounts: []int64{record.UserID},
|
||||
NoticeType: firstRechargeRewardNoticeType,
|
||||
Title: firstRechargeRewardNoticeTitle,
|
||||
Content: firstRechargeRewardNoticeContent,
|
||||
Expand: expand,
|
||||
})
|
||||
}
|
||||
|
||||
func decodeRechargeEventPayload(payload string, expectedTag string) (RechargeEvent, bool, error) {
|
||||
var event RechargeEvent
|
||||
if err := json.Unmarshal([]byte(payload), &event); err != nil {
|
||||
return event, false, err
|
||||
}
|
||||
if strings.TrimSpace(event.EventID) != "" || event.UserID.Int64() > 0 {
|
||||
return event, false, nil
|
||||
}
|
||||
|
||||
var envelope rechargeMessageEnvelope
|
||||
if err := json.Unmarshal([]byte(payload), &envelope); err != nil {
|
||||
return RechargeEvent{}, false, err
|
||||
}
|
||||
if shouldSkipEnvelope(envelope.Tag, expectedTag) {
|
||||
return RechargeEvent{}, true, nil
|
||||
}
|
||||
bodyPayload, ok, err := envelope.bodyPayload()
|
||||
if err != nil || !ok {
|
||||
return RechargeEvent{}, false, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(bodyPayload), &event); err != nil {
|
||||
return RechargeEvent{}, false, err
|
||||
}
|
||||
return event, false, nil
|
||||
}
|
||||
|
||||
type rechargeMessageEnvelope struct {
|
||||
Tag string `json:"tag"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
}
|
||||
|
||||
func (e rechargeMessageEnvelope) bodyPayload() (string, bool, error) {
|
||||
raw := strings.TrimSpace(string(e.Body))
|
||||
if raw == "" || raw == "null" {
|
||||
return "", false, nil
|
||||
}
|
||||
if strings.HasPrefix(raw, "\"") {
|
||||
var body string
|
||||
if err := json.Unmarshal(e.Body, &body); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
body = strings.TrimSpace(body)
|
||||
return body, body != "", nil
|
||||
}
|
||||
return raw, true, nil
|
||||
}
|
||||
|
||||
func shouldSkipEnvelope(actualTag, expectedTag string) bool {
|
||||
actualTag = strings.TrimSpace(actualTag)
|
||||
expectedTag = strings.TrimSpace(expectedTag)
|
||||
if actualTag == "" || expectedTag == "" || expectedTag == "*" {
|
||||
return false
|
||||
}
|
||||
return actualTag != expectedTag
|
||||
}
|
||||
|
||||
func isAcceptedPaymentMethod(paymentMethod string, _ string) bool {
|
||||
paymentMethod = strings.ToUpper(strings.TrimSpace(paymentMethod))
|
||||
return paymentMethod == paymentMethodGoogle || paymentMethod == paymentMethodThirdParty
|
||||
}
|
||||
|
||||
func normalizeRechargeAppVersion(event RechargeEvent) string {
|
||||
return firstNonEmpty(
|
||||
event.AppVersion.String(),
|
||||
event.ClientVersion.String(),
|
||||
event.Version.String(),
|
||||
event.ReqVersion.String(),
|
||||
rechargePayloadString(event.Payload, "appVersion", "clientVersion", "version", "reqVersion"),
|
||||
)
|
||||
}
|
||||
|
||||
func normalizeRechargeGoogleProductID(event RechargeEvent) string {
|
||||
return normalizeGoogleProductID(firstNonEmpty(
|
||||
event.GoogleProductID,
|
||||
event.ProductID.String(),
|
||||
rechargePayloadString(event.Payload, "googleProductId", "productId", "product_id"),
|
||||
))
|
||||
}
|
||||
|
||||
func normalizeGoogleProductID(value string) string {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func rechargePayloadString(payload json.RawMessage, keys ...string) string {
|
||||
if len(payload) == 0 {
|
||||
return ""
|
||||
}
|
||||
var values map[string]any
|
||||
if err := json.Unmarshal(payload, &values); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range keys {
|
||||
if value, ok := values[key]; ok {
|
||||
if text := strings.TrimSpace(fmt.Sprint(value)); text != "" && text != "<nil>" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstPositiveAmountCents(values ...string) int64 {
|
||||
for _, value := range values {
|
||||
cents, err := parseAmountCents(value)
|
||||
if err == nil && cents > 0 {
|
||||
return cents
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseEventTime(raw string) time.Time {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, raw); err == nil {
|
||||
return t
|
||||
}
|
||||
if t, err := time.Parse("2006-01-02 15:04:05", raw); err == nil {
|
||||
return t
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isDuplicateError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
text := strings.ToLower(err.Error())
|
||||
return strings.Contains(text, "duplicate") || strings.Contains(text, "unique constraint")
|
||||
}
|
||||
|
||||
func truncateMessage(message string, limit int) string {
|
||||
message = strings.TrimSpace(message)
|
||||
if limit <= 0 || len(message) <= limit {
|
||||
return message
|
||||
}
|
||||
return message[:limit]
|
||||
}
|
||||
112
internal/service/firstrechargereward/home.go
Normal file
112
internal/service/firstrechargereward/home.go
Normal file
@ -0,0 +1,112 @@
|
||||
package firstrechargereward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"chatapp3-golang/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
activityStatusNotConfigured = "NOT_CONFIGURED"
|
||||
activityStatusDisabled = "DISABLED"
|
||||
activityStatusOngoing = "ONGOING"
|
||||
activityStatusPending = "PENDING"
|
||||
activityStatusRewarded = "REWARDED"
|
||||
activityStatusFailed = "FAILED"
|
||||
)
|
||||
|
||||
// GetHome returns app-facing first recharge reward config and the user's grant state.
|
||||
func (s *Service) GetHome(ctx context.Context, user AuthUser, appVersion string) (*HomeResponse, error) {
|
||||
sysOrigin, err := requireUser(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.java == nil {
|
||||
return nil, NewAppError(http.StatusServiceUnavailable, "java_gateway_unavailable", "java gateway is unavailable")
|
||||
}
|
||||
|
||||
bundle, err := s.loadConfigBundle(ctx, sysOrigin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if bundle.Config == nil {
|
||||
return &HomeResponse{
|
||||
Configured: false,
|
||||
Enabled: false,
|
||||
ActivityStatus: activityStatusNotConfigured,
|
||||
UserID: user.UserID,
|
||||
SysOrigin: sysOrigin,
|
||||
LevelConfigs: []LevelPayload{},
|
||||
HasRewardRecord: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
rewardItems := s.loadRewardItemsMap(ctx, bundle.Levels)
|
||||
resp := &HomeResponse{
|
||||
Configured: true,
|
||||
Enabled: bundle.Config.Enabled,
|
||||
ActivityStatus: activityStatusOngoing,
|
||||
UserID: user.UserID,
|
||||
SysOrigin: bundle.Config.SysOrigin,
|
||||
MinAppVersion: bundle.Config.MinAppVersion,
|
||||
LevelConfigs: buildLevelPayloads(bundle.Levels, rewardItems, 0, 0, false),
|
||||
HasRewardRecord: false,
|
||||
}
|
||||
if !bundle.Config.Enabled {
|
||||
resp.ActivityStatus = activityStatusDisabled
|
||||
}
|
||||
if bundle.Config.Enabled && !isFirstRechargeRewardAppVersionSupported(appVersion, bundle.Config.MinAppVersion) {
|
||||
resp.Enabled = false
|
||||
resp.ActivityStatus = activityStatusDisabled
|
||||
}
|
||||
|
||||
record, ok, err := s.loadUserGrantRecord(ctx, bundle.Config.SysOrigin, user.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
resp.HasRewardRecord = true
|
||||
resp.RewardStatus = record.Status
|
||||
resp.Rewarded = record.Status == statusSuccess
|
||||
resp.MatchedLevel = record.Level
|
||||
resp.RechargeAmount = formatAmountCents(record.RechargeAmountCents)
|
||||
resp.RechargeAmountCents = record.RechargeAmountCents
|
||||
resp.RewardGroupID = record.RewardGroupID
|
||||
resp.RewardGroupName = record.RewardGroupName
|
||||
resp.RewardItems = rewardItems[record.RewardGroupID]
|
||||
resp.LevelConfigs = buildLevelPayloads(bundle.Levels, rewardItems, record.RechargeAmountCents, record.Level, false)
|
||||
|
||||
switch record.Status {
|
||||
case statusSuccess:
|
||||
resp.ActivityStatus = activityStatusRewarded
|
||||
case statusPending:
|
||||
resp.ActivityStatus = activityStatusPending
|
||||
case statusFailed:
|
||||
resp.ActivityStatus = activityStatusFailed
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadUserGrantRecord(ctx context.Context, sysOrigin string, userID int64) (model.FirstRechargeRewardGrantRecord, bool, error) {
|
||||
if userID <= 0 {
|
||||
return model.FirstRechargeRewardGrantRecord{}, false, nil
|
||||
}
|
||||
var record model.FirstRechargeRewardGrantRecord
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("sys_origin = ? AND user_id = ?", s.normalizeSysOrigin(sysOrigin), userID).
|
||||
First(&record).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.FirstRechargeRewardGrantRecord{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return model.FirstRechargeRewardGrantRecord{}, false, err
|
||||
}
|
||||
return record, true, nil
|
||||
}
|
||||
139
internal/service/firstrechargereward/lifecycle.go
Normal file
139
internal/service/firstrechargereward/lifecycle.go
Normal file
@ -0,0 +1,139 @@
|
||||
package firstrechargereward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/common"
|
||||
|
||||
rmq "github.com/apache/rocketmq-clients/golang/v5"
|
||||
"github.com/apache/rocketmq-clients/golang/v5/credentials"
|
||||
)
|
||||
|
||||
// StartMessageConsumer starts the first recharge reward RocketMQ consumer.
|
||||
func (s *Service) StartMessageConsumer(ctx context.Context) error {
|
||||
if !s.cfg.FirstRechargeReward.MQ.Enabled {
|
||||
log.Printf("first recharge reward rocketmq consumer disabled")
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(s.cfg.FirstRechargeReward.MQ.Endpoint) == "" ||
|
||||
strings.TrimSpace(s.cfg.FirstRechargeReward.MQ.AccessKey) == "" ||
|
||||
strings.TrimSpace(s.cfg.FirstRechargeReward.MQ.AccessSecret) == "" ||
|
||||
strings.TrimSpace(s.cfg.FirstRechargeReward.MQ.Topic) == "" ||
|
||||
strings.TrimSpace(s.cfg.FirstRechargeReward.MQ.ConsumerGroup) == "" {
|
||||
log.Printf("first recharge reward rocketmq consumer skipped: endpoint, credentials, topic or consumer group missing")
|
||||
return nil
|
||||
}
|
||||
|
||||
go s.startRocketMQConsumerWithRetry(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) startRocketMQConsumerWithRetry(ctx context.Context) {
|
||||
retryDelay := 5 * time.Second
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := s.startRocketMQConsumer(ctx); err != nil {
|
||||
log.Printf("start first recharge reward rocketmq consumer failed: %v; retry in %s", err, retryDelay)
|
||||
timer := time.NewTimer(retryDelay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
if retryDelay < time.Minute {
|
||||
retryDelay *= 2
|
||||
if retryDelay > time.Minute {
|
||||
retryDelay = time.Minute
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) startRocketMQConsumer(ctx context.Context) error {
|
||||
filter := rmq.SUB_ALL
|
||||
if tag := strings.TrimSpace(s.cfg.FirstRechargeReward.MQ.Tag); tag != "" && tag != "*" {
|
||||
filter = rmq.NewFilterExpression(tag)
|
||||
}
|
||||
|
||||
consumer, err := rmq.NewPushConsumer(&rmq.Config{
|
||||
Endpoint: s.cfg.FirstRechargeReward.MQ.Endpoint,
|
||||
NameSpace: s.cfg.FirstRechargeReward.MQ.Namespace,
|
||||
ConsumerGroup: s.cfg.FirstRechargeReward.MQ.ConsumerGroup,
|
||||
Credentials: &credentials.SessionCredentials{
|
||||
AccessKey: s.cfg.FirstRechargeReward.MQ.AccessKey,
|
||||
AccessSecret: s.cfg.FirstRechargeReward.MQ.AccessSecret,
|
||||
SecurityToken: s.cfg.FirstRechargeReward.MQ.SecurityToken,
|
||||
},
|
||||
},
|
||||
rmq.WithPushSubscriptionExpressions(map[string]*rmq.FilterExpression{
|
||||
s.cfg.FirstRechargeReward.MQ.Topic: filter,
|
||||
}),
|
||||
rmq.WithPushConsumptionThreadCount(4),
|
||||
rmq.WithPushMaxCacheMessageCount(256),
|
||||
rmq.WithPushMessageListener(&rmq.FuncMessageListener{
|
||||
Consume: func(messageView *rmq.MessageView) rmq.ConsumerResult {
|
||||
if messageView == nil {
|
||||
return rmq.SUCCESS
|
||||
}
|
||||
if err := s.processRocketMQMessage(context.Background(), messageView); err != nil {
|
||||
log.Printf("first recharge reward mq process failed. messageId=%s err=%v", messageView.GetMessageId(), err)
|
||||
return rmq.FAILURE
|
||||
}
|
||||
return rmq.SUCCESS
|
||||
},
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := consumer.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.rocketConsumer = consumer
|
||||
log.Printf("first recharge reward rocketmq consumer started. topic=%s group=%s tag=%s",
|
||||
s.cfg.FirstRechargeReward.MQ.Topic,
|
||||
s.cfg.FirstRechargeReward.MQ.ConsumerGroup,
|
||||
s.cfg.FirstRechargeReward.MQ.Tag,
|
||||
)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
if s.rocketConsumer != nil {
|
||||
_ = s.rocketConsumer.GracefulStop()
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) processRocketMQMessage(ctx context.Context, messageView *rmq.MessageView) error {
|
||||
payload := strings.TrimSpace(string(messageView.GetBody()))
|
||||
if payload == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.ProcessRechargePayload(ctx, payload); err != nil {
|
||||
var syntaxErr *json.SyntaxError
|
||||
var typeErr *json.UnmarshalTypeError
|
||||
if errors.As(err, &syntaxErr) || errors.As(err, &typeErr) {
|
||||
log.Printf("drop malformed first recharge reward message. messageId=%s err=%v", messageView.GetMessageId(), err)
|
||||
return nil
|
||||
}
|
||||
var appErr *common.AppError
|
||||
if errors.As(err, &appErr) && appErr.Status >= http.StatusBadRequest && appErr.Status < http.StatusInternalServerError {
|
||||
log.Printf("drop invalid first recharge reward message. messageId=%s code=%s err=%v", messageView.GetMessageId(), appErr.Code, err)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
180
internal/service/firstrechargereward/mapper.go
Normal file
180
internal/service/firstrechargereward/mapper.go
Normal file
@ -0,0 +1,180 @@
|
||||
package firstrechargereward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/integration"
|
||||
"chatapp3-golang/internal/model"
|
||||
)
|
||||
|
||||
func configSnapshotFromModel(row model.FirstRechargeRewardConfig) configSnapshot {
|
||||
return configSnapshot{
|
||||
ID: row.ID,
|
||||
SysOrigin: strings.ToUpper(strings.TrimSpace(row.SysOrigin)),
|
||||
Enabled: row.Enabled,
|
||||
MinAppVersion: strings.TrimSpace(row.MinAppVersion),
|
||||
UpdateTime: row.UpdateTime,
|
||||
}
|
||||
}
|
||||
|
||||
func levelSnapshotFromModel(row model.FirstRechargeRewardLevel) levelSnapshot {
|
||||
return levelSnapshot{
|
||||
ID: row.ID,
|
||||
Level: row.Level,
|
||||
RechargeAmountCents: row.RechargeAmountCents,
|
||||
GoogleProductID: normalizeGoogleProductID(row.GoogleProductID),
|
||||
RewardGroupID: row.RewardGroupID,
|
||||
RewardGroupName: strings.TrimSpace(row.RewardGroupName),
|
||||
Enabled: row.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
func refConfigSnapshot(value configSnapshot) *configSnapshot {
|
||||
return &value
|
||||
}
|
||||
|
||||
func buildLevelPayloads(
|
||||
levels []levelSnapshot,
|
||||
rewardItems map[int64][]RewardItem,
|
||||
currentRechargeCents int64,
|
||||
matchedLevel int,
|
||||
admin bool,
|
||||
) []LevelPayload {
|
||||
payloads := make([]LevelPayload, 0, len(levels))
|
||||
for _, level := range levels {
|
||||
if !admin && !level.Enabled {
|
||||
continue
|
||||
}
|
||||
payloads = append(payloads, LevelPayload{
|
||||
ID: level.ID,
|
||||
Level: level.Level,
|
||||
RechargeAmount: formatAmountCents(level.RechargeAmountCents),
|
||||
RechargeAmountCents: level.RechargeAmountCents,
|
||||
GoogleProductID: level.GoogleProductID,
|
||||
RewardGroupID: level.RewardGroupID,
|
||||
RewardGroupName: level.RewardGroupName,
|
||||
RewardItems: rewardItems[level.RewardGroupID],
|
||||
Enabled: level.Enabled,
|
||||
Reached: currentRechargeCents >= level.RechargeAmountCents,
|
||||
Current: matchedLevel > 0 && matchedLevel == level.Level,
|
||||
})
|
||||
}
|
||||
return payloads
|
||||
}
|
||||
|
||||
func pickMatchedLevel(levels []levelSnapshot, rechargeAmountCents int64, googleProductID string) (levelSnapshot, bool) {
|
||||
googleProductID = normalizeGoogleProductID(googleProductID)
|
||||
if googleProductID != "" {
|
||||
for _, level := range levels {
|
||||
if !level.Enabled || level.RewardGroupID <= 0 || level.GoogleProductID == "" {
|
||||
continue
|
||||
}
|
||||
if level.GoogleProductID == googleProductID {
|
||||
return level, true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, level := range levels {
|
||||
if !level.Enabled || level.RechargeAmountCents <= 0 || level.RewardGroupID <= 0 {
|
||||
continue
|
||||
}
|
||||
if rechargeAmountCents == level.RechargeAmountCents {
|
||||
return level, true
|
||||
}
|
||||
}
|
||||
return levelSnapshot{}, false
|
||||
}
|
||||
|
||||
func sortLevels(levels []levelSnapshot) {
|
||||
sort.Slice(levels, func(i, j int) bool {
|
||||
if levels[i].RechargeAmountCents == levels[j].RechargeAmountCents {
|
||||
return levels[i].Level < levels[j].Level
|
||||
}
|
||||
return levels[i].RechargeAmountCents < levels[j].RechargeAmountCents
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) loadRewardItemsMap(ctx context.Context, levels []levelSnapshot) map[int64][]RewardItem {
|
||||
if s.java == nil {
|
||||
return map[int64][]RewardItem{}
|
||||
}
|
||||
groupIDs := make([]int64, 0)
|
||||
seen := make(map[int64]struct{})
|
||||
for _, level := range levels {
|
||||
if level.RewardGroupID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[level.RewardGroupID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[level.RewardGroupID] = struct{}{}
|
||||
groupIDs = append(groupIDs, level.RewardGroupID)
|
||||
}
|
||||
sort.Slice(groupIDs, func(i, j int) bool { return groupIDs[i] < groupIDs[j] })
|
||||
|
||||
result := make(map[int64][]RewardItem, len(groupIDs))
|
||||
for _, groupID := range groupIDs {
|
||||
detail, err := s.java.GetRewardGroupDetail(ctx, groupID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
result[groupID] = rewardItemsFromGroup(detail)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func rewardItemsFromGroup(detail integration.RewardGroupDetail) []RewardItem {
|
||||
items := make([]RewardItem, 0, len(detail.RewardConfigList))
|
||||
for _, item := range detail.RewardConfigList {
|
||||
items = append(items, RewardItem{
|
||||
ID: int64(item.ID),
|
||||
Type: rewardItemDisplayType(item),
|
||||
Name: strings.TrimSpace(item.Name),
|
||||
Content: strings.TrimSpace(item.Content),
|
||||
Quantity: int64(item.Quantity),
|
||||
Cover: firstNonEmpty(item.Cover, item.SourceURL),
|
||||
Remark: strings.TrimSpace(item.Remark),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func rewardItemDisplayType(item integration.RewardGroupItem) string {
|
||||
itemType := strings.ToUpper(strings.TrimSpace(item.Type))
|
||||
detailType := strings.ToUpper(strings.TrimSpace(item.DetailType))
|
||||
if itemType == "PROPS" {
|
||||
if detailType != "" && detailType != "PROPS" {
|
||||
return detailType
|
||||
}
|
||||
if isVipRewardItem(item) {
|
||||
return "NOBLE_VIP"
|
||||
}
|
||||
}
|
||||
return itemType
|
||||
}
|
||||
|
||||
func isVipRewardItem(item integration.RewardGroupItem) bool {
|
||||
name := strings.ToUpper(strings.TrimSpace(item.Name))
|
||||
remark := strings.ToUpper(strings.TrimSpace(item.Remark))
|
||||
return strings.Contains(name, "VIP") ||
|
||||
strings.Contains(name, "NOBLE") ||
|
||||
strings.Contains(remark, "VIP") ||
|
||||
strings.Contains(remark, "NOBLE")
|
||||
}
|
||||
|
||||
func formatDateTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
func formatPtrDateTime(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return formatDateTime(*t)
|
||||
}
|
||||
31
internal/service/firstrechargereward/money.go
Normal file
31
internal/service/firstrechargereward/money.go
Normal file
@ -0,0 +1,31 @@
|
||||
package firstrechargereward
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func parseAmountCents(raw string) (int64, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return 0, nil
|
||||
}
|
||||
value = strings.TrimPrefix(value, "$")
|
||||
parsed, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if parsed < 0 {
|
||||
return 0, fmt.Errorf("amount must not be negative")
|
||||
}
|
||||
return int64(math.Round(parsed * 100)), nil
|
||||
}
|
||||
|
||||
func formatAmountCents(cents int64) string {
|
||||
if cents <= 0 {
|
||||
return "0.00"
|
||||
}
|
||||
return fmt.Sprintf("%.2f", float64(cents)/100)
|
||||
}
|
||||
109
internal/service/firstrechargereward/records.go
Normal file
109
internal/service/firstrechargereward/records.go
Normal file
@ -0,0 +1,109 @@
|
||||
package firstrechargereward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"chatapp3-golang/internal/model"
|
||||
)
|
||||
|
||||
// PageGrantRecords returns admin-facing first recharge reward grant records.
|
||||
func (s *Service) PageGrantRecords(
|
||||
ctx context.Context,
|
||||
sysOrigin string,
|
||||
status string,
|
||||
userID int64,
|
||||
cursor int,
|
||||
limit int,
|
||||
) (*GrantRecordPageResponse, error) {
|
||||
sysOrigin = s.normalizeSysOrigin(sysOrigin)
|
||||
status = strings.ToUpper(strings.TrimSpace(status))
|
||||
cursor, limit = NormalizePage(cursor, limit)
|
||||
|
||||
query := s.db.WithContext(ctx).Model(&model.FirstRechargeRewardGrantRecord{}).
|
||||
Where("sys_origin = ?", sysOrigin)
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
if userID > 0 {
|
||||
query = query.Where("user_id = ?", userID)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rows []model.FirstRechargeRewardGrantRecord
|
||||
if err := query.
|
||||
Order("create_time desc").
|
||||
Offset((cursor - 1) * limit).
|
||||
Limit(limit).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rewardItems := s.loadRewardItemsForRecords(ctx, rows)
|
||||
views := make([]GrantRecordView, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
views = append(views, grantRecordViewFromModel(row, rewardItems[row.RewardGroupID]))
|
||||
}
|
||||
return &GrantRecordPageResponse{
|
||||
Records: views,
|
||||
Total: total,
|
||||
Current: cursor,
|
||||
Size: limit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadRewardItemsForRecords(ctx context.Context, rows []model.FirstRechargeRewardGrantRecord) map[int64][]RewardItem {
|
||||
levels := make([]levelSnapshot, 0, len(rows))
|
||||
seen := make(map[int64]struct{})
|
||||
for _, row := range rows {
|
||||
if row.RewardGroupID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[row.RewardGroupID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[row.RewardGroupID] = struct{}{}
|
||||
levels = append(levels, levelSnapshot{
|
||||
RewardGroupID: row.RewardGroupID,
|
||||
})
|
||||
}
|
||||
return s.loadRewardItemsMap(ctx, levels)
|
||||
}
|
||||
|
||||
func grantRecordViewFromModel(row model.FirstRechargeRewardGrantRecord, rewardItems []RewardItem) GrantRecordView {
|
||||
return GrantRecordView{
|
||||
ID: row.ID,
|
||||
EventID: row.EventID,
|
||||
UserID: row.UserID,
|
||||
Account: row.Account,
|
||||
UserAvatar: row.UserAvatar,
|
||||
UserNickname: row.UserNickname,
|
||||
CountryCode: row.CountryCode,
|
||||
CountryName: row.CountryName,
|
||||
SysOrigin: row.SysOrigin,
|
||||
RechargeAmount: formatAmountCents(row.RechargeAmountCents),
|
||||
RechargeAmountCents: row.RechargeAmountCents,
|
||||
RechargeThreshold: formatAmountCents(row.RechargeThresholdCents),
|
||||
RechargeThresholdCents: row.RechargeThresholdCents,
|
||||
Level: row.Level,
|
||||
PayPlatform: row.PayPlatform,
|
||||
PaymentMethod: row.PaymentMethod,
|
||||
GoogleProductID: row.GoogleProductID,
|
||||
SourceOrderID: row.SourceOrderID,
|
||||
RewardGroupID: row.RewardGroupID,
|
||||
RewardGroupName: row.RewardGroupName,
|
||||
RewardItems: rewardItems,
|
||||
Status: row.Status,
|
||||
RewardGroupStatus: row.RewardGroupStatus,
|
||||
RetryCount: row.RetryCount,
|
||||
LastError: row.LastError,
|
||||
EventTime: formatDateTime(row.EventTime),
|
||||
SentAt: formatPtrDateTime(row.SentAt),
|
||||
CreateTime: formatDateTime(row.CreateTime),
|
||||
UpdateTime: formatDateTime(row.UpdateTime),
|
||||
}
|
||||
}
|
||||
567
internal/service/firstrechargereward/service_test.go
Normal file
567
internal/service/firstrechargereward/service_test.go
Normal file
@ -0,0 +1,567 @@
|
||||
package firstrechargereward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"chatapp3-golang/internal/config"
|
||||
"chatapp3-golang/internal/integration"
|
||||
"chatapp3-golang/internal/model"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestSaveConfigReturnsRewardItems(t *testing.T) {
|
||||
service, gateway, _ := newTestService(t)
|
||||
gateway.groups[1001] = testRewardGroup(1001, "首冲礼包")
|
||||
|
||||
req := mustDecodeSaveConfigRequest(t, `{
|
||||
"sysOrigin": "LIKEI",
|
||||
"enabled": true,
|
||||
"minAppVersion": "1.5.0",
|
||||
"levelConfigs": [
|
||||
{"level": 1, "rechargeAmount": "9.99", "googleProductId": "gold_999", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
|
||||
]
|
||||
}`)
|
||||
resp, err := service.SaveConfig(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
if !resp.Configured || !resp.Enabled {
|
||||
t.Fatalf("SaveConfig() configured/enabled = %v/%v", resp.Configured, resp.Enabled)
|
||||
}
|
||||
if len(resp.LevelConfigs) != 1 {
|
||||
t.Fatalf("level count = %d", len(resp.LevelConfigs))
|
||||
}
|
||||
level := resp.LevelConfigs[0]
|
||||
if level.RechargeAmountCents != 999 || level.RewardGroupID != 1001 {
|
||||
t.Fatalf("level payload = %+v", level)
|
||||
}
|
||||
if level.GoogleProductID != "gold_999" {
|
||||
t.Fatalf("google product id = %q", level.GoogleProductID)
|
||||
}
|
||||
if len(level.RewardItems) != 1 || level.RewardItems[0].Name != "首冲礼包奖励" {
|
||||
t.Fatalf("reward items = %+v", level.RewardItems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveConfigReturnsVipCoverFromSourceURL(t *testing.T) {
|
||||
service, gateway, _ := newTestService(t)
|
||||
gateway.groups[2001] = integration.RewardGroupDetail{
|
||||
ID: integration.Int64Value(2001),
|
||||
Name: "VIP礼包",
|
||||
RewardConfigList: []integration.RewardGroupItem{
|
||||
{
|
||||
ID: integration.Int64Value(11),
|
||||
Type: "PROPS",
|
||||
DetailType: "NOBLE_VIP",
|
||||
Name: "VIP 1",
|
||||
Content: "2049402425177018400",
|
||||
Quantity: integration.Int64Value(30),
|
||||
Cover: "",
|
||||
SourceURL: "https://example.com/vip-short-badge.png",
|
||||
Remark: "30 天",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
|
||||
"sysOrigin": "LIKEI",
|
||||
"enabled": true,
|
||||
"minAppVersion": "1.5.0",
|
||||
"levelConfigs": [
|
||||
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "2001", "rewardGroupName": "VIP礼包", "enabled": true}
|
||||
]
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
if len(resp.LevelConfigs) != 1 || len(resp.LevelConfigs[0].RewardItems) != 1 {
|
||||
t.Fatalf("reward items = %+v", resp.LevelConfigs)
|
||||
}
|
||||
if got := resp.LevelConfigs[0].RewardItems[0].Cover; got != "https://example.com/vip-short-badge.png" {
|
||||
t.Fatalf("vip cover = %q", got)
|
||||
}
|
||||
if got := resp.LevelConfigs[0].RewardItems[0].Type; got != "NOBLE_VIP" {
|
||||
t.Fatalf("vip type = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewardItemsReturnConcretePropsType(t *testing.T) {
|
||||
service, gateway, _ := newTestService(t)
|
||||
gateway.groups[3001] = integration.RewardGroupDetail{
|
||||
ID: integration.Int64Value(3001),
|
||||
Name: "道具礼包",
|
||||
RewardConfigList: []integration.RewardGroupItem{
|
||||
{
|
||||
ID: integration.Int64Value(21),
|
||||
Type: "PROPS",
|
||||
DetailType: "RIDE",
|
||||
Name: "Luxury sports car",
|
||||
Content: "2044620024357908482",
|
||||
Quantity: integration.Int64Value(7),
|
||||
Cover: "https://example.com/ride.png",
|
||||
Remark: "Luxury sports car",
|
||||
},
|
||||
{
|
||||
ID: integration.Int64Value(22),
|
||||
Type: "PROPS",
|
||||
Name: "VIP 3",
|
||||
Content: "2050200000000000003",
|
||||
Quantity: integration.Int64Value(3),
|
||||
Cover: "https://example.com/vip3.png",
|
||||
Remark: "VIP 3",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
|
||||
"sysOrigin": "LIKEI",
|
||||
"enabled": true,
|
||||
"minAppVersion": "1.5.0",
|
||||
"levelConfigs": [
|
||||
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "3001", "rewardGroupName": "道具礼包", "enabled": true}
|
||||
]
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
items := resp.LevelConfigs[0].RewardItems
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("reward items = %+v", items)
|
||||
}
|
||||
if items[0].Type != "RIDE" {
|
||||
t.Fatalf("ride type = %q", items[0].Type)
|
||||
}
|
||||
if items[1].Type != "NOBLE_VIP" {
|
||||
t.Fatalf("vip fallback type = %q", items[1].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
|
||||
service, gateway, db := newTestService(t)
|
||||
gateway.groups[1001] = testRewardGroup(1001, "首冲礼包")
|
||||
gateway.profiles[123] = integration.UserProfile{
|
||||
ID: integration.Int64Value(123),
|
||||
Account: "100123",
|
||||
UserNickname: "tester",
|
||||
CountryCode: "SA",
|
||||
}
|
||||
|
||||
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
|
||||
"sysOrigin": "LIKEI",
|
||||
"enabled": true,
|
||||
"minAppVersion": "1.5.0",
|
||||
"levelConfigs": [
|
||||
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
|
||||
]
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
unsupported, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
|
||||
"eventId": "RECHARGE_SUCCESS:APPLE:1",
|
||||
"sysOrigin": "LIKEI",
|
||||
"userId": "123",
|
||||
"amountCents": "999",
|
||||
"payPlatform": "APPLE",
|
||||
"paymentMethod": "APPLE",
|
||||
"appVersion": "1.5.0",
|
||||
"sourceOrderId": "1"
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("unsupported ProcessRechargeEvent() error = %v", err)
|
||||
}
|
||||
if unsupported.Processed || unsupported.Reason != "unsupported_payment_method" {
|
||||
t.Fatalf("unsupported response = %+v", unsupported)
|
||||
}
|
||||
|
||||
lowAmount, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
|
||||
"eventId": "RECHARGE_SUCCESS:GOOGLE:LOW",
|
||||
"sysOrigin": "LIKEI",
|
||||
"userId": "123",
|
||||
"amountCents": "499",
|
||||
"payPlatform": "GOOGLE",
|
||||
"paymentMethod": "GOOGLE",
|
||||
"appVersion": "1.5.0",
|
||||
"sourceOrderId": "LOW"
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("low amount ProcessRechargeEvent() error = %v", err)
|
||||
}
|
||||
if lowAmount.Processed || lowAmount.Reason != "level_not_matched" {
|
||||
t.Fatalf("low amount response = %+v", lowAmount)
|
||||
}
|
||||
|
||||
granted, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
|
||||
"eventId": "RECHARGE_SUCCESS:GOOGLE:OK",
|
||||
"sysOrigin": "LIKEI",
|
||||
"userId": "123",
|
||||
"amountCents": "999",
|
||||
"payPlatform": "GOOGLE",
|
||||
"paymentMethod": "GOOGLE",
|
||||
"appVersion": "1.5.0",
|
||||
"sourceOrderId": "OK",
|
||||
"occurredAt": "2026-05-21T12:00:00Z"
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("grant ProcessRechargeEvent() error = %v", err)
|
||||
}
|
||||
if !granted.Processed || granted.Status != statusSuccess || granted.Level != 1 {
|
||||
t.Fatalf("grant response = %+v", granted)
|
||||
}
|
||||
if len(gateway.rewardRequests) != 1 {
|
||||
t.Fatalf("reward request count = %d", len(gateway.rewardRequests))
|
||||
}
|
||||
if got := gateway.rewardRequests[0].Origin; got != firstRechargeRewardOrigin {
|
||||
t.Fatalf("reward origin = %s", got)
|
||||
}
|
||||
if len(gateway.notices) != 1 || gateway.notices[0].NoticeType != firstRechargeRewardNoticeType {
|
||||
t.Fatalf("notices = %+v", gateway.notices)
|
||||
}
|
||||
|
||||
duplicate, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
|
||||
"eventId": "RECHARGE_SUCCESS:GOOGLE:OK",
|
||||
"sysOrigin": "LIKEI",
|
||||
"userId": "123",
|
||||
"amountCents": "999",
|
||||
"payPlatform": "GOOGLE",
|
||||
"paymentMethod": "GOOGLE",
|
||||
"appVersion": "1.5.0",
|
||||
"sourceOrderId": "OK"
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate ProcessRechargeEvent() error = %v", err)
|
||||
}
|
||||
if !duplicate.Processed || duplicate.Status != statusSuccess {
|
||||
t.Fatalf("duplicate response = %+v", duplicate)
|
||||
}
|
||||
if len(gateway.rewardRequests) != 1 {
|
||||
t.Fatalf("duplicate reward request count = %d", len(gateway.rewardRequests))
|
||||
}
|
||||
|
||||
thirdPartyUnknownPlatform, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
|
||||
"eventId": "RECHARGE_SUCCESS:THIRD_PARTY:CUSTOM",
|
||||
"sysOrigin": "LIKEI",
|
||||
"userId": "456",
|
||||
"amountCents": "999",
|
||||
"payPlatform": "CUSTOM_PAY",
|
||||
"paymentMethod": "THIRD_PARTY",
|
||||
"appVersion": "1.5.1",
|
||||
"sourceOrderId": "CUSTOM"
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("third party ProcessRechargeEvent() error = %v", err)
|
||||
}
|
||||
if !thirdPartyUnknownPlatform.Processed || thirdPartyUnknownPlatform.Status != statusSuccess {
|
||||
t.Fatalf("third party response = %+v", thirdPartyUnknownPlatform)
|
||||
}
|
||||
if len(gateway.rewardRequests) != 2 {
|
||||
t.Fatalf("third party reward request count = %d", len(gateway.rewardRequests))
|
||||
}
|
||||
|
||||
googleDifferentPlatform, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
|
||||
"eventId": "RECHARGE_SUCCESS:GOOGLE:CUSTOM",
|
||||
"sysOrigin": "LIKEI",
|
||||
"userId": "789",
|
||||
"amountCents": "999",
|
||||
"payPlatform": "CUSTOM_GOOGLE_GATEWAY",
|
||||
"paymentMethod": "GOOGLE",
|
||||
"appVersion": "1.5.1",
|
||||
"sourceOrderId": "CUSTOM_GOOGLE"
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("google ProcessRechargeEvent() error = %v", err)
|
||||
}
|
||||
if !googleDifferentPlatform.Processed || googleDifferentPlatform.Status != statusSuccess {
|
||||
t.Fatalf("google response = %+v", googleDifferentPlatform)
|
||||
}
|
||||
if len(gateway.rewardRequests) != 3 {
|
||||
t.Fatalf("google reward request count = %d", len(gateway.rewardRequests))
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&model.FirstRechargeRewardGrantRecord{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count grant records: %v", err)
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("grant record count = %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessRechargeEventStartsCountingAtSupportedAppVersion(t *testing.T) {
|
||||
service, gateway, db := newTestService(t)
|
||||
gateway.groups[1001] = testRewardGroup(1001, "首冲礼包")
|
||||
|
||||
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
|
||||
"sysOrigin": "LIKEI",
|
||||
"enabled": true,
|
||||
"minAppVersion": "1.5.0",
|
||||
"levelConfigs": [
|
||||
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
|
||||
]
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
oldVersion, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
|
||||
"eventId": "RECHARGE_SUCCESS:GOOGLE:OLD_VERSION",
|
||||
"sysOrigin": "LIKEI",
|
||||
"userId": "123",
|
||||
"amountCents": "999",
|
||||
"payPlatform": "GOOGLE",
|
||||
"paymentMethod": "GOOGLE",
|
||||
"appVersion": "1.4.9",
|
||||
"sourceOrderId": "OLD_VERSION"
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("old version ProcessRechargeEvent() error = %v", err)
|
||||
}
|
||||
if oldVersion.Processed || oldVersion.Reason != "app_version_not_supported" {
|
||||
t.Fatalf("old version response = %+v", oldVersion)
|
||||
}
|
||||
|
||||
missingVersion, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
|
||||
"eventId": "RECHARGE_SUCCESS:GOOGLE:MISSING_VERSION",
|
||||
"sysOrigin": "LIKEI",
|
||||
"userId": "123",
|
||||
"amountCents": "999",
|
||||
"payPlatform": "GOOGLE",
|
||||
"paymentMethod": "GOOGLE",
|
||||
"sourceOrderId": "MISSING_VERSION"
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("missing version ProcessRechargeEvent() error = %v", err)
|
||||
}
|
||||
if missingVersion.Processed || missingVersion.Reason != "app_version_not_supported" {
|
||||
t.Fatalf("missing version response = %+v", missingVersion)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&model.FirstRechargeRewardGrantRecord{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count grant records after unsupported versions: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("grant record count after unsupported versions = %d", count)
|
||||
}
|
||||
|
||||
supported, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
|
||||
"eventId": "RECHARGE_SUCCESS:GOOGLE:SUPPORTED_VERSION",
|
||||
"sysOrigin": "LIKEI",
|
||||
"userId": "123",
|
||||
"amountCents": "999",
|
||||
"payPlatform": "GOOGLE",
|
||||
"paymentMethod": "GOOGLE",
|
||||
"appVersion": "1.5.0",
|
||||
"sourceOrderId": "SUPPORTED_VERSION"
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("supported version ProcessRechargeEvent() error = %v", err)
|
||||
}
|
||||
if !supported.Processed || supported.Status != statusSuccess {
|
||||
t.Fatalf("supported version response = %+v", supported)
|
||||
}
|
||||
if err := db.Model(&model.FirstRechargeRewardGrantRecord{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count grant records after supported version: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("grant record count after supported version = %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstRechargeRewardAppVersionSupported(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appVersion string
|
||||
want bool
|
||||
}{
|
||||
{name: "missing", appVersion: "", want: false},
|
||||
{name: "below", appVersion: "1.4.9", want: false},
|
||||
{name: "equal", appVersion: "1.5.0", want: true},
|
||||
{name: "short equal", appVersion: "1.5", want: true},
|
||||
{name: "above", appVersion: "1.5.1", want: true},
|
||||
{name: "suffix", appVersion: "1.5.0+100", want: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isFirstRechargeRewardAppVersionSupported(tt.appVersion, "1.5.0"); got != tt.want {
|
||||
t.Fatalf("isFirstRechargeRewardAppVersionSupported(%q) = %v, want %v", tt.appVersion, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeRequiresConfiguredMinAppVersion(t *testing.T) {
|
||||
service, gateway, _ := newTestService(t)
|
||||
gateway.groups[1001] = testRewardGroup(1001, "首冲礼包")
|
||||
|
||||
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
|
||||
"sysOrigin": "LIKEI",
|
||||
"enabled": true,
|
||||
"minAppVersion": "2.0.0",
|
||||
"levelConfigs": [
|
||||
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
|
||||
]
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
oldVersion, err := service.GetHome(context.Background(), AuthUser{UserID: 123, SysOrigin: "LIKEI"}, "1.9.9")
|
||||
if err != nil {
|
||||
t.Fatalf("old version GetHome() error = %v", err)
|
||||
}
|
||||
if oldVersion.Enabled || oldVersion.ActivityStatus != activityStatusDisabled {
|
||||
t.Fatalf("old version home = %+v", oldVersion)
|
||||
}
|
||||
|
||||
supported, err := service.GetHome(context.Background(), AuthUser{UserID: 123, SysOrigin: "LIKEI"}, "2.0.0")
|
||||
if err != nil {
|
||||
t.Fatalf("supported GetHome() error = %v", err)
|
||||
}
|
||||
if !supported.Enabled || supported.ActivityStatus != activityStatusOngoing {
|
||||
t.Fatalf("supported home = %+v", supported)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessRechargeEventMatchesGoogleProductIDBeforeAmount(t *testing.T) {
|
||||
service, gateway, _ := newTestService(t)
|
||||
gateway.groups[1001] = testRewardGroup(1001, "首冲礼包")
|
||||
gateway.groups[1002] = testRewardGroup(1002, "Google 首冲礼包")
|
||||
|
||||
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
|
||||
"sysOrigin": "LIKEI",
|
||||
"enabled": true,
|
||||
"minAppVersion": "1.5.0",
|
||||
"levelConfigs": [
|
||||
{"level": 1, "rechargeAmount": "9.99", "googleProductId": "gold_999", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true},
|
||||
{"level": 2, "rechargeAmount": "19.99", "googleProductId": "first_recharge_google_1999", "rewardGroupId": "1002", "rewardGroupName": "Google 首冲礼包", "enabled": true}
|
||||
]
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
granted, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
|
||||
"eventId": "RECHARGE_SUCCESS:GOOGLE:PRODUCT_ID",
|
||||
"sysOrigin": "LIKEI",
|
||||
"userId": "123",
|
||||
"amountCents": "999",
|
||||
"payPlatform": "GOOGLE",
|
||||
"paymentMethod": "GOOGLE",
|
||||
"googleProductId": "first_recharge_google_1999",
|
||||
"appVersion": "1.5.0",
|
||||
"sourceOrderId": "PRODUCT_ID"
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("product id ProcessRechargeEvent() error = %v", err)
|
||||
}
|
||||
if !granted.Processed || granted.Status != statusSuccess || granted.Level != 2 {
|
||||
t.Fatalf("product id response = %+v", granted)
|
||||
}
|
||||
if len(gateway.rewardRequests) != 1 || gateway.rewardRequests[0].SourceGroupID != 1002 {
|
||||
t.Fatalf("reward requests = %+v", gateway.rewardRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestService(t *testing.T) (*Service, *fakeGateway, *gorm.DB) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&model.FirstRechargeRewardConfig{},
|
||||
&model.FirstRechargeRewardLevel{},
|
||||
&model.FirstRechargeRewardGrantRecord{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate sqlite: %v", err)
|
||||
}
|
||||
gateway := &fakeGateway{
|
||||
groups: map[int64]integration.RewardGroupDetail{},
|
||||
profiles: map[int64]integration.UserProfile{},
|
||||
}
|
||||
service := NewService(config.Config{
|
||||
FirstRechargeReward: config.FirstRechargeRewardConfig{
|
||||
DefaultSysOrigin: "LIKEI",
|
||||
},
|
||||
}, db, gateway)
|
||||
return service, gateway, db
|
||||
}
|
||||
|
||||
func mustDecodeSaveConfigRequest(t *testing.T, raw string) SaveConfigRequest {
|
||||
t.Helper()
|
||||
var req SaveConfigRequest
|
||||
if err := json.Unmarshal([]byte(raw), &req); err != nil {
|
||||
t.Fatalf("decode SaveConfigRequest: %v", err)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func mustDecodeRechargeEvent(t *testing.T, raw string) RechargeEvent {
|
||||
t.Helper()
|
||||
var event RechargeEvent
|
||||
if err := json.Unmarshal([]byte(raw), &event); err != nil {
|
||||
t.Fatalf("decode RechargeEvent: %v", err)
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
func testRewardGroup(id int64, name string) integration.RewardGroupDetail {
|
||||
return integration.RewardGroupDetail{
|
||||
ID: integration.Int64Value(id),
|
||||
Name: name,
|
||||
RewardConfigList: []integration.RewardGroupItem{
|
||||
{
|
||||
ID: integration.Int64Value(11),
|
||||
Type: "PROPS",
|
||||
DetailType: "RIDE",
|
||||
Name: name + "奖励",
|
||||
Content: "10001",
|
||||
Quantity: integration.Int64Value(1),
|
||||
Cover: "https://example.com/reward.png",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type fakeGateway struct {
|
||||
groups map[int64]integration.RewardGroupDetail
|
||||
profiles map[int64]integration.UserProfile
|
||||
rewardRequests []integration.SendActivityRewardRequest
|
||||
notices []integration.OfficialNoticeCustomizeRequest
|
||||
cacheRemoved []int64
|
||||
}
|
||||
|
||||
func (g *fakeGateway) GetRewardGroupDetail(_ context.Context, groupID int64) (integration.RewardGroupDetail, error) {
|
||||
if group, ok := g.groups[groupID]; ok {
|
||||
return group, nil
|
||||
}
|
||||
return testRewardGroup(groupID, "默认奖励组"), nil
|
||||
}
|
||||
|
||||
func (g *fakeGateway) GetUserProfile(_ context.Context, userID int64) (integration.UserProfile, error) {
|
||||
if profile, ok := g.profiles[userID]; ok {
|
||||
return profile, nil
|
||||
}
|
||||
return integration.UserProfile{ID: integration.Int64Value(userID)}, nil
|
||||
}
|
||||
|
||||
func (g *fakeGateway) SendActivityReward(_ context.Context, req integration.SendActivityRewardRequest) error {
|
||||
g.rewardRequests = append(g.rewardRequests, req)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *fakeGateway) SendOfficialNoticeCustomize(_ context.Context, req integration.OfficialNoticeCustomizeRequest) error {
|
||||
g.notices = append(g.notices, req)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *fakeGateway) RemoveUserProfileCacheAll(_ context.Context, userID int64) error {
|
||||
g.cacheRemoved = append(g.cacheRemoved, userID)
|
||||
return nil
|
||||
}
|
||||
372
internal/service/firstrechargereward/types.go
Normal file
372
internal/service/firstrechargereward/types.go
Normal file
@ -0,0 +1,372 @@
|
||||
package firstrechargereward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/common"
|
||||
"chatapp3-golang/internal/config"
|
||||
"chatapp3-golang/internal/integration"
|
||||
|
||||
rmq "github.com/apache/rocketmq-clients/golang/v5"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
statusPending = "PENDING"
|
||||
statusSuccess = "SUCCESS"
|
||||
statusFailed = "FAILED"
|
||||
|
||||
paymentMethodGoogle = "GOOGLE"
|
||||
paymentMethodThirdParty = "THIRD_PARTY"
|
||||
|
||||
firstRechargeRewardOrigin = "FIRST_CHARGE_REWARD"
|
||||
firstRechargeRewardNoticeType = "FIRST_RECHARGE_REWARD_GRANTED"
|
||||
firstRechargeRewardNoticeTitle = "First recharge reward"
|
||||
firstRechargeRewardNoticeContent = "Reward granted"
|
||||
)
|
||||
|
||||
type AppError = common.AppError
|
||||
type AuthUser = common.AuthUser
|
||||
|
||||
var NewAppError = common.NewAppError
|
||||
|
||||
type dbHandle interface {
|
||||
WithContext(ctx context.Context) *gorm.DB
|
||||
}
|
||||
|
||||
type gateway interface {
|
||||
GetRewardGroupDetail(ctx context.Context, groupID int64) (integration.RewardGroupDetail, error)
|
||||
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error)
|
||||
SendActivityReward(ctx context.Context, req integration.SendActivityRewardRequest) error
|
||||
SendOfficialNoticeCustomize(ctx context.Context, req integration.OfficialNoticeCustomizeRequest) error
|
||||
RemoveUserProfileCacheAll(ctx context.Context, userID int64) error
|
||||
}
|
||||
|
||||
// Service owns first recharge reward config, MQ consumption, and reward dispatch.
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
db dbHandle
|
||||
java gateway
|
||||
rocketConsumer rmq.PushConsumer
|
||||
}
|
||||
|
||||
func NewService(cfg config.Config, db dbHandle, java gateway) *Service {
|
||||
return &Service{cfg: cfg, db: db, java: java}
|
||||
}
|
||||
|
||||
type LevelPayload struct {
|
||||
ID int64 `json:"id,string"`
|
||||
Level int `json:"level"`
|
||||
RechargeAmount string `json:"rechargeAmount"`
|
||||
RechargeAmountCents int64 `json:"rechargeAmountCents"`
|
||||
GoogleProductID string `json:"googleProductId,omitempty"`
|
||||
RewardGroupID int64 `json:"rewardGroupId,string"`
|
||||
RewardGroupName string `json:"rewardGroupName,omitempty"`
|
||||
RewardItems []RewardItem `json:"rewardItems"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Reached bool `json:"reached"`
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
|
||||
type LevelInput struct {
|
||||
ID flexibleInt64 `json:"id"`
|
||||
Level int `json:"level"`
|
||||
RechargeAmount flexibleAmount `json:"rechargeAmount"`
|
||||
RechargeAmountCents int64 `json:"rechargeAmountCents"`
|
||||
GoogleProductID string `json:"googleProductId"`
|
||||
RewardGroupID flexibleInt64 `json:"rewardGroupId"`
|
||||
RewardGroupName string `json:"rewardGroupName"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type ConfigResponse struct {
|
||||
Configured bool `json:"configured"`
|
||||
ID int64 `json:"id,string"`
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
Enabled bool `json:"enabled"`
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
UpdateTime string `json:"updateTime,omitempty"`
|
||||
LevelConfigs []LevelPayload `json:"levelConfigs"`
|
||||
}
|
||||
|
||||
type SaveConfigRequest struct {
|
||||
ID flexibleInt64 `json:"id"`
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
Enabled bool `json:"enabled"`
|
||||
MinAppVersion string `json:"minAppVersion"`
|
||||
LevelConfigs []LevelInput `json:"levelConfigs"`
|
||||
}
|
||||
|
||||
type HomeResponse struct {
|
||||
Configured bool `json:"configured"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ActivityStatus string `json:"activityStatus"`
|
||||
UserID int64 `json:"userId,string"`
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
HasRewardRecord bool `json:"hasRewardRecord"`
|
||||
RewardStatus string `json:"rewardStatus,omitempty"`
|
||||
Rewarded bool `json:"rewarded"`
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
MatchedLevel int `json:"matchedLevel,omitempty"`
|
||||
RechargeAmount string `json:"rechargeAmount,omitempty"`
|
||||
RechargeAmountCents int64 `json:"rechargeAmountCents,omitempty"`
|
||||
RewardGroupID int64 `json:"rewardGroupId,string,omitempty"`
|
||||
RewardGroupName string `json:"rewardGroupName,omitempty"`
|
||||
RewardItems []RewardItem `json:"rewardItems,omitempty"`
|
||||
LevelConfigs []LevelPayload `json:"levelConfigs"`
|
||||
}
|
||||
|
||||
type GrantRecordPageResponse struct {
|
||||
Records []GrantRecordView `json:"records"`
|
||||
Total int64 `json:"total"`
|
||||
Current int `json:"current"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
type GrantRecordView struct {
|
||||
ID int64 `json:"id,string"`
|
||||
EventID string `json:"eventId"`
|
||||
UserID int64 `json:"userId,string"`
|
||||
Account string `json:"account,omitempty"`
|
||||
UserAvatar string `json:"userAvatar,omitempty"`
|
||||
UserNickname string `json:"userNickname,omitempty"`
|
||||
CountryCode string `json:"countryCode,omitempty"`
|
||||
CountryName string `json:"countryName,omitempty"`
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
RechargeAmount string `json:"rechargeAmount"`
|
||||
RechargeAmountCents int64 `json:"rechargeAmountCents"`
|
||||
RechargeThreshold string `json:"rechargeThreshold"`
|
||||
RechargeThresholdCents int64 `json:"rechargeThresholdCents"`
|
||||
Level int `json:"level"`
|
||||
PayPlatform string `json:"payPlatform"`
|
||||
PaymentMethod string `json:"paymentMethod"`
|
||||
GoogleProductID string `json:"googleProductId,omitempty"`
|
||||
SourceOrderID string `json:"sourceOrderId,omitempty"`
|
||||
RewardGroupID int64 `json:"rewardGroupId,string"`
|
||||
RewardGroupName string `json:"rewardGroupName,omitempty"`
|
||||
RewardItems []RewardItem `json:"rewardItems"`
|
||||
Status string `json:"status"`
|
||||
RewardGroupStatus string `json:"rewardGroupStatus"`
|
||||
RetryCount int `json:"retryCount"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
EventTime string `json:"eventTime,omitempty"`
|
||||
SentAt string `json:"sentAt,omitempty"`
|
||||
CreateTime string `json:"createTime,omitempty"`
|
||||
UpdateTime string `json:"updateTime,omitempty"`
|
||||
}
|
||||
|
||||
type RewardItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
Cover string `json:"cover"`
|
||||
Remark string `json:"remark,omitempty"`
|
||||
}
|
||||
|
||||
type RechargeEvent struct {
|
||||
EventID string `json:"eventId"`
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
UserID flexibleInt64 `json:"userId"`
|
||||
AmountCents flexibleInt64 `json:"amountCents"`
|
||||
Amount flexibleString `json:"amount"`
|
||||
AmountUSD flexibleString `json:"amountUsd"`
|
||||
USDAmount flexibleString `json:"usdAmount"`
|
||||
Currency string `json:"currency"`
|
||||
PayPlatform string `json:"payPlatform"`
|
||||
PaymentMethod string `json:"paymentMethod"`
|
||||
GoogleProductID string `json:"googleProductId"`
|
||||
ProductID flexibleString `json:"productId"`
|
||||
AppVersion flexibleString `json:"appVersion"`
|
||||
ClientVersion flexibleString `json:"clientVersion"`
|
||||
Version flexibleString `json:"version"`
|
||||
ReqVersion flexibleString `json:"reqVersion"`
|
||||
SourceOrderID flexibleString `json:"sourceOrderId"`
|
||||
OrderID flexibleString `json:"orderId"`
|
||||
OccurredAt flexibleString `json:"occurredAt"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type ProcessRechargeResponse struct {
|
||||
Processed bool `json:"processed"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
RecordID int64 `json:"recordId,string,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Level int `json:"level,omitempty"`
|
||||
}
|
||||
|
||||
type configBundle struct {
|
||||
Config *configSnapshot
|
||||
Levels []levelSnapshot
|
||||
}
|
||||
|
||||
type configSnapshot struct {
|
||||
ID int64
|
||||
SysOrigin string
|
||||
Enabled bool
|
||||
MinAppVersion string
|
||||
UpdateTime time.Time
|
||||
}
|
||||
|
||||
type levelSnapshot struct {
|
||||
ID int64
|
||||
Level int
|
||||
RechargeAmountCents int64
|
||||
GoogleProductID string
|
||||
RewardGroupID int64
|
||||
RewardGroupName string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
type normalizedRechargeEvent struct {
|
||||
EventID string
|
||||
SysOrigin string
|
||||
UserID int64
|
||||
AmountCents int64
|
||||
Currency string
|
||||
PayPlatform string
|
||||
PaymentMethod string
|
||||
AppVersion string
|
||||
GoogleProductID string
|
||||
SourceOrderID string
|
||||
OccurredAt time.Time
|
||||
}
|
||||
|
||||
type flexibleInt64 int64
|
||||
|
||||
func (v *flexibleInt64) UnmarshalJSON(data []byte) error {
|
||||
raw := strings.TrimSpace(string(data))
|
||||
switch raw {
|
||||
case "", "null", `""`:
|
||||
*v = 0
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) {
|
||||
unquoted, err := strconv.Unquote(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = strings.TrimSpace(unquoted)
|
||||
if raw == "" {
|
||||
*v = 0
|
||||
return nil
|
||||
}
|
||||
}
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid int64 value: %w", err)
|
||||
}
|
||||
*v = flexibleInt64(parsed)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v flexibleInt64) Int64() int64 { return int64(v) }
|
||||
|
||||
func (v flexibleInt64) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(strconv.FormatInt(int64(v), 10))
|
||||
}
|
||||
|
||||
type flexibleString string
|
||||
|
||||
func (v *flexibleString) UnmarshalJSON(data []byte) error {
|
||||
raw := strings.TrimSpace(string(data))
|
||||
if raw == "" || raw == "null" {
|
||||
*v = ""
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) {
|
||||
unquoted, err := strconv.Unquote(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*v = flexibleString(strings.TrimSpace(unquoted))
|
||||
return nil
|
||||
}
|
||||
*v = flexibleString(raw)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v flexibleString) String() string {
|
||||
return strings.TrimSpace(string(v))
|
||||
}
|
||||
|
||||
type flexibleAmount int64
|
||||
|
||||
func (v *flexibleAmount) UnmarshalJSON(data []byte) error {
|
||||
raw := strings.TrimSpace(string(data))
|
||||
switch raw {
|
||||
case "", "null", `""`:
|
||||
*v = 0
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) {
|
||||
unquoted, err := strconv.Unquote(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = strings.TrimSpace(unquoted)
|
||||
}
|
||||
cents, err := parseAmountCents(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*v = flexibleAmount(cents)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v flexibleAmount) Cents() int64 {
|
||||
return int64(v)
|
||||
}
|
||||
|
||||
func ParseInt64(raw string) int64 {
|
||||
value, _ := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
return value
|
||||
}
|
||||
|
||||
func NormalizePage(cursor int, limit int) (int, int) {
|
||||
if cursor <= 0 {
|
||||
cursor = 1
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
return cursor, limit
|
||||
}
|
||||
|
||||
func (s *Service) normalizeSysOrigin(sysOrigin string) string {
|
||||
sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin))
|
||||
if sysOrigin != "" {
|
||||
return sysOrigin
|
||||
}
|
||||
if fallback := strings.ToUpper(strings.TrimSpace(s.cfg.FirstRechargeReward.DefaultSysOrigin)); fallback != "" {
|
||||
return fallback
|
||||
}
|
||||
if fallback := strings.ToUpper(strings.TrimSpace(s.cfg.RechargeReward.DefaultSysOrigin)); fallback != "" {
|
||||
return fallback
|
||||
}
|
||||
if fallback := strings.ToUpper(strings.TrimSpace(s.cfg.WeekStar.DefaultSysOrigin)); fallback != "" {
|
||||
return fallback
|
||||
}
|
||||
return "LIKEI"
|
||||
}
|
||||
|
||||
func requireUser(user AuthUser) (string, error) {
|
||||
if user.UserID <= 0 {
|
||||
return "", NewAppError(http.StatusUnauthorized, "invalid_user", "user is required")
|
||||
}
|
||||
sysOrigin := strings.ToUpper(strings.TrimSpace(user.SysOrigin))
|
||||
if sysOrigin == "" {
|
||||
return "", NewAppError(http.StatusUnauthorized, "invalid_sys_origin", "authenticated sysOrigin is required")
|
||||
}
|
||||
return sysOrigin, nil
|
||||
}
|
||||
79
internal/service/firstrechargereward/version.go
Normal file
79
internal/service/firstrechargereward/version.go
Normal file
@ -0,0 +1,79 @@
|
||||
package firstrechargereward
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const defaultFirstRechargeRewardMinAppVersion = "1.5.0"
|
||||
|
||||
func isFirstRechargeRewardAppVersionSupported(appVersion string, minAppVersion string) bool {
|
||||
appVersion = strings.TrimSpace(appVersion)
|
||||
minAppVersion = strings.TrimSpace(minAppVersion)
|
||||
if minAppVersion == "" {
|
||||
minAppVersion = defaultFirstRechargeRewardMinAppVersion
|
||||
}
|
||||
if appVersion == "" {
|
||||
return false
|
||||
}
|
||||
return compareAppVersion(appVersion, minAppVersion) >= 0
|
||||
}
|
||||
|
||||
func compareAppVersion(left, right string) int {
|
||||
leftParts := parseAppVersion(left)
|
||||
rightParts := parseAppVersion(right)
|
||||
maxLen := len(leftParts)
|
||||
if len(rightParts) > maxLen {
|
||||
maxLen = len(rightParts)
|
||||
}
|
||||
for i := 0; i < maxLen; i++ {
|
||||
var lv, rv int
|
||||
if i < len(leftParts) {
|
||||
lv = leftParts[i]
|
||||
}
|
||||
if i < len(rightParts) {
|
||||
rv = rightParts[i]
|
||||
}
|
||||
if lv > rv {
|
||||
return 1
|
||||
}
|
||||
if lv < rv {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseAppVersion(version string) []int {
|
||||
version = strings.TrimSpace(version)
|
||||
if version == "" {
|
||||
return []int{0}
|
||||
}
|
||||
parts := strings.Split(version, ".")
|
||||
result := make([]int, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
result = append(result, 0)
|
||||
continue
|
||||
}
|
||||
digits := strings.Builder{}
|
||||
for _, r := range part {
|
||||
if r < '0' || r > '9' {
|
||||
break
|
||||
}
|
||||
digits.WriteRune(r)
|
||||
}
|
||||
if digits.Len() == 0 {
|
||||
result = append(result, 0)
|
||||
continue
|
||||
}
|
||||
value, err := strconv.Atoi(digits.String())
|
||||
if err != nil {
|
||||
result = append(result, 0)
|
||||
continue
|
||||
}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
387
internal/service/gameopen/hotgame.go
Normal file
387
internal/service/gameopen/hotgame.go
Normal file
@ -0,0 +1,387 @@
|
||||
package gameopen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"chatapp3-golang/internal/integration"
|
||||
"chatapp3-golang/internal/model"
|
||||
"chatapp3-golang/internal/service/taskcenter"
|
||||
)
|
||||
|
||||
const (
|
||||
hotgameVendorType = "HOTGAME"
|
||||
hotgameCodeTokenInvalid = 1001
|
||||
hotgameCodeInsufficientBalance = 2001
|
||||
hotgameCodeDuplicateOrder = 3001
|
||||
hotgameCodeBadRequest = 4001
|
||||
hotgameCodeSignatureError = 4002
|
||||
hotgameCodeServerError = 5000
|
||||
)
|
||||
|
||||
// HotgameGetUserInfoRequest 是热游 /getUserInfo 回调入参。
|
||||
type HotgameGetUserInfoRequest struct {
|
||||
GameID string `json:"gameId"`
|
||||
UID string `json:"uid"`
|
||||
Token string `json:"token"`
|
||||
Sign string `json:"sign"`
|
||||
}
|
||||
|
||||
// HotgameUpdateBalanceRequest 是热游 /updateBalance 回调入参;roundId 文档是 int,所以这里兼容数字和字符串。
|
||||
type HotgameUpdateBalanceRequest struct {
|
||||
OrderID string `json:"orderId"`
|
||||
GameID string `json:"gameId"`
|
||||
RoundID any `json:"roundId"`
|
||||
UID string `json:"uid"`
|
||||
Coin int64 `json:"coin"`
|
||||
Type int `json:"type"`
|
||||
Token string `json:"token"`
|
||||
Sign string `json:"sign"`
|
||||
}
|
||||
|
||||
// HandleHotgameGetUserInfo 按热游协议查询玩家资料和余额。
|
||||
func (s *GameOpenService) HandleHotgameGetUserInfo(ctx context.Context, req HotgameGetUserInfoRequest, rawJSON string) CallbackResponse {
|
||||
if err := s.validateHotgameGetUserInfoRequest(req); err != nil {
|
||||
resp := failResponse(hotgameCodeBadRequest, err.Error())
|
||||
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, "", req.toQueryUserRequest(), nil, gameOpenLogStatusFailed)
|
||||
return resp
|
||||
}
|
||||
appKey := s.resolveCallbackAppKeyForVendor(ctx, hotgameVendorType, req.GameID, req.Token)
|
||||
if strings.TrimSpace(appKey) == "" {
|
||||
resp := failResponse(hotgameCodeBadRequest, "app key is blank")
|
||||
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, "", req.toQueryUserRequest(), nil, gameOpenLogStatusFailed)
|
||||
return resp
|
||||
}
|
||||
if !verifySign(req.Sign, buildHotgameGetUserInfoSign(req, appKey)) {
|
||||
resp := failResponse(hotgameCodeSignatureError, "signature error")
|
||||
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, "", req.toQueryUserRequest(), nil, gameOpenLogStatusFailed)
|
||||
return resp
|
||||
}
|
||||
|
||||
req.Token = normalizeCallbackToken(req.Token)
|
||||
credential, err := s.java.AuthenticateToken(ctx, req.Token)
|
||||
if err != nil {
|
||||
resp := failResponse(hotgameCodeTokenInvalid, "token invalid")
|
||||
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, "", req.toQueryUserRequest(), nil, gameOpenLogStatusFailed)
|
||||
return resp
|
||||
}
|
||||
resolved, err := s.resolveUserWithToken(ctx, credential, req.UID)
|
||||
if err != nil {
|
||||
resp := failResponse(hotgameCodeTokenInvalid, "uid token mismatch")
|
||||
userID := int64(credential.UserID)
|
||||
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, credential.SysOrigin, req.toQueryUserRequest(), &userID, gameOpenLogStatusFailed)
|
||||
return resp
|
||||
}
|
||||
userID := resolved.userID
|
||||
|
||||
profile := resolved.profile
|
||||
if int64(profile.ID) == 0 {
|
||||
profile, err = s.java.GetUserProfile(ctx, userID)
|
||||
}
|
||||
if err != nil {
|
||||
resp := failResponse(hotgameCodeServerError, "profile load failed")
|
||||
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, credential.SysOrigin, req.toQueryUserRequest(), &userID, gameOpenLogStatusFailed)
|
||||
return resp
|
||||
}
|
||||
balance, err := s.readUserBalance(ctx, userID)
|
||||
if err != nil {
|
||||
resp := failResponse(hotgameCodeServerError, "balance load failed")
|
||||
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, credential.SysOrigin, req.toQueryUserRequest(), &userID, gameOpenLogStatusFailed)
|
||||
return resp
|
||||
}
|
||||
|
||||
resp := successResponse(map[string]any{
|
||||
"uid": req.UID,
|
||||
"nickname": profile.UserNickname,
|
||||
"avatar": profile.UserAvatar,
|
||||
"coin": balance,
|
||||
"vipLevel": 0,
|
||||
})
|
||||
s.saveCallbackLog(ctx, "hotgame-get-user-info", rawJSON, resp, credential.SysOrigin, req.toQueryUserRequest(), &userID, gameOpenLogStatusSuccess)
|
||||
return resp
|
||||
}
|
||||
|
||||
// HandleHotgameUpdateBalance 按热游协议处理实时扣加币,并用 orderId 做钱包事件幂等。
|
||||
func (s *GameOpenService) HandleHotgameUpdateBalance(ctx context.Context, req HotgameUpdateBalanceRequest, rawJSON string) CallbackResponse {
|
||||
updateReq := req.toUpdateCoinRequest()
|
||||
if err := s.validateHotgameUpdateBalanceRequest(req); err != nil {
|
||||
resp := failResponse(hotgameCodeBadRequest, err.Error())
|
||||
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, "", updateReq, nil, gameOpenLogStatusFailed)
|
||||
return resp
|
||||
}
|
||||
appKey := s.resolveCallbackAppKeyForVendor(ctx, hotgameVendorType, req.GameID, req.Token)
|
||||
if strings.TrimSpace(appKey) == "" {
|
||||
resp := failResponse(hotgameCodeBadRequest, "app key is blank")
|
||||
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, "", updateReq, nil, gameOpenLogStatusFailed)
|
||||
return resp
|
||||
}
|
||||
if !verifySign(req.Sign, buildHotgameUpdateBalanceSign(req, appKey)) {
|
||||
resp := failResponse(hotgameCodeSignatureError, "signature error")
|
||||
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, "", updateReq, nil, gameOpenLogStatusFailed)
|
||||
return resp
|
||||
}
|
||||
|
||||
req.Token = normalizeCallbackToken(req.Token)
|
||||
updateReq.Token = req.Token
|
||||
credential, err := s.java.AuthenticateToken(ctx, req.Token)
|
||||
if err != nil {
|
||||
resp := failResponse(hotgameCodeTokenInvalid, "token invalid")
|
||||
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, "", updateReq, nil, gameOpenLogStatusFailed)
|
||||
return resp
|
||||
}
|
||||
resolved, err := s.resolveUserWithToken(ctx, credential, req.UID)
|
||||
if err != nil {
|
||||
resp := failResponse(hotgameCodeTokenInvalid, "uid token mismatch")
|
||||
userID := int64(credential.UserID)
|
||||
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed)
|
||||
return resp
|
||||
}
|
||||
userID := resolved.userID
|
||||
|
||||
eventID := "GAME_HOTGAME:" + strings.TrimSpace(req.OrderID)
|
||||
exists, err := s.java.ExistsGoldEvent(ctx, eventID)
|
||||
if err != nil {
|
||||
resp := failResponse(hotgameCodeServerError, "event check failed")
|
||||
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed)
|
||||
return resp
|
||||
}
|
||||
if exists {
|
||||
resp := failResponse(hotgameCodeDuplicateOrder, "duplicate orderId")
|
||||
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed)
|
||||
return resp
|
||||
}
|
||||
|
||||
if err := s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{
|
||||
ReceiptType: receiptTypeFromType(req.Type),
|
||||
UserID: userID,
|
||||
SysOrigin: credential.SysOrigin,
|
||||
EventID: eventID,
|
||||
Remark: fmt.Sprintf("hotgame=%s type=%d round=%s", req.GameID, req.Type, updateReq.RoundID),
|
||||
Amount: integration.NewPennyAmountPayloadFromDollar(req.Coin),
|
||||
CloseDelayAsset: false,
|
||||
OpUserType: "APP",
|
||||
CustomizeOrigin: "GAME_HOTGAME_" + strings.TrimSpace(req.GameID),
|
||||
CustomizeOriginDesc: "HOTGAME[" + strings.TrimSpace(req.GameID) + "]",
|
||||
}); err != nil {
|
||||
// 热游文档要求扣款失败必须返回失败码;支出失败统一归到余额不足类,防止游戏继续开奖。
|
||||
code := hotgameCodeServerError
|
||||
if req.Type == 1 || looksLikeInsufficientBalance(err) {
|
||||
code = hotgameCodeInsufficientBalance
|
||||
}
|
||||
resp := failResponse(code, err.Error())
|
||||
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed)
|
||||
return resp
|
||||
}
|
||||
|
||||
balance, err := s.readUserBalance(ctx, userID)
|
||||
if err != nil {
|
||||
resp := failResponse(hotgameCodeServerError, "balance load failed")
|
||||
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusFailed)
|
||||
return resp
|
||||
}
|
||||
|
||||
resp := successResponse(map[string]any{"coin": balance, "responseId": eventID})
|
||||
s.saveCallbackLog(ctx, "hotgame-update-balance", rawJSON, resp, credential.SysOrigin, updateReq, &userID, gameOpenLogStatusSuccess)
|
||||
s.reportHotgameConsumeTaskEvent(ctx, credential.SysOrigin, userID, updateReq)
|
||||
return resp
|
||||
}
|
||||
|
||||
func (req HotgameGetUserInfoRequest) toQueryUserRequest() QueryUserRequest {
|
||||
return QueryUserRequest{
|
||||
GameID: req.GameID,
|
||||
UID: req.UID,
|
||||
Token: req.Token,
|
||||
Sign: req.Sign,
|
||||
}
|
||||
}
|
||||
|
||||
func (req HotgameUpdateBalanceRequest) toUpdateCoinRequest() UpdateCoinRequest {
|
||||
return UpdateCoinRequest{
|
||||
OrderID: req.OrderID,
|
||||
GameID: req.GameID,
|
||||
RoundID: hotgameRoundIDString(req.RoundID),
|
||||
UID: req.UID,
|
||||
Coin: req.Coin,
|
||||
Type: req.Type,
|
||||
Token: req.Token,
|
||||
Sign: req.Sign,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *GameOpenService) validateHotgameGetUserInfoRequest(req HotgameGetUserInfoRequest) error {
|
||||
switch {
|
||||
case strings.TrimSpace(req.GameID) == "":
|
||||
return newBadRequest("gameId is required")
|
||||
case strings.TrimSpace(req.UID) == "":
|
||||
return newBadRequest("uid is required")
|
||||
case strings.TrimSpace(req.Token) == "":
|
||||
return newBadRequest("token is required")
|
||||
case strings.TrimSpace(req.Sign) == "":
|
||||
return newBadRequest("sign is required")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *GameOpenService) validateHotgameUpdateBalanceRequest(req HotgameUpdateBalanceRequest) error {
|
||||
switch {
|
||||
case strings.TrimSpace(req.OrderID) == "":
|
||||
return newBadRequest("orderId is required")
|
||||
case strings.TrimSpace(req.GameID) == "":
|
||||
return newBadRequest("gameId is required")
|
||||
case strings.TrimSpace(hotgameRoundIDString(req.RoundID)) == "":
|
||||
return newBadRequest("roundId is required")
|
||||
case strings.TrimSpace(req.UID) == "":
|
||||
return newBadRequest("uid is required")
|
||||
case req.Coin <= 0:
|
||||
return newBadRequest("coin is required")
|
||||
case req.Type != 1 && req.Type != 2:
|
||||
return newBadRequest("type must be 1 or 2")
|
||||
case strings.TrimSpace(req.Token) == "":
|
||||
return newBadRequest("token is required")
|
||||
case strings.TrimSpace(req.Sign) == "":
|
||||
return newBadRequest("sign is required")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildHotgameGetUserInfoSign(req HotgameGetUserInfoRequest, key string) string {
|
||||
return md5Hex(req.GameID + req.UID + req.Token + key)
|
||||
}
|
||||
|
||||
func buildHotgameUpdateBalanceSign(req HotgameUpdateBalanceRequest, key string) string {
|
||||
return md5Hex(
|
||||
req.OrderID +
|
||||
req.GameID +
|
||||
hotgameRoundIDString(req.RoundID) +
|
||||
req.UID +
|
||||
fmt.Sprintf("%d", req.Coin) +
|
||||
fmt.Sprintf("%d", req.Type) +
|
||||
req.Token +
|
||||
key,
|
||||
)
|
||||
}
|
||||
|
||||
func hotgameRoundIDString(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
case json.Number:
|
||||
return strings.TrimSpace(typed.String())
|
||||
case float64:
|
||||
if typed == float64(int64(typed)) {
|
||||
return fmt.Sprintf("%d", int64(typed))
|
||||
}
|
||||
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%f", typed), "0"), ".")
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprint(typed))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *GameOpenService) resolveCallbackAppKeyForVendor(ctx context.Context, vendorType string, gameID, token string) string {
|
||||
if s != nil && s.repo.DB != nil {
|
||||
if sysOrigin := callbackTokenSysOrigin(token); sysOrigin != "" {
|
||||
if appKey := s.resolveActiveProviderAppKey(ctx, vendorType, sysOrigin); appKey != "" {
|
||||
return appKey
|
||||
}
|
||||
}
|
||||
if appKey := s.resolveProviderAppKeyByGame(ctx, vendorType, gameID); appKey != "" {
|
||||
return appKey
|
||||
}
|
||||
}
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(s.cfg.GameOpen.AppKey)
|
||||
}
|
||||
|
||||
func (s *GameOpenService) resolveProviderAppKeyByGame(ctx context.Context, vendorType string, gameID string) string {
|
||||
gameID = strings.TrimSpace(gameID)
|
||||
if gameID == "" {
|
||||
return ""
|
||||
}
|
||||
switch strings.ToUpper(strings.TrimSpace(vendorType)) {
|
||||
case "LINGXIAN":
|
||||
return s.resolveLingxianAppKeyByGame(ctx, gameID)
|
||||
case hotgameVendorType:
|
||||
var row struct {
|
||||
AppKey string
|
||||
}
|
||||
err := s.repo.DB.WithContext(ctx).
|
||||
Table("hotgame_provider_config AS cfg").
|
||||
Select("cfg.app_key AS app_key").
|
||||
Joins("JOIN sys_game_list_vendor_ext ext ON ext.sys_origin = cfg.sys_origin AND ext.profile = cfg.profile AND ext.vendor_type = ? AND ext.enabled = ?", hotgameVendorType, true).
|
||||
Where("cfg.active = ? AND ext.vendor_game_id = ?", true, gameID).
|
||||
Order("cfg.update_time DESC").
|
||||
Limit(1).
|
||||
Scan(&row).Error
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(row.AppKey)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (s *GameOpenService) resolveActiveProviderAppKey(ctx context.Context, vendorType string, sysOrigin string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(vendorType)) {
|
||||
case "LINGXIAN":
|
||||
return s.resolveActiveLingxianAppKey(ctx, sysOrigin)
|
||||
case hotgameVendorType:
|
||||
sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin))
|
||||
if sysOrigin == "" {
|
||||
return ""
|
||||
}
|
||||
var row model.HotgameProviderConfig
|
||||
err := s.repo.DB.WithContext(ctx).
|
||||
Where("sys_origin = ? AND active = ?", sysOrigin, true).
|
||||
Order("update_time DESC").
|
||||
Limit(1).
|
||||
First(&row).Error
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(row.AppKey)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func looksLikeInsufficientBalance(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
return strings.Contains(message, "insufficient") ||
|
||||
strings.Contains(message, "balance not made") ||
|
||||
strings.Contains(message, "余额不足")
|
||||
}
|
||||
|
||||
func (s *GameOpenService) reportHotgameConsumeTaskEvent(ctx context.Context, sysOrigin string, userID int64, req UpdateCoinRequest) {
|
||||
if s.taskReporter == nil || receiptTypeFromType(req.Type) != "EXPENDITURE" {
|
||||
return
|
||||
}
|
||||
deltaValue := req.Coin
|
||||
if deltaValue < 0 {
|
||||
deltaValue = -deltaValue
|
||||
}
|
||||
if deltaValue <= 0 {
|
||||
return
|
||||
}
|
||||
payload := map[string]any{
|
||||
"provider": hotgameVendorType,
|
||||
"orderId": strings.TrimSpace(req.OrderID),
|
||||
"gameId": strings.TrimSpace(req.GameID),
|
||||
"roundId": strings.TrimSpace(req.RoundID),
|
||||
}
|
||||
if err := s.taskReporter.ReportSimpleEvent(ctx, sysOrigin, "GAME_HOTGAME_CONSUME:"+strings.TrimSpace(req.OrderID), taskcenter.EventTypeGameConsumeGold, userID, deltaValue, payload); err != nil {
|
||||
log.Printf("report hotgame task event failed: %v", err)
|
||||
}
|
||||
}
|
||||
@ -219,20 +219,7 @@ func (s *GameOpenService) GetIntegrationInfo(ctx context.Context, publicBaseURL
|
||||
}
|
||||
|
||||
func (s *GameOpenService) resolveCallbackAppKey(ctx context.Context, gameID, token string) string {
|
||||
if s != nil && s.repo.DB != nil {
|
||||
if sysOrigin := callbackTokenSysOrigin(token); sysOrigin != "" {
|
||||
if appKey := s.resolveActiveLingxianAppKey(ctx, sysOrigin); appKey != "" {
|
||||
return appKey
|
||||
}
|
||||
}
|
||||
if appKey := s.resolveLingxianAppKeyByGame(ctx, gameID); appKey != "" {
|
||||
return appKey
|
||||
}
|
||||
}
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(s.cfg.GameOpen.AppKey)
|
||||
return s.resolveCallbackAppKeyForVendor(ctx, "LINGXIAN", gameID, token)
|
||||
}
|
||||
|
||||
func (s *GameOpenService) resolveLingxianAppKeyByGame(ctx context.Context, gameID string) string {
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"chatapp3-golang/internal/integration"
|
||||
"chatapp3-golang/internal/model"
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -20,6 +21,7 @@ type stubGateway struct {
|
||||
accountMap map[string]integration.UserProfile
|
||||
balance int64
|
||||
exists bool
|
||||
changeErr error
|
||||
cmd integration.GoldReceiptCommand
|
||||
}
|
||||
|
||||
@ -72,7 +74,7 @@ func (s *stubGateway) ExistsGoldEvent(context.Context, string) (bool, error) {
|
||||
|
||||
func (s *stubGateway) ChangeGoldBalance(_ context.Context, cmd integration.GoldReceiptCommand) error {
|
||||
s.cmd = cmd
|
||||
return nil
|
||||
return s.changeErr
|
||||
}
|
||||
|
||||
func TestHandleQueryUser(t *testing.T) {
|
||||
@ -351,6 +353,97 @@ func TestCallbacksUseLingxianProviderConfigAppKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHotgameGetUserInfoUsesHotgameSign(t *testing.T) {
|
||||
gateway := &stubGateway{
|
||||
credential: integration.UserCredential{UserID: 1234567, SysOrigin: "LIKEI"},
|
||||
profile: integration.UserProfile{
|
||||
ID: 1234567,
|
||||
Account: "1234567",
|
||||
UserNickname: "tester",
|
||||
UserAvatar: "https://example.com/avatar.png",
|
||||
},
|
||||
balance: 88,
|
||||
}
|
||||
service := NewGameOpenService(config.Config{
|
||||
GameOpen: config.GameOpenConfig{AppKey: "hotgame-key"},
|
||||
}, nil, gateway)
|
||||
req := HotgameGetUserInfoRequest{
|
||||
GameID: "1",
|
||||
UID: "1234567",
|
||||
Token: "token",
|
||||
}
|
||||
req.Sign = buildHotgameGetUserInfoSign(req, "hotgame-key")
|
||||
|
||||
resp := service.HandleHotgameGetUserInfo(context.Background(), req, "{}")
|
||||
if resp.ErrorCode != 0 {
|
||||
t.Fatalf("HandleHotgameGetUserInfo() error = %+v", resp)
|
||||
}
|
||||
data, ok := resp.Data.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("HandleHotgameGetUserInfo() data type = %T", resp.Data)
|
||||
}
|
||||
if got := data["vipLevel"]; got != 0 {
|
||||
t.Fatalf("HandleHotgameGetUserInfo() vipLevel = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHotgameUpdateBalanceAcceptsNumericRoundIDAndDuplicateReturns3001(t *testing.T) {
|
||||
gateway := &stubGateway{
|
||||
credential: integration.UserCredential{UserID: 1234567, SysOrigin: "LIKEI"},
|
||||
profile: integration.UserProfile{ID: 1234567, Account: "1234567"},
|
||||
balance: 99,
|
||||
exists: true,
|
||||
}
|
||||
service := NewGameOpenService(config.Config{
|
||||
GameOpen: config.GameOpenConfig{AppKey: "hotgame-key"},
|
||||
}, nil, gateway)
|
||||
req := HotgameUpdateBalanceRequest{
|
||||
OrderID: "order-1",
|
||||
GameID: "1",
|
||||
RoundID: float64(3199),
|
||||
UID: "1234567",
|
||||
Coin: 15,
|
||||
Type: 1,
|
||||
Token: "token",
|
||||
}
|
||||
req.Sign = buildHotgameUpdateBalanceSign(req, "hotgame-key")
|
||||
|
||||
resp := service.HandleHotgameUpdateBalance(context.Background(), req, "{}")
|
||||
if resp.ErrorCode != hotgameCodeDuplicateOrder {
|
||||
t.Fatalf("HandleHotgameUpdateBalance() errorCode = %d, want %d", resp.ErrorCode, hotgameCodeDuplicateOrder)
|
||||
}
|
||||
if gateway.cmd.EventID != "" {
|
||||
t.Fatalf("HandleHotgameUpdateBalance() should not change duplicate order, got %q", gateway.cmd.EventID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHotgameUpdateBalanceDeductFailureReturns2001(t *testing.T) {
|
||||
gateway := &stubGateway{
|
||||
credential: integration.UserCredential{UserID: 1234567, SysOrigin: "LIKEI"},
|
||||
profile: integration.UserProfile{ID: 1234567, Account: "1234567"},
|
||||
balance: 99,
|
||||
changeErr: errors.New("insufficient_balance"),
|
||||
}
|
||||
service := NewGameOpenService(config.Config{
|
||||
GameOpen: config.GameOpenConfig{AppKey: "hotgame-key"},
|
||||
}, nil, gateway)
|
||||
req := HotgameUpdateBalanceRequest{
|
||||
OrderID: "order-1",
|
||||
GameID: "1",
|
||||
RoundID: float64(3199),
|
||||
UID: "1234567",
|
||||
Coin: 15,
|
||||
Type: 1,
|
||||
Token: "token",
|
||||
}
|
||||
req.Sign = buildHotgameUpdateBalanceSign(req, "hotgame-key")
|
||||
|
||||
resp := service.HandleHotgameUpdateBalance(context.Background(), req, "{}")
|
||||
if resp.ErrorCode != hotgameCodeInsufficientBalance {
|
||||
t.Fatalf("HandleHotgameUpdateBalance() errorCode = %d, want %d", resp.ErrorCode, hotgameCodeInsufficientBalance)
|
||||
}
|
||||
}
|
||||
|
||||
func newGameOpenTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
@ -359,6 +452,7 @@ func newGameOpenTestDB(t *testing.T) *gorm.DB {
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&model.LingxianProviderConfig{},
|
||||
&model.HotgameProviderConfig{},
|
||||
&model.SysGameListVendorExt{},
|
||||
&model.GameOpenCallbackLog{},
|
||||
); err != nil {
|
||||
|
||||
@ -14,6 +14,10 @@ type Registry struct {
|
||||
ordered []Provider
|
||||
}
|
||||
|
||||
var emptyGameListUserIDs = map[int64]struct{}{
|
||||
2061394478798794754: {},
|
||||
}
|
||||
|
||||
// NewRegistry 创建厂商注册表。
|
||||
func NewRegistry(providers ...Provider) *Registry {
|
||||
r := &Registry{
|
||||
@ -75,6 +79,9 @@ func (r *Registry) List() ProviderListResponse {
|
||||
|
||||
// ListShortcutGames 返回聚合后的快捷游戏列表。
|
||||
func (r *Registry) ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) {
|
||||
if IsEmptyGameListUser(user.UserID) {
|
||||
return []RoomGameListItem{}, nil
|
||||
}
|
||||
if r == nil || len(r.ordered) == 0 {
|
||||
return []RoomGameListItem{}, nil
|
||||
}
|
||||
@ -96,6 +103,9 @@ func (r *Registry) ListShortcutGames(ctx context.Context, user AuthUser, roomID
|
||||
|
||||
// ListRoomGames 返回聚合后的房间游戏列表。
|
||||
func (r *Registry) ListRoomGames(ctx context.Context, user AuthUser, roomID, category string) (*RoomGameListResponse, error) {
|
||||
if IsEmptyGameListUser(user.UserID) {
|
||||
return &RoomGameListResponse{Items: []RoomGameListItem{}}, nil
|
||||
}
|
||||
if r == nil || len(r.ordered) == 0 {
|
||||
return &RoomGameListResponse{Items: []RoomGameListItem{}}, nil
|
||||
}
|
||||
@ -114,6 +124,11 @@ func (r *Registry) ListRoomGames(ctx context.Context, user AuthUser, roomID, cat
|
||||
return &RoomGameListResponse{Items: items}, nil
|
||||
}
|
||||
|
||||
func IsEmptyGameListUser(userID int64) bool {
|
||||
_, ok := emptyGameListUserIDs[userID]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ResolveByLaunchRequest 根据启动请求解析内部对应的厂商。
|
||||
func (r *Registry) ResolveByLaunchRequest(ctx context.Context, user AuthUser, req LaunchRequest) (Provider, error) {
|
||||
if r == nil {
|
||||
@ -236,6 +251,8 @@ func normalizeProviderAlias(key string) string {
|
||||
switch normalizeKey(key) {
|
||||
case "LEADER":
|
||||
return "LINGXIAN"
|
||||
case "HOTGAME", "HOT_GAME", "REYOU", "LALU":
|
||||
return "HOTGAME"
|
||||
default:
|
||||
return normalizeKey(key)
|
||||
}
|
||||
|
||||
@ -80,6 +80,20 @@ func TestRegistryResolveMapsLeaderAliasToLingxian(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryResolveMapsHotgameAliases(t *testing.T) {
|
||||
registry := NewRegistry(&stubProvider{key: "HOTGAME", name: "热游"})
|
||||
|
||||
for _, alias := range []string{"HOTGAME", "hot_game", "REYOU", "lalu"} {
|
||||
provider, err := registry.Resolve(alias)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve(%q) error = %v", alias, err)
|
||||
}
|
||||
if provider.Key() != "HOTGAME" {
|
||||
t.Fatalf("Resolve(%q) key = %q, want HOTGAME", alias, provider.Key())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryListKeepsRegistrationOrder(t *testing.T) {
|
||||
registry := NewRegistry(
|
||||
&stubProvider{key: "BAISHUN", name: "百顺"},
|
||||
@ -125,6 +139,47 @@ func TestRegistryListRoomGamesAggregatesAndSorts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryListRoomGamesReturnsEmptyForHardcodedTestUser(t *testing.T) {
|
||||
registry := NewRegistry(&stubProvider{
|
||||
key: "BAISHUN",
|
||||
name: "百顺",
|
||||
room: []RoomGameListItem{{GameID: "a", Sort: 30}},
|
||||
})
|
||||
|
||||
resp, err := registry.ListRoomGames(
|
||||
context.Background(),
|
||||
AuthUser{UserID: 2061394478798794754},
|
||||
"room-1",
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRoomGames() error = %v", err)
|
||||
}
|
||||
if len(resp.Items) != 0 {
|
||||
t.Fatalf("ListRoomGames() items = %d, want 0", len(resp.Items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryListShortcutGamesReturnsEmptyForHardcodedTestUser(t *testing.T) {
|
||||
registry := NewRegistry(&stubProvider{
|
||||
key: "BAISHUN",
|
||||
name: "百顺",
|
||||
shortcut: []RoomGameListItem{{GameID: "a", Sort: 30}},
|
||||
})
|
||||
|
||||
items, err := registry.ListShortcutGames(
|
||||
context.Background(),
|
||||
AuthUser{UserID: 2061394478798794754},
|
||||
"room-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ListShortcutGames() error = %v", err)
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("ListShortcutGames() items = %d, want 0", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryLaunchGameResolvesProviderByGameID(t *testing.T) {
|
||||
baishunProvider := &stubProvider{
|
||||
key: "BAISHUN",
|
||||
|
||||
251
internal/service/gameprovider/visibility.go
Normal file
251
internal/service/gameprovider/visibility.go
Normal file
@ -0,0 +1,251 @@
|
||||
package gameprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/integration"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
gameListTargetSysOrigin = "LIKEI"
|
||||
gameListTurkeyCountryCode = "TR"
|
||||
gameListTurkeyBlockLevel = 2
|
||||
ipCountryCachePrefix = "GAME_LIST:IP_COUNTRY:"
|
||||
ipCountryCacheTTL = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// VisibilityGateway 聚合游戏列表门禁需要的 Java 用户区域和等级能力。
|
||||
type VisibilityGateway interface {
|
||||
GetUserRegion(ctx context.Context, userID int64, authorization string) (integration.UserRegion, error)
|
||||
GetUserLevel(ctx context.Context, sysOrigin string, userID int64) (integration.UserLevel, error)
|
||||
ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error)
|
||||
}
|
||||
|
||||
// IPCountryResolver 把请求 IP 解析成国家二字码。
|
||||
type IPCountryResolver interface {
|
||||
CountryCode(ctx context.Context, ip string) (string, error)
|
||||
}
|
||||
|
||||
// VisibilityResult 返回门禁结果,并把已经查到的区域带回路由继续参与 SQL 过滤。
|
||||
type VisibilityResult struct {
|
||||
Allow bool
|
||||
RegionID string
|
||||
RegionCode string
|
||||
}
|
||||
|
||||
// VisibilityPolicy 判断当前用户是否可以看到游戏列表。
|
||||
type VisibilityPolicy struct {
|
||||
java VisibilityGateway
|
||||
ipResolver IPCountryResolver
|
||||
targetCountry string
|
||||
targetSys string
|
||||
blockLevel int
|
||||
}
|
||||
|
||||
// NewVisibilityPolicy 创建 LIKEI 游戏列表门禁:土耳其区域或土耳其 IP 的用户财富等级必须大于 2,其他用户直接展示。
|
||||
func NewVisibilityPolicy(java VisibilityGateway, ipResolver IPCountryResolver) *VisibilityPolicy {
|
||||
return &VisibilityPolicy{
|
||||
java: java,
|
||||
ipResolver: ipResolver,
|
||||
targetCountry: gameListTurkeyCountryCode,
|
||||
targetSys: gameListTargetSysOrigin,
|
||||
blockLevel: gameListTurkeyBlockLevel,
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate 只拦截土耳其区域或土耳其 IP 的低财富等级用户;非土耳其区域且非土耳其 IP 的用户直接展示游戏列表。
|
||||
func (p *VisibilityPolicy) Evaluate(ctx context.Context, user AuthUser, clientIP string) (VisibilityResult, error) {
|
||||
if p == nil || !strings.EqualFold(strings.TrimSpace(user.SysOrigin), p.targetSys) {
|
||||
return VisibilityResult{Allow: true, RegionID: user.RegionID, RegionCode: user.RegionCode}, nil
|
||||
}
|
||||
if user.UserID <= 0 || p.java == nil {
|
||||
return VisibilityResult{}, nil
|
||||
}
|
||||
|
||||
region, err := p.java.GetUserRegion(ctx, user.UserID, user.Authorization)
|
||||
if err != nil {
|
||||
return VisibilityResult{}, err
|
||||
}
|
||||
result := VisibilityResult{
|
||||
RegionID: strings.TrimSpace(region.RegionID),
|
||||
RegionCode: strings.TrimSpace(region.RegionCode),
|
||||
}
|
||||
|
||||
// 区域配置是运营可变数据,所以先从 Java 区域配置解析 TR 对应的 regionCode,再判断用户是否属于土耳其区域。
|
||||
targetRegionCode, err := p.java.ResolveRegionCodeByCountryCode(ctx, user.SysOrigin, p.targetCountry)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
turkeyRegion := matchesTargetRegion(result.RegionCode, targetRegionCode, p.targetCountry)
|
||||
turkeyIP := false
|
||||
if p.ipResolver != nil {
|
||||
countryCode, err := p.ipResolver.CountryCode(ctx, clientIP)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
turkeyIP = strings.EqualFold(strings.TrimSpace(countryCode), p.targetCountry)
|
||||
}
|
||||
|
||||
// 用户区域和当前 IP 都不是土耳其时,直接放行;只有命中土耳其门禁时才需要读取财富等级。
|
||||
if !turkeyRegion && !turkeyIP {
|
||||
result.Allow = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
level, err := p.java.GetUserLevel(ctx, user.SysOrigin, user.UserID)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Allow = level.WealthLevel > p.blockLevel
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func matchesTargetRegion(userRegionCode, targetRegionCode, targetCountry string) bool {
|
||||
userRegionCode = strings.TrimSpace(userRegionCode)
|
||||
targetRegionCode = strings.TrimSpace(targetRegionCode)
|
||||
if targetRegionCode != "" {
|
||||
return strings.EqualFold(userRegionCode, targetRegionCode)
|
||||
}
|
||||
return strings.EqualFold(userRegionCode, strings.TrimSpace(targetCountry))
|
||||
}
|
||||
|
||||
// CachedIPCountryResolver 使用 Redis 缓存 IP 国家码,未命中时按顺序调用几个公开 geoip provider。
|
||||
type CachedIPCountryResolver struct {
|
||||
cache redis.Cmdable
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewCachedIPCountryResolver 创建可复用的 IP 国家解析器。
|
||||
func NewCachedIPCountryResolver(cache redis.Cmdable, timeout time.Duration) *CachedIPCountryResolver {
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
return &CachedIPCountryResolver{
|
||||
cache: cache,
|
||||
httpClient: &http.Client{
|
||||
Timeout: timeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CountryCode 返回 IP 对应国家二字码;解析不到时返回空字符串,调用方负责按不可见处理。
|
||||
func (r *CachedIPCountryResolver) CountryCode(ctx context.Context, ip string) (string, error) {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" || ip == "-" {
|
||||
return "", nil
|
||||
}
|
||||
if cached := r.cachedCountryCode(ctx, ip); cached != "" {
|
||||
return cached, nil
|
||||
}
|
||||
for _, provider := range ipGeoProviders {
|
||||
countryCode := r.requestCountryCode(ctx, provider, ip)
|
||||
if countryCode == "" {
|
||||
continue
|
||||
}
|
||||
r.cacheCountryCode(ctx, ip, countryCode)
|
||||
return countryCode, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (r *CachedIPCountryResolver) cachedCountryCode(ctx context.Context, ip string) string {
|
||||
if r == nil || r.cache == nil {
|
||||
return ""
|
||||
}
|
||||
value, err := r.cache.Get(ctx, ipCountryCachePrefix+ip).Result()
|
||||
if err != nil && !errors.Is(err, redis.Nil) {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func (r *CachedIPCountryResolver) cacheCountryCode(ctx context.Context, ip string, countryCode string) {
|
||||
if r == nil || r.cache == nil || strings.TrimSpace(countryCode) == "" {
|
||||
return
|
||||
}
|
||||
_ = r.cache.Set(ctx, ipCountryCachePrefix+ip, strings.ToUpper(strings.TrimSpace(countryCode)), ipCountryCacheTTL).Err()
|
||||
}
|
||||
|
||||
type ipGeoProvider struct {
|
||||
name string
|
||||
urlFormat string
|
||||
}
|
||||
|
||||
var ipGeoProviders = []ipGeoProvider{
|
||||
{name: "ip.sb", urlFormat: "https://api.ip.sb/geoip/%s"},
|
||||
{name: "freeipapi", urlFormat: "https://freeipapi.com/api/json/%s"},
|
||||
{name: "ipwhois", urlFormat: "https://ipwhois.app/json/%s?format=json"},
|
||||
{name: "ip.nc.gy", urlFormat: "https://ip.nc.gy/json?ip=%s"},
|
||||
}
|
||||
|
||||
func (r *CachedIPCountryResolver) requestCountryCode(ctx context.Context, provider ipGeoProvider, ip string) string {
|
||||
if r == nil || r.httpClient == nil {
|
||||
return ""
|
||||
}
|
||||
endpoint := fmt.Sprintf(provider.urlFormat, url.QueryEscape(ip))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return ""
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil || len(body) == 0 {
|
||||
return ""
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return ""
|
||||
}
|
||||
return countryCodeFromProvider(provider.name, payload)
|
||||
}
|
||||
|
||||
func countryCodeFromProvider(provider string, payload map[string]any) string {
|
||||
switch provider {
|
||||
case "ip.sb":
|
||||
return upperString(payload["country_code"])
|
||||
case "freeipapi":
|
||||
return upperString(payload["countryCode"])
|
||||
case "ipwhois":
|
||||
if success, ok := payload["success"].(bool); ok && !success {
|
||||
return ""
|
||||
}
|
||||
return upperString(payload["country_code"])
|
||||
case "ip.nc.gy":
|
||||
if code := countryObjectCode(payload["country"]); code != "" {
|
||||
return code
|
||||
}
|
||||
return countryObjectCode(payload["registered_country"])
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func countryObjectCode(value any) string {
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return upperString(object["iso_code"])
|
||||
}
|
||||
|
||||
func upperString(value any) string {
|
||||
text, _ := value.(string)
|
||||
return strings.ToUpper(strings.TrimSpace(text))
|
||||
}
|
||||
139
internal/service/gameprovider/visibility_test.go
Normal file
139
internal/service/gameprovider/visibility_test.go
Normal file
@ -0,0 +1,139 @@
|
||||
package gameprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"chatapp3-golang/internal/integration"
|
||||
)
|
||||
|
||||
type stubVisibilityGateway struct {
|
||||
region integration.UserRegion
|
||||
level integration.UserLevel
|
||||
targetRegionCode string
|
||||
}
|
||||
|
||||
func (s stubVisibilityGateway) GetUserRegion(context.Context, int64, string) (integration.UserRegion, error) {
|
||||
return s.region, nil
|
||||
}
|
||||
|
||||
func (s stubVisibilityGateway) GetUserLevel(context.Context, string, int64) (integration.UserLevel, error) {
|
||||
return s.level, nil
|
||||
}
|
||||
|
||||
func (s stubVisibilityGateway) ResolveRegionCodeByCountryCode(context.Context, string, string) (string, error) {
|
||||
return s.targetRegionCode, nil
|
||||
}
|
||||
|
||||
type stubIPCountryResolver struct {
|
||||
countryCode string
|
||||
}
|
||||
|
||||
func (s stubIPCountryResolver) CountryCode(context.Context, string) (string, error) {
|
||||
return s.countryCode, nil
|
||||
}
|
||||
|
||||
const account1007500UserID int64 = 2054163533368717314
|
||||
|
||||
func TestVisibilityPolicyAppliesTurkeyRegionOrIPWealthGate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
region integration.UserRegion
|
||||
ipCountry string
|
||||
wealth int
|
||||
wantAllow bool
|
||||
wantRegion integration.UserRegion
|
||||
}{
|
||||
{
|
||||
name: "turkey region level 2 is blocked",
|
||||
region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
|
||||
ipCountry: "ZA",
|
||||
wealth: 2,
|
||||
wantAllow: false,
|
||||
wantRegion: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
|
||||
},
|
||||
{
|
||||
name: "turkey region level 3 is visible even when ip is not turkey",
|
||||
region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
|
||||
ipCountry: "ZA",
|
||||
wealth: 3,
|
||||
wantAllow: true,
|
||||
wantRegion: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
|
||||
},
|
||||
{
|
||||
name: "turkey ip level 2 is blocked",
|
||||
region: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
|
||||
ipCountry: "TR",
|
||||
wealth: 2,
|
||||
wantAllow: false,
|
||||
wantRegion: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
|
||||
},
|
||||
{
|
||||
name: "turkey ip level 3 is visible",
|
||||
region: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
|
||||
ipCountry: "TR",
|
||||
wealth: 3,
|
||||
wantAllow: true,
|
||||
wantRegion: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
|
||||
},
|
||||
{
|
||||
name: "non turkey region and non turkey ip are visible even at level 1",
|
||||
region: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
|
||||
ipCountry: "ZA",
|
||||
wealth: 1,
|
||||
wantAllow: true,
|
||||
wantRegion: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
|
||||
},
|
||||
{
|
||||
name: "account 1007500 current region and ip are visible",
|
||||
region: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
|
||||
ipCountry: "ZA",
|
||||
wealth: 35,
|
||||
wantAllow: true,
|
||||
wantRegion: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
policy := NewVisibilityPolicy(
|
||||
stubVisibilityGateway{
|
||||
region: tt.region,
|
||||
level: integration.UserLevel{WealthLevel: tt.wealth},
|
||||
targetRegionCode: "土耳其",
|
||||
},
|
||||
stubIPCountryResolver{countryCode: tt.ipCountry},
|
||||
)
|
||||
|
||||
result, err := policy.Evaluate(context.Background(), AuthUser{UserID: account1007500UserID, SysOrigin: "LIKEI"}, "1.1.1.1")
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate() error = %v", err)
|
||||
}
|
||||
if result.Allow != tt.wantAllow {
|
||||
t.Fatalf("Evaluate() allow = %v, want %v", result.Allow, tt.wantAllow)
|
||||
}
|
||||
if result.RegionID != tt.wantRegion.RegionID || result.RegionCode != tt.wantRegion.RegionCode {
|
||||
t.Fatalf("Evaluate() region = %#v, want %#v", result, tt.wantRegion)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibilityPolicySkipsOtherSysOrigin(t *testing.T) {
|
||||
policy := NewVisibilityPolicy(
|
||||
stubVisibilityGateway{
|
||||
region: integration.UserRegion{RegionID: "2046067036517363714", RegionCode: "AR"},
|
||||
level: integration.UserLevel{WealthLevel: 0},
|
||||
targetRegionCode: "TR",
|
||||
},
|
||||
stubIPCountryResolver{countryCode: "SA"},
|
||||
)
|
||||
|
||||
result, err := policy.Evaluate(context.Background(), AuthUser{UserID: 1001, SysOrigin: "ASWAT"}, "1.1.1.1")
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate() error = %v", err)
|
||||
}
|
||||
if !result.Allow {
|
||||
t.Fatalf("Evaluate() allow = false, want true")
|
||||
}
|
||||
}
|
||||
676
internal/service/highwin/admin.go
Normal file
676
internal/service/highwin/admin.go
Normal file
@ -0,0 +1,676 @@
|
||||
package highwin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/common"
|
||||
"chatapp3-golang/internal/service/regionimgroup"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
SimulationKindLuckyGift = "LUCKY_GIFT"
|
||||
SimulationKindGameWin = "GAME_WIN"
|
||||
adminTimeLayout = "2006-01-02 15:04:05"
|
||||
)
|
||||
|
||||
type BroadcastSimulationRequest struct {
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
RegionCode string `json:"regionCode"`
|
||||
Kind string `json:"kind"`
|
||||
SenderUserID string `json:"senderUserId"`
|
||||
RoomID string `json:"roomId"`
|
||||
Multiple string `json:"multiple"`
|
||||
WinAmount string `json:"winAmount"`
|
||||
|
||||
AcceptUserID string `json:"acceptUserId"`
|
||||
GiftID string `json:"giftId"`
|
||||
GiftCandy string `json:"giftCandy"`
|
||||
GiftQuantity string `json:"giftQuantity"`
|
||||
GameID string `json:"gameId"`
|
||||
GameRoundID string `json:"gameRoundId"`
|
||||
GameURL string `json:"gameUrl"`
|
||||
BetAmount string `json:"betAmount"`
|
||||
ProviderOrder string `json:"providerOrderId"`
|
||||
}
|
||||
|
||||
type BroadcastSimulationResponse struct {
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
RegionCode string `json:"regionCode"`
|
||||
GroupID string `json:"groupId"`
|
||||
Type string `json:"type"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
type BroadcastSimulationWorker struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
broadcaster RegionBroadcaster
|
||||
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
config ContinuousSimulationRequest
|
||||
status ContinuousSimulationStatus
|
||||
}
|
||||
|
||||
type ContinuousSimulationRequest struct {
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
RegionCodes []string `json:"regionCodes"`
|
||||
EnableLuckyGift bool `json:"enableLuckyGift"`
|
||||
EnableGameWin bool `json:"enableGameWin"`
|
||||
MinDelaySeconds int `json:"minDelaySeconds"`
|
||||
MaxDelaySeconds int `json:"maxDelaySeconds"`
|
||||
|
||||
BetAmounts []int64 `json:"betAmounts"`
|
||||
Multiples []float64 `json:"multiples"`
|
||||
GameWinAmounts []int64 `json:"gameWinAmounts"`
|
||||
LuckyGiftWinAmounts []int64 `json:"luckyGiftWinAmounts"`
|
||||
GiftCandyOptions []int64 `json:"giftCandyOptions"`
|
||||
GiftQuantityOptions []int64 `json:"giftQuantityOptions"`
|
||||
}
|
||||
|
||||
type ContinuousSimulationStatus struct {
|
||||
Running bool `json:"running"`
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
RegionCodes []string `json:"regionCodes"`
|
||||
StartedAt string `json:"startedAt,omitempty"`
|
||||
LastRunAt string `json:"lastRunAt,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
NextDelaySec int `json:"nextDelaySec"`
|
||||
LuckyGiftCount int64 `json:"luckyGiftCount"`
|
||||
GameWinCount int64 `json:"gameWinCount"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
Config any `json:"config,omitempty"`
|
||||
UpdateTime time.Time `json:"-"`
|
||||
}
|
||||
|
||||
type continuousCandidateUser struct {
|
||||
ID int64
|
||||
}
|
||||
|
||||
type continuousCandidateGame struct {
|
||||
GameID string
|
||||
Cover string
|
||||
}
|
||||
|
||||
type continuousCandidateRoom struct {
|
||||
RoomID int64
|
||||
}
|
||||
|
||||
func NewBroadcastSimulationWorker(db *gorm.DB, redisClient *redis.Client, broadcaster RegionBroadcaster) *BroadcastSimulationWorker {
|
||||
return &BroadcastSimulationWorker{
|
||||
db: db,
|
||||
redis: redisClient,
|
||||
broadcaster: broadcaster,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *BroadcastSimulationWorker) Start(ctx context.Context, req ContinuousSimulationRequest) (*ContinuousSimulationStatus, error) {
|
||||
req = normalizeContinuousRequest(req)
|
||||
if err := validateContinuousRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if w == nil || w.db == nil || w.redis == nil || w.broadcaster == nil {
|
||||
return nil, common.NewAppError(http.StatusInternalServerError, "simulation_worker_not_configured", "simulation worker is not configured")
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
if w.cancel != nil {
|
||||
w.cancel()
|
||||
}
|
||||
workerCtx, cancel := context.WithCancel(context.Background())
|
||||
w.cancel = cancel
|
||||
w.config = req
|
||||
w.status = ContinuousSimulationStatus{
|
||||
Running: true,
|
||||
SysOrigin: req.SysOrigin,
|
||||
RegionCodes: append([]string(nil), req.RegionCodes...),
|
||||
StartedAt: time.Now().Format(adminTimeLayout),
|
||||
Config: req,
|
||||
}
|
||||
status := w.status
|
||||
w.mu.Unlock()
|
||||
|
||||
go w.run(workerCtx, req)
|
||||
return &status, nil
|
||||
}
|
||||
|
||||
func (w *BroadcastSimulationWorker) Stop() *ContinuousSimulationStatus {
|
||||
if w == nil {
|
||||
return &ContinuousSimulationStatus{}
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.cancel != nil {
|
||||
w.cancel()
|
||||
w.cancel = nil
|
||||
}
|
||||
w.status.Running = false
|
||||
w.status.NextDelaySec = 0
|
||||
w.status.UpdateTime = time.Now()
|
||||
status := w.status
|
||||
status.Config = w.config
|
||||
return &status
|
||||
}
|
||||
|
||||
func (w *BroadcastSimulationWorker) Status() *ContinuousSimulationStatus {
|
||||
if w == nil {
|
||||
return &ContinuousSimulationStatus{}
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
status := w.status
|
||||
status.Config = w.config
|
||||
return &status
|
||||
}
|
||||
|
||||
func (w *BroadcastSimulationWorker) run(ctx context.Context, req ContinuousSimulationRequest) {
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
for {
|
||||
delay := randomDelaySeconds(rng, req.MinDelaySeconds, req.MaxDelaySeconds)
|
||||
w.updateStatus(func(status *ContinuousSimulationStatus) {
|
||||
status.NextDelaySec = delay
|
||||
})
|
||||
|
||||
timer := time.NewTimer(time.Duration(delay) * time.Second)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
w.updateStatus(func(status *ContinuousSimulationStatus) {
|
||||
status.Running = false
|
||||
status.NextDelaySec = 0
|
||||
})
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
|
||||
w.runOnce(ctx, req, rng)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *BroadcastSimulationWorker) runOnce(ctx context.Context, req ContinuousSimulationRequest, rng *rand.Rand) {
|
||||
regionCode := req.RegionCodes[rng.Intn(len(req.RegionCodes))]
|
||||
userID, err := w.randomOfflineUser(ctx, req.SysOrigin, rng)
|
||||
if err != nil {
|
||||
w.setLastError(err)
|
||||
return
|
||||
}
|
||||
roomID, err := w.randomActiveRoom(ctx, req.SysOrigin, rng)
|
||||
if err != nil {
|
||||
w.setLastError(err)
|
||||
return
|
||||
}
|
||||
|
||||
if req.EnableLuckyGift {
|
||||
winAmount := pickInt64(rng, req.LuckyGiftWinAmounts)
|
||||
if winAmount <= 0 {
|
||||
winAmount = pickInt64(rng, req.GameWinAmounts)
|
||||
}
|
||||
err := w.sendContinuous(ctx, BroadcastSimulationRequest{
|
||||
SysOrigin: req.SysOrigin,
|
||||
RegionCode: regionCode,
|
||||
Kind: SimulationKindLuckyGift,
|
||||
SenderUserID: strconv.FormatInt(userID, 10),
|
||||
RoomID: strconv.FormatInt(roomID, 10),
|
||||
Multiple: formatFloat(pickFloat64(rng, req.Multiples)),
|
||||
WinAmount: strconv.FormatInt(winAmount, 10),
|
||||
GiftCandy: strconv.FormatInt(pickInt64(rng, req.GiftCandyOptions), 10),
|
||||
GiftQuantity: strconv.FormatInt(pickInt64(rng, req.GiftQuantityOptions), 10),
|
||||
ProviderOrder: fmt.Sprintf("worker-lucky-%d", time.Now().UnixNano()),
|
||||
})
|
||||
if err != nil {
|
||||
w.setLastError(err)
|
||||
} else {
|
||||
w.incrementCount(true)
|
||||
}
|
||||
}
|
||||
|
||||
if req.EnableGameWin {
|
||||
game, err := w.randomGame(ctx, req.SysOrigin)
|
||||
if err != nil {
|
||||
w.setLastError(err)
|
||||
return
|
||||
}
|
||||
err = w.sendContinuous(ctx, BroadcastSimulationRequest{
|
||||
SysOrigin: req.SysOrigin,
|
||||
RegionCode: regionCode,
|
||||
Kind: SimulationKindGameWin,
|
||||
SenderUserID: strconv.FormatInt(userID, 10),
|
||||
RoomID: strconv.FormatInt(roomID, 10),
|
||||
Multiple: formatFloat(pickFloat64(rng, req.Multiples)),
|
||||
WinAmount: strconv.FormatInt(pickInt64(rng, req.GameWinAmounts), 10),
|
||||
GameID: game.GameID,
|
||||
GameURL: game.Cover,
|
||||
GameRoundID: fmt.Sprintf("worker-game-%d", time.Now().UnixNano()),
|
||||
BetAmount: strconv.FormatInt(pickInt64(rng, req.BetAmounts), 10),
|
||||
})
|
||||
if err != nil {
|
||||
w.setLastError(err)
|
||||
} else {
|
||||
w.incrementCount(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func SendSimulationBroadcast(
|
||||
ctx context.Context,
|
||||
broadcaster RegionBroadcaster,
|
||||
req BroadcastSimulationRequest,
|
||||
) (*BroadcastSimulationResponse, error) {
|
||||
if broadcaster == nil {
|
||||
return nil, common.NewAppError(http.StatusInternalServerError, "region_broadcaster_missing", "region broadcaster is missing")
|
||||
}
|
||||
messageType, err := simulationMessageType(req.Kind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userID, err := parseRequiredInt64(req.SenderUserID, "senderUserId")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roomID := strings.TrimSpace(req.RoomID)
|
||||
if roomID == "" {
|
||||
return nil, common.NewAppError(http.StatusBadRequest, "missing_room_id", "roomId is required")
|
||||
}
|
||||
amount, err := parseRequiredInt64(req.WinAmount, "winAmount")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
multiple, err := parseRequiredFloat64(req.Multiple, "multiple")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ShouldBroadcast(multiple, amount) {
|
||||
return nil, common.NewAppError(http.StatusBadRequest, "broadcast_threshold_not_met", "multiple must be >= 10 and winAmount must be > 0")
|
||||
}
|
||||
|
||||
data := simulationData(messageType, req, amount)
|
||||
event := BroadcastEvent{
|
||||
SysOrigin: req.SysOrigin,
|
||||
RegionCode: req.RegionCode,
|
||||
Type: messageType,
|
||||
UserID: userID,
|
||||
RoomID: roomID,
|
||||
Multiple: multiple,
|
||||
Amount: amount,
|
||||
Data: data,
|
||||
}
|
||||
if err := SendRegionBroadcast(ctx, broadcaster, event); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
previewData := cloneData(data)
|
||||
setRequired(previewData, "userId", strconv.FormatInt(userID, 10))
|
||||
setRequired(previewData, "sendUserId", strconv.FormatInt(userID, 10))
|
||||
setRequired(previewData, "senderUserId", strconv.FormatInt(userID, 10))
|
||||
setRequired(previewData, "roomId", roomID)
|
||||
setRequired(previewData, "multiple", normalizeMultiple(multiple))
|
||||
setRequired(previewData, "winMultiple", normalizeMultiple(multiple))
|
||||
setRequired(previewData, "winAmount", amount)
|
||||
setRequired(previewData, "awardAmount", amount)
|
||||
setRequired(previewData, "msg", highWinMessage(previewData, messageType, strconv.FormatInt(userID, 10), multiple, amount))
|
||||
|
||||
return &BroadcastSimulationResponse{
|
||||
SysOrigin: strings.ToUpper(strings.TrimSpace(req.SysOrigin)),
|
||||
RegionCode: strings.TrimSpace(req.RegionCode),
|
||||
Type: messageType,
|
||||
Data: previewData,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *BroadcastSimulationWorker) sendContinuous(ctx context.Context, req BroadcastSimulationRequest) error {
|
||||
_, err := SendSimulationBroadcast(ctx, w.broadcaster, req)
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *BroadcastSimulationWorker) randomOfflineUser(ctx context.Context, sysOrigin string, rng *rand.Rand) (int64, error) {
|
||||
onlineIDs := w.onlineUserIDs(ctx, sysOrigin)
|
||||
query := w.db.WithContext(ctx).
|
||||
Table("user_base_info AS user").
|
||||
Select("user.id AS id").
|
||||
Where("user.origin_sys = ?", sysOrigin).
|
||||
Where("user.id > 0")
|
||||
if len(onlineIDs) > 0 && len(onlineIDs) <= 1000 {
|
||||
query = query.Where("user.id NOT IN ?", onlineIDs)
|
||||
}
|
||||
var users []continuousCandidateUser
|
||||
if err := query.Order("RAND()").Limit(50).Scan(&users).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
filtered := make([]continuousCandidateUser, 0, len(users))
|
||||
onlineSet := make(map[int64]struct{}, len(onlineIDs))
|
||||
for _, id := range onlineIDs {
|
||||
onlineSet[id] = struct{}{}
|
||||
}
|
||||
for _, user := range users {
|
||||
if user.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, online := onlineSet[user.ID]; online {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, user)
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return 0, common.NewAppError(http.StatusNotFound, "simulation_offline_user_not_found", "offline user not found")
|
||||
}
|
||||
return filtered[rng.Intn(len(filtered))].ID, nil
|
||||
}
|
||||
|
||||
func (w *BroadcastSimulationWorker) randomActiveRoom(ctx context.Context, sysOrigin string, rng *rand.Rand) (int64, error) {
|
||||
rooms, err := w.activeRooms(ctx, sysOrigin, 10)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(rooms) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return rooms[rng.Intn(len(rooms))].RoomID, nil
|
||||
}
|
||||
|
||||
func (w *BroadcastSimulationWorker) randomGame(ctx context.Context, sysOrigin string) (continuousCandidateGame, error) {
|
||||
var game continuousCandidateGame
|
||||
err := w.db.WithContext(ctx).
|
||||
Table("sys_game_list_config").
|
||||
Select("game_id AS game_id, cover AS cover").
|
||||
Where("sys_origin = ? AND cover <> ''", sysOrigin).
|
||||
Order("RAND()").
|
||||
Limit(1).
|
||||
Scan(&game).Error
|
||||
if err != nil {
|
||||
return continuousCandidateGame{}, err
|
||||
}
|
||||
if strings.TrimSpace(game.GameID) == "" || strings.TrimSpace(game.Cover) == "" {
|
||||
return continuousCandidateGame{}, common.NewAppError(http.StatusNotFound, "simulation_game_not_found", "game cover not found")
|
||||
}
|
||||
return game, nil
|
||||
}
|
||||
|
||||
func (w *BroadcastSimulationWorker) activeRooms(ctx context.Context, sysOrigin string, minUsers int64) ([]continuousCandidateRoom, error) {
|
||||
var cursor uint64
|
||||
var rooms []continuousCandidateRoom
|
||||
prefix := fmt.Sprintf("voice_room:online:%s:", strings.ToUpper(strings.TrimSpace(sysOrigin)))
|
||||
for {
|
||||
keys, next, err := w.redis.Scan(ctx, cursor, prefix+"*", 100).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, key := range keys {
|
||||
count, err := w.redis.SCard(ctx, key).Result()
|
||||
if err != nil || count <= minUsers {
|
||||
continue
|
||||
}
|
||||
roomID, err := strconv.ParseInt(strings.TrimPrefix(key, prefix), 10, 64)
|
||||
if err != nil || roomID <= 0 {
|
||||
continue
|
||||
}
|
||||
rooms = append(rooms, continuousCandidateRoom{RoomID: roomID})
|
||||
}
|
||||
cursor = next
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return rooms, nil
|
||||
}
|
||||
|
||||
func (w *BroadcastSimulationWorker) onlineUserIDs(ctx context.Context, sysOrigin string) []int64 {
|
||||
var cursor uint64
|
||||
prefix := fmt.Sprintf("voice_room:online:%s:", strings.ToUpper(strings.TrimSpace(sysOrigin)))
|
||||
seen := map[int64]struct{}{}
|
||||
for {
|
||||
keys, next, err := w.redis.Scan(ctx, cursor, prefix+"*", 100).Result()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, key := range keys {
|
||||
members, err := w.redis.SMembers(ctx, key).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, raw := range members {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
if err == nil && id > 0 {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor = next
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
ids := make([]int64, 0, len(seen))
|
||||
for id := range seen {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (w *BroadcastSimulationWorker) updateStatus(fn func(*ContinuousSimulationStatus)) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
fn(&w.status)
|
||||
w.status.UpdateTime = time.Now()
|
||||
}
|
||||
|
||||
func (w *BroadcastSimulationWorker) setLastError(err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
w.updateStatus(func(status *ContinuousSimulationStatus) {
|
||||
status.LastError = err.Error()
|
||||
status.LastRunAt = time.Now().Format(adminTimeLayout)
|
||||
})
|
||||
}
|
||||
|
||||
func (w *BroadcastSimulationWorker) incrementCount(luckyGift bool) {
|
||||
w.updateStatus(func(status *ContinuousSimulationStatus) {
|
||||
status.LastError = ""
|
||||
status.LastRunAt = time.Now().Format(adminTimeLayout)
|
||||
status.TotalCount++
|
||||
if luckyGift {
|
||||
status.LuckyGiftCount++
|
||||
} else {
|
||||
status.GameWinCount++
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeContinuousRequest(req ContinuousSimulationRequest) ContinuousSimulationRequest {
|
||||
req.SysOrigin = strings.ToUpper(strings.TrimSpace(req.SysOrigin))
|
||||
req.RegionCodes = normalizeStringList(req.RegionCodes)
|
||||
if req.MinDelaySeconds < 0 {
|
||||
req.MinDelaySeconds = 0
|
||||
}
|
||||
if req.MaxDelaySeconds < req.MinDelaySeconds {
|
||||
req.MaxDelaySeconds = req.MinDelaySeconds
|
||||
}
|
||||
req.BetAmounts = positiveInt64List(req.BetAmounts, []int64{100, 200, 500, 1000})
|
||||
req.Multiples = positiveFloat64List(req.Multiples, []float64{10, 20, 50})
|
||||
req.GameWinAmounts = positiveInt64List(req.GameWinAmounts, []int64{1000, 2000, 5000})
|
||||
req.LuckyGiftWinAmounts = positiveInt64List(req.LuckyGiftWinAmounts, []int64{1000, 2000, 5000})
|
||||
req.GiftCandyOptions = positiveInt64List(req.GiftCandyOptions, []int64{100, 200, 500})
|
||||
req.GiftQuantityOptions = positiveInt64List(req.GiftQuantityOptions, []int64{1, 5, 10})
|
||||
return req
|
||||
}
|
||||
|
||||
func validateContinuousRequest(req ContinuousSimulationRequest) error {
|
||||
if req.SysOrigin == "" {
|
||||
return common.NewAppError(http.StatusBadRequest, "missing_sys_origin", "sysOrigin is required")
|
||||
}
|
||||
if len(req.RegionCodes) == 0 {
|
||||
return common.NewAppError(http.StatusBadRequest, "missing_region_codes", "regionCodes is required")
|
||||
}
|
||||
if !req.EnableLuckyGift && !req.EnableGameWin {
|
||||
return common.NewAppError(http.StatusBadRequest, "missing_broadcast_type", "enable at least one broadcast type")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeStringList(values []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func positiveInt64List(values []int64, fallback []int64) []int64 {
|
||||
result := make([]int64, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value > 0 {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return append([]int64(nil), fallback...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func positiveFloat64List(values []float64, fallback []float64) []float64 {
|
||||
result := make([]float64, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value >= ThresholdMultiple {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return append([]float64(nil), fallback...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func randomDelaySeconds(rng *rand.Rand, min, max int) int {
|
||||
if max <= min {
|
||||
return min
|
||||
}
|
||||
return min + rng.Intn(max-min+1)
|
||||
}
|
||||
|
||||
func pickInt64(rng *rand.Rand, values []int64) int64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
return values[rng.Intn(len(values))]
|
||||
}
|
||||
|
||||
func pickFloat64(rng *rand.Rand, values []float64) float64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
return values[rng.Intn(len(values))]
|
||||
}
|
||||
|
||||
func formatFloat(value float64) string {
|
||||
return strconv.FormatFloat(value, 'f', -1, 64)
|
||||
}
|
||||
|
||||
func simulationMessageType(kind string) (string, error) {
|
||||
switch strings.ToUpper(strings.TrimSpace(kind)) {
|
||||
case SimulationKindLuckyGift:
|
||||
return MessageTypeLuckyGift, nil
|
||||
case SimulationKindGameWin:
|
||||
return MessageTypeBaishunWin, nil
|
||||
default:
|
||||
return "", common.NewAppError(http.StatusBadRequest, "invalid_broadcast_kind", "kind must be LUCKY_GIFT or GAME_WIN")
|
||||
}
|
||||
}
|
||||
|
||||
func simulationData(messageType string, req BroadcastSimulationRequest, amount int64) map[string]any {
|
||||
now := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
switch messageType {
|
||||
case MessageTypeLuckyGift:
|
||||
data := map[string]any{
|
||||
"businessId": firstNonBlank(req.ProviderOrder, "admin-sim-lucky-gift-"+now),
|
||||
"orderId": firstNonBlank(req.ProviderOrder, "admin-sim-order-"+now),
|
||||
"globalNews": true,
|
||||
"multipleType": "WIN",
|
||||
}
|
||||
setOptional(data, "acceptUserId", req.AcceptUserID)
|
||||
setOptional(data, "giftId", req.GiftID)
|
||||
setOptionalInt64(data, "giftCandy", req.GiftCandy)
|
||||
setOptionalInt64(data, "giftQuantity", req.GiftQuantity)
|
||||
return data
|
||||
default:
|
||||
data := map[string]any{
|
||||
"gameRoundId": firstNonBlank(req.GameRoundID, "admin-sim-game-round-"+now),
|
||||
"currencyDiff": amount,
|
||||
}
|
||||
setOptional(data, "gameId", req.GameID)
|
||||
setOptional(data, "gameUrl", req.GameURL)
|
||||
setOptionalInt64(data, "betAmount", req.BetAmount)
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
func setOptional(data map[string]any, key string, value string) {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
data[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
func setOptionalInt64(data map[string]any, key string, value string) {
|
||||
parsed, err := parseOptionalInt64(value)
|
||||
if err == nil && parsed > 0 {
|
||||
data[key] = parsed
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonBlank(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseRequiredInt64(value string, field string) (int64, error) {
|
||||
parsed, err := parseOptionalInt64(value)
|
||||
if err != nil || parsed <= 0 {
|
||||
return 0, common.NewAppError(http.StatusBadRequest, "invalid_"+field, fmt.Sprintf("%s must be a positive integer", field))
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func parseOptionalInt64(value string) (int64, error) {
|
||||
raw := strings.TrimSpace(value)
|
||||
if raw == "" {
|
||||
return 0, nil
|
||||
}
|
||||
return strconv.ParseInt(raw, 10, 64)
|
||||
}
|
||||
|
||||
func parseRequiredFloat64(value string, field string) (float64, error) {
|
||||
raw := strings.TrimSpace(value)
|
||||
if raw == "" {
|
||||
return 0, common.NewAppError(http.StatusBadRequest, "invalid_"+field, fmt.Sprintf("%s must be a positive number", field))
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
return 0, common.NewAppError(http.StatusBadRequest, "invalid_"+field, fmt.Sprintf("%s must be a positive number", field))
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
var _ RegionBroadcaster = (*regionimgroup.Service)(nil)
|
||||
150
internal/service/highwin/broadcast.go
Normal file
150
internal/service/highwin/broadcast.go
Normal file
@ -0,0 +1,150 @@
|
||||
package highwin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"chatapp3-golang/internal/service/regionimgroup"
|
||||
)
|
||||
|
||||
const (
|
||||
ThresholdMultiple = 10
|
||||
|
||||
MessageTypeLuckyGift = "GAME_LUCKY_GIFT"
|
||||
MessageTypeBaishunWin = "GAME_BAISHUN_WIN"
|
||||
)
|
||||
|
||||
type RegionBroadcaster interface {
|
||||
SendRegionBroadcast(ctx context.Context, req regionimgroup.RegionBroadcastRequest) (*regionimgroup.RegionBroadcastResponse, error)
|
||||
}
|
||||
|
||||
type BroadcastEvent struct {
|
||||
SysOrigin string
|
||||
RegionCode string
|
||||
Type string
|
||||
UserID int64
|
||||
RoomID string
|
||||
Multiple float64
|
||||
Amount int64
|
||||
Data map[string]any
|
||||
}
|
||||
|
||||
func IntegralMultiple(amount, base int64) int64 {
|
||||
if amount <= 0 || base <= 0 {
|
||||
return 0
|
||||
}
|
||||
return amount / base
|
||||
}
|
||||
|
||||
func Multiple(amount, base int64) float64 {
|
||||
if amount <= 0 || base <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(amount) / float64(base)
|
||||
}
|
||||
|
||||
func ShouldBroadcast(multiple float64, amount int64) bool {
|
||||
return amount > 0 && multiple >= ThresholdMultiple
|
||||
}
|
||||
|
||||
func SendRegionBroadcast(ctx context.Context, broadcaster RegionBroadcaster, event BroadcastEvent) error {
|
||||
if broadcaster == nil || !ShouldBroadcast(event.Multiple, event.Amount) {
|
||||
return nil
|
||||
}
|
||||
messageType := strings.TrimSpace(event.Type)
|
||||
if messageType == "" {
|
||||
return fmt.Errorf("high win broadcast type is required")
|
||||
}
|
||||
if event.UserID <= 0 {
|
||||
return fmt.Errorf("high win broadcast user id is required")
|
||||
}
|
||||
roomID := strings.TrimSpace(event.RoomID)
|
||||
if roomID == "" {
|
||||
return fmt.Errorf("high win broadcast room id is required")
|
||||
}
|
||||
|
||||
data := cloneData(event.Data)
|
||||
userIDText := strconv.FormatInt(event.UserID, 10)
|
||||
setRequired(data, "userId", userIDText)
|
||||
setRequired(data, "sendUserId", userIDText)
|
||||
setRequired(data, "senderUserId", userIDText)
|
||||
setRequired(data, "roomId", roomID)
|
||||
setRequired(data, "multiple", normalizeMultiple(event.Multiple))
|
||||
setRequired(data, "winMultiple", normalizeMultiple(event.Multiple))
|
||||
setRequired(data, "winAmount", event.Amount)
|
||||
setRequired(data, "awardAmount", event.Amount)
|
||||
setRequired(data, "msg", highWinMessage(data, messageType, userIDText, event.Multiple, event.Amount))
|
||||
|
||||
_, err := broadcaster.SendRegionBroadcast(ctx, regionimgroup.RegionBroadcastRequest{
|
||||
SysOrigin: strings.TrimSpace(event.SysOrigin),
|
||||
RegionCode: strings.TrimSpace(event.RegionCode),
|
||||
Type: messageType,
|
||||
Data: data,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func cloneData(data map[string]any) map[string]any {
|
||||
clone := map[string]any{}
|
||||
for key, value := range data {
|
||||
clone[key] = value
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func setRequired(data map[string]any, key string, value any) {
|
||||
data[key] = value
|
||||
}
|
||||
|
||||
func normalizeMultiple(value float64) any {
|
||||
if value <= 0 {
|
||||
return 0
|
||||
}
|
||||
if math.Trunc(value) == value {
|
||||
return int64(value)
|
||||
}
|
||||
return math.Round(value*100) / 100
|
||||
}
|
||||
|
||||
func highWinMessage(data map[string]any, messageType, fallbackUser string, multiple float64, amount int64) string {
|
||||
user := firstTextValue(data, "userNickname", "nickname", "account", "actualAccount", "sendUserId", "senderUserId", "userId")
|
||||
if user == "" {
|
||||
user = fallbackUser
|
||||
}
|
||||
return fmt.Sprintf("%s play %s get x %s win %d coins!!!", user, highWinPlayTarget(messageType), formatMultipleText(multiple), amount)
|
||||
}
|
||||
|
||||
func highWinPlayTarget(messageType string) string {
|
||||
switch strings.TrimSpace(messageType) {
|
||||
case MessageTypeLuckyGift:
|
||||
return "lucky_gift"
|
||||
case MessageTypeBaishunWin:
|
||||
return "game"
|
||||
default:
|
||||
return "game"
|
||||
}
|
||||
}
|
||||
|
||||
func firstTextValue(data map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(fmt.Sprint(data[key])); value != "" && value != "<nil>" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func formatMultipleText(value float64) string {
|
||||
normalized := normalizeMultiple(value)
|
||||
switch typed := normalized.(type) {
|
||||
case int64:
|
||||
return strconv.FormatInt(typed, 10)
|
||||
case float64:
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64)
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprint(typed))
|
||||
}
|
||||
}
|
||||
71
internal/service/highwin/broadcast_test.go
Normal file
71
internal/service/highwin/broadcast_test.go
Normal file
@ -0,0 +1,71 @@
|
||||
package highwin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"chatapp3-golang/internal/service/regionimgroup"
|
||||
)
|
||||
|
||||
func TestSendRegionBroadcastBuildsRequiredPayload(t *testing.T) {
|
||||
broadcaster := &fakeRegionBroadcaster{}
|
||||
|
||||
err := SendRegionBroadcast(context.Background(), broadcaster, BroadcastEvent{
|
||||
SysOrigin: "LIKEI",
|
||||
Type: MessageTypeBaishunWin,
|
||||
UserID: 1001,
|
||||
RoomID: "9001",
|
||||
Multiple: 10.5,
|
||||
Amount: 2100,
|
||||
Data: map[string]any{
|
||||
"gameId": 1146,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendRegionBroadcast() error = %v", err)
|
||||
}
|
||||
if len(broadcaster.requests) != 1 {
|
||||
t.Fatalf("requests = %d, want 1", len(broadcaster.requests))
|
||||
}
|
||||
req := broadcaster.requests[0]
|
||||
if req.Type != MessageTypeBaishunWin {
|
||||
t.Fatalf("type = %q, want %q", req.Type, MessageTypeBaishunWin)
|
||||
}
|
||||
if req.Data["userId"] != "1001" || req.Data["roomId"] != "9001" ||
|
||||
req.Data["winAmount"] != int64(2100) || req.Data["multiple"] != 10.5 ||
|
||||
req.Data["msg"] != "1001 play game get x 10.5 win 2100 coins!!!" {
|
||||
t.Fatalf("payload = %+v", req.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendRegionBroadcastSkipsBelowThreshold(t *testing.T) {
|
||||
broadcaster := &fakeRegionBroadcaster{}
|
||||
|
||||
err := SendRegionBroadcast(context.Background(), broadcaster, BroadcastEvent{
|
||||
Type: MessageTypeLuckyGift,
|
||||
UserID: 1001,
|
||||
RoomID: "9001",
|
||||
Multiple: 9,
|
||||
Amount: 900,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendRegionBroadcast() error = %v", err)
|
||||
}
|
||||
if len(broadcaster.requests) != 0 {
|
||||
t.Fatalf("requests = %d, want 0", len(broadcaster.requests))
|
||||
}
|
||||
}
|
||||
|
||||
type fakeRegionBroadcaster struct {
|
||||
requests []regionimgroup.RegionBroadcastRequest
|
||||
}
|
||||
|
||||
func (f *fakeRegionBroadcaster) SendRegionBroadcast(_ context.Context, req regionimgroup.RegionBroadcastRequest) (*regionimgroup.RegionBroadcastResponse, error) {
|
||||
f.requests = append(f.requests, req)
|
||||
return ®ionimgroup.RegionBroadcastResponse{
|
||||
SysOrigin: req.SysOrigin,
|
||||
RegionCode: "AR",
|
||||
GroupID: "@region-ar",
|
||||
Type: req.Type,
|
||||
}, nil
|
||||
}
|
||||
315
internal/service/hotgame/app_provider.go
Normal file
315
internal/service/hotgame/app_provider.go
Normal file
@ -0,0 +1,315 @@
|
||||
package hotgame
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"chatapp3-golang/internal/common"
|
||||
"chatapp3-golang/internal/service/gameprovider"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Key 返回统一厂商标识,和后台游戏来源 HOTGAME 保持一致。
|
||||
func (p *AppProvider) Key() string {
|
||||
return vendorType
|
||||
}
|
||||
|
||||
// DisplayName 返回后台和调试接口展示名。
|
||||
func (p *AppProvider) DisplayName() string {
|
||||
return displayName
|
||||
}
|
||||
|
||||
// SupportsLaunch 判断统一启动请求是否命中热游配置。
|
||||
func (p *AppProvider) SupportsLaunch(ctx context.Context, user gameprovider.AuthUser, req gameprovider.LaunchRequest) (bool, error) {
|
||||
_, err := p.service.findGameRow(ctx, user.SysOrigin, req)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
var appErr *common.AppError
|
||||
if errors.As(err, &appErr) && appErr.Code == "game_not_found" {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
// ListShortcutGames 返回统一快捷列表,热游沿用完整列表前 5 个。
|
||||
func (p *AppProvider) ListShortcutGames(ctx context.Context, user gameprovider.AuthUser, roomID string) ([]gameprovider.RoomGameListItem, error) {
|
||||
resp, err := p.ListRoomGames(ctx, user, roomID, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp.Items) > 5 {
|
||||
resp.Items = resp.Items[:5]
|
||||
}
|
||||
return resp.Items, nil
|
||||
}
|
||||
|
||||
// ListRoomGames 返回用户当前区域可见的热游房间游戏列表。
|
||||
func (p *AppProvider) ListRoomGames(ctx context.Context, user gameprovider.AuthUser, roomID, category string) (*gameprovider.RoomGameListResponse, error) {
|
||||
items, err := p.service.listRoomGames(ctx, user.SysOrigin, user.RegionID, category)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &gameprovider.RoomGameListResponse{Items: items}, nil
|
||||
}
|
||||
|
||||
// GetRoomState 热游 H5 不需要 Go 持久化房间状态,统一入口只返回空闲态。
|
||||
func (p *AppProvider) GetRoomState(ctx context.Context, user gameprovider.AuthUser, roomID string) (*gameprovider.RoomStateResponse, error) {
|
||||
return &gameprovider.RoomStateResponse{RoomID: roomID, State: "IDLE", Provider: vendorType}, nil
|
||||
}
|
||||
|
||||
// LaunchGame 生成热游 H5 URL,uid/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 热游关闭只影响客户端 WebView,Go 侧不保留会话状态。
|
||||
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
|
||||
}
|
||||
}
|
||||
446
internal/service/hotgame/catalog.go
Normal file
446
internal/service/hotgame/catalog.go
Normal file
@ -0,0 +1,446 @@
|
||||
package hotgame
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/model"
|
||||
"chatapp3-golang/internal/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var defaultCatalogGames = []defaultCatalogGame{
|
||||
{
|
||||
GameID: "1",
|
||||
Name: "幸运77(Lucky77)",
|
||||
TestURL: "https://hk.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}",
|
||||
ProdURL: "https://cdn-res.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}",
|
||||
},
|
||||
{
|
||||
GameID: "13",
|
||||
Name: "海盗捕鱼(PirateFishing)",
|
||||
TestURL: "https://hk.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
|
||||
ProdURL: "https://cdn-res.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
|
||||
},
|
||||
{
|
||||
GameID: "19",
|
||||
Name: "FortuneSlot (宝石solt)",
|
||||
TestURL: "https://hk.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
|
||||
ProdURL: "https://cdn-res.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
|
||||
},
|
||||
{
|
||||
GameID: "15",
|
||||
Name: "美味摩天轮(Delicious)",
|
||||
TestURL: "https://hk.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
|
||||
ProdURL: "https://cdn-res.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
|
||||
},
|
||||
{
|
||||
GameID: "20",
|
||||
Name: "水果派对(FruitParty)",
|
||||
TestURL: "https://hk.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
|
||||
ProdURL: "https://cdn-res.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
|
||||
},
|
||||
{
|
||||
GameID: "16",
|
||||
Name: "太空摩天轮(GreedyStar)",
|
||||
TestURL: "https://hk.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
|
||||
ProdURL: "https://cdn-res.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}",
|
||||
},
|
||||
}
|
||||
|
||||
// SyncCatalog 把表格里确认过的热游可用游戏同步到本地目录表。
|
||||
func (s *Service) SyncCatalog(ctx context.Context, req SyncCatalogRequest) (*SyncCatalogResponse, error) {
|
||||
sysOrigin := normalizeSysOrigin(req.SysOrigin)
|
||||
profile, err := s.requestProfile(ctx, sysOrigin, req.Profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.requireRuntimeConfigForProfile(ctx, sysOrigin, profile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filter := buildVendorGameIDFilter(req.VendorGameIDs)
|
||||
resp := &SyncCatalogResponse{}
|
||||
for _, game := range defaultCatalogGames {
|
||||
vendorGameID := strings.TrimSpace(game.GameID)
|
||||
if vendorGameID == "" {
|
||||
resp.Skipped++
|
||||
continue
|
||||
}
|
||||
if len(filter) > 0 {
|
||||
if _, ok := filter[vendorGameID]; !ok {
|
||||
resp.Skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
id, err := utils.NextID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
launchURL := hotgameLaunchURLForProfile(game, profile)
|
||||
record := model.HotgameGameCatalog{
|
||||
ID: id,
|
||||
SysOrigin: sysOrigin,
|
||||
Profile: profile,
|
||||
InternalGameID: internalGameID(vendorGameID),
|
||||
VendorGameID: vendorGameID,
|
||||
Name: game.Name,
|
||||
Cover: "",
|
||||
PreviewURL: launchURL,
|
||||
DownloadURL: launchURL,
|
||||
PackageVersion: "2026",
|
||||
RawJSON: utils.MustJSONString(game, ""),
|
||||
Status: catalogEnabled,
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "sys_origin"}, {Name: "profile"}, {Name: "vendor_game_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"internal_game_id", "name", "cover", "preview_url", "download_url",
|
||||
"package_version", "raw_json", "status", "update_time",
|
||||
}),
|
||||
}).Create(&record).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Force {
|
||||
resp.Updated++
|
||||
} else {
|
||||
resp.Inserted++
|
||||
}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// PageCatalog 返回热游目录,并标记每个目录游戏是否已经进入统一游戏列表。
|
||||
func (s *Service) PageCatalog(ctx context.Context, sysOrigin, profile, keyword string, cursor, limit int) (*CatalogPageResponse, error) {
|
||||
sysOrigin = normalizeSysOrigin(sysOrigin)
|
||||
profile, err := s.requestProfile(ctx, sysOrigin, profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cursor = normalizePageCursor(cursor)
|
||||
limit = normalizePageLimit(limit)
|
||||
|
||||
countQuery := s.buildCatalogQuery(ctx, sysOrigin, profile, keyword)
|
||||
var total int64
|
||||
if err := countQuery.Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type row struct {
|
||||
VendorGameID string
|
||||
Profile string
|
||||
InternalGameID string
|
||||
Name string
|
||||
Cover string
|
||||
PreviewURL string
|
||||
DownloadURL string
|
||||
PackageVersion string
|
||||
Status string
|
||||
AddedConfigID *int64
|
||||
Showcase *bool
|
||||
UpdateTime string
|
||||
}
|
||||
var rows []row
|
||||
if err := s.buildCatalogQuery(ctx, sysOrigin, profile, keyword).
|
||||
Select(`
|
||||
cat.vendor_game_id AS vendor_game_id,
|
||||
cat.profile AS profile,
|
||||
cat.internal_game_id AS internal_game_id,
|
||||
cat.name AS name,
|
||||
cat.cover AS cover,
|
||||
cat.preview_url AS preview_url,
|
||||
cat.download_url AS download_url,
|
||||
cat.package_version AS package_version,
|
||||
cat.status AS status,
|
||||
cfg.id AS added_config_id,
|
||||
cfg.is_showcase AS showcase,
|
||||
DATE_FORMAT(cat.update_time, '%Y-%m-%d %H:%i:%s') AS update_time
|
||||
`).
|
||||
Order("CAST(cat.vendor_game_id AS UNSIGNED) ASC").
|
||||
Limit(limit).
|
||||
Offset((cursor - 1) * limit).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]CatalogItem, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, CatalogItem{
|
||||
VendorGameID: row.VendorGameID,
|
||||
Profile: normalizeProfile(row.Profile),
|
||||
GameID: row.InternalGameID,
|
||||
Name: row.Name,
|
||||
Cover: row.Cover,
|
||||
PreviewURL: row.PreviewURL,
|
||||
DownloadURL: row.DownloadURL,
|
||||
PackageVersion: row.PackageVersion,
|
||||
Status: row.Status,
|
||||
Added: row.AddedConfigID != nil && *row.AddedConfigID > 0,
|
||||
Showcase: row.Showcase != nil && *row.Showcase,
|
||||
UpdateTime: row.UpdateTime,
|
||||
})
|
||||
}
|
||||
return &CatalogPageResponse{Cursor: cursor, Limit: limit, Records: items, Total: total}, nil
|
||||
}
|
||||
|
||||
func (s *Service) buildCatalogQuery(ctx context.Context, sysOrigin, profile, keyword string) *gorm.DB {
|
||||
query := s.db.WithContext(ctx).
|
||||
Table("hotgame_game_catalog AS cat").
|
||||
Joins(`
|
||||
LEFT JOIN sys_game_list_vendor_ext ext
|
||||
ON ext.sys_origin = cat.sys_origin
|
||||
AND ext.profile = cat.profile
|
||||
AND ext.vendor_type = ?
|
||||
AND ext.vendor_game_id = cat.vendor_game_id
|
||||
AND ext.enabled = 1
|
||||
`, vendorType).
|
||||
Joins(`
|
||||
LEFT JOIN sys_game_list_config cfg
|
||||
ON cfg.id = ext.game_list_config_id
|
||||
AND cfg.sys_origin = cat.sys_origin
|
||||
AND cfg.game_origin = ?
|
||||
`, vendorType).
|
||||
Where("cat.sys_origin = ? AND cat.profile = ?", sysOrigin, profile)
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
query = query.Where("cat.name LIKE ? OR cat.internal_game_id LIKE ? OR cat.vendor_game_id LIKE ?", like, like, like)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// FetchAdminCatalog 供后台按钮调用,热游没有远程目录接口,因此读取内置对接表目录。
|
||||
func (s *Service) FetchAdminCatalog(ctx context.Context, req SyncCatalogRequest) (*SyncCatalogResponse, error) {
|
||||
return s.SyncCatalog(ctx, req)
|
||||
}
|
||||
|
||||
// ImportCatalogGames 把热游目录导入统一游戏列表,app 侧列表随后会通过统一 provider 返回。
|
||||
func (s *Service) ImportCatalogGames(ctx context.Context, sysOrigin, profile string, vendorGameIDs []string, defaultShowcase ...bool) (*ImportCatalogResponse, error) {
|
||||
sysOrigin = normalizeSysOrigin(sysOrigin)
|
||||
profile, err := s.requestProfile(ctx, sysOrigin, profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tx := s.db.WithContext(ctx).Begin()
|
||||
if tx.Error != nil {
|
||||
return nil, tx.Error
|
||||
}
|
||||
resp, err := s.importCatalogGamesTx(tx, sysOrigin, profile, buildVendorGameIDFilter(vendorGameIDs), resolveImportDefaultShowcase(defaultShowcase...))
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.clearJavaGameListCache(ctx)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// SyncAdminGames 一次完成目录同步和统一游戏列表导入,便于后台首次配置。
|
||||
func (s *Service) SyncAdminGames(ctx context.Context, req SyncCatalogRequest) (*SyncAdminCatalogResponse, error) {
|
||||
syncResp, err := s.SyncCatalog(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
importResp, err := s.ImportCatalogGames(ctx, req.SysOrigin, req.Profile, req.VendorGameIDs, resolveBoolPtrDefault(req.DefaultShowcase, true))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SyncAdminCatalogResponse{
|
||||
Inserted: syncResp.Inserted,
|
||||
Updated: syncResp.Updated,
|
||||
Skipped: syncResp.Skipped,
|
||||
Existing: importResp.Existing,
|
||||
Imported: importResp.Imported,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) importCatalogGamesTx(tx *gorm.DB, sysOrigin, profile string, filter map[string]struct{}, defaultShowcase bool) (*ImportCatalogResponse, error) {
|
||||
query := tx.Where("sys_origin = ? AND profile = ? AND status = ?", sysOrigin, profile, catalogEnabled)
|
||||
if len(filter) > 0 {
|
||||
ids := make([]string, 0, len(filter))
|
||||
for id := range filter {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
query = query.Where("vendor_game_id IN ?", ids)
|
||||
}
|
||||
|
||||
var catalogs []model.HotgameGameCatalog
|
||||
if err := query.Order("CAST(vendor_game_id AS UNSIGNED) ASC").Find(&catalogs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := &ImportCatalogResponse{}
|
||||
for _, catalog := range catalogs {
|
||||
vendorGameID := strings.TrimSpace(catalog.VendorGameID)
|
||||
if vendorGameID == "" {
|
||||
continue
|
||||
}
|
||||
now := time.Now()
|
||||
fullScreen := false
|
||||
var ext model.SysGameListVendorExt
|
||||
err := tx.Where("sys_origin = ? AND profile = ? AND vendor_type = ? AND vendor_game_id = ?", sysOrigin, profile, vendorType, vendorGameID).First(&ext).Error
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return nil, err
|
||||
}
|
||||
if err == nil && ext.GameListConfigID > 0 {
|
||||
if err := s.refreshExistingGame(tx, ext.GameListConfigID, catalog, fullScreen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp.Existing++
|
||||
continue
|
||||
}
|
||||
|
||||
nextCfgID, idErr := utils.NextID()
|
||||
if idErr != nil {
|
||||
return nil, idErr
|
||||
}
|
||||
cfg := model.SysGameListConfig{
|
||||
ID: nextCfgID,
|
||||
SysOrigin: sysOrigin,
|
||||
GameOrigin: vendorType,
|
||||
GameID: defaultIfBlank(catalog.InternalGameID, vendorGameID),
|
||||
Name: catalog.Name,
|
||||
Category: roomCategory,
|
||||
GameCode: vendorGameID,
|
||||
// 热游启动地址包含 uid/token/lang 占位符且整体很长,不能在缺少封面时回填到 cover;封面为空时交给客户端展示默认占位,真正启动地址只放在扩展表里。
|
||||
Cover: hotgameCatalogCover(catalog),
|
||||
Showcase: defaultShowcase,
|
||||
Sort: parseSort(vendorGameID),
|
||||
FullScreen: fullScreen,
|
||||
ClientOrigin: "COMMON",
|
||||
GameMode: "[3]",
|
||||
}
|
||||
if err := tx.Create(&cfg).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nextID, idErr := utils.NextID()
|
||||
if idErr != nil {
|
||||
return nil, idErr
|
||||
}
|
||||
newExt := model.SysGameListVendorExt{
|
||||
ID: nextID,
|
||||
GameListConfigID: cfg.ID,
|
||||
SysOrigin: sysOrigin,
|
||||
Profile: profile,
|
||||
VendorType: vendorType,
|
||||
VendorGameID: vendorGameID,
|
||||
LaunchMode: launchModeH5,
|
||||
PackageVersion: catalog.PackageVersion,
|
||||
PackageURL: catalog.DownloadURL,
|
||||
PreviewURL: catalog.PreviewURL,
|
||||
BridgeSchemaVersion: "1.0",
|
||||
ExtraJSON: `{"launchParams":{"gameType":"HOTGAME","screenMode":"seven"}}`,
|
||||
Enabled: true,
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
}
|
||||
if err := tx.Create(&newExt).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp.Imported++
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) refreshExistingGame(tx *gorm.DB, configID int64, catalog model.HotgameGameCatalog, fullScreen bool) error {
|
||||
// 目录链接来自热游正式表格,重新同步时要同时刷新主表展示信息和扩展表启动入口,避免旧 URL 继续下发给 app。
|
||||
if err := tx.Model(&model.SysGameListConfig{}).
|
||||
Where("id = ? AND game_origin = ?", configID, vendorType).
|
||||
Updates(map[string]any{
|
||||
"name": catalog.Name,
|
||||
// 这里和新导入保持一致,只刷新真实封面,避免把 H5 启动链接写入封面列导致 MySQL 长度错误。
|
||||
"cover": hotgameCatalogCover(catalog),
|
||||
"full_screen": fullScreen,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.SysGameListVendorExt{}).
|
||||
Where("game_list_config_id = ? AND vendor_type = ?", configID, vendorType).
|
||||
Updates(map[string]any{
|
||||
"package_version": catalog.PackageVersion,
|
||||
"package_url": catalog.DownloadURL,
|
||||
"preview_url": catalog.PreviewURL,
|
||||
"extra_json": `{"launchParams":{"gameType":"HOTGAME","screenMode":"seven"}}`,
|
||||
"update_time": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func hotgameLaunchURLForProfile(game defaultCatalogGame, profile string) string {
|
||||
if normalizeProfile(profile) == profileTest {
|
||||
return strings.TrimSpace(game.TestURL)
|
||||
}
|
||||
return strings.TrimSpace(game.ProdURL)
|
||||
}
|
||||
|
||||
func hotgameCatalogCover(catalog model.HotgameGameCatalog) string {
|
||||
return strings.TrimSpace(catalog.Cover)
|
||||
}
|
||||
|
||||
func resolveImportDefaultShowcase(values ...bool) bool {
|
||||
if len(values) == 0 {
|
||||
return true
|
||||
}
|
||||
return values[0]
|
||||
}
|
||||
|
||||
func resolveBoolPtrDefault(value *bool, fallback bool) bool {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func buildVendorGameIDFilter(vendorGameIDs []string) map[string]struct{} {
|
||||
filter := make(map[string]struct{}, len(vendorGameIDs))
|
||||
for _, id := range vendorGameIDs {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
filter[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
return filter
|
||||
}
|
||||
|
||||
func normalizePageCursor(cursor int) int {
|
||||
if cursor <= 0 {
|
||||
return 1
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
func normalizePageLimit(limit int) int {
|
||||
if limit <= 0 {
|
||||
return 20
|
||||
}
|
||||
if limit > 200 {
|
||||
return 200
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func parseSort(value string) int64 {
|
||||
var sortValue int64
|
||||
_, _ = fmt.Sscanf(strings.TrimSpace(value), "%d", &sortValue)
|
||||
return sortValue
|
||||
}
|
||||
|
||||
func catalogRawMode(rawJSON string) string {
|
||||
var raw struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(strings.TrimSpace(rawJSON)), &raw)
|
||||
return strings.TrimSpace(raw.Mode)
|
||||
}
|
||||
135
internal/service/hotgame/catalog_test.go
Normal file
135
internal/service/hotgame/catalog_test.go
Normal file
@ -0,0 +1,135 @@
|
||||
package hotgame
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/config"
|
||||
"chatapp3-golang/internal/model"
|
||||
"chatapp3-golang/internal/service/gameprovider"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestSyncImportAndListRoomGames(t *testing.T) {
|
||||
db := newHotgameTestDB(t)
|
||||
service := NewService(config.Config{}, db, nil)
|
||||
|
||||
resp, err := service.SyncCatalog(context.Background(), SyncCatalogRequest{
|
||||
SysOrigin: "LIKEI",
|
||||
Profile: "PROD",
|
||||
VendorGameIDs: []string{"1"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SyncCatalog() error = %v", err)
|
||||
}
|
||||
if resp.Inserted != 1 {
|
||||
t.Fatalf("SyncCatalog() inserted = %d, want 1", resp.Inserted)
|
||||
}
|
||||
|
||||
importResp, err := service.ImportCatalogGames(context.Background(), "LIKEI", "PROD", []string{"1"}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportCatalogGames() error = %v", err)
|
||||
}
|
||||
if importResp.Imported != 1 {
|
||||
t.Fatalf("ImportCatalogGames() imported = %d, want 1", importResp.Imported)
|
||||
}
|
||||
|
||||
var cfg model.SysGameListConfig
|
||||
if err := db.Where("game_origin = ? AND game_id = ?", vendorType, "hg_1").First(&cfg).Error; err != nil {
|
||||
t.Fatalf("load imported game config: %v", err)
|
||||
}
|
||||
if cfg.Cover != "" {
|
||||
t.Fatalf("imported cover = %q, want blank because launch url must stay out of cover", cfg.Cover)
|
||||
}
|
||||
|
||||
var ext model.SysGameListVendorExt
|
||||
if err := db.Where("game_list_config_id = ? AND vendor_type = ?", cfg.ID, vendorType).First(&ext).Error; err != nil {
|
||||
t.Fatalf("load imported vendor ext: %v", err)
|
||||
}
|
||||
if !strings.Contains(ext.PreviewURL, "uid={uid}") || !strings.Contains(ext.PackageURL, "token={token}") {
|
||||
t.Fatalf("vendor ext lost launch url placeholders: preview=%q package=%q", ext.PreviewURL, ext.PackageURL)
|
||||
}
|
||||
|
||||
provider := NewAppProvider(service)
|
||||
list, err := provider.ListRoomGames(context.Background(), gameprovider.AuthUser{UserID: 1234567, SysOrigin: "LIKEI"}, "room-1", "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListRoomGames() error = %v", err)
|
||||
}
|
||||
if len(list.Items) != 1 {
|
||||
t.Fatalf("ListRoomGames() items = %d, want 1", len(list.Items))
|
||||
}
|
||||
item := list.Items[0]
|
||||
if item.Provider != vendorType || item.ProviderGameID != "1" || item.GameID != "hg_1" {
|
||||
t.Fatalf("ListRoomGames() item = %+v", item)
|
||||
}
|
||||
if item.Cover != "" {
|
||||
t.Fatalf("ListRoomGames() cover = %q, want blank because hotgame preview url is not an image", item.Cover)
|
||||
}
|
||||
if item.LaunchParams["screenMode"] != "seven" {
|
||||
t.Fatalf("ListRoomGames() screenMode = %v, want seven", item.LaunchParams["screenMode"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchGameReplacesHotgameURLParams(t *testing.T) {
|
||||
db := newHotgameTestDB(t)
|
||||
service := NewService(config.Config{}, db, nil)
|
||||
if _, err := service.SyncAdminGames(context.Background(), SyncCatalogRequest{
|
||||
SysOrigin: "LIKEI",
|
||||
Profile: "PROD",
|
||||
VendorGameIDs: []string{"1"},
|
||||
}); err != nil {
|
||||
t.Fatalf("SyncAdminGames() error = %v", err)
|
||||
}
|
||||
|
||||
provider := NewAppProvider(service)
|
||||
resp, err := provider.LaunchGame(
|
||||
context.Background(),
|
||||
gameprovider.AuthUser{UserID: 1234567, SysOrigin: "LIKEI", Token: "token=="},
|
||||
gameprovider.LaunchRequest{RoomID: "room-1", GameID: "hg_1", Params: map[string]any{"lang": "tr-TR"}},
|
||||
"127.0.0.1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("LaunchGame() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp.Entry.EntryURL, "uid=1234567") {
|
||||
t.Fatalf("LaunchGame() url missing uid: %s", resp.Entry.EntryURL)
|
||||
}
|
||||
if !strings.Contains(resp.Entry.EntryURL, "token=token%3D%3D") {
|
||||
t.Fatalf("LaunchGame() url missing escaped token: %s", resp.Entry.EntryURL)
|
||||
}
|
||||
if !strings.Contains(resp.Entry.EntryURL, "lang=tr") {
|
||||
t.Fatalf("LaunchGame() url missing lang: %s", resp.Entry.EntryURL)
|
||||
}
|
||||
}
|
||||
|
||||
func newHotgameTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&model.HotgameProviderConfig{},
|
||||
&model.HotgameGameCatalog{},
|
||||
&model.SysGameListConfig{},
|
||||
&model.SysGameListVendorExt{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate sqlite: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.HotgameProviderConfig{
|
||||
ID: 1,
|
||||
SysOrigin: "LIKEI",
|
||||
Profile: "PROD",
|
||||
Active: true,
|
||||
AppKey: "hotgame-key",
|
||||
CreateTime: time.Now(),
|
||||
UpdateTime: time.Now(),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create provider config: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
203
internal/service/hotgame/config.go
Normal file
203
internal/service/hotgame/config.go
Normal file
@ -0,0 +1,203 @@
|
||||
package hotgame
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/common"
|
||||
"chatapp3-golang/internal/model"
|
||||
"chatapp3-golang/internal/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func (s *Service) defaultRuntimeConfig(sysOrigin string) runtimeConfig {
|
||||
return runtimeConfig{
|
||||
SysOrigin: normalizeSysOrigin(sysOrigin),
|
||||
Profile: profileProd,
|
||||
AppKey: strings.TrimSpace(s.cfg.Hotgame.AppKey),
|
||||
CallbackBaseURL: strings.TrimSpace(s.cfg.Hotgame.CallbackBaseURL),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) activeProfile(ctx context.Context, sysOrigin string) (string, error) {
|
||||
sysOrigin = normalizeSysOrigin(sysOrigin)
|
||||
var row model.HotgameProviderConfig
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("sys_origin = ? AND active = ?", sysOrigin, true).
|
||||
Order("update_time DESC").
|
||||
Limit(1).
|
||||
First(&row).Error
|
||||
if err == nil {
|
||||
return normalizeProfile(row.Profile), nil
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return profileProd, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
func (s *Service) requestProfile(ctx context.Context, sysOrigin, profile string) (string, error) {
|
||||
if strings.TrimSpace(profile) != "" {
|
||||
return normalizeProfile(profile), nil
|
||||
}
|
||||
return s.activeProfile(ctx, sysOrigin)
|
||||
}
|
||||
|
||||
func (s *Service) resolveRuntimeConfigForProfile(ctx context.Context, sysOrigin, profile string) (runtimeConfig, error) {
|
||||
sysOrigin = normalizeSysOrigin(sysOrigin)
|
||||
profile = normalizeProfile(profile)
|
||||
base := s.defaultRuntimeConfig(sysOrigin)
|
||||
base.Profile = profile
|
||||
|
||||
var row model.HotgameProviderConfig
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("sys_origin = ? AND profile = ?", sysOrigin, profile).
|
||||
Limit(1).
|
||||
First(&row).Error
|
||||
if err == nil {
|
||||
return configToRuntime(base, row), nil
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return base, nil
|
||||
}
|
||||
return runtimeConfig{}, err
|
||||
}
|
||||
|
||||
func (s *Service) requireRuntimeConfigForProfile(ctx context.Context, sysOrigin, profile string) (runtimeConfig, error) {
|
||||
cfg, err := s.resolveRuntimeConfigForProfile(ctx, sysOrigin, profile)
|
||||
if err != nil {
|
||||
return runtimeConfig{}, err
|
||||
}
|
||||
if strings.TrimSpace(cfg.AppKey) == "" {
|
||||
return runtimeConfig{}, common.NewAppError(http.StatusBadRequest, "hotgame_config_missing", "hotgame appKey is required")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// GetProviderConfig 读取某个系统、某个环境的热游配置,并附带当前启用环境。
|
||||
func (s *Service) GetProviderConfig(ctx context.Context, sysOrigin, profile string) (*ProviderConfigItem, error) {
|
||||
sysOrigin = normalizeSysOrigin(sysOrigin)
|
||||
activeProfile, err := s.activeProfile(ctx, sysOrigin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profile, err = s.requestProfile(ctx, sysOrigin, profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtimeCfg, err := s.resolveRuntimeConfigForProfile(ctx, sysOrigin, profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var row model.HotgameProviderConfig
|
||||
_ = s.db.WithContext(ctx).
|
||||
Where("sys_origin = ? AND profile = ?", sysOrigin, profile).
|
||||
Limit(1).
|
||||
First(&row).Error
|
||||
|
||||
return &ProviderConfigItem{
|
||||
ID: row.ID,
|
||||
SysOrigin: runtimeCfg.SysOrigin,
|
||||
Profile: runtimeCfg.Profile,
|
||||
ActiveProfile: activeProfile,
|
||||
Active: runtimeCfg.Profile == activeProfile,
|
||||
AppKey: runtimeCfg.AppKey,
|
||||
CallbackBaseURL: runtimeCfg.CallbackBaseURL,
|
||||
TestUID: runtimeCfg.TestUID,
|
||||
TestToken: runtimeCfg.TestToken,
|
||||
UpdateTime: formatTime(row.UpdateTime),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SaveProviderConfig 保存热游配置;首个配置自动设为当前启用环境。
|
||||
func (s *Service) SaveProviderConfig(ctx context.Context, req SaveProviderConfigRequest) (*ProviderConfigItem, error) {
|
||||
sysOrigin := normalizeSysOrigin(req.SysOrigin)
|
||||
profile := normalizeProfile(req.Profile)
|
||||
appKey := strings.TrimSpace(req.AppKey)
|
||||
if appKey == "" {
|
||||
return nil, common.NewAppError(http.StatusBadRequest, "app_key_required", "appKey is required")
|
||||
}
|
||||
|
||||
id, err := utils.NextID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
row := model.HotgameProviderConfig{
|
||||
ID: id,
|
||||
SysOrigin: sysOrigin,
|
||||
Profile: profile,
|
||||
AppKey: appKey,
|
||||
CallbackBaseURL: strings.TrimSpace(req.CallbackBaseURL),
|
||||
TestUID: strings.TrimSpace(req.TestUID),
|
||||
TestToken: strings.TrimSpace(req.TestToken),
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
}
|
||||
|
||||
var activeCount int64
|
||||
if err := s.db.WithContext(ctx).
|
||||
Model(&model.HotgameProviderConfig{}).
|
||||
Where("sys_origin = ? AND active = ?", sysOrigin, true).
|
||||
Count(&activeCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.Active = activeCount == 0
|
||||
|
||||
if err := s.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "sys_origin"}, {Name: "profile"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"app_key", "callback_base_url", "test_uid", "test_token", "update_time",
|
||||
}),
|
||||
}).Create(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.Active {
|
||||
s.clearJavaGameListCache(ctx)
|
||||
}
|
||||
return s.GetProviderConfig(ctx, sysOrigin, profile)
|
||||
}
|
||||
|
||||
// ActivateProviderProfile 切换 app 侧读取的热游环境,并清理 Java 老游戏列表缓存。
|
||||
func (s *Service) ActivateProviderProfile(ctx context.Context, req ActivateProviderProfileRequest) (*ProviderConfigItem, error) {
|
||||
sysOrigin := normalizeSysOrigin(req.SysOrigin)
|
||||
profile := normalizeProfile(req.Profile)
|
||||
var row model.HotgameProviderConfig
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("sys_origin = ? AND profile = ?", sysOrigin, profile).
|
||||
Limit(1).
|
||||
First(&row).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, common.NewAppError(http.StatusBadRequest, "profile_config_missing", "profile config is missing")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&model.HotgameProviderConfig{}).
|
||||
Where("sys_origin = ?", sysOrigin).
|
||||
Updates(map[string]any{"active": false, "update_time": now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.HotgameProviderConfig{}).
|
||||
Where("sys_origin = ? AND profile = ?", sysOrigin, profile).
|
||||
Updates(map[string]any{"active": true, "update_time": now}).Error
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.clearJavaGameListCache(ctx)
|
||||
return s.GetProviderConfig(ctx, sysOrigin, profile)
|
||||
}
|
||||
|
||||
func (s *Service) clearJavaGameListCache(ctx context.Context) {
|
||||
if s.cache == nil {
|
||||
return
|
||||
}
|
||||
_ = s.cache.Del(ctx, "GAME_LIST").Err()
|
||||
}
|
||||
235
internal/service/hotgame/types.go
Normal file
235
internal/service/hotgame/types.go
Normal file
@ -0,0 +1,235 @@
|
||||
package hotgame
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/config"
|
||||
"chatapp3-golang/internal/model"
|
||||
"chatapp3-golang/internal/service/gameprovider"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
vendorType = "HOTGAME"
|
||||
displayName = "热游"
|
||||
profileProd = "PROD"
|
||||
profileTest = "TEST"
|
||||
launchModeH5 = "H5_REMOTE"
|
||||
catalogEnabled = "ENABLED"
|
||||
roomCategory = "CHAT_ROOM"
|
||||
defaultLaunchLang = "en"
|
||||
)
|
||||
|
||||
type dbHandle interface {
|
||||
WithContext(ctx context.Context) *gorm.DB
|
||||
}
|
||||
|
||||
// Service 负责热游后台配置、目录导入、app 列表和启动 URL 生成。
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
db dbHandle
|
||||
cache redis.Cmdable
|
||||
}
|
||||
|
||||
// NewService 创建热游服务;db 为空时路由仍可安全跳过。
|
||||
func NewService(cfg config.Config, db dbHandle, cache redis.Cmdable) *Service {
|
||||
return &Service{cfg: cfg, db: db, cache: cache}
|
||||
}
|
||||
|
||||
// runtimeConfig 是某个系统、某个环境下实际用于启动和回调验签的配置快照。
|
||||
type runtimeConfig struct {
|
||||
SysOrigin string
|
||||
Profile string
|
||||
AppKey string
|
||||
CallbackBaseURL string
|
||||
TestUID string
|
||||
TestToken string
|
||||
}
|
||||
|
||||
// ProviderConfigItem 是后台热游配置页读取到的配置。
|
||||
type ProviderConfigItem struct {
|
||||
ID int64 `json:"id"`
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
Profile string `json:"profile"`
|
||||
ActiveProfile string `json:"activeProfile"`
|
||||
Active bool `json:"active"`
|
||||
AppKey string `json:"appKey"`
|
||||
CallbackBaseURL string `json:"callbackBaseUrl"`
|
||||
TestUID string `json:"testUid"`
|
||||
TestToken string `json:"testToken"`
|
||||
UpdateTime string `json:"updateTime"`
|
||||
}
|
||||
|
||||
// SaveProviderConfigRequest 是后台保存热游接入配置的入参。
|
||||
type SaveProviderConfigRequest struct {
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
Profile string `json:"profile"`
|
||||
AppKey string `json:"appKey"`
|
||||
CallbackBaseURL string `json:"callbackBaseUrl"`
|
||||
TestUID string `json:"testUid"`
|
||||
TestToken string `json:"testToken"`
|
||||
}
|
||||
|
||||
// ActivateProviderProfileRequest 是后台切换当前启用热游环境的入参。
|
||||
type ActivateProviderProfileRequest struct {
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
Profile string `json:"profile"`
|
||||
}
|
||||
|
||||
// SyncCatalogRequest 是后台同步或导入热游目录的入参。
|
||||
type SyncCatalogRequest struct {
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
Profile string `json:"profile"`
|
||||
VendorGameIDs []string `json:"vendorGameIds"`
|
||||
Force bool `json:"force"`
|
||||
DefaultShowcase *bool `json:"defaultShowcase"`
|
||||
}
|
||||
|
||||
// SyncCatalogResponse 是热游目录同步结果。
|
||||
type SyncCatalogResponse struct {
|
||||
Inserted int `json:"inserted"`
|
||||
Updated int `json:"updated"`
|
||||
Skipped int `json:"skipped"`
|
||||
}
|
||||
|
||||
// ImportCatalogResponse 是热游目录导入统一游戏列表的结果。
|
||||
type ImportCatalogResponse struct {
|
||||
Existing int `json:"existing"`
|
||||
Imported int `json:"imported"`
|
||||
}
|
||||
|
||||
// SyncAdminCatalogResponse 是“同步目录并导入游戏”的合并结果。
|
||||
type SyncAdminCatalogResponse struct {
|
||||
Inserted int `json:"inserted"`
|
||||
Updated int `json:"updated"`
|
||||
Skipped int `json:"skipped"`
|
||||
Existing int `json:"existing"`
|
||||
Imported int `json:"imported"`
|
||||
}
|
||||
|
||||
// CatalogItem 是后台热游目录表格行。
|
||||
type CatalogItem struct {
|
||||
VendorGameID string `json:"vendorGameId"`
|
||||
Profile string `json:"profile"`
|
||||
GameID string `json:"gameId"`
|
||||
Name string `json:"name"`
|
||||
Cover string `json:"cover"`
|
||||
PreviewURL string `json:"previewUrl"`
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
PackageVersion string `json:"packageVersion"`
|
||||
Status string `json:"status"`
|
||||
Added bool `json:"added"`
|
||||
Showcase bool `json:"showcase"`
|
||||
UpdateTime string `json:"updateTime"`
|
||||
}
|
||||
|
||||
// CatalogPageResponse 是热游目录分页响应。
|
||||
type CatalogPageResponse struct {
|
||||
Cursor int `json:"cursor"`
|
||||
Limit int `json:"limit"`
|
||||
Records []CatalogItem `json:"records"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type defaultCatalogGame struct {
|
||||
GameID string
|
||||
Name string
|
||||
TestURL string
|
||||
ProdURL string
|
||||
}
|
||||
|
||||
type gameRow struct {
|
||||
ConfigID int64
|
||||
SysOrigin string
|
||||
Profile string
|
||||
InternalGameID string
|
||||
Name string
|
||||
Category string
|
||||
Cover string
|
||||
Sort int64
|
||||
FullScreen bool
|
||||
VendorGameID string
|
||||
LaunchMode string
|
||||
ExtPackageVersion string
|
||||
ExtPreviewURL string
|
||||
PackageURL string
|
||||
ExtOrientation *int
|
||||
ExtSafeHeight int
|
||||
ExtExtraJSON string
|
||||
CatalogName string
|
||||
CatalogCover string
|
||||
CatalogPreviewURL string
|
||||
CatalogDownloadURL string
|
||||
CatalogPackageVersion string
|
||||
CatalogStatus string
|
||||
}
|
||||
|
||||
var _ gameprovider.Provider = (*AppProvider)(nil)
|
||||
|
||||
// AppProvider 把热游服务适配到统一游戏厂商注册表。
|
||||
type AppProvider struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
// NewAppProvider 创建热游 app 侧 provider。
|
||||
func NewAppProvider(service *Service) *AppProvider {
|
||||
if service == nil {
|
||||
return nil
|
||||
}
|
||||
return &AppProvider{service: service}
|
||||
}
|
||||
|
||||
func normalizeSysOrigin(sysOrigin string) string {
|
||||
sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin))
|
||||
if sysOrigin == "" {
|
||||
return "LIKEI"
|
||||
}
|
||||
return sysOrigin
|
||||
}
|
||||
|
||||
func normalizeProfile(profile string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(profile)) {
|
||||
case "", "PROD", "PRODUCT", "PRODUCTION", "ONLINE", "OFFICIAL", "FORMAL":
|
||||
return profileProd
|
||||
case "TEST", "TESTING", "SANDBOX", "DEV":
|
||||
return profileTest
|
||||
default:
|
||||
return strings.ToUpper(strings.TrimSpace(profile))
|
||||
}
|
||||
}
|
||||
|
||||
func defaultIfBlank(value, fallback string) string {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
|
||||
func formatTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
func configToRuntime(base runtimeConfig, row model.HotgameProviderConfig) runtimeConfig {
|
||||
base.SysOrigin = normalizeSysOrigin(defaultIfBlank(row.SysOrigin, base.SysOrigin))
|
||||
base.Profile = normalizeProfile(defaultIfBlank(row.Profile, base.Profile))
|
||||
base.AppKey = defaultIfBlank(row.AppKey, base.AppKey)
|
||||
base.CallbackBaseURL = defaultIfBlank(row.CallbackBaseURL, base.CallbackBaseURL)
|
||||
base.TestUID = strings.TrimSpace(row.TestUID)
|
||||
base.TestToken = strings.TrimSpace(row.TestToken)
|
||||
return base
|
||||
}
|
||||
|
||||
func internalGameID(vendorGameID string) string {
|
||||
vendorGameID = strings.TrimSpace(vendorGameID)
|
||||
if vendorGameID == "" {
|
||||
return ""
|
||||
}
|
||||
return "hg_" + vendorGameID
|
||||
}
|
||||
@ -47,7 +47,7 @@ func (p *AppProvider) ListShortcutGames(ctx context.Context, user gameprovider.A
|
||||
}
|
||||
|
||||
func (p *AppProvider) ListRoomGames(ctx context.Context, user gameprovider.AuthUser, roomID, category string) (*gameprovider.RoomGameListResponse, error) {
|
||||
items, err := p.service.listRoomGames(ctx, user.SysOrigin, category)
|
||||
items, err := p.service.listRoomGames(ctx, user.SysOrigin, user.RegionID, category)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -116,7 +116,7 @@ func (p *AppProvider) CloseGame(ctx context.Context, user gameprovider.AuthUser,
|
||||
return &gameprovider.RoomStateResponse{RoomID: req.RoomID, State: "IDLE", Provider: vendorType}, nil
|
||||
}
|
||||
|
||||
func (s *Service) listRoomGames(ctx context.Context, sysOrigin, category string) ([]gameprovider.RoomGameListItem, error) {
|
||||
func (s *Service) listRoomGames(ctx context.Context, sysOrigin, regionID, category string) ([]gameprovider.RoomGameListItem, error) {
|
||||
sysOrigin = normalizeSysOrigin(sysOrigin)
|
||||
profile, err := s.activeProfile(ctx, sysOrigin)
|
||||
if err != nil {
|
||||
@ -125,6 +125,10 @@ func (s *Service) listRoomGames(ctx context.Context, sysOrigin, category string)
|
||||
var rows []gameRow
|
||||
query := s.baseGameQuery(ctx, sysOrigin, profile).
|
||||
Where("cfg.sys_origin = ? AND cfg.is_showcase = 1 AND ext.vendor_type = ?", sysOrigin, vendorType)
|
||||
if strings.TrimSpace(regionID) != "" {
|
||||
// regions 为空表示全区域;有值时按用户区域 ID 精确匹配,避免土耳其用户看到其他区域游戏。
|
||||
query = query.Where("(COALESCE(TRIM(cfg.regions), '') = '' OR FIND_IN_SET(?, REPLACE(COALESCE(cfg.regions, ''), ' ', '')) > 0)", strings.TrimSpace(regionID))
|
||||
}
|
||||
if strings.TrimSpace(category) != "" && !strings.EqualFold(strings.TrimSpace(category), roomCategory) {
|
||||
query = query.Where("cfg.category = ?", category)
|
||||
}
|
||||
|
||||
@ -140,6 +140,8 @@ func (s *LuckyGiftService) Draw(ctx context.Context, req LuckyGiftDrawRequest) (
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.publishHighWinRegionBroadcasts(ctx, req, results, balanceAfter)
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
|
||||
51
internal/service/luckygift/high_win.go
Normal file
51
internal/service/luckygift/high_win.go
Normal file
@ -0,0 +1,51 @@
|
||||
package luckygift
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"chatapp3-golang/internal/service/highwin"
|
||||
)
|
||||
|
||||
func (s *LuckyGiftService) publishHighWinRegionBroadcasts(ctx context.Context, req LuckyGiftDrawRequest, results []LuckyGiftDrawResult, balanceAfter int64) {
|
||||
if s == nil || s.highWinBroadcaster == nil || req.GiftPrice <= 0 {
|
||||
return
|
||||
}
|
||||
for _, result := range results {
|
||||
if result.RewardNum <= 0 {
|
||||
continue
|
||||
}
|
||||
multiple := highwin.IntegralMultiple(result.RewardNum, req.GiftPrice)
|
||||
if !highwin.ShouldBroadcast(float64(multiple), result.RewardNum) {
|
||||
continue
|
||||
}
|
||||
data := map[string]any{
|
||||
"businessId": req.BusinessID,
|
||||
"orderId": result.ID,
|
||||
"providerOrderId": result.OrderID,
|
||||
"acceptUserId": strconv.FormatInt(result.AcceptUserID, 10),
|
||||
"giftId": strconv.FormatInt(req.GiftID, 10),
|
||||
"giftCandy": req.GiftPrice,
|
||||
"giftQuantity": req.GiftNum,
|
||||
"multipleType": luckyGiftMultipleType(multiple),
|
||||
"balance": balanceAfter,
|
||||
"globalNews": true,
|
||||
}
|
||||
if err := highwin.SendRegionBroadcast(ctx, s.highWinBroadcaster, highwin.BroadcastEvent{
|
||||
SysOrigin: req.SysOrigin,
|
||||
Type: highwin.MessageTypeLuckyGift,
|
||||
UserID: req.SendUserID,
|
||||
RoomID: strconv.FormatInt(req.RoomID, 10),
|
||||
Multiple: float64(multiple),
|
||||
Amount: result.RewardNum,
|
||||
Data: data,
|
||||
}); err != nil {
|
||||
log.Printf("send lucky gift high win region broadcast failed businessId=%s orderId=%s: %v", req.BusinessID, result.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func luckyGiftMultipleType(_ int64) string {
|
||||
return "WIN"
|
||||
}
|
||||
48
internal/service/luckygift/high_win_test.go
Normal file
48
internal/service/luckygift/high_win_test.go
Normal file
@ -0,0 +1,48 @@
|
||||
package luckygift
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"chatapp3-golang/internal/service/regionimgroup"
|
||||
)
|
||||
|
||||
func TestPublishHighWinRegionBroadcastsSendsOnlyTenTimesAndAbove(t *testing.T) {
|
||||
broadcaster := &fakeLuckyGiftRegionBroadcaster{}
|
||||
service := &LuckyGiftService{highWinBroadcaster: broadcaster}
|
||||
|
||||
service.publishHighWinRegionBroadcasts(context.Background(), LuckyGiftDrawRequest{
|
||||
BusinessID: "biz-1",
|
||||
SysOrigin: "LIKEI",
|
||||
RoomID: 9001,
|
||||
SendUserID: 1001,
|
||||
GiftID: 5001,
|
||||
GiftPrice: 100,
|
||||
GiftNum: 1,
|
||||
}, []LuckyGiftDrawResult{
|
||||
{ID: "low", AcceptUserID: 2001, RewardNum: 900},
|
||||
{ID: "high", OrderID: "LG_high", AcceptUserID: 2002, RewardNum: 1000},
|
||||
}, 90000)
|
||||
|
||||
if len(broadcaster.requests) != 1 {
|
||||
t.Fatalf("requests = %d, want 1", len(broadcaster.requests))
|
||||
}
|
||||
req := broadcaster.requests[0]
|
||||
if req.Type != "GAME_LUCKY_GIFT" {
|
||||
t.Fatalf("type = %q, want GAME_LUCKY_GIFT", req.Type)
|
||||
}
|
||||
if req.Data["sendUserId"] != "1001" || req.Data["roomId"] != "9001" ||
|
||||
req.Data["multiple"] != int64(10) || req.Data["awardAmount"] != int64(1000) ||
|
||||
req.Data["acceptUserId"] != "2002" {
|
||||
t.Fatalf("payload = %+v", req.Data)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeLuckyGiftRegionBroadcaster struct {
|
||||
requests []regionimgroup.RegionBroadcastRequest
|
||||
}
|
||||
|
||||
func (f *fakeLuckyGiftRegionBroadcaster) SendRegionBroadcast(_ context.Context, req regionimgroup.RegionBroadcastRequest) (*regionimgroup.RegionBroadcastResponse, error) {
|
||||
f.requests = append(f.requests, req)
|
||||
return ®ionimgroup.RegionBroadcastResponse{Type: req.Type}, nil
|
||||
}
|
||||
@ -3,6 +3,7 @@ package luckygift
|
||||
import (
|
||||
"chatapp3-golang/internal/config"
|
||||
"chatapp3-golang/internal/integration"
|
||||
"chatapp3-golang/internal/service/highwin"
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
@ -39,10 +40,11 @@ type luckyGiftPorts struct {
|
||||
|
||||
// LuckyGiftService 负责幸运礼物抽奖、三方调用和钱包返奖。
|
||||
type LuckyGiftService struct {
|
||||
cfg config.Config
|
||||
repo luckyGiftPorts
|
||||
java luckyGiftGateway
|
||||
httpClient *http.Client
|
||||
cfg config.Config
|
||||
repo luckyGiftPorts
|
||||
java luckyGiftGateway
|
||||
httpClient *http.Client
|
||||
highWinBroadcaster highwin.RegionBroadcaster
|
||||
}
|
||||
|
||||
// LuckyGiftDrawRequest 是幸运礼物抽奖入参。
|
||||
@ -60,6 +62,10 @@ type LuckyGiftDrawRequest struct {
|
||||
Orders []LuckyGiftDrawOrderInput `json:"orders"`
|
||||
}
|
||||
|
||||
func (s *LuckyGiftService) SetHighWinBroadcaster(broadcaster highwin.RegionBroadcaster) {
|
||||
s.highWinBroadcaster = broadcaster
|
||||
}
|
||||
|
||||
// LuckyGiftDrawOrderInput 表示单个收礼人的抽奖子单。
|
||||
type LuckyGiftDrawOrderInput struct {
|
||||
ID string `json:"id"`
|
||||
|
||||
367
internal/service/managercenter/bd_leader.go
Normal file
367
internal/service/managercenter/bd_leader.go
Normal file
@ -0,0 +1,367 @@
|
||||
package managercenter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type bdLeaderRow struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
Origin string `gorm:"column:sys_origin"`
|
||||
UserID int64 `gorm:"column:user_id"`
|
||||
Contact string `gorm:"column:contact"`
|
||||
RegionID string `gorm:"column:region_id"`
|
||||
CreateTime time.Time `gorm:"column:create_time"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
CreateUser int64 `gorm:"column:create_user"`
|
||||
UpdateUser int64 `gorm:"column:update_user"`
|
||||
}
|
||||
|
||||
func (bdLeaderRow) TableName() string {
|
||||
return "room_bd_lead_base_info"
|
||||
}
|
||||
|
||||
type businessDevelopmentBaseInfoRow struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
Origin string `gorm:"column:sys_origin"`
|
||||
BDLeadUserID *int64 `gorm:"column:bd_lead_user_id"`
|
||||
UserID int64 `gorm:"column:user_id"`
|
||||
Contact string `gorm:"column:contact"`
|
||||
Region string `gorm:"column:region"`
|
||||
MemberQuantity int64 `gorm:"column:member_quantity"`
|
||||
CreateTime time.Time `gorm:"column:create_time"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
CreateUser int64 `gorm:"column:create_user"`
|
||||
UpdateUser int64 `gorm:"column:update_user"`
|
||||
}
|
||||
|
||||
func (businessDevelopmentBaseInfoRow) TableName() string {
|
||||
return "room_business_development_base_info"
|
||||
}
|
||||
|
||||
type userHistoryIdentityRow struct {
|
||||
UserID int64 `gorm:"column:user_id;primaryKey"`
|
||||
IsBD bool `gorm:"column:is_bd"`
|
||||
IsAgent bool `gorm:"column:is_agent"`
|
||||
IsHost bool `gorm:"column:is_host"`
|
||||
IsManager bool `gorm:"column:is_manager"`
|
||||
CreateTime time.Time `gorm:"column:create_time"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
CreateUser int64 `gorm:"column:create_user"`
|
||||
UpdateUser int64 `gorm:"column:update_user"`
|
||||
}
|
||||
|
||||
func (userHistoryIdentityRow) TableName() string {
|
||||
return "user_history_identity"
|
||||
}
|
||||
|
||||
type administratorRow struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
UserID int64 `gorm:"column:user_id"`
|
||||
Status bool `gorm:"column:status"`
|
||||
}
|
||||
|
||||
func (administratorRow) TableName() string {
|
||||
return "sys_administrator"
|
||||
}
|
||||
|
||||
type bdLeaderListRow struct {
|
||||
ID int64 `gorm:"column:id"`
|
||||
UserID int64 `gorm:"column:user_id"`
|
||||
Origin string `gorm:"column:sys_origin"`
|
||||
Contact string `gorm:"column:contact"`
|
||||
RegionID string `gorm:"column:region_id"`
|
||||
CreateTime time.Time `gorm:"column:create_time"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
Account string `gorm:"column:account"`
|
||||
UserAvatar string `gorm:"column:user_avatar"`
|
||||
UserNickname string `gorm:"column:user_nickname"`
|
||||
}
|
||||
|
||||
func (s *Service) ListBDLeaders(ctx context.Context, user AuthUser) (*BDLeaderListResponse, error) {
|
||||
manager, err := s.requireManagerInfo(ctx, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := s.db.WithContext(ctx).
|
||||
Table("room_bd_lead_base_info AS leader").
|
||||
Select(strings.Join([]string{
|
||||
"leader.id",
|
||||
"leader.user_id",
|
||||
"leader.sys_origin",
|
||||
"leader.contact",
|
||||
"leader.region_id",
|
||||
"leader.create_time",
|
||||
"leader.update_time",
|
||||
"user_info.account",
|
||||
"user_info.user_avatar",
|
||||
"user_info.user_nickname",
|
||||
}, ", ")).
|
||||
Joins("LEFT JOIN user_base_info AS user_info ON user_info.id = leader.user_id AND user_info.is_del = ?", false).
|
||||
Where("leader.create_user = ?", user.UserID)
|
||||
|
||||
if origin := managerSysOrigin(manager, user); origin != "" {
|
||||
query = query.Where("leader.sys_origin = ?", origin)
|
||||
}
|
||||
|
||||
var rows []bdLeaderListRow
|
||||
if err := query.Order("leader.create_time DESC, leader.id DESC").Find(&rows).Error; err != nil {
|
||||
return nil, serverError("bd_leader_query_failed", err.Error())
|
||||
}
|
||||
|
||||
records := make([]BDLeaderView, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
records = append(records, bdLeaderListRowToView(row))
|
||||
}
|
||||
return &BDLeaderListResponse{Count: len(records), Records: records}, nil
|
||||
}
|
||||
|
||||
func (s *Service) AddBDLeader(ctx context.Context, user AuthUser, req AddBDLeaderRequest) (*AddBDLeaderResponse, error) {
|
||||
manager, err := s.requireManagerFeature(ctx, user, managerFeatureAddBDLeader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.UserID.Int64() <= 0 {
|
||||
return nil, badRequest("user_required", "userId is required")
|
||||
}
|
||||
|
||||
target, err := s.loadUserRowByID(ctx, req.UserID.Int64())
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, notFound("user_not_found", "user not found")
|
||||
}
|
||||
return nil, serverError("user_query_failed", err.Error())
|
||||
}
|
||||
|
||||
sysOrigin := managerSysOrigin(manager, user)
|
||||
if sysOrigin == "" {
|
||||
sysOrigin = normalizeSysOrigin(target.OriginSys)
|
||||
}
|
||||
if target.OriginSys != "" && sysOrigin != "" && !strings.EqualFold(target.OriginSys, sysOrigin) {
|
||||
return nil, badRequest("bd_leader_origin_mismatch", "target user sysOrigin does not match manager")
|
||||
}
|
||||
|
||||
regionID := strings.TrimSpace(req.RegionID)
|
||||
if regionID == "" {
|
||||
regionID = strings.TrimSpace(manager.RegionID)
|
||||
}
|
||||
if regionID == "" {
|
||||
return nil, badRequest("manager_region_required", "manager region is required")
|
||||
}
|
||||
|
||||
if exists, err := s.userIsAdministrator(ctx, target.ID); err != nil {
|
||||
return nil, err
|
||||
} else if exists {
|
||||
return nil, badRequest("user_is_admin", "administrator cannot be added as BD leader")
|
||||
}
|
||||
|
||||
if exists, err := s.userIsBDLeader(ctx, target.ID); err != nil {
|
||||
return nil, err
|
||||
} else if exists {
|
||||
return nil, badRequest("user_already_bd_leader", "user is already a BD leader")
|
||||
}
|
||||
|
||||
leaderID, err := utils.NextID()
|
||||
if err != nil {
|
||||
return nil, serverError("id_generate_failed", err.Error())
|
||||
}
|
||||
now := time.Now()
|
||||
contact := strings.TrimSpace(req.Contact)
|
||||
leader := bdLeaderRow{
|
||||
ID: leaderID,
|
||||
Origin: sysOrigin,
|
||||
UserID: target.ID,
|
||||
Contact: contact,
|
||||
RegionID: regionID,
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
CreateUser: user.UserID,
|
||||
UpdateUser: user.UserID,
|
||||
}
|
||||
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&leader).Error; err != nil {
|
||||
return serverError("bd_leader_create_failed", err.Error())
|
||||
}
|
||||
if err := upsertBDBaseInfo(tx, leader, user.UserID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertUserHistoryBD(tx, target.ID, user.UserID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &AddBDLeaderResponse{
|
||||
Success: true,
|
||||
Leader: bdLeaderRowToView(leader, target),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) requireManagerInfo(ctx context.Context, user AuthUser) (teamManagerInfoRow, error) {
|
||||
if user.UserID <= 0 {
|
||||
return teamManagerInfoRow{}, unauthorized("invalid_user", "authenticated user is required")
|
||||
}
|
||||
if s.db == nil {
|
||||
return teamManagerInfoRow{}, serviceUnavailable("database_unavailable", "database is unavailable")
|
||||
}
|
||||
|
||||
var row teamManagerInfoRow
|
||||
err := s.db.WithContext(ctx).
|
||||
Table("team_manager_base_info").
|
||||
Where("user_id = ?", user.UserID).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return teamManagerInfoRow{}, forbidden("not_manager", "user is not manager")
|
||||
}
|
||||
if err != nil {
|
||||
return teamManagerInfoRow{}, serverError("manager_query_failed", err.Error())
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func managerSysOrigin(manager teamManagerInfoRow, user AuthUser) string {
|
||||
if origin := strings.TrimSpace(manager.Origin); origin != "" {
|
||||
return origin
|
||||
}
|
||||
return strings.TrimSpace(user.SysOrigin)
|
||||
}
|
||||
|
||||
func (s *Service) userIsAdministrator(ctx context.Context, userID int64) (bool, error) {
|
||||
var count int64
|
||||
if err := s.db.WithContext(ctx).
|
||||
Table("sys_administrator").
|
||||
Where("user_id = ? AND status = ?", userID, true).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, serverError("administrator_query_failed", err.Error())
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *Service) userIsBDLeader(ctx context.Context, userID int64) (bool, error) {
|
||||
var count int64
|
||||
if err := s.db.WithContext(ctx).
|
||||
Table("room_bd_lead_base_info").
|
||||
Where("user_id = ?", userID).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, serverError("bd_leader_query_failed", err.Error())
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func upsertBDBaseInfo(tx *gorm.DB, leader bdLeaderRow, operatorID int64, now time.Time) error {
|
||||
var row businessDevelopmentBaseInfoRow
|
||||
err := tx.Where("user_id = ?", leader.UserID).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
id, idErr := utils.NextID()
|
||||
if idErr != nil {
|
||||
return serverError("id_generate_failed", idErr.Error())
|
||||
}
|
||||
leadUserID := leader.UserID
|
||||
return tx.Create(&businessDevelopmentBaseInfoRow{
|
||||
ID: id,
|
||||
Origin: leader.Origin,
|
||||
BDLeadUserID: &leadUserID,
|
||||
UserID: leader.UserID,
|
||||
Contact: leader.Contact,
|
||||
Region: leader.RegionID,
|
||||
MemberQuantity: 0,
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
CreateUser: operatorID,
|
||||
UpdateUser: operatorID,
|
||||
}).Error
|
||||
}
|
||||
if err != nil {
|
||||
return serverError("bd_info_query_failed", err.Error())
|
||||
}
|
||||
if row.BDLeadUserID != nil && *row.BDLeadUserID > 0 {
|
||||
return badRequest("user_already_bound_bd_leader", "user already has a BD leader")
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
"bd_lead_user_id": leader.UserID,
|
||||
"region": leader.RegionID,
|
||||
"update_time": now,
|
||||
"update_user": operatorID,
|
||||
}
|
||||
if leader.Contact != "" {
|
||||
updates["contact"] = leader.Contact
|
||||
}
|
||||
if leader.Origin != "" {
|
||||
updates["sys_origin"] = leader.Origin
|
||||
}
|
||||
if err := tx.Model(&businessDevelopmentBaseInfoRow{}).
|
||||
Where("id = ?", row.ID).
|
||||
Updates(updates).Error; err != nil {
|
||||
return serverError("bd_info_update_failed", err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertUserHistoryBD(tx *gorm.DB, userID int64, operatorID int64, now time.Time) error {
|
||||
var row userHistoryIdentityRow
|
||||
err := tx.Where("user_id = ?", userID).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return tx.Create(&userHistoryIdentityRow{
|
||||
UserID: userID,
|
||||
IsBD: true,
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
CreateUser: operatorID,
|
||||
UpdateUser: operatorID,
|
||||
}).Error
|
||||
}
|
||||
if err != nil {
|
||||
return serverError("history_identity_query_failed", err.Error())
|
||||
}
|
||||
if err := tx.Model(&userHistoryIdentityRow{}).
|
||||
Where("user_id = ?", userID).
|
||||
Updates(map[string]any{
|
||||
"is_bd": true,
|
||||
"update_time": now,
|
||||
"update_user": operatorID,
|
||||
}).Error; err != nil {
|
||||
return serverError("history_identity_update_failed", err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bdLeaderRowToView(leader bdLeaderRow, user userBaseInfoRow) BDLeaderView {
|
||||
return BDLeaderView{
|
||||
ID: ID(leader.ID),
|
||||
UserID: ID(leader.UserID),
|
||||
Account: user.Account,
|
||||
UserAvatar: user.UserAvatar,
|
||||
UserNickname: user.UserNickname,
|
||||
Contact: leader.Contact,
|
||||
RegionID: leader.RegionID,
|
||||
OriginSys: leader.Origin,
|
||||
CreateTime: leader.CreateTime,
|
||||
UpdateTime: leader.UpdateTime,
|
||||
}
|
||||
}
|
||||
|
||||
func bdLeaderListRowToView(row bdLeaderListRow) BDLeaderView {
|
||||
return BDLeaderView{
|
||||
ID: ID(row.ID),
|
||||
UserID: ID(row.UserID),
|
||||
Account: row.Account,
|
||||
UserAvatar: row.UserAvatar,
|
||||
UserNickname: row.UserNickname,
|
||||
Contact: row.Contact,
|
||||
RegionID: row.RegionID,
|
||||
OriginSys: row.Origin,
|
||||
CreateTime: row.CreateTime,
|
||||
UpdateTime: row.UpdateTime,
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/integration"
|
||||
"chatapp3-golang/internal/model"
|
||||
@ -14,9 +15,18 @@ import (
|
||||
)
|
||||
|
||||
type teamManagerInfoRow struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
UserID int64 `gorm:"column:user_id"`
|
||||
Origin string `gorm:"column:sys_origin"`
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
UserID int64 `gorm:"column:user_id"`
|
||||
Origin string `gorm:"column:sys_origin"`
|
||||
Contact string `gorm:"column:contact"`
|
||||
RegionID string `gorm:"column:region_id"`
|
||||
CanAddBDLeader *bool `gorm:"column:can_add_bd_leader"`
|
||||
CanGiftProps *bool `gorm:"column:can_gift_props"`
|
||||
CanBanUser *bool `gorm:"column:can_ban_user"`
|
||||
CanUnbanUser *bool `gorm:"column:can_unban_user"`
|
||||
CanChangeCountry *bool `gorm:"column:can_change_country"`
|
||||
CreateUser int64 `gorm:"column:create_user"`
|
||||
UpdateUser int64 `gorm:"column:update_user"`
|
||||
}
|
||||
|
||||
func (teamManagerInfoRow) TableName() string {
|
||||
@ -24,21 +34,38 @@ func (teamManagerInfoRow) TableName() string {
|
||||
}
|
||||
|
||||
type userBaseInfoRow struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
Account string `gorm:"column:account"`
|
||||
UserAvatar string `gorm:"column:user_avatar"`
|
||||
UserNickname string `gorm:"column:user_nickname"`
|
||||
CountryCode string `gorm:"column:country_code"`
|
||||
CountryName string `gorm:"column:country_name"`
|
||||
OriginSys string `gorm:"column:origin_sys"`
|
||||
AccountStatus string `gorm:"column:account_status"`
|
||||
IsDel bool `gorm:"column:is_del"`
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
Account string `gorm:"column:account"`
|
||||
UserAvatar string `gorm:"column:user_avatar"`
|
||||
UserNickname string `gorm:"column:user_nickname"`
|
||||
CountryID int64 `gorm:"column:country_id"`
|
||||
CountryCode string `gorm:"column:country_code"`
|
||||
CountryName string `gorm:"column:country_name"`
|
||||
OriginSys string `gorm:"column:origin_sys"`
|
||||
AccountStatus string `gorm:"column:account_status"`
|
||||
IsDel bool `gorm:"column:is_del"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
UpdateUser int64 `gorm:"column:update_user"`
|
||||
}
|
||||
|
||||
func (userBaseInfoRow) TableName() string {
|
||||
return "user_base_info"
|
||||
}
|
||||
|
||||
type sysCountryCodeRow struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
AlphaTwo string `gorm:"column:alpha_two"`
|
||||
CountryName string `gorm:"column:country_name"`
|
||||
NationalFlag string `gorm:"column:national_flag"`
|
||||
PhonePrefix int `gorm:"column:phone_prefix"`
|
||||
Open bool `gorm:"column:is_open"`
|
||||
Sort int `gorm:"column:sort"`
|
||||
}
|
||||
|
||||
func (sysCountryCodeRow) TableName() string {
|
||||
return "sys_country_code"
|
||||
}
|
||||
|
||||
type propsSourceRecordRow struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
Type string `gorm:"column:type"`
|
||||
@ -69,7 +96,8 @@ func (propsNobleVIPAbilityRow) TableName() string {
|
||||
}
|
||||
|
||||
func (s *Service) GetProfile(ctx context.Context, user AuthUser) (*ProfileResponse, error) {
|
||||
if err := s.ensureManager(ctx, user); err != nil {
|
||||
manager, err := s.requireManagerInfo(ctx, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -77,7 +105,7 @@ func (s *Service) GetProfile(ctx context.Context, user AuthUser) (*ProfileRespon
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ProfileResponse{Manager: view}, nil
|
||||
return &ProfileResponse{Manager: view, Features: managerFeaturesFromRow(manager)}, nil
|
||||
}
|
||||
|
||||
func (s *Service) SearchUser(ctx context.Context, user AuthUser, account string) (*UserSearchResponse, error) {
|
||||
@ -97,6 +125,34 @@ func (s *Service) SearchUser(ctx context.Context, user AuthUser, account string)
|
||||
return &UserSearchResponse{User: view}, nil
|
||||
}
|
||||
|
||||
// ListCountries 给经理中心返回当前开放国家,H5 只展示可绑定目标,避免经理手输无效国家码。
|
||||
func (s *Service) ListCountries(ctx context.Context, user AuthUser) (*CountryListResponse, error) {
|
||||
if err := s.ensureManager(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.db == nil {
|
||||
return nil, serviceUnavailable("database_unavailable", "database is unavailable")
|
||||
}
|
||||
|
||||
var rows []sysCountryCodeRow
|
||||
if err := s.db.WithContext(ctx).
|
||||
Table("sys_country_code").
|
||||
Select("id, alpha_two, country_name, national_flag, phone_prefix, is_open, sort").
|
||||
Where("is_open = ?", true).
|
||||
Order("sort ASC, country_name ASC, id ASC").
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, serverError("country_query_failed", err.Error())
|
||||
}
|
||||
|
||||
records := make([]CountryView, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if view, ok := countryRowToView(row); ok {
|
||||
records = append(records, view)
|
||||
}
|
||||
}
|
||||
return &CountryListResponse{Records: records}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListProps(ctx context.Context, user AuthUser, propsType string) (*PropsListResponse, error) {
|
||||
if err := s.ensureManager(ctx, user); err != nil {
|
||||
return nil, err
|
||||
@ -144,7 +200,7 @@ func (s *Service) ListProps(ctx context.Context, user AuthUser, propsType string
|
||||
}
|
||||
|
||||
func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsRequest) (*SendPropsResponse, error) {
|
||||
if err := s.ensureManager(ctx, user); err != nil {
|
||||
if _, err := s.requireManagerFeature(ctx, user, managerFeatureGiftProps); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.AcceptUserID.Int64() <= 0 {
|
||||
@ -239,7 +295,7 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
|
||||
}
|
||||
|
||||
func (s *Service) BanUser(ctx context.Context, user AuthUser, req BanUserRequest) (*BanUserResponse, error) {
|
||||
if err := s.ensureManager(ctx, user); err != nil {
|
||||
if _, err := s.requireManagerFeature(ctx, user, managerFeatureBanUser); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.UserID.Int64() <= 0 {
|
||||
@ -286,7 +342,7 @@ func (s *Service) BanUser(ctx context.Context, user AuthUser, req BanUserRequest
|
||||
}
|
||||
|
||||
func (s *Service) UnbanUser(ctx context.Context, user AuthUser, req UnbanUserRequest) (*UnbanUserResponse, error) {
|
||||
if err := s.ensureManager(ctx, user); err != nil {
|
||||
if _, err := s.requireManagerFeature(ctx, user, managerFeatureUnbanUser); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.UserID.Int64() <= 0 {
|
||||
@ -323,26 +379,114 @@ func (s *Service) UnbanUser(ctx context.Context, user AuthUser, req UnbanUserReq
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureManager(ctx context.Context, user AuthUser) error {
|
||||
if user.UserID <= 0 {
|
||||
return unauthorized("invalid_user", "authenticated user is required")
|
||||
// ChangeUserCountry 只允许 admin 开启改国家权限的经理移动普通用户国家;目标用户已有业务身份时拒绝,避免跨国家后团队、币商和主播归属数据不一致。
|
||||
func (s *Service) ChangeUserCountry(ctx context.Context, user AuthUser, req ChangeUserCountryRequest) (*ChangeUserCountryResponse, error) {
|
||||
if _, err := s.requireManagerFeature(ctx, user, managerFeatureChangeCountry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.UserID.Int64() <= 0 {
|
||||
return nil, badRequest("user_required", "userId is required")
|
||||
}
|
||||
if req.CountryID.Int64() <= 0 {
|
||||
return nil, badRequest("country_required", "countryId is required")
|
||||
}
|
||||
if s.db == nil {
|
||||
return serviceUnavailable("database_unavailable", "database is unavailable")
|
||||
return nil, serviceUnavailable("database_unavailable", "database is unavailable")
|
||||
}
|
||||
|
||||
var count int64
|
||||
err := s.db.WithContext(ctx).
|
||||
Table("team_manager_base_info").
|
||||
Where("user_id = ?", user.UserID).
|
||||
Count(&count).Error
|
||||
target, err := s.loadUserRowByID(ctx, req.UserID.Int64())
|
||||
if err != nil {
|
||||
return serverError("manager_query_failed", err.Error())
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, notFound("user_not_found", "user not found")
|
||||
}
|
||||
return nil, serverError("user_query_failed", err.Error())
|
||||
}
|
||||
if count == 0 {
|
||||
return forbidden("not_manager", "user is not manager")
|
||||
country, err := s.loadOpenCountryByID(ctx, req.CountryID.Int64())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.ensureUserCountryMovable(ctx, target.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 只改国家三元组,不触碰昵称、头像等资料审核字段;清理 Java 资料缓存后 App/H5 会重新读到新国家。
|
||||
updates := map[string]any{
|
||||
"country_id": country.ID,
|
||||
"country_code": strings.ToUpper(strings.TrimSpace(country.AlphaTwo)),
|
||||
"country_name": strings.TrimSpace(country.CountryName),
|
||||
"update_time": time.Now(),
|
||||
"update_user": user.UserID,
|
||||
}
|
||||
if err := s.db.WithContext(ctx).
|
||||
Table("user_base_info").
|
||||
Where("id = ? AND is_del = ?", target.ID, false).
|
||||
Updates(updates).Error; err != nil {
|
||||
return nil, serverError("user_country_update_failed", err.Error())
|
||||
}
|
||||
if s.java != nil {
|
||||
if err := s.java.RemoveUserProfileCacheAll(ctx, target.ID); err != nil {
|
||||
return nil, serverError("remove_profile_cache_failed", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
target.CountryID = country.ID
|
||||
target.CountryCode = strings.ToUpper(strings.TrimSpace(country.AlphaTwo))
|
||||
target.CountryName = strings.TrimSpace(country.CountryName)
|
||||
view, err := s.userRowToView(ctx, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ChangeUserCountryResponse{Success: true, User: view}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureManager(ctx context.Context, user AuthUser) error {
|
||||
_, err := s.requireManagerInfo(ctx, user)
|
||||
return err
|
||||
}
|
||||
|
||||
// requireManagerFeature 先确认调用人仍然是团队经理,再检查 admin 给这个经理打开的单项 H5 操作权限。
|
||||
// 这样前端隐藏按钮和后端写操作使用同一张 team_manager_base_info 表做判断,H5 被绕过时也会被服务层拦截。
|
||||
func (s *Service) requireManagerFeature(ctx context.Context, user AuthUser, feature managerFeature) (teamManagerInfoRow, error) {
|
||||
manager, err := s.requireManagerInfo(ctx, user)
|
||||
if err != nil {
|
||||
return teamManagerInfoRow{}, err
|
||||
}
|
||||
features := managerFeaturesFromRow(manager)
|
||||
if !features.enabled(feature) {
|
||||
return teamManagerInfoRow{}, forbidden("manager_feature_disabled", "manager feature is disabled")
|
||||
}
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
func managerFeaturesFromRow(row teamManagerInfoRow) ManagerFeatures {
|
||||
return ManagerFeatures{
|
||||
AddBDLeader: defaultEnabled(row.CanAddBDLeader),
|
||||
GiftProps: defaultEnabled(row.CanGiftProps),
|
||||
BanUser: defaultEnabled(row.CanBanUser),
|
||||
UnbanUser: defaultEnabled(row.CanUnbanUser),
|
||||
ChangeCountry: defaultEnabled(row.CanChangeCountry),
|
||||
}
|
||||
}
|
||||
|
||||
func defaultEnabled(value *bool) bool {
|
||||
return value == nil || *value
|
||||
}
|
||||
|
||||
func (features ManagerFeatures) enabled(feature managerFeature) bool {
|
||||
switch feature {
|
||||
case managerFeatureAddBDLeader:
|
||||
return features.AddBDLeader
|
||||
case managerFeatureGiftProps:
|
||||
return features.GiftProps
|
||||
case managerFeatureBanUser:
|
||||
return features.BanUser
|
||||
case managerFeatureUnbanUser:
|
||||
return features.UnbanUser
|
||||
case managerFeatureChangeCountry:
|
||||
return features.ChangeCountry
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) findUserView(ctx context.Context, account string) (UserView, error) {
|
||||
@ -410,6 +554,65 @@ func (s *Service) loadUserRowByAccount(ctx context.Context, account string) (use
|
||||
return row, err
|
||||
}
|
||||
|
||||
func (s *Service) loadOpenCountryByID(ctx context.Context, countryID int64) (sysCountryCodeRow, error) {
|
||||
var row sysCountryCodeRow
|
||||
err := s.db.WithContext(ctx).
|
||||
Table("sys_country_code").
|
||||
Select("id, alpha_two, country_name, national_flag, phone_prefix, is_open, sort").
|
||||
Where("id = ? AND is_open = ?", countryID, true).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return sysCountryCodeRow{}, notFound("country_not_found", "country not found")
|
||||
}
|
||||
if err != nil {
|
||||
return sysCountryCodeRow{}, serverError("country_query_failed", err.Error())
|
||||
}
|
||||
if strings.TrimSpace(row.AlphaTwo) == "" {
|
||||
return sysCountryCodeRow{}, badRequest("country_code_required", "country code is required")
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// ensureUserCountryMovable 串行检查所有会绑定归属关系的身份;任一命中都返回同一个业务错误,前端显示统一提示。
|
||||
func (s *Service) ensureUserCountryMovable(ctx context.Context, userID int64) error {
|
||||
locked, err := s.userHasLockedHistoryIdentity(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if locked {
|
||||
return badRequest("user_identity_locked", "user has host, agency, BD, BD leader or coin seller identity and cannot be moved")
|
||||
}
|
||||
locked, err = s.userIsBDLeader(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if locked {
|
||||
return badRequest("user_identity_locked", "user has host, agency, BD, BD leader or coin seller identity and cannot be moved")
|
||||
}
|
||||
if s.java == nil {
|
||||
return serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable")
|
||||
}
|
||||
locked, err = s.java.ExistsFreightSeller(ctx, userID)
|
||||
if err != nil {
|
||||
return serverError("coin_seller_query_failed", err.Error())
|
||||
}
|
||||
if locked {
|
||||
return badRequest("user_identity_locked", "user has host, agency, BD, BD leader or coin seller identity and cannot be moved")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) userHasLockedHistoryIdentity(ctx context.Context, userID int64) (bool, error) {
|
||||
var count int64
|
||||
if err := s.db.WithContext(ctx).
|
||||
Table("user_history_identity").
|
||||
Where("user_id = ? AND (is_host = ? OR is_agent = ? OR is_bd = ?)", userID, true, true, true).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, serverError("history_identity_query_failed", err.Error())
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *Service) userRowToView(ctx context.Context, row userBaseInfoRow) (UserView, error) {
|
||||
hasRecharge, canBan := s.userRechargeEligibility(ctx, row.ID)
|
||||
canBan = canBan && !isUnbannableAccountStatus(row.AccountStatus)
|
||||
@ -418,6 +621,7 @@ func (s *Service) userRowToView(ctx context.Context, row userBaseInfoRow) (UserV
|
||||
Account: row.Account,
|
||||
UserAvatar: row.UserAvatar,
|
||||
UserNickname: row.UserNickname,
|
||||
CountryID: ID(row.CountryID),
|
||||
CountryCode: row.CountryCode,
|
||||
CountryName: row.CountryName,
|
||||
OriginSys: row.OriginSys,
|
||||
@ -435,6 +639,7 @@ func (s *Service) javaProfileToView(ctx context.Context, profile integration.Use
|
||||
Account: profile.Account,
|
||||
UserAvatar: profile.UserAvatar,
|
||||
UserNickname: profile.UserNickname,
|
||||
CountryID: ID(profile.CountryID),
|
||||
CountryCode: profile.CountryCode,
|
||||
CountryName: profile.CountryName,
|
||||
OriginSys: profile.OriginSys,
|
||||
@ -460,6 +665,9 @@ func overlayJavaProfile(view *UserView, profile integration.UserProfile) {
|
||||
if profile.CountryName != "" {
|
||||
view.CountryName = profile.CountryName
|
||||
}
|
||||
if int64(profile.CountryID) > 0 {
|
||||
view.CountryID = ID(profile.CountryID)
|
||||
}
|
||||
if profile.OriginSys != "" {
|
||||
view.OriginSys = profile.OriginSys
|
||||
}
|
||||
@ -471,6 +679,21 @@ func overlayJavaProfile(view *UserView, profile integration.UserProfile) {
|
||||
}
|
||||
}
|
||||
|
||||
func countryRowToView(row sysCountryCodeRow) (CountryView, bool) {
|
||||
code := strings.ToUpper(strings.TrimSpace(row.AlphaTwo))
|
||||
name := strings.TrimSpace(row.CountryName)
|
||||
if row.ID <= 0 || code == "" || name == "" {
|
||||
return CountryView{}, false
|
||||
}
|
||||
return CountryView{
|
||||
ID: ID(row.ID),
|
||||
CountryCode: code,
|
||||
CountryName: name,
|
||||
NationalFlag: strings.TrimSpace(row.NationalFlag),
|
||||
PhonePrefix: row.PhonePrefix,
|
||||
}, true
|
||||
}
|
||||
|
||||
func (s *Service) userHasRecharge(ctx context.Context, userID int64) (bool, error) {
|
||||
if userID <= 0 {
|
||||
return false, nil
|
||||
@ -536,7 +759,7 @@ func (s *Service) loadGiftableNobleVIPAbility(ctx context.Context, propsID int64
|
||||
return propsNobleVIPAbilityRow{}, serverError("vip_ability_query_failed", err.Error())
|
||||
}
|
||||
if row.VIPLevel > maxGiftableVIPLevel {
|
||||
return propsNobleVIPAbilityRow{}, badRequest("vip_not_giftable", "only noble VIP level 1-3 can be gifted")
|
||||
return propsNobleVIPAbilityRow{}, badRequest("vip_not_giftable", "only noble VIP level 1-5 can be gifted")
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
@ -634,7 +857,7 @@ func propsRowToView(row propsSourceRecordRow, vipLevels map[int64]int) (PropsVie
|
||||
if row.Type == propsTypeNobleVIP {
|
||||
var exists bool
|
||||
level, exists = vipLevels[row.ID]
|
||||
if !exists || level > 3 {
|
||||
if !exists || level > maxGiftableVIPLevel {
|
||||
return PropsView{}, false
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,8 @@ import (
|
||||
type fakeManagerGateway struct {
|
||||
recharges map[int64][]integration.UserTotalRecharge
|
||||
rechargeErr error
|
||||
freightSellers map[int64]bool
|
||||
freightSellerErr error
|
||||
abilities map[int64]integration.PropsNobleVIPAbility
|
||||
maxAbility integration.PropsNobleVIPAbility
|
||||
giveRequests []integration.GivePropsBackpackRequest
|
||||
@ -52,6 +54,13 @@ func (f *fakeManagerGateway) MapUserTotalRecharge(_ context.Context, userIDs []i
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (f *fakeManagerGateway) ExistsFreightSeller(_ context.Context, userID int64) (bool, error) {
|
||||
if f.freightSellerErr != nil {
|
||||
return false, f.freightSellerErr
|
||||
}
|
||||
return f.freightSellers[userID], nil
|
||||
}
|
||||
|
||||
func (f *fakeManagerGateway) GivePropsBackpack(_ context.Context, req integration.GivePropsBackpackRequest) error {
|
||||
f.giveRequests = append(f.giveRequests, req)
|
||||
return nil
|
||||
@ -98,10 +107,14 @@ func TestListPropsRequiresAdminFreeAndFiltersVIPLevels(t *testing.T) {
|
||||
propsSourceRecordRow{ID: 102, Type: propsTypeRide, Name: "Paid ride", AdminFree: false},
|
||||
propsSourceRecordRow{ID: 201, Type: propsTypeNobleVIP, Name: "VIP 3", AdminFree: true},
|
||||
propsSourceRecordRow{ID: 204, Type: propsTypeNobleVIP, Name: "VIP 4", AdminFree: true},
|
||||
propsSourceRecordRow{ID: 205, Type: propsTypeNobleVIP, Name: "VIP 5", AdminFree: true},
|
||||
propsSourceRecordRow{ID: 206, Type: propsTypeNobleVIP, Name: "VIP 6", AdminFree: true},
|
||||
)
|
||||
seedAbilities(t, service.db.(*gorm.DB),
|
||||
propsNobleVIPAbilityRow{ID: 201, VIPLevel: 3},
|
||||
propsNobleVIPAbilityRow{ID: 204, VIPLevel: 4},
|
||||
propsNobleVIPAbilityRow{ID: 205, VIPLevel: 5},
|
||||
propsNobleVIPAbilityRow{ID: 206, VIPLevel: 6},
|
||||
)
|
||||
|
||||
resp, err := service.ListProps(context.Background(), AuthUser{UserID: 1}, "")
|
||||
@ -120,10 +133,16 @@ func TestListPropsRequiresAdminFreeAndFiltersVIPLevels(t *testing.T) {
|
||||
t.Fatalf("records = %+v, non-admin-free props must be hidden", resp.Records)
|
||||
}
|
||||
if item, ok := got[201]; !ok || item.Days != defaultVIPGiftDays || item.VIPLevel != 3 {
|
||||
t.Fatalf("vip 201 = %+v, want level 3 with 3 days", item)
|
||||
t.Fatalf("vip 201 = %+v, want level 3 with default VIP days", item)
|
||||
}
|
||||
if _, ok := got[204]; ok {
|
||||
t.Fatalf("records = %+v, VIP level 4 must be hidden", resp.Records)
|
||||
if item, ok := got[204]; !ok || item.VIPLevel != 4 {
|
||||
t.Fatalf("vip 204 = %+v, want level 4 visible", item)
|
||||
}
|
||||
if item, ok := got[205]; !ok || item.VIPLevel != 5 {
|
||||
t.Fatalf("vip 205 = %+v, want level 5 visible", item)
|
||||
}
|
||||
if _, ok := got[206]; ok {
|
||||
t.Fatalf("records = %+v, VIP level 6 must be hidden", resp.Records)
|
||||
}
|
||||
}
|
||||
|
||||
@ -136,18 +155,32 @@ func TestListPropsReturnsVIPLevelConfigs(t *testing.T) {
|
||||
model.VipLevelConfig{ID: 302, SysOrigin: "LIKEI", Level: 2, LevelCode: "VIP2", DisplayName: "VIP 2", Enabled: false, DurationDays: 30},
|
||||
model.VipLevelConfig{ID: 303, SysOrigin: "ATYOU", Level: 1, LevelCode: "VIP1", DisplayName: "ATYOU VIP 1", Enabled: true, DurationDays: 30},
|
||||
model.VipLevelConfig{ID: 304, SysOrigin: "LIKEI", Level: 4, LevelCode: "VIP4", DisplayName: "VIP 4", Enabled: true, DurationDays: 30},
|
||||
model.VipLevelConfig{ID: 305, SysOrigin: "LIKEI", Level: 5, LevelCode: "VIP5", DisplayName: "VIP 5", Enabled: true, DurationDays: 30},
|
||||
model.VipLevelConfig{ID: 306, SysOrigin: "LIKEI", Level: 6, LevelCode: "VIP6", DisplayName: "VIP 6", Enabled: true, DurationDays: 30},
|
||||
)
|
||||
|
||||
resp, err := service.ListProps(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, propsTypeNobleVIP)
|
||||
if err != nil {
|
||||
t.Fatalf("ListProps() error = %v", err)
|
||||
}
|
||||
if len(resp.Records) != 1 {
|
||||
t.Fatalf("records = %+v, want only one enabled LIKEI vip config", resp.Records)
|
||||
if len(resp.Records) != 3 {
|
||||
t.Fatalf("records = %+v, want enabled LIKEI vip configs up to level 5", resp.Records)
|
||||
}
|
||||
got := resp.Records[0]
|
||||
if got.ID.Int64() != 301 || got.Type != propsTypeNobleVIP || got.VIPLevel != 1 || got.Days != 30 {
|
||||
t.Fatalf("vip props = %+v, want config 301 level 1", got)
|
||||
got := map[int64]PropsView{}
|
||||
for _, item := range resp.Records {
|
||||
got[item.ID.Int64()] = item
|
||||
}
|
||||
if item := got[301]; item.Type != propsTypeNobleVIP || item.VIPLevel != 1 || item.Days != 30 {
|
||||
t.Fatalf("vip props 301 = %+v, want config 301 level 1", item)
|
||||
}
|
||||
if item := got[304]; item.Type != propsTypeNobleVIP || item.VIPLevel != 4 || item.Days != 30 {
|
||||
t.Fatalf("vip props 304 = %+v, want config 304 level 4", item)
|
||||
}
|
||||
if item := got[305]; item.Type != propsTypeNobleVIP || item.VIPLevel != 5 || item.Days != 30 {
|
||||
t.Fatalf("vip props 305 = %+v, want config 305 level 5", item)
|
||||
}
|
||||
if _, ok := got[306]; ok {
|
||||
t.Fatalf("records = %+v, VIP level 6 must be hidden", resp.Records)
|
||||
}
|
||||
}
|
||||
|
||||
@ -167,6 +200,112 @@ func TestGetProfileToleratesRechargeQueryFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProfileReturnsManagerFeatures(t *testing.T) {
|
||||
service, _ := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManagerWithFeatures(t, db, 1, ManagerFeatures{
|
||||
AddBDLeader: true,
|
||||
GiftProps: false,
|
||||
BanUser: true,
|
||||
UnbanUser: false,
|
||||
ChangeCountry: true,
|
||||
})
|
||||
seedUsers(t, db, userBaseInfoRow{ID: 1, Account: "1"})
|
||||
|
||||
resp, err := service.GetProfile(context.Background(), AuthUser{UserID: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("GetProfile() error = %v", err)
|
||||
}
|
||||
if !resp.Features.AddBDLeader || resp.Features.GiftProps || !resp.Features.BanUser || resp.Features.UnbanUser || !resp.Features.ChangeCountry {
|
||||
t.Fatalf("features = %+v, want row switches returned", resp.Features)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddBDLeaderCreatesLeaderAndListEntry(t *testing.T) {
|
||||
service, _ := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManager(t, db, 1)
|
||||
seedUsers(t, db,
|
||||
userBaseInfoRow{ID: 2, Account: "2002", UserNickname: "leader", OriginSys: "LIKEI"},
|
||||
)
|
||||
|
||||
resp, err := service.AddBDLeader(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, AddBDLeaderRequest{
|
||||
UserID: ID(2),
|
||||
Contact: "whatsapp",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddBDLeader() error = %v", err)
|
||||
}
|
||||
if !resp.Success || resp.Leader.UserID.Int64() != 2 || resp.Leader.RegionID != "SA" {
|
||||
t.Fatalf("resp = %+v, want created leader for user 2 in manager region", resp)
|
||||
}
|
||||
|
||||
var bdInfo businessDevelopmentBaseInfoRow
|
||||
if err := db.Where("user_id = ?", int64(2)).First(&bdInfo).Error; err != nil {
|
||||
t.Fatalf("load bd info: %v", err)
|
||||
}
|
||||
if bdInfo.BDLeadUserID == nil || *bdInfo.BDLeadUserID != 2 || bdInfo.Region != "SA" {
|
||||
t.Fatalf("bd info = %+v, want self-linked BD leader info", bdInfo)
|
||||
}
|
||||
|
||||
var identity userHistoryIdentityRow
|
||||
if err := db.Where("user_id = ?", int64(2)).First(&identity).Error; err != nil {
|
||||
t.Fatalf("load identity: %v", err)
|
||||
}
|
||||
if !identity.IsBD {
|
||||
t.Fatalf("identity = %+v, want is_bd=true", identity)
|
||||
}
|
||||
|
||||
list, err := service.ListBDLeaders(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListBDLeaders() error = %v", err)
|
||||
}
|
||||
if list.Count != 1 || list.Records[0].UserID.Int64() != 2 || list.Records[0].Account != "2002" {
|
||||
t.Fatalf("list = %+v, want one created leader", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddBDLeaderRejectsExistingLeader(t *testing.T) {
|
||||
service, _ := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManager(t, db, 1)
|
||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2002", OriginSys: "LIKEI"})
|
||||
if err := db.Create(&bdLeaderRow{ID: 10, UserID: 2, Origin: "LIKEI", RegionID: "SA", CreateUser: 1}).Error; err != nil {
|
||||
t.Fatalf("seed bd leader: %v", err)
|
||||
}
|
||||
|
||||
_, err := service.AddBDLeader(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, AddBDLeaderRequest{UserID: ID(2)})
|
||||
assertAppErrorCode(t, err, "user_already_bd_leader")
|
||||
}
|
||||
|
||||
func TestAddBDLeaderRejectsTargetFromOtherOrigin(t *testing.T) {
|
||||
service, _ := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManager(t, db, 1)
|
||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2002", OriginSys: "ATYOU"})
|
||||
|
||||
_, err := service.AddBDLeader(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, AddBDLeaderRequest{UserID: ID(2)})
|
||||
assertAppErrorCode(t, err, "bd_leader_origin_mismatch")
|
||||
}
|
||||
|
||||
func TestAddBDLeaderRejectsDisabledFeature(t *testing.T) {
|
||||
service, _ := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManagerWithFeatures(t, db, 1, ManagerFeatures{GiftProps: true, BanUser: true, UnbanUser: true})
|
||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2002", OriginSys: "LIKEI"})
|
||||
|
||||
_, err := service.AddBDLeader(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, AddBDLeaderRequest{UserID: ID(2)})
|
||||
assertAppErrorCode(t, err, "manager_feature_disabled")
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&bdLeaderRow{}).Where("user_id = ?", int64(2)).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count bd leaders: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("bd leader count = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendPropsRejectsNonAdminFree(t *testing.T) {
|
||||
service, fake := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
@ -227,6 +366,23 @@ func TestSendNobleVIPGivesAccessoriesAndSwitchesMax(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendPropsRejectsDisabledFeature(t *testing.T) {
|
||||
service, fake := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManagerWithFeatures(t, db, 1, ManagerFeatures{AddBDLeader: true, BanUser: true, UnbanUser: true})
|
||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2"})
|
||||
seedProps(t, db, propsSourceRecordRow{ID: 101, Type: propsTypeRide, Name: "Giftable ride", AdminFree: true})
|
||||
|
||||
_, err := service.SendProps(context.Background(), AuthUser{UserID: 1}, SendPropsRequest{
|
||||
AcceptUserID: ID(2),
|
||||
PropsID: ID(101),
|
||||
})
|
||||
assertAppErrorCode(t, err, "manager_feature_disabled")
|
||||
if len(fake.giveRequests) != 0 {
|
||||
t.Fatalf("give requests = %+v, want none", fake.giveRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendBadgeActivatesBadgeBackpack(t *testing.T) {
|
||||
service, fake := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
@ -266,12 +422,12 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
|
||||
seedVIPConfigs(t, db, model.VipLevelConfig{
|
||||
ID: 301,
|
||||
SysOrigin: "LIKEI",
|
||||
Level: 3,
|
||||
LevelCode: "VIP3",
|
||||
DisplayName: "VIP 3",
|
||||
Level: 5,
|
||||
LevelCode: "VIP5",
|
||||
DisplayName: "VIP 5",
|
||||
Enabled: true,
|
||||
DurationDays: 30,
|
||||
PriceGold: 3000,
|
||||
PriceGold: 5000,
|
||||
})
|
||||
|
||||
resp, err := service.SendProps(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, SendPropsRequest{
|
||||
@ -281,7 +437,7 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("SendProps() error = %v", err)
|
||||
}
|
||||
if !resp.Success || resp.Type != propsTypeNobleVIP || resp.VIPLevel != 3 || resp.Days != 30 {
|
||||
if !resp.Success || resp.Type != propsTypeNobleVIP || resp.VIPLevel != 5 || resp.Days != 30 {
|
||||
t.Fatalf("resp = %+v, want vip gift success", resp)
|
||||
}
|
||||
|
||||
@ -289,8 +445,8 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
|
||||
if err := db.Where("sys_origin = ? AND user_id = ?", "LIKEI", int64(2)).First(&state).Error; err != nil {
|
||||
t.Fatalf("load state: %v", err)
|
||||
}
|
||||
if state.Level != 3 || state.Status != vipStatusActive {
|
||||
t.Fatalf("state = %+v, want vip3 active", state)
|
||||
if state.Level != 5 || state.Status != vipStatusActive {
|
||||
t.Fatalf("state = %+v, want vip5 active", state)
|
||||
}
|
||||
var orders int64
|
||||
if err := db.Model(&model.VipOrderRecord{}).Where("user_id = ? AND order_type = ?", int64(2), vipOrderTypeGMGift).Count(&orders).Error; err != nil {
|
||||
@ -300,7 +456,7 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
|
||||
t.Fatalf("orders = %d, want 1", orders)
|
||||
}
|
||||
var records int64
|
||||
if err := db.Model(&gmVIPGiftRecordRow{}).Where("user_id = ? AND vip_level = ?", int64(2), 3).Count(&records).Error; err != nil {
|
||||
if err := db.Model(&gmVIPGiftRecordRow{}).Where("user_id = ? AND vip_level = ?", int64(2), 5).Count(&records).Error; err != nil {
|
||||
t.Fatalf("count gift records: %v", err)
|
||||
}
|
||||
if records != 1 {
|
||||
@ -308,6 +464,48 @@ func TestSendVIPUsesVIPLevelConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVIPGiftCleanupExpiresOldVIPResourcesOnly(t *testing.T) {
|
||||
service, _ := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
if err := db.AutoMigrate(&managerVIPCleanupPropsRow{}, &managerVIPCleanupBadgeRow{}); err != nil {
|
||||
t.Fatalf("auto migrate cleanup tables: %v", err)
|
||||
}
|
||||
now := time.Date(2026, 5, 30, 10, 0, 0, 0, time.UTC)
|
||||
future := now.AddDate(0, 0, 20)
|
||||
userID := int64(2)
|
||||
|
||||
seedManagerVIPCleanupProps(t, db,
|
||||
managerVIPCleanupPropsRow{ID: 1, UserID: userID, PropsID: 101, Type: propsTypeAvatarFrame, ExpireTime: future, UseProps: true, AllowGive: true},
|
||||
managerVIPCleanupPropsRow{ID: 2, UserID: userID, PropsID: 201, Type: propsTypeAvatarFrame, ExpireTime: future, UseProps: true, AllowGive: true},
|
||||
managerVIPCleanupPropsRow{ID: 3, UserID: userID, PropsID: 999, Type: propsTypeAvatarFrame, ExpireTime: future, UseProps: true, AllowGive: true},
|
||||
managerVIPCleanupPropsRow{ID: 4, UserID: userID, PropsID: 103, Type: propsTypeDataCard, ExpireTime: future, UseProps: true, AllowGive: true},
|
||||
)
|
||||
seedManagerVIPCleanupBadges(t, db,
|
||||
managerVIPCleanupBadgeRow{ID: 1, UserID: userID, BadgeID: 100, ExpireTime: future, UseProps: true},
|
||||
managerVIPCleanupBadgeRow{ID: 2, UserID: userID, BadgeID: 200, ExpireTime: future, UseProps: true},
|
||||
)
|
||||
|
||||
target := vipGiftResourcesFromConfig(model.VipLevelConfig{
|
||||
BadgeResourceID: 200,
|
||||
AvatarFrameResourceID: 201,
|
||||
})
|
||||
all := append(vipGiftResourcesFromConfig(model.VipLevelConfig{
|
||||
BadgeResourceID: 100,
|
||||
AvatarFrameResourceID: 101,
|
||||
BackgroundCardResourceID: 103,
|
||||
}), target...)
|
||||
if err := service.cleanupSupersededVIPGiftResources(context.Background(), userID, target, all, now); err != nil {
|
||||
t.Fatalf("cleanupSupersededVIPGiftResources() error = %v", err)
|
||||
}
|
||||
|
||||
assertManagerVIPCleanupProps(t, db, 101, now, false, false)
|
||||
assertManagerVIPCleanupProps(t, db, 103, now, false, false)
|
||||
assertManagerVIPCleanupProps(t, db, 201, future, true, true)
|
||||
assertManagerVIPCleanupProps(t, db, 999, future, true, true)
|
||||
assertManagerVIPCleanupBadge(t, db, 100, now, false)
|
||||
assertManagerVIPCleanupBadge(t, db, 200, future, true)
|
||||
}
|
||||
|
||||
func TestSendVIPRejectsLevelAboveGiftLimit(t *testing.T) {
|
||||
service, _ := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
@ -319,9 +517,9 @@ func TestSendVIPRejectsLevelAboveGiftLimit(t *testing.T) {
|
||||
seedVIPConfigs(t, db, model.VipLevelConfig{
|
||||
ID: 304,
|
||||
SysOrigin: "LIKEI",
|
||||
Level: 4,
|
||||
LevelCode: "VIP4",
|
||||
DisplayName: "VIP 4",
|
||||
Level: 6,
|
||||
LevelCode: "VIP6",
|
||||
DisplayName: "VIP 6",
|
||||
Enabled: true,
|
||||
DurationDays: 30,
|
||||
})
|
||||
@ -333,6 +531,90 @@ func TestSendVIPRejectsLevelAboveGiftLimit(t *testing.T) {
|
||||
assertAppErrorCode(t, err, "vip_not_giftable")
|
||||
}
|
||||
|
||||
type managerVIPCleanupPropsRow struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
UserID int64 `gorm:"column:user_id"`
|
||||
PropsID int64 `gorm:"column:props_id"`
|
||||
Type string `gorm:"column:type"`
|
||||
ExpireTime time.Time `gorm:"column:expire_time"`
|
||||
UseProps bool `gorm:"column:is_use_props"`
|
||||
AllowGive bool `gorm:"column:allow_give"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
}
|
||||
|
||||
func (managerVIPCleanupPropsRow) TableName() string {
|
||||
return "user_props_backpack"
|
||||
}
|
||||
|
||||
type managerVIPCleanupBadgeRow struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
UserID int64 `gorm:"column:user_id"`
|
||||
BadgeID int64 `gorm:"column:badge_id"`
|
||||
ExpireType string `gorm:"column:expire_type"`
|
||||
ExpireTime time.Time `gorm:"column:expire_time"`
|
||||
UseProps bool `gorm:"column:is_use_props"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
}
|
||||
|
||||
func (managerVIPCleanupBadgeRow) TableName() string {
|
||||
return "user_badge_backpack"
|
||||
}
|
||||
|
||||
func seedManagerVIPCleanupProps(t *testing.T, db *gorm.DB, rows ...managerVIPCleanupPropsRow) {
|
||||
t.Helper()
|
||||
for _, row := range rows {
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("seed manager cleanup props: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func seedManagerVIPCleanupBadges(t *testing.T, db *gorm.DB, rows ...managerVIPCleanupBadgeRow) {
|
||||
t.Helper()
|
||||
for _, row := range rows {
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("seed manager cleanup badge: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertManagerVIPCleanupProps(t *testing.T, db *gorm.DB, propsID int64, expireTime time.Time, useProps bool, allowGive bool) {
|
||||
t.Helper()
|
||||
var row managerVIPCleanupPropsRow
|
||||
if err := db.Where("props_id = ?", propsID).First(&row).Error; err != nil {
|
||||
t.Fatalf("load manager cleanup props %d: %v", propsID, err)
|
||||
}
|
||||
if !row.ExpireTime.Equal(expireTime) || row.UseProps != useProps || row.AllowGive != allowGive {
|
||||
t.Fatalf("props %d = expire:%s use:%v allow:%v, want expire:%s use:%v allow:%v",
|
||||
propsID, row.ExpireTime, row.UseProps, row.AllowGive, expireTime, useProps, allowGive)
|
||||
}
|
||||
}
|
||||
|
||||
func assertManagerVIPCleanupBadge(t *testing.T, db *gorm.DB, badgeID int64, expireTime time.Time, useProps bool) {
|
||||
t.Helper()
|
||||
var row managerVIPCleanupBadgeRow
|
||||
if err := db.Where("badge_id = ?", badgeID).First(&row).Error; err != nil {
|
||||
t.Fatalf("load manager cleanup badge %d: %v", badgeID, err)
|
||||
}
|
||||
if !row.ExpireTime.Equal(expireTime) || row.UseProps != useProps {
|
||||
t.Fatalf("badge %d = expire:%s use:%v, want expire:%s use:%v",
|
||||
badgeID, row.ExpireTime, row.UseProps, expireTime, useProps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanUserRejectsDisabledFeature(t *testing.T) {
|
||||
service, fake := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManagerWithFeatures(t, db, 1, ManagerFeatures{AddBDLeader: true, GiftProps: true, UnbanUser: true})
|
||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2"})
|
||||
|
||||
_, err := service.BanUser(context.Background(), AuthUser{UserID: 1}, BanUserRequest{UserID: ID(2)})
|
||||
assertAppErrorCode(t, err, "manager_feature_disabled")
|
||||
if len(fake.punishRequests) != 0 {
|
||||
t.Fatalf("punish requests = %+v, want none", fake.punishRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanUserRejectsRechargedUser(t *testing.T) {
|
||||
service, fake := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
@ -412,6 +694,19 @@ func TestBanUserWithoutRechargeCallsPunishment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnbanUserRejectsDisabledFeature(t *testing.T) {
|
||||
service, fake := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManagerWithFeatures(t, db, 1, ManagerFeatures{AddBDLeader: true, GiftProps: true, BanUser: true})
|
||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", AccountStatus: accountStatusArchive})
|
||||
|
||||
_, err := service.UnbanUser(context.Background(), AuthUser{UserID: 1}, UnbanUserRequest{UserID: ID(2)})
|
||||
assertAppErrorCode(t, err, "manager_feature_disabled")
|
||||
if len(fake.unblockRequests) != 0 {
|
||||
t.Fatalf("unblock requests = %+v, want none", fake.unblockRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnbanUserRejectsNormalUser(t *testing.T) {
|
||||
service, fake := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
@ -471,6 +766,117 @@ func TestUnbanUserAllowsFrozenUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCountriesReturnsOpenCountries(t *testing.T) {
|
||||
service, _ := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManager(t, db, 1)
|
||||
seedCountries(t, db,
|
||||
sysCountryCodeRow{ID: 10, AlphaTwo: "SA", CountryName: "Saudi Arabia", Open: true, Sort: 2},
|
||||
sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true, Sort: 1},
|
||||
sysCountryCodeRow{ID: 12, AlphaTwo: "XX", CountryName: "Closed", Open: false, Sort: 3},
|
||||
)
|
||||
|
||||
resp, err := service.ListCountries(context.Background(), AuthUser{UserID: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("ListCountries() error = %v", err)
|
||||
}
|
||||
if len(resp.Records) != 2 {
|
||||
t.Fatalf("countries = %+v, want two open rows", resp.Records)
|
||||
}
|
||||
if resp.Records[0].CountryCode != "AE" || resp.Records[1].CountryCode != "SA" {
|
||||
t.Fatalf("countries = %+v, want sort order AE then SA", resp.Records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeUserCountryUpdatesCountryAndClearsCache(t *testing.T) {
|
||||
service, fake := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManager(t, db, 1)
|
||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA", CountryName: "Saudi Arabia"})
|
||||
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
|
||||
|
||||
resp, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{
|
||||
UserID: ID(2),
|
||||
CountryID: ID(11),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ChangeUserCountry() error = %v", err)
|
||||
}
|
||||
if !resp.Success || resp.User.CountryCode != "AE" || resp.User.CountryName != "United Arab Emirates" {
|
||||
t.Fatalf("resp = %+v, want changed country", resp)
|
||||
}
|
||||
|
||||
var row userBaseInfoRow
|
||||
if err := db.Where("id = ?", int64(2)).First(&row).Error; err != nil {
|
||||
t.Fatalf("load user: %v", err)
|
||||
}
|
||||
if row.CountryID != 11 || row.CountryCode != "AE" || row.CountryName != "United Arab Emirates" || row.UpdateUser != 1 {
|
||||
t.Fatalf("user row = %+v, want AE country and manager updater", row)
|
||||
}
|
||||
if !equalInt64Slice(fake.cacheCleared, []int64{2}) {
|
||||
t.Fatalf("cacheCleared = %+v, want [2]", fake.cacheCleared)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeUserCountryRejectsDisabledFeature(t *testing.T) {
|
||||
service, _ := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManagerWithFeatures(t, db, 1, ManagerFeatures{AddBDLeader: true, GiftProps: true, BanUser: true, UnbanUser: true})
|
||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA", CountryName: "Saudi Arabia"})
|
||||
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
|
||||
|
||||
_, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{UserID: ID(2), CountryID: ID(11)})
|
||||
assertAppErrorCode(t, err, "manager_feature_disabled")
|
||||
|
||||
var row userBaseInfoRow
|
||||
if err := db.Where("id = ?", int64(2)).First(&row).Error; err != nil {
|
||||
t.Fatalf("load user: %v", err)
|
||||
}
|
||||
if row.CountryID != 10 || row.CountryCode != "SA" {
|
||||
t.Fatalf("user row = %+v, want unchanged country when feature disabled", row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeUserCountryRejectsHistoryIdentity(t *testing.T) {
|
||||
service, _ := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManager(t, db, 1)
|
||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA"})
|
||||
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
|
||||
if err := db.Create(&userHistoryIdentityRow{UserID: 2, IsHost: true}).Error; err != nil {
|
||||
t.Fatalf("seed identity: %v", err)
|
||||
}
|
||||
|
||||
_, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{UserID: ID(2), CountryID: ID(11)})
|
||||
assertAppErrorCode(t, err, "user_identity_locked")
|
||||
}
|
||||
|
||||
func TestChangeUserCountryRejectsBDLeader(t *testing.T) {
|
||||
service, _ := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManager(t, db, 1)
|
||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA"})
|
||||
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
|
||||
if err := db.Create(&bdLeaderRow{ID: 20, UserID: 2, Origin: "LIKEI", RegionID: "SA"}).Error; err != nil {
|
||||
t.Fatalf("seed bd leader: %v", err)
|
||||
}
|
||||
|
||||
_, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{UserID: ID(2), CountryID: ID(11)})
|
||||
assertAppErrorCode(t, err, "user_identity_locked")
|
||||
}
|
||||
|
||||
func TestChangeUserCountryRejectsCoinSeller(t *testing.T) {
|
||||
service, fake := newManagerCenterTestService(t)
|
||||
db := service.db.(*gorm.DB)
|
||||
seedManager(t, db, 1)
|
||||
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA"})
|
||||
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
|
||||
fake.freightSellers[2] = true
|
||||
|
||||
_, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{UserID: ID(2), CountryID: ID(11)})
|
||||
assertAppErrorCode(t, err, "user_identity_locked")
|
||||
}
|
||||
|
||||
func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
@ -480,6 +886,11 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
|
||||
if err := db.AutoMigrate(
|
||||
&teamManagerInfoRow{},
|
||||
&userBaseInfoRow{},
|
||||
&sysCountryCodeRow{},
|
||||
&bdLeaderRow{},
|
||||
&businessDevelopmentBaseInfoRow{},
|
||||
&userHistoryIdentityRow{},
|
||||
&administratorRow{},
|
||||
&propsSourceRecordRow{},
|
||||
&propsNobleVIPAbilityRow{},
|
||||
&model.VipLevelConfig{},
|
||||
@ -490,19 +901,45 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
fake := &fakeManagerGateway{
|
||||
recharges: map[int64][]integration.UserTotalRecharge{},
|
||||
abilities: map[int64]integration.PropsNobleVIPAbility{},
|
||||
recharges: map[int64][]integration.UserTotalRecharge{},
|
||||
freightSellers: map[int64]bool{},
|
||||
abilities: map[int64]integration.PropsNobleVIPAbility{},
|
||||
}
|
||||
return NewService(db, fake), fake
|
||||
}
|
||||
|
||||
func seedManager(t *testing.T, db *gorm.DB, userID int64) {
|
||||
t.Helper()
|
||||
if err := db.Create(&teamManagerInfoRow{ID: userID, UserID: userID, Origin: "LIKEI"}).Error; err != nil {
|
||||
seedManagerWithFeatures(t, db, userID, ManagerFeatures{
|
||||
AddBDLeader: true,
|
||||
GiftProps: true,
|
||||
BanUser: true,
|
||||
UnbanUser: true,
|
||||
ChangeCountry: true,
|
||||
})
|
||||
}
|
||||
|
||||
func seedManagerWithFeatures(t *testing.T, db *gorm.DB, userID int64, features ManagerFeatures) {
|
||||
t.Helper()
|
||||
if err := db.Create(&teamManagerInfoRow{
|
||||
ID: userID,
|
||||
UserID: userID,
|
||||
Origin: "LIKEI",
|
||||
RegionID: "SA",
|
||||
CanAddBDLeader: boolPtr(features.AddBDLeader),
|
||||
CanGiftProps: boolPtr(features.GiftProps),
|
||||
CanBanUser: boolPtr(features.BanUser),
|
||||
CanUnbanUser: boolPtr(features.UnbanUser),
|
||||
CanChangeCountry: boolPtr(features.ChangeCountry),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed manager: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func boolPtr(value bool) *bool {
|
||||
return &value
|
||||
}
|
||||
|
||||
func seedUsers(t *testing.T, db *gorm.DB, rows ...userBaseInfoRow) {
|
||||
t.Helper()
|
||||
for _, row := range rows {
|
||||
@ -515,6 +952,15 @@ func seedUsers(t *testing.T, db *gorm.DB, rows ...userBaseInfoRow) {
|
||||
}
|
||||
}
|
||||
|
||||
func seedCountries(t *testing.T, db *gorm.DB, rows ...sysCountryCodeRow) {
|
||||
t.Helper()
|
||||
for _, row := range rows {
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("seed country: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func seedProps(t *testing.T, db *gorm.DB, rows ...propsSourceRecordRow) {
|
||||
t.Helper()
|
||||
for _, row := range rows {
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/common"
|
||||
"chatapp3-golang/internal/integration"
|
||||
@ -27,9 +28,10 @@ const (
|
||||
vipGiftOrigin = "VIP_PURCHASE"
|
||||
vipGiftOriginDesc = "VIP purchase"
|
||||
|
||||
defaultGiftDays = 7
|
||||
defaultVIPGiftDays = 30
|
||||
maxGiftableVIPLevel = 3
|
||||
defaultGiftDays = 7
|
||||
defaultVIPGiftDays = 30
|
||||
// 经理中心 VIP 赠送按当前运营范围放开 VIP1-VIP5;列表和发放校验共用该上限,避免展示可选但提交失败。
|
||||
maxGiftableVIPLevel = 5
|
||||
|
||||
accountStatusNormal = "NORMAL"
|
||||
accountStatusFreeze = "FREEZE"
|
||||
@ -37,6 +39,16 @@ const (
|
||||
accountStatusArchiveDevice = "ARCHIVE_DEVICE"
|
||||
)
|
||||
|
||||
type managerFeature string
|
||||
|
||||
const (
|
||||
managerFeatureAddBDLeader managerFeature = "add_bd_leader"
|
||||
managerFeatureGiftProps managerFeature = "gift_props"
|
||||
managerFeatureBanUser managerFeature = "ban_user"
|
||||
managerFeatureUnbanUser managerFeature = "unban_user"
|
||||
managerFeatureChangeCountry managerFeature = "change_country"
|
||||
)
|
||||
|
||||
type AppError = common.AppError
|
||||
type AuthUser = common.AuthUser
|
||||
|
||||
@ -50,6 +62,7 @@ type managerGateway interface {
|
||||
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error)
|
||||
GetUserProfileByAccountOne(ctx context.Context, account string) (integration.UserProfile, error)
|
||||
MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error)
|
||||
ExistsFreightSeller(ctx context.Context, userID int64) (bool, error)
|
||||
GivePropsBackpack(ctx context.Context, req integration.GivePropsBackpackRequest) error
|
||||
GetNobleVIPAbility(ctx context.Context, sourceID int64) (integration.PropsNobleVIPAbility, error)
|
||||
GetUserMaxNobleVIPAbility(ctx context.Context, userID int64) (integration.PropsNobleVIPAbility, error)
|
||||
@ -105,18 +118,40 @@ func (id *ID) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
type ProfileResponse struct {
|
||||
Manager UserView `json:"manager"`
|
||||
Manager UserView `json:"manager"`
|
||||
Features ManagerFeatures `json:"features"`
|
||||
}
|
||||
|
||||
type ManagerFeatures struct {
|
||||
AddBDLeader bool `json:"addBdLeader"`
|
||||
GiftProps bool `json:"giftProps"`
|
||||
BanUser bool `json:"banUser"`
|
||||
UnbanUser bool `json:"unbanUser"`
|
||||
ChangeCountry bool `json:"changeCountry"`
|
||||
}
|
||||
|
||||
type UserSearchResponse struct {
|
||||
User UserView `json:"user"`
|
||||
}
|
||||
|
||||
type CountryListResponse struct {
|
||||
Records []CountryView `json:"records"`
|
||||
}
|
||||
|
||||
type CountryView struct {
|
||||
ID ID `json:"id"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
CountryName string `json:"countryName"`
|
||||
NationalFlag string `json:"nationalFlag,omitempty"`
|
||||
PhonePrefix int `json:"phonePrefix,omitempty"`
|
||||
}
|
||||
|
||||
type UserView struct {
|
||||
UserID ID `json:"userId"`
|
||||
Account string `json:"account"`
|
||||
UserAvatar string `json:"userAvatar"`
|
||||
UserNickname string `json:"userNickname"`
|
||||
CountryID ID `json:"countryId,omitempty"`
|
||||
CountryCode string `json:"countryCode,omitempty"`
|
||||
CountryName string `json:"countryName,omitempty"`
|
||||
OriginSys string `json:"originSys,omitempty"`
|
||||
@ -178,6 +213,45 @@ type UnbanUserResponse struct {
|
||||
AccountStatus string `json:"accountStatus"`
|
||||
}
|
||||
|
||||
type ChangeUserCountryRequest struct {
|
||||
UserID ID `json:"userId"`
|
||||
CountryID ID `json:"countryId"`
|
||||
}
|
||||
|
||||
type ChangeUserCountryResponse struct {
|
||||
Success bool `json:"success"`
|
||||
User UserView `json:"user"`
|
||||
}
|
||||
|
||||
type BDLeaderListResponse struct {
|
||||
Count int `json:"count"`
|
||||
Records []BDLeaderView `json:"records"`
|
||||
}
|
||||
|
||||
type BDLeaderView struct {
|
||||
ID ID `json:"id"`
|
||||
UserID ID `json:"userId"`
|
||||
Account string `json:"account"`
|
||||
UserAvatar string `json:"userAvatar"`
|
||||
UserNickname string `json:"userNickname"`
|
||||
Contact string `json:"contact,omitempty"`
|
||||
RegionID string `json:"regionId,omitempty"`
|
||||
OriginSys string `json:"originSys,omitempty"`
|
||||
CreateTime time.Time `json:"createTime,omitempty"`
|
||||
UpdateTime time.Time `json:"updateTime,omitempty"`
|
||||
}
|
||||
|
||||
type AddBDLeaderRequest struct {
|
||||
UserID ID `json:"userId"`
|
||||
Contact string `json:"contact"`
|
||||
RegionID string `json:"regionId"`
|
||||
}
|
||||
|
||||
type AddBDLeaderResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Leader BDLeaderView `json:"leader"`
|
||||
}
|
||||
|
||||
func badRequest(code, message string) error {
|
||||
return NewAppError(http.StatusBadRequest, code, message)
|
||||
}
|
||||
|
||||
@ -171,6 +171,7 @@ func (s *Service) sendVIPGift(ctx context.Context, manager AuthUser, target user
|
||||
CreateTime: now,
|
||||
}
|
||||
}
|
||||
previousResources := vipGiftResourcesFromState(state)
|
||||
applyVIPGiftConfigToState(state, config, startAt, expireAt, now)
|
||||
if err := tx.Save(state).Error; err != nil {
|
||||
return nil, serverError("vip_state_save_failed", err.Error())
|
||||
@ -209,7 +210,7 @@ func (s *Service) sendVIPGift(ctx context.Context, manager AuthUser, target user
|
||||
}
|
||||
committed = true
|
||||
|
||||
if err := s.grantVIPGiftResources(ctx, target.ID, manager.UserID, config, expireAt, now); err != nil {
|
||||
if err := s.grantVIPGiftResources(ctx, sysOrigin, target.ID, manager.UserID, config, expireAt, previousResources, now); err != nil {
|
||||
return nil, serverError("vip_resource_grant_failed", err.Error())
|
||||
}
|
||||
|
||||
@ -301,11 +302,8 @@ func applyVIPGiftConfigToState(state *model.UserVipState, config model.VipLevelC
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) grantVIPGiftResources(ctx context.Context, userID int64, managerID int64, config model.VipLevelConfig, expireAt time.Time, now time.Time) error {
|
||||
func (s *Service) grantVIPGiftResources(ctx context.Context, sysOrigin string, userID int64, managerID int64, config model.VipLevelConfig, expireAt time.Time, additionalOldResources []vipGiftResource, now time.Time) error {
|
||||
resources := vipGiftResourcesFromConfig(config)
|
||||
if len(resources) == 0 {
|
||||
return nil
|
||||
}
|
||||
days := config.DurationDays
|
||||
if days <= 0 {
|
||||
days = defaultVIPGiftDays
|
||||
@ -337,6 +335,14 @@ func (s *Service) grantVIPGiftResources(ctx context.Context, userID int64, manag
|
||||
return err
|
||||
}
|
||||
}
|
||||
allResources, err := s.vipGiftResourcesFromSysOrigin(ctx, sysOrigin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
allResources = append(allResources, additionalOldResources...)
|
||||
if err := s.cleanupSupersededVIPGiftResources(ctx, userID, resources, allResources, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.java.RemoveUserProfileCacheAll(ctx, userID); err != nil {
|
||||
return fmt.Errorf("remove vip user profile cache: %w", err)
|
||||
}
|
||||
@ -374,6 +380,7 @@ func vipGiftResourcesFromConfig(config model.VipLevelConfig) []vipGiftResource {
|
||||
{resourceID: config.EntryEffectResourceID, propsType: propsTypeRide},
|
||||
{resourceID: config.ChatBubbleResourceID, propsType: propsTypeChatBubble},
|
||||
{resourceID: config.FloatPictureResourceID, propsType: propsTypeFloatPicture},
|
||||
{resourceID: config.BackgroundCardResourceID, propsType: propsTypeDataCard},
|
||||
}
|
||||
out := make([]vipGiftResource, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
@ -384,6 +391,104 @@ func vipGiftResourcesFromConfig(config model.VipLevelConfig) []vipGiftResource {
|
||||
return out
|
||||
}
|
||||
|
||||
func vipGiftResourcesFromState(state *model.UserVipState) []vipGiftResource {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
return compactVIPGiftResources([]vipGiftResource{
|
||||
{resourceID: state.BadgeResourceID, propsType: propsTypeBadge, badge: true},
|
||||
{resourceID: state.ShortBadgeResourceID, propsType: propsTypeBadge, badge: true},
|
||||
{resourceID: state.AvatarFrameResourceID, propsType: propsTypeAvatarFrame},
|
||||
{resourceID: state.EntryEffectResourceID, propsType: propsTypeRide},
|
||||
{resourceID: state.ChatBubbleResourceID, propsType: propsTypeChatBubble},
|
||||
{resourceID: state.FloatPictureResourceID, propsType: propsTypeFloatPicture},
|
||||
{resourceID: state.BackgroundCardResourceID, propsType: propsTypeDataCard},
|
||||
})
|
||||
}
|
||||
|
||||
func compactVIPGiftResources(resources []vipGiftResource) []vipGiftResource {
|
||||
out := make([]vipGiftResource, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
if resource.resourceID > 0 {
|
||||
out = append(out, resource)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) vipGiftResourcesFromSysOrigin(ctx context.Context, sysOrigin string) ([]vipGiftResource, error) {
|
||||
var configs []model.VipLevelConfig
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("sys_origin = ?", normalizeSysOrigin(sysOrigin)).
|
||||
Find(&configs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resources := make([]vipGiftResource, 0, len(configs)*7)
|
||||
for _, config := range configs {
|
||||
resources = append(resources, vipGiftResourcesFromConfig(config)...)
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func (s *Service) cleanupSupersededVIPGiftResources(ctx context.Context, userID int64, targetResources []vipGiftResource, allVIPResources []vipGiftResource, now time.Time) error {
|
||||
targets := make(map[string]struct{}, len(targetResources))
|
||||
for _, resource := range targetResources {
|
||||
targets[vipGiftResourceKey(resource)] = struct{}{}
|
||||
}
|
||||
|
||||
badgeIDs := make([]int64, 0)
|
||||
propsIDsByType := map[string][]int64{}
|
||||
seen := make(map[string]struct{}, len(allVIPResources))
|
||||
for _, resource := range allVIPResources {
|
||||
if resource.resourceID <= 0 {
|
||||
continue
|
||||
}
|
||||
key := vipGiftResourceKey(resource)
|
||||
if _, exists := targets[key]; exists {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
if resource.badge {
|
||||
badgeIDs = append(badgeIDs, resource.resourceID)
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(resource.propsType) != "" {
|
||||
propsIDsByType[resource.propsType] = append(propsIDsByType[resource.propsType], resource.resourceID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(badgeIDs) > 0 {
|
||||
if err := s.db.WithContext(ctx).Exec(
|
||||
"UPDATE user_badge_backpack SET expire_type = ?, expire_time = ?, is_use_props = ?, update_time = ? WHERE user_id = ? AND badge_id IN ? AND expire_time > ?",
|
||||
"TEMPORARY", now, false, now, userID, badgeIDs, now,
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for propsType, propsIDs := range propsIDsByType {
|
||||
if len(propsIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Exec(
|
||||
"UPDATE user_props_backpack SET expire_time = ?, is_use_props = ?, allow_give = ?, update_time = ? WHERE user_id = ? AND props_id IN ? AND type = ? AND expire_time > ?",
|
||||
now, false, false, now, userID, propsIDs, propsType, now,
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func vipGiftResourceKey(resource vipGiftResource) string {
|
||||
if resource.badge {
|
||||
return fmt.Sprintf("BADGE:%d", resource.resourceID)
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", resource.propsType, resource.resourceID)
|
||||
}
|
||||
|
||||
func (s *Service) managerApplicantName(ctx context.Context, managerID int64) string {
|
||||
row, err := s.loadUserRowByID(ctx, managerID)
|
||||
if err != nil {
|
||||
|
||||
@ -117,7 +117,7 @@ ORDER BY claims.last_claim_time DESC, claims.user_id ASC`
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rewardItems := s.loadRewardItemsMap(ctx, claimDetailLevels(details))
|
||||
rewardItems := s.loadRewardItemsMap(ctx, sysOrigin, claimDetailLevels(details))
|
||||
records = make([]RechargeRewardClaimRecordView, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
records = append(records, rechargeRewardClaimRecordView(row, displayMonth, rechargeDate, details[row.UserID], rewardItems))
|
||||
@ -283,6 +283,9 @@ func claimDetailPayloads(
|
||||
if detail.ResourceGroupID != nil {
|
||||
items = rewardItems[*detail.ResourceGroupID]
|
||||
}
|
||||
if items == nil {
|
||||
items = []RechargeRewardRewardItem{}
|
||||
}
|
||||
result = append(result, RechargeRewardLevelPayload{
|
||||
ID: detail.ActivityID,
|
||||
Level: level,
|
||||
@ -313,6 +316,9 @@ func reachedRechargeRewardLevels(
|
||||
if level.RewardGroupID != nil {
|
||||
items = rewardItems[*level.RewardGroupID]
|
||||
}
|
||||
if items == nil {
|
||||
items = []RechargeRewardRewardItem{}
|
||||
}
|
||||
result = append(result, RechargeRewardLevelPayload{
|
||||
ID: level.ID,
|
||||
Level: level.Level,
|
||||
@ -332,6 +338,10 @@ func reachedRechargeRewardLevels(
|
||||
func rechargeRewardClaimedRewards(levels []RechargeRewardLevelPayload) []RechargeRewardClaimedRewardPayload {
|
||||
result := make([]RechargeRewardClaimedRewardPayload, 0, len(levels))
|
||||
for _, level := range levels {
|
||||
items := level.RewardItems
|
||||
if items == nil {
|
||||
items = []RechargeRewardRewardItem{}
|
||||
}
|
||||
result = append(result, RechargeRewardClaimedRewardPayload{
|
||||
Level: level.Level,
|
||||
RechargeAmount: level.RechargeAmount,
|
||||
@ -339,7 +349,7 @@ func rechargeRewardClaimedRewards(levels []RechargeRewardLevelPayload) []Recharg
|
||||
RewardGold: level.RewardGold,
|
||||
RewardGroupID: level.RewardGroupID,
|
||||
RewardGroupName: level.RewardGroupName,
|
||||
RewardItems: level.RewardItems,
|
||||
RewardItems: items,
|
||||
})
|
||||
}
|
||||
return result
|
||||
|
||||
@ -39,7 +39,7 @@ func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*RechargeRew
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rewardItems := s.loadRewardItemsMap(ctx, levels)
|
||||
rewardItems := s.loadRewardItemsMap(ctx, configRow.SysOrigin, levels)
|
||||
return &RechargeRewardConfigResponse{
|
||||
Configured: true,
|
||||
ID: configRow.ID,
|
||||
@ -60,7 +60,7 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveRechargeRewardConfigRe
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error())
|
||||
}
|
||||
|
||||
levels, err := s.normalizeLevelInputs(req.LevelConfigs)
|
||||
levels, err := s.normalizeLevelInputs(ctx, sysOrigin, req.LevelConfigs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -133,7 +133,7 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveRechargeRewardConfigRe
|
||||
return s.GetConfig(ctx, rechargeRewardSysOriginBySavedID(ctx, s.db, savedConfigID, sysOrigin))
|
||||
}
|
||||
|
||||
func (s *Service) normalizeLevelInputs(inputs []RechargeRewardLevelInput) ([]model.RechargeRewardLevel, error) {
|
||||
func (s *Service) normalizeLevelInputs(ctx context.Context, sysOrigin string, inputs []RechargeRewardLevelInput) ([]model.RechargeRewardLevel, error) {
|
||||
if len(inputs) == 0 {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_level_configs", "levelConfigs is required")
|
||||
}
|
||||
@ -152,7 +152,7 @@ func (s *Service) normalizeLevelInputs(inputs []RechargeRewardLevelInput) ([]mod
|
||||
if amountCents <= 0 {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_recharge_amount", "rechargeAmount must be greater than 0")
|
||||
}
|
||||
rewardGroupID := item.RewardGroupID.Int64Ptr()
|
||||
rewardGroupID := s.resolveRewardGroupID(ctx, sysOrigin, item.RewardGroupID.Int64Ptr(), item.RewardGroupName)
|
||||
if item.RewardGold <= 0 && (rewardGroupID == nil || *rewardGroupID <= 0) {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_reward", "rewardGold or rewardGroupId is required")
|
||||
}
|
||||
@ -188,6 +188,22 @@ func (s *Service) normalizeLevelInputs(inputs []RechargeRewardLevelInput) ([]mod
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveRewardGroupID(ctx context.Context, sysOrigin string, rewardGroupID *int64, rewardGroupName string) *int64 {
|
||||
normalized := normalizeRewardGroupID(rewardGroupID)
|
||||
if normalized == nil || s.java == nil || strings.TrimSpace(rewardGroupName) == "" {
|
||||
return normalized
|
||||
}
|
||||
if detail, err := s.java.GetRewardGroupDetail(ctx, *normalized); err == nil && rewardGroupDetailHasItems(detail) {
|
||||
return normalized
|
||||
}
|
||||
detail, err := s.java.FindRewardGroupDetail(ctx, sysOrigin, rewardGroupName)
|
||||
if err != nil || int64(detail.ID) <= 0 {
|
||||
return normalized
|
||||
}
|
||||
resolved := int64(detail.ID)
|
||||
return &resolved
|
||||
}
|
||||
|
||||
func (s *Service) loadConfigBundle(ctx context.Context, sysOrigin string) (*rechargeRewardConfigBundle, error) {
|
||||
sysOrigin = s.normalizeSysOrigin(sysOrigin)
|
||||
|
||||
|
||||
@ -2,7 +2,9 @@ package rechargereward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -113,7 +115,7 @@ func TestSaveConfigUpsertsBySysOrigin(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetHomeUsesJavaRechargeAndMarksLevels(t *testing.T) {
|
||||
groupID := int64(9001)
|
||||
groupID := int64(2056981527522242600)
|
||||
java := rechargeRewardTestGateway{
|
||||
total: map[int64][]integration.UserTotalRecharge{
|
||||
42: {
|
||||
@ -132,7 +134,14 @@ func TestGetHomeUsesJavaRechargeAndMarksLevels(t *testing.T) {
|
||||
ID: integration.Int64Value(groupID),
|
||||
Name: "Gift Box",
|
||||
RewardConfigList: []integration.RewardGroupItem{
|
||||
{ID: integration.Int64Value(1), Type: "PROP", Name: "Frame", Quantity: integration.Int64Value(1), Cover: "https://example.com/frame.png"},
|
||||
{
|
||||
ID: integration.Int64Value(1),
|
||||
Type: "PROPS",
|
||||
DetailType: "AVATAR_FRAME",
|
||||
Name: "Frame",
|
||||
Quantity: integration.Int64Value(1),
|
||||
SourceURL: "https://example.com/frame.svga",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -196,6 +205,63 @@ INSERT INTO likei_wallet.user_freight_balance_running_water (accept_user_id, sys
|
||||
if len(resp.LevelConfigs[0].RewardItems) != 1 || resp.LevelConfigs[0].RewardItems[0].Name != "Frame" {
|
||||
t.Fatalf("reward items = %+v", resp.LevelConfigs[0].RewardItems)
|
||||
}
|
||||
if got := resp.LevelConfigs[0].RewardItems[0]; got.Type != "AVATAR_FRAME" || got.Cover != "https://example.com/frame.svga" {
|
||||
t.Fatalf("reward item display fields = %+v", got)
|
||||
}
|
||||
if resp.LevelConfigs[1].RewardItems == nil {
|
||||
t.Fatalf("gold-only level RewardItems = nil, want empty slice")
|
||||
}
|
||||
encoded, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal response: %v", err)
|
||||
}
|
||||
body := string(encoded)
|
||||
if !strings.Contains(body, `"rewardGroupId":"2056981527522242600"`) {
|
||||
t.Fatalf("encoded rewardGroupId is not a string: %s", body)
|
||||
}
|
||||
if strings.Contains(body, `"rewardItems":null`) {
|
||||
t.Fatalf("encoded rewardItems contains null: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHomeFallsBackToRewardGroupNameWhenStoredIDLostPrecision(t *testing.T) {
|
||||
actualGroupID := int64(2056723577461338114)
|
||||
roundedGroupID := int64(2056723577461338000)
|
||||
service, _ := newTestService(t, rechargeRewardTestGateway{
|
||||
groups: map[int64]integration.RewardGroupDetail{
|
||||
actualGroupID: {
|
||||
ID: integration.Int64Value(actualGroupID),
|
||||
Name: "累充活动$50",
|
||||
RewardConfigList: []integration.RewardGroupItem{
|
||||
{ID: integration.Int64Value(1), Type: "BADGE", Name: "recharge50$", Quantity: integration.Int64Value(30), Cover: "https://example.com/badge.png"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if _, err := service.SaveConfig(context.Background(), SaveRechargeRewardConfigRequest{
|
||||
SysOrigin: "LIKEI",
|
||||
Enabled: true,
|
||||
Timezone: "Asia/Riyadh",
|
||||
LevelConfigs: []RechargeRewardLevelInput{
|
||||
{Level: 1, RechargeAmount: mustAmount(t, "50"), RewardGroupID: ptrFlexibleInt64(roundedGroupID), RewardGroupName: "累充活动$50", Enabled: true},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
resp, err := service.GetConfig(context.Background(), "LIKEI")
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfig() error = %v", err)
|
||||
}
|
||||
if len(resp.LevelConfigs) != 1 || len(resp.LevelConfigs[0].RewardItems) != 1 {
|
||||
t.Fatalf("reward items = %+v, want fallback by reward group name", resp.LevelConfigs)
|
||||
}
|
||||
if resp.LevelConfigs[0].RewardGroupID == nil || *resp.LevelConfigs[0].RewardGroupID != actualGroupID {
|
||||
t.Fatalf("rewardGroupId = %v, want resolved actual group id %d", resp.LevelConfigs[0].RewardGroupID, actualGroupID)
|
||||
}
|
||||
if got := resp.LevelConfigs[0].RewardItems[0].Name; got != "recharge50$" {
|
||||
t.Fatalf("reward item name = %q, want recharge50$", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageClaimRecordsUsesActualReceiveRecords(t *testing.T) {
|
||||
@ -382,6 +448,15 @@ func (g rechargeRewardTestGateway) GetRewardGroupDetail(_ context.Context, group
|
||||
return integration.RewardGroupDetail{}, fmt.Errorf("group not found")
|
||||
}
|
||||
|
||||
func (g rechargeRewardTestGateway) FindRewardGroupDetail(_ context.Context, _ string, name string) (integration.RewardGroupDetail, error) {
|
||||
for _, detail := range g.groups {
|
||||
if detail.Name == name {
|
||||
return detail, nil
|
||||
}
|
||||
}
|
||||
return integration.RewardGroupDetail{}, fmt.Errorf("group not found")
|
||||
}
|
||||
|
||||
func (g rechargeRewardTestGateway) MapUserTotalRecharge(context.Context, []int64) (map[int64][]integration.UserTotalRecharge, error) {
|
||||
return g.total, nil
|
||||
}
|
||||
|
||||
@ -83,7 +83,7 @@ func (s *Service) GetHome(ctx context.Context, user AuthUser) (*RechargeRewardHo
|
||||
resp.NextRechargeAmount = formatAmountCents(next.RechargeAmountCents)
|
||||
resp.NextRechargeAmountCents = next.RechargeAmountCents
|
||||
}
|
||||
rewardItems := s.loadRewardItemsMap(ctx, bundle.Levels)
|
||||
rewardItems := s.loadRewardItemsMap(ctx, resp.SysOrigin, bundle.Levels)
|
||||
matchedLevel := 0
|
||||
if matchedOK {
|
||||
matchedLevel = matched.Level
|
||||
|
||||
@ -47,6 +47,9 @@ func buildLevelPayloads(
|
||||
if level.RewardGroupID != nil {
|
||||
items = rewardItems[*level.RewardGroupID]
|
||||
}
|
||||
if items == nil {
|
||||
items = []RechargeRewardRewardItem{}
|
||||
}
|
||||
payloads = append(payloads, RechargeRewardLevelPayload{
|
||||
ID: level.ID,
|
||||
Level: level.Level,
|
||||
@ -74,6 +77,9 @@ func buildAdminLevelPayloads(
|
||||
if level.RewardGroupID != nil {
|
||||
items = rewardItems[*level.RewardGroupID]
|
||||
}
|
||||
if items == nil {
|
||||
items = []RechargeRewardRewardItem{}
|
||||
}
|
||||
payloads = append(payloads, RechargeRewardLevelPayload{
|
||||
ID: level.ID,
|
||||
Level: level.Level,
|
||||
@ -125,6 +131,7 @@ func sortLevels(levels []rechargeRewardLevelSnapshot) {
|
||||
|
||||
func (s *Service) loadRewardItemsMap(
|
||||
ctx context.Context,
|
||||
sysOrigin string,
|
||||
levels []rechargeRewardLevelSnapshot,
|
||||
) map[int64][]RechargeRewardRewardItem {
|
||||
if s.java == nil {
|
||||
@ -132,10 +139,14 @@ func (s *Service) loadRewardItemsMap(
|
||||
}
|
||||
groupIDs := make([]int64, 0)
|
||||
seen := make(map[int64]struct{})
|
||||
groupNames := make(map[int64]string)
|
||||
for _, level := range levels {
|
||||
if level.RewardGroupID == nil || *level.RewardGroupID <= 0 {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(level.RewardGroupName) != "" {
|
||||
groupNames[*level.RewardGroupID] = level.RewardGroupName
|
||||
}
|
||||
if _, exists := seen[*level.RewardGroupID]; exists {
|
||||
continue
|
||||
}
|
||||
@ -147,26 +158,71 @@ func (s *Service) loadRewardItemsMap(
|
||||
result := make(map[int64][]RechargeRewardRewardItem, len(groupIDs))
|
||||
for _, groupID := range groupIDs {
|
||||
detail, err := s.java.GetRewardGroupDetail(ctx, groupID)
|
||||
if err != nil {
|
||||
continue
|
||||
if err != nil || !rewardGroupDetailHasItems(detail) {
|
||||
if fallback, fallbackErr := s.java.FindRewardGroupDetail(ctx, sysOrigin, groupNames[groupID]); fallbackErr == nil && rewardGroupDetailHasItems(fallback) {
|
||||
detail = fallback
|
||||
} else {
|
||||
result[groupID] = []RechargeRewardRewardItem{}
|
||||
continue
|
||||
}
|
||||
}
|
||||
items := make([]RechargeRewardRewardItem, 0, len(detail.RewardConfigList))
|
||||
for _, item := range detail.RewardConfigList {
|
||||
items = append(items, RechargeRewardRewardItem{
|
||||
ID: int64(item.ID),
|
||||
Type: strings.TrimSpace(item.Type),
|
||||
Name: strings.TrimSpace(item.Name),
|
||||
Content: strings.TrimSpace(item.Content),
|
||||
Quantity: int64(item.Quantity),
|
||||
Cover: strings.TrimSpace(item.Cover),
|
||||
Remark: strings.TrimSpace(item.Remark),
|
||||
})
|
||||
}
|
||||
result[groupID] = items
|
||||
result[groupID] = rewardItemsFromGroup(detail)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func rewardGroupDetailHasItems(detail integration.RewardGroupDetail) bool {
|
||||
return len(detail.RewardConfigList) > 0
|
||||
}
|
||||
|
||||
func rewardItemsFromGroup(detail integration.RewardGroupDetail) []RechargeRewardRewardItem {
|
||||
items := make([]RechargeRewardRewardItem, 0, len(detail.RewardConfigList))
|
||||
for _, item := range detail.RewardConfigList {
|
||||
items = append(items, RechargeRewardRewardItem{
|
||||
ID: int64(item.ID),
|
||||
Type: rewardItemDisplayType(item),
|
||||
Name: strings.TrimSpace(item.Name),
|
||||
Content: strings.TrimSpace(item.Content),
|
||||
Quantity: int64(item.Quantity),
|
||||
Cover: firstNonEmpty(item.Cover, item.SourceURL),
|
||||
Remark: strings.TrimSpace(item.Remark),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func rewardItemDisplayType(item integration.RewardGroupItem) string {
|
||||
itemType := strings.ToUpper(strings.TrimSpace(item.Type))
|
||||
detailType := strings.ToUpper(strings.TrimSpace(item.DetailType))
|
||||
if itemType == "PROPS" {
|
||||
if detailType != "" && detailType != "PROPS" {
|
||||
return detailType
|
||||
}
|
||||
if isVipRewardItem(item) {
|
||||
return "NOBLE_VIP"
|
||||
}
|
||||
}
|
||||
return itemType
|
||||
}
|
||||
|
||||
func isVipRewardItem(item integration.RewardGroupItem) bool {
|
||||
name := strings.ToUpper(strings.TrimSpace(item.Name))
|
||||
remark := strings.ToUpper(strings.TrimSpace(item.Remark))
|
||||
return strings.Contains(name, "VIP") ||
|
||||
strings.Contains(name, "NOBLE") ||
|
||||
strings.Contains(remark, "VIP") ||
|
||||
strings.Contains(remark, "NOBLE")
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func totalRechargeCents(entries []integration.UserTotalRecharge) int64 {
|
||||
var total int64
|
||||
for _, entry := range entries {
|
||||
|
||||
@ -35,6 +35,7 @@ type rechargeRewardDB interface {
|
||||
type rechargeRewardJavaGateway interface {
|
||||
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error)
|
||||
GetRewardGroupDetail(ctx context.Context, groupID int64) (integration.RewardGroupDetail, error)
|
||||
FindRewardGroupDetail(ctx context.Context, sysOrigin string, name string) (integration.RewardGroupDetail, error)
|
||||
MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error)
|
||||
GetThisMonthTotalPersonalRecharge(ctx context.Context, userID int64) (integration.DecimalString, error)
|
||||
}
|
||||
@ -75,7 +76,7 @@ type RechargeRewardLevelPayload struct {
|
||||
RechargeAmount string `json:"rechargeAmount"`
|
||||
RechargeAmountCents int64 `json:"rechargeAmountCents"`
|
||||
RewardGold int64 `json:"rewardGold"`
|
||||
RewardGroupID *int64 `json:"rewardGroupId,omitempty"`
|
||||
RewardGroupID *int64 `json:"rewardGroupId,string,omitempty"`
|
||||
RewardGroupName string `json:"rewardGroupName,omitempty"`
|
||||
RewardItems []RechargeRewardRewardItem `json:"rewardItems"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@ -175,7 +176,7 @@ type RechargeRewardClaimedRewardPayload struct {
|
||||
RechargeAmount string `json:"rechargeAmount"`
|
||||
RechargeAmountCents int64 `json:"rechargeAmountCents"`
|
||||
RewardGold int64 `json:"rewardGold"`
|
||||
RewardGroupID *int64 `json:"rewardGroupId,omitempty"`
|
||||
RewardGroupID *int64 `json:"rewardGroupId,string,omitempty"`
|
||||
RewardGroupName string `json:"rewardGroupName,omitempty"`
|
||||
RewardItems []RechargeRewardRewardItem `json:"rewardItems"`
|
||||
}
|
||||
|
||||
@ -66,6 +66,8 @@ func (s *Service) SendRegionBroadcast(ctx context.Context, req RegionBroadcastRe
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
refreshHighWinMessage(data, messageType)
|
||||
refreshCPRelationMessage(data, messageType)
|
||||
|
||||
body := map[string]any{
|
||||
"type": messageType,
|
||||
@ -182,6 +184,60 @@ func firstInt64Value(values ...any) int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func refreshHighWinMessage(data map[string]any, messageType string) {
|
||||
target := highWinPlayTarget(messageType)
|
||||
if target == "" {
|
||||
return
|
||||
}
|
||||
user := firstTextValue(data["userNickname"], data["nickname"], data["account"], data["actualAccount"], data["userId"])
|
||||
multiple := firstTextValue(data["multiple"], data["winMultiple"])
|
||||
amount := firstTextValue(data["winAmount"], data["awardAmount"])
|
||||
if user == "" || multiple == "" || amount == "" {
|
||||
return
|
||||
}
|
||||
data["msg"] = fmt.Sprintf("%s play %s get x %s win %s coins!!!", user, target, multiple, amount)
|
||||
}
|
||||
|
||||
func highWinPlayTarget(messageType string) string {
|
||||
switch strings.TrimSpace(messageType) {
|
||||
case "GAME_LUCKY_GIFT":
|
||||
return "lucky_gift"
|
||||
case "GAME_BAISHUN_WIN":
|
||||
return "game"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func refreshCPRelationMessage(data map[string]any, messageType string) {
|
||||
if strings.TrimSpace(messageType) != "CP_RELATION_BROADCAST" {
|
||||
return
|
||||
}
|
||||
user := firstTextValue(data["userNickname"], data["nickname"], data["account"], data["actualAccount"], data["userId"])
|
||||
cpUser := firstTextValue(data["cpUserNickname"], data["acceptNickname"], data["toUserName"], data["cpUserAccount"], data["acceptAccount"], data["cpUserId"], data["acceptUserId"])
|
||||
if user == "" || cpUser == "" {
|
||||
return
|
||||
}
|
||||
switch strings.ToUpper(strings.TrimSpace(firstTextValue(data["relationType"]))) {
|
||||
case "BROTHER", "BROTHERS":
|
||||
data["msg"] = fmt.Sprintf("%s与%s结为兄弟", user, cpUser)
|
||||
case "SISTERS", "SISTER":
|
||||
data["msg"] = fmt.Sprintf("%s与%s结为姐妹", user, cpUser)
|
||||
default:
|
||||
data["msg"] = fmt.Sprintf("%s与%s get cp relation", user, cpUser)
|
||||
}
|
||||
}
|
||||
|
||||
func firstTextValue(values ...any) string {
|
||||
for _, value := range values {
|
||||
text := strings.TrimSpace(fmt.Sprint(value))
|
||||
if text != "" && text != "<nil>" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstInt64FromList(value any) int64 {
|
||||
switch typed := value.(type) {
|
||||
case []any:
|
||||
|
||||
@ -133,6 +133,97 @@ func TestSendRegionBroadcastResolvesRegionAndEnrichesGiftPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendRegionBroadcastRefreshesHighWinMessageWithProfile(t *testing.T) {
|
||||
service, db := newRegionIMGroupTestService(t)
|
||||
fakeIM := &fakeIMGateway{}
|
||||
service.im = fakeIM
|
||||
now := time.Now()
|
||||
if err := db.Create(&model.VoiceRoomRegionIMGroup{
|
||||
ID: 3010,
|
||||
SysOrigin: "LIKEI",
|
||||
RegionCode: "AR",
|
||||
RegionName: "Arab",
|
||||
GroupID: "@region-ar",
|
||||
GroupName: "region-ar",
|
||||
Status: imGroupStatusActive,
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed im group: %v", err)
|
||||
}
|
||||
|
||||
_, err := service.SendRegionBroadcast(context.Background(), RegionBroadcastRequest{
|
||||
SysOrigin: "LIKEI",
|
||||
Type: "GAME_BAISHUN_WIN",
|
||||
Data: map[string]any{
|
||||
"userId": "1001",
|
||||
"roomId": "9001",
|
||||
"multiple": 10,
|
||||
"winAmount": 1000,
|
||||
"msg": "1001 play game get x 10 win 1000 coins!!!",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendRegionBroadcast() error = %v", err)
|
||||
}
|
||||
body, ok := fakeIM.sent[0].body.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("sent body type = %T", fakeIM.sent[0].body)
|
||||
}
|
||||
data, ok := body["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("sent data type = %T", body["data"])
|
||||
}
|
||||
if data["msg"] != "sender play game get x 10 win 1000 coins!!!" {
|
||||
t.Fatalf("msg = %v", data["msg"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendRegionBroadcastRefreshesCPRelationMessageWithProfiles(t *testing.T) {
|
||||
service, db := newRegionIMGroupTestService(t)
|
||||
fakeIM := &fakeIMGateway{}
|
||||
service.im = fakeIM
|
||||
now := time.Now()
|
||||
if err := db.Create(&model.VoiceRoomRegionIMGroup{
|
||||
ID: 3011,
|
||||
SysOrigin: "LIKEI",
|
||||
RegionCode: "AR",
|
||||
RegionName: "Arab",
|
||||
GroupID: "@region-ar",
|
||||
GroupName: "region-ar",
|
||||
Status: imGroupStatusActive,
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed im group: %v", err)
|
||||
}
|
||||
|
||||
_, err := service.SendRegionBroadcast(context.Background(), RegionBroadcastRequest{
|
||||
SysOrigin: "LIKEI",
|
||||
Type: "CP_RELATION_BROADCAST",
|
||||
Data: map[string]any{
|
||||
"userId": "1001",
|
||||
"acceptUserId": "1002",
|
||||
"relationType": "SISTERS",
|
||||
"msg": "1001与1002结为姐妹",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendRegionBroadcast() error = %v", err)
|
||||
}
|
||||
body, ok := fakeIM.sent[0].body.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("sent body type = %T", fakeIM.sent[0].body)
|
||||
}
|
||||
data, ok := body["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("sent data type = %T", body["data"])
|
||||
}
|
||||
if data["msg"] != "sender与claimer结为姐妹" {
|
||||
t.Fatalf("msg = %v", data["msg"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendRegionBroadcastPrefersJavaRegionResolver(t *testing.T) {
|
||||
service, db := newRegionIMGroupTestService(t)
|
||||
service.regionResolver = fakeRegionResolver{
|
||||
|
||||
@ -20,6 +20,11 @@ import (
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTaskCenterArchiveBatchSize = 500
|
||||
maxTaskCenterArchiveBatchSize = 1000
|
||||
)
|
||||
|
||||
// StartArchiveWorker 启动任务中心事件 COS 归档 worker。
|
||||
func (s *Service) StartArchiveWorker(ctx context.Context) error {
|
||||
if !s.cfg.TaskCenter.Archive.Enabled {
|
||||
@ -68,10 +73,7 @@ func (s *Service) runArchiveWorker(ctx context.Context, workerIndex int) {
|
||||
}
|
||||
|
||||
func (s *Service) archivePendingEvents(ctx context.Context) error {
|
||||
batchSize := s.cfg.TaskCenter.Archive.BatchSize
|
||||
if batchSize <= 0 {
|
||||
batchSize = 10000
|
||||
}
|
||||
batchSize := normalizeTaskCenterArchiveBatchSize(s.cfg.TaskCenter.Archive.BatchSize)
|
||||
rows, err := s.claimArchiveRows(ctx, batchSize)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -97,16 +99,46 @@ func (s *Service) claimArchiveRows(ctx context.Context, batchSize int) ([]model.
|
||||
var rows []model.TaskCenterEventArchiveOutbox
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
staleProcessingBefore := time.Now().Add(-10 * time.Minute)
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
|
||||
Where("status IN ? OR (status = ? AND update_time < ?)",
|
||||
[]string{ArchiveStatusPending, ArchiveStatusFailed},
|
||||
ArchiveStatusProcessing,
|
||||
staleProcessingBefore,
|
||||
).
|
||||
Order("create_time asc, id asc").
|
||||
Limit(batchSize).
|
||||
Find(&rows).Error; err != nil {
|
||||
return err
|
||||
staleLimit, _ := taskCenterArchiveClaimLimits(batchSize)
|
||||
if staleLimit > 0 {
|
||||
var staleRows []model.TaskCenterEventArchiveOutbox
|
||||
// PROCESSING 超时行保留固定接管额度,避免 PENDING/FAILED 持续堆积时旧锁永久饥饿。
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
|
||||
Where("status = ? AND update_time < ?", ArchiveStatusProcessing, staleProcessingBefore).
|
||||
Order("update_time asc, id asc").
|
||||
Limit(staleLimit).
|
||||
Find(&staleRows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
rows = append(rows, staleRows...)
|
||||
}
|
||||
if remaining := batchSize - len(rows); remaining > 0 {
|
||||
pendingLimit := taskCenterArchivePendingClaimLimit(remaining)
|
||||
for _, retryable := range []struct {
|
||||
status string
|
||||
limit int
|
||||
}{
|
||||
{status: ArchiveStatusPending, limit: pendingLimit},
|
||||
{status: ArchiveStatusFailed, limit: remaining},
|
||||
} {
|
||||
if retryable.limit <= 0 || remaining <= 0 {
|
||||
continue
|
||||
}
|
||||
if retryable.limit > remaining {
|
||||
retryable.limit = remaining
|
||||
}
|
||||
var retryableRows []model.TaskCenterEventArchiveOutbox
|
||||
// PENDING 和 FAILED 分开抢占,避免 IN 跨 status 排序触发 filesort,同时给 FAILED 保留重试额度。
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
|
||||
Where("status = ?", retryable.status).
|
||||
Order("create_time asc, id asc").
|
||||
Limit(retryable.limit).
|
||||
Find(&retryableRows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
rows = append(rows, retryableRows...)
|
||||
remaining -= len(retryableRows)
|
||||
}
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
@ -124,6 +156,44 @@ func (s *Service) claimArchiveRows(ctx context.Context, batchSize int) ([]model.
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func normalizeTaskCenterArchiveBatchSize(batchSize int) int {
|
||||
if batchSize <= 0 {
|
||||
return defaultTaskCenterArchiveBatchSize
|
||||
}
|
||||
if batchSize > maxTaskCenterArchiveBatchSize {
|
||||
return maxTaskCenterArchiveBatchSize
|
||||
}
|
||||
return batchSize
|
||||
}
|
||||
|
||||
func taskCenterArchiveClaimLimits(batchSize int) (int, int) {
|
||||
if batchSize <= 1 {
|
||||
return batchSize, 0
|
||||
}
|
||||
staleLimit := batchSize / 10
|
||||
if staleLimit < 1 {
|
||||
staleLimit = 1
|
||||
}
|
||||
if staleLimit > 100 {
|
||||
staleLimit = 100
|
||||
}
|
||||
return staleLimit, batchSize - staleLimit
|
||||
}
|
||||
|
||||
func taskCenterArchivePendingClaimLimit(retryableLimit int) int {
|
||||
if retryableLimit <= 1 {
|
||||
return retryableLimit
|
||||
}
|
||||
failedReserve := retryableLimit / 10
|
||||
if failedReserve < 1 {
|
||||
failedReserve = 1
|
||||
}
|
||||
if failedReserve > 100 {
|
||||
failedReserve = 100
|
||||
}
|
||||
return retryableLimit - failedReserve
|
||||
}
|
||||
|
||||
func (s *Service) newCOSClient() (*cos.Client, error) {
|
||||
bucketURL, err := url.Parse(fmt.Sprintf("https://%s.cos.%s.myqcloud.com",
|
||||
strings.TrimSpace(s.cfg.TaskCenter.Archive.Bucket),
|
||||
|
||||
@ -42,6 +42,73 @@ func newTestService(t *testing.T, java taskCenterJavaGateway) (*Service, *gorm.D
|
||||
return service, db
|
||||
}
|
||||
|
||||
func TestNormalizeTaskCenterArchiveBatchSize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input int
|
||||
want int
|
||||
}{
|
||||
{name: "default", input: 0, want: defaultTaskCenterArchiveBatchSize},
|
||||
{name: "negative default", input: -1, want: defaultTaskCenterArchiveBatchSize},
|
||||
{name: "keeps configured value", input: 200, want: 200},
|
||||
{name: "caps large batch", input: 10000, want: maxTaskCenterArchiveBatchSize},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := normalizeTaskCenterArchiveBatchSize(tt.input); got != tt.want {
|
||||
t.Fatalf("normalizeTaskCenterArchiveBatchSize(%d) = %d, want %d", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskCenterArchiveClaimLimitsReserveStaleRows(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
batchSize int
|
||||
wantStale int
|
||||
wantRetryable int
|
||||
}{
|
||||
{name: "single row", batchSize: 1, wantStale: 1, wantRetryable: 0},
|
||||
{name: "small batch keeps one stale slot", batchSize: 5, wantStale: 1, wantRetryable: 4},
|
||||
{name: "normal batch uses ten percent", batchSize: 500, wantStale: 50, wantRetryable: 450},
|
||||
{name: "large batch caps stale slot", batchSize: 1000, wantStale: 100, wantRetryable: 900},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotStale, gotRetryable := taskCenterArchiveClaimLimits(tt.batchSize)
|
||||
if gotStale != tt.wantStale || gotRetryable != tt.wantRetryable {
|
||||
t.Fatalf("taskCenterArchiveClaimLimits(%d) = (%d, %d), want (%d, %d)",
|
||||
tt.batchSize, gotStale, gotRetryable, tt.wantStale, tt.wantRetryable)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskCenterArchivePendingClaimLimitReservesFailedRows(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
retryableLimit int
|
||||
want int
|
||||
}{
|
||||
{name: "empty", retryableLimit: 0, want: 0},
|
||||
{name: "single row stays pending first", retryableLimit: 1, want: 1},
|
||||
{name: "small retry batch keeps one failed slot", retryableLimit: 4, want: 3},
|
||||
{name: "normal retry batch reserves ten percent", retryableLimit: 450, want: 405},
|
||||
{name: "large retry batch caps failed reserve", retryableLimit: 900, want: 810},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := taskCenterArchivePendingClaimLimit(tt.retryableLimit); got != tt.want {
|
||||
t.Fatalf("taskCenterArchivePendingClaimLimit(%d) = %d, want %d", tt.retryableLimit, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyTaskConfigEventListAndClaim(t *testing.T) {
|
||||
java := &taskCenterTestGateway{events: map[string]bool{}}
|
||||
service, db := newTestService(t, java)
|
||||
|
||||
@ -170,7 +170,7 @@ func (s *Service) Purchase(ctx context.Context, user AuthUser, req PurchaseReque
|
||||
if stateErr != nil {
|
||||
return nil, stateErr
|
||||
}
|
||||
if err := s.ensureStateResourcesGranted(ctx, user.UserID, state, now); err != nil {
|
||||
if err := s.ensureStateResourcesGranted(ctx, sysOrigin, user.UserID, state, now); err != nil {
|
||||
return nil, NewAppError(http.StatusBadGateway, "vip_resource_grant_failed", err.Error())
|
||||
}
|
||||
resources, resourceErr := s.loadResourceLookup(ctx, appendResourceIDsFromState(nil, state))
|
||||
@ -274,6 +274,7 @@ func (s *Service) Purchase(ctx context.Context, user AuthUser, req PurchaseReque
|
||||
}
|
||||
}
|
||||
|
||||
previousResources := vipGrantResourcesFromState(state)
|
||||
applyPlanToState(state, plan, now)
|
||||
order.Status = orderStatusSuccess
|
||||
order.ErrorMessage = ""
|
||||
@ -289,7 +290,7 @@ func (s *Service) Purchase(ctx context.Context, user AuthUser, req PurchaseReque
|
||||
}
|
||||
committed = true
|
||||
|
||||
if err := s.ensurePlanResourcesGranted(ctx, user.UserID, plan, now); err != nil {
|
||||
if err := s.ensurePlanResourcesGranted(ctx, sysOrigin, user.UserID, plan, configs, previousResources, now); err != nil {
|
||||
return nil, NewAppError(http.StatusBadGateway, "vip_resource_grant_failed", err.Error())
|
||||
}
|
||||
|
||||
@ -536,15 +537,28 @@ func expireState(state *model.UserVipState, now time.Time) {
|
||||
state.UpdateTime = now
|
||||
}
|
||||
|
||||
func (s *Service) ensurePlanResourcesGranted(ctx context.Context, userID int64, plan purchasePlan, now time.Time) error {
|
||||
return s.ensureVIPResourcesGranted(ctx, userID, vipGrantResourcesFromConfig(plan.TargetConfig), plan.ExpireAt, plan.DurationDays, now)
|
||||
func (s *Service) ensurePlanResourcesGranted(ctx context.Context, sysOrigin string, userID int64, plan purchasePlan, configs map[int]model.VipLevelConfig, additionalOldResources []vipGrantResource, now time.Time) error {
|
||||
targetResources := vipGrantResourcesFromConfig(plan.TargetConfig)
|
||||
if err := s.ensureVIPResourcesGranted(ctx, userID, targetResources, plan.ExpireAt, plan.DurationDays, now); err != nil {
|
||||
return err
|
||||
}
|
||||
allVIPResources := append(vipGrantResourcesFromConfigs(configs), additionalOldResources...)
|
||||
return s.cleanupSupersededVIPResources(ctx, userID, targetResources, allVIPResources, now)
|
||||
}
|
||||
|
||||
func (s *Service) ensureStateResourcesGranted(ctx context.Context, userID int64, state *model.UserVipState, now time.Time) error {
|
||||
func (s *Service) ensureStateResourcesGranted(ctx context.Context, sysOrigin string, userID int64, state *model.UserVipState, now time.Time) error {
|
||||
if !isActiveState(state, now) {
|
||||
return nil
|
||||
}
|
||||
return s.ensureVIPResourcesGranted(ctx, userID, vipGrantResourcesFromState(state), state.ExpireAt, vipGrantDaysUntil(now, state.ExpireAt), now)
|
||||
targetResources := vipGrantResourcesFromState(state)
|
||||
if err := s.ensureVIPResourcesGranted(ctx, userID, targetResources, state.ExpireAt, vipGrantDaysUntil(now, state.ExpireAt), now); err != nil {
|
||||
return err
|
||||
}
|
||||
configs, err := s.loadConfigMap(ctx, sysOrigin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.cleanupSupersededVIPResources(ctx, userID, targetResources, vipGrantResourcesFromConfigs(configs), now)
|
||||
}
|
||||
|
||||
func (s *Service) ensureVIPResourcesGranted(ctx context.Context, userID int64, resources []vipGrantResource, expireAt time.Time, days int, now time.Time) error {
|
||||
@ -685,6 +699,17 @@ func vipGrantResourcesFromState(state *model.UserVipState) []vipGrantResource {
|
||||
})
|
||||
}
|
||||
|
||||
func vipGrantResourcesFromConfigs(configs map[int]model.VipLevelConfig) []vipGrantResource {
|
||||
if len(configs) == 0 {
|
||||
return nil
|
||||
}
|
||||
resources := make([]vipGrantResource, 0, len(configs)*7)
|
||||
for _, config := range configs {
|
||||
resources = append(resources, vipGrantResourcesFromConfig(config)...)
|
||||
}
|
||||
return compactVIPGrantResources(resources)
|
||||
}
|
||||
|
||||
func compactVIPGrantResources(resources []vipGrantResource) []vipGrantResource {
|
||||
compacted := make([]vipGrantResource, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
@ -695,6 +720,65 @@ func compactVIPGrantResources(resources []vipGrantResource) []vipGrantResource {
|
||||
return compacted
|
||||
}
|
||||
|
||||
func (s *Service) cleanupSupersededVIPResources(ctx context.Context, userID int64, targetResources []vipGrantResource, allVIPResources []vipGrantResource, now time.Time) error {
|
||||
targets := make(map[string]struct{}, len(targetResources))
|
||||
for _, resource := range targetResources {
|
||||
targets[vipGrantResourceKey(resource)] = struct{}{}
|
||||
}
|
||||
|
||||
badgeIDs := make([]int64, 0)
|
||||
propsIDsByType := map[string][]int64{}
|
||||
seen := make(map[string]struct{}, len(allVIPResources))
|
||||
for _, resource := range allVIPResources {
|
||||
if resource.ResourceID <= 0 {
|
||||
continue
|
||||
}
|
||||
key := vipGrantResourceKey(resource)
|
||||
if _, exists := targets[key]; exists {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
if resource.Badge {
|
||||
badgeIDs = append(badgeIDs, resource.ResourceID)
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(resource.PropsType) != "" {
|
||||
propsIDsByType[resource.PropsType] = append(propsIDsByType[resource.PropsType], resource.ResourceID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(badgeIDs) > 0 {
|
||||
if err := s.db.WithContext(ctx).Exec(
|
||||
"UPDATE user_badge_backpack SET expire_type = ?, expire_time = ?, is_use_props = ?, update_time = ? WHERE user_id = ? AND badge_id IN ? AND expire_time > ?",
|
||||
"TEMPORARY", now, false, now, userID, badgeIDs, now,
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for propsType, propsIDs := range propsIDsByType {
|
||||
if len(propsIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Exec(
|
||||
"UPDATE user_props_backpack SET expire_time = ?, is_use_props = ?, allow_give = ?, update_time = ? WHERE user_id = ? AND props_id IN ? AND type = ? AND expire_time > ?",
|
||||
now, false, false, now, userID, propsIDs, propsType, now,
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func vipGrantResourceKey(resource vipGrantResource) string {
|
||||
if resource.Badge {
|
||||
return fmt.Sprintf("BADGE:%d", resource.ResourceID)
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", resource.PropsType, resource.ResourceID)
|
||||
}
|
||||
|
||||
func vipGrantDaysUntil(now time.Time, expireAt time.Time) int {
|
||||
if !expireAt.After(now) {
|
||||
return 1
|
||||
|
||||
@ -1,13 +1,18 @@
|
||||
package vip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/model"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestBuildPurchasePlanNewPurchase(t *testing.T) {
|
||||
@ -129,6 +134,33 @@ func TestVipResourcePayloadAcceptsStringResourceID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVipLevelPayloadMarshalsIDAsString(t *testing.T) {
|
||||
payload := payloadFromConfig(model.VipLevelConfig{
|
||||
ID: 2049402425177018368,
|
||||
SysOrigin: "LIKEI",
|
||||
Level: 1,
|
||||
LevelCode: "VIP1",
|
||||
DisplayName: "VIP 1",
|
||||
Enabled: true,
|
||||
})
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal vip level payload: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(body), `"id":"2049402425177018368"`) {
|
||||
t.Fatalf("vip level id was not emitted as string: %s", body)
|
||||
}
|
||||
|
||||
var decoded VipLevelPayload
|
||||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal vip level payload: %v", err)
|
||||
}
|
||||
if decoded.ID.Int64() != 2049402425177018368 {
|
||||
t.Fatalf("decoded id = %d, want 2049402425177018368", decoded.ID.Int64())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePayloadUsesResourceGroupDetails(t *testing.T) {
|
||||
row := model.VipLevelConfig{
|
||||
Level: 2,
|
||||
@ -294,6 +326,47 @@ func TestVIPGrantResourcesFromConfigMapsBackpackTypes(t *testing.T) {
|
||||
assertGrant(6, 16, vipPropsTypeDataCard, false)
|
||||
}
|
||||
|
||||
func TestCleanupSupersededVIPResourcesExpiresOnlyOldVIPResources(t *testing.T) {
|
||||
db := openVIPCleanupTestDB(t)
|
||||
service := &Service{db: db}
|
||||
now := time.Date(2026, 5, 30, 10, 0, 0, 0, time.UTC)
|
||||
future := now.AddDate(0, 0, 20)
|
||||
oldTime := now.AddDate(0, 0, -1)
|
||||
userID := int64(1001)
|
||||
|
||||
seedVIPCleanupRows(t, db,
|
||||
vipCleanupPropsRow{ID: 1, UserID: userID, PropsID: 101, Type: vipPropsTypeAvatarFrame, ExpireTime: future, UseProps: true, AllowGive: true, UpdateTime: oldTime},
|
||||
vipCleanupPropsRow{ID: 2, UserID: userID, PropsID: 201, Type: vipPropsTypeAvatarFrame, ExpireTime: future, UseProps: true, AllowGive: true, UpdateTime: oldTime},
|
||||
vipCleanupPropsRow{ID: 3, UserID: userID, PropsID: 999, Type: vipPropsTypeAvatarFrame, ExpireTime: future, UseProps: true, AllowGive: true, UpdateTime: oldTime},
|
||||
vipCleanupPropsRow{ID: 4, UserID: userID, PropsID: 102, Type: vipPropsTypeRide, ExpireTime: future, UseProps: true, AllowGive: true, UpdateTime: oldTime},
|
||||
)
|
||||
seedVIPCleanupBadges(t, db,
|
||||
vipCleanupBadgeRow{ID: 1, UserID: userID, BadgeID: 100, ExpireTime: future, UseProps: true, UpdateTime: oldTime},
|
||||
vipCleanupBadgeRow{ID: 2, UserID: userID, BadgeID: 200, ExpireTime: future, UseProps: true, UpdateTime: oldTime},
|
||||
)
|
||||
|
||||
target := vipGrantResourcesFromConfig(model.VipLevelConfig{
|
||||
BadgeResourceID: 200,
|
||||
AvatarFrameResourceID: 201,
|
||||
})
|
||||
all := append(vipGrantResourcesFromConfig(model.VipLevelConfig{
|
||||
BadgeResourceID: 100,
|
||||
AvatarFrameResourceID: 101,
|
||||
EntryEffectResourceID: 102,
|
||||
}), target...)
|
||||
|
||||
if err := service.cleanupSupersededVIPResources(context.Background(), userID, target, all, now); err != nil {
|
||||
t.Fatalf("cleanupSupersededVIPResources() error = %v", err)
|
||||
}
|
||||
|
||||
assertVIPCleanupProps(t, db, 101, now, false, false)
|
||||
assertVIPCleanupProps(t, db, 102, now, false, false)
|
||||
assertVIPCleanupProps(t, db, 201, future, true, true)
|
||||
assertVIPCleanupProps(t, db, 999, future, true, true)
|
||||
assertVIPCleanupBadge(t, db, 100, now, false)
|
||||
assertVIPCleanupBadge(t, db, 200, future, true)
|
||||
}
|
||||
|
||||
func TestVIPGrantDaysUntilCeilsPartialDay(t *testing.T) {
|
||||
now := time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC)
|
||||
expireAt := now.Add((24 * time.Hour) + time.Second)
|
||||
@ -306,6 +379,89 @@ func TestVIPGrantDaysUntilCeilsPartialDay(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type vipCleanupPropsRow struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
UserID int64 `gorm:"column:user_id"`
|
||||
PropsID int64 `gorm:"column:props_id"`
|
||||
Type string `gorm:"column:type"`
|
||||
ExpireTime time.Time `gorm:"column:expire_time"`
|
||||
UseProps bool `gorm:"column:is_use_props"`
|
||||
AllowGive bool `gorm:"column:allow_give"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
}
|
||||
|
||||
func (vipCleanupPropsRow) TableName() string {
|
||||
return "user_props_backpack"
|
||||
}
|
||||
|
||||
type vipCleanupBadgeRow struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
UserID int64 `gorm:"column:user_id"`
|
||||
BadgeID int64 `gorm:"column:badge_id"`
|
||||
ExpireType string `gorm:"column:expire_type"`
|
||||
ExpireTime time.Time `gorm:"column:expire_time"`
|
||||
UseProps bool `gorm:"column:is_use_props"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
}
|
||||
|
||||
func (vipCleanupBadgeRow) TableName() string {
|
||||
return "user_badge_backpack"
|
||||
}
|
||||
|
||||
func openVIPCleanupTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&vipCleanupPropsRow{}, &vipCleanupBadgeRow{}); err != nil {
|
||||
t.Fatalf("auto migrate cleanup tables: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func seedVIPCleanupRows(t *testing.T, db *gorm.DB, rows ...vipCleanupPropsRow) {
|
||||
t.Helper()
|
||||
for _, row := range rows {
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("seed props row: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func seedVIPCleanupBadges(t *testing.T, db *gorm.DB, rows ...vipCleanupBadgeRow) {
|
||||
t.Helper()
|
||||
for _, row := range rows {
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("seed badge row: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertVIPCleanupProps(t *testing.T, db *gorm.DB, propsID int64, expireTime time.Time, useProps bool, allowGive bool) {
|
||||
t.Helper()
|
||||
var row vipCleanupPropsRow
|
||||
if err := db.Where("props_id = ?", propsID).First(&row).Error; err != nil {
|
||||
t.Fatalf("load props %d: %v", propsID, err)
|
||||
}
|
||||
if !row.ExpireTime.Equal(expireTime) || row.UseProps != useProps || row.AllowGive != allowGive {
|
||||
t.Fatalf("props %d = expire:%s use:%v allow:%v, want expire:%s use:%v allow:%v",
|
||||
propsID, row.ExpireTime, row.UseProps, row.AllowGive, expireTime, useProps, allowGive)
|
||||
}
|
||||
}
|
||||
|
||||
func assertVIPCleanupBadge(t *testing.T, db *gorm.DB, badgeID int64, expireTime time.Time, useProps bool) {
|
||||
t.Helper()
|
||||
var row vipCleanupBadgeRow
|
||||
if err := db.Where("badge_id = ?", badgeID).First(&row).Error; err != nil {
|
||||
t.Fatalf("load badge %d: %v", badgeID, err)
|
||||
}
|
||||
if !row.ExpireTime.Equal(expireTime) || row.UseProps != useProps {
|
||||
t.Fatalf("badge %d = expire:%s use:%v, want expire:%s use:%v",
|
||||
badgeID, row.ExpireTime, row.UseProps, expireTime, useProps)
|
||||
}
|
||||
}
|
||||
|
||||
func vipTestConfigs() map[int]model.VipLevelConfig {
|
||||
configs := make(map[int]model.VipLevelConfig, levelCount)
|
||||
for level := 1; level <= levelCount; level++ {
|
||||
|
||||
@ -83,7 +83,7 @@ type ConfigResponse struct {
|
||||
}
|
||||
|
||||
type VipLevelPayload struct {
|
||||
ID int64 `json:"id"`
|
||||
ID ResourceID `json:"id"`
|
||||
Level int `json:"level"`
|
||||
LevelCode string `json:"levelCode"`
|
||||
DisplayName string `json:"displayName"`
|
||||
@ -365,7 +365,7 @@ func payloadFromConfigWithResources(row model.VipLevelConfig, resources vipResou
|
||||
code = levelCode(row.Level)
|
||||
}
|
||||
return VipLevelPayload{
|
||||
ID: row.ID,
|
||||
ID: ResourceID(row.ID),
|
||||
Level: row.Level,
|
||||
LevelCode: code,
|
||||
DisplayName: displayName,
|
||||
|
||||
@ -182,6 +182,12 @@ func (s *Service) completeClaimWallet(ctx context.Context, claim model.VoiceRoom
|
||||
}
|
||||
now := time.Now()
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var lockedPacket model.VoiceRoomRedPacket
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ?", claim.PacketID).
|
||||
First(&lockedPacket).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.VoiceRoomRedPacketClaim{}).
|
||||
Where("id = ?", claim.ID).
|
||||
Updates(map[string]any{
|
||||
@ -200,7 +206,7 @@ func (s *Service) completeClaimWallet(ctx context.Context, claim model.VoiceRoom
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.VoiceRoomRedPacket{}).
|
||||
Where("id = ? AND remain_count = 0 AND status = ?", claim.PacketID, packetStatusActive).
|
||||
Where("id = ? AND remain_count = 0 AND status = ?", lockedPacket.ID, packetStatusActive).
|
||||
Updates(map[string]any{
|
||||
"status": packetStatusFinished,
|
||||
"update_time": now,
|
||||
|
||||
@ -399,6 +399,9 @@ func TestInRoomRewardWorkerSnapshotsSelectsAndGrants(t *testing.T) {
|
||||
if err := rdb.SAdd(ctx, onlineRoomKey("LIKEI", roomID), userID).Err(); err != nil {
|
||||
t.Fatalf("sadd online: %v", err)
|
||||
}
|
||||
if err := rdb.Set(ctx, userRoomKey("LIKEI", userID), strconv.FormatInt(roomID, 10), time.Minute).Err(); err != nil {
|
||||
t.Fatalf("set user room: %v", err)
|
||||
}
|
||||
if err := rdb.Set(ctx, userSeenKey("LIKEI", userID), strconv.FormatInt(time.Now().UnixMilli(), 10), time.Minute).Err(); err != nil {
|
||||
t.Fatalf("set seen: %v", err)
|
||||
}
|
||||
@ -471,6 +474,92 @@ func TestInRoomRewardWorkerSnapshotsSelectsAndGrants(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInRoomRewardWorkerSkipsUsersNoLongerInRoom(t *testing.T) {
|
||||
gateway := newFakeRocketGateway()
|
||||
service, db, rdb, mr := newTestServiceWithRedis(t, gateway)
|
||||
defer mr.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
seedRocketConfig(t, db)
|
||||
seedLevelConfigs(t, db, []int64{100, 200, 300, 400, 500, 600})
|
||||
seedRewardConfig(t, db, 1, rewardSceneTop1, rewardTypeGold, 10)
|
||||
seedRewardConfig(t, db, 1, rewardSceneIgnite, rewardTypeGold, 5)
|
||||
seedRewardConfigWithCopies(t, db, 1, rewardSceneInRoom, rewardTypeGold, 1, 4)
|
||||
|
||||
roomID := int64(9001)
|
||||
for _, userID := range []int64{1001, 1002} {
|
||||
if err := rdb.SAdd(ctx, onlineRoomKey("LIKEI", roomID), userID).Err(); err != nil {
|
||||
t.Fatalf("sadd active online: %v", err)
|
||||
}
|
||||
if err := rdb.Set(ctx, userRoomKey("LIKEI", userID), strconv.FormatInt(roomID, 10), time.Minute).Err(); err != nil {
|
||||
t.Fatalf("set active room: %v", err)
|
||||
}
|
||||
if err := rdb.Set(ctx, userSeenKey("LIKEI", userID), strconv.FormatInt(now.UnixMilli(), 10), time.Minute).Err(); err != nil {
|
||||
t.Fatalf("set active seen: %v", err)
|
||||
}
|
||||
}
|
||||
staleUsers := []int64{1003, 1004}
|
||||
for _, userID := range staleUsers {
|
||||
if err := rdb.SAdd(ctx, onlineRoomKey("LIKEI", roomID), userID).Err(); err != nil {
|
||||
t.Fatalf("sadd stale online: %v", err)
|
||||
}
|
||||
if err := rdb.Set(ctx, userSeenKey("LIKEI", userID), strconv.FormatInt(now.UnixMilli(), 10), time.Minute).Err(); err != nil {
|
||||
t.Fatalf("set stale seen: %v", err)
|
||||
}
|
||||
}
|
||||
if err := rdb.Set(ctx, userRoomKey("LIKEI", staleUsers[1]), strconv.FormatInt(roomID+1, 10), time.Minute).Err(); err != nil {
|
||||
t.Fatalf("set stale room: %v", err)
|
||||
}
|
||||
|
||||
payload := `{
|
||||
"trackId":"track-launch-in-room-stale",
|
||||
"sysOrigin":"LIKEI",
|
||||
"sendUserId":"1001",
|
||||
"roomId":"9001",
|
||||
"quantity":1,
|
||||
"giftConfig":{"id":"11","giftCandy":100,"type":"GOLD","giftTab":"NORMAL"},
|
||||
"createTime":` + strconvFormatMillis(now) + `
|
||||
}`
|
||||
resp, err := service.ProcessGiftPayload(ctx, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessGiftPayload() error = %v", err)
|
||||
}
|
||||
if !resp.Processed {
|
||||
t.Fatalf("response = %+v, want processed", resp)
|
||||
}
|
||||
if _, err := service.ProcessDueLaunches(ctx, now.Add(11*time.Second), 10); err != nil {
|
||||
t.Fatalf("ProcessDueLaunches() error = %v", err)
|
||||
}
|
||||
if _, err := service.ProcessDueInRoomRewards(ctx, now.Add(30*time.Second), 10); err != nil {
|
||||
t.Fatalf("ProcessDueInRoomRewards() error = %v", err)
|
||||
}
|
||||
|
||||
var snapshotCount int64
|
||||
if err := db.Model(&model.VoiceRoomRocketAudienceSnapshot{}).Count(&snapshotCount).Error; err != nil {
|
||||
t.Fatalf("count snapshot: %v", err)
|
||||
}
|
||||
if snapshotCount != 2 {
|
||||
t.Fatalf("snapshot count = %d, want 2 active users only", snapshotCount)
|
||||
}
|
||||
for _, userID := range staleUsers {
|
||||
var userSnapshotCount int64
|
||||
if err := db.Model(&model.VoiceRoomRocketAudienceSnapshot{}).Where("user_id = ?", userID).Count(&userSnapshotCount).Error; err != nil {
|
||||
t.Fatalf("count stale snapshot: %v", err)
|
||||
}
|
||||
if userSnapshotCount != 0 {
|
||||
t.Fatalf("stale user %d snapshot count = %d, want 0", userID, userSnapshotCount)
|
||||
}
|
||||
var rewardCount int64
|
||||
if err := db.Model(&model.VoiceRoomRocketRewardRecord{}).Where("user_id = ? AND reward_scene = ?", userID, rewardSceneInRoom).Count(&rewardCount).Error; err != nil {
|
||||
t.Fatalf("count stale reward: %v", err)
|
||||
}
|
||||
if rewardCount != 0 {
|
||||
t.Fatalf("stale user %d in-room reward count = %d, want 0", userID, rewardCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessPendingRewardsGrantsBadgeThroughBadgeGateway(t *testing.T) {
|
||||
gateway := newFakeRocketGateway()
|
||||
service, db := newTestService(t)
|
||||
|
||||
@ -33,9 +33,14 @@ func (s *Service) listOnlineRoomUsers(ctx context.Context, sysOrigin string, roo
|
||||
continue
|
||||
}
|
||||
seen[userID] = struct{}{}
|
||||
seenAt, active := s.loadActiveRoomSeenTime(ctx, sysOrigin, roomID, userID, now)
|
||||
if !active {
|
||||
_ = s.repo.Redis.SRem(ctx, onlineRoomKey(sysOrigin, roomID), userID).Err()
|
||||
continue
|
||||
}
|
||||
users = append(users, roomAudienceUser{
|
||||
UserID: userID,
|
||||
LastSeenTime: s.loadUserSeenTime(ctx, sysOrigin, userID, now),
|
||||
LastSeenTime: seenAt,
|
||||
})
|
||||
}
|
||||
sort.Slice(users, func(i, j int) bool {
|
||||
@ -44,6 +49,43 @@ func (s *Service) listOnlineRoomUsers(ctx context.Context, sysOrigin string, roo
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadActiveRoomSeenTime(ctx context.Context, sysOrigin string, roomID, userID int64, now time.Time) (time.Time, bool) {
|
||||
if s.repo.Redis == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
rawRoomID, err := s.repo.Redis.Get(ctx, userRoomKey(sysOrigin, userID)).Result()
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
currentRoomID, err := strconv.ParseInt(strings.TrimSpace(rawRoomID), 10, 64)
|
||||
if err != nil || currentRoomID != roomID {
|
||||
return time.Time{}, false
|
||||
}
|
||||
seenAt, ok := s.loadUserSeenTimeStrict(ctx, sysOrigin, userID, now)
|
||||
if !ok {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if ttl := s.roomPresenceTTL(); ttl > 0 && now.Sub(seenAt) > ttl {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return seenAt, true
|
||||
}
|
||||
|
||||
func (s *Service) loadUserSeenTimeStrict(ctx context.Context, sysOrigin string, userID int64, fallback time.Time) (time.Time, bool) {
|
||||
if s.repo.Redis == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
raw, err := s.repo.Redis.Get(ctx, userSeenKey(sysOrigin, userID)).Result()
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
millis, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
if err != nil || millis <= 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return resolveMillisTime(millis, fallback), true
|
||||
}
|
||||
|
||||
func (s *Service) loadUserSeenTime(ctx context.Context, sysOrigin string, userID int64, fallback time.Time) time.Time {
|
||||
if s.repo.Redis == nil {
|
||||
return fallback
|
||||
@ -59,10 +101,22 @@ func (s *Service) loadUserSeenTime(ctx context.Context, sysOrigin string, userID
|
||||
return resolveMillisTime(millis, fallback)
|
||||
}
|
||||
|
||||
func (s *Service) roomPresenceTTL() time.Duration {
|
||||
seconds := s.cfg.VoiceRoomRedPacket.PresenceTTLSeconds
|
||||
if seconds <= 0 {
|
||||
seconds = defaultPresenceTTLSeconds
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
func onlineRoomKey(sysOrigin string, roomID int64) string {
|
||||
return fmt.Sprintf("voice_room:online:%s:%d", strings.ToUpper(strings.TrimSpace(sysOrigin)), roomID)
|
||||
}
|
||||
|
||||
func userRoomKey(sysOrigin string, userID int64) string {
|
||||
return fmt.Sprintf("voice_room:user_room:%s:%d", strings.ToUpper(strings.TrimSpace(sysOrigin)), userID)
|
||||
}
|
||||
|
||||
func userSeenKey(sysOrigin string, userID int64) string {
|
||||
return fmt.Sprintf("voice_room:user_seen:%s:%d", strings.ToUpper(strings.TrimSpace(sysOrigin)), userID)
|
||||
}
|
||||
|
||||
@ -32,6 +32,7 @@ const (
|
||||
defaultWorkerBatchSize = 100
|
||||
defaultWorkerInterval = time.Second
|
||||
defaultRoomLockTTL = 10 * time.Second
|
||||
defaultPresenceTTLSeconds = 60
|
||||
|
||||
statusCharging = "CHARGING"
|
||||
statusLaunching = "LAUNCHING"
|
||||
|
||||
@ -126,16 +126,18 @@ func (s *Service) PageAdminRecords(
|
||||
records = append(records, payload)
|
||||
}
|
||||
return &RecordPageResponse{
|
||||
Records: records,
|
||||
Total: total,
|
||||
Current: cursor,
|
||||
Size: limit,
|
||||
TotalPaidGold: summary.TotalPaidGold,
|
||||
TotalRewardGold: summary.TotalRewardGold,
|
||||
TotalDrawTimes: summary.TotalDrawTimes,
|
||||
ParticipantCount: summary.ParticipantCount,
|
||||
StartTime: formatDateTime(filter.StartTime),
|
||||
EndTime: formatDateTime(filter.EndTime),
|
||||
Records: records,
|
||||
Total: total,
|
||||
Current: cursor,
|
||||
Size: limit,
|
||||
TotalPaidGold: summary.TotalPaidGold,
|
||||
TotalRewardGold: summary.TotalRewardGold,
|
||||
TotalGiftRewardGold: summary.TotalGiftRewardGold,
|
||||
TotalDecorationRewardGold: summary.TotalDecorationRewardGold,
|
||||
TotalDrawTimes: summary.TotalDrawTimes,
|
||||
ParticipantCount: summary.ParticipantCount,
|
||||
StartTime: formatDateTime(filter.StartTime),
|
||||
EndTime: formatDateTime(filter.EndTime),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -250,10 +252,12 @@ type wheelAdminRecordFilter struct {
|
||||
}
|
||||
|
||||
type wheelAdminRecordSummary struct {
|
||||
TotalPaidGold int64
|
||||
TotalRewardGold int64
|
||||
TotalDrawTimes int64
|
||||
ParticipantCount int64
|
||||
TotalPaidGold int64
|
||||
TotalRewardGold int64
|
||||
TotalGiftRewardGold int64
|
||||
TotalDecorationRewardGold int64
|
||||
TotalDrawTimes int64
|
||||
ParticipantCount int64
|
||||
}
|
||||
|
||||
func newWheelAdminRecordFilter(sysOrigin string, category string, userID int64, status string, startRaw string, endRaw string) (wheelAdminRecordFilter, error) {
|
||||
@ -348,9 +352,16 @@ func (s *Service) adminRecordQuery(ctx context.Context, filter wheelAdminRecordF
|
||||
|
||||
func (s *Service) adminRecordSummary(ctx context.Context, filter wheelAdminRecordFilter) (wheelAdminRecordSummary, error) {
|
||||
var summary wheelAdminRecordSummary
|
||||
// totalRewardGold keeps the historical dashboard number, including direct gold rewards.
|
||||
// The two split fields only count RESOURCE rewards: GIFT is the gift value, every other
|
||||
// resourceType is treated as decoration value for the admin cards.
|
||||
if err := s.adminRecordQuery(ctx, filter).
|
||||
Select("COALESCE(SUM(CASE WHEN status = ? THEN CASE WHEN reward_type = ? THEN gold_amount ELSE display_gold_amount END ELSE 0 END), 0)", drawStatusSuccess, rewardTypeGold).
|
||||
Scan(&summary.TotalRewardGold).Error; err != nil {
|
||||
Select(`
|
||||
COALESCE(SUM(CASE WHEN status = ? THEN CASE WHEN reward_type = ? THEN gold_amount ELSE display_gold_amount END ELSE 0 END), 0) AS total_reward_gold,
|
||||
COALESCE(SUM(CASE WHEN status = ? AND reward_type = ? AND UPPER(resource_type) = ? THEN display_gold_amount ELSE 0 END), 0) AS total_gift_reward_gold,
|
||||
COALESCE(SUM(CASE WHEN status = ? AND reward_type = ? AND UPPER(resource_type) <> ? THEN display_gold_amount ELSE 0 END), 0) AS total_decoration_reward_gold
|
||||
`, drawStatusSuccess, rewardTypeGold, drawStatusSuccess, rewardTypeResource, resourceTypeGift, drawStatusSuccess, rewardTypeResource, resourceTypeGift).
|
||||
Scan(&summary).Error; err != nil {
|
||||
return summary, err
|
||||
}
|
||||
if err := s.adminRecordQuery(ctx, filter).
|
||||
|
||||
@ -104,8 +104,8 @@ func TestPageAdminRecordsIncludesUserInfoAndRangeSummary(t *testing.T) {
|
||||
PaidGold: 200,
|
||||
RewardType: rewardTypeResource,
|
||||
ResourceID: &giftID,
|
||||
ResourceType: resourceTypeGift,
|
||||
ResourceName: "Gift B",
|
||||
ResourceType: "AVATAR_FRAME",
|
||||
ResourceName: "Frame B",
|
||||
DisplayGoldAmount: 30,
|
||||
Probability: 500000,
|
||||
Status: drawStatusSuccess,
|
||||
@ -149,6 +149,12 @@ func TestPageAdminRecordsIncludesUserInfoAndRangeSummary(t *testing.T) {
|
||||
if resp.TotalRewardGold != 150 {
|
||||
t.Fatalf("total reward = %d, want 150", resp.TotalRewardGold)
|
||||
}
|
||||
if resp.TotalGiftRewardGold != 50 {
|
||||
t.Fatalf("total gift reward = %d, want 50", resp.TotalGiftRewardGold)
|
||||
}
|
||||
if resp.TotalDecorationRewardGold != 30 {
|
||||
t.Fatalf("total decoration reward = %d, want 30", resp.TotalDecorationRewardGold)
|
||||
}
|
||||
if resp.TotalDrawTimes != 12 {
|
||||
t.Fatalf("total draw times = %d, want 12", resp.TotalDrawTimes)
|
||||
}
|
||||
|
||||
@ -307,16 +307,18 @@ type DrawRecordPayload struct {
|
||||
|
||||
// RecordPageResponse is an admin record page.
|
||||
type RecordPageResponse struct {
|
||||
Records []DrawRecordPayload `json:"records"`
|
||||
Total int64 `json:"total"`
|
||||
Current int `json:"current"`
|
||||
Size int `json:"size"`
|
||||
TotalPaidGold int64 `json:"totalPaidGold,omitempty"`
|
||||
TotalRewardGold int64 `json:"totalRewardGold,omitempty"`
|
||||
TotalDrawTimes int64 `json:"totalDrawTimes,omitempty"`
|
||||
ParticipantCount int64 `json:"participantCount,omitempty"`
|
||||
StartTime string `json:"startTime,omitempty"`
|
||||
EndTime string `json:"endTime,omitempty"`
|
||||
Records []DrawRecordPayload `json:"records"`
|
||||
Total int64 `json:"total"`
|
||||
Current int `json:"current"`
|
||||
Size int `json:"size"`
|
||||
TotalPaidGold int64 `json:"totalPaidGold,omitempty"`
|
||||
TotalRewardGold int64 `json:"totalRewardGold,omitempty"`
|
||||
TotalGiftRewardGold int64 `json:"totalGiftRewardGold,omitempty"`
|
||||
TotalDecorationRewardGold int64 `json:"totalDecorationRewardGold,omitempty"`
|
||||
TotalDrawTimes int64 `json:"totalDrawTimes,omitempty"`
|
||||
ParticipantCount int64 `json:"participantCount,omitempty"`
|
||||
StartTime string `json:"startTime,omitempty"`
|
||||
EndTime string `json:"endTime,omitempty"`
|
||||
}
|
||||
|
||||
func normalizeSysOrigin(sysOrigin string) string {
|
||||
|
||||
67
migrations/049_first_recharge_reward.sql
Normal file
67
migrations/049_first_recharge_reward.sql
Normal file
@ -0,0 +1,67 @@
|
||||
CREATE TABLE IF NOT EXISTS `first_recharge_reward_config` (
|
||||
`id` bigint NOT NULL COMMENT '主键ID',
|
||||
`sys_origin` varchar(32) NOT NULL COMMENT '来源系统',
|
||||
`enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用',
|
||||
`min_app_version` varchar(32) NOT NULL DEFAULT '1.5.0' COMMENT '最低生效App版本',
|
||||
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_first_recharge_reward_sys_origin` (`sys_origin`),
|
||||
KEY `idx_first_recharge_reward_enabled` (`enabled`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励配置';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `first_recharge_reward_level` (
|
||||
`id` bigint NOT NULL COMMENT '主键ID',
|
||||
`config_id` bigint NOT NULL COMMENT '首冲奖励主配置ID',
|
||||
`level` int NOT NULL COMMENT '档位',
|
||||
`recharge_amount_cents` bigint NOT NULL DEFAULT '0' COMMENT '充值金额门槛,单位为分',
|
||||
`google_product_id` varchar(128) NOT NULL DEFAULT '' COMMENT 'Google商品ID',
|
||||
`reward_group_id` bigint NOT NULL DEFAULT '0' COMMENT '奖励资源组ID',
|
||||
`reward_group_name` varchar(255) NOT NULL DEFAULT '' COMMENT '奖励资源组名称快照',
|
||||
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用',
|
||||
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_first_recharge_reward_level` (`config_id`, `level`),
|
||||
UNIQUE KEY `uk_first_recharge_reward_amount` (`config_id`, `recharge_amount_cents`),
|
||||
KEY `idx_first_recharge_reward_level_google_product` (`google_product_id`),
|
||||
KEY `idx_first_recharge_reward_level_config` (`config_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励充值档位';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `first_recharge_reward_grant_record` (
|
||||
`id` bigint NOT NULL COMMENT '主键ID',
|
||||
`event_id` varchar(128) NOT NULL COMMENT '充值事件幂等ID',
|
||||
`config_id` bigint NOT NULL COMMENT '首冲奖励主配置ID',
|
||||
`level_id` bigint NOT NULL COMMENT '首冲奖励档位ID',
|
||||
`sys_origin` varchar(32) NOT NULL COMMENT '来源系统',
|
||||
`user_id` bigint NOT NULL COMMENT '用户ID',
|
||||
`account` varchar(64) NOT NULL DEFAULT '' COMMENT '用户账号快照',
|
||||
`user_avatar` varchar(1024) NOT NULL DEFAULT '' COMMENT '用户头像快照',
|
||||
`user_nickname` varchar(255) NOT NULL DEFAULT '' COMMENT '用户昵称快照',
|
||||
`country_code` varchar(32) NOT NULL DEFAULT '' COMMENT '国家编码快照',
|
||||
`country_name` varchar(128) NOT NULL DEFAULT '' COMMENT '国家名称快照',
|
||||
`recharge_amount_cents` bigint NOT NULL DEFAULT '0' COMMENT '本次充值金额,单位为分',
|
||||
`recharge_threshold_cents` bigint NOT NULL DEFAULT '0' COMMENT '命中档位门槛,单位为分',
|
||||
`level` int NOT NULL COMMENT '命中档位',
|
||||
`pay_platform` varchar(64) NOT NULL DEFAULT '' COMMENT '支付平台:GOOGLE/MIFA_PAY/PAYER_MAX等',
|
||||
`payment_method` varchar(32) NOT NULL DEFAULT '' COMMENT '充值方式:GOOGLE/THIRD_PARTY',
|
||||
`google_product_id` varchar(128) NOT NULL DEFAULT '' COMMENT 'Google商品ID',
|
||||
`source_order_id` varchar(128) NOT NULL DEFAULT '' COMMENT '来源订单ID',
|
||||
`reward_group_id` bigint NOT NULL DEFAULT '0' COMMENT '奖励资源组ID',
|
||||
`reward_group_name` varchar(255) NOT NULL DEFAULT '' COMMENT '奖励资源组名称快照',
|
||||
`reward_group_track_id` bigint NOT NULL DEFAULT '0' COMMENT '奖励资源组发放幂等追踪ID',
|
||||
`status` varchar(32) NOT NULL DEFAULT 'PENDING' COMMENT '整体发放状态:PENDING/SUCCESS/FAILED',
|
||||
`reward_group_status` varchar(32) NOT NULL DEFAULT 'PENDING' COMMENT '奖励组发放状态:PENDING/SUCCESS/FAILED',
|
||||
`retry_count` int NOT NULL DEFAULT '0' COMMENT '重试次数',
|
||||
`last_error` varchar(1024) NOT NULL DEFAULT '' COMMENT '最近一次失败原因',
|
||||
`event_time` datetime(3) DEFAULT NULL COMMENT '充值事件时间',
|
||||
`sent_at` datetime(3) DEFAULT NULL COMMENT '发放成功时间',
|
||||
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_first_recharge_reward_event` (`event_id`),
|
||||
UNIQUE KEY `uk_first_recharge_reward_user` (`sys_origin`, `user_id`),
|
||||
KEY `idx_first_recharge_reward_status` (`sys_origin`, `user_id`, `status`),
|
||||
KEY `idx_first_recharge_reward_grant_google_product` (`google_product_id`),
|
||||
KEY `idx_first_recharge_reward_order` (`source_order_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励发放记录';
|
||||
@ -0,0 +1,71 @@
|
||||
SET @current_schema := DATABASE();
|
||||
|
||||
SET @add_first_recharge_reward_min_app_version_sql := IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = @current_schema
|
||||
AND table_name = 'first_recharge_reward_config'
|
||||
AND column_name = 'min_app_version'
|
||||
),
|
||||
'ALTER TABLE `first_recharge_reward_config` ADD COLUMN `min_app_version` varchar(32) NOT NULL DEFAULT ''1.5.0'' COMMENT ''最低生效App版本'' AFTER `enabled`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE add_first_recharge_reward_min_app_version_stmt FROM @add_first_recharge_reward_min_app_version_sql;
|
||||
EXECUTE add_first_recharge_reward_min_app_version_stmt;
|
||||
DEALLOCATE PREPARE add_first_recharge_reward_min_app_version_stmt;
|
||||
|
||||
SET @add_first_recharge_reward_level_google_product_sql := IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = @current_schema
|
||||
AND table_name = 'first_recharge_reward_level'
|
||||
AND column_name = 'google_product_id'
|
||||
),
|
||||
'ALTER TABLE `first_recharge_reward_level` ADD COLUMN `google_product_id` varchar(128) NOT NULL DEFAULT '''' COMMENT ''Google商品ID'' AFTER `recharge_amount_cents`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE add_first_recharge_reward_level_google_product_stmt FROM @add_first_recharge_reward_level_google_product_sql;
|
||||
EXECUTE add_first_recharge_reward_level_google_product_stmt;
|
||||
DEALLOCATE PREPARE add_first_recharge_reward_level_google_product_stmt;
|
||||
|
||||
SET @add_first_recharge_reward_record_google_product_sql := IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = @current_schema
|
||||
AND table_name = 'first_recharge_reward_grant_record'
|
||||
AND column_name = 'google_product_id'
|
||||
),
|
||||
'ALTER TABLE `first_recharge_reward_grant_record` ADD COLUMN `google_product_id` varchar(128) NOT NULL DEFAULT '''' COMMENT ''Google商品ID'' AFTER `payment_method`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE add_first_recharge_reward_record_google_product_stmt FROM @add_first_recharge_reward_record_google_product_sql;
|
||||
EXECUTE add_first_recharge_reward_record_google_product_stmt;
|
||||
DEALLOCATE PREPARE add_first_recharge_reward_record_google_product_stmt;
|
||||
|
||||
SET @add_first_recharge_reward_level_google_product_idx_sql := IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.statistics
|
||||
WHERE table_schema = @current_schema
|
||||
AND table_name = 'first_recharge_reward_level'
|
||||
AND index_name = 'idx_first_recharge_reward_level_google_product'
|
||||
),
|
||||
'ALTER TABLE `first_recharge_reward_level` ADD INDEX `idx_first_recharge_reward_level_google_product` (`google_product_id`)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE add_first_recharge_reward_level_google_product_idx_stmt FROM @add_first_recharge_reward_level_google_product_idx_sql;
|
||||
EXECUTE add_first_recharge_reward_level_google_product_idx_stmt;
|
||||
DEALLOCATE PREPARE add_first_recharge_reward_level_google_product_idx_stmt;
|
||||
|
||||
SET @add_first_recharge_reward_record_google_product_idx_sql := IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.statistics
|
||||
WHERE table_schema = @current_schema
|
||||
AND table_name = 'first_recharge_reward_grant_record'
|
||||
AND index_name = 'idx_first_recharge_reward_grant_google_product'
|
||||
),
|
||||
'ALTER TABLE `first_recharge_reward_grant_record` ADD INDEX `idx_first_recharge_reward_grant_google_product` (`google_product_id`)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE add_first_recharge_reward_record_google_product_idx_stmt FROM @add_first_recharge_reward_record_google_product_idx_sql;
|
||||
EXECUTE add_first_recharge_reward_record_google_product_idx_stmt;
|
||||
DEALLOCATE PREPARE add_first_recharge_reward_record_google_product_idx_stmt;
|
||||
@ -0,0 +1,29 @@
|
||||
SET @current_schema := DATABASE();
|
||||
|
||||
SET @voice_room_red_packet_slice_claim_order_idx_columns := (
|
||||
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @current_schema
|
||||
AND table_name = 'voice_room_red_packet_slice'
|
||||
AND index_name = 'idx_voice_room_red_packet_slice_claim_order'
|
||||
);
|
||||
|
||||
SET @drop_voice_room_red_packet_slice_claim_order_idx_sql := IF(
|
||||
@voice_room_red_packet_slice_claim_order_idx_columns IS NOT NULL
|
||||
AND @voice_room_red_packet_slice_claim_order_idx_columns <> 'packet_id,status,sort_no,id',
|
||||
'ALTER TABLE `voice_room_red_packet_slice` DROP INDEX `idx_voice_room_red_packet_slice_claim_order`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE drop_voice_room_red_packet_slice_claim_order_idx_stmt FROM @drop_voice_room_red_packet_slice_claim_order_idx_sql;
|
||||
EXECUTE drop_voice_room_red_packet_slice_claim_order_idx_stmt;
|
||||
DEALLOCATE PREPARE drop_voice_room_red_packet_slice_claim_order_idx_stmt;
|
||||
|
||||
SET @add_voice_room_red_packet_slice_claim_order_idx_sql := IF(
|
||||
@voice_room_red_packet_slice_claim_order_idx_columns IS NULL
|
||||
OR @voice_room_red_packet_slice_claim_order_idx_columns <> 'packet_id,status,sort_no,id',
|
||||
'ALTER TABLE `voice_room_red_packet_slice` ADD INDEX `idx_voice_room_red_packet_slice_claim_order` (`packet_id`, `status`, `sort_no`, `id`)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE add_voice_room_red_packet_slice_claim_order_idx_stmt FROM @add_voice_room_red_packet_slice_claim_order_idx_sql;
|
||||
EXECUTE add_voice_room_red_packet_slice_claim_order_idx_stmt;
|
||||
DEALLOCATE PREPARE add_voice_room_red_packet_slice_claim_order_idx_stmt;
|
||||
17
migrations/051_game_list_turkey_region.sql
Normal file
17
migrations/051_game_list_turkey_region.sql
Normal file
@ -0,0 +1,17 @@
|
||||
-- Limit LIKEI app game-list rows to the Turkey region.
|
||||
-- Production Turkey Region observed in sys_region_config: 2046066409959649281.
|
||||
-- Re-check sys_region_config before production execution if region data has been rebuilt.
|
||||
-- After direct SQL execution, clear Redis GAME_LIST* cache or wait for its 1-hour TTL.
|
||||
|
||||
START TRANSACTION;
|
||||
|
||||
SET @sys_origin := 'LIKEI';
|
||||
SET @turkey_region_id := '2046066409959649281';
|
||||
|
||||
UPDATE sys_game_list_config
|
||||
SET regions = @turkey_region_id,
|
||||
update_time = NOW(3)
|
||||
WHERE sys_origin = @sys_origin
|
||||
AND is_showcase = 1;
|
||||
|
||||
COMMIT;
|
||||
106
migrations/052_hotgame_provider_config.sql
Normal file
106
migrations/052_hotgame_provider_config.sql
Normal file
@ -0,0 +1,106 @@
|
||||
CREATE TABLE IF NOT EXISTS hotgame_provider_config (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
sys_origin VARCHAR(32) NOT NULL,
|
||||
profile VARCHAR(32) NOT NULL DEFAULT 'PROD',
|
||||
active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
app_key VARCHAR(255) NOT NULL DEFAULT '',
|
||||
callback_base_url VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
test_uid VARCHAR(64) DEFAULT NULL,
|
||||
test_token VARCHAR(1024) DEFAULT NULL,
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_hotgame_provider_sys_origin_profile (sys_origin, profile),
|
||||
KEY idx_hotgame_provider_active (sys_origin, active, update_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hotgame_game_catalog (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
sys_origin VARCHAR(32) NOT NULL,
|
||||
profile VARCHAR(32) NOT NULL DEFAULT 'PROD',
|
||||
internal_game_id VARCHAR(64) NOT NULL,
|
||||
vendor_game_id VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
cover VARCHAR(1024) DEFAULT NULL,
|
||||
preview_url VARCHAR(1024) DEFAULT NULL,
|
||||
download_url VARCHAR(1024) DEFAULT NULL,
|
||||
package_version VARCHAR(32) DEFAULT NULL,
|
||||
raw_json LONGTEXT DEFAULT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'ENABLED',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_hotgame_catalog_sys_profile_vendor_game (sys_origin, profile, vendor_game_id),
|
||||
KEY idx_hotgame_catalog_profile_internal_game (sys_origin, profile, internal_game_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
INSERT INTO hotgame_provider_config (
|
||||
id, sys_origin, profile, active, app_key, callback_base_url, create_time, update_time
|
||||
) VALUES
|
||||
(202606100520001, 'LIKEI', 'PROD', 1, 'laluprodappkey', 'https://api.global-interaction.com/', NOW(), NOW()),
|
||||
(202606100520002, 'LIKEI', 'TEST', 0, 'lalutestappkey', 'https://api-test.global-interaction.com/', NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
app_key = IF(TRIM(app_key) = '', VALUES(app_key), app_key),
|
||||
callback_base_url = IF(TRIM(callback_base_url) = '', VALUES(callback_base_url), callback_base_url),
|
||||
update_time = NOW();
|
||||
|
||||
INSERT INTO hotgame_game_catalog (
|
||||
id, sys_origin, profile, internal_game_id, vendor_game_id, name, cover,
|
||||
preview_url, download_url, package_version, raw_json, status, create_time, update_time
|
||||
) VALUES
|
||||
(202606100520101, 'LIKEI', 'TEST', 'hg_1', '1', '幸运77(Lucky77)', '',
|
||||
'https://hk.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}',
|
||||
'https://hk.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}',
|
||||
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
||||
(202606100520102, 'LIKEI', 'TEST', 'hg_13', '13', '海盗捕鱼(PirateFishing)', '',
|
||||
'https://hk.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||
'https://hk.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
||||
(202606100520103, 'LIKEI', 'TEST', 'hg_19', '19', 'FortuneSlot (宝石solt)', '',
|
||||
'https://hk.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||
'https://hk.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
||||
(202606100520104, 'LIKEI', 'TEST', 'hg_15', '15', '美味摩天轮(Delicious)', '',
|
||||
'https://hk.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||
'https://hk.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
||||
(202606100520105, 'LIKEI', 'TEST', 'hg_20', '20', '水果派对(FruitParty)', '',
|
||||
'https://hk.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||
'https://hk.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
||||
(202606100520106, 'LIKEI', 'TEST', 'hg_16', '16', '太空摩天轮(GreedyStar)', '',
|
||||
'https://hk.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||
'https://hk.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
||||
(202606100520201, 'LIKEI', 'PROD', 'hg_1', '1', '幸运77(Lucky77)', '',
|
||||
'https://cdn-res.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}',
|
||||
'https://cdn-res.hotgame.win/Seven7/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxLCJtYWNoQXJlYSI6Mn0%3D&uid={uid}&token={token}&lang={lang}',
|
||||
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
||||
(202606100520202, 'LIKEI', 'PROD', 'hg_13', '13', '海盗捕鱼(PirateFishing)', '',
|
||||
'https://cdn-res.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||
'https://cdn-res.hotgame.win/PirateFishing/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxMywibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
||||
(202606100520203, 'LIKEI', 'PROD', 'hg_19', '19', 'FortuneSlot (宝石solt)', '',
|
||||
'https://cdn-res.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||
'https://cdn-res.hotgame.win/FortuneSlot/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxOSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
||||
(202606100520204, 'LIKEI', 'PROD', 'hg_15', '15', '美味摩天轮(Delicious)', '',
|
||||
'https://cdn-res.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||
'https://cdn-res.hotgame.win/Wheel_Delicious/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNSwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
||||
(202606100520205, 'LIKEI', 'PROD', 'hg_20', '20', '水果派对(FruitParty)', '',
|
||||
'https://cdn-res.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||
'https://cdn-res.hotgame.win/FruitParty2/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoyMCwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW()),
|
||||
(202606100520206, 'LIKEI', 'PROD', 'hg_16', '16', '太空摩天轮(GreedyStar)', '',
|
||||
'https://cdn-res.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||
'https://cdn-res.hotgame.win/Wheel_OuterSpace/index.html?__inherit__=eyJwbGF0SWQiOjEwMTEsInBsYXRLZXkiOiJGWWRSMk02Z3FnIiwiZ2FtZUlkIjoxNiwibWFjaEFyZWEiOjJ9&uid={uid}&token={token}&lang={lang}',
|
||||
'2026', '{"mode":"seven"}', 'ENABLED', NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
internal_game_id = VALUES(internal_game_id),
|
||||
name = VALUES(name),
|
||||
cover = VALUES(cover),
|
||||
preview_url = VALUES(preview_url),
|
||||
download_url = VALUES(download_url),
|
||||
package_version = VALUES(package_version),
|
||||
raw_json = VALUES(raw_json),
|
||||
status = VALUES(status),
|
||||
update_time = NOW();
|
||||
17
migrations/053_sys_game_list_cover_length.sql
Normal file
17
migrations/053_sys_game_list_cover_length.sql
Normal file
@ -0,0 +1,17 @@
|
||||
SET @current_schema := DATABASE();
|
||||
|
||||
SET @modify_sys_game_list_cover_sql := IF(
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @current_schema
|
||||
AND table_name = 'sys_game_list_config'
|
||||
AND column_name = 'cover'
|
||||
),
|
||||
'ALTER TABLE `sys_game_list_config` MODIFY COLUMN `cover` VARCHAR(1024) DEFAULT NULL COMMENT ''封面图''',
|
||||
'SELECT 1'
|
||||
);
|
||||
|
||||
PREPARE modify_sys_game_list_cover_stmt FROM @modify_sys_game_list_cover_sql;
|
||||
EXECUTE modify_sys_game_list_cover_stmt;
|
||||
DEALLOCATE PREPARE modify_sys_game_list_cover_stmt;
|
||||
61
migrations/054_team_manager_feature_switch.sql
Normal file
61
migrations/054_team_manager_feature_switch.sql
Normal file
@ -0,0 +1,61 @@
|
||||
SET @table_schema = DATABASE();
|
||||
|
||||
SET @ddl = IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @table_schema
|
||||
AND TABLE_NAME = 'team_manager_base_info'
|
||||
AND COLUMN_NAME = 'can_add_bd_leader'
|
||||
),
|
||||
'SELECT 1',
|
||||
"ALTER TABLE `team_manager_base_info` ADD COLUMN `can_add_bd_leader` tinyint(1) NOT NULL DEFAULT 1 COMMENT '经理中心是否允许添加BD Leader' AFTER `region_id`"
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @table_schema
|
||||
AND TABLE_NAME = 'team_manager_base_info'
|
||||
AND COLUMN_NAME = 'can_gift_props'
|
||||
),
|
||||
'SELECT 1',
|
||||
"ALTER TABLE `team_manager_base_info` ADD COLUMN `can_gift_props` tinyint(1) NOT NULL DEFAULT 1 COMMENT '经理中心是否允许赠送道具' AFTER `can_add_bd_leader`"
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @table_schema
|
||||
AND TABLE_NAME = 'team_manager_base_info'
|
||||
AND COLUMN_NAME = 'can_ban_user'
|
||||
),
|
||||
'SELECT 1',
|
||||
"ALTER TABLE `team_manager_base_info` ADD COLUMN `can_ban_user` tinyint(1) NOT NULL DEFAULT 1 COMMENT '经理中心是否允许封禁用户' AFTER `can_gift_props`"
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @table_schema
|
||||
AND TABLE_NAME = 'team_manager_base_info'
|
||||
AND COLUMN_NAME = 'can_unban_user'
|
||||
),
|
||||
'SELECT 1',
|
||||
"ALTER TABLE `team_manager_base_info` ADD COLUMN `can_unban_user` tinyint(1) NOT NULL DEFAULT 1 COMMENT '经理中心是否允许解封用户' AFTER `can_ban_user`"
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
16
migrations/055_team_manager_country_switch.sql
Normal file
16
migrations/055_team_manager_country_switch.sql
Normal file
@ -0,0 +1,16 @@
|
||||
SET @table_schema = DATABASE();
|
||||
|
||||
SET @ddl = IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @table_schema
|
||||
AND TABLE_NAME = 'team_manager_base_info'
|
||||
AND COLUMN_NAME = 'can_change_country'
|
||||
),
|
||||
'SELECT 1',
|
||||
"ALTER TABLE `team_manager_base_info` ADD COLUMN `can_change_country` tinyint(1) NOT NULL DEFAULT 1 COMMENT '经理中心是否允许修改用户国家' AFTER `can_unban_user`"
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
29
migrations/056_task_center_archive_outbox_claim_indexes.sql
Normal file
29
migrations/056_task_center_archive_outbox_claim_indexes.sql
Normal file
@ -0,0 +1,29 @@
|
||||
SET @current_schema := DATABASE();
|
||||
|
||||
SET @add_task_center_archive_claim_create_sql := IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE table_schema = @current_schema
|
||||
AND table_name = 'task_center_event_archive_outbox'
|
||||
AND index_name = 'idx_task_center_archive_claim_create'
|
||||
),
|
||||
'ALTER TABLE `task_center_event_archive_outbox` ADD INDEX `idx_task_center_archive_claim_create` (`status`, `create_time`, `id`)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE add_task_center_archive_claim_create_stmt FROM @add_task_center_archive_claim_create_sql;
|
||||
EXECUTE add_task_center_archive_claim_create_stmt;
|
||||
DEALLOCATE PREPARE add_task_center_archive_claim_create_stmt;
|
||||
|
||||
SET @add_task_center_archive_claim_stale_sql := IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE table_schema = @current_schema
|
||||
AND table_name = 'task_center_event_archive_outbox'
|
||||
AND index_name = 'idx_task_center_archive_claim_stale'
|
||||
),
|
||||
'ALTER TABLE `task_center_event_archive_outbox` ADD INDEX `idx_task_center_archive_claim_stale` (`status`, `update_time`, `id`)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE add_task_center_archive_claim_stale_stmt FROM @add_task_center_archive_claim_stale_sql;
|
||||
EXECUTE add_task_center_archive_claim_stale_stmt;
|
||||
DEALLOCATE PREPARE add_task_center_archive_claim_stale_stmt;
|
||||
20
migrations/057_game_lucky_gift_count_ctime_index.sql
Normal file
20
migrations/057_game_lucky_gift_count_ctime_index.sql
Normal file
@ -0,0 +1,20 @@
|
||||
-- dashboard-cdc-worker 每 5 分钟按 create_time 24 小时窗口回读 game_lucky_gift_count(1400 万行),
|
||||
-- 原有索引均以 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;
|
||||
@ -429,6 +429,7 @@ func seedPresence(ctx context.Context, rdb *redis.Client, roomID int64, users []
|
||||
key := fmt.Sprintf("voice_room:online:LIKEI:%d", roomID)
|
||||
for _, userID := range users {
|
||||
must(rdb.SAdd(ctx, key, userID).Err(), "seed online room")
|
||||
must(rdb.Set(ctx, fmt.Sprintf("voice_room:user_room:LIKEI:%d", userID), strconv.FormatInt(roomID, 10), time.Minute).Err(), "seed user room")
|
||||
must(rdb.Set(ctx, fmt.Sprintf("voice_room:user_seen:LIKEI:%d", userID), strconv.FormatInt(time.Now().UnixMilli(), 10), time.Minute).Err(), "seed user seen")
|
||||
}
|
||||
must(rdb.Expire(ctx, key, time.Minute).Err(), "expire online room")
|
||||
|
||||
@ -109,6 +109,9 @@ type verifySummary struct {
|
||||
Top1PopupBeforeAck int `json:"top1PopupBeforeAck"`
|
||||
Top1PopupAfterAck int `json:"top1PopupAfterAck"`
|
||||
IgnitePopupCount int `json:"ignitePopupCount"`
|
||||
LeftUserID int64 `json:"leftUserId,string"`
|
||||
LeftUserSnapshotCount int64 `json:"leftUserSnapshotCount"`
|
||||
LeftUserRewardCount int64 `json:"leftUserRewardCount"`
|
||||
RewardRecordCount int64 `json:"rewardRecordCount"`
|
||||
SnapshotCount int64 `json:"snapshotCount"`
|
||||
SelectedAudienceCount int64 `json:"selectedAudienceCount"`
|
||||
@ -162,6 +165,11 @@ func main() {
|
||||
cleanup(ctx, db, rdb, room.RoomID, day)
|
||||
seedConfig(ctx, service, db)
|
||||
seedPresence(ctx, rdb, room.RoomID, append([]int64{room.UserID, igniteUserID}, otherUsers[1:]...))
|
||||
leftUserID := otherUsers[2]
|
||||
must(rdb.Del(ctx,
|
||||
fmt.Sprintf("voice_room:user_room:LIKEI:%d", leftUserID),
|
||||
fmt.Sprintf("voice_room:user_seen:LIKEI:%d", leftUserID),
|
||||
).Err(), "simulate left user presence")
|
||||
|
||||
initial, err := service.GetStatus(ctx, user, room.RoomID)
|
||||
must(err, "initial status")
|
||||
@ -179,7 +187,17 @@ func main() {
|
||||
secondPayload := mqEnvelope(giftPayload("LOCAL_VERIFY_TRACK_2", igniteUserID, room.RoomID, 80, now.Add(time.Second)))
|
||||
second, err := service.ProcessGiftPayload(ctx, secondPayload)
|
||||
must(err, "process second gift")
|
||||
assert(second.Processed && second.AcceptedEnergy == 40 && second.Launched && second.LaunchNo != "", "second gift result mismatch")
|
||||
assert(second.Processed && second.AcceptedEnergy == 40 && !second.Launched, "second gift result mismatch")
|
||||
|
||||
launched, err := service.ProcessDueLaunches(ctx, now.Add(11*time.Second), 10)
|
||||
must(err, "process due launches")
|
||||
assert(launched == 1, "due launch count mismatch")
|
||||
var launch model.VoiceRoomRocketLaunch
|
||||
must(db.WithContext(ctx).
|
||||
Where("room_id = ? AND day_key = ?", room.RoomID, day).
|
||||
Order("create_time DESC").
|
||||
First(&launch).Error, "load launch")
|
||||
assert(strings.TrimSpace(launch.LaunchNo) != "", "launch no missing")
|
||||
|
||||
finalStatus, err := service.GetStatus(ctx, user, room.RoomID)
|
||||
must(err, "final status")
|
||||
@ -213,7 +231,7 @@ func main() {
|
||||
must(err, "top1 reward records")
|
||||
assert(len(rewardRecords.Records) >= 1, "top1 reward record count mismatch")
|
||||
|
||||
dueProcessed, err := service.ProcessDueInRoomRewards(ctx, time.Now().Add(2*time.Second), 10)
|
||||
dueProcessed, err := service.ProcessDueInRoomRewards(ctx, now.Add(30*time.Second), 10)
|
||||
must(err, "process due in-room rewards")
|
||||
assert(dueProcessed == 1, "due in-room reward count mismatch")
|
||||
|
||||
@ -233,10 +251,22 @@ func main() {
|
||||
must(db.WithContext(ctx).Model(&model.VoiceRoomRocketAudienceSnapshot{}).
|
||||
Where("room_id = ?", room.RoomID).
|
||||
Count(&snapshotCount).Error, "count snapshots")
|
||||
assert(snapshotCount == 3, "snapshot count mismatch")
|
||||
var selectedCount int64
|
||||
must(db.WithContext(ctx).Model(&model.VoiceRoomRocketAudienceSnapshot{}).
|
||||
Where("room_id = ? AND selected = ?", room.RoomID, true).
|
||||
Count(&selectedCount).Error, "count selected snapshots")
|
||||
assert(selectedCount == 3, "selected snapshot count mismatch")
|
||||
var leftSnapshotCount int64
|
||||
must(db.WithContext(ctx).Model(&model.VoiceRoomRocketAudienceSnapshot{}).
|
||||
Where("room_id = ? AND user_id = ?", room.RoomID, leftUserID).
|
||||
Count(&leftSnapshotCount).Error, "count left user snapshots")
|
||||
assert(leftSnapshotCount == 0, "left user snapshot mismatch")
|
||||
var leftRewardCount int64
|
||||
must(db.WithContext(ctx).Model(&model.VoiceRoomRocketRewardRecord{}).
|
||||
Where("room_id = ? AND day_key = ? AND user_id = ?", room.RoomID, day, leftUserID).
|
||||
Count(&leftRewardCount).Error, "count left user rewards")
|
||||
assert(leftRewardCount == 0, "left user reward mismatch")
|
||||
var successCount int64
|
||||
must(db.WithContext(ctx).Model(&model.VoiceRoomRocketRewardRecord{}).
|
||||
Where("room_id = ? AND day_key = ? AND grant_status = ?", room.RoomID, day, "SUCCESS").
|
||||
@ -258,13 +288,16 @@ func main() {
|
||||
FirstAcceptedEnergy: first.AcceptedEnergy,
|
||||
SecondRawEnergy: 80,
|
||||
SecondAcceptedEnergy: second.AcceptedEnergy,
|
||||
LaunchNo: second.LaunchNo,
|
||||
LaunchNo: launch.LaunchNo,
|
||||
StatusLevel: finalStatus.CurrentLevel,
|
||||
StatusEnergy: finalStatus.CurrentEnergy,
|
||||
KingRecords: len(king.Records),
|
||||
Top1PopupBeforeAck: len(top1Popups.Records),
|
||||
Top1PopupAfterAck: len(top1AfterAck.Records),
|
||||
IgnitePopupCount: len(ignitePopups.Records),
|
||||
LeftUserID: leftUserID,
|
||||
LeftUserSnapshotCount: leftSnapshotCount,
|
||||
LeftUserRewardCount: leftRewardCount,
|
||||
RewardRecordCount: rewardCount,
|
||||
SnapshotCount: snapshotCount,
|
||||
SelectedAudienceCount: selectedCount,
|
||||
@ -312,6 +345,7 @@ func seedPresence(ctx context.Context, rdb *redis.Client, roomID int64, users []
|
||||
key := fmt.Sprintf("voice_room:online:LIKEI:%d", roomID)
|
||||
for _, userID := range users {
|
||||
must(rdb.SAdd(ctx, key, userID).Err(), "seed online room")
|
||||
must(rdb.Set(ctx, fmt.Sprintf("voice_room:user_room:LIKEI:%d", userID), strconv.FormatInt(roomID, 10), time.Minute).Err(), "seed user room")
|
||||
must(rdb.Set(ctx, fmt.Sprintf("voice_room:user_seen:LIKEI:%d", userID), strconv.FormatInt(time.Now().UnixMilli(), 10), time.Minute).Err(), "seed user seen")
|
||||
}
|
||||
must(rdb.Expire(ctx, key, time.Minute).Err(), "expire online room")
|
||||
@ -373,7 +407,7 @@ func seedConfig(ctx context.Context, service *voiceroomrocket.Service, db *gorm.
|
||||
Rewards: []voiceroomrocket.RewardConfigPayload{
|
||||
{Level: 1, RewardScene: "TOP1", RewardType: "GOLD", RewardName: "Local Verify Top1 Gold", RewardAmount: 10, Enabled: true, Sort: 1},
|
||||
{Level: 1, RewardScene: "IGNITE", RewardType: "GOLD", RewardName: "Local Verify Ignite Gold", RewardAmount: 5, Enabled: true, Sort: 1},
|
||||
{Level: 1, RewardScene: "IN_ROOM", RewardType: "GOLD", RewardName: "Local Verify In Room Gold", RewardAmount: 1, Enabled: true, Sort: 1},
|
||||
{Level: 1, RewardScene: "IN_ROOM", RewardType: "GOLD", RewardName: "Local Verify In Room Gold", RewardAmount: 1, RewardCopies: 3, Enabled: true, Sort: 1},
|
||||
},
|
||||
})
|
||||
must(err, "save rewards")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user