完善租客等级免押与积分管理

This commit is contained in:
yml2213
2026-07-26 18:07:10 +08:00
parent 379536e5c5
commit c594b543d1
24 changed files with 1296 additions and 178 deletions
+29 -21
View File
@@ -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 {
@@ -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:
+127 -51
View File
@@ -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,
}
}
@@ -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)
}
@@ -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