Files

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")
}