diff --git a/backend/internal/model/order.go b/backend/internal/model/order.go index a094f88..f1e14d6 100644 --- a/backend/internal/model/order.go +++ b/backend/internal/model/order.go @@ -21,6 +21,9 @@ type RentalOrder struct { DepositAmountCent int64 `gorm:"not null;default:0" json:"-"` DepositOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"` DepositWaivedAmountCent int64 `gorm:"not null;default:0" json:"-"` + DepositFreeLevelQuotaCent int64 `gorm:"not null;default:0" json:"deposit_free_level_quota_cent"` + DepositFreeManualQuotaCent int64 `gorm:"not null;default:0" json:"deposit_free_manual_quota_cent"` + DepositFreeUsedBeforeCent int64 `gorm:"not null;default:0" json:"deposit_free_used_before_cent"` PlatformFeeCent int64 `gorm:"not null;default:0" json:"-"` RentOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"` RentDiscountAmountCent int64 `gorm:"not null;default:0" json:"-"` diff --git a/backend/internal/model/renter_growth.go b/backend/internal/model/renter_growth.go index f0ecd4b..f6d46c9 100644 --- a/backend/internal/model/renter_growth.go +++ b/backend/internal/model/renter_growth.go @@ -4,17 +4,18 @@ import "time" // RenterGrowthLedger 记录租客成长积分变动流水,便于排查订单完成后的积分发放。 type RenterGrowthLedger struct { - ID uint64 `gorm:"primaryKey" json:"id"` - UserID uint64 `gorm:"not null;index" json:"user_id"` - OrderID uint64 `gorm:"not null;default:0;index;uniqueIndex:uk_renter_growth_order_source,priority:1" json:"order_id"` - Points int64 `gorm:"not null;default:0" json:"points"` - BeforePoints int64 `gorm:"not null;default:0" json:"before_points"` - AfterPoints int64 `gorm:"not null;default:0" json:"after_points"` - BeforeLevel string `gorm:"size:32;not null;default:'normal'" json:"before_level"` - AfterLevel string `gorm:"size:32;not null;default:'normal'" json:"after_level"` - Source string `gorm:"size:32;not null;default:'order_completed';uniqueIndex:uk_renter_growth_order_source,priority:2" json:"source"` - Remark string `gorm:"size:255;not null;default:''" json:"remark"` - CreatedAt time.Time `json:"created_at"` + ID uint64 `gorm:"primaryKey" json:"id"` + UserID uint64 `gorm:"not null;index" json:"user_id"` + OrderID *uint64 `gorm:"index;uniqueIndex:uk_renter_growth_order_source,priority:1" json:"order_id,omitempty"` + Points int64 `gorm:"not null;default:0" json:"points"` + BeforePoints int64 `gorm:"not null;default:0" json:"before_points"` + AfterPoints int64 `gorm:"not null;default:0" json:"after_points"` + BeforeLevel string `gorm:"size:32;not null;default:'normal'" json:"before_level"` + AfterLevel string `gorm:"size:32;not null;default:'normal'" json:"after_level"` + Source string `gorm:"size:32;not null;default:'order_completed';uniqueIndex:uk_renter_growth_order_source,priority:2" json:"source"` + OperatorAdminID *uint64 `gorm:"index" json:"operator_admin_id,omitempty"` + Remark string `gorm:"size:255;not null;default:''" json:"remark"` + CreatedAt time.Time `json:"created_at"` } func (RenterGrowthLedger) TableName() string { diff --git a/backend/internal/modules/adminuser/dto.go b/backend/internal/modules/adminuser/dto.go index be6822d..66f0d04 100644 --- a/backend/internal/modules/adminuser/dto.go +++ b/backend/internal/modules/adminuser/dto.go @@ -3,27 +3,30 @@ package adminuser import "time" type UserDTO struct { - ID uint64 `json:"id"` - Phone string `json:"phone"` - Nickname string `json:"nickname"` - RealnameStatus string `json:"realname_status"` - RiskStatus string `json:"risk_status"` - CreditScore int `json:"credit_score"` - DepositFreeQuotaCent int64 `json:"deposit_free_quota_cent"` - DepositFreeUsedCent int64 `json:"deposit_free_used_cent"` - DepositFreeRemainingCent int64 `json:"deposit_free_remaining_cent"` - RenterGrowthPoints int64 `json:"renter_growth_points"` - RenterGrowthLevel string `json:"renter_growth_level"` - RenterGrowthLevelName string `json:"renter_growth_level_name"` - AvailableBalanceCent int64 `json:"available_balance_cent"` - FrozenBalanceCent int64 `json:"frozen_balance_cent"` - Status string `json:"status"` - OrderCount int64 `json:"order_count"` - ListingCount int64 `json:"listing_count"` - DisputeCount int64 `json:"dispute_count"` - LastLoginAt *time.Time `json:"last_login_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `json:"id"` + Phone string `json:"phone"` + Nickname string `json:"nickname"` + RealnameStatus string `json:"realname_status"` + RiskStatus string `json:"risk_status"` + CreditScore int `json:"credit_score"` + DepositFreeQuotaCent int64 `json:"deposit_free_quota_cent"` + DepositFreeManualQuotaCent int64 `json:"deposit_free_manual_quota_cent"` + DepositFreeLevelQuotaCent int64 `json:"deposit_free_level_quota_cent"` + DepositFreeEffectiveQuotaCent int64 `json:"deposit_free_effective_quota_cent"` + DepositFreeUsedCent int64 `json:"deposit_free_used_cent"` + DepositFreeRemainingCent int64 `json:"deposit_free_remaining_cent"` + RenterGrowthPoints int64 `json:"renter_growth_points"` + RenterGrowthLevel string `json:"renter_growth_level"` + RenterGrowthLevelName string `json:"renter_growth_level_name"` + AvailableBalanceCent int64 `json:"available_balance_cent"` + FrozenBalanceCent int64 `json:"frozen_balance_cent"` + Status string `json:"status"` + OrderCount int64 `json:"order_count"` + ListingCount int64 `json:"listing_count"` + DisputeCount int64 `json:"dispute_count"` + LastLoginAt *time.Time `json:"last_login_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type FreezeRequest struct { Reason string `json:"reason"` @@ -43,6 +46,11 @@ type DepositFreeQuotaRequest struct { AmountCent int64 `json:"amount_cent"` } +type GrowthPointsAdjustRequest struct { + TargetPoints int64 `json:"target_points"` + Reason string `json:"reason" binding:"required"` +} + // WalletAdjustRequest 后台人工调整用户可用余额。 // Direction: in=加款 out=扣款;仅操作 available,不改 frozen。 type WalletAdjustRequest struct { diff --git a/backend/internal/modules/adminuser/handler.go b/backend/internal/modules/adminuser/handler.go index c83befd..a0bc329 100644 --- a/backend/internal/modules/adminuser/handler.go +++ b/backend/internal/modules/adminuser/handler.go @@ -120,6 +120,29 @@ func (h *Handler) SetDepositFreeQuota(c *gin.Context) { response.OK(c, item) } +func (h *Handler) AdjustGrowthPoints(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + userID, ok := parseID(c) + if !ok { + return + } + var req GrowthPointsAdjustRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "成长积分调整参数不正确") + return + } + item, err := h.service.AdjustGrowthPoints(c.Request.Context(), adminID, userID, req, auditMeta(c)) + if err != nil { + writeAdminUserError(c, err) + return + } + response.OK(c, item) +} + func (h *Handler) RevokeRealname(c *gin.Context) { adminID, ok := currentAdminID(c) if !ok { @@ -232,6 +255,12 @@ func writeAdminUserError(c *gin.Context, err error) { response.BadRequest(c, "请填写 2-200 字的调账原因") case errors.Is(err, ErrInsufficientBalance): response.Error(c, http.StatusConflict, "insufficient_balance", "用户可用余额不足,无法扣款") + case errors.Is(err, ErrInvalidGrowthPoints): + response.BadRequest(c, "目标积分不正确,需在 0 到 10 亿之间") + case errors.Is(err, ErrInvalidGrowthReason): + response.BadRequest(c, "请填写 2-200 字的积分调整原因") + case errors.Is(err, ErrGrowthPointsUnchanged): + response.Error(c, http.StatusConflict, "growth_points_unchanged", "目标积分与当前积分相同") case IsNotFound(err): response.Error(c, http.StatusNotFound, "not_found", "用户不存在") default: diff --git a/backend/internal/modules/adminuser/repository.go b/backend/internal/modules/adminuser/repository.go index 140c97f..5ced310 100644 --- a/backend/internal/modules/adminuser/repository.go +++ b/backend/internal/modules/adminuser/repository.go @@ -34,8 +34,12 @@ func NewRepository(db *gorm.DB, encryptors ...crypto.Encryptor) *Repository { } func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQuery) (*PaginatedResult, error) { + growthConfig, err := rentergrowth.ConfigForTx(r.db.WithContext(ctx)) + if err != nil { + return nil, err + } var total int64 - countTx := applyUserFilter(r.db.WithContext(ctx).Table("users AS u"), query) + countTx := applyUserFilter(r.db.WithContext(ctx).Table("users AS u"), query, growthConfig) if err := countTx.Count(&total).Error; err != nil { return nil, err } @@ -54,8 +58,8 @@ func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQue Joins("LEFT JOIN (SELECT user_id, COUNT(*) AS dispute_count FROM (SELECT initiator_id AS user_id FROM disputes UNION ALL SELECT target_user_id AS user_id FROM disputes) AS dispute_users GROUP BY user_id) AS d ON d.user_id = u.id"). Joins("LEFT JOIN (SELECT renter_id AS user_id, SUM(deposit_waived_amount_cent) AS deposit_free_used_cent FROM rental_orders WHERE status NOT IN ('completed', 'cancelled', 'closed') GROUP BY renter_id) AS df ON df.user_id = u.id"). Joins("LEFT JOIN wallet_accounts AS w ON w.user_id = u.id") - listTx = applyUserFilter(listTx, query) - err := listTx. + listTx = applyUserFilter(listTx, query, growthConfig) + err = listTx. Order("u.id DESC"). Offset(offset).Limit(pageSize). Scan(&rows).Error @@ -64,18 +68,22 @@ func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQue } items := make([]UserDTO, 0, len(rows)) for _, row := range rows { - items = append(items, row.toDTO()) + items = append(items, row.toDTO(growthConfig)) } return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil } // applyUserFilter 将列表筛选条件应用到查询(基于 users 表别名 u)。 -func applyUserFilter(tx *gorm.DB, query ListQuery) *gorm.DB { +func applyUserFilter(tx *gorm.DB, query ListQuery, growthConfig rentergrowth.Config) *gorm.DB { if query.Status != "" { tx = tx.Where("u.status = ?", query.Status) } if query.HasDepositFreeQuota { - tx = tx.Where("u.deposit_free_quota_cent > 0") + if minPoints, ok := minimumGrowthQuotaPoints(growthConfig); ok { + tx = tx.Where("u.deposit_free_quota_cent > 0 OR u.renter_growth_points >= ?", minPoints) + } else { + tx = tx.Where("u.deposit_free_quota_cent > 0") + } } if keyword := strings.TrimSpace(query.Keyword); keyword != "" { like := "%" + keyword + "%" @@ -89,6 +97,24 @@ func applyUserFilter(tx *gorm.DB, query ListQuery) *gorm.DB { return tx } +func minimumGrowthQuotaPoints(cfg rentergrowth.Config) (int64, bool) { + if !cfg.Enabled { + return 0, false + } + var minPoints int64 + found := false + for _, level := range cfg.Levels { + if level.DepositFreeQuotaCent <= 0 { + continue + } + if !found || level.MinPoints < minPoints { + minPoints = level.MinPoints + found = true + } + } + return minPoints, found +} + func (r *Repository) Freeze(ctx context.Context, adminID uint64, userID uint64, req FreezeRequest, meta AuditMeta) (*UserDTO, error) { return r.updateStatus(ctx, adminID, userID, "frozen", "frozen", "admin_user.freeze", req.Reason, meta) } @@ -124,6 +150,62 @@ func (r *Repository) SetDepositFreeQuota(ctx context.Context, adminID uint64, us return r.Find(ctx, userID) } +func (r *Repository) AdjustGrowthPoints(ctx context.Context, adminID uint64, userID uint64, req GrowthPointsAdjustRequest, meta AuditMeta) (*UserDTO, error) { + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var user model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil { + return err + } + if user.RenterGrowthPoints == req.TargetPoints { + return ErrGrowthPointsUnchanged + } + cfg, err := rentergrowth.ConfigForTx(tx) + if err != nil { + return err + } + beforePoints := user.RenterGrowthPoints + beforeLevel := rentergrowth.LevelForPoints(cfg, beforePoints) + afterLevel := rentergrowth.LevelForPoints(cfg, req.TargetPoints) + if !cfg.Enabled { + beforeLevel = rentergrowth.LevelForPoints(cfg, 0) + afterLevel = beforeLevel + } + user.RenterGrowthPoints = req.TargetPoints + user.RenterGrowthLevel = afterLevel.Code + if err := tx.Save(&user).Error; err != nil { + return err + } + ledger := model.RenterGrowthLedger{ + UserID: user.ID, + Points: req.TargetPoints - beforePoints, + BeforePoints: beforePoints, + AfterPoints: req.TargetPoints, + BeforeLevel: beforeLevel.Code, + AfterLevel: afterLevel.Code, + Source: rentergrowth.SourceAdminAdjustment, + OperatorAdminID: &adminID, + Remark: "后台调整成长积分: " + req.Reason, + CreatedAt: time.Now(), + } + if err := tx.Create(&ledger).Error; err != nil { + return err + } + return appendAuditLog(tx, adminID, "admin_user.adjust_growth_points", user.ID, meta, map[string]any{ + "user_id": user.ID, + "before_points": beforePoints, + "after_points": req.TargetPoints, + "change_points": req.TargetPoints - beforePoints, + "before_level": beforeLevel.Code, + "after_level": afterLevel.Code, + "reason": req.Reason, + }) + }) + if err != nil { + return nil, err + } + return r.Find(ctx, userID) +} + func (r *Repository) updateStatus(ctx context.Context, adminID uint64, userID uint64, status string, riskStatus string, action string, reason string, meta AuditMeta) (*UserDTO, error) { err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var user model.User @@ -270,8 +352,12 @@ func (r *Repository) ManualRealname(ctx context.Context, adminID uint64, userID } func (r *Repository) Find(ctx context.Context, userID uint64) (*UserDTO, error) { + growthConfig, err := rentergrowth.ConfigForTx(r.db.WithContext(ctx)) + if err != nil { + return nil, err + } var row userRow - err := r.db.WithContext(ctx).Table("users AS u"). + err = r.db.WithContext(ctx).Table("users AS u"). Select(`u.*, COALESCE(o.order_count, 0) AS order_count, COALESCE(l.listing_count, 0) AS listing_count, @@ -289,7 +375,7 @@ func (r *Repository) Find(ctx context.Context, userID uint64) (*UserDTO, error) if err != nil { return nil, err } - dto := row.toDTO() + dto := row.toDTO(growthConfig) return &dto, nil } @@ -385,53 +471,43 @@ type userRow struct { FrozenBalanceCent int64 } -func (row userRow) toDTO() UserDTO { - remaining := row.DepositFreeQuotaCent - row.DepositFreeUsedCent +func (row userRow) toDTO(growthConfig rentergrowth.Config) UserDTO { + level := rentergrowth.LevelForPoints(growthConfig, row.RenterGrowthPoints) + levelQuotaCent := level.DepositFreeQuotaCent + if !growthConfig.Enabled { + level = rentergrowth.LevelForPoints(growthConfig, 0) + levelQuotaCent = 0 + } + effectiveQuotaCent := rentergrowth.EffectiveDepositFreeQuotaCent(levelQuotaCent, row.DepositFreeQuotaCent) + remaining := effectiveQuotaCent - row.DepositFreeUsedCent if remaining < 0 { remaining = 0 } return UserDTO{ - ID: row.ID, - Phone: row.Phone, - Nickname: row.Nickname, - RealnameStatus: row.RealnameStatus, - RiskStatus: row.RiskStatus, - CreditScore: row.CreditScore, - DepositFreeQuotaCent: row.DepositFreeQuotaCent, - DepositFreeUsedCent: row.DepositFreeUsedCent, - DepositFreeRemainingCent: remaining, - RenterGrowthPoints: row.RenterGrowthPoints, - RenterGrowthLevel: effectiveRenterGrowthLevel(row.User), - RenterGrowthLevelName: renterGrowthLevelName(row.RenterGrowthLevel), - AvailableBalanceCent: row.AvailableBalanceCent, - FrozenBalanceCent: row.FrozenBalanceCent, - Status: row.Status, - OrderCount: row.OrderCount, - ListingCount: row.ListingCount, - DisputeCount: row.DisputeCount, - LastLoginAt: row.LastLoginAt, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - } -} - -func effectiveRenterGrowthLevel(user model.User) string { - if user.RenterGrowthLevel != "" { - return user.RenterGrowthLevel - } - return rentergrowth.DefaultLevelCode -} - -func renterGrowthLevelName(code string) string { - switch code { - case "platinum": - return "铂金" - case "diamond": - return "钻石" - case "peak": - return "巅峰" - default: - return rentergrowth.DefaultLevelName + ID: row.ID, + Phone: row.Phone, + Nickname: row.Nickname, + RealnameStatus: row.RealnameStatus, + RiskStatus: row.RiskStatus, + CreditScore: row.CreditScore, + DepositFreeQuotaCent: row.DepositFreeQuotaCent, + DepositFreeManualQuotaCent: row.DepositFreeQuotaCent, + DepositFreeLevelQuotaCent: levelQuotaCent, + DepositFreeEffectiveQuotaCent: effectiveQuotaCent, + DepositFreeUsedCent: row.DepositFreeUsedCent, + DepositFreeRemainingCent: remaining, + RenterGrowthPoints: row.RenterGrowthPoints, + RenterGrowthLevel: level.Code, + RenterGrowthLevelName: level.Name, + AvailableBalanceCent: row.AvailableBalanceCent, + FrozenBalanceCent: row.FrozenBalanceCent, + Status: row.Status, + OrderCount: row.OrderCount, + ListingCount: row.ListingCount, + DisputeCount: row.DisputeCount, + LastLoginAt: row.LastLoginAt, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, } } diff --git a/backend/internal/modules/adminuser/repository_test.go b/backend/internal/modules/adminuser/repository_test.go index 864ed8f..5359fcf 100644 --- a/backend/internal/modules/adminuser/repository_test.go +++ b/backend/internal/modules/adminuser/repository_test.go @@ -6,6 +6,7 @@ import ( "testing" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/rentergrowth" "gorm.io/driver/sqlite" "gorm.io/gorm" @@ -69,17 +70,59 @@ func TestApplyUserFilterHasDepositFreeQuota(t *testing.T) { stmt := applyUserFilter(db.Table("users AS u"), ListQuery{ Status: "active", HasDepositFreeQuota: true, - }).Find(&[]struct{}{}).Statement + }, rentergrowth.DefaultConfig()).Find(&[]struct{}{}).Statement sql := stmt.SQL.String() - if !strings.Contains(sql, "u.deposit_free_quota_cent > 0") { + if !strings.Contains(sql, "u.deposit_free_quota_cent > 0 OR u.renter_growth_points >=") { t.Fatalf("SQL 未包含免押额度过滤:%s", sql) } - if len(stmt.Vars) != 1 || stmt.Vars[0] != "active" { + if len(stmt.Vars) != 2 || stmt.Vars[0] != "active" || stmt.Vars[1] != int64(300) { t.Fatalf("筛选变量不正确:%v", stmt.Vars) } } +func TestAdjustGrowthPointsWritesRepeatableLedgers(t *testing.T) { + db := setupAdminUserTestDB(t) + user := model.User{ + Phone: "13900000005", + Status: "active", + RenterGrowthPoints: 290, + RenterGrowthLevel: rentergrowth.DefaultLevelCode, + } + if err := db.Create(&user).Error; err != nil { + t.Fatalf("创建用户失败:%v", err) + } + repo := NewRepository(db) + if _, err := repo.AdjustGrowthPoints(t.Context(), 66, user.ID, GrowthPointsAdjustRequest{ + TargetPoints: 1000, + Reason: "运营活动赠送", + }, AuditMeta{RequestID: "growth-1"}); err != nil { + t.Fatalf("第一次调整积分失败:%v", err) + } + got, err := repo.AdjustGrowthPoints(t.Context(), 66, user.ID, GrowthPointsAdjustRequest{ + TargetPoints: 200, + Reason: "修正错误赠送", + }, AuditMeta{RequestID: "growth-2"}) + if err != nil { + t.Fatalf("第二次调整积分失败:%v", err) + } + if got.RenterGrowthPoints != 200 || got.RenterGrowthLevel != rentergrowth.DefaultLevelCode { + t.Fatalf("调整后成长信息 = %d/%s, want 200/normal", got.RenterGrowthPoints, got.RenterGrowthLevel) + } + var ledgers []model.RenterGrowthLedger + if err := db.Where("user_id = ? AND source = ?", user.ID, rentergrowth.SourceAdminAdjustment).Order("id ASC").Find(&ledgers).Error; err != nil { + t.Fatalf("查询积分流水失败:%v", err) + } + if len(ledgers) != 2 || ledgers[0].Points != 710 || ledgers[1].Points != -800 { + t.Fatalf("积分流水不正确:%+v", ledgers) + } + for _, ledger := range ledgers { + if ledger.OrderID != nil || ledger.OperatorAdminID == nil || *ledger.OperatorAdminID != 66 { + t.Fatalf("人工积分流水关联不正确:%+v", ledger) + } + } +} + func TestManualRealnameUpdatesUserRecordAndAudit(t *testing.T) { db := setupAdminUserTestDB(t) user := model.User{ @@ -265,6 +308,8 @@ func setupAdminUserTestDB(t *testing.T) *gorm.DB { &model.Dispute{}, &model.WalletAccount{}, &model.WalletLedger{}, + &model.SystemConfig{}, + &model.RenterGrowthLedger{}, ); err != nil { t.Fatalf("数据库迁移失败:%v", err) } diff --git a/backend/internal/modules/adminuser/service.go b/backend/internal/modules/adminuser/service.go index d855674..e7a4ed4 100644 --- a/backend/internal/modules/adminuser/service.go +++ b/backend/internal/modules/adminuser/service.go @@ -17,11 +17,16 @@ var ( ErrInvalidWalletAmount = errors.New("invalid wallet amount") ErrInvalidWalletReason = errors.New("invalid wallet reason") ErrInsufficientBalance = errors.New("insufficient balance") + ErrInvalidGrowthPoints = errors.New("invalid growth points") + ErrInvalidGrowthReason = errors.New("invalid growth reason") + ErrGrowthPointsUnchanged = errors.New("growth points unchanged") ) // 单笔人工调账上限:10 万元,防止误填。 const maxWalletAdjustAmountCent int64 = 10_000_000 +const maxRenterGrowthPoints int64 = 1_000_000_000 + var manualRealnameIDPattern = regexp.MustCompile(`^\d{17}[\dXx]$`) type Service struct { @@ -69,6 +74,21 @@ func (s *Service) SetDepositFreeQuota(ctx context.Context, adminID uint64, userI return s.repo.SetDepositFreeQuota(ctx, adminID, userID, req, meta) } +func (s *Service) AdjustGrowthPoints(ctx context.Context, adminID uint64, userID uint64, req GrowthPointsAdjustRequest, meta AuditMeta) (*UserDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if userID == 0 || req.TargetPoints < 0 || req.TargetPoints > maxRenterGrowthPoints { + return nil, ErrInvalidGrowthPoints + } + reason := strings.TrimSpace(req.Reason) + if reasonLen := len([]rune(reason)); reasonLen < 2 || reasonLen > 200 { + return nil, ErrInvalidGrowthReason + } + req.Reason = reason + return s.repo.AdjustGrowthPoints(ctx, adminID, userID, req, meta) +} + func (s *Service) RevokeRealname(ctx context.Context, adminID uint64, userID uint64, req RevokeRealnameRequest, meta AuditMeta) (*UserDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index 09cce1e..f6d4cd1 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -31,6 +31,9 @@ type OrderDTO struct { DepositAmountCent int64 `json:"deposit_amount_cent"` DepositOriginalAmountCent int64 `json:"deposit_original_amount_cent"` DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"` + DepositFreeLevelQuotaCent int64 `json:"deposit_free_level_quota_cent,omitempty"` + DepositFreeManualQuotaCent int64 `json:"deposit_free_manual_quota_cent,omitempty"` + DepositFreeUsedBeforeCent int64 `json:"deposit_free_used_before_cent,omitempty"` PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"` RentOriginalAmountCent int64 `json:"rent_original_amount_cent,omitempty"` RentDiscountAmountCent int64 `json:"rent_discount_amount_cent,omitempty"` diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go index 48c65e0..0f7e3c2 100644 --- a/backend/internal/modules/order/lifecycle.go +++ b/backend/internal/modules/order/lifecycle.go @@ -54,36 +54,39 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ pricing.RentAmountCent -= rentDiscountAmountCent } depositOriginalAmountCent := listing.DepositAmountCent - paidDepositAmountCent, waivedDepositAmountCent, err := r.depositAmountsForOrder(tx, renterID, depositOriginalAmountCent) + depositWaiver, err := r.depositAmountsForOrder(tx, renterID, depositOriginalAmountCent, growthSnapshot) if err != nil { return err } order := model.RentalOrder{ - OrderNo: orderNo, - ListingID: listing.ID, - AccountID: listing.AccountID, - OwnerID: listing.OwnerID, - RenterID: renterID, - EstimatedDurationHours: rentHours, - RentAmountCent: pricing.RentAmountCent, - OwnerRentAmountCent: pricing.OwnerRentAmountCent, - DepositAmountCent: paidDepositAmountCent, - DepositOriginalAmountCent: depositOriginalAmountCent, - DepositWaivedAmountCent: waivedDepositAmountCent, - PlatformFeeCent: pricing.PlatformFeeCent, - RentOriginalAmountCent: rentOriginalAmountCent, - RentDiscountAmountCent: rentDiscountAmountCent, - RenterGrowthLevel: growthSnapshot.LevelCode, - RenterGrowthLevelName: growthSnapshot.LevelName, - RenterDiscountBps: growthSnapshot.DiscountBps, - AccountSnapshot: snapshot, - Status: orderStatusPendingPayment, - HandoffStatus: handoffStatusNone, - HandoffMode: listingHandoffMode(listing), - SettlementMode: listingSettlementMode(listing), - ManagedAdminID: listing.ManagedAdminID, - SettlementStatus: settlementStatusUnsettled, - OfflineSettlementStatus: offlineSettlementStatusNone, + OrderNo: orderNo, + ListingID: listing.ID, + AccountID: listing.AccountID, + OwnerID: listing.OwnerID, + RenterID: renterID, + EstimatedDurationHours: rentHours, + RentAmountCent: pricing.RentAmountCent, + OwnerRentAmountCent: pricing.OwnerRentAmountCent, + DepositAmountCent: depositWaiver.PaidCent, + DepositOriginalAmountCent: depositOriginalAmountCent, + DepositWaivedAmountCent: depositWaiver.WaivedCent, + DepositFreeLevelQuotaCent: growthSnapshot.LevelDepositFreeQuotaCent, + DepositFreeManualQuotaCent: growthSnapshot.ManualDepositFreeQuotaCent, + DepositFreeUsedBeforeCent: depositWaiver.UsedBeforeCent, + PlatformFeeCent: pricing.PlatformFeeCent, + RentOriginalAmountCent: rentOriginalAmountCent, + RentDiscountAmountCent: rentDiscountAmountCent, + RenterGrowthLevel: growthSnapshot.LevelCode, + RenterGrowthLevelName: growthSnapshot.LevelName, + RenterDiscountBps: growthSnapshot.DiscountBps, + AccountSnapshot: snapshot, + Status: orderStatusPendingPayment, + HandoffStatus: handoffStatusNone, + HandoffMode: listingHandoffMode(listing), + SettlementMode: listingSettlementMode(listing), + ManagedAdminID: listing.ManagedAdminID, + SettlementStatus: settlementStatusUnsettled, + OfflineSettlementStatus: offlineSettlementStatusNone, } if err := tx.Create(&order).Error; err != nil { return err @@ -128,20 +131,26 @@ func listingSettlementMode(listing model.RentalListing) string { return settlementModeOwnerWallet } -func (r *Repository) depositAmountsForOrder(tx *gorm.DB, renterID uint64, originalDepositCent int64) (int64, int64, error) { +type depositWaiverAmounts struct { + PaidCent int64 + WaivedCent int64 + UsedBeforeCent int64 +} + +func (r *Repository) depositAmountsForOrder(tx *gorm.DB, renterID uint64, originalDepositCent int64, growthSnapshot rentergrowth.DiscountSnapshot) (depositWaiverAmounts, error) { if originalDepositCent <= 0 { - return 0, 0, nil - } - var user model.User - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, renterID).Error; err != nil { - return 0, 0, err + return depositWaiverAmounts{}, nil } used, err := activeDepositFreeUsed(tx, renterID) if err != nil { - return 0, 0, err + return depositWaiverAmounts{}, err } - paidDeposit, waivedDeposit := calculateDepositWaiver(originalDepositCent, user.DepositFreeQuotaCent, used) - return paidDeposit, waivedDeposit, nil + paidDeposit, waivedDeposit := calculateDepositWaiver(originalDepositCent, growthSnapshot.EffectiveDepositFreeQuotaCent, used) + return depositWaiverAmounts{ + PaidCent: paidDeposit, + WaivedCent: waivedDeposit, + UsedBeforeCent: used, + }, nil } func activeDepositFreeUsed(tx *gorm.DB, renterID uint64) (int64, error) { diff --git a/backend/internal/modules/order/presenter.go b/backend/internal/modules/order/presenter.go index c62ab62..604565b 100644 --- a/backend/internal/modules/order/presenter.go +++ b/backend/internal/modules/order/presenter.go @@ -81,6 +81,9 @@ func (row orderRow) toAdminDTO() OrderDTO { DepositAmountCent: row.DepositAmountCent, DepositOriginalAmountCent: effectiveDepositOriginalAmountCent(row.RentalOrder), DepositWaivedAmountCent: row.DepositWaivedAmountCent, + DepositFreeLevelQuotaCent: row.DepositFreeLevelQuotaCent, + DepositFreeManualQuotaCent: row.DepositFreeManualQuotaCent, + DepositFreeUsedBeforeCent: row.DepositFreeUsedBeforeCent, PlatformFeeCent: &platformFeeCent, RentOriginalAmountCent: effectiveRentOriginalAmountCent(row.RentalOrder), RentDiscountAmountCent: row.RentDiscountAmountCent, diff --git a/backend/internal/modules/order/repository_integration_test.go b/backend/internal/modules/order/repository_integration_test.go index c348c79..3789054 100644 --- a/backend/internal/modules/order/repository_integration_test.go +++ b/backend/internal/modules/order/repository_integration_test.go @@ -146,9 +146,10 @@ func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) { owner := model.User{Phone: "13800001101"} renter := model.User{ - Phone: "13800001102", - RenterGrowthPoints: 300, - RenterGrowthLevel: "platinum", + Phone: "13800001102", + RenterGrowthPoints: 300, + RenterGrowthLevel: "platinum", + DepositFreeQuotaCent: 20000, } if err := db.Create(&owner).Error; err != nil { t.Fatalf("create owner failed: %v", err) @@ -167,11 +168,12 @@ func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) { t.Fatalf("create account failed: %v", err) } listing := model.RentalListing{ - AccountID: account.ID, - OwnerID: owner.ID, - PriceCent: 10000, - Status: listingStatusPublished, - ReviewStatus: listingReviewStatusApproved, + AccountID: account.ID, + OwnerID: owner.ID, + PriceCent: 10000, + DepositAmountCent: 100000, + Status: listingStatusPublished, + ReviewStatus: listingReviewStatusApproved, } if err := db.Create(&listing).Error; err != nil { t.Fatalf("create listing failed: %v", err) @@ -197,6 +199,12 @@ func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) { if saved.OwnerRentAmountCent != 9000 || saved.PlatformFeeCent != 900 { t.Fatalf("pricing = owner %d platform %d, want 9000/900", saved.OwnerRentAmountCent, saved.PlatformFeeCent) } + if saved.DepositAmountCent != 30000 || saved.DepositWaivedAmountCent != 70000 { + t.Fatalf("deposit = paid %d waived %d, want 30000/70000", saved.DepositAmountCent, saved.DepositWaivedAmountCent) + } + if saved.DepositFreeLevelQuotaCent != 50000 || saved.DepositFreeManualQuotaCent != 20000 || saved.DepositFreeUsedBeforeCent != 0 { + t.Fatalf("deposit quota snapshot = level %d manual %d used %d", saved.DepositFreeLevelQuotaCent, saved.DepositFreeManualQuotaCent, saved.DepositFreeUsedBeforeCent) + } } // TestBuildOrderPricing 测试订单定价计算 diff --git a/backend/internal/modules/rentergrowth/growth.go b/backend/internal/modules/rentergrowth/growth.go index 14c2edf..14a76ff 100644 --- a/backend/internal/modules/rentergrowth/growth.go +++ b/backend/internal/modules/rentergrowth/growth.go @@ -14,14 +14,31 @@ import ( ) const ( - ConfigKey = "renter.growth_level_rules" - DefaultLevelCode = "normal" - DefaultLevelName = "普通" - DefaultDiscountBps = 10000 - DefaultPointsPerYuan = 1 - SourceOrderCompleted = "order_completed" + 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" + 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"` @@ -29,17 +46,21 @@ type Config struct { } type LevelRule struct { - Code string `json:"code"` - Name string `json:"name"` - MinPoints int64 `json:"min_points"` - DiscountBps int `json:"discount_bps"` + 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 + LevelCode string + LevelName string + DiscountBps int + UserPoints int64 + LevelDepositFreeQuotaCent int64 + ManualDepositFreeQuotaCent int64 + EffectiveDepositFreeQuotaCent int64 } func DefaultConfig() Config { @@ -47,14 +68,41 @@ func DefaultConfig() 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}, + {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 { @@ -63,6 +111,63 @@ func DefaultConfigValue() string { 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 { @@ -97,19 +202,26 @@ func SnapshotForUser(tx *gorm.DB, userID uint64) (DiscountSnapshot, error) { 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 } + 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, + LevelCode: level.Code, + LevelName: level.Name, + DiscountBps: level.DiscountBps, + UserPoints: user.RenterGrowthPoints, + LevelDepositFreeQuotaCent: level.DepositFreeQuotaCent, + ManualDepositFreeQuotaCent: manualQuotaCent, + EffectiveDepositFreeQuotaCent: addQuotaCent(level.DepositFreeQuotaCent, manualQuotaCent), }, nil } @@ -182,7 +294,7 @@ func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualRentCent i now := time.Now() ledger := model.RenterGrowthLedger{ UserID: order.RenterID, - OrderID: order.ID, + OrderID: &order.ID, Points: points, BeforePoints: beforePoints, AfterPoints: afterPoints, @@ -224,6 +336,30 @@ func LevelForPoints(cfg Config, points int64) LevelRule { 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 { @@ -249,6 +385,9 @@ func normalizeConfig(cfg Config) Config { 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 @@ -258,10 +397,11 @@ func normalizeConfig(cfg Config) Config { } if !hasNormal { levels = append(levels, LevelRule{ - Code: DefaultLevelCode, - Name: DefaultLevelName, - MinPoints: 0, - DiscountBps: DefaultDiscountBps, + Code: DefaultLevelCode, + Name: DefaultLevelName, + MinPoints: 0, + DiscountBps: DefaultDiscountBps, + DepositFreeQuotaCent: 0, }) } sort.SliceStable(levels, func(i, j int) bool { @@ -271,6 +411,36 @@ func normalizeConfig(cfg Config) Config { 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 diff --git a/backend/internal/modules/rentergrowth/growth_test.go b/backend/internal/modules/rentergrowth/growth_test.go index ef1f02a..c23d9c6 100644 --- a/backend/internal/modules/rentergrowth/growth_test.go +++ b/backend/internal/modules/rentergrowth/growth_test.go @@ -1,12 +1,99 @@ package rentergrowth import ( + "encoding/json" "testing" "hfb_sys/backend/internal/database" "hfb_sys/backend/internal/model" ) +func TestDefaultConfigIncludesDepositFreeQuota(t *testing.T) { + cfg := DefaultConfig() + wants := map[string]int64{ + DefaultLevelCode: 0, + "platinum": DefaultPlatinumQuotaCent, + "diamond": DefaultDiamondQuotaCent, + "peak": DefaultPeakQuotaCent, + } + for _, level := range cfg.Levels { + if level.DepositFreeQuotaCent != wants[level.Code] { + t.Fatalf("等级 %s 免押额度 = %d, want %d", level.Code, level.DepositFreeQuotaCent, wants[level.Code]) + } + } +} + +func TestLegacyConfigBackfillsQuotaButKeepsExplicitZero(t *testing.T) { + legacy := `{"enabled":true,"points_per_yuan":1,"levels":[{"code":"normal","name":"普通","min_points":0,"discount_bps":10000},{"code":"platinum","name":"铂金","min_points":300,"discount_bps":9900},{"code":"diamond","name":"钻石","min_points":1000,"discount_bps":9800},{"code":"peak","name":"巅峰","min_points":5000,"discount_bps":9500}]}` + cfg, err := ParseConfigValue(legacy) + if err != nil { + t.Fatalf("解析旧配置失败:%v", err) + } + if LevelForPoints(cfg, 300).DepositFreeQuotaCent != DefaultPlatinumQuotaCent { + t.Fatalf("旧配置未补齐铂金免押额度") + } + + var raw map[string]any + if err := json.Unmarshal([]byte(legacy), &raw); err != nil { + t.Fatalf("解析测试 JSON 失败:%v", err) + } + levels := raw["levels"].([]any) + levels[1].(map[string]any)["deposit_free_quota_cent"] = float64(0) + explicitZero, err := json.Marshal(raw) + if err != nil { + t.Fatalf("生成测试配置失败:%v", err) + } + cfg, err = ParseConfigValue(string(explicitZero)) + if err != nil { + t.Fatalf("解析显式零额度配置失败:%v", err) + } + if LevelForPoints(cfg, 300).DepositFreeQuotaCent != 0 { + t.Fatalf("显式配置的零额度被默认值覆盖") + } +} + +func TestSnapshotCombinesLevelAndManualDepositQuota(t *testing.T) { + db := database.NewTestDB() + if err := db.AutoMigrate(&model.User{}, &model.SystemConfig{}); err != nil { + t.Fatalf("migrate failed: %v", err) + } + user := model.User{Phone: "13800009999", RenterGrowthPoints: 1000, DepositFreeQuotaCent: 30_000} + if err := db.Create(&user).Error; err != nil { + t.Fatalf("create user failed: %v", err) + } + snapshot, err := SnapshotForUser(db, user.ID) + if err != nil { + t.Fatalf("SnapshotForUser() error = %v", err) + } + if snapshot.LevelDepositFreeQuotaCent != DefaultDiamondQuotaCent || snapshot.ManualDepositFreeQuotaCent != 30_000 || snapshot.EffectiveDepositFreeQuotaCent != 130_000 { + t.Fatalf("免押额度快照不正确:%+v", snapshot) + } +} + +func TestSyncAllUserLevelsUsesUpdatedThresholds(t *testing.T) { + db := database.NewTestDB() + if err := db.AutoMigrate(&model.User{}); err != nil { + t.Fatalf("migrate failed: %v", err) + } + users := []model.User{ + {Phone: "13800009001", RenterGrowthPoints: 2000, RenterGrowthLevel: DefaultLevelCode}, + {Phone: "13800009002", RenterGrowthPoints: 6000, RenterGrowthLevel: DefaultLevelCode}, + } + if err := db.Create(&users).Error; err != nil { + t.Fatalf("create users failed: %v", err) + } + if err := SyncAllUserLevels(db, DefaultConfig()); err != nil { + t.Fatalf("SyncAllUserLevels() error = %v", err) + } + var saved []model.User + if err := db.Order("id ASC").Find(&saved).Error; err != nil { + t.Fatalf("find users failed: %v", err) + } + if saved[0].RenterGrowthLevel != "diamond" || saved[1].RenterGrowthLevel != "peak" { + t.Fatalf("同步后等级不正确:%s/%s", saved[0].RenterGrowthLevel, saved[1].RenterGrowthLevel) + } +} + func TestLevelForPointsStartsFromNormal(t *testing.T) { cfg := DefaultConfig() cases := []struct { diff --git a/backend/internal/modules/systemconfig/repository.go b/backend/internal/modules/systemconfig/repository.go index 4d0ed7d..75e1b03 100644 --- a/backend/internal/modules/systemconfig/repository.go +++ b/backend/internal/modules/systemconfig/repository.go @@ -37,7 +37,7 @@ var defaultConfigs = []defaultConfig{ {Key: listingPublishAgreementsConfigKey, Value: defaultListingPublishAgreementsConfigValue(), Description: "发布账号前协议配置 JSON"}, {Key: orderAgreementsConfigKey, Value: defaultOrderAgreementsConfigValue(), Description: "下单前协议配置 JSON"}, {Key: postRentalNoticeConfigKey, Value: defaultPostRentalNoticeConfigValue(), Description: "租后须知配置 JSON"}, - {Key: rentergrowth.ConfigKey, Value: rentergrowth.DefaultConfigValue(), Description: "租客成长等级规则 JSON(积分门槛/下单折扣)"}, + {Key: rentergrowth.ConfigKey, Value: rentergrowth.DefaultConfigValue(), Description: "租客成长等级规则(积分门槛、租金折扣、等级免押额度)"}, {Key: "chat.default_support_admin_id", Value: "2", Description: "默认客服 ID(必须是启用状态的客服角色)"}, {Key: "chat.auto_welcome_message", Value: "欢迎加入订单群聊!如有任何问题,请随时沟通。", Description: "建群后自动发送的欢迎话术"}, {Key: "chat.listing_group_welcome", Value: "欢迎加入账号群!请号主扫描下方二维码加入企业微信群,方便客服与您及时联系。", Description: "发布群创建后自动发送的欢迎语"}, @@ -135,6 +135,9 @@ func (r *Repository) Update(ctx context.Context, actorID uint64, key string, req if err := tx.Save(&row).Error; err != nil { return err } + if err := syncRenterGrowthLevels(tx, key, req.Value); err != nil { + return err + } if err := appendAuditLog(tx, actorID, "system_config.update", row.ID, meta, map[string]any{ "key": row.Key, "before": before, @@ -144,6 +147,9 @@ func (r *Repository) Update(ctx context.Context, actorID uint64, key string, req } return nil } + if err := syncRenterGrowthLevels(tx, key, req.Value); err != nil { + return err + } return appendAuditLog(tx, actorID, "system_config.create", row.ID, meta, map[string]any{ "key": row.Key, "value": row.Value, @@ -156,6 +162,17 @@ func (r *Repository) Update(ctx context.Context, actorID uint64, key string, req return &dto, nil } +func syncRenterGrowthLevels(tx *gorm.DB, key string, value string) error { + if key != rentergrowth.ConfigKey { + return nil + } + cfg, err := rentergrowth.ParseConfigValue(value) + if err != nil { + return err + } + return rentergrowth.SyncAllUserLevels(tx, cfg) +} + func (r *Repository) ensureDefaults(ctx context.Context) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { for _, item := range defaultConfigs { diff --git a/backend/internal/modules/systemconfig/service_update.go b/backend/internal/modules/systemconfig/service_update.go index 0873097..6f401f5 100644 --- a/backend/internal/modules/systemconfig/service_update.go +++ b/backend/internal/modules/systemconfig/service_update.go @@ -4,6 +4,8 @@ import ( "context" "strconv" "strings" + + "hfb_sys/backend/internal/modules/rentergrowth" ) func (s *Service) Update(ctx context.Context, actorID uint64, key string, req UpdateRequest, meta AuditMeta) (*ConfigDTO, error) { @@ -19,5 +21,16 @@ func (s *Service) Update(ctx context.Context, actorID uint64, key string, req Up return nil, ErrInvalidConfig } } + if key == rentergrowth.ConfigKey { + cfg, err := rentergrowth.ParseConfigValue(req.Value) + if err != nil { + return nil, ErrInvalidConfig + } + value, err := rentergrowth.MarshalConfig(cfg) + if err != nil { + return nil, ErrInvalidConfig + } + req.Value = value + } return s.repo.Update(ctx, actorID, key, req, meta) } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index b4d2b4f..7d4014a 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -565,6 +565,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/users/:id/freeze", requirePerm("user:freeze"), adminUserHandler.Freeze) adminRoutes.POST("/users/:id/unfreeze", requirePerm("user:unfreeze"), adminUserHandler.Unfreeze) adminRoutes.POST("/users/:id/deposit-free-quota", requirePerm("user:deposit_free"), adminUserHandler.SetDepositFreeQuota) + adminRoutes.POST("/users/:id/growth-points", requirePerm("user:growth_points"), adminUserHandler.AdjustGrowthPoints) adminRoutes.POST("/users/:id/manual-realname", requirePerm("user:manual_realname"), adminUserHandler.ManualRealname) adminRoutes.POST("/users/:id/revoke-realname", requirePerm("user:revoke_realname"), adminUserHandler.RevokeRealname) adminRoutes.POST("/users/:id/wallet/adjust", requirePerm("user:wallet_adjust"), adminUserHandler.AdjustWallet) diff --git a/backend/migrations/000040_renter_growth_deposit_and_admin_points.sql b/backend/migrations/000040_renter_growth_deposit_and_admin_points.sql new file mode 100644 index 0000000..e706037 --- /dev/null +++ b/backend/migrations/000040_renter_growth_deposit_and_admin_points.sql @@ -0,0 +1,54 @@ +-- +goose Up + +ALTER TABLE rental_orders + ADD COLUMN deposit_free_level_quota_cent BIGINT NOT NULL DEFAULT 0 COMMENT '下单时等级免押额度快照(分)' AFTER deposit_waived_amount_cent, + ADD COLUMN deposit_free_manual_quota_cent BIGINT NOT NULL DEFAULT 0 COMMENT '下单时人工免押额度快照(分)' AFTER deposit_free_level_quota_cent, + ADD COLUMN deposit_free_used_before_cent BIGINT NOT NULL DEFAULT 0 COMMENT '下单前进行中订单已占用免押额度(分)' AFTER deposit_free_manual_quota_cent; + +ALTER TABLE renter_growth_ledger + DROP KEY uk_renter_growth_order_source, + MODIFY COLUMN order_id BIGINT UNSIGNED NULL COMMENT '租号订单ID,人工调整时为空', + ADD COLUMN operator_admin_id BIGINT UNSIGNED NULL COMMENT '人工调整管理员ID' AFTER source, + ADD UNIQUE KEY uk_renter_growth_order_source (order_id, source), + ADD KEY idx_renter_growth_ledger_operator (operator_admin_id); + +INSERT INTO permissions (code, name, resource, action) VALUES +('user:growth_points', '调整成长积分', 'user', 'growth_points') +ON DUPLICATE KEY UPDATE + name = VALUES(name), + resource = VALUES(resource), + action = VALUES(action); + +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r, permissions p +WHERE r.code IN ('super_admin', 'ops') AND p.code = 'user:growth_points'; + +UPDATE system_configs +SET description = '租客成长等级规则(积分门槛、租金折扣、等级免押额度)' +WHERE `key` = 'renter.growth_level_rules'; + +-- +goose Down + +DELETE rp FROM role_permissions rp +JOIN permissions p ON p.id = rp.permission_id +WHERE p.code = 'user:growth_points'; + +DELETE FROM permissions WHERE code = 'user:growth_points'; + +ALTER TABLE renter_growth_ledger + DROP KEY idx_renter_growth_ledger_operator, + DROP KEY uk_renter_growth_order_source, + DROP COLUMN operator_admin_id; + +DELETE FROM renter_growth_ledger WHERE source = 'admin_adjustment'; + +UPDATE renter_growth_ledger SET order_id = 0 WHERE order_id IS NULL; + +ALTER TABLE renter_growth_ledger + MODIFY COLUMN order_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租号订单ID', + ADD UNIQUE KEY uk_renter_growth_order_source (order_id, source); + +ALTER TABLE rental_orders + DROP COLUMN deposit_free_used_before_cent, + DROP COLUMN deposit_free_manual_quota_cent, + DROP COLUMN deposit_free_level_quota_cent; diff --git a/frontend/src/features/admin/api/adminUsers.ts b/frontend/src/features/admin/api/adminUsers.ts index a031cec..ad04a26 100644 --- a/frontend/src/features/admin/api/adminUsers.ts +++ b/frontend/src/features/admin/api/adminUsers.ts @@ -11,6 +11,9 @@ export interface AdminUserItem { risk_status: RiskStatus credit_score: number deposit_free_quota_cent: number + deposit_free_manual_quota_cent: number + deposit_free_level_quota_cent: number + deposit_free_effective_quota_cent: number deposit_free_used_cent: number deposit_free_remaining_cent: number renter_growth_points: number @@ -34,6 +37,11 @@ export interface AdminWalletAdjustPayload { reference_no?: string } +export interface AdminGrowthPointsAdjustPayload { + target_points: number + reason: string +} + export interface AdminUserQuery { keyword?: string status?: '' | UserStatus @@ -74,6 +82,17 @@ export async function setAdminUserDepositFreeQuota(id: number, amountCent: numbe return data.data } +export async function adjustAdminUserGrowthPoints( + id: number, + payload: AdminGrowthPointsAdjustPayload +) { + const { data } = await apiClient.post>( + `/admin/users/${id}/growth-points`, + payload + ) + return data.data +} + export async function revokeAdminUserRealname(id: number, reason: string) { const { data } = await apiClient.post>( `/admin/users/${id}/revoke-realname`, diff --git a/frontend/src/features/admin/api/renterGrowth.ts b/frontend/src/features/admin/api/renterGrowth.ts new file mode 100644 index 0000000..bbe8fd8 --- /dev/null +++ b/frontend/src/features/admin/api/renterGrowth.ts @@ -0,0 +1,92 @@ +export interface RenterGrowthLevelRule { + code: 'normal' | 'platinum' | 'diamond' | 'peak' + name: string + min_points: number + discount_bps: number + deposit_free_quota_cent: number +} + +export interface RenterGrowthConfig { + enabled: boolean + points_per_yuan: number + levels: RenterGrowthLevelRule[] +} + +export const defaultRenterGrowthConfig: RenterGrowthConfig = { + enabled: true, + points_per_yuan: 1, + levels: [ + { + code: 'normal', + name: '普通', + min_points: 0, + discount_bps: 10000, + deposit_free_quota_cent: 0, + }, + { + code: 'platinum', + name: '铂金', + min_points: 300, + discount_bps: 9900, + deposit_free_quota_cent: 50000, + }, + { + code: 'diamond', + name: '钻石', + min_points: 1000, + discount_bps: 9800, + deposit_free_quota_cent: 100000, + }, + { + code: 'peak', + name: '巅峰', + min_points: 5000, + discount_bps: 9500, + deposit_free_quota_cent: 150000, + }, + ], +} + +export function parseRenterGrowthConfig(raw: string): RenterGrowthConfig { + const parsed = parseRawConfig(raw) + const incomingLevels = Array.isArray(parsed.levels) ? parsed.levels : [] + return { + enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : true, + points_per_yuan: positiveInteger(parsed.points_per_yuan, 1), + levels: defaultRenterGrowthConfig.levels.map(defaultLevel => { + const incoming = incomingLevels.find(level => level?.code === defaultLevel.code) + return { + code: defaultLevel.code, + name: defaultLevel.name, + min_points: nonNegativeInteger(incoming?.min_points, defaultLevel.min_points), + discount_bps: positiveInteger(incoming?.discount_bps, defaultLevel.discount_bps), + deposit_free_quota_cent: nonNegativeInteger( + incoming?.deposit_free_quota_cent, + defaultLevel.deposit_free_quota_cent + ), + } + }), + } +} + +function parseRawConfig(raw: string): Partial { + try { + return JSON.parse(raw) as Partial + } catch { + return {} + } +} + +export function cloneRenterGrowthConfig(config: RenterGrowthConfig): RenterGrowthConfig { + return JSON.parse(JSON.stringify(config)) as RenterGrowthConfig +} + +function positiveInteger(value: unknown, fallback: number) { + const number = Number(value) + return Number.isInteger(number) && number > 0 ? number : fallback +} + +function nonNegativeInteger(value: unknown, fallback: number) { + const number = Number(value) + return Number.isInteger(number) && number >= 0 ? number : fallback +} diff --git a/frontend/src/features/admin/components/RenterGrowthConfigDialog.vue b/frontend/src/features/admin/components/RenterGrowthConfigDialog.vue new file mode 100644 index 0000000..509a461 --- /dev/null +++ b/frontend/src/features/admin/components/RenterGrowthConfigDialog.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/frontend/src/features/admin/views/AdminSystemConfigsView.vue b/frontend/src/features/admin/views/AdminSystemConfigsView.vue index 482a82c..3f0d4b7 100644 --- a/frontend/src/features/admin/views/AdminSystemConfigsView.vue +++ b/frontend/src/features/admin/views/AdminSystemConfigsView.vue @@ -18,6 +18,7 @@ import { } from '@/features/listings/api/homeConfig' import type { OrderAgreements } from '@/features/orders/api/orders' import { fetchSystemConfigs, type SystemConfig } from '@/features/admin/api/systemConfigs' +import { parseRenterGrowthConfig } from '@/features/admin/api/renterGrowth' import { formatDateTime } from '@/shared/utils/time' import { safeParseJSON } from '@/shared/utils/json' import { formatSystemConfigSelectValue } from '@/shared/utils/systemConfigOptions' @@ -28,14 +29,12 @@ import SalePriceDialog from '../components/SalePriceDialog.vue' import HomeAnnouncementsDialog from '../components/HomeAnnouncementsDialog.vue' import HomeBannersDialog from '../components/HomeBannersDialog.vue' import AwRecycleDialog from '../components/AwRecycleDialog.vue' -import { - defaultAWRecycleConfig, - type AWRecycleConfig, -} from '@/features/listings/api/homeConfig' +import { defaultAWRecycleConfig, type AWRecycleConfig } from '@/features/listings/api/homeConfig' import ListingPublishAgreementsDialog from '../components/ListingPublishAgreementsDialog.vue' import OrderAgreementsDialog from '../components/OrderAgreementsDialog.vue' import PostRentalNoticeDialog from '../components/PostRentalNoticeDialog.vue' import GeneralConfigDialog from '../components/GeneralConfigDialog.vue' +import RenterGrowthConfigDialog from '../components/RenterGrowthConfigDialog.vue' import AutoWelcomeConfig from '../components/AutoWelcomeConfig.vue' import { fetchAutoWelcomeMessage, @@ -80,6 +79,7 @@ const listingPublishAgreementsVisible = ref(false) const agreementsVisible = ref(false) const postRentalNoticeVisible = ref(false) const generalVisible = ref(false) +const renterGrowthVisible = ref(false) const publishConfig = computed( () => configs.value.find(item => item.key === 'listing.publish_options') || null @@ -90,6 +90,9 @@ const salePriceConfig = computed( const listingPublishCooldownConfig = computed( () => configs.value.find(item => item.key === 'listing.publish_cooldown_minutes') || null ) +const renterGrowthConfig = computed( + () => configs.value.find(item => item.key === 'renter.growth_level_rules') || null +) const homeAnnouncementsConfig = computed( () => configs.value.find(item => item.key === 'mobile.home_announcements') || null ) @@ -153,7 +156,8 @@ const regularConfigs = computed(() => item.key !== 'chat.renter_retention_days_after_order_end' && item.key !== 'integration.paddle_ocr_token' && item.key !== 'integration.paddle_ocr_job_url' && - item.key !== 'integration.paddle_ocr_model' + item.key !== 'integration.paddle_ocr_model' && + item.key !== 'renter.growth_level_rules' ) ) @@ -197,6 +201,15 @@ const awRecycleStats = computed(() => { } }) +const renterGrowthStats = computed(() => { + const config = parseRenterGrowthConfig(renterGrowthConfig.value?.value || '') + return { + enabled: config.enabled, + pointsPerYuan: config.points_per_yuan, + levels: config.levels.filter(level => level.code !== 'normal'), + } +}) + const agreementStats = computed(() => { const agreements = parseOrderAgreements(orderAgreementsConfig.value?.value || '') return { @@ -261,6 +274,8 @@ function openEdit(row: SystemConfig) { agreementsVisible.value = true } else if (row.key === 'profile.post_rental_notice') { postRentalNoticeVisible.value = true + } else if (row.key === 'renter.growth_level_rules') { + renterGrowthVisible.value = true } else if (row.key === 'chat.renter_retention_days_after_order_end') { generalVisible.value = true } else { @@ -410,7 +425,7 @@ function formatConfigValue(row: SystemConfig) {
@@ -514,6 +529,31 @@ function formatConfigValue(row: SystemConfig) {
+
+
+
+

Renter Growth

+

租客成长等级

+ 管理完成订单后的积分换算、各等级租金支付比例与等级免押额度。 +
+ 编辑成长等级 +
+
+
+ {{ renterGrowthStats.enabled ? '已启用' : '已停用' }} + 成长权益 +
+
+ {{ renterGrowthStats.pointsPerYuan }} + 每元积分 +
+
+ ¥{{ level.deposit_free_quota_cent / 100 }} + {{ level.name }}免押 +
+
+
+
更新 - {{ formatHomeConfigStatus(awRecycleConfig || homeBannersConfig, '未初始化') }} + {{ + formatHomeConfigStatus(awRecycleConfig || homeBannersConfig, '未初始化') + }}
@@ -836,6 +878,13 @@ function formatConfigValue(row: SystemConfig) { @saved="loadConfigs" /> + + adminSession.hasPermission('user:wallet_adjust')) +const canAdjustGrowthPoints = computed(() => adminSession.hasPermission('user:growth_points')) const submitting = ref(false) const activeUser = ref(null) @@ -31,9 +33,14 @@ const quotaUser = ref(null) const realnameUser = ref(null) const manualRealnameUser = ref(null) const walletUser = ref(null) +const growthUser = ref(null) const freezeReason = ref('') const revokeRealnameReason = ref('') const quotaAmount = ref(0) +const growthForm = reactive({ + targetPoints: 0, + reason: '', +}) const manualRealnameForm = reactive({ name: '', idNo: '', @@ -85,6 +92,12 @@ function openDepositQuota(row: AdminUserItem) { quotaAmount.value = centToYuan(row.deposit_free_quota_cent) } +function openGrowthPoints(row: AdminUserItem) { + growthUser.value = row + growthForm.targetPoints = Number(row.renter_growth_points || 0) + growthForm.reason = '' +} + function openWalletAdjust(row: AdminUserItem) { walletUser.value = row walletForm.direction = 'out' @@ -98,7 +111,7 @@ async function handleSetDepositQuota() { submitting.value = true try { await setAdminUserDepositFreeQuota(quotaUser.value.id, yuanToCent(quotaAmount.value)) - ElMessage.success('免押额度已更新') + ElMessage.success('人工免押额度已更新') quotaUser.value = null await loadUsers() } catch (error) { @@ -108,6 +121,51 @@ async function handleSetDepositQuota() { } } +async function handleAdjustGrowthPoints() { + if (!growthUser.value) return + const targetPoints = Number(growthForm.targetPoints) + if (!Number.isInteger(targetPoints) || targetPoints < 0 || targetPoints > 1000000000) { + ElMessage.warning('目标积分必须是 0 到 10 亿之间的整数') + return + } + if (targetPoints === Number(growthUser.value.renter_growth_points || 0)) { + ElMessage.warning('目标积分与当前积分相同') + return + } + if (growthForm.reason.trim().length < 2) { + ElMessage.warning('请填写至少 2 个字的调整原因') + return + } + const direction = targetPoints > growthUser.value.renter_growth_points ? '提升' : '降低' + try { + await ElMessageBox.confirm( + `确认将用户 ${growthUser.value.phone || growthUser.value.id} 的成长积分从 ${growthUser.value.renter_growth_points} ${direction}至 ${targetPoints}?`, + '确认调整积分', + { + type: 'warning', + confirmButtonText: '确认调整', + cancelButtonText: '取消', + } + ) + } catch { + return + } + submitting.value = true + try { + await adjustAdminUserGrowthPoints(growthUser.value.id, { + target_points: targetPoints, + reason: growthForm.reason.trim(), + }) + ElMessage.success('成长积分已更新,等级已自动重新计算') + growthUser.value = null + await loadUsers() + } catch (error) { + ElMessage.error(readError(error, '调整成长积分失败')) + } finally { + submitting.value = false + } +} + async function handleFreeze() { if (!activeUser.value) return submitting.value = true @@ -258,7 +316,7 @@ function moneyCent(value: number | string | undefined) { 刷新 @@ -314,7 +372,9 @@ function moneyCent(value: number | string | undefined) { @@ -335,8 +395,16 @@ function moneyCent(value: number | string | undefined) { - - + + @@ -344,7 +412,7 @@ function moneyCent(value: number | string | undefined) { - +