feat: 完善商品筛选、发布配置与优质号主流程
This commit is contained in:
@@ -158,6 +158,16 @@ func TestApplyAdminListFiltersSmartRouting(t *testing.T) {
|
|||||||
query: AdminListQuery{Keyword: "12345"},
|
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 = ?",
|
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 {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -110,22 +110,24 @@ type AdminUpdateScreenshotsRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AdminListQuery struct {
|
type AdminListQuery struct {
|
||||||
OwnerID uint64
|
OwnerID uint64
|
||||||
Keyword string
|
Keyword string
|
||||||
Status string
|
Status string
|
||||||
ReviewStatus string
|
ReviewStatus string
|
||||||
Insurance []string
|
CompensationPlan string
|
||||||
Stamina []string
|
Zone string
|
||||||
Load []string
|
Insurance []string
|
||||||
SkinGroup []string
|
Stamina []string
|
||||||
SkinName []string
|
Load []string
|
||||||
MinCoin *float64
|
SkinGroup []string
|
||||||
MaxCoin *float64
|
SkinName []string
|
||||||
MinDeposit *float64
|
MinCoin *float64
|
||||||
MaxDeposit *float64
|
MaxCoin *float64
|
||||||
Limit int
|
MinDeposit *float64
|
||||||
Page int
|
MaxDeposit *float64
|
||||||
PageSize int
|
Limit int
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminListResult struct {
|
type AdminListResult struct {
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ func parseAdminListQuery(c *gin.Context) (AdminListQuery, bool) {
|
|||||||
query.Keyword = strings.TrimSpace(c.Query("keyword"))
|
query.Keyword = strings.TrimSpace(c.Query("keyword"))
|
||||||
query.Status = c.Query("status")
|
query.Status = c.Query("status")
|
||||||
query.ReviewStatus = c.Query("review_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.Insurance = parseCSVQuery(c.Query("insurance"))
|
||||||
query.Stamina = parseCSVQuery(c.Query("stamina"))
|
query.Stamina = parseCSVQuery(c.Query("stamina"))
|
||||||
query.Load = parseCSVQuery(c.Query("load"))
|
query.Load = parseCSVQuery(c.Query("load"))
|
||||||
|
|||||||
@@ -131,6 +131,15 @@ func (r *Repository) applyAdminListFilters(db *gorm.DB, query AdminListQuery) *g
|
|||||||
if query.ReviewStatus != "" {
|
if query.ReviewStatus != "" {
|
||||||
db = db.Where("l.review_status = ?", 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 {
|
if query.MinCoin != nil {
|
||||||
db = db.Where(
|
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 >= ?)",
|
"EXISTS (SELECT 1 FROM game_accounts AS coin_account WHERE coin_account.id = l.account_id AND coin_account.haf_coin_amount >= ?)",
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func (r *Repository) prepareQualityOwnerPayment(ctx context.Context, userID uint
|
|||||||
Order("id DESC").
|
Order("id DESC").
|
||||||
First(&existing).Error
|
First(&existing).Error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if canReuseOrderPayment(existing, *runtimeConfig) {
|
if canReuseQualityOwnerPayment(existing, *runtimeConfig) {
|
||||||
existing.PayWay = firstNonEmpty(req.PayWay, existing.PayWay, runtimeConfig.PayWay, "ZFBZF")
|
existing.PayWay = firstNonEmpty(req.PayWay, existing.PayWay, runtimeConfig.PayWay, "ZFBZF")
|
||||||
existing.JSPayFlag = firstNonEmpty(req.JSPayFlag, existing.JSPayFlag, runtimeConfig.JSPayFlag, "2")
|
existing.JSPayFlag = firstNonEmpty(req.JSPayFlag, existing.JSPayFlag, runtimeConfig.JSPayFlag, "2")
|
||||||
existing.AmountCent = amountCent
|
existing.AmountCent = amountCent
|
||||||
@@ -87,6 +87,15 @@ func (r *Repository) prepareQualityOwnerPayment(ctx context.Context, userID uint
|
|||||||
return r.findPaymentByID(ctx, paymentID)
|
return r.findPaymentByID(ctx, paymentID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// canReuseQualityOwnerPayment 仅复用尚未完成的有效支付单。
|
||||||
|
// 已支付单属于上一轮开通周期,取消后必须重新创建支付单,避免“旧单已支付”被误当成新一轮开通。
|
||||||
|
func canReuseQualityOwnerPayment(payment model.PaymentOrder, runtimeConfig runtimePaymentConfig) bool {
|
||||||
|
if payment.Status == "paid" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return canReuseOrderPayment(payment, runtimeConfig)
|
||||||
|
}
|
||||||
|
|
||||||
func newQualityOwnerPayment(userID uint64, amountCent int64, req StartPaymentRequest, runtimeConfig runtimePaymentConfig) (model.PaymentOrder, error) {
|
func newQualityOwnerPayment(userID uint64, amountCent int64, req StartPaymentRequest, runtimeConfig runtimePaymentConfig) (model.PaymentOrder, error) {
|
||||||
paymentNo, err := newPaymentNo()
|
paymentNo, err := newPaymentNo()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package payment
|
package payment
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/database"
|
"hfb_sys/backend/internal/database"
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
"hfb_sys/backend/internal/modules/paymentconfig"
|
||||||
"hfb_sys/backend/internal/modules/qualityowner"
|
"hfb_sys/backend/internal/modules/qualityowner"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -95,3 +97,168 @@ func TestNewQualityOwnerPaymentUsesBizIDAndMockCashier(t *testing.T) {
|
|||||||
t.Fatalf("mock 支付单未生成收银台: %+v", payment)
|
t.Fatalf("mock 支付单未生成收银台: %+v", payment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCanReuseQualityOwnerPaymentRejectsPaidActivation(t *testing.T) {
|
||||||
|
config := runtimePaymentConfig{
|
||||||
|
Provider: "leshua",
|
||||||
|
MerchantID: "merchant-1",
|
||||||
|
PayWay: "ZFBZF",
|
||||||
|
}
|
||||||
|
paid := model.PaymentOrder{
|
||||||
|
Status: "paid",
|
||||||
|
Provider: "leshua",
|
||||||
|
MerchantID: "merchant-1",
|
||||||
|
PayWay: "ZFBZF",
|
||||||
|
}
|
||||||
|
if canReuseQualityOwnerPayment(paid, config) {
|
||||||
|
t.Fatal("已完成的上轮保证金支付单不应被复用")
|
||||||
|
}
|
||||||
|
|
||||||
|
paying := paid
|
||||||
|
paying.Status = "paying"
|
||||||
|
if !canReuseQualityOwnerPayment(paying, config) {
|
||||||
|
t.Fatal("同一配置下尚未完成的支付单应继续复用")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQualityOwnerCanReactivateAfterCancelAndOldPaymentCannotReactivate(t *testing.T) {
|
||||||
|
db := setupPaymentTestDB(t)
|
||||||
|
user := model.User{
|
||||||
|
Phone: "13900009402",
|
||||||
|
RealnameStatus: "verified",
|
||||||
|
Status: "active",
|
||||||
|
}
|
||||||
|
if err := db.Create(&user).Error; err != nil {
|
||||||
|
t.Fatalf("创建用户失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&model.WalletAccount{
|
||||||
|
UserID: user.ID,
|
||||||
|
Status: "active",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("创建钱包失败: %v", err)
|
||||||
|
}
|
||||||
|
config := model.PaymentMerchantConfig{
|
||||||
|
Name: "优质号主模拟支付",
|
||||||
|
Provider: "mock",
|
||||||
|
MerchantID: "QUALITY_OWNER_MOCK",
|
||||||
|
PayWay: "ZFBZF",
|
||||||
|
IsDefault: true,
|
||||||
|
Status: "active",
|
||||||
|
Environment: "sandbox",
|
||||||
|
}
|
||||||
|
if err := db.Create(&config).Error; err != nil {
|
||||||
|
t.Fatalf("创建支付配置失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
qualityOwnerRepo := qualityowner.NewRepository(db)
|
||||||
|
paymentRepo := NewRepository(
|
||||||
|
db,
|
||||||
|
paymentconfig.NewRepository(db, &paymentconfig.MockEncryptor{}),
|
||||||
|
nil,
|
||||||
|
WithQualityOwnerRepo(qualityOwnerRepo),
|
||||||
|
)
|
||||||
|
|
||||||
|
first, err := paymentRepo.StartQualityOwner(t.Context(), user.ID, StartPaymentRequest{PayWay: "ZFBZF"}, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("首次在线开通失败: %v", err)
|
||||||
|
}
|
||||||
|
if !first.Paid {
|
||||||
|
t.Fatalf("首次支付状态 = %q, want paid", first.Status)
|
||||||
|
}
|
||||||
|
status, err := qualityOwnerRepo.Status(t.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取首次开通状态失败: %v", err)
|
||||||
|
}
|
||||||
|
if !status.Active {
|
||||||
|
t.Fatal("首次支付后应开通优质号主")
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err = qualityOwnerRepo.Cancel(t.Context(), user.ID, qualityowner.AuditMeta{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("取消优质号主失败: %v", err)
|
||||||
|
}
|
||||||
|
if status.Active {
|
||||||
|
t.Fatal("取消后不应保持优质号主身份")
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstPayment model.PaymentOrder
|
||||||
|
if err := db.First(&firstPayment, first.ID).Error; err != nil {
|
||||||
|
t.Fatalf("读取首次支付单失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := paymentRepo.confirmPaid(
|
||||||
|
t.Context(),
|
||||||
|
&firstPayment,
|
||||||
|
"2",
|
||||||
|
time.Now(),
|
||||||
|
map[string]string{"status": "2", "third_order_id": firstPayment.ThirdOrderID},
|
||||||
|
channelSourceMock,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("重放旧支付回调失败: %v", err)
|
||||||
|
}
|
||||||
|
status, err = qualityOwnerRepo.Status(t.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取旧回调后状态失败: %v", err)
|
||||||
|
}
|
||||||
|
if status.Active {
|
||||||
|
t.Fatal("取消后重放旧支付回调不应重新开通优质号主")
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := paymentRepo.StartQualityOwner(t.Context(), user.ID, StartPaymentRequest{PayWay: "ZFBZF"}, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("取消后再次在线开通失败: %v", err)
|
||||||
|
}
|
||||||
|
if !second.Paid {
|
||||||
|
t.Fatalf("第二次支付状态 = %q, want paid", second.Status)
|
||||||
|
}
|
||||||
|
if second.ID == first.ID || second.PaymentNo == first.PaymentNo {
|
||||||
|
t.Fatalf("第二次开通复用了旧支付单: first=%+v second=%+v", first, second)
|
||||||
|
}
|
||||||
|
status, err = qualityOwnerRepo.Status(t.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取第二次开通状态失败: %v", err)
|
||||||
|
}
|
||||||
|
if !status.Active || status.DepositSource != qualityowner.DepositSourcePaid {
|
||||||
|
t.Fatalf("第二次在线支付后状态不正确: %+v", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var paymentCount int64
|
||||||
|
if err := db.Model(&model.PaymentOrder{}).
|
||||||
|
Where("user_id = ? AND biz_type = ?", user.ID, qualityOwnerPayBizType).
|
||||||
|
Count(&paymentCount).Error; err != nil {
|
||||||
|
t.Fatalf("统计保证金支付单失败: %v", err)
|
||||||
|
}
|
||||||
|
if paymentCount != 2 {
|
||||||
|
t.Fatalf("保证金支付单数量 = %d, want 2", paymentCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
var duplicateFrozenCount int64
|
||||||
|
if err := db.Model(&model.WalletLedger{}).
|
||||||
|
Where(
|
||||||
|
"user_id = ? AND biz_type = ? AND biz_no = ? AND direction = ? AND balance_type = ?",
|
||||||
|
user.ID,
|
||||||
|
"quality_owner_deposit",
|
||||||
|
first.PaymentNo,
|
||||||
|
"in",
|
||||||
|
"frozen",
|
||||||
|
).
|
||||||
|
Count(&duplicateFrozenCount).Error; err != nil {
|
||||||
|
t.Fatalf("统计首次保证金冻结流水失败: %v", err)
|
||||||
|
}
|
||||||
|
if duplicateFrozenCount != 1 {
|
||||||
|
t.Fatalf("首次支付冻结流水数量 = %d, want 1", duplicateFrozenCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmQualityOwnerPaymentRejectsEmptyPaymentNo(t *testing.T) {
|
||||||
|
db := setupPaymentTestDB(t)
|
||||||
|
repo := NewRepository(db, nil, nil, WithQualityOwnerRepo(qualityowner.NewRepository(db)))
|
||||||
|
payment := model.PaymentOrder{
|
||||||
|
BizType: qualityOwnerPayBizType,
|
||||||
|
UserID: 1,
|
||||||
|
AmountCent: 20_000,
|
||||||
|
}
|
||||||
|
err := repo.confirmPaid(t.Context(), &payment, "2", time.Now(), nil, channelSourceMock)
|
||||||
|
if !errors.Is(err, qualityowner.ErrInvalidUser) {
|
||||||
|
t.Fatalf("空支付单号 error = %v, want quality owner invalid user", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -121,13 +121,31 @@ func (r *Repository) PaymentAmount(ctx context.Context, userID uint64) (int64, b
|
|||||||
// ConfirmQualityOwnerDepositPaidTx 在支付回调事务内完成在线保证金入账并开通资格。
|
// ConfirmQualityOwnerDepositPaidTx 在支付回调事务内完成在线保证金入账并开通资格。
|
||||||
// 外部支付已经由渠道完成,因此这里只增加冻结余额,不再扣减可用余额。
|
// 外部支付已经由渠道完成,因此这里只增加冻结余额,不再扣减可用余额。
|
||||||
func (r *Repository) ConfirmQualityOwnerDepositPaidTx(tx *gorm.DB, userID uint64, amountCent int64, paymentNo string) error {
|
func (r *Repository) ConfirmQualityOwnerDepositPaidTx(tx *gorm.DB, userID uint64, amountCent int64, paymentNo string) error {
|
||||||
if tx == nil || userID == 0 || amountCent <= 0 {
|
paymentNo = strings.TrimSpace(paymentNo)
|
||||||
|
if tx == nil || userID == 0 || amountCent <= 0 || paymentNo == "" {
|
||||||
return ErrInvalidUser
|
return ErrInvalidUser
|
||||||
}
|
}
|
||||||
var user model.User
|
var user model.User
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
var appliedCount int64
|
||||||
|
if err := tx.Model(&model.WalletLedger{}).
|
||||||
|
Where(
|
||||||
|
"user_id = ? AND distribution_site_id = 0 AND biz_type = ? AND biz_no = ? AND direction = ? AND balance_type = ?",
|
||||||
|
userID,
|
||||||
|
"quality_owner_deposit",
|
||||||
|
paymentNo,
|
||||||
|
"in",
|
||||||
|
"frozen",
|
||||||
|
).
|
||||||
|
Count(&appliedCount).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 同一支付单已消费过时保持幂等。即使资格后来被取消,也不能让旧支付回调重新开通。
|
||||||
|
if appliedCount > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
// 同一支付回调可能重复到达;已经是优质号主时保持幂等。
|
// 同一支付回调可能重复到达;已经是优质号主时保持幂等。
|
||||||
if user.QualityOwnerStatus == StatusActive {
|
if user.QualityOwnerStatus == StatusActive {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ type PublishPriceConfig struct {
|
|||||||
DepositPlaceholder string `json:"deposit_placeholder"`
|
DepositPlaceholder string `json:"deposit_placeholder"`
|
||||||
DepositHint string `json:"deposit_hint"`
|
DepositHint string `json:"deposit_hint"`
|
||||||
DepositTooltip string `json:"deposit_tooltip"`
|
DepositTooltip string `json:"deposit_tooltip"`
|
||||||
|
PremiumZoneTooltip string `json:"premium_zone_tooltip"`
|
||||||
CompensationNote string `json:"compensation_note"`
|
CompensationNote string `json:"compensation_note"`
|
||||||
PricePlaceholder string `json:"price_placeholder"`
|
PricePlaceholder string `json:"price_placeholder"`
|
||||||
RatioDescription string `json:"ratio_description"`
|
RatioDescription string `json:"ratio_description"`
|
||||||
|
|||||||
@@ -234,6 +234,9 @@ func normalizePublishOptions(options *PublishOptionsDTO) {
|
|||||||
if options.PriceConfig.DepositTooltip == "" {
|
if options.PriceConfig.DepositTooltip == "" {
|
||||||
options.PriceConfig.DepositTooltip = defaults.PriceConfig.DepositTooltip
|
options.PriceConfig.DepositTooltip = defaults.PriceConfig.DepositTooltip
|
||||||
}
|
}
|
||||||
|
if options.PriceConfig.PremiumZoneTooltip == "" {
|
||||||
|
options.PriceConfig.PremiumZoneTooltip = defaults.PriceConfig.PremiumZoneTooltip
|
||||||
|
}
|
||||||
if options.PriceConfig.CompensationNote == "" {
|
if options.PriceConfig.CompensationNote == "" {
|
||||||
options.PriceConfig.CompensationNote = defaults.PriceConfig.CompensationNote
|
options.PriceConfig.CompensationNote = defaults.PriceConfig.CompensationNote
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,13 @@ func DefaultPublishOptions() PublishOptionsDTO {
|
|||||||
4. 额外保障选项
|
4. 额外保障选项
|
||||||
|
|
||||||
若您在设置足额押金后仍感不放心,可选择购买平台提供的《全额包赔服务》。一旦发生封号损失,将获得账号市场价值的全额赔付,让您的出租过程后顾无忧。`,
|
若您在设置足额押金后仍感不放心,可选择购买平台提供的《全额包赔服务》。一旦发生封号损失,将获得账号市场价值的全额赔付,让您的出租过程后顾无忧。`,
|
||||||
|
PremiumZoneTooltip: `极品专区是面向优质号主和高价值账号的专属展示区。
|
||||||
|
|
||||||
|
1. 发布条件:仅已认证且有效的优质号主可以开启;未开通时需先缴纳平台要求的信用保证金并完成优质号主认证。
|
||||||
|
2. 专区展示:商品审核通过后将在首页“极品专区”展示,获得更聚焦的高端账号曝光。
|
||||||
|
3. 价格规则:系统会按后台配置在基础比例上计算极品专区专享比例,审核页展示的最终价格通过后不再改价。
|
||||||
|
4. 保险服务:可选择无忧租号保险,服务费率按优质号主及极品专区的当前配置执行,并从卖家实际到手租金中扣除。
|
||||||
|
5. 责任说明:开启专区不会免除商品审核、资料真实性及发布协议中的责任,请如实填写账号资产与出租条件。`,
|
||||||
CompensationNote: "普通赔付方案如下:账号在租期内或祖期外出现封禁,追回、洗号等情况,最高可获赔全款相金+押金\n\n无忧保险赔付方案:按账号市场价值进行赔付(可参照螃蟹,盼之等主流交易平台)。注:选择此方案,需额外抽取订单佣金的8.0%作为服务费:押金须达到推荐押金方可购买。",
|
CompensationNote: "普通赔付方案如下:账号在租期内或祖期外出现封禁,追回、洗号等情况,最高可获赔全款相金+押金\n\n无忧保险赔付方案:按账号市场价值进行赔付(可参照螃蟹,盼之等主流交易平台)。注:选择此方案,需额外抽取订单佣金的8.0%作为服务费:押金须达到推荐押金方可购买。",
|
||||||
PricePlaceholder: "填写币数、保险、体力和负重后自动计算;发布总价=纯币基础价+额外消耗品",
|
PricePlaceholder: "填写币数、保险、体力和负重后自动计算;发布总价=纯币基础价+额外消耗品",
|
||||||
RatioDescription: "1:xx 表示 1 元人民币(RMB)等价兑换 xx 万哈夫币",
|
RatioDescription: "1:xx 表示 1 元人民币(RMB)等价兑换 xx 万哈夫币",
|
||||||
|
|||||||
@@ -164,6 +164,20 @@ func TestNormalizePublishOptionsValueDefaultsCompensationNote(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizePublishOptionsValueDefaultsPremiumZoneTooltip(t *testing.T) {
|
||||||
|
normalized, err := normalizePublishOptionsValue(`{"schema_version":2}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("规范化失败: %v", err)
|
||||||
|
}
|
||||||
|
var options PublishOptionsDTO
|
||||||
|
if err := json.Unmarshal([]byte(normalized), &options); err != nil {
|
||||||
|
t.Fatalf("解析规范化结果失败: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(options.PriceConfig.PremiumZoneTooltip, "极品专区是面向优质号主") {
|
||||||
|
t.Fatalf("极品专区说明默认文案缺失: %q", options.PriceConfig.PremiumZoneTooltip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizePublishOptionsValuePreservesExplicitZeroPremiumReviewRatioMarkup(t *testing.T) {
|
func TestNormalizePublishOptionsValuePreservesExplicitZeroPremiumReviewRatioMarkup(t *testing.T) {
|
||||||
options := DefaultPublishOptions()
|
options := DefaultPublishOptions()
|
||||||
options.RatioConfig.PremiumReviewRatioMarkup = 0
|
options.RatioConfig.PremiumReviewRatioMarkup = 0
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ async function handleSave() {
|
|||||||
|
|
||||||
<div class="editor-block">
|
<div class="editor-block">
|
||||||
<div class="editor-block-title">
|
<div class="editor-block-title">
|
||||||
<strong>押金与价格提示</strong>
|
<strong>押金、专区与价格提示</strong>
|
||||||
</div>
|
</div>
|
||||||
<el-form-item label="押金提示">
|
<el-form-item label="押金提示">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -344,6 +344,14 @@ async function handleSave() {
|
|||||||
placeholder="支持换行,发布页点击或悬停押金旁的说明图标时展示"
|
placeholder="支持换行,发布页点击或悬停押金旁的说明图标时展示"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="极品专区说明">
|
||||||
|
<el-input
|
||||||
|
v-model="publishOptionsDraft.price_config.premium_zone_tooltip"
|
||||||
|
type="textarea"
|
||||||
|
:rows="10"
|
||||||
|
placeholder="支持换行,发布页悬停或点击极品专区旁的说明图标时展示"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="赔付方案说明">
|
<el-form-item label="赔付方案说明">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="publishOptionsDraft.price_config.compensation_note"
|
v-model="publishOptionsDraft.price_config.compensation_note"
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ const filters = reactive<AdminListingQuery>({
|
|||||||
owner_id: '',
|
owner_id: '',
|
||||||
status: '',
|
status: '',
|
||||||
review_status: '',
|
review_status: '',
|
||||||
|
compensation_plan: '',
|
||||||
|
zone: '',
|
||||||
insurance: '',
|
insurance: '',
|
||||||
stamina: '',
|
stamina: '',
|
||||||
load: '',
|
load: '',
|
||||||
@@ -178,6 +180,8 @@ function resetFilters() {
|
|||||||
filters.owner_id = ''
|
filters.owner_id = ''
|
||||||
filters.status = ''
|
filters.status = ''
|
||||||
filters.review_status = ''
|
filters.review_status = ''
|
||||||
|
filters.compensation_plan = ''
|
||||||
|
filters.zone = ''
|
||||||
filters.min_coin = undefined
|
filters.min_coin = undefined
|
||||||
filters.max_coin = undefined
|
filters.max_coin = undefined
|
||||||
filters.min_deposit = undefined
|
filters.min_deposit = undefined
|
||||||
@@ -797,6 +801,18 @@ function formatQuantity(value: number) {
|
|||||||
<el-option label="已拒绝" value="rejected" />
|
<el-option label="已拒绝" value="rejected" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item class="filter-select" label="赔付方案">
|
||||||
|
<el-select v-model="filters.compensation_plan" clearable placeholder="全部方案">
|
||||||
|
<el-option label="无忧保险" value="insurance" />
|
||||||
|
<el-option label="普通赔付" value="normal" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item class="filter-select" label="专区">
|
||||||
|
<el-select v-model="filters.zone" clearable placeholder="全部专区">
|
||||||
|
<el-option label="普通专区" value="normal" />
|
||||||
|
<el-option label="极品专区" value="premium" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item class="filter-select" label="资产">
|
<el-form-item class="filter-select" label="资产">
|
||||||
<el-popover
|
<el-popover
|
||||||
:visible="activeFilterPopover === 'asset'"
|
:visible="activeFilterPopover === 'asset'"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ describe('mergeListingPublishOptions', () => {
|
|||||||
deposit_placeholder: '',
|
deposit_placeholder: '',
|
||||||
deposit_hint: '',
|
deposit_hint: '',
|
||||||
deposit_tooltip: '自定义押金说明',
|
deposit_tooltip: '自定义押金说明',
|
||||||
|
premium_zone_tooltip: '',
|
||||||
price_placeholder: '',
|
price_placeholder: '',
|
||||||
ratio_description: '',
|
ratio_description: '',
|
||||||
},
|
},
|
||||||
@@ -31,6 +32,7 @@ describe('mergeListingPublishOptions', () => {
|
|||||||
deposit_placeholder: '',
|
deposit_placeholder: '',
|
||||||
deposit_hint: '',
|
deposit_hint: '',
|
||||||
deposit_tooltip: '',
|
deposit_tooltip: '',
|
||||||
|
premium_zone_tooltip: '',
|
||||||
compensation_note: '自定义赔付方案说明',
|
compensation_note: '自定义赔付方案说明',
|
||||||
price_placeholder: '',
|
price_placeholder: '',
|
||||||
ratio_description: '',
|
ratio_description: '',
|
||||||
@@ -39,4 +41,20 @@ describe('mergeListingPublishOptions', () => {
|
|||||||
|
|
||||||
expect(options.price_config.compensation_note).toBe('自定义赔付方案说明')
|
expect(options.price_config.compensation_note).toBe('自定义赔付方案说明')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('读取后台配置的极品专区说明', () => {
|
||||||
|
const options = mergeListingPublishOptions({
|
||||||
|
price_config: {
|
||||||
|
deposit_placeholder: '',
|
||||||
|
deposit_hint: '',
|
||||||
|
deposit_tooltip: '',
|
||||||
|
premium_zone_tooltip: '自定义极品专区说明',
|
||||||
|
compensation_note: '',
|
||||||
|
price_placeholder: '',
|
||||||
|
ratio_description: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(options.price_config.premium_zone_tooltip).toBe('自定义极品专区说明')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface PublishPriceConfig {
|
|||||||
deposit_placeholder: string
|
deposit_placeholder: string
|
||||||
deposit_hint: string
|
deposit_hint: string
|
||||||
deposit_tooltip: string
|
deposit_tooltip: string
|
||||||
|
premium_zone_tooltip: string
|
||||||
compensation_note: string
|
compensation_note: string
|
||||||
price_placeholder: string
|
price_placeholder: string
|
||||||
ratio_description: string
|
ratio_description: string
|
||||||
@@ -148,6 +149,14 @@ const defaultDepositTooltip = `1. 系统推荐押金(基础覆盖)
|
|||||||
|
|
||||||
若您在设置足额押金后仍感不放心,可选择购买平台提供的《全额包赔服务》。一旦发生封号损失,将获得账号市场价值的全额赔付,让您的出租过程后顾无忧。`
|
若您在设置足额押金后仍感不放心,可选择购买平台提供的《全额包赔服务》。一旦发生封号损失,将获得账号市场价值的全额赔付,让您的出租过程后顾无忧。`
|
||||||
|
|
||||||
|
const defaultPremiumZoneTooltip = `极品专区是面向优质号主和高价值账号的专属展示区。
|
||||||
|
|
||||||
|
1. 发布条件:仅已认证且有效的优质号主可以开启;未开通时需先缴纳平台要求的信用保证金并完成优质号主认证。
|
||||||
|
2. 专区展示:商品审核通过后将在首页“极品专区”展示,获得更聚焦的高端账号曝光。
|
||||||
|
3. 价格规则:系统会按后台配置在基础比例上计算极品专区专享比例,审核页展示的最终价格通过后不再改价。
|
||||||
|
4. 保险服务:可选择无忧租号保险,服务费率按优质号主及极品专区的当前配置执行,并从卖家实际到手租金中扣除。
|
||||||
|
5. 责任说明:开启专区不会免除商品审核、资料真实性及发布协议中的责任,请如实填写账号资产与出租条件。`
|
||||||
|
|
||||||
export const emptyListingPublishOptions: ListingPublishOptions = {
|
export const emptyListingPublishOptions: ListingPublishOptions = {
|
||||||
schema_version: 2,
|
schema_version: 2,
|
||||||
unlock_demulan_enabled: false,
|
unlock_demulan_enabled: false,
|
||||||
@@ -169,6 +178,7 @@ export const emptyListingPublishOptions: ListingPublishOptions = {
|
|||||||
deposit_placeholder: '',
|
deposit_placeholder: '',
|
||||||
deposit_hint: '租客缴纳,封号必赔付',
|
deposit_hint: '租客缴纳,封号必赔付',
|
||||||
deposit_tooltip: defaultDepositTooltip,
|
deposit_tooltip: defaultDepositTooltip,
|
||||||
|
premium_zone_tooltip: defaultPremiumZoneTooltip,
|
||||||
compensation_note:
|
compensation_note:
|
||||||
'普通赔付方案如下:账号在租期内或祖期外出现封禁,追回、洗号等情况,最高可获赔全款相金+押金\n\n无忧保险赔付方案:按账号市场价值进行赔付(可参照螃蟹,盼之等主流交易平台)。注:选择此方案,需额外抽取订单佣金的8.0%作为服务费:押金须达到推荐押金方可购买。',
|
'普通赔付方案如下:账号在租期内或祖期外出现封禁,追回、洗号等情况,最高可获赔全款相金+押金\n\n无忧保险赔付方案:按账号市场价值进行赔付(可参照螃蟹,盼之等主流交易平台)。注:选择此方案,需额外抽取订单佣金的8.0%作为服务费:押金须达到推荐押金方可购买。',
|
||||||
price_placeholder: '',
|
price_placeholder: '',
|
||||||
@@ -360,6 +370,10 @@ function normalizePriceConfig(value?: unknown): PublishPriceConfig {
|
|||||||
typeof row.deposit_tooltip === 'string' && row.deposit_tooltip.trim()
|
typeof row.deposit_tooltip === 'string' && row.deposit_tooltip.trim()
|
||||||
? row.deposit_tooltip.trim()
|
? row.deposit_tooltip.trim()
|
||||||
: emptyListingPublishOptions.price_config.deposit_tooltip,
|
: emptyListingPublishOptions.price_config.deposit_tooltip,
|
||||||
|
premium_zone_tooltip:
|
||||||
|
typeof row.premium_zone_tooltip === 'string' && row.premium_zone_tooltip.trim()
|
||||||
|
? row.premium_zone_tooltip.trim()
|
||||||
|
: emptyListingPublishOptions.price_config.premium_zone_tooltip,
|
||||||
compensation_note:
|
compensation_note:
|
||||||
typeof row.compensation_note === 'string' && row.compensation_note.trim()
|
typeof row.compensation_note === 'string' && row.compensation_note.trim()
|
||||||
? row.compensation_note.trim()
|
? row.compensation_note.trim()
|
||||||
|
|||||||
@@ -186,6 +186,8 @@ export interface AdminListingQuery {
|
|||||||
owner_id?: string
|
owner_id?: string
|
||||||
status?: ListingStatus | ''
|
status?: ListingStatus | ''
|
||||||
review_status?: ListingReviewStatus | ''
|
review_status?: ListingReviewStatus | ''
|
||||||
|
compensation_plan?: 'normal' | 'insurance' | ''
|
||||||
|
zone?: 'normal' | 'premium' | ''
|
||||||
insurance?: string
|
insurance?: string
|
||||||
stamina?: string
|
stamina?: string
|
||||||
load?: string
|
load?: string
|
||||||
|
|||||||
@@ -139,6 +139,16 @@ function showDepositGuide() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showPremiumZoneGuide() {
|
||||||
|
if (!priceConfig.value.premium_zone_tooltip) return
|
||||||
|
void showDialog({
|
||||||
|
title: '极品专区说明',
|
||||||
|
message: priceConfig.value.premium_zone_tooltip,
|
||||||
|
messageAlign: 'left',
|
||||||
|
confirmButtonText: '我知道了',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function selectRadio<T extends string>(value: T, setter: (value: T) => void) {
|
function selectRadio<T extends string>(value: T, setter: (value: T) => void) {
|
||||||
setter(value)
|
setter(value)
|
||||||
}
|
}
|
||||||
@@ -887,6 +897,15 @@ const screenshotGuides: Record<string, string> = {
|
|||||||
<div class="premium-zone-copy">
|
<div class="premium-zone-copy">
|
||||||
<div class="premium-zone-title">
|
<div class="premium-zone-title">
|
||||||
<strong>极品专区</strong>
|
<strong>极品专区</strong>
|
||||||
|
<button
|
||||||
|
v-if="priceConfig.premium_zone_tooltip"
|
||||||
|
type="button"
|
||||||
|
class="deposit-guide-trigger"
|
||||||
|
aria-label="查看极品专区说明"
|
||||||
|
@click.prevent.stop="showPremiumZoneGuide"
|
||||||
|
>
|
||||||
|
<van-icon name="info-o" />
|
||||||
|
</button>
|
||||||
<em v-if="isQualityOwner">优质号主已认证</em>
|
<em v-if="isQualityOwner">优质号主已认证</em>
|
||||||
<em v-else class="is-locked">优质号主专享</em>
|
<em v-else class="is-locked">优质号主专享</em>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -711,6 +711,26 @@ function selectDailyLoss(value: string | number) {
|
|||||||
<div class="premium-zone-copy">
|
<div class="premium-zone-copy">
|
||||||
<div class="premium-zone-title">
|
<div class="premium-zone-title">
|
||||||
<strong>极品专区</strong>
|
<strong>极品专区</strong>
|
||||||
|
<el-popover
|
||||||
|
v-if="priceConfig.premium_zone_tooltip"
|
||||||
|
placement="top-start"
|
||||||
|
:width="560"
|
||||||
|
trigger="hover"
|
||||||
|
popper-class="deposit-guide-popper"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="deposit-guide-trigger"
|
||||||
|
aria-label="查看极品专区说明"
|
||||||
|
>
|
||||||
|
<el-icon><InfoFilled /></el-icon>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<div class="deposit-guide-content">
|
||||||
|
{{ priceConfig.premium_zone_tooltip }}
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
<em v-if="isQualityOwner">优质号主已认证</em>
|
<em v-if="isQualityOwner">优质号主已认证</em>
|
||||||
<em v-else class="is-locked">优质号主专享</em>
|
<em v-else class="is-locked">优质号主专享</em>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user