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
+2
View File
@@ -12,6 +12,7 @@ import (
"hfb_sys/backend/internal/config"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/jobs/distributionsettlement"
"hfb_sys/backend/internal/jobs/fileuploadcleanup"
"hfb_sys/backend/internal/jobs/ordertimeout"
"hfb_sys/backend/internal/jobs/refundretry"
@@ -99,6 +100,7 @@ func main() {
if deps.DB != nil {
ordertimeout.New(deps.DB, deps.Redis, logger).Start(jobCtx)
fileuploadcleanup.New(deps.DB, deps.Redis, logger).Start(jobCtx)
distributionsettlement.New(deps.DB, deps.Redis, logger).Start(jobCtx)
if paymentConfigRepo := newPaymentConfigRepositoryForJobs(cfg, deps.DB, logger); paymentConfigRepo != nil {
paymentRepo := payment.NewRepository(deps.DB, paymentConfigRepo, nil, payment.WithLogger(logger))
refundretry.New(deps.DB, deps.Redis, logger, paymentRepo).Start(jobCtx)
+12 -1
View File
@@ -494,7 +494,18 @@ func truncateAllTables(db *gorm.DB) error {
}
func listAutoIncrementTables(db *sql.DB) ([]string, error) {
rows, err := db.Query(
ctx := context.Background()
conn, err := db.Conn(ctx)
if err != nil {
return nil, err
}
defer func() { _ = conn.Close() }()
// MySQL 8 默认缓存 information_schema 的统计信息(86400s),
// 直接读取 auto_increment 可能拿到陈旧值并跳过重置,必须在本连接关闭缓存后再查。
if _, err := conn.ExecContext(ctx, "SET SESSION information_schema_stats_expiry = 0"); err != nil {
return nil, err
}
rows, err := conn.QueryContext(ctx,
"SELECT table_name FROM information_schema.tables "+
"WHERE table_schema = DATABASE() AND table_name <> ? AND auto_increment > 1",
schemaMetaTable,
+27 -27
View File
@@ -29,10 +29,10 @@ func openDistributionE2EDB(t *testing.T) *gorm.DB {
// 4. 支付成功自动触发快照:
// - 号主成本 80 元保底;
// - 分站自主加价 25 元 100% 归卖出分站;
// - 主站提价净利润(20 元 - 通道费 0.63 元 = 19.37 元)按 35% / 40% / 25% 严丝合缝切分;
// - 卖出分站总获得 32.75 元;
// - 主站提价利润 20 元(通道费 0.63 元由主站承担)按 35% / 40% / 25% 严丝合缝切分;
// - 卖出分站总获得 33.00 元;
//
// 5. 完单后创建结算批次并执行结算,分站金库钱包精确入账 32.75 元,财务状态置为 settled。
// 5. 完单后创建结算批次并执行结算,分站金库钱包精确入账 33.00 元,财务状态置为 settled。
func TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement(t *testing.T) {
db := openDistributionE2EDB(t)
ctx := context.Background()
@@ -201,17 +201,17 @@ func TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement(t *testing.T)
if financial.GrossPaidAmountCent != 12500 || financial.MarkupAmountCent != 2500 {
t.Fatalf("financial snapshot error: gross=%d, markup=%d", financial.GrossPaidAmountCent, financial.MarkupAmountCent)
}
// 支付通道费 0.5%:12500 * 50 / 10000 = 63 分
// 支付通道费 0.5%:12500 * 50 / 10000 = 63 分,由主站承担,不参与分成基数
if financial.PaymentFeeCent != 63 {
t.Fatalf("payment fee = %d, want 63", financial.PaymentFeeCent)
}
// 主站提价总额 10000 - 8000 = 2000 分,扣除通道费 63 分后利润基数 = 1937 分
if financial.SplitBaseAmountCent != 1937 {
t.Fatalf("split base amount = %d, want 1937", financial.SplitBaseAmountCent)
// 主站提价总额 10000 - 8000 = 2000 分,通道费不扣减,利润基数 = 2000 分
if financial.SplitBaseAmountCent != 2000 {
t.Fatalf("split base amount = %d, want 2000", financial.SplitBaseAmountCent)
}
// 平台服务费 25%:1937 * 25% = 484 分
if financial.ServiceFeeCent != 484 {
t.Fatalf("service fee = %d, want 484", financial.ServiceFeeCent)
// 平台服务费 25%:2000 * 25% = 500 分
if financial.ServiceFeeCent != 500 {
t.Fatalf("service fee = %d, want 500", financial.ServiceFeeCent)
}
// 验证各参与方收益快照
@@ -221,7 +221,7 @@ func TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement(t *testing.T)
}
// 参与方 0:distributor_sell(分站 A,卖方)
// 收益 = 分站自主加价 2500 分 + 提价分成 40% (1937 * 40% = 775) = 3275 分
// 收益 = 分站自主加价 2500 分 + 提价分成 40% (2000 * 40% = 800) = 3300 分
var distributorParty, platformParty model.DistributionOrderParty
for _, p := range parties {
if p.PartyRole == "distributor_sell" {
@@ -230,15 +230,15 @@ func TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement(t *testing.T)
platformParty = p
}
}
if distributorParty.IncomeAmountCent != 3275 || distributorParty.PartyID != siteA.ID {
t.Fatalf("distributor_sell party error: income=%d (want 3275), partyID=%d", distributorParty.IncomeAmountCent, distributorParty.PartyID)
if distributorParty.IncomeAmountCent != 3300 || distributorParty.PartyID != siteA.ID {
t.Fatalf("distributor_sell party error: income=%d (want 3300), partyID=%d", distributorParty.IncomeAmountCent, distributorParty.PartyID)
}
// 参与方 1:platform_receive(平台收号方,35% 分成:1937 * 35% = 678 分)
if platformParty.IncomeAmountCent != 678 {
t.Fatalf("platform_receive party error: income=%d (want 678)", platformParty.IncomeAmountCent)
// 参与方 1:platform_receive(平台收号方,35% 分成:2000 * 35% = 700 分)
if platformParty.IncomeAmountCent != 700 {
t.Fatalf("platform_receive party error: income=%d (want 700)", platformParty.IncomeAmountCent)
}
// 尾差校验:1937 - (775 + 678) - 484 = 0
// 尾差校验:2000 - (800 + 700) - 500 = 0
if financial.RoundingAdjustmentCent != 0 {
t.Fatalf("rounding adjustment = %d, want 0", financial.RoundingAdjustmentCent)
}
@@ -257,8 +257,8 @@ func TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement(t *testing.T)
if err != nil {
t.Fatalf("CreateSettlementBatch failed: %v", err)
}
if batch.TotalAmountCent != 3275 {
t.Fatalf("batch total amount = %d, want 3275", batch.TotalAmountCent)
if batch.TotalAmountCent != 3300 {
t.Fatalf("batch total amount = %d, want 3300", batch.TotalAmountCent)
}
// 确认结算批次入账
@@ -284,8 +284,8 @@ func TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement(t *testing.T)
if err := db.Where("user_id = 0 AND distribution_site_id = ?", siteA.ID).First(&siteWallet).Error; err != nil {
t.Fatalf("find site wallet failed: %v", err)
}
if siteWallet.AvailableBalanceCent != 3275 {
t.Fatalf("site wallet balance = %d, want 3275", siteWallet.AvailableBalanceCent)
if siteWallet.AvailableBalanceCent != 3300 {
t.Fatalf("site wallet balance = %d, want 3300", siteWallet.AvailableBalanceCent)
}
// 验证钱包流水记录
@@ -293,7 +293,7 @@ func TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement(t *testing.T)
if err := db.Where("user_id = 0 AND distribution_site_id = ? AND biz_type = 'distribution_settlement'", siteA.ID).First(&ledger).Error; err != nil {
t.Fatalf("find wallet ledger failed: %v", err)
}
if ledger.AmountCent != 3275 || ledger.Direction != "in" {
if ledger.AmountCent != 3300 || ledger.Direction != "in" {
t.Fatalf("wallet ledger error: amount=%d, direction=%s", ledger.AmountCent, ledger.Direction)
}
@@ -302,9 +302,9 @@ func TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement(t *testing.T)
t.Logf("号主保底成本: %d 分 (80.00元)", 8000)
t.Logf("分站自主加价(独享): %d 分 (25.00元)", shelfMarkupCent)
t.Logf("支付通道手续费(0.5%%): %d 分 (0.63元)", financial.PaymentFeeCent)
t.Logf("主站提价净利润: %d 分 (19.37元)", financial.SplitBaseAmountCent)
t.Logf(" ├─ 收号方(主站 35%%): %d 分 (6.78元)", platformParty.IncomeAmountCent)
t.Logf(" ├─ 卖出方提价分成(分站 40%%): %d 分 (7.75元)", distributorParty.IncomeAmountCent-shelfMarkupCent)
t.Logf(" └─ 平台服务费(25%%): %d 分 (4.84元)", financial.ServiceFeeCent)
t.Logf("分站 A 最终结算到账: %d 分 (32.75元) [加价25元 + 分成7.75元]", siteWallet.AvailableBalanceCent)
t.Logf("主站提价净利润(主站承担通道费): %d 分 (20.00元)", financial.SplitBaseAmountCent)
t.Logf(" ├─ 收号方(主站 35%%): %d 分 (7.00元)", platformParty.IncomeAmountCent)
t.Logf(" ├─ 卖出方提价分成(分站 40%%): %d 分 (8.00元)", distributorParty.IncomeAmountCent-shelfMarkupCent)
t.Logf(" └─ 平台服务费(25%%): %d 分 (5.00元)", financial.ServiceFeeCent)
t.Logf("分站 A 最终结算到账: %d 分 (33.00元) [加价25元 + 分成8元]", siteWallet.AvailableBalanceCent)
}
@@ -0,0 +1,117 @@
package distributionsettlement
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"hfb_sys/backend/internal/modules/distribution"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
)
const releaseLockKey = "hfb:job:distributionsettlement:lock"
type Job struct {
db *gorm.DB
redis *redis.Client
logger *zap.Logger
interval time.Duration
instanceID string
}
func New(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger) *Job {
return &Job{
db: db,
redis: redisClient,
logger: logger,
interval: 10 * time.Minute,
instanceID: newInstanceID(),
}
}
// acquireLock 通过 Redis 分布式锁确保同一时刻只有一个实例执行结算释放。
// 未配置 Redis 时直接执行;释放逻辑本身幂等(行锁 + 结算明细唯一性)。
func (j *Job) acquireLock(ctx context.Context) (func(), bool) {
if j.redis == nil {
return func() {}, true
}
ok, err := j.redis.SetNX(ctx, releaseLockKey, j.instanceID, j.lockTTL()).Result()
if err != nil {
j.logger.Warn("分销自动结算任务获取锁失败,将以单实例模式执行", zap.Error(err))
return func() {}, true
}
if !ok {
return nil, false
}
return j.releaseLock, true
}
// releaseLock 仅在锁仍归本实例时释放,避免误删其他实例的锁。
func (j *Job) releaseLock() {
if j.redis == nil {
return
}
relCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
script := redis.NewScript(`if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`)
if err := script.Run(relCtx, j.redis, []string{releaseLockKey}, j.instanceID).Err(); err != nil {
j.logger.Warn("分销自动结算任务释放锁失败", zap.Error(err))
}
}
func (j *Job) lockTTL() time.Duration {
return 2 * j.interval
}
func newInstanceID() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("inst-%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
func (j *Job) Start(ctx context.Context) {
if j == nil || j.db == nil {
return
}
go j.loop(ctx)
}
func (j *Job) loop(ctx context.Context) {
j.run(ctx)
ticker := time.NewTicker(j.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
j.logger.Debug("分销自动结算任务已停止")
return
case <-ticker.C:
j.run(ctx)
}
}
}
func (j *Job) run(ctx context.Context) {
release, ok := j.acquireLock(ctx)
if !ok {
return
}
defer release()
repo := distribution.NewRepository(j.db)
count, err := repo.AutoReleaseDueSettlements(ctx)
if err != nil {
j.logger.Warn("分销自动结算释放失败", zap.Error(err))
return
}
if count > 0 {
j.logger.Info("分销自动结算释放完成", zap.Int("orders", count))
}
}
+3
View File
@@ -18,6 +18,7 @@ type DistributionSite struct {
DefaultSupportGroupID *uint64 `gorm:"index" json:"default_support_group_id,omitempty"`
TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"`
TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"`
SettlementHoldDays int `gorm:"not null;default:7" json:"settlement_hold_days"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -102,6 +103,8 @@ type DistributionOrderFinancial struct {
SplitBaseAmountCent int64 `gorm:"not null;default:0" json:"split_base_amount_cent"`
RoundingAdjustmentCent int64 `gorm:"not null;default:0" json:"rounding_adjustment_cent"`
SettlementStatus string `gorm:"size:24;not null;default:'pending';index" json:"settlement_status"`
ReleaseAt *time.Time `gorm:"index:idx_distribution_financial_release,priority:2" json:"release_at,omitempty"`
ReleasedAt *time.Time `json:"released_at,omitempty"`
SettledAt *time.Time `json:"settled_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -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
@@ -0,0 +1,30 @@
-- +goose Up
-- 分销站点结算冷却期:订单完成后 N 天自动释放到可提现余额,0 表示立即释放。
ALTER TABLE distribution_sites
ADD COLUMN settlement_hold_days INT NOT NULL DEFAULT 7 COMMENT '订单完成后结算释放冷却天数,0=立即释放';
-- 财务快照记录释放时间:release_at 为订单完成时间 + 站点冷却期,released_at 为实际入账时间。
ALTER TABLE distribution_order_financials
ADD COLUMN release_at DATETIME(6) NULL COMMENT '预计释放时间(订单完成时间+站点冷却期)',
ADD COLUMN released_at DATETIME(6) NULL COMMENT '实际释放入账时间',
ADD KEY idx_distribution_financial_release (settlement_status, release_at);
-- 回填历史已完成订单的释放时间,使其纳入自动释放。
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 = 'completed';
-- +goose Down
ALTER TABLE distribution_order_financials
DROP KEY idx_distribution_financial_release,
DROP COLUMN released_at,
DROP COLUMN release_at;
ALTER TABLE distribution_sites
DROP COLUMN settlement_hold_days;
+3
View File
@@ -152,6 +152,8 @@ export interface DistributionOrder {
handoff_status?: string
refund_status: string
settlement_status: string
distributor_settlement_status?: string
release_at?: string
distribution_mode: string
gross_paid_amount_cent?: number
refund_amount_cent?: number
@@ -504,6 +506,7 @@ export interface SiteWallet {
site_id: number
available_balance_cent: number
frozen_balance_cent: number
pending_release_cent: number
total_settled_cent: number
total_withdrawn_cent: number
}
+23 -7
View File
@@ -67,6 +67,25 @@ const SETTLEMENT_STATUS_MAP: Record<string, { label: string; color: string }> =
closed: { label: '已关闭', color: 'default' },
}
function settlementTag(row: DistributionOrder): { label: string; color: string } {
if (row.distributor_settlement_status === 'settled') {
return { label: '已释放入账', color: 'success' }
}
if (row.distributor_settlement_status === 'pending' && row.release_at) {
const releaseDate = new Date(row.release_at).toLocaleDateString('zh-CN', {
month: 'numeric',
day: 'numeric',
})
return { label: `待释放 ${releaseDate}`, color: 'processing' }
}
return (
SETTLEMENT_STATUS_MAP[row.settlement_status] || {
label: row.settlement_status,
color: 'default',
}
)
}
const REFUND_STATUS_MAP: Record<string, { label: string; color: string }> = {
none: { label: '未退款', color: 'default' },
pending: { label: '退款审核中', color: 'orange' },
@@ -455,12 +474,9 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
},
{
title: '结算状态',
width: 110,
width: 130,
render: (_: unknown, row: DistributionOrder) => {
const conf = SETTLEMENT_STATUS_MAP[row.settlement_status] || {
label: row.settlement_status,
color: 'default',
}
const conf = settlementTag(row)
return <Tag color={conf.color}>{conf.label}</Tag>
},
},
@@ -873,10 +889,10 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
<Col span={8}>
<div style={{ color: '#8c8c8c', fontSize: 12, marginBottom: 4 }}>结算状态</div>
<Tag
color={SETTLEMENT_STATUS_MAP[selectedOrder.settlement_status]?.color || 'default'}
color={settlementTag(selectedOrder).color}
style={{ fontSize: 13, padding: '2px 8px' }}
>
{SETTLEMENT_STATUS_MAP[selectedOrder.settlement_status]?.label || selectedOrder.settlement_status}
{settlementTag(selectedOrder).label}
</Tag>
</Col>
</Row>
+9 -9
View File
@@ -166,12 +166,12 @@ export default function Settlements() {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Title level={3} style={{ marginBottom: 4 }}>
{isSuperadmin ? '结算管理与批次出账' : '周期结算单与财务对账'}
{isSuperadmin ? '结算管理与批次对账' : '结算对账单与财务对账'}
</Typography.Title>
<Typography.Text type="secondary">
{isSuperadmin
? '主站财务管理员可按周期创建结算批次并执行打款审核'
: '查看主站定期出具的结算账单,核对订单分成明细与资金入账凭证'}
? '系统按站点自动结算入账并生成批次;也可手工创建批次立即结算'
: '订单完成冷却期结束后系统自动释放入账,此处查看对账批次与入账明细'}
</Typography.Text>
</div>
{isSuperadmin && sites.length > 1 && (
@@ -191,18 +191,18 @@ export default function Settlements() {
<Alert
type="info"
showIcon
message="财务对账与结算说明"
description="分销订单在完成履约且双方验收确认后自动归集出账。结算单经主站财务审核打款后,收益将100%直接计入商户钱包可用余额,可随时在「资金与提现」中申请提现。"
message="自动结算与对账说明"
description="订单完成后进入站点结算冷却期(由主站后台按商户配置,默认 7 天,0 天为立即释放),期满系统自动生成结算批次并将收益释放到「资金与提现」的可提现余额;退款或维权中的订单会暂停释放。"
/>
{/* Superadmin batch creation controls */}
{isSuperadmin && (
<Card title="管理员批次出账操作" size="small" style={{ background: '#f8fafc' }}>
<Card title="管理员手工出账(立即结算)" size="small" style={{ background: '#f8fafc' }}>
<Space wrap>
<Typography.Text strong>选择结算结算周期:</Typography.Text>
<Typography.Text strong>选择结算周期:</Typography.Text>
<DatePicker.RangePicker showTime onChange={(value) => setRange(value)} />
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate} loading={creating}>
生成结算账单批次
生成结算批次并结算
</Button>
</Space>
</Card>
@@ -219,7 +219,7 @@ export default function Settlements() {
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无结算账单。当期订单履约完成后将在下一个结算周期出具对账单。"
description="暂无结算批次。订单完成冷却期结束后系统会自动出具对账单并入账。"
/>
),
}}
+16 -4
View File
@@ -24,6 +24,7 @@ import {
DollarCircleOutlined,
LockOutlined,
CheckCircleOutlined,
HourglassOutlined,
} from '@ant-design/icons'
import {
fetchSiteWallet,
@@ -193,7 +194,7 @@ export default function Wallet() {
</div>
<Row gutter={16}>
<Col span={6}>
<Col flex="1">
<Card bordered={false} style={{ background: 'linear-gradient(135deg, #1677ff 0%, #0958d9 100%)', color: '#fff' }}>
<Statistic
title={<span style={{ color: 'rgba(255,255,255,0.85)' }}>可提现余额</span>}
@@ -204,7 +205,18 @@ export default function Wallet() {
/>
</Card>
</Col>
<Col span={6}>
<Col flex="1">
<Card bordered={false} style={{ background: '#fafafa' }}>
<Statistic
title="待释放收益 (订单完成冷却中)"
value={((wallet?.pending_release_cent ?? 0) / 100).toFixed(2)}
prefix={<HourglassOutlined style={{ color: '#1677ff' }} />}
suffix="元"
valueStyle={{ color: '#1677ff' }}
/>
</Card>
</Col>
<Col flex="1">
<Card bordered={false} style={{ background: '#fafafa' }}>
<Statistic
title="冻结中余额 (提现审核中)"
@@ -215,7 +227,7 @@ export default function Wallet() {
/>
</Card>
</Col>
<Col span={6}>
<Col flex="1">
<Card bordered={false} style={{ background: '#fafafa' }}>
<Statistic
title="累计已结算收益"
@@ -226,7 +238,7 @@ export default function Wallet() {
/>
</Card>
</Col>
<Col span={6}>
<Col flex="1">
<Card bordered={false} style={{ background: '#fafafa' }}>
<Statistic
title="累计已成功提现"
@@ -10,6 +10,7 @@ export interface DistributionSite {
display_account_enabled: boolean
tax_enabled: boolean
tax_rate_bps: number
settlement_hold_days: number
created_at: string
}
@@ -88,6 +89,7 @@ export interface OnboardDistributionSitePayload {
display_account_enabled?: boolean
tax_enabled?: boolean
tax_rate_bps?: number
settlement_hold_days?: number
distributor_receive_ratio?: number
platform_sell_ratio?: number
service_fee_value?: number
@@ -129,7 +131,13 @@ export async function updateDistributionSite(
payload: Partial<
Pick<
DistributionSite,
'site_name' | 'domain' | 'status' | 'display_account_enabled' | 'tax_enabled' | 'tax_rate_bps'
| 'site_name'
| 'domain'
| 'status'
| 'display_account_enabled'
| 'tax_enabled'
| 'tax_rate_bps'
| 'settlement_hold_days'
>
>
) {
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Plus, Shop, Link, DocumentCopy, Refresh } from '@element-plus/icons-vue'
import { Plus, Shop, Link, DocumentCopy, Refresh, Clock } from '@element-plus/icons-vue'
import {
fetchDistributionSites,
onboardDistributionSite,
syncDistributionSiteAccounts,
updateDistributionSite,
type DistributionSite,
type OnboardDistributionSitePayload,
type OnboardDistributionSiteResult,
@@ -23,6 +24,37 @@ const onboardFormRef = ref<FormInstance>()
const successDialogVisible = ref(false)
const onboardResult = ref<OnboardDistributionSiteResult | null>(null)
const settlementDialogVisible = ref(false)
const settlementSubmitting = ref(false)
const settlementForm = reactive({
site_id: 0,
site_name: '',
settlement_hold_days: 7,
})
function handleOpenSettlement(row: DistributionSite) {
settlementForm.site_id = row.id
settlementForm.site_name = row.site_name
settlementForm.settlement_hold_days = row.settlement_hold_days ?? 7
settlementDialogVisible.value = true
}
async function handleSettlementSubmit() {
settlementSubmitting.value = true
try {
await updateDistributionSite(settlementForm.site_id, {
settlement_hold_days: Number(settlementForm.settlement_hold_days),
})
ElMessage.success(`【${settlementForm.site_name}】结算冷却期已更新`)
settlementDialogVisible.value = false
await loadSites()
} catch (err) {
ElMessage.error(readError(err, '更新结算冷却期失败'))
} finally {
settlementSubmitting.value = false
}
}
const onboardForm = reactive({
site_name: '',
site_code: '',
@@ -203,12 +235,19 @@ onMounted(() => {
<el-tag v-else type="info" size="small">免税</el-tag>
</template>
</el-table-column>
<el-table-column label="结算冷却期" width="110">
<template #default="{ row }">
<el-tag :type="row.settlement_hold_days > 0 ? 'primary' : 'success'" size="small">
{{ row.settlement_hold_days > 0 ? `${row.settlement_hold_days} 天` : '立即释放' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" prop="created_at" min-width="160">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
@@ -219,6 +258,9 @@ onMounted(() => {
>
同步商品
</el-button>
<el-button type="primary" link :icon="Clock" @click="handleOpenSettlement(row)">
结算设置
</el-button>
</template>
</el-table-column>
</el-table>
@@ -371,6 +413,42 @@ onMounted(() => {
<el-button @click="successDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 结算冷却期设置对话框 -->
<el-dialog
v-model="settlementDialogVisible"
title="结算冷却期设置"
width="480px"
destroy-on-close
>
<el-alert
type="info"
:closable="false"
show-icon
title="订单完成后进入冷却期,期满自动释放到商户可提现余额;0 天表示完成即释放。修改后对尚未释放的已完成订单立即生效。"
style="margin-bottom: 16px"
/>
<el-form label-width="110px">
<el-form-item label="分销站点">
<el-input :model-value="settlementForm.site_name" disabled />
</el-form-item>
<el-form-item label="冷却天数">
<el-input-number
v-model="settlementForm.settlement_hold_days"
:min="0"
:max="365"
:step="1"
/>
<span class="settlement-hint">天(0-365)</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="settlementDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="settlementSubmitting" @click="handleSettlementSubmit">
保存设置
</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -433,4 +511,10 @@ export default {
padding-bottom: 6px;
border-bottom: 1px dashed #e5e7eb;
}
.settlement-hint {
margin-left: 8px;
color: #6b7280;
font-size: 13px;
}
</style>