feat: 接入旧站管理端并完整迁移后台能力
This commit is contained in:
@@ -16,10 +16,17 @@ type UserDTO struct {
|
||||
DepositFreeUsedCent int64 `json:"deposit_free_used_cent"`
|
||||
DepositFreeRemainingCent int64 `json:"deposit_free_remaining_cent"`
|
||||
RenterGrowthPoints int64 `json:"renter_growth_points"`
|
||||
PointsAvailable int64 `json:"points_available"`
|
||||
RenterGrowthLevel string `json:"renter_growth_level"`
|
||||
RenterGrowthLevelName string `json:"renter_growth_level_name"`
|
||||
RenterGrowthLevelOverride string `json:"renter_growth_level_override"`
|
||||
RenterGrowthLevelManual bool `json:"renter_growth_level_manual"`
|
||||
AvailableBalanceCent int64 `json:"available_balance_cent"`
|
||||
FrozenBalanceCent int64 `json:"frozen_balance_cent"`
|
||||
QualityOwnerStatus string `json:"quality_owner_status"`
|
||||
QualityOwnerDepositCent int64 `json:"quality_owner_deposit_cent"`
|
||||
QualityOwnerDepositSource string `json:"quality_owner_deposit_source"`
|
||||
QualityOwnerActivatedAt *time.Time `json:"quality_owner_activated_at"`
|
||||
Status string `json:"status"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
ListingCount int64 `json:"listing_count"`
|
||||
@@ -51,6 +58,11 @@ type GrowthPointsAdjustRequest struct {
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
}
|
||||
|
||||
type GrowthLevelAdjustRequest struct {
|
||||
TargetLevel string `json:"target_level"`
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
}
|
||||
|
||||
// WalletAdjustRequest 后台人工调整用户可用余额。
|
||||
// Direction: in=加款 out=扣款;仅操作 available,不改 frozen。
|
||||
type WalletAdjustRequest struct {
|
||||
@@ -65,6 +77,13 @@ type ListQuery struct {
|
||||
Keyword string
|
||||
Status string
|
||||
HasDepositFreeQuota bool
|
||||
QualityOwnerOnly bool
|
||||
// DepositFreeQuota: has=有免押额度, none=无免押额度, 空=全部。
|
||||
DepositFreeQuota string
|
||||
// OwnerType: quality=优质号主, normal=普通用户, 空=全部。
|
||||
OwnerType string
|
||||
// GrowthLevel: 成长等级 code(normal/platinum/diamond/peak),空=全部。
|
||||
GrowthLevel string
|
||||
}
|
||||
|
||||
type PaginatedResult struct {
|
||||
|
||||
@@ -41,6 +41,10 @@ func (h *Handler) List(c *gin.Context) {
|
||||
Keyword: strings.TrimSpace(c.Query("keyword")),
|
||||
Status: strings.TrimSpace(c.Query("status")),
|
||||
HasDepositFreeQuota: parseBoolQuery(c.Query("has_deposit_free_quota")),
|
||||
QualityOwnerOnly: parseBoolQuery(c.Query("quality_owner_only")),
|
||||
DepositFreeQuota: normalizeEnumQuery(c.Query("deposit_free_quota"), "has", "none"),
|
||||
OwnerType: normalizeEnumQuery(c.Query("owner_type"), "quality", "normal"),
|
||||
GrowthLevel: strings.TrimSpace(c.Query("growth_level")),
|
||||
}
|
||||
result, err := h.service.List(c.Request.Context(), page, pageSize, query)
|
||||
if err != nil {
|
||||
@@ -59,6 +63,16 @@ func parseBoolQuery(value string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeEnumQuery(value string, allowed ...string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
for _, item := range allowed {
|
||||
if value == item {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *Handler) Freeze(c *gin.Context) {
|
||||
adminID, ok := currentAdminID(c)
|
||||
if !ok {
|
||||
@@ -143,6 +157,29 @@ func (h *Handler) AdjustGrowthPoints(c *gin.Context) {
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) AdjustGrowthLevel(c *gin.Context) {
|
||||
adminID, ok := currentAdminID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少管理员上下文")
|
||||
return
|
||||
}
|
||||
userID, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req GrowthLevelAdjustRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "成长等级调整参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.AdjustGrowthLevel(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 {
|
||||
@@ -264,6 +301,12 @@ func writeAdminUserError(c *gin.Context, err error) {
|
||||
response.BadRequest(c, "请填写 2-200 字的积分调整原因")
|
||||
case errors.Is(err, ErrGrowthPointsUnchanged):
|
||||
response.Error(c, http.StatusConflict, "growth_points_unchanged", "目标积分与当前积分相同")
|
||||
case errors.Is(err, ErrInvalidGrowthLevel):
|
||||
response.BadRequest(c, "目标成长等级不正确")
|
||||
case errors.Is(err, ErrInvalidGrowthLevelReason):
|
||||
response.BadRequest(c, "请填写 2-200 字的等级调整原因")
|
||||
case errors.Is(err, ErrGrowthLevelUnchanged):
|
||||
response.Error(c, http.StatusConflict, "growth_level_unchanged", "目标等级与当前手动等级相同")
|
||||
case IsNotFound(err):
|
||||
response.Error(c, http.StatusNotFound, "not_found", "用户不存在")
|
||||
default:
|
||||
|
||||
@@ -52,6 +52,11 @@ func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQue
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if query.GrowthLevel != "" {
|
||||
if _, ok := rentergrowth.LevelForCode(growthConfig, query.GrowthLevel); !ok {
|
||||
return nil, ErrInvalidGrowthLevel
|
||||
}
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
var rows []userRow
|
||||
listTx := r.db.WithContext(ctx).Table("users AS u").
|
||||
@@ -99,13 +104,31 @@ func applyUserFilter(tx *gorm.DB, query ListQuery, growthConfig rentergrowth.Con
|
||||
if query.Status != "" {
|
||||
tx = tx.Where("u.status = ?", query.Status)
|
||||
}
|
||||
if query.HasDepositFreeQuota {
|
||||
if minPoints, ok := minimumGrowthQuotaPoints(growthConfig); ok {
|
||||
tx = tx.Where("u.deposit_free_quota_cent > 0 OR u.renter_growth_points >= ?", minPoints)
|
||||
depositFreeQuota := query.DepositFreeQuota
|
||||
if depositFreeQuota == "" && query.HasDepositFreeQuota {
|
||||
depositFreeQuota = "has"
|
||||
}
|
||||
if depositFreeQuota != "" {
|
||||
condition, args := depositFreeQuotaCondition(growthConfig)
|
||||
if depositFreeQuota == "none" {
|
||||
tx = tx.Where("NOT ("+condition+")", args...)
|
||||
} else {
|
||||
tx = tx.Where("u.deposit_free_quota_cent > 0")
|
||||
tx = tx.Where(condition, args...)
|
||||
}
|
||||
}
|
||||
ownerType := query.OwnerType
|
||||
if ownerType == "" && query.QualityOwnerOnly {
|
||||
ownerType = "quality"
|
||||
}
|
||||
switch ownerType {
|
||||
case "quality":
|
||||
tx = tx.Where("u.quality_owner_status = ?", "active")
|
||||
case "normal":
|
||||
tx = tx.Where("u.quality_owner_status <> ?", "active")
|
||||
}
|
||||
if query.GrowthLevel != "" {
|
||||
tx = tx.Where("u.renter_growth_level = ?", query.GrowthLevel)
|
||||
}
|
||||
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||||
if len(keyword) == 11 && keyword[0] == '1' && isAllDigits(keyword) {
|
||||
// 11 位手机号:命中 users(phone) 唯一索引
|
||||
@@ -122,6 +145,31 @@ func applyUserFilter(tx *gorm.DB, query ListQuery, growthConfig rentergrowth.Con
|
||||
return tx
|
||||
}
|
||||
|
||||
// depositFreeQuotaCondition 返回“有免押额度”的 SQL 条件:
|
||||
// 人工额度 > 0,或当前成长等级自带免押额度。
|
||||
func depositFreeQuotaCondition(cfg rentergrowth.Config) (string, []any) {
|
||||
quotaLevelCodes := growthQuotaLevelCodes(cfg)
|
||||
if minPoints, ok := minimumGrowthQuotaPoints(cfg); ok {
|
||||
return "u.deposit_free_quota_cent > 0 OR u.renter_growth_points >= ? OR u.renter_growth_level_override IN ?",
|
||||
[]any{minPoints, quotaLevelCodes}
|
||||
}
|
||||
if len(quotaLevelCodes) > 0 {
|
||||
return "u.deposit_free_quota_cent > 0 OR u.renter_growth_level_override IN ?",
|
||||
[]any{quotaLevelCodes}
|
||||
}
|
||||
return "u.deposit_free_quota_cent > 0", nil
|
||||
}
|
||||
|
||||
func growthQuotaLevelCodes(cfg rentergrowth.Config) []string {
|
||||
codes := make([]string, 0, len(cfg.Levels))
|
||||
for _, level := range cfg.Levels {
|
||||
if level.DepositFreeQuotaCent > 0 {
|
||||
codes = append(codes, level.Code)
|
||||
}
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
func minimumGrowthQuotaPoints(cfg rentergrowth.Config) (int64, bool) {
|
||||
if !cfg.Enabled {
|
||||
return 0, false
|
||||
@@ -189,28 +237,39 @@ func (r *Repository) AdjustGrowthPoints(ctx context.Context, adminID uint64, use
|
||||
return err
|
||||
}
|
||||
beforePoints := user.RenterGrowthPoints
|
||||
beforeLevel := rentergrowth.LevelForPoints(cfg, beforePoints)
|
||||
afterLevel := rentergrowth.LevelForPoints(cfg, req.TargetPoints)
|
||||
if !cfg.Enabled {
|
||||
beforeAvailable := user.PointsAvailable
|
||||
pointsDelta := req.TargetPoints - beforePoints
|
||||
afterAvailable := beforeAvailable + pointsDelta
|
||||
if afterAvailable < 0 {
|
||||
afterAvailable = 0
|
||||
}
|
||||
beforeLevel := rentergrowth.EffectiveLevelForUser(cfg, beforePoints, user.RenterGrowthLevelOverride)
|
||||
afterLevel := rentergrowth.LevelForUserAfterPoints(cfg, req.TargetPoints, user.RenterGrowthLevelOverride)
|
||||
if !cfg.Enabled && user.RenterGrowthLevelOverride == "" {
|
||||
beforeLevel = rentergrowth.LevelForPoints(cfg, 0)
|
||||
afterLevel = beforeLevel
|
||||
}
|
||||
user.RenterGrowthPoints = req.TargetPoints
|
||||
user.RenterGrowthLevel = afterLevel.Code
|
||||
user.PointsAvailable = afterAvailable
|
||||
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(),
|
||||
UserID: user.ID,
|
||||
Role: "admin",
|
||||
Points: pointsDelta,
|
||||
PointsMultiplierBps: afterLevel.PointsMultiplierBps,
|
||||
BeforePoints: beforePoints,
|
||||
AfterPoints: req.TargetPoints,
|
||||
AvailableBefore: beforeAvailable,
|
||||
AvailableAfter: afterAvailable,
|
||||
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
|
||||
@@ -219,7 +278,7 @@ func (r *Repository) AdjustGrowthPoints(ctx context.Context, adminID uint64, use
|
||||
"user_id": user.ID,
|
||||
"before_points": beforePoints,
|
||||
"after_points": req.TargetPoints,
|
||||
"change_points": req.TargetPoints - beforePoints,
|
||||
"change_points": pointsDelta,
|
||||
"before_level": beforeLevel.Code,
|
||||
"after_level": afterLevel.Code,
|
||||
"reason": req.Reason,
|
||||
@@ -231,6 +290,56 @@ func (r *Repository) AdjustGrowthPoints(ctx context.Context, adminID uint64, use
|
||||
return r.Find(ctx, userID)
|
||||
}
|
||||
|
||||
func (r *Repository) AdjustGrowthLevel(ctx context.Context, adminID uint64, userID uint64, req GrowthLevelAdjustRequest, 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
|
||||
}
|
||||
cfg, err := rentergrowth.ConfigForTx(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetLevel := strings.TrimSpace(req.TargetLevel)
|
||||
beforeOverride := user.RenterGrowthLevelOverride
|
||||
beforeLevel := rentergrowth.EffectiveLevelForUser(cfg, user.RenterGrowthPoints, beforeOverride)
|
||||
if beforeOverride == "" && !cfg.Enabled {
|
||||
beforeLevel = rentergrowth.LevelForPoints(cfg, 0)
|
||||
}
|
||||
afterLevel := rentergrowth.LevelForPoints(cfg, user.RenterGrowthPoints)
|
||||
if targetLevel != "" {
|
||||
level, ok := rentergrowth.LevelForCode(cfg, targetLevel)
|
||||
if !ok {
|
||||
return ErrInvalidGrowthLevel
|
||||
}
|
||||
afterLevel = level
|
||||
} else if !cfg.Enabled {
|
||||
afterLevel = rentergrowth.LevelForPoints(cfg, 0)
|
||||
}
|
||||
if targetLevel == beforeOverride && user.RenterGrowthLevel == afterLevel.Code {
|
||||
return ErrGrowthLevelUnchanged
|
||||
}
|
||||
|
||||
user.RenterGrowthLevelOverride = targetLevel
|
||||
user.RenterGrowthLevel = afterLevel.Code
|
||||
if err := tx.Save(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return appendAuditLog(tx, adminID, "admin_user.adjust_growth_level", user.ID, meta, map[string]any{
|
||||
"user_id": user.ID,
|
||||
"before_level": beforeLevel.Code,
|
||||
"after_level": afterLevel.Code,
|
||||
"before_override": beforeOverride,
|
||||
"after_override": targetLevel,
|
||||
"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) {
|
||||
statusChanged := false
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
@@ -534,9 +643,13 @@ type userRow struct {
|
||||
}
|
||||
|
||||
func (row userRow) toDTO(growthConfig rentergrowth.Config) UserDTO {
|
||||
level := rentergrowth.LevelForPoints(growthConfig, row.RenterGrowthPoints)
|
||||
level := rentergrowth.EffectiveLevelForUser(
|
||||
growthConfig,
|
||||
row.RenterGrowthPoints,
|
||||
row.RenterGrowthLevelOverride,
|
||||
)
|
||||
levelQuotaCent := level.DepositFreeQuotaCent
|
||||
if !growthConfig.Enabled {
|
||||
if !growthConfig.Enabled && row.RenterGrowthLevelOverride == "" {
|
||||
level = rentergrowth.LevelForPoints(growthConfig, 0)
|
||||
levelQuotaCent = 0
|
||||
}
|
||||
@@ -559,10 +672,17 @@ func (row userRow) toDTO(growthConfig rentergrowth.Config) UserDTO {
|
||||
DepositFreeUsedCent: row.DepositFreeUsedCent,
|
||||
DepositFreeRemainingCent: remaining,
|
||||
RenterGrowthPoints: row.RenterGrowthPoints,
|
||||
PointsAvailable: row.PointsAvailable,
|
||||
RenterGrowthLevel: level.Code,
|
||||
RenterGrowthLevelName: level.Name,
|
||||
RenterGrowthLevelOverride: row.RenterGrowthLevelOverride,
|
||||
RenterGrowthLevelManual: row.RenterGrowthLevelOverride != "",
|
||||
AvailableBalanceCent: row.AvailableBalanceCent,
|
||||
FrozenBalanceCent: row.FrozenBalanceCent,
|
||||
QualityOwnerStatus: row.QualityOwnerStatus,
|
||||
QualityOwnerDepositCent: row.QualityOwnerDepositCent,
|
||||
QualityOwnerDepositSource: row.QualityOwnerDepositSource,
|
||||
QualityOwnerActivatedAt: row.QualityOwnerActivatedAt,
|
||||
Status: row.Status,
|
||||
OrderCount: row.OrderCount,
|
||||
ListingCount: row.ListingCount,
|
||||
|
||||
@@ -73,9 +73,138 @@ func TestApplyUserFilterHasDepositFreeQuota(t *testing.T) {
|
||||
if !strings.Contains(sql, "u.deposit_free_quota_cent > 0 OR u.renter_growth_points >=") {
|
||||
t.Fatalf("SQL 未包含免押额度过滤:%s", sql)
|
||||
}
|
||||
if len(stmt.Vars) != 2 || stmt.Vars[0] != "active" || stmt.Vars[1] != int64(300) {
|
||||
if len(stmt.Vars) != 5 || stmt.Vars[0] != "active" || stmt.Vars[1] != int64(300) {
|
||||
t.Fatalf("筛选变量不正确:%v", stmt.Vars)
|
||||
}
|
||||
overrideCodes := stmt.Vars[2:]
|
||||
if len(overrideCodes) != 3 ||
|
||||
overrideCodes[0] != "platinum" ||
|
||||
overrideCodes[1] != "diamond" ||
|
||||
overrideCodes[2] != "peak" {
|
||||
t.Fatalf("手动等级筛选变量不正确:%v", overrideCodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyUserFilterNoneDepositFreeQuota(t *testing.T) {
|
||||
db := database.NewDryRunDB()
|
||||
|
||||
stmt := applyUserFilter(db.Table("users AS u"), ListQuery{
|
||||
DepositFreeQuota: "none",
|
||||
}, rentergrowth.DefaultConfig()).Find(&[]struct{}{}).Statement
|
||||
sql := stmt.SQL.String()
|
||||
|
||||
if !strings.Contains(sql, "NOT (u.deposit_free_quota_cent > 0 OR u.renter_growth_points >= ? OR u.renter_growth_level_override IN (?,?,?))") {
|
||||
t.Fatalf("SQL 未包含无免押额度过滤:%s", sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyUserFilterOwnerTypeAndGrowthLevel(t *testing.T) {
|
||||
db := database.NewDryRunDB()
|
||||
|
||||
stmt := applyUserFilter(db.Table("users AS u"), ListQuery{
|
||||
OwnerType: "normal",
|
||||
GrowthLevel: "diamond",
|
||||
}, rentergrowth.DefaultConfig()).Find(&[]struct{}{}).Statement
|
||||
sql := stmt.SQL.String()
|
||||
|
||||
if !strings.Contains(sql, "u.quality_owner_status <> ?") {
|
||||
t.Fatalf("SQL 未包含普通号主过滤:%s", sql)
|
||||
}
|
||||
if !strings.Contains(sql, "u.renter_growth_level = ?") {
|
||||
t.Fatalf("SQL 未包含成长等级过滤:%s", sql)
|
||||
}
|
||||
if len(stmt.Vars) != 2 || stmt.Vars[0] != "active" || stmt.Vars[1] != "diamond" {
|
||||
t.Fatalf("筛选变量不正确:%v", stmt.Vars)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeEnumQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{input: " QUALITY ", want: "quality"},
|
||||
{input: "normal", want: "normal"},
|
||||
{input: "unknown", want: ""},
|
||||
{input: "", want: ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := normalizeEnumQuery(tt.input, "quality", "normal"); got != tt.want {
|
||||
t.Fatalf("normalizeEnumQuery(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFiltersByDepositQuotaOwnerTypeAndGrowthLevel(t *testing.T) {
|
||||
db := setupAdminUserTestDB(t)
|
||||
users := []model.User{
|
||||
{Phone: "13900001001", Nickname: "普通无免押", Status: "active", QualityOwnerStatus: "active", RenterGrowthLevel: "normal"},
|
||||
{Phone: "13900001002", Nickname: "铂金有免押", Status: "active", QualityOwnerStatus: "none", RenterGrowthPoints: 300, RenterGrowthLevel: "platinum"},
|
||||
{Phone: "13900001003", Nickname: "钻石有免押", Status: "active", QualityOwnerStatus: "none", RenterGrowthPoints: 1000, RenterGrowthLevel: "diamond"},
|
||||
{Phone: "13900001004", Nickname: "人工免押", Status: "active", QualityOwnerStatus: "none", DepositFreeQuotaCent: 20000, RenterGrowthLevel: "normal"},
|
||||
}
|
||||
if err := db.Create(&users).Error; err != nil {
|
||||
t.Fatalf("创建用户失败:%v", err)
|
||||
}
|
||||
repo := NewRepository(db)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query ListQuery
|
||||
wantIDs []uint64
|
||||
}{
|
||||
{
|
||||
name: "有免押额度",
|
||||
query: ListQuery{DepositFreeQuota: "has"},
|
||||
wantIDs: []uint64{users[1].ID, users[2].ID, users[3].ID},
|
||||
},
|
||||
{
|
||||
name: "无免押额度",
|
||||
query: ListQuery{DepositFreeQuota: "none"},
|
||||
wantIDs: []uint64{users[0].ID},
|
||||
},
|
||||
{
|
||||
name: "优质号主",
|
||||
query: ListQuery{OwnerType: "quality"},
|
||||
wantIDs: []uint64{users[0].ID},
|
||||
},
|
||||
{
|
||||
name: "普通用户",
|
||||
query: ListQuery{OwnerType: "normal"},
|
||||
wantIDs: []uint64{users[1].ID, users[2].ID, users[3].ID},
|
||||
},
|
||||
{
|
||||
name: "铂金等级",
|
||||
query: ListQuery{GrowthLevel: "platinum"},
|
||||
wantIDs: []uint64{users[1].ID},
|
||||
},
|
||||
{
|
||||
name: "组合筛选",
|
||||
query: ListQuery{OwnerType: "normal", GrowthLevel: "normal", DepositFreeQuota: "has"},
|
||||
wantIDs: []uint64{users[3].ID},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := repo.List(t.Context(), 1, 20, tt.query)
|
||||
if err != nil {
|
||||
t.Fatalf("List() error = %v", err)
|
||||
}
|
||||
if result.Total != int64(len(tt.wantIDs)) {
|
||||
t.Fatalf("Total = %d, want %d", result.Total, len(tt.wantIDs))
|
||||
}
|
||||
items := result.Items.([]UserDTO)
|
||||
got := make(map[uint64]bool, len(items))
|
||||
for _, item := range items {
|
||||
got[item.ID] = true
|
||||
}
|
||||
for _, id := range tt.wantIDs {
|
||||
if !got[id] {
|
||||
t.Fatalf("结果缺少用户 %d:%v", id, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustGrowthPointsWritesRepeatableLedgers(t *testing.T) {
|
||||
@@ -120,6 +249,65 @@ func TestAdjustGrowthPointsWritesRepeatableLedgers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustGrowthLevelLocksLevelAndWritesAudit(t *testing.T) {
|
||||
db := setupAdminUserTestDB(t)
|
||||
user := model.User{
|
||||
Phone: "13900000006",
|
||||
Status: "active",
|
||||
RenterGrowthPoints: 100,
|
||||
RenterGrowthLevel: rentergrowth.DefaultLevelCode,
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("创建用户失败:%v", err)
|
||||
}
|
||||
repo := NewRepository(db)
|
||||
|
||||
got, err := repo.AdjustGrowthLevel(t.Context(), 66, user.ID, GrowthLevelAdjustRequest{
|
||||
TargetLevel: "platinum",
|
||||
Reason: "客服核验后手动调整",
|
||||
}, AuditMeta{RequestID: "level-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("手动调整等级失败:%v", err)
|
||||
}
|
||||
if got.RenterGrowthLevel != "platinum" || !got.RenterGrowthLevelManual ||
|
||||
got.RenterGrowthLevelOverride != "platinum" {
|
||||
t.Fatalf("手动等级结果不正确:%+v", got)
|
||||
}
|
||||
|
||||
got, err = repo.AdjustGrowthPoints(t.Context(), 66, user.ID, GrowthPointsAdjustRequest{
|
||||
TargetPoints: 1000,
|
||||
Reason: "补充成长积分",
|
||||
}, AuditMeta{RequestID: "points-after-level"})
|
||||
if err != nil {
|
||||
t.Fatalf("调整积分失败:%v", err)
|
||||
}
|
||||
if got.RenterGrowthLevel != "platinum" || got.RenterGrowthLevelOverride != "platinum" {
|
||||
t.Fatalf("积分调整不应覆盖手动等级:%+v", got)
|
||||
}
|
||||
|
||||
got, err = repo.AdjustGrowthLevel(t.Context(), 66, user.ID, GrowthLevelAdjustRequest{
|
||||
TargetLevel: "",
|
||||
Reason: "恢复积分自动计算",
|
||||
}, AuditMeta{RequestID: "level-auto"})
|
||||
if err != nil {
|
||||
t.Fatalf("恢复自动等级失败:%v", err)
|
||||
}
|
||||
if got.RenterGrowthLevel != "diamond" || got.RenterGrowthLevelManual ||
|
||||
got.RenterGrowthLevelOverride != "" {
|
||||
t.Fatalf("恢复自动等级结果不正确:%+v", got)
|
||||
}
|
||||
|
||||
var auditCount int64
|
||||
if err := db.Model(&model.AuditLog{}).
|
||||
Where("action = ? AND biz_id = ?", "admin_user.adjust_growth_level", user.ID).
|
||||
Count(&auditCount).Error; err != nil {
|
||||
t.Fatalf("查询审计日志失败:%v", err)
|
||||
}
|
||||
if auditCount != 2 {
|
||||
t.Fatalf("等级调整审计日志数量 = %d, want 2", auditCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualRealnameUpdatesUserRecordAndAudit(t *testing.T) {
|
||||
db := setupAdminUserTestDB(t)
|
||||
user := model.User{
|
||||
|
||||
@@ -7,22 +7,26 @@ import (
|
||||
"strings"
|
||||
|
||||
"hfb_jysh/backend/internal/modules/realname"
|
||||
"hfb_jysh/backend/internal/modules/rentergrowth"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||
ErrInvalidUser = errors.New("invalid user")
|
||||
ErrInvalidRealnameInput = errors.New("invalid realname input")
|
||||
ErrRealnameAlreadyVerified = errors.New("realname already verified")
|
||||
ErrRealnameIDNoUsed = realname.ErrIDNoAlreadyUsed
|
||||
ErrRealnameNotRevocable = errors.New("realname not revocable")
|
||||
ErrInvalidWalletDirection = errors.New("invalid wallet direction")
|
||||
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")
|
||||
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||
ErrInvalidUser = errors.New("invalid user")
|
||||
ErrInvalidRealnameInput = errors.New("invalid realname input")
|
||||
ErrRealnameAlreadyVerified = errors.New("realname already verified")
|
||||
ErrRealnameIDNoUsed = realname.ErrIDNoAlreadyUsed
|
||||
ErrRealnameNotRevocable = errors.New("realname not revocable")
|
||||
ErrInvalidWalletDirection = errors.New("invalid wallet direction")
|
||||
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")
|
||||
ErrInvalidGrowthLevel = errors.New("invalid growth level")
|
||||
ErrInvalidGrowthLevelReason = errors.New("invalid growth level reason")
|
||||
ErrGrowthLevelUnchanged = errors.New("growth level unchanged")
|
||||
)
|
||||
|
||||
// 单笔人工调账上限:10 万元,防止误填。
|
||||
@@ -99,6 +103,27 @@ func (s *Service) AdjustGrowthPoints(ctx context.Context, adminID uint64, userID
|
||||
return s.repo.AdjustGrowthPoints(ctx, adminID, userID, req, meta)
|
||||
}
|
||||
|
||||
func (s *Service) AdjustGrowthLevel(ctx context.Context, adminID uint64, userID uint64, req GrowthLevelAdjustRequest, meta AuditMeta) (*UserDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
if userID == 0 {
|
||||
return nil, ErrInvalidUser
|
||||
}
|
||||
req.TargetLevel = strings.TrimSpace(req.TargetLevel)
|
||||
if req.TargetLevel != "" {
|
||||
if _, ok := rentergrowth.LevelForCode(rentergrowth.DefaultConfig(), req.TargetLevel); !ok {
|
||||
return nil, ErrInvalidGrowthLevel
|
||||
}
|
||||
}
|
||||
reason := strings.TrimSpace(req.Reason)
|
||||
if reasonLen := len([]rune(reason)); reasonLen < 2 || reasonLen > 200 {
|
||||
return nil, ErrInvalidGrowthLevelReason
|
||||
}
|
||||
req.Reason = reason
|
||||
return s.repo.AdjustGrowthLevel(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
|
||||
|
||||
Reference in New Issue
Block a user