chore(init): 初始化解忧商行项目并完成 Phase 0 基座

This commit is contained in:
yml2213
2026-10-30 11:46:10 +08:00
commit 8d9481c251
689 changed files with 138703 additions and 0 deletions
@@ -0,0 +1,505 @@
package distribution
import (
"errors"
"sync"
"time"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/wallet"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// 结账单状态常量,与 order 模块的 checkoutStatus* 取值一致。
// order 模块依赖 distribution,本包不能反向 import,故在此本地声明,避免裸字符串漂移。
const (
checkoutStatusAccepted = "accepted"
checkoutStatusSettled = "settled"
)
// distributionTablesPresent 探测分销财务表是否存在,仅首次访问查询 information_schema,
// 兼容尚未执行分销迁移的旧环境,避免每次支付/退款事务都做系统表查询。
var (
distributionTablePresent bool
distributionTableCheckOnce sync.Once
)
func distributionTablesPresent(tx *gorm.DB) bool {
distributionTableCheckOnce.Do(func() {
distributionTablePresent = tx.Migrator().HasTable(&model.DistributionOrderFinancial{})
})
return distributionTablePresent
}
// resolveDistributionMode 只根据买卖双方站点判定模式,不查库、不猜规则。
// buySiteID / listingSiteID 为 0 表示主站。
func resolveDistributionMode(buySiteID, listingSiteID uint64) string {
switch {
case buySiteID == 0 && listingSiteID == 0:
return ""
case buySiteID == 0:
return ModeTwoPartyDistributorReceive
case listingSiteID == 0:
return ModeTwoPartyPlatformReceive
case buySiteID == listingSiteID:
return ModeSiteSelfSell
default:
return ModeThreeParty
}
}
func derefSiteID(id *uint64) uint64 {
if id == nil || *id == 0 {
return 0
}
return *id
}
func loadListingSiteID(tx *gorm.DB, listingID uint64) (uint64, error) {
if listingID == 0 {
return 0, nil
}
var listing model.RentalListing
if err := tx.Select("id, distribution_site_id").First(&listing, listingID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, nil
}
return 0, err
}
return derefSiteID(listing.DistributionSiteID), nil
}
func loadActiveSiteByID(tx *gorm.DB, siteID uint64) (*model.DistributionSite, error) {
var site model.DistributionSite
if err := tx.Where("id = ? AND status = 'active'", siteID).First(&site).Error; err != nil {
return nil, ErrSiteNotFound
}
return &site, nil
}
func loadActiveSellSiteByCode(tx *gorm.DB, siteCode string) (*model.DistributionSite, error) {
var site model.DistributionSite
if err := tx.Where("site_code = ? AND status = 'active' AND display_account_enabled = ?", siteCode, true).First(&site).Error; err != nil {
return nil, ErrSiteNotFound
}
return &site, nil
}
func ensureAccountDisplayTx(tx *gorm.DB, siteID, listingID, accountID uint64) error {
var relation model.DistributionSiteAccount
if err := tx.Where("site_id = ? AND listing_id = ? AND account_id = ? AND display_status = 'enabled'", siteID, listingID, accountID).First(&relation).Error; err != nil {
return ErrAccountDisplayNotFound
}
return nil
}
func findActiveRuleTx(tx *gorm.DB, siteID uint64, mode string, at time.Time) (*model.DistributionRuleVersion, error) {
var rule model.DistributionRuleVersion
err := tx.Where("mode = ? AND status = 'active' AND effective_from <= ? AND (effective_to IS NULL OR effective_to > ?)", mode, at, at).
Where("site_id = ? OR site_id IS NULL", siteID).
Order("CASE WHEN site_id IS NULL THEN 1 ELSE 0 END, effective_from DESC, id DESC").
First(&rule).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrRuleNotFound
}
return nil, err
}
return &rule, nil
}
// ResolveOrderSourceTx 先按买卖站点判定模式,再查该 mode 的生效规则,不再在两方规则之间猜优先级。
func ResolveOrderSourceTx(tx *gorm.DB, siteCode string, listingID, accountID uint64, at time.Time) (*model.DistributionSite, *model.DistributionRuleVersion, error) {
listingSiteID, err := loadListingSiteID(tx, listingID)
if err != nil {
return nil, nil, err
}
var buySite *model.DistributionSite
buySiteID := uint64(0)
if siteCode != "" {
buySite, err = loadActiveSellSiteByCode(tx, siteCode)
if err != nil {
return nil, nil, err
}
if err := ensureAccountDisplayTx(tx, buySite.ID, listingID, accountID); err != nil {
return nil, nil, err
}
buySiteID = buySite.ID
}
mode := resolveDistributionMode(buySiteID, listingSiteID)
if mode == "" {
return nil, nil, nil
}
var anchor *model.DistributionSite
switch mode {
case ModeTwoPartyDistributorReceive:
anchor, err = loadActiveSiteByID(tx, listingSiteID)
if err != nil {
return nil, nil, err
}
default:
if buySite == nil {
return nil, nil, ErrSiteNotFound
}
anchor = buySite
}
rule, err := findActiveRuleTx(tx, anchor.ID, mode, at)
if err != nil {
return nil, nil, err
}
return anchor, rule, nil
}
func ruleFromVersion(rule model.DistributionRuleVersion) Rule {
parsed := Rule{
Mode: rule.Mode,
PlatformReceiveRatio: rule.PlatformReceiveRatio,
PlatformSellRatio: rule.PlatformSellRatio,
DistributorReceiveRatio: rule.DistributorReceiveRatio,
DistributorSellRatio: rule.DistributorSellRatio,
DistributorARatio: rule.DistributorARatio,
DistributorBRatio: rule.DistributorBRatio,
ServiceFeeValue: rule.ServiceFeeValue,
ServiceFeeUnit: rule.ServiceFeeUnit,
PaymentFeeBPS: rule.PaymentFeeBPS,
TaxEnabled: rule.TaxEnabled,
TaxRateBPS: rule.TaxRateBPS,
}
normalizeRulePercents(&parsed)
return parsed
}
// calculateForRule 按规则快照计算手续费、服务费和分成基数。
func calculateForRule(rule model.DistributionRuleVersion, input SettlementInput) (SettlementResult, error) {
return CalculateSettlement(ruleFromVersion(rule), input)
}
// SnapshotPaidOrderTx 在支付确认事务内幂等写入财务和参与方快照。
// 利润 = 租金 − 号主 − 加价(通道手续费由主站承担,只记录),再按 35%/40%/25% 切;尾差记入 RoundingAdjustmentCent。
func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error {
if !distributionTablesPresent(tx) {
return nil
}
var order model.RentalOrder
if err := tx.First(&order, orderID).Error; err != nil {
return err
}
if order.DistributionSiteID == nil || order.DistributionRuleVersionID == nil {
return nil
}
var rule model.DistributionRuleVersion
if err := tx.First(&rule, *order.DistributionRuleVersionID).Error; err != nil {
return err
}
grossRent := order.RentAmountCent
if grossRent <= 0 {
grossRent = grossPaidCent
}
markup := order.ShelfMarkupCent
if markup < 0 {
markup = 0
}
ownerRent := order.OwnerRentAmountCent
if ownerRent < 0 {
ownerRent = 0
}
result, err := calculateForRule(rule, SettlementInput{GrossPaidAmountCent: grossRent, OwnerRentAmountCent: ownerRent, MarkupAmountCent: markup})
if err != nil {
return err
}
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, result.MarkupAmountCent)
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, result.MarkupAmountCent, 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
}
var count int64
if err := tx.Model(&model.DistributionOrderParty{}).Where("order_id = ?", order.ID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
parties := make([]model.DistributionOrderParty, 0, len(splits))
for _, split := range splits {
partyType := PartyTypeSite
if split.PartyRole == "platform_sell" || split.PartyRole == "platform_receive" {
partyType = PartyTypePlatform
}
partyID := *order.DistributionSiteID
if partyType == PartyTypePlatform {
partyID = 0
} else if split.PartyRole == "distributor_a" {
// 三方模式下 distributor_a 为商品收号站
var listing model.RentalListing
if err := tx.Select("distribution_site_id").First(&listing, order.ListingID).Error; err == nil && listing.DistributionSiteID != nil {
partyID = *listing.DistributionSiteID
}
}
partySiteID := *order.DistributionSiteID
if partyType == PartyTypeSite && partyID > 0 {
partySiteID = partyID
}
parties = append(parties, model.DistributionOrderParty{OrderID: order.ID, SiteID: partySiteID, PartyRole: split.PartyRole, PartyType: partyType, PartyID: partyID, Ratio: split.Ratio, IncomeAmountCent: split.AmountCent, ProfitAmountCent: split.AmountCent})
}
if len(parties) == 0 {
return nil
}
return tx.Create(&parties).Error
}
// ApplyRefundTx 按累计成功退款或结账结算重算手续费、分成基数和各方分成,不覆盖订单创建时的规则版本。
func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) 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
}
if refundAmountCent < 0 {
return ErrInvalidAmount
}
var rule model.DistributionRuleVersion
if err := tx.First(&rule, financial.RuleVersionID).Error; err != nil {
return err
}
var order model.RentalOrder
if err := tx.First(&order, orderID).Error; err != nil {
return err
}
// 判定实际租金退款和号主应得租金:
// 1. 如果存在已达成一致的结账单(accepted 或 settled),以结账单中的实际消费租金与号主实际所得为准
var checkout model.OrderCheckout
hasAcceptedCheckout := false
if err := tx.Where("order_id = ? AND status IN ?", orderID, []string{checkoutStatusAccepted, checkoutStatusSettled}).
Order("id DESC").First(&checkout).Error; err == nil {
hasAcceptedCheckout = true
}
rentRefundCent := refundAmountCent
ownerRent := order.OwnerRentAmountCent
if ownerRent < 0 {
ownerRent = 0
}
if hasAcceptedCheckout {
// 结账单中,实际有效租金为 checkout.RentAmountCent
// 真实的租金退款为 订单初始租金 - 实际有效租金
actualRentRefund := order.RentAmountCent - checkout.RentAmountCent
if actualRentRefund < 0 {
actualRentRefund = 0
}
rentRefundCent = actualRentRefund
// 号主实际租金所得以结账单的实际租金收入为准
ownerRent = checkout.OwnerRentAmountCent
if ownerRent < 0 {
ownerRent = 0
}
} else {
// 无结账单场景(例如交接超时、提前取消退款、维权全额退款):
// refundAmountCent 是租客收到的总退款(含押金与租金),押金不计入分销毛收入
// 先抵扣押金退款,剩余部分才属于租金退款
depositRefund := order.DepositAmountCent
if refundAmountCent < depositRefund {
depositRefund = refundAmountCent
}
actualRentRefund := refundAmountCent - depositRefund
if actualRentRefund < 0 {
actualRentRefund = 0
}
rentRefundCent = actualRentRefund
// 如果租金全额退款,号主应得租金为 0
if rentRefundCent >= financial.GrossPaidAmountCent {
ownerRent = 0
}
}
if rentRefundCent > financial.GrossPaidAmountCent {
rentRefundCent = financial.GrossPaidAmountCent
}
result, err := calculateForRule(rule, SettlementInput{
GrossPaidAmountCent: financial.GrossPaidAmountCent,
RefundAmountCent: rentRefundCent,
OwnerRentAmountCent: ownerRent,
MarkupAmountCent: financial.MarkupAmountCent,
})
if err != nil {
return err
}
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, result.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,
"payment_fee_cent": result.PaymentFeeCent,
"service_fee_cent": result.ServiceFeeCent,
"split_base_amount_cent": result.SplitBaseAmountCent,
"rounding_adjustment_cent": SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, result.MarkupAmountCent, splits),
"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("id = ?", party.ID).
Updates(map[string]any{
"income_amount_cent": split.AmountCent,
"profit_amount_cent": split.AmountCent,
}).Error; err != nil {
return err
}
}
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 !orderStatusAllowsRelease(order.Status) {
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 !orderStatusAllowsRelease(order.Status) {
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
}
func orderStatusAllowsRelease(status string) bool {
return status == "completed" || status == "closed"
}
// 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
}