feat(user): 支持后台调整等级并限制极品专区可见性
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -168,6 +168,8 @@ type PublicListQuery struct {
|
||||
MaxSecretKD *float64
|
||||
ResourceRanges map[string]NumberRange
|
||||
DistributionSiteCode string
|
||||
ViewerUserID uint64
|
||||
CanViewPremium bool
|
||||
}
|
||||
|
||||
type NumberRange struct {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -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<ApiResponse<AdminUserItem>>(
|
||||
`/admin/users/${id}/growth-level`,
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function revokeAdminUserRealname(id: number, reason: string) {
|
||||
const { data } = await apiClient.post<ApiResponse<AdminUserItem>>(
|
||||
`/admin/users/${id}/revoke-realname`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -464,7 +464,10 @@ function goPayments() {
|
||||
</div>
|
||||
<div>
|
||||
<span>成长等级</span>
|
||||
<strong>{{ user.renter_growth_level_name || '普通' }}</strong>
|
||||
<strong>
|
||||
{{ user.renter_growth_level_name || '普通' }}
|
||||
<em v-if="user.renter_growth_level_manual">手动</em>
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>注册时间</span>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AdminUserItem | null>(null)
|
||||
const manualRealnameUser = ref<AdminUserItem | null>(null)
|
||||
const walletUser = ref<AdminUserItem | null>(null)
|
||||
const growthUser = ref<AdminUserItem | null>(null)
|
||||
const growthLevelUser = ref<AdminUserItem | null>(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) {
|
||||
</el-table-column>
|
||||
<el-table-column label="成长等级" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="success" effect="light">{{
|
||||
row.renter_growth_level_name || '普通'
|
||||
}}</el-tag>
|
||||
<div class="growth-level-cell">
|
||||
<el-tag type="success" effect="light">{{
|
||||
row.renter_growth_level_name || '普通'
|
||||
}}</el-tag>
|
||||
<el-tag
|
||||
v-if="row.renter_growth_level_manual"
|
||||
type="warning"
|
||||
effect="plain"
|
||||
size="small"
|
||||
>
|
||||
手动
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="成长积分" width="100" align="right">
|
||||
@@ -475,7 +543,7 @@ function moneyCent(value: number | string | undefined) {
|
||||
<el-table-column label="注册时间" min-width="160">
|
||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="470" fixed="right" align="right">
|
||||
<el-table-column label="操作" width="520" fixed="right" align="right">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button
|
||||
@@ -505,6 +573,15 @@ function moneyCent(value: number | string | undefined) {
|
||||
>
|
||||
积分
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canAdjustGrowthLevel"
|
||||
text
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="openGrowthLevel(row)"
|
||||
>
|
||||
等级
|
||||
</el-button>
|
||||
<el-button text type="primary" size="small" @click="openDepositQuota(row)">
|
||||
人工免押
|
||||
</el-button>
|
||||
@@ -752,6 +829,60 @@ function moneyCent(value: number | string | undefined) {
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
:model-value="!!growthLevelUser"
|
||||
title="调整成长等级"
|
||||
width="540px"
|
||||
@update:model-value="growthLevelUser = null"
|
||||
>
|
||||
<div v-if="growthLevelUser" class="dialog-body growth-dialog">
|
||||
<p>
|
||||
<strong>{{ growthLevelUser.phone }}</strong> · {{ growthLevelUser.nickname || '-' }} ·
|
||||
当前
|
||||
{{ growthLevelUser.renter_growth_level_name }}
|
||||
{{ growthLevelUser.renter_growth_level_manual ? '(手动锁定)' : '(自动计算)' }}
|
||||
</p>
|
||||
<div class="growth-current">
|
||||
<span>当前成长积分</span>
|
||||
<strong>{{ growthLevelUser.renter_growth_points }}</strong>
|
||||
</div>
|
||||
<el-form label-position="top" @submit.prevent>
|
||||
<el-form-item label="目标等级">
|
||||
<el-select v-model="growthLevelForm.targetLevel" class="full-control">
|
||||
<el-option label="恢复按积分自动计算" value="" />
|
||||
<el-option
|
||||
v-for="level in growthLevelOptions"
|
||||
:key="level.code"
|
||||
:label="level.name"
|
||||
:value="level.code"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
title="手动等级会锁定,后续积分变动不会自动覆盖;选择“恢复按积分自动计算”可解除锁定。操作会写入审计日志。"
|
||||
/>
|
||||
<el-form-item label="调整原因(必填)" class="level-reason-item">
|
||||
<el-input
|
||||
v-model="growthLevelForm.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="例如:特殊用户授权、客服补偿或修正异常等级"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button :disabled="submitting" @click="growthLevelUser = null">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleAdjustGrowthLevel">
|
||||
确认调整
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
:model-value="!!walletUser"
|
||||
title="余额管理"
|
||||
|
||||
@@ -45,8 +45,12 @@ import HomeFilters from '../components/HomeFilters.vue'
|
||||
import HomeZones from '../components/HomeZones.vue'
|
||||
import ListingCard from '../components/ListingCard.vue'
|
||||
import { mapDistributionHomeBanners, useDistributionSiteStore } from '@/stores/distributionSite'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { canViewPremiumZone } from '@/shared/utils/growthLevel'
|
||||
|
||||
const siteStore = useDistributionSiteStore()
|
||||
const session = useSessionStore()
|
||||
const canViewPremium = computed(() => canViewPremiumZone(session.renterGrowthLevel))
|
||||
|
||||
const announcements = ref<string[]>(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) {
|
||||
<el-empty v-else description="暂无展示图片" />
|
||||
</el-dialog>
|
||||
|
||||
<section class="premium-spotlight" aria-label="优质号主精选">
|
||||
<section v-if="canViewPremium" class="premium-spotlight" aria-label="优质号主精选">
|
||||
<div class="premium-spotlight-mark">
|
||||
<el-icon :size="24"><Trophy /></el-icon>
|
||||
</div>
|
||||
@@ -711,16 +738,16 @@ if (!restored) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && listings.length === 0" class="home-empty-state">
|
||||
<div v-if="!loading && visibleListings.length === 0" class="home-empty-state">
|
||||
<strong>没有符合条件的账号</strong>
|
||||
<span>可以放宽筛选条件或切回全部专区。</span>
|
||||
<button type="button" @click="handleResetFilters">重置条件</button>
|
||||
</div>
|
||||
<div v-else v-loading="loading" class="enhanced-desktop-list">
|
||||
<ListingCard v-for="item in listings" :key="item.id" :listing="item" />
|
||||
<ListingCard v-for="item in visibleListings" :key="item.id" :listing="item" />
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && listings.length" class="infinite-load-state">
|
||||
<div v-if="!loading && visibleListings.length" class="infinite-load-state">
|
||||
<span v-if="loadingMore">正在加载更多账号...</span>
|
||||
<span v-else-if="!hasMoreListings">已经到底了</span>
|
||||
</div>
|
||||
|
||||
@@ -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<Record<string, unknown>>(query, 'filters', {})
|
||||
)
|
||||
@@ -1008,6 +1029,7 @@ if (!restoreHomeConfigFromCache()) {
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="canViewPremium"
|
||||
type="button"
|
||||
class="mobile-premium-spotlight"
|
||||
aria-label="进入优质号主极品专区"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { canViewPremiumZone } from '../growthLevel'
|
||||
|
||||
describe('canViewPremiumZone', () => {
|
||||
it('仅铂金及以上等级可以查看极品专区', () => {
|
||||
expect(canViewPremiumZone('normal')).toBe(false)
|
||||
expect(canViewPremiumZone('')).toBe(false)
|
||||
expect(canViewPremiumZone('platinum')).toBe(true)
|
||||
expect(canViewPremiumZone('diamond')).toBe(true)
|
||||
expect(canViewPremiumZone('peak')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,7 @@ export const AUDIT_ACTION_OPTIONS: AuditOption[] = [
|
||||
{ value: 'admin_user.revoke_realname', label: '撤销实名', group: '用户', highRisk: true },
|
||||
{ value: 'admin_user.wallet_adjust', label: '调整用户余额', group: '用户', highRisk: true },
|
||||
{ value: 'admin_user.set_quality_owner', label: '设置优质号主', group: '用户', highRisk: true },
|
||||
{ value: 'admin_user.adjust_growth_level', label: '调整成长等级', group: '用户', highRisk: true },
|
||||
{ value: 'quality_owner.activate', label: '缴纳优质号主保证金', group: '用户', highRisk: true },
|
||||
{ value: 'quality_owner.cancel', label: '取消优质号主', group: '用户', highRisk: true },
|
||||
// 商品
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
const premiumZoneLevels = new Set(['platinum', 'diamond', 'peak'])
|
||||
|
||||
export function canViewPremiumZone(levelCode: string | undefined | null) {
|
||||
return premiumZoneLevels.has(String(levelCode || '').trim())
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export function buildMobileHomeListSignature(input: {
|
||||
activeZone: string
|
||||
selectedFilters: Record<string, string[]>
|
||||
rangeFilters: MobileHomeRangeFilters
|
||||
premiumAccess: boolean
|
||||
}) {
|
||||
return JSON.stringify({
|
||||
keyword: input.searchValue.trim(),
|
||||
@@ -31,6 +32,7 @@ export function buildMobileHomeListSignature(input: {
|
||||
zone: input.activeZone,
|
||||
selected: input.selectedFilters,
|
||||
ranges: input.rangeFilters,
|
||||
premiumAccess: input.premiumAccess,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user