Compare commits
4 Commits
c4d4f03ddf
...
a02e22ac74
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a02e22ac74 | ||
|
|
74063d0908 | ||
|
|
810dc0345b | ||
|
|
4926d5dfd2 |
@ -16,6 +16,7 @@ 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"
|
||||
@ -60,6 +61,7 @@ func main() {
|
||||
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)
|
||||
@ -92,6 +94,9 @@ 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 := voiceRoomRocketService.Start(workerCtx); err != nil {
|
||||
log.Fatalf("start voice room rocket workers failed: %v", err)
|
||||
}
|
||||
@ -105,32 +110,33 @@ func main() {
|
||||
lingxian.NewAppProvider(lingxianService),
|
||||
)
|
||||
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,
|
||||
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,
|
||||
})
|
||||
server := &http.Server{
|
||||
Addr: app.Config.HTTP.ListenAddr,
|
||||
|
||||
@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"chatapp3-golang/internal/bootstrap"
|
||||
"chatapp3-golang/internal/service/firstrechargereward"
|
||||
"chatapp3-golang/internal/service/regionimgroup"
|
||||
"chatapp3-golang/internal/service/registerreward"
|
||||
"chatapp3-golang/internal/service/taskcenter"
|
||||
@ -27,6 +28,7 @@ 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)
|
||||
voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room)
|
||||
voiceRoomRedPacketService.SetRegionResolver(&app.Gateways)
|
||||
@ -45,6 +47,9 @@ 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 := voiceRoomRocketService.Start(workerCtx); err != nil {
|
||||
log.Fatalf("start voice room rocket workers failed: %v", err)
|
||||
}
|
||||
|
||||
@ -10,23 +10,24 @@ import (
|
||||
|
||||
// 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
|
||||
TencentIM TencentIMConfig
|
||||
Baishun BaishunConfig
|
||||
Lingxian LingxianConfig
|
||||
GameOpen GameOpenConfig
|
||||
LuckyGift LuckyGiftConfig
|
||||
BinanceRecharge BinanceRechargeConfig
|
||||
}
|
||||
|
||||
// HTTPConfig 保存 HTTP 服务运行配置。
|
||||
@ -81,8 +82,30 @@ type InviteConfig struct {
|
||||
|
||||
// RechargeRewardConfig 保存充值奖励活动配置。
|
||||
type RechargeRewardConfig struct {
|
||||
DefaultSysOrigin string
|
||||
Timezone string
|
||||
StorageTimezone string
|
||||
StartDate string
|
||||
CoinSellerGoldPerUSD int64
|
||||
}
|
||||
|
||||
// FirstRechargeRewardConfig 保存首冲奖励模块配置。
|
||||
type FirstRechargeRewardConfig struct {
|
||||
DefaultSysOrigin string
|
||||
Timezone 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 保存任务中心模块配置。
|
||||
@ -167,6 +190,7 @@ type VoiceRoomRocketConfig struct {
|
||||
InRoomRewardDueZSetKey string
|
||||
RoomLockTTLSeconds int
|
||||
BroadcastStreamKey string
|
||||
BroadcastStreamMaxLen int64
|
||||
InRoomRewardScanIntervalSeconds int
|
||||
InRoomRewardBatchSize int
|
||||
InRoomRewardUserLimit int
|
||||
@ -229,10 +253,11 @@ type GameOpenConfig struct {
|
||||
|
||||
// LuckyGiftConfig 保存幸运礼物模块配置。
|
||||
type LuckyGiftConfig struct {
|
||||
Enabled bool
|
||||
Timeout time.Duration
|
||||
RewardStreamKey string
|
||||
Providers []LuckyGiftProviderConfig
|
||||
Enabled bool
|
||||
Timeout time.Duration
|
||||
RewardStreamKey string
|
||||
RewardStreamMaxLen int64
|
||||
Providers []LuckyGiftProviderConfig
|
||||
}
|
||||
|
||||
// BinanceRechargeConfig 保存币安自动充值验证的运行配置。
|
||||
@ -260,6 +285,9 @@ 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"}, "")
|
||||
@ -311,6 +339,35 @@ func Load() Config {
|
||||
[]string{"CHATAPP_RECHARGE_REWARD_TIMEZONE", "RECHARGE_REWARD_TIMEZONE", "CHATAPP_WEEK_STAR_TIMEZONE", "WEEK_STAR_TIMEZONE"},
|
||||
"Asia/Riyadh",
|
||||
),
|
||||
StorageTimezone: getEnvAny(
|
||||
[]string{"CHATAPP_RECHARGE_REWARD_STORAGE_TIMEZONE", "RECHARGE_REWARD_STORAGE_TIMEZONE"},
|
||||
"Asia/Shanghai",
|
||||
),
|
||||
StartDate: getEnvAny(
|
||||
[]string{"CHATAPP_RECHARGE_REWARD_START_DATE", "RECHARGE_REWARD_START_DATE"},
|
||||
"2026-05-21",
|
||||
),
|
||||
CoinSellerGoldPerUSD: int64(getEnvIntAny(
|
||||
[]string{"CHATAPP_RECHARGE_REWARD_COIN_SELLER_GOLD_PER_USD", "RECHARGE_REWARD_COIN_SELLER_GOLD_PER_USD"},
|
||||
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(
|
||||
@ -433,6 +490,10 @@ func Load() Config {
|
||||
[]string{"CHATAPP_VOICE_ROOM_ROCKET_BROADCAST_STREAM_KEY", "VOICE_ROOM_ROCKET_BROADCAST_STREAM_KEY"},
|
||||
"voice_room:rocket:broadcast",
|
||||
),
|
||||
BroadcastStreamMaxLen: int64(getEnvIntAny(
|
||||
[]string{"CHATAPP_VOICE_ROOM_ROCKET_BROADCAST_STREAM_MAX_LEN", "VOICE_ROOM_ROCKET_BROADCAST_STREAM_MAX_LEN"},
|
||||
10000,
|
||||
)),
|
||||
InRoomRewardScanIntervalSeconds: getEnvIntAny(
|
||||
[]string{"CHATAPP_VOICE_ROOM_ROCKET_IN_ROOM_SCAN_INTERVAL_SECONDS", "VOICE_ROOM_ROCKET_IN_ROOM_SCAN_INTERVAL_SECONDS"},
|
||||
1,
|
||||
@ -515,10 +576,11 @@ func Load() Config {
|
||||
TestPassword: getEnvAny([]string{"CHATAPP_GAME_OPEN_TEST_PASSWORD", "GAME_OPEN_TEST_PASSWORD"}, "1234567"),
|
||||
},
|
||||
LuckyGift: LuckyGiftConfig{
|
||||
Enabled: getEnvBoolAny([]string{"CHATAPP_LUCKY_GIFT_ENABLED", "LUCKY_GIFT_ENABLED", "LUCKY_GIFT_GO_ENABLED"}, false),
|
||||
Timeout: time.Duration(getEnvIntAny([]string{"CHATAPP_LUCKY_GIFT_TIMEOUT_MILLIS", "LUCKY_GIFT_TIMEOUT_MILLIS", "LUCKY_GIFT_GO_TIMEOUT_MILLIS"}, 8000)) * time.Millisecond,
|
||||
RewardStreamKey: getEnvAny([]string{"CHATAPP_LUCKY_GIFT_REWARD_STREAM_KEY", "LUCKY_GIFT_REWARD_STREAM_KEY"}, "lucky-gift:reward"),
|
||||
Providers: loadLuckyGiftConfigs(),
|
||||
Enabled: getEnvBoolAny([]string{"CHATAPP_LUCKY_GIFT_ENABLED", "LUCKY_GIFT_ENABLED", "LUCKY_GIFT_GO_ENABLED"}, false),
|
||||
Timeout: time.Duration(getEnvIntAny([]string{"CHATAPP_LUCKY_GIFT_TIMEOUT_MILLIS", "LUCKY_GIFT_TIMEOUT_MILLIS", "LUCKY_GIFT_GO_TIMEOUT_MILLIS"}, 8000)) * time.Millisecond,
|
||||
RewardStreamKey: getEnvAny([]string{"CHATAPP_LUCKY_GIFT_REWARD_STREAM_KEY", "LUCKY_GIFT_REWARD_STREAM_KEY"}, "lucky-gift:reward"),
|
||||
RewardStreamMaxLen: int64(getEnvIntAny([]string{"CHATAPP_LUCKY_GIFT_REWARD_STREAM_MAX_LEN", "LUCKY_GIFT_REWARD_STREAM_MAX_LEN"}, 100000)),
|
||||
Providers: loadLuckyGiftConfigs(),
|
||||
},
|
||||
BinanceRecharge: BinanceRechargeConfig{
|
||||
BaseURL: getEnvAny([]string{"CHATAPP_BINANCE_RECHARGE_BASE_URL", "BINANCE_RECHARGE_BASE_URL"}, "https://api.binance.com"),
|
||||
|
||||
65
internal/model/first_recharge_reward_models.go
Normal file
65
internal/model/first_recharge_reward_models.go
Normal file
@ -0,0 +1,65 @@
|
||||
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"`
|
||||
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"`
|
||||
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"`
|
||||
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"
|
||||
}
|
||||
@ -29,3 +29,39 @@ type RechargeRewardLevel struct {
|
||||
}
|
||||
|
||||
func (RechargeRewardLevel) TableName() string { return "recharge_reward_level" }
|
||||
|
||||
// RechargeRewardGrantRecord 记录某个充值周期内用户各档位的实际发放状态。
|
||||
type RechargeRewardGrantRecord struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
ConfigID int64 `gorm:"column:config_id;index:idx_recharge_reward_grant_config_cycle,priority:1"`
|
||||
LevelID int64 `gorm:"column:level_id"`
|
||||
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_recharge_reward_grant_level,priority:1;index:idx_recharge_reward_grant_date,priority:1"`
|
||||
CycleKey string `gorm:"column:cycle_key;size:32;uniqueIndex:uk_recharge_reward_grant_level,priority:2;index:idx_recharge_reward_grant_config_cycle,priority:2"`
|
||||
RechargeDate int `gorm:"column:recharge_date;index:idx_recharge_reward_grant_user,priority:2;index:idx_recharge_reward_grant_date,priority:2"`
|
||||
PeriodStartAt time.Time `gorm:"column:period_start_at"`
|
||||
PeriodEndAt time.Time `gorm:"column:period_end_at"`
|
||||
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_recharge_reward_grant_level,priority:3;index:idx_recharge_reward_grant_user,priority:1"`
|
||||
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"`
|
||||
Level int `gorm:"column:level;uniqueIndex:uk_recharge_reward_grant_level,priority:4"`
|
||||
RechargeThresholdCents int64 `gorm:"column:recharge_threshold_cents"`
|
||||
RewardGold int64 `gorm:"column:reward_gold"`
|
||||
RewardGroupID *int64 `gorm:"column:reward_group_id"`
|
||||
RewardGroupName string `gorm:"column:reward_group_name;size:255"`
|
||||
RewardGroupTrackID int64 `gorm:"column:reward_group_track_id"`
|
||||
GoldEventID string `gorm:"column:gold_event_id;size:128;index:idx_recharge_reward_gold_event"`
|
||||
Status string `gorm:"column:status;size:32;index:idx_recharge_reward_grant_config_cycle,priority:3;index:idx_recharge_reward_grant_date,priority:3"`
|
||||
RewardGroupStatus string `gorm:"column:reward_group_status;size:32"`
|
||||
RewardGoldStatus string `gorm:"column:reward_gold_status;size:32"`
|
||||
RetryCount int `gorm:"column:retry_count"`
|
||||
LastError string `gorm:"column:last_error;size:1024"`
|
||||
SentAt *time.Time `gorm:"column:sent_at"`
|
||||
CreateTime time.Time `gorm:"column:create_time"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
}
|
||||
|
||||
func (RechargeRewardGrantRecord) TableName() string { return "recharge_reward_grant_record" }
|
||||
|
||||
70
internal/router/first_recharge_reward_routes.go
Normal file
70
internal/router/first_recharge_reward_routes.go
Normal file
@ -0,0 +1,70 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"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))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeOK(c, resp)
|
||||
})
|
||||
}
|
||||
@ -10,6 +10,7 @@ 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"
|
||||
@ -38,32 +39,33 @@ 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
|
||||
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
|
||||
}
|
||||
|
||||
// NewRouter 创建 Gin 路由,并按模块注册所有接口。
|
||||
@ -82,6 +84,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)
|
||||
|
||||
234
internal/service/firstrechargereward/config.go
Normal file
234
internal/service/firstrechargereward/config.go
Normal file
@ -0,0 +1,234 @@
|
||||
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,
|
||||
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")
|
||||
}
|
||||
|
||||
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.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))
|
||||
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")
|
||||
}
|
||||
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,
|
||||
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
|
||||
}
|
||||
407
internal/service/firstrechargereward/event.go
Normal file
407
internal/service/firstrechargereward/event.go
Normal file
@ -0,0 +1,407 @@
|
||||
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
|
||||
}
|
||||
matched, ok := pickMatchedLevel(bundle.Levels, normalized.AmountCents)
|
||||
if !ok {
|
||||
return &ProcessRechargeResponse{Processed: false, Reason: "amount_not_reached"}, 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())
|
||||
}
|
||||
if amountCents <= 0 {
|
||||
return normalizedRechargeEvent{}, NewAppError(http.StatusBadRequest, "invalid_amount", "amountCents or amount is required")
|
||||
}
|
||||
|
||||
payPlatform := strings.ToUpper(strings.TrimSpace(event.PayPlatform))
|
||||
paymentMethod := strings.ToUpper(strings.TrimSpace(event.PaymentMethod))
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
"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 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]
|
||||
}
|
||||
107
internal/service/firstrechargereward/home.go
Normal file
107
internal/service/firstrechargereward/home.go
Normal file
@ -0,0 +1,107 @@
|
||||
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) (*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,
|
||||
LevelConfigs: buildLevelPayloads(bundle.Levels, rewardItems, 0, 0, false),
|
||||
HasRewardRecord: false,
|
||||
}
|
||||
if !bundle.Config.Enabled {
|
||||
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
|
||||
}
|
||||
146
internal/service/firstrechargereward/mapper.go
Normal file
146
internal/service/firstrechargereward/mapper.go
Normal file
@ -0,0 +1,146 @@
|
||||
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,
|
||||
UpdateTime: row.UpdateTime,
|
||||
}
|
||||
}
|
||||
|
||||
func levelSnapshotFromModel(row model.FirstRechargeRewardLevel) levelSnapshot {
|
||||
return levelSnapshot{
|
||||
ID: row.ID,
|
||||
Level: row.Level,
|
||||
RechargeAmountCents: row.RechargeAmountCents,
|
||||
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,
|
||||
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) (levelSnapshot, bool) {
|
||||
var matched levelSnapshot
|
||||
ok := false
|
||||
for _, level := range levels {
|
||||
if !level.Enabled || level.RechargeAmountCents <= 0 || level.RewardGroupID <= 0 {
|
||||
continue
|
||||
}
|
||||
if rechargeAmountCents >= level.RechargeAmountCents && (!ok || level.RechargeAmountCents > matched.RechargeAmountCents) {
|
||||
matched = level
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
return matched, ok
|
||||
}
|
||||
|
||||
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: 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),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
108
internal/service/firstrechargereward/records.go
Normal file
108
internal/service/firstrechargereward/records.go
Normal file
@ -0,0 +1,108 @@
|
||||
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,
|
||||
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),
|
||||
}
|
||||
}
|
||||
286
internal/service/firstrechargereward/service_test.go
Normal file
286
internal/service/firstrechargereward/service_test.go
Normal file
@ -0,0 +1,286 @@
|
||||
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,
|
||||
"levelConfigs": [
|
||||
{"level": 1, "rechargeAmount": "9.99", "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 len(level.RewardItems) != 1 || level.RewardItems[0].Name != "首冲礼包奖励" {
|
||||
t.Fatalf("reward items = %+v", level.RewardItems)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
"levelConfigs": [
|
||||
{"level": 1, "rechargeAmount": "5.00", "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",
|
||||
"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",
|
||||
"sourceOrderId": "LOW"
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("low amount ProcessRechargeEvent() error = %v", err)
|
||||
}
|
||||
if lowAmount.Processed || lowAmount.Reason != "amount_not_reached" {
|
||||
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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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 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",
|
||||
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
|
||||
}
|
||||
356
internal/service/firstrechargereward/types.go
Normal file
356
internal/service/firstrechargereward/types.go
Normal file
@ -0,0 +1,356 @@
|
||||
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"`
|
||||
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"`
|
||||
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"`
|
||||
UpdateTime string `json:"updateTime,omitempty"`
|
||||
LevelConfigs []LevelPayload `json:"levelConfigs"`
|
||||
}
|
||||
|
||||
type SaveConfigRequest struct {
|
||||
ID flexibleInt64 `json:"id"`
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
Enabled bool `json:"enabled"`
|
||||
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"`
|
||||
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"`
|
||||
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"`
|
||||
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
|
||||
UpdateTime time.Time
|
||||
}
|
||||
|
||||
type levelSnapshot struct {
|
||||
ID int64
|
||||
Level int
|
||||
RechargeAmountCents int64
|
||||
RewardGroupID int64
|
||||
RewardGroupName string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
type normalizedRechargeEvent struct {
|
||||
EventID string
|
||||
SysOrigin string
|
||||
UserID int64
|
||||
AmountCents int64
|
||||
Currency string
|
||||
PayPlatform string
|
||||
PaymentMethod 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
|
||||
}
|
||||
@ -78,6 +78,8 @@ func (s *LuckyGiftService) publishRewardEvent(
|
||||
|
||||
_, err := s.repo.Redis.XAdd(ctx, &redis.XAddArgs{
|
||||
Stream: streamKey,
|
||||
MaxLen: s.cfg.LuckyGift.RewardStreamMaxLen,
|
||||
Approx: s.cfg.LuckyGift.RewardStreamMaxLen > 0,
|
||||
Values: map[string]any{
|
||||
"eventId": event.EventID,
|
||||
"businessId": event.BusinessID,
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"chatapp3-golang/internal/config"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
@ -23,7 +24,8 @@ func TestPublishRewardEventWritesWinningResultsToStream(t *testing.T) {
|
||||
service := &LuckyGiftService{
|
||||
cfg: config.Config{
|
||||
LuckyGift: config.LuckyGiftConfig{
|
||||
RewardStreamKey: "lucky-gift:reward",
|
||||
RewardStreamKey: "lucky-gift:reward",
|
||||
RewardStreamMaxLen: 2,
|
||||
},
|
||||
},
|
||||
repo: luckyGiftPorts{Redis: client},
|
||||
@ -89,7 +91,8 @@ func TestPublishRewardEventSkipsZeroRewardTotal(t *testing.T) {
|
||||
service := &LuckyGiftService{
|
||||
cfg: config.Config{
|
||||
LuckyGift: config.LuckyGiftConfig{
|
||||
RewardStreamKey: "lucky-gift:reward",
|
||||
RewardStreamKey: "lucky-gift:reward",
|
||||
RewardStreamMaxLen: 2,
|
||||
},
|
||||
},
|
||||
repo: luckyGiftPorts{Redis: client},
|
||||
@ -107,3 +110,48 @@ func TestPublishRewardEventSkipsZeroRewardTotal(t *testing.T) {
|
||||
t.Fatalf("expected no stream records for zero reward total, got %d", len(streams))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishRewardEventTrimsStreamByConfiguredMaxLen(t *testing.T) {
|
||||
mr, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("miniredis run failed: %v", err)
|
||||
}
|
||||
defer mr.Close()
|
||||
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
defer client.Close()
|
||||
|
||||
service := &LuckyGiftService{
|
||||
cfg: config.Config{
|
||||
LuckyGift: config.LuckyGiftConfig{
|
||||
RewardStreamKey: "lucky-gift:reward",
|
||||
RewardStreamMaxLen: 2,
|
||||
},
|
||||
},
|
||||
repo: luckyGiftPorts{Redis: client},
|
||||
}
|
||||
|
||||
for idx := 1; idx <= 3; idx++ {
|
||||
req := LuckyGiftDrawRequest{
|
||||
BusinessID: "biz-" + strconv.Itoa(idx),
|
||||
SysOrigin: "LIKEI",
|
||||
RoomID: 2001,
|
||||
SendUserID: 3001,
|
||||
GiftID: 4001,
|
||||
GiftPrice: 500,
|
||||
GiftNum: 2,
|
||||
}
|
||||
results := []LuckyGiftDrawResult{{ID: "order-a", AcceptUserID: 9001, RewardNum: 88}}
|
||||
if err := service.publishRewardEvent(context.Background(), req, results, 88); err != nil {
|
||||
t.Fatalf("publishRewardEvent returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
count, err := client.XLen(context.Background(), "lucky-gift:reward").Result()
|
||||
if err != nil {
|
||||
t.Fatalf("xlen returned error: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("expected trimmed stream length 2, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,48 +2,33 @@ package rechargereward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var personalMonthlyRechargeTypes = []string{
|
||||
"UNDEFINED",
|
||||
"HUAWEI",
|
||||
"GOOGLE",
|
||||
"APPLE",
|
||||
"PAYER_MAX",
|
||||
"SHIPPING_AGENT",
|
||||
"STRIPE",
|
||||
"SALARY_EXCHANGE",
|
||||
"PAY_PAL",
|
||||
"CLIPSPAY",
|
||||
"SELLER_AGENT",
|
||||
}
|
||||
|
||||
type rechargeRewardClaimRecordRow struct {
|
||||
UserID int64 `gorm:"column:user_id"`
|
||||
RechargeAmount string `gorm:"column:recharge_amount"`
|
||||
LastRechargeTime string `gorm:"column:last_recharge_time"`
|
||||
LastClaimTime string `gorm:"column:last_claim_time"`
|
||||
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"`
|
||||
UserID int64 `gorm:"column:user_id"`
|
||||
RechargeAmountCents int64 `gorm:"column:recharge_amount_cents"`
|
||||
LastRechargeTime string `gorm:"column:last_recharge_time"`
|
||||
LastClaimTime string `gorm:"column:last_claim_time"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type rechargeRewardClaimDetailRow struct {
|
||||
UserID int64 `gorm:"column:user_id"`
|
||||
ActivityID int64 `gorm:"column:activity_id"`
|
||||
Level int `gorm:"column:level"`
|
||||
ResourceGroupID *int64 `gorm:"column:resource_group_id"`
|
||||
RuleDescription string `gorm:"column:rule_description"`
|
||||
JSONData string `gorm:"column:json_data"`
|
||||
ClaimTime string `gorm:"column:claim_time"`
|
||||
UserID int64 `gorm:"column:user_id"`
|
||||
ActivityID int64 `gorm:"column:activity_id"`
|
||||
Level int `gorm:"column:level"`
|
||||
ResourceGroupID *int64 `gorm:"column:resource_group_id"`
|
||||
RewardGroupName string `gorm:"column:reward_group_name"`
|
||||
RechargeThresholdCents int64 `gorm:"column:recharge_threshold_cents"`
|
||||
RewardGold int64 `gorm:"column:reward_gold"`
|
||||
ClaimTime string `gorm:"column:claim_time"`
|
||||
}
|
||||
|
||||
// PageClaimRecords 分页返回真实领取过累计充值奖励的用户。
|
||||
@ -67,12 +52,10 @@ func (s *Service) PageClaimRecords(ctx context.Context, sysOrigin string, month
|
||||
var total int64
|
||||
countSQL := `
|
||||
SELECT COUNT(DISTINCT receive.user_id)
|
||||
FROM user_activity_reward_receive_record AS receive
|
||||
INNER JOIN props_activity_rule_config AS rule ON rule.id = receive.activity_id
|
||||
FROM recharge_reward_grant_record AS receive
|
||||
WHERE receive.recharge_date = ?
|
||||
AND rule.sys_origin = ?
|
||||
AND rule.activity_type = 'CUMULATIVE_RECHARGE'
|
||||
AND COALESCE(rule.is_del, 0) = 0
|
||||
AND receive.sys_origin = ?
|
||||
AND receive.status = 'SUCCESS'
|
||||
AND (? = 0 OR receive.user_id = ?)`
|
||||
if err := s.db.WithContext(ctx).Raw(
|
||||
countSQL,
|
||||
@ -90,40 +73,34 @@ WHERE receive.recharge_date = ?
|
||||
listSQL := `
|
||||
SELECT
|
||||
claims.user_id,
|
||||
COALESCE(recharge.recharge_amount, 0) AS recharge_amount,
|
||||
COALESCE(recharge.last_recharge_time, '') AS last_recharge_time,
|
||||
claims.recharge_amount_cents,
|
||||
claims.last_recharge_time,
|
||||
claims.last_claim_time,
|
||||
user.account,
|
||||
user.user_avatar,
|
||||
user.user_nickname,
|
||||
user.country_code,
|
||||
user.country_name
|
||||
claims.account,
|
||||
claims.user_avatar,
|
||||
claims.user_nickname,
|
||||
claims.country_code,
|
||||
claims.country_name
|
||||
FROM (
|
||||
SELECT
|
||||
receive.user_id,
|
||||
MAX(receive.create_time) AS last_claim_time
|
||||
FROM user_activity_reward_receive_record AS receive
|
||||
INNER JOIN props_activity_rule_config AS rule ON rule.id = receive.activity_id
|
||||
MAX(receive.recharge_amount_cents) AS recharge_amount_cents,
|
||||
COALESCE(MAX(receive.sent_at), MAX(receive.update_time), '') AS last_recharge_time,
|
||||
COALESCE(MAX(receive.sent_at), MAX(receive.create_time), '') AS last_claim_time,
|
||||
MAX(receive.account) AS account,
|
||||
MAX(receive.user_avatar) AS user_avatar,
|
||||
MAX(receive.user_nickname) AS user_nickname,
|
||||
MAX(receive.country_code) AS country_code,
|
||||
MAX(receive.country_name) AS country_name
|
||||
FROM recharge_reward_grant_record AS receive
|
||||
WHERE receive.recharge_date = ?
|
||||
AND rule.sys_origin = ?
|
||||
AND rule.activity_type = 'CUMULATIVE_RECHARGE'
|
||||
AND COALESCE(rule.is_del, 0) = 0
|
||||
AND receive.sys_origin = ?
|
||||
AND receive.status = 'SUCCESS'
|
||||
AND (? = 0 OR receive.user_id = ?)
|
||||
GROUP BY receive.user_id
|
||||
ORDER BY MAX(receive.create_time) DESC, receive.user_id ASC
|
||||
ORDER BY COALESCE(MAX(receive.sent_at), MAX(receive.create_time)) DESC, receive.user_id ASC
|
||||
LIMIT ? OFFSET ?
|
||||
) AS claims
|
||||
INNER JOIN user_base_info AS user ON user.id = claims.user_id
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
recharge.user_id,
|
||||
SUM(recharge.amount) AS recharge_amount,
|
||||
MAX(recharge.update_time) AS last_recharge_time
|
||||
FROM user_monthly_recharge_v2 AS recharge
|
||||
WHERE recharge.recharge_date = ?
|
||||
AND recharge.type IN ?
|
||||
GROUP BY recharge.user_id
|
||||
) AS recharge ON recharge.user_id = claims.user_id
|
||||
ORDER BY claims.last_claim_time DESC, claims.user_id ASC`
|
||||
if err := s.db.WithContext(ctx).Raw(
|
||||
listSQL,
|
||||
@ -133,8 +110,6 @@ ORDER BY claims.last_claim_time DESC, claims.user_id ASC`
|
||||
userID,
|
||||
limit,
|
||||
(cursor-1)*limit,
|
||||
rechargeDate,
|
||||
personalMonthlyRechargeTypes,
|
||||
).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -207,7 +182,6 @@ func rechargeRewardClaimRecordView(
|
||||
details []rechargeRewardClaimDetailRow,
|
||||
rewardItems map[int64][]RechargeRewardRewardItem,
|
||||
) RechargeRewardClaimRecordView {
|
||||
rechargeCents := parseIntegrationAmountCents(integrationAmount(row.RechargeAmount))
|
||||
reached := claimDetailPayloads(details, rewardItems)
|
||||
return RechargeRewardClaimRecordView{
|
||||
RecordKey: fmt.Sprintf("%d-%d", row.UserID, rechargeDate),
|
||||
@ -219,8 +193,8 @@ func rechargeRewardClaimRecordView(
|
||||
CountryName: strings.TrimSpace(row.CountryName),
|
||||
RechargeMonth: month,
|
||||
RechargeDate: rechargeDate,
|
||||
RechargeAmount: formatAmountCents(rechargeCents),
|
||||
RechargeAmountCents: rechargeCents,
|
||||
RechargeAmount: formatAmountCents(row.RechargeAmountCents),
|
||||
RechargeAmountCents: row.RechargeAmountCents,
|
||||
ReachedLevels: reached,
|
||||
ClaimedRewards: rechargeRewardClaimedRewards(reached),
|
||||
LastRechargeTime: formatRechargeRewardRecordTime(row.LastRechargeTime),
|
||||
@ -235,20 +209,19 @@ func (s *Service) loadClaimDetails(ctx context.Context, sysOrigin string, rechar
|
||||
detailSQL := `
|
||||
SELECT
|
||||
receive.user_id,
|
||||
receive.activity_id,
|
||||
COALESCE(rule.sort, 0) AS level,
|
||||
rule.resource_group_id,
|
||||
rule.rule_description,
|
||||
rule.json_data,
|
||||
receive.create_time AS claim_time
|
||||
FROM user_activity_reward_receive_record AS receive
|
||||
INNER JOIN props_activity_rule_config AS rule ON rule.id = receive.activity_id
|
||||
receive.level_id AS activity_id,
|
||||
receive.level,
|
||||
receive.reward_group_id AS resource_group_id,
|
||||
receive.reward_group_name,
|
||||
receive.recharge_threshold_cents,
|
||||
receive.reward_gold,
|
||||
COALESCE(receive.sent_at, receive.create_time) AS claim_time
|
||||
FROM recharge_reward_grant_record AS receive
|
||||
WHERE receive.recharge_date = ?
|
||||
AND rule.sys_origin = ?
|
||||
AND rule.activity_type = 'CUMULATIVE_RECHARGE'
|
||||
AND COALESCE(rule.is_del, 0) = 0
|
||||
AND receive.sys_origin = ?
|
||||
AND receive.status = 'SUCCESS'
|
||||
AND receive.user_id IN ?
|
||||
ORDER BY receive.user_id ASC, COALESCE(rule.sort, 0) ASC, receive.create_time ASC`
|
||||
ORDER BY receive.user_id ASC, receive.level ASC, receive.create_time ASC`
|
||||
if err := s.db.WithContext(ctx).Raw(detailSQL, rechargeDate, sysOrigin, userIDs).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -310,14 +283,14 @@ func claimDetailPayloads(
|
||||
if detail.ResourceGroupID != nil {
|
||||
items = rewardItems[*detail.ResourceGroupID]
|
||||
}
|
||||
thresholdCents := rechargeRewardRuleQuantityCents(detail.JSONData)
|
||||
result = append(result, RechargeRewardLevelPayload{
|
||||
ID: detail.ActivityID,
|
||||
Level: level,
|
||||
RechargeAmount: formatAmountCents(thresholdCents),
|
||||
RechargeAmountCents: thresholdCents,
|
||||
RechargeAmount: formatAmountCents(detail.RechargeThresholdCents),
|
||||
RechargeAmountCents: detail.RechargeThresholdCents,
|
||||
RewardGold: detail.RewardGold,
|
||||
RewardGroupID: detail.ResourceGroupID,
|
||||
RewardGroupName: strings.TrimSpace(detail.RuleDescription),
|
||||
RewardGroupName: strings.TrimSpace(detail.RewardGroupName),
|
||||
RewardItems: items,
|
||||
Enabled: true,
|
||||
Reached: true,
|
||||
@ -326,33 +299,6 @@ func claimDetailPayloads(
|
||||
return result
|
||||
}
|
||||
|
||||
func rechargeRewardRuleQuantityCents(raw string) int64 {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil {
|
||||
return 0
|
||||
}
|
||||
value, ok := payload["quantity"]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return int64(math.Round(typed * 100))
|
||||
case string:
|
||||
cents, err := parseAmountCents(typed)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return cents
|
||||
default:
|
||||
cents, err := parseAmountCents(fmt.Sprint(typed))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return cents
|
||||
}
|
||||
}
|
||||
|
||||
func reachedRechargeRewardLevels(
|
||||
levels []rechargeRewardLevelSnapshot,
|
||||
rewardItems map[int64][]RechargeRewardRewardItem,
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chatapp3-golang/internal/config"
|
||||
"chatapp3-golang/internal/integration"
|
||||
@ -22,14 +23,21 @@ func newTestService(t *testing.T, java rechargeRewardJavaGateway) (*Service, *go
|
||||
if err := db.AutoMigrate(
|
||||
&model.RechargeRewardConfig{},
|
||||
&model.RechargeRewardLevel{},
|
||||
&model.RechargeRewardGrantRecord{},
|
||||
&model.UserBaseInfo{},
|
||||
); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
if err := db.Exec(`ALTER TABLE user_base_info ADD COLUMN is_del INTEGER DEFAULT 0`).Error; err != nil {
|
||||
t.Fatalf("add is_del: %v", err)
|
||||
}
|
||||
service := NewService(config.Config{
|
||||
RechargeReward: config.RechargeRewardConfig{
|
||||
DefaultSysOrigin: "LIKEI",
|
||||
Timezone: "Asia/Riyadh",
|
||||
DefaultSysOrigin: "LIKEI",
|
||||
Timezone: "Asia/Riyadh",
|
||||
StorageTimezone: "Asia/Shanghai",
|
||||
StartDate: "2020-01-01",
|
||||
CoinSellerGoldPerUSD: 100000,
|
||||
},
|
||||
}, db, java)
|
||||
return service, db
|
||||
@ -107,7 +115,6 @@ func TestSaveConfigUpsertsBySysOrigin(t *testing.T) {
|
||||
func TestGetHomeUsesJavaRechargeAndMarksLevels(t *testing.T) {
|
||||
groupID := int64(9001)
|
||||
java := rechargeRewardTestGateway{
|
||||
monthly: integration.DecimalString("150.50"),
|
||||
total: map[int64][]integration.UserTotalRecharge{
|
||||
42: {
|
||||
{UserID: integration.Int64Value(42), Type: "GOOGLE", Amount: integration.DecimalString("5000.00")},
|
||||
@ -130,7 +137,37 @@ func TestGetHomeUsesJavaRechargeAndMarksLevels(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
service, _ := newTestService(t, java)
|
||||
service, db := newTestService(t, java)
|
||||
createRechargeRewardSourceTables(t, db)
|
||||
if err := db.Create(&model.UserBaseInfo{
|
||||
ID: 42,
|
||||
Account: "1000042",
|
||||
UserNickname: "Nina",
|
||||
OriginSys: "LIKEI",
|
||||
CountryCode: "SA",
|
||||
CountryName: "Saudi Arabia",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
rechargeAt := rechargeRewardStorageTime(time.Now().In(service.location), service.storageLocation)
|
||||
if err := db.Exec(`
|
||||
INSERT INTO order_purchase_history (user_id, sys_origin, evn, is_trial_period, status, pay_platform, unit_price, create_time) VALUES
|
||||
(42, 'LIKEI', 'PROD', 0, 'COMPLETE', 'GOOGLE', 120.50, ?)
|
||||
`, rechargeAt).Error; err != nil {
|
||||
t.Fatalf("insert google recharge: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
INSERT INTO order_user_purchase_pay (user_id, sys_origin, evn, factory_code, pay_status, receipt_type, refund_status, compute_usd_amount, create_time, update_time) VALUES
|
||||
(42, 'LIKEI', 'PROD', 'MIFA_PAY', 'SUCCESSFUL', 'PAYMENT', 'NONE', 29.00, ?, ?)
|
||||
`, rechargeAt, rechargeAt).Error; err != nil {
|
||||
t.Fatalf("insert mifapay recharge: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
INSERT INTO likei_wallet.user_freight_balance_running_water (accept_user_id, sys_origin, origin, type, quantity, create_time) VALUES
|
||||
(42, 'LIKEI', 'SHIPMENT', 1, 100000, ?)
|
||||
`, rechargeAt).Error; err != nil {
|
||||
t.Fatalf("insert seller recharge: %v", err)
|
||||
}
|
||||
if _, err := service.SaveConfig(context.Background(), SaveRechargeRewardConfigRequest{
|
||||
SysOrigin: "LIKEI",
|
||||
Enabled: true,
|
||||
@ -174,39 +211,6 @@ func TestPageClaimRecordsUsesActualReceiveRecords(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE user_monthly_recharge_v2 (
|
||||
user_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
amount DECIMAL(12,2) NOT NULL,
|
||||
recharge_date INTEGER NOT NULL,
|
||||
update_time DATETIME NOT NULL
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("create monthly recharge table: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE user_activity_reward_receive_record (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
recharge_date INTEGER NOT NULL,
|
||||
activity_id INTEGER NOT NULL,
|
||||
create_time DATETIME NOT NULL
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("create receive record table: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE props_activity_rule_config (
|
||||
id INTEGER PRIMARY KEY,
|
||||
sys_origin TEXT NOT NULL,
|
||||
activity_type TEXT NOT NULL,
|
||||
resource_group_id INTEGER NOT NULL,
|
||||
json_data TEXT NOT NULL,
|
||||
sort INTEGER NOT NULL,
|
||||
rule_description TEXT NOT NULL,
|
||||
is_del INTEGER NOT NULL
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("create activity rule table: %v", err)
|
||||
}
|
||||
if _, err := service.SaveConfig(context.Background(), SaveRechargeRewardConfigRequest{
|
||||
SysOrigin: "LIKEI",
|
||||
Enabled: true,
|
||||
@ -228,25 +232,33 @@ CREATE TABLE props_activity_rule_config (
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
INSERT INTO user_monthly_recharge_v2 (user_id, type, amount, recharge_date, update_time) VALUES
|
||||
(42, 'GOOGLE', 150.50, 202605, '2026-05-07 10:00:00'),
|
||||
(42, 'MIFA_PAY', 70.00, 202605, '2026-05-07 11:00:00'),
|
||||
(43, 'GOOGLE', 300.00, 202605, '2026-05-07 12:00:00')
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert monthly recharge: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
INSERT INTO props_activity_rule_config (id, sys_origin, activity_type, resource_group_id, json_data, sort, rule_description, is_del) VALUES
|
||||
(7001, 'LIKEI', 'CUMULATIVE_RECHARGE', 9001, '{"quantity":100}', 1, 'Gift Box', 0)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert activity rule: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
INSERT INTO user_activity_reward_receive_record (id, user_id, recharge_date, activity_id, create_time) VALUES
|
||||
(8001, 42, 202605, 7001, '2026-05-07 10:30:00')
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert receive record: %v", err)
|
||||
if err := db.Create(&model.RechargeRewardGrantRecord{
|
||||
ID: 8001,
|
||||
ConfigID: 1001,
|
||||
LevelID: 7001,
|
||||
SysOrigin: "LIKEI",
|
||||
CycleKey: "20260521",
|
||||
RechargeDate: 202605,
|
||||
PeriodStartAt: time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC),
|
||||
PeriodEndAt: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC),
|
||||
UserID: 42,
|
||||
Account: "1000042",
|
||||
UserNickname: "Nina",
|
||||
CountryCode: "SA",
|
||||
CountryName: "Saudi Arabia",
|
||||
RechargeAmountCents: 15050,
|
||||
Level: 1,
|
||||
RechargeThresholdCents: 10000,
|
||||
RewardGroupID: &groupID,
|
||||
RewardGroupName: "Gift Box",
|
||||
Status: "SUCCESS",
|
||||
RewardGroupStatus: "SUCCESS",
|
||||
RewardGoldStatus: "SKIPPED",
|
||||
SentAt: ptrTime(time.Date(2026, 5, 21, 10, 30, 0, 0, time.UTC)),
|
||||
CreateTime: time.Date(2026, 5, 21, 10, 30, 0, 0, time.UTC),
|
||||
UpdateTime: time.Date(2026, 5, 21, 10, 30, 0, 0, time.UTC),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("insert grant record: %v", err)
|
||||
}
|
||||
|
||||
resp, err := service.PageClaimRecords(context.Background(), "LIKEI", "2026-05", 0, 1, 20)
|
||||
@ -302,6 +314,56 @@ func ptrFlexibleInt64(value int64) flexibleOptionalInt64 {
|
||||
return flexibleOptionalInt64{value: &value}
|
||||
}
|
||||
|
||||
func ptrTime(value time.Time) *time.Time {
|
||||
return &value
|
||||
}
|
||||
|
||||
func createRechargeRewardSourceTables(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`ATTACH DATABASE ':memory:' AS likei_wallet`).Error; err != nil {
|
||||
t.Fatalf("attach wallet schema: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE order_purchase_history (
|
||||
user_id INTEGER NOT NULL,
|
||||
sys_origin TEXT NOT NULL,
|
||||
evn TEXT NOT NULL,
|
||||
is_trial_period INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
pay_platform TEXT NOT NULL,
|
||||
unit_price DECIMAL(12,2) NOT NULL,
|
||||
create_time DATETIME NOT NULL
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("create order_purchase_history: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE order_user_purchase_pay (
|
||||
user_id INTEGER NOT NULL,
|
||||
sys_origin TEXT NOT NULL,
|
||||
evn TEXT NOT NULL,
|
||||
factory_code TEXT NOT NULL,
|
||||
pay_status TEXT NOT NULL,
|
||||
receipt_type TEXT NOT NULL,
|
||||
refund_status TEXT NOT NULL,
|
||||
compute_usd_amount DECIMAL(12,2) NOT NULL,
|
||||
create_time DATETIME NOT NULL,
|
||||
update_time DATETIME NOT NULL
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("create order_user_purchase_pay: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE likei_wallet.user_freight_balance_running_water (
|
||||
accept_user_id INTEGER NOT NULL,
|
||||
sys_origin TEXT NOT NULL,
|
||||
origin TEXT NOT NULL,
|
||||
type INTEGER NOT NULL,
|
||||
quantity DECIMAL(20,2) NOT NULL,
|
||||
create_time DATETIME NOT NULL
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("create wallet freight water: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type rechargeRewardTestGateway struct {
|
||||
monthly integration.DecimalString
|
||||
total map[int64][]integration.UserTotalRecharge
|
||||
|
||||
@ -26,11 +26,15 @@ func (s *Service) GetHome(ctx context.Context, user AuthUser) (*RechargeRewardHo
|
||||
location = resolveLocation(bundle.Config.Timezone, s.location)
|
||||
}
|
||||
now := time.Now().In(location)
|
||||
periodStart, periodEnd := currentMonthBounds(now, location)
|
||||
period := s.displayPeriod(now, location)
|
||||
currentPeriod, periodActive := s.currentPeriod(now, location)
|
||||
|
||||
currentRechargeCents, err := s.loadCurrentRechargeCents(ctx, user.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var currentRechargeCents int64
|
||||
if periodActive {
|
||||
currentRechargeCents, err = s.loadCurrentRechargeCents(ctx, sysOrigin, user.UserID, currentPeriod)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
accumulatedRechargeCents, err := s.loadAccumulatedRechargeCents(ctx, user.UserID)
|
||||
if err != nil {
|
||||
@ -43,9 +47,9 @@ func (s *Service) GetHome(ctx context.Context, user AuthUser) (*RechargeRewardHo
|
||||
Enabled: bundle.Config != nil && bundle.Config.Enabled,
|
||||
SysOrigin: sysOrigin,
|
||||
Timezone: location.String(),
|
||||
PeriodStartAt: formatDateTime(periodStart),
|
||||
PeriodEndAt: formatDateTime(periodEnd),
|
||||
CountdownMillis: maxInt64(0, periodEnd.UnixMilli()-now.UnixMilli()),
|
||||
PeriodStartAt: formatDateTime(period.StartAt),
|
||||
PeriodEndAt: formatDateTime(period.EndAt),
|
||||
CountdownMillis: maxInt64(0, period.EndAt.UnixMilli()-now.UnixMilli()),
|
||||
UserID: user.UserID,
|
||||
CurrentRechargeAmount: formatAmountCents(currentRechargeCents),
|
||||
CurrentRechargeAmountCents: currentRechargeCents,
|
||||
@ -88,14 +92,6 @@ func (s *Service) GetHome(ctx context.Context, user AuthUser) (*RechargeRewardHo
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadCurrentRechargeCents(ctx context.Context, userID int64) (int64, error) {
|
||||
amount, err := s.java.GetThisMonthTotalPersonalRecharge(ctx, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return parseIntegrationAmountCents(integrationAmount(amount)), nil
|
||||
}
|
||||
|
||||
func (s *Service) loadAccumulatedRechargeCents(ctx context.Context, userID int64) (int64, error) {
|
||||
recharges, err := s.java.MapUserTotalRecharge(ctx, []int64{userID})
|
||||
if err != nil {
|
||||
@ -104,12 +100,6 @@ func (s *Service) loadAccumulatedRechargeCents(ctx context.Context, userID int64
|
||||
return totalRechargeCents(recharges[userID]), nil
|
||||
}
|
||||
|
||||
func currentMonthBounds(now time.Time, location *time.Location) (time.Time, time.Time) {
|
||||
local := now.In(location)
|
||||
start := time.Date(local.Year(), local.Month(), 1, 0, 0, 0, 0, location)
|
||||
return start, start.AddDate(0, 1, 0)
|
||||
}
|
||||
|
||||
func resolveLocation(timezone string, fallback *time.Location) *time.Location {
|
||||
location, err := time.LoadLocation(normalizeRechargeRewardTimezone(timezone))
|
||||
if err == nil {
|
||||
|
||||
75
internal/service/rechargereward/period.go
Normal file
75
internal/service/rechargereward/period.go
Normal file
@ -0,0 +1,75 @@
|
||||
package rechargereward
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type rechargeRewardPeriod struct {
|
||||
CycleKey string
|
||||
RechargeDate int
|
||||
StartAt time.Time
|
||||
EndAt time.Time
|
||||
}
|
||||
|
||||
func (s *Service) currentPeriod(now time.Time, location *time.Location) (rechargeRewardPeriod, bool) {
|
||||
startAt := rechargeRewardStartAt(s.cfg.RechargeReward.StartDate, location)
|
||||
return currentRechargeRewardPeriod(now, startAt, location)
|
||||
}
|
||||
|
||||
func (s *Service) displayPeriod(now time.Time, location *time.Location) rechargeRewardPeriod {
|
||||
startAt := rechargeRewardStartAt(s.cfg.RechargeReward.StartDate, location)
|
||||
period, ok := currentRechargeRewardPeriod(now, startAt, location)
|
||||
if ok {
|
||||
return period
|
||||
}
|
||||
monthStart := time.Date(startAt.Year(), startAt.Month(), 1, 0, 0, 0, 0, location)
|
||||
return rechargeRewardPeriod{
|
||||
CycleKey: startAt.Format("20060102"),
|
||||
RechargeDate: startAt.Year()*100 + int(startAt.Month()),
|
||||
StartAt: startAt,
|
||||
EndAt: monthStart.AddDate(0, 1, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func currentRechargeRewardPeriod(now time.Time, startAt time.Time, location *time.Location) (rechargeRewardPeriod, bool) {
|
||||
local := now.In(location)
|
||||
start := startAt.In(location)
|
||||
if local.Before(start) {
|
||||
return rechargeRewardPeriod{}, false
|
||||
}
|
||||
monthStart := time.Date(local.Year(), local.Month(), 1, 0, 0, 0, 0, location)
|
||||
periodStart := monthStart
|
||||
if periodStart.Before(start) {
|
||||
periodStart = start
|
||||
}
|
||||
periodEnd := monthStart.AddDate(0, 1, 0)
|
||||
if !local.Before(periodEnd) {
|
||||
return rechargeRewardPeriod{}, false
|
||||
}
|
||||
return rechargeRewardPeriod{
|
||||
CycleKey: periodStart.Format("20060102"),
|
||||
RechargeDate: periodStart.Year()*100 + int(periodStart.Month()),
|
||||
StartAt: periodStart,
|
||||
EndAt: periodEnd,
|
||||
}, true
|
||||
}
|
||||
|
||||
func rechargeRewardStartAt(raw string, location *time.Location) time.Time {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
value = "2026-05-21"
|
||||
}
|
||||
parsed, err := time.ParseInLocation("2006-01-02", value, location)
|
||||
if err != nil {
|
||||
parsed, _ = time.ParseInLocation("2006-01-02", "2026-05-21", location)
|
||||
}
|
||||
return time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, location)
|
||||
}
|
||||
|
||||
func rechargeRewardStorageTime(t time.Time, storageLocation *time.Location) string {
|
||||
if storageLocation == nil {
|
||||
storageLocation = t.Location()
|
||||
}
|
||||
return t.In(storageLocation).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
102
internal/service/rechargereward/recharge_amount.go
Normal file
102
internal/service/rechargereward/recharge_amount.go
Normal file
@ -0,0 +1,102 @@
|
||||
package rechargereward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var rechargeRewardOrderPlatforms = []string{
|
||||
"GOOGLE",
|
||||
"PAYER_MAX",
|
||||
"AIRWALLEX",
|
||||
"PAYNICORN",
|
||||
"STRIPE",
|
||||
"PAY_PAL",
|
||||
"CLIPSPAY",
|
||||
}
|
||||
|
||||
type rechargeRewardAmountRow struct {
|
||||
RechargeAmountCents int64 `gorm:"column:recharge_amount_cents"`
|
||||
}
|
||||
|
||||
func (s *Service) loadCurrentRechargeCents(ctx context.Context, sysOrigin string, userID int64, period rechargeRewardPeriod) (int64, error) {
|
||||
if userID <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin))
|
||||
startAt := rechargeRewardStorageTime(period.StartAt, s.storageLocation)
|
||||
endAt := rechargeRewardStorageTime(period.EndAt, s.storageLocation)
|
||||
coinSellerGoldPerUSD := s.cfg.RechargeReward.CoinSellerGoldPerUSD
|
||||
if coinSellerGoldPerUSD <= 0 {
|
||||
coinSellerGoldPerUSD = 100000
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT CAST(IFNULL(SUM(source.amount_cents), 0) AS SIGNED) AS recharge_amount_cents
|
||||
FROM (
|
||||
SELECT CAST(ROUND(SUM(IFNULL(t.unit_price, 0)) * 100, 0) AS SIGNED) AS amount_cents
|
||||
FROM order_purchase_history AS t
|
||||
INNER JOIN user_base_info AS u ON u.id = t.user_id
|
||||
WHERE t.evn = 'PROD'
|
||||
AND t.is_trial_period = 0
|
||||
AND t.status = 'COMPLETE'
|
||||
AND t.pay_platform IN ?
|
||||
AND (u.is_del = 0 OR u.is_del IS NULL)
|
||||
AND UPPER(TRIM(u.origin_sys)) = ?
|
||||
AND t.user_id = ?
|
||||
AND t.create_time >= ?
|
||||
AND t.create_time < ?
|
||||
UNION ALL
|
||||
SELECT CAST(ROUND(SUM(IFNULL(t.compute_usd_amount, 0)) * 100, 0) AS SIGNED) AS amount_cents
|
||||
FROM order_user_purchase_pay AS t
|
||||
INNER JOIN user_base_info AS u ON u.id = t.user_id
|
||||
WHERE t.evn = 'PROD'
|
||||
AND t.factory_code = 'MIFA_PAY'
|
||||
AND t.pay_status = 'SUCCESSFUL'
|
||||
AND t.receipt_type = 'PAYMENT'
|
||||
AND t.refund_status = 'NONE'
|
||||
AND (u.is_del = 0 OR u.is_del IS NULL)
|
||||
AND UPPER(TRIM(u.origin_sys)) = ?
|
||||
AND t.user_id = ?
|
||||
AND IFNULL(t.update_time, t.create_time) >= ?
|
||||
AND IFNULL(t.update_time, t.create_time) < ?
|
||||
UNION ALL
|
||||
SELECT CAST(ROUND(SUM(IFNULL(t.quantity, 0) * 100 / ?), 0) AS SIGNED) AS amount_cents
|
||||
FROM likei_wallet.user_freight_balance_running_water AS t
|
||||
INNER JOIN user_base_info AS u ON u.id = t.accept_user_id
|
||||
WHERE t.origin = 'SHIPMENT'
|
||||
AND t.type = 1
|
||||
AND t.accept_user_id IS NOT NULL
|
||||
AND IFNULL(t.quantity, 0) > 0
|
||||
AND (u.is_del = 0 OR u.is_del IS NULL)
|
||||
AND UPPER(TRIM(u.origin_sys)) = ?
|
||||
AND t.accept_user_id = ?
|
||||
AND t.create_time >= ?
|
||||
AND t.create_time < ?
|
||||
) AS source`
|
||||
|
||||
var row rechargeRewardAmountRow
|
||||
if err := s.db.WithContext(ctx).Raw(
|
||||
query,
|
||||
rechargeRewardOrderPlatforms,
|
||||
sysOrigin,
|
||||
userID,
|
||||
startAt,
|
||||
endAt,
|
||||
sysOrigin,
|
||||
userID,
|
||||
startAt,
|
||||
endAt,
|
||||
coinSellerGoldPerUSD,
|
||||
sysOrigin,
|
||||
userID,
|
||||
startAt,
|
||||
endAt,
|
||||
).Scan(&row).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if row.RechargeAmountCents < 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return row.RechargeAmountCents, nil
|
||||
}
|
||||
@ -41,10 +41,11 @@ type rechargeRewardJavaGateway interface {
|
||||
|
||||
// Service 负责充值奖励后台配置和 H5 用户态数据。
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
db rechargeRewardDB
|
||||
java rechargeRewardJavaGateway
|
||||
location *time.Location
|
||||
cfg config.Config
|
||||
db rechargeRewardDB
|
||||
java rechargeRewardJavaGateway
|
||||
location *time.Location
|
||||
storageLocation *time.Location
|
||||
}
|
||||
|
||||
func NewService(cfg config.Config, db rechargeRewardDB, java rechargeRewardJavaGateway) *Service {
|
||||
@ -56,7 +57,15 @@ func NewService(cfg config.Config, db rechargeRewardDB, java rechargeRewardJavaG
|
||||
if err != nil {
|
||||
location = time.UTC
|
||||
}
|
||||
return &Service{cfg: cfg, db: db, java: java, location: location}
|
||||
storageTimezone := strings.TrimSpace(cfg.RechargeReward.StorageTimezone)
|
||||
if storageTimezone == "" {
|
||||
storageTimezone = "Asia/Shanghai"
|
||||
}
|
||||
storageLocation, err := time.LoadLocation(storageTimezone)
|
||||
if err != nil {
|
||||
storageLocation = location
|
||||
}
|
||||
return &Service{cfg: cfg, db: db, java: java, location: location, storageLocation: storageLocation}
|
||||
}
|
||||
|
||||
// RechargeRewardLevelPayload 是前后台共用的充值奖励档位读模型。
|
||||
|
||||
@ -207,7 +207,7 @@ func containsGiftToken(list []string, token string) bool {
|
||||
|
||||
func isDefaultBlockedGiftToken(token string) bool {
|
||||
switch normalizeGiftToken(token) {
|
||||
case "BACKPACK", "BAG", "DIAMOND", "LUCKY", "ACTIVITY", "CP", "MAGIC", "FREE":
|
||||
case "DIAMOND", "LUCKY", "ACTIVITY", "CP", "MAGIC", "FREE":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@ -217,11 +217,83 @@ func TestDecodeGiftEventPayloadEnvelope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiftEnergyRulesRejectUnsupportedAndAllowConfiguredGift(t *testing.T) {
|
||||
func TestBackpackGiftIncreasesRocketEnergy(t *testing.T) {
|
||||
service, db := newTestService(t)
|
||||
ctx := context.Background()
|
||||
seedRocketConfig(t, db)
|
||||
seedLevelConfigs(t, db, []int64{100, 200, 300, 400, 500, 600})
|
||||
seedLevelConfigs(t, db, []int64{1000, 2000, 3000, 4000, 5000, 6000})
|
||||
|
||||
payloads := []struct {
|
||||
raw string
|
||||
energy int64
|
||||
}{
|
||||
{
|
||||
energy: 30,
|
||||
raw: `{
|
||||
"trackId":"track-root-bag-gift",
|
||||
"sysOrigin":"LIKEI",
|
||||
"sendUserId":"1001",
|
||||
"roomId":"9001",
|
||||
"quantity":1,
|
||||
"bagGift":true,
|
||||
"giftConfig":{"id":"11","giftCandy":30,"type":"GOLD","giftTab":"NORMAL"},
|
||||
"giftValue":{"actualAmount":30},
|
||||
"createTime":` + strconvFormatMillis(time.Now()) + `
|
||||
}`,
|
||||
},
|
||||
{
|
||||
energy: 40,
|
||||
raw: `{
|
||||
"trackId":"track-value-bag-gift",
|
||||
"sysOrigin":"LIKEI",
|
||||
"sendUserId":"1001",
|
||||
"roomId":"9001",
|
||||
"quantity":1,
|
||||
"giftConfig":{"id":"11","giftCandy":40,"type":"GOLD","giftTab":"NORMAL"},
|
||||
"giftValue":{"actualAmount":40,"bag":true},
|
||||
"createTime":` + strconvFormatMillis(time.Now().Add(time.Second)) + `
|
||||
}`,
|
||||
},
|
||||
}
|
||||
for _, payload := range payloads {
|
||||
processed, err := service.ProcessGiftPayload(ctx, payload.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessGiftPayload(backpack) error = %v", err)
|
||||
}
|
||||
if !processed.Processed || processed.AcceptedEnergy != payload.energy {
|
||||
t.Fatalf("processed = %+v, want accepted energy %d", processed, payload.energy)
|
||||
}
|
||||
}
|
||||
|
||||
var status model.VoiceRoomRocketStatus
|
||||
if err := db.Where("sys_origin = ? AND room_id = ?", "LIKEI", 9001).
|
||||
First(&status).Error; err != nil {
|
||||
t.Fatalf("find status: %v", err)
|
||||
}
|
||||
if status.CurrentEnergy != 70 {
|
||||
t.Fatalf("current energy = %d, want 70", status.CurrentEnergy)
|
||||
}
|
||||
|
||||
var logs []model.VoiceRoomRocketGiftEventLog
|
||||
if err := db.Where("track_id IN ?", []string{"track-root-bag-gift", "track-value-bag-gift"}).
|
||||
Find(&logs).Error; err != nil {
|
||||
t.Fatalf("find logs: %v", err)
|
||||
}
|
||||
if len(logs) != 2 {
|
||||
t.Fatalf("logs = %d, want 2", len(logs))
|
||||
}
|
||||
for _, log := range logs {
|
||||
if log.Status != eventStatusSuccess || log.AcceptedEnergy <= 0 {
|
||||
t.Fatalf("log = %+v, want success with accepted energy", log)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiftEnergyRulesAllowBackpackAndConfiguredGift(t *testing.T) {
|
||||
service, db := newTestService(t)
|
||||
ctx := context.Background()
|
||||
seedRocketConfig(t, db)
|
||||
seedLevelConfigs(t, db, []int64{1000, 2000, 3000, 4000, 5000, 6000})
|
||||
|
||||
backpackPayload := `{
|
||||
"trackId":"track-backpack",
|
||||
@ -232,12 +304,12 @@ func TestGiftEnergyRulesRejectUnsupportedAndAllowConfiguredGift(t *testing.T) {
|
||||
"giftConfig":{"id":"11","giftCandy":99,"type":"GOLD","giftTab":"BACKPACK"},
|
||||
"createTime":` + strconvFormatMillis(time.Now()) + `
|
||||
}`
|
||||
ignored, err := service.ProcessGiftPayload(ctx, backpackPayload)
|
||||
backpack, err := service.ProcessGiftPayload(ctx, backpackPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessGiftPayload(backpack) error = %v", err)
|
||||
}
|
||||
if ignored.Processed || ignored.Reason != "unsupported_gift" {
|
||||
t.Fatalf("ignored = %+v, want unsupported_gift", ignored)
|
||||
if !backpack.Processed || backpack.AcceptedEnergy != 99 {
|
||||
t.Fatalf("backpack = %+v, want 99 energy", backpack)
|
||||
}
|
||||
|
||||
if err := db.Model(&model.VoiceRoomRocketConfig{}).
|
||||
@ -744,6 +816,29 @@ func TestNotifyRewardRecordPublishesIMAndRedisStream(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishRewardMessageTrimsBroadcastStreamByConfiguredMaxLen(t *testing.T) {
|
||||
service, _, rdb, mr := newTestServiceWithRedis(t, newFakeRocketGateway())
|
||||
defer mr.Close()
|
||||
service.cfg.VoiceRoomRocket.BroadcastStreamMaxLen = 2
|
||||
|
||||
for idx := 1; idx <= 3; idx++ {
|
||||
service.publishRewardMessage(context.Background(), 9001, "launch-"+strconv.Itoa(idx), map[string]any{
|
||||
"type": imTypeRewardPopup,
|
||||
"data": map[string]any{
|
||||
"roomId": "9001",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
count, err := rdb.XLen(context.Background(), "voice_room:rocket:broadcast").Result()
|
||||
if err != nil {
|
||||
t.Fatalf("xlen broadcast: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("broadcast stream count = %d, want 2", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusUpdatePublishesRegionIM(t *testing.T) {
|
||||
gateway := newFakeRocketGateway()
|
||||
service, db, _, mr := newTestServiceWithRedis(t, gateway)
|
||||
@ -938,6 +1033,8 @@ func newTestServiceWithRedis(t *testing.T, gateway *fakeRocketGateway) (*Service
|
||||
service.userProfiles = gateway
|
||||
service.wallet = gateway
|
||||
service.rewardGateway = gateway
|
||||
service.cfg.VoiceRoomRocket.BroadcastStreamKey = "voice_room:rocket:broadcast"
|
||||
service.cfg.VoiceRoomRocket.BroadcastStreamMaxLen = 100
|
||||
service.cfg.VoiceRoomRocket.InRoomRewardUserLimit = 3
|
||||
service.cfg.VoiceRoomRocket.InRoomRewardDueZSetKey = "voice_room:rocket:in_room_due"
|
||||
return service, db, rdb, mr
|
||||
|
||||
@ -13,6 +13,22 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (s *Service) broadcastStreamArgs(values map[string]any) *redis.XAddArgs {
|
||||
streamKey := strings.TrimSpace(s.cfg.VoiceRoomRocket.BroadcastStreamKey)
|
||||
if streamKey == "" {
|
||||
streamKey = "voice_room:rocket:broadcast"
|
||||
}
|
||||
args := &redis.XAddArgs{
|
||||
Stream: streamKey,
|
||||
Values: values,
|
||||
}
|
||||
if s.cfg.VoiceRoomRocket.BroadcastStreamMaxLen > 0 {
|
||||
args.MaxLen = s.cfg.VoiceRoomRocket.BroadcastStreamMaxLen
|
||||
args.Approx = true
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func (s *Service) notifyStatusUpdate(ctx context.Context, sysOrigin string, roomID int64, senderUserIDs ...int64) error {
|
||||
data := map[string]any{
|
||||
"roomId": strconv.FormatInt(roomID, 10),
|
||||
@ -35,18 +51,11 @@ func (s *Service) notifyStatusUpdate(ctx context.Context, sysOrigin string, room
|
||||
"roomId": strconv.FormatInt(roomID, 10),
|
||||
"data": data,
|
||||
})
|
||||
streamKey := strings.TrimSpace(s.cfg.VoiceRoomRocket.BroadcastStreamKey)
|
||||
if streamKey == "" {
|
||||
streamKey = "voice_room:rocket:broadcast"
|
||||
}
|
||||
_ = s.repo.Redis.XAdd(ctx, &redis.XAddArgs{
|
||||
Stream: streamKey,
|
||||
Values: map[string]any{
|
||||
"type": imTypeStatusUpdate,
|
||||
"roomId": strconv.FormatInt(roomID, 10),
|
||||
"body": string(body),
|
||||
},
|
||||
}).Err()
|
||||
_ = s.repo.Redis.XAdd(ctx, s.broadcastStreamArgs(map[string]any{
|
||||
"type": imTypeStatusUpdate,
|
||||
"roomId": strconv.FormatInt(roomID, 10),
|
||||
"body": string(body),
|
||||
})).Err()
|
||||
}
|
||||
_ = s.notifyRegionStatusUpdate(ctx, sysOrigin, senderUserID, data)
|
||||
if s.im == nil {
|
||||
@ -147,19 +156,12 @@ func (s *Service) notifyLaunch(ctx context.Context, launch model.VoiceRoomRocket
|
||||
}
|
||||
if s.repo.Redis != nil {
|
||||
payload, _ := json.Marshal(body)
|
||||
streamKey := strings.TrimSpace(s.cfg.VoiceRoomRocket.BroadcastStreamKey)
|
||||
if streamKey == "" {
|
||||
streamKey = "voice_room:rocket:broadcast"
|
||||
}
|
||||
_ = s.repo.Redis.XAdd(ctx, &redis.XAddArgs{
|
||||
Stream: streamKey,
|
||||
Values: map[string]any{
|
||||
"type": imTypeLaunchBroadcast,
|
||||
"roomId": strconv.FormatInt(launch.RoomID, 10),
|
||||
"launchNo": launch.LaunchNo,
|
||||
"body": string(payload),
|
||||
},
|
||||
}).Err()
|
||||
_ = s.repo.Redis.XAdd(ctx, s.broadcastStreamArgs(map[string]any{
|
||||
"type": imTypeLaunchBroadcast,
|
||||
"roomId": strconv.FormatInt(launch.RoomID, 10),
|
||||
"launchNo": launch.LaunchNo,
|
||||
"body": string(payload),
|
||||
})).Err()
|
||||
}
|
||||
if s.im == nil {
|
||||
return nil
|
||||
@ -241,19 +243,12 @@ func (s *Service) publishRewardMessage(ctx context.Context, roomID int64, launch
|
||||
return
|
||||
}
|
||||
payload, _ := json.Marshal(body)
|
||||
streamKey := strings.TrimSpace(s.cfg.VoiceRoomRocket.BroadcastStreamKey)
|
||||
if streamKey == "" {
|
||||
streamKey = "voice_room:rocket:broadcast"
|
||||
}
|
||||
_ = s.repo.Redis.XAdd(ctx, &redis.XAddArgs{
|
||||
Stream: streamKey,
|
||||
Values: map[string]any{
|
||||
"type": body["type"],
|
||||
"roomId": strconv.FormatInt(roomID, 10),
|
||||
"launchNo": launchNo,
|
||||
"body": string(payload),
|
||||
},
|
||||
}).Err()
|
||||
_ = s.repo.Redis.XAdd(ctx, s.broadcastStreamArgs(map[string]any{
|
||||
"type": body["type"],
|
||||
"roomId": strconv.FormatInt(roomID, 10),
|
||||
"launchNo": launchNo,
|
||||
"body": string(payload),
|
||||
})).Err()
|
||||
}
|
||||
|
||||
func (s *Service) resolveRoomAccount(ctx context.Context, roomID int64) string {
|
||||
|
||||
38
migrations/048_recharge_reward_grant_record.sql
Normal file
38
migrations/048_recharge_reward_grant_record.sql
Normal file
@ -0,0 +1,38 @@
|
||||
CREATE TABLE IF NOT EXISTS `recharge_reward_grant_record` (
|
||||
`id` bigint 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 '来源系统',
|
||||
`cycle_key` varchar(32) NOT NULL COMMENT '充值周期标识,周期开始日yyyyMMdd',
|
||||
`recharge_date` int NOT NULL COMMENT '充值年月yyyyMM,用于后台筛选',
|
||||
`period_start_at` datetime(3) NOT NULL COMMENT '周期开始时间,活动时区墙上时间',
|
||||
`period_end_at` datetime(3) 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 '发放时周期累计充值金额,单位为分',
|
||||
`level` int NOT NULL COMMENT '奖励档位',
|
||||
`recharge_threshold_cents` bigint NOT NULL DEFAULT '0' COMMENT '充值金额门槛,单位为分',
|
||||
`reward_gold` bigint NOT NULL DEFAULT '0' COMMENT '金币奖励数量',
|
||||
`reward_group_id` bigint DEFAULT NULL COMMENT '奖励组ID',
|
||||
`reward_group_name` varchar(255) NOT NULL DEFAULT '' COMMENT '奖励组名称快照',
|
||||
`reward_group_track_id` bigint NOT NULL DEFAULT '0' COMMENT '奖励组发放幂等追踪ID',
|
||||
`gold_event_id` varchar(128) NOT NULL DEFAULT '' COMMENT '金币发放钱包事件ID',
|
||||
`status` varchar(32) NOT NULL DEFAULT 'PENDING' COMMENT '整体发放状态:PENDING/SUCCESS/FAILED',
|
||||
`reward_group_status` varchar(32) NOT NULL DEFAULT 'SKIPPED' COMMENT '奖励组发放状态:PENDING/SUCCESS/FAILED/SKIPPED',
|
||||
`reward_gold_status` varchar(32) NOT NULL DEFAULT 'SKIPPED' COMMENT '金币发放状态:PENDING/SUCCESS/FAILED/SKIPPED',
|
||||
`retry_count` int NOT NULL DEFAULT '0' COMMENT '重试次数',
|
||||
`last_error` varchar(1024) NOT NULL DEFAULT '' 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_recharge_reward_grant_level` (`sys_origin`, `cycle_key`, `user_id`, `level`),
|
||||
KEY `idx_recharge_reward_grant_config_cycle` (`config_id`, `cycle_key`, `status`),
|
||||
KEY `idx_recharge_reward_grant_user` (`user_id`, `recharge_date`),
|
||||
KEY `idx_recharge_reward_grant_date` (`sys_origin`, `recharge_date`, `status`),
|
||||
KEY `idx_recharge_reward_gold_event` (`gold_event_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='充值奖励周期发放记录';
|
||||
62
migrations/049_first_recharge_reward.sql
Normal file
62
migrations/049_first_recharge_reward.sql
Normal file
@ -0,0 +1,62 @@
|
||||
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 '是否启用',
|
||||
`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 '充值金额门槛,单位为分',
|
||||
`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_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',
|
||||
`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_order` (`source_order_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励发放记录';
|
||||
Loading…
x
Reference in New Issue
Block a user