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