This commit is contained in:
hy001 2026-04-29 18:45:52 +08:00
parent a9ab2b8589
commit c16c6a7bd9
22 changed files with 3183 additions and 10 deletions

View File

@ -1,6 +1,15 @@
package main package main
import ( import (
"context"
"errors"
"log"
"net/http"
"os/signal"
"syscall"
"time"
_ "time/tzdata"
"chatapp3-golang/internal/bootstrap" "chatapp3-golang/internal/bootstrap"
"chatapp3-golang/internal/router" "chatapp3-golang/internal/router"
"chatapp3-golang/internal/service/baishun" "chatapp3-golang/internal/service/baishun"
@ -10,18 +19,12 @@ import (
"chatapp3-golang/internal/service/invite" "chatapp3-golang/internal/service/invite"
"chatapp3-golang/internal/service/lingxian" "chatapp3-golang/internal/service/lingxian"
"chatapp3-golang/internal/service/luckygift" "chatapp3-golang/internal/service/luckygift"
"chatapp3-golang/internal/service/rechargereward"
"chatapp3-golang/internal/service/registerreward" "chatapp3-golang/internal/service/registerreward"
"chatapp3-golang/internal/service/roomturnoverreward" "chatapp3-golang/internal/service/roomturnoverreward"
"chatapp3-golang/internal/service/signinreward" "chatapp3-golang/internal/service/signinreward"
"chatapp3-golang/internal/service/vip"
"chatapp3-golang/internal/service/weekstar" "chatapp3-golang/internal/service/weekstar"
"context"
"errors"
"log"
"net/http"
"os/signal"
"syscall"
"time"
_ "time/tzdata"
) )
func main() { func main() {
@ -39,7 +42,9 @@ func main() {
gameOpenService := gameopen.NewGameOpenService(app.Config, app.Repository.DB, &app.Gateways) gameOpenService := gameopen.NewGameOpenService(app.Config, app.Repository.DB, &app.Gateways)
luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet) 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) 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) 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) weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways) roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways)
hostCenterService := hostcenter.NewService(app.Config, &app.Gateways) hostCenterService := hostcenter.NewService(app.Config, &app.Gateways)
@ -62,9 +67,11 @@ func main() {
GameProviders: gameProviders, GameProviders: gameProviders,
LuckyGift: luckyGiftService, LuckyGift: luckyGiftService,
HostCenter: hostCenterService, HostCenter: hostCenterService,
RechargeReward: rechargeRewardService,
RegisterReward: registerRewardService, RegisterReward: registerRewardService,
RoomTurnoverReward: roomTurnoverRewardService, RoomTurnoverReward: roomTurnoverRewardService,
SignInReward: signInRewardService, SignInReward: signInRewardService,
VIP: vipService,
WeekStar: weekStarService, WeekStar: weekStarService,
}) })
server := &http.Server{ server := &http.Server{

View File

@ -15,6 +15,7 @@ type Config struct {
Java JavaConfig Java JavaConfig
Worker WorkerConfig Worker WorkerConfig
Invite InviteConfig Invite InviteConfig
RechargeReward RechargeRewardConfig
WeekStar WeekStarConfig WeekStar WeekStarConfig
RoomTurnoverReward RoomTurnoverRewardConfig RoomTurnoverReward RoomTurnoverRewardConfig
Baishun BaishunConfig Baishun BaishunConfig
@ -48,6 +49,7 @@ type JavaConfig struct {
BridgeBaseURL string BridgeBaseURL string
ExternalBaseURL string ExternalBaseURL string
OtherBaseURL string OtherBaseURL string
OrderBaseURL string
WalletBaseURL string WalletBaseURL string
} }
@ -72,6 +74,12 @@ type InviteConfig struct {
PublicH5Path string PublicH5Path string
} }
// RechargeRewardConfig 保存充值奖励活动配置。
type RechargeRewardConfig struct {
DefaultSysOrigin string
Timezone string
}
// WeekStarConfig 保存周榜模块配置。 // WeekStarConfig 保存周榜模块配置。
type WeekStarConfig struct { type WeekStarConfig struct {
DefaultSysOrigin string DefaultSysOrigin string
@ -170,6 +178,7 @@ func Load() Config {
BridgeBaseURL: getEnvAny([]string{"CHATAPP_JAVA_BRIDGE_BASE_URL", "INVITE_JAVA_BRIDGE_BASE_URL"}, consoleBaseURL), 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"), 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), 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"), WalletBaseURL: getEnvAny([]string{"CHATAPP_JAVA_WALLET_BASE_URL", "GAME_JAVA_WALLET_BASE_URL"}, "http://127.0.0.1:2300"),
}, },
Worker: WorkerConfig{ Worker: WorkerConfig{
@ -186,6 +195,16 @@ func Load() Config {
PublicBaseURL: getEnvAny([]string{"CHATAPP_INVITE_PUBLIC_BASE_URL", "INVITE_PUBLIC_BASE_URL"}, "http://localhost:2900"), 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"), 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{ WeekStar: WeekStarConfig{
DefaultSysOrigin: strings.ToUpper(getEnvAny([]string{"CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"}, "LIKEI")), 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"), Timezone: getEnvAny([]string{"CHATAPP_WEEK_STAR_TIMEZONE", "WEEK_STAR_TIMEZONE"}, "Asia/Riyadh"),

View File

@ -12,6 +12,7 @@ type Gateways struct {
Device *DeviceGateway Device *DeviceGateway
InviteCode *InviteCodeGateway InviteCode *InviteCodeGateway
Notice *NoticeGateway Notice *NoticeGateway
Order *OrderGateway
RewardDispatch *RewardDispatchGateway RewardDispatch *RewardDispatchGateway
RewardQuery *RewardQueryGateway RewardQuery *RewardQueryGateway
Profile *ProfileGateway Profile *ProfileGateway
@ -28,6 +29,7 @@ func NewGateways(cfg config.Config) Gateways {
Device: &DeviceGateway{client: client}, Device: &DeviceGateway{client: client},
InviteCode: &InviteCodeGateway{client: client}, InviteCode: &InviteCodeGateway{client: client},
Notice: &NoticeGateway{client: client}, Notice: &NoticeGateway{client: client},
Order: &OrderGateway{client: client},
RewardDispatch: &RewardDispatchGateway{client: client}, RewardDispatch: &RewardDispatchGateway{client: client},
RewardQuery: &RewardQueryGateway{client: client}, RewardQuery: &RewardQueryGateway{client: client},
Profile: &ProfileGateway{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) 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 透传到奖励发放网关。 // GrantGold 透传到奖励发放网关。
func (g *Gateways) GrantGold(ctx context.Context, req GrantGoldRequest) error { func (g *Gateways) GrantGold(ctx context.Context, req GrantGoldRequest) error {
return g.RewardDispatch.GrantGold(ctx, req) 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) 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 负责活动奖励发放接口。 // RewardDispatchGateway 负责活动奖励发放接口。
type RewardDispatchGateway struct { type RewardDispatchGateway struct {
client *Client client *Client

View File

@ -192,6 +192,15 @@ type RewardGroupDetail struct {
RewardConfigList []RewardGroupItem `json:"rewardConfigList"` 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 { type resultResponse[T any] struct {
Code int `json:"code"` Code int `json:"code"`
Message string `json:"message"` 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" endpoint := strings.TrimRight(c.cfg.Java.ConsoleBaseURL, "/") + "/account/info"
headers := http.Header{} headers := javaConsoleHeaders(authorization)
headers.Set("Authorization", authorization)
var resp resultResponse[ConsoleAccount] var resp resultResponse[ConsoleAccount]
if err := c.getJSON(ctx, endpoint, headers, &resp); err != nil { 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 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) { func parseUserCredentialToken(token string) (UserCredential, error) {
token = strings.TrimSpace(token) token = strings.TrimSpace(token)
if token == "" { if token == "" {
@ -483,6 +503,42 @@ func (c *Client) GetRewardGroupDetail(ctx context.Context, groupID int64) (Rewar
return resp.Body, nil 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) { 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)) endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getByUserId?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10))
var resp resultResponse[UserProfile] var resp resultResponse[UserProfile]

View File

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

View File

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

View File

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

View File

@ -13,9 +13,11 @@ import (
"chatapp3-golang/internal/service/invite" "chatapp3-golang/internal/service/invite"
"chatapp3-golang/internal/service/lingxian" "chatapp3-golang/internal/service/lingxian"
"chatapp3-golang/internal/service/luckygift" "chatapp3-golang/internal/service/luckygift"
"chatapp3-golang/internal/service/rechargereward"
"chatapp3-golang/internal/service/registerreward" "chatapp3-golang/internal/service/registerreward"
"chatapp3-golang/internal/service/roomturnoverreward" "chatapp3-golang/internal/service/roomturnoverreward"
"chatapp3-golang/internal/service/signinreward" "chatapp3-golang/internal/service/signinreward"
"chatapp3-golang/internal/service/vip"
"chatapp3-golang/internal/service/weekstar" "chatapp3-golang/internal/service/weekstar"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -30,9 +32,11 @@ type Services struct {
GameProviders *gameprovider.Registry GameProviders *gameprovider.Registry
LuckyGift *luckygift.LuckyGiftService LuckyGift *luckygift.LuckyGiftService
HostCenter *hostcenter.Service HostCenter *hostcenter.Service
RechargeReward *rechargereward.Service
RegisterReward *registerreward.RegisterRewardService RegisterReward *registerreward.RegisterRewardService
RoomTurnoverReward *roomturnoverreward.Service RoomTurnoverReward *roomturnoverreward.Service
SignInReward *signinreward.SignInRewardService SignInReward *signinreward.SignInRewardService
VIP *vip.Service
WeekStar *weekstar.WeekStarService WeekStar *weekstar.WeekStarService
} }
@ -48,8 +52,10 @@ func NewRouter(
registerHealthRoutes(engine, cfg, repository) registerHealthRoutes(engine, cfg, repository)
registerInviteRoutes(engine, javaClient, services.Invite) registerInviteRoutes(engine, javaClient, services.Invite)
registerRechargeRewardRoutes(engine, javaClient, services.RechargeReward)
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward) registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
registerSignInRewardRoutes(engine, javaClient, services.SignInReward) registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
registerVipRoutes(engine, javaClient, services.VIP)
registerWeekStarRoutes(engine, javaClient, services.WeekStar) registerWeekStarRoutes(engine, javaClient, services.WeekStar)
registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward) registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward)
registerHostCenterRoutes(engine, services.HostCenter) registerHostCenterRoutes(engine, services.HostCenter)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

547
internal/service/vip/app.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -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购买订单记录';

View File

@ -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='充值奖励档位配置';