feat(user): 支持后台调整等级并限制极品专区可见性

This commit is contained in:
yml2213
2026-11-05 12:02:48 +08:00
parent 09f244a93d
commit 0d558bdce0
35 changed files with 934 additions and 131 deletions
@@ -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{
+38 -13
View File
@@ -7,22 +7,26 @@ import (
"strings"
"hfb_sys/backend/internal/modules/realname"
"hfb_sys/backend/internal/modules/rentergrowth"
)
var (
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrInvalidUser = errors.New("invalid user")
ErrInvalidRealnameInput = errors.New("invalid realname input")
ErrRealnameAlreadyVerified = errors.New("realname already verified")
ErrRealnameIDNoUsed = realname.ErrIDNoAlreadyUsed
ErrRealnameNotRevocable = errors.New("realname not revocable")
ErrInvalidWalletDirection = errors.New("invalid wallet direction")
ErrInvalidWalletAmount = errors.New("invalid wallet amount")
ErrInvalidWalletReason = errors.New("invalid wallet reason")
ErrInsufficientBalance = errors.New("insufficient balance")
ErrInvalidGrowthPoints = errors.New("invalid growth points")
ErrInvalidGrowthReason = errors.New("invalid growth reason")
ErrGrowthPointsUnchanged = errors.New("growth points unchanged")
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrInvalidUser = errors.New("invalid user")
ErrInvalidRealnameInput = errors.New("invalid realname input")
ErrRealnameAlreadyVerified = errors.New("realname already verified")
ErrRealnameIDNoUsed = realname.ErrIDNoAlreadyUsed
ErrRealnameNotRevocable = errors.New("realname not revocable")
ErrInvalidWalletDirection = errors.New("invalid wallet direction")
ErrInvalidWalletAmount = errors.New("invalid wallet amount")
ErrInvalidWalletReason = errors.New("invalid wallet reason")
ErrInsufficientBalance = errors.New("insufficient balance")
ErrInvalidGrowthPoints = errors.New("invalid growth points")
ErrInvalidGrowthReason = errors.New("invalid growth reason")
ErrGrowthPointsUnchanged = errors.New("growth points unchanged")
ErrInvalidGrowthLevel = errors.New("invalid growth level")
ErrInvalidGrowthLevelReason = errors.New("invalid growth level reason")
ErrGrowthLevelUnchanged = errors.New("growth level unchanged")
)
// 单笔人工调账上限:10 万元,防止误填。
@@ -99,6 +103,27 @@ func (s *Service) AdjustGrowthPoints(ctx context.Context, adminID uint64, userID
return s.repo.AdjustGrowthPoints(ctx, adminID, userID, req, meta)
}
func (s *Service) AdjustGrowthLevel(ctx context.Context, adminID uint64, userID uint64, req GrowthLevelAdjustRequest, meta AuditMeta) (*UserDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if userID == 0 {
return nil, ErrInvalidUser
}
req.TargetLevel = strings.TrimSpace(req.TargetLevel)
if req.TargetLevel != "" {
if _, ok := rentergrowth.LevelForCode(rentergrowth.DefaultConfig(), req.TargetLevel); !ok {
return nil, ErrInvalidGrowthLevel
}
}
reason := strings.TrimSpace(req.Reason)
if reasonLen := len([]rune(reason)); reasonLen < 2 || reasonLen > 200 {
return nil, ErrInvalidGrowthLevelReason
}
req.Reason = reason
return s.repo.AdjustGrowthLevel(ctx, adminID, userID, req, meta)
}
func (s *Service) RevokeRealname(ctx context.Context, adminID uint64, userID uint64, req RevokeRealnameRequest, meta AuditMeta) (*UserDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
+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,
ExpiresAt: now.Add(publicZoneCountCacheTTL),
Counts: counts,
NonPremiumTotal: nonPremiumTotal,
ExpiresAt: now.Add(publicZoneCountCacheTTL),
}
return copyPublicZoneCounts(counts), nil
return copyPublicZoneCounts(counts), nonPremiumTotal, nil
}
func copyPublicZoneCounts(counts map[string]int64) map[string]int64 {
@@ -237,11 +261,20 @@ func copyPublicZoneCounts(counts map[string]int64) map[string]int64 {
return copied
}
func (r *Repository) FindPublic(ctx context.Context, id uint64, siteCode string) (*ListingDTO, error) {
func (r *Repository) FindPublic(ctx context.Context, id uint64, siteCode string, viewerUserID uint64) (*ListingDTO, error) {
dto, err := r.findDTO(ctx, "l.id = ? AND l.status = ? AND l.review_status = ? AND l.in_transaction = ?", id, "published", "approved", false)
if err != nil {
return nil, err
}
if dto.IsPremium {
canView, err := r.canViewPremium(ctx, viewerUserID)
if err != nil {
return nil, err
}
if !canView {
return nil, gorm.ErrRecordNotFound
}
}
applyPublicListingURLs(dto)
items := []ListingDTO{*dto}
if err := r.applyShelfMarkup(ctx, siteCode, items); err != nil {
@@ -251,6 +284,30 @@ func (r *Repository) FindPublic(ctx context.Context, id uint64, siteCode string)
return dto, nil
}
func (r *Repository) canViewPremium(ctx context.Context, userID uint64) (bool, error) {
if userID == 0 {
return false, nil
}
var user model.User
if err := r.db.WithContext(ctx).
Select("id, renter_growth_points, renter_growth_level_override").
First(&user, userID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return false, nil
}
return false, err
}
cfg, err := rentergrowth.ConfigForTx(r.db.WithContext(ctx))
if err != nil {
return false, err
}
level := rentergrowth.EffectiveLevelForUser(cfg, user.RenterGrowthPoints, user.RenterGrowthLevelOverride)
if !cfg.Enabled && user.RenterGrowthLevelOverride == "" {
level = rentergrowth.LevelForPoints(cfg, 0)
}
return rentergrowth.CanViewPremiumLevel(level.Code), nil
}
func (r *Repository) applyShelfMarkup(ctx context.Context, siteCode string, items []ListingDTO) error {
if siteCode == "" || len(items) == 0 {
return nil
@@ -73,8 +73,9 @@ func (r *Repository) IsQualityOwner(ctx context.Context, userID uint64) (bool, e
}
type publicZoneCountCache struct {
Counts map[string]int64
ExpiresAt time.Time
Counts map[string]int64
NonPremiumTotal int64
ExpiresAt time.Time
}
const publicZoneCountCacheTTL = 5 * time.Second
@@ -18,11 +18,11 @@ func (s *Service) ListMine(ctx context.Context, ownerID uint64, siteCode ...stri
return s.repo.ListMine(ctx, ownerID, siteCode...)
}
func (s *Service) FindPublic(ctx context.Context, id uint64, siteCode string) (*ListingDTO, error) {
func (s *Service) FindPublic(ctx context.Context, id uint64, siteCode string, viewerUserID uint64) (*ListingDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.FindPublic(ctx, id, siteCode)
return s.repo.FindPublic(ctx, id, siteCode, viewerUserID)
}
func (s *Service) FindPublicCoverKey(ctx context.Context, id uint64) (string, error) {
@@ -719,14 +719,14 @@ func TestListPublicWithDistributionSiteCode(t *testing.T) {
t.Fatalf("expected public price 12500 with ratio delta, got %d", res.Items[0].PriceCent)
}
detail, err := repo.FindPublic(context.Background(), l1.ID, "site_alpha")
detail, err := repo.FindPublic(context.Background(), l1.ID, "site_alpha", 0)
if err != nil {
t.Fatalf("FindPublic err: %v", err)
}
if detail.PriceCent != 12500 {
t.Fatalf("expected detail price 12500 with ratio delta, got %d", detail.PriceCent)
}
mainDetail, err := repo.FindPublic(context.Background(), l1.ID, "")
mainDetail, err := repo.FindPublic(context.Background(), l1.ID, "", 0)
if err != nil {
t.Fatalf("FindPublic main err: %v", err)
}
@@ -22,6 +22,8 @@ func writeOrderError(c *gin.Context, err error) {
response.Error(c, http.StatusConflict, "listing_unavailable", "该账号暂不可租")
case errors.Is(err, ErrCannotRentOwnListing):
response.BadRequest(c, "不能租用自己发布的账号")
case errors.Is(err, ErrPremiumLevelRequired):
response.Error(c, http.StatusForbidden, "platinum_level_required", "成长等级达到铂金及以上才能租用极品专区账号")
case errors.Is(err, ErrInsufficientBalance):
response.Error(c, http.StatusConflict, "insufficient_balance", "钱包余额不足,请先充值")
case errors.Is(err, ErrChannelPaymentRequired):
@@ -103,6 +103,9 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
DiscountBps: rentergrowth.DefaultDiscountBps,
}
}
if listing.IsPremium && !rentergrowth.CanViewPremiumLevel(growthSnapshot.LevelCode) {
return ErrPremiumLevelRequired
}
rentDiscountAmountCent := rentergrowth.CalculateDiscountCent(pricing.PureCoinAmountCent, pricing.PureCoinPlatformFeeCent, growthSnapshot.DiscountBps)
if rentDiscountAmountCent > 0 {
pricing.PlatformFeeCent -= rentDiscountAmountCent
@@ -10,6 +10,7 @@ var (
ErrInvalidRentHours = errors.New("invalid rent hours")
ErrListingUnavailable = errors.New("listing unavailable")
ErrCannotRentOwnListing = errors.New("cannot rent own listing")
ErrPremiumLevelRequired = errors.New("platinum level required for premium listing")
ErrInsufficientBalance = errors.New("insufficient balance")
ErrOrderCannotPay = errors.New("order cannot pay")
ErrChannelPaymentRequired = errors.New("channel payment required")
@@ -34,7 +34,11 @@ func (r *Repository) Summary(ctx context.Context, userID uint64) (*SummaryDTO, e
if err != nil {
return nil, err
}
level := rentergrowth.LevelForPoints(cfg, user.RenterGrowthPoints)
level := rentergrowth.EffectiveLevelForUser(
cfg,
user.RenterGrowthPoints,
user.RenterGrowthLevelOverride,
)
levels := make([]GrowthLevelDTO, 0, len(cfg.Levels))
for _, item := range cfg.Levels {
levels = append(levels, GrowthLevelDTO{
@@ -221,10 +221,10 @@ func SnapshotForUser(tx *gorm.DB, userID uint64) (DiscountSnapshot, error) {
snapshot.UserPoints = user.RenterGrowthPoints
snapshot.ManualDepositFreeQuotaCent = manualQuotaCent
snapshot.EffectiveDepositFreeQuotaCent = manualQuotaCent
if !cfg.Enabled {
if !cfg.Enabled && strings.TrimSpace(user.RenterGrowthLevelOverride) == "" {
return snapshot, nil
}
level := LevelForPoints(cfg, user.RenterGrowthPoints)
level := EffectiveLevelForUser(cfg, user.RenterGrowthPoints, user.RenterGrowthLevelOverride)
return DiscountSnapshot{
LevelCode: level.Code,
LevelName: level.Name,
@@ -307,7 +307,7 @@ func awardOrderSide(tx *gorm.DB, order *model.RentalOrder, cfg Config, role stri
}
beforePoints := user.RenterGrowthPoints
beforeAvailable := user.PointsAvailable
beforeLevel := LevelForPoints(cfg, beforePoints)
beforeLevel := EffectiveLevelForUser(cfg, beforePoints, user.RenterGrowthLevelOverride)
multiplierBps := beforeLevel.PointsMultiplierBps
points := PointsForAmount(basisCent, cfg.PointsPerYuan, multiplierBps)
if points <= 0 {
@@ -319,7 +319,7 @@ func awardOrderSide(tx *gorm.DB, order *model.RentalOrder, cfg Config, role stri
return nil
}
afterPoints := beforePoints + points
afterLevel := LevelForPoints(cfg, afterPoints)
afterLevel := LevelForUserAfterPoints(cfg, afterPoints, user.RenterGrowthLevelOverride)
user.RenterGrowthPoints = afterPoints
user.RenterGrowthLevel = afterLevel.Code
user.PointsAvailable = beforeAvailable + points
@@ -446,10 +446,10 @@ func revokeOrderSide(tx *gorm.DB, order *model.RentalOrder, cfg Config, role str
beforeAvailable := maxInt64(user.PointsAvailable, 0)
revokedPoints := minInt64(points, beforePoints)
revokedAvailable := minInt64(points, beforeAvailable)
beforeLevel := LevelForPoints(cfg, beforePoints)
beforeLevel := EffectiveLevelForUser(cfg, beforePoints, user.RenterGrowthLevelOverride)
afterPoints := beforePoints - revokedPoints
afterLevel := LevelForPoints(cfg, afterPoints)
if !cfg.Enabled {
afterLevel := LevelForUserAfterPoints(cfg, afterPoints, user.RenterGrowthLevelOverride)
if !cfg.Enabled && user.RenterGrowthLevelOverride == "" {
beforeLevel = LevelRule{Code: DefaultLevelCode, Name: DefaultLevelName}
afterLevel = beforeLevel
}
@@ -516,6 +516,43 @@ func LevelForPoints(cfg Config, points int64) LevelRule {
return current
}
func LevelForCode(cfg Config, code string) (LevelRule, bool) {
cfg = normalizeConfig(cfg)
code = strings.TrimSpace(code)
if code == "" {
return LevelRule{}, false
}
for _, level := range cfg.Levels {
if level.Code == code {
return level, true
}
}
return LevelRule{}, false
}
func EffectiveLevelForUser(cfg Config, points int64, override string) LevelRule {
if level, ok := LevelForCode(cfg, override); ok {
return level
}
return LevelForPoints(cfg, points)
}
func LevelForUserAfterPoints(cfg Config, points int64, override string) LevelRule {
if level, ok := LevelForCode(cfg, override); ok {
return level
}
return LevelForPoints(cfg, points)
}
func CanViewPremiumLevel(code string) bool {
switch strings.TrimSpace(code) {
case "platinum", "diamond", "peak":
return true
default:
return false
}
}
func EffectiveDepositFreeQuotaCent(levelQuotaCent int64, manualQuotaCent int64) int64 {
return addQuotaCent(levelQuotaCent, manualQuotaCent)
}
@@ -526,7 +563,14 @@ func SyncAllUserLevels(tx *gorm.DB, cfg Config) error {
return nil
}
if !cfg.Enabled {
return tx.Model(&model.User{}).Where("renter_growth_level <> ?", DefaultLevelCode).Update("renter_growth_level", DefaultLevelCode).Error
if err := tx.Model(&model.User{}).
Where("renter_growth_level_override = ? AND renter_growth_level <> ?", "", DefaultLevelCode).
Update("renter_growth_level", DefaultLevelCode).Error; err != nil {
return err
}
return tx.Model(&model.User{}).
Where("renter_growth_level_override <> ?", "").
UpdateColumn("renter_growth_level", gorm.Expr("renter_growth_level_override")).Error
}
cfg = normalizeConfig(cfg)
caseSQL := "CASE"
@@ -537,7 +581,14 @@ func SyncAllUserLevels(tx *gorm.DB, cfg Config) error {
}
caseSQL += " ELSE ? END"
args = append(args, DefaultLevelCode)
return tx.Model(&model.User{}).Where("1 = 1").Update("renter_growth_level", gorm.Expr(caseSQL, args...)).Error
if err := tx.Model(&model.User{}).
Where("renter_growth_level_override = ?", "").
Update("renter_growth_level", gorm.Expr(caseSQL, args...)).Error; err != nil {
return err
}
return tx.Model(&model.User{}).
Where("renter_growth_level_override <> ?", "").
UpdateColumn("renter_growth_level", gorm.Expr("renter_growth_level_override")).Error
}
func normalizeConfig(cfg Config) Config {
@@ -128,6 +128,48 @@ func TestLevelForPointsStartsFromNormal(t *testing.T) {
}
}
func TestEffectiveLevelForUserHonorsManualOverride(t *testing.T) {
cfg := DefaultConfig()
if got := EffectiveLevelForUser(cfg, 0, "diamond"); got.Code != "diamond" {
t.Fatalf("手动等级 = %s, want diamond", got.Code)
}
if got := EffectiveLevelForUser(cfg, 5000, ""); got.Code != "peak" {
t.Fatalf("自动等级 = %s, want peak", got.Code)
}
if !CanViewPremiumLevel("platinum") || !CanViewPremiumLevel("diamond") || !CanViewPremiumLevel("peak") {
t.Fatal("铂金及以上等级应可查看极品专区")
}
if CanViewPremiumLevel(DefaultLevelCode) {
t.Fatal("普通等级不应查看极品专区")
}
}
func TestSyncAllUserLevelsPreservesManualOverride(t *testing.T) {
db := database.NewTestDB()
if err := database.AutoMigrateIfMissing(db, &model.User{}); err != nil {
t.Fatalf("migrate failed: %v", err)
}
user := model.User{
Phone: "13800009003",
RenterGrowthPoints: 0,
RenterGrowthLevel: "platinum",
RenterGrowthLevelOverride: "platinum",
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("create user failed: %v", err)
}
if err := SyncAllUserLevels(db, DefaultConfig()); err != nil {
t.Fatalf("SyncAllUserLevels() error = %v", err)
}
var saved model.User
if err := db.First(&saved, user.ID).Error; err != nil {
t.Fatalf("find user failed: %v", err)
}
if saved.RenterGrowthLevel != "platinum" || saved.RenterGrowthLevelOverride != "platinum" {
t.Fatalf("手动等级被同步覆盖:%+v", saved)
}
}
func TestCalculateDiscountCentCapsAtPlatformFee(t *testing.T) {
if got := CalculateDiscountCent(10000, 60, 9900); got != 60 {
t.Fatalf("discount = %d, want capped platform fee 60", got)