From a02e22ac748af1d6529db823807e904e2e9f0ffe Mon Sep 17 00:00:00 2001 From: hy001 Date: Fri, 22 May 2026 10:10:04 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A6=96=E5=86=B2=E5=A5=96=E5=8A=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/api/main.go | 58 +-- cmd/consumer/main.go | 5 + internal/config/config.go | 74 +++- .../model/first_recharge_reward_models.go | 65 +++ .../router/first_recharge_reward_routes.go | 70 +++ internal/router/router.go | 55 +-- .../service/firstrechargereward/config.go | 234 ++++++++++ internal/service/firstrechargereward/event.go | 407 ++++++++++++++++++ internal/service/firstrechargereward/home.go | 107 +++++ .../service/firstrechargereward/lifecycle.go | 139 ++++++ .../service/firstrechargereward/mapper.go | 146 +++++++ internal/service/firstrechargereward/money.go | 31 ++ .../service/firstrechargereward/records.go | 108 +++++ .../firstrechargereward/service_test.go | 286 ++++++++++++ internal/service/firstrechargereward/types.go | 356 +++++++++++++++ migrations/049_first_recharge_reward.sql | 62 +++ 16 files changed, 2134 insertions(+), 69 deletions(-) create mode 100644 internal/model/first_recharge_reward_models.go create mode 100644 internal/router/first_recharge_reward_routes.go create mode 100644 internal/service/firstrechargereward/config.go create mode 100644 internal/service/firstrechargereward/event.go create mode 100644 internal/service/firstrechargereward/home.go create mode 100644 internal/service/firstrechargereward/lifecycle.go create mode 100644 internal/service/firstrechargereward/mapper.go create mode 100644 internal/service/firstrechargereward/money.go create mode 100644 internal/service/firstrechargereward/records.go create mode 100644 internal/service/firstrechargereward/service_test.go create mode 100644 internal/service/firstrechargereward/types.go create mode 100644 migrations/049_first_recharge_reward.sql diff --git a/cmd/api/main.go b/cmd/api/main.go index 15e45d9..ea590fb 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -16,6 +16,7 @@ import ( "chatapp3-golang/internal/service/baishun" "chatapp3-golang/internal/service/binancerecharge" "chatapp3-golang/internal/service/errorlog" + "chatapp3-golang/internal/service/firstrechargereward" "chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/hostcenter" @@ -60,6 +61,7 @@ func main() { rechargeAgencyService := rechargeagency.NewService(app.Config, app.Repository.DB, &app.Gateways) registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) rechargeRewardService := rechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways) + firstRechargeRewardService := firstrechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways) signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways) userBadgeService := userbadge.NewService(app.Repository.DB) @@ -92,6 +94,9 @@ func main() { if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil { log.Fatalf("start task center message consumer failed: %v", err) } + if err := firstRechargeRewardService.StartMessageConsumer(workerCtx); err != nil { + log.Fatalf("start first recharge reward message consumer failed: %v", err) + } if err := voiceRoomRocketService.Start(workerCtx); err != nil { log.Fatalf("start voice room rocket workers failed: %v", err) } @@ -105,32 +110,33 @@ func main() { lingxian.NewAppProvider(lingxianService), ) engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{ - AppPopup: appPopupService, - ErrorLog: errorLogService, - Invite: inviteService, - Baishun: baishunService, - BinanceRecharge: binanceRechargeService, - Lingxian: lingxianService, - GameOpen: gameOpenService, - GameProviders: gameProviders, - LuckyGift: luckyGiftService, - ManagerCenter: managerCenterService, - PropsStore: propsStoreService, - HostCenter: hostCenterService, - RechargeAgency: rechargeAgencyService, - RechargeReward: rechargeRewardService, - RegionIMGroup: regionIMGroupService, - RegisterReward: registerRewardService, - RoomTurnoverReward: roomTurnoverRewardService, - SignInReward: signInRewardService, - SmashEgg: smashEggService, - TaskCenter: taskCenterService, - UserBadge: userBadgeService, - VIP: vipService, - Wheel: wheelService, - VoiceRoomRedPacket: voiceRoomRedPacketService, - VoiceRoomRocket: voiceRoomRocketService, - WeekStar: weekStarService, + AppPopup: appPopupService, + ErrorLog: errorLogService, + Invite: inviteService, + Baishun: baishunService, + BinanceRecharge: binanceRechargeService, + FirstRechargeReward: firstRechargeRewardService, + Lingxian: lingxianService, + GameOpen: gameOpenService, + GameProviders: gameProviders, + LuckyGift: luckyGiftService, + ManagerCenter: managerCenterService, + PropsStore: propsStoreService, + HostCenter: hostCenterService, + RechargeAgency: rechargeAgencyService, + RechargeReward: rechargeRewardService, + RegionIMGroup: regionIMGroupService, + RegisterReward: registerRewardService, + RoomTurnoverReward: roomTurnoverRewardService, + SignInReward: signInRewardService, + SmashEgg: smashEggService, + TaskCenter: taskCenterService, + UserBadge: userBadgeService, + VIP: vipService, + Wheel: wheelService, + VoiceRoomRedPacket: voiceRoomRedPacketService, + VoiceRoomRocket: voiceRoomRocketService, + WeekStar: weekStarService, }) server := &http.Server{ Addr: app.Config.HTTP.ListenAddr, diff --git a/cmd/consumer/main.go b/cmd/consumer/main.go index bbe3604..df1ae7b 100644 --- a/cmd/consumer/main.go +++ b/cmd/consumer/main.go @@ -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) } diff --git a/internal/config/config.go b/internal/config/config.go index 7c843f6..acea750 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,23 +10,24 @@ import ( // Config 是按运行层和业务模块分段后的应用配置。 type Config struct { - HTTP HTTPConfig - Store StoreConfig - Java JavaConfig - Worker WorkerConfig - Invite InviteConfig - RechargeReward RechargeRewardConfig - TaskCenter TaskCenterConfig - WeekStar WeekStarConfig - RoomTurnoverReward RoomTurnoverRewardConfig - VoiceRoomRedPacket VoiceRoomRedPacketConfig - VoiceRoomRocket VoiceRoomRocketConfig - TencentIM TencentIMConfig - Baishun BaishunConfig - Lingxian LingxianConfig - GameOpen GameOpenConfig - LuckyGift LuckyGiftConfig - BinanceRecharge BinanceRechargeConfig + HTTP HTTPConfig + Store StoreConfig + Java JavaConfig + Worker WorkerConfig + Invite InviteConfig + RechargeReward RechargeRewardConfig + FirstRechargeReward FirstRechargeRewardConfig + TaskCenter TaskCenterConfig + WeekStar WeekStarConfig + RoomTurnoverReward RoomTurnoverRewardConfig + VoiceRoomRedPacket VoiceRoomRedPacketConfig + VoiceRoomRocket VoiceRoomRocketConfig + TencentIM TencentIMConfig + Baishun BaishunConfig + Lingxian LingxianConfig + GameOpen GameOpenConfig + LuckyGift LuckyGiftConfig + BinanceRecharge BinanceRechargeConfig } // HTTPConfig 保存 HTTP 服务运行配置。 @@ -88,6 +89,25 @@ type RechargeRewardConfig struct { CoinSellerGoldPerUSD int64 } +// FirstRechargeRewardConfig 保存首冲奖励模块配置。 +type FirstRechargeRewardConfig struct { + DefaultSysOrigin string + MQ FirstRechargeRewardMQConfig +} + +// FirstRechargeRewardMQConfig 保存首冲奖励订阅通用充值成功 MQ 的消费配置。 +type FirstRechargeRewardMQConfig struct { + Enabled bool + Endpoint string + Namespace string + AccessKey string + AccessSecret string + SecurityToken string + ConsumerGroup string + Topic string + Tag string +} + // TaskCenterConfig 保存任务中心模块配置。 type TaskCenterConfig struct { DefaultSysOrigin string @@ -265,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"}, "") @@ -329,6 +352,23 @@ func Load() Config { 100000, )), }, + FirstRechargeReward: FirstRechargeRewardConfig{ + DefaultSysOrigin: strings.ToUpper(getEnvAny( + []string{"CHATAPP_FIRST_RECHARGE_REWARD_DEFAULT_SYS_ORIGIN", "FIRST_RECHARGE_REWARD_DEFAULT_SYS_ORIGIN", "CHATAPP_RECHARGE_REWARD_DEFAULT_SYS_ORIGIN", "RECHARGE_REWARD_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"}, + "LIKEI", + )), + MQ: FirstRechargeRewardMQConfig{ + Enabled: getEnvBoolAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_ENABLED", "FIRST_RECHARGE_REWARD_MQ_ENABLED", "CHATAPP_RECHARGE_SUCCESS_MQ_ENABLED", "RECHARGE_SUCCESS_MQ_ENABLED"}, false), + Endpoint: firstRechargeRewardMQEndpoint, + Namespace: getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_NAMESPACE", "FIRST_RECHARGE_REWARD_MQ_NAMESPACE", "CHATAPP_RECHARGE_SUCCESS_MQ_NAMESPACE", "RECHARGE_SUCCESS_MQ_NAMESPACE"}, ""), + AccessKey: firstRechargeRewardMQAccessKey, + AccessSecret: firstRechargeRewardMQAccessSecret, + SecurityToken: getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_SECURITY_TOKEN", "FIRST_RECHARGE_REWARD_MQ_SECURITY_TOKEN", "CHATAPP_RECHARGE_SUCCESS_MQ_SECURITY_TOKEN", "RECHARGE_SUCCESS_MQ_SECURITY_TOKEN"}, ""), + ConsumerGroup: getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_CONSUMER_GROUP", "FIRST_RECHARGE_REWARD_MQ_CONSUMER_GROUP"}, "first-recharge-reward"), + Topic: getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_TOPIC", "FIRST_RECHARGE_REWARD_MQ_TOPIC", "CHATAPP_RECHARGE_SUCCESS_MQ_TOPIC", "RECHARGE_SUCCESS_MQ_TOPIC"}, "RC_DEFAULT_APP_ORDINARY"), + Tag: getEnvAny([]string{"CHATAPP_FIRST_RECHARGE_REWARD_MQ_TAG", "FIRST_RECHARGE_REWARD_MQ_TAG", "CHATAPP_RECHARGE_SUCCESS_MQ_TAG", "RECHARGE_SUCCESS_MQ_TAG"}, "recharge_success_v1"), + }, + }, TaskCenter: TaskCenterConfig{ DefaultSysOrigin: strings.ToUpper(getEnvAny( []string{"CHATAPP_TASK_CENTER_DEFAULT_SYS_ORIGIN", "TASK_CENTER_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"}, diff --git a/internal/model/first_recharge_reward_models.go b/internal/model/first_recharge_reward_models.go new file mode 100644 index 0000000..cc62945 --- /dev/null +++ b/internal/model/first_recharge_reward_models.go @@ -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" +} diff --git a/internal/router/first_recharge_reward_routes.go b/internal/router/first_recharge_reward_routes.go new file mode 100644 index 0000000..3dea949 --- /dev/null +++ b/internal/router/first_recharge_reward_routes.go @@ -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) + }) +} diff --git a/internal/router/router.go b/internal/router/router.go index 67118bf..d5a1b95 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -10,6 +10,7 @@ import ( "chatapp3-golang/internal/service/baishun" "chatapp3-golang/internal/service/binancerecharge" "chatapp3-golang/internal/service/errorlog" + "chatapp3-golang/internal/service/firstrechargereward" "chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/hostcenter" @@ -38,32 +39,33 @@ import ( // Services 聚合所有业务模块服务,供路由层统一装配。 type Services struct { - AppPopup *apppopup.Service - ErrorLog *errorlog.Service - Invite *invite.InviteService - Baishun *baishun.BaishunService - BinanceRecharge *binancerecharge.Service - Lingxian *lingxian.Service - GameOpen *gameopen.GameOpenService - GameProviders *gameprovider.Registry - LuckyGift *luckygift.LuckyGiftService - ManagerCenter *managercenter.Service - PropsStore *propsstore.Service - HostCenter *hostcenter.Service - RechargeAgency *rechargeagency.Service - RechargeReward *rechargereward.Service - RegionIMGroup *regionimgroup.Service - RegisterReward *registerreward.RegisterRewardService - RoomTurnoverReward *roomturnoverreward.Service - SignInReward *signinreward.SignInRewardService - SmashEgg *smashegg.Service - TaskCenter *taskcenter.Service - UserBadge *userbadge.Service - VIP *vip.Service - Wheel *wheel.Service - VoiceRoomRedPacket *voiceroomredpacket.Service - VoiceRoomRocket *voiceroomrocket.Service - WeekStar *weekstar.WeekStarService + AppPopup *apppopup.Service + ErrorLog *errorlog.Service + Invite *invite.InviteService + Baishun *baishun.BaishunService + BinanceRecharge *binancerecharge.Service + FirstRechargeReward *firstrechargereward.Service + Lingxian *lingxian.Service + GameOpen *gameopen.GameOpenService + GameProviders *gameprovider.Registry + LuckyGift *luckygift.LuckyGiftService + ManagerCenter *managercenter.Service + PropsStore *propsstore.Service + HostCenter *hostcenter.Service + RechargeAgency *rechargeagency.Service + RechargeReward *rechargereward.Service + RegionIMGroup *regionimgroup.Service + RegisterReward *registerreward.RegisterRewardService + RoomTurnoverReward *roomturnoverreward.Service + SignInReward *signinreward.SignInRewardService + SmashEgg *smashegg.Service + TaskCenter *taskcenter.Service + UserBadge *userbadge.Service + VIP *vip.Service + Wheel *wheel.Service + VoiceRoomRedPacket *voiceroomredpacket.Service + VoiceRoomRocket *voiceroomrocket.Service + WeekStar *weekstar.WeekStarService } // NewRouter 创建 Gin 路由,并按模块注册所有接口。 @@ -82,6 +84,7 @@ func NewRouter( registerInviteRoutes(engine, javaClient, services.Invite) registerRechargeAgencyRoutes(engine, javaClient, services.RechargeAgency) registerRechargeRewardRoutes(engine, javaClient, services.RechargeReward) + registerFirstRechargeRewardRoutes(engine, javaClient, services.FirstRechargeReward) registerBinanceRechargeRoutes(engine, javaClient, services.BinanceRecharge) registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward) registerSignInRewardRoutes(engine, javaClient, services.SignInReward) diff --git a/internal/service/firstrechargereward/config.go b/internal/service/firstrechargereward/config.go new file mode 100644 index 0000000..c526b39 --- /dev/null +++ b/internal/service/firstrechargereward/config.go @@ -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 +} diff --git a/internal/service/firstrechargereward/event.go b/internal/service/firstrechargereward/event.go new file mode 100644 index 0000000..b64f928 --- /dev/null +++ b/internal/service/firstrechargereward/event.go @@ -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] +} diff --git a/internal/service/firstrechargereward/home.go b/internal/service/firstrechargereward/home.go new file mode 100644 index 0000000..a748044 --- /dev/null +++ b/internal/service/firstrechargereward/home.go @@ -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 +} diff --git a/internal/service/firstrechargereward/lifecycle.go b/internal/service/firstrechargereward/lifecycle.go new file mode 100644 index 0000000..c0c5e16 --- /dev/null +++ b/internal/service/firstrechargereward/lifecycle.go @@ -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 +} diff --git a/internal/service/firstrechargereward/mapper.go b/internal/service/firstrechargereward/mapper.go new file mode 100644 index 0000000..4140465 --- /dev/null +++ b/internal/service/firstrechargereward/mapper.go @@ -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) +} diff --git a/internal/service/firstrechargereward/money.go b/internal/service/firstrechargereward/money.go new file mode 100644 index 0000000..f35dd04 --- /dev/null +++ b/internal/service/firstrechargereward/money.go @@ -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) +} diff --git a/internal/service/firstrechargereward/records.go b/internal/service/firstrechargereward/records.go new file mode 100644 index 0000000..665bfae --- /dev/null +++ b/internal/service/firstrechargereward/records.go @@ -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), + } +} diff --git a/internal/service/firstrechargereward/service_test.go b/internal/service/firstrechargereward/service_test.go new file mode 100644 index 0000000..09df3bf --- /dev/null +++ b/internal/service/firstrechargereward/service_test.go @@ -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 +} diff --git a/internal/service/firstrechargereward/types.go b/internal/service/firstrechargereward/types.go new file mode 100644 index 0000000..e32e521 --- /dev/null +++ b/internal/service/firstrechargereward/types.go @@ -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 +} diff --git a/migrations/049_first_recharge_reward.sql b/migrations/049_first_recharge_reward.sql new file mode 100644 index 0000000..f332df5 --- /dev/null +++ b/migrations/049_first_recharge_reward.sql @@ -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='首冲奖励发放记录';