534 lines
16 KiB
Go
534 lines
16 KiB
Go
package rentergrowth
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"hfb_sys/backend/internal/model"
|
|
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
const (
|
|
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"
|
|
SourceOrderRefunded = "order_refunded"
|
|
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"`
|
|
Levels []LevelRule `json:"levels"`
|
|
}
|
|
|
|
type LevelRule 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"`
|
|
}
|
|
|
|
type DiscountSnapshot struct {
|
|
LevelCode string
|
|
LevelName string
|
|
DiscountBps int
|
|
UserPoints int64
|
|
LevelDepositFreeQuotaCent int64
|
|
ManualDepositFreeQuotaCent int64
|
|
EffectiveDepositFreeQuotaCent int64
|
|
}
|
|
|
|
func DefaultConfig() Config {
|
|
return Config{
|
|
Enabled: true,
|
|
PointsPerYuan: DefaultPointsPerYuan,
|
|
Levels: []LevelRule{
|
|
{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 {
|
|
return `{"enabled":true,"points_per_yuan":1,"levels":[{"code":"normal","name":"普通","min_points":0,"discount_bps":10000}]}`
|
|
}
|
|
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 {
|
|
return cfg, nil
|
|
}
|
|
var row model.SystemConfig
|
|
if err := tx.Where("`key` = ?", ConfigKey).First(&row).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) || isMissingConfigTableError(err) {
|
|
return cfg, nil
|
|
}
|
|
return cfg, err
|
|
}
|
|
if strings.TrimSpace(row.Value) == "" {
|
|
return cfg, nil
|
|
}
|
|
if err := json.Unmarshal([]byte(row.Value), &cfg); err != nil {
|
|
return DefaultConfig(), nil
|
|
}
|
|
return normalizeConfig(cfg), nil
|
|
}
|
|
|
|
func SnapshotForUser(tx *gorm.DB, userID uint64) (DiscountSnapshot, error) {
|
|
snapshot := DiscountSnapshot{
|
|
LevelCode: DefaultLevelCode,
|
|
LevelName: DefaultLevelName,
|
|
DiscountBps: DefaultDiscountBps,
|
|
}
|
|
if tx == nil || userID == 0 {
|
|
return snapshot, nil
|
|
}
|
|
cfg, err := ConfigForTx(tx)
|
|
if err != nil {
|
|
return snapshot, err
|
|
}
|
|
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,
|
|
LevelDepositFreeQuotaCent: level.DepositFreeQuotaCent,
|
|
ManualDepositFreeQuotaCent: manualQuotaCent,
|
|
EffectiveDepositFreeQuotaCent: addQuotaCent(level.DepositFreeQuotaCent, manualQuotaCent),
|
|
}, nil
|
|
}
|
|
|
|
func CalculateDiscountCent(originalRentCent int64, platformFeeCent int64, discountBps int) int64 {
|
|
if originalRentCent <= 0 || platformFeeCent <= 0 {
|
|
return 0
|
|
}
|
|
if discountBps <= 0 || discountBps >= DefaultDiscountBps {
|
|
return 0
|
|
}
|
|
discountedRentCent := originalRentCent * int64(discountBps) / DefaultDiscountBps
|
|
discountCent := originalRentCent - discountedRentCent
|
|
if discountCent < 0 {
|
|
return 0
|
|
}
|
|
if discountCent > platformFeeCent {
|
|
return platformFeeCent
|
|
}
|
|
return discountCent
|
|
}
|
|
|
|
func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualPureCoinCent int64, source string) error {
|
|
if tx == nil || order == nil || order.ID == 0 || order.RenterID == 0 {
|
|
return nil
|
|
}
|
|
if order.GrowthPointsAwarded > 0 || order.GrowthPointsAwardedAt != nil {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(source) == "" {
|
|
source = SourceOrderCompleted
|
|
}
|
|
var existing model.RenterGrowthLedger
|
|
err := tx.Where("order_id = ? AND source = ?", order.ID, source).First(&existing).Error
|
|
if err == nil {
|
|
order.GrowthPointsAwarded = existing.Points
|
|
if !existing.CreatedAt.IsZero() {
|
|
awardedAt := existing.CreatedAt
|
|
order.GrowthPointsAwardedAt = &awardedAt
|
|
}
|
|
return nil
|
|
}
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return err
|
|
}
|
|
|
|
cfg, err := ConfigForTx(tx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
order.GrowthPointsBasisCent = maxInt64(actualPureCoinCent, 0)
|
|
order.GrowthPointsPerYuan = cfg.PointsPerYuan
|
|
if !cfg.Enabled {
|
|
return nil
|
|
}
|
|
points := PointsForPureCoin(actualPureCoinCent, cfg.PointsPerYuan)
|
|
if points <= 0 {
|
|
return nil
|
|
}
|
|
var user model.User
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, order.RenterID).Error; err != nil {
|
|
return err
|
|
}
|
|
beforePoints := user.RenterGrowthPoints
|
|
beforeLevel := LevelForPoints(cfg, beforePoints)
|
|
afterPoints := beforePoints + points
|
|
afterLevel := LevelForPoints(cfg, afterPoints)
|
|
user.RenterGrowthPoints = afterPoints
|
|
user.RenterGrowthLevel = afterLevel.Code
|
|
if err := tx.Save(&user).Error; err != nil {
|
|
return err
|
|
}
|
|
now := time.Now()
|
|
ledger := model.RenterGrowthLedger{
|
|
UserID: order.RenterID,
|
|
OrderID: &order.ID,
|
|
Points: points,
|
|
BasisAmountCent: actualPureCoinCent,
|
|
PointsPerYuan: cfg.PointsPerYuan,
|
|
BeforePoints: beforePoints,
|
|
AfterPoints: afterPoints,
|
|
BeforeLevel: beforeLevel.Code,
|
|
AfterLevel: afterLevel.Code,
|
|
Source: source,
|
|
Remark: "租号订单完成发放成长积分",
|
|
CreatedAt: now,
|
|
}
|
|
if err := tx.Create(&ledger).Error; err != nil {
|
|
return err
|
|
}
|
|
order.GrowthPointsAwarded = points
|
|
order.GrowthPointsAwardedAt = &now
|
|
return nil
|
|
}
|
|
|
|
// RevokeOrderCompleted 在已完成订单被全额人工退款后扣回该单已发放的成长积分。
|
|
func RevokeOrderCompleted(tx *gorm.DB, order *model.RentalOrder) error {
|
|
if tx == nil || order == nil || order.ID == 0 || order.RenterID == 0 {
|
|
return nil
|
|
}
|
|
var existing model.RenterGrowthLedger
|
|
err := tx.Where("order_id = ? AND source = ?", order.ID, SourceOrderRefunded).First(&existing).Error
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return err
|
|
}
|
|
|
|
points := order.GrowthPointsAwarded
|
|
if points <= 0 {
|
|
var awarded model.RenterGrowthLedger
|
|
if err := tx.Where("order_id = ? AND source = ?", order.ID, SourceOrderCompleted).First(&awarded).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
points = awarded.Points
|
|
}
|
|
if points <= 0 {
|
|
return nil
|
|
}
|
|
|
|
cfg, err := ConfigForTx(tx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var user model.User
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, order.RenterID).Error; err != nil {
|
|
return err
|
|
}
|
|
beforePoints := maxInt64(user.RenterGrowthPoints, 0)
|
|
revokedPoints := points
|
|
if revokedPoints > beforePoints {
|
|
revokedPoints = beforePoints
|
|
}
|
|
beforeLevel := LevelForPoints(cfg, beforePoints)
|
|
afterPoints := beforePoints - revokedPoints
|
|
afterLevel := LevelForPoints(cfg, afterPoints)
|
|
if !cfg.Enabled {
|
|
beforeLevel = LevelRule{Code: DefaultLevelCode, Name: DefaultLevelName}
|
|
afterLevel = beforeLevel
|
|
}
|
|
user.RenterGrowthPoints = afterPoints
|
|
user.RenterGrowthLevel = afterLevel.Code
|
|
if err := tx.Save(&user).Error; err != nil {
|
|
return err
|
|
}
|
|
orderID := order.ID
|
|
return tx.Create(&model.RenterGrowthLedger{
|
|
UserID: order.RenterID,
|
|
OrderID: &orderID,
|
|
Points: -revokedPoints,
|
|
BasisAmountCent: order.GrowthPointsBasisCent,
|
|
PointsPerYuan: order.GrowthPointsPerYuan,
|
|
BeforePoints: beforePoints,
|
|
AfterPoints: afterPoints,
|
|
BeforeLevel: beforeLevel.Code,
|
|
AfterLevel: afterLevel.Code,
|
|
Source: SourceOrderRefunded,
|
|
Remark: "已完成订单全额退款扣回成长积分",
|
|
CreatedAt: time.Now(),
|
|
}).Error
|
|
}
|
|
|
|
func PointsForPureCoin(actualPureCoinCent int64, pointsPerYuan int64) int64 {
|
|
if actualPureCoinCent <= 0 {
|
|
return 0
|
|
}
|
|
if pointsPerYuan <= 0 {
|
|
pointsPerYuan = DefaultPointsPerYuan
|
|
}
|
|
return (actualPureCoinCent / 100) * pointsPerYuan
|
|
}
|
|
|
|
// PointsForRent 保留旧调用兼容,积分基数现统一解释为实际纯币金额。
|
|
func PointsForRent(actualPureCoinCent int64, pointsPerYuan int64) int64 {
|
|
return PointsForPureCoin(actualPureCoinCent, pointsPerYuan)
|
|
}
|
|
|
|
func LevelForPoints(cfg Config, points int64) LevelRule {
|
|
cfg = normalizeConfig(cfg)
|
|
if points < 0 {
|
|
points = 0
|
|
}
|
|
current := cfg.Levels[0]
|
|
for _, level := range cfg.Levels {
|
|
if points >= level.MinPoints {
|
|
current = level
|
|
}
|
|
}
|
|
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 {
|
|
cfg.PointsPerYuan = defaultCfg.PointsPerYuan
|
|
}
|
|
if len(cfg.Levels) == 0 {
|
|
cfg.Levels = defaultCfg.Levels
|
|
}
|
|
levels := make([]LevelRule, 0, len(cfg.Levels)+1)
|
|
hasNormal := false
|
|
for _, level := range cfg.Levels {
|
|
level.Code = strings.TrimSpace(level.Code)
|
|
level.Name = strings.TrimSpace(level.Name)
|
|
if level.Code == "" {
|
|
continue
|
|
}
|
|
if level.Name == "" {
|
|
level.Name = level.Code
|
|
}
|
|
if level.MinPoints < 0 {
|
|
level.MinPoints = 0
|
|
}
|
|
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
|
|
level.MinPoints = 0
|
|
}
|
|
levels = append(levels, level)
|
|
}
|
|
if !hasNormal {
|
|
levels = append(levels, LevelRule{
|
|
Code: DefaultLevelCode,
|
|
Name: DefaultLevelName,
|
|
MinPoints: 0,
|
|
DiscountBps: DefaultDiscountBps,
|
|
DepositFreeQuotaCent: 0,
|
|
})
|
|
}
|
|
sort.SliceStable(levels, func(i, j int) bool {
|
|
return levels[i].MinPoints < levels[j].MinPoints
|
|
})
|
|
cfg.Levels = levels
|
|
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
|
|
}
|
|
message := strings.ToLower(err.Error())
|
|
return strings.Contains(message, "no such table") ||
|
|
strings.Contains(message, "doesn't exist") ||
|
|
strings.Contains(message, "does not exist")
|
|
}
|