Compare commits

...

4 Commits

Author SHA1 Message Date
hy001
a02e22ac74 首冲奖励 2026-05-22 10:10:04 +08:00
hy001
74063d0908 充值奖励结算 2026-05-21 11:14:55 +08:00
hy001
810dc0345b fix: trim rtc reward streams 2026-05-21 10:50:18 +08:00
hy001
4926d5dfd2 修复 背包礼物不增加 火箭能量 2026-05-20 14:17:45 +08:00
29 changed files with 2803 additions and 316 deletions

View File

@ -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,

View File

@ -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)
}

View File

@ -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"),

View 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"
}

View File

@ -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" }

View 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)
})
}

View File

@ -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)

View 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
}

View 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]
}

View 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
}

View File

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

View File

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

View File

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

View File

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

View 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
}

View 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
}

View File

@ -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,

View File

@ -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)
}
}

View File

@ -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,

View File

@ -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

View File

@ -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 {

View 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")
}

View 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
}

View File

@ -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 是前后台共用的充值奖励档位读模型。

View File

@ -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

View File

@ -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

View File

@ -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 {

View 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='充值奖励周期发放记录';

View 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='首冲奖励发放记录';