feat(distribution): 通道费改主站承担,并按冷却期自动释放结算

利润不再扣支付通道费;站点可配置结算冷却期,到期自动入账,已释放后退款从收款站钱包回冲。同时修复当日自动批次无法追加、三方回冲打错钱包,以及测试库自增重置被 MySQL 统计缓存跳过的问题。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-09-10 17:11:58 +08:00
co-authored by Cursor
parent 47ceb53c09
commit 647fdc0b4a
24 changed files with 1399 additions and 129 deletions
@@ -6,8 +6,10 @@ import (
"time"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/wallet"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// distributionTablesPresent 探测分销财务表是否存在,仅首次访问查询 information_schema,
@@ -172,7 +174,7 @@ func calculateForRule(rule model.DistributionRuleVersion, input SettlementInput)
}
// SnapshotPaidOrderTx 在支付确认事务内幂等写入财务和参与方快照。
// 利润 = 租金 − 号主 − 通道手续费,再按 35%/40%/25% 切;尾差记入 RoundingAdjustmentCent。
// 利润 = 租金 − 号主 − 加价(通道手续费由主站承担,只记录),再按 35%/40%/25% 切;尾差记入 RoundingAdjustmentCent。
func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error {
if !distributionTablesPresent(tx) {
return nil
@@ -206,6 +208,14 @@ func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error
}
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, markup)
financial := model.DistributionOrderFinancial{OrderID: order.ID, SiteID: *order.DistributionSiteID, RuleVersionID: rule.ID, GrossPaidAmountCent: grossRent, NetPaidAmountCent: result.NetPaidAmountCent, PaymentFeeBPS: rule.PaymentFeeBPS, PaymentFeeCent: result.PaymentFeeCent, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: markup, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, RoundingAdjustmentCent: SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, markup, splits), SettlementStatus: "pending"}
// 支付回调可能重复触发:保留已写入的释放与结算时间,避免把已释放的快照重置回待释放。
var existing model.DistributionOrderFinancial
if err := tx.Where("order_id = ?", order.ID).First(&existing).Error; err == nil {
financial.ReleaseAt = existing.ReleaseAt
financial.ReleasedAt = existing.ReleasedAt
financial.SettledAt = existing.SettledAt
financial.SettlementStatus = existing.SettlementStatus
}
if err := tx.Where("order_id = ?", order.ID).Assign(financial).FirstOrCreate(&financial).Error; err != nil {
return err
}
@@ -246,7 +256,7 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
return nil
}
var financial model.DistributionOrderFinancial
if err := tx.Where("order_id = ?", orderID).First(&financial).Error; err != nil {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("order_id = ?", orderID).First(&financial).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
@@ -327,6 +337,15 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
return err
}
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, financial.MarkupAmountCent)
// 已释放入账的订单保持 settled,避免自动释放任务重复入账;差额通过回冲流水追偿。
settlementStatus := "pending"
if financial.ReleasedAt != nil {
settlementStatus = "settled"
}
releaseAt, err := computeReleaseAt(tx, order, financial)
if err != nil {
return err
}
if err := tx.Model(&financial).Updates(map[string]any{
"refund_amount_cent": rentRefundCent,
"net_paid_amount_cent": result.NetPaidAmountCent,
@@ -334,13 +353,47 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
"service_fee_cent": result.ServiceFeeCent,
"split_base_amount_cent": result.SplitBaseAmountCent,
"rounding_adjustment_cent": SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, financial.MarkupAmountCent, splits),
"settlement_status": "pending",
"settlement_status": settlementStatus,
"release_at": releaseAt,
}).Error; err != nil {
return err
}
for _, split := range splits {
var party model.DistributionOrderParty
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("order_id = ? AND party_role = ?", orderID, split.PartyRole).First(&party).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
continue
}
return err
}
if party.IncomeAmountCent == split.AmountCent && party.ProfitAmountCent == split.AmountCent {
continue
}
// 已结算入账的分站份额被退款调减时,从收款站点钱包扣回差额(允许扣成负数,由后续收益抵扣)。
settled, err := partySettled(tx, party.ID)
if err != nil {
return err
}
if split.AmountCent < party.IncomeAmountCent && party.PartyType == PartyTypeSite && settled {
reversal := party.IncomeAmountCent - split.AmountCent
if err := wallet.AppendEntries(tx, wallet.Entry{
UserID: 0,
DistributionSiteID: party.PartyID,
OrderID: &orderID,
Direction: "out",
AmountCent: reversal,
BalanceType: "available",
BizType: "distribution_reversal",
BizNo: order.OrderNo,
Remark: "结算入账后退款回冲",
AllowNegative: true,
}); err != nil {
return err
}
}
if err := tx.Model(&model.DistributionOrderParty{}).
Where("order_id = ? AND party_role = ?", orderID, split.PartyRole).
Where("id = ?", party.ID).
Updates(map[string]any{
"income_amount_cent": split.AmountCent,
"profit_amount_cent": split.AmountCent,
@@ -350,3 +403,88 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
}
return nil
}
// EnsureCompletedReleaseAtTx 在订单进入 completed 且尚未入账时写入 release_at。
// 无分销快照时为 no-op,供结账、仲裁、退款重算共用,避免完单后漏写导致永远无法自动释放。
func EnsureCompletedReleaseAtTx(tx *gorm.DB, orderID uint64) error {
if !distributionTablesPresent(tx) {
return nil
}
var financial model.DistributionOrderFinancial
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("order_id = ?", orderID).First(&financial).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
var order model.RentalOrder
if err := tx.Select("id, status, settled_at").First(&order, orderID).Error; err != nil {
return err
}
releaseAt, err := computeReleaseAt(tx, order, financial)
if err != nil {
return err
}
return tx.Model(&financial).Update("release_at", releaseAt).Error
}
// computeReleaseAt 按站点当前冷却天数计算释放时间;已入账快照保持原 release_at。
func computeReleaseAt(tx *gorm.DB, order model.RentalOrder, financial model.DistributionOrderFinancial) (*time.Time, error) {
if financial.ReleasedAt != nil {
return financial.ReleaseAt, nil
}
if order.Status != "completed" {
return financial.ReleaseAt, nil
}
completedAt := order.SettledAt
if completedAt == nil {
now := time.Now()
completedAt = &now
}
var site model.DistributionSite
if err := tx.Select("id, settlement_hold_days").First(&site, financial.SiteID).Error; err != nil {
return nil, err
}
due := completedAt.AddDate(0, 0, site.SettlementHoldDays)
return &due, nil
}
// rewritePendingReleaseAt 站点冷却天数变更后,按新规则回写尚未入账的已完成订单释放时间。
func rewritePendingReleaseAt(tx *gorm.DB, siteID uint64) error {
var financials []model.DistributionOrderFinancial
if err := tx.Where("site_id = ? AND settlement_status = ? AND released_at IS NULL", siteID, "pending").
Find(&financials).Error; err != nil {
return err
}
for _, financial := range financials {
var order model.RentalOrder
if err := tx.Select("id, status, settled_at").First(&order, financial.OrderID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
continue
}
return err
}
if order.Status != "completed" {
continue
}
releaseAt, err := computeReleaseAt(tx, order, financial)
if err != nil {
return err
}
if err := tx.Model(&model.DistributionOrderFinancial{}).Where("id = ?", financial.ID).
Update("release_at", releaseAt).Error; err != nil {
return err
}
}
return nil
}
// partySettled 判断参与方份额是否已经通过结算批次入账。
func partySettled(tx *gorm.DB, partyID uint64) (bool, error) {
var count int64
if err := tx.Model(&model.DistributionSettlementItem{}).
Where("party_id = ? AND status = 'settled'", partyID).Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}