From 36ebe7a5f1b3d361251196b2826efed876e68e61 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 10 Sep 2026 19:13:39 +0800 Subject: [PATCH] =?UTF-8?q?fix(distribution):=20=E7=BB=93=E7=AE=97?= =?UTF-8?q?=E5=B9=82=E7=AD=89=E4=B8=8E=E9=80=80=E6=AC=BE=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA=EF=BC=8C=E8=A1=A5=E5=85=B3=E5=8D=95=E9=87=8A?= =?UTF-8?q?=E6=94=BE=E4=B8=8E=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 结算明细加唯一键 (order_id, party_id):自动释放与手工批次并发时以唯一键冲突为准跳过该参与方,不再重复入账 - 自动释放先无锁筛出本站未入账参与方,收敛加锁范围;手工批次建批次后先锁周期内财务快照,与自动释放串行化 - 累计退款额改为「本次回调金额与已退款成功流水合计取较大值」,多笔退款与退款结果重复回调不再把口径写小 - 关单三路径(AdminClose / AdminRejectRefund 的 close 分支 / AdminSeal)补写 release_at,接线原死代码 EnsureCompletedReleaseAtTx - 仲裁分销重算改用实际退款额(扣除押金暂扣),避免站点被多扣 - 订单列表与详情的提价分成取 min(收入, 货架加价),退款压缩加价后不再显示负数 - 新增迁移 000070(结算明细唯一键)、000071(补回填 closed 订单释放时间) - 新增 5 个测试文件:结算幂等与展示钳制、结算 job 幂等、累计退款、关单补写释放时间、仲裁押金暂扣口径 --- .../jobs/distributionsettlement/job_test.go | 108 +++++++ .../internal/modules/dispute/arbitration.go | 2 +- ...itration_deposit_hold_distribution_test.go | 144 +++++++++ .../internal/modules/distribution/release.go | 36 ++- .../modules/distribution/repository.go | 42 ++- .../distribution/settlement_guard_test.go | 292 ++++++++++++++++++ .../internal/modules/order/admin_actions.go | 20 ++ .../modules/order/admin_close_release_test.go | 238 ++++++++++++++ backend/internal/modules/payment/refund.go | 17 +- .../modules/payment/refund_cumulative_test.go | 96 ++++++ ...tribution_settlement_item_party_unique.sql | 12 + ...istribution_release_at_backfill_closed.sql | 17 + docs/分销网管理后台设计方案.md | 3 + 13 files changed, 1017 insertions(+), 10 deletions(-) create mode 100644 backend/internal/jobs/distributionsettlement/job_test.go create mode 100644 backend/internal/modules/dispute/arbitration_deposit_hold_distribution_test.go create mode 100644 backend/internal/modules/distribution/settlement_guard_test.go create mode 100644 backend/internal/modules/order/admin_close_release_test.go create mode 100644 backend/internal/modules/payment/refund_cumulative_test.go create mode 100644 backend/migrations/000070_distribution_settlement_item_party_unique.sql create mode 100644 backend/migrations/000071_distribution_release_at_backfill_closed.sql diff --git a/backend/internal/jobs/distributionsettlement/job_test.go b/backend/internal/jobs/distributionsettlement/job_test.go new file mode 100644 index 0000000..af864c2 --- /dev/null +++ b/backend/internal/jobs/distributionsettlement/job_test.go @@ -0,0 +1,108 @@ +package distributionsettlement + +import ( + "testing" + "time" + + "hfb_sys/backend/internal/database" + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/distribution" + + "go.uber.org/zap" + "gorm.io/gorm" +) + +func setupSettlementJobTestDB(t *testing.T) *gorm.DB { + t.Helper() + db := database.NewTestDB() + if err := database.AutoMigrateIfMissing(db, + &model.AdminUser{}, &model.RentalListing{}, &model.GameAccount{}, &model.User{}, + &model.RentalOrder{}, &model.DistributionSite{}, &model.DistributionRuleVersion{}, + &model.DistributionOrderFinancial{}, &model.DistributionOrderParty{}, + &model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{}, + &model.WalletAccount{}, &model.WalletLedger{}, &model.Dispute{}, + ); err != nil { + t.Fatal(err) + } + return db +} + +// TestRunReleasesDueSettlementOnce 断言结算 job 会把到期订单入账到站点钱包并置财务快照为 settled, +// 且重复执行不会重复入账(幂等)。 +func TestRunReleasesDueSettlementOnce(t *testing.T) { + db := setupSettlementJobTestDB(t) + job := New(db, nil, zap.NewNop()) + now := time.Now() + past := now.Add(-time.Minute) + + site := model.DistributionSite{ + SiteCode: "job_site", SiteName: "定时结算站", Domain: "job.dist.com", + Status: "active", DisplayAccountEnabled: true, SettlementHoldDays: 0, + } + if err := db.Create(&site).Error; err != nil { + t.Fatal(err) + } + rule := model.DistributionRuleVersion{ + SiteID: &site.ID, Mode: distribution.ModeTwoPartyDistributorReceive, Version: 1, Status: "active", + DistributorReceiveRatio: 35, PlatformSellRatio: 40, + ServiceFeeUnit: distribution.ServiceFeeUnitFixedAmount, EffectiveFrom: now.Add(-time.Hour), + } + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + order := model.RentalOrder{ + OrderNo: "ORD-JOB-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, + Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "none", + DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.DistributionOrderFinancial{ + OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID, + GrossPaidAmountCent: 10000, NetPaidAmountCent: 10000, + SplitBaseAmountCent: 4000, SettlementStatus: "pending", ReleaseAt: &past, + }).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.DistributionOrderParty{ + OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", + PartyType: distribution.PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400, + }).Error; err != nil { + t.Fatal(err) + } + + job.run(t.Context()) + + var financial model.DistributionOrderFinancial + if err := db.Where("order_id = ?", order.ID).First(&financial).Error; err != nil { + t.Fatal(err) + } + if financial.SettlementStatus != "settled" || financial.ReleasedAt == nil { + t.Fatalf("财务快照未置为已结算: %+v", financial) + } + var wallet model.WalletAccount + if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&wallet).Error; err != nil { + t.Fatal(err) + } + if wallet.AvailableBalanceCent != 1400 { + t.Fatalf("站点钱包 = %d, want 1400", wallet.AvailableBalanceCent) + } + + // 第二次执行不得重复入账。 + job.run(t.Context()) + if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&wallet).Error; err != nil { + t.Fatal(err) + } + if wallet.AvailableBalanceCent != 1400 { + t.Fatalf("重复执行后站点钱包 = %d, want 1400(幂等)", wallet.AvailableBalanceCent) + } + var itemCount int64 + if err := db.Model(&model.DistributionSettlementItem{}).Where("order_id = ?", order.ID). + Count(&itemCount).Error; err != nil { + t.Fatal(err) + } + if itemCount != 1 { + t.Fatalf("结算明细 = %d, want 1", itemCount) + } +} diff --git a/backend/internal/modules/dispute/arbitration.go b/backend/internal/modules/dispute/arbitration.go index 23d7d21..ab5fba3 100644 --- a/backend/internal/modules/dispute/arbitration.go +++ b/backend/internal/modules/dispute/arbitration.go @@ -132,7 +132,7 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r return err } if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 { - if err := distribution.ApplyRefundTx(tx, order.ID, settlement.RenterRefundAmountCent); err != nil { + if err := distribution.ApplyRefundTx(tx, order.ID, refundAmountCent); err != nil { return err } } diff --git a/backend/internal/modules/dispute/arbitration_deposit_hold_distribution_test.go b/backend/internal/modules/dispute/arbitration_deposit_hold_distribution_test.go new file mode 100644 index 0000000..a04e557 --- /dev/null +++ b/backend/internal/modules/dispute/arbitration_deposit_hold_distribution_test.go @@ -0,0 +1,144 @@ +package dispute + +import ( + "context" + "testing" + "time" + + "hfb_sys/backend/internal/database" + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" +) + +// createHeldDepositDistributionFixture 构造「押金暂扣 + 分销站点」的仲裁前置数据: +// 站点 + 生效规则版本 + 进行中订单(押金 60 元全额暂扣)+ 待结算分销财务快照(毛收入 = 订单租金)。 +func createHeldDepositDistributionFixture(t *testing.T, db *gorm.DB) (model.DistributionSite, model.DistributionRuleVersion, model.RentalOrder, model.DistributionOrderFinancial) { + t.Helper() + if err := database.AutoMigrateIfMissing(db, + &model.DistributionSite{}, + &model.DistributionRuleVersion{}, + &model.DistributionOrderFinancial{}, + ); err != nil { + t.Fatalf("补齐分销表失败: %v", err) + } + + site := model.DistributionSite{ + SiteCode: "arb_hold_site", + SiteName: "仲裁暂扣站", + Domain: "arb-hold.dist.com", + Status: "active", + DisplayAccountEnabled: true, + SettlementHoldDays: 3, + } + if err := db.Create(&site).Error; err != nil { + t.Fatalf("创建分销站点失败: %v", err) + } + rule := model.DistributionRuleVersion{ + SiteID: &site.ID, + Mode: "two_party_distributor_receive", + Version: 1, + Status: "active", + DistributorReceiveRatio: 35, + PlatformSellRatio: 40, + ServiceFeeValue: 25, + ServiceFeeUnit: "percent", + PaymentFeeBPS: 50, + EffectiveFrom: time.Now().Add(-time.Hour), + } + if err := db.Create(&rule).Error; err != nil { + t.Fatalf("创建规则版本失败: %v", err) + } + + _, _, order := createDisputeOrderFixture(t, db, model.RentalOrder{ + Status: "renting", + HandoffStatus: "received", + SettlementStatus: "unsettled", + RentAmountCent: 10000, + OwnerRentAmountCent: 8000, + DepositAmountCent: 6000, + DepositHoldStatus: model.DepositHoldStatusHeld, + DepositHoldAmountCent: 6000, + DistributionSiteID: &site.ID, + DistributionRuleVersionID: &rule.ID, + }) + + financial := model.DistributionOrderFinancial{ + OrderID: order.ID, + SiteID: site.ID, + RuleVersionID: rule.ID, + GrossPaidAmountCent: order.RentAmountCent, + NetPaidAmountCent: order.RentAmountCent, + SplitBaseAmountCent: 2000, + SettlementStatus: "pending", + } + if err := db.Create(&financial).Error; err != nil { + t.Fatalf("创建分销财务快照失败: %v", err) + } + return site, rule, order, financial +} + +// TestArbitrationDistributionRefundUsesActualRefundWithDepositHold 断言押金暂扣订单仲裁时, +// 分销重算使用「实际退款额」(裁决退款扣除被暂扣押金部分),而不是裁决书上的退款额。 +// 用未扣除的裁决额重算会让分销快照多记退款、net_paid 少记(站点被多扣)。 +func TestArbitrationDistributionRefundUsesActualRefundWithDepositHold(t *testing.T) { + db := setupDisputeTestDB(t) + startedRefundCent := int64(-1) + repo := NewRepository(db, Dependencies{ + RefundStarter: RefundStarterFunc(func(_ context.Context, _ uint64, refundAmountCent int64, _ string, _ string) (string, error) { + startedRefundCent = refundAmountCent + return "refunded", nil + }), + }) + _, _, order, financial := createHeldDepositDistributionFixture(t, db) + + created, err := repo.Create(t.Context(), order.RenterID, order.ID, CreateRequest{ + Type: "haf_coin_dispute", + Description: "押金暂扣期间申请仲裁全额退款", + }) + if err != nil { + t.Fatalf("创建申诉失败: %v", err) + } + + if _, err := repo.Arbitrate(t.Context(), 9001, created.ID, ArbitrateRequest{ + Result: "full_refund", + Remark: "仲裁全额退款,押金部分暂扣中", + }, AuditMeta{}); err != nil { + t.Fatalf("仲裁失败: %v", err) + } + + // 裁决额 = 租金 + 押金;押金全额暂扣 → 实际退款 = 裁决额 − 押金。 + arbitrationRefundCent := order.RentAmountCent + order.DepositAmountCent + actualRefundCent := arbitrationRefundCent - order.DepositHoldAmountCent + if actualRefundCent >= arbitrationRefundCent { + t.Fatalf("fixture 未构造出「实际退款 < 裁决额」:actual=%d arbitration=%d", actualRefundCent, arbitrationRefundCent) + } + if startedRefundCent != actualRefundCent { + t.Fatalf("实际发起退款 = %d, want %d(裁决额 %d 扣除被暂扣押金 %d)", + startedRefundCent, actualRefundCent, arbitrationRefundCent, order.DepositHoldAmountCent) + } + + var savedOrder model.RentalOrder + if err := db.First(&savedOrder, order.ID).Error; err != nil { + t.Fatalf("读取订单失败: %v", err) + } + if savedOrder.RefundStatus != "pending" || savedOrder.RefundAmountCent != actualRefundCent { + t.Fatalf("订单退款 = %s/%d, want pending/%d", savedOrder.RefundStatus, savedOrder.RefundAmountCent, actualRefundCent) + } + + // 押金退款不计入分销毛收入:实际退款先抵扣押金,剩余才是租金退款。 + wantRentRefundCent := actualRefundCent - order.DepositAmountCent + unheldRentRefundCent := arbitrationRefundCent - order.DepositAmountCent + var snapshot model.DistributionOrderFinancial + if err := db.First(&snapshot, financial.ID).Error; err != nil { + t.Fatalf("读取分销快照失败: %v", err) + } + if snapshot.RefundAmountCent != wantRentRefundCent { + t.Fatalf("分销快照 refund_amount_cent = %d, want %d(按未扣除的裁决额 %d 重算会得到 %d)", + snapshot.RefundAmountCent, wantRentRefundCent, arbitrationRefundCent, unheldRentRefundCent) + } + if wantNetPaid := order.RentAmountCent - wantRentRefundCent; snapshot.NetPaidAmountCent != wantNetPaid { + t.Fatalf("分销快照 net_paid_amount_cent = %d, want %d(毛收入 %d − 实际租金退款 %d)", + snapshot.NetPaidAmountCent, wantNetPaid, order.RentAmountCent, wantRentRefundCent) + } +} diff --git a/backend/internal/modules/distribution/release.go b/backend/internal/modules/distribution/release.go index 82cbd97..2a949b2 100644 --- a/backend/internal/modules/distribution/release.go +++ b/backend/internal/modules/distribution/release.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "slices" + "strings" "time" "hfb_sys/backend/internal/model" @@ -77,16 +78,30 @@ func (r *Repository) AutoReleaseDueSettlements(ctx context.Context) (int, error) func (r *Repository) releaseSiteSettlements(ctx context.Context, siteID uint64, orderIDs []uint64, now time.Time) (int, error) { releasedOrders := 0 err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 先不加锁筛出本站尚未入账的候选参与方对应的订单,收敛后续加锁范围: + // 调用方传入的是全站到期订单,其中可能大多数已被手工批次纳入,无需加锁。 + var candidateOrderIDs []uint64 + if err := tx.Table("distribution_order_parties p"). + Select("DISTINCT p.order_id"). + Where("p.order_id IN ? AND p.party_type = ? AND p.party_id = ?", orderIDs, PartyTypeSite, siteID). + Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.party_id = p.id)"). + Order("p.order_id ASC"). + Scan(&candidateOrderIDs).Error; err != nil { + return err + } + if len(candidateOrderIDs) == 0 { + return nil + } // 先锁财务快照再锁参与方,与 ApplyRefundTx 保持同一顺序,避免并发退款回冲时死锁。 var lockedFinancials []model.DistributionOrderFinancial if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). - Where("order_id IN ?", orderIDs). + Where("order_id IN ?", candidateOrderIDs). Order("id ASC").Find(&lockedFinancials).Error; err != nil { return err } var parties []model.DistributionOrderParty if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). - Where("order_id IN ? AND party_type = ? AND party_id = ?", orderIDs, PartyTypeSite, siteID). + Where("order_id IN ? AND party_type = ? AND party_id = ?", candidateOrderIDs, PartyTypeSite, siteID). Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.party_id = distribution_order_parties.id)"). Order("order_id ASC, id ASC").Find(&parties).Error; err != nil { return err @@ -125,6 +140,11 @@ func (r *Repository) releaseSiteSettlements(ctx context.Context, siteID uint64, AmountCent: party.IncomeAmountCent, Status: "settled", } if err := tx.Create(&item).Error; err != nil { + if isDuplicateSettlementItem(err) { + // 唯一键命中说明另一条路径(手工批次或并发自动释放)已为该参与方建过明细, + // 跳过它,避免重复入账。 + continue + } return err } total += party.IncomeAmountCent @@ -168,6 +188,18 @@ func (r *Repository) releaseSiteSettlements(ctx context.Context, siteID uint64, return releasedOrders, nil } +// isDuplicateSettlementItem 判断错误是否为结算明细唯一键冲突。 +// GORM 未开启 TranslateError 时 MySQL 1062 不会映射为 ErrDuplicatedKey,因此同时检查错误链与错误文本。 +func isDuplicateSettlementItem(err error) bool { + if err == nil { + return false + } + if errors.Is(err, gorm.ErrDuplicatedKey) { + return true + } + return strings.Contains(err.Error(), "1062") +} + // releasableOrder 复核订单是否仍满足释放条件,并返回财务快照。 // 订单在扫描后可能进入退款或维权流程,因此事务内需要重新校验。 func releasableOrder(tx *gorm.DB, orderID uint64, now time.Time) (bool, error) { diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index 998761c..6cb2c78 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -593,8 +593,14 @@ func (r *Repository) ListOrders(ctx context.Context, query DistributionOrderList totalEarning += p.IncomeAmountCent if p.PartyRole == "distributor_sell" || p.PartyRole == "distributor_b" { roles = append(roles, "sell") - markupCent += items[i].ShelfMarkupCent - splitCent += (p.IncomeAmountCent - items[i].ShelfMarkupCent) + // 退款压缩加价后收入可能小于货架加价:加价部分取两者较小值, + // 避免展示负的提价分成。 + markup := items[i].ShelfMarkupCent + if p.IncomeAmountCent < markup { + markup = p.IncomeAmountCent + } + markupCent += markup + splitCent += p.IncomeAmountCent - markup } else if p.PartyRole == "distributor_receive" || p.PartyRole == "distributor_a" { roles = append(roles, "receive") splitCent += p.IncomeAmountCent @@ -670,8 +676,14 @@ func (r *Repository) FindOrder(ctx context.Context, adminID, id uint64) (*Distri totalEarning += p.IncomeAmountCent if p.PartyRole == "distributor_sell" || p.PartyRole == "distributor_b" { roles = append(roles, "sell") - markupCent += item.ShelfMarkupCent - splitCent += (p.IncomeAmountCent - item.ShelfMarkupCent) + // 退款压缩加价后收入可能小于货架加价:加价部分取两者较小值, + // 避免展示负的提价分成。 + markup := item.ShelfMarkupCent + if p.IncomeAmountCent < markup { + markup = p.IncomeAmountCent + } + markupCent += markup + splitCent += p.IncomeAmountCent - markup } else if p.PartyRole == "distributor_receive" || p.PartyRole == "distributor_a" { roles = append(roles, "receive") splitCent += p.IncomeAmountCent @@ -998,6 +1010,28 @@ func (r *Repository) CreateSettlementBatch(ctx context.Context, adminID uint64, if err := tx.Create(&batch).Error; err != nil { return err } + // 与自动释放路径串行化:先按周期锁定待结算财务快照,再选参与方, + // 避免同一订单同时被手工批次与自动释放纳入明细、重复入账。 + // 分两步(先查 ID 再 FOR UPDATE)而不是 JOIN + FOR UPDATE,避免顺带锁住 rental_orders 行。 + var pendingOrderIDs []uint64 + if err := tx.Table("distribution_order_financials f"). + Select("f.order_id"). + Joins("JOIN rental_orders ro ON ro.id = f.order_id"). + Where("f.settlement_status = 'pending'"). + Where("ro.status IN ?", settlementReadyOrderStatuses). + Where("f.created_at >= ? AND f.created_at < ?", req.PeriodStart, req.PeriodEnd). + Order("f.order_id ASC"). + Scan(&pendingOrderIDs).Error; err != nil { + return err + } + if len(pendingOrderIDs) > 0 { + var lockedFinancials []model.DistributionOrderFinancial + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("order_id IN ?", pendingOrderIDs). + Order("id ASC").Find(&lockedFinancials).Error; err != nil { + return err + } + } // 只收本站 site party。平台份不进批次,三方订单按 party 分别结算。 // 仅纳入已完结订单,避免租赁中订单被提前打入人工批次。 var parties []model.DistributionOrderParty diff --git a/backend/internal/modules/distribution/settlement_guard_test.go b/backend/internal/modules/distribution/settlement_guard_test.go new file mode 100644 index 0000000..238f43d --- /dev/null +++ b/backend/internal/modules/distribution/settlement_guard_test.go @@ -0,0 +1,292 @@ +package distribution + +import ( + "testing" + "time" + + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" +) + +// addSettlementOrder 在某站点下追加一笔可结算订单:已完成订单 + 待结算财务快照 + 站点参与方。 +func addSettlementOrder(t *testing.T, db *gorm.DB, siteID, ruleID uint64, orderNo string, incomeCent int64) (model.RentalOrder, model.DistributionOrderFinancial, model.DistributionOrderParty) { + t.Helper() + order := model.RentalOrder{ + OrderNo: orderNo, ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, + Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "none", + DistributionSiteID: &siteID, DistributionRuleVersionID: &ruleID, + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + financial := model.DistributionOrderFinancial{ + OrderID: order.ID, SiteID: siteID, RuleVersionID: ruleID, + GrossPaidAmountCent: 10000, NetPaidAmountCent: 10000, + SplitBaseAmountCent: 4000, SettlementStatus: "pending", + } + if err := db.Create(&financial).Error; err != nil { + t.Fatal(err) + } + party := model.DistributionOrderParty{ + OrderID: order.ID, SiteID: siteID, PartyRole: "distributor_receive", + PartyType: PartyTypeSite, PartyID: siteID, IncomeAmountCent: incomeCent, + } + if err := db.Create(&party).Error; err != nil { + t.Fatal(err) + } + return order, financial, party +} + +// settlementFixture 构造站点 + 规则 + 一笔可结算订单,供结算幂等用例复用。 +func settlementFixture(t *testing.T, db *gorm.DB, siteCode, orderNo string, incomeCent int64) (model.DistributionSite, model.DistributionRuleVersion, model.RentalOrder, model.DistributionOrderFinancial, model.DistributionOrderParty) { + t.Helper() + now := time.Now() + site := mustCreateDistSite(t, db, siteCode, siteCode+"站点", siteCode+".dist.com") + rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now) + order, financial, party := addSettlementOrder(t, db, site.ID, rule.ID, orderNo, incomeCent) + return site, rule, order, financial, party +} + +func mustCreateDraftBatch(t *testing.T, db *gorm.DB, siteID uint64, batchNo string) model.DistributionSettlementBatch { + t.Helper() + now := time.Now() + batch := model.DistributionSettlementBatch{ + SiteID: siteID, BatchNo: batchNo, + PeriodStart: now.Add(-time.Hour), PeriodEnd: now.Add(time.Hour), Status: "draft", + } + if err := db.Create(&batch).Error; err != nil { + t.Fatal(err) + } + return batch +} + +// TestSettlementItemOrderPartyUniqueKey 断言 000070 唯一键生效: +// 同一 (order_id, party_id) 插入第二条结算明细必须失败。 +func TestSettlementItemOrderPartyUniqueKey(t *testing.T) { + db := distributionTestDB(t) + site, _, order, _, party := settlementFixture(t, db, "uk_site", "ORD-UK-1", 1400) + batchA := mustCreateDraftBatch(t, db, site.ID, "DS-UK-A") + batchB := mustCreateDraftBatch(t, db, site.ID, "DS-UK-B") + + if err := db.Create(&model.DistributionSettlementItem{ + BatchID: batchA.ID, OrderID: order.ID, PartyID: party.ID, AmountCent: 1400, Status: "pending", + }).Error; err != nil { + t.Fatal(err) + } + err := db.Create(&model.DistributionSettlementItem{ + BatchID: batchB.ID, OrderID: order.ID, PartyID: party.ID, AmountCent: 700, Status: "pending", + }).Error + if err == nil { + t.Fatal("同一 (order_id, party_id) 插入第二条结算明细必须失败") + } + if !isDuplicateSettlementItem(err) { + t.Fatalf("重复错误应被识别为唯一键冲突,got %v", err) + } +} + +// TestAutoReleaseSkipsPartyInManualBatch 断言已进手工批次(pending 明细)的参与方 +// 不会被自动释放重复入账,且该财务快照不会被置为 settled。 +func TestAutoReleaseSkipsPartyInManualBatch(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + now := time.Now() + past := now.Add(-time.Minute) + + site, rule, _, financialInBatch, _ := settlementFixture(t, db, "dup_site", "ORD-DUP-1", 1400) + batch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{ + SiteID: site.ID, PeriodStart: now.Add(-time.Hour), PeriodEnd: now.Add(time.Hour), + }) + if err != nil { + t.Fatal(err) + } + if batch.TotalAmountCent != 1400 { + t.Fatalf("手工批次总额 = %d, want 1400", batch.TotalAmountCent) + } + + // 同站点再补一笔真正可自动释放的订单,保证自动释放仍在运行而不是整体跳过。 + _, financialDue, _ := addSettlementOrder(t, db, site.ID, rule.ID, "ORD-DUP-2", 700) + for _, id := range []uint64{financialInBatch.ID, financialDue.ID} { + if err := db.Model(&model.DistributionOrderFinancial{}).Where("id = ?", id). + Update("release_at", past).Error; err != nil { + t.Fatal(err) + } + } + + released, err := repo.AutoReleaseDueSettlements(t.Context()) + if err != nil { + t.Fatal(err) + } + if released != 1 { + t.Fatalf("released = %d, want 1(仅未入账的订单2)", released) + } + + var wallet model.WalletAccount + if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&wallet).Error; err != nil { + t.Fatal(err) + } + if wallet.AvailableBalanceCent != 700 { + t.Fatalf("站点钱包 = %d, want 700(手工批次内订单不得重复入账)", wallet.AvailableBalanceCent) + } + + var inBatch model.DistributionOrderFinancial + if err := db.First(&inBatch, financialInBatch.ID).Error; err != nil { + t.Fatal(err) + } + if inBatch.SettlementStatus != "pending" || inBatch.ReleasedAt != nil { + t.Fatalf("手工批次内财务快照不应被自动释放:%+v", inBatch) + } + var due model.DistributionOrderFinancial + if err := db.First(&due, financialDue.ID).Error; err != nil { + t.Fatal(err) + } + if due.SettlementStatus != "settled" || due.ReleasedAt == nil { + t.Fatalf("到期订单应被释放:%+v", due) + } +} + +// TestCreateSettlementBatchExcludesAutoReleasedParty 断言自动释放已入账的参与方 +// 不会再进入手工批次(NOT EXISTS 生效,批次总额为 0)。 +func TestCreateSettlementBatchExcludesAutoReleasedParty(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + now := time.Now() + past := now.Add(-time.Minute) + + site, _, order, financial, _ := settlementFixture(t, db, "rel_batch_site", "ORD-REL-BATCH", 1400) + if err := db.Model(&model.DistributionOrderFinancial{}).Where("id = ?", financial.ID). + Update("release_at", past).Error; err != nil { + t.Fatal(err) + } + released, err := repo.AutoReleaseDueSettlements(t.Context()) + if err != nil || released != 1 { + t.Fatalf("自动释放 released = %d err=%v, want 1", released, err) + } + + batch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{ + SiteID: site.ID, PeriodStart: now.Add(-time.Hour), PeriodEnd: now.Add(time.Hour), + }) + if err != nil { + t.Fatal(err) + } + if batch.TotalAmountCent != 0 { + t.Fatalf("已自动释放的参与方不应再进手工批次,total=%d", batch.TotalAmountCent) + } + var itemCount int64 + if err := db.Model(&model.DistributionSettlementItem{}).Where("batch_id = ?", batch.ID). + Count(&itemCount).Error; err != nil { + t.Fatal(err) + } + if itemCount != 0 { + t.Fatalf("手工批次明细 = %d, want 0", itemCount) + } + if _, err := repo.MarkSettlementBatch(t.Context(), 0, batch.ID); err != ErrSettlementBatchEmpty { + t.Fatalf("空批次结算错误 = %v, want ErrSettlementBatchEmpty", err) + } + var settled model.DistributionOrderFinancial + if err := db.Where("order_id = ?", order.ID).First(&settled).Error; err != nil { + t.Fatal(err) + } + if settled.SettlementStatus != "settled" { + t.Fatalf("自动释放后的快照应保持 settled,got %s", settled.SettlementStatus) + } +} + +// TestEnsureCompletedReleaseAtTxSetsReleaseAtForClosedOrder 断言关单(closed) +// 同样属于可释放终态,EnsureCompletedReleaseAtTx 会补写非空 release_at。 +func TestEnsureCompletedReleaseAtTxSetsReleaseAtForClosedOrder(t *testing.T) { + db := distributionTestDB(t) + now := time.Now() + site := mustCreateDistSite(t, db, "closed_ens_site", "关单释放站", "closed-ens.dist.com") + if err := db.Model(&site).Update("settlement_hold_days", 4).Error; err != nil { + t.Fatal(err) + } + rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now) + settledAt := now.Add(-time.Hour).Truncate(time.Second) + order := model.RentalOrder{ + OrderNo: "ORD-CLOSED-ENS-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, + Status: "closed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, + DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, + SettledAt: &settledAt, + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + if err := SnapshotPaidOrderTx(db, order.ID, 10000); err != nil { + t.Fatal(err) + } + if err := EnsureCompletedReleaseAtTx(db, order.ID); err != nil { + t.Fatal(err) + } + var financial model.DistributionOrderFinancial + if err := db.Where("order_id = ?", order.ID).First(&financial).Error; err != nil { + t.Fatal(err) + } + if financial.ReleaseAt == nil { + t.Fatal("closed 订单应写入 release_at") + } + want := settledAt.AddDate(0, 0, 4) + if !financial.ReleaseAt.Equal(want) { + t.Fatalf("release_at = %v, want %v", financial.ReleaseAt, want) + } +} + +// TestOrderDTOClampsCompressedMarkup 断言退款压缩加价(income < shelf_markup)后, +// 列表与详情的提价分成不为负,且 markup + split == earning。 +func TestOrderDTOClampsCompressedMarkup(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + now := time.Now() + site := mustCreateDistSite(t, db, "clamp_site", "压缩展示站", "clamp.dist.com") + rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyPlatformReceive, now) + order := model.RentalOrder{ + OrderNo: "ORD-CLAMP-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, + Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 600, ShelfMarkupCent: 3000, + DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + if err := SnapshotPaidOrderTx(db, order.ID, 10000); err != nil { + t.Fatal(err) + } + // 退款 9000 后净付款 1000、利润 0、有效加价 400 < 货架加价 3000。 + if err := ApplyRefundTx(db, order.ID, 9000); err != nil { + t.Fatal(err) + } + var party model.DistributionOrderParty + if err := db.Where("order_id = ? AND party_role = ?", order.ID, "distributor_sell").First(&party).Error; err != nil { + t.Fatal(err) + } + if party.IncomeAmountCent != 400 || party.IncomeAmountCent >= order.ShelfMarkupCent { + t.Fatalf("fixture 未构造出 income(%d) < shelf_markup(%d)", party.IncomeAmountCent, order.ShelfMarkupCent) + } + + assertClamped := func(label string, earning, markup, split int64) { + t.Helper() + if markup < 0 || split < 0 { + t.Fatalf("%s 提价分成/分成基数出现负数: markup=%d split=%d", label, markup, split) + } + if markup != 400 || split != 0 { + t.Fatalf("%s markup=%d split=%d, want 400/0", label, markup, split) + } + if earning != 400 || markup+split != earning { + t.Fatalf("%s earning=%d, markup+split=%d, want 两者相等且为 400", label, earning, markup+split) + } + } + + page, err := repo.ListOrders(t.Context(), DistributionOrderListQuery{SiteID: site.ID, Kind: "sold", Page: 1, Size: 10}) + if err != nil { + t.Fatal(err) + } + if len(page.Items) != 1 { + t.Fatalf("列表条数 = %d, want 1", len(page.Items)) + } + assertClamped("列表", page.Items[0].DistributorEarningCent, page.Items[0].DistributorMarkupCent, page.Items[0].DistributorSplitCent) + + detail, err := repo.FindOrder(t.Context(), 0, order.ID) + if err != nil { + t.Fatal(err) + } + assertClamped("详情", detail.DistributorEarningCent, detail.DistributorMarkupCent, detail.DistributorSplitCent) +} diff --git a/backend/internal/modules/order/admin_actions.go b/backend/internal/modules/order/admin_actions.go index 2af2586..d70de9d 100644 --- a/backend/internal/modules/order/admin_actions.go +++ b/backend/internal/modules/order/admin_actions.go @@ -6,6 +6,7 @@ import ( "time" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/distribution" "hfb_sys/backend/internal/modules/notification" "gorm.io/gorm" @@ -90,6 +91,12 @@ func (r *Repository) AdminClose(ctx context.Context, adminID uint64, orderID uin if err := tx.Save(order).Error; err != nil { return err } + // 关单同样属于可释放终态,补写释放时间,避免不退款关单的订单永不自动释放。 + if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 { + if err := distribution.EnsureCompletedReleaseAtTx(tx, order.ID); err != nil { + return err + } + } if err := tx.Save(listing).Error; err != nil { return err } @@ -498,6 +505,13 @@ func (r *Repository) AdminRejectRefund(ctx context.Context, orderID uint64, acti if err := tx.Save(order).Error; err != nil { return err } + // 关单同样属于可释放终态,补写释放时间,避免不退款关单的订单永不自动释放; + // restore 分支订单未终结,不在此列。 + if order.Status == orderStatusClosed && order.DistributionSiteID != nil && *order.DistributionSiteID > 0 { + if err := distribution.EnsureCompletedReleaseAtTx(tx, order.ID); err != nil { + return err + } + } if err := tx.Save(listing).Error; err != nil { return err } @@ -591,6 +605,12 @@ func (r *Repository) AdminSeal(ctx context.Context, adminID uint64, orderID uint if err := tx.Save(order).Error; err != nil { return err } + // 关单同样属于可释放终态,补写释放时间,避免不退款关单的订单永不自动释放。 + if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 { + if err := distribution.EnsureCompletedReleaseAtTx(tx, order.ID); err != nil { + return err + } + } if err := tx.Save(listing).Error; err != nil { return err } diff --git a/backend/internal/modules/order/admin_close_release_test.go b/backend/internal/modules/order/admin_close_release_test.go new file mode 100644 index 0000000..64bc369 --- /dev/null +++ b/backend/internal/modules/order/admin_close_release_test.go @@ -0,0 +1,238 @@ +package order + +import ( + "context" + "testing" + "time" + + "hfb_sys/backend/internal/database" + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" +) + +// ensureDistributionTables 补齐分销表(测试库由迁移克隆,通常已存在,这里是幂等兜底)。 +func ensureDistributionTables(t *testing.T, db *gorm.DB) { + t.Helper() + if err := database.AutoMigrateIfMissing(db, + &model.DistributionSite{}, + &model.DistributionRuleVersion{}, + &model.DistributionOrderFinancial{}, + ); err != nil { + t.Fatalf("补齐分销表失败: %v", err) + } +} + +// createDistributionSiteOrderFixture 构造分销站点订单:分销站点(冷却 3 天)+ 生效规则版本 + +// 进行中订单(非终态,未结算)+ 待结算财务快照(release_at 为空)。 +func createDistributionSiteOrderFixture(t *testing.T, db *gorm.DB, orderNo, siteCode, domain, phonePrefix string) (model.RentalOrder, model.DistributionOrderFinancial, model.DistributionSite) { + t.Helper() + ensureDistributionTables(t, db) + + owner := model.User{Phone: phonePrefix + "01"} + renter := model.User{Phone: phonePrefix + "02"} + if err := db.Create(&owner).Error; err != nil { + t.Fatalf("创建号主失败: %v", err) + } + if err := db.Create(&renter).Error; err != nil { + t.Fatalf("创建租客失败: %v", err) + } + account := model.GameAccount{ + OwnerID: owner.ID, + ServerRegion: "国服", + LoginPlatform: "steam", + Title: "关单释放测试账号", + Status: accountStatusRented, + } + if err := db.Create(&account).Error; err != nil { + t.Fatalf("创建账号失败: %v", err) + } + listing := model.RentalListing{ + AccountID: account.ID, + OwnerID: owner.ID, + PriceCent: 10000, + Status: listingStatusRented, + ReviewStatus: listingReviewStatusApproved, + InTransaction: true, + } + if err := db.Create(&listing).Error; err != nil { + t.Fatalf("创建商品失败: %v", err) + } + + site := model.DistributionSite{ + SiteCode: siteCode, + SiteName: "关单释放站", + Domain: domain, + Status: "active", + DisplayAccountEnabled: true, + SettlementHoldDays: 3, + } + if err := db.Create(&site).Error; err != nil { + t.Fatalf("创建分销站点失败: %v", err) + } + rule := model.DistributionRuleVersion{ + SiteID: &site.ID, + Mode: "two_party_distributor_receive", + Version: 1, + Status: "active", + DistributorReceiveRatio: 35, + PlatformSellRatio: 40, + ServiceFeeValue: 25, + ServiceFeeUnit: "percent", + PaymentFeeBPS: 50, + EffectiveFrom: time.Now().Add(-time.Hour), + } + if err := db.Create(&rule).Error; err != nil { + t.Fatalf("创建规则版本失败: %v", err) + } + + order := model.RentalOrder{ + OrderNo: orderNo, + ListingID: listing.ID, + AccountID: account.ID, + OwnerID: owner.ID, + RenterID: renter.ID, + RentAmountCent: 10000, + OwnerRentAmountCent: 8000, + DepositAmountCent: 2000, + Status: orderStatusRenting, + HandoffStatus: handoffStatusReceived, + SettlementStatus: settlementStatusUnsettled, + DistributionSiteID: &site.ID, + DistributionRuleVersionID: &rule.ID, + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("创建订单失败: %v", err) + } + financial := model.DistributionOrderFinancial{ + OrderID: order.ID, + SiteID: site.ID, + RuleVersionID: rule.ID, + GrossPaidAmountCent: 10000, + NetPaidAmountCent: 10000, + SplitBaseAmountCent: 2000, + SettlementStatus: "pending", + } + if err := db.Create(&financial).Error; err != nil { + t.Fatalf("创建分销财务快照失败: %v", err) + } + return order, financial, site +} + +// TestAdminCloseWritesDistributionReleaseAt 断言分销站点订单被客服关单后,会在同一事务内 +// 补写 distribution_order_financials.release_at(= settled_at + 站点 settlement_hold_days)。 +// 自动释放任务要求 release_at IS NOT NULL,缺少补写时这类关单订单永远不会被结算。 +func TestAdminCloseWritesDistributionReleaseAt(t *testing.T) { + db := setupOrderTestDB(t) + repo := NewRepository(db, Dependencies{ + RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) { + return "refunding", nil + }), + }) + order, financial, site := createDistributionSiteOrderFixture(t, db, "ORD-CLOSE-REL-1", "close_rel_site", "close-rel.dist.com", "138000090") + + if err := repo.AdminClose(t.Context(), 9001, order.ID, AdminActionRequest{Reason: "客服核实后关单"}, AuditMeta{}); err != nil { + t.Fatalf("AdminClose() error = %v", err) + } + + var savedOrder model.RentalOrder + if err := db.First(&savedOrder, order.ID).Error; err != nil { + t.Fatalf("读取订单失败: %v", err) + } + if savedOrder.Status != orderStatusClosed || savedOrder.SettlementStatus != settlementStatusClosed { + t.Fatalf("关单后订单状态 = %s/%s, want %s/%s", savedOrder.Status, savedOrder.SettlementStatus, orderStatusClosed, settlementStatusClosed) + } + if savedOrder.SettledAt == nil { + t.Fatal("关单应写入 settled_at") + } + + var savedFinancial model.DistributionOrderFinancial + if err := db.First(&savedFinancial, financial.ID).Error; err != nil { + t.Fatalf("读取分销财务快照失败: %v", err) + } + if savedFinancial.ReleaseAt == nil { + t.Fatal("关单后应补写分销快照 release_at,否则该订单永远不会被自动释放结算") + } + want := savedOrder.SettledAt.AddDate(0, 0, site.SettlementHoldDays) + if !savedFinancial.ReleaseAt.Equal(want) { + t.Fatalf("release_at = %v, want %v(settled_at + %d 天)", savedFinancial.ReleaseAt, want, site.SettlementHoldDays) + } +} + +// TestAdminCloseWithoutDistributionSiteIsNoop 断言非分销订单(DistributionSiteID 为空) +// 关单不受补写逻辑影响:没有分销快照也不报错,订单照常关闭,用来钉住无分销时为 no-op。 +func TestAdminCloseWithoutDistributionSiteIsNoop(t *testing.T) { + db := setupOrderTestDB(t) + repo := NewRepository(db, Dependencies{ + RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) { + return "refunding", nil + }), + }) + ensureDistributionTables(t, db) + + owner := model.User{Phone: "13800009501"} + renter := model.User{Phone: "13800009502"} + if err := db.Create(&owner).Error; err != nil { + t.Fatalf("创建号主失败: %v", err) + } + if err := db.Create(&renter).Error; err != nil { + t.Fatalf("创建租客失败: %v", err) + } + account := model.GameAccount{ + OwnerID: owner.ID, + ServerRegion: "国服", + LoginPlatform: "steam", + Title: "非分销关单账号", + Status: accountStatusRented, + } + if err := db.Create(&account).Error; err != nil { + t.Fatalf("创建账号失败: %v", err) + } + listing := model.RentalListing{ + AccountID: account.ID, + OwnerID: owner.ID, + PriceCent: 10000, + Status: listingStatusRented, + ReviewStatus: listingReviewStatusApproved, + InTransaction: true, + } + if err := db.Create(&listing).Error; err != nil { + t.Fatalf("创建商品失败: %v", err) + } + order := model.RentalOrder{ + OrderNo: "ORD-CLOSE-NODIST-1", + ListingID: listing.ID, + AccountID: account.ID, + OwnerID: owner.ID, + RenterID: renter.ID, + RentAmountCent: 10000, + OwnerRentAmountCent: 8000, + DepositAmountCent: 2000, + Status: orderStatusRenting, + HandoffStatus: handoffStatusReceived, + SettlementStatus: settlementStatusUnsettled, + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("创建订单失败: %v", err) + } + + if err := repo.AdminClose(t.Context(), 9002, order.ID, AdminActionRequest{Reason: "普通订单关单"}, AuditMeta{}); err != nil { + t.Fatalf("无分销站点订单关单不应报错,got %v", err) + } + + var saved model.RentalOrder + if err := db.First(&saved, order.ID).Error; err != nil { + t.Fatalf("读取订单失败: %v", err) + } + if saved.Status != orderStatusClosed { + t.Fatalf("订单状态 = %s, want %s", saved.Status, orderStatusClosed) + } + var snapshotCount int64 + if err := db.Model(&model.DistributionOrderFinancial{}).Where("order_id = ?", order.ID). + Count(&snapshotCount).Error; err != nil { + t.Fatalf("统计分销财务快照失败: %v", err) + } + if snapshotCount != 0 { + t.Fatalf("非分销订单不应产生分销财务快照,got %d", snapshotCount) + } +} diff --git a/backend/internal/modules/payment/refund.go b/backend/internal/modules/payment/refund.go index e9558ec..52a24c9 100644 --- a/backend/internal/modules/payment/refund.go +++ b/backend/internal/modules/payment/refund.go @@ -421,11 +421,22 @@ func (r *Repository) updateOrderRefundStatus(ctx context.Context, orderID uint64 if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil { return err } + // 同一订单可能多笔退款,且退款结果查询会重复回调: + // 以已退款成功流水合计为口径,并取本次金额与累计额的较大值, + // 保证 order.RefundAmountCent 单调不回退、重复回调幂等。 + var refundedTotal int64 + if err := tx.Model(&model.PaymentOrder{}). + Where("order_id = ? AND status = ?", orderID, "refunded"). + Where("biz_type IN ?", refundBizTypes). + Select("COALESCE(SUM(amount_cent), 0)").Scan(&refundedTotal).Error; err != nil { + return err + } + cumulative := max(refundAmountCent, refundedTotal) now := time.Now() order.RefundStatus = "refunded" - order.RefundAmountCent = refundAmountCent + order.RefundAmountCent = cumulative order.RefundedAt = &now - if bizType == "admin_refund" && order.Status == "completed" && refundAmountCent >= order.RentAmountCent+order.DepositAmountCent { + if bizType == "admin_refund" && order.Status == "completed" && cumulative >= order.RentAmountCent+order.DepositAmountCent { if err := rentergrowth.RevokeOrderCompleted(tx, &order); err != nil { return err } @@ -433,7 +444,7 @@ func (r *Repository) updateOrderRefundStatus(ctx context.Context, orderID uint64 if err := tx.Save(&order).Error; err != nil { return err } - return distribution.ApplyRefundTx(tx, orderID, refundAmountCent) + return distribution.ApplyRefundTx(tx, orderID, cumulative) }) } func (r *Repository) markOrderRefunding(ctx context.Context, orderID uint64, refundAmountCent int64) error { diff --git a/backend/internal/modules/payment/refund_cumulative_test.go b/backend/internal/modules/payment/refund_cumulative_test.go new file mode 100644 index 0000000..9024519 --- /dev/null +++ b/backend/internal/modules/payment/refund_cumulative_test.go @@ -0,0 +1,96 @@ +package payment + +import ( + "testing" + "time" + + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/distribution" +) + +// TestUpdateOrderRefundStatusUsesCumulativeRefundedAmount 断言同一订单多笔退款时, +// 单笔回调传入的金额不会把订单累计退款额写小,分销财务快照按合计口径重算。 +func TestUpdateOrderRefundStatusUsesCumulativeRefundedAmount(t *testing.T) { + db := setupPaymentTestDB(t) + repo := NewRepository(db, nil, nil) + now := time.Now() + + site := model.DistributionSite{ + SiteCode: "cum_site", SiteName: "累计退款站", Domain: "cum.dist.com", + Status: "active", DisplayAccountEnabled: true, + } + if err := db.Create(&site).Error; err != nil { + t.Fatal(err) + } + rule := model.DistributionRuleVersion{ + SiteID: &site.ID, Mode: distribution.ModeTwoPartyPlatformReceive, Version: 1, Status: "active", + PlatformReceiveRatio: 35, DistributorSellRatio: 40, ServiceFeeValue: 0, + ServiceFeeUnit: distribution.ServiceFeeUnitFixedAmount, PaymentFeeBPS: 0, + EffectiveFrom: now.Add(-time.Hour), + } + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + order := model.RentalOrder{ + OrderNo: "ORD-CUM-REFUND", ListingID: 1, AccountID: 1, OwnerID: 1, RenterID: 2, + Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, + DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + if err := distribution.SnapshotPaidOrderTx(db, order.ID, 10000); err != nil { + t.Fatal(err) + } + + // 两笔已退款成功流水:300 + 700 = 1000。 + refunds := []model.PaymentOrder{ + {PaymentNo: "REF-CUM-1", OrderID: order.ID, OrderNo: order.OrderNo, UserID: order.RenterID, Provider: "mock", ThirdOrderID: "REF-CUM-1", AmountCent: 300, BizType: "admin_refund", Status: "refunded"}, + {PaymentNo: "REF-CUM-2", OrderID: order.ID, OrderNo: order.OrderNo, UserID: order.RenterID, Provider: "mock", ThirdOrderID: "REF-CUM-2", AmountCent: 700, BizType: "rent_refund", Status: "refunded"}, + } + if err := db.Create(&refunds).Error; err != nil { + t.Fatal(err) + } + + // 只传其中一笔金额调用:累计口径应取两笔合计 1000。 + if err := repo.updateOrderRefundStatus(t.Context(), order.ID, 300, "admin_refund"); err != nil { + t.Fatal(err) + } + var saved model.RentalOrder + if err := db.First(&saved, order.ID).Error; err != nil { + t.Fatal(err) + } + if saved.RefundAmountCent != 1000 { + t.Fatalf("order.RefundAmountCent = %d, want 1000(两笔退款合计)", saved.RefundAmountCent) + } + if saved.RefundStatus != "refunded" { + t.Fatalf("order.RefundStatus = %s, want refunded", saved.RefundStatus) + } + + var financial model.DistributionOrderFinancial + if err := db.Where("order_id = ?", order.ID).First(&financial).Error; err != nil { + t.Fatal(err) + } + if financial.RefundAmountCent != 1000 || financial.NetPaidAmountCent != 9000 || financial.SplitBaseAmountCent != 3000 { + t.Fatalf("分销快照未按累计退款重算:refund=%d netPaid=%d splitBase=%d", + financial.RefundAmountCent, financial.NetPaidAmountCent, financial.SplitBaseAmountCent) + } + var party model.DistributionOrderParty + if err := db.Where("order_id = ? AND party_role = ?", order.ID, "distributor_sell").First(&party).Error; err != nil { + t.Fatal(err) + } + if party.IncomeAmountCent != 1200 { + t.Fatalf("分站份额 = %d, want 1200(利润 3000 的 40%%)", party.IncomeAmountCent) + } + + // 退款结果查询重复回调不应把累计额写小。 + if err := repo.updateOrderRefundStatus(t.Context(), order.ID, 300, "admin_refund"); err != nil { + t.Fatal(err) + } + if err := db.First(&saved, order.ID).Error; err != nil { + t.Fatal(err) + } + if saved.RefundAmountCent != 1000 { + t.Fatalf("重复回调后 order.RefundAmountCent = %d, want 1000(单调不回退)", saved.RefundAmountCent) + } +} diff --git a/backend/migrations/000070_distribution_settlement_item_party_unique.sql b/backend/migrations/000070_distribution_settlement_item_party_unique.sql new file mode 100644 index 0000000..c1f66ae --- /dev/null +++ b/backend/migrations/000070_distribution_settlement_item_party_unique.sql @@ -0,0 +1,12 @@ +-- +goose Up + +-- 同一订单同一参与方只允许一条结算明细,防止自动释放与手工批次并发重复入账。 +-- 自动释放按 NOT EXISTS 选参与方,并发下两条路径可能同时落明细并各自入账, +-- 由该唯一键兜底,写明细失败重复错误时跳过该参与方。 +ALTER TABLE distribution_settlement_items + ADD UNIQUE KEY uk_distribution_settlement_item_party (order_id, party_id); + +-- +goose Down + +ALTER TABLE distribution_settlement_items + DROP KEY uk_distribution_settlement_item_party; diff --git a/backend/migrations/000071_distribution_release_at_backfill_closed.sql b/backend/migrations/000071_distribution_release_at_backfill_closed.sql new file mode 100644 index 0000000..aa150ae --- /dev/null +++ b/backend/migrations/000071_distribution_release_at_backfill_closed.sql @@ -0,0 +1,17 @@ +-- +goose Up + +-- 000069 回填历史释放时间时只覆盖了 completed,遗漏了同样是可释放终态的 closed 订单, +-- 导致不退款关单(AdminRejectRefund close、AdminSeal 等)的订单 release_at 永久为 NULL。 +-- 这里补齐 closed,使这些订单重新纳入自动释放扫描。 +UPDATE distribution_order_financials f +JOIN rental_orders ro ON ro.id = f.order_id +JOIN distribution_sites s ON s.id = f.site_id +SET f.release_at = DATE_ADD(COALESCE(ro.settled_at, f.created_at), INTERVAL s.settlement_hold_days DAY) +WHERE f.settlement_status = 'pending' + AND f.release_at IS NULL + AND ro.status IN ('completed', 'closed'); + +-- +goose Down + +-- 回填不可逆:无法区分补写的 release_at 与原有值,Down 用占位语句保持 goose 格式。 +SELECT 1; diff --git a/docs/分销网管理后台设计方案.md b/docs/分销网管理后台设计方案.md index 7b1b45c..0422b26 100644 --- a/docs/分销网管理后台设计方案.md +++ b/docs/分销网管理后台设计方案.md @@ -776,6 +776,9 @@ distribution.hao858.com { - 各主体独立计算税费。 - 重复支付、退款和结算请求不会重复入账。 - 分销结算明细与钱包流水一致。 +- 同一订单同一参与方只允许一条结算明细(唯一键 `uk_distribution_settlement_item_party`):自动释放与手工批次并发时以唯一键冲突为准跳过该参与方,不重复入账。 +- 订单累计退款额取「本次回调金额与已退款成功流水合计」的较大值,多笔退款与退款结果重复回调都不会把口径写小;分销重算与仲裁回冲均按此累计额计算。 +- 关单(`AdminClose` / `AdminSeal` / `AdminRejectRefund` 的 close 分支)属于可释放终态,会补写 `release_at`,避免不退款关单的订单永远不被自动结算。 ### 业务正确性