vip
This commit is contained in:
parent
a9ab2b8589
commit
c16c6a7bd9
@ -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{
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
31
internal/model/recharge_reward_models.go
Normal file
31
internal/model/recharge_reward_models.go
Normal 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" }
|
||||
91
internal/model/vip_models.go
Normal file
91
internal/model/vip_models.go
Normal 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" }
|
||||
53
internal/router/recharge_reward_routes.go
Normal file
53
internal/router/recharge_reward_routes.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@ -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)
|
||||
|
||||
132
internal/router/vip_routes.go
Normal file
132
internal/router/vip_routes.go
Normal 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)
|
||||
})
|
||||
}
|
||||
271
internal/service/rechargereward/config.go
Normal file
271
internal/service/rechargereward/config.go
Normal 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
|
||||
}
|
||||
218
internal/service/rechargereward/config_test.go
Normal file
218
internal/service/rechargereward/config_test.go
Normal 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
|
||||
}
|
||||
143
internal/service/rechargereward/home.go
Normal file
143
internal/service/rechargereward/home.go
Normal 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
|
||||
}
|
||||
183
internal/service/rechargereward/mapper.go
Normal file
183
internal/service/rechargereward/mapper.go
Normal 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")
|
||||
}
|
||||
75
internal/service/rechargereward/money.go
Normal file
75
internal/service/rechargereward/money.go
Normal 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
|
||||
300
internal/service/rechargereward/types.go
Normal file
300
internal/service/rechargereward/types.go
Normal 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
|
||||
}
|
||||
166
internal/service/vip/admin.go
Normal file
166
internal/service/vip/admin.go
Normal 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
547
internal/service/vip/app.go
Normal 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
|
||||
}
|
||||
199
internal/service/vip/config.go
Normal file
199
internal/service/vip/config.go
Normal 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
|
||||
}
|
||||
136
internal/service/vip/purchase_test.go
Normal file
136
internal/service/vip/purchase_test.go
Normal 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
|
||||
}
|
||||
398
internal/service/vip/types.go
Normal file
398
internal/service/vip/types.go
Normal 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),
|
||||
}
|
||||
}
|
||||
86
migrations/019_vip_system.sql
Normal file
86
migrations/019_vip_system.sql
Normal 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购买订单记录';
|
||||
29
migrations/020_recharge_reward.sql
Normal file
29
migrations/020_recharge_reward.sql
Normal 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='充值奖励档位配置';
|
||||
Loading…
x
Reference in New Issue
Block a user