feat(user): 支持后台调整等级并限制极品专区可见性
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user