完善租客等级免押与积分管理
This commit is contained in:
@@ -14,14 +14,31 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
ConfigKey = "renter.growth_level_rules"
|
||||
DefaultLevelCode = "normal"
|
||||
DefaultLevelName = "普通"
|
||||
DefaultDiscountBps = 10000
|
||||
DefaultPointsPerYuan = 1
|
||||
SourceOrderCompleted = "order_completed"
|
||||
ConfigKey = "renter.growth_level_rules"
|
||||
DefaultLevelCode = "normal"
|
||||
DefaultLevelName = "普通"
|
||||
DefaultDiscountBps = 10000
|
||||
DefaultPointsPerYuan = 1
|
||||
DefaultPlatinumQuotaCent = 50_000
|
||||
DefaultDiamondQuotaCent = 100_000
|
||||
DefaultPeakQuotaCent = 150_000
|
||||
MaxPointsPerYuan = 1_000
|
||||
MaxLevelPoints = 1_000_000_000
|
||||
MaxLevelQuotaCent = 100_000_000
|
||||
SourceOrderCompleted = "order_completed"
|
||||
SourceAdminAdjustment = "admin_adjustment"
|
||||
)
|
||||
|
||||
var fixedLevels = []struct {
|
||||
Code string
|
||||
Name string
|
||||
}{
|
||||
{Code: DefaultLevelCode, Name: DefaultLevelName},
|
||||
{Code: "platinum", Name: "铂金"},
|
||||
{Code: "diamond", Name: "钻石"},
|
||||
{Code: "peak", Name: "巅峰"},
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
PointsPerYuan int64 `json:"points_per_yuan"`
|
||||
@@ -29,17 +46,21 @@ type Config struct {
|
||||
}
|
||||
|
||||
type LevelRule struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
MinPoints int64 `json:"min_points"`
|
||||
DiscountBps int `json:"discount_bps"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
MinPoints int64 `json:"min_points"`
|
||||
DiscountBps int `json:"discount_bps"`
|
||||
DepositFreeQuotaCent int64 `json:"deposit_free_quota_cent"`
|
||||
}
|
||||
|
||||
type DiscountSnapshot struct {
|
||||
LevelCode string
|
||||
LevelName string
|
||||
DiscountBps int
|
||||
UserPoints int64
|
||||
LevelCode string
|
||||
LevelName string
|
||||
DiscountBps int
|
||||
UserPoints int64
|
||||
LevelDepositFreeQuotaCent int64
|
||||
ManualDepositFreeQuotaCent int64
|
||||
EffectiveDepositFreeQuotaCent int64
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
@@ -47,14 +68,41 @@ func DefaultConfig() Config {
|
||||
Enabled: true,
|
||||
PointsPerYuan: DefaultPointsPerYuan,
|
||||
Levels: []LevelRule{
|
||||
{Code: DefaultLevelCode, Name: DefaultLevelName, MinPoints: 0, DiscountBps: DefaultDiscountBps},
|
||||
{Code: "platinum", Name: "铂金", MinPoints: 300, DiscountBps: 9900},
|
||||
{Code: "diamond", Name: "钻石", MinPoints: 1000, DiscountBps: 9800},
|
||||
{Code: "peak", Name: "巅峰", MinPoints: 5000, DiscountBps: 9500},
|
||||
{Code: DefaultLevelCode, Name: DefaultLevelName, MinPoints: 0, DiscountBps: DefaultDiscountBps, DepositFreeQuotaCent: 0},
|
||||
{Code: "platinum", Name: "铂金", MinPoints: 300, DiscountBps: 9900, DepositFreeQuotaCent: DefaultPlatinumQuotaCent},
|
||||
{Code: "diamond", Name: "钻石", MinPoints: 1000, DiscountBps: 9800, DepositFreeQuotaCent: DefaultDiamondQuotaCent},
|
||||
{Code: "peak", Name: "巅峰", MinPoints: 5000, DiscountBps: 9500, DepositFreeQuotaCent: DefaultPeakQuotaCent},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// UnmarshalJSON 为旧配置补齐等级免押默认值,同时保留后台显式配置的 0 元额度。
|
||||
func (level *LevelRule) UnmarshalJSON(data []byte) error {
|
||||
type rawLevelRule struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
MinPoints int64 `json:"min_points"`
|
||||
DiscountBps int `json:"discount_bps"`
|
||||
DepositFreeQuotaCent *int64 `json:"deposit_free_quota_cent"`
|
||||
}
|
||||
var raw rawLevelRule
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
quotaCent := defaultDepositFreeQuotaCent(raw.Code)
|
||||
if raw.DepositFreeQuotaCent != nil {
|
||||
quotaCent = *raw.DepositFreeQuotaCent
|
||||
}
|
||||
*level = LevelRule{
|
||||
Code: raw.Code,
|
||||
Name: raw.Name,
|
||||
MinPoints: raw.MinPoints,
|
||||
DiscountBps: raw.DiscountBps,
|
||||
DepositFreeQuotaCent: quotaCent,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DefaultConfigValue() string {
|
||||
raw, err := json.Marshal(DefaultConfig())
|
||||
if err != nil {
|
||||
@@ -63,6 +111,63 @@ func DefaultConfigValue() string {
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// ParseConfigValue 严格解析后台提交的成长等级配置。
|
||||
func ParseConfigValue(value string) (Config, error) {
|
||||
var cfg Config
|
||||
if err := json.Unmarshal([]byte(value), &cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := ValidateConfig(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return normalizeConfig(cfg), nil
|
||||
}
|
||||
|
||||
func MarshalConfig(cfg Config) (string, error) {
|
||||
raw, err := json.Marshal(normalizeConfig(cfg))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
// ValidateConfig 保证四个固定等级完整,且权益不会随等级提升而倒退。
|
||||
func ValidateConfig(cfg Config) error {
|
||||
if cfg.PointsPerYuan <= 0 || cfg.PointsPerYuan > MaxPointsPerYuan || len(cfg.Levels) != len(fixedLevels) {
|
||||
return errors.New("invalid renter growth config")
|
||||
}
|
||||
byCode := make(map[string]LevelRule, len(cfg.Levels))
|
||||
for _, level := range cfg.Levels {
|
||||
level.Code = strings.TrimSpace(level.Code)
|
||||
if _, exists := byCode[level.Code]; exists {
|
||||
return errors.New("duplicate renter growth level")
|
||||
}
|
||||
byCode[level.Code] = level
|
||||
}
|
||||
var previous LevelRule
|
||||
for index, fixed := range fixedLevels {
|
||||
level, ok := byCode[fixed.Code]
|
||||
if !ok || strings.TrimSpace(level.Name) != fixed.Name {
|
||||
return errors.New("missing renter growth level")
|
||||
}
|
||||
if level.MinPoints < 0 || level.MinPoints > MaxLevelPoints || level.DiscountBps <= 0 || level.DiscountBps > DefaultDiscountBps || level.DepositFreeQuotaCent < 0 || level.DepositFreeQuotaCent > MaxLevelQuotaCent {
|
||||
return errors.New("invalid renter growth level value")
|
||||
}
|
||||
if index == 0 {
|
||||
if level.MinPoints != 0 || level.DiscountBps != DefaultDiscountBps || level.DepositFreeQuotaCent != 0 {
|
||||
return errors.New("invalid normal renter growth level")
|
||||
}
|
||||
previous = level
|
||||
continue
|
||||
}
|
||||
if level.MinPoints <= previous.MinPoints || level.DiscountBps > previous.DiscountBps || level.DepositFreeQuotaCent < previous.DepositFreeQuotaCent {
|
||||
return errors.New("renter growth levels must increase")
|
||||
}
|
||||
previous = level
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ConfigForTx(tx *gorm.DB) (Config, error) {
|
||||
cfg := DefaultConfig()
|
||||
if tx == nil {
|
||||
@@ -97,19 +202,26 @@ func SnapshotForUser(tx *gorm.DB, userID uint64) (DiscountSnapshot, error) {
|
||||
if err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
return snapshot, nil
|
||||
}
|
||||
var user model.User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
manualQuotaCent := maxInt64(user.DepositFreeQuotaCent, 0)
|
||||
snapshot.UserPoints = user.RenterGrowthPoints
|
||||
snapshot.ManualDepositFreeQuotaCent = manualQuotaCent
|
||||
snapshot.EffectiveDepositFreeQuotaCent = manualQuotaCent
|
||||
if !cfg.Enabled {
|
||||
return snapshot, nil
|
||||
}
|
||||
level := LevelForPoints(cfg, user.RenterGrowthPoints)
|
||||
return DiscountSnapshot{
|
||||
LevelCode: level.Code,
|
||||
LevelName: level.Name,
|
||||
DiscountBps: level.DiscountBps,
|
||||
UserPoints: user.RenterGrowthPoints,
|
||||
LevelCode: level.Code,
|
||||
LevelName: level.Name,
|
||||
DiscountBps: level.DiscountBps,
|
||||
UserPoints: user.RenterGrowthPoints,
|
||||
LevelDepositFreeQuotaCent: level.DepositFreeQuotaCent,
|
||||
ManualDepositFreeQuotaCent: manualQuotaCent,
|
||||
EffectiveDepositFreeQuotaCent: addQuotaCent(level.DepositFreeQuotaCent, manualQuotaCent),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -182,7 +294,7 @@ func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualRentCent i
|
||||
now := time.Now()
|
||||
ledger := model.RenterGrowthLedger{
|
||||
UserID: order.RenterID,
|
||||
OrderID: order.ID,
|
||||
OrderID: &order.ID,
|
||||
Points: points,
|
||||
BeforePoints: beforePoints,
|
||||
AfterPoints: afterPoints,
|
||||
@@ -224,6 +336,30 @@ func LevelForPoints(cfg Config, points int64) LevelRule {
|
||||
return current
|
||||
}
|
||||
|
||||
func EffectiveDepositFreeQuotaCent(levelQuotaCent int64, manualQuotaCent int64) int64 {
|
||||
return addQuotaCent(levelQuotaCent, manualQuotaCent)
|
||||
}
|
||||
|
||||
// SyncAllUserLevels 在等级门槛调整后同步用户表中的展示缓存,订单仍以积分实时计算为准。
|
||||
func SyncAllUserLevels(tx *gorm.DB, cfg Config) error {
|
||||
if tx == nil {
|
||||
return nil
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
return tx.Model(&model.User{}).Where("renter_growth_level <> ?", DefaultLevelCode).Update("renter_growth_level", DefaultLevelCode).Error
|
||||
}
|
||||
cfg = normalizeConfig(cfg)
|
||||
caseSQL := "CASE"
|
||||
args := make([]any, 0, len(cfg.Levels)*2+1)
|
||||
for index := len(cfg.Levels) - 1; index >= 1; index-- {
|
||||
caseSQL += " WHEN renter_growth_points >= ? THEN ?"
|
||||
args = append(args, cfg.Levels[index].MinPoints, cfg.Levels[index].Code)
|
||||
}
|
||||
caseSQL += " ELSE ? END"
|
||||
args = append(args, DefaultLevelCode)
|
||||
return tx.Model(&model.User{}).Where("1 = 1").Update("renter_growth_level", gorm.Expr(caseSQL, args...)).Error
|
||||
}
|
||||
|
||||
func normalizeConfig(cfg Config) Config {
|
||||
defaultCfg := DefaultConfig()
|
||||
if cfg.PointsPerYuan <= 0 {
|
||||
@@ -249,6 +385,9 @@ func normalizeConfig(cfg Config) Config {
|
||||
if level.DiscountBps <= 0 || level.DiscountBps > DefaultDiscountBps {
|
||||
level.DiscountBps = DefaultDiscountBps
|
||||
}
|
||||
if level.DepositFreeQuotaCent < 0 {
|
||||
level.DepositFreeQuotaCent = 0
|
||||
}
|
||||
if level.Code == DefaultLevelCode {
|
||||
hasNormal = true
|
||||
level.Name = DefaultLevelName
|
||||
@@ -258,10 +397,11 @@ func normalizeConfig(cfg Config) Config {
|
||||
}
|
||||
if !hasNormal {
|
||||
levels = append(levels, LevelRule{
|
||||
Code: DefaultLevelCode,
|
||||
Name: DefaultLevelName,
|
||||
MinPoints: 0,
|
||||
DiscountBps: DefaultDiscountBps,
|
||||
Code: DefaultLevelCode,
|
||||
Name: DefaultLevelName,
|
||||
MinPoints: 0,
|
||||
DiscountBps: DefaultDiscountBps,
|
||||
DepositFreeQuotaCent: 0,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(levels, func(i, j int) bool {
|
||||
@@ -271,6 +411,36 @@ func normalizeConfig(cfg Config) Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
func defaultDepositFreeQuotaCent(code string) int64 {
|
||||
switch strings.TrimSpace(code) {
|
||||
case "platinum":
|
||||
return DefaultPlatinumQuotaCent
|
||||
case "diamond":
|
||||
return DefaultDiamondQuotaCent
|
||||
case "peak":
|
||||
return DefaultPeakQuotaCent
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func addQuotaCent(left int64, right int64) int64 {
|
||||
left = maxInt64(left, 0)
|
||||
right = maxInt64(right, 0)
|
||||
const maxInt64Value = int64(^uint64(0) >> 1)
|
||||
if left > maxInt64Value-right {
|
||||
return maxInt64Value
|
||||
}
|
||||
return left + right
|
||||
}
|
||||
|
||||
func maxInt64(left int64, right int64) int64 {
|
||||
if left > right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
func isMissingConfigTableError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
|
||||
@@ -1,12 +1,99 @@
|
||||
package rentergrowth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"hfb_sys/backend/internal/database"
|
||||
"hfb_sys/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestDefaultConfigIncludesDepositFreeQuota(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
wants := map[string]int64{
|
||||
DefaultLevelCode: 0,
|
||||
"platinum": DefaultPlatinumQuotaCent,
|
||||
"diamond": DefaultDiamondQuotaCent,
|
||||
"peak": DefaultPeakQuotaCent,
|
||||
}
|
||||
for _, level := range cfg.Levels {
|
||||
if level.DepositFreeQuotaCent != wants[level.Code] {
|
||||
t.Fatalf("等级 %s 免押额度 = %d, want %d", level.Code, level.DepositFreeQuotaCent, wants[level.Code])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyConfigBackfillsQuotaButKeepsExplicitZero(t *testing.T) {
|
||||
legacy := `{"enabled":true,"points_per_yuan":1,"levels":[{"code":"normal","name":"普通","min_points":0,"discount_bps":10000},{"code":"platinum","name":"铂金","min_points":300,"discount_bps":9900},{"code":"diamond","name":"钻石","min_points":1000,"discount_bps":9800},{"code":"peak","name":"巅峰","min_points":5000,"discount_bps":9500}]}`
|
||||
cfg, err := ParseConfigValue(legacy)
|
||||
if err != nil {
|
||||
t.Fatalf("解析旧配置失败:%v", err)
|
||||
}
|
||||
if LevelForPoints(cfg, 300).DepositFreeQuotaCent != DefaultPlatinumQuotaCent {
|
||||
t.Fatalf("旧配置未补齐铂金免押额度")
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal([]byte(legacy), &raw); err != nil {
|
||||
t.Fatalf("解析测试 JSON 失败:%v", err)
|
||||
}
|
||||
levels := raw["levels"].([]any)
|
||||
levels[1].(map[string]any)["deposit_free_quota_cent"] = float64(0)
|
||||
explicitZero, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("生成测试配置失败:%v", err)
|
||||
}
|
||||
cfg, err = ParseConfigValue(string(explicitZero))
|
||||
if err != nil {
|
||||
t.Fatalf("解析显式零额度配置失败:%v", err)
|
||||
}
|
||||
if LevelForPoints(cfg, 300).DepositFreeQuotaCent != 0 {
|
||||
t.Fatalf("显式配置的零额度被默认值覆盖")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotCombinesLevelAndManualDepositQuota(t *testing.T) {
|
||||
db := database.NewTestDB()
|
||||
if err := db.AutoMigrate(&model.User{}, &model.SystemConfig{}); err != nil {
|
||||
t.Fatalf("migrate failed: %v", err)
|
||||
}
|
||||
user := model.User{Phone: "13800009999", RenterGrowthPoints: 1000, DepositFreeQuotaCent: 30_000}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("create user failed: %v", err)
|
||||
}
|
||||
snapshot, err := SnapshotForUser(db, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("SnapshotForUser() error = %v", err)
|
||||
}
|
||||
if snapshot.LevelDepositFreeQuotaCent != DefaultDiamondQuotaCent || snapshot.ManualDepositFreeQuotaCent != 30_000 || snapshot.EffectiveDepositFreeQuotaCent != 130_000 {
|
||||
t.Fatalf("免押额度快照不正确:%+v", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncAllUserLevelsUsesUpdatedThresholds(t *testing.T) {
|
||||
db := database.NewTestDB()
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
t.Fatalf("migrate failed: %v", err)
|
||||
}
|
||||
users := []model.User{
|
||||
{Phone: "13800009001", RenterGrowthPoints: 2000, RenterGrowthLevel: DefaultLevelCode},
|
||||
{Phone: "13800009002", RenterGrowthPoints: 6000, RenterGrowthLevel: DefaultLevelCode},
|
||||
}
|
||||
if err := db.Create(&users).Error; err != nil {
|
||||
t.Fatalf("create users failed: %v", err)
|
||||
}
|
||||
if err := SyncAllUserLevels(db, DefaultConfig()); err != nil {
|
||||
t.Fatalf("SyncAllUserLevels() error = %v", err)
|
||||
}
|
||||
var saved []model.User
|
||||
if err := db.Order("id ASC").Find(&saved).Error; err != nil {
|
||||
t.Fatalf("find users failed: %v", err)
|
||||
}
|
||||
if saved[0].RenterGrowthLevel != "diamond" || saved[1].RenterGrowthLevel != "peak" {
|
||||
t.Fatalf("同步后等级不正确:%s/%s", saved[0].RenterGrowthLevel, saved[1].RenterGrowthLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevelForPointsStartsFromNormal(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cases := []struct {
|
||||
|
||||
Reference in New Issue
Block a user