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)
}
@ -110,6 +115,7 @@ func main() {
Invite: inviteService,
Baishun: baishunService,
BinanceRecharge: binanceRechargeService,
FirstRechargeReward: firstRechargeRewardService,
Lingxian: lingxianService,
GameOpen: gameOpenService,
GameProviders: gameProviders,

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

@ -16,6 +16,7 @@ type Config struct {
Worker WorkerConfig
Invite InviteConfig
RechargeReward RechargeRewardConfig
FirstRechargeReward FirstRechargeRewardConfig
TaskCenter TaskCenterConfig
WeekStar WeekStarConfig
RoomTurnoverReward RoomTurnoverRewardConfig
@ -83,6 +84,28 @@ type InviteConfig struct {
type RechargeRewardConfig struct {
DefaultSysOrigin string
Timezone string
StorageTimezone string
StartDate string
CoinSellerGoldPerUSD int64
}
// FirstRechargeRewardConfig 保存首冲奖励模块配置。
type FirstRechargeRewardConfig struct {
DefaultSysOrigin string
MQ FirstRechargeRewardMQConfig
}
// FirstRechargeRewardMQConfig 保存首冲奖励订阅通用充值成功 MQ 的消费配置。
type FirstRechargeRewardMQConfig struct {
Enabled bool
Endpoint string
Namespace string
AccessKey string
AccessSecret string
SecurityToken string
ConsumerGroup string
Topic string
Tag string
}
// TaskCenterConfig 保存任务中心模块配置。
@ -167,6 +190,7 @@ type VoiceRoomRocketConfig struct {
InRoomRewardDueZSetKey string
RoomLockTTLSeconds int
BroadcastStreamKey string
BroadcastStreamMaxLen int64
InRoomRewardScanIntervalSeconds int
InRoomRewardBatchSize int
InRoomRewardUserLimit int
@ -232,6 +256,7 @@ type LuckyGiftConfig struct {
Enabled bool
Timeout time.Duration
RewardStreamKey string
RewardStreamMaxLen int64
Providers []LuckyGiftProviderConfig
}
@ -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,
@ -518,6 +579,7 @@ func Load() Config {
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{

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"
@ -43,6 +44,7 @@ type Services struct {
Invite *invite.InviteService
Baishun *baishun.BaishunService
BinanceRecharge *binancerecharge.Service
FirstRechargeReward *firstrechargereward.Service
Lingxian *lingxian.Service
GameOpen *gameopen.GameOpenService
GameProviders *gameprovider.Registry
@ -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"
@ -24,6 +25,7 @@ func TestPublishRewardEventWritesWinningResultsToStream(t *testing.T) {
cfg: config.Config{
LuckyGift: config.LuckyGiftConfig{
RewardStreamKey: "lucky-gift:reward",
RewardStreamMaxLen: 2,
},
},
repo: luckyGiftPorts{Redis: client},
@ -90,6 +92,7 @@ func TestPublishRewardEventSkipsZeroRewardTotal(t *testing.T) {
cfg: config.Config{
LuckyGift: config.LuckyGiftConfig{
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,31 +2,15 @@ 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"`
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"`
@ -41,8 +25,9 @@ type rechargeRewardClaimDetailRow struct {
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"`
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"`
}
@ -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",
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,12 +26,16 @@ 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)
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 {
return nil, err
@ -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

@ -45,6 +45,7 @@ type Service struct {
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{
_ = s.repo.Redis.XAdd(ctx, s.broadcastStreamArgs(map[string]any{
"type": imTypeStatusUpdate,
"roomId": strconv.FormatInt(roomID, 10),
"body": string(body),
},
}).Err()
})).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{
_ = 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()
})).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{
_ = s.repo.Redis.XAdd(ctx, s.broadcastStreamArgs(map[string]any{
"type": body["type"],
"roomId": strconv.FormatInt(roomID, 10),
"launchNo": launchNo,
"body": string(payload),
},
}).Err()
})).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='首冲奖励发放记录';