完善租客等级免押与积分管理
This commit is contained in:
@@ -21,6 +21,9 @@ type RentalOrder struct {
|
|||||||
DepositAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
DepositAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||||
DepositOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
DepositOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||||
DepositWaivedAmountCent 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:"-"`
|
PlatformFeeCent int64 `gorm:"not null;default:0" json:"-"`
|
||||||
RentOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
RentOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||||
RentDiscountAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
RentDiscountAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||||
|
|||||||
@@ -4,17 +4,18 @@ import "time"
|
|||||||
|
|
||||||
// RenterGrowthLedger 记录租客成长积分变动流水,便于排查订单完成后的积分发放。
|
// RenterGrowthLedger 记录租客成长积分变动流水,便于排查订单完成后的积分发放。
|
||||||
type RenterGrowthLedger struct {
|
type RenterGrowthLedger struct {
|
||||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
UserID uint64 `gorm:"not null;index" json:"user_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"`
|
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"`
|
Points int64 `gorm:"not null;default:0" json:"points"`
|
||||||
BeforePoints int64 `gorm:"not null;default:0" json:"before_points"`
|
BeforePoints int64 `gorm:"not null;default:0" json:"before_points"`
|
||||||
AfterPoints int64 `gorm:"not null;default:0" json:"after_points"`
|
AfterPoints int64 `gorm:"not null;default:0" json:"after_points"`
|
||||||
BeforeLevel string `gorm:"size:32;not null;default:'normal'" json:"before_level"`
|
BeforeLevel string `gorm:"size:32;not null;default:'normal'" json:"before_level"`
|
||||||
AfterLevel string `gorm:"size:32;not null;default:'normal'" json:"after_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"`
|
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"`
|
OperatorAdminID *uint64 `gorm:"index" json:"operator_admin_id,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
Remark string `gorm:"size:255;not null;default:''" json:"remark"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (RenterGrowthLedger) TableName() string {
|
func (RenterGrowthLedger) TableName() string {
|
||||||
|
|||||||
@@ -3,27 +3,30 @@ package adminuser
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type UserDTO struct {
|
type UserDTO struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
RealnameStatus string `json:"realname_status"`
|
RealnameStatus string `json:"realname_status"`
|
||||||
RiskStatus string `json:"risk_status"`
|
RiskStatus string `json:"risk_status"`
|
||||||
CreditScore int `json:"credit_score"`
|
CreditScore int `json:"credit_score"`
|
||||||
DepositFreeQuotaCent int64 `json:"deposit_free_quota_cent"`
|
DepositFreeQuotaCent int64 `json:"deposit_free_quota_cent"`
|
||||||
DepositFreeUsedCent int64 `json:"deposit_free_used_cent"`
|
DepositFreeManualQuotaCent int64 `json:"deposit_free_manual_quota_cent"`
|
||||||
DepositFreeRemainingCent int64 `json:"deposit_free_remaining_cent"`
|
DepositFreeLevelQuotaCent int64 `json:"deposit_free_level_quota_cent"`
|
||||||
RenterGrowthPoints int64 `json:"renter_growth_points"`
|
DepositFreeEffectiveQuotaCent int64 `json:"deposit_free_effective_quota_cent"`
|
||||||
RenterGrowthLevel string `json:"renter_growth_level"`
|
DepositFreeUsedCent int64 `json:"deposit_free_used_cent"`
|
||||||
RenterGrowthLevelName string `json:"renter_growth_level_name"`
|
DepositFreeRemainingCent int64 `json:"deposit_free_remaining_cent"`
|
||||||
AvailableBalanceCent int64 `json:"available_balance_cent"`
|
RenterGrowthPoints int64 `json:"renter_growth_points"`
|
||||||
FrozenBalanceCent int64 `json:"frozen_balance_cent"`
|
RenterGrowthLevel string `json:"renter_growth_level"`
|
||||||
Status string `json:"status"`
|
RenterGrowthLevelName string `json:"renter_growth_level_name"`
|
||||||
OrderCount int64 `json:"order_count"`
|
AvailableBalanceCent int64 `json:"available_balance_cent"`
|
||||||
ListingCount int64 `json:"listing_count"`
|
FrozenBalanceCent int64 `json:"frozen_balance_cent"`
|
||||||
DisputeCount int64 `json:"dispute_count"`
|
Status string `json:"status"`
|
||||||
LastLoginAt *time.Time `json:"last_login_at"`
|
OrderCount int64 `json:"order_count"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
ListingCount int64 `json:"listing_count"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
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 {
|
type FreezeRequest struct {
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
@@ -43,6 +46,11 @@ type DepositFreeQuotaRequest struct {
|
|||||||
AmountCent int64 `json:"amount_cent"`
|
AmountCent int64 `json:"amount_cent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GrowthPointsAdjustRequest struct {
|
||||||
|
TargetPoints int64 `json:"target_points"`
|
||||||
|
Reason string `json:"reason" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
// WalletAdjustRequest 后台人工调整用户可用余额。
|
// WalletAdjustRequest 后台人工调整用户可用余额。
|
||||||
// Direction: in=加款 out=扣款;仅操作 available,不改 frozen。
|
// Direction: in=加款 out=扣款;仅操作 available,不改 frozen。
|
||||||
type WalletAdjustRequest struct {
|
type WalletAdjustRequest struct {
|
||||||
|
|||||||
@@ -120,6 +120,29 @@ func (h *Handler) SetDepositFreeQuota(c *gin.Context) {
|
|||||||
response.OK(c, item)
|
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) {
|
func (h *Handler) RevokeRealname(c *gin.Context) {
|
||||||
adminID, ok := currentAdminID(c)
|
adminID, ok := currentAdminID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -232,6 +255,12 @@ func writeAdminUserError(c *gin.Context, err error) {
|
|||||||
response.BadRequest(c, "请填写 2-200 字的调账原因")
|
response.BadRequest(c, "请填写 2-200 字的调账原因")
|
||||||
case errors.Is(err, ErrInsufficientBalance):
|
case errors.Is(err, ErrInsufficientBalance):
|
||||||
response.Error(c, http.StatusConflict, "insufficient_balance", "用户可用余额不足,无法扣款")
|
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):
|
case IsNotFound(err):
|
||||||
response.Error(c, http.StatusNotFound, "not_found", "用户不存在")
|
response.Error(c, http.StatusNotFound, "not_found", "用户不存在")
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -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) {
|
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
|
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 {
|
if err := countTx.Count(&total).Error; err != nil {
|
||||||
return nil, err
|
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 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 (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")
|
Joins("LEFT JOIN wallet_accounts AS w ON w.user_id = u.id")
|
||||||
listTx = applyUserFilter(listTx, query)
|
listTx = applyUserFilter(listTx, query, growthConfig)
|
||||||
err := listTx.
|
err = listTx.
|
||||||
Order("u.id DESC").
|
Order("u.id DESC").
|
||||||
Offset(offset).Limit(pageSize).
|
Offset(offset).Limit(pageSize).
|
||||||
Scan(&rows).Error
|
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))
|
items := make([]UserDTO, 0, len(rows))
|
||||||
for _, row := range 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
|
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyUserFilter 将列表筛选条件应用到查询(基于 users 表别名 u)。
|
// 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 != "" {
|
if query.Status != "" {
|
||||||
tx = tx.Where("u.status = ?", query.Status)
|
tx = tx.Where("u.status = ?", query.Status)
|
||||||
}
|
}
|
||||||
if query.HasDepositFreeQuota {
|
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 != "" {
|
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||||||
like := "%" + keyword + "%"
|
like := "%" + keyword + "%"
|
||||||
@@ -89,6 +97,24 @@ func applyUserFilter(tx *gorm.DB, query ListQuery) *gorm.DB {
|
|||||||
return tx
|
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) {
|
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)
|
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)
|
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) {
|
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 {
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
var user model.User
|
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) {
|
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
|
var row userRow
|
||||||
err := r.db.WithContext(ctx).Table("users AS u").
|
err = r.db.WithContext(ctx).Table("users AS u").
|
||||||
Select(`u.*,
|
Select(`u.*,
|
||||||
COALESCE(o.order_count, 0) AS order_count,
|
COALESCE(o.order_count, 0) AS order_count,
|
||||||
COALESCE(l.listing_count, 0) AS listing_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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
dto := row.toDTO()
|
dto := row.toDTO(growthConfig)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,53 +471,43 @@ type userRow struct {
|
|||||||
FrozenBalanceCent int64
|
FrozenBalanceCent int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (row userRow) toDTO() UserDTO {
|
func (row userRow) toDTO(growthConfig rentergrowth.Config) UserDTO {
|
||||||
remaining := row.DepositFreeQuotaCent - row.DepositFreeUsedCent
|
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 {
|
if remaining < 0 {
|
||||||
remaining = 0
|
remaining = 0
|
||||||
}
|
}
|
||||||
return UserDTO{
|
return UserDTO{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
Phone: row.Phone,
|
Phone: row.Phone,
|
||||||
Nickname: row.Nickname,
|
Nickname: row.Nickname,
|
||||||
RealnameStatus: row.RealnameStatus,
|
RealnameStatus: row.RealnameStatus,
|
||||||
RiskStatus: row.RiskStatus,
|
RiskStatus: row.RiskStatus,
|
||||||
CreditScore: row.CreditScore,
|
CreditScore: row.CreditScore,
|
||||||
DepositFreeQuotaCent: row.DepositFreeQuotaCent,
|
DepositFreeQuotaCent: row.DepositFreeQuotaCent,
|
||||||
DepositFreeUsedCent: row.DepositFreeUsedCent,
|
DepositFreeManualQuotaCent: row.DepositFreeQuotaCent,
|
||||||
DepositFreeRemainingCent: remaining,
|
DepositFreeLevelQuotaCent: levelQuotaCent,
|
||||||
RenterGrowthPoints: row.RenterGrowthPoints,
|
DepositFreeEffectiveQuotaCent: effectiveQuotaCent,
|
||||||
RenterGrowthLevel: effectiveRenterGrowthLevel(row.User),
|
DepositFreeUsedCent: row.DepositFreeUsedCent,
|
||||||
RenterGrowthLevelName: renterGrowthLevelName(row.RenterGrowthLevel),
|
DepositFreeRemainingCent: remaining,
|
||||||
AvailableBalanceCent: row.AvailableBalanceCent,
|
RenterGrowthPoints: row.RenterGrowthPoints,
|
||||||
FrozenBalanceCent: row.FrozenBalanceCent,
|
RenterGrowthLevel: level.Code,
|
||||||
Status: row.Status,
|
RenterGrowthLevelName: level.Name,
|
||||||
OrderCount: row.OrderCount,
|
AvailableBalanceCent: row.AvailableBalanceCent,
|
||||||
ListingCount: row.ListingCount,
|
FrozenBalanceCent: row.FrozenBalanceCent,
|
||||||
DisputeCount: row.DisputeCount,
|
Status: row.Status,
|
||||||
LastLoginAt: row.LastLoginAt,
|
OrderCount: row.OrderCount,
|
||||||
CreatedAt: row.CreatedAt,
|
ListingCount: row.ListingCount,
|
||||||
UpdatedAt: row.UpdatedAt,
|
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
"hfb_sys/backend/internal/modules/rentergrowth"
|
||||||
|
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -69,17 +70,59 @@ func TestApplyUserFilterHasDepositFreeQuota(t *testing.T) {
|
|||||||
stmt := applyUserFilter(db.Table("users AS u"), ListQuery{
|
stmt := applyUserFilter(db.Table("users AS u"), ListQuery{
|
||||||
Status: "active",
|
Status: "active",
|
||||||
HasDepositFreeQuota: true,
|
HasDepositFreeQuota: true,
|
||||||
}).Find(&[]struct{}{}).Statement
|
}, rentergrowth.DefaultConfig()).Find(&[]struct{}{}).Statement
|
||||||
sql := stmt.SQL.String()
|
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)
|
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)
|
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) {
|
func TestManualRealnameUpdatesUserRecordAndAudit(t *testing.T) {
|
||||||
db := setupAdminUserTestDB(t)
|
db := setupAdminUserTestDB(t)
|
||||||
user := model.User{
|
user := model.User{
|
||||||
@@ -265,6 +308,8 @@ func setupAdminUserTestDB(t *testing.T) *gorm.DB {
|
|||||||
&model.Dispute{},
|
&model.Dispute{},
|
||||||
&model.WalletAccount{},
|
&model.WalletAccount{},
|
||||||
&model.WalletLedger{},
|
&model.WalletLedger{},
|
||||||
|
&model.SystemConfig{},
|
||||||
|
&model.RenterGrowthLedger{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatalf("数据库迁移失败:%v", err)
|
t.Fatalf("数据库迁移失败:%v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,16 @@ var (
|
|||||||
ErrInvalidWalletAmount = errors.New("invalid wallet amount")
|
ErrInvalidWalletAmount = errors.New("invalid wallet amount")
|
||||||
ErrInvalidWalletReason = errors.New("invalid wallet reason")
|
ErrInvalidWalletReason = errors.New("invalid wallet reason")
|
||||||
ErrInsufficientBalance = errors.New("insufficient balance")
|
ErrInsufficientBalance = errors.New("insufficient balance")
|
||||||
|
ErrInvalidGrowthPoints = errors.New("invalid growth points")
|
||||||
|
ErrInvalidGrowthReason = errors.New("invalid growth reason")
|
||||||
|
ErrGrowthPointsUnchanged = errors.New("growth points unchanged")
|
||||||
)
|
)
|
||||||
|
|
||||||
// 单笔人工调账上限:10 万元,防止误填。
|
// 单笔人工调账上限:10 万元,防止误填。
|
||||||
const maxWalletAdjustAmountCent int64 = 10_000_000
|
const maxWalletAdjustAmountCent int64 = 10_000_000
|
||||||
|
|
||||||
|
const maxRenterGrowthPoints int64 = 1_000_000_000
|
||||||
|
|
||||||
var manualRealnameIDPattern = regexp.MustCompile(`^\d{17}[\dXx]$`)
|
var manualRealnameIDPattern = regexp.MustCompile(`^\d{17}[\dXx]$`)
|
||||||
|
|
||||||
type Service struct {
|
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)
|
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) {
|
func (s *Service) RevokeRealname(ctx context.Context, adminID uint64, userID uint64, req RevokeRealnameRequest, meta AuditMeta) (*UserDTO, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ type OrderDTO struct {
|
|||||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
||||||
DepositOriginalAmountCent int64 `json:"deposit_original_amount_cent"`
|
DepositOriginalAmountCent int64 `json:"deposit_original_amount_cent"`
|
||||||
DepositWaivedAmountCent int64 `json:"deposit_waived_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"`
|
PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"`
|
||||||
RentOriginalAmountCent int64 `json:"rent_original_amount_cent,omitempty"`
|
RentOriginalAmountCent int64 `json:"rent_original_amount_cent,omitempty"`
|
||||||
RentDiscountAmountCent int64 `json:"rent_discount_amount_cent,omitempty"`
|
RentDiscountAmountCent int64 `json:"rent_discount_amount_cent,omitempty"`
|
||||||
|
|||||||
@@ -54,36 +54,39 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
|
|||||||
pricing.RentAmountCent -= rentDiscountAmountCent
|
pricing.RentAmountCent -= rentDiscountAmountCent
|
||||||
}
|
}
|
||||||
depositOriginalAmountCent := listing.DepositAmountCent
|
depositOriginalAmountCent := listing.DepositAmountCent
|
||||||
paidDepositAmountCent, waivedDepositAmountCent, err := r.depositAmountsForOrder(tx, renterID, depositOriginalAmountCent)
|
depositWaiver, err := r.depositAmountsForOrder(tx, renterID, depositOriginalAmountCent, growthSnapshot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
order := model.RentalOrder{
|
order := model.RentalOrder{
|
||||||
OrderNo: orderNo,
|
OrderNo: orderNo,
|
||||||
ListingID: listing.ID,
|
ListingID: listing.ID,
|
||||||
AccountID: listing.AccountID,
|
AccountID: listing.AccountID,
|
||||||
OwnerID: listing.OwnerID,
|
OwnerID: listing.OwnerID,
|
||||||
RenterID: renterID,
|
RenterID: renterID,
|
||||||
EstimatedDurationHours: rentHours,
|
EstimatedDurationHours: rentHours,
|
||||||
RentAmountCent: pricing.RentAmountCent,
|
RentAmountCent: pricing.RentAmountCent,
|
||||||
OwnerRentAmountCent: pricing.OwnerRentAmountCent,
|
OwnerRentAmountCent: pricing.OwnerRentAmountCent,
|
||||||
DepositAmountCent: paidDepositAmountCent,
|
DepositAmountCent: depositWaiver.PaidCent,
|
||||||
DepositOriginalAmountCent: depositOriginalAmountCent,
|
DepositOriginalAmountCent: depositOriginalAmountCent,
|
||||||
DepositWaivedAmountCent: waivedDepositAmountCent,
|
DepositWaivedAmountCent: depositWaiver.WaivedCent,
|
||||||
PlatformFeeCent: pricing.PlatformFeeCent,
|
DepositFreeLevelQuotaCent: growthSnapshot.LevelDepositFreeQuotaCent,
|
||||||
RentOriginalAmountCent: rentOriginalAmountCent,
|
DepositFreeManualQuotaCent: growthSnapshot.ManualDepositFreeQuotaCent,
|
||||||
RentDiscountAmountCent: rentDiscountAmountCent,
|
DepositFreeUsedBeforeCent: depositWaiver.UsedBeforeCent,
|
||||||
RenterGrowthLevel: growthSnapshot.LevelCode,
|
PlatformFeeCent: pricing.PlatformFeeCent,
|
||||||
RenterGrowthLevelName: growthSnapshot.LevelName,
|
RentOriginalAmountCent: rentOriginalAmountCent,
|
||||||
RenterDiscountBps: growthSnapshot.DiscountBps,
|
RentDiscountAmountCent: rentDiscountAmountCent,
|
||||||
AccountSnapshot: snapshot,
|
RenterGrowthLevel: growthSnapshot.LevelCode,
|
||||||
Status: orderStatusPendingPayment,
|
RenterGrowthLevelName: growthSnapshot.LevelName,
|
||||||
HandoffStatus: handoffStatusNone,
|
RenterDiscountBps: growthSnapshot.DiscountBps,
|
||||||
HandoffMode: listingHandoffMode(listing),
|
AccountSnapshot: snapshot,
|
||||||
SettlementMode: listingSettlementMode(listing),
|
Status: orderStatusPendingPayment,
|
||||||
ManagedAdminID: listing.ManagedAdminID,
|
HandoffStatus: handoffStatusNone,
|
||||||
SettlementStatus: settlementStatusUnsettled,
|
HandoffMode: listingHandoffMode(listing),
|
||||||
OfflineSettlementStatus: offlineSettlementStatusNone,
|
SettlementMode: listingSettlementMode(listing),
|
||||||
|
ManagedAdminID: listing.ManagedAdminID,
|
||||||
|
SettlementStatus: settlementStatusUnsettled,
|
||||||
|
OfflineSettlementStatus: offlineSettlementStatusNone,
|
||||||
}
|
}
|
||||||
if err := tx.Create(&order).Error; err != nil {
|
if err := tx.Create(&order).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -128,20 +131,26 @@ func listingSettlementMode(listing model.RentalListing) string {
|
|||||||
return settlementModeOwnerWallet
|
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 {
|
if originalDepositCent <= 0 {
|
||||||
return 0, 0, nil
|
return depositWaiverAmounts{}, nil
|
||||||
}
|
|
||||||
var user model.User
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, renterID).Error; err != nil {
|
|
||||||
return 0, 0, err
|
|
||||||
}
|
}
|
||||||
used, err := activeDepositFreeUsed(tx, renterID)
|
used, err := activeDepositFreeUsed(tx, renterID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, err
|
return depositWaiverAmounts{}, err
|
||||||
}
|
}
|
||||||
paidDeposit, waivedDeposit := calculateDepositWaiver(originalDepositCent, user.DepositFreeQuotaCent, used)
|
paidDeposit, waivedDeposit := calculateDepositWaiver(originalDepositCent, growthSnapshot.EffectiveDepositFreeQuotaCent, used)
|
||||||
return paidDeposit, waivedDeposit, nil
|
return depositWaiverAmounts{
|
||||||
|
PaidCent: paidDeposit,
|
||||||
|
WaivedCent: waivedDeposit,
|
||||||
|
UsedBeforeCent: used,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func activeDepositFreeUsed(tx *gorm.DB, renterID uint64) (int64, error) {
|
func activeDepositFreeUsed(tx *gorm.DB, renterID uint64) (int64, error) {
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ func (row orderRow) toAdminDTO() OrderDTO {
|
|||||||
DepositAmountCent: row.DepositAmountCent,
|
DepositAmountCent: row.DepositAmountCent,
|
||||||
DepositOriginalAmountCent: effectiveDepositOriginalAmountCent(row.RentalOrder),
|
DepositOriginalAmountCent: effectiveDepositOriginalAmountCent(row.RentalOrder),
|
||||||
DepositWaivedAmountCent: row.DepositWaivedAmountCent,
|
DepositWaivedAmountCent: row.DepositWaivedAmountCent,
|
||||||
|
DepositFreeLevelQuotaCent: row.DepositFreeLevelQuotaCent,
|
||||||
|
DepositFreeManualQuotaCent: row.DepositFreeManualQuotaCent,
|
||||||
|
DepositFreeUsedBeforeCent: row.DepositFreeUsedBeforeCent,
|
||||||
PlatformFeeCent: &platformFeeCent,
|
PlatformFeeCent: &platformFeeCent,
|
||||||
RentOriginalAmountCent: effectiveRentOriginalAmountCent(row.RentalOrder),
|
RentOriginalAmountCent: effectiveRentOriginalAmountCent(row.RentalOrder),
|
||||||
RentDiscountAmountCent: row.RentDiscountAmountCent,
|
RentDiscountAmountCent: row.RentDiscountAmountCent,
|
||||||
|
|||||||
@@ -146,9 +146,10 @@ func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) {
|
|||||||
|
|
||||||
owner := model.User{Phone: "13800001101"}
|
owner := model.User{Phone: "13800001101"}
|
||||||
renter := model.User{
|
renter := model.User{
|
||||||
Phone: "13800001102",
|
Phone: "13800001102",
|
||||||
RenterGrowthPoints: 300,
|
RenterGrowthPoints: 300,
|
||||||
RenterGrowthLevel: "platinum",
|
RenterGrowthLevel: "platinum",
|
||||||
|
DepositFreeQuotaCent: 20000,
|
||||||
}
|
}
|
||||||
if err := db.Create(&owner).Error; err != nil {
|
if err := db.Create(&owner).Error; err != nil {
|
||||||
t.Fatalf("create owner failed: %v", err)
|
t.Fatalf("create owner failed: %v", err)
|
||||||
@@ -167,11 +168,12 @@ func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) {
|
|||||||
t.Fatalf("create account failed: %v", err)
|
t.Fatalf("create account failed: %v", err)
|
||||||
}
|
}
|
||||||
listing := model.RentalListing{
|
listing := model.RentalListing{
|
||||||
AccountID: account.ID,
|
AccountID: account.ID,
|
||||||
OwnerID: owner.ID,
|
OwnerID: owner.ID,
|
||||||
PriceCent: 10000,
|
PriceCent: 10000,
|
||||||
Status: listingStatusPublished,
|
DepositAmountCent: 100000,
|
||||||
ReviewStatus: listingReviewStatusApproved,
|
Status: listingStatusPublished,
|
||||||
|
ReviewStatus: listingReviewStatusApproved,
|
||||||
}
|
}
|
||||||
if err := db.Create(&listing).Error; err != nil {
|
if err := db.Create(&listing).Error; err != nil {
|
||||||
t.Fatalf("create listing failed: %v", err)
|
t.Fatalf("create listing failed: %v", err)
|
||||||
@@ -197,6 +199,12 @@ func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) {
|
|||||||
if saved.OwnerRentAmountCent != 9000 || saved.PlatformFeeCent != 900 {
|
if saved.OwnerRentAmountCent != 9000 || saved.PlatformFeeCent != 900 {
|
||||||
t.Fatalf("pricing = owner %d platform %d, want 9000/900", saved.OwnerRentAmountCent, saved.PlatformFeeCent)
|
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 测试订单定价计算
|
// TestBuildOrderPricing 测试订单定价计算
|
||||||
|
|||||||
@@ -14,14 +14,31 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ConfigKey = "renter.growth_level_rules"
|
ConfigKey = "renter.growth_level_rules"
|
||||||
DefaultLevelCode = "normal"
|
DefaultLevelCode = "normal"
|
||||||
DefaultLevelName = "普通"
|
DefaultLevelName = "普通"
|
||||||
DefaultDiscountBps = 10000
|
DefaultDiscountBps = 10000
|
||||||
DefaultPointsPerYuan = 1
|
DefaultPointsPerYuan = 1
|
||||||
SourceOrderCompleted = "order_completed"
|
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 {
|
type Config struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
PointsPerYuan int64 `json:"points_per_yuan"`
|
PointsPerYuan int64 `json:"points_per_yuan"`
|
||||||
@@ -29,17 +46,21 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type LevelRule struct {
|
type LevelRule struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
MinPoints int64 `json:"min_points"`
|
MinPoints int64 `json:"min_points"`
|
||||||
DiscountBps int `json:"discount_bps"`
|
DiscountBps int `json:"discount_bps"`
|
||||||
|
DepositFreeQuotaCent int64 `json:"deposit_free_quota_cent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DiscountSnapshot struct {
|
type DiscountSnapshot struct {
|
||||||
LevelCode string
|
LevelCode string
|
||||||
LevelName string
|
LevelName string
|
||||||
DiscountBps int
|
DiscountBps int
|
||||||
UserPoints int64
|
UserPoints int64
|
||||||
|
LevelDepositFreeQuotaCent int64
|
||||||
|
ManualDepositFreeQuotaCent int64
|
||||||
|
EffectiveDepositFreeQuotaCent int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultConfig() Config {
|
func DefaultConfig() Config {
|
||||||
@@ -47,14 +68,41 @@ func DefaultConfig() Config {
|
|||||||
Enabled: true,
|
Enabled: true,
|
||||||
PointsPerYuan: DefaultPointsPerYuan,
|
PointsPerYuan: DefaultPointsPerYuan,
|
||||||
Levels: []LevelRule{
|
Levels: []LevelRule{
|
||||||
{Code: DefaultLevelCode, Name: DefaultLevelName, MinPoints: 0, DiscountBps: DefaultDiscountBps},
|
{Code: DefaultLevelCode, Name: DefaultLevelName, MinPoints: 0, DiscountBps: DefaultDiscountBps, DepositFreeQuotaCent: 0},
|
||||||
{Code: "platinum", Name: "铂金", MinPoints: 300, DiscountBps: 9900},
|
{Code: "platinum", Name: "铂金", MinPoints: 300, DiscountBps: 9900, DepositFreeQuotaCent: DefaultPlatinumQuotaCent},
|
||||||
{Code: "diamond", Name: "钻石", MinPoints: 1000, DiscountBps: 9800},
|
{Code: "diamond", Name: "钻石", MinPoints: 1000, DiscountBps: 9800, DepositFreeQuotaCent: DefaultDiamondQuotaCent},
|
||||||
{Code: "peak", Name: "巅峰", MinPoints: 5000, DiscountBps: 9500},
|
{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 {
|
func DefaultConfigValue() string {
|
||||||
raw, err := json.Marshal(DefaultConfig())
|
raw, err := json.Marshal(DefaultConfig())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -63,6 +111,63 @@ func DefaultConfigValue() string {
|
|||||||
return string(raw)
|
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) {
|
func ConfigForTx(tx *gorm.DB) (Config, error) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
if tx == nil {
|
if tx == nil {
|
||||||
@@ -97,19 +202,26 @@ func SnapshotForUser(tx *gorm.DB, userID uint64) (DiscountSnapshot, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return snapshot, err
|
return snapshot, err
|
||||||
}
|
}
|
||||||
if !cfg.Enabled {
|
|
||||||
return snapshot, nil
|
|
||||||
}
|
|
||||||
var user model.User
|
var user model.User
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
|
||||||
return snapshot, err
|
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)
|
level := LevelForPoints(cfg, user.RenterGrowthPoints)
|
||||||
return DiscountSnapshot{
|
return DiscountSnapshot{
|
||||||
LevelCode: level.Code,
|
LevelCode: level.Code,
|
||||||
LevelName: level.Name,
|
LevelName: level.Name,
|
||||||
DiscountBps: level.DiscountBps,
|
DiscountBps: level.DiscountBps,
|
||||||
UserPoints: user.RenterGrowthPoints,
|
UserPoints: user.RenterGrowthPoints,
|
||||||
|
LevelDepositFreeQuotaCent: level.DepositFreeQuotaCent,
|
||||||
|
ManualDepositFreeQuotaCent: manualQuotaCent,
|
||||||
|
EffectiveDepositFreeQuotaCent: addQuotaCent(level.DepositFreeQuotaCent, manualQuotaCent),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +294,7 @@ func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualRentCent i
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
ledger := model.RenterGrowthLedger{
|
ledger := model.RenterGrowthLedger{
|
||||||
UserID: order.RenterID,
|
UserID: order.RenterID,
|
||||||
OrderID: order.ID,
|
OrderID: &order.ID,
|
||||||
Points: points,
|
Points: points,
|
||||||
BeforePoints: beforePoints,
|
BeforePoints: beforePoints,
|
||||||
AfterPoints: afterPoints,
|
AfterPoints: afterPoints,
|
||||||
@@ -224,6 +336,30 @@ func LevelForPoints(cfg Config, points int64) LevelRule {
|
|||||||
return current
|
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 {
|
func normalizeConfig(cfg Config) Config {
|
||||||
defaultCfg := DefaultConfig()
|
defaultCfg := DefaultConfig()
|
||||||
if cfg.PointsPerYuan <= 0 {
|
if cfg.PointsPerYuan <= 0 {
|
||||||
@@ -249,6 +385,9 @@ func normalizeConfig(cfg Config) Config {
|
|||||||
if level.DiscountBps <= 0 || level.DiscountBps > DefaultDiscountBps {
|
if level.DiscountBps <= 0 || level.DiscountBps > DefaultDiscountBps {
|
||||||
level.DiscountBps = DefaultDiscountBps
|
level.DiscountBps = DefaultDiscountBps
|
||||||
}
|
}
|
||||||
|
if level.DepositFreeQuotaCent < 0 {
|
||||||
|
level.DepositFreeQuotaCent = 0
|
||||||
|
}
|
||||||
if level.Code == DefaultLevelCode {
|
if level.Code == DefaultLevelCode {
|
||||||
hasNormal = true
|
hasNormal = true
|
||||||
level.Name = DefaultLevelName
|
level.Name = DefaultLevelName
|
||||||
@@ -258,10 +397,11 @@ func normalizeConfig(cfg Config) Config {
|
|||||||
}
|
}
|
||||||
if !hasNormal {
|
if !hasNormal {
|
||||||
levels = append(levels, LevelRule{
|
levels = append(levels, LevelRule{
|
||||||
Code: DefaultLevelCode,
|
Code: DefaultLevelCode,
|
||||||
Name: DefaultLevelName,
|
Name: DefaultLevelName,
|
||||||
MinPoints: 0,
|
MinPoints: 0,
|
||||||
DiscountBps: DefaultDiscountBps,
|
DiscountBps: DefaultDiscountBps,
|
||||||
|
DepositFreeQuotaCent: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
sort.SliceStable(levels, func(i, j int) bool {
|
sort.SliceStable(levels, func(i, j int) bool {
|
||||||
@@ -271,6 +411,36 @@ func normalizeConfig(cfg Config) Config {
|
|||||||
return cfg
|
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 {
|
func isMissingConfigTableError(err error) bool {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -1,12 +1,99 @@
|
|||||||
package rentergrowth
|
package rentergrowth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/database"
|
"hfb_sys/backend/internal/database"
|
||||||
"hfb_sys/backend/internal/model"
|
"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) {
|
func TestLevelForPointsStartsFromNormal(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ var defaultConfigs = []defaultConfig{
|
|||||||
{Key: listingPublishAgreementsConfigKey, Value: defaultListingPublishAgreementsConfigValue(), Description: "发布账号前协议配置 JSON"},
|
{Key: listingPublishAgreementsConfigKey, Value: defaultListingPublishAgreementsConfigValue(), Description: "发布账号前协议配置 JSON"},
|
||||||
{Key: orderAgreementsConfigKey, Value: defaultOrderAgreementsConfigValue(), Description: "下单前协议配置 JSON"},
|
{Key: orderAgreementsConfigKey, Value: defaultOrderAgreementsConfigValue(), Description: "下单前协议配置 JSON"},
|
||||||
{Key: postRentalNoticeConfigKey, Value: defaultPostRentalNoticeConfigValue(), 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.default_support_admin_id", Value: "2", Description: "默认客服 ID(必须是启用状态的客服角色)"},
|
||||||
{Key: "chat.auto_welcome_message", Value: "欢迎加入订单群聊!如有任何问题,请随时沟通。", Description: "建群后自动发送的欢迎话术"},
|
{Key: "chat.auto_welcome_message", Value: "欢迎加入订单群聊!如有任何问题,请随时沟通。", Description: "建群后自动发送的欢迎话术"},
|
||||||
{Key: "chat.listing_group_welcome", 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 {
|
if err := tx.Save(&row).Error; err != nil {
|
||||||
return err
|
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{
|
if err := appendAuditLog(tx, actorID, "system_config.update", row.ID, meta, map[string]any{
|
||||||
"key": row.Key,
|
"key": row.Key,
|
||||||
"before": before,
|
"before": before,
|
||||||
@@ -144,6 +147,9 @@ func (r *Repository) Update(ctx context.Context, actorID uint64, key string, req
|
|||||||
}
|
}
|
||||||
return nil
|
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{
|
return appendAuditLog(tx, actorID, "system_config.create", row.ID, meta, map[string]any{
|
||||||
"key": row.Key,
|
"key": row.Key,
|
||||||
"value": row.Value,
|
"value": row.Value,
|
||||||
@@ -156,6 +162,17 @@ func (r *Repository) Update(ctx context.Context, actorID uint64, key string, req
|
|||||||
return &dto, nil
|
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 {
|
func (r *Repository) ensureDefaults(ctx context.Context) error {
|
||||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
for _, item := range defaultConfigs {
|
for _, item := range defaultConfigs {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/modules/rentergrowth"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) Update(ctx context.Context, actorID uint64, key string, req UpdateRequest, meta AuditMeta) (*ConfigDTO, error) {
|
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
|
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)
|
return s.repo.Update(ctx, actorID, key, req, meta)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/freeze", requirePerm("user:freeze"), adminUserHandler.Freeze)
|
||||||
adminRoutes.POST("/users/:id/unfreeze", requirePerm("user:unfreeze"), adminUserHandler.Unfreeze)
|
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/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/manual-realname", requirePerm("user:manual_realname"), adminUserHandler.ManualRealname)
|
||||||
adminRoutes.POST("/users/:id/revoke-realname", requirePerm("user:revoke_realname"), adminUserHandler.RevokeRealname)
|
adminRoutes.POST("/users/:id/revoke-realname", requirePerm("user:revoke_realname"), adminUserHandler.RevokeRealname)
|
||||||
adminRoutes.POST("/users/:id/wallet/adjust", requirePerm("user:wallet_adjust"), adminUserHandler.AdjustWallet)
|
adminRoutes.POST("/users/:id/wallet/adjust", requirePerm("user:wallet_adjust"), adminUserHandler.AdjustWallet)
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -11,6 +11,9 @@ export interface AdminUserItem {
|
|||||||
risk_status: RiskStatus
|
risk_status: RiskStatus
|
||||||
credit_score: number
|
credit_score: number
|
||||||
deposit_free_quota_cent: 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_used_cent: number
|
||||||
deposit_free_remaining_cent: number
|
deposit_free_remaining_cent: number
|
||||||
renter_growth_points: number
|
renter_growth_points: number
|
||||||
@@ -34,6 +37,11 @@ export interface AdminWalletAdjustPayload {
|
|||||||
reference_no?: string
|
reference_no?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminGrowthPointsAdjustPayload {
|
||||||
|
target_points: number
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminUserQuery {
|
export interface AdminUserQuery {
|
||||||
keyword?: string
|
keyword?: string
|
||||||
status?: '' | UserStatus
|
status?: '' | UserStatus
|
||||||
@@ -74,6 +82,17 @@ export async function setAdminUserDepositFreeQuota(id: number, amountCent: numbe
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function adjustAdminUserGrowthPoints(
|
||||||
|
id: number,
|
||||||
|
payload: AdminGrowthPointsAdjustPayload
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<AdminUserItem>>(
|
||||||
|
`/admin/users/${id}/growth-points`,
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
export async function revokeAdminUserRealname(id: number, reason: string) {
|
export async function revokeAdminUserRealname(id: number, reason: string) {
|
||||||
const { data } = await apiClient.post<ApiResponse<AdminUserItem>>(
|
const { data } = await apiClient.post<ApiResponse<AdminUserItem>>(
|
||||||
`/admin/users/${id}/revoke-realname`,
|
`/admin/users/${id}/revoke-realname`,
|
||||||
|
|||||||
@@ -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<RenterGrowthConfig> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as Partial<RenterGrowthConfig>
|
||||||
|
} 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { updateSystemConfig, type SystemConfig } from '@/features/admin/api/systemConfigs'
|
||||||
|
import {
|
||||||
|
cloneRenterGrowthConfig,
|
||||||
|
defaultRenterGrowthConfig,
|
||||||
|
parseRenterGrowthConfig,
|
||||||
|
type RenterGrowthConfig,
|
||||||
|
type RenterGrowthLevelRule,
|
||||||
|
} from '@/features/admin/api/renterGrowth'
|
||||||
|
import { readError } from '@/shared/utils/error'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
config: SystemConfig
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:modelValue', value: boolean): void
|
||||||
|
(event: 'saved'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const submitting = ref(false)
|
||||||
|
const draft = ref<RenterGrowthConfig>(cloneRenterGrowthConfig(defaultRenterGrowthConfig))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.modelValue, props.config.value] as const,
|
||||||
|
([visible]) => {
|
||||||
|
if (visible) draft.value = parseRenterGrowthConfig(props.config.value)
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
function discountPercent(level: RenterGrowthLevelRule) {
|
||||||
|
return level.discount_bps / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDiscountPercent(level: RenterGrowthLevelRule, value: number | undefined) {
|
||||||
|
level.discount_bps = Math.round(Number(value || 0) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotaYuan(level: RenterGrowthLevelRule) {
|
||||||
|
return level.deposit_free_quota_cent / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateQuotaYuan(level: RenterGrowthLevelRule, value: number | undefined) {
|
||||||
|
level.deposit_free_quota_cent = Math.round(Number(value || 0) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDefaults() {
|
||||||
|
draft.value = cloneRenterGrowthConfig(defaultRenterGrowthConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDraft() {
|
||||||
|
if (!Number.isInteger(draft.value.points_per_yuan) || draft.value.points_per_yuan <= 0) {
|
||||||
|
return '每元积分必须是大于 0 的整数'
|
||||||
|
}
|
||||||
|
for (const [index, level] of draft.value.levels.entries()) {
|
||||||
|
const previous = draft.value.levels[index - 1]
|
||||||
|
if (!Number.isInteger(level.min_points) || level.min_points < 0) {
|
||||||
|
return `${level.name}积分门槛不正确`
|
||||||
|
}
|
||||||
|
if (index === 0 && level.min_points !== 0) return '普通等级积分门槛必须为 0'
|
||||||
|
if (previous && level.min_points <= previous.min_points) return '等级积分门槛必须严格递增'
|
||||||
|
if (level.discount_bps <= 0 || level.discount_bps > 10000) {
|
||||||
|
return `${level.name}支付比例必须在 0% 到 100% 之间`
|
||||||
|
}
|
||||||
|
if (previous && level.discount_bps > previous.discount_bps)
|
||||||
|
return '高等级支付比例不能高于低等级'
|
||||||
|
if (!Number.isInteger(level.deposit_free_quota_cent) || level.deposit_free_quota_cent < 0) {
|
||||||
|
return `${level.name}免押额度不正确`
|
||||||
|
}
|
||||||
|
if (previous && level.deposit_free_quota_cent < previous.deposit_free_quota_cent) {
|
||||||
|
return '高等级免押额度不能低于低等级'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const validationMessage = validateDraft()
|
||||||
|
if (validationMessage) {
|
||||||
|
ElMessage.warning(validationMessage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await updateSystemConfig(props.config.key, {
|
||||||
|
value: JSON.stringify(draft.value),
|
||||||
|
description: '租客成长等级规则(积分门槛、租金折扣、等级免押额度)',
|
||||||
|
})
|
||||||
|
ElMessage.success('成长等级配置已更新')
|
||||||
|
emit('saved')
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '成长等级配置保存失败'))
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
:model-value="modelValue"
|
||||||
|
title="成长等级配置"
|
||||||
|
width="860px"
|
||||||
|
destroy-on-close
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<div class="growth-config-form">
|
||||||
|
<div class="growth-switch-row">
|
||||||
|
<span>启用成长等级</span>
|
||||||
|
<el-switch v-model="draft.enabled" />
|
||||||
|
</div>
|
||||||
|
<el-form label-position="top" @submit.prevent>
|
||||||
|
<el-form-item label="每完成 1 元实付租金获得积分">
|
||||||
|
<el-input-number
|
||||||
|
v-model="draft.points_per_yuan"
|
||||||
|
:min="1"
|
||||||
|
:max="1000"
|
||||||
|
:step="1"
|
||||||
|
step-strictly
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table :data="draft.levels" border>
|
||||||
|
<el-table-column prop="name" label="等级" width="100" />
|
||||||
|
<el-table-column label="积分门槛" min-width="170">
|
||||||
|
<template #default="{ row, $index }">
|
||||||
|
<el-input-number
|
||||||
|
v-model="row.min_points"
|
||||||
|
:disabled="$index === 0"
|
||||||
|
:min="0"
|
||||||
|
:max="1000000000"
|
||||||
|
:step="100"
|
||||||
|
step-strictly
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="租金支付比例" min-width="190">
|
||||||
|
<template #default="{ row, $index }">
|
||||||
|
<el-input-number
|
||||||
|
:model-value="discountPercent(row)"
|
||||||
|
:disabled="$index === 0"
|
||||||
|
:min="0.01"
|
||||||
|
:max="100"
|
||||||
|
:precision="2"
|
||||||
|
:step="0.5"
|
||||||
|
@update:model-value="updateDiscountPercent(row, $event)"
|
||||||
|
/>
|
||||||
|
<span class="unit">%</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="等级免押额度" min-width="190">
|
||||||
|
<template #default="{ row, $index }">
|
||||||
|
<el-input-number
|
||||||
|
:model-value="quotaYuan(row)"
|
||||||
|
:disabled="$index === 0"
|
||||||
|
:min="0"
|
||||||
|
:max="1000000"
|
||||||
|
:precision="0"
|
||||||
|
:step="100"
|
||||||
|
@update:model-value="updateQuotaYuan(row, $event)"
|
||||||
|
/>
|
||||||
|
<span class="unit">元</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button :disabled="submitting" @click="resetDefaults">恢复默认</el-button>
|
||||||
|
<el-button :disabled="submitting" @click="emit('update:modelValue', false)">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSave">保存配置</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.growth-config-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.growth-switch-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 40px;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.growth-config-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.growth-config-form :deep(.el-input-number) {
|
||||||
|
width: calc(100% - 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit {
|
||||||
|
display: inline-block;
|
||||||
|
width: 28px;
|
||||||
|
margin-left: 4px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.growth-config-form :deep(.el-table) {
|
||||||
|
min-width: 760px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '@/features/listings/api/homeConfig'
|
} from '@/features/listings/api/homeConfig'
|
||||||
import type { OrderAgreements } from '@/features/orders/api/orders'
|
import type { OrderAgreements } from '@/features/orders/api/orders'
|
||||||
import { fetchSystemConfigs, type SystemConfig } from '@/features/admin/api/systemConfigs'
|
import { fetchSystemConfigs, type SystemConfig } from '@/features/admin/api/systemConfigs'
|
||||||
|
import { parseRenterGrowthConfig } from '@/features/admin/api/renterGrowth'
|
||||||
import { formatDateTime } from '@/shared/utils/time'
|
import { formatDateTime } from '@/shared/utils/time'
|
||||||
import { safeParseJSON } from '@/shared/utils/json'
|
import { safeParseJSON } from '@/shared/utils/json'
|
||||||
import { formatSystemConfigSelectValue } from '@/shared/utils/systemConfigOptions'
|
import { formatSystemConfigSelectValue } from '@/shared/utils/systemConfigOptions'
|
||||||
@@ -28,14 +29,12 @@ import SalePriceDialog from '../components/SalePriceDialog.vue'
|
|||||||
import HomeAnnouncementsDialog from '../components/HomeAnnouncementsDialog.vue'
|
import HomeAnnouncementsDialog from '../components/HomeAnnouncementsDialog.vue'
|
||||||
import HomeBannersDialog from '../components/HomeBannersDialog.vue'
|
import HomeBannersDialog from '../components/HomeBannersDialog.vue'
|
||||||
import AwRecycleDialog from '../components/AwRecycleDialog.vue'
|
import AwRecycleDialog from '../components/AwRecycleDialog.vue'
|
||||||
import {
|
import { defaultAWRecycleConfig, type AWRecycleConfig } from '@/features/listings/api/homeConfig'
|
||||||
defaultAWRecycleConfig,
|
|
||||||
type AWRecycleConfig,
|
|
||||||
} from '@/features/listings/api/homeConfig'
|
|
||||||
import ListingPublishAgreementsDialog from '../components/ListingPublishAgreementsDialog.vue'
|
import ListingPublishAgreementsDialog from '../components/ListingPublishAgreementsDialog.vue'
|
||||||
import OrderAgreementsDialog from '../components/OrderAgreementsDialog.vue'
|
import OrderAgreementsDialog from '../components/OrderAgreementsDialog.vue'
|
||||||
import PostRentalNoticeDialog from '../components/PostRentalNoticeDialog.vue'
|
import PostRentalNoticeDialog from '../components/PostRentalNoticeDialog.vue'
|
||||||
import GeneralConfigDialog from '../components/GeneralConfigDialog.vue'
|
import GeneralConfigDialog from '../components/GeneralConfigDialog.vue'
|
||||||
|
import RenterGrowthConfigDialog from '../components/RenterGrowthConfigDialog.vue'
|
||||||
import AutoWelcomeConfig from '../components/AutoWelcomeConfig.vue'
|
import AutoWelcomeConfig from '../components/AutoWelcomeConfig.vue'
|
||||||
import {
|
import {
|
||||||
fetchAutoWelcomeMessage,
|
fetchAutoWelcomeMessage,
|
||||||
@@ -80,6 +79,7 @@ const listingPublishAgreementsVisible = ref(false)
|
|||||||
const agreementsVisible = ref(false)
|
const agreementsVisible = ref(false)
|
||||||
const postRentalNoticeVisible = ref(false)
|
const postRentalNoticeVisible = ref(false)
|
||||||
const generalVisible = ref(false)
|
const generalVisible = ref(false)
|
||||||
|
const renterGrowthVisible = ref(false)
|
||||||
|
|
||||||
const publishConfig = computed(
|
const publishConfig = computed(
|
||||||
() => configs.value.find(item => item.key === 'listing.publish_options') || null
|
() => configs.value.find(item => item.key === 'listing.publish_options') || null
|
||||||
@@ -90,6 +90,9 @@ const salePriceConfig = computed(
|
|||||||
const listingPublishCooldownConfig = computed(
|
const listingPublishCooldownConfig = computed(
|
||||||
() => configs.value.find(item => item.key === 'listing.publish_cooldown_minutes') || null
|
() => 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(
|
const homeAnnouncementsConfig = computed(
|
||||||
() => configs.value.find(item => item.key === 'mobile.home_announcements') || null
|
() => 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 !== 'chat.renter_retention_days_after_order_end' &&
|
||||||
item.key !== 'integration.paddle_ocr_token' &&
|
item.key !== 'integration.paddle_ocr_token' &&
|
||||||
item.key !== 'integration.paddle_ocr_job_url' &&
|
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 agreementStats = computed(() => {
|
||||||
const agreements = parseOrderAgreements(orderAgreementsConfig.value?.value || '')
|
const agreements = parseOrderAgreements(orderAgreementsConfig.value?.value || '')
|
||||||
return {
|
return {
|
||||||
@@ -261,6 +274,8 @@ function openEdit(row: SystemConfig) {
|
|||||||
agreementsVisible.value = true
|
agreementsVisible.value = true
|
||||||
} else if (row.key === 'profile.post_rental_notice') {
|
} else if (row.key === 'profile.post_rental_notice') {
|
||||||
postRentalNoticeVisible.value = true
|
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') {
|
} else if (row.key === 'chat.renter_retention_days_after_order_end') {
|
||||||
generalVisible.value = true
|
generalVisible.value = true
|
||||||
} else {
|
} else {
|
||||||
@@ -410,7 +425,7 @@ function formatConfigValue(row: SystemConfig) {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<p class="eyebrow">Configs</p>
|
<p class="eyebrow">Configs</p>
|
||||||
<h1>系统配置</h1>
|
<h1>系统配置</h1>
|
||||||
<p>管理交接超时、归还超时、短信限流、最低押金和抽成比例。</p>
|
<p>管理发布、订单、成长等级、首页内容与外部集成参数。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-if="publishConfig" class="publish-config-panel">
|
<section v-if="publishConfig" class="publish-config-panel">
|
||||||
@@ -514,6 +529,31 @@ function formatConfigValue(row: SystemConfig) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="renterGrowthConfig" class="publish-config-panel">
|
||||||
|
<div class="publish-config-main">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Renter Growth</p>
|
||||||
|
<h2>租客成长等级</h2>
|
||||||
|
<span>管理完成订单后的积分换算、各等级租金支付比例与等级免押额度。</span>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" @click="openEdit(renterGrowthConfig)">编辑成长等级</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="publish-stat-grid home-stat-grid">
|
||||||
|
<div class="publish-stat">
|
||||||
|
<strong>{{ renterGrowthStats.enabled ? '已启用' : '已停用' }}</strong>
|
||||||
|
<span>成长权益</span>
|
||||||
|
</div>
|
||||||
|
<div class="publish-stat">
|
||||||
|
<strong>{{ renterGrowthStats.pointsPerYuan }}</strong>
|
||||||
|
<span>每元积分</span>
|
||||||
|
</div>
|
||||||
|
<div v-for="level in renterGrowthStats.levels" :key="level.code" class="publish-stat">
|
||||||
|
<strong>¥{{ level.deposit_free_quota_cent / 100 }}</strong>
|
||||||
|
<span>{{ level.name }}免押</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
v-if="homeAnnouncementsConfig || homeBannersConfig || awRecycleConfig"
|
v-if="homeAnnouncementsConfig || homeBannersConfig || awRecycleConfig"
|
||||||
class="publish-config-panel"
|
class="publish-config-panel"
|
||||||
@@ -555,7 +595,9 @@ function formatConfigValue(row: SystemConfig) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="publish-stat">
|
<div class="publish-stat">
|
||||||
<strong>更新</strong>
|
<strong>更新</strong>
|
||||||
<span>{{ formatHomeConfigStatus(awRecycleConfig || homeBannersConfig, '未初始化') }}</span>
|
<span>{{
|
||||||
|
formatHomeConfigStatus(awRecycleConfig || homeBannersConfig, '未初始化')
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -836,6 +878,13 @@ function formatConfigValue(row: SystemConfig) {
|
|||||||
@saved="loadConfigs"
|
@saved="loadConfigs"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<RenterGrowthConfigDialog
|
||||||
|
v-if="renterGrowthConfig"
|
||||||
|
v-model="renterGrowthVisible"
|
||||||
|
:config="renterGrowthConfig"
|
||||||
|
@saved="loadConfigs"
|
||||||
|
/>
|
||||||
|
|
||||||
<GeneralConfigDialog
|
<GeneralConfigDialog
|
||||||
v-if="currentEditingConfig"
|
v-if="currentEditingConfig"
|
||||||
v-model="generalVisible"
|
v-model="generalVisible"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Search } from '@element-plus/icons-vue'
|
|||||||
import { computed, reactive, ref } from 'vue'
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
adjustAdminUserGrowthPoints,
|
||||||
adjustAdminUserWallet,
|
adjustAdminUserWallet,
|
||||||
fetchAdminUsers,
|
fetchAdminUsers,
|
||||||
freezeAdminUser,
|
freezeAdminUser,
|
||||||
@@ -24,6 +25,7 @@ import AdminTablePagination from '../components/AdminTablePagination.vue'
|
|||||||
|
|
||||||
const adminSession = useAdminSessionStore()
|
const adminSession = useAdminSessionStore()
|
||||||
const canAdjustWallet = computed(() => adminSession.hasPermission('user:wallet_adjust'))
|
const canAdjustWallet = computed(() => adminSession.hasPermission('user:wallet_adjust'))
|
||||||
|
const canAdjustGrowthPoints = computed(() => adminSession.hasPermission('user:growth_points'))
|
||||||
|
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const activeUser = ref<AdminUserItem | null>(null)
|
const activeUser = ref<AdminUserItem | null>(null)
|
||||||
@@ -31,9 +33,14 @@ const quotaUser = ref<AdminUserItem | null>(null)
|
|||||||
const realnameUser = ref<AdminUserItem | null>(null)
|
const realnameUser = ref<AdminUserItem | null>(null)
|
||||||
const manualRealnameUser = ref<AdminUserItem | null>(null)
|
const manualRealnameUser = ref<AdminUserItem | null>(null)
|
||||||
const walletUser = ref<AdminUserItem | null>(null)
|
const walletUser = ref<AdminUserItem | null>(null)
|
||||||
|
const growthUser = ref<AdminUserItem | null>(null)
|
||||||
const freezeReason = ref('')
|
const freezeReason = ref('')
|
||||||
const revokeRealnameReason = ref('')
|
const revokeRealnameReason = ref('')
|
||||||
const quotaAmount = ref(0)
|
const quotaAmount = ref(0)
|
||||||
|
const growthForm = reactive({
|
||||||
|
targetPoints: 0,
|
||||||
|
reason: '',
|
||||||
|
})
|
||||||
const manualRealnameForm = reactive({
|
const manualRealnameForm = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
idNo: '',
|
idNo: '',
|
||||||
@@ -85,6 +92,12 @@ function openDepositQuota(row: AdminUserItem) {
|
|||||||
quotaAmount.value = centToYuan(row.deposit_free_quota_cent)
|
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) {
|
function openWalletAdjust(row: AdminUserItem) {
|
||||||
walletUser.value = row
|
walletUser.value = row
|
||||||
walletForm.direction = 'out'
|
walletForm.direction = 'out'
|
||||||
@@ -98,7 +111,7 @@ async function handleSetDepositQuota() {
|
|||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
await setAdminUserDepositFreeQuota(quotaUser.value.id, yuanToCent(quotaAmount.value))
|
await setAdminUserDepositFreeQuota(quotaUser.value.id, yuanToCent(quotaAmount.value))
|
||||||
ElMessage.success('免押额度已更新')
|
ElMessage.success('人工免押额度已更新')
|
||||||
quotaUser.value = null
|
quotaUser.value = null
|
||||||
await loadUsers()
|
await loadUsers()
|
||||||
} catch (error) {
|
} 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() {
|
async function handleFreeze() {
|
||||||
if (!activeUser.value) return
|
if (!activeUser.value) return
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
@@ -258,7 +316,7 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<p class="eyebrow">Users</p>
|
<p class="eyebrow">Users</p>
|
||||||
<h1>用户管理</h1>
|
<h1>用户管理</h1>
|
||||||
<p>查看用户信息与钱包余额,处理冻结、实名与余额调账。</p>
|
<p>查看用户信息,处理成长积分、人工免押、实名与余额调账。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button @click="loadUsers">刷新</el-button>
|
<el-button @click="loadUsers">刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -314,7 +372,9 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="成长等级" width="100">
|
<el-table-column label="成长等级" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag type="success" effect="light">{{ row.renter_growth_level_name || '普通' }}</el-tag>
|
<el-tag type="success" effect="light">{{
|
||||||
|
row.renter_growth_level_name || '普通'
|
||||||
|
}}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="成长积分" width="100" align="right">
|
<el-table-column label="成长积分" width="100" align="right">
|
||||||
@@ -335,8 +395,16 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="免押额度" width="100" align="right">
|
<el-table-column label="免押总额" width="150" align="right">
|
||||||
<template #default="{ row }">¥{{ moneyCent(row.deposit_free_quota_cent) }}</template>
|
<template #default="{ row }">
|
||||||
|
<div class="quota-cell">
|
||||||
|
<strong>¥{{ moneyCent(row.deposit_free_effective_quota_cent) }}</strong>
|
||||||
|
<span>
|
||||||
|
等级 {{ moneyCent(row.deposit_free_level_quota_cent) }} + 人工
|
||||||
|
{{ moneyCent(row.deposit_free_manual_quota_cent) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="剩余免押" width="100" align="right">
|
<el-table-column label="剩余免押" width="100" align="right">
|
||||||
<template #default="{ row }">¥{{ moneyCent(row.deposit_free_remaining_cent) }}</template>
|
<template #default="{ row }">¥{{ moneyCent(row.deposit_free_remaining_cent) }}</template>
|
||||||
@@ -344,7 +412,7 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
<el-table-column label="注册时间" min-width="160">
|
<el-table-column label="注册时间" min-width="160">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="320" fixed="right" align="right">
|
<el-table-column label="操作" width="360" fixed="right" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<el-button
|
<el-button
|
||||||
@@ -356,8 +424,17 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
>
|
>
|
||||||
余额
|
余额
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="canAdjustGrowthPoints"
|
||||||
|
text
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="openGrowthPoints(row)"
|
||||||
|
>
|
||||||
|
积分
|
||||||
|
</el-button>
|
||||||
<el-button text type="primary" size="small" @click="openDepositQuota(row)">
|
<el-button text type="primary" size="small" @click="openDepositQuota(row)">
|
||||||
免押额度
|
人工免押
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="row.realname_status !== 'verified'"
|
v-if="row.realname_status !== 'verified'"
|
||||||
@@ -513,7 +590,7 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
:model-value="!!quotaUser"
|
:model-value="!!quotaUser"
|
||||||
title="设置免押额度"
|
title="设置人工免押额度"
|
||||||
width="520px"
|
width="520px"
|
||||||
@update:model-value="quotaUser = null"
|
@update:model-value="quotaUser = null"
|
||||||
>
|
>
|
||||||
@@ -522,10 +599,15 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
<strong>{{ quotaUser.phone }}</strong> · {{ quotaUser.nickname }}
|
<strong>{{ quotaUser.phone }}</strong> · {{ quotaUser.nickname }}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
已占用 ¥{{ moneyCent(quotaUser.deposit_free_used_cent) }},剩余 ¥{{
|
等级额度 ¥{{ moneyCent(quotaUser.deposit_free_level_quota_cent) }},人工额度 ¥{{
|
||||||
moneyCent(quotaUser.deposit_free_remaining_cent)
|
moneyCent(quotaUser.deposit_free_manual_quota_cent)
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
|
<div class="quota-summary">
|
||||||
|
<span>有效总额 ¥{{ moneyCent(quotaUser.deposit_free_effective_quota_cent) }}</span>
|
||||||
|
<span>已占用 ¥{{ moneyCent(quotaUser.deposit_free_used_cent) }}</span>
|
||||||
|
<strong>剩余 ¥{{ moneyCent(quotaUser.deposit_free_remaining_cent) }}</strong>
|
||||||
|
</div>
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="quotaAmount"
|
v-model="quotaAmount"
|
||||||
class="full-control"
|
class="full-control"
|
||||||
@@ -542,6 +624,52 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
:model-value="!!growthUser"
|
||||||
|
title="调整成长积分"
|
||||||
|
width="540px"
|
||||||
|
@update:model-value="growthUser = null"
|
||||||
|
>
|
||||||
|
<div v-if="growthUser" class="dialog-body growth-dialog">
|
||||||
|
<p>
|
||||||
|
<strong>{{ growthUser.phone }}</strong> · {{ growthUser.nickname || '-' }} · 当前
|
||||||
|
{{ growthUser.renter_growth_level_name }}
|
||||||
|
</p>
|
||||||
|
<div class="growth-current">
|
||||||
|
<span>当前积分</span>
|
||||||
|
<strong>{{ growthUser.renter_growth_points }}</strong>
|
||||||
|
</div>
|
||||||
|
<el-form label-position="top" @submit.prevent>
|
||||||
|
<el-form-item label="目标积分">
|
||||||
|
<el-input-number
|
||||||
|
v-model="growthForm.targetPoints"
|
||||||
|
class="full-control"
|
||||||
|
:min="0"
|
||||||
|
:max="1000000000"
|
||||||
|
:step="100"
|
||||||
|
step-strictly
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="调整原因(必填)">
|
||||||
|
<el-input
|
||||||
|
v-model="growthForm.reason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="200"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="例如:运营活动赠送或修正误发积分"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button :disabled="submitting" @click="growthUser = null">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleAdjustGrowthPoints">
|
||||||
|
确认调整
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
:model-value="!!walletUser"
|
:model-value="!!walletUser"
|
||||||
title="余额管理"
|
title="余额管理"
|
||||||
@@ -655,6 +783,22 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
color: #c2410c;
|
color: #c2410c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quota-cell {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-cell strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-cell span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
.table-actions {
|
.table-actions {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
@@ -688,6 +832,43 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quota-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-summary strong {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.growth-dialog {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.growth-current {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.growth-current strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 22px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.wallet-balance-cards {
|
.wallet-balance-cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export interface Order {
|
|||||||
deposit_amount_cent: number
|
deposit_amount_cent: number
|
||||||
deposit_original_amount_cent: number
|
deposit_original_amount_cent: number
|
||||||
deposit_waived_amount_cent: number
|
deposit_waived_amount_cent: number
|
||||||
|
deposit_free_level_quota_cent?: number
|
||||||
|
deposit_free_manual_quota_cent?: number
|
||||||
|
deposit_free_used_before_cent?: number
|
||||||
platform_fee_cent?: number
|
platform_fee_cent?: number
|
||||||
rent_original_amount_cent?: number
|
rent_original_amount_cent?: number
|
||||||
rent_discount_amount_cent?: number
|
rent_discount_amount_cent?: number
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ export const AUDIT_ACTION_OPTIONS: AuditOption[] = [
|
|||||||
{ value: 'admin_user.unfreeze', label: '解冻用户', group: '用户' },
|
{ value: 'admin_user.unfreeze', label: '解冻用户', group: '用户' },
|
||||||
{
|
{
|
||||||
value: 'admin_user.set_deposit_free_quota',
|
value: 'admin_user.set_deposit_free_quota',
|
||||||
label: '设置免押额度',
|
label: '设置人工免押额度',
|
||||||
|
group: '用户',
|
||||||
|
highRisk: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'admin_user.adjust_growth_points',
|
||||||
|
label: '调整成长积分',
|
||||||
group: '用户',
|
group: '用户',
|
||||||
highRisk: true,
|
highRisk: true,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user