完善租客等级免押与积分管理

This commit is contained in:
yml2213
2026-07-26 18:07:10 +08:00
parent 379536e5c5
commit c594b543d1
24 changed files with 1296 additions and 178 deletions
+200 -30
View File
@@ -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 {