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