From f01329e04974da4fd7b1f7d2d49a98df43e61804 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 5 Nov 2026 19:00:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=95=86=E5=93=81?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E3=80=81=E5=8F=91=E5=B8=83=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E4=B8=8E=E4=BC=98=E8=B4=A8=E5=8F=B7=E4=B8=BB=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/listing/admin_filter_test.go | 10 + backend/internal/modules/listing/dto.go | 34 +- .../internal/modules/listing/handler_parse.go | 2 + backend/internal/modules/listing/query.go | 9 + .../modules/payment/quality_owner_payment.go | 11 +- .../payment/quality_owner_payment_test.go | 167 +++ .../modules/qualityowner/repository.go | 20 +- backend/internal/modules/systemconfig/dto.go | 1 + .../modules/systemconfig/normalize.go | 3 + .../modules/systemconfig/publish_options.go | 7 + .../systemconfig/publish_options_test.go | 14 + .../admin/components/PublishOptionsDialog.vue | 10 +- .../admin/views/AdminListingsView.vue | 16 + .../src/features/auth/views/ProfileView.vue | 1059 ++++++++++++++--- .../listings/api/listingOptions.spec.ts | 18 + .../features/listings/api/listingOptions.ts | 14 + .../src/features/listings/api/listings.ts | 2 + .../views/MobileSellerListingCreateView.vue | 19 + .../seller/views/SellerListingCreateView.vue | 20 + 19 files changed, 1256 insertions(+), 180 deletions(-) diff --git a/backend/internal/modules/listing/admin_filter_test.go b/backend/internal/modules/listing/admin_filter_test.go index 8fe65cd8..66ebbaec 100644 --- a/backend/internal/modules/listing/admin_filter_test.go +++ b/backend/internal/modules/listing/admin_filter_test.go @@ -158,6 +158,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 { diff --git a/backend/internal/modules/listing/dto.go b/backend/internal/modules/listing/dto.go index a971e3db..5a9b689f 100644 --- a/backend/internal/modules/listing/dto.go +++ b/backend/internal/modules/listing/dto.go @@ -110,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 { diff --git a/backend/internal/modules/listing/handler_parse.go b/backend/internal/modules/listing/handler_parse.go index 4b55f475..e3de2370 100644 --- a/backend/internal/modules/listing/handler_parse.go +++ b/backend/internal/modules/listing/handler_parse.go @@ -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")) diff --git a/backend/internal/modules/listing/query.go b/backend/internal/modules/listing/query.go index 5d986f55..c32dd4a9 100644 --- a/backend/internal/modules/listing/query.go +++ b/backend/internal/modules/listing/query.go @@ -131,6 +131,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 >= ?)", diff --git a/backend/internal/modules/payment/quality_owner_payment.go b/backend/internal/modules/payment/quality_owner_payment.go index 4f36abca..dd33e656 100644 --- a/backend/internal/modules/payment/quality_owner_payment.go +++ b/backend/internal/modules/payment/quality_owner_payment.go @@ -52,7 +52,7 @@ func (r *Repository) prepareQualityOwnerPayment(ctx context.Context, userID uint Order("id DESC"). First(&existing).Error if err == nil { - if canReuseOrderPayment(existing, *runtimeConfig) { + if canReuseQualityOwnerPayment(existing, *runtimeConfig) { existing.PayWay = firstNonEmpty(req.PayWay, existing.PayWay, runtimeConfig.PayWay, "ZFBZF") existing.JSPayFlag = firstNonEmpty(req.JSPayFlag, existing.JSPayFlag, runtimeConfig.JSPayFlag, "2") existing.AmountCent = amountCent @@ -87,6 +87,15 @@ func (r *Repository) prepareQualityOwnerPayment(ctx context.Context, userID uint 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) { paymentNo, err := newPaymentNo() if err != nil { diff --git a/backend/internal/modules/payment/quality_owner_payment_test.go b/backend/internal/modules/payment/quality_owner_payment_test.go index 98fae461..140a14f2 100644 --- a/backend/internal/modules/payment/quality_owner_payment_test.go +++ b/backend/internal/modules/payment/quality_owner_payment_test.go @@ -1,11 +1,13 @@ package payment import ( + "errors" "testing" "time" "hfb_sys/backend/internal/database" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/paymentconfig" "hfb_sys/backend/internal/modules/qualityowner" ) @@ -95,3 +97,168 @@ func TestNewQualityOwnerPaymentUsesBizIDAndMockCashier(t *testing.T) { 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) + } +} diff --git a/backend/internal/modules/qualityowner/repository.go b/backend/internal/modules/qualityowner/repository.go index ee7a22d7..93f0d8c7 100644 --- a/backend/internal/modules/qualityowner/repository.go +++ b/backend/internal/modules/qualityowner/repository.go @@ -121,13 +121,31 @@ func (r *Repository) PaymentAmount(ctx context.Context, userID uint64) (int64, b // ConfirmQualityOwnerDepositPaidTx 在支付回调事务内完成在线保证金入账并开通资格。 // 外部支付已经由渠道完成,因此这里只增加冻结余额,不再扣减可用余额。 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 } var user model.User if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil { 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 { return nil diff --git a/backend/internal/modules/systemconfig/dto.go b/backend/internal/modules/systemconfig/dto.go index e46d5b05..23b36d9e 100644 --- a/backend/internal/modules/systemconfig/dto.go +++ b/backend/internal/modules/systemconfig/dto.go @@ -167,6 +167,7 @@ type PublishPriceConfig struct { DepositPlaceholder string `json:"deposit_placeholder"` DepositHint string `json:"deposit_hint"` DepositTooltip string `json:"deposit_tooltip"` + PremiumZoneTooltip string `json:"premium_zone_tooltip"` CompensationNote string `json:"compensation_note"` PricePlaceholder string `json:"price_placeholder"` RatioDescription string `json:"ratio_description"` diff --git a/backend/internal/modules/systemconfig/normalize.go b/backend/internal/modules/systemconfig/normalize.go index babd2872..7bd91d15 100644 --- a/backend/internal/modules/systemconfig/normalize.go +++ b/backend/internal/modules/systemconfig/normalize.go @@ -234,6 +234,9 @@ func normalizePublishOptions(options *PublishOptionsDTO) { if options.PriceConfig.DepositTooltip == "" { options.PriceConfig.DepositTooltip = defaults.PriceConfig.DepositTooltip } + if options.PriceConfig.PremiumZoneTooltip == "" { + options.PriceConfig.PremiumZoneTooltip = defaults.PriceConfig.PremiumZoneTooltip + } if options.PriceConfig.CompensationNote == "" { options.PriceConfig.CompensationNote = defaults.PriceConfig.CompensationNote } diff --git a/backend/internal/modules/systemconfig/publish_options.go b/backend/internal/modules/systemconfig/publish_options.go index bca97dd6..efc969ec 100644 --- a/backend/internal/modules/systemconfig/publish_options.go +++ b/backend/internal/modules/systemconfig/publish_options.go @@ -115,6 +115,13 @@ func DefaultPublishOptions() PublishOptionsDTO { 4. 额外保障选项 若您在设置足额押金后仍感不放心,可选择购买平台提供的《全额包赔服务》。一旦发生封号损失,将获得账号市场价值的全额赔付,让您的出租过程后顾无忧。`, + PremiumZoneTooltip: `极品专区是面向优质号主和高价值账号的专属展示区。 + +1. 发布条件:仅已认证且有效的优质号主可以开启;未开通时需先缴纳平台要求的信用保证金并完成优质号主认证。 +2. 专区展示:商品审核通过后将在首页“极品专区”展示,获得更聚焦的高端账号曝光。 +3. 价格规则:系统会按后台配置在基础比例上计算极品专区专享比例,审核页展示的最终价格通过后不再改价。 +4. 保险服务:可选择无忧租号保险,服务费率按优质号主及极品专区的当前配置执行,并从卖家实际到手租金中扣除。 +5. 责任说明:开启专区不会免除商品审核、资料真实性及发布协议中的责任,请如实填写账号资产与出租条件。`, CompensationNote: "普通赔付方案如下:账号在租期内或祖期外出现封禁,追回、洗号等情况,最高可获赔全款相金+押金\n\n无忧保险赔付方案:按账号市场价值进行赔付(可参照螃蟹,盼之等主流交易平台)。注:选择此方案,需额外抽取订单佣金的8.0%作为服务费:押金须达到推荐押金方可购买。", PricePlaceholder: "填写币数、保险、体力和负重后自动计算;发布总价=纯币基础价+额外消耗品", RatioDescription: "1:xx 表示 1 元人民币(RMB)等价兑换 xx 万哈夫币", diff --git a/backend/internal/modules/systemconfig/publish_options_test.go b/backend/internal/modules/systemconfig/publish_options_test.go index 128387a6..94ec3692 100644 --- a/backend/internal/modules/systemconfig/publish_options_test.go +++ b/backend/internal/modules/systemconfig/publish_options_test.go @@ -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) { options := DefaultPublishOptions() options.RatioConfig.PremiumReviewRatioMarkup = 0 diff --git a/frontend/src/features/admin/components/PublishOptionsDialog.vue b/frontend/src/features/admin/components/PublishOptionsDialog.vue index 360ba2eb..aaff4578 100644 --- a/frontend/src/features/admin/components/PublishOptionsDialog.vue +++ b/frontend/src/features/admin/components/PublishOptionsDialog.vue @@ -320,7 +320,7 @@ async function handleSave() {
- 押金与价格提示 + 押金、专区与价格提示
+ + + ({ owner_id: '', status: '', review_status: '', + compensation_plan: '', + zone: '', insurance: '', stamina: '', load: '', @@ -178,6 +180,8 @@ function resetFilters() { filters.owner_id = '' filters.status = '' filters.review_status = '' + filters.compensation_plan = '' + filters.zone = '' filters.min_coin = undefined filters.max_coin = undefined filters.min_deposit = undefined @@ -797,6 +801,18 @@ function formatQuantity(value: number) { + + + + + + + + + + + + { if (session.realnameStatus === 'rejected') return 'rejected' return 'unverified' }) +const qualityOwnerRequiredDepositCent = computed( + () => qualityOwnerStatus.value?.required_deposit_cent || 20000 +) +const qualityOwnerAvailableBalanceCent = computed( + () => qualityOwnerStatus.value?.available_balance_cent || 0 +) +const qualityOwnerHasEnoughBalance = computed( + () => qualityOwnerAvailableBalanceCent.value >= qualityOwnerRequiredDepositCent.value +) +const qualityOwnerNormalFeePercent = computed(() => + ((qualityOwnerStatus.value?.insurance_fee_bps || 800) / 100).toFixed(1) +) +const qualityOwnerPremiumFeePercent = computed(() => + ((qualityOwnerStatus.value?.premium_insurance_fee_bps || 400) / 100).toFixed(1) +) onMounted(async () => { try { @@ -291,24 +306,28 @@ async function savePassword() {