From 0d558bdce0455aef2ba68d6934d289ef879341d9 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 5 Nov 2026 12:02:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(user):=20=E6=94=AF=E6=8C=81=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E8=B0=83=E6=95=B4=E7=AD=89=E7=BA=A7=E5=B9=B6=E9=99=90?= =?UTF-8?q?=E5=88=B6=E6=9E=81=E5=93=81=E4=B8=93=E5=8C=BA=E5=8F=AF=E8=A7=81?= =?UTF-8?q?=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/middleware/auth.go | 30 ++++ backend/internal/model/user.go | 1 + backend/internal/modules/adminuser/dto.go | 7 + backend/internal/modules/adminuser/handler.go | 29 ++++ .../internal/modules/adminuser/repository.go | 88 +++++++++- .../modules/adminuser/repository_test.go | 68 +++++++- backend/internal/modules/adminuser/service.go | 51 ++++-- backend/internal/modules/listing/dto.go | 2 + .../modules/listing/handler_public.go | 16 +- .../listing/premium_visibility_test.go | 82 +++++++++ .../internal/modules/listing/public_filter.go | 3 + .../modules/listing/public_filter_test.go | 38 ++++- .../internal/modules/listing/public_query.go | 79 +++++++-- .../internal/modules/listing/repository.go | 5 +- .../internal/modules/listing/service_query.go | 4 +- .../internal/modules/listing/service_test.go | 4 +- .../internal/modules/order/handler_error.go | 2 + backend/internal/modules/order/lifecycle.go | 3 + backend/internal/modules/order/service.go | 1 + .../internal/modules/pointsmall/repository.go | 6 +- .../internal/modules/rentergrowth/growth.go | 69 +++++++- .../modules/rentergrowth/growth_test.go | 42 +++++ backend/internal/router/router.go | 6 +- .../000095_manual_renter_growth_level.sql | 26 +++ frontend/src/features/admin/api/adminUsers.ts | 18 ++ .../components/AdminUserDetailDrawer.css | 12 ++ .../components/AdminUserDetailDrawer.vue | 5 +- .../features/admin/views/AdminUsersView.css | 11 ++ .../features/admin/views/AdminUsersView.vue | 139 ++++++++++++++- .../src/features/listings/views/HomeView.vue | 161 ++++++++++-------- .../listings/views/MobileHomeView.vue | 36 +++- .../utils/__tests__/growthLevel.spec.ts | 13 ++ frontend/src/shared/utils/auditLabels.ts | 1 + frontend/src/shared/utils/growthLevel.ts | 5 + frontend/src/stores/mobileHomeCache.ts | 2 + 35 files changed, 934 insertions(+), 131 deletions(-) create mode 100644 backend/internal/modules/listing/premium_visibility_test.go create mode 100644 backend/migrations/000095_manual_renter_growth_level.sql create mode 100644 frontend/src/shared/utils/__tests__/growthLevel.spec.ts create mode 100644 frontend/src/shared/utils/growthLevel.ts diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index eec9e6dd..271fa6af 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -96,6 +96,36 @@ func Auth(jwtManager *auth.JWTManager, validate UserTokenValidatorFunc) gin.Hand } } +// OptionalAuth 在请求携带有效用户令牌时注入用户上下文,否则按匿名访问继续。 +func OptionalAuth(jwtManager *auth.JWTManager, validate UserTokenValidatorFunc) gin.HandlerFunc { + return func(c *gin.Context) { + tokenText := extractBearerToken(c) + if tokenText == "" { + if cookieToken, err := c.Cookie(UserAccessCookieName); err == nil { + tokenText = strings.TrimSpace(cookieToken) + } + } + if tokenText == "" { + c.Next() + return + } + claims, err := jwtManager.ParseSubject(tokenText, "access", "user") + if err != nil { + c.Next() + return + } + if validate != nil { + if err := validate(c.Request.Context(), claims.UserID, claims.TokenVersion); err != nil { + c.Next() + return + } + } + c.Set(ContextUserID, claims.UserID) + c.Set(ContextPhone, claims.Phone) + c.Next() + } +} + // AuthUserOrAdmin 允许普通用户或管理员访问共享资源(如私有文件流)。 func AuthUserOrAdmin(jwtManager *auth.JWTManager, userValidate UserTokenValidatorFunc, adminValidate AdminTokenValidatorFunc) gin.HandlerFunc { return func(c *gin.Context) { diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index 41351a3c..f05750a5 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -14,6 +14,7 @@ type User struct { DepositFreeQuotaCent int64 `gorm:"not null;default:0" json:"-"` RenterGrowthPoints int64 `gorm:"not null;default:0;index:idx_users_renter_growth_level,priority:2" json:"renter_growth_points"` RenterGrowthLevel string `gorm:"size:32;not null;default:'normal';index:idx_users_renter_growth_level,priority:1" json:"renter_growth_level"` + RenterGrowthLevelOverride string `gorm:"size:32;not null;default:''" json:"-"` PointsAvailable int64 `gorm:"not null;default:0" json:"points_available"` Status string `gorm:"size:32;not null;default:'active'" json:"status"` QualityOwnerStatus string `gorm:"size:16;not null;default:'none';index:idx_users_quality_owner_status" json:"quality_owner_status"` diff --git a/backend/internal/modules/adminuser/dto.go b/backend/internal/modules/adminuser/dto.go index 7febd0f4..a9b1633a 100644 --- a/backend/internal/modules/adminuser/dto.go +++ b/backend/internal/modules/adminuser/dto.go @@ -19,6 +19,8 @@ type UserDTO struct { 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"` @@ -56,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 { diff --git a/backend/internal/modules/adminuser/handler.go b/backend/internal/modules/adminuser/handler.go index 05f339f0..2ca3e669 100644 --- a/backend/internal/modules/adminuser/handler.go +++ b/backend/internal/modules/adminuser/handler.go @@ -144,6 +144,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 { @@ -265,6 +288,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: diff --git a/backend/internal/modules/adminuser/repository.go b/backend/internal/modules/adminuser/repository.go index f9ea1dc1..0f5b7849 100644 --- a/backend/internal/modules/adminuser/repository.go +++ b/backend/internal/modules/adminuser/repository.go @@ -100,8 +100,18 @@ func applyUserFilter(tx *gorm.DB, query ListQuery, growthConfig rentergrowth.Con tx = tx.Where("u.status = ?", query.Status) } if query.HasDepositFreeQuota { + quotaLevelCodes := growthQuotaLevelCodes(growthConfig) if minPoints, ok := minimumGrowthQuotaPoints(growthConfig); ok { - tx = tx.Where("u.deposit_free_quota_cent > 0 OR u.renter_growth_points >= ?", minPoints) + tx = tx.Where( + "u.deposit_free_quota_cent > 0 OR u.renter_growth_points >= ? OR u.renter_growth_level_override IN ?", + minPoints, + quotaLevelCodes, + ) + } else if len(quotaLevelCodes) > 0 { + tx = tx.Where( + "u.deposit_free_quota_cent > 0 OR u.renter_growth_level_override IN ?", + quotaLevelCodes, + ) } else { tx = tx.Where("u.deposit_free_quota_cent > 0") } @@ -125,6 +135,16 @@ func applyUserFilter(tx *gorm.DB, query ListQuery, growthConfig rentergrowth.Con return tx } +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 @@ -198,9 +218,9 @@ func (r *Repository) AdjustGrowthPoints(ctx context.Context, adminID uint64, use if afterAvailable < 0 { afterAvailable = 0 } - beforeLevel := rentergrowth.LevelForPoints(cfg, beforePoints) - afterLevel := rentergrowth.LevelForPoints(cfg, req.TargetPoints) - if !cfg.Enabled { + 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 } @@ -245,6 +265,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 { @@ -548,9 +618,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 } @@ -576,6 +650,8 @@ func (row userRow) toDTO(growthConfig rentergrowth.Config) UserDTO { PointsAvailable: row.PointsAvailable, RenterGrowthLevel: level.Code, RenterGrowthLevelName: level.Name, + RenterGrowthLevelOverride: row.RenterGrowthLevelOverride, + RenterGrowthLevelManual: row.RenterGrowthLevelOverride != "", AvailableBalanceCent: row.AvailableBalanceCent, FrozenBalanceCent: row.FrozenBalanceCent, QualityOwnerStatus: row.QualityOwnerStatus, diff --git a/backend/internal/modules/adminuser/repository_test.go b/backend/internal/modules/adminuser/repository_test.go index cc2d4e35..dc764313 100644 --- a/backend/internal/modules/adminuser/repository_test.go +++ b/backend/internal/modules/adminuser/repository_test.go @@ -73,9 +73,16 @@ 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 TestAdjustGrowthPointsWritesRepeatableLedgers(t *testing.T) { @@ -120,6 +127,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{ diff --git a/backend/internal/modules/adminuser/service.go b/backend/internal/modules/adminuser/service.go index 14b43f74..764a891e 100644 --- a/backend/internal/modules/adminuser/service.go +++ b/backend/internal/modules/adminuser/service.go @@ -7,22 +7,26 @@ import ( "strings" "hfb_sys/backend/internal/modules/realname" + "hfb_sys/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 diff --git a/backend/internal/modules/listing/dto.go b/backend/internal/modules/listing/dto.go index 08805632..a971e3db 100644 --- a/backend/internal/modules/listing/dto.go +++ b/backend/internal/modules/listing/dto.go @@ -168,6 +168,8 @@ type PublicListQuery struct { MaxSecretKD *float64 ResourceRanges map[string]NumberRange DistributionSiteCode string + ViewerUserID uint64 + CanViewPremium bool } type NumberRange struct { diff --git a/backend/internal/modules/listing/handler_public.go b/backend/internal/modules/listing/handler_public.go index 763f1a96..ab56b15b 100644 --- a/backend/internal/modules/listing/handler_public.go +++ b/backend/internal/modules/listing/handler_public.go @@ -4,6 +4,7 @@ import ( "net/http" "strconv" + "hfb_sys/backend/internal/middleware" filemodule "hfb_sys/backend/internal/modules/file" "hfb_sys/backend/internal/sitecode" "hfb_sys/backend/pkg/response" @@ -12,7 +13,9 @@ import ( ) func (h *Handler) ListPublic(c *gin.Context) { - items, err := h.service.ListPublic(c.Request.Context(), parsePublicListQuery(c)) + query := parsePublicListQuery(c) + query.ViewerUserID = optionalCurrentUserID(c) + items, err := h.service.ListPublic(c.Request.Context(), query) if err != nil { writeListingError(c, err) return @@ -26,7 +29,7 @@ func (h *Handler) FindPublic(c *gin.Context) { return } siteCode := sitecode.FromRequest(c) - item, err := h.service.FindPublic(c.Request.Context(), id, siteCode) + item, err := h.service.FindPublic(c.Request.Context(), id, siteCode, optionalCurrentUserID(c)) if err != nil { writeListingError(c, err) return @@ -34,6 +37,15 @@ func (h *Handler) FindPublic(c *gin.Context) { response.OK(c, item) } +func optionalCurrentUserID(c *gin.Context) uint64 { + value, ok := c.Get(middleware.ContextUserID) + if !ok { + return 0 + } + userID, _ := value.(uint64) + return userID +} + func (h *Handler) Cover(c *gin.Context) { id, ok := parseID(c) if !ok { diff --git a/backend/internal/modules/listing/premium_visibility_test.go b/backend/internal/modules/listing/premium_visibility_test.go new file mode 100644 index 00000000..f2161c52 --- /dev/null +++ b/backend/internal/modules/listing/premium_visibility_test.go @@ -0,0 +1,82 @@ +package listing + +import ( + "context" + "errors" + "testing" + + "hfb_sys/backend/internal/database" + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" +) + +func TestPremiumListingOnlyVisibleToPlatinumUsers(t *testing.T) { + db := database.NewTestDB() + if err := database.MigrateListingLifecycleTestSchema(db); err != nil { + t.Fatalf("migrate listing schema: %v", err) + } + if err := database.AutoMigrateIfMissing(db, &model.User{}, &model.SystemConfig{}); err != nil { + t.Fatalf("migrate user schema: %v", err) + } + users := []model.User{ + {Phone: "13900001101", RenterGrowthLevel: "normal"}, + { + Phone: "13900001102", + RenterGrowthLevel: "platinum", + RenterGrowthLevelOverride: "platinum", + }, + } + if err := db.Create(&users).Error; err != nil { + t.Fatalf("create users: %v", err) + } + account := model.GameAccount{Title: "极品账号", GameName: "delta", Status: "active", HafCoinAmount: 100_000_000} + if err := db.Create(&account).Error; err != nil { + t.Fatalf("create account: %v", err) + } + item := model.RentalListing{ + ListingNo: "L-PREMIUM-1", + AccountID: account.ID, + Status: "published", + ReviewStatus: "approved", + PriceCent: 20_000, + IsPremium: true, + PublishedAt: nil, + InTransaction: false, + } + if err := db.Create(&item).Error; err != nil { + t.Fatalf("create listing: %v", err) + } + repo := NewRepository(db, nil) + + anonymous, err := repo.ListPublic(context.Background(), PublicListQuery{}) + if err != nil { + t.Fatalf("anonymous ListPublic: %v", err) + } + if len(anonymous.Items) != 0 || anonymous.Total != 0 || anonymous.ZoneCounts["premium"] != 0 { + t.Fatalf("anonymous should not see premium listing: %+v", anonymous) + } + + regular, err := repo.ListPublic(context.Background(), PublicListQuery{ViewerUserID: users[0].ID}) + if err != nil { + t.Fatalf("regular ListPublic: %v", err) + } + if len(regular.Items) != 0 || regular.Total != 0 { + t.Fatalf("regular user should not see premium listing: %+v", regular) + } + + platinum, err := repo.ListPublic(context.Background(), PublicListQuery{ViewerUserID: users[1].ID}) + if err != nil { + t.Fatalf("platinum ListPublic: %v", err) + } + if len(platinum.Items) != 1 || platinum.Total != 1 || platinum.ZoneCounts["premium"] != 1 { + t.Fatalf("platinum user should see premium listing: %+v", platinum) + } + + if _, err := repo.FindPublic(context.Background(), item.ID, "", users[0].ID); !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("regular detail error = %v, want record not found", err) + } + if _, err := repo.FindPublic(context.Background(), item.ID, "", users[1].ID); err != nil { + t.Fatalf("platinum should see premium detail: %v", err) + } +} diff --git a/backend/internal/modules/listing/public_filter.go b/backend/internal/modules/listing/public_filter.go index 8b774172..7db62875 100644 --- a/backend/internal/modules/listing/public_filter.go +++ b/backend/internal/modules/listing/public_filter.go @@ -19,6 +19,9 @@ func filterPublicListings(items []ListingDTO, query PublicListQuery) []ListingDT } func matchesPublicQuery(item ListingDTO, query PublicListQuery) bool { + if item.IsPremium && !query.CanViewPremium { + return false + } if !matchesPublicZone(item, query.Zone) { return false } diff --git a/backend/internal/modules/listing/public_filter_test.go b/backend/internal/modules/listing/public_filter_test.go index fe63aa4b..b9591123 100644 --- a/backend/internal/modules/listing/public_filter_test.go +++ b/backend/internal/modules/listing/public_filter_test.go @@ -1,6 +1,11 @@ package listing -import "testing" +import ( + "testing" + + "hfb_sys/backend/internal/database" + "hfb_sys/backend/internal/model" +) func TestFilterPublicListingsByRatio(t *testing.T) { minRatio := 20.0 @@ -32,6 +37,37 @@ func TestMatchesPublicZonePremium(t *testing.T) { } } +func TestPremiumListingRequiresPremiumLevelVisibility(t *testing.T) { + premium := ListingDTO{ID: 1, IsPremium: true} + if matchesPublicQuery(premium, PublicListQuery{}) { + t.Fatal("未授权用户不应匹配极品专区账号") + } + if !matchesPublicQuery(premium, PublicListQuery{CanViewPremium: true}) { + t.Fatal("铂金及以上用户应匹配极品专区账号") + } +} + +func TestCanViewPremiumUsesEffectiveUserLevel(t *testing.T) { + db := database.NewTestDB() + if err := database.AutoMigrateIfMissing(db, &model.User{}, &model.SystemConfig{}); err != nil { + t.Fatalf("migrate failed: %v", err) + } + users := []model.User{ + {Phone: "13900001001", RenterGrowthLevel: "normal"}, + {Phone: "13900001002", RenterGrowthLevel: "platinum", RenterGrowthLevelOverride: "platinum"}, + } + if err := db.Create(&users).Error; err != nil { + t.Fatalf("create users failed: %v", err) + } + repo := NewRepository(db, nil) + if allowed, err := repo.canViewPremium(t.Context(), users[0].ID); err != nil || allowed { + t.Fatalf("普通用户可见性 = %v, err = %v, want false", allowed, err) + } + if allowed, err := repo.canViewPremium(t.Context(), users[1].ID); err != nil || !allowed { + t.Fatalf("铂金用户可见性 = %v, err = %v, want true", allowed, err) + } +} + func TestFilterPublicListingsRegionMultiSelectRequiresAll(t *testing.T) { items := []ListingDTO{ { diff --git a/backend/internal/modules/listing/public_query.go b/backend/internal/modules/listing/public_query.go index 664d72fc..9ceb07d1 100644 --- a/backend/internal/modules/listing/public_query.go +++ b/backend/internal/modules/listing/public_query.go @@ -6,11 +6,19 @@ import ( "strings" "time" + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/rentergrowth" + "gorm.io/datatypes" "gorm.io/gorm" ) func (r *Repository) ListPublic(ctx context.Context, query PublicListQuery) (*PublicListResult, error) { + canViewPremium, err := r.canViewPremium(ctx, query.ViewerUserID) + if err != nil { + return nil, err + } + query.CanViewPremium = canViewPremium page, pageSize := normalizedPublicPage(query) if canListPublicWithSQL(query) { return r.listPublicPage(ctx, query, page, pageSize) @@ -25,7 +33,8 @@ func (r *Repository) ListPublic(ctx context.Context, query PublicListQuery) (*Pu } var rows []listingRow - err := db.Order("l.published_at DESC, l.id DESC"). + db = applyPremiumVisibility(db, query) + err = db.Order("l.published_at DESC, l.id DESC"). Scan(&rows).Error if err != nil { return nil, err @@ -67,7 +76,7 @@ func (r *Repository) ListPublic(ctx context.Context, query PublicListQuery) (*Pu func (r *Repository) listPublicPage(ctx context.Context, query PublicListQuery, page int, pageSize int) (*PublicListResult, error) { var total int64 - if err := r.db.WithContext(ctx).Table("rental_listings AS l"). + if err := applyPremiumVisibility(r.db.WithContext(ctx).Table("rental_listings AS l"), query). Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false). Count(&total).Error; err != nil { return nil, err @@ -75,7 +84,7 @@ func (r *Repository) listPublicPage(ctx context.Context, query PublicListQuery, var rows []listingRow offset := (page - 1) * pageSize - err := applyPublicSQLSort(r.baseQuery(ctx), query.Sort). + err := applyPublicSQLSort(applyPremiumVisibility(r.baseQuery(ctx), query), query.Sort). Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false). Limit(pageSize). Offset(offset). @@ -83,10 +92,14 @@ func (r *Repository) listPublicPage(ctx context.Context, query PublicListQuery, if err != nil { return nil, err } - zoneCounts, err := r.publicZoneCountsCached(ctx) + zoneCounts, nonPremiumTotal, err := r.publicZoneCountsCached(ctx) if err != nil { return nil, err } + if !query.CanViewPremium { + zoneCounts["all"] = nonPremiumTotal + zoneCounts["premium"] = 0 + } return &PublicListResult{ Items: publicListings(rowsToDTO(rows)), Total: total, @@ -96,6 +109,13 @@ func (r *Repository) listPublicPage(ctx context.Context, query PublicListQuery, }, nil } +func applyPremiumVisibility(tx *gorm.DB, query PublicListQuery) *gorm.DB { + if query.CanViewPremium { + return tx + } + return tx.Where("l.is_premium = ?", false) +} + func normalizedPublicPage(query PublicListQuery) (int, int) { page := query.Page if page <= 0 { @@ -175,12 +195,12 @@ func publicShuffleSeed() int64 { return int64(now.Year()*366 + now.YearDay()) } -func (r *Repository) publicZoneCountsCached(ctx context.Context) (map[string]int64, error) { +func (r *Repository) publicZoneCountsCached(ctx context.Context) (map[string]int64, int64, error) { now := time.Now() r.publicZoneCountsMu.Lock() defer r.publicZoneCountsMu.Unlock() if r.publicZoneCounts.Counts != nil && now.Before(r.publicZoneCounts.ExpiresAt) { - return copyPublicZoneCounts(r.publicZoneCounts.Counts), nil + return copyPublicZoneCounts(r.publicZoneCounts.Counts), r.publicZoneCounts.NonPremiumTotal, nil } var rows []publicZoneRow @@ -190,7 +210,7 @@ func (r *Repository) publicZoneCountsCached(ctx context.Context) (map[string]int Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false). Scan(&rows).Error if err != nil { - return nil, err + return nil, 0, err } counts := map[string]int64{ "all": int64(len(rows)), @@ -201,6 +221,7 @@ func (r *Repository) publicZoneCountsCached(ctx context.Context) (map[string]int "highCoin": 0, "premium": 0, } + var nonPremiumTotal int64 for _, row := range rows { summary := decodeAssetSummary(row.AssetSummary) if isAcceleratedSale(summary) { @@ -220,13 +241,16 @@ func (r *Repository) publicZoneCountsCached(ctx context.Context) (map[string]int } if row.IsPremium { counts["premium"]++ + } else { + nonPremiumTotal++ } } r.publicZoneCounts = publicZoneCountCache{ - Counts: counts, - ExpiresAt: now.Add(publicZoneCountCacheTTL), + Counts: counts, + NonPremiumTotal: nonPremiumTotal, + ExpiresAt: now.Add(publicZoneCountCacheTTL), } - return copyPublicZoneCounts(counts), nil + return copyPublicZoneCounts(counts), nonPremiumTotal, nil } func copyPublicZoneCounts(counts map[string]int64) map[string]int64 { @@ -237,11 +261,20 @@ func copyPublicZoneCounts(counts map[string]int64) map[string]int64 { return copied } -func (r *Repository) FindPublic(ctx context.Context, id uint64, siteCode string) (*ListingDTO, error) { +func (r *Repository) FindPublic(ctx context.Context, id uint64, siteCode string, viewerUserID uint64) (*ListingDTO, error) { dto, err := r.findDTO(ctx, "l.id = ? AND l.status = ? AND l.review_status = ? AND l.in_transaction = ?", id, "published", "approved", false) if err != nil { return nil, err } + if dto.IsPremium { + canView, err := r.canViewPremium(ctx, viewerUserID) + if err != nil { + return nil, err + } + if !canView { + return nil, gorm.ErrRecordNotFound + } + } applyPublicListingURLs(dto) items := []ListingDTO{*dto} if err := r.applyShelfMarkup(ctx, siteCode, items); err != nil { @@ -251,6 +284,30 @@ func (r *Repository) FindPublic(ctx context.Context, id uint64, siteCode string) return dto, nil } +func (r *Repository) canViewPremium(ctx context.Context, userID uint64) (bool, error) { + if userID == 0 { + return false, nil + } + var user model.User + if err := r.db.WithContext(ctx). + Select("id, renter_growth_points, renter_growth_level_override"). + First(&user, userID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return false, nil + } + return false, err + } + cfg, err := rentergrowth.ConfigForTx(r.db.WithContext(ctx)) + if err != nil { + return false, err + } + level := rentergrowth.EffectiveLevelForUser(cfg, user.RenterGrowthPoints, user.RenterGrowthLevelOverride) + if !cfg.Enabled && user.RenterGrowthLevelOverride == "" { + level = rentergrowth.LevelForPoints(cfg, 0) + } + return rentergrowth.CanViewPremiumLevel(level.Code), nil +} + func (r *Repository) applyShelfMarkup(ctx context.Context, siteCode string, items []ListingDTO) error { if siteCode == "" || len(items) == 0 { return nil diff --git a/backend/internal/modules/listing/repository.go b/backend/internal/modules/listing/repository.go index 05def5b1..e2ff00dc 100644 --- a/backend/internal/modules/listing/repository.go +++ b/backend/internal/modules/listing/repository.go @@ -73,8 +73,9 @@ func (r *Repository) IsQualityOwner(ctx context.Context, userID uint64) (bool, e } type publicZoneCountCache struct { - Counts map[string]int64 - ExpiresAt time.Time + Counts map[string]int64 + NonPremiumTotal int64 + ExpiresAt time.Time } const publicZoneCountCacheTTL = 5 * time.Second diff --git a/backend/internal/modules/listing/service_query.go b/backend/internal/modules/listing/service_query.go index c49d09ca..323e3556 100644 --- a/backend/internal/modules/listing/service_query.go +++ b/backend/internal/modules/listing/service_query.go @@ -18,11 +18,11 @@ func (s *Service) ListMine(ctx context.Context, ownerID uint64, siteCode ...stri return s.repo.ListMine(ctx, ownerID, siteCode...) } -func (s *Service) FindPublic(ctx context.Context, id uint64, siteCode string) (*ListingDTO, error) { +func (s *Service) FindPublic(ctx context.Context, id uint64, siteCode string, viewerUserID uint64) (*ListingDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } - return s.repo.FindPublic(ctx, id, siteCode) + return s.repo.FindPublic(ctx, id, siteCode, viewerUserID) } func (s *Service) FindPublicCoverKey(ctx context.Context, id uint64) (string, error) { diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go index d703b019..ec9d6d47 100644 --- a/backend/internal/modules/listing/service_test.go +++ b/backend/internal/modules/listing/service_test.go @@ -719,14 +719,14 @@ func TestListPublicWithDistributionSiteCode(t *testing.T) { t.Fatalf("expected public price 12500 with ratio delta, got %d", res.Items[0].PriceCent) } - detail, err := repo.FindPublic(context.Background(), l1.ID, "site_alpha") + detail, err := repo.FindPublic(context.Background(), l1.ID, "site_alpha", 0) if err != nil { t.Fatalf("FindPublic err: %v", err) } if detail.PriceCent != 12500 { t.Fatalf("expected detail price 12500 with ratio delta, got %d", detail.PriceCent) } - mainDetail, err := repo.FindPublic(context.Background(), l1.ID, "") + mainDetail, err := repo.FindPublic(context.Background(), l1.ID, "", 0) if err != nil { t.Fatalf("FindPublic main err: %v", err) } diff --git a/backend/internal/modules/order/handler_error.go b/backend/internal/modules/order/handler_error.go index 0418caab..fec7e6d0 100644 --- a/backend/internal/modules/order/handler_error.go +++ b/backend/internal/modules/order/handler_error.go @@ -22,6 +22,8 @@ func writeOrderError(c *gin.Context, err error) { response.Error(c, http.StatusConflict, "listing_unavailable", "该账号暂不可租") case errors.Is(err, ErrCannotRentOwnListing): response.BadRequest(c, "不能租用自己发布的账号") + case errors.Is(err, ErrPremiumLevelRequired): + response.Error(c, http.StatusForbidden, "platinum_level_required", "成长等级达到铂金及以上才能租用极品专区账号") case errors.Is(err, ErrInsufficientBalance): response.Error(c, http.StatusConflict, "insufficient_balance", "钱包余额不足,请先充值") case errors.Is(err, ErrChannelPaymentRequired): diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go index 9606e8ab..8118282b 100644 --- a/backend/internal/modules/order/lifecycle.go +++ b/backend/internal/modules/order/lifecycle.go @@ -103,6 +103,9 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ DiscountBps: rentergrowth.DefaultDiscountBps, } } + if listing.IsPremium && !rentergrowth.CanViewPremiumLevel(growthSnapshot.LevelCode) { + return ErrPremiumLevelRequired + } rentDiscountAmountCent := rentergrowth.CalculateDiscountCent(pricing.PureCoinAmountCent, pricing.PureCoinPlatformFeeCent, growthSnapshot.DiscountBps) if rentDiscountAmountCent > 0 { pricing.PlatformFeeCent -= rentDiscountAmountCent diff --git a/backend/internal/modules/order/service.go b/backend/internal/modules/order/service.go index f90d8446..f6f81bee 100644 --- a/backend/internal/modules/order/service.go +++ b/backend/internal/modules/order/service.go @@ -10,6 +10,7 @@ var ( ErrInvalidRentHours = errors.New("invalid rent hours") ErrListingUnavailable = errors.New("listing unavailable") ErrCannotRentOwnListing = errors.New("cannot rent own listing") + ErrPremiumLevelRequired = errors.New("platinum level required for premium listing") ErrInsufficientBalance = errors.New("insufficient balance") ErrOrderCannotPay = errors.New("order cannot pay") ErrChannelPaymentRequired = errors.New("channel payment required") diff --git a/backend/internal/modules/pointsmall/repository.go b/backend/internal/modules/pointsmall/repository.go index 1b025583..ce45ec3e 100644 --- a/backend/internal/modules/pointsmall/repository.go +++ b/backend/internal/modules/pointsmall/repository.go @@ -34,7 +34,11 @@ func (r *Repository) Summary(ctx context.Context, userID uint64) (*SummaryDTO, e if err != nil { return nil, err } - level := rentergrowth.LevelForPoints(cfg, user.RenterGrowthPoints) + level := rentergrowth.EffectiveLevelForUser( + cfg, + user.RenterGrowthPoints, + user.RenterGrowthLevelOverride, + ) levels := make([]GrowthLevelDTO, 0, len(cfg.Levels)) for _, item := range cfg.Levels { levels = append(levels, GrowthLevelDTO{ diff --git a/backend/internal/modules/rentergrowth/growth.go b/backend/internal/modules/rentergrowth/growth.go index 1d116fdd..39d04c9e 100644 --- a/backend/internal/modules/rentergrowth/growth.go +++ b/backend/internal/modules/rentergrowth/growth.go @@ -221,10 +221,10 @@ func SnapshotForUser(tx *gorm.DB, userID uint64) (DiscountSnapshot, error) { snapshot.UserPoints = user.RenterGrowthPoints snapshot.ManualDepositFreeQuotaCent = manualQuotaCent snapshot.EffectiveDepositFreeQuotaCent = manualQuotaCent - if !cfg.Enabled { + if !cfg.Enabled && strings.TrimSpace(user.RenterGrowthLevelOverride) == "" { return snapshot, nil } - level := LevelForPoints(cfg, user.RenterGrowthPoints) + level := EffectiveLevelForUser(cfg, user.RenterGrowthPoints, user.RenterGrowthLevelOverride) return DiscountSnapshot{ LevelCode: level.Code, LevelName: level.Name, @@ -307,7 +307,7 @@ func awardOrderSide(tx *gorm.DB, order *model.RentalOrder, cfg Config, role stri } beforePoints := user.RenterGrowthPoints beforeAvailable := user.PointsAvailable - beforeLevel := LevelForPoints(cfg, beforePoints) + beforeLevel := EffectiveLevelForUser(cfg, beforePoints, user.RenterGrowthLevelOverride) multiplierBps := beforeLevel.PointsMultiplierBps points := PointsForAmount(basisCent, cfg.PointsPerYuan, multiplierBps) if points <= 0 { @@ -319,7 +319,7 @@ func awardOrderSide(tx *gorm.DB, order *model.RentalOrder, cfg Config, role stri return nil } afterPoints := beforePoints + points - afterLevel := LevelForPoints(cfg, afterPoints) + afterLevel := LevelForUserAfterPoints(cfg, afterPoints, user.RenterGrowthLevelOverride) user.RenterGrowthPoints = afterPoints user.RenterGrowthLevel = afterLevel.Code user.PointsAvailable = beforeAvailable + points @@ -446,10 +446,10 @@ func revokeOrderSide(tx *gorm.DB, order *model.RentalOrder, cfg Config, role str beforeAvailable := maxInt64(user.PointsAvailable, 0) revokedPoints := minInt64(points, beforePoints) revokedAvailable := minInt64(points, beforeAvailable) - beforeLevel := LevelForPoints(cfg, beforePoints) + beforeLevel := EffectiveLevelForUser(cfg, beforePoints, user.RenterGrowthLevelOverride) afterPoints := beforePoints - revokedPoints - afterLevel := LevelForPoints(cfg, afterPoints) - if !cfg.Enabled { + afterLevel := LevelForUserAfterPoints(cfg, afterPoints, user.RenterGrowthLevelOverride) + if !cfg.Enabled && user.RenterGrowthLevelOverride == "" { beforeLevel = LevelRule{Code: DefaultLevelCode, Name: DefaultLevelName} afterLevel = beforeLevel } @@ -516,6 +516,43 @@ func LevelForPoints(cfg Config, points int64) LevelRule { return current } +func LevelForCode(cfg Config, code string) (LevelRule, bool) { + cfg = normalizeConfig(cfg) + code = strings.TrimSpace(code) + if code == "" { + return LevelRule{}, false + } + for _, level := range cfg.Levels { + if level.Code == code { + return level, true + } + } + return LevelRule{}, false +} + +func EffectiveLevelForUser(cfg Config, points int64, override string) LevelRule { + if level, ok := LevelForCode(cfg, override); ok { + return level + } + return LevelForPoints(cfg, points) +} + +func LevelForUserAfterPoints(cfg Config, points int64, override string) LevelRule { + if level, ok := LevelForCode(cfg, override); ok { + return level + } + return LevelForPoints(cfg, points) +} + +func CanViewPremiumLevel(code string) bool { + switch strings.TrimSpace(code) { + case "platinum", "diamond", "peak": + return true + default: + return false + } +} + func EffectiveDepositFreeQuotaCent(levelQuotaCent int64, manualQuotaCent int64) int64 { return addQuotaCent(levelQuotaCent, manualQuotaCent) } @@ -526,7 +563,14 @@ func SyncAllUserLevels(tx *gorm.DB, cfg Config) error { return nil } if !cfg.Enabled { - return tx.Model(&model.User{}).Where("renter_growth_level <> ?", DefaultLevelCode).Update("renter_growth_level", DefaultLevelCode).Error + if err := tx.Model(&model.User{}). + Where("renter_growth_level_override = ? AND renter_growth_level <> ?", "", DefaultLevelCode). + Update("renter_growth_level", DefaultLevelCode).Error; err != nil { + return err + } + return tx.Model(&model.User{}). + Where("renter_growth_level_override <> ?", ""). + UpdateColumn("renter_growth_level", gorm.Expr("renter_growth_level_override")).Error } cfg = normalizeConfig(cfg) caseSQL := "CASE" @@ -537,7 +581,14 @@ func SyncAllUserLevels(tx *gorm.DB, cfg Config) error { } caseSQL += " ELSE ? END" args = append(args, DefaultLevelCode) - return tx.Model(&model.User{}).Where("1 = 1").Update("renter_growth_level", gorm.Expr(caseSQL, args...)).Error + if err := tx.Model(&model.User{}). + Where("renter_growth_level_override = ?", ""). + Update("renter_growth_level", gorm.Expr(caseSQL, args...)).Error; err != nil { + return err + } + return tx.Model(&model.User{}). + Where("renter_growth_level_override <> ?", ""). + UpdateColumn("renter_growth_level", gorm.Expr("renter_growth_level_override")).Error } func normalizeConfig(cfg Config) Config { diff --git a/backend/internal/modules/rentergrowth/growth_test.go b/backend/internal/modules/rentergrowth/growth_test.go index ec5062ae..fbf88541 100644 --- a/backend/internal/modules/rentergrowth/growth_test.go +++ b/backend/internal/modules/rentergrowth/growth_test.go @@ -128,6 +128,48 @@ func TestLevelForPointsStartsFromNormal(t *testing.T) { } } +func TestEffectiveLevelForUserHonorsManualOverride(t *testing.T) { + cfg := DefaultConfig() + if got := EffectiveLevelForUser(cfg, 0, "diamond"); got.Code != "diamond" { + t.Fatalf("手动等级 = %s, want diamond", got.Code) + } + if got := EffectiveLevelForUser(cfg, 5000, ""); got.Code != "peak" { + t.Fatalf("自动等级 = %s, want peak", got.Code) + } + if !CanViewPremiumLevel("platinum") || !CanViewPremiumLevel("diamond") || !CanViewPremiumLevel("peak") { + t.Fatal("铂金及以上等级应可查看极品专区") + } + if CanViewPremiumLevel(DefaultLevelCode) { + t.Fatal("普通等级不应查看极品专区") + } +} + +func TestSyncAllUserLevelsPreservesManualOverride(t *testing.T) { + db := database.NewTestDB() + if err := database.AutoMigrateIfMissing(db, &model.User{}); err != nil { + t.Fatalf("migrate failed: %v", err) + } + user := model.User{ + Phone: "13800009003", + RenterGrowthPoints: 0, + RenterGrowthLevel: "platinum", + RenterGrowthLevelOverride: "platinum", + } + if err := db.Create(&user).Error; err != nil { + t.Fatalf("create user failed: %v", err) + } + if err := SyncAllUserLevels(db, DefaultConfig()); err != nil { + t.Fatalf("SyncAllUserLevels() error = %v", err) + } + var saved model.User + if err := db.First(&saved, user.ID).Error; err != nil { + t.Fatalf("find user failed: %v", err) + } + if saved.RenterGrowthLevel != "platinum" || saved.RenterGrowthLevelOverride != "platinum" { + t.Fatalf("手动等级被同步覆盖:%+v", saved) + } +} + func TestCalculateDiscountCentCapsAtPlatformFee(t *testing.T) { if got := CalculateDiscountCent(10000, 60, 9900); got != 60 { t.Fatalf("discount = %d, want capped platform fee 60", got) diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 0288b86d..b0535fef 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -427,6 +427,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { return err } requireAuth := middleware.Auth(jwtManager, validateUserToken) + optionalAuth := middleware.OptionalAuth(jwtManager, validateUserToken) var validateAdminToken middleware.AdminTokenValidatorFunc if adminAuthRepo != nil { validateAdminToken = func(ctx context.Context, adminID uint64, tokenVersion int64) (middleware.AdminTokenContext, error) { @@ -527,11 +528,11 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { listingRoutes := api.Group("/listings") { - listingRoutes.GET("", listingHandler.ListPublic) + listingRoutes.GET("", optionalAuth, listingHandler.ListPublic) listingRoutes.GET("/default-upload-screenshot", listingHandler.DefaultUploadScreenshot) listingRoutes.GET("/:id/cover", listingHandler.Cover) listingRoutes.GET("/:id/screenshots/:index", listingHandler.Screenshot) - listingRoutes.GET("/:id", listingHandler.FindPublic) + listingRoutes.GET("/:id", optionalAuth, listingHandler.FindPublic) listingRoutes.POST("", requireAuth, requireRealname, listingHandler.Create) listingRoutes.PUT("/:id", requireAuth, requireRealname, listingHandler.Update) listingRoutes.POST("/:id/submit-review", requireAuth, requireRealname, listingHandler.SubmitReview) @@ -801,6 +802,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/users/:id/unfreeze", requirePerm("user:unfreeze"), adminUserHandler.Unfreeze) adminRoutes.POST("/users/:id/deposit-free-quota", requirePerm("user:deposit_free"), adminUserHandler.SetDepositFreeQuota) adminRoutes.POST("/users/:id/growth-points", requirePerm("user:growth_points"), adminUserHandler.AdjustGrowthPoints) + adminRoutes.POST("/users/:id/growth-level", requirePerm("user:growth_level"), adminUserHandler.AdjustGrowthLevel) adminRoutes.POST("/users/:id/manual-realname", requirePerm("user:manual_realname"), adminUserHandler.ManualRealname) adminRoutes.POST("/users/:id/revoke-realname", requirePerm("user:revoke_realname"), adminUserHandler.RevokeRealname) adminRoutes.POST("/users/:id/wallet/adjust", requirePerm("user:wallet_adjust"), adminUserHandler.AdjustWallet) diff --git a/backend/migrations/000095_manual_renter_growth_level.sql b/backend/migrations/000095_manual_renter_growth_level.sql new file mode 100644 index 00000000..fc595346 --- /dev/null +++ b/backend/migrations/000095_manual_renter_growth_level.sql @@ -0,0 +1,26 @@ +-- +goose Up + +ALTER TABLE users + ADD COLUMN renter_growth_level_override VARCHAR(32) NOT NULL DEFAULT '' COMMENT '后台手动锁定成长等级,空值表示按积分自动计算' AFTER renter_growth_level; + +INSERT INTO permissions (code, name, resource, action) VALUES +('user:growth_level', '调整成长等级', 'user', 'growth_level') +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_level'; + +-- +goose Down + +DELETE rp FROM role_permissions rp +JOIN permissions p ON p.id = rp.permission_id +WHERE p.code = 'user:growth_level'; + +DELETE FROM permissions WHERE code = 'user:growth_level'; + +ALTER TABLE users + DROP COLUMN renter_growth_level_override; diff --git a/frontend/src/features/admin/api/adminUsers.ts b/frontend/src/features/admin/api/adminUsers.ts index efd16ab4..334d29b8 100644 --- a/frontend/src/features/admin/api/adminUsers.ts +++ b/frontend/src/features/admin/api/adminUsers.ts @@ -20,6 +20,8 @@ export interface AdminUserItem { points_available: number renter_growth_level: string renter_growth_level_name: string + renter_growth_level_override: string + renter_growth_level_manual: boolean available_balance_cent: number frozen_balance_cent: number quality_owner_status: 'none' | 'active' | string @@ -47,6 +49,11 @@ export interface AdminGrowthPointsAdjustPayload { reason: string } +export interface AdminGrowthLevelAdjustPayload { + target_level: string + reason: string +} + export interface AdminUserQuery { keyword?: string status?: '' | UserStatus @@ -100,6 +107,17 @@ export async function adjustAdminUserGrowthPoints( return data.data } +export async function adjustAdminUserGrowthLevel( + id: number, + payload: AdminGrowthLevelAdjustPayload +) { + const { data } = await apiClient.post>( + `/admin/users/${id}/growth-level`, + payload + ) + return data.data +} + export async function revokeAdminUserRealname(id: number, reason: string) { const { data } = await apiClient.post>( `/admin/users/${id}/revoke-realname`, diff --git a/frontend/src/features/admin/components/AdminUserDetailDrawer.css b/frontend/src/features/admin/components/AdminUserDetailDrawer.css index 774c484c..204aeac4 100644 --- a/frontend/src/features/admin/components/AdminUserDetailDrawer.css +++ b/frontend/src/features/admin/components/AdminUserDetailDrawer.css @@ -59,6 +59,18 @@ font-variant-numeric: tabular-nums; } +.user-detail__metrics strong em { + display: inline-flex; + margin-left: 5px; + padding: 1px 6px; + border-radius: 8px; + background: #fff7ed; + color: #c2410c; + font-size: 10px; + font-style: normal; + vertical-align: middle; +} + .tab-toolbar { display: flex; align-items: center; diff --git a/frontend/src/features/admin/components/AdminUserDetailDrawer.vue b/frontend/src/features/admin/components/AdminUserDetailDrawer.vue index 941cb39d..61f25ac3 100644 --- a/frontend/src/features/admin/components/AdminUserDetailDrawer.vue +++ b/frontend/src/features/admin/components/AdminUserDetailDrawer.vue @@ -464,7 +464,10 @@ function goPayments() {
成长等级 - {{ user.renter_growth_level_name || '普通' }} + + {{ user.renter_growth_level_name || '普通' }} + 手动 +
注册时间 diff --git a/frontend/src/features/admin/views/AdminUsersView.css b/frontend/src/features/admin/views/AdminUsersView.css index c51733ec..a3c0c565 100644 --- a/frontend/src/features/admin/views/AdminUsersView.css +++ b/frontend/src/features/admin/views/AdminUsersView.css @@ -111,6 +111,17 @@ font-variant-numeric: tabular-nums; } +.growth-level-cell { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 5px; +} + +.level-reason-item { + margin-top: 16px; +} + .wallet-balance-cards { display: grid; grid-template-columns: 1fr 1fr; diff --git a/frontend/src/features/admin/views/AdminUsersView.vue b/frontend/src/features/admin/views/AdminUsersView.vue index f8c5a6f3..b9ff5e2d 100644 --- a/frontend/src/features/admin/views/AdminUsersView.vue +++ b/frontend/src/features/admin/views/AdminUsersView.vue @@ -5,6 +5,7 @@ import { Search } from '@element-plus/icons-vue' import { computed, reactive, ref } from 'vue' import { + adjustAdminUserGrowthLevel, adjustAdminUserGrowthPoints, adjustAdminUserWallet, fetchAdminUsers, @@ -17,6 +18,7 @@ import { type AdminUserItem, type AdminUserQuery, } from '@/features/admin/api/adminUsers' +import { defaultRenterGrowthConfig } from '@/features/admin/api/renterGrowth' import { centToYuan, formatCent, yuanToCent } from '@/shared/utils/money' import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable' import { realnameStatusLabel, userStatusLabel } from '@/shared/utils/statusLabels' @@ -28,6 +30,7 @@ import AdminUserDetailDrawer from '../components/AdminUserDetailDrawer.vue' const adminSession = useAdminSessionStore() const canAdjustWallet = computed(() => adminSession.hasPermission('user:wallet_adjust')) const canAdjustGrowthPoints = computed(() => adminSession.hasPermission('user:growth_points')) +const canAdjustGrowthLevel = computed(() => adminSession.hasPermission('user:growth_level')) const canSetQualityOwner = computed(() => adminSession.hasPermission('user:quality_owner')) const canViewUserDetail = computed( () => @@ -44,6 +47,7 @@ const realnameUser = ref(null) const manualRealnameUser = ref(null) const walletUser = ref(null) const growthUser = ref(null) +const growthLevelUser = ref(null) const freezeReason = ref('') const revokeRealnameReason = ref('') const quotaAmount = ref(0) @@ -51,6 +55,11 @@ const growthForm = reactive({ targetPoints: 0, reason: '', }) +const growthLevelForm = reactive({ + targetLevel: '', + reason: '', +}) +const growthLevelOptions = defaultRenterGrowthConfig.levels const manualRealnameForm = reactive({ name: '', idNo: '', @@ -114,6 +123,12 @@ function openGrowthPoints(row: AdminUserItem) { growthForm.reason = '' } +function openGrowthLevel(row: AdminUserItem) { + growthLevelUser.value = row + growthLevelForm.targetLevel = row.renter_growth_level_override || '' + growthLevelForm.reason = '' +} + function openWalletAdjust(row: AdminUserItem) { walletUser.value = row walletForm.direction = 'out' @@ -182,6 +197,49 @@ async function handleAdjustGrowthPoints() { } } +async function handleAdjustGrowthLevel() { + if (!growthLevelUser.value) return + const targetLevel = growthLevelForm.targetLevel.trim() + if (targetLevel === (growthLevelUser.value.renter_growth_level_override || '')) { + ElMessage.warning('目标等级与当前手动等级相同') + return + } + if (growthLevelForm.reason.trim().length < 2) { + ElMessage.warning('请填写至少 2 个字的调整原因') + return + } + const targetName = targetLevel + ? growthLevelOptions.find(level => level.code === targetLevel)?.name || targetLevel + : '按积分自动计算' + try { + await ElMessageBox.confirm( + `确认将用户 ${growthLevelUser.value.phone || growthLevelUser.value.id} 的成长等级调整为「${targetName}」?\n手动设置后会锁定等级,后续积分变动不会自动覆盖。`, + '确认调整等级', + { + type: 'warning', + confirmButtonText: '确认调整', + cancelButtonText: '取消', + } + ) + } catch { + return + } + submitting.value = true + try { + await adjustAdminUserGrowthLevel(growthLevelUser.value.id, { + target_level: targetLevel, + reason: growthLevelForm.reason.trim(), + }) + ElMessage.success('成长等级已更新') + growthLevelUser.value = null + await loadUsers() + } catch (error) { + ElMessage.error(readError(error, '调整成长等级失败')) + } finally { + submitting.value = false + } +} + async function handleFreeze() { if (!activeUser.value) return submitting.value = true @@ -435,9 +493,19 @@ function moneyCent(value: number | string | undefined) { @@ -475,7 +543,7 @@ function moneyCent(value: number | string | undefined) { - + + +
+

+ {{ growthLevelUser.phone }} · {{ growthLevelUser.nickname || '-' }} · + 当前 + {{ growthLevelUser.renter_growth_level_name }} + {{ growthLevelUser.renter_growth_level_manual ? '(手动锁定)' : '(自动计算)' }} +

+
+ 当前成长积分 + {{ growthLevelUser.renter_growth_points }} +
+ + + + + + + + + + + + +
+ +
+ canViewPremiumZone(session.renterGrowthLevel)) const announcements = ref(defaultHomeAnnouncements) const displayAnnouncements = computed(() => { @@ -164,6 +168,14 @@ const activeZone = ref('all') const route = useRoute() const router = useRouter() +watch(canViewPremium, allowed => { + if (!allowed && activeZone.value === 'premium') { + activeZone.value = 'all' + } + pcHomeCache.clearList() + void loadListingsPage(true) +}) + const { filters, regionOptions, @@ -191,6 +203,9 @@ const { restoreSnapshot, getSnapshot, } = useListingQuery(filters, sortBy, activeZone) +const visibleListings = computed(() => + canViewPremium.value ? listings.value : listings.value.filter(item => !item.is_premium) +) watch( () => route.query, @@ -206,69 +221,78 @@ watch( } ) -const statCards = computed(() => [ - { - label: '优质号主精选', - value: `${zoneCount('premium')}`, - hint: '认证优质号主 · 极品专区', - }, - { label: '可租账号', value: `${totalListings.value}`, hint: '当前筛选结果' }, - { - label: '高哈夫币', - value: `${zoneCount('highCoin')}`, - hint: '100M 以上', - }, - { - label: '账密登录', - value: `${zoneCount('password')}`, - hint: '交接更快', - }, -]) +const statCards = computed(() => { + const cards = [ + { + key: 'premium', + label: '优质号主精选', + value: `${zoneCount('premium')}`, + hint: '认证优质号主 · 极品专区', + }, + { key: 'all', label: '可租账号', value: `${totalListings.value}`, hint: '当前筛选结果' }, + { + key: 'highCoin', + label: '高哈夫币', + value: `${zoneCount('highCoin')}`, + hint: '100M 以上', + }, + { + key: 'password', + label: '账密登录', + value: `${zoneCount('password')}`, + hint: '交接更快', + }, + ] + return canViewPremium.value ? cards : cards.filter(card => card.key !== 'premium') +}) -const zoneOptions = computed(() => [ - { - key: 'all', - label: '全部专区', - hint: '当前可租账号', - count: zoneCount('all'), - }, - { - key: 'premium', - label: '极品专区', - hint: '优质号主精选', - count: zoneCount('premium'), - }, - { - key: 'sale', - label: '特惠专区', - hint: '价格更划算', - count: zoneCount('sale'), - }, - { - key: 'gift', - label: '赠送专区', - hint: '含赠送物品', - count: zoneCount('gift'), - }, - { - key: 'night', - label: '夜间专区', - hint: '0-2点可响应上号', - count: zoneCount('night'), - }, - { - key: 'password', - label: '账密专区', - hint: '交接更快', - count: zoneCount('password'), - }, - { - key: 'highCoin', - label: '高币专区', - hint: '100M 以上', - count: zoneCount('highCoin'), - }, -]) +const zoneOptions = computed(() => { + const zones = [ + { + key: 'all', + label: '全部专区', + hint: '当前可租账号', + count: zoneCount('all'), + }, + { + key: 'premium', + label: '极品专区', + hint: '优质号主精选', + count: zoneCount('premium'), + }, + { + key: 'sale', + label: '特惠专区', + hint: '价格更划算', + count: zoneCount('sale'), + }, + { + key: 'gift', + label: '赠送专区', + hint: '含赠送物品', + count: zoneCount('gift'), + }, + { + key: 'night', + label: '夜间专区', + hint: '0-2点可响应上号', + count: zoneCount('night'), + }, + { + key: 'password', + label: '账密专区', + hint: '交接更快', + count: zoneCount('password'), + }, + { + key: 'highCoin', + label: '高币专区', + hint: '100M 以上', + count: zoneCount('highCoin'), + }, + ] + return canViewPremium.value ? zones : zones.filter(zone => zone.key !== 'premium') +}) async function loadHome() { loading.value = true @@ -360,7 +384,10 @@ function applyRouteQueryToHomeState() { } function desktopHomeStateSignature() { - return JSON.stringify(buildDesktopHomeQueryValues()) + return JSON.stringify({ + filters: buildDesktopHomeQueryValues(), + premiumAccess: canViewPremium.value, + }) } function syncHomeQuery() { @@ -652,7 +679,7 @@ if (!restored) { -
+
@@ -711,16 +738,16 @@ if (!restored) {
-
+
没有符合条件的账号 可以放宽筛选条件或切回全部专区。
- +
-
+
正在加载更多账号... 已经到底了
diff --git a/frontend/src/features/listings/views/MobileHomeView.vue b/frontend/src/features/listings/views/MobileHomeView.vue index c3d191cd..7f43e3b1 100644 --- a/frontend/src/features/listings/views/MobileHomeView.vue +++ b/frontend/src/features/listings/views/MobileHomeView.vue @@ -72,10 +72,12 @@ import { import { buildMobileHomeListSignature, useMobileHomeCacheStore } from '@/stores/mobileHomeCache' import { mapDistributionHomeBanners, useDistributionSiteStore } from '@/stores/distributionSite' import { useSessionStore } from '@/stores/session' +import { canViewPremiumZone } from '@/shared/utils/growthLevel' const router = useRouter() const route = useRoute() const session = useSessionStore() +const canViewPremium = computed(() => canViewPremiumZone(session.renterGrowthLevel)) const siteStore = useDistributionSiteStore() const homeCache = useMobileHomeCacheStore() const { run: runBannerAction } = useBannerAction() @@ -167,7 +169,7 @@ const sortOptions: MobileSortOption[] = [ { key: 'published', label: '最新发布', shortLabel: '最新发布' }, ] -const zoneOptions = [ +const allZoneOptions = [ { key: 'all', label: '全部', hint: '当前可租' }, { key: 'premium', label: '极品', hint: '优质号主精选' }, { key: 'sale', label: '特惠', hint: '价格更划算' }, @@ -176,6 +178,9 @@ const zoneOptions = [ { key: 'password', label: '账密', hint: '交接更快' }, { key: 'highCoin', label: '高币', hint: '100M以上' }, ] +const zoneOptions = computed(() => + canViewPremium.value ? allZoneOptions : allZoneOptions.filter(zone => zone.key !== 'premium') +) /** 统一使用领域层导出的预设区间,保证与桌面端完全一致 */ const rangePresets = defaultRangePresets @@ -321,18 +326,32 @@ const activeFilterCount = computed(() => { }) const displayListings = computed(() => { - return listings.value + return canViewPremium.value ? listings.value : listings.value.filter(item => !item.is_premium) }) const visibleZoneOptions = computed(() => - zoneOptions.map(zone => ({ - ...zone, - count: zoneCount(zone.key), - })) + zoneOptions.value.map(zone => { + const count = + !canViewPremium.value && zone.key === 'all' + ? Math.max(Number(zoneCounts.value.all || 0) - Number(zoneCounts.value.premium || 0), 0) + : zoneCount(zone.key) + return { + ...zone, + count, + } + }) ) applyRouteQueryToMobileState() +watch(canViewPremium, allowed => { + homeCache.clearList() + if (!allowed && activeZone.value === 'premium') { + activeZone.value = 'all' + } + void loadListings(true) +}) + function currentListSignature() { return buildMobileHomeListSignature({ searchValue: searchValue.value, @@ -340,6 +359,7 @@ function currentListSignature() { activeZone: activeZone.value, selectedFilters: selectedFilters.value, rangeFilters: rangeFilters.value, + premiumAccess: canViewPremium.value, }) } @@ -675,7 +695,8 @@ function applyRouteQueryToMobileState() { : storedQuery const nextSearch = readQueryString(query, 'keyword') const nextSort = readQueryString(query, 'sort') || 'recommended' - const nextZone = readQueryString(query, 'zone') || 'all' + const requestedZone = readQueryString(query, 'zone') || 'all' + const nextZone = requestedZone === 'premium' && !canViewPremium.value ? 'all' : requestedZone const nextFilters = normalizeSelectedFilters( decodeHomeQueryJson>(query, 'filters', {}) ) @@ -1008,6 +1029,7 @@ if (!restoreHomeConfigFromCache()) {