vip
This commit is contained in:
parent
a9ab2b8589
commit
c16c6a7bd9
@ -1,6 +1,15 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
_ "time/tzdata"
|
||||||
|
|
||||||
"chatapp3-golang/internal/bootstrap"
|
"chatapp3-golang/internal/bootstrap"
|
||||||
"chatapp3-golang/internal/router"
|
"chatapp3-golang/internal/router"
|
||||||
"chatapp3-golang/internal/service/baishun"
|
"chatapp3-golang/internal/service/baishun"
|
||||||
@ -10,18 +19,12 @@ import (
|
|||||||
"chatapp3-golang/internal/service/invite"
|
"chatapp3-golang/internal/service/invite"
|
||||||
"chatapp3-golang/internal/service/lingxian"
|
"chatapp3-golang/internal/service/lingxian"
|
||||||
"chatapp3-golang/internal/service/luckygift"
|
"chatapp3-golang/internal/service/luckygift"
|
||||||
|
"chatapp3-golang/internal/service/rechargereward"
|
||||||
"chatapp3-golang/internal/service/registerreward"
|
"chatapp3-golang/internal/service/registerreward"
|
||||||
"chatapp3-golang/internal/service/roomturnoverreward"
|
"chatapp3-golang/internal/service/roomturnoverreward"
|
||||||
"chatapp3-golang/internal/service/signinreward"
|
"chatapp3-golang/internal/service/signinreward"
|
||||||
|
"chatapp3-golang/internal/service/vip"
|
||||||
"chatapp3-golang/internal/service/weekstar"
|
"chatapp3-golang/internal/service/weekstar"
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
_ "time/tzdata"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@ -39,7 +42,9 @@ func main() {
|
|||||||
gameOpenService := gameopen.NewGameOpenService(app.Config, app.Repository.DB, &app.Gateways)
|
gameOpenService := gameopen.NewGameOpenService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet)
|
luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet)
|
||||||
registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||||
|
rechargeRewardService := rechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||||
|
vipService := vip.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||||
roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
hostCenterService := hostcenter.NewService(app.Config, &app.Gateways)
|
hostCenterService := hostcenter.NewService(app.Config, &app.Gateways)
|
||||||
@ -62,9 +67,11 @@ func main() {
|
|||||||
GameProviders: gameProviders,
|
GameProviders: gameProviders,
|
||||||
LuckyGift: luckyGiftService,
|
LuckyGift: luckyGiftService,
|
||||||
HostCenter: hostCenterService,
|
HostCenter: hostCenterService,
|
||||||
|
RechargeReward: rechargeRewardService,
|
||||||
RegisterReward: registerRewardService,
|
RegisterReward: registerRewardService,
|
||||||
RoomTurnoverReward: roomTurnoverRewardService,
|
RoomTurnoverReward: roomTurnoverRewardService,
|
||||||
SignInReward: signInRewardService,
|
SignInReward: signInRewardService,
|
||||||
|
VIP: vipService,
|
||||||
WeekStar: weekStarService,
|
WeekStar: weekStarService,
|
||||||
})
|
})
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
|
|||||||
@ -15,6 +15,7 @@ type Config struct {
|
|||||||
Java JavaConfig
|
Java JavaConfig
|
||||||
Worker WorkerConfig
|
Worker WorkerConfig
|
||||||
Invite InviteConfig
|
Invite InviteConfig
|
||||||
|
RechargeReward RechargeRewardConfig
|
||||||
WeekStar WeekStarConfig
|
WeekStar WeekStarConfig
|
||||||
RoomTurnoverReward RoomTurnoverRewardConfig
|
RoomTurnoverReward RoomTurnoverRewardConfig
|
||||||
Baishun BaishunConfig
|
Baishun BaishunConfig
|
||||||
@ -48,6 +49,7 @@ type JavaConfig struct {
|
|||||||
BridgeBaseURL string
|
BridgeBaseURL string
|
||||||
ExternalBaseURL string
|
ExternalBaseURL string
|
||||||
OtherBaseURL string
|
OtherBaseURL string
|
||||||
|
OrderBaseURL string
|
||||||
WalletBaseURL string
|
WalletBaseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,6 +74,12 @@ type InviteConfig struct {
|
|||||||
PublicH5Path string
|
PublicH5Path string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RechargeRewardConfig 保存充值奖励活动配置。
|
||||||
|
type RechargeRewardConfig struct {
|
||||||
|
DefaultSysOrigin string
|
||||||
|
Timezone string
|
||||||
|
}
|
||||||
|
|
||||||
// WeekStarConfig 保存周榜模块配置。
|
// WeekStarConfig 保存周榜模块配置。
|
||||||
type WeekStarConfig struct {
|
type WeekStarConfig struct {
|
||||||
DefaultSysOrigin string
|
DefaultSysOrigin string
|
||||||
@ -170,6 +178,7 @@ func Load() Config {
|
|||||||
BridgeBaseURL: getEnvAny([]string{"CHATAPP_JAVA_BRIDGE_BASE_URL", "INVITE_JAVA_BRIDGE_BASE_URL"}, consoleBaseURL),
|
BridgeBaseURL: getEnvAny([]string{"CHATAPP_JAVA_BRIDGE_BASE_URL", "INVITE_JAVA_BRIDGE_BASE_URL"}, consoleBaseURL),
|
||||||
ExternalBaseURL: getEnvAny([]string{"CHATAPP_JAVA_EXTERNAL_BASE_URL", "INVITE_JAVA_EXTERNAL_BASE_URL"}, "http://127.0.0.1:3000"),
|
ExternalBaseURL: getEnvAny([]string{"CHATAPP_JAVA_EXTERNAL_BASE_URL", "INVITE_JAVA_EXTERNAL_BASE_URL"}, "http://127.0.0.1:3000"),
|
||||||
OtherBaseURL: getEnvAny([]string{"CHATAPP_JAVA_OTHER_BASE_URL", "GAME_JAVA_OTHER_BASE_URL", "INVITE_JAVA_APP_BASE_URL"}, javaAppBaseURL),
|
OtherBaseURL: getEnvAny([]string{"CHATAPP_JAVA_OTHER_BASE_URL", "GAME_JAVA_OTHER_BASE_URL", "INVITE_JAVA_APP_BASE_URL"}, javaAppBaseURL),
|
||||||
|
OrderBaseURL: getEnvAny([]string{"CHATAPP_JAVA_ORDER_BASE_URL", "GAME_JAVA_ORDER_BASE_URL"}, "http://127.0.0.1:2600"),
|
||||||
WalletBaseURL: getEnvAny([]string{"CHATAPP_JAVA_WALLET_BASE_URL", "GAME_JAVA_WALLET_BASE_URL"}, "http://127.0.0.1:2300"),
|
WalletBaseURL: getEnvAny([]string{"CHATAPP_JAVA_WALLET_BASE_URL", "GAME_JAVA_WALLET_BASE_URL"}, "http://127.0.0.1:2300"),
|
||||||
},
|
},
|
||||||
Worker: WorkerConfig{
|
Worker: WorkerConfig{
|
||||||
@ -186,6 +195,16 @@ func Load() Config {
|
|||||||
PublicBaseURL: getEnvAny([]string{"CHATAPP_INVITE_PUBLIC_BASE_URL", "INVITE_PUBLIC_BASE_URL"}, "http://localhost:2900"),
|
PublicBaseURL: getEnvAny([]string{"CHATAPP_INVITE_PUBLIC_BASE_URL", "INVITE_PUBLIC_BASE_URL"}, "http://localhost:2900"),
|
||||||
PublicH5Path: getEnvAny([]string{"CHATAPP_INVITE_PUBLIC_H5_PATH", "INVITE_PUBLIC_H5_PATH"}, "/h5/app-invite/landing.html"),
|
PublicH5Path: getEnvAny([]string{"CHATAPP_INVITE_PUBLIC_H5_PATH", "INVITE_PUBLIC_H5_PATH"}, "/h5/app-invite/landing.html"),
|
||||||
},
|
},
|
||||||
|
RechargeReward: RechargeRewardConfig{
|
||||||
|
DefaultSysOrigin: strings.ToUpper(getEnvAny(
|
||||||
|
[]string{"CHATAPP_RECHARGE_REWARD_DEFAULT_SYS_ORIGIN", "RECHARGE_REWARD_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"},
|
||||||
|
"LIKEI",
|
||||||
|
)),
|
||||||
|
Timezone: getEnvAny(
|
||||||
|
[]string{"CHATAPP_RECHARGE_REWARD_TIMEZONE", "RECHARGE_REWARD_TIMEZONE", "CHATAPP_WEEK_STAR_TIMEZONE", "WEEK_STAR_TIMEZONE"},
|
||||||
|
"Asia/Riyadh",
|
||||||
|
),
|
||||||
|
},
|
||||||
WeekStar: WeekStarConfig{
|
WeekStar: WeekStarConfig{
|
||||||
DefaultSysOrigin: strings.ToUpper(getEnvAny([]string{"CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"}, "LIKEI")),
|
DefaultSysOrigin: strings.ToUpper(getEnvAny([]string{"CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"}, "LIKEI")),
|
||||||
Timezone: getEnvAny([]string{"CHATAPP_WEEK_STAR_TIMEZONE", "WEEK_STAR_TIMEZONE"}, "Asia/Riyadh"),
|
Timezone: getEnvAny([]string{"CHATAPP_WEEK_STAR_TIMEZONE", "WEEK_STAR_TIMEZONE"}, "Asia/Riyadh"),
|
||||||
|
|||||||
@ -12,6 +12,7 @@ type Gateways struct {
|
|||||||
Device *DeviceGateway
|
Device *DeviceGateway
|
||||||
InviteCode *InviteCodeGateway
|
InviteCode *InviteCodeGateway
|
||||||
Notice *NoticeGateway
|
Notice *NoticeGateway
|
||||||
|
Order *OrderGateway
|
||||||
RewardDispatch *RewardDispatchGateway
|
RewardDispatch *RewardDispatchGateway
|
||||||
RewardQuery *RewardQueryGateway
|
RewardQuery *RewardQueryGateway
|
||||||
Profile *ProfileGateway
|
Profile *ProfileGateway
|
||||||
@ -28,6 +29,7 @@ func NewGateways(cfg config.Config) Gateways {
|
|||||||
Device: &DeviceGateway{client: client},
|
Device: &DeviceGateway{client: client},
|
||||||
InviteCode: &InviteCodeGateway{client: client},
|
InviteCode: &InviteCodeGateway{client: client},
|
||||||
Notice: &NoticeGateway{client: client},
|
Notice: &NoticeGateway{client: client},
|
||||||
|
Order: &OrderGateway{client: client},
|
||||||
RewardDispatch: &RewardDispatchGateway{client: client},
|
RewardDispatch: &RewardDispatchGateway{client: client},
|
||||||
RewardQuery: &RewardQueryGateway{client: client},
|
RewardQuery: &RewardQueryGateway{client: client},
|
||||||
Profile: &ProfileGateway{client: client},
|
Profile: &ProfileGateway{client: client},
|
||||||
@ -67,6 +69,16 @@ func (g *Gateways) SendOfficialNoticeCustomize(ctx context.Context, req Official
|
|||||||
return g.Notice.SendOfficialNoticeCustomize(ctx, req)
|
return g.Notice.SendOfficialNoticeCustomize(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MapUserTotalRecharge 透传到订单充值统计网关。
|
||||||
|
func (g *Gateways) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]UserTotalRecharge, error) {
|
||||||
|
return g.Order.MapUserTotalRecharge(ctx, userIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetThisMonthTotalPersonalRecharge 透传到订单充值统计网关。
|
||||||
|
func (g *Gateways) GetThisMonthTotalPersonalRecharge(ctx context.Context, userID int64) (DecimalString, error) {
|
||||||
|
return g.Order.GetThisMonthTotalPersonalRecharge(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
// GrantGold 透传到奖励发放网关。
|
// GrantGold 透传到奖励发放网关。
|
||||||
func (g *Gateways) GrantGold(ctx context.Context, req GrantGoldRequest) error {
|
func (g *Gateways) GrantGold(ctx context.Context, req GrantGoldRequest) error {
|
||||||
return g.RewardDispatch.GrantGold(ctx, req)
|
return g.RewardDispatch.GrantGold(ctx, req)
|
||||||
@ -202,6 +214,21 @@ func (g *NoticeGateway) SendOfficialNoticeCustomize(ctx context.Context, req Off
|
|||||||
return g.client.SendOfficialNoticeCustomize(ctx, req)
|
return g.client.SendOfficialNoticeCustomize(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OrderGateway 负责订单充值统计接口。
|
||||||
|
type OrderGateway struct {
|
||||||
|
client *Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapUserTotalRecharge 查询用户累计充值分组。
|
||||||
|
func (g *OrderGateway) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]UserTotalRecharge, error) {
|
||||||
|
return g.client.MapUserTotalRecharge(ctx, userIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetThisMonthTotalPersonalRecharge 查询用户本月个人充值。
|
||||||
|
func (g *OrderGateway) GetThisMonthTotalPersonalRecharge(ctx context.Context, userID int64) (DecimalString, error) {
|
||||||
|
return g.client.GetThisMonthTotalPersonalRecharge(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
// RewardDispatchGateway 负责活动奖励发放接口。
|
// RewardDispatchGateway 负责活动奖励发放接口。
|
||||||
type RewardDispatchGateway struct {
|
type RewardDispatchGateway struct {
|
||||||
client *Client
|
client *Client
|
||||||
|
|||||||
@ -192,6 +192,15 @@ type RewardGroupDetail struct {
|
|||||||
RewardConfigList []RewardGroupItem `json:"rewardConfigList"`
|
RewardConfigList []RewardGroupItem `json:"rewardConfigList"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserTotalRecharge struct {
|
||||||
|
ID Int64Value `json:"id"`
|
||||||
|
UserID Int64Value `json:"userId"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Amount DecimalString `json:"amount"`
|
||||||
|
CreateTime string `json:"createTime"`
|
||||||
|
UpdateTime string `json:"updateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
type resultResponse[T any] struct {
|
type resultResponse[T any] struct {
|
||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
@ -326,8 +335,7 @@ func (c *Client) AuthenticateConsoleToken(ctx context.Context, authorization str
|
|||||||
}
|
}
|
||||||
|
|
||||||
endpoint := strings.TrimRight(c.cfg.Java.ConsoleBaseURL, "/") + "/account/info"
|
endpoint := strings.TrimRight(c.cfg.Java.ConsoleBaseURL, "/") + "/account/info"
|
||||||
headers := http.Header{}
|
headers := javaConsoleHeaders(authorization)
|
||||||
headers.Set("Authorization", authorization)
|
|
||||||
|
|
||||||
var resp resultResponse[ConsoleAccount]
|
var resp resultResponse[ConsoleAccount]
|
||||||
if err := c.getJSON(ctx, endpoint, headers, &resp); err != nil {
|
if err := c.getJSON(ctx, endpoint, headers, &resp); err != nil {
|
||||||
@ -339,6 +347,18 @@ func (c *Client) AuthenticateConsoleToken(ctx context.Context, authorization str
|
|||||||
return resp.Body, nil
|
return resp.Body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func javaConsoleHeaders(authorization string) http.Header {
|
||||||
|
headers := http.Header{}
|
||||||
|
headers.Set("Authorization", strings.TrimSpace(authorization))
|
||||||
|
headers.Set("Req-Sys-Origin", "origin=ops;originChild=ops;")
|
||||||
|
headers.Set("Req-Client", "Ops")
|
||||||
|
headers.Set("Req-App-Intel", "version=1.0.0;build=1;channel=ops;")
|
||||||
|
headers.Set("Req-Version", "v2")
|
||||||
|
headers.Set("Req-Lang", "zh-CN")
|
||||||
|
headers.Set("Req-Zone", "Asia/Shanghai")
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
func parseUserCredentialToken(token string) (UserCredential, error) {
|
func parseUserCredentialToken(token string) (UserCredential, error) {
|
||||||
token = strings.TrimSpace(token)
|
token = strings.TrimSpace(token)
|
||||||
if token == "" {
|
if token == "" {
|
||||||
@ -483,6 +503,42 @@ func (c *Client) GetRewardGroupDetail(ctx context.Context, groupID int64) (Rewar
|
|||||||
return resp.Body, nil
|
return resp.Body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]UserTotalRecharge, error) {
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return map[int64][]UserTotalRecharge{}, nil
|
||||||
|
}
|
||||||
|
endpoint := c.cfg.Java.OrderBaseURL + "/total/recharge/client/mapGroup"
|
||||||
|
var resp resultResponse[map[string][]UserTotalRecharge]
|
||||||
|
if err := c.postJSON(ctx, endpoint, userIDs, nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make(map[int64][]UserTotalRecharge, len(resp.Body))
|
||||||
|
for key, value := range resp.Body {
|
||||||
|
userID, err := strconv.ParseInt(strings.TrimSpace(key), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[userID] = value
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetThisMonthTotalPersonalRecharge(ctx context.Context, userID int64) (DecimalString, error) {
|
||||||
|
if userID <= 0 {
|
||||||
|
return DecimalString("0"), nil
|
||||||
|
}
|
||||||
|
endpoint := c.cfg.Java.OrderBaseURL + "/user-recharge-count/client/getThisMonthTotalPersonalRecharge?userId=" +
|
||||||
|
url.QueryEscape(strconv.FormatInt(userID, 10))
|
||||||
|
var resp resultResponse[DecimalString]
|
||||||
|
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(string(resp.Body)) == "" {
|
||||||
|
return DecimalString("0"), nil
|
||||||
|
}
|
||||||
|
return resp.Body, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) GetUserProfile(ctx context.Context, userID int64) (UserProfile, error) {
|
func (c *Client) GetUserProfile(ctx context.Context, userID int64) (UserProfile, error) {
|
||||||
endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getByUserId?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10))
|
endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getByUserId?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10))
|
||||||
var resp resultResponse[UserProfile]
|
var resp resultResponse[UserProfile]
|
||||||
|
|||||||
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/invite"
|
||||||
"chatapp3-golang/internal/service/lingxian"
|
"chatapp3-golang/internal/service/lingxian"
|
||||||
"chatapp3-golang/internal/service/luckygift"
|
"chatapp3-golang/internal/service/luckygift"
|
||||||
|
"chatapp3-golang/internal/service/rechargereward"
|
||||||
"chatapp3-golang/internal/service/registerreward"
|
"chatapp3-golang/internal/service/registerreward"
|
||||||
"chatapp3-golang/internal/service/roomturnoverreward"
|
"chatapp3-golang/internal/service/roomturnoverreward"
|
||||||
"chatapp3-golang/internal/service/signinreward"
|
"chatapp3-golang/internal/service/signinreward"
|
||||||
|
"chatapp3-golang/internal/service/vip"
|
||||||
"chatapp3-golang/internal/service/weekstar"
|
"chatapp3-golang/internal/service/weekstar"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@ -30,9 +32,11 @@ type Services struct {
|
|||||||
GameProviders *gameprovider.Registry
|
GameProviders *gameprovider.Registry
|
||||||
LuckyGift *luckygift.LuckyGiftService
|
LuckyGift *luckygift.LuckyGiftService
|
||||||
HostCenter *hostcenter.Service
|
HostCenter *hostcenter.Service
|
||||||
|
RechargeReward *rechargereward.Service
|
||||||
RegisterReward *registerreward.RegisterRewardService
|
RegisterReward *registerreward.RegisterRewardService
|
||||||
RoomTurnoverReward *roomturnoverreward.Service
|
RoomTurnoverReward *roomturnoverreward.Service
|
||||||
SignInReward *signinreward.SignInRewardService
|
SignInReward *signinreward.SignInRewardService
|
||||||
|
VIP *vip.Service
|
||||||
WeekStar *weekstar.WeekStarService
|
WeekStar *weekstar.WeekStarService
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,8 +52,10 @@ func NewRouter(
|
|||||||
|
|
||||||
registerHealthRoutes(engine, cfg, repository)
|
registerHealthRoutes(engine, cfg, repository)
|
||||||
registerInviteRoutes(engine, javaClient, services.Invite)
|
registerInviteRoutes(engine, javaClient, services.Invite)
|
||||||
|
registerRechargeRewardRoutes(engine, javaClient, services.RechargeReward)
|
||||||
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
|
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
|
||||||
registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
|
registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
|
||||||
|
registerVipRoutes(engine, javaClient, services.VIP)
|
||||||
registerWeekStarRoutes(engine, javaClient, services.WeekStar)
|
registerWeekStarRoutes(engine, javaClient, services.WeekStar)
|
||||||
registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward)
|
registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward)
|
||||||
registerHostCenterRoutes(engine, services.HostCenter)
|
registerHostCenterRoutes(engine, services.HostCenter)
|
||||||
|
|||||||
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