fix(distribution): 结算幂等与退款口径加固,补关单释放与回归测试

- 结算明细加唯一键 (order_id, party_id):自动释放与手工批次并发时以唯一键冲突为准跳过该参与方,不再重复入账
- 自动释放先无锁筛出本站未入账参与方,收敛加锁范围;手工批次建批次后先锁周期内财务快照,与自动释放串行化
- 累计退款额改为「本次回调金额与已退款成功流水合计取较大值」,多笔退款与退款结果重复回调不再把口径写小
- 关单三路径(AdminClose / AdminRejectRefund 的 close 分支 / AdminSeal)补写 release_at,接线原死代码 EnsureCompletedReleaseAtTx
- 仲裁分销重算改用实际退款额(扣除押金暂扣),避免站点被多扣
- 订单列表与详情的提价分成取 min(收入, 货架加价),退款压缩加价后不再显示负数
- 新增迁移 000070(结算明细唯一键)、000071(补回填 closed 订单释放时间)
- 新增 5 个测试文件:结算幂等与展示钳制、结算 job 幂等、累计退款、关单补写释放时间、仲裁押金暂扣口径
This commit is contained in:
yml2213
2026-09-10 19:13:39 +08:00
parent e3a75fb2d7
commit 36ebe7a5f1
13 changed files with 1017 additions and 10 deletions
@@ -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) {
@@ -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
@@ -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)
}