From c16c6a7bd9af0804b4ede706baad50139c0d686c Mon Sep 17 00:00:00 2001 From: hy001 Date: Wed, 29 Apr 2026 18:45:52 +0800 Subject: [PATCH] vip --- cmd/api/main.go | 23 +- internal/config/config.go | 19 + internal/integration/gateways.go | 27 + internal/integration/java.go | 60 +- internal/model/recharge_reward_models.go | 31 + internal/model/vip_models.go | 91 +++ internal/router/recharge_reward_routes.go | 53 ++ internal/router/router.go | 6 + internal/router/vip_routes.go | 132 +++++ internal/service/rechargereward/config.go | 271 +++++++++ .../service/rechargereward/config_test.go | 218 +++++++ internal/service/rechargereward/home.go | 143 +++++ internal/service/rechargereward/mapper.go | 183 ++++++ internal/service/rechargereward/money.go | 75 +++ internal/service/rechargereward/types.go | 300 ++++++++++ internal/service/vip/admin.go | 166 ++++++ internal/service/vip/app.go | 547 ++++++++++++++++++ internal/service/vip/config.go | 199 +++++++ internal/service/vip/purchase_test.go | 136 +++++ internal/service/vip/types.go | 398 +++++++++++++ migrations/019_vip_system.sql | 86 +++ migrations/020_recharge_reward.sql | 29 + 22 files changed, 3183 insertions(+), 10 deletions(-) create mode 100644 internal/model/recharge_reward_models.go create mode 100644 internal/model/vip_models.go create mode 100644 internal/router/recharge_reward_routes.go create mode 100644 internal/router/vip_routes.go create mode 100644 internal/service/rechargereward/config.go create mode 100644 internal/service/rechargereward/config_test.go create mode 100644 internal/service/rechargereward/home.go create mode 100644 internal/service/rechargereward/mapper.go create mode 100644 internal/service/rechargereward/money.go create mode 100644 internal/service/rechargereward/types.go create mode 100644 internal/service/vip/admin.go create mode 100644 internal/service/vip/app.go create mode 100644 internal/service/vip/config.go create mode 100644 internal/service/vip/purchase_test.go create mode 100644 internal/service/vip/types.go create mode 100644 migrations/019_vip_system.sql create mode 100644 migrations/020_recharge_reward.sql diff --git a/cmd/api/main.go b/cmd/api/main.go index 52b1339..a0bd2c3 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -1,6 +1,15 @@ package main import ( + "context" + "errors" + "log" + "net/http" + "os/signal" + "syscall" + "time" + _ "time/tzdata" + "chatapp3-golang/internal/bootstrap" "chatapp3-golang/internal/router" "chatapp3-golang/internal/service/baishun" @@ -10,18 +19,12 @@ import ( "chatapp3-golang/internal/service/invite" "chatapp3-golang/internal/service/lingxian" "chatapp3-golang/internal/service/luckygift" + "chatapp3-golang/internal/service/rechargereward" "chatapp3-golang/internal/service/registerreward" "chatapp3-golang/internal/service/roomturnoverreward" "chatapp3-golang/internal/service/signinreward" + "chatapp3-golang/internal/service/vip" "chatapp3-golang/internal/service/weekstar" - "context" - "errors" - "log" - "net/http" - "os/signal" - "syscall" - "time" - _ "time/tzdata" ) func main() { @@ -39,7 +42,9 @@ func main() { gameOpenService := gameopen.NewGameOpenService(app.Config, app.Repository.DB, &app.Gateways) luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet) registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) + rechargeRewardService := rechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways) signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) + vipService := vip.NewService(app.Config, app.Repository.DB, &app.Gateways) weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways) hostCenterService := hostcenter.NewService(app.Config, &app.Gateways) @@ -62,9 +67,11 @@ func main() { GameProviders: gameProviders, LuckyGift: luckyGiftService, HostCenter: hostCenterService, + RechargeReward: rechargeRewardService, RegisterReward: registerRewardService, RoomTurnoverReward: roomTurnoverRewardService, SignInReward: signInRewardService, + VIP: vipService, WeekStar: weekStarService, }) server := &http.Server{ diff --git a/internal/config/config.go b/internal/config/config.go index a0f8fb8..81125d3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,7 @@ type Config struct { Java JavaConfig Worker WorkerConfig Invite InviteConfig + RechargeReward RechargeRewardConfig WeekStar WeekStarConfig RoomTurnoverReward RoomTurnoverRewardConfig Baishun BaishunConfig @@ -48,6 +49,7 @@ type JavaConfig struct { BridgeBaseURL string ExternalBaseURL string OtherBaseURL string + OrderBaseURL string WalletBaseURL string } @@ -72,6 +74,12 @@ type InviteConfig struct { PublicH5Path string } +// RechargeRewardConfig 保存充值奖励活动配置。 +type RechargeRewardConfig struct { + DefaultSysOrigin string + Timezone string +} + // WeekStarConfig 保存周榜模块配置。 type WeekStarConfig struct { DefaultSysOrigin string @@ -170,6 +178,7 @@ func Load() Config { BridgeBaseURL: getEnvAny([]string{"CHATAPP_JAVA_BRIDGE_BASE_URL", "INVITE_JAVA_BRIDGE_BASE_URL"}, consoleBaseURL), ExternalBaseURL: getEnvAny([]string{"CHATAPP_JAVA_EXTERNAL_BASE_URL", "INVITE_JAVA_EXTERNAL_BASE_URL"}, "http://127.0.0.1:3000"), OtherBaseURL: getEnvAny([]string{"CHATAPP_JAVA_OTHER_BASE_URL", "GAME_JAVA_OTHER_BASE_URL", "INVITE_JAVA_APP_BASE_URL"}, javaAppBaseURL), + OrderBaseURL: getEnvAny([]string{"CHATAPP_JAVA_ORDER_BASE_URL", "GAME_JAVA_ORDER_BASE_URL"}, "http://127.0.0.1:2600"), WalletBaseURL: getEnvAny([]string{"CHATAPP_JAVA_WALLET_BASE_URL", "GAME_JAVA_WALLET_BASE_URL"}, "http://127.0.0.1:2300"), }, Worker: WorkerConfig{ @@ -186,6 +195,16 @@ func Load() Config { PublicBaseURL: getEnvAny([]string{"CHATAPP_INVITE_PUBLIC_BASE_URL", "INVITE_PUBLIC_BASE_URL"}, "http://localhost:2900"), PublicH5Path: getEnvAny([]string{"CHATAPP_INVITE_PUBLIC_H5_PATH", "INVITE_PUBLIC_H5_PATH"}, "/h5/app-invite/landing.html"), }, + RechargeReward: RechargeRewardConfig{ + DefaultSysOrigin: strings.ToUpper(getEnvAny( + []string{"CHATAPP_RECHARGE_REWARD_DEFAULT_SYS_ORIGIN", "RECHARGE_REWARD_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"}, + "LIKEI", + )), + Timezone: getEnvAny( + []string{"CHATAPP_RECHARGE_REWARD_TIMEZONE", "RECHARGE_REWARD_TIMEZONE", "CHATAPP_WEEK_STAR_TIMEZONE", "WEEK_STAR_TIMEZONE"}, + "Asia/Riyadh", + ), + }, WeekStar: WeekStarConfig{ DefaultSysOrigin: strings.ToUpper(getEnvAny([]string{"CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"}, "LIKEI")), Timezone: getEnvAny([]string{"CHATAPP_WEEK_STAR_TIMEZONE", "WEEK_STAR_TIMEZONE"}, "Asia/Riyadh"), diff --git a/internal/integration/gateways.go b/internal/integration/gateways.go index 81299a1..061b804 100644 --- a/internal/integration/gateways.go +++ b/internal/integration/gateways.go @@ -12,6 +12,7 @@ type Gateways struct { Device *DeviceGateway InviteCode *InviteCodeGateway Notice *NoticeGateway + Order *OrderGateway RewardDispatch *RewardDispatchGateway RewardQuery *RewardQueryGateway Profile *ProfileGateway @@ -28,6 +29,7 @@ func NewGateways(cfg config.Config) Gateways { Device: &DeviceGateway{client: client}, InviteCode: &InviteCodeGateway{client: client}, Notice: &NoticeGateway{client: client}, + Order: &OrderGateway{client: client}, RewardDispatch: &RewardDispatchGateway{client: client}, RewardQuery: &RewardQueryGateway{client: client}, Profile: &ProfileGateway{client: client}, @@ -67,6 +69,16 @@ func (g *Gateways) SendOfficialNoticeCustomize(ctx context.Context, req Official return g.Notice.SendOfficialNoticeCustomize(ctx, req) } +// MapUserTotalRecharge 透传到订单充值统计网关。 +func (g *Gateways) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]UserTotalRecharge, error) { + return g.Order.MapUserTotalRecharge(ctx, userIDs) +} + +// GetThisMonthTotalPersonalRecharge 透传到订单充值统计网关。 +func (g *Gateways) GetThisMonthTotalPersonalRecharge(ctx context.Context, userID int64) (DecimalString, error) { + return g.Order.GetThisMonthTotalPersonalRecharge(ctx, userID) +} + // GrantGold 透传到奖励发放网关。 func (g *Gateways) GrantGold(ctx context.Context, req GrantGoldRequest) error { return g.RewardDispatch.GrantGold(ctx, req) @@ -202,6 +214,21 @@ func (g *NoticeGateway) SendOfficialNoticeCustomize(ctx context.Context, req Off return g.client.SendOfficialNoticeCustomize(ctx, req) } +// OrderGateway 负责订单充值统计接口。 +type OrderGateway struct { + client *Client +} + +// MapUserTotalRecharge 查询用户累计充值分组。 +func (g *OrderGateway) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]UserTotalRecharge, error) { + return g.client.MapUserTotalRecharge(ctx, userIDs) +} + +// GetThisMonthTotalPersonalRecharge 查询用户本月个人充值。 +func (g *OrderGateway) GetThisMonthTotalPersonalRecharge(ctx context.Context, userID int64) (DecimalString, error) { + return g.client.GetThisMonthTotalPersonalRecharge(ctx, userID) +} + // RewardDispatchGateway 负责活动奖励发放接口。 type RewardDispatchGateway struct { client *Client diff --git a/internal/integration/java.go b/internal/integration/java.go index 08ef7ba..8c0a19f 100644 --- a/internal/integration/java.go +++ b/internal/integration/java.go @@ -192,6 +192,15 @@ type RewardGroupDetail struct { RewardConfigList []RewardGroupItem `json:"rewardConfigList"` } +type UserTotalRecharge struct { + ID Int64Value `json:"id"` + UserID Int64Value `json:"userId"` + Type string `json:"type"` + Amount DecimalString `json:"amount"` + CreateTime string `json:"createTime"` + UpdateTime string `json:"updateTime"` +} + type resultResponse[T any] struct { Code int `json:"code"` Message string `json:"message"` @@ -326,8 +335,7 @@ func (c *Client) AuthenticateConsoleToken(ctx context.Context, authorization str } endpoint := strings.TrimRight(c.cfg.Java.ConsoleBaseURL, "/") + "/account/info" - headers := http.Header{} - headers.Set("Authorization", authorization) + headers := javaConsoleHeaders(authorization) var resp resultResponse[ConsoleAccount] if err := c.getJSON(ctx, endpoint, headers, &resp); err != nil { @@ -339,6 +347,18 @@ func (c *Client) AuthenticateConsoleToken(ctx context.Context, authorization str return resp.Body, nil } +func javaConsoleHeaders(authorization string) http.Header { + headers := http.Header{} + headers.Set("Authorization", strings.TrimSpace(authorization)) + headers.Set("Req-Sys-Origin", "origin=ops;originChild=ops;") + headers.Set("Req-Client", "Ops") + headers.Set("Req-App-Intel", "version=1.0.0;build=1;channel=ops;") + headers.Set("Req-Version", "v2") + headers.Set("Req-Lang", "zh-CN") + headers.Set("Req-Zone", "Asia/Shanghai") + return headers +} + func parseUserCredentialToken(token string) (UserCredential, error) { token = strings.TrimSpace(token) if token == "" { @@ -483,6 +503,42 @@ func (c *Client) GetRewardGroupDetail(ctx context.Context, groupID int64) (Rewar return resp.Body, nil } +func (c *Client) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]UserTotalRecharge, error) { + if len(userIDs) == 0 { + return map[int64][]UserTotalRecharge{}, nil + } + endpoint := c.cfg.Java.OrderBaseURL + "/total/recharge/client/mapGroup" + var resp resultResponse[map[string][]UserTotalRecharge] + if err := c.postJSON(ctx, endpoint, userIDs, nil, &resp); err != nil { + return nil, err + } + result := make(map[int64][]UserTotalRecharge, len(resp.Body)) + for key, value := range resp.Body { + userID, err := strconv.ParseInt(strings.TrimSpace(key), 10, 64) + if err != nil { + continue + } + result[userID] = value + } + return result, nil +} + +func (c *Client) GetThisMonthTotalPersonalRecharge(ctx context.Context, userID int64) (DecimalString, error) { + if userID <= 0 { + return DecimalString("0"), nil + } + endpoint := c.cfg.Java.OrderBaseURL + "/user-recharge-count/client/getThisMonthTotalPersonalRecharge?userId=" + + url.QueryEscape(strconv.FormatInt(userID, 10)) + var resp resultResponse[DecimalString] + if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { + return "", err + } + if strings.TrimSpace(string(resp.Body)) == "" { + return DecimalString("0"), nil + } + return resp.Body, nil +} + func (c *Client) GetUserProfile(ctx context.Context, userID int64) (UserProfile, error) { endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getByUserId?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10)) var resp resultResponse[UserProfile] diff --git a/internal/model/recharge_reward_models.go b/internal/model/recharge_reward_models.go new file mode 100644 index 0000000..430427b --- /dev/null +++ b/internal/model/recharge_reward_models.go @@ -0,0 +1,31 @@ +package model + +import "time" + +// RechargeRewardConfig 保存充值奖励活动的系统级配置。 +type RechargeRewardConfig struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_recharge_reward_sys_origin"` + Enabled bool `gorm:"column:enabled;index:idx_recharge_reward_enabled"` + Timezone string `gorm:"column:timezone;size:64"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (RechargeRewardConfig) TableName() string { return "recharge_reward_config" } + +// RechargeRewardLevel 保存达到某个充值门槛后展示的奖励配置。 +type RechargeRewardLevel struct { + ID int64 `gorm:"column:id;primaryKey"` + ConfigID int64 `gorm:"column:config_id;uniqueIndex:uk_recharge_reward_level,priority:1;uniqueIndex:uk_recharge_reward_amount,priority:1;index:idx_recharge_reward_level_config"` + Level int `gorm:"column:level;uniqueIndex:uk_recharge_reward_level,priority:2"` + RechargeAmountCents int64 `gorm:"column:recharge_amount_cents;uniqueIndex:uk_recharge_reward_amount,priority:2"` + RewardGold int64 `gorm:"column:reward_gold"` + 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 (RechargeRewardLevel) TableName() string { return "recharge_reward_level" } diff --git a/internal/model/vip_models.go b/internal/model/vip_models.go new file mode 100644 index 0000000..92a21d5 --- /dev/null +++ b/internal/model/vip_models.go @@ -0,0 +1,91 @@ +package model + +import "time" + +// VipLevelConfig stores the operator-managed 30-day VIP level configuration. +type VipLevelConfig struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_vip_level_config,priority:1;index:idx_vip_level_config_sys_origin,priority:1"` + Level int `gorm:"column:level;uniqueIndex:uk_vip_level_config,priority:2"` + LevelCode string `gorm:"column:level_code;size:16"` + DisplayName string `gorm:"column:display_name;size:64"` + Enabled bool `gorm:"column:enabled"` + DurationDays int `gorm:"column:duration_days"` + PriceGold int64 `gorm:"column:price_gold"` + BadgeResourceID int64 `gorm:"column:badge_resource_id"` + BadgeName string `gorm:"column:badge_name;size:128"` + BadgeURL string `gorm:"column:badge_url;size:512"` + AvatarFrameResourceID int64 `gorm:"column:avatar_frame_resource_id"` + AvatarFrameName string `gorm:"column:avatar_frame_name;size:128"` + AvatarFrameURL string `gorm:"column:avatar_frame_url;size:512"` + EntryEffectResourceID int64 `gorm:"column:entry_effect_resource_id"` + EntryEffectName string `gorm:"column:entry_effect_name;size:128"` + EntryEffectURL string `gorm:"column:entry_effect_url;size:512"` + ChatBubbleResourceID int64 `gorm:"column:chat_bubble_resource_id"` + ChatBubbleName string `gorm:"column:chat_bubble_name;size:128"` + ChatBubbleURL string `gorm:"column:chat_bubble_url;size:512"` + FloatPictureResourceID int64 `gorm:"column:float_picture_resource_id"` + FloatPictureName string `gorm:"column:float_picture_name;size:128"` + FloatPictureURL string `gorm:"column:float_picture_url;size:512"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +// TableName returns the VIP level config table name. +func (VipLevelConfig) TableName() string { return "vip_level_config" } + +// UserVipState stores the single active VIP state for a user. +type UserVipState struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_user_vip_state,priority:1;index:idx_user_vip_state_lookup,priority:1"` + UserID int64 `gorm:"column:user_id;uniqueIndex:uk_user_vip_state,priority:2;index:idx_user_vip_state_lookup,priority:2"` + Level int `gorm:"column:level"` + LevelCode string `gorm:"column:level_code;size:16"` + DisplayName string `gorm:"column:display_name;size:64"` + Status string `gorm:"column:status;size:32;index:idx_user_vip_state_lookup,priority:3"` + StartAt time.Time `gorm:"column:start_at"` + ExpireAt time.Time `gorm:"column:expire_at;index:idx_user_vip_expire"` + BadgeResourceID int64 `gorm:"column:badge_resource_id"` + BadgeName string `gorm:"column:badge_name;size:128"` + BadgeURL string `gorm:"column:badge_url;size:512"` + AvatarFrameResourceID int64 `gorm:"column:avatar_frame_resource_id"` + AvatarFrameName string `gorm:"column:avatar_frame_name;size:128"` + AvatarFrameURL string `gorm:"column:avatar_frame_url;size:512"` + EntryEffectResourceID int64 `gorm:"column:entry_effect_resource_id"` + EntryEffectName string `gorm:"column:entry_effect_name;size:128"` + EntryEffectURL string `gorm:"column:entry_effect_url;size:512"` + ChatBubbleResourceID int64 `gorm:"column:chat_bubble_resource_id"` + ChatBubbleName string `gorm:"column:chat_bubble_name;size:128"` + ChatBubbleURL string `gorm:"column:chat_bubble_url;size:512"` + FloatPictureResourceID int64 `gorm:"column:float_picture_resource_id"` + FloatPictureName string `gorm:"column:float_picture_name;size:128"` + FloatPictureURL string `gorm:"column:float_picture_url;size:512"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +// TableName returns the user VIP state table name. +func (UserVipState) TableName() string { return "user_vip_state" } + +// VipOrderRecord stores VIP purchase, renew, and upgrade records. +type VipOrderRecord struct { + ID int64 `gorm:"column:id;primaryKey"` + EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_vip_order_event"` + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_vip_order_user_time,priority:1"` + UserID int64 `gorm:"column:user_id;index:idx_vip_order_user_time,priority:2"` + OrderType string `gorm:"column:order_type;size:32"` + FromLevel int `gorm:"column:from_level"` + ToLevel int `gorm:"column:to_level"` + DurationDays int `gorm:"column:duration_days"` + OriginalPriceGold int64 `gorm:"column:original_price_gold"` + PaidGold int64 `gorm:"column:paid_gold"` + Status string `gorm:"column:status;size:32;index:idx_vip_order_status"` + StartAt time.Time `gorm:"column:start_at"` + ExpireAt time.Time `gorm:"column:expire_at"` + ErrorMessage string `gorm:"column:error_message;size:512"` + CreateTime time.Time `gorm:"column:create_time;index:idx_vip_order_user_time,priority:3"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +// TableName returns the VIP order table name. +func (VipOrderRecord) TableName() string { return "vip_order_record" } diff --git a/internal/router/recharge_reward_routes.go b/internal/router/recharge_reward_routes.go new file mode 100644 index 0000000..965982d --- /dev/null +++ b/internal/router/recharge_reward_routes.go @@ -0,0 +1,53 @@ +package router + +import ( + "net/http" + + "chatapp3-golang/internal/service/rechargereward" + + "github.com/gin-gonic/gin" +) + +// registerRechargeRewardRoutes 注册充值奖励 H5 和后台配置路由。 +func registerRechargeRewardRoutes(engine *gin.Engine, javaClient authGateway, service *rechargereward.Service) { + if service == nil { + return + } + + appGroup := engine.Group("/app/h5/recharge-reward") + appGroup.Use(authMiddleware(javaClient)) + appGroup.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) + }) + + group := engine.Group("/resident-activity/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 rechargereward.SaveRechargeRewardConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, rechargereward.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) + }) +} diff --git a/internal/router/router.go b/internal/router/router.go index a638f7b..046186a 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -13,9 +13,11 @@ import ( "chatapp3-golang/internal/service/invite" "chatapp3-golang/internal/service/lingxian" "chatapp3-golang/internal/service/luckygift" + "chatapp3-golang/internal/service/rechargereward" "chatapp3-golang/internal/service/registerreward" "chatapp3-golang/internal/service/roomturnoverreward" "chatapp3-golang/internal/service/signinreward" + "chatapp3-golang/internal/service/vip" "chatapp3-golang/internal/service/weekstar" "github.com/gin-gonic/gin" @@ -30,9 +32,11 @@ type Services struct { GameProviders *gameprovider.Registry LuckyGift *luckygift.LuckyGiftService HostCenter *hostcenter.Service + RechargeReward *rechargereward.Service RegisterReward *registerreward.RegisterRewardService RoomTurnoverReward *roomturnoverreward.Service SignInReward *signinreward.SignInRewardService + VIP *vip.Service WeekStar *weekstar.WeekStarService } @@ -48,8 +52,10 @@ func NewRouter( registerHealthRoutes(engine, cfg, repository) registerInviteRoutes(engine, javaClient, services.Invite) + registerRechargeRewardRoutes(engine, javaClient, services.RechargeReward) registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward) registerSignInRewardRoutes(engine, javaClient, services.SignInReward) + registerVipRoutes(engine, javaClient, services.VIP) registerWeekStarRoutes(engine, javaClient, services.WeekStar) registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward) registerHostCenterRoutes(engine, services.HostCenter) diff --git a/internal/router/vip_routes.go b/internal/router/vip_routes.go new file mode 100644 index 0000000..b9b21d1 --- /dev/null +++ b/internal/router/vip_routes.go @@ -0,0 +1,132 @@ +package router + +import ( + "net/http" + "strconv" + "strings" + + "chatapp3-golang/internal/service/vip" + + "github.com/gin-gonic/gin" +) + +// registerVipRoutes registers app and admin VIP APIs. +func registerVipRoutes(engine *gin.Engine, javaClient authGateway, vipService *vip.Service) { + if vipService == nil { + return + } + + appGroup := engine.Group("/app/vip") + appGroup.Use(authMiddleware(javaClient)) + appGroup.GET("/home", func(c *gin.Context) { + resp, err := vipService.GetHome(c.Request.Context(), mustAuthUser(c)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.GET("/status", func(c *gin.Context) { + resp, err := vipService.GetStatus(c.Request.Context(), mustAuthUser(c)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.POST("/preview", func(c *gin.Context) { + var req vip.PurchaseRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, vip.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := vipService.PreviewPurchase(c.Request.Context(), mustAuthUser(c), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.POST("/purchase", func(c *gin.Context) { + var req vip.PurchaseRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, vip.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := vipService.Purchase(c.Request.Context(), mustAuthUser(c), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.GET("/orders", func(c *gin.Context) { + cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1"))) + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) + resp, err := vipService.PageUserOrders(c.Request.Context(), mustAuthUser(c), cursor, limit) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + residentGroup := engine.Group("/resident-activity/vip") + residentGroup.Use(consoleAuthMiddleware(javaClient)) + residentGroup.GET("/config", func(c *gin.Context) { + resp, err := vipService.GetConfig(c.Request.Context(), c.Query("sysOrigin")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + residentGroup.POST("/config/save", func(c *gin.Context) { + var req vip.SaveConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, vip.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := vipService.SaveConfig(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + residentGroup.GET("/order/page", func(c *gin.Context) { + cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1"))) + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) + resp, err := vipService.PageOrders( + c.Request.Context(), + c.Query("sysOrigin"), + vip.ParseUserID(c.Query("userId")), + c.Query("status"), + c.Query("orderType"), + cursor, + limit, + ) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + residentGroup.GET("/user-state/page", func(c *gin.Context) { + cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1"))) + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) + resp, err := vipService.PageUserStates( + c.Request.Context(), + c.Query("sysOrigin"), + vip.ParseUserID(c.Query("userId")), + c.Query("status"), + cursor, + limit, + ) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) +} diff --git a/internal/service/rechargereward/config.go b/internal/service/rechargereward/config.go new file mode 100644 index 0000000..93be59d --- /dev/null +++ b/internal/service/rechargereward/config.go @@ -0,0 +1,271 @@ +package rechargereward + +import ( + "context" + "errors" + "net/http" + "sort" + "strings" + "time" + + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + + "gorm.io/gorm" +) + +// GetConfig 返回充值奖励后台配置;未配置时返回空档位草稿。 +func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*RechargeRewardConfigResponse, error) { + sysOrigin = s.normalizeSysOrigin(sysOrigin) + + var configRow model.RechargeRewardConfig + err := s.db.WithContext(ctx). + Where("sys_origin = ?", sysOrigin). + First(&configRow).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return &RechargeRewardConfigResponse{ + Configured: false, + SysOrigin: sysOrigin, + Enabled: false, + Timezone: s.defaultTimezone(), + LevelConfigs: []RechargeRewardLevelPayload{}, + }, 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 &RechargeRewardConfigResponse{ + Configured: true, + ID: configRow.ID, + SysOrigin: configRow.SysOrigin, + Enabled: configRow.Enabled, + Timezone: normalizeRechargeRewardTimezone(configRow.Timezone), + UpdateTime: formatDateTime(configRow.UpdateTime), + LevelConfigs: buildAdminLevelPayloads(levels, rewardItems), + }, nil +} + +// SaveConfig 保存充值奖励后台配置;同一系统只保留一份当前配置。 +func (s *Service) SaveConfig(ctx context.Context, req SaveRechargeRewardConfigRequest) (*RechargeRewardConfigResponse, error) { + requestID := req.ID.Int64() + sysOrigin := s.normalizeSysOrigin(req.SysOrigin) + timezone := normalizeRechargeRewardTimezone(req.Timezone) + if _, err := time.LoadLocation(timezone); err != nil { + return nil, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error()) + } + + 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.RechargeRewardConfig + if requestID > 0 { + err := tx.Where("id = ?", requestID).First(&configRow).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return NewAppError(http.StatusNotFound, "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.RechargeRewardConfig{ + ID: configID, + SysOrigin: sysOrigin, + CreateTime: now, + } + } else if err != nil { + return err + } + } + + configRow.SysOrigin = sysOrigin + configRow.Enabled = req.Enabled + configRow.Timezone = timezone + 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.RechargeRewardLevel{}).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, rechargeRewardSysOriginBySavedID(ctx, s.db, savedConfigID, sysOrigin)) +} + +func (s *Service) normalizeLevelInputs(inputs []RechargeRewardLevelInput) ([]model.RechargeRewardLevel, 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.RechargeRewardLevel, 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.Int64Ptr() + if item.RewardGold <= 0 && (rewardGroupID == nil || *rewardGroupID <= 0) { + return nil, NewAppError(http.StatusBadRequest, "invalid_reward", "rewardGold or 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.RechargeRewardLevel{ + ID: id, + Level: item.Level, + RechargeAmountCents: amountCents, + RewardGold: item.RewardGold, + RewardGroupID: normalizeRewardGroupID(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) (*rechargeRewardConfigBundle, error) { + sysOrigin = s.normalizeSysOrigin(sysOrigin) + + var configRow model.RechargeRewardConfig + err := s.db.WithContext(ctx). + Where("sys_origin = ?", sysOrigin). + First(&configRow).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return &rechargeRewardConfigBundle{}, nil + } + if err != nil { + return nil, err + } + levels, err := s.loadLevels(ctx, configRow.ID) + if err != nil { + return nil, err + } + return &rechargeRewardConfigBundle{ + Config: refConfigSnapshot(configSnapshotFromModel(configRow)), + Levels: levels, + }, nil +} + +func (s *Service) loadLevels(ctx context.Context, configID int64) ([]rechargeRewardLevelSnapshot, error) { + var rows []model.RechargeRewardLevel + if configID <= 0 { + return []rechargeRewardLevelSnapshot{}, nil + } + 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([]rechargeRewardLevelSnapshot, 0, len(rows)) + for _, row := range rows { + levels = append(levels, levelSnapshotFromModel(row)) + } + sortLevels(levels) + return levels, nil +} + +func (s *Service) defaultTimezone() string { + timezone := strings.TrimSpace(s.cfg.RechargeReward.Timezone) + if timezone == "" { + timezone = defaultRechargeRewardTimezone + } + return timezone +} + +func hasEnabledLevel(levels []model.RechargeRewardLevel) bool { + for _, level := range levels { + if level.Enabled { + return true + } + } + return false +} + +func normalizeRewardGroupID(id *int64) *int64 { + if id == nil || *id <= 0 { + return nil + } + return id +} + +func refConfigSnapshot(value rechargeRewardConfigSnapshot) *rechargeRewardConfigSnapshot { + return &value +} + +func rechargeRewardSysOriginBySavedID(ctx context.Context, db rechargeRewardDB, configID int64, fallback string) string { + if configID <= 0 { + return fallback + } + var row model.RechargeRewardConfig + if err := db.WithContext(ctx).Select("sys_origin").Where("id = ?", configID).First(&row).Error; err != nil { + return fallback + } + return row.SysOrigin +} diff --git a/internal/service/rechargereward/config_test.go b/internal/service/rechargereward/config_test.go new file mode 100644 index 0000000..0d3c765 --- /dev/null +++ b/internal/service/rechargereward/config_test.go @@ -0,0 +1,218 @@ +package rechargereward + +import ( + "context" + "fmt" + "testing" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func newTestService(t *testing.T, java rechargeRewardJavaGateway) (*Service, *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.RechargeRewardConfig{}, + &model.RechargeRewardLevel{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + service := NewService(config.Config{ + RechargeReward: config.RechargeRewardConfig{ + DefaultSysOrigin: "LIKEI", + Timezone: "Asia/Riyadh", + }, + }, db, java) + return service, db +} + +func TestGetConfigReturnsEmptyDraftWhenMissing(t *testing.T) { + service, _ := newTestService(t, nil) + + resp, err := service.GetConfig(context.Background(), "") + if err != nil { + t.Fatalf("GetConfig() error = %v", err) + } + if resp.Configured || resp.Enabled { + t.Fatalf("GetConfig() configured/enabled = %v/%v, want false/false", resp.Configured, resp.Enabled) + } + if resp.SysOrigin != "LIKEI" || resp.Timezone != "Asia/Riyadh" { + t.Fatalf("GetConfig() sys/timezone = %q/%q", resp.SysOrigin, resp.Timezone) + } + if len(resp.LevelConfigs) != 0 { + t.Fatalf("default level count = %d, want 0", len(resp.LevelConfigs)) + } +} + +func TestSaveConfigRejectsDuplicateRechargeAmount(t *testing.T) { + service, _ := newTestService(t, nil) + + _, err := service.SaveConfig(context.Background(), SaveRechargeRewardConfigRequest{ + SysOrigin: "LIKEI", + Enabled: true, + Timezone: "Asia/Riyadh", + LevelConfigs: []RechargeRewardLevelInput{ + {Level: 1, RechargeAmount: mustAmount(t, "100"), RewardGold: 1000, Enabled: true}, + {Level: 2, RechargeAmount: mustAmount(t, "100.00"), RewardGold: 2000, Enabled: true}, + }, + }) + if err == nil { + t.Fatalf("SaveConfig() error = nil, want duplicate amount error") + } +} + +func TestSaveConfigUpsertsBySysOrigin(t *testing.T) { + service, _ := newTestService(t, nil) + ctx := context.Background() + + first, err := service.SaveConfig(ctx, SaveRechargeRewardConfigRequest{ + SysOrigin: "likei", + Enabled: true, + Timezone: "Asia/Riyadh", + LevelConfigs: []RechargeRewardLevelInput{ + {Level: 1, RechargeAmount: mustAmount(t, "100"), RewardGold: 1000, Enabled: true}, + }, + }) + if err != nil { + t.Fatalf("first SaveConfig() error = %v", err) + } + second, err := service.SaveConfig(ctx, SaveRechargeRewardConfigRequest{ + SysOrigin: "LIKEI", + Enabled: true, + Timezone: "Asia/Riyadh", + LevelConfigs: []RechargeRewardLevelInput{ + {Level: 1, RechargeAmount: mustAmount(t, "200.50"), RewardGold: 3000, Enabled: true}, + }, + }) + if err != nil { + t.Fatalf("second SaveConfig() error = %v", err) + } + if first.ID != second.ID { + t.Fatalf("config ID changed on upsert: first=%d second=%d", first.ID, second.ID) + } + if got := second.LevelConfigs[0]; got.RechargeAmount != "200.50" || got.RewardGold != 3000 { + t.Fatalf("upserted level = %+v", got) + } +} + +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")}, + {UserID: integration.Int64Value(42), Type: "APPLE", Amount: integration.DecimalString("998.00")}, + }, + }, + profile: integration.UserProfile{ + ID: integration.Int64Value(42), + Account: "12345678", + UserNickname: "nanfangjian", + UserAvatar: "https://example.com/avatar.png", + }, + groups: map[int64]integration.RewardGroupDetail{ + groupID: { + ID: integration.Int64Value(groupID), + Name: "Gift Box", + RewardConfigList: []integration.RewardGroupItem{ + {ID: integration.Int64Value(1), Type: "PROP", Name: "Frame", Quantity: integration.Int64Value(1), Cover: "https://example.com/frame.png"}, + }, + }, + }, + } + service, _ := newTestService(t, java) + if _, err := service.SaveConfig(context.Background(), SaveRechargeRewardConfigRequest{ + SysOrigin: "LIKEI", + Enabled: true, + Timezone: "Asia/Riyadh", + LevelConfigs: []RechargeRewardLevelInput{ + {Level: 1, RechargeAmount: mustAmount(t, "100"), RewardGroupID: ptrFlexibleInt64(groupID), Enabled: true}, + {Level: 2, RechargeAmount: mustAmount(t, "200"), RewardGold: 3000, Enabled: true}, + }, + }); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + resp, err := service.GetHome(context.Background(), AuthUser{UserID: 42, SysOrigin: "LIKEI"}) + if err != nil { + t.Fatalf("GetHome() error = %v", err) + } + if resp.CurrentRechargeAmount != "150.50" || resp.AccumulatedRechargeAmount != "5998.00" { + t.Fatalf("amounts = current %q accumulated %q", resp.CurrentRechargeAmount, resp.AccumulatedRechargeAmount) + } + if resp.MatchedLevel != 1 || resp.NextLevel != 2 { + t.Fatalf("levels = matched %d next %d", resp.MatchedLevel, resp.NextLevel) + } + if len(resp.LevelConfigs) != 2 || !resp.LevelConfigs[0].Reached || resp.LevelConfigs[1].Reached { + t.Fatalf("level reach state = %+v", resp.LevelConfigs) + } + if len(resp.LevelConfigs[0].RewardItems) != 1 || resp.LevelConfigs[0].RewardItems[0].Name != "Frame" { + t.Fatalf("reward items = %+v", resp.LevelConfigs[0].RewardItems) + } +} + +func TestParseAmountCents(t *testing.T) { + tests := map[string]int64{ + "100": 10000, + "100.5": 10050, + "100.50": 10050, + "5,998.00": 599800, + } + for input, want := range tests { + got, err := parseAmountCents(input) + if err != nil { + t.Fatalf("parseAmountCents(%q) error = %v", input, err) + } + if got != want { + t.Fatalf("parseAmountCents(%q) = %d, want %d", input, got, want) + } + } +} + +func mustAmount(t *testing.T, raw string) flexibleAmount { + t.Helper() + cents, err := parseAmountCents(raw) + if err != nil { + t.Fatalf("parse amount %q: %v", raw, err) + } + return flexibleAmount(cents) +} + +func ptrFlexibleInt64(value int64) flexibleOptionalInt64 { + return flexibleOptionalInt64{value: &value} +} + +type rechargeRewardTestGateway struct { + monthly integration.DecimalString + total map[int64][]integration.UserTotalRecharge + profile integration.UserProfile + groups map[int64]integration.RewardGroupDetail +} + +func (g rechargeRewardTestGateway) GetUserProfile(context.Context, int64) (integration.UserProfile, error) { + return g.profile, nil +} + +func (g rechargeRewardTestGateway) GetRewardGroupDetail(_ context.Context, groupID int64) (integration.RewardGroupDetail, error) { + if detail, ok := g.groups[groupID]; ok { + return detail, nil + } + return integration.RewardGroupDetail{}, fmt.Errorf("group not found") +} + +func (g rechargeRewardTestGateway) MapUserTotalRecharge(context.Context, []int64) (map[int64][]integration.UserTotalRecharge, error) { + return g.total, nil +} + +func (g rechargeRewardTestGateway) GetThisMonthTotalPersonalRecharge(context.Context, int64) (integration.DecimalString, error) { + return g.monthly, nil +} diff --git a/internal/service/rechargereward/home.go b/internal/service/rechargereward/home.go new file mode 100644 index 0000000..9d987df --- /dev/null +++ b/internal/service/rechargereward/home.go @@ -0,0 +1,143 @@ +package rechargereward + +import ( + "context" + "net/http" + "strings" + "time" +) + +// GetHome 返回当前登录用户的充值奖励 H5 数据。 +func (s *Service) GetHome(ctx context.Context, user AuthUser) (*RechargeRewardHomeResponse, error) { + sysOrigin, err := requireRechargeRewardUser(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 + } + location := s.location + if bundle.Config != nil { + location = resolveLocation(bundle.Config.Timezone, s.location) + } + now := time.Now().In(location) + periodStart, periodEnd := currentMonthBounds(now, location) + + currentRechargeCents, err := s.loadCurrentRechargeCents(ctx, user.UserID) + if err != nil { + return nil, err + } + accumulatedRechargeCents, err := s.loadAccumulatedRechargeCents(ctx, user.UserID) + if err != nil { + return nil, err + } + + resp := &RechargeRewardHomeResponse{ + ActivityStatus: rechargeRewardActivityStatus(bundle.Config != nil && bundle.Config.Enabled), + Configured: bundle.Config != nil, + 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()), + UserID: user.UserID, + CurrentRechargeAmount: formatAmountCents(currentRechargeCents), + CurrentRechargeAmountCents: currentRechargeCents, + AccumulatedRechargeAmount: formatAmountCents(accumulatedRechargeCents), + AccumulatedRechargeAmountCents: accumulatedRechargeCents, + LevelConfigs: []RechargeRewardLevelPayload{}, + } + if bundle.Config != nil { + resp.SysOrigin = bundle.Config.SysOrigin + resp.Timezone = normalizeRechargeRewardTimezone(bundle.Config.Timezone) + } + + if profile, err := s.java.GetUserProfile(ctx, user.UserID); err == nil { + resp.UserAccount = profile.Account + resp.UserNickname = profile.UserNickname + resp.UserAvatar = profile.UserAvatar + } else if !isEmptyJavaProfileError(err) { + return nil, err + } + + if bundle.Config == nil { + return resp, nil + } + matched, matchedOK := pickMatchedLevel(bundle.Levels, currentRechargeCents) + next, nextOK := pickNextLevel(bundle.Levels, currentRechargeCents) + if matchedOK { + resp.MatchedLevel = matched.Level + } + if nextOK { + resp.NextLevel = next.Level + resp.NextRechargeAmount = formatAmountCents(next.RechargeAmountCents) + resp.NextRechargeAmountCents = next.RechargeAmountCents + } + rewardItems := s.loadRewardItemsMap(ctx, bundle.Levels) + matchedLevel := 0 + if matchedOK { + matchedLevel = matched.Level + } + resp.LevelConfigs = buildLevelPayloads(bundle.Levels, rewardItems, currentRechargeCents, matchedLevel) + 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 { + return 0, err + } + 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 { + return location + } + if fallback != nil { + return fallback + } + return time.UTC +} + +func rechargeRewardActivityStatus(enabled bool) string { + if enabled { + return rechargeRewardActivityStatusOngoing + } + return rechargeRewardActivityStatusDisabled +} + +func isEmptyJavaProfileError(err error) bool { + if err == nil { + return false + } + return strings.Contains(strings.ToLower(err.Error()), "empty") +} + +func maxInt64(a, b int64) int64 { + if a > b { + return a + } + return b +} diff --git a/internal/service/rechargereward/mapper.go b/internal/service/rechargereward/mapper.go new file mode 100644 index 0000000..36f7bac --- /dev/null +++ b/internal/service/rechargereward/mapper.go @@ -0,0 +1,183 @@ +package rechargereward + +import ( + "context" + "sort" + "strings" + "time" + + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" +) + +func configSnapshotFromModel(row model.RechargeRewardConfig) rechargeRewardConfigSnapshot { + return rechargeRewardConfigSnapshot{ + ID: row.ID, + SysOrigin: row.SysOrigin, + Enabled: row.Enabled, + Timezone: normalizeRechargeRewardTimezone(row.Timezone), + UpdateTime: row.UpdateTime, + } +} + +func levelSnapshotFromModel(row model.RechargeRewardLevel) rechargeRewardLevelSnapshot { + return rechargeRewardLevelSnapshot{ + ID: row.ID, + Level: row.Level, + RechargeAmountCents: row.RechargeAmountCents, + RewardGold: row.RewardGold, + RewardGroupID: row.RewardGroupID, + RewardGroupName: strings.TrimSpace(row.RewardGroupName), + Enabled: row.Enabled, + } +} + +func buildLevelPayloads( + levels []rechargeRewardLevelSnapshot, + rewardItems map[int64][]RechargeRewardRewardItem, + currentRechargeCents int64, + matchedLevel int, +) []RechargeRewardLevelPayload { + payloads := make([]RechargeRewardLevelPayload, 0, len(levels)) + for _, level := range levels { + if !level.Enabled { + continue + } + var items []RechargeRewardRewardItem + if level.RewardGroupID != nil { + items = rewardItems[*level.RewardGroupID] + } + payloads = append(payloads, RechargeRewardLevelPayload{ + ID: level.ID, + Level: level.Level, + RechargeAmount: formatAmountCents(level.RechargeAmountCents), + RechargeAmountCents: level.RechargeAmountCents, + RewardGold: level.RewardGold, + RewardGroupID: level.RewardGroupID, + RewardGroupName: level.RewardGroupName, + RewardItems: items, + Enabled: level.Enabled, + Reached: currentRechargeCents >= level.RechargeAmountCents, + Current: matchedLevel > 0 && level.Level == matchedLevel, + }) + } + return payloads +} + +func buildAdminLevelPayloads( + levels []rechargeRewardLevelSnapshot, + rewardItems map[int64][]RechargeRewardRewardItem, +) []RechargeRewardLevelPayload { + payloads := make([]RechargeRewardLevelPayload, 0, len(levels)) + for _, level := range levels { + var items []RechargeRewardRewardItem + if level.RewardGroupID != nil { + items = rewardItems[*level.RewardGroupID] + } + payloads = append(payloads, RechargeRewardLevelPayload{ + ID: level.ID, + Level: level.Level, + RechargeAmount: formatAmountCents(level.RechargeAmountCents), + RechargeAmountCents: level.RechargeAmountCents, + RewardGold: level.RewardGold, + RewardGroupID: level.RewardGroupID, + RewardGroupName: level.RewardGroupName, + RewardItems: items, + Enabled: level.Enabled, + }) + } + return payloads +} + +func pickMatchedLevel(levels []rechargeRewardLevelSnapshot, currentRechargeCents int64) (rechargeRewardLevelSnapshot, bool) { + var matched rechargeRewardLevelSnapshot + ok := false + for _, level := range levels { + if !level.Enabled || level.RechargeAmountCents <= 0 { + continue + } + if currentRechargeCents >= level.RechargeAmountCents && (!ok || level.RechargeAmountCents > matched.RechargeAmountCents) { + matched = level + ok = true + } + } + return matched, ok +} + +func pickNextLevel(levels []rechargeRewardLevelSnapshot, currentRechargeCents int64) (rechargeRewardLevelSnapshot, bool) { + for _, level := range levels { + if !level.Enabled || level.RechargeAmountCents <= currentRechargeCents { + continue + } + return level, true + } + return rechargeRewardLevelSnapshot{}, false +} + +func sortLevels(levels []rechargeRewardLevelSnapshot) { + 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 []rechargeRewardLevelSnapshot, +) map[int64][]RechargeRewardRewardItem { + if s.java == nil { + return map[int64][]RechargeRewardRewardItem{} + } + groupIDs := make([]int64, 0) + seen := make(map[int64]struct{}) + for _, level := range levels { + if level.RewardGroupID == nil || *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][]RechargeRewardRewardItem, len(groupIDs)) + for _, groupID := range groupIDs { + detail, err := s.java.GetRewardGroupDetail(ctx, groupID) + if err != nil { + continue + } + items := make([]RechargeRewardRewardItem, 0, len(detail.RewardConfigList)) + for _, item := range detail.RewardConfigList { + items = append(items, RechargeRewardRewardItem{ + ID: int64(item.ID), + Type: strings.TrimSpace(item.Type), + Name: strings.TrimSpace(item.Name), + Content: strings.TrimSpace(item.Content), + Quantity: int64(item.Quantity), + Cover: strings.TrimSpace(item.Cover), + Remark: strings.TrimSpace(item.Remark), + }) + } + result[groupID] = items + } + return result +} + +func totalRechargeCents(entries []integration.UserTotalRecharge) int64 { + var total int64 + for _, entry := range entries { + total += parseIntegrationAmountCents(integrationAmount(entry.Amount)) + } + return total +} + +func formatDateTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format("2006-01-02 15:04:05") +} diff --git a/internal/service/rechargereward/money.go b/internal/service/rechargereward/money.go new file mode 100644 index 0000000..027d84d --- /dev/null +++ b/internal/service/rechargereward/money.go @@ -0,0 +1,75 @@ +package rechargereward + +import ( + "fmt" + "strconv" + "strings" +) + +func parseAmountCents(raw string) (int64, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0, nil + } + raw = strings.ReplaceAll(raw, ",", "") + if strings.HasPrefix(raw, "+") { + raw = strings.TrimPrefix(raw, "+") + } + if strings.HasPrefix(raw, "-") { + return 0, fmt.Errorf("amount must be non-negative") + } + + parts := strings.Split(raw, ".") + if len(parts) > 2 { + return 0, fmt.Errorf("invalid amount") + } + integerPart := strings.TrimSpace(parts[0]) + if integerPart == "" { + integerPart = "0" + } + for _, ch := range integerPart { + if ch < '0' || ch > '9' { + return 0, fmt.Errorf("invalid amount") + } + } + dollars, err := strconv.ParseInt(integerPart, 10, 64) + if err != nil { + return 0, err + } + centsPart := "00" + if len(parts) == 2 { + fraction := strings.TrimSpace(parts[1]) + for _, ch := range fraction { + if ch < '0' || ch > '9' { + return 0, fmt.Errorf("invalid amount") + } + } + if len(fraction) == 1 { + centsPart = fraction + "0" + } else if len(fraction) >= 2 { + centsPart = fraction[:2] + } + } + cents, err := strconv.ParseInt(centsPart, 10, 64) + if err != nil { + return 0, err + } + return dollars*100 + cents, nil +} + +func parseIntegrationAmountCents(value integrationAmount) int64 { + cents, err := parseAmountCents(string(value)) + if err != nil { + return 0 + } + return cents +} + +func formatAmountCents(cents int64) string { + if cents < 0 { + cents = 0 + } + return fmt.Sprintf("%d.%02d", cents/100, cents%100) +} + +type integrationAmount string diff --git a/internal/service/rechargereward/types.go b/internal/service/rechargereward/types.go new file mode 100644 index 0000000..d2aa969 --- /dev/null +++ b/internal/service/rechargereward/types.go @@ -0,0 +1,300 @@ +package rechargereward + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + + "gorm.io/gorm" +) + +const ( + rechargeRewardActivityStatusOngoing = "ONGOING" + rechargeRewardActivityStatusDisabled = "DISABLED" + + defaultRechargeRewardTimezone = "Asia/Riyadh" +) + +type AppError = common.AppError +type AuthUser = common.AuthUser + +var NewAppError = common.NewAppError + +type rechargeRewardDB interface { + WithContext(ctx context.Context) *gorm.DB +} + +type rechargeRewardJavaGateway interface { + GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error) + GetRewardGroupDetail(ctx context.Context, groupID int64) (integration.RewardGroupDetail, error) + MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error) + GetThisMonthTotalPersonalRecharge(ctx context.Context, userID int64) (integration.DecimalString, error) +} + +// Service 负责充值奖励后台配置和 H5 用户态数据。 +type Service struct { + cfg config.Config + db rechargeRewardDB + java rechargeRewardJavaGateway + location *time.Location +} + +func NewService(cfg config.Config, db rechargeRewardDB, java rechargeRewardJavaGateway) *Service { + timezone := strings.TrimSpace(cfg.RechargeReward.Timezone) + if timezone == "" { + timezone = defaultRechargeRewardTimezone + } + location, err := time.LoadLocation(timezone) + if err != nil { + location = time.UTC + } + return &Service{cfg: cfg, db: db, java: java, location: location} +} + +// RechargeRewardLevelPayload 是前后台共用的充值奖励档位读模型。 +type RechargeRewardLevelPayload struct { + ID int64 `json:"id,string"` + Level int `json:"level"` + RechargeAmount string `json:"rechargeAmount"` + RechargeAmountCents int64 `json:"rechargeAmountCents"` + RewardGold int64 `json:"rewardGold"` + RewardGroupID *int64 `json:"rewardGroupId,omitempty"` + RewardGroupName string `json:"rewardGroupName,omitempty"` + RewardItems []RechargeRewardRewardItem `json:"rewardItems"` + Enabled bool `json:"enabled"` + Reached bool `json:"reached"` + Current bool `json:"current"` +} + +// RechargeRewardLevelInput 是后台保存单个充值奖励档位的入参。 +type RechargeRewardLevelInput struct { + ID flexibleInt64 `json:"id"` + Level int `json:"level"` + RechargeAmount flexibleAmount `json:"rechargeAmount"` + RechargeAmountCents int64 `json:"rechargeAmountCents"` + RewardGold int64 `json:"rewardGold"` + RewardGroupID flexibleOptionalInt64 `json:"rewardGroupId"` + RewardGroupName string `json:"rewardGroupName"` + Enabled bool `json:"enabled"` +} + +// RechargeRewardConfigResponse 是后台配置页读模型。 +type RechargeRewardConfigResponse struct { + Configured bool `json:"configured"` + ID int64 `json:"id,string"` + SysOrigin string `json:"sysOrigin"` + Enabled bool `json:"enabled"` + Timezone string `json:"timezone"` + UpdateTime string `json:"updateTime,omitempty"` + LevelConfigs []RechargeRewardLevelPayload `json:"levelConfigs"` +} + +// SaveRechargeRewardConfigRequest 是后台保存充值奖励配置的入参。 +type SaveRechargeRewardConfigRequest struct { + ID flexibleInt64 `json:"id"` + SysOrigin string `json:"sysOrigin"` + Enabled bool `json:"enabled"` + Timezone string `json:"timezone"` + LevelConfigs []RechargeRewardLevelInput `json:"levelConfigs"` +} + +// RechargeRewardHomeResponse 是 H5 充值奖励首页数据。 +type RechargeRewardHomeResponse struct { + ActivityStatus string `json:"activityStatus"` + Configured bool `json:"configured"` + Enabled bool `json:"enabled"` + SysOrigin string `json:"sysOrigin"` + Timezone string `json:"timezone"` + PeriodStartAt string `json:"periodStartAt"` + PeriodEndAt string `json:"periodEndAt"` + CountdownMillis int64 `json:"countdownMillis"` + UserID int64 `json:"userId,string"` + UserAccount string `json:"userAccount,omitempty"` + UserNickname string `json:"userNickname,omitempty"` + UserAvatar string `json:"userAvatar,omitempty"` + CurrentRechargeAmount string `json:"currentRechargeAmount"` + CurrentRechargeAmountCents int64 `json:"currentRechargeAmountCents"` + AccumulatedRechargeAmount string `json:"accumulatedRechargeAmount"` + AccumulatedRechargeAmountCents int64 `json:"accumulatedRechargeAmountCents"` + MatchedLevel int `json:"matchedLevel"` + NextLevel int `json:"nextLevel"` + NextRechargeAmount string `json:"nextRechargeAmount,omitempty"` + NextRechargeAmountCents int64 `json:"nextRechargeAmountCents,omitempty"` + LevelConfigs []RechargeRewardLevelPayload `json:"levelConfigs"` +} + +// RechargeRewardRewardItem 是奖励组内的单个物品展示项。 +type RechargeRewardRewardItem 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 rechargeRewardConfigBundle struct { + Config *rechargeRewardConfigSnapshot + Levels []rechargeRewardLevelSnapshot +} + +type rechargeRewardConfigSnapshot struct { + ID int64 + SysOrigin string + Enabled bool + Timezone string + UpdateTime time.Time +} + +type rechargeRewardLevelSnapshot struct { + ID int64 + Level int + RechargeAmountCents int64 + RewardGold int64 + RewardGroupID *int64 + RewardGroupName string + Enabled bool +} + +type flexibleInt64 int64 + +func (v *flexibleInt64) UnmarshalJSON(data []byte) error { + raw := strings.TrimSpace(string(data)) + switch raw { + case "", "null", `""`: + *v = 0 + return nil + } + if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) { + unquoted, err := strconv.Unquote(raw) + if err != nil { + return err + } + raw = strings.TrimSpace(unquoted) + } + parsed, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return 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 flexibleOptionalInt64 struct { + value *int64 +} + +func (v *flexibleOptionalInt64) UnmarshalJSON(data []byte) error { + raw := strings.TrimSpace(string(data)) + switch raw { + case "", "null", `""`: + v.value = nil + 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.value = nil + return nil + } + } + parsed, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return fmt.Errorf("invalid int64 value: %w", err) + } + v.value = &parsed + return nil +} + +func (v flexibleOptionalInt64) Int64Ptr() *int64 { + return v.value +} + +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) + if raw == "" { + *v = 0 + return nil + } + } + cents, err := parseAmountCents(raw) + if err != nil { + return err + } + *v = flexibleAmount(cents) + return nil +} + +func (v flexibleAmount) Cents() int64 { + return int64(v) +} + +func normalizeRechargeRewardTimezone(timezone string) string { + timezone = strings.TrimSpace(timezone) + if timezone == "" { + return defaultRechargeRewardTimezone + } + return timezone +} + +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.RechargeReward.DefaultSysOrigin)); fallback != "" { + return fallback + } + if fallback := strings.ToUpper(strings.TrimSpace(s.cfg.RoomTurnoverReward.DefaultSysOrigin)); fallback != "" { + return fallback + } + if fallback := strings.ToUpper(strings.TrimSpace(s.cfg.WeekStar.DefaultSysOrigin)); fallback != "" { + return fallback + } + return "LIKEI" +} + +func requireRechargeRewardUser(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/internal/service/vip/admin.go b/internal/service/vip/admin.go new file mode 100644 index 0000000..3c07478 --- /dev/null +++ b/internal/service/vip/admin.go @@ -0,0 +1,166 @@ +package vip + +import ( + "context" + "net/http" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/model" +) + +// PageOrders returns VIP purchase records for the admin page. +func (s *Service) PageOrders( + ctx context.Context, + sysOrigin string, + userID int64, + status string, + orderType string, + cursor int, + limit int, +) (*OrderPageResponse, error) { + sysOrigin = normalizeSysOrigin(sysOrigin) + if sysOrigin == "" { + return nil, NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required") + } + page, size := normalizePage(cursor, limit) + + query := s.db.WithContext(ctx).Model(&model.VipOrderRecord{}).Where("sys_origin = ?", sysOrigin) + if userID > 0 { + query = query.Where("user_id = ?", userID) + } + if normalizedStatus := strings.ToUpper(strings.TrimSpace(status)); normalizedStatus != "" { + query = query.Where("status = ?", normalizedStatus) + } + if normalizedType := strings.ToUpper(strings.TrimSpace(orderType)); normalizedType != "" { + query = query.Where("order_type = ?", normalizedType) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, err + } + + records := make([]VipOrderPayload, 0) + if total > 0 { + var rows []model.VipOrderRecord + if err := query. + Order("create_time desc"). + Order("id desc"). + Offset((page - 1) * size). + Limit(size). + Find(&rows).Error; err != nil { + return nil, err + } + records = make([]VipOrderPayload, 0, len(rows)) + for _, row := range rows { + records = append(records, orderPayloadFromModel(row)) + } + } + + return &OrderPageResponse{Records: records, Total: total}, nil +} + +// PageUserStates returns the current VIP state rows for the admin page. +func (s *Service) PageUserStates( + ctx context.Context, + sysOrigin string, + userID int64, + status string, + cursor int, + limit int, +) (*UserStatePageResponse, error) { + sysOrigin = normalizeSysOrigin(sysOrigin) + if sysOrigin == "" { + return nil, NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required") + } + now := time.Now() + if err := s.expireDueStates(ctx, sysOrigin, now); err != nil { + return nil, err + } + page, size := normalizePage(cursor, limit) + + query := s.db.WithContext(ctx).Model(&model.UserVipState{}).Where("sys_origin = ?", sysOrigin) + if userID > 0 { + query = query.Where("user_id = ?", userID) + } + if normalizedStatus := strings.ToUpper(strings.TrimSpace(status)); normalizedStatus != "" { + query = query.Where("status = ?", normalizedStatus) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, err + } + + records := make([]UserVipStatePayload, 0) + if total > 0 { + var rows []model.UserVipState + if err := query. + Order("update_time desc"). + Order("id desc"). + Offset((page - 1) * size). + Limit(size). + Find(&rows).Error; err != nil { + return nil, err + } + records = make([]UserVipStatePayload, 0, len(rows)) + for idx := range rows { + records = append(records, statePayloadFromModel(&rows[idx], now)) + } + } + + return &UserStatePageResponse{Records: records, Total: total}, nil +} + +// PageUserOrders returns the current user's VIP order records. +func (s *Service) PageUserOrders(ctx context.Context, user AuthUser, cursor int, limit int) (*OrderPageResponse, error) { + sysOrigin, err := normalizeUser(user) + if err != nil { + return nil, err + } + return s.PageOrders(ctx, sysOrigin, user.UserID, "", "", cursor, limit) +} + +func (s *Service) expireDueStates(ctx context.Context, sysOrigin string, now time.Time) error { + updates := map[string]any{ + "level": 0, + "level_code": "", + "display_name": "", + "status": statusExpired, + "badge_resource_id": 0, + "badge_name": "", + "badge_url": "", + "avatar_frame_resource_id": 0, + "avatar_frame_name": "", + "avatar_frame_url": "", + "entry_effect_resource_id": 0, + "entry_effect_name": "", + "entry_effect_url": "", + "chat_bubble_resource_id": 0, + "chat_bubble_name": "", + "chat_bubble_url": "", + "float_picture_resource_id": 0, + "float_picture_name": "", + "float_picture_url": "", + "update_time": now, + } + return s.db.WithContext(ctx). + Model(&model.UserVipState{}). + Where("sys_origin = ? AND status = ? AND expire_at <= ?", sysOrigin, statusActive, now). + Updates(updates).Error +} + +// ParseUserID parses optional userId filters from query strings. +func ParseUserID(raw string) int64 { + value := strings.TrimSpace(raw) + if value == "" { + return 0 + } + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return 0 + } + return parsed +} diff --git a/internal/service/vip/app.go b/internal/service/vip/app.go new file mode 100644 index 0000000..088d724 --- /dev/null +++ b/internal/service/vip/app.go @@ -0,0 +1,547 @@ +package vip + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// GetHome returns VIP configuration plus the current user's purchasable state. +func (s *Service) GetHome(ctx context.Context, user AuthUser) (*HomeResponse, error) { + sysOrigin, err := normalizeUser(user) + if err != nil { + return nil, err + } + + configs, err := s.loadConfigMap(ctx, sysOrigin) + if err != nil { + return nil, err + } + now := time.Now() + state, err := s.loadUserState(ctx, sysOrigin, user.UserID, now) + if err != nil { + return nil, err + } + activeState := activeStateOrNil(state, now) + + levels := make([]VipLevelHomePayload, 0, levelCount) + for level := 1; level <= levelCount; level++ { + payload := defaultLevelPayload(level) + config, exists := configs[level] + if exists { + payload = payloadFromConfig(config) + } + item := VipLevelHomePayload{VipLevelPayload: payload} + if exists && config.Enabled { + plan, planErr := buildPurchasePlan(now, activeState, config, configs) + item.CanPurchase = planErr == nil + if planErr == nil { + item.PayableGold = plan.PayableGold + } + } + levels = append(levels, item) + } + + statePayload := statePayloadFromModel(state, now) + statePayload.UserID = user.UserID + statePayload.SysOrigin = sysOrigin + return &HomeResponse{ + UserID: user.UserID, + SysOrigin: sysOrigin, + Configured: len(configs) > 0, + State: statePayload, + Levels: levels, + }, nil +} + +// GetStatus returns only the current user's VIP state. +func (s *Service) GetStatus(ctx context.Context, user AuthUser) (*UserVipStatePayload, error) { + sysOrigin, err := normalizeUser(user) + if err != nil { + return nil, err + } + now := time.Now() + state, err := s.loadUserState(ctx, sysOrigin, user.UserID, now) + if err != nil { + return nil, err + } + payload := statePayloadFromModel(state, now) + payload.UserID = user.UserID + payload.SysOrigin = sysOrigin + return &payload, nil +} + +// PreviewPurchase calculates the cost and resulting period before purchase. +func (s *Service) PreviewPurchase(ctx context.Context, user AuthUser, req PurchaseRequest) (*PurchasePreviewResponse, error) { + sysOrigin, err := normalizeUser(user) + if err != nil { + return nil, err + } + + targetLevel := parseTargetLevel(req) + if err := validateLevel(targetLevel); err != nil { + return nil, err + } + configs, err := s.loadConfigMap(ctx, sysOrigin) + if err != nil { + return nil, err + } + targetConfig, err := resolveTargetConfig(configs, targetLevel) + if err != nil { + return nil, err + } + now := time.Now() + state, err := s.loadUserState(ctx, sysOrigin, user.UserID, now) + if err != nil { + return nil, err + } + plan, err := buildPurchasePlan(now, activeStateOrNil(state, now), targetConfig, configs) + if err != nil { + return nil, err + } + + return &PurchasePreviewResponse{ + UserID: user.UserID, + SysOrigin: sysOrigin, + OrderType: plan.OrderType, + CurrentLevel: plan.CurrentLevel, + TargetLevel: plan.TargetLevel, + DurationDays: plan.DurationDays, + OriginalPriceGold: plan.OriginalPriceGold, + PayableGold: plan.PayableGold, + StartAt: formatTime(plan.StartAt), + ExpireAt: formatTime(plan.ExpireAt), + TargetConfig: payloadFromConfig(plan.TargetConfig), + }, nil +} + +// Purchase buys, renews, or upgrades a 30-day VIP package. +func (s *Service) Purchase(ctx context.Context, user AuthUser, req PurchaseRequest) (*PurchaseResponse, error) { + sysOrigin, err := normalizeUser(user) + if err != nil { + return nil, err + } + + targetLevel := parseTargetLevel(req) + if err := validateLevel(targetLevel); err != nil { + return nil, err + } + + idempotentKey := normalizeIdempotentKey(req.BizIdempotentKey) + if idempotentKey != "" { + eventID := buildVIPEventID(sysOrigin, user.UserID, idempotentKey) + existing, err := s.loadOrderByEventID(ctx, eventID) + if err != nil { + return nil, err + } + if existing != nil { + if existing.Status != orderStatusSuccess { + return nil, NewAppError(http.StatusConflict, "vip_order_in_progress", "vip order is already submitted") + } + now := time.Now() + state, stateErr := s.loadUserState(ctx, sysOrigin, user.UserID, now) + if stateErr != nil { + return nil, stateErr + } + statePayload := statePayloadFromModel(state, now) + statePayload.UserID = user.UserID + statePayload.SysOrigin = sysOrigin + return &PurchaseResponse{ + Success: true, + Order: orderPayloadFromModel(*existing), + State: statePayload, + }, nil + } + } + + configs, err := s.loadConfigMap(ctx, sysOrigin) + if err != nil { + return nil, err + } + targetConfig, err := resolveTargetConfig(configs, targetLevel) + if err != nil { + return nil, err + } + + now := time.Now() + orderID, err := utils.NextID() + if err != nil { + return nil, err + } + eventKey := idempotentKey + if eventKey == "" { + eventKey = strconv.FormatInt(orderID, 10) + } + + tx := s.db.WithContext(ctx).Begin() + if tx.Error != nil { + return nil, tx.Error + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback().Error + } + }() + + state, err := s.loadOrCreateLockedState(tx, sysOrigin, user.UserID, now) + if err != nil { + return nil, err + } + activeState := activeStateOrNil(state, now) + plan, err := buildPurchasePlan(now, activeState, targetConfig, configs) + if err != nil { + return nil, err + } + + order := model.VipOrderRecord{ + ID: orderID, + EventID: buildVIPEventID(sysOrigin, user.UserID, eventKey), + SysOrigin: sysOrigin, + UserID: user.UserID, + OrderType: plan.OrderType, + FromLevel: plan.CurrentLevel, + ToLevel: plan.TargetLevel, + DurationDays: plan.DurationDays, + OriginalPriceGold: plan.OriginalPriceGold, + PaidGold: plan.PayableGold, + Status: orderStatusInit, + StartAt: plan.StartAt, + ExpireAt: plan.ExpireAt, + CreateTime: now, + UpdateTime: now, + } + if err := tx.Create(&order).Error; err != nil { + return nil, err + } + + if plan.PayableGold > 0 { + if s.java == nil { + err := NewAppError(http.StatusServiceUnavailable, "wallet_unavailable", "wallet gateway is unavailable") + if saveErr := s.markOrderFailed(tx, &order, err.Error()); saveErr != nil { + return nil, saveErr + } + if commitErr := tx.Commit().Error; commitErr != nil { + return nil, commitErr + } + committed = true + return nil, err + } + if err := s.java.ChangeGoldBalance(ctx, buildWalletCommand(user.UserID, sysOrigin, order)); err != nil { + appErr := NewAppError(http.StatusBadGateway, "vip_wallet_deduct_failed", err.Error()) + if saveErr := s.markOrderFailed(tx, &order, err.Error()); saveErr != nil { + return nil, saveErr + } + if commitErr := tx.Commit().Error; commitErr != nil { + return nil, commitErr + } + committed = true + return nil, appErr + } + } + + applyPlanToState(state, plan, now) + order.Status = orderStatusSuccess + order.ErrorMessage = "" + order.UpdateTime = now + if err := tx.Save(state).Error; err != nil { + return nil, err + } + if err := tx.Save(&order).Error; err != nil { + return nil, err + } + if err := tx.Commit().Error; err != nil { + return nil, err + } + committed = true + + payload := statePayloadFromModel(state, now) + return &PurchaseResponse{ + Success: true, + Order: orderPayloadFromModel(order), + State: payload, + }, nil +} + +func normalizeUser(user AuthUser) (string, error) { + if user.UserID <= 0 { + return "", NewAppError(http.StatusUnauthorized, "invalid_user", "user is required") + } + sysOrigin := normalizeSysOrigin(user.SysOrigin) + if sysOrigin == "" { + return "", NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required") + } + return sysOrigin, nil +} + +func resolveTargetConfig(configs map[int]model.VipLevelConfig, targetLevel int) (model.VipLevelConfig, error) { + config, exists := configs[targetLevel] + if !exists { + return model.VipLevelConfig{}, NewAppError(http.StatusBadRequest, "vip_not_configured", "target vip level is not configured") + } + if !config.Enabled { + return model.VipLevelConfig{}, NewAppError(http.StatusBadRequest, "vip_level_disabled", "target vip level is disabled") + } + if config.PriceGold < 0 { + return model.VipLevelConfig{}, NewAppError(http.StatusBadRequest, "invalid_price", "target vip level price is invalid") + } + if config.DurationDays <= 0 { + config.DurationDays = vipDurationDay + } + return config, nil +} + +func buildPurchasePlan( + now time.Time, + currentState *model.UserVipState, + targetConfig model.VipLevelConfig, + configs map[int]model.VipLevelConfig, +) (purchasePlan, error) { + targetLevel := targetConfig.Level + if err := validateLevel(targetLevel); err != nil { + return purchasePlan{}, err + } + if !targetConfig.Enabled { + return purchasePlan{}, NewAppError(http.StatusBadRequest, "vip_level_disabled", "target vip level is disabled") + } + + durationDays := targetConfig.DurationDays + if durationDays <= 0 { + durationDays = vipDurationDay + } + + currentLevel := 0 + startAt := now + expireAt := now.AddDate(0, 0, durationDays) + orderType := orderTypePurchase + payableGold := targetConfig.PriceGold + + if isActiveState(currentState, now) { + currentLevel = currentState.Level + if targetLevel < currentLevel { + return purchasePlan{}, NewAppError(http.StatusBadRequest, "vip_downgrade_not_allowed", "vip downgrade is not allowed") + } + startAt = currentState.StartAt + if startAt.IsZero() { + startAt = now + } + if targetLevel == currentLevel { + orderType = orderTypeRenew + expireAt = currentState.ExpireAt.AddDate(0, 0, durationDays) + payableGold = targetConfig.PriceGold + } else { + orderType = orderTypeUpgrade + expireAt = currentState.ExpireAt + currentPrice := int64(0) + if currentConfig, exists := configs[currentLevel]; exists { + currentPrice = currentConfig.PriceGold + } + payableGold = targetConfig.PriceGold - currentPrice + if payableGold < 0 { + payableGold = 0 + } + } + } + + return purchasePlan{ + OrderType: orderType, + CurrentLevel: currentLevel, + TargetLevel: targetLevel, + DurationDays: durationDays, + OriginalPriceGold: targetConfig.PriceGold, + PayableGold: payableGold, + StartAt: startAt, + ExpireAt: expireAt, + TargetConfig: targetConfig, + }, nil +} + +func (s *Service) loadUserState(ctx context.Context, sysOrigin string, userID int64, now time.Time) (*model.UserVipState, error) { + var row model.UserVipState + err := s.db.WithContext(ctx). + Where("sys_origin = ? AND user_id = ?", sysOrigin, userID). + First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + if row.Status == statusActive && !row.ExpireAt.After(now) { + expireState(&row, now) + if err := s.db.WithContext(ctx).Save(&row).Error; err != nil { + return nil, err + } + } + return &row, nil +} + +func (s *Service) loadOrCreateLockedState(tx *gorm.DB, sysOrigin string, userID int64, now time.Time) (*model.UserVipState, error) { + var row model.UserVipState + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("sys_origin = ? AND user_id = ?", sysOrigin, userID). + First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + nextID, idErr := utils.NextID() + if idErr != nil { + return nil, idErr + } + row = model.UserVipState{ + ID: nextID, + SysOrigin: sysOrigin, + UserID: userID, + Status: statusExpired, + StartAt: now, + ExpireAt: now, + CreateTime: now, + UpdateTime: now, + } + if err := tx.Create(&row).Error; err != nil { + return nil, err + } + return &row, nil + } + if err != nil { + return nil, err + } + if row.Status == statusActive && !row.ExpireAt.After(now) { + expireState(&row, now) + if err := tx.Save(&row).Error; err != nil { + return nil, err + } + } + return &row, nil +} + +func activeStateOrNil(state *model.UserVipState, now time.Time) *model.UserVipState { + if isActiveState(state, now) { + return state + } + return nil +} + +func applyPlanToState(state *model.UserVipState, plan purchasePlan, now time.Time) { + state.Level = plan.TargetLevel + state.LevelCode = levelCode(plan.TargetLevel) + state.DisplayName = strings.TrimSpace(plan.TargetConfig.DisplayName) + if state.DisplayName == "" { + state.DisplayName = levelDisplayName(plan.TargetLevel) + } + state.Status = statusActive + state.StartAt = plan.StartAt + state.ExpireAt = plan.ExpireAt + state.BadgeResourceID = plan.TargetConfig.BadgeResourceID + state.BadgeName = plan.TargetConfig.BadgeName + state.BadgeURL = plan.TargetConfig.BadgeURL + state.AvatarFrameResourceID = plan.TargetConfig.AvatarFrameResourceID + state.AvatarFrameName = plan.TargetConfig.AvatarFrameName + state.AvatarFrameURL = plan.TargetConfig.AvatarFrameURL + state.EntryEffectResourceID = plan.TargetConfig.EntryEffectResourceID + state.EntryEffectName = plan.TargetConfig.EntryEffectName + state.EntryEffectURL = plan.TargetConfig.EntryEffectURL + state.ChatBubbleResourceID = plan.TargetConfig.ChatBubbleResourceID + state.ChatBubbleName = plan.TargetConfig.ChatBubbleName + state.ChatBubbleURL = plan.TargetConfig.ChatBubbleURL + state.FloatPictureResourceID = plan.TargetConfig.FloatPictureResourceID + state.FloatPictureName = plan.TargetConfig.FloatPictureName + state.FloatPictureURL = plan.TargetConfig.FloatPictureURL + state.UpdateTime = now + if state.CreateTime.IsZero() { + state.CreateTime = now + } +} + +func expireState(state *model.UserVipState, now time.Time) { + state.Level = 0 + state.LevelCode = "" + state.DisplayName = "" + state.Status = statusExpired + state.BadgeResourceID = 0 + state.BadgeName = "" + state.BadgeURL = "" + state.AvatarFrameResourceID = 0 + state.AvatarFrameName = "" + state.AvatarFrameURL = "" + state.EntryEffectResourceID = 0 + state.EntryEffectName = "" + state.EntryEffectURL = "" + state.ChatBubbleResourceID = 0 + state.ChatBubbleName = "" + state.ChatBubbleURL = "" + state.FloatPictureResourceID = 0 + state.FloatPictureName = "" + state.FloatPictureURL = "" + state.UpdateTime = now +} + +func buildWalletCommand(userID int64, sysOrigin string, order model.VipOrderRecord) integration.GoldReceiptCommand { + return integration.GoldReceiptCommand{ + ReceiptType: walletReceiptExpenditure, + UserID: userID, + SysOrigin: sysOrigin, + EventID: order.EventID, + Remark: fmt.Sprintf("VIP %s from level %d to %d, %d days", order.OrderType, order.FromLevel, order.ToLevel, order.DurationDays), + Amount: integration.NewPennyAmountPayloadFromDollar(order.PaidGold), + CloseDelayAsset: false, + OpUserType: "APP", + CustomizeOrigin: walletOrigin, + CustomizeOriginDesc: "VIP purchase", + } +} + +func (s *Service) markOrderFailed(tx *gorm.DB, order *model.VipOrderRecord, message string) error { + order.Status = orderStatusFailed + order.ErrorMessage = trimErrorMessage(message) + order.UpdateTime = time.Now() + return tx.Save(order).Error +} + +func (s *Service) loadOrderByEventID(ctx context.Context, eventID string) (*model.VipOrderRecord, error) { + var row model.VipOrderRecord + err := s.db.WithContext(ctx).Where("event_id = ?", eventID).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &row, nil +} + +func buildVIPEventID(sysOrigin string, userID int64, key string) string { + sysPart := normalizeSysOrigin(sysOrigin) + if len(sysPart) > 8 { + sysPart = sysPart[:8] + } + sum := sha1.Sum([]byte(fmt.Sprintf("%s:%d:%s", sysOrigin, userID, key))) + return fmt.Sprintf("VIP:%s:%d:%s", sysPart, userID, hex.EncodeToString(sum[:])[:16]) +} + +func normalizeIdempotentKey(key string) string { + key = strings.TrimSpace(key) + if len(key) > 64 { + key = key[:64] + } + return key +} + +func trimErrorMessage(message string) string { + message = strings.TrimSpace(message) + if len(message) > 512 { + return message[:512] + } + return message +} diff --git a/internal/service/vip/config.go b/internal/service/vip/config.go new file mode 100644 index 0000000..59a3ee5 --- /dev/null +++ b/internal/service/vip/config.go @@ -0,0 +1,199 @@ +package vip + +import ( + "context" + "errors" + "net/http" + "sort" + "strings" + "time" + + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + + "gorm.io/gorm" +) + +// GetConfig returns the operator-managed VIP configuration for one sysOrigin. +func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*ConfigResponse, error) { + sysOrigin = normalizeSysOrigin(sysOrigin) + if sysOrigin == "" { + return nil, NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required") + } + + rows, err := s.loadConfigRows(ctx, sysOrigin) + if err != nil { + return nil, err + } + + levels := buildDefaultLevelPayloads() + configured := len(rows) > 0 + var updateTime time.Time + for _, row := range rows { + if row.Level < 1 || row.Level > levelCount { + continue + } + levels[row.Level-1] = payloadFromConfig(row) + if row.UpdateTime.After(updateTime) { + updateTime = row.UpdateTime + } + } + + return &ConfigResponse{ + Configured: configured, + SysOrigin: sysOrigin, + Levels: levels, + UpdateTime: formatTime(updateTime), + }, nil +} + +// SaveConfig saves all five VIP levels for one sysOrigin. +func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*ConfigResponse, error) { + sysOrigin := normalizeSysOrigin(req.SysOrigin) + if sysOrigin == "" { + return nil, NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required") + } + + levels, err := normalizeSaveLevels(req.Levels) + if err != nil { + return nil, err + } + + now := time.Now() + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for _, item := range levels { + var row model.VipLevelConfig + err := tx.Where("sys_origin = ? AND level = ?", sysOrigin, item.Level).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + nextID, idErr := utils.NextID() + if idErr != nil { + return idErr + } + row = model.VipLevelConfig{ + ID: nextID, + SysOrigin: sysOrigin, + Level: item.Level, + CreateTime: now, + } + } else if err != nil { + return err + } + + applyLevelPayloadToConfig(&row, item, now) + if err := tx.Save(&row).Error; err != nil { + return err + } + } + return nil + }); err != nil { + return nil, err + } + + return s.GetConfig(ctx, sysOrigin) +} + +func normalizeSaveLevels(levels []VipLevelPayload) ([]VipLevelPayload, error) { + if len(levels) != levelCount { + return nil, NewAppError(http.StatusBadRequest, "invalid_levels", "levels must contain exactly 5 items") + } + + seen := make(map[int]struct{}, levelCount) + normalized := make([]VipLevelPayload, 0, levelCount) + for _, item := range levels { + if err := validateLevel(item.Level); err != nil { + return nil, err + } + if _, exists := seen[item.Level]; exists { + return nil, NewAppError(http.StatusBadRequest, "duplicate_level", "level must be unique") + } + seen[item.Level] = struct{}{} + if item.PriceGold < 0 { + return nil, NewAppError(http.StatusBadRequest, "invalid_price", "priceGold must be greater than or equal to 0") + } + item.LevelCode = levelCode(item.Level) + item.DisplayName = strings.TrimSpace(item.DisplayName) + if item.DisplayName == "" { + item.DisplayName = levelDisplayName(item.Level) + } + item.DurationDays = vipDurationDay + item.Badge = normalizeResource(item.Badge) + item.AvatarFrame = normalizeResource(item.AvatarFrame) + item.EntryEffect = normalizeResource(item.EntryEffect) + item.ChatBubble = normalizeResource(item.ChatBubble) + item.FloatPicture = normalizeResource(item.FloatPicture) + normalized = append(normalized, item) + } + + sort.Slice(normalized, func(i, j int) bool { + return normalized[i].Level < normalized[j].Level + }) + return normalized, nil +} + +func normalizeResource(resource VipResourcePayload) VipResourcePayload { + if resource.ResourceID < 0 { + resource.ResourceID = 0 + } + resource.Name = strings.TrimSpace(resource.Name) + resource.URL = strings.TrimSpace(resource.URL) + return resource +} + +func applyLevelPayloadToConfig(row *model.VipLevelConfig, item VipLevelPayload, now time.Time) { + row.Level = item.Level + row.LevelCode = levelCode(item.Level) + row.DisplayName = strings.TrimSpace(item.DisplayName) + row.Enabled = item.Enabled + row.DurationDays = vipDurationDay + row.PriceGold = item.PriceGold + row.BadgeResourceID = item.Badge.ResourceID.Int64() + row.BadgeName = item.Badge.Name + row.BadgeURL = item.Badge.URL + row.AvatarFrameResourceID = item.AvatarFrame.ResourceID.Int64() + row.AvatarFrameName = item.AvatarFrame.Name + row.AvatarFrameURL = item.AvatarFrame.URL + row.EntryEffectResourceID = item.EntryEffect.ResourceID.Int64() + row.EntryEffectName = item.EntryEffect.Name + row.EntryEffectURL = item.EntryEffect.URL + row.ChatBubbleResourceID = item.ChatBubble.ResourceID.Int64() + row.ChatBubbleName = item.ChatBubble.Name + row.ChatBubbleURL = item.ChatBubble.URL + row.FloatPictureResourceID = item.FloatPicture.ResourceID.Int64() + row.FloatPictureName = item.FloatPicture.Name + row.FloatPictureURL = item.FloatPicture.URL + row.UpdateTime = now + if row.CreateTime.IsZero() { + row.CreateTime = now + } +} + +func (s *Service) loadConfigRows(ctx context.Context, sysOrigin string) ([]model.VipLevelConfig, error) { + var rows []model.VipLevelConfig + if err := s.db.WithContext(ctx). + Where("sys_origin = ?", normalizeSysOrigin(sysOrigin)). + Order("level asc"). + Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +func (s *Service) loadConfigMap(ctx context.Context, sysOrigin string) (map[int]model.VipLevelConfig, error) { + rows, err := s.loadConfigRows(ctx, sysOrigin) + if err != nil { + return nil, err + } + configs := make(map[int]model.VipLevelConfig, len(rows)) + for _, row := range rows { + configs[row.Level] = row + } + return configs, nil +} + +func buildDefaultLevelPayloads() []VipLevelPayload { + levels := make([]VipLevelPayload, 0, levelCount) + for level := 1; level <= levelCount; level++ { + levels = append(levels, defaultLevelPayload(level)) + } + return levels +} diff --git a/internal/service/vip/purchase_test.go b/internal/service/vip/purchase_test.go new file mode 100644 index 0000000..09be14c --- /dev/null +++ b/internal/service/vip/purchase_test.go @@ -0,0 +1,136 @@ +package vip + +import ( + "encoding/json" + "testing" + "time" + + "chatapp3-golang/internal/model" +) + +func TestBuildPurchasePlanNewPurchase(t *testing.T) { + now := time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC) + configs := vipTestConfigs() + + plan, err := buildPurchasePlan(now, nil, configs[2], configs) + if err != nil { + t.Fatalf("buildPurchasePlan returned error: %v", err) + } + + if plan.OrderType != orderTypePurchase { + t.Fatalf("order type = %s, want %s", plan.OrderType, orderTypePurchase) + } + if plan.CurrentLevel != 0 || plan.TargetLevel != 2 { + t.Fatalf("levels = %d -> %d, want 0 -> 2", plan.CurrentLevel, plan.TargetLevel) + } + if plan.PayableGold != 2000 { + t.Fatalf("payable = %d, want 2000", plan.PayableGold) + } + if !plan.StartAt.Equal(now) || !plan.ExpireAt.Equal(now.AddDate(0, 0, vipDurationDay)) { + t.Fatalf("period = %s -> %s, want %s -> %s", plan.StartAt, plan.ExpireAt, now, now.AddDate(0, 0, vipDurationDay)) + } +} + +func TestBuildPurchasePlanUpgradePaysDifferenceAndKeepsExpireAt(t *testing.T) { + now := time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC) + startAt := now.AddDate(0, 0, -5) + expireAt := now.AddDate(0, 0, 25) + configs := vipTestConfigs() + state := &model.UserVipState{ + Level: 2, + Status: statusActive, + StartAt: startAt, + ExpireAt: expireAt, + } + + plan, err := buildPurchasePlan(now, state, configs[4], configs) + if err != nil { + t.Fatalf("buildPurchasePlan returned error: %v", err) + } + + if plan.OrderType != orderTypeUpgrade { + t.Fatalf("order type = %s, want %s", plan.OrderType, orderTypeUpgrade) + } + if plan.PayableGold != 2000 { + t.Fatalf("payable = %d, want 2000", plan.PayableGold) + } + if !plan.StartAt.Equal(startAt) || !plan.ExpireAt.Equal(expireAt) { + t.Fatalf("upgrade period changed to %s -> %s, want %s -> %s", plan.StartAt, plan.ExpireAt, startAt, expireAt) + } +} + +func TestBuildPurchasePlanRenewExtendsThirtyDays(t *testing.T) { + now := time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC) + startAt := now.AddDate(0, 0, -5) + expireAt := now.AddDate(0, 0, 25) + configs := vipTestConfigs() + state := &model.UserVipState{ + Level: 3, + Status: statusActive, + StartAt: startAt, + ExpireAt: expireAt, + } + + plan, err := buildPurchasePlan(now, state, configs[3], configs) + if err != nil { + t.Fatalf("buildPurchasePlan returned error: %v", err) + } + + if plan.OrderType != orderTypeRenew { + t.Fatalf("order type = %s, want %s", plan.OrderType, orderTypeRenew) + } + if plan.PayableGold != 3000 { + t.Fatalf("payable = %d, want 3000", plan.PayableGold) + } + if !plan.ExpireAt.Equal(expireAt.AddDate(0, 0, vipDurationDay)) { + t.Fatalf("renew expireAt = %s, want %s", plan.ExpireAt, expireAt.AddDate(0, 0, vipDurationDay)) + } +} + +func TestBuildPurchasePlanRejectsDowngrade(t *testing.T) { + now := time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC) + configs := vipTestConfigs() + state := &model.UserVipState{ + Level: 4, + Status: statusActive, + StartAt: now.AddDate(0, 0, -5), + ExpireAt: now.AddDate(0, 0, 25), + } + + if _, err := buildPurchasePlan(now, state, configs[2], configs); err == nil { + t.Fatal("buildPurchasePlan returned nil error for downgrade") + } +} + +func TestVipResourcePayloadAcceptsStringResourceID(t *testing.T) { + var payload VipResourcePayload + if err := json.Unmarshal([]byte(`{"resourceId":"2045029471274201089","name":"vip","url":"https://example.com/a.svga"}`), &payload); err != nil { + t.Fatalf("unmarshal resource payload: %v", err) + } + if payload.ResourceID.Int64() != 2045029471274201089 { + t.Fatalf("resource id = %d, want 2045029471274201089", payload.ResourceID.Int64()) + } + + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal resource payload: %v", err) + } + if got := string(body); got != `{"resourceId":"2045029471274201089","name":"vip","url":"https://example.com/a.svga"}` { + t.Fatalf("json = %s", got) + } +} + +func vipTestConfigs() map[int]model.VipLevelConfig { + configs := make(map[int]model.VipLevelConfig, levelCount) + for level := 1; level <= levelCount; level++ { + configs[level] = model.VipLevelConfig{ + Level: level, + LevelCode: levelCode(level), + DisplayName: levelDisplayName(level), + Enabled: true, + DurationDays: vipDurationDay, + PriceGold: int64(level * 1000), + } + } + return configs +} diff --git a/internal/service/vip/types.go b/internal/service/vip/types.go new file mode 100644 index 0000000..ff2c18e --- /dev/null +++ b/internal/service/vip/types.go @@ -0,0 +1,398 @@ +package vip + +import ( + "context" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + + "gorm.io/gorm" +) + +const ( + levelCount = 5 + vipDurationDay = 30 + + statusActive = "ACTIVE" + statusExpired = "EXPIRED" + + orderStatusInit = "INIT" + orderStatusSuccess = "SUCCESS" + orderStatusFailed = "FAILED" + + orderTypePurchase = "PURCHASE" + orderTypeRenew = "RENEW" + orderTypeUpgrade = "UPGRADE" + + walletReceiptIncome = "INCOME" + walletReceiptExpenditure = "EXPENDITURE" + walletOrigin = "VIP_PURCHASE" +) + +type AppError = common.AppError +type AuthUser = common.AuthUser + +var NewAppError = common.NewAppError + +type vipDB interface { + WithContext(ctx context.Context) *gorm.DB +} + +type vipGateway interface { + ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error +} + +type Service struct { + cfg config.Config + db vipDB + java vipGateway +} + +func NewService(cfg config.Config, db vipDB, java vipGateway) *Service { + return &Service{cfg: cfg, db: db, java: java} +} + +type SaveConfigRequest struct { + SysOrigin string `json:"sysOrigin"` + Levels []VipLevelPayload `json:"levels"` +} + +type ConfigResponse struct { + Configured bool `json:"configured"` + SysOrigin string `json:"sysOrigin"` + Levels []VipLevelPayload `json:"levels"` + UpdateTime string `json:"updateTime,omitempty"` +} + +type VipLevelPayload struct { + ID int64 `json:"id"` + Level int `json:"level"` + LevelCode string `json:"levelCode"` + DisplayName string `json:"displayName"` + Enabled bool `json:"enabled"` + DurationDays int `json:"durationDays"` + PriceGold int64 `json:"priceGold"` + Badge VipResourcePayload `json:"badge"` + AvatarFrame VipResourcePayload `json:"avatarFrame"` + EntryEffect VipResourcePayload `json:"entryEffect"` + ChatBubble VipResourcePayload `json:"chatBubble"` + FloatPicture VipResourcePayload `json:"floatPicture"` + UpdateTime string `json:"updateTime,omitempty"` +} + +type VipResourcePayload struct { + ResourceID ResourceID `json:"resourceId"` + Name string `json:"name"` + URL string `json:"url"` +} + +type ResourceID int64 + +func (id ResourceID) Int64() int64 { + return int64(id) +} + +func (id ResourceID) MarshalJSON() ([]byte, error) { + return []byte(strconv.Quote(strconv.FormatInt(int64(id), 10))), nil +} + +func (id *ResourceID) UnmarshalJSON(data []byte) error { + raw := strings.TrimSpace(string(data)) + if raw == "" || raw == "null" { + *id = 0 + return nil + } + if strings.HasPrefix(raw, "\"") { + unquoted, err := strconv.Unquote(raw) + if err != nil { + return err + } + raw = strings.TrimSpace(unquoted) + } + if raw == "" { + *id = 0 + return nil + } + parsed, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return err + } + *id = ResourceID(parsed) + return nil +} + +type PurchaseRequest struct { + TargetLevel int `json:"targetLevel"` + TargetLevelCode string `json:"targetLevelCode"` + BizIdempotentKey string `json:"bizIdempotentKey"` +} + +type PurchasePreviewResponse struct { + UserID int64 `json:"userId"` + SysOrigin string `json:"sysOrigin"` + OrderType string `json:"orderType"` + CurrentLevel int `json:"currentLevel"` + TargetLevel int `json:"targetLevel"` + DurationDays int `json:"durationDays"` + OriginalPriceGold int64 `json:"originalPriceGold"` + PayableGold int64 `json:"payableGold"` + StartAt string `json:"startAt,omitempty"` + ExpireAt string `json:"expireAt,omitempty"` + TargetConfig VipLevelPayload `json:"targetConfig"` +} + +type PurchaseResponse struct { + Success bool `json:"success"` + Order VipOrderPayload `json:"order"` + State UserVipStatePayload `json:"state"` +} + +type HomeResponse struct { + UserID int64 `json:"userId"` + SysOrigin string `json:"sysOrigin"` + Configured bool `json:"configured"` + State UserVipStatePayload `json:"state"` + Levels []VipLevelHomePayload `json:"levels"` +} + +type VipLevelHomePayload struct { + VipLevelPayload + CanPurchase bool `json:"canPurchase"` + PayableGold int64 `json:"payableGold"` +} + +type UserVipStatePayload struct { + UserID int64 `json:"userId"` + SysOrigin string `json:"sysOrigin"` + Active bool `json:"active"` + Status string `json:"status"` + Level int `json:"level"` + LevelCode string `json:"levelCode"` + DisplayName string `json:"displayName"` + StartAt string `json:"startAt,omitempty"` + ExpireAt string `json:"expireAt,omitempty"` + Badge VipResourcePayload `json:"badge,omitempty"` + AvatarFrame VipResourcePayload `json:"avatarFrame,omitempty"` + EntryEffect VipResourcePayload `json:"entryEffect,omitempty"` + ChatBubble VipResourcePayload `json:"chatBubble,omitempty"` + FloatPicture VipResourcePayload `json:"floatPicture,omitempty"` + UpdateTime string `json:"updateTime,omitempty"` +} + +type VipOrderPayload struct { + ID int64 `json:"id"` + EventID string `json:"eventId"` + SysOrigin string `json:"sysOrigin"` + UserID int64 `json:"userId"` + OrderType string `json:"orderType"` + FromLevel int `json:"fromLevel"` + ToLevel int `json:"toLevel"` + DurationDays int `json:"durationDays"` + OriginalPriceGold int64 `json:"originalPriceGold"` + PaidGold int64 `json:"paidGold"` + Status string `json:"status"` + StartAt string `json:"startAt,omitempty"` + ExpireAt string `json:"expireAt,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` + CreateTime string `json:"createTime,omitempty"` + UpdateTime string `json:"updateTime,omitempty"` +} + +type OrderPageResponse struct { + Records []VipOrderPayload `json:"records"` + Total int64 `json:"total"` +} + +type UserStatePageResponse struct { + Records []UserVipStatePayload `json:"records"` + Total int64 `json:"total"` +} + +type purchasePlan struct { + OrderType string + CurrentLevel int + TargetLevel int + DurationDays int + OriginalPriceGold int64 + PayableGold int64 + StartAt time.Time + ExpireAt time.Time + TargetConfig model.VipLevelConfig +} + +func normalizeSysOrigin(sysOrigin string) string { + return strings.ToUpper(strings.TrimSpace(sysOrigin)) +} + +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 levelCode(level int) string { + return fmt.Sprintf("VIP%d", level) +} + +func levelDisplayName(level int) string { + return fmt.Sprintf("VIP %d", level) +} + +func parseTargetLevel(req PurchaseRequest) int { + if req.TargetLevel >= 1 && req.TargetLevel <= levelCount { + return req.TargetLevel + } + code := strings.ToUpper(strings.TrimSpace(req.TargetLevelCode)) + if strings.HasPrefix(code, "VIP") { + if parsed, err := strconv.Atoi(strings.TrimPrefix(code, "VIP")); err == nil { + return parsed + } + } + return 0 +} + +func validateLevel(level int) error { + if level < 1 || level > levelCount { + return NewAppError(http.StatusBadRequest, "vip_level_invalid", "target level must be 1-5") + } + return nil +} + +func formatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format("2006-01-02 15:04:05") +} + +func isActiveState(state *model.UserVipState, now time.Time) bool { + return state != nil && state.Status == statusActive && state.Level > 0 && state.ExpireAt.After(now) +} + +func defaultLevelPayload(level int) VipLevelPayload { + return VipLevelPayload{ + Level: level, + LevelCode: levelCode(level), + DisplayName: levelDisplayName(level), + Enabled: false, + DurationDays: vipDurationDay, + } +} + +func payloadFromConfig(row model.VipLevelConfig) VipLevelPayload { + duration := row.DurationDays + if duration <= 0 { + duration = vipDurationDay + } + displayName := strings.TrimSpace(row.DisplayName) + if displayName == "" { + displayName = levelDisplayName(row.Level) + } + code := strings.TrimSpace(row.LevelCode) + if code == "" { + code = levelCode(row.Level) + } + return VipLevelPayload{ + ID: row.ID, + Level: row.Level, + LevelCode: code, + DisplayName: displayName, + Enabled: row.Enabled, + DurationDays: duration, + PriceGold: row.PriceGold, + Badge: VipResourcePayload{ + ResourceID: ResourceID(row.BadgeResourceID), + Name: row.BadgeName, + URL: row.BadgeURL, + }, + AvatarFrame: VipResourcePayload{ + ResourceID: ResourceID(row.AvatarFrameResourceID), + Name: row.AvatarFrameName, + URL: row.AvatarFrameURL, + }, + EntryEffect: VipResourcePayload{ + ResourceID: ResourceID(row.EntryEffectResourceID), + Name: row.EntryEffectName, + URL: row.EntryEffectURL, + }, + ChatBubble: VipResourcePayload{ + ResourceID: ResourceID(row.ChatBubbleResourceID), + Name: row.ChatBubbleName, + URL: row.ChatBubbleURL, + }, + FloatPicture: VipResourcePayload{ + ResourceID: ResourceID(row.FloatPictureResourceID), + Name: row.FloatPictureName, + URL: row.FloatPictureURL, + }, + UpdateTime: formatTime(row.UpdateTime), + } +} + +func statePayloadFromModel(row *model.UserVipState, now time.Time) UserVipStatePayload { + if row == nil { + return UserVipStatePayload{Active: false, Status: statusExpired} + } + active := isActiveState(row, now) + payload := UserVipStatePayload{ + UserID: row.UserID, + SysOrigin: row.SysOrigin, + Active: active, + Status: row.Status, + Level: row.Level, + LevelCode: row.LevelCode, + DisplayName: row.DisplayName, + StartAt: formatTime(row.StartAt), + ExpireAt: formatTime(row.ExpireAt), + UpdateTime: formatTime(row.UpdateTime), + } + if !active { + payload.Active = false + payload.Status = statusExpired + payload.Level = 0 + payload.LevelCode = "" + payload.DisplayName = "" + return payload + } + payload.Badge = VipResourcePayload{ResourceID: ResourceID(row.BadgeResourceID), Name: row.BadgeName, URL: row.BadgeURL} + payload.AvatarFrame = VipResourcePayload{ResourceID: ResourceID(row.AvatarFrameResourceID), Name: row.AvatarFrameName, URL: row.AvatarFrameURL} + payload.EntryEffect = VipResourcePayload{ResourceID: ResourceID(row.EntryEffectResourceID), Name: row.EntryEffectName, URL: row.EntryEffectURL} + payload.ChatBubble = VipResourcePayload{ResourceID: ResourceID(row.ChatBubbleResourceID), Name: row.ChatBubbleName, URL: row.ChatBubbleURL} + payload.FloatPicture = VipResourcePayload{ResourceID: ResourceID(row.FloatPictureResourceID), Name: row.FloatPictureName, URL: row.FloatPictureURL} + return payload +} + +func orderPayloadFromModel(row model.VipOrderRecord) VipOrderPayload { + return VipOrderPayload{ + ID: row.ID, + EventID: row.EventID, + SysOrigin: row.SysOrigin, + UserID: row.UserID, + OrderType: row.OrderType, + FromLevel: row.FromLevel, + ToLevel: row.ToLevel, + DurationDays: row.DurationDays, + OriginalPriceGold: row.OriginalPriceGold, + PaidGold: row.PaidGold, + Status: row.Status, + StartAt: formatTime(row.StartAt), + ExpireAt: formatTime(row.ExpireAt), + ErrorMessage: row.ErrorMessage, + CreateTime: formatTime(row.CreateTime), + UpdateTime: formatTime(row.UpdateTime), + } +} diff --git a/migrations/019_vip_system.sql b/migrations/019_vip_system.sql new file mode 100644 index 0000000..283371f --- /dev/null +++ b/migrations/019_vip_system.sql @@ -0,0 +1,86 @@ +CREATE TABLE IF NOT EXISTS `vip_level_config` ( + `id` bigint NOT NULL COMMENT '主键ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `level` int NOT NULL COMMENT 'VIP等级 1-5', + `level_code` varchar(16) NOT NULL DEFAULT '' COMMENT '等级编码 VIP1-VIP5', + `display_name` varchar(64) NOT NULL DEFAULT '' COMMENT '显示名称', + `enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用', + `duration_days` int NOT NULL DEFAULT '30' COMMENT '售卖时长,当前固定30天', + `price_gold` bigint NOT NULL DEFAULT '0' COMMENT '购买价格,金币', + `badge_resource_id` bigint NOT NULL DEFAULT '0' COMMENT '徽章资源ID', + `badge_name` varchar(128) NOT NULL DEFAULT '' COMMENT '徽章名称', + `badge_url` varchar(512) NOT NULL DEFAULT '' COMMENT '徽章资源URL', + `avatar_frame_resource_id` bigint NOT NULL DEFAULT '0' COMMENT '头像框资源ID', + `avatar_frame_name` varchar(128) NOT NULL DEFAULT '' COMMENT '头像框名称', + `avatar_frame_url` varchar(512) NOT NULL DEFAULT '' COMMENT '头像框资源URL', + `entry_effect_resource_id` bigint NOT NULL DEFAULT '0' COMMENT '进场特效资源ID', + `entry_effect_name` varchar(128) NOT NULL DEFAULT '' COMMENT '进场特效名称', + `entry_effect_url` varchar(512) NOT NULL DEFAULT '' COMMENT '进场特效资源URL', + `chat_bubble_resource_id` bigint NOT NULL DEFAULT '0' COMMENT '聊天气泡资源ID', + `chat_bubble_name` varchar(128) NOT NULL DEFAULT '' COMMENT '聊天气泡名称', + `chat_bubble_url` varchar(512) NOT NULL DEFAULT '' COMMENT '聊天气泡资源URL', + `float_picture_resource_id` bigint NOT NULL DEFAULT '0' COMMENT '飘窗资源ID', + `float_picture_name` varchar(128) NOT NULL DEFAULT '' COMMENT '飘窗名称', + `float_picture_url` varchar(512) NOT NULL DEFAULT '' COMMENT '飘窗资源URL', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_vip_level_config` (`sys_origin`, `level`), + KEY `idx_vip_level_config_sys_origin` (`sys_origin`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='VIP等级配置'; + +CREATE TABLE IF NOT EXISTS `user_vip_state` ( + `id` bigint NOT NULL COMMENT '主键ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `user_id` bigint NOT NULL COMMENT '用户ID', + `level` int NOT NULL DEFAULT '0' COMMENT '当前VIP等级', + `level_code` varchar(16) NOT NULL DEFAULT '' COMMENT '当前等级编码', + `display_name` varchar(64) NOT NULL DEFAULT '' COMMENT '当前显示名称', + `status` varchar(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态 ACTIVE/EXPIRED', + `start_at` datetime(3) DEFAULT NULL COMMENT '开始时间', + `expire_at` datetime(3) DEFAULT NULL COMMENT '到期时间', + `badge_resource_id` bigint NOT NULL DEFAULT '0' COMMENT '当前徽章资源ID', + `badge_name` varchar(128) NOT NULL DEFAULT '' COMMENT '当前徽章名称', + `badge_url` varchar(512) NOT NULL DEFAULT '' COMMENT '当前徽章资源URL', + `avatar_frame_resource_id` bigint NOT NULL DEFAULT '0' COMMENT '当前头像框资源ID', + `avatar_frame_name` varchar(128) NOT NULL DEFAULT '' COMMENT '当前头像框名称', + `avatar_frame_url` varchar(512) NOT NULL DEFAULT '' COMMENT '当前头像框资源URL', + `entry_effect_resource_id` bigint NOT NULL DEFAULT '0' COMMENT '当前进场特效资源ID', + `entry_effect_name` varchar(128) NOT NULL DEFAULT '' COMMENT '当前进场特效名称', + `entry_effect_url` varchar(512) NOT NULL DEFAULT '' COMMENT '当前进场特效资源URL', + `chat_bubble_resource_id` bigint NOT NULL DEFAULT '0' COMMENT '当前聊天气泡资源ID', + `chat_bubble_name` varchar(128) NOT NULL DEFAULT '' COMMENT '当前聊天气泡名称', + `chat_bubble_url` varchar(512) NOT NULL DEFAULT '' COMMENT '当前聊天气泡资源URL', + `float_picture_resource_id` bigint NOT NULL DEFAULT '0' COMMENT '当前飘窗资源ID', + `float_picture_name` varchar(128) NOT NULL DEFAULT '' COMMENT '当前飘窗名称', + `float_picture_url` varchar(512) NOT NULL DEFAULT '' COMMENT '当前飘窗资源URL', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_vip_state` (`sys_origin`, `user_id`), + KEY `idx_user_vip_state_lookup` (`sys_origin`, `user_id`, `status`), + KEY `idx_user_vip_expire` (`expire_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户VIP当前状态'; + +CREATE TABLE IF NOT EXISTS `vip_order_record` ( + `id` bigint NOT NULL COMMENT '主键ID', + `event_id` varchar(128) NOT NULL COMMENT '钱包幂等事件ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `user_id` bigint NOT NULL COMMENT '用户ID', + `order_type` varchar(32) NOT NULL DEFAULT '' COMMENT '订单类型 PURCHASE/RENEW/UPGRADE', + `from_level` int NOT NULL DEFAULT '0' COMMENT '购买前等级', + `to_level` int NOT NULL DEFAULT '0' COMMENT '目标等级', + `duration_days` int NOT NULL DEFAULT '30' COMMENT '购买时长', + `original_price_gold` bigint NOT NULL DEFAULT '0' COMMENT '目标等级原价', + `paid_gold` bigint NOT NULL DEFAULT '0' COMMENT '实际支付金币', + `status` varchar(32) NOT NULL DEFAULT 'INIT' COMMENT '订单状态 INIT/SUCCESS/FAILED', + `start_at` datetime(3) DEFAULT NULL COMMENT '权益开始时间', + `expire_at` datetime(3) DEFAULT NULL COMMENT '权益到期时间', + `error_message` varchar(512) NOT NULL DEFAULT '' COMMENT '失败原因', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_vip_order_event` (`event_id`), + KEY `idx_vip_order_user_time` (`sys_origin`, `user_id`, `create_time`), + KEY `idx_vip_order_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='VIP购买订单记录'; diff --git a/migrations/020_recharge_reward.sql b/migrations/020_recharge_reward.sql new file mode 100644 index 0000000..b31a9f6 --- /dev/null +++ b/migrations/020_recharge_reward.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS `recharge_reward_config` ( + `id` bigint NOT NULL COMMENT '主键ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用', + `timezone` varchar(64) NOT NULL DEFAULT 'Asia/Riyadh' COMMENT '活动时区', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_recharge_reward_sys_origin` (`sys_origin`), + KEY `idx_recharge_reward_enabled` (`enabled`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='充值奖励主配置'; + +CREATE TABLE IF NOT EXISTS `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 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 '奖励组名称快照', + `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_recharge_reward_level` (`config_id`, `level`), + UNIQUE KEY `uk_recharge_reward_amount` (`config_id`, `recharge_amount_cents`), + KEY `idx_recharge_reward_level_config` (`config_id`), + KEY `idx_recharge_reward_group` (`reward_group_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='充值奖励档位配置';