7 Commits
62 changed files with 2151 additions and 194 deletions
+30
View File
@@ -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) {
+1
View File
@@ -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,6 +7,7 @@ import (
"strings"
"hfb_sys/backend/internal/modules/realname"
"hfb_sys/backend/internal/modules/rentergrowth"
)
var (
@@ -23,6 +24,9 @@ var (
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
+2
View File
@@ -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,
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
@@ -74,6 +74,7 @@ func (r *Repository) IsQualityOwner(ctx context.Context, userID uint64) (bool, e
type publicZoneCountCache struct {
Counts map[string]int64
NonPremiumTotal int64
ExpiresAt time.Time
}
@@ -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{
@@ -3,6 +3,7 @@ package qualityowner
import (
"encoding/json"
"errors"
"strings"
"time"
)
@@ -15,6 +16,9 @@ const (
defaultDepositCent = int64(20_000)
defaultInsuranceBps = 800
defaultPremiumRateBps = 400
defaultAgreementReadSeconds = 15
defaultAgreementTitle = "优质号主服务协议"
MaxAgreementReadSeconds = 300
)
var ErrInvalidRules = errors.New("invalid quality owner rules")
@@ -23,6 +27,9 @@ type Rules struct {
DepositAmountCent int64 `json:"deposit_amount_cent"`
InsuranceFeeBps int `json:"insurance_fee_bps"`
PremiumInsuranceFeeBps int `json:"premium_insurance_fee_bps"`
AgreementTitle string `json:"agreement_title"`
AgreementContent string `json:"agreement_content"`
AgreementReadSeconds int `json:"agreement_read_seconds"`
}
func DefaultRules() Rules {
@@ -30,6 +37,156 @@ func DefaultRules() Rules {
DepositAmountCent: defaultDepositCent,
InsuranceFeeBps: defaultInsuranceBps,
PremiumInsuranceFeeBps: defaultPremiumRateBps,
AgreementTitle: defaultAgreementTitle,
AgreementReadSeconds: defaultAgreementReadSeconds,
AgreementContent: `本协议由大锤商行平台(以下简称“平台”)与申请成为“优质号主”的用户(以下简称“号主”)共同签署,自号主勾选同意并完成信用保证金冻结之日起生效。
一、总则
1.1 为保障平台账号租赁交易的秩序与安全,平台推出「优质号主」认证计划,旨在筛选信誉良好、服务高效的号主,为其提供专属权益与曝光资源,包括将账号发布至「极品专区」并享有专项收益比例等。
1.2 号主自愿申请成为优质号主,并承诺遵守本协议全部条款。
二、定义
| 术语 | 定义 |
|---|---|
| 优质号主 | 通过平台认证、完成200元信用保证金冻结并遵守本协议的高信誉号主 |
| 《极品专区》 | 优质号主可选择发布的专属渠道,仅限大锤商行至尊SVIP用户租用;优质号主在该渠道发布账号可享有专项收益比例及专属标识 |
| 《大锤商行》 | 优质号主可选择发布的公开渠道,面向全平台注册用户租用 |
| 信用保证金(押金) | 号主为获得优质号主发布资格而授权平台冻结的200元信用保证金,仅冻结、不扣除 |
| 大锤商行至尊SVIP | 平台最高等级VIP用户,须同时满足租客免押资格方可租用《极品专区》渠道账号 |
三、成为优质号主的条件
3.1 信用保证金标准
本计划仅设一个信用保证金档位,所有申请者统一适用:
| 申请类型 | 信用保证金 | 备注 |
|---|---:|---|
| 所有申请者 | 200元 | 仅冻结,不扣除;不设其他金额档位或免押档位 |
3.2 强制前置条件
所有申请者须强制阅读《账号出租号主须知》满15秒,方可继续申请流程。
3.3 信用保证金特别约定
3.3.1 保证金性质:200元为信用保证金,仅用于信用展示及发布资格核验,不属于租赁费用、平台服务收入、违约金或处罚金。平台不扣除、不没收该保证金。
3.3.2 冻结方式:号主申请成为优质号主时,授权平台冻结200元信用保证金。冻结期间,该保证金不参与扣罚。
3.3.3 解冻条件:号主在无正在进行中的订单且无未解决的纠纷时,可随时向平台申请解冻全部信用保证金。平台审核通过后,按原支付路径或平台规则允许的方式解冻退还。
3.3.4 校验机制:每次发布账号时,系统自动核验信用保证金是否足额冻结:
- 已足额冻结 → 可正常发布;
- 未冻结或冻结不足 → 不可发布,系统弹窗提示“请先冻结200元信用保证金”。
3.3.5 违规处理与保证金:号主单方面违规的,平台有权依据本协议及平台规则采取警告、下架账号、限制发布、取消优质号主资格等措施,但信用保证金不扣除。如存在未完成订单或未解决纠纷,信用保证金可继续冻结至相关事项处理完毕。
四、优质号主的权利
成功成为优质号主后,享有以下权益:
| 序号 | 权益内容 | 具体说明 |
|---|---|---|
| 4.1 | 专项收益比例 | 优质号主将账号发布至「极品专区」的,享有专项收益比例:号主到手金额比例较普通渠道上浮1个百分点(即平台服务费比例相应下调1个百分点,具体以发布页面公示为准);该权益可与VIP权益及优惠券叠加使用 |
| 4.2 | 发布渠道选择 | 发布时可自由选择投放至「极品专区」或「大锤商行」(详见第五条) |
| 4.3 | 特殊曝光标识 | 优质号主发布的账号显示“优质号主”专属标识;发布至「极品专区」的账号同时展示“极品专区”专属标签 |
| 4.4 | 包赔手续费减半 | 购买全额包赔服务,手续费由8%降至4% |
| 4.5 | 平台兜底保障 | 非因号主明显过错导致的问题(如租客恶意损坏账号等),由平台按规则负责兜底赔偿 |
五、发布渠道规则
优质号主在每次发布账号时,必须从以下两个渠道中二选一:
5.1 渠道对比
| 对比项 | 《极品专区》 | 《大锤商行》 |
|---|---|---|
| 租用资格 | 仅限大锤商行至尊SVIP,且须具备租客免押资格 | 全平台注册用户 |
| 租客筛选标准 | 累计消费≥1万元 + 无纠纷记录 + 无封禁记录 | 无特殊限制 |
| 适用场景 | 高客单价、极致安全、专项收益比例 | 高曝光、快速出单 |
5.2 关于《极品专区》渠道
5.2.1 选择该渠道的账号,将展示“极品专区”专属标签。
5.2.2 非大锤商行至尊SVIP用户,或虽为大锤商行至尊SVIP但无租客免押资格的,不可查看及租用该渠道账号。
5.2.3 该渠道租客均为平台严选的优质租客,号主可享受“基本绝对安全”的出租体验。
5.2.4 优质号主在该渠道发布账号,可按本协议第4.1款享有专项收益比例。
5.3 关于《大锤商行》渠道
5.3.1 选择该渠道的账号,面向全体平台注册用户开放租用。
5.3.2 号主仍享有平台兜底保障权益(详见第4.5款)。
六、号主的义务与违规处理
6.1 号主的基本义务
优质号主在交易中应当:
6.1.1 按照约定时间交付账号,不得无故毁约、爽约;
6.1.2 在租客使用过程中,及时配合上号验证;
6.1.3 在需要时,积极配合完成人脸识别解除等操作;
6.1.4 不得在出租期间擅自顶号、修改密码或通过其他方式收回账号;
6.1.5 与租客保持友善沟通,不得出现辱骂、威胁等不文明行为。
6.2 违规行为与处罚
平台对违规行为采取警告、下架、限制发布、取消资格等措施;信用保证金不扣除。
| 违规行为 | 处理措施 |
|---|---|
| 顶号 | 警告、立即下架、限制发布;情节严重者取消优质号主资格 |
| 毁约不出租 | 警告、下架、限制发布;情节严重者取消优质号主资格 |
| 无法及时配合上号 | 警告、限制发布;多次违规者取消资格 |
| 无法配合解除人脸 | 警告、下架、限制发布;情节严重者取消资格 |
| 其他违反平台规则的行为 | 依据平台规则处理 |
上述处理均不扣除信用保证金;如存在未完成订单或未解决纠纷,信用保证金可继续冻结至相关事项处理完毕。
七、收益与结算
7.1 号主通过本平台获得的租赁收入,平台将按对应渠道的结算规则进行结算。
7.2 优质号主将账号发布至「极品专区」的,按本协议第4.1款享有专项收益比例;该权益可与VIP权益及优惠券叠加使用。
7.3 平台将在租客确认收货后将收益划转至号主账户余额,号主可随时申请提现。
八、免责条款
8.1 因不可抗力(包括但不限于自然灾害、战争、罢工、政府行为、网络故障、系统维护等)导致协议无法履行的,双方互不承担责任。
8.2 平台有权根据法律法规变化或业务运营需要,对本协议进行修订,修订后的协议将在平台公示。如号主继续使用优质号主服务,视为接受修订内容。
九、协议终止
9.1 号主可随时申请退出优质号主计划。满足信用保证金解冻条件的,200元信用保证金予以解冻退还,优质号主资格即时取消。
9.2 号主严重违反本协议或平台规则的,平台有权单方面取消其优质号主资格。信用保证金不扣除;如存在未完成订单或未解决纠纷,可继续冻结至相关事项处理完毕。
9.3 优质号主资格取消后,对应的权益(特殊曝光、包赔手续费减半、发布渠道选择、极品专区专项收益比例等)同步终止。
十、附则
10.1 本协议的解释权归大锤商行平台所有。
10.2 本协议自号主勾选同意并完成200元信用保证金冻结之日起生效。
10.3 如本协议与平台其他规则存在冲突,以本协议为准。
本人已详细阅读并充分理解本协议全部内容,同意遵守上述条款。`,
}
}
@@ -55,6 +212,15 @@ func ParseConfigValue(value string) (Rules, error) {
if rules.PremiumInsuranceFeeBps < 0 || rules.PremiumInsuranceFeeBps > rules.InsuranceFeeBps {
return Rules{}, ErrInvalidRules
}
rules.AgreementTitle = strings.TrimSpace(rules.AgreementTitle)
rules.AgreementContent = strings.TrimSpace(rules.AgreementContent)
if rules.AgreementTitle == "" || rules.AgreementContent == "" {
return Rules{}, ErrInvalidRules
}
if rules.AgreementReadSeconds < defaultAgreementReadSeconds ||
rules.AgreementReadSeconds > MaxAgreementReadSeconds {
return Rules{}, ErrInvalidRules
}
return rules, nil
}
@@ -79,6 +245,9 @@ type StatusDTO struct {
RequiredDepositCent int64 `json:"required_deposit_cent"`
InsuranceFeeBps int `json:"insurance_fee_bps"`
PremiumInsuranceFeeBps int `json:"premium_insurance_fee_bps"`
AgreementTitle string `json:"agreement_title"`
AgreementContent string `json:"agreement_content"`
AgreementReadSeconds int `json:"agreement_read_seconds"`
}
type AdminSetRequest struct {
@@ -10,6 +10,11 @@ func TestParseConfigValueDefaultsAndValidation(t *testing.T) {
if rules.DepositAmountCent != defaultDepositCent || rules.InsuranceFeeBps != 800 || rules.PremiumInsuranceFeeBps != 400 {
t.Fatalf("unexpected rules: %+v", rules)
}
if rules.AgreementTitle != defaultAgreementTitle ||
rules.AgreementReadSeconds != defaultAgreementReadSeconds ||
rules.AgreementContent == "" {
t.Fatalf("unexpected agreement defaults: %+v", rules)
}
if _, err := ParseConfigValue(`{"deposit_amount_cent":20000,"insurance_fee_bps":400,"premium_insurance_fee_bps":800}`); err == nil {
t.Fatal("expected premium rate above normal rate to be rejected")
@@ -17,6 +22,9 @@ func TestParseConfigValueDefaultsAndValidation(t *testing.T) {
if _, err := ParseConfigValue(`{"deposit_amount_cent":0}`); err == nil {
t.Fatal("expected zero deposit to be rejected")
}
if _, err := ParseConfigValue(`{"agreement_read_seconds":14}`); err == nil {
t.Fatal("expected agreement reading time below 15 seconds to be rejected")
}
}
func TestDefaultConfigValueCanBeParsed(t *testing.T) {
@@ -27,4 +35,7 @@ func TestDefaultConfigValueCanBeParsed(t *testing.T) {
if rules.DepositAmountCent != 20000 || rules.InsuranceFeeBps != 800 || rules.PremiumInsuranceFeeBps != 400 {
t.Fatalf("unexpected default rules: %+v", rules)
}
if rules.AgreementReadSeconds != 15 || rules.AgreementTitle == "" || rules.AgreementContent == "" {
t.Fatalf("unexpected default agreement: %+v", rules)
}
}
@@ -468,5 +468,8 @@ func toStatusDTO(user model.User, account model.WalletAccount, rules Rules, acti
RequiredDepositCent: rules.DepositAmountCent,
InsuranceFeeBps: rules.InsuranceFeeBps,
PremiumInsuranceFeeBps: rules.PremiumInsuranceFeeBps,
AgreementTitle: rules.AgreementTitle,
AgreementContent: rules.AgreementContent,
AgreementReadSeconds: rules.AgreementReadSeconds,
}
}
+61 -10
View File
@@ -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,
@@ -288,7 +288,7 @@ func awardOrderSide(tx *gorm.DB, order *model.RentalOrder, cfg Config, role stri
setOrderPointsSnapshot(order, role, existing.BasisAmountCent, existing.PointsPerYuan, existing.PointsMultiplierBps, existing.Points, existing.CreatedAt)
return nil
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if role == "renter" {
@@ -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)
@@ -166,6 +166,8 @@ type PublishScreenshotSlot struct {
type PublishPriceConfig struct {
DepositPlaceholder string `json:"deposit_placeholder"`
DepositHint string `json:"deposit_hint"`
DepositTooltip string `json:"deposit_tooltip"`
CompensationNote string `json:"compensation_note"`
PricePlaceholder string `json:"price_placeholder"`
RatioDescription string `json:"ratio_description"`
}
@@ -231,6 +231,12 @@ func normalizePublishOptions(options *PublishOptionsDTO) {
if options.PriceConfig.DepositHint == "" {
options.PriceConfig.DepositHint = defaults.PriceConfig.DepositHint
}
if options.PriceConfig.DepositTooltip == "" {
options.PriceConfig.DepositTooltip = defaults.PriceConfig.DepositTooltip
}
if options.PriceConfig.CompensationNote == "" {
options.PriceConfig.CompensationNote = defaults.PriceConfig.CompensationNote
}
if options.PriceConfig.PricePlaceholder == "" {
options.PriceConfig.PricePlaceholder = defaults.PriceConfig.PricePlaceholder
}
@@ -93,6 +93,29 @@ func DefaultPublishOptions() PublishOptionsDTO {
PriceConfig: PublishPriceConfig{
DepositPlaceholder: "温馨提示:本押金用于保障账号安全。若买家在租用期间,使用非纯币类道具/资源(如消耗品、材料等)或导致账号被封禁,相关损失将直接从押金中扣除进行赔付。押金过高会延长出租等待时间(请自行衡量)感谢理解。",
DepositHint: "租客缴纳,封号必赔付",
DepositTooltip: `1. 系统推荐押金(基础覆盖)
系统将根据您填写的出租皮肤、特勤处等级等信息,自动计算并生成一个推荐押金额度。该额度已覆盖上述主要资产的合理市场价值,可作为押金设置的基础参考。
2. 系统推荐押金未覆盖项目(需自行评估)
请注意,系统推荐押金暂不包含以下资产类型,您需要根据实际情况额外评估其价值:
- 收藏室内的稀有物品
- 其他具有个人纪念意义或情怀价值的虚拟资产(如限定称号、绝版道具等)
3. 未出租价值折算规则(重点)
请在「收藏室及不出租部分哈弗币价值」中填写收藏室等不出租资产折合的哈弗币。系统按 1元=50万 计算参考值,并自动更新推荐押金:低于参考值时覆盖为参考值,否则追加参考值的 50%。
示例:基础推荐押金 200 元、收藏室价值 200M → 参考值 400 元,200<400,推荐押金更新为 400 元。
目的:该举能够有效覆盖租客可能出现的洗号、违规操作导致账号封禁等风险带来的损失,为您提供充分的风险缓冲。
4. 额外保障选项
若您在设置足额押金后仍感不放心,可选择购买平台提供的《全额包赔服务》。一旦发生封号损失,将获得账号市场价值的全额赔付,让您的出租过程后顾无忧。`,
CompensationNote: "普通赔付方案如下:账号在租期内或祖期外出现封禁,追回、洗号等情况,最高可获赔全款相金+押金\n\n无忧保险赔付方案:按账号市场价值进行赔付(可参照螃蟹,盼之等主流交易平台)。注:选择此方案,需额外抽取订单佣金的8.0%作为服务费:押金须达到推荐押金方可购买。",
PricePlaceholder: "填写币数、保险、体力和负重后自动计算;发布总价=纯币基础价+额外消耗品",
RatioDescription: "1:xx 表示 1 元人民币(RMB)等价兑换 xx 万哈夫币",
},
@@ -136,6 +136,34 @@ func TestNormalizePublishOptionsValueDefaultsQualityOwnerRatioDiscount(t *testin
}
}
func TestNormalizePublishOptionsValueDefaultsDepositTooltip(t *testing.T) {
normalized, err := normalizePublishOptionsValue(`{"schema_version":2}`)
if err != nil {
t.Fatalf("规范化失败: %v", err)
}
var options PublishOptionsDTO
if err := json.Unmarshal([]byte(normalized), &options); err != nil {
t.Fatalf("解析规范化结果失败: %v", err)
}
if !strings.Contains(options.PriceConfig.DepositTooltip, "系统推荐押金(基础覆盖)") {
t.Fatalf("押金说明默认文案缺失: %q", options.PriceConfig.DepositTooltip)
}
}
func TestNormalizePublishOptionsValueDefaultsCompensationNote(t *testing.T) {
normalized, err := normalizePublishOptionsValue(`{"schema_version":2}`)
if err != nil {
t.Fatalf("规范化失败: %v", err)
}
var options PublishOptionsDTO
if err := json.Unmarshal([]byte(normalized), &options); err != nil {
t.Fatalf("解析规范化结果失败: %v", err)
}
if !strings.Contains(options.PriceConfig.CompensationNote, "普通赔付方案如下") {
t.Fatalf("赔付方案说明默认文案缺失: %q", options.PriceConfig.CompensationNote)
}
}
func TestNormalizePublishOptionsValuePreservesExplicitZeroPremiumReviewRatioMarkup(t *testing.T) {
options := DefaultPublishOptions()
options.RatioConfig.PremiumReviewRatioMarkup = 0
@@ -40,7 +40,7 @@ var defaultConfigs = []defaultConfig{
{Key: "order.return_overdue_grace_minutes", Value: "10", Description: "预计截止后结账宽限分钟数"},
{Key: "listing.review_required", Value: "false", Description: "发布账号是否需要后台人工审核"},
{Key: "listing.publish_cooldown_minutes", Value: "5", Description: "同一用户两次新增发布账号的最小间隔分钟数(0 表示关闭)"},
{Key: qualityowner.ConfigKey, Value: qualityowner.DefaultConfigValue(), Description: "优质号主保证金与无忧租号保险费率配置 JSON"},
{Key: qualityowner.ConfigKey, Value: qualityowner.DefaultConfigValue(), Description: "优质号主保证金、开通协议与无忧租号保险费率配置 JSON"},
{Key: listingPublishAgreementsConfigKey, Value: defaultListingPublishAgreementsConfigValue(), Description: "发布账号前协议配置 JSON"},
{Key: orderAgreementsConfigKey, Value: defaultOrderAgreementsConfigValue(), Description: "下单前协议配置 JSON"},
{Key: postRentalNoticeConfigKey, Value: defaultPostRentalNoticeConfigValue(), Description: "租后须知配置 JSON"},
@@ -134,6 +134,12 @@ func (r *Repository) List(ctx context.Context) ([]ConfigDTO, error) {
}
items := make([]ConfigDTO, 0, len(rows))
for _, row := range rows {
if row.Key == qualityowner.ConfigKey {
rules, err := qualityowner.ParseConfigValue(row.Value)
if err == nil {
row.Value, _ = qualityowner.MarshalConfig(rules)
}
}
items = append(items, toDTO(row))
}
return items, nil
+4 -2
View File
@@ -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>
@@ -336,6 +336,22 @@ async function handleSave() {
placeholder="例如:租客缴纳,封号必赔付"
/>
</el-form-item>
<el-form-item label="押金悬浮说明">
<el-input
v-model="publishOptionsDraft.price_config.deposit_tooltip"
type="textarea"
:rows="12"
placeholder="支持换行,发布页点击或悬停押金旁的说明图标时展示"
/>
</el-form-item>
<el-form-item label="赔付方案说明">
<el-input
v-model="publishOptionsDraft.price_config.compensation_note"
type="textarea"
:rows="6"
placeholder="支持换行,发布页选择赔付方案后展示"
/>
</el-form-item>
<el-form-item label="价格提示">
<el-input
v-model="publishOptionsDraft.price_config.price_placeholder"
@@ -19,6 +19,9 @@ const form = reactive({
depositYuan: 200,
insurancePercent: 8,
premiumInsurancePercent: 4,
agreementTitle: '',
agreementContent: '',
agreementReadSeconds: 15,
description: '',
saving: false,
})
@@ -32,10 +35,18 @@ watch(
form.depositYuan = Number(parsed.deposit_amount_cent || 20000) / 100
form.insurancePercent = Number(parsed.insurance_fee_bps || 800) / 100
form.premiumInsurancePercent = Number(parsed.premium_insurance_fee_bps || 400) / 100
form.agreementTitle =
typeof parsed.agreement_title === 'string' ? parsed.agreement_title : '优质号主服务协议'
form.agreementContent =
typeof parsed.agreement_content === 'string' ? parsed.agreement_content : ''
form.agreementReadSeconds = Math.max(15, Number(parsed.agreement_read_seconds || 15))
} catch {
form.depositYuan = 200
form.insurancePercent = 8
form.premiumInsurancePercent = 4
form.agreementTitle = '优质号主服务协议'
form.agreementContent = ''
form.agreementReadSeconds = 15
}
form.description = props.config.description || ''
},
@@ -55,6 +66,14 @@ async function save() {
ElMessage.warning('极品专区费率不能高于普通保险费率')
return
}
if (!form.agreementTitle.trim() || !form.agreementContent.trim()) {
ElMessage.warning('请填写优质号主协议标题和正文')
return
}
if (form.agreementReadSeconds < 15 || form.agreementReadSeconds > 300) {
ElMessage.warning('强制阅读时长必须在 15-300 秒之间')
return
}
form.saving = true
try {
await updateSystemConfig(props.config.key, {
@@ -62,6 +81,9 @@ async function save() {
deposit_amount_cent: Math.round(form.depositYuan * 100),
insurance_fee_bps: Math.round(form.insurancePercent * 100),
premium_insurance_fee_bps: Math.round(form.premiumInsurancePercent * 100),
agreement_title: form.agreementTitle.trim(),
agreement_content: form.agreementContent.trim(),
agreement_read_seconds: Math.round(form.agreementReadSeconds),
}),
description: form.description,
})
@@ -80,7 +102,8 @@ async function save() {
<el-dialog
:model-value="modelValue"
title="优质号主与无忧租号保险"
width="560px"
width="900px"
class="quality-owner-config-dialog"
@update:model-value="emit('update:modelValue', $event)"
>
<el-form label-position="top">
@@ -105,6 +128,27 @@ async function save() {
:step="0.5"
/>
</el-form-item>
<el-form-item label="开通协议标题">
<el-input v-model="form.agreementTitle" placeholder="优质号主服务协议" />
</el-form-item>
<el-form-item label="强制阅读时长(秒)">
<el-input-number
v-model="form.agreementReadSeconds"
:min="15"
:max="300"
:step="1"
:precision="0"
/>
<span class="agreement-config-hint">最低 15 秒,用户阅读完成并勾选同意后才能继续。</span>
</el-form-item>
<el-form-item label="开通协议正文">
<el-input
v-model="form.agreementContent"
type="textarea"
:rows="20"
placeholder="支持 Markdown 标题、列表和表格"
/>
</el-form-item>
<el-form-item label="配置说明">
<el-input v-model="form.description" type="textarea" :rows="3" />
</el-form-item>
@@ -112,7 +156,7 @@ async function save() {
<el-alert
type="info"
:closable="false"
title="费率按号主实际到手租金计算,并在订单结算时扣减;历史订单保留下单时费率快照。"
title="费率按号主实际到手租金计算,并在订单结算时扣减;历史订单保留下单时费率快照。协议正文支持 Markdown 表格。"
/>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
@@ -120,3 +164,17 @@ async function save() {
</template>
</el-dialog>
</template>
<style scoped>
:global(.quality-owner-config-dialog .el-dialog__body) {
max-height: 72vh;
overflow-y: auto;
padding-right: 18px;
}
.agreement-config-hint {
margin-left: 10px;
color: #64748b;
font-size: 12px;
}
</style>
@@ -660,12 +660,11 @@ function formatConfigValue(row: SystemConfig) {
<p class="eyebrow">Premium Owner</p>
<h2>优质号主与无忧租号保险</h2>
<span>
配置优质号主保证金,以及普通发布和「优质号主 + 极品专区」的无忧租号保险服务费率。
配置优质号主保证金、开通协议,以及普通发布和「优质号主 +
极品专区」的无忧租号保险服务费率。
</span>
</div>
<el-button type="primary" @click="openEdit(qualityOwnerConfig)">
编辑保证金与费率
</el-button>
<el-button type="primary" @click="openEdit(qualityOwnerConfig)"> 编辑配置 </el-button>
</div>
<div class="publish-stat-grid home-stat-grid">
<div class="publish-stat">
@@ -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 }">
<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="余额管理"
@@ -882,6 +882,77 @@
min-width: 72px;
}
.quality-owner-agreement-popup {
overflow: hidden;
}
.quality-owner-agreement-shell {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
height: 100%;
background: #fff;
}
.quality-owner-agreement-header {
padding: 18px 46px 12px 16px;
border-bottom: 1px solid #eef1f5;
}
.quality-owner-agreement-header h3 {
margin: 0;
color: #20242a;
font-size: 17px;
font-weight: 900;
}
.quality-owner-agreement-header p {
margin: 5px 0 0;
color: #858c96;
font-size: 11px;
}
.quality-owner-agreement-scroll {
overflow-y: auto;
padding: 12px 16px 16px;
overscroll-behavior: contain;
}
.quality-owner-agreement-actions {
display: grid;
gap: 10px;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
border-top: 1px solid #eef1f5;
background: #fff;
}
.quality-owner-agreement-actions :deep(.van-checkbox__label) {
color: #7c2d12;
font-size: 11px;
font-weight: 700;
line-height: 1.5;
}
.quality-owner-agreement-submit {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
}
.quality-owner-agreement-submit > span {
color: #c2410c;
font-size: 11px;
font-weight: 800;
}
.quality-owner-agreement-submit > span.is-ready {
color: #15803d;
}
.quality-owner-agreement-submit :deep(.van-button) {
min-width: 132px;
}
/* ========== 响应式适配 ========== */
@media (min-width: 520px) {
.mobile-profile-shell {
@@ -26,6 +26,8 @@ import {
startQualityOwnerPayment,
type QualityOwnerStatus,
} from '@/features/quality-owner/api/qualityOwner'
import QualityOwnerAgreementContent from '@/features/quality-owner/components/QualityOwnerAgreementContent.vue'
import { useQualityOwnerAgreement } from '@/features/quality-owner/composables/useQualityOwnerAgreement'
import type { PaymentPayWay } from '@/features/orders/api/orders'
import { useMobilePayWaySelect } from '@/features/orders/composables/useMobilePayWaySelect'
import { useMobilePaymentCashier } from '@/features/orders/composables/useMobilePaymentCashier'
@@ -50,6 +52,15 @@ const qualityOwnerStatus = ref<QualityOwnerStatus | null>(null)
const qualityOwnerLoading = ref(false)
const qualityOwnerOnlineLoading = ref(false)
const qualityOwnerCancelling = ref(false)
const {
visible: qualityOwnerAgreementVisible,
accepted: qualityOwnerAgreementAccepted,
remainingSeconds: qualityOwnerAgreementRemainingSeconds,
canConfirm: qualityOwnerAgreementCanConfirm,
open: openQualityOwnerAgreement,
close: closeQualityOwnerAgreement,
confirm: confirmQualityOwnerAgreement,
} = useQualityOwnerAgreement()
const qualityOwnerPayWaySelect = useMobilePayWaySelect()
const qualityOwnerCashier = useMobilePaymentCashier({
queryPayment: () => queryQualityOwnerPayment(),
@@ -164,22 +175,13 @@ async function loadQualityOwnerStatus() {
}
}
async function handleActivateQualityOwner() {
function handleActivateQualityOwner() {
const status = qualityOwnerStatus.value
if (!status || status.active) return
try {
await showDialog({
title: '开通优质号主',
message: `确认从可用余额中扣除 ¥${formatCent(
status.required_deposit_cent
)} 作为保证金?取消资格时会退回余额。`,
showCancelButton: true,
confirmButtonText: '确认缴纳',
cancelButtonText: '取消',
})
} catch {
return
}
openQualityOwnerAgreement(status, activateQualityOwnerByBalance)
}
async function activateQualityOwnerByBalance() {
qualityOwnerLoading.value = true
try {
qualityOwnerStatus.value = await activateQualityOwner()
@@ -193,7 +195,13 @@ async function handleActivateQualityOwner() {
}
}
async function handleOnlineQualityOwnerPayment() {
function handleOnlineQualityOwnerPayment() {
const status = qualityOwnerStatus.value
if (!status || status.active) return
openQualityOwnerAgreement(status, continueOnlineQualityOwnerPayment)
}
async function continueOnlineQualityOwnerPayment() {
const payWay = await qualityOwnerPayWaySelect.select()
if (!payWay) return
qualityOwnerOnlineLoading.value = true
@@ -760,6 +768,51 @@ function resolveAvatarURL(url: string | undefined | null) {
@refresh="qualityOwnerCashier.refreshPaymentStatus(false)"
/>
<van-popup
v-model:show="qualityOwnerAgreementVisible"
position="bottom"
round
closeable
:close-on-click-overlay="false"
class="quality-owner-agreement-popup"
:style="{ height: '86%' }"
@closed="closeQualityOwnerAgreement"
>
<div class="quality-owner-agreement-shell">
<header class="quality-owner-agreement-header">
<h3>{{ qualityOwnerStatus?.agreement_title || '优质号主服务协议' }}</h3>
<p>请完整阅读后勾选同意并继续开通</p>
</header>
<div class="quality-owner-agreement-scroll">
<QualityOwnerAgreementContent :content="qualityOwnerStatus?.agreement_content || ''" />
</div>
<footer class="quality-owner-agreement-actions">
<van-checkbox v-model="qualityOwnerAgreementAccepted" icon-size="18px">
本人已详细阅读并充分理解本协议全部内容,同意遵守上述条款
</van-checkbox>
<div class="quality-owner-agreement-submit">
<span v-if="qualityOwnerAgreementRemainingSeconds > 0">
还需阅读 {{ qualityOwnerAgreementRemainingSeconds }} 秒
</span>
<span v-else class="is-ready">已完成强制阅读</span>
<van-button
round
type="primary"
color="#7c3aed"
:disabled="!qualityOwnerAgreementCanConfirm"
@click="confirmQualityOwnerAgreement"
>
{{
qualityOwnerAgreementRemainingSeconds > 0
? `还需阅读 ${qualityOwnerAgreementRemainingSeconds} 秒`
: '同意并继续'
}}
</van-button>
</div>
</footer>
</div>
</van-popup>
<!-- 账单明细 Popup -->
<van-popup
v-model:show="showLedgers"
+103 -16
View File
@@ -29,6 +29,8 @@ import {
startQualityOwnerPayment,
type QualityOwnerStatus,
} from '@/features/quality-owner/api/qualityOwner'
import QualityOwnerAgreementContent from '@/features/quality-owner/components/QualityOwnerAgreementContent.vue'
import { useQualityOwnerAgreement } from '@/features/quality-owner/composables/useQualityOwnerAgreement'
import { formatCent } from '@/shared/utils/money'
import type { PaymentPayWay } from '@/features/orders/api/orders'
import { useOrderPaymentCashier } from '@/features/orders/composables/useOrderPaymentCashier'
@@ -42,6 +44,15 @@ const qualityOwnerOnlineLoading = ref(false)
const qualityOwnerCancelling = ref(false)
const qualityOwnerPayWayVisible = ref(false)
const qualityOwnerStatus = ref<QualityOwnerStatus | null>(null)
const {
visible: qualityOwnerAgreementVisible,
accepted: qualityOwnerAgreementAccepted,
remainingSeconds: qualityOwnerAgreementRemainingSeconds,
canConfirm: qualityOwnerAgreementCanConfirm,
open: openQualityOwnerAgreement,
close: closeQualityOwnerAgreement,
confirm: confirmQualityOwnerAgreement,
} = useQualityOwnerAgreement()
const uploadingAvatar = ref(false)
const avatarFileInput = ref<HTMLInputElement | null>(null)
const form = reactive({
@@ -101,23 +112,13 @@ async function loadQualityOwnerStatus() {
}
}
async function handleActivateQualityOwner() {
function handleActivateQualityOwner() {
const status = qualityOwnerStatus.value
if (!status || status.active) return
const deposit = formatCent(status.required_deposit_cent)
try {
await ElMessageBox.confirm(
`确认从可用余额中扣除 ¥${deposit} 作为优质号主保证金?保证金会冻结,取消优质号主资格时退回。`,
'开通优质号主',
{
type: 'warning',
confirmButtonText: '确认缴纳',
cancelButtonText: '取消',
}
)
} catch {
return
}
openQualityOwnerAgreement(status, activateQualityOwnerByBalance)
}
async function activateQualityOwnerByBalance() {
qualityOwnerLoading.value = true
try {
qualityOwnerStatus.value = await activateQualityOwner()
@@ -131,6 +132,12 @@ async function handleActivateQualityOwner() {
}
function openQualityOwnerOnlinePayment() {
const status = qualityOwnerStatus.value
if (!status || status.active) return
openQualityOwnerAgreement(status, continueQualityOwnerOnlinePayment)
}
function continueQualityOwnerOnlinePayment() {
qualityOwnerPayWayVisible.value = true
}
@@ -507,7 +514,11 @@ async function savePassword() {
>
余额支付开通
</el-button>
<el-button :loading="qualityOwnerOnlineLoading" @click="openQualityOwnerOnlinePayment">
<el-button
:loading="qualityOwnerOnlineLoading"
:disabled="!qualityOwnerStatus"
@click="openQualityOwnerOnlinePayment"
>
微信 / 支付宝支付开通
</el-button>
</div>
@@ -527,6 +538,43 @@ async function savePassword() {
<PayWaySelectDialog v-model="qualityOwnerPayWayVisible" @choose="chooseQualityOwnerPayWay" />
<el-dialog
v-model="qualityOwnerAgreementVisible"
:title="qualityOwnerStatus?.agreement_title || '优质号主服务协议'"
width="760px"
append-to-body
:close-on-click-modal="false"
class="quality-owner-agreement-dialog"
@closed="closeQualityOwnerAgreement"
>
<div class="quality-owner-agreement-body">
<QualityOwnerAgreementContent :content="qualityOwnerStatus?.agreement_content || ''" />
<div class="quality-owner-agreement-consent">
<el-checkbox v-model="qualityOwnerAgreementAccepted">
本人已详细阅读并充分理解本协议全部内容,同意遵守上述条款
</el-checkbox>
<span v-if="qualityOwnerAgreementRemainingSeconds > 0">
还需阅读 {{ qualityOwnerAgreementRemainingSeconds }} 秒
</span>
<span v-else class="is-ready">已完成强制阅读</span>
</div>
</div>
<template #footer>
<el-button @click="closeQualityOwnerAgreement">取消</el-button>
<el-button
type="primary"
:disabled="!qualityOwnerAgreementCanConfirm"
@click="confirmQualityOwnerAgreement"
>
{{
qualityOwnerAgreementRemainingSeconds > 0
? `还需阅读 ${qualityOwnerAgreementRemainingSeconds} 秒`
: '同意并继续'
}}
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="qualityOwnerPaymentVisible"
title="优质号主保证金支付"
@@ -1165,6 +1213,45 @@ async function savePassword() {
margin-left: 0;
}
.quality-owner-agreement-body {
display: grid;
gap: 16px;
}
.quality-owner-agreement-body > :first-child {
max-height: 58vh;
overflow-y: auto;
padding: 4px 18px 16px 4px;
}
.quality-owner-agreement-consent {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border: 1px solid #fed7aa;
border-radius: 8px;
background: #fff7ed;
}
.quality-owner-agreement-consent :deep(.el-checkbox__label) {
color: #7c2d12;
font-size: 13px;
font-weight: 700;
}
.quality-owner-agreement-consent > span {
flex-shrink: 0;
color: #c2410c;
font-size: 12px;
font-weight: 800;
}
.quality-owner-agreement-consent > span.is-ready {
color: #15803d;
}
.quality-owner-pay-dialog-body {
display: grid;
gap: 14px;
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { mergeListingPublishOptions } from './listingOptions'
describe('mergeListingPublishOptions', () => {
it('保留配置结构版本,避免保存时触发旧版迁移', () => {
const options = mergeListingPublishOptions({
schema_version: 2,
})
expect(options.schema_version).toBe(2)
})
it('读取后台配置的押金说明', () => {
const options = mergeListingPublishOptions({
price_config: {
deposit_placeholder: '',
deposit_hint: '',
deposit_tooltip: '自定义押金说明',
price_placeholder: '',
ratio_description: '',
},
})
expect(options.price_config.deposit_tooltip).toBe('自定义押金说明')
})
it('读取后台配置的赔付方案说明', () => {
const options = mergeListingPublishOptions({
price_config: {
deposit_placeholder: '',
deposit_hint: '',
deposit_tooltip: '',
compensation_note: '自定义赔付方案说明',
price_placeholder: '',
ratio_description: '',
},
})
expect(options.price_config.compensation_note).toBe('自定义赔付方案说明')
})
})
@@ -32,6 +32,8 @@ export interface PublishScreenshotSlot {
export interface PublishPriceConfig {
deposit_placeholder: string
deposit_hint: string
deposit_tooltip: string
compensation_note: string
price_placeholder: string
ratio_description: string
}
@@ -101,6 +103,7 @@ export interface ListingPublishAgreements {
}
export interface ListingPublishOptions {
schema_version: number
/** 发布页是否展示「是否解锁德穆兰」选项 */
unlock_demulan_enabled: boolean
next_season_pass_enabled: boolean
@@ -122,7 +125,31 @@ export interface ListingPublishOptions {
ratio_config: PublishRatioConfig
}
const defaultDepositTooltip = `1. 系统推荐押金(基础覆盖)
系统将根据您填写的出租皮肤、特勤处等级等信息,自动计算并生成一个推荐押金额度。该额度已覆盖上述主要资产的合理市场价值,可作为押金设置的基础参考。
2. 系统推荐押金未覆盖项目(需自行评估)
请注意,系统推荐押金暂不包含以下资产类型,您需要根据实际情况额外评估其价值:
- 收藏室内的稀有物品
- 其他具有个人纪念意义或情怀价值的虚拟资产(如限定称号、绝版道具等)
3. 未出租价值折算规则(重点)
请在「收藏室及不出租部分哈弗币价值」中填写收藏室等不出租资产折合的哈弗币。系统按 1元=50万 计算参考值,并自动更新推荐押金:低于参考值时覆盖为参考值,否则追加参考值的 50%。
示例:基础推荐押金 200 元、收藏室价值 200M → 参考值 400 元,200<400,推荐押金更新为 400 元。
目的:该举能够有效覆盖租客可能出现的洗号、违规操作导致账号封禁等风险带来的损失,为您提供充分的风险缓冲。
4. 额外保障选项
若您在设置足额押金后仍感不放心,可选择购买平台提供的《全额包赔服务》。一旦发生封号损失,将获得账号市场价值的全额赔付,让您的出租过程后顾无忧。`
export const emptyListingPublishOptions: ListingPublishOptions = {
schema_version: 2,
unlock_demulan_enabled: false,
next_season_pass_enabled: false,
server_options: [],
@@ -141,6 +168,9 @@ export const emptyListingPublishOptions: ListingPublishOptions = {
price_config: {
deposit_placeholder: '',
deposit_hint: '租客缴纳,封号必赔付',
deposit_tooltip: defaultDepositTooltip,
compensation_note:
'普通赔付方案如下:账号在租期内或祖期外出现封禁,追回、洗号等情况,最高可获赔全款相金+押金\n\n无忧保险赔付方案:按账号市场价值进行赔付(可参照螃蟹,盼之等主流交易平台)。注:选择此方案,需额外抽取订单佣金的8.0%作为服务费:押金须达到推荐押金方可购买。',
price_placeholder: '',
ratio_description: '',
},
@@ -215,6 +245,7 @@ export function mergeListingPublishOptions(
options?: Partial<ListingPublishOptions>
): ListingPublishOptions {
return {
schema_version: readPositiveInteger(options?.schema_version, 2),
unlock_demulan_enabled: options?.unlock_demulan_enabled === true,
next_season_pass_enabled: options?.next_season_pass_enabled === true,
server_options: normalizeStringList(options?.server_options),
@@ -325,6 +356,14 @@ function normalizePriceConfig(value?: unknown): PublishPriceConfig {
typeof row.deposit_hint === 'string' && row.deposit_hint.trim()
? row.deposit_hint.trim()
: emptyListingPublishOptions.price_config.deposit_hint,
deposit_tooltip:
typeof row.deposit_tooltip === 'string' && row.deposit_tooltip.trim()
? row.deposit_tooltip.trim()
: emptyListingPublishOptions.price_config.deposit_tooltip,
compensation_note:
typeof row.compensation_note === 'string' && row.compensation_note.trim()
? row.compensation_note.trim()
: emptyListingPublishOptions.price_config.compensation_note,
price_placeholder:
typeof row.price_placeholder === 'string' ? row.price_placeholder.trim() : '',
ratio_description:
@@ -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,26 +221,33 @@ watch(
}
)
const statCards = computed(() => [
const statCards = computed(() => {
const cards = [
{
key: 'premium',
label: '优质号主精选',
value: `${zoneCount('premium')}`,
hint: '认证优质号主 · 极品专区',
},
{ label: '可租账号', value: `${totalListings.value}`, 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(() => [
const zoneOptions = computed(() => {
const zones = [
{
key: 'all',
label: '全部专区',
@@ -268,7 +290,9 @@ const zoneOptions = computed(() => [
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 => ({
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: zoneCount(zone.key),
}))
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="进入优质号主极品专区"
@@ -15,11 +15,19 @@ export interface QualityOwnerStatus {
required_deposit_cent: number
insurance_fee_bps: number
premium_insurance_fee_bps: number
agreement_title: string
agreement_content: string
agreement_read_seconds: number
}
export async function fetchQualityOwnerStatus() {
const { data } = await apiClient.get<ApiResponse<QualityOwnerStatus>>('/quality-owner/status')
return data.data
return {
...data.data,
agreement_title: data.data.agreement_title || '优质号主服务协议',
agreement_content: data.data.agreement_content || '',
agreement_read_seconds: Math.max(15, Number(data.data.agreement_read_seconds || 15)),
}
}
export async function activateQualityOwner() {
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { computed } from 'vue'
import AnnouncementContent from '@/features/announcement/components/AnnouncementContent.vue'
import { renderAnnouncementMarkdown } from '@/features/announcement/utils/markdown'
const props = defineProps<{ content: string }>()
const html = computed(() => renderAnnouncementMarkdown(props.content))
</script>
<template>
<AnnouncementContent class="quality-owner-agreement-content" :html="html" />
</template>
<style scoped>
.quality-owner-agreement-content {
color: #334155;
font-size: 13px;
line-height: 1.8;
overflow-wrap: anywhere;
}
.quality-owner-agreement-content :deep(h1),
.quality-owner-agreement-content :deep(h2),
.quality-owner-agreement-content :deep(h3) {
margin: 18px 0 8px;
color: #1f2937;
font-weight: 800;
line-height: 1.45;
}
.quality-owner-agreement-content :deep(p) {
margin: 8px 0;
}
.quality-owner-agreement-content :deep(ul),
.quality-owner-agreement-content :deep(ol) {
margin: 8px 0;
padding-left: 22px;
}
.quality-owner-agreement-content :deep(table) {
display: block;
width: 100%;
max-width: 100%;
margin: 12px 0;
overflow-x: auto;
border-collapse: collapse;
}
.quality-owner-agreement-content :deep(th),
.quality-owner-agreement-content :deep(td) {
min-width: 110px;
padding: 8px 10px;
border: 1px solid #e2e8f0;
text-align: left;
vertical-align: top;
}
.quality-owner-agreement-content :deep(th) {
background: #f8fafc;
color: #1f2937;
font-weight: 800;
}
@media (max-width: 640px) {
.quality-owner-agreement-content {
font-size: 12px;
line-height: 1.7;
}
.quality-owner-agreement-content :deep(th),
.quality-owner-agreement-content :deep(td) {
min-width: 96px;
padding: 7px 8px;
}
}
</style>
@@ -0,0 +1,69 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { QualityOwnerStatus } from '@/features/quality-owner/api/qualityOwner'
import { useQualityOwnerAgreement } from '../useQualityOwnerAgreement'
function status(readSeconds = 15): QualityOwnerStatus {
return {
user_id: 1,
active: false,
status: 'none',
can_cancel: false,
active_premium_listing_count: 0,
deposit_amount_cent: 0,
deposit_source: '',
available_balance_cent: 20000,
required_deposit_cent: 20000,
insurance_fee_bps: 800,
premium_insurance_fee_bps: 400,
agreement_title: '优质号主服务协议',
agreement_content: '协议正文',
agreement_read_seconds: readSeconds,
}
}
afterEach(() => {
vi.useRealTimers()
})
describe('useQualityOwnerAgreement', () => {
it('阅读满 15 秒且勾选后才能确认', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-11-05T00:00:00Z'))
const action = vi.fn()
const agreement = useQualityOwnerAgreement()
agreement.open(status(), action)
agreement.accepted.value = true
expect(agreement.canConfirm.value).toBe(false)
vi.advanceTimersByTime(14_999)
expect(agreement.remainingSeconds.value).toBe(1)
expect(agreement.canConfirm.value).toBe(false)
vi.advanceTimersByTime(1)
expect(agreement.remainingSeconds.value).toBe(0)
expect(agreement.canConfirm.value).toBe(true)
await agreement.confirm()
expect(action).toHaveBeenCalledOnce()
expect(agreement.visible.value).toBe(false)
})
it('关闭后重新打开会重新计时并清除同意状态', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-11-05T00:00:00Z'))
const agreement = useQualityOwnerAgreement()
agreement.open(status(20), vi.fn())
agreement.accepted.value = true
vi.advanceTimersByTime(5000)
agreement.close()
agreement.open(status(), vi.fn())
expect(agreement.remainingSeconds.value).toBe(15)
expect(agreement.accepted.value).toBe(false)
expect(agreement.canConfirm.value).toBe(false)
agreement.close()
})
})
@@ -0,0 +1,72 @@
import { computed, getCurrentInstance, onUnmounted, ref } from 'vue'
import type { QualityOwnerStatus } from '@/features/quality-owner/api/qualityOwner'
type AgreementAction = () => void | Promise<void>
export function useQualityOwnerAgreement() {
const visible = ref(false)
const accepted = ref(false)
const remainingSeconds = ref(0)
const totalSeconds = ref(15)
let pendingAction: AgreementAction | null = null
let startedAt = 0
let timer: ReturnType<typeof setInterval> | null = null
const canConfirm = computed(() => visible.value && accepted.value && remainingSeconds.value <= 0)
function stopTimer() {
if (timer) {
clearInterval(timer)
timer = null
}
}
function updateRemaining() {
const elapsed = Math.floor((Date.now() - startedAt) / 1000)
remainingSeconds.value = Math.max(totalSeconds.value - elapsed, 0)
if (remainingSeconds.value <= 0) stopTimer()
}
function open(status: QualityOwnerStatus, action: AgreementAction) {
stopTimer()
totalSeconds.value = Math.max(15, Math.floor(Number(status.agreement_read_seconds || 15)))
remainingSeconds.value = totalSeconds.value
startedAt = Date.now()
accepted.value = false
pendingAction = action
visible.value = true
timer = setInterval(updateRemaining, 250)
}
function close() {
stopTimer()
visible.value = false
accepted.value = false
remainingSeconds.value = 0
pendingAction = null
}
async function confirm() {
if (!canConfirm.value) return false
const action = pendingAction
close()
await action?.()
return true
}
if (getCurrentInstance()) {
onUnmounted(stopTimer)
}
return {
visible,
accepted,
remainingSeconds,
totalSeconds,
canConfirm,
open,
close,
confirm,
}
}
@@ -37,7 +37,12 @@ import {
} from '@/features/seller/composables/usePublishDraft'
import type { PublishForm } from '@/shared/types/publish'
import { yuanToCent } from '@/shared/utils/money'
import { dailyLossOptions, formatNumber, roundRatio } from '@/shared/utils/pricing'
import {
calculateInsuranceFeeAmount,
dailyLossOptions,
formatNumber,
roundRatio,
} from '@/shared/utils/pricing'
import { useDistributionSiteStore } from '@/stores/distributionSite'
const draftSaveDelay = 400
@@ -113,6 +118,14 @@ export function usePublishForm(options: UsePublishFormOptions) {
receiveCostDelta,
isQualityOwner,
})
const calculatedInsuranceFee = computed(() =>
form.compensation_plan === 'insurance'
? calculateInsuranceFeeAmount(pricing.calculatedSellerPrice.value, insuranceFeeBps.value)
: 0
)
const calculatedTakeHomePrice = computed(() =>
Math.max(pricing.calculatedSellerPrice.value - calculatedInsuranceFee.value, 0)
)
const qualityOwnerSaleRatioSelected = computed(
() =>
isQualityOwner.value &&
@@ -1033,6 +1046,8 @@ export function usePublishForm(options: UsePublishFormOptions) {
isQualityOwnerExclusiveRatioActive,
insuranceFeeBps,
insuranceFeePercent,
calculatedInsuranceFee,
calculatedTakeHomePrice,
virtualAssetSaleAgreementChecked,
sellerAgreementChecked,
passwordAndDeviceConfirmed,
@@ -837,6 +837,22 @@
min-width: 0;
}
.deposit-heading .deposit-heading-title {
gap: 4px;
}
.deposit-guide-trigger {
display: inline-grid;
width: 22px;
height: 22px;
padding: 0;
place-items: center;
border: 0;
background: transparent;
color: #f97316;
font-size: 17px;
}
.deposit-heading b {
color: #f04452;
font-size: 13px;
@@ -1109,11 +1125,62 @@
background: #faf5ff;
}
.take-home-preview {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #e5e7eb;
}
.take-home-preview > div {
display: grid;
align-content: center;
gap: 4px;
min-width: 0;
padding: 10px;
background: #fff;
}
.take-home-preview span {
color: #858c96;
font-size: 10px;
line-height: 1.4;
}
.take-home-preview strong {
overflow-wrap: anywhere;
color: #20242a;
font-size: 15px;
font-weight: 800;
}
.take-home-preview strong.is-deduction {
color: #c2410c;
}
.take-home-preview .take-home-net {
grid-column: 1 / -1;
background: #fff7ed;
}
.take-home-preview .take-home-net span {
color: #9a3412;
}
.take-home-preview .take-home-net strong {
color: #f97316;
font-size: 19px;
}
.coverage-note {
margin: 0;
color: #858c96;
font-size: 10px;
line-height: 1.55;
white-space: pre-line;
}
.publish-agreement-card {
@@ -34,6 +34,8 @@ const {
qualityOwnerSaleRatio,
minAcceleratedSaleRatio,
insuranceFeePercent,
calculatedInsuranceFee,
calculatedTakeHomePrice,
showBanRecordRiskHint,
unlockDemulanEnabled,
nextSeasonPassEnabled,
@@ -127,6 +129,16 @@ function openPublishAgreement(agreement: AgreementContent) {
agreementPopupVisible.value = true
}
function showDepositGuide() {
if (!priceConfig.value.deposit_tooltip) return
void showDialog({
title: '押金说明',
message: priceConfig.value.deposit_tooltip,
messageAlign: 'left',
confirmButtonText: '我知道了',
})
}
function selectRadio<T extends string>(value: T, setter: (value: T) => void) {
setter(value)
}
@@ -687,7 +699,18 @@ const screenshotGuides: Record<string, string> = {
<h2 class="section-title">押金与价格</h2>
<div class="deposit-card">
<label class="deposit-heading">
<span>押金<b>*</b></span>
<span class="deposit-heading-title">
押金<b>*</b>
<button
v-if="priceConfig.deposit_tooltip"
type="button"
class="deposit-guide-trigger"
aria-label="查看押金说明"
@click.prevent.stop="showDepositGuide"
>
<van-icon name="info-o" />
</button>
</span>
<em v-if="priceConfig.deposit_hint">{{ priceConfig.deposit_hint }}</em>
</label>
<div class="deposit-input-wrap">
@@ -937,8 +960,34 @@ const screenshotGuides: Record<string, string> = {
</em>
</button>
</div>
<div
class="take-home-preview"
:class="{ 'has-insurance': form.compensation_plan === 'insurance' }"
>
<div>
<span>卖家发布价格</span>
<strong>¥{{ formatMoney(calculatedSellerPrice) }}</strong>
</div>
<div>
<span v-if="form.compensation_plan === 'insurance'">
无忧租号保险({{ insuranceFeePercent.toFixed(1) }}%)
</span>
<span v-else>普通赔付</span>
<strong :class="{ 'is-deduction': form.compensation_plan === 'insurance' }">
{{
form.compensation_plan === 'insurance'
? `-¥${formatMoney(calculatedInsuranceFee)}`
: '¥0.0'
}}
</strong>
</div>
<div class="take-home-net">
<span>预计到手价格</span>
<strong>¥{{ formatMoney(calculatedTakeHomePrice) }}</strong>
</div>
</div>
<p class="coverage-note">
无忧租号保险在普通赔付基础上增加账号市场价值保障,服务费按实际到手租金扣除。
{{ priceConfig.compensation_note }}
</p>
</div>
@@ -164,6 +164,44 @@
align-items: center;
}
.deposit-label-title {
display: inline-flex;
align-items: center;
gap: 3px;
}
.deposit-guide-trigger {
display: inline-grid;
width: 18px;
height: 18px;
padding: 0;
place-items: center;
border: 0;
background: transparent;
color: var(--color-orange-primary);
cursor: help;
}
.deposit-guide-trigger:hover,
.deposit-guide-trigger:focus-visible {
color: var(--color-orange-dark);
outline: none;
}
.deposit-guide-content {
max-height: min(62vh, 560px);
overflow-y: auto;
color: #3f4753;
font-size: 13px;
line-height: 1.75;
white-space: pre-wrap;
}
:global(.deposit-guide-popper.el-popover) {
max-width: min(560px, calc(100vw - 32px));
padding: 14px 16px;
}
.deposit-label em {
display: inline-flex;
align-items: center;
@@ -923,6 +961,10 @@
text-align: right;
}
.summary-breakdown strong.is-deduction {
color: #c2410c;
}
.summary-list {
display: grid;
gap: 8px;
@@ -1256,12 +1298,63 @@
box-shadow: 0 0 0 1px rgba(124, 58, 237, 0.08);
}
.take-home-preview {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: 9px;
background: #e5e7eb;
}
.take-home-preview > div {
display: grid;
align-content: center;
gap: 6px;
min-width: 0;
padding: 12px 14px;
background: #fff;
}
.take-home-preview span {
color: #6b7280;
font-size: 11px;
font-weight: 700;
line-height: 1.45;
}
.take-home-preview strong {
overflow-wrap: anywhere;
color: #1f2937;
font-size: 17px;
font-weight: 900;
}
.take-home-preview strong.is-deduction {
color: #c2410c;
}
.take-home-preview .take-home-net {
background: #fff7ed;
}
.take-home-preview .take-home-net span {
color: #9a3412;
}
.take-home-preview .take-home-net strong {
color: var(--color-orange-primary);
font-size: 21px;
}
.coverage-note {
margin: 0;
color: #7b8492;
font-size: 12px;
font-weight: 600;
line-height: 1.7;
white-space: pre-line;
}
@media (max-width: 1120px) {
@@ -1350,4 +1443,12 @@
.coverage-option-grid {
grid-template-columns: 1fr;
}
.take-home-preview {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.take-home-preview .take-home-net {
grid-column: 1 / -1;
}
}
@@ -3,6 +3,7 @@ import {
ArrowRight,
Delete,
DocumentChecked,
InfoFilled,
Picture,
RefreshRight,
UploadFilled,
@@ -33,6 +34,8 @@ const {
qualityOwnerSaleRatio,
minAcceleratedSaleRatio,
insuranceFeePercent,
calculatedInsuranceFee,
calculatedTakeHomePrice,
virtualAssetSaleAgreementChecked,
sellerAgreementChecked,
passwordAndDeviceConfirmed,
@@ -542,10 +545,26 @@ function selectDailyLoss(value: string | number) {
<PublishSection title="押金与价格" description="系统按资料自动计算售价">
<div class="compact-grid two">
<label class="input-block">
<span class="deposit-label"
>押金<b>*</b
><em v-if="priceConfig.deposit_hint">{{ priceConfig.deposit_hint }}</em></span
<span class="deposit-label">
<span class="deposit-label-title">
押金<b>*</b>
<el-popover
v-if="priceConfig.deposit_tooltip"
placement="top-start"
:width="560"
trigger="hover"
popper-class="deposit-guide-popper"
>
<template #reference>
<button type="button" class="deposit-guide-trigger" aria-label="查看押金说明">
<el-icon><InfoFilled /></el-icon>
</button>
</template>
<div class="deposit-guide-content">{{ priceConfig.deposit_tooltip }}</div>
</el-popover>
</span>
<em v-if="priceConfig.deposit_hint">{{ priceConfig.deposit_hint }}</em>
</span>
<div class="deposit-control">
<el-input-number
v-model="form.depositAmountYuan"
@@ -766,8 +785,34 @@ function selectDailyLoss(value: string | number) {
</em>
</button>
</div>
<div
class="take-home-preview"
:class="{ 'has-insurance': form.compensation_plan === 'insurance' }"
>
<div>
<span>卖家发布价格</span>
<strong>¥{{ formatMoney(calculatedSellerPrice) }}</strong>
</div>
<div>
<span v-if="form.compensation_plan === 'insurance'">
无忧租号保险({{ insuranceFeePercent.toFixed(1) }}%)
</span>
<span v-else>普通赔付</span>
<strong :class="{ 'is-deduction': form.compensation_plan === 'insurance' }">
{{
form.compensation_plan === 'insurance'
? `-¥${formatMoney(calculatedInsuranceFee)}`
: '¥0.0'
}}
</strong>
</div>
<div class="take-home-net">
<span>预计到手价格</span>
<strong>¥{{ formatMoney(calculatedTakeHomePrice) }}</strong>
</div>
</div>
<p class="coverage-note">
普通赔付覆盖封禁、追回、洗号等情况;无忧租号保险在此基础上增加账号市场价值保障。
{{ priceConfig.compensation_note }}
</p>
</div>
@@ -786,13 +831,17 @@ function selectDailyLoss(value: string | number) {
<aside class="summary-column">
<div class="summary-panel">
<div class="summary-main">
<span>卖家发布价格</span>
<span>预计到手价格</span>
<strong>{{
calculatedSellerPrice ? `¥${formatMoney(calculatedSellerPrice)}` : '--'
calculatedTakeHomePrice ? `¥${formatMoney(calculatedTakeHomePrice)}` : '--'
}}</strong>
</div>
<div class="summary-breakdown">
<div class="summary-breakdown-title">价格明细</div>
<div>
<span>卖家发布价格</span>
<strong>¥{{ formatMoney(calculatedSellerPrice) }}</strong>
</div>
<div>
<span>纯币价格</span>
<strong>{{
@@ -803,6 +852,10 @@ function selectDailyLoss(value: string | number) {
<span>额外物品价格</span>
<strong>¥{{ formatMoney(calculatedConsumablePrice) }}</strong>
</div>
<div v-if="form.compensation_plan === 'insurance'">
<span>无忧租号保险({{ insuranceFeePercent.toFixed(1) }}%)</span>
<strong class="is-deduction">-¥{{ formatMoney(calculatedInsuranceFee) }}</strong>
</div>
<div>
<span>押金价格</span>
<strong>{{
@@ -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)
})
})
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { calculateInsuranceFeeAmount } from '../pricing'
describe('calculateInsuranceFeeAmount', () => {
it('普通赔付不收取保险服务费', () => {
expect(calculateInsuranceFeeAmount(238.1, 0)).toBe(0)
})
it('按普通保险费率计算并四舍五入到分', () => {
expect(calculateInsuranceFeeAmount(238.1, 800)).toBe(19.05)
})
it('按极品专区专享费率计算', () => {
expect(calculateInsuranceFeeAmount(238.1, 400)).toBe(9.52)
})
it('保险服务费不超过卖家租金', () => {
expect(calculateInsuranceFeeAmount(1, 10_000)).toBe(1)
})
})
+1
View File
@@ -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 },
// 商品
+5
View File
@@ -0,0 +1,5 @@
const premiumZoneLevels = new Set(['platinum', 'diamond', 'peak'])
export function canViewPremiumZone(levelCode: string | undefined | null) {
return premiumZoneLevels.has(String(levelCode || '').trim())
}
+7
View File
@@ -26,6 +26,13 @@ export function roundMoney(value: number) {
return Math.round(value * 10) / 10
}
export function calculateInsuranceFeeAmount(ownerIncomeYuan: number, feeBps: number) {
const ownerIncomeCent = Math.max(Math.round(Number(ownerIncomeYuan || 0) * 100), 0)
const safeFeeBps = Math.max(Math.round(Number(feeBps || 0)), 0)
if (ownerIncomeCent <= 0 || safeFeeBps <= 0) return 0
return Math.min(Math.round((ownerIncomeCent * safeFeeBps) / 10_000), ownerIncomeCent) / 100
}
/**
* 将比例四舍五入到一位小数
*/
+2
View File
@@ -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,
})
}