package rentergrowth import ( "encoding/json" "errors" "fmt" "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 DefaultMultiplierBps = 10_000 MaxPointsMultiplierBps = 100_000 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"` PointsMultiplierBps int `json:"points_multiplier_bps"` 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, PointsMultiplierBps: 10_000, DiscountBps: DefaultDiscountBps, DepositFreeQuotaCent: 0}, {Code: "platinum", Name: "铂金", MinPoints: 300, PointsMultiplierBps: 12_000, DiscountBps: 9900, DepositFreeQuotaCent: DefaultPlatinumQuotaCent}, {Code: "diamond", Name: "钻石", MinPoints: 1000, PointsMultiplierBps: 15_000, DiscountBps: 9800, DepositFreeQuotaCent: DefaultDiamondQuotaCent}, {Code: "peak", Name: "巅峰", MinPoints: 5000, PointsMultiplierBps: 20_000, 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"` PointsMultiplierBps *int `json:"points_multiplier_bps"` 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 } multiplierBps := defaultPointsMultiplierBps(raw.Code) if raw.PointsMultiplierBps != nil { multiplierBps = *raw.PointsMultiplierBps } *level = LevelRule{ Code: raw.Code, Name: raw.Name, MinPoints: raw.MinPoints, PointsMultiplierBps: multiplierBps, 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.PointsMultiplierBps <= 0 || level.PointsMultiplierBps > MaxPointsMultiplierBps || 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.PointsMultiplierBps < previous.PointsMultiplierBps || 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 { return AwardOrderBothCompleted(tx, order, actualPureCoinCent, 0, source) } // AwardOrderBothCompleted 按租客实际支出和卖家实际收入分别发放积分。 func AwardOrderBothCompleted(tx *gorm.DB, order *model.RentalOrder, renterBasisCent int64, ownerBasisCent int64, source string) error { if tx == nil || order == nil || order.ID == 0 || order.RenterID == 0 { return nil } if strings.TrimSpace(source) == "" { source = SourceOrderCompleted } cfg, err := ConfigForTx(tx) if err != nil { return err } if err := awardOrderSide(tx, order, cfg, "renter", order.RenterID, renterBasisCent, source); err != nil { return err } if order.OwnerID > 0 && order.OwnerID != order.RenterID { return awardOrderSide(tx, order, cfg, "owner", order.OwnerID, ownerBasisCent, source) } return nil } func awardOrderSide(tx *gorm.DB, order *model.RentalOrder, cfg Config, role string, userID uint64, basisCent int64, source string) error { basisCent = maxInt64(basisCent, 0) bizKey := fmt.Sprintf("order:%d:%s:%s", order.ID, role, source) var existing model.RenterGrowthLedger err := tx.Where("biz_key = ?", bizKey).First(&existing).Error if err == nil { setOrderPointsSnapshot(order, role, existing.BasisAmountCent, existing.PointsPerYuan, existing.PointsMultiplierBps, existing.Points, existing.CreatedAt) return nil } if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } if role == "renter" { order.GrowthPointsBasisCent = basisCent order.GrowthPointsPerYuan = cfg.PointsPerYuan } else { order.OwnerGrowthPointsBasisCent = basisCent order.OwnerGrowthPointsPerYuan = cfg.PointsPerYuan } if !cfg.Enabled || basisCent <= 0 { return nil } var user model.User if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil { return err } beforePoints := user.RenterGrowthPoints beforeAvailable := user.PointsAvailable beforeLevel := LevelForPoints(cfg, beforePoints) multiplierBps := beforeLevel.PointsMultiplierBps points := PointsForAmount(basisCent, cfg.PointsPerYuan, multiplierBps) if points <= 0 { if role == "renter" { order.GrowthPointsMultiplierBps = multiplierBps } else { order.OwnerGrowthPointsMultiplierBps = multiplierBps } return nil } afterPoints := beforePoints + points afterLevel := LevelForPoints(cfg, afterPoints) user.RenterGrowthPoints = afterPoints user.RenterGrowthLevel = afterLevel.Code user.PointsAvailable = beforeAvailable + points if err := tx.Save(&user).Error; err != nil { return err } now := time.Now() ledger := model.RenterGrowthLedger{ UserID: userID, Role: role, OrderID: &order.ID, Points: points, BasisAmountCent: basisCent, PointsPerYuan: cfg.PointsPerYuan, PointsMultiplierBps: multiplierBps, BeforePoints: beforePoints, AfterPoints: afterPoints, AvailableBefore: beforeAvailable, AvailableAfter: user.PointsAvailable, BeforeLevel: beforeLevel.Code, AfterLevel: afterLevel.Code, Source: source, BizKey: &bizKey, Remark: "订单完成发放积分", CreatedAt: now, } if err := tx.Create(&ledger).Error; err != nil { return err } setOrderPointsSnapshot(order, role, basisCent, cfg.PointsPerYuan, multiplierBps, points, now) return nil } func setOrderPointsSnapshot(order *model.RentalOrder, role string, basisCent, perYuan int64, multiplierBps int, points int64, awardedAt time.Time) { if role == "owner" { order.OwnerGrowthPointsBasisCent = basisCent order.OwnerGrowthPointsPerYuan = perYuan order.OwnerGrowthPointsMultiplierBps = multiplierBps order.OwnerGrowthPointsAwarded = points if !awardedAt.IsZero() { order.OwnerGrowthPointsAwardedAt = &awardedAt } return } order.GrowthPointsBasisCent = basisCent order.GrowthPointsPerYuan = perYuan order.GrowthPointsMultiplierBps = multiplierBps order.GrowthPointsAwarded = points if !awardedAt.IsZero() { order.GrowthPointsAwardedAt = &awardedAt } } // RevokeOrderCompleted 在已完成订单被全额人工退款后扣回该单已发放的成长积分。 func RevokeOrderCompleted(tx *gorm.DB, order *model.RentalOrder) error { if tx == nil || order == nil || order.ID == 0 || order.RenterID == 0 { return nil } cfg, err := ConfigForTx(tx) if err != nil { return err } for _, side := range []struct { role string userID uint64 }{ {role: "renter", userID: order.RenterID}, {role: "owner", userID: order.OwnerID}, } { if side.userID == 0 { continue } if err := revokeOrderSide(tx, order, cfg, side.role, side.userID); err != nil { return err } } return nil } func revokeOrderSide(tx *gorm.DB, order *model.RentalOrder, cfg Config, role string, userID uint64) error { bizKey := fmt.Sprintf("order:%d:%s:%s", order.ID, role, SourceOrderRefunded) var existing model.RenterGrowthLedger err := tx.Where("biz_key = ?", bizKey).First(&existing).Error if err == nil { return nil } if !errors.Is(err, gorm.ErrRecordNotFound) { return err } var awarded model.RenterGrowthLedger if err := tx.Where( "order_id = ? AND role = ? AND source = ?", order.ID, role, SourceOrderCompleted, ).First(&awarded).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { if role != "renter" || order.GrowthPointsAwarded <= 0 { return nil } awarded = model.RenterGrowthLedger{ UserID: userID, Role: role, OrderID: &order.ID, Points: order.GrowthPointsAwarded, BasisAmountCent: order.GrowthPointsBasisCent, PointsPerYuan: order.GrowthPointsPerYuan, PointsMultiplierBps: order.GrowthPointsMultiplierBps, Source: SourceOrderCompleted, } } else { return err } } points := awarded.Points if points <= 0 { return nil } var user model.User if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil { return err } beforePoints := maxInt64(user.RenterGrowthPoints, 0) beforeAvailable := maxInt64(user.PointsAvailable, 0) revokedPoints := minInt64(points, beforePoints) revokedAvailable := minInt64(points, beforeAvailable) 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 user.PointsAvailable = beforeAvailable - revokedAvailable if err := tx.Save(&user).Error; err != nil { return err } return tx.Create(&model.RenterGrowthLedger{ UserID: userID, Role: role, OrderID: &order.ID, Points: -revokedPoints, BasisAmountCent: awarded.BasisAmountCent, PointsPerYuan: awarded.PointsPerYuan, PointsMultiplierBps: awarded.PointsMultiplierBps, BeforePoints: beforePoints, AfterPoints: afterPoints, AvailableBefore: beforeAvailable, AvailableAfter: user.PointsAvailable, BeforeLevel: beforeLevel.Code, AfterLevel: afterLevel.Code, Source: SourceOrderRefunded, BizKey: &bizKey, Remark: "已完成订单全额退款扣回积分", CreatedAt: time.Now(), }).Error } func PointsForPureCoin(actualPureCoinCent int64, pointsPerYuan int64) int64 { return PointsForAmount(actualPureCoinCent, pointsPerYuan, DefaultMultiplierBps) } func PointsForAmount(basisCent int64, pointsPerYuan int64, multiplierBps int) int64 { if basisCent <= 0 { return 0 } if pointsPerYuan <= 0 { pointsPerYuan = DefaultPointsPerYuan } if multiplierBps <= 0 { multiplierBps = DefaultMultiplierBps } return basisCent * pointsPerYuan * int64(multiplierBps) / (100 * DefaultMultiplierBps) } // 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.PointsMultiplierBps <= 0 || level.PointsMultiplierBps > MaxPointsMultiplierBps { level.PointsMultiplierBps = defaultPointsMultiplierBps(level.Code) } 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, PointsMultiplierBps: DefaultMultiplierBps, 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 defaultPointsMultiplierBps(code string) int { switch strings.TrimSpace(code) { case "platinum": return 12_000 case "diamond": return 15_000 case "peak": return 20_000 default: return DefaultMultiplierBps } } 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 minInt64(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") }