feat: 接入旧站管理端并完整迁移后台能力
This commit is contained in:
@@ -217,6 +217,16 @@ func TestApplyAdminListFiltersSmartRouting(t *testing.T) {
|
||||
query: AdminListQuery{Keyword: "12345"},
|
||||
wantSQLFrag: "l.listing_no = ? OR EXISTS (SELECT 1 FROM rental_orders AS o WHERE o.listing_id = l.id AND o.order_no = ?) OR l.id = ?",
|
||||
},
|
||||
{
|
||||
name: "赔付方案按商品快照筛选",
|
||||
query: AdminListQuery{CompensationPlan: "insurance"},
|
||||
wantSQLFrag: "l.compensation_plan = ?",
|
||||
},
|
||||
{
|
||||
name: "极品专区按商品快照筛选",
|
||||
query: AdminListQuery{Zone: "premium"},
|
||||
wantSQLFrag: "l.is_premium = ?",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -15,6 +15,7 @@ type ListingDTO struct {
|
||||
OwnerID uint64 `json:"owner_id"`
|
||||
OwnerPhone string `json:"owner_phone,omitempty"`
|
||||
OwnerNickname string `json:"owner_nickname,omitempty"`
|
||||
OwnerIsQualityOwner bool `json:"owner_is_quality_owner"`
|
||||
SourceChannel string `json:"source_channel,omitempty"`
|
||||
IsExternalUpload bool `json:"is_external_upload"`
|
||||
Title string `json:"title"`
|
||||
@@ -41,22 +42,29 @@ type ListingDTO struct {
|
||||
DistributionSiteID *uint64 `json:"distribution_site_id,omitempty"`
|
||||
DistributionSiteCode string `json:"distribution_site_code,omitempty"`
|
||||
DistributionSiteName string `json:"distribution_site_name,omitempty"`
|
||||
IsPremium bool `json:"is_premium"`
|
||||
CompensationPlan string `json:"compensation_plan"`
|
||||
InsuranceFeeBps int `json:"insurance_fee_bps"`
|
||||
ListingGroupConversationID uint64 `json:"listing_group_conversation_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
ServerRegion string `json:"server_region" binding:"required"`
|
||||
LoginPlatform string `json:"login_platform"`
|
||||
RankLevel string `json:"rank_level"`
|
||||
HafCoinAmount int64 `json:"haf_coin_amount"`
|
||||
AssetSummary map[string]any `json:"asset_summary"`
|
||||
ScreenshotURLS []string `json:"screenshot_urls"`
|
||||
PriceCent int64 `json:"price_cent"`
|
||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
ServerRegion string `json:"server_region" binding:"required"`
|
||||
LoginPlatform string `json:"login_platform"`
|
||||
RankLevel string `json:"rank_level"`
|
||||
HafCoinAmount int64 `json:"haf_coin_amount"`
|
||||
AssetSummary map[string]any `json:"asset_summary"`
|
||||
ScreenshotURLS []string `json:"screenshot_urls"`
|
||||
PriceCent int64 `json:"price_cent"`
|
||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
||||
IsPremium bool `json:"is_premium"`
|
||||
CompensationPlan string `json:"compensation_plan" binding:"omitempty,oneof=normal insurance"`
|
||||
InsuranceFeeBps int `json:"-"` // 服务端按配置和用户资格生成,不接受前端指定。
|
||||
OwnerIsQualityOwner bool `json:"-"` // 服务端鉴权结果,用于校验优质号主专享比例。
|
||||
|
||||
AgreedVirtualAssetSale bool `json:"agreed_virtual_asset_sale"`
|
||||
AgreedSellerAgreement bool `json:"agreed_seller_agreement"`
|
||||
@@ -102,22 +110,24 @@ type AdminUpdateScreenshotsRequest struct {
|
||||
}
|
||||
|
||||
type AdminListQuery struct {
|
||||
OwnerID uint64
|
||||
Keyword string
|
||||
Status string
|
||||
ReviewStatus string
|
||||
Insurance []string
|
||||
Stamina []string
|
||||
Load []string
|
||||
SkinGroup []string
|
||||
SkinName []string
|
||||
MinCoin *float64
|
||||
MaxCoin *float64
|
||||
MinDeposit *float64
|
||||
MaxDeposit *float64
|
||||
Limit int
|
||||
Page int
|
||||
PageSize int
|
||||
OwnerID uint64
|
||||
Keyword string
|
||||
Status string
|
||||
ReviewStatus string
|
||||
CompensationPlan string
|
||||
Zone string
|
||||
Insurance []string
|
||||
Stamina []string
|
||||
Load []string
|
||||
SkinGroup []string
|
||||
SkinName []string
|
||||
MinCoin *float64
|
||||
MaxCoin *float64
|
||||
MinDeposit *float64
|
||||
MaxDeposit *float64
|
||||
Limit int
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type AdminListResult struct {
|
||||
@@ -160,6 +170,8 @@ type PublicListQuery struct {
|
||||
MaxSecretKD *float64
|
||||
ResourceRanges map[string]NumberRange
|
||||
DistributionSiteCode string
|
||||
ViewerUserID uint64
|
||||
CanViewPremium bool
|
||||
}
|
||||
|
||||
type NumberRange struct {
|
||||
|
||||
@@ -57,6 +57,10 @@ func writeListingError(c *gin.Context, err error) {
|
||||
response.BadRequest(c, "发布比例必须在参考比例至参考比例加 10 之间")
|
||||
case errors.Is(err, ErrDistributionSiteInvalid):
|
||||
response.BadRequest(c, "分销站点无效或已停用")
|
||||
case errors.Is(err, ErrPremiumOwnerRequired):
|
||||
response.Error(c, http.StatusForbidden, "quality_owner_required", "仅优质号主可以发布到极品专区")
|
||||
case errors.Is(err, ErrQualityRatioPremiumOnly):
|
||||
response.BadRequest(c, "优质号主专享比例仅可用于极品专区")
|
||||
case isUploadValidationError(err):
|
||||
response.BadRequest(c, err.Error())
|
||||
case isFireLevelTooLow(err):
|
||||
|
||||
@@ -75,6 +75,8 @@ func parseAdminListQuery(c *gin.Context) (AdminListQuery, bool) {
|
||||
query.Keyword = strings.TrimSpace(c.Query("keyword"))
|
||||
query.Status = c.Query("status")
|
||||
query.ReviewStatus = c.Query("review_status")
|
||||
query.CompensationPlan = strings.TrimSpace(c.Query("compensation_plan"))
|
||||
query.Zone = strings.TrimSpace(c.Query("zone"))
|
||||
query.Insurance = parseCSVQuery(c.Query("insurance"))
|
||||
query.Stamina = parseCSVQuery(c.Query("stamina"))
|
||||
query.Load = parseCSVQuery(c.Query("load"))
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"hfb_jysh/backend/internal/middleware"
|
||||
filemodule "hfb_jysh/backend/internal/modules/file"
|
||||
"hfb_jysh/backend/internal/sitecode"
|
||||
"hfb_jysh/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 {
|
||||
|
||||
@@ -76,6 +76,9 @@ func (r *Repository) Create(ctx context.Context, ownerID uint64, req CreateReque
|
||||
OwnerID: ownerID,
|
||||
PriceCent: priceCent,
|
||||
DepositAmountCent: req.DepositAmountCent,
|
||||
IsPremium: req.IsPremium,
|
||||
CompensationPlan: req.CompensationPlan,
|
||||
InsuranceFeeBps: req.InsuranceFeeBps,
|
||||
Status: listingStatus,
|
||||
ReviewStatus: reviewStatus,
|
||||
PublishedAt: publishedAt,
|
||||
@@ -289,6 +292,9 @@ func (r *Repository) Update(ctx context.Context, ownerID uint64, listingID uint6
|
||||
|
||||
listing.PriceCent = normalizedListingPriceCent(req)
|
||||
listing.DepositAmountCent = req.DepositAmountCent
|
||||
listing.IsPremium = req.IsPremium
|
||||
listing.CompensationPlan = req.CompensationPlan
|
||||
listing.InsuranceFeeBps = req.InsuranceFeeBps
|
||||
fromStatus := listing.Status
|
||||
listingStatus, reviewStatus, publishedAt := initialPublishState(reviewRequired)
|
||||
listing.Status = listingStatus
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package listing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"hfb_jysh/backend/internal/database"
|
||||
"hfb_jysh/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)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ type listingRow struct {
|
||||
Title string
|
||||
OwnerPhone string
|
||||
OwnerNickname string
|
||||
OwnerIsQualityOwner bool `gorm:"column:owner_is_quality_owner"`
|
||||
DisplaySourceChannel string `gorm:"column:display_source_channel"`
|
||||
IsExternalUpload bool `gorm:"column:is_external_upload"`
|
||||
DistributionSiteCode string `gorm:"column:distribution_site_code"`
|
||||
@@ -225,6 +226,7 @@ func (row listingRow) toDTO() ListingDTO {
|
||||
OwnerID: row.OwnerID,
|
||||
OwnerPhone: row.OwnerPhone,
|
||||
OwnerNickname: row.OwnerNickname,
|
||||
OwnerIsQualityOwner: row.OwnerIsQualityOwner,
|
||||
SourceChannel: row.DisplaySourceChannel,
|
||||
IsExternalUpload: row.IsExternalUpload,
|
||||
Title: row.Title,
|
||||
@@ -249,6 +251,9 @@ func (row listingRow) toDTO() ListingDTO {
|
||||
DistributionSiteID: row.DistributionSiteID,
|
||||
DistributionSiteCode: row.DistributionSiteCode,
|
||||
DistributionSiteName: row.DistributionSiteName,
|
||||
IsPremium: row.IsPremium,
|
||||
CompensationPlan: normalizedCompensationPlan(row.CompensationPlan),
|
||||
InsuranceFeeBps: row.InsuranceFeeBps,
|
||||
ReviewReason: reviewReason,
|
||||
PublishedAt: row.PublishedAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
@@ -285,6 +290,9 @@ func toDTO(account model.GameAccount, listing model.RentalListing) *ListingDTO {
|
||||
SettlementMode: listing.SettlementMode,
|
||||
ManagedAdminID: listing.ManagedAdminID,
|
||||
DistributionSiteID: listing.DistributionSiteID,
|
||||
IsPremium: listing.IsPremium,
|
||||
CompensationPlan: normalizedCompensationPlan(listing.CompensationPlan),
|
||||
InsuranceFeeBps: listing.InsuranceFeeBps,
|
||||
ReviewReason: reviewReason,
|
||||
PublishedAt: listing.PublishedAt,
|
||||
CreatedAt: listing.CreatedAt,
|
||||
@@ -292,6 +300,13 @@ func toDTO(account model.GameAccount, listing model.RentalListing) *ListingDTO {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedCompensationPlan(plan string) string {
|
||||
if plan == CompensationPlanInsurance {
|
||||
return CompensationPlanInsurance
|
||||
}
|
||||
return CompensationPlanNormal
|
||||
}
|
||||
|
||||
func normalizedReviewState(status string, reviewStatus string, reviewReason string) (string, string) {
|
||||
if status == "offline" {
|
||||
return "none", reviewReason
|
||||
|
||||
@@ -20,6 +20,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
|
||||
}
|
||||
@@ -188,6 +191,8 @@ func matchesPublicZone(item ListingDTO, zone string) bool {
|
||||
return strings.Contains(item.LoginPlatform, "账密") || strings.Contains(item.LoginPlatform, "账号密码")
|
||||
case "highCoin":
|
||||
return coinMFromListing(item) >= 100
|
||||
case "premium":
|
||||
return item.IsPremium
|
||||
default:
|
||||
return true
|
||||
}
|
||||
@@ -201,9 +206,10 @@ func publicZoneCounts(items []ListingDTO) map[string]int64 {
|
||||
"night": 0,
|
||||
"password": 0,
|
||||
"highCoin": 0,
|
||||
"premium": 0,
|
||||
}
|
||||
for _, item := range items {
|
||||
for _, zone := range []string{"sale", "gift", "night", "password", "highCoin"} {
|
||||
for _, zone := range []string{"sale", "gift", "night", "password", "highCoin", "premium"} {
|
||||
if matchesPublicZone(item, zone) {
|
||||
counts[zone]++
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package listing
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hfb_jysh/backend/internal/database"
|
||||
"hfb_jysh/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestFilterPublicListingsByRatio(t *testing.T) {
|
||||
minRatio := 20.0
|
||||
@@ -46,6 +51,48 @@ func TestFilterPublicListingsByRoundedRatio(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesPublicZonePremium(t *testing.T) {
|
||||
premium := ListingDTO{ID: 1, IsPremium: true}
|
||||
ordinary := ListingDTO{ID: 2}
|
||||
if !matchesPublicZone(premium, "premium") {
|
||||
t.Fatal("premium listing should match premium zone")
|
||||
}
|
||||
if matchesPublicZone(ordinary, "premium") {
|
||||
t.Fatal("ordinary listing should not match premium zone")
|
||||
}
|
||||
}
|
||||
|
||||
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_jysh/backend/internal/model"
|
||||
"hfb_jysh/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,22 +195,22 @@ 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
|
||||
err := r.db.WithContext(ctx).Table("rental_listings AS l").
|
||||
Select("a.login_platform, a.haf_coin_amount, a.asset_summary").
|
||||
Select("a.login_platform, a.haf_coin_amount, a.asset_summary, l.is_premium").
|
||||
Joins("JOIN game_accounts AS a ON a.id = l.account_id").
|
||||
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)),
|
||||
@@ -199,7 +219,9 @@ func (r *Repository) publicZoneCountsCached(ctx context.Context) (map[string]int
|
||||
"night": 0,
|
||||
"password": 0,
|
||||
"highCoin": 0,
|
||||
"premium": 0,
|
||||
}
|
||||
var nonPremiumTotal int64
|
||||
for _, row := range rows {
|
||||
summary := decodeAssetSummary(row.AssetSummary)
|
||||
if isAcceleratedSale(summary) {
|
||||
@@ -217,12 +239,18 @@ func (r *Repository) publicZoneCountsCached(ctx context.Context) (map[string]int
|
||||
if float64(row.HafCoinAmount)/1000000 >= 100 {
|
||||
counts["highCoin"]++
|
||||
}
|
||||
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 {
|
||||
@@ -233,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)
|
||||
applyPublicListingPrice(dto)
|
||||
items := []ListingDTO{*dto}
|
||||
@@ -248,6 +285,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
|
||||
@@ -320,4 +381,5 @@ type publicZoneRow struct {
|
||||
LoginPlatform string
|
||||
HafCoinAmount int64
|
||||
AssetSummary datatypes.JSON `gorm:"column:asset_summary"`
|
||||
IsPremium bool `gorm:"column:is_premium"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package listing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"hfb_jysh/backend/internal/database"
|
||||
"hfb_jysh/backend/internal/model"
|
||||
"hfb_jysh/backend/internal/modules/qualityowner"
|
||||
)
|
||||
|
||||
func TestApplyPublishEntitlementsPremiumInsurance(t *testing.T) {
|
||||
db := database.NewTestDB()
|
||||
owner := model.User{
|
||||
Phone: "13900009311",
|
||||
RealnameStatus: "verified",
|
||||
Status: "active",
|
||||
QualityOwnerStatus: qualityowner.StatusActive,
|
||||
}
|
||||
if err := db.Create(&owner).Error; err != nil {
|
||||
t.Fatalf("创建优质号主失败: %v", err)
|
||||
}
|
||||
service := NewService(NewRepository(db, nil), stubConfigReader{
|
||||
qualityowner.ConfigKey: qualityowner.DefaultConfigValue(),
|
||||
})
|
||||
|
||||
req := CreateRequest{
|
||||
IsPremium: true,
|
||||
CompensationPlan: CompensationPlanInsurance,
|
||||
}
|
||||
if err := service.applyPublishEntitlements(t.Context(), owner.ID, &req); err != nil {
|
||||
t.Fatalf("应用优质号主发布权益失败: %v", err)
|
||||
}
|
||||
if req.InsuranceFeeBps != 400 {
|
||||
t.Fatalf("极品专区保险费率 = %d, want 400", req.InsuranceFeeBps)
|
||||
}
|
||||
if !req.OwnerIsQualityOwner {
|
||||
t.Fatal("优质号主发布请求未标记专享比例资格")
|
||||
}
|
||||
|
||||
ordinary := CreateRequest{CompensationPlan: CompensationPlanInsurance}
|
||||
if err := service.applyPublishEntitlements(t.Context(), owner.ID, &ordinary); err != nil {
|
||||
t.Fatalf("应用普通发布权益失败: %v", err)
|
||||
}
|
||||
if ordinary.InsuranceFeeBps != 800 {
|
||||
t.Fatalf("普通保险费率 = %d, want 800", ordinary.InsuranceFeeBps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPublishEntitlementsRejectsNonQualityPremiumOwner(t *testing.T) {
|
||||
db := database.NewTestDB()
|
||||
owner := model.User{
|
||||
Phone: "13900009312",
|
||||
RealnameStatus: "verified",
|
||||
Status: "active",
|
||||
}
|
||||
if err := db.Create(&owner).Error; err != nil {
|
||||
t.Fatalf("创建普通号主失败: %v", err)
|
||||
}
|
||||
service := NewService(NewRepository(db, nil), stubConfigReader{
|
||||
qualityowner.ConfigKey: qualityowner.DefaultConfigValue(),
|
||||
})
|
||||
req := CreateRequest{IsPremium: true, CompensationPlan: CompensationPlanInsurance}
|
||||
if err := service.applyPublishEntitlements(t.Context(), owner.ID, &req); !errors.Is(err, ErrPremiumOwnerRequired) {
|
||||
t.Fatalf("error = %v, want ErrPremiumOwnerRequired", err)
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,15 @@ func (r *Repository) applyAdminListFilters(db *gorm.DB, query AdminListQuery) *g
|
||||
if query.ReviewStatus != "" {
|
||||
db = db.Where("l.review_status = ?", query.ReviewStatus)
|
||||
}
|
||||
if query.CompensationPlan != "" {
|
||||
db = db.Where("l.compensation_plan = ?", query.CompensationPlan)
|
||||
}
|
||||
switch query.Zone {
|
||||
case "premium":
|
||||
db = db.Where("l.is_premium = ?", true)
|
||||
case "normal":
|
||||
db = db.Where("l.is_premium = ?", false)
|
||||
}
|
||||
if query.MinCoin != nil {
|
||||
db = db.Where(
|
||||
"EXISTS (SELECT 1 FROM game_accounts AS coin_account WHERE coin_account.id = l.account_id AND coin_account.haf_coin_amount >= ?)",
|
||||
@@ -200,6 +209,7 @@ func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
|
||||
return r.db.WithContext(ctx).Table("rental_listings AS l").
|
||||
Select(`l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level,
|
||||
a.haf_coin_amount, a.asset_summary, a.screenshot_urls, COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname,
|
||||
COALESCE(u.quality_owner_status = 'active', 0) AS owner_is_quality_owner,
|
||||
CASE WHEN lu.id IS NULL THEN 0 ELSE 1 END AS is_external_upload,
|
||||
COALESCE(src_site.site_code, '') AS distribution_site_code,
|
||||
COALESCE(src_site.site_name, '') AS distribution_site_name,
|
||||
|
||||
@@ -72,7 +72,7 @@ func (s *Service) applyMainSiteReceivePricing(ctx context.Context, req *CreateRe
|
||||
return err
|
||||
}
|
||||
applyMainSiteReceivePricing(req, options, config, receiveRatioDelta, receiveCostDelta, onDistributionSite)
|
||||
return nil
|
||||
return validateQualityOwnerPricingMode(req, onDistributionSite)
|
||||
}
|
||||
|
||||
func applyMainSiteReceivePricing(req *CreateRequest, options systemconfig.PublishOptionsDTO, config salePriceConfig, receiveRatioDelta, receiveCostDelta float64, onDistributionSite bool) {
|
||||
@@ -93,9 +93,19 @@ func applyMainSiteReceivePricing(req *CreateRequest, options systemconfig.Publis
|
||||
if requestedRatioBPS <= 0 {
|
||||
requestedRatioBPS = summaryBPS(breakdown, "accelerated_sale_ratio_bps", "accelerated_sale_ratio")
|
||||
}
|
||||
minSellerRatioBPS := referenceRatioBPS
|
||||
if req.OwnerIsQualityOwner {
|
||||
minSellerRatioBPS -= ratioToBPS(
|
||||
NormalizeQualityOwnerRatioDiscount(options.RatioConfig.QualityOwnerRatioDiscount),
|
||||
)
|
||||
minFloorBPS := ratioToBPS(minSiteReceiveRatio)
|
||||
if minSellerRatioBPS < minFloorBPS {
|
||||
minSellerRatioBPS = minFloorBPS
|
||||
}
|
||||
}
|
||||
costDelta := NormalizeSiteReceiveCostDelta(receiveCostDelta)
|
||||
ratioDelta := NormalizeSiteReceiveRatioDelta(receiveRatioDelta)
|
||||
sellerRatioBPS := clampSaleRatioBPS(referenceRatioBPS, requestedRatioBPS)
|
||||
sellerRatioBPS := clampSaleRatioFloorBPS(referenceRatioBPS, requestedRatioBPS, minSellerRatioBPS)
|
||||
// 分站新口径:收号比例 = R + 压价点数(收号价更低),买家定价锚定主站参考比例 R,不随收号价联动。
|
||||
if onDistributionSite {
|
||||
sellerRatioBPS = applySiteReceiveCostDeltaBPS(referenceRatioBPS, costDelta)
|
||||
@@ -117,6 +127,33 @@ func applyMainSiteReceivePricing(req *CreateRequest, options systemconfig.Publis
|
||||
consumablePriceCent,
|
||||
config,
|
||||
)
|
||||
if req.IsPremium {
|
||||
premiumMarkupBPS := ratioToBPS(
|
||||
NormalizePremiumReviewRatioMarkup(options.RatioConfig.PremiumReviewRatioMarkup),
|
||||
)
|
||||
if premiumMarkupBPS > 0 && pricing.BuyerRatioBPS > premiumMarkupBPS {
|
||||
beforeRatioBPS := pricing.BuyerRatioBPS
|
||||
afterRatioBPS := beforeRatioBPS - premiumMarkupBPS
|
||||
buyerCoinBasePriceCent := sellerCoinBasePriceCentFromHafCoin(
|
||||
req.HafCoinAmount,
|
||||
afterRatioBPS,
|
||||
)
|
||||
pricing = buildExternalPlatformPricingCent(
|
||||
req.HafCoinAmount,
|
||||
sellerCoinBasePriceCent,
|
||||
consumablePriceCent,
|
||||
buyerCoinBasePriceCent,
|
||||
pricing.RuleType,
|
||||
)
|
||||
breakdown["premium_review_ratio_markup"] = bpsToRatio(premiumMarkupBPS)
|
||||
breakdown["premium_review_ratio_markup_bps"] = premiumMarkupBPS
|
||||
breakdown["premium_review_ratio_before"] = roundRatio(bpsToRatio(beforeRatioBPS))
|
||||
breakdown["premium_review_ratio_before_bps"] = beforeRatioBPS
|
||||
breakdown["premium_review_ratio_after"] = roundRatio(bpsToRatio(pricing.BuyerRatioBPS))
|
||||
breakdown["premium_review_ratio_after_bps"] = pricing.BuyerRatioBPS
|
||||
breakdown["premium_review_ratio_markup_applied"] = true
|
||||
}
|
||||
}
|
||||
dailyLossRatioAdjustmentBPS := calculateDailyLossRatioAdjustmentBPS(req.AssetSummary)
|
||||
|
||||
// 规范字段:整数分 + 基点。
|
||||
@@ -139,6 +176,11 @@ func applyMainSiteReceivePricing(req *CreateRequest, options systemconfig.Publis
|
||||
breakdown["daily_loss_ratio_adjustment"] = bpsToRatio(dailyLossRatioAdjustmentBPS)
|
||||
breakdown["accelerated_sale_ratio"] = bpsToRatio(sellerRatioBPS)
|
||||
breakdown["accelerated_sale_ratio_bps"] = sellerRatioBPS
|
||||
if sellerRatioBPS < referenceRatioBPS {
|
||||
breakdown["quality_owner_ratio_discount"] = bpsToRatio(referenceRatioBPS - sellerRatioBPS)
|
||||
breakdown["quality_owner_ratio_discount_bps"] = referenceRatioBPS - sellerRatioBPS
|
||||
breakdown["pricing_mode"] = "quality_owner_exclusive"
|
||||
}
|
||||
if onDistributionSite {
|
||||
breakdown["site_receive_cost_delta"] = costDelta
|
||||
breakdown["pricing_model"] = "site_cost_decoupled_v1"
|
||||
@@ -155,6 +197,18 @@ func applyMainSiteReceivePricing(req *CreateRequest, options systemconfig.Publis
|
||||
req.PriceCent = pricing.BuyerTotalPriceCent
|
||||
}
|
||||
|
||||
func validateQualityOwnerPricingMode(req *CreateRequest, onDistributionSite bool) error {
|
||||
if req == nil || onDistributionSite || !req.OwnerIsQualityOwner || req.IsPremium {
|
||||
return nil
|
||||
}
|
||||
breakdown := ensurePriceBreakdown(req.AssetSummary)
|
||||
if readSummaryNumber(breakdown["seller_ratio"]) <
|
||||
readSummaryNumber(breakdown["seller_reference_ratio"]) {
|
||||
return ErrQualityRatioPremiumOnly
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NormalizeSiteReceiveCostDelta 将分站收号压价点数限制在 [0, 20]。
|
||||
// 点数越大,分站收号比例越高(相对主站参考比例),收号价越低;0 表示与主站同价收号。
|
||||
func NormalizeSiteReceiveCostDelta(delta float64) float64 {
|
||||
@@ -177,6 +231,45 @@ func ApplySiteReceiveCostDelta(referenceRatio, delta float64) float64 {
|
||||
return siteRatio
|
||||
}
|
||||
|
||||
func clampSaleRatioFloorBPS(referenceBPS, requestedBPS, floorBPS int64) int64 {
|
||||
if referenceBPS <= 0 {
|
||||
return 0
|
||||
}
|
||||
if requestedBPS <= 0 {
|
||||
return referenceBPS
|
||||
}
|
||||
if requestedBPS < floorBPS {
|
||||
return floorBPS
|
||||
}
|
||||
maxBPS := referenceBPS + maxAcceleratedSaleRatioBPSIncrement
|
||||
if requestedBPS > maxBPS {
|
||||
return maxBPS
|
||||
}
|
||||
return requestedBPS
|
||||
}
|
||||
|
||||
// NormalizeQualityOwnerRatioDiscount 限制优质号主专享比例下调值,默认 1、最大 10。
|
||||
func NormalizeQualityOwnerRatioDiscount(value float64) float64 {
|
||||
if value < 0 || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return 0
|
||||
}
|
||||
if value > maxAcceleratedSaleRatioIncrement {
|
||||
return maxAcceleratedSaleRatioIncrement
|
||||
}
|
||||
return roundRatio(value)
|
||||
}
|
||||
|
||||
// NormalizePremiumReviewRatioMarkup 限制极品专区发布时额外增加的比例加价点数,最大 10。
|
||||
func NormalizePremiumReviewRatioMarkup(value float64) float64 {
|
||||
if value < 0 || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return 0
|
||||
}
|
||||
if value > maxAcceleratedSaleRatioIncrement {
|
||||
return maxAcceleratedSaleRatioIncrement
|
||||
}
|
||||
return roundRatio(value)
|
||||
}
|
||||
|
||||
// referenceSellerCoinBasePrice 计算主站参考比例下的号主基础价,用作买家定价锚点。
|
||||
func referenceSellerCoinBasePrice(coinWan, referenceRatio float64) float64 {
|
||||
if referenceRatio <= 0 {
|
||||
@@ -223,7 +316,7 @@ func (s *Service) publishRatioOptions(ctx context.Context) (systemconfig.Publish
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return options, nil
|
||||
}
|
||||
var parsed systemconfig.PublishOptionsDTO
|
||||
parsed := options
|
||||
if err := json.Unmarshal([]byte(value), &parsed); err != nil {
|
||||
return options, nil
|
||||
}
|
||||
@@ -266,13 +359,13 @@ func calculateSellerReferenceRatio(coinM float64, summary map[string]any, option
|
||||
calculateDailyLossRatioAdjustment(dailyLoss)
|
||||
}
|
||||
|
||||
func clampSaleRatio(referenceRatio, requestedRatio float64) float64 {
|
||||
func clampSaleRatio(referenceRatio, requestedRatio, minRatio float64) float64 {
|
||||
maxRatio := roundRatio(referenceRatio + maxAcceleratedSaleRatioIncrement)
|
||||
if requestedRatio <= 0 {
|
||||
return referenceRatio
|
||||
}
|
||||
if requestedRatio < referenceRatio {
|
||||
return referenceRatio
|
||||
if requestedRatio < minRatio {
|
||||
return minRatio
|
||||
}
|
||||
if requestedRatio > maxRatio {
|
||||
return maxRatio
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package listing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"hfb_jysh/backend/internal/modules/systemconfig"
|
||||
@@ -104,6 +105,85 @@ func TestApplyMainSiteReceivePricingKeepsAcceleratedRatioWithinMainSiteRange(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMainSiteReceivePricingIncludesPremiumReviewMarkup(t *testing.T) {
|
||||
req := fullReceivePricingRequest(50)
|
||||
req.IsPremium = true
|
||||
applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, 0, false)
|
||||
breakdown := ensurePriceBreakdown(req.AssetSummary)
|
||||
if got := readSummaryNumber(breakdown["premium_review_ratio_markup"]); got != 1 {
|
||||
t.Fatalf("premium review markup = %v, want 1", got)
|
||||
}
|
||||
if got := readSummaryNumber(breakdown["premium_review_ratio_before"]); got != 45 {
|
||||
t.Fatalf("premium buyer ratio before = %v, want 45", got)
|
||||
}
|
||||
if got := readSummaryNumber(breakdown["premium_review_ratio_after"]); got != 44 {
|
||||
t.Fatalf("premium buyer ratio after = %v, want 44", got)
|
||||
}
|
||||
if applied, _ := breakdown["premium_review_ratio_markup_applied"].(bool); !applied {
|
||||
t.Fatal("premium review markup should be applied before review")
|
||||
}
|
||||
if req.PriceCent != 22727 {
|
||||
t.Fatalf("premium PriceCent = %d, want integer-cent result 22727", req.PriceCent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMainSiteReceivePricingAllowsQualityOwnerExclusiveRatio(t *testing.T) {
|
||||
req := fullReceivePricingRequest(42)
|
||||
req.OwnerIsQualityOwner = true
|
||||
options := systemconfig.DefaultPublishOptions()
|
||||
applyMainSiteReceivePricing(&req, options, defaultSalePriceConfig(), 0, 0, false)
|
||||
breakdown := ensurePriceBreakdown(req.AssetSummary)
|
||||
if readSummaryNumber(breakdown["seller_ratio"]) != 42 {
|
||||
t.Fatalf("quality owner seller_ratio = %v, want 42", breakdown["seller_ratio"])
|
||||
}
|
||||
if readSummaryNumber(breakdown["quality_owner_ratio_discount"]) != 1 {
|
||||
t.Fatalf(
|
||||
"quality owner discount = %v, want 1",
|
||||
breakdown["quality_owner_ratio_discount"],
|
||||
)
|
||||
}
|
||||
|
||||
ordinary := fullReceivePricingRequest(42)
|
||||
applyMainSiteReceivePricing(&ordinary, options, defaultSalePriceConfig(), 0, 0, false)
|
||||
ordinaryBreakdown := ensurePriceBreakdown(ordinary.AssetSummary)
|
||||
if readSummaryNumber(ordinaryBreakdown["seller_ratio"]) != 43 {
|
||||
t.Fatalf("ordinary seller_ratio = %v, want reference 43", ordinaryBreakdown["seller_ratio"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestQualityOwnerExclusiveRatioUsesConfiguredDiscount(t *testing.T) {
|
||||
req := fullReceivePricingRequest(40)
|
||||
req.OwnerIsQualityOwner = true
|
||||
options := systemconfig.DefaultPublishOptions()
|
||||
options.RatioConfig.QualityOwnerRatioDiscount = 3
|
||||
applyMainSiteReceivePricing(&req, options, defaultSalePriceConfig(), 0, 0, false)
|
||||
breakdown := ensurePriceBreakdown(req.AssetSummary)
|
||||
if readSummaryNumber(breakdown["seller_ratio"]) != 40 {
|
||||
t.Fatalf("configured quality owner seller_ratio = %v, want 40", breakdown["seller_ratio"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestQualityOwnerExclusiveRatioRequiresPremiumZone(t *testing.T) {
|
||||
req := fullReceivePricingRequest(42)
|
||||
req.OwnerIsQualityOwner = true
|
||||
applyMainSiteReceivePricing(
|
||||
&req,
|
||||
systemconfig.DefaultPublishOptions(),
|
||||
defaultSalePriceConfig(),
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
)
|
||||
if err := validateQualityOwnerPricingMode(&req, false); !errors.Is(err, ErrQualityRatioPremiumOnly) {
|
||||
t.Fatalf("error = %v, want ErrQualityRatioPremiumOnly", err)
|
||||
}
|
||||
|
||||
req.IsPremium = true
|
||||
if err := validateQualityOwnerPricingMode(&req, false); err != nil {
|
||||
t.Fatalf("极品专区发布不应拒绝优质号主专享比例: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionReceiveCostDeltaZeroKeepsMainSitePrice(t *testing.T) {
|
||||
req := fullReceivePricingRequest(50)
|
||||
applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, 0, true)
|
||||
@@ -169,10 +249,10 @@ func TestApplySiteReceiveRatioDeltaCapsAtMainSite(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestClampSaleRatioRejectsBelowReference(t *testing.T) {
|
||||
if got := clampSaleRatio(43, 10); got != 43 {
|
||||
if got := clampSaleRatio(43, 10, 43); got != 43 {
|
||||
t.Fatalf("clamp below reference = %v, want 43", got)
|
||||
}
|
||||
if got := clampSaleRatio(43, 80); got != 53 {
|
||||
if got := clampSaleRatio(43, 80, 43); got != 53 {
|
||||
t.Fatalf("clamp above max = %v, want 53", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package listing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -57,9 +58,24 @@ func (r *Repository) syncPublishedShelf(tx *gorm.DB, listing *model.RentalListin
|
||||
return r.shelfSyncer.EnsureListingShelvedTx(tx, listing.ID, listing.AccountID)
|
||||
}
|
||||
|
||||
func (r *Repository) IsQualityOwner(ctx context.Context, userID uint64) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, ErrDependencyUnavailable
|
||||
}
|
||||
var status string
|
||||
err := r.db.WithContext(ctx).Model(&model.User{}).
|
||||
Where("id = ?", userID).
|
||||
Pluck("quality_owner_status", &status).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return status == "active", nil
|
||||
}
|
||||
|
||||
type publicZoneCountCache struct {
|
||||
Counts map[string]int64
|
||||
ExpiresAt time.Time
|
||||
Counts map[string]int64
|
||||
NonPremiumTotal int64
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
const publicZoneCountCacheTTL = 5 * time.Second
|
||||
|
||||
@@ -61,6 +61,9 @@ func (r *Repository) TransferOwner(ctx context.Context, adminID uint64, listingI
|
||||
}
|
||||
return err
|
||||
}
|
||||
if listing.IsPremium && target.QualityOwnerStatus != "active" {
|
||||
return ErrPremiumOwnerRequired
|
||||
}
|
||||
|
||||
beforeOwnerID := listing.OwnerID
|
||||
listing.OwnerID = target.ID
|
||||
|
||||
@@ -33,6 +33,8 @@ var (
|
||||
ErrExternalAdjustment = errors.New("invalid external listing adjustment")
|
||||
ErrSaleRatioOutOfRange = errors.New("sale ratio out of range")
|
||||
ErrDistributionSiteInvalid = errors.New("invalid distribution site")
|
||||
ErrPremiumOwnerRequired = errors.New("premium listing requires quality owner")
|
||||
ErrQualityRatioPremiumOnly = errors.New("quality owner exclusive ratio requires premium zone")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -52,6 +54,8 @@ const (
|
||||
defaultPublishCooldownMinutes = 5
|
||||
maxExternalUploadItems = 10
|
||||
defaultUploadScreenshot = "/api/listings/default-upload-screenshot?v=webp1"
|
||||
CompensationPlanNormal = "normal"
|
||||
CompensationPlanInsurance = "insurance"
|
||||
)
|
||||
|
||||
type PublishCooldownError struct {
|
||||
|
||||
@@ -3,6 +3,8 @@ package listing
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"hfb_jysh/backend/internal/modules/qualityowner"
|
||||
)
|
||||
|
||||
func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest, siteCode ...string) (*ListingDTO, error) {
|
||||
@@ -16,6 +18,9 @@ func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest,
|
||||
if len(siteCode) > 0 {
|
||||
effectiveSiteCode = strings.TrimSpace(siteCode[0])
|
||||
}
|
||||
if err := s.applyPublishEntitlements(ctx, ownerID, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delta, onDistribution := s.repo.resolveSiteReceiveCostDelta(ctx, 0, effectiveSiteCode)
|
||||
if err := s.applyMainSiteReceivePricing(ctx, &req, 0, delta, onDistribution); err != nil {
|
||||
return nil, err
|
||||
@@ -49,6 +54,9 @@ func (s *Service) Update(ctx context.Context, ownerID uint64, id uint64, req Upd
|
||||
if len(siteCode) > 0 {
|
||||
effectiveSiteCode = strings.TrimSpace(siteCode[0])
|
||||
}
|
||||
if err := s.applyPublishEntitlements(ctx, ownerID, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delta, onDistribution := s.repo.resolveSiteReceiveCostDelta(ctx, id, effectiveSiteCode)
|
||||
if err := s.applyMainSiteReceivePricing(ctx, &req, 0, delta, onDistribution); err != nil {
|
||||
return nil, err
|
||||
@@ -70,6 +78,46 @@ func (s *Service) Update(ctx context.Context, ownerID uint64, id uint64, req Upd
|
||||
return s.repo.Update(ctx, ownerID, id, req, reviewRequired)
|
||||
}
|
||||
|
||||
func (s *Service) applyPublishEntitlements(ctx context.Context, ownerID uint64, req *CreateRequest) error {
|
||||
req.CompensationPlan = strings.TrimSpace(req.CompensationPlan)
|
||||
if req.CompensationPlan == "" {
|
||||
req.CompensationPlan = CompensationPlanNormal
|
||||
}
|
||||
if req.CompensationPlan != CompensationPlanNormal && req.CompensationPlan != CompensationPlanInsurance {
|
||||
return ErrInvalidInput
|
||||
}
|
||||
|
||||
isQualityOwner, err := s.repo.IsQualityOwner(ctx, ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if req.IsPremium && !isQualityOwner {
|
||||
return ErrPremiumOwnerRequired
|
||||
}
|
||||
req.OwnerIsQualityOwner = isQualityOwner
|
||||
|
||||
req.InsuranceFeeBps = 0
|
||||
if req.CompensationPlan != CompensationPlanInsurance {
|
||||
return nil
|
||||
}
|
||||
rules := qualityowner.DefaultRules()
|
||||
if s.config != nil {
|
||||
value, err := s.config.FindValue(ctx, qualityowner.ConfigKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rules, err = qualityowner.ParseConfigValue(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
req.InsuranceFeeBps = rules.InsuranceFeeBps
|
||||
if req.IsPremium && isQualityOwner {
|
||||
req.InsuranceFeeBps = rules.PremiumInsuranceFeeBps
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) SubmitReview(ctx context.Context, ownerID uint64, id uint64) (*ListingDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
|
||||
@@ -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