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
@@ -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