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 SourceOrderCompleted = "order_completed" ) 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"` } type DiscountSnapshot struct { LevelCode string LevelName string DiscountBps int UserPoints int64 } func DefaultConfig() Config { return 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}, }, } } 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) } 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 } 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 } level := LevelForPoints(cfg, user.RenterGrowthPoints) return DiscountSnapshot{ LevelCode: level.Code, LevelName: level.Name, DiscountBps: level.DiscountBps, UserPoints: user.RenterGrowthPoints, }, 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, actualRentCent 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 } if !cfg.Enabled { return nil } points := PointsForRent(actualRentCent, 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, 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 } func PointsForRent(actualRentCent int64, pointsPerYuan int64) int64 { if actualRentCent <= 0 { return 0 } if pointsPerYuan <= 0 { pointsPerYuan = DefaultPointsPerYuan } return (actualRentCent / 100) * 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 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.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, }) } sort.SliceStable(levels, func(i, j int) bool { return levels[i].MinPoints < levels[j].MinPoints }) cfg.Levels = levels return cfg } 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") }