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

View File

@ -15,6 +15,7 @@ type Config struct {
Java JavaConfig
Worker WorkerConfig
Invite InviteConfig
RechargeReward RechargeRewardConfig
WeekStar WeekStarConfig
RoomTurnoverReward RoomTurnoverRewardConfig
Baishun BaishunConfig
@ -48,6 +49,7 @@ type JavaConfig struct {
BridgeBaseURL string
ExternalBaseURL string
OtherBaseURL string
OrderBaseURL string
WalletBaseURL string
}
@ -72,6 +74,12 @@ type InviteConfig struct {
PublicH5Path string
}
// RechargeRewardConfig 保存充值奖励活动配置。
type RechargeRewardConfig struct {
DefaultSysOrigin string
Timezone string
}
// WeekStarConfig 保存周榜模块配置。
type WeekStarConfig struct {
DefaultSysOrigin string
@ -170,6 +178,7 @@ func Load() Config {
BridgeBaseURL: getEnvAny([]string{"CHATAPP_JAVA_BRIDGE_BASE_URL", "INVITE_JAVA_BRIDGE_BASE_URL"}, consoleBaseURL),
ExternalBaseURL: getEnvAny([]string{"CHATAPP_JAVA_EXTERNAL_BASE_URL", "INVITE_JAVA_EXTERNAL_BASE_URL"}, "http://127.0.0.1:3000"),
OtherBaseURL: getEnvAny([]string{"CHATAPP_JAVA_OTHER_BASE_URL", "GAME_JAVA_OTHER_BASE_URL", "INVITE_JAVA_APP_BASE_URL"}, javaAppBaseURL),
OrderBaseURL: getEnvAny([]string{"CHATAPP_JAVA_ORDER_BASE_URL", "GAME_JAVA_ORDER_BASE_URL"}, "http://127.0.0.1:2600"),
WalletBaseURL: getEnvAny([]string{"CHATAPP_JAVA_WALLET_BASE_URL", "GAME_JAVA_WALLET_BASE_URL"}, "http://127.0.0.1:2300"),
},
Worker: WorkerConfig{
@ -186,6 +195,16 @@ func Load() Config {
PublicBaseURL: getEnvAny([]string{"CHATAPP_INVITE_PUBLIC_BASE_URL", "INVITE_PUBLIC_BASE_URL"}, "http://localhost:2900"),
PublicH5Path: getEnvAny([]string{"CHATAPP_INVITE_PUBLIC_H5_PATH", "INVITE_PUBLIC_H5_PATH"}, "/h5/app-invite/landing.html"),
},
RechargeReward: RechargeRewardConfig{
DefaultSysOrigin: strings.ToUpper(getEnvAny(
[]string{"CHATAPP_RECHARGE_REWARD_DEFAULT_SYS_ORIGIN", "RECHARGE_REWARD_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"},
"LIKEI",
)),
Timezone: getEnvAny(
[]string{"CHATAPP_RECHARGE_REWARD_TIMEZONE", "RECHARGE_REWARD_TIMEZONE", "CHATAPP_WEEK_STAR_TIMEZONE", "WEEK_STAR_TIMEZONE"},
"Asia/Riyadh",
),
},
WeekStar: WeekStarConfig{
DefaultSysOrigin: strings.ToUpper(getEnvAny([]string{"CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"}, "LIKEI")),
Timezone: getEnvAny([]string{"CHATAPP_WEEK_STAR_TIMEZONE", "WEEK_STAR_TIMEZONE"}, "Asia/Riyadh"),

View File

@ -12,6 +12,7 @@ type Gateways struct {
Device *DeviceGateway
InviteCode *InviteCodeGateway
Notice *NoticeGateway
Order *OrderGateway
RewardDispatch *RewardDispatchGateway
RewardQuery *RewardQueryGateway
Profile *ProfileGateway
@ -28,6 +29,7 @@ func NewGateways(cfg config.Config) Gateways {
Device: &DeviceGateway{client: client},
InviteCode: &InviteCodeGateway{client: client},
Notice: &NoticeGateway{client: client},
Order: &OrderGateway{client: client},
RewardDispatch: &RewardDispatchGateway{client: client},
RewardQuery: &RewardQueryGateway{client: client},
Profile: &ProfileGateway{client: client},
@ -67,6 +69,16 @@ func (g *Gateways) SendOfficialNoticeCustomize(ctx context.Context, req Official
return g.Notice.SendOfficialNoticeCustomize(ctx, req)
}
// MapUserTotalRecharge 透传到订单充值统计网关。
func (g *Gateways) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]UserTotalRecharge, error) {
return g.Order.MapUserTotalRecharge(ctx, userIDs)
}
// GetThisMonthTotalPersonalRecharge 透传到订单充值统计网关。
func (g *Gateways) GetThisMonthTotalPersonalRecharge(ctx context.Context, userID int64) (DecimalString, error) {
return g.Order.GetThisMonthTotalPersonalRecharge(ctx, userID)
}
// GrantGold 透传到奖励发放网关。
func (g *Gateways) GrantGold(ctx context.Context, req GrantGoldRequest) error {
return g.RewardDispatch.GrantGold(ctx, req)
@ -202,6 +214,21 @@ func (g *NoticeGateway) SendOfficialNoticeCustomize(ctx context.Context, req Off
return g.client.SendOfficialNoticeCustomize(ctx, req)
}
// OrderGateway 负责订单充值统计接口。
type OrderGateway struct {
client *Client
}
// MapUserTotalRecharge 查询用户累计充值分组。
func (g *OrderGateway) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]UserTotalRecharge, error) {
return g.client.MapUserTotalRecharge(ctx, userIDs)
}
// GetThisMonthTotalPersonalRecharge 查询用户本月个人充值。
func (g *OrderGateway) GetThisMonthTotalPersonalRecharge(ctx context.Context, userID int64) (DecimalString, error) {
return g.client.GetThisMonthTotalPersonalRecharge(ctx, userID)
}
// RewardDispatchGateway 负责活动奖励发放接口。
type RewardDispatchGateway struct {
client *Client

View File

@ -192,6 +192,15 @@ type RewardGroupDetail struct {
RewardConfigList []RewardGroupItem `json:"rewardConfigList"`
}
type UserTotalRecharge struct {
ID Int64Value `json:"id"`
UserID Int64Value `json:"userId"`
Type string `json:"type"`
Amount DecimalString `json:"amount"`
CreateTime string `json:"createTime"`
UpdateTime string `json:"updateTime"`
}
type resultResponse[T any] struct {
Code int `json:"code"`
Message string `json:"message"`
@ -326,8 +335,7 @@ func (c *Client) AuthenticateConsoleToken(ctx context.Context, authorization str
}
endpoint := strings.TrimRight(c.cfg.Java.ConsoleBaseURL, "/") + "/account/info"
headers := http.Header{}
headers.Set("Authorization", authorization)
headers := javaConsoleHeaders(authorization)
var resp resultResponse[ConsoleAccount]
if err := c.getJSON(ctx, endpoint, headers, &resp); err != nil {
@ -339,6 +347,18 @@ func (c *Client) AuthenticateConsoleToken(ctx context.Context, authorization str
return resp.Body, nil
}
func javaConsoleHeaders(authorization string) http.Header {
headers := http.Header{}
headers.Set("Authorization", strings.TrimSpace(authorization))
headers.Set("Req-Sys-Origin", "origin=ops;originChild=ops;")
headers.Set("Req-Client", "Ops")
headers.Set("Req-App-Intel", "version=1.0.0;build=1;channel=ops;")
headers.Set("Req-Version", "v2")
headers.Set("Req-Lang", "zh-CN")
headers.Set("Req-Zone", "Asia/Shanghai")
return headers
}
func parseUserCredentialToken(token string) (UserCredential, error) {
token = strings.TrimSpace(token)
if token == "" {
@ -483,6 +503,42 @@ func (c *Client) GetRewardGroupDetail(ctx context.Context, groupID int64) (Rewar
return resp.Body, nil
}
func (c *Client) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]UserTotalRecharge, error) {
if len(userIDs) == 0 {
return map[int64][]UserTotalRecharge{}, nil
}
endpoint := c.cfg.Java.OrderBaseURL + "/total/recharge/client/mapGroup"
var resp resultResponse[map[string][]UserTotalRecharge]
if err := c.postJSON(ctx, endpoint, userIDs, nil, &resp); err != nil {
return nil, err
}
result := make(map[int64][]UserTotalRecharge, len(resp.Body))
for key, value := range resp.Body {
userID, err := strconv.ParseInt(strings.TrimSpace(key), 10, 64)
if err != nil {
continue
}
result[userID] = value
}
return result, nil
}
func (c *Client) GetThisMonthTotalPersonalRecharge(ctx context.Context, userID int64) (DecimalString, error) {
if userID <= 0 {
return DecimalString("0"), nil
}
endpoint := c.cfg.Java.OrderBaseURL + "/user-recharge-count/client/getThisMonthTotalPersonalRecharge?userId=" +
url.QueryEscape(strconv.FormatInt(userID, 10))
var resp resultResponse[DecimalString]
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
return "", err
}
if strings.TrimSpace(string(resp.Body)) == "" {
return DecimalString("0"), nil
}
return resp.Body, nil
}
func (c *Client) GetUserProfile(ctx context.Context, userID int64) (UserProfile, error) {
endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getByUserId?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10))
var resp resultResponse[UserProfile]

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

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