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
@@ -11,6 +11,7 @@ import (
"hfb_sys/backend/internal/logging"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/adminnotification"
"hfb_sys/backend/internal/modules/distribution"
"hfb_sys/backend/internal/modules/notification"
ordermodule "hfb_sys/backend/internal/modules/order"
"hfb_sys/backend/internal/modules/rentergrowth"
@@ -130,6 +131,11 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
if err := tx.Save(&order).Error; err != nil {
return err
}
if order.Status == "completed" {
if err := distribution.EnsureCompletedReleaseAtTx(tx, order.ID); err != nil {
return err
}
}
if err := tx.Save(&listing).Error; err != nil {
return err
}
+21 -11
View File
@@ -17,6 +17,7 @@ type SiteDTO struct {
DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"`
TaxEnabled bool `json:"tax_enabled"`
TaxRateBPS int64 `json:"tax_rate_bps"`
SettlementHoldDays int `json:"settlement_hold_days"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -29,6 +30,7 @@ type CreateSiteRequest struct {
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
TaxEnabled *bool `json:"tax_enabled"`
TaxRateBPS *int64 `json:"tax_rate_bps"`
SettlementHoldDays *int `json:"settlement_hold_days"`
}
type UpdateSiteRequest struct {
@@ -39,6 +41,7 @@ type UpdateSiteRequest struct {
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
TaxEnabled *bool `json:"tax_enabled"`
TaxRateBPS *int64 `json:"tax_rate_bps"`
SettlementHoldDays *int `json:"settlement_hold_days"`
LogoURL *string `json:"logo_url"`
FaviconURL *string `json:"favicon_url"`
BannersJSON *string `json:"banners_json"`
@@ -91,17 +94,22 @@ type UpdateRuleStatusRequest struct {
}
type DistributionOrderDTO struct {
OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"`
SiteID uint64 `json:"site_id"`
ListingID uint64 `json:"listing_id"`
AccountID uint64 `json:"account_id"`
OwnerID uint64 `json:"owner_id"`
RenterID uint64 `json:"renter_id"`
Status string `json:"status"`
HandoffStatus string `json:"handoff_status"`
RefundStatus string `json:"refund_status"`
SettlementStatus string `json:"settlement_status"`
OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"`
SiteID uint64 `json:"site_id"`
ListingID uint64 `json:"listing_id"`
AccountID uint64 `json:"account_id"`
OwnerID uint64 `json:"owner_id"`
RenterID uint64 `json:"renter_id"`
Status string `json:"status"`
HandoffStatus string `json:"handoff_status"`
RefundStatus string `json:"refund_status"`
SettlementStatus string `json:"settlement_status"`
// 分销快照释放状态:pending=待释放,settled=已释放入账;release_at 为预计释放时间。
DistributorSettlementStatus string `json:"distributor_settlement_status"`
ReleaseAt *time.Time `json:"release_at,omitempty"`
DistributionMode string `json:"distribution_mode"`
GrossPaidAmountCent int64 `json:"gross_paid_amount_cent"`
RefundAmountCent int64 `json:"refund_amount_cent"`
@@ -318,6 +326,7 @@ type OnboardSiteRequest struct {
DisplayAccountEnabled *bool `json:"display_account_enabled"`
TaxEnabled *bool `json:"tax_enabled"`
TaxRateBPS *int64 `json:"tax_rate_bps"`
SettlementHoldDays *int `json:"settlement_hold_days"`
AdminUsername string `json:"admin_username" binding:"required,min=3,max=64"`
AdminPassword string `json:"admin_password" binding:"required,min=6,max=64"`
AdminNickname string `json:"admin_nickname"`
@@ -403,6 +412,7 @@ type SiteWalletDTO struct {
SiteID uint64 `json:"site_id"`
AvailableBalanceCent int64 `json:"available_balance_cent"`
FrozenBalanceCent int64 `json:"frozen_balance_cent"`
PendingReleaseCent int64 `json:"pending_release_cent"`
TotalSettledCent int64 `json:"total_settled_cent"`
TotalWithdrawnCent int64 `json:"total_withdrawn_cent"`
}
@@ -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
}
@@ -303,7 +303,7 @@ func TestSnapshotPaidOrderTxAndApplyRefundTx(t *testing.T) {
if financial.GrossPaidAmountCent != grossPaid || financial.SiteID != site.ID {
t.Fatalf("unexpected financial snapshot: %+v", financial)
}
if financial.SplitBaseAmountCent != 3950 || financial.ServiceFeeCent != 988 {
if financial.SplitBaseAmountCent != 4000 || financial.ServiceFeeCent != 1000 || financial.PaymentFeeCent != 50 {
t.Fatalf("profit snapshot = %+v", financial)
}
@@ -311,10 +311,10 @@ func TestSnapshotPaidOrderTxAndApplyRefundTx(t *testing.T) {
if err := db.Where("order_id = ?", order.ID).Order("id ASC").Find(&parties).Error; err != nil || len(parties) != 2 {
t.Fatalf("unexpected parties count: len=%d, err=%v", len(parties), err)
}
if parties[0].PartyRole != "distributor_receive" || parties[0].PartyType != PartyTypeSite || parties[0].PartyID != site.ID || parties[0].IncomeAmountCent != 1383 {
if parties[0].PartyRole != "distributor_receive" || parties[0].PartyType != PartyTypeSite || parties[0].PartyID != site.ID || parties[0].IncomeAmountCent != 1400 {
t.Fatalf("distributor party = %+v", parties[0])
}
if parties[1].PartyRole != "platform_sell" || parties[1].PartyType != PartyTypePlatform || parties[1].PartyID != 0 || parties[1].IncomeAmountCent != 1580 {
if parties[1].PartyRole != "platform_sell" || parties[1].PartyType != PartyTypePlatform || parties[1].PartyID != 0 || parties[1].IncomeAmountCent != 1600 {
t.Fatalf("platform party = %+v", parties[1])
}
@@ -393,10 +393,10 @@ func TestSnapshotPaidOrderTxSplitsPercentOfProfit(t *testing.T) {
if seller == nil || platform == nil {
t.Fatalf("missing parties: %+v", parties)
}
if seller.IncomeAmountCent != 1580 {
t.Fatalf("seller 40%% of profit = %d, want 1580", seller.IncomeAmountCent)
if seller.IncomeAmountCent != 1600 {
t.Fatalf("seller 40%% of profit = %d, want 1600", seller.IncomeAmountCent)
}
if platform.IncomeAmountCent != 1383 || platform.PartyID != 0 {
if platform.IncomeAmountCent != 1400 || platform.PartyID != 0 {
t.Fatalf("platform receive = %+v", platform)
}
}
@@ -460,15 +460,15 @@ func TestApplyRefundTxWithCheckoutAndDeposit(t *testing.T) {
if financial.NetPaidAmountCent != 53330 {
t.Fatalf("financial.NetPaidAmountCent = %d, want 53330", financial.NetPaidAmountCent)
}
if financial.SplitBaseAmountCent != 6913 {
t.Fatalf("financial.SplitBaseAmountCent = %d, want 6913", financial.SplitBaseAmountCent)
if financial.SplitBaseAmountCent != 7180 {
t.Fatalf("financial.SplitBaseAmountCent = %d, want 7180", financial.SplitBaseAmountCent)
}
var distributorParty model.DistributionOrderParty
if err := db.Where("order_id = ? AND party_role = 'distributor_receive'", order.ID).First(&distributorParty).Error; err != nil {
t.Fatal(err)
}
if distributorParty.IncomeAmountCent != 2420 {
t.Fatalf("distributorParty.IncomeAmountCent = %d, want 2420", distributorParty.IncomeAmountCent)
if distributorParty.IncomeAmountCent != 2513 {
t.Fatalf("distributorParty.IncomeAmountCent = %d, want 2513", distributorParty.IncomeAmountCent)
}
}
@@ -133,7 +133,8 @@ func normalizeRulePercents(row *Rule) {
}
}
// CalculateSettlement 先扣通道手续费和号主应得,得到利润后再按 35/40/25 切。
// CalculateSettlement 先扣号主应得与分站加价,得到利润后再按 35/40/25 切。
// 支付通道手续费由主站承担:只记录金额供对账,不再从分成基数中扣除。
func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, error) {
if input.GrossPaidAmountCent < 0 || input.RefundAmountCent < 0 || input.OwnerRentAmountCent < 0 || input.MarkupAmountCent < 0 || input.RefundAmountCent > input.GrossPaidAmountCent {
return SettlementResult{}, ErrInvalidAmount
@@ -146,7 +147,7 @@ func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, er
}
netPaid := input.GrossPaidAmountCent - input.RefundAmountCent
paymentFee := roundHalfUp(float64(netPaid) * float64(rule.PaymentFeeBPS) / 10000)
profit := netPaid - input.OwnerRentAmountCent - input.MarkupAmountCent - paymentFee
profit := netPaid - input.OwnerRentAmountCent - input.MarkupAmountCent
if profit < 0 {
profit = 0
}
@@ -37,14 +37,14 @@ func TestNormalizeSplitPercent(t *testing.T) {
}
}
func TestCalculateSettlementSplitsProfitAfterOwnerAndFee(t *testing.T) {
func TestCalculateSettlementSplitsProfitWithoutPaymentFee(t *testing.T) {
rule := Rule{ServiceFeeValue: 25, ServiceFeeUnit: ServiceFeeUnitPercent, PaymentFeeBPS: 50}
got, err := CalculateSettlement(rule, SettlementInput{GrossPaidAmountCent: 10000, OwnerRentAmountCent: 6000})
if err != nil {
t.Fatal(err)
}
// 通道费 50,利润 10000-6000-50=3950,服务费 25%=988
if got.NetPaidAmountCent != 10000 || got.PaymentFeeCent != 50 || got.SplitBaseAmountCent != 3950 || got.ServiceFeeCent != 988 {
// 通道费 50 由主站承担(仅记录),利润 10000-6000=4000,服务费 25%=1000
if got.NetPaidAmountCent != 10000 || got.PaymentFeeCent != 50 || got.SplitBaseAmountCent != 4000 || got.ServiceFeeCent != 1000 {
t.Fatalf("unexpected settlement: %#v", got)
}
}
@@ -0,0 +1,228 @@
package distribution
import (
"context"
"errors"
"fmt"
"slices"
"time"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/wallet"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// autoBatchPrefix 区分自动释放批次与主站财务手工创建的结算批次(DS 前缀)。
const autoBatchPrefix = "DA"
// autoReleaseBlockedRefundStatuses 进行中的退款不得自动释放,避免先入账再回冲。
var autoReleaseBlockedRefundStatuses = []string{"pending", "pending_review", "refunding"}
// autoReleaseBlockedDisputeStatuses 开放中的维权订单暂缓释放。
var autoReleaseBlockedDisputeStatuses = []string{"open", "processing"}
// AutoReleaseDueSettlements 扫描到达释放时间的分销订单,按站点自动创建批次并释放到可提现余额。
// 返回实际触达释放的订单数。
func (r *Repository) AutoReleaseDueSettlements(ctx context.Context) (int, error) {
if r == nil || r.db == nil {
return 0, ErrDependencyUnavailable
}
if !liveMoneyMovementEnabled() {
return 0, ErrLiveMoneyMovementFrozen
}
now := time.Now()
var orderIDs []uint64
if err := r.db.WithContext(ctx).
Table("distribution_order_financials f").
Select("DISTINCT f.order_id").
Joins("JOIN rental_orders ro ON ro.id = f.order_id").
Where("f.settlement_status = ? AND f.release_at IS NOT NULL AND f.release_at <= ?", "pending", now).
Where("ro.status = ?", "completed").
Where("ro.refund_status NOT IN ?", autoReleaseBlockedRefundStatuses).
Where("NOT EXISTS (SELECT 1 FROM disputes d WHERE d.order_id = ro.id AND d.status IN ?)", autoReleaseBlockedDisputeStatuses).
Order("f.order_id ASC").
Scan(&orderIDs).Error; err != nil {
return 0, err
}
if len(orderIDs) == 0 {
return 0, nil
}
var siteIDs []uint64
if err := r.db.WithContext(ctx).
Table("distribution_order_parties p").
Select("DISTINCT p.party_id").
Where("p.order_id IN ? AND p.party_type = ?", orderIDs, PartyTypeSite).
Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.party_id = p.id)").
Order("p.party_id ASC").
Scan(&siteIDs).Error; err != nil {
return 0, err
}
released := 0
for _, siteID := range siteIDs {
if siteID == 0 {
continue
}
count, err := r.releaseSiteSettlements(ctx, siteID, orderIDs, now)
if err != nil {
return released, fmt.Errorf("释放站点 %d 分销结算失败: %w", siteID, err)
}
released += count
}
return released, nil
}
// releaseSiteSettlements 在事务内释放指定站点上所有到期订单的份额:
// 复用当天的自动批次,生成结算明细并把总额入账到站点钱包。
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 {
// 先锁财务快照再锁参与方,与 ApplyRefundTx 保持同一顺序,避免并发退款回冲时死锁。
var lockedFinancials []model.DistributionOrderFinancial
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("order_id IN ?", orderIDs).
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("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
}
releasable := make(map[uint64]bool, len(parties))
checked := make(map[uint64]struct{}, len(parties))
eligible := make([]model.DistributionOrderParty, 0, len(parties))
for _, party := range parties {
if _, done := checked[party.OrderID]; !done {
ok, err := releasableOrder(tx, party.OrderID, now)
if err != nil {
return err
}
checked[party.OrderID] = struct{}{}
releasable[party.OrderID] = ok
}
if !releasable[party.OrderID] {
continue
}
eligible = append(eligible, party)
}
if len(eligible) == 0 {
return nil
}
batch, err := ensureAutoBatch(tx, siteID, now)
if err != nil {
return err
}
var total int64
orderSet := make(map[uint64]struct{}, len(eligible))
for _, party := range eligible {
item := model.DistributionSettlementItem{
BatchID: batch.ID, OrderID: party.OrderID, PartyID: party.ID,
AmountCent: party.IncomeAmountCent, Status: "settled",
}
if err := tx.Create(&item).Error; err != nil {
return err
}
total += party.IncomeAmountCent
orderSet[party.OrderID] = struct{}{}
}
if total > 0 {
if err := wallet.AppendEntries(tx, wallet.Entry{
UserID: 0,
DistributionSiteID: siteID,
Direction: "in",
AmountCent: total,
BalanceType: "available",
BizType: "distribution_settlement",
BizNo: batch.BatchNo,
Remark: fmt.Sprintf("分销自动结算释放 %s", batch.BatchNo),
}); err != nil {
return err
}
}
if err := tx.Model(&model.DistributionSettlementBatch{}).Where("id = ?", batch.ID).
Updates(map[string]any{
"total_amount_cent": gorm.Expr("total_amount_cent + ?", total),
"status": "settled",
"settled_at": now,
}).Error; err != nil {
return err
}
orderList := make([]uint64, 0, len(orderSet))
for orderID := range orderSet {
orderList = append(orderList, orderID)
}
if err := markFinancialsSettledIfSitePartiesDone(tx, orderList, now); err != nil {
return err
}
releasedOrders = len(orderList)
return nil
})
if err != nil {
return 0, err
}
return releasedOrders, nil
}
// releasableOrder 复核订单是否仍满足释放条件,并返回财务快照。
// 订单在扫描后可能进入退款或维权流程,因此事务内需要重新校验。
func releasableOrder(tx *gorm.DB, orderID uint64, now time.Time) (bool, error) {
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 false, nil
}
return false, err
}
if financial.SettlementStatus != "pending" || financial.ReleaseAt == nil || financial.ReleaseAt.After(now) {
return false, nil
}
var order model.RentalOrder
if err := tx.Select("id, status, refund_status").First(&order, orderID).Error; err != nil {
return false, err
}
if order.Status != "completed" || slices.Contains(autoReleaseBlockedRefundStatuses, order.RefundStatus) {
return false, nil
}
var openDisputes int64
if err := tx.Table("disputes").
Where("order_id = ? AND status IN ?", orderID, autoReleaseBlockedDisputeStatuses).
Count(&openDisputes).Error; err != nil {
return false, err
}
return openDisputes == 0, nil
}
// ensureAutoBatch 复用当天的自动批次(不论 pending/settled),没有则创建,保证对账按站点按天聚合。
func ensureAutoBatch(tx *gorm.DB, siteID uint64, now time.Time) (*model.DistributionSettlementBatch, error) {
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
var batch model.DistributionSettlementBatch
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("site_id = ? AND batch_no LIKE ? AND period_start = ?", siteID, autoBatchPrefix+"%", startOfDay).
Order("id ASC").First(&batch).Error
if err == nil {
return &batch, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
batch = model.DistributionSettlementBatch{
SiteID: siteID,
BatchNo: fmt.Sprintf("%s%s%d", autoBatchPrefix, now.Format("20060102150405.000000000"), siteID),
PeriodStart: startOfDay,
PeriodEnd: startOfDay.AddDate(0, 0, 1),
Status: "pending",
}
if err := tx.Create(&batch).Error; err != nil {
return nil, err
}
return &batch, nil
}
@@ -0,0 +1,443 @@
package distribution
import (
"testing"
"time"
"hfb_sys/backend/internal/model"
)
func TestApplyRefundTxSetsReleaseAtForCompletedOrder(t *testing.T) {
db := distributionTestDB(t)
now := time.Now()
site := mustCreateDistSite(t, db, "rel_site", "释放测试站", "rel.dist.com")
if err := db.Model(&site).Update("settlement_hold_days", 3).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-RELEASE-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
Status: "completed", 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 := ApplyRefundTx(db, order.ID, 0); 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.Fatalf("release_at not set: %+v", financial)
}
want := settledAt.AddDate(0, 0, 3)
if !financial.ReleaseAt.Equal(want) {
t.Fatalf("release_at = %v, want %v", financial.ReleaseAt, want)
}
}
func TestAutoReleaseDueSettlements(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
now := time.Now()
site := mustCreateDistSite(t, db, "auto_site", "自动释放站", "auto.dist.com")
rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now)
past := now.Add(-time.Minute)
order := model.RentalOrder{
OrderNo: "ORD-AUTO-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)
}
financial := model.DistributionOrderFinancial{
OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID,
GrossPaidAmountCent: 10000, NetPaidAmountCent: 10000,
SplitBaseAmountCent: 4000, SettlementStatus: "pending", ReleaseAt: &past,
}
if err := db.Create(&financial).Error; err != nil {
t.Fatal(err)
}
parties := []model.DistributionOrderParty{
{OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400},
{OrderID: order.ID, SiteID: site.ID, PartyRole: "platform_sell", PartyType: PartyTypePlatform, PartyID: 0, IncomeAmountCent: 1600},
}
if err := db.Create(&parties).Error; err != nil {
t.Fatal(err)
}
released, err := repo.AutoReleaseDueSettlements(t.Context())
if err != nil {
t.Fatalf("AutoReleaseDueSettlements failed: %v", err)
}
if released != 1 {
t.Fatalf("released = %d, want 1", released)
}
var updated model.DistributionOrderFinancial
if err := db.Where("order_id = ?", order.ID).First(&updated).Error; err != nil {
t.Fatal(err)
}
if updated.SettlementStatus != "settled" || updated.ReleasedAt == nil {
t.Fatalf("financial not released: %+v", updated)
}
var account model.WalletAccount
if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&account).Error; err != nil {
t.Fatal(err)
}
if account.AvailableBalanceCent != 1400 {
t.Fatalf("site wallet = %d, want 1400", account.AvailableBalanceCent)
}
var batch model.DistributionSettlementBatch
if err := db.Where("site_id = ? AND status = 'settled'", site.ID).First(&batch).Error; err != nil {
t.Fatal(err)
}
if batch.TotalAmountCent != 1400 || len(batch.BatchNo) < 2 || batch.BatchNo[:2] != autoBatchPrefix {
t.Fatalf("batch = %+v", batch)
}
var itemCount int64
if err := db.Model(&model.DistributionSettlementItem{}).
Where("batch_id = ? AND status = 'settled'", batch.ID).Count(&itemCount).Error; err != nil {
t.Fatal(err)
}
if itemCount != 1 {
t.Fatalf("settled items = %d, want 1", itemCount)
}
// 幂等:重复执行不再释放,也不再入账。
released, err = repo.AutoReleaseDueSettlements(t.Context())
if err != nil {
t.Fatal(err)
}
if released != 0 {
t.Fatalf("second run released = %d, want 0", released)
}
if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&account).Error; err != nil {
t.Fatal(err)
}
if account.AvailableBalanceCent != 1400 {
t.Fatalf("site wallet after rerun = %d, want 1400", account.AvailableBalanceCent)
}
}
func TestAutoReleaseSkipsOrderInRefund(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
now := time.Now()
site := mustCreateDistSite(t, db, "hold_site", "退款拦截站", "hold.dist.com")
rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now)
past := now.Add(-time.Minute)
order := model.RentalOrder{
OrderNo: "ORD-HOLD-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "pending_review",
DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID,
}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
financial := model.DistributionOrderFinancial{
OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID,
GrossPaidAmountCent: 10000, NetPaidAmountCent: 10000,
SplitBaseAmountCent: 4000, SettlementStatus: "pending", ReleaseAt: &past,
}
if err := db.Create(&financial).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.DistributionOrderParty{
OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive",
PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400,
}).Error; err != nil {
t.Fatal(err)
}
released, err := repo.AutoReleaseDueSettlements(t.Context())
if err != nil {
t.Fatal(err)
}
if released != 0 {
t.Fatalf("released = %d, want 0 while refund is pending", released)
}
}
func TestRefundAfterReleaseReversesSiteWallet(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
now := time.Now()
site := mustCreateDistSite(t, db, "rev_site", "回冲测试站", "rev.dist.com")
rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now)
past := now.Add(-time.Minute)
order := model.RentalOrder{
OrderNo: "ORD-REV-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)
}
financial := model.DistributionOrderFinancial{
OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID,
GrossPaidAmountCent: 10000, NetPaidAmountCent: 10000,
SplitBaseAmountCent: 4000, SettlementStatus: "pending", ReleaseAt: &past,
}
if err := db.Create(&financial).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&[]model.DistributionOrderParty{
{OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400},
{OrderID: order.ID, SiteID: site.ID, PartyRole: "platform_sell", PartyType: PartyTypePlatform, PartyID: 0, IncomeAmountCent: 1600},
}).Error; err != nil {
t.Fatal(err)
}
if _, err := repo.AutoReleaseDueSettlements(t.Context()); err != nil {
t.Fatal(err)
}
// 释放后退款 3000:利润 1000,分站份额降为 350,应回冲 1050。
if err := ApplyRefundTx(db, order.ID, 3000); err != nil {
t.Fatal(err)
}
var account model.WalletAccount
if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&account).Error; err != nil {
t.Fatal(err)
}
if account.AvailableBalanceCent != 350 {
t.Fatalf("wallet after reversal = %d, want 350", account.AvailableBalanceCent)
}
var updated model.DistributionOrderFinancial
if err := db.Where("order_id = ?", order.ID).First(&updated).Error; err != nil {
t.Fatal(err)
}
if updated.SettlementStatus != "settled" {
t.Fatalf("released financial must stay settled, got %s", updated.SettlementStatus)
}
// 余额被提空后再次回冲应允许扣成负数,避免回冲失败。
if err := db.Model(&model.WalletAccount{}).Where("id = ?", account.ID).
Update("available_balance_cent", 0).Error; err != nil {
t.Fatal(err)
}
if err := ApplyRefundTx(db, order.ID, 10000); err != nil {
t.Fatalf("reverse with empty wallet failed: %v", err)
}
if err := db.Where("id = ?", account.ID).First(&account).Error; err != nil {
t.Fatal(err)
}
if account.AvailableBalanceCent != -350 {
t.Fatalf("wallet = %d, want -350 (allow negative on reversal)", account.AvailableBalanceCent)
}
}
func TestEnsureCompletedReleaseAtTxSetsReleaseAt(t *testing.T) {
db := distributionTestDB(t)
now := time.Now()
site := mustCreateDistSite(t, db, "ens_site", "完单释放站", "ens.dist.com")
if err := db.Model(&site).Update("settlement_hold_days", 2).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-ENS-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
Status: "completed", 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("release_at not set")
}
want := settledAt.AddDate(0, 0, 2)
if !financial.ReleaseAt.Equal(want) {
t.Fatalf("release_at = %v, want %v", financial.ReleaseAt, want)
}
}
func TestAutoReleaseAppendsSameDayBatch(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
now := time.Now()
site := mustCreateDistSite(t, db, "day_site", "同日追加站", "day.dist.com")
rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now)
past := now.Add(-time.Minute)
seed := func(orderNo string) {
t.Helper()
order := model.RentalOrder{
OrderNo: orderNo, 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: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400,
}).Error; err != nil {
t.Fatal(err)
}
}
seed("ORD-DAY-1")
if released, err := repo.AutoReleaseDueSettlements(t.Context()); err != nil || released != 1 {
t.Fatalf("first release = %d err=%v, want 1", released, err)
}
var firstBatch model.DistributionSettlementBatch
if err := db.Where("site_id = ?", site.ID).First(&firstBatch).Error; err != nil {
t.Fatal(err)
}
seed("ORD-DAY-2")
if released, err := repo.AutoReleaseDueSettlements(t.Context()); err != nil || released != 1 {
t.Fatalf("second release = %d err=%v, want 1", released, err)
}
var batches []model.DistributionSettlementBatch
if err := db.Where("site_id = ? AND batch_no LIKE ?", site.ID, autoBatchPrefix+"%").Find(&batches).Error; err != nil {
t.Fatal(err)
}
if len(batches) != 1 {
t.Fatalf("same-day auto batches = %d, want 1", len(batches))
}
if batches[0].ID != firstBatch.ID {
t.Fatalf("second release created a new batch %d, want %d", batches[0].ID, firstBatch.ID)
}
if batches[0].TotalAmountCent != 2800 {
t.Fatalf("batch total = %d, want 2800", batches[0].TotalAmountCent)
}
var itemCount int64
if err := db.Model(&model.DistributionSettlementItem{}).Where("batch_id = ?", batches[0].ID).Count(&itemCount).Error; err != nil {
t.Fatal(err)
}
if itemCount != 2 {
t.Fatalf("batch items = %d, want 2", itemCount)
}
var account model.WalletAccount
if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&account).Error; err != nil {
t.Fatal(err)
}
if account.AvailableBalanceCent != 2800 {
t.Fatalf("wallet = %d, want 2800", account.AvailableBalanceCent)
}
}
func TestAutoReleaseSkipsRefundingOrder(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
now := time.Now()
site := mustCreateDistSite(t, db, "rf_site", "退款中拦截站", "rf.dist.com")
rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now)
past := now.Add(-time.Minute)
order := model.RentalOrder{
OrderNo: "ORD-RF-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "refunding",
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: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400,
}).Error; err != nil {
t.Fatal(err)
}
released, err := repo.AutoReleaseDueSettlements(t.Context())
if err != nil {
t.Fatal(err)
}
if released != 0 {
t.Fatalf("released = %d, want 0 while refunding", released)
}
}
func TestThreePartyRefundAfterReleaseReversesReceiverWallet(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
now := time.Now()
siteA := mustCreateDistSite(t, db, "rev_a", "收号站", "reva.dist.com")
siteB := mustCreateDistSite(t, db, "rev_b", "卖出站", "revb.dist.com")
rule := mustCreateActiveRule(t, db, siteB.ID, ModeThreeParty, now)
past := now.Add(-time.Minute)
order := model.RentalOrder{
OrderNo: "ORD-3P-REV", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "none",
DistributionSiteID: &siteB.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: siteB.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: siteB.ID, PartyRole: "distributor_a", PartyType: PartyTypeSite, PartyID: siteA.ID, IncomeAmountCent: 1400, Ratio: 35},
{OrderID: order.ID, SiteID: siteB.ID, PartyRole: "distributor_b", PartyType: PartyTypeSite, PartyID: siteB.ID, IncomeAmountCent: 1600, Ratio: 40},
}).Error; err != nil {
t.Fatal(err)
}
if released, err := repo.AutoReleaseDueSettlements(t.Context()); err != nil || released != 2 {
t.Fatalf("released = %d err=%v, want 2", released, err)
}
// 退款 3000 后利润 1000:A 350、B 400;回冲必须打到 PartyID(收号站 A),不能打到快照 SiteID(卖出站 B)。
if err := ApplyRefundTx(db, order.ID, 3000); err != nil {
t.Fatal(err)
}
var walletA, walletB model.WalletAccount
if err := db.Where("user_id = 0 AND distribution_site_id = ?", siteA.ID).First(&walletA).Error; err != nil {
t.Fatal(err)
}
if err := db.Where("user_id = 0 AND distribution_site_id = ?", siteB.ID).First(&walletB).Error; err != nil {
t.Fatal(err)
}
if walletA.AvailableBalanceCent != 350 {
t.Fatalf("receiver wallet A = %d, want 350 (clawback on PartyID)", walletA.AvailableBalanceCent)
}
if walletB.AvailableBalanceCent != 400 {
t.Fatalf("seller wallet B = %d, want 400", walletB.AvailableBalanceCent)
}
}
@@ -46,6 +46,7 @@ var (
ErrSyncListingNotFound = errors.New("sync listing not found")
ErrEmptyListingIDs = errors.New("listing ids required")
ErrInvalidListingStatus = errors.New("invalid listing status")
ErrInvalidSettlementHold = errors.New("settlement hold days must be between 0 and 365")
)
const maxSyncListingIDs = 200
@@ -55,6 +56,14 @@ func liveMoneyMovementEnabled() bool {
return env != "production" && env != "prod"
}
// normalizeSettlementHoldDays 校验站点结算冷却天数,允许 0(订单完成即释放)到 365 天。
func normalizeSettlementHoldDays(days int) (int, error) {
if days < 0 || days > 365 {
return 0, ErrInvalidSettlementHold
}
return days, nil
}
type Repository struct {
db *gorm.DB
redis *redis.Client
@@ -232,6 +241,13 @@ func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*Si
if req.TaxRateBPS != nil {
row.TaxRateBPS = *req.TaxRateBPS
}
if req.SettlementHoldDays != nil {
holdDays, err := normalizeSettlementHoldDays(*req.SettlementHoldDays)
if err != nil {
return nil, err
}
row.SettlementHoldDays = holdDays
}
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
return nil, err
}
@@ -244,52 +260,70 @@ func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*Si
func (r *Repository) UpdateSite(ctx context.Context, adminID, id uint64, req UpdateSiteRequest) (*SiteDTO, error) {
var row model.DistributionSite
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSiteNotFound
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrSiteNotFound
}
return err
}
return nil, err
}
if err := r.ensureSiteAccess(ctx, adminID, row.ID); err != nil {
return nil, err
}
if req.SiteName != nil {
row.SiteName = strings.TrimSpace(*req.SiteName)
}
if req.Domain != nil {
row.Domain = strings.TrimSpace(*req.Domain)
}
if req.Status != nil {
row.Status = strings.TrimSpace(*req.Status)
}
if req.DisplayAccountEnabled != nil {
row.DisplayAccountEnabled = *req.DisplayAccountEnabled
}
if req.DefaultSupportGroupID != nil {
row.DefaultSupportGroupID = req.DefaultSupportGroupID
}
if req.TaxEnabled != nil {
row.TaxEnabled = *req.TaxEnabled
}
if req.TaxRateBPS != nil {
row.TaxRateBPS = *req.TaxRateBPS
}
if req.LogoURL != nil {
row.LogoURL = strings.TrimSpace(*req.LogoURL)
}
if req.FaviconURL != nil {
row.FaviconURL = strings.TrimSpace(*req.FaviconURL)
}
if req.BannersJSON != nil {
row.BannersJSON = *req.BannersJSON
}
if req.AnnouncementsJSON != nil {
row.AnnouncementsJSON = *req.AnnouncementsJSON
}
if req.FooterInfoJSON != nil {
row.FooterInfoJSON = *req.FooterInfoJSON
}
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
if err := r.ensureSiteAccessWithDB(ctx, tx, adminID, row.ID); err != nil {
return err
}
if req.SiteName != nil {
row.SiteName = strings.TrimSpace(*req.SiteName)
}
if req.Domain != nil {
row.Domain = strings.TrimSpace(*req.Domain)
}
if req.Status != nil {
row.Status = strings.TrimSpace(*req.Status)
}
if req.DisplayAccountEnabled != nil {
row.DisplayAccountEnabled = *req.DisplayAccountEnabled
}
if req.DefaultSupportGroupID != nil {
row.DefaultSupportGroupID = req.DefaultSupportGroupID
}
if req.TaxEnabled != nil {
row.TaxEnabled = *req.TaxEnabled
}
if req.TaxRateBPS != nil {
row.TaxRateBPS = *req.TaxRateBPS
}
holdDaysChanged := false
if req.SettlementHoldDays != nil {
holdDays, err := normalizeSettlementHoldDays(*req.SettlementHoldDays)
if err != nil {
return err
}
holdDaysChanged = holdDays != row.SettlementHoldDays
row.SettlementHoldDays = holdDays
}
if req.LogoURL != nil {
row.LogoURL = strings.TrimSpace(*req.LogoURL)
}
if req.FaviconURL != nil {
row.FaviconURL = strings.TrimSpace(*req.FaviconURL)
}
if req.BannersJSON != nil {
row.BannersJSON = *req.BannersJSON
}
if req.AnnouncementsJSON != nil {
row.AnnouncementsJSON = *req.AnnouncementsJSON
}
if req.FooterInfoJSON != nil {
row.FooterInfoJSON = *req.FooterInfoJSON
}
if err := tx.Save(&row).Error; err != nil {
return err
}
if holdDaysChanged {
return rewritePendingReleaseAt(tx, row.ID)
}
return nil
})
if err != nil {
return nil, err
}
item := siteDTO(row)
@@ -403,6 +437,7 @@ func siteDTO(row model.DistributionSite) SiteDTO {
DefaultSupportGroupID: row.DefaultSupportGroupID,
TaxEnabled: row.TaxEnabled,
TaxRateBPS: row.TaxRateBPS,
SettlementHoldDays: row.SettlementHoldDays,
LogoURL: row.LogoURL,
FaviconURL: row.FaviconURL,
BannersJSON: row.BannersJSON,
@@ -476,6 +511,8 @@ func (r *Repository) ListOrders(ctx context.Context, query DistributionOrderList
COALESCE(f.tax_amount_cent, 0) AS tax_amount_cent,
COALESCE(f.split_base_amount_cent, 0) AS split_base_amount_cent,
COALESCE(f.rounding_adjustment_cent, 0) AS rounding_adjustment_cent,
COALESCE(f.settlement_status, '') AS distributor_settlement_status,
f.release_at AS release_at,
ro.created_at,
COALESCE(rl.listing_no, '') AS listing_no,
COALESCE(ga.title, '') AS title,
@@ -555,6 +592,8 @@ func (r *Repository) FindOrder(ctx context.Context, adminID, id uint64) (*Distri
COALESCE(f.tax_amount_cent, 0) AS tax_amount_cent,
COALESCE(f.split_base_amount_cent, 0) AS split_base_amount_cent,
COALESCE(f.rounding_adjustment_cent, 0) AS rounding_adjustment_cent,
COALESCE(f.settlement_status, '') AS distributor_settlement_status,
f.release_at AS release_at,
ro.created_at,
COALESCE(rl.listing_no, '') AS listing_no,
COALESCE(ga.title, '') AS title,
@@ -1048,7 +1087,7 @@ func markFinancialsSettledIfSitePartiesDone(tx *gorm.DB, orderIDs []uint64, now
}
if err := tx.Model(&model.DistributionOrderFinancial{}).
Where("order_id = ?", orderID).
Updates(map[string]any{"settlement_status": "settled", "settled_at": now}).Error; err != nil {
Updates(map[string]any{"settlement_status": "settled", "settled_at": now, "released_at": now}).Error; err != nil {
return err
}
}
@@ -1312,6 +1351,14 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*
if req.TaxRateBPS != nil {
taxRate = *req.TaxRateBPS
}
holdDays := 7
if req.SettlementHoldDays != nil {
normalized, err := normalizeSettlementHoldDays(*req.SettlementHoldDays)
if err != nil {
return err
}
holdDays = normalized
}
site := model.DistributionSite{
SiteCode: req.SiteCode,
@@ -1321,6 +1368,7 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*
DisplayAccountEnabled: displayEnabled,
TaxEnabled: taxEnabled,
TaxRateBPS: taxRate,
SettlementHoldDays: holdDays,
}
if err := tx.Create(&site).Error; err != nil {
return err
@@ -1891,6 +1939,19 @@ func (r *Repository) GetSiteWallet(ctx context.Context, adminID, siteID uint64)
Where("dsb.site_id = ? AND dsi.status = 'settled'", siteID).
Select("COALESCE(SUM(dsi.amount_cent), 0)").Scan(&totalSettled).Error
// 待释放收益:订单已完成、快照待释放、且尚未进入任何结算批次的站内份额。
var pendingRelease int64
_ = r.db.WithContext(ctx).Table("distribution_order_parties p").
Joins("JOIN distribution_order_financials f ON f.order_id = p.order_id").
Joins("JOIN rental_orders ro ON ro.id = p.order_id").
Where("p.party_type = ? AND p.party_id = ?", PartyTypeSite, siteID).
Where("f.settlement_status = 'pending' AND f.release_at IS NOT NULL").
Where("ro.status = 'completed'").
Where("ro.refund_status NOT IN ?", autoReleaseBlockedRefundStatuses).
Where("NOT EXISTS (SELECT 1 FROM disputes d WHERE d.order_id = ro.id AND d.status IN ?)", autoReleaseBlockedDisputeStatuses).
Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.party_id = p.id)").
Select("COALESCE(SUM(p.income_amount_cent), 0)").Scan(&pendingRelease).Error
var totalWithdrawn int64
_ = r.db.WithContext(ctx).Model(&model.WithdrawalRequest{}).
Where("user_id = 0 AND distribution_site_id = ? AND status = 'completed'", siteID).
@@ -1900,6 +1961,7 @@ func (r *Repository) GetSiteWallet(ctx context.Context, adminID, siteID uint64)
SiteID: siteID,
AvailableBalanceCent: account.AvailableBalanceCent,
FrozenBalanceCent: account.FrozenBalanceCent,
PendingReleaseCent: pendingRelease,
TotalSettledCent: totalSettled,
TotalWithdrawnCent: totalWithdrawn,
}, nil
@@ -23,6 +23,7 @@ func distributionTestDB(t *testing.T) *gorm.DB {
&model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{},
&model.RentalListing{}, &model.GameAccount{}, &model.User{},
&model.WalletAccount{}, &model.WalletLedger{}, &model.WithdrawalRequest{},
&model.Dispute{},
); err != nil {
t.Fatal(err)
}
@@ -984,3 +985,93 @@ func TestLiveMoneyMovementFrozenInProduction(t *testing.T) {
t.Fatalf("withdraw err = %v", err)
}
}
func TestUpdateSiteRewritesPendingReleaseAt(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
now := time.Now()
site := mustCreateDistSite(t, db, "hold_upd", "冷却回写站", "holdupd.dist.com")
if err := db.Model(&site).Update("settlement_hold_days", 7).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-HOLD-UPD", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000,
DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID,
SettledAt: &settledAt,
}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
oldRelease := settledAt.AddDate(0, 0, 7)
if err := db.Create(&model.DistributionOrderFinancial{
OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID,
GrossPaidAmountCent: 10000, SettlementStatus: "pending", ReleaseAt: &oldRelease,
}).Error; err != nil {
t.Fatal(err)
}
holdDays := 0
if _, err := repo.UpdateSite(t.Context(), 0, site.ID, UpdateSiteRequest{SettlementHoldDays: &holdDays}); 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 || !financial.ReleaseAt.Equal(settledAt) {
t.Fatalf("release_at = %v, want %v after hold days set to 0", financial.ReleaseAt, settledAt)
}
}
func TestGetSiteWalletPendingReleaseExcludesOpenDispute(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
now := time.Now()
site := mustCreateDistSite(t, db, "pend_dsp", "待释放纠纷站", "penddsp.dist.com")
rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now)
past := now.Add(-time.Minute)
order := model.RentalOrder{
OrderNo: "ORD-PEND-DSP", 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, 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: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400,
}).Error; err != nil {
t.Fatal(err)
}
walletDTO, err := repo.GetSiteWallet(t.Context(), 0, site.ID)
if err != nil {
t.Fatal(err)
}
if walletDTO.PendingReleaseCent != 1400 {
t.Fatalf("pending release = %d, want 1400", walletDTO.PendingReleaseCent)
}
if err := db.Create(&model.Dispute{
OrderID: order.ID, InitiatorID: 4, TargetUserID: 3, Type: "cannot_login", Status: "open",
}).Error; err != nil {
t.Fatal(err)
}
walletDTO, err = repo.GetSiteWallet(t.Context(), 0, site.ID)
if err != nil {
t.Fatal(err)
}
if walletDTO.PendingReleaseCent != 0 {
t.Fatalf("pending release with open dispute = %d, want 0", walletDTO.PendingReleaseCent)
}
}
@@ -57,7 +57,9 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
return nil, err
}
if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 {
_ = distribution.ApplyRefundTx(tx, order.ID, 0)
if err := distribution.ApplyRefundTx(tx, order.ID, 0); err != nil {
return nil, err
}
}
return refund, nil
@@ -30,6 +30,9 @@ type Entry struct {
BizType string
BizNo string
Remark string
// AllowNegative 仅用于结算回冲等追偿场景:余额不足时允许扣成负数,
// 由后续收益抵扣,避免回冲失败导致账实不一致。
AllowNegative bool
}
func NewRepository(db *gorm.DB) *Repository {
@@ -240,7 +243,7 @@ func applyEntry(account *model.WalletAccount, entry Entry) (int64, error) {
if entry.Direction == "in" {
account.AvailableBalanceCent += entry.AmountCent
} else {
if account.AvailableBalanceCent < entry.AmountCent {
if account.AvailableBalanceCent < entry.AmountCent && !entry.AllowNegative {
return 0, ErrInsufficientBalance
}
account.AvailableBalanceCent -= entry.AmountCent