完善租客等级免押与积分管理
This commit is contained in:
@@ -3,27 +3,30 @@ package adminuser
|
||||
import "time"
|
||||
|
||||
type UserDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
Phone string `json:"phone"`
|
||||
Nickname string `json:"nickname"`
|
||||
RealnameStatus string `json:"realname_status"`
|
||||
RiskStatus string `json:"risk_status"`
|
||||
CreditScore int `json:"credit_score"`
|
||||
DepositFreeQuotaCent int64 `json:"deposit_free_quota_cent"`
|
||||
DepositFreeUsedCent int64 `json:"deposit_free_used_cent"`
|
||||
DepositFreeRemainingCent int64 `json:"deposit_free_remaining_cent"`
|
||||
RenterGrowthPoints int64 `json:"renter_growth_points"`
|
||||
RenterGrowthLevel string `json:"renter_growth_level"`
|
||||
RenterGrowthLevelName string `json:"renter_growth_level_name"`
|
||||
AvailableBalanceCent int64 `json:"available_balance_cent"`
|
||||
FrozenBalanceCent int64 `json:"frozen_balance_cent"`
|
||||
Status string `json:"status"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
ListingCount int64 `json:"listing_count"`
|
||||
DisputeCount int64 `json:"dispute_count"`
|
||||
LastLoginAt *time.Time `json:"last_login_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint64 `json:"id"`
|
||||
Phone string `json:"phone"`
|
||||
Nickname string `json:"nickname"`
|
||||
RealnameStatus string `json:"realname_status"`
|
||||
RiskStatus string `json:"risk_status"`
|
||||
CreditScore int `json:"credit_score"`
|
||||
DepositFreeQuotaCent int64 `json:"deposit_free_quota_cent"`
|
||||
DepositFreeManualQuotaCent int64 `json:"deposit_free_manual_quota_cent"`
|
||||
DepositFreeLevelQuotaCent int64 `json:"deposit_free_level_quota_cent"`
|
||||
DepositFreeEffectiveQuotaCent int64 `json:"deposit_free_effective_quota_cent"`
|
||||
DepositFreeUsedCent int64 `json:"deposit_free_used_cent"`
|
||||
DepositFreeRemainingCent int64 `json:"deposit_free_remaining_cent"`
|
||||
RenterGrowthPoints int64 `json:"renter_growth_points"`
|
||||
RenterGrowthLevel string `json:"renter_growth_level"`
|
||||
RenterGrowthLevelName string `json:"renter_growth_level_name"`
|
||||
AvailableBalanceCent int64 `json:"available_balance_cent"`
|
||||
FrozenBalanceCent int64 `json:"frozen_balance_cent"`
|
||||
Status string `json:"status"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
ListingCount int64 `json:"listing_count"`
|
||||
DisputeCount int64 `json:"dispute_count"`
|
||||
LastLoginAt *time.Time `json:"last_login_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
type FreezeRequest struct {
|
||||
Reason string `json:"reason"`
|
||||
@@ -43,6 +46,11 @@ type DepositFreeQuotaRequest struct {
|
||||
AmountCent int64 `json:"amount_cent"`
|
||||
}
|
||||
|
||||
type GrowthPointsAdjustRequest struct {
|
||||
TargetPoints int64 `json:"target_points"`
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
}
|
||||
|
||||
// WalletAdjustRequest 后台人工调整用户可用余额。
|
||||
// Direction: in=加款 out=扣款;仅操作 available,不改 frozen。
|
||||
type WalletAdjustRequest struct {
|
||||
|
||||
@@ -120,6 +120,29 @@ func (h *Handler) SetDepositFreeQuota(c *gin.Context) {
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) AdjustGrowthPoints(c *gin.Context) {
|
||||
adminID, ok := currentAdminID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少管理员上下文")
|
||||
return
|
||||
}
|
||||
userID, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req GrowthPointsAdjustRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "成长积分调整参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.AdjustGrowthPoints(c.Request.Context(), adminID, userID, req, auditMeta(c))
|
||||
if err != nil {
|
||||
writeAdminUserError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) RevokeRealname(c *gin.Context) {
|
||||
adminID, ok := currentAdminID(c)
|
||||
if !ok {
|
||||
@@ -232,6 +255,12 @@ func writeAdminUserError(c *gin.Context, err error) {
|
||||
response.BadRequest(c, "请填写 2-200 字的调账原因")
|
||||
case errors.Is(err, ErrInsufficientBalance):
|
||||
response.Error(c, http.StatusConflict, "insufficient_balance", "用户可用余额不足,无法扣款")
|
||||
case errors.Is(err, ErrInvalidGrowthPoints):
|
||||
response.BadRequest(c, "目标积分不正确,需在 0 到 10 亿之间")
|
||||
case errors.Is(err, ErrInvalidGrowthReason):
|
||||
response.BadRequest(c, "请填写 2-200 字的积分调整原因")
|
||||
case errors.Is(err, ErrGrowthPointsUnchanged):
|
||||
response.Error(c, http.StatusConflict, "growth_points_unchanged", "目标积分与当前积分相同")
|
||||
case IsNotFound(err):
|
||||
response.Error(c, http.StatusNotFound, "not_found", "用户不存在")
|
||||
default:
|
||||
|
||||
@@ -34,8 +34,12 @@ func NewRepository(db *gorm.DB, encryptors ...crypto.Encryptor) *Repository {
|
||||
}
|
||||
|
||||
func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQuery) (*PaginatedResult, error) {
|
||||
growthConfig, err := rentergrowth.ConfigForTx(r.db.WithContext(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var total int64
|
||||
countTx := applyUserFilter(r.db.WithContext(ctx).Table("users AS u"), query)
|
||||
countTx := applyUserFilter(r.db.WithContext(ctx).Table("users AS u"), query, growthConfig)
|
||||
if err := countTx.Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -54,8 +58,8 @@ func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQue
|
||||
Joins("LEFT JOIN (SELECT user_id, COUNT(*) AS dispute_count FROM (SELECT initiator_id AS user_id FROM disputes UNION ALL SELECT target_user_id AS user_id FROM disputes) AS dispute_users GROUP BY user_id) AS d ON d.user_id = u.id").
|
||||
Joins("LEFT JOIN (SELECT renter_id AS user_id, SUM(deposit_waived_amount_cent) AS deposit_free_used_cent FROM rental_orders WHERE status NOT IN ('completed', 'cancelled', 'closed') GROUP BY renter_id) AS df ON df.user_id = u.id").
|
||||
Joins("LEFT JOIN wallet_accounts AS w ON w.user_id = u.id")
|
||||
listTx = applyUserFilter(listTx, query)
|
||||
err := listTx.
|
||||
listTx = applyUserFilter(listTx, query, growthConfig)
|
||||
err = listTx.
|
||||
Order("u.id DESC").
|
||||
Offset(offset).Limit(pageSize).
|
||||
Scan(&rows).Error
|
||||
@@ -64,18 +68,22 @@ func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQue
|
||||
}
|
||||
items := make([]UserDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, row.toDTO())
|
||||
items = append(items, row.toDTO(growthConfig))
|
||||
}
|
||||
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
|
||||
// applyUserFilter 将列表筛选条件应用到查询(基于 users 表别名 u)。
|
||||
func applyUserFilter(tx *gorm.DB, query ListQuery) *gorm.DB {
|
||||
func applyUserFilter(tx *gorm.DB, query ListQuery, growthConfig rentergrowth.Config) *gorm.DB {
|
||||
if query.Status != "" {
|
||||
tx = tx.Where("u.status = ?", query.Status)
|
||||
}
|
||||
if query.HasDepositFreeQuota {
|
||||
tx = tx.Where("u.deposit_free_quota_cent > 0")
|
||||
if minPoints, ok := minimumGrowthQuotaPoints(growthConfig); ok {
|
||||
tx = tx.Where("u.deposit_free_quota_cent > 0 OR u.renter_growth_points >= ?", minPoints)
|
||||
} else {
|
||||
tx = tx.Where("u.deposit_free_quota_cent > 0")
|
||||
}
|
||||
}
|
||||
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
@@ -89,6 +97,24 @@ func applyUserFilter(tx *gorm.DB, query ListQuery) *gorm.DB {
|
||||
return tx
|
||||
}
|
||||
|
||||
func minimumGrowthQuotaPoints(cfg rentergrowth.Config) (int64, bool) {
|
||||
if !cfg.Enabled {
|
||||
return 0, false
|
||||
}
|
||||
var minPoints int64
|
||||
found := false
|
||||
for _, level := range cfg.Levels {
|
||||
if level.DepositFreeQuotaCent <= 0 {
|
||||
continue
|
||||
}
|
||||
if !found || level.MinPoints < minPoints {
|
||||
minPoints = level.MinPoints
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return minPoints, found
|
||||
}
|
||||
|
||||
func (r *Repository) Freeze(ctx context.Context, adminID uint64, userID uint64, req FreezeRequest, meta AuditMeta) (*UserDTO, error) {
|
||||
return r.updateStatus(ctx, adminID, userID, "frozen", "frozen", "admin_user.freeze", req.Reason, meta)
|
||||
}
|
||||
@@ -124,6 +150,62 @@ func (r *Repository) SetDepositFreeQuota(ctx context.Context, adminID uint64, us
|
||||
return r.Find(ctx, userID)
|
||||
}
|
||||
|
||||
func (r *Repository) AdjustGrowthPoints(ctx context.Context, adminID uint64, userID uint64, req GrowthPointsAdjustRequest, meta AuditMeta) (*UserDTO, error) {
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var user model.User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if user.RenterGrowthPoints == req.TargetPoints {
|
||||
return ErrGrowthPointsUnchanged
|
||||
}
|
||||
cfg, err := rentergrowth.ConfigForTx(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
beforePoints := user.RenterGrowthPoints
|
||||
beforeLevel := rentergrowth.LevelForPoints(cfg, beforePoints)
|
||||
afterLevel := rentergrowth.LevelForPoints(cfg, req.TargetPoints)
|
||||
if !cfg.Enabled {
|
||||
beforeLevel = rentergrowth.LevelForPoints(cfg, 0)
|
||||
afterLevel = beforeLevel
|
||||
}
|
||||
user.RenterGrowthPoints = req.TargetPoints
|
||||
user.RenterGrowthLevel = afterLevel.Code
|
||||
if err := tx.Save(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
ledger := model.RenterGrowthLedger{
|
||||
UserID: user.ID,
|
||||
Points: req.TargetPoints - beforePoints,
|
||||
BeforePoints: beforePoints,
|
||||
AfterPoints: req.TargetPoints,
|
||||
BeforeLevel: beforeLevel.Code,
|
||||
AfterLevel: afterLevel.Code,
|
||||
Source: rentergrowth.SourceAdminAdjustment,
|
||||
OperatorAdminID: &adminID,
|
||||
Remark: "后台调整成长积分: " + req.Reason,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := tx.Create(&ledger).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return appendAuditLog(tx, adminID, "admin_user.adjust_growth_points", user.ID, meta, map[string]any{
|
||||
"user_id": user.ID,
|
||||
"before_points": beforePoints,
|
||||
"after_points": req.TargetPoints,
|
||||
"change_points": req.TargetPoints - beforePoints,
|
||||
"before_level": beforeLevel.Code,
|
||||
"after_level": afterLevel.Code,
|
||||
"reason": req.Reason,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Find(ctx, userID)
|
||||
}
|
||||
|
||||
func (r *Repository) updateStatus(ctx context.Context, adminID uint64, userID uint64, status string, riskStatus string, action string, reason string, meta AuditMeta) (*UserDTO, error) {
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var user model.User
|
||||
@@ -270,8 +352,12 @@ func (r *Repository) ManualRealname(ctx context.Context, adminID uint64, userID
|
||||
}
|
||||
|
||||
func (r *Repository) Find(ctx context.Context, userID uint64) (*UserDTO, error) {
|
||||
growthConfig, err := rentergrowth.ConfigForTx(r.db.WithContext(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var row userRow
|
||||
err := r.db.WithContext(ctx).Table("users AS u").
|
||||
err = r.db.WithContext(ctx).Table("users AS u").
|
||||
Select(`u.*,
|
||||
COALESCE(o.order_count, 0) AS order_count,
|
||||
COALESCE(l.listing_count, 0) AS listing_count,
|
||||
@@ -289,7 +375,7 @@ func (r *Repository) Find(ctx context.Context, userID uint64) (*UserDTO, error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := row.toDTO()
|
||||
dto := row.toDTO(growthConfig)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
@@ -385,53 +471,43 @@ type userRow struct {
|
||||
FrozenBalanceCent int64
|
||||
}
|
||||
|
||||
func (row userRow) toDTO() UserDTO {
|
||||
remaining := row.DepositFreeQuotaCent - row.DepositFreeUsedCent
|
||||
func (row userRow) toDTO(growthConfig rentergrowth.Config) UserDTO {
|
||||
level := rentergrowth.LevelForPoints(growthConfig, row.RenterGrowthPoints)
|
||||
levelQuotaCent := level.DepositFreeQuotaCent
|
||||
if !growthConfig.Enabled {
|
||||
level = rentergrowth.LevelForPoints(growthConfig, 0)
|
||||
levelQuotaCent = 0
|
||||
}
|
||||
effectiveQuotaCent := rentergrowth.EffectiveDepositFreeQuotaCent(levelQuotaCent, row.DepositFreeQuotaCent)
|
||||
remaining := effectiveQuotaCent - row.DepositFreeUsedCent
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
return UserDTO{
|
||||
ID: row.ID,
|
||||
Phone: row.Phone,
|
||||
Nickname: row.Nickname,
|
||||
RealnameStatus: row.RealnameStatus,
|
||||
RiskStatus: row.RiskStatus,
|
||||
CreditScore: row.CreditScore,
|
||||
DepositFreeQuotaCent: row.DepositFreeQuotaCent,
|
||||
DepositFreeUsedCent: row.DepositFreeUsedCent,
|
||||
DepositFreeRemainingCent: remaining,
|
||||
RenterGrowthPoints: row.RenterGrowthPoints,
|
||||
RenterGrowthLevel: effectiveRenterGrowthLevel(row.User),
|
||||
RenterGrowthLevelName: renterGrowthLevelName(row.RenterGrowthLevel),
|
||||
AvailableBalanceCent: row.AvailableBalanceCent,
|
||||
FrozenBalanceCent: row.FrozenBalanceCent,
|
||||
Status: row.Status,
|
||||
OrderCount: row.OrderCount,
|
||||
ListingCount: row.ListingCount,
|
||||
DisputeCount: row.DisputeCount,
|
||||
LastLoginAt: row.LastLoginAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func effectiveRenterGrowthLevel(user model.User) string {
|
||||
if user.RenterGrowthLevel != "" {
|
||||
return user.RenterGrowthLevel
|
||||
}
|
||||
return rentergrowth.DefaultLevelCode
|
||||
}
|
||||
|
||||
func renterGrowthLevelName(code string) string {
|
||||
switch code {
|
||||
case "platinum":
|
||||
return "铂金"
|
||||
case "diamond":
|
||||
return "钻石"
|
||||
case "peak":
|
||||
return "巅峰"
|
||||
default:
|
||||
return rentergrowth.DefaultLevelName
|
||||
ID: row.ID,
|
||||
Phone: row.Phone,
|
||||
Nickname: row.Nickname,
|
||||
RealnameStatus: row.RealnameStatus,
|
||||
RiskStatus: row.RiskStatus,
|
||||
CreditScore: row.CreditScore,
|
||||
DepositFreeQuotaCent: row.DepositFreeQuotaCent,
|
||||
DepositFreeManualQuotaCent: row.DepositFreeQuotaCent,
|
||||
DepositFreeLevelQuotaCent: levelQuotaCent,
|
||||
DepositFreeEffectiveQuotaCent: effectiveQuotaCent,
|
||||
DepositFreeUsedCent: row.DepositFreeUsedCent,
|
||||
DepositFreeRemainingCent: remaining,
|
||||
RenterGrowthPoints: row.RenterGrowthPoints,
|
||||
RenterGrowthLevel: level.Code,
|
||||
RenterGrowthLevelName: level.Name,
|
||||
AvailableBalanceCent: row.AvailableBalanceCent,
|
||||
FrozenBalanceCent: row.FrozenBalanceCent,
|
||||
Status: row.Status,
|
||||
OrderCount: row.OrderCount,
|
||||
ListingCount: row.ListingCount,
|
||||
DisputeCount: row.DisputeCount,
|
||||
LastLoginAt: row.LastLoginAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/rentergrowth"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
@@ -69,17 +70,59 @@ func TestApplyUserFilterHasDepositFreeQuota(t *testing.T) {
|
||||
stmt := applyUserFilter(db.Table("users AS u"), ListQuery{
|
||||
Status: "active",
|
||||
HasDepositFreeQuota: true,
|
||||
}).Find(&[]struct{}{}).Statement
|
||||
}, rentergrowth.DefaultConfig()).Find(&[]struct{}{}).Statement
|
||||
sql := stmt.SQL.String()
|
||||
|
||||
if !strings.Contains(sql, "u.deposit_free_quota_cent > 0") {
|
||||
if !strings.Contains(sql, "u.deposit_free_quota_cent > 0 OR u.renter_growth_points >=") {
|
||||
t.Fatalf("SQL 未包含免押额度过滤:%s", sql)
|
||||
}
|
||||
if len(stmt.Vars) != 1 || stmt.Vars[0] != "active" {
|
||||
if len(stmt.Vars) != 2 || stmt.Vars[0] != "active" || stmt.Vars[1] != int64(300) {
|
||||
t.Fatalf("筛选变量不正确:%v", stmt.Vars)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustGrowthPointsWritesRepeatableLedgers(t *testing.T) {
|
||||
db := setupAdminUserTestDB(t)
|
||||
user := model.User{
|
||||
Phone: "13900000005",
|
||||
Status: "active",
|
||||
RenterGrowthPoints: 290,
|
||||
RenterGrowthLevel: rentergrowth.DefaultLevelCode,
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("创建用户失败:%v", err)
|
||||
}
|
||||
repo := NewRepository(db)
|
||||
if _, err := repo.AdjustGrowthPoints(t.Context(), 66, user.ID, GrowthPointsAdjustRequest{
|
||||
TargetPoints: 1000,
|
||||
Reason: "运营活动赠送",
|
||||
}, AuditMeta{RequestID: "growth-1"}); err != nil {
|
||||
t.Fatalf("第一次调整积分失败:%v", err)
|
||||
}
|
||||
got, err := repo.AdjustGrowthPoints(t.Context(), 66, user.ID, GrowthPointsAdjustRequest{
|
||||
TargetPoints: 200,
|
||||
Reason: "修正错误赠送",
|
||||
}, AuditMeta{RequestID: "growth-2"})
|
||||
if err != nil {
|
||||
t.Fatalf("第二次调整积分失败:%v", err)
|
||||
}
|
||||
if got.RenterGrowthPoints != 200 || got.RenterGrowthLevel != rentergrowth.DefaultLevelCode {
|
||||
t.Fatalf("调整后成长信息 = %d/%s, want 200/normal", got.RenterGrowthPoints, got.RenterGrowthLevel)
|
||||
}
|
||||
var ledgers []model.RenterGrowthLedger
|
||||
if err := db.Where("user_id = ? AND source = ?", user.ID, rentergrowth.SourceAdminAdjustment).Order("id ASC").Find(&ledgers).Error; err != nil {
|
||||
t.Fatalf("查询积分流水失败:%v", err)
|
||||
}
|
||||
if len(ledgers) != 2 || ledgers[0].Points != 710 || ledgers[1].Points != -800 {
|
||||
t.Fatalf("积分流水不正确:%+v", ledgers)
|
||||
}
|
||||
for _, ledger := range ledgers {
|
||||
if ledger.OrderID != nil || ledger.OperatorAdminID == nil || *ledger.OperatorAdminID != 66 {
|
||||
t.Fatalf("人工积分流水关联不正确:%+v", ledger)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualRealnameUpdatesUserRecordAndAudit(t *testing.T) {
|
||||
db := setupAdminUserTestDB(t)
|
||||
user := model.User{
|
||||
@@ -265,6 +308,8 @@ func setupAdminUserTestDB(t *testing.T) *gorm.DB {
|
||||
&model.Dispute{},
|
||||
&model.WalletAccount{},
|
||||
&model.WalletLedger{},
|
||||
&model.SystemConfig{},
|
||||
&model.RenterGrowthLedger{},
|
||||
); err != nil {
|
||||
t.Fatalf("数据库迁移失败:%v", err)
|
||||
}
|
||||
|
||||
@@ -17,11 +17,16 @@ var (
|
||||
ErrInvalidWalletAmount = errors.New("invalid wallet amount")
|
||||
ErrInvalidWalletReason = errors.New("invalid wallet reason")
|
||||
ErrInsufficientBalance = errors.New("insufficient balance")
|
||||
ErrInvalidGrowthPoints = errors.New("invalid growth points")
|
||||
ErrInvalidGrowthReason = errors.New("invalid growth reason")
|
||||
ErrGrowthPointsUnchanged = errors.New("growth points unchanged")
|
||||
)
|
||||
|
||||
// 单笔人工调账上限:10 万元,防止误填。
|
||||
const maxWalletAdjustAmountCent int64 = 10_000_000
|
||||
|
||||
const maxRenterGrowthPoints int64 = 1_000_000_000
|
||||
|
||||
var manualRealnameIDPattern = regexp.MustCompile(`^\d{17}[\dXx]$`)
|
||||
|
||||
type Service struct {
|
||||
@@ -69,6 +74,21 @@ func (s *Service) SetDepositFreeQuota(ctx context.Context, adminID uint64, userI
|
||||
return s.repo.SetDepositFreeQuota(ctx, adminID, userID, req, meta)
|
||||
}
|
||||
|
||||
func (s *Service) AdjustGrowthPoints(ctx context.Context, adminID uint64, userID uint64, req GrowthPointsAdjustRequest, meta AuditMeta) (*UserDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
if userID == 0 || req.TargetPoints < 0 || req.TargetPoints > maxRenterGrowthPoints {
|
||||
return nil, ErrInvalidGrowthPoints
|
||||
}
|
||||
reason := strings.TrimSpace(req.Reason)
|
||||
if reasonLen := len([]rune(reason)); reasonLen < 2 || reasonLen > 200 {
|
||||
return nil, ErrInvalidGrowthReason
|
||||
}
|
||||
req.Reason = reason
|
||||
return s.repo.AdjustGrowthPoints(ctx, adminID, userID, req, meta)
|
||||
}
|
||||
|
||||
func (s *Service) RevokeRealname(ctx context.Context, adminID uint64, userID uint64, req RevokeRealnameRequest, meta AuditMeta) (*UserDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
|
||||
@@ -31,6 +31,9 @@ type OrderDTO struct {
|
||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
||||
DepositOriginalAmountCent int64 `json:"deposit_original_amount_cent"`
|
||||
DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"`
|
||||
DepositFreeLevelQuotaCent int64 `json:"deposit_free_level_quota_cent,omitempty"`
|
||||
DepositFreeManualQuotaCent int64 `json:"deposit_free_manual_quota_cent,omitempty"`
|
||||
DepositFreeUsedBeforeCent int64 `json:"deposit_free_used_before_cent,omitempty"`
|
||||
PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"`
|
||||
RentOriginalAmountCent int64 `json:"rent_original_amount_cent,omitempty"`
|
||||
RentDiscountAmountCent int64 `json:"rent_discount_amount_cent,omitempty"`
|
||||
|
||||
@@ -54,36 +54,39 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
|
||||
pricing.RentAmountCent -= rentDiscountAmountCent
|
||||
}
|
||||
depositOriginalAmountCent := listing.DepositAmountCent
|
||||
paidDepositAmountCent, waivedDepositAmountCent, err := r.depositAmountsForOrder(tx, renterID, depositOriginalAmountCent)
|
||||
depositWaiver, err := r.depositAmountsForOrder(tx, renterID, depositOriginalAmountCent, growthSnapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
order := model.RentalOrder{
|
||||
OrderNo: orderNo,
|
||||
ListingID: listing.ID,
|
||||
AccountID: listing.AccountID,
|
||||
OwnerID: listing.OwnerID,
|
||||
RenterID: renterID,
|
||||
EstimatedDurationHours: rentHours,
|
||||
RentAmountCent: pricing.RentAmountCent,
|
||||
OwnerRentAmountCent: pricing.OwnerRentAmountCent,
|
||||
DepositAmountCent: paidDepositAmountCent,
|
||||
DepositOriginalAmountCent: depositOriginalAmountCent,
|
||||
DepositWaivedAmountCent: waivedDepositAmountCent,
|
||||
PlatformFeeCent: pricing.PlatformFeeCent,
|
||||
RentOriginalAmountCent: rentOriginalAmountCent,
|
||||
RentDiscountAmountCent: rentDiscountAmountCent,
|
||||
RenterGrowthLevel: growthSnapshot.LevelCode,
|
||||
RenterGrowthLevelName: growthSnapshot.LevelName,
|
||||
RenterDiscountBps: growthSnapshot.DiscountBps,
|
||||
AccountSnapshot: snapshot,
|
||||
Status: orderStatusPendingPayment,
|
||||
HandoffStatus: handoffStatusNone,
|
||||
HandoffMode: listingHandoffMode(listing),
|
||||
SettlementMode: listingSettlementMode(listing),
|
||||
ManagedAdminID: listing.ManagedAdminID,
|
||||
SettlementStatus: settlementStatusUnsettled,
|
||||
OfflineSettlementStatus: offlineSettlementStatusNone,
|
||||
OrderNo: orderNo,
|
||||
ListingID: listing.ID,
|
||||
AccountID: listing.AccountID,
|
||||
OwnerID: listing.OwnerID,
|
||||
RenterID: renterID,
|
||||
EstimatedDurationHours: rentHours,
|
||||
RentAmountCent: pricing.RentAmountCent,
|
||||
OwnerRentAmountCent: pricing.OwnerRentAmountCent,
|
||||
DepositAmountCent: depositWaiver.PaidCent,
|
||||
DepositOriginalAmountCent: depositOriginalAmountCent,
|
||||
DepositWaivedAmountCent: depositWaiver.WaivedCent,
|
||||
DepositFreeLevelQuotaCent: growthSnapshot.LevelDepositFreeQuotaCent,
|
||||
DepositFreeManualQuotaCent: growthSnapshot.ManualDepositFreeQuotaCent,
|
||||
DepositFreeUsedBeforeCent: depositWaiver.UsedBeforeCent,
|
||||
PlatformFeeCent: pricing.PlatformFeeCent,
|
||||
RentOriginalAmountCent: rentOriginalAmountCent,
|
||||
RentDiscountAmountCent: rentDiscountAmountCent,
|
||||
RenterGrowthLevel: growthSnapshot.LevelCode,
|
||||
RenterGrowthLevelName: growthSnapshot.LevelName,
|
||||
RenterDiscountBps: growthSnapshot.DiscountBps,
|
||||
AccountSnapshot: snapshot,
|
||||
Status: orderStatusPendingPayment,
|
||||
HandoffStatus: handoffStatusNone,
|
||||
HandoffMode: listingHandoffMode(listing),
|
||||
SettlementMode: listingSettlementMode(listing),
|
||||
ManagedAdminID: listing.ManagedAdminID,
|
||||
SettlementStatus: settlementStatusUnsettled,
|
||||
OfflineSettlementStatus: offlineSettlementStatusNone,
|
||||
}
|
||||
if err := tx.Create(&order).Error; err != nil {
|
||||
return err
|
||||
@@ -128,20 +131,26 @@ func listingSettlementMode(listing model.RentalListing) string {
|
||||
return settlementModeOwnerWallet
|
||||
}
|
||||
|
||||
func (r *Repository) depositAmountsForOrder(tx *gorm.DB, renterID uint64, originalDepositCent int64) (int64, int64, error) {
|
||||
type depositWaiverAmounts struct {
|
||||
PaidCent int64
|
||||
WaivedCent int64
|
||||
UsedBeforeCent int64
|
||||
}
|
||||
|
||||
func (r *Repository) depositAmountsForOrder(tx *gorm.DB, renterID uint64, originalDepositCent int64, growthSnapshot rentergrowth.DiscountSnapshot) (depositWaiverAmounts, error) {
|
||||
if originalDepositCent <= 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
var user model.User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, renterID).Error; err != nil {
|
||||
return 0, 0, err
|
||||
return depositWaiverAmounts{}, nil
|
||||
}
|
||||
used, err := activeDepositFreeUsed(tx, renterID)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
return depositWaiverAmounts{}, err
|
||||
}
|
||||
paidDeposit, waivedDeposit := calculateDepositWaiver(originalDepositCent, user.DepositFreeQuotaCent, used)
|
||||
return paidDeposit, waivedDeposit, nil
|
||||
paidDeposit, waivedDeposit := calculateDepositWaiver(originalDepositCent, growthSnapshot.EffectiveDepositFreeQuotaCent, used)
|
||||
return depositWaiverAmounts{
|
||||
PaidCent: paidDeposit,
|
||||
WaivedCent: waivedDeposit,
|
||||
UsedBeforeCent: used,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func activeDepositFreeUsed(tx *gorm.DB, renterID uint64) (int64, error) {
|
||||
|
||||
@@ -81,6 +81,9 @@ func (row orderRow) toAdminDTO() OrderDTO {
|
||||
DepositAmountCent: row.DepositAmountCent,
|
||||
DepositOriginalAmountCent: effectiveDepositOriginalAmountCent(row.RentalOrder),
|
||||
DepositWaivedAmountCent: row.DepositWaivedAmountCent,
|
||||
DepositFreeLevelQuotaCent: row.DepositFreeLevelQuotaCent,
|
||||
DepositFreeManualQuotaCent: row.DepositFreeManualQuotaCent,
|
||||
DepositFreeUsedBeforeCent: row.DepositFreeUsedBeforeCent,
|
||||
PlatformFeeCent: &platformFeeCent,
|
||||
RentOriginalAmountCent: effectiveRentOriginalAmountCent(row.RentalOrder),
|
||||
RentDiscountAmountCent: row.RentDiscountAmountCent,
|
||||
|
||||
@@ -146,9 +146,10 @@ func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) {
|
||||
|
||||
owner := model.User{Phone: "13800001101"}
|
||||
renter := model.User{
|
||||
Phone: "13800001102",
|
||||
RenterGrowthPoints: 300,
|
||||
RenterGrowthLevel: "platinum",
|
||||
Phone: "13800001102",
|
||||
RenterGrowthPoints: 300,
|
||||
RenterGrowthLevel: "platinum",
|
||||
DepositFreeQuotaCent: 20000,
|
||||
}
|
||||
if err := db.Create(&owner).Error; err != nil {
|
||||
t.Fatalf("create owner failed: %v", err)
|
||||
@@ -167,11 +168,12 @@ func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) {
|
||||
t.Fatalf("create account failed: %v", err)
|
||||
}
|
||||
listing := model.RentalListing{
|
||||
AccountID: account.ID,
|
||||
OwnerID: owner.ID,
|
||||
PriceCent: 10000,
|
||||
Status: listingStatusPublished,
|
||||
ReviewStatus: listingReviewStatusApproved,
|
||||
AccountID: account.ID,
|
||||
OwnerID: owner.ID,
|
||||
PriceCent: 10000,
|
||||
DepositAmountCent: 100000,
|
||||
Status: listingStatusPublished,
|
||||
ReviewStatus: listingReviewStatusApproved,
|
||||
}
|
||||
if err := db.Create(&listing).Error; err != nil {
|
||||
t.Fatalf("create listing failed: %v", err)
|
||||
@@ -197,6 +199,12 @@ func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) {
|
||||
if saved.OwnerRentAmountCent != 9000 || saved.PlatformFeeCent != 900 {
|
||||
t.Fatalf("pricing = owner %d platform %d, want 9000/900", saved.OwnerRentAmountCent, saved.PlatformFeeCent)
|
||||
}
|
||||
if saved.DepositAmountCent != 30000 || saved.DepositWaivedAmountCent != 70000 {
|
||||
t.Fatalf("deposit = paid %d waived %d, want 30000/70000", saved.DepositAmountCent, saved.DepositWaivedAmountCent)
|
||||
}
|
||||
if saved.DepositFreeLevelQuotaCent != 50000 || saved.DepositFreeManualQuotaCent != 20000 || saved.DepositFreeUsedBeforeCent != 0 {
|
||||
t.Fatalf("deposit quota snapshot = level %d manual %d used %d", saved.DepositFreeLevelQuotaCent, saved.DepositFreeManualQuotaCent, saved.DepositFreeUsedBeforeCent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildOrderPricing 测试订单定价计算
|
||||
|
||||
@@ -14,14 +14,31 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
ConfigKey = "renter.growth_level_rules"
|
||||
DefaultLevelCode = "normal"
|
||||
DefaultLevelName = "普通"
|
||||
DefaultDiscountBps = 10000
|
||||
DefaultPointsPerYuan = 1
|
||||
SourceOrderCompleted = "order_completed"
|
||||
ConfigKey = "renter.growth_level_rules"
|
||||
DefaultLevelCode = "normal"
|
||||
DefaultLevelName = "普通"
|
||||
DefaultDiscountBps = 10000
|
||||
DefaultPointsPerYuan = 1
|
||||
DefaultPlatinumQuotaCent = 50_000
|
||||
DefaultDiamondQuotaCent = 100_000
|
||||
DefaultPeakQuotaCent = 150_000
|
||||
MaxPointsPerYuan = 1_000
|
||||
MaxLevelPoints = 1_000_000_000
|
||||
MaxLevelQuotaCent = 100_000_000
|
||||
SourceOrderCompleted = "order_completed"
|
||||
SourceAdminAdjustment = "admin_adjustment"
|
||||
)
|
||||
|
||||
var fixedLevels = []struct {
|
||||
Code string
|
||||
Name string
|
||||
}{
|
||||
{Code: DefaultLevelCode, Name: DefaultLevelName},
|
||||
{Code: "platinum", Name: "铂金"},
|
||||
{Code: "diamond", Name: "钻石"},
|
||||
{Code: "peak", Name: "巅峰"},
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
PointsPerYuan int64 `json:"points_per_yuan"`
|
||||
@@ -29,17 +46,21 @@ type Config struct {
|
||||
}
|
||||
|
||||
type LevelRule struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
MinPoints int64 `json:"min_points"`
|
||||
DiscountBps int `json:"discount_bps"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
MinPoints int64 `json:"min_points"`
|
||||
DiscountBps int `json:"discount_bps"`
|
||||
DepositFreeQuotaCent int64 `json:"deposit_free_quota_cent"`
|
||||
}
|
||||
|
||||
type DiscountSnapshot struct {
|
||||
LevelCode string
|
||||
LevelName string
|
||||
DiscountBps int
|
||||
UserPoints int64
|
||||
LevelCode string
|
||||
LevelName string
|
||||
DiscountBps int
|
||||
UserPoints int64
|
||||
LevelDepositFreeQuotaCent int64
|
||||
ManualDepositFreeQuotaCent int64
|
||||
EffectiveDepositFreeQuotaCent int64
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
@@ -47,14 +68,41 @@ func DefaultConfig() Config {
|
||||
Enabled: true,
|
||||
PointsPerYuan: DefaultPointsPerYuan,
|
||||
Levels: []LevelRule{
|
||||
{Code: DefaultLevelCode, Name: DefaultLevelName, MinPoints: 0, DiscountBps: DefaultDiscountBps},
|
||||
{Code: "platinum", Name: "铂金", MinPoints: 300, DiscountBps: 9900},
|
||||
{Code: "diamond", Name: "钻石", MinPoints: 1000, DiscountBps: 9800},
|
||||
{Code: "peak", Name: "巅峰", MinPoints: 5000, DiscountBps: 9500},
|
||||
{Code: DefaultLevelCode, Name: DefaultLevelName, MinPoints: 0, DiscountBps: DefaultDiscountBps, DepositFreeQuotaCent: 0},
|
||||
{Code: "platinum", Name: "铂金", MinPoints: 300, DiscountBps: 9900, DepositFreeQuotaCent: DefaultPlatinumQuotaCent},
|
||||
{Code: "diamond", Name: "钻石", MinPoints: 1000, DiscountBps: 9800, DepositFreeQuotaCent: DefaultDiamondQuotaCent},
|
||||
{Code: "peak", Name: "巅峰", MinPoints: 5000, DiscountBps: 9500, DepositFreeQuotaCent: DefaultPeakQuotaCent},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// UnmarshalJSON 为旧配置补齐等级免押默认值,同时保留后台显式配置的 0 元额度。
|
||||
func (level *LevelRule) UnmarshalJSON(data []byte) error {
|
||||
type rawLevelRule struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
MinPoints int64 `json:"min_points"`
|
||||
DiscountBps int `json:"discount_bps"`
|
||||
DepositFreeQuotaCent *int64 `json:"deposit_free_quota_cent"`
|
||||
}
|
||||
var raw rawLevelRule
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
quotaCent := defaultDepositFreeQuotaCent(raw.Code)
|
||||
if raw.DepositFreeQuotaCent != nil {
|
||||
quotaCent = *raw.DepositFreeQuotaCent
|
||||
}
|
||||
*level = LevelRule{
|
||||
Code: raw.Code,
|
||||
Name: raw.Name,
|
||||
MinPoints: raw.MinPoints,
|
||||
DiscountBps: raw.DiscountBps,
|
||||
DepositFreeQuotaCent: quotaCent,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DefaultConfigValue() string {
|
||||
raw, err := json.Marshal(DefaultConfig())
|
||||
if err != nil {
|
||||
@@ -63,6 +111,63 @@ func DefaultConfigValue() string {
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// ParseConfigValue 严格解析后台提交的成长等级配置。
|
||||
func ParseConfigValue(value string) (Config, error) {
|
||||
var cfg Config
|
||||
if err := json.Unmarshal([]byte(value), &cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := ValidateConfig(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return normalizeConfig(cfg), nil
|
||||
}
|
||||
|
||||
func MarshalConfig(cfg Config) (string, error) {
|
||||
raw, err := json.Marshal(normalizeConfig(cfg))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
// ValidateConfig 保证四个固定等级完整,且权益不会随等级提升而倒退。
|
||||
func ValidateConfig(cfg Config) error {
|
||||
if cfg.PointsPerYuan <= 0 || cfg.PointsPerYuan > MaxPointsPerYuan || len(cfg.Levels) != len(fixedLevels) {
|
||||
return errors.New("invalid renter growth config")
|
||||
}
|
||||
byCode := make(map[string]LevelRule, len(cfg.Levels))
|
||||
for _, level := range cfg.Levels {
|
||||
level.Code = strings.TrimSpace(level.Code)
|
||||
if _, exists := byCode[level.Code]; exists {
|
||||
return errors.New("duplicate renter growth level")
|
||||
}
|
||||
byCode[level.Code] = level
|
||||
}
|
||||
var previous LevelRule
|
||||
for index, fixed := range fixedLevels {
|
||||
level, ok := byCode[fixed.Code]
|
||||
if !ok || strings.TrimSpace(level.Name) != fixed.Name {
|
||||
return errors.New("missing renter growth level")
|
||||
}
|
||||
if level.MinPoints < 0 || level.MinPoints > MaxLevelPoints || level.DiscountBps <= 0 || level.DiscountBps > DefaultDiscountBps || level.DepositFreeQuotaCent < 0 || level.DepositFreeQuotaCent > MaxLevelQuotaCent {
|
||||
return errors.New("invalid renter growth level value")
|
||||
}
|
||||
if index == 0 {
|
||||
if level.MinPoints != 0 || level.DiscountBps != DefaultDiscountBps || level.DepositFreeQuotaCent != 0 {
|
||||
return errors.New("invalid normal renter growth level")
|
||||
}
|
||||
previous = level
|
||||
continue
|
||||
}
|
||||
if level.MinPoints <= previous.MinPoints || level.DiscountBps > previous.DiscountBps || level.DepositFreeQuotaCent < previous.DepositFreeQuotaCent {
|
||||
return errors.New("renter growth levels must increase")
|
||||
}
|
||||
previous = level
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ConfigForTx(tx *gorm.DB) (Config, error) {
|
||||
cfg := DefaultConfig()
|
||||
if tx == nil {
|
||||
@@ -97,19 +202,26 @@ func SnapshotForUser(tx *gorm.DB, userID uint64) (DiscountSnapshot, error) {
|
||||
if err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
return snapshot, nil
|
||||
}
|
||||
var user model.User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
manualQuotaCent := maxInt64(user.DepositFreeQuotaCent, 0)
|
||||
snapshot.UserPoints = user.RenterGrowthPoints
|
||||
snapshot.ManualDepositFreeQuotaCent = manualQuotaCent
|
||||
snapshot.EffectiveDepositFreeQuotaCent = manualQuotaCent
|
||||
if !cfg.Enabled {
|
||||
return snapshot, nil
|
||||
}
|
||||
level := LevelForPoints(cfg, user.RenterGrowthPoints)
|
||||
return DiscountSnapshot{
|
||||
LevelCode: level.Code,
|
||||
LevelName: level.Name,
|
||||
DiscountBps: level.DiscountBps,
|
||||
UserPoints: user.RenterGrowthPoints,
|
||||
LevelCode: level.Code,
|
||||
LevelName: level.Name,
|
||||
DiscountBps: level.DiscountBps,
|
||||
UserPoints: user.RenterGrowthPoints,
|
||||
LevelDepositFreeQuotaCent: level.DepositFreeQuotaCent,
|
||||
ManualDepositFreeQuotaCent: manualQuotaCent,
|
||||
EffectiveDepositFreeQuotaCent: addQuotaCent(level.DepositFreeQuotaCent, manualQuotaCent),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -182,7 +294,7 @@ func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualRentCent i
|
||||
now := time.Now()
|
||||
ledger := model.RenterGrowthLedger{
|
||||
UserID: order.RenterID,
|
||||
OrderID: order.ID,
|
||||
OrderID: &order.ID,
|
||||
Points: points,
|
||||
BeforePoints: beforePoints,
|
||||
AfterPoints: afterPoints,
|
||||
@@ -224,6 +336,30 @@ func LevelForPoints(cfg Config, points int64) LevelRule {
|
||||
return current
|
||||
}
|
||||
|
||||
func EffectiveDepositFreeQuotaCent(levelQuotaCent int64, manualQuotaCent int64) int64 {
|
||||
return addQuotaCent(levelQuotaCent, manualQuotaCent)
|
||||
}
|
||||
|
||||
// SyncAllUserLevels 在等级门槛调整后同步用户表中的展示缓存,订单仍以积分实时计算为准。
|
||||
func SyncAllUserLevels(tx *gorm.DB, cfg Config) error {
|
||||
if tx == nil {
|
||||
return nil
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
return tx.Model(&model.User{}).Where("renter_growth_level <> ?", DefaultLevelCode).Update("renter_growth_level", DefaultLevelCode).Error
|
||||
}
|
||||
cfg = normalizeConfig(cfg)
|
||||
caseSQL := "CASE"
|
||||
args := make([]any, 0, len(cfg.Levels)*2+1)
|
||||
for index := len(cfg.Levels) - 1; index >= 1; index-- {
|
||||
caseSQL += " WHEN renter_growth_points >= ? THEN ?"
|
||||
args = append(args, cfg.Levels[index].MinPoints, cfg.Levels[index].Code)
|
||||
}
|
||||
caseSQL += " ELSE ? END"
|
||||
args = append(args, DefaultLevelCode)
|
||||
return tx.Model(&model.User{}).Where("1 = 1").Update("renter_growth_level", gorm.Expr(caseSQL, args...)).Error
|
||||
}
|
||||
|
||||
func normalizeConfig(cfg Config) Config {
|
||||
defaultCfg := DefaultConfig()
|
||||
if cfg.PointsPerYuan <= 0 {
|
||||
@@ -249,6 +385,9 @@ func normalizeConfig(cfg Config) Config {
|
||||
if level.DiscountBps <= 0 || level.DiscountBps > DefaultDiscountBps {
|
||||
level.DiscountBps = DefaultDiscountBps
|
||||
}
|
||||
if level.DepositFreeQuotaCent < 0 {
|
||||
level.DepositFreeQuotaCent = 0
|
||||
}
|
||||
if level.Code == DefaultLevelCode {
|
||||
hasNormal = true
|
||||
level.Name = DefaultLevelName
|
||||
@@ -258,10 +397,11 @@ func normalizeConfig(cfg Config) Config {
|
||||
}
|
||||
if !hasNormal {
|
||||
levels = append(levels, LevelRule{
|
||||
Code: DefaultLevelCode,
|
||||
Name: DefaultLevelName,
|
||||
MinPoints: 0,
|
||||
DiscountBps: DefaultDiscountBps,
|
||||
Code: DefaultLevelCode,
|
||||
Name: DefaultLevelName,
|
||||
MinPoints: 0,
|
||||
DiscountBps: DefaultDiscountBps,
|
||||
DepositFreeQuotaCent: 0,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(levels, func(i, j int) bool {
|
||||
@@ -271,6 +411,36 @@ func normalizeConfig(cfg Config) Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
func defaultDepositFreeQuotaCent(code string) int64 {
|
||||
switch strings.TrimSpace(code) {
|
||||
case "platinum":
|
||||
return DefaultPlatinumQuotaCent
|
||||
case "diamond":
|
||||
return DefaultDiamondQuotaCent
|
||||
case "peak":
|
||||
return DefaultPeakQuotaCent
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func addQuotaCent(left int64, right int64) int64 {
|
||||
left = maxInt64(left, 0)
|
||||
right = maxInt64(right, 0)
|
||||
const maxInt64Value = int64(^uint64(0) >> 1)
|
||||
if left > maxInt64Value-right {
|
||||
return maxInt64Value
|
||||
}
|
||||
return left + right
|
||||
}
|
||||
|
||||
func maxInt64(left int64, right int64) int64 {
|
||||
if left > right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
func isMissingConfigTableError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
|
||||
@@ -1,12 +1,99 @@
|
||||
package rentergrowth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"hfb_sys/backend/internal/database"
|
||||
"hfb_sys/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestDefaultConfigIncludesDepositFreeQuota(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
wants := map[string]int64{
|
||||
DefaultLevelCode: 0,
|
||||
"platinum": DefaultPlatinumQuotaCent,
|
||||
"diamond": DefaultDiamondQuotaCent,
|
||||
"peak": DefaultPeakQuotaCent,
|
||||
}
|
||||
for _, level := range cfg.Levels {
|
||||
if level.DepositFreeQuotaCent != wants[level.Code] {
|
||||
t.Fatalf("等级 %s 免押额度 = %d, want %d", level.Code, level.DepositFreeQuotaCent, wants[level.Code])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyConfigBackfillsQuotaButKeepsExplicitZero(t *testing.T) {
|
||||
legacy := `{"enabled":true,"points_per_yuan":1,"levels":[{"code":"normal","name":"普通","min_points":0,"discount_bps":10000},{"code":"platinum","name":"铂金","min_points":300,"discount_bps":9900},{"code":"diamond","name":"钻石","min_points":1000,"discount_bps":9800},{"code":"peak","name":"巅峰","min_points":5000,"discount_bps":9500}]}`
|
||||
cfg, err := ParseConfigValue(legacy)
|
||||
if err != nil {
|
||||
t.Fatalf("解析旧配置失败:%v", err)
|
||||
}
|
||||
if LevelForPoints(cfg, 300).DepositFreeQuotaCent != DefaultPlatinumQuotaCent {
|
||||
t.Fatalf("旧配置未补齐铂金免押额度")
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal([]byte(legacy), &raw); err != nil {
|
||||
t.Fatalf("解析测试 JSON 失败:%v", err)
|
||||
}
|
||||
levels := raw["levels"].([]any)
|
||||
levels[1].(map[string]any)["deposit_free_quota_cent"] = float64(0)
|
||||
explicitZero, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("生成测试配置失败:%v", err)
|
||||
}
|
||||
cfg, err = ParseConfigValue(string(explicitZero))
|
||||
if err != nil {
|
||||
t.Fatalf("解析显式零额度配置失败:%v", err)
|
||||
}
|
||||
if LevelForPoints(cfg, 300).DepositFreeQuotaCent != 0 {
|
||||
t.Fatalf("显式配置的零额度被默认值覆盖")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotCombinesLevelAndManualDepositQuota(t *testing.T) {
|
||||
db := database.NewTestDB()
|
||||
if err := db.AutoMigrate(&model.User{}, &model.SystemConfig{}); err != nil {
|
||||
t.Fatalf("migrate failed: %v", err)
|
||||
}
|
||||
user := model.User{Phone: "13800009999", RenterGrowthPoints: 1000, DepositFreeQuotaCent: 30_000}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("create user failed: %v", err)
|
||||
}
|
||||
snapshot, err := SnapshotForUser(db, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("SnapshotForUser() error = %v", err)
|
||||
}
|
||||
if snapshot.LevelDepositFreeQuotaCent != DefaultDiamondQuotaCent || snapshot.ManualDepositFreeQuotaCent != 30_000 || snapshot.EffectiveDepositFreeQuotaCent != 130_000 {
|
||||
t.Fatalf("免押额度快照不正确:%+v", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncAllUserLevelsUsesUpdatedThresholds(t *testing.T) {
|
||||
db := database.NewTestDB()
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
t.Fatalf("migrate failed: %v", err)
|
||||
}
|
||||
users := []model.User{
|
||||
{Phone: "13800009001", RenterGrowthPoints: 2000, RenterGrowthLevel: DefaultLevelCode},
|
||||
{Phone: "13800009002", RenterGrowthPoints: 6000, RenterGrowthLevel: DefaultLevelCode},
|
||||
}
|
||||
if err := db.Create(&users).Error; err != nil {
|
||||
t.Fatalf("create users failed: %v", err)
|
||||
}
|
||||
if err := SyncAllUserLevels(db, DefaultConfig()); err != nil {
|
||||
t.Fatalf("SyncAllUserLevels() error = %v", err)
|
||||
}
|
||||
var saved []model.User
|
||||
if err := db.Order("id ASC").Find(&saved).Error; err != nil {
|
||||
t.Fatalf("find users failed: %v", err)
|
||||
}
|
||||
if saved[0].RenterGrowthLevel != "diamond" || saved[1].RenterGrowthLevel != "peak" {
|
||||
t.Fatalf("同步后等级不正确:%s/%s", saved[0].RenterGrowthLevel, saved[1].RenterGrowthLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevelForPointsStartsFromNormal(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cases := []struct {
|
||||
|
||||
@@ -37,7 +37,7 @@ var defaultConfigs = []defaultConfig{
|
||||
{Key: listingPublishAgreementsConfigKey, Value: defaultListingPublishAgreementsConfigValue(), Description: "发布账号前协议配置 JSON"},
|
||||
{Key: orderAgreementsConfigKey, Value: defaultOrderAgreementsConfigValue(), Description: "下单前协议配置 JSON"},
|
||||
{Key: postRentalNoticeConfigKey, Value: defaultPostRentalNoticeConfigValue(), Description: "租后须知配置 JSON"},
|
||||
{Key: rentergrowth.ConfigKey, Value: rentergrowth.DefaultConfigValue(), Description: "租客成长等级规则 JSON(积分门槛/下单折扣)"},
|
||||
{Key: rentergrowth.ConfigKey, Value: rentergrowth.DefaultConfigValue(), Description: "租客成长等级规则(积分门槛、租金折扣、等级免押额度)"},
|
||||
{Key: "chat.default_support_admin_id", Value: "2", Description: "默认客服 ID(必须是启用状态的客服角色)"},
|
||||
{Key: "chat.auto_welcome_message", Value: "欢迎加入订单群聊!如有任何问题,请随时沟通。", Description: "建群后自动发送的欢迎话术"},
|
||||
{Key: "chat.listing_group_welcome", Value: "欢迎加入账号群!请号主扫描下方二维码加入企业微信群,方便客服与您及时联系。", Description: "发布群创建后自动发送的欢迎语"},
|
||||
@@ -135,6 +135,9 @@ func (r *Repository) Update(ctx context.Context, actorID uint64, key string, req
|
||||
if err := tx.Save(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := syncRenterGrowthLevels(tx, key, req.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := appendAuditLog(tx, actorID, "system_config.update", row.ID, meta, map[string]any{
|
||||
"key": row.Key,
|
||||
"before": before,
|
||||
@@ -144,6 +147,9 @@ func (r *Repository) Update(ctx context.Context, actorID uint64, key string, req
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := syncRenterGrowthLevels(tx, key, req.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
return appendAuditLog(tx, actorID, "system_config.create", row.ID, meta, map[string]any{
|
||||
"key": row.Key,
|
||||
"value": row.Value,
|
||||
@@ -156,6 +162,17 @@ func (r *Repository) Update(ctx context.Context, actorID uint64, key string, req
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func syncRenterGrowthLevels(tx *gorm.DB, key string, value string) error {
|
||||
if key != rentergrowth.ConfigKey {
|
||||
return nil
|
||||
}
|
||||
cfg, err := rentergrowth.ParseConfigValue(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rentergrowth.SyncAllUserLevels(tx, cfg)
|
||||
}
|
||||
|
||||
func (r *Repository) ensureDefaults(ctx context.Context) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for _, item := range defaultConfigs {
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"hfb_sys/backend/internal/modules/rentergrowth"
|
||||
)
|
||||
|
||||
func (s *Service) Update(ctx context.Context, actorID uint64, key string, req UpdateRequest, meta AuditMeta) (*ConfigDTO, error) {
|
||||
@@ -19,5 +21,16 @@ func (s *Service) Update(ctx context.Context, actorID uint64, key string, req Up
|
||||
return nil, ErrInvalidConfig
|
||||
}
|
||||
}
|
||||
if key == rentergrowth.ConfigKey {
|
||||
cfg, err := rentergrowth.ParseConfigValue(req.Value)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidConfig
|
||||
}
|
||||
value, err := rentergrowth.MarshalConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidConfig
|
||||
}
|
||||
req.Value = value
|
||||
}
|
||||
return s.repo.Update(ctx, actorID, key, req, meta)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user