feat(quality-owner): 增加优质号主、极品专区与无忧租号保险
- 支持保证金余额支付、在线支付开通及后台手动设置审计 - 新增极品专区发布权限、赔付方案与保险结算费率快照 - 优化首页、商品卡和个人中心的优质号主身份展示
This commit is contained in:
@@ -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,6 +42,9 @@ 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"`
|
||||
@@ -57,6 +61,9 @@ type CreateRequest struct {
|
||||
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:"-"` // 服务端按配置和用户资格生成,不接受前端指定。
|
||||
|
||||
AgreedVirtualAssetSale bool `json:"agreed_virtual_asset_sale"`
|
||||
AgreedSellerAgreement bool `json:"agreed_seller_agreement"`
|
||||
|
||||
@@ -57,6 +57,8 @@ 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 isUploadValidationError(err):
|
||||
response.BadRequest(c, err.Error())
|
||||
case isFireLevelTooLow(err):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,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"`
|
||||
@@ -171,6 +172,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,
|
||||
@@ -195,6 +197,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,
|
||||
@@ -231,6 +236,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,
|
||||
@@ -238,6 +246,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
|
||||
|
||||
@@ -185,6 +185,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
|
||||
}
|
||||
@@ -198,9 +200,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]++
|
||||
}
|
||||
|
||||
@@ -21,6 +21,17 @@ func TestFilterPublicListingsByRatio(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 TestFilterPublicListingsRegionMultiSelectRequiresAll(t *testing.T) {
|
||||
items := []ListingDTO{
|
||||
{
|
||||
|
||||
@@ -185,7 +185,7 @@ func (r *Repository) publicZoneCountsCached(ctx context.Context) (map[string]int
|
||||
|
||||
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
|
||||
@@ -199,6 +199,7 @@ func (r *Repository) publicZoneCountsCached(ctx context.Context) (map[string]int
|
||||
"night": 0,
|
||||
"password": 0,
|
||||
"highCoin": 0,
|
||||
"premium": 0,
|
||||
}
|
||||
for _, row := range rows {
|
||||
summary := decodeAssetSummary(row.AssetSummary)
|
||||
@@ -217,6 +218,9 @@ func (r *Repository) publicZoneCountsCached(ctx context.Context) (map[string]int
|
||||
if float64(row.HafCoinAmount)/1000000 >= 100 {
|
||||
counts["highCoin"]++
|
||||
}
|
||||
if row.IsPremium {
|
||||
counts["premium"]++
|
||||
}
|
||||
}
|
||||
r.publicZoneCounts = publicZoneCountCache{
|
||||
Counts: counts,
|
||||
@@ -319,4 +323,5 @@ type publicZoneRow struct {
|
||||
LoginPlatform string
|
||||
HafCoinAmount int64
|
||||
AssetSummary datatypes.JSON `gorm:"column:asset_summary"`
|
||||
IsPremium bool `gorm:"column:is_premium"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package listing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"hfb_sys/backend/internal/database"
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -225,6 +225,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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package listing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -57,6 +58,20 @@ 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
|
||||
|
||||
@@ -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,7 @@ 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")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -52,6 +53,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_sys/backend/internal/modules/qualityowner"
|
||||
)
|
||||
|
||||
func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest, siteCode ...string) (*ListingDTO, error) {
|
||||
@@ -30,6 +32,9 @@ func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest,
|
||||
if err := validateRequiredOnlineTime(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.applyPublishEntitlements(ctx, ownerID, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cooldown, err := s.publishCooldown(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -63,6 +68,9 @@ func (s *Service) Update(ctx context.Context, ownerID uint64, id uint64, req Upd
|
||||
if err := validateRequiredOnlineTime(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.applyPublishEntitlements(ctx, ownerID, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reviewRequired, err := s.reviewRequired(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -70,6 +78,45 @@ 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.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
|
||||
|
||||
Reference in New Issue
Block a user