feat: 接入旧站管理端并完整迁移后台能力

This commit is contained in:
yml
2026-11-12 22:03:01 +08:00
parent e5f923706b
commit a4441bba9a
509 changed files with 140714 additions and 1068 deletions
@@ -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 {
+38 -26
View File
@@ -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)
}
}
+10
View File
@@ -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)
}
}
+18 -2
View File
@@ -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)
}