补全分销分账结算闭环并修复站点越权

- 权限落库:新增 000066 迁移,注册 11 个 distribution:* 权限并授权 super_admin/ops/finance
- 越权修复:siteAccessAllowed 校验贯穿站点/规则/订单/展示/支付通道/结算全部按 ID 操作
- 分账闭环:SplitSettlement 按比例拆分分成基数,销售加价归卖出方,尾差记入 rounding_adjustment_cent 归平台;支付快照与退款重算均按拆分写入参与方金额
- 结算批次:创建时聚合周期内待结算订单生成明细并汇总金额,结算时校验明细与总额一致后联动置位财务快照
- 手工补数接口禁止覆盖已有快照并写入审计日志
- 杂项:二维码池校验站点启用状态且不吞真实 DB 错误;绑定支付通道校验商户配置存在;表探测改为 sync.Once 缓存
This commit is contained in:
yml2213
2026-09-05 11:12:44 +08:00
parent 576cadcafa
commit ac777f5b16
11 changed files with 547 additions and 98 deletions
+11 -4
View File
@@ -306,11 +306,18 @@ func fetchUnusedQrCode(tx *gorm.DB) (*model.ChatQrCode, error) {
func fetchUnusedQrCodeForListing(tx *gorm.DB, listingID uint64) (*model.ChatQrCode, error) {
var relation model.DistributionSiteAccount
if err := tx.Where("listing_id = ? AND display_status = 'enabled'", listingID).Order("id ASC").First(&relation).Error; err == nil {
// 仅匹配启用中的分销站点,真实 DB 错误直接返回,避免误回退到全局二维码池。
err := tx.Table("distribution_site_accounts AS relation").
Select("relation.*").
Joins("JOIN distribution_sites AS site ON site.id = relation.site_id AND site.status = 'active'").
Where("relation.listing_id = ? AND relation.display_status = 'enabled'", listingID).
Order("relation.id ASC").
Scan(&relation).Error
if err != nil {
return nil, err
}
if relation.ID != 0 {
return fetchUnusedQrCodeForSite(tx, &relation.SiteID)
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
// 兼容尚未执行分销迁移的环境,继续使用全局二维码池。
return fetchUnusedQrCodeForSite(tx, nil)
}
return fetchUnusedQrCodeForSite(tx, nil)
}
@@ -23,6 +23,8 @@ func setupQrCodeTestDB(t *testing.T) *gorm.DB {
}
if err := db.AutoMigrate(
&model.ChatQrCode{},
&model.DistributionSite{},
&model.DistributionSiteAccount{},
&model.ChatConversation{},
&model.ChatParticipant{},
&model.ChatMessage{},
+3 -2
View File
@@ -100,8 +100,9 @@ type DistributionOrderDTO struct {
ServiceFeeCent int64 `json:"service_fee_cent"`
MarkupAmountCent int64 `json:"markup_amount_cent"`
TaxAmountCent int64 `json:"tax_amount_cent"`
SplitBaseAmountCent int64 `json:"split_base_amount_cent"`
CreatedAt time.Time `json:"created_at"`
SplitBaseAmountCent int64 `json:"split_base_amount_cent"`
RoundingAdjustmentCent int64 `json:"rounding_adjustment_cent"`
CreatedAt time.Time `json:"created_at"`
}
type DistributionOrderListQuery struct {
@@ -31,7 +31,8 @@ func (h *Handler) GetSite(c *gin.Context) {
if !ok {
return
}
item, err := h.service.FindSite(c.Request.Context(), id)
adminID, _ := currentAdminID(c)
item, err := h.service.FindSite(c.Request.Context(), adminID, id)
if err != nil {
writeError(c, err)
return
@@ -63,7 +64,8 @@ func (h *Handler) UpdateSite(c *gin.Context) {
response.BadRequest(c, "分销网站参数不正确")
return
}
item, err := h.service.UpdateSite(c.Request.Context(), id, req)
adminID, _ := currentAdminID(c)
item, err := h.service.UpdateSite(c.Request.Context(), adminID, id, req)
if err != nil {
writeError(c, err)
return
@@ -81,7 +83,8 @@ func (h *Handler) ListRules(c *gin.Context) {
}
siteID = &id
}
items, err := h.service.ListRules(c.Request.Context(), siteID, c.Query("mode"))
adminID, _ := currentAdminID(c)
items, err := h.service.ListRules(c.Request.Context(), adminID, siteID, c.Query("mode"))
if err != nil {
writeError(c, err)
return
@@ -118,7 +121,8 @@ func (h *Handler) UpdateRuleStatus(c *gin.Context) {
response.BadRequest(c, "规则状态不正确")
return
}
item, err := h.service.UpdateRuleStatus(c.Request.Context(), id, req.Status)
adminID, _ := currentAdminID(c)
item, err := h.service.UpdateRuleStatus(c.Request.Context(), adminID, id, req.Status)
if err != nil {
writeError(c, err)
return
@@ -152,7 +156,8 @@ func (h *Handler) FindOrder(c *gin.Context) {
if !ok {
return
}
item, err := h.service.FindOrder(c.Request.Context(), id)
adminID, _ := currentAdminID(c)
item, err := h.service.FindOrder(c.Request.Context(), adminID, id)
if err != nil {
writeError(c, err)
return
@@ -170,7 +175,8 @@ func (h *Handler) CreateFinancialSnapshot(c *gin.Context) {
response.BadRequest(c, "分销财务快照参数不正确")
return
}
item, err := h.service.CreateFinancialSnapshot(c.Request.Context(), id, req)
adminID, _ := currentAdminID(c)
item, err := h.service.CreateFinancialSnapshot(c.Request.Context(), adminID, id, req)
if err != nil {
writeError(c, err)
return
@@ -210,7 +216,8 @@ func (h *Handler) UpdateAccountDisplay(c *gin.Context) {
response.BadRequest(c, "账号展示状态不正确")
return
}
item, err := h.service.UpdateAccountDisplay(c.Request.Context(), id, req.DisplayStatus)
adminID, _ := currentAdminID(c)
item, err := h.service.UpdateAccountDisplay(c.Request.Context(), adminID, id, req.DisplayStatus)
if err != nil {
writeError(c, err)
return
@@ -268,7 +275,8 @@ func (h *Handler) UnbindPaymentChannel(c *gin.Context) {
if !ok {
return
}
if err := h.service.UnbindPaymentChannel(c.Request.Context(), id); err != nil {
adminID, _ := currentAdminID(c)
if err := h.service.UnbindPaymentChannel(c.Request.Context(), adminID, id); err != nil {
writeError(c, err)
return
}
@@ -319,7 +327,8 @@ func (h *Handler) MarkSettlementBatch(c *gin.Context) {
if !ok {
return
}
item, err := h.service.MarkSettlementBatch(c.Request.Context(), id)
adminID, _ := currentAdminID(c)
item, err := h.service.MarkSettlementBatch(c.Request.Context(), adminID, id)
if err != nil {
writeError(c, err)
return
@@ -363,7 +372,11 @@ func writeError(c *gin.Context, err error) {
switch {
case errors.Is(err, ErrSiteNotFound), errors.Is(err, ErrRuleNotFound), errors.Is(err, ErrOrderNotFound), errors.Is(err, ErrAccountDisplayNotFound), errors.Is(err, ErrPaymentChannelNotFound), errors.Is(err, ErrSettlementBatchNotFound):
response.NotFound(c, "分销资源不存在")
case errors.Is(err, ErrInvalidAmount), errors.Is(err, ErrInvalidRate), errors.Is(err, ErrInvalidServiceUnit), errors.Is(err, ErrUnsupportedMode), errors.Is(err, ErrInvalidOrderKind), errors.Is(err, ErrInvalidPeriod), errors.Is(err, ErrBatchAlreadySettled):
case errors.Is(err, ErrPaymentConfigNotFound):
response.BadRequest(c, "支付商户配置不存在")
case errors.Is(err, ErrOrderSnapshotExists):
response.Error(c, http.StatusConflict, "snapshot_exists", "该订单已存在财务快照,不能重复补数")
case errors.Is(err, ErrInvalidAmount), errors.Is(err, ErrInvalidRate), errors.Is(err, ErrInvalidServiceUnit), errors.Is(err, ErrUnsupportedMode), errors.Is(err, ErrInvalidOrderKind), errors.Is(err, ErrInvalidPeriod), errors.Is(err, ErrBatchAlreadySettled), errors.Is(err, ErrSettlementBatchEmpty), errors.Is(err, ErrSettlementTotalMismatch):
response.BadRequest(c, "分销规则参数不正确")
case errors.Is(err, ErrSiteAccessDenied):
response.Error(c, http.StatusForbidden, "forbidden", "无权访问该分销站点")
@@ -2,6 +2,7 @@ package distribution
import (
"errors"
"sync"
"time"
"hfb_sys/backend/internal/model"
@@ -9,7 +10,22 @@ import (
"gorm.io/gorm"
)
// 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
}
// ResolveOrderSourceTx 校验站点、账号展示关系和生效规则,供正常订单创建事务写入快照引用。
// 第一期仅启用 two_party_distributor_receive 模式,其他模式规则不会命中(见设计文档实施顺序)。
func ResolveOrderSourceTx(tx *gorm.DB, siteCode string, listingID, accountID uint64, at time.Time) (*model.DistributionSite, *model.DistributionRuleVersion, error) {
if siteCode == "" {
return nil, nil, nil
@@ -32,9 +48,18 @@ func ResolveOrderSourceTx(tx *gorm.DB, siteCode string, listingID, accountID uin
return &site, &rule, nil
}
// calculateForRule 按规则快照计算手续费、服务费和分成基数。
func calculateForRule(rule model.DistributionRuleVersion, input SettlementInput) (SettlementResult, error) {
return CalculateSettlement(Rule{
Mode: rule.Mode, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit,
PaymentFeeBPS: rule.PaymentFeeBPS, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS,
}, input)
}
// SnapshotPaidOrderTx 在支付确认事务内幂等写入财务和参与方快照。
// 参与方金额按规则比例对分成基数拆分,剩余(含尾差)记入 RoundingAdjustmentCent 归平台。
func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error {
if !tx.Migrator().HasTable(&model.DistributionOrderFinancial{}) {
if !distributionTablesPresent(tx) {
return nil
}
var order model.RentalOrder
@@ -49,11 +74,12 @@ func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error
return err
}
markup := order.PlatformFeeCent
result, err := CalculateSettlement(Rule{ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, PaymentFeeBPS: rule.PaymentFeeBPS, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS}, SettlementInput{GrossPaidAmountCent: grossPaidCent, MarkupAmountCent: markup, GrossProfitByParty: []PartyProfitInput{{PartyRole: "distributor_receive", IncomeAmountCent: order.OwnerRentAmountCent}, {PartyRole: "platform_sell", IncomeAmountCent: order.PlatformFeeCent}}})
result, err := calculateForRule(rule, SettlementInput{GrossPaidAmountCent: grossPaidCent, MarkupAmountCent: markup})
if err != nil {
return err
}
financial := model.DistributionOrderFinancial{OrderID: order.ID, SiteID: *order.DistributionSiteID, RuleVersionID: rule.ID, GrossPaidAmountCent: grossPaidCent, 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, SettlementStatus: "pending"}
splits := SplitSettlement(Rule{Mode: rule.Mode}, result.SplitBaseAmountCent, markup)
financial := model.DistributionOrderFinancial{OrderID: order.ID, SiteID: *order.DistributionSiteID, RuleVersionID: rule.ID, GrossPaidAmountCent: grossPaidCent, 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: SplitRemainderCent(result.SplitBaseAmountCent, splits), SettlementStatus: "pending"}
if err := tx.Where("order_id = ?", order.ID).Assign(financial).FirstOrCreate(&financial).Error; err != nil {
return err
}
@@ -64,16 +90,27 @@ func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error
if count > 0 {
return nil
}
parties := []model.DistributionOrderParty{
{OrderID: order.ID, SiteID: *order.DistributionSiteID, PartyRole: "distributor_receive", PartyType: "site", PartyID: *order.DistributionSiteID, Ratio: rule.DistributorReceiveRatio, IncomeAmountCent: order.OwnerRentAmountCent, ProfitAmountCent: order.OwnerRentAmountCent},
{OrderID: order.ID, SiteID: *order.DistributionSiteID, PartyRole: "platform_sell", PartyType: "platform", Ratio: rule.PlatformSellRatio, IncomeAmountCent: order.PlatformFeeCent, ProfitAmountCent: order.PlatformFeeCent},
parties := make([]model.DistributionOrderParty, 0, len(splits))
for _, split := range splits {
partyType := "site"
if split.PartyRole == "platform_sell" || split.PartyRole == "platform_receive" {
partyType = "platform"
}
partyID := *order.DistributionSiteID
if partyType == "platform" {
partyID = 0
}
parties = append(parties, model.DistributionOrderParty{OrderID: order.ID, SiteID: *order.DistributionSiteID, 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 按累计成功退款金额重算手续费和分成基数,不覆盖订单创建时的规则版本。
// ApplyRefundTx 按累计成功退款金额重算手续费、分成基数和各方分成,不覆盖订单创建时的规则版本。
func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
if !tx.Migrator().HasTable(&model.DistributionOrderFinancial{}) {
if !distributionTablesPresent(tx) {
return nil
}
var financial model.DistributionOrderFinancial
@@ -90,9 +127,20 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
if err := tx.First(&rule, financial.RuleVersionID).Error; err != nil {
return err
}
result, err := CalculateSettlement(Rule{ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, PaymentFeeBPS: rule.PaymentFeeBPS, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS}, SettlementInput{GrossPaidAmountCent: financial.GrossPaidAmountCent, RefundAmountCent: refundAmountCent, MarkupAmountCent: financial.MarkupAmountCent})
result, err := calculateForRule(rule, SettlementInput{GrossPaidAmountCent: financial.GrossPaidAmountCent, RefundAmountCent: refundAmountCent, MarkupAmountCent: financial.MarkupAmountCent})
if err != nil {
return err
}
return tx.Model(&financial).Updates(map[string]any{"refund_amount_cent": refundAmountCent, "net_paid_amount_cent": result.NetPaidAmountCent, "payment_fee_cent": result.PaymentFeeCent, "service_fee_cent": result.ServiceFeeCent, "split_base_amount_cent": result.SplitBaseAmountCent, "settlement_status": "pending"}).Error
splits := SplitSettlement(Rule{Mode: rule.Mode}, result.SplitBaseAmountCent, financial.MarkupAmountCent)
if err := tx.Model(&financial).Updates(map[string]any{"refund_amount_cent": refundAmountCent, "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": SplitRemainderCent(result.SplitBaseAmountCent, splits), "settlement_status": "pending"}).Error; err != nil {
return err
}
for _, split := range splits {
if err := tx.Model(&model.DistributionOrderParty{}).
Where("order_id = ? AND party_role = ?", orderID, split.PartyRole).
Updates(map[string]any{"income_amount_cent": split.AmountCent, "profit_amount_cent": split.AmountCent}).Error; err != nil {
return err
}
}
return nil
}
@@ -126,7 +126,7 @@ func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, er
result.TaxByParty = make([]PartyTaxResult, 0, len(input.GrossProfitByParty))
for _, party := range input.GrossProfitByParty {
profit := party.IncomeAmountCent - party.CostAmountCent
tax := roundHalfUp(float64(max(profit, 0)) * float64(rule.TaxRateBPS) / 10000)
tax := roundHalfUp(float64(maxInt64(profit, 0)) * float64(rule.TaxRateBPS) / 10000)
result.TaxAmountCent += tax
result.TaxByParty = append(result.TaxByParty, PartyTaxResult{PartyRole: party.PartyRole, GrossProfitCent: profit, TaxAmountCent: tax})
}
@@ -154,9 +154,76 @@ func roundHalfUp(value float64) int64 {
return int64(math.Floor(value + 0.5))
}
func max(a, b int64) int64 {
func maxInt64(a, b int64) int64 {
if a > b {
return a
}
return b
}
// PartySplit 是一次分成拆分中单个参与方的金额。
type PartySplit struct {
PartyRole string
Ratio float64
AmountCent int64
}
// SplitSettlement 按规则模式把分成基数以百分比拆给各分销参与方。
// 销售加价全部计入卖出方收入(设计文档 7.6);
// 剩余金额(含四舍五入尾差)由调用方归入平台留存并记录 RoundingAdjustmentCent,
// 最终业务口径以设计文档 7.2 待确认项的结论为准。
func SplitSettlement(rule Rule, splitBase int64, markupCent int64) []PartySplit {
roles := []struct {
role string
ratio float64
}{}
switch rule.Mode {
case ModeTwoPartyDistributorReceive:
roles = []struct {
role string
ratio float64
}{{"distributor_receive", rule.DistributorReceiveRatio}, {"platform_sell", rule.PlatformSellRatio}}
case ModeTwoPartyPlatformReceive:
roles = []struct {
role string
ratio float64
}{{"distributor_sell", rule.DistributorSellRatio}, {"platform_receive", rule.PlatformReceiveRatio}}
case ModeThreeParty:
roles = []struct {
role string
ratio float64
}{{"distributor_a", rule.DistributorARatio}, {"distributor_b", rule.DistributorBRatio}}
default:
return nil
}
splits := make([]PartySplit, 0, len(roles))
for _, item := range roles {
amount := roundHalfUp(float64(splitBase) * item.ratio / 100)
splits = append(splits, PartySplit{PartyRole: item.role, Ratio: item.ratio, AmountCent: maxInt64(amount, 0)})
}
if markupCent > 0 {
// 加价归卖出方:两方-分销收号与三方模式的卖出方分别是 platform_sell / distributor_b。
sellerRole := "distributor_sell"
switch rule.Mode {
case ModeTwoPartyDistributorReceive:
sellerRole = "platform_sell"
case ModeThreeParty:
sellerRole = "distributor_b"
}
for i := range splits {
if splits[i].PartyRole == sellerRole {
splits[i].AmountCent += markupCent
}
}
}
return splits
}
// SplitRemainderCent 返回分成基数扣除各方分成后的平台留存金额(含尾差)。
func SplitRemainderCent(splitBase int64, splits []PartySplit) int64 {
sum := int64(0)
for _, item := range splits {
sum += item.AmountCent
}
return splitBase - sum
}
@@ -53,3 +53,50 @@ func TestCalculateSettlementRejectsRefundOverPayment(t *testing.T) {
t.Fatalf("err = %v", err)
}
}
func TestSplitSettlementTwoPartyDistributorReceive(t *testing.T) {
rule := Rule{Mode: ModeTwoPartyDistributorReceive, DistributorReceiveRatio: 3.5, PlatformSellRatio: 4}
splits := SplitSettlement(rule, 10000, 0)
if len(splits) != 2 {
t.Fatalf("splits = %#v", splits)
}
if splits[0].PartyRole != "distributor_receive" || splits[0].AmountCent != 350 {
t.Fatalf("distributor split = %#v", splits[0])
}
if splits[1].PartyRole != "platform_sell" || splits[1].AmountCent != 400 {
t.Fatalf("platform split = %#v", splits[1])
}
if got := SplitRemainderCent(10000, splits); got != 9250 {
t.Fatalf("remainder = %d, want 9250", got)
}
}
func TestSplitSettlementMarkupGoesToSeller(t *testing.T) {
// 两方-分销收号模式:卖出方是主站(platform_sell)。
rule := Rule{Mode: ModeTwoPartyDistributorReceive, DistributorReceiveRatio: 3.5, PlatformSellRatio: 4}
splits := SplitSettlement(rule, 10000, 300)
if splits[1].AmountCent != 700 {
t.Fatalf("platform split with markup = %d, want 700", splits[1].AmountCent)
}
// 两方-分销卖出模式:卖出方是分销网(distributor_sell)。
ruleSell := Rule{Mode: ModeTwoPartyPlatformReceive, DistributorSellRatio: 4, PlatformReceiveRatio: 3.5}
splitsSell := SplitSettlement(ruleSell, 10000, 300)
if splitsSell[0].PartyRole != "distributor_sell" || splitsSell[0].AmountCent != 700 {
t.Fatalf("distributor sell split with markup = %#v", splitsSell[0])
}
if got := SplitRemainderCent(10000, splitsSell); got != 8950 {
t.Fatalf("remainder = %d, want 8950", got)
}
}
func TestSplitSettlementThreeParty(t *testing.T) {
rule := Rule{Mode: ModeThreeParty, DistributorARatio: 3.5, DistributorBRatio: 4}
splits := SplitSettlement(rule, 10001, 0)
if len(splits) != 2 || splits[0].AmountCent != 350 || splits[1].AmountCent != 400 {
t.Fatalf("splits = %#v", splits)
}
// 尾差归平台:10001 - 750 = 9251。
if got := SplitRemainderCent(10001, splits); got != 9251 {
t.Fatalf("remainder = %d, want 9251", got)
}
}
@@ -6,9 +6,11 @@ import (
"strings"
"time"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var (
@@ -21,6 +23,10 @@ var (
ErrInvalidPeriod = errors.New("invalid distribution settlement period")
ErrSettlementBatchNotFound = errors.New("distribution settlement batch not found")
ErrBatchAlreadySettled = errors.New("distribution settlement batch already settled")
ErrSettlementBatchEmpty = errors.New("distribution settlement batch empty")
ErrSettlementTotalMismatch = errors.New("distribution settlement total mismatch")
ErrPaymentConfigNotFound = errors.New("distribution payment config not found")
ErrOrderSnapshotExists = errors.New("distribution order snapshot exists")
ErrSiteAccessDenied = errors.New("distribution site access denied")
)
@@ -53,6 +59,34 @@ func (r *Repository) ScopeSiteID(ctx context.Context, adminID, requested uint64)
return 0, nil
}
// ensureSiteAccess 校验管理员可访问指定站点:
// 无成员记录(超级管理员语义)放行,有成员记录则必须命中。
func (r *Repository) ensureSiteAccess(ctx context.Context, adminID, siteID uint64) error {
if adminID == 0 {
return nil
}
var count int64
if err := r.db.WithContext(ctx).Model(&model.DistributionSiteMember{}).
Where("admin_user_id = ? AND status = 'active'", adminID).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return nil
}
if siteID == 0 {
return ErrSiteAccessDenied
}
var allowed int64
if err := r.db.WithContext(ctx).Model(&model.DistributionSiteMember{}).
Where("admin_user_id = ? AND site_id = ? AND status = 'active'", adminID, siteID).Count(&allowed).Error; err != nil {
return err
}
if allowed == 0 {
return ErrSiteAccessDenied
}
return nil
}
func (r *Repository) ListSites(ctx context.Context) ([]SiteDTO, error) {
var rows []model.DistributionSite
if err := r.db.WithContext(ctx).Order("id ASC").Find(&rows).Error; err != nil {
@@ -65,7 +99,7 @@ func (r *Repository) ListSites(ctx context.Context) ([]SiteDTO, error) {
return items, nil
}
func (r *Repository) FindSite(ctx context.Context, id uint64) (*SiteDTO, error) {
func (r *Repository) FindSite(ctx context.Context, adminID, id uint64) (*SiteDTO, error) {
var row model.DistributionSite
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -73,6 +107,9 @@ func (r *Repository) FindSite(ctx context.Context, id uint64) (*SiteDTO, error)
}
return nil, err
}
if err := r.ensureSiteAccess(ctx, adminID, row.ID); err != nil {
return nil, err
}
item := siteDTO(row)
return &item, nil
}
@@ -101,7 +138,7 @@ func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*Si
return &item, nil
}
func (r *Repository) UpdateSite(ctx context.Context, id uint64, req UpdateSiteRequest) (*SiteDTO, error) {
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) {
@@ -109,6 +146,9 @@ func (r *Repository) UpdateSite(ctx context.Context, id uint64, req UpdateSiteRe
}
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)
}
@@ -137,11 +177,14 @@ func (r *Repository) UpdateSite(ctx context.Context, id uint64, req UpdateSiteRe
return &item, nil
}
func (r *Repository) ListRules(ctx context.Context, siteID *uint64, mode string) ([]RuleDTO, error) {
func (r *Repository) ListRules(ctx context.Context, adminID uint64, siteID *uint64, mode string) ([]RuleDTO, error) {
db := r.db.WithContext(ctx).Model(&model.DistributionRuleVersion{}).Order("effective_from DESC, id DESC")
if siteID == nil {
db = db.Where("site_id IS NULL")
} else {
if err := r.ensureSiteAccess(ctx, adminID, *siteID); err != nil {
return nil, err
}
db = db.Where("site_id = ?", *siteID)
}
if mode = strings.TrimSpace(mode); mode != "" {
@@ -159,6 +202,11 @@ func (r *Repository) ListRules(ctx context.Context, siteID *uint64, mode string)
}
func (r *Repository) CreateRule(ctx context.Context, adminID uint64, req CreateRuleRequest) (*RuleDTO, error) {
if req.SiteID != nil {
if err := r.ensureSiteAccess(ctx, adminID, *req.SiteID); err != nil {
return nil, err
}
}
version := 1
var maxVersion int
db := r.db.WithContext(ctx).Model(&model.DistributionRuleVersion{}).Where("mode = ?", req.Mode)
@@ -197,7 +245,7 @@ func (r *Repository) CreateRule(ctx context.Context, adminID uint64, req CreateR
return &item, nil
}
func (r *Repository) UpdateRuleStatus(ctx context.Context, id uint64, status string) (*RuleDTO, error) {
func (r *Repository) UpdateRuleStatus(ctx context.Context, adminID, id uint64, status string) (*RuleDTO, error) {
var row model.DistributionRuleVersion
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -205,6 +253,11 @@ func (r *Repository) UpdateRuleStatus(ctx context.Context, id uint64, status str
}
return nil, err
}
if row.SiteID != nil {
if err := r.ensureSiteAccess(ctx, adminID, *row.SiteID); err != nil {
return nil, err
}
}
row.Status = strings.TrimSpace(status)
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
return nil, err
@@ -253,22 +306,25 @@ func (r *Repository) ListOrders(ctx context.Context, query DistributionOrderList
return nil, err
}
items := make([]DistributionOrderDTO, 0)
selectSQL := "ro.id AS order_id, ro.order_no, ro.distribution_site_id AS site_id, ro.listing_id, ro.account_id, ro.owner_id, ro.renter_id, ro.status, ro.handoff_status, ro.refund_status, ro.settlement_status, ro.distribution_mode, f.gross_paid_amount_cent, f.refund_amount_cent, f.net_paid_amount_cent, f.payment_fee_cent, f.service_fee_cent, f.markup_amount_cent, f.tax_amount_cent, f.split_base_amount_cent, ro.created_at"
selectSQL := "ro.id AS order_id, ro.order_no, ro.distribution_site_id AS site_id, ro.listing_id, ro.account_id, ro.owner_id, ro.renter_id, ro.status, ro.handoff_status, ro.refund_status, ro.settlement_status, ro.distribution_mode, f.gross_paid_amount_cent, f.refund_amount_cent, f.net_paid_amount_cent, f.payment_fee_cent, f.service_fee_cent, f.markup_amount_cent, f.tax_amount_cent, f.split_base_amount_cent, f.rounding_adjustment_cent, ro.created_at"
if err := base.Select(selectSQL).Order("ro.id DESC").Offset((page - 1) * size).Limit(size).Scan(&items).Error; err != nil {
return nil, err
}
return &DistributionOrderPage{Items: items, Total: total, Page: page, PageSize: size}, nil
}
func (r *Repository) FindOrder(ctx context.Context, id uint64) (*DistributionOrderDTO, error) {
func (r *Repository) FindOrder(ctx context.Context, adminID, id uint64) (*DistributionOrderDTO, error) {
var item DistributionOrderDTO
err := r.db.WithContext(ctx).Table("rental_orders ro").Joins("JOIN distribution_order_financials f ON f.order_id = ro.id").Select("ro.id AS order_id, ro.order_no, ro.distribution_site_id AS site_id, ro.listing_id, ro.account_id, ro.owner_id, ro.renter_id, ro.status, ro.handoff_status, ro.refund_status, ro.settlement_status, ro.distribution_mode, f.gross_paid_amount_cent, f.refund_amount_cent, f.net_paid_amount_cent, f.payment_fee_cent, f.service_fee_cent, f.markup_amount_cent, f.tax_amount_cent, f.split_base_amount_cent, ro.created_at").Where("ro.id = ?", id).Scan(&item).Error
err := r.db.WithContext(ctx).Table("rental_orders ro").Joins("JOIN distribution_order_financials f ON f.order_id = ro.id").Select("ro.id AS order_id, ro.order_no, ro.distribution_site_id AS site_id, ro.listing_id, ro.account_id, ro.owner_id, ro.renter_id, ro.status, ro.handoff_status, ro.refund_status, ro.settlement_status, ro.distribution_mode, f.gross_paid_amount_cent, f.refund_amount_cent, f.net_paid_amount_cent, f.payment_fee_cent, f.service_fee_cent, f.markup_amount_cent, f.tax_amount_cent, f.split_base_amount_cent, f.rounding_adjustment_cent, ro.created_at").Where("ro.id = ?", id).Scan(&item).Error
if err != nil {
return nil, err
}
if item.OrderID == 0 {
return nil, ErrOrderNotFound
}
if err := r.ensureSiteAccess(ctx, adminID, item.SiteID); err != nil {
return nil, err
}
return &item, nil
}
@@ -288,7 +344,7 @@ func (r *Repository) ListAccountDisplays(ctx context.Context, siteID uint64, sta
return items, nil
}
func (r *Repository) UpdateAccountDisplay(ctx context.Context, id uint64, status string) (*AccountDisplayDTO, error) {
func (r *Repository) UpdateAccountDisplay(ctx context.Context, adminID, id uint64, status string) (*AccountDisplayDTO, error) {
var row model.DistributionSiteAccount
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -296,6 +352,9 @@ func (r *Repository) UpdateAccountDisplay(ctx context.Context, id uint64, status
}
return nil, err
}
if err := r.ensureSiteAccess(ctx, adminID, row.SiteID); err != nil {
return nil, err
}
row.DisplayStatus = strings.TrimSpace(status)
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
return nil, err
@@ -317,6 +376,14 @@ func (r *Repository) ListPaymentChannels(ctx context.Context, siteID uint64) ([]
}
func (r *Repository) BindPaymentChannel(ctx context.Context, siteID uint64, req BindPaymentChannelRequest) (*PaymentChannelDTO, error) {
var configCount int64
if err := r.db.WithContext(ctx).Model(&model.PaymentMerchantConfig{}).
Where("id = ?", req.ConfigID).Count(&configCount).Error; err != nil {
return nil, err
}
if configCount == 0 {
return nil, ErrPaymentConfigNotFound
}
row := model.DistributionSitePaymentChannel{SiteID: siteID, ConfigID: req.ConfigID, Priority: req.Priority, Status: "active"}
if err := r.db.WithContext(ctx).Where("site_id = ? AND config_id = ?", siteID, req.ConfigID).FirstOrCreate(&row).Error; err != nil {
return nil, err
@@ -324,15 +391,18 @@ func (r *Repository) BindPaymentChannel(ctx context.Context, siteID uint64, req
return &PaymentChannelDTO{ID: row.ID, SiteID: row.SiteID, ConfigID: row.ConfigID, Priority: row.Priority, Status: row.Status}, nil
}
func (r *Repository) UnbindPaymentChannel(ctx context.Context, id uint64) error {
result := r.db.WithContext(ctx).Delete(&model.DistributionSitePaymentChannel{}, id)
if result.Error != nil {
return result.Error
func (r *Repository) UnbindPaymentChannel(ctx context.Context, adminID, id uint64) error {
var row model.DistributionSitePaymentChannel
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPaymentChannelNotFound
}
return err
}
if result.RowsAffected == 0 {
return ErrPaymentChannelNotFound
if err := r.ensureSiteAccess(ctx, adminID, row.SiteID); err != nil {
return err
}
return nil
return r.db.WithContext(ctx).Delete(&row).Error
}
func (r *Repository) ListSettlementBatches(ctx context.Context, siteID uint64) ([]SettlementBatchDTO, error) {
@@ -351,34 +421,107 @@ func (r *Repository) CreateSettlementBatch(ctx context.Context, adminID uint64,
if req.PeriodEnd.Before(req.PeriodStart) {
return nil, ErrInvalidPeriod
}
row := model.DistributionSettlementBatch{SiteID: req.SiteID, BatchNo: "DS" + time.Now().Format("20060102150405.000000000"), PeriodStart: req.PeriodStart, PeriodEnd: req.PeriodEnd, Status: "draft", CreatedBy: &adminID}
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
if err := r.ensureSiteAccess(ctx, adminID, req.SiteID); err != nil {
return nil, err
}
return &SettlementBatchDTO{ID: row.ID, SiteID: row.SiteID, BatchNo: row.BatchNo, PeriodStart: row.PeriodStart, PeriodEnd: row.PeriodEnd, TotalAmountCent: row.TotalAmountCent, Status: row.Status}, nil
}
func (r *Repository) MarkSettlementBatch(ctx context.Context, id uint64) (*SettlementBatchDTO, error) {
var row model.DistributionSettlementBatch
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSettlementBatchNotFound
if req.SiteID == 0 {
return nil, ErrInvalidPeriod
}
var batch model.DistributionSettlementBatch
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
batch = model.DistributionSettlementBatch{SiteID: req.SiteID, BatchNo: "DS" + time.Now().Format("20060102150405.000000000"), PeriodStart: req.PeriodStart, PeriodEnd: req.PeriodEnd, Status: "draft", CreatedBy: &adminID}
if err := tx.Create(&batch).Error; err != nil {
return err
}
// 聚合周期内尚未进入任何批次的待结算订单财务快照。
var financials []model.DistributionOrderFinancial
if err := tx.Where("site_id = ? AND settlement_status = 'pending'", req.SiteID).
Where("created_at >= ? AND created_at < ?", req.PeriodStart, req.PeriodEnd).
Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.order_id = distribution_order_financials.order_id)").
Order("id ASC").Find(&financials).Error; err != nil {
return err
}
total := int64(0)
for _, financial := range financials {
var parties []model.DistributionOrderParty
if err := tx.Where("order_id = ?", financial.OrderID).Find(&parties).Error; err != nil {
return err
}
for _, party := range parties {
item := model.DistributionSettlementItem{BatchID: batch.ID, OrderID: financial.OrderID, PartyID: party.ID, AmountCent: party.IncomeAmountCent, Status: "pending"}
if err := tx.Create(&item).Error; err != nil {
return err
}
total += party.IncomeAmountCent
}
}
batch.TotalAmountCent = total
return tx.Model(&model.DistributionSettlementBatch{}).Where("id = ?", batch.ID).
Update("total_amount_cent", total).Error
})
if err != nil {
return nil, err
}
if row.Status == "settled" {
return nil, ErrBatchAlreadySettled
}
now := time.Now()
row.Status = "settled"
row.SettledAt = &now
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
return &SettlementBatchDTO{ID: batch.ID, SiteID: batch.SiteID, BatchNo: batch.BatchNo, PeriodStart: batch.PeriodStart, PeriodEnd: batch.PeriodEnd, TotalAmountCent: batch.TotalAmountCent, Status: batch.Status}, nil
}
func (r *Repository) MarkSettlementBatch(ctx context.Context, adminID, id uint64) (*SettlementBatchDTO, error) {
var row model.DistributionSettlementBatch
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrSettlementBatchNotFound
}
return err
}
if err := r.ensureSiteAccess(ctx, adminID, row.SiteID); err != nil {
return err
}
if row.Status == "settled" {
return ErrBatchAlreadySettled
}
var items []model.DistributionSettlementItem
if err := tx.Where("batch_id = ?", row.ID).Find(&items).Error; err != nil {
return err
}
if len(items) == 0 {
return ErrSettlementBatchEmpty
}
var total int64
for _, item := range items {
total += item.AmountCent
}
if total != row.TotalAmountCent {
return ErrSettlementTotalMismatch
}
now := time.Now()
if err := tx.Model(&model.DistributionSettlementItem{}).Where("batch_id = ?", row.ID).
Update("status", "settled").Error; err != nil {
return err
}
if err := tx.Model(&model.DistributionOrderFinancial{}).
Where("order_id IN (?)", tx.Model(&model.DistributionSettlementItem{}).
Select("order_id").Where("batch_id = ?", row.ID)).
Updates(map[string]any{"settlement_status": "settled", "settled_at": now}).Error; err != nil {
return err
}
row.Status = "settled"
row.SettledAt = &now
return tx.Save(&row).Error
})
if err != nil {
return nil, err
}
return &SettlementBatchDTO{ID: row.ID, SiteID: row.SiteID, BatchNo: row.BatchNo, PeriodStart: row.PeriodStart, PeriodEnd: row.PeriodEnd, TotalAmountCent: row.TotalAmountCent, Status: row.Status}, nil
}
func (r *Repository) CreateFinancialSnapshot(ctx context.Context, orderID uint64, req CreateFinancialSnapshotRequest) (*DistributionOrderDTO, error) {
func (r *Repository) CreateFinancialSnapshot(ctx context.Context, adminID, orderID uint64, req CreateFinancialSnapshotRequest) (*DistributionOrderDTO, error) {
if req.SiteID == 0 || orderID == 0 {
return nil, ErrInvalidAmount
}
if err := r.ensureSiteAccess(ctx, adminID, req.SiteID); err != nil {
return nil, err
}
var rule model.DistributionRuleVersion
if err := r.db.WithContext(ctx).First(&rule, req.RuleVersionID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -386,23 +529,33 @@ func (r *Repository) CreateFinancialSnapshot(ctx context.Context, orderID uint64
}
return nil, err
}
if req.SiteID == 0 || orderID == 0 {
return nil, ErrInvalidAmount
var orderExists int64
if err := r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Count(&orderExists).Error; err != nil {
return nil, err
}
if orderExists == 0 {
return nil, ErrOrderNotFound
}
// 手工补数只允许创建,不允许覆盖支付事务写入的真实快照。
var existing int64
if err := r.db.WithContext(ctx).Model(&model.DistributionOrderFinancial{}).
Where("order_id = ?", orderID).Count(&existing).Error; err != nil {
return nil, err
}
if existing > 0 {
return nil, ErrOrderSnapshotExists
}
partyInputs := make([]PartyProfitInput, 0, len(req.Parties))
for _, party := range req.Parties {
partyInputs = append(partyInputs, PartyProfitInput{PartyRole: party.PartyRole, IncomeAmountCent: party.IncomeAmountCent, CostAmountCent: party.CostAmountCent})
}
result, err := CalculateSettlement(Rule{ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, PaymentFeeBPS: rule.PaymentFeeBPS, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS}, SettlementInput{GrossPaidAmountCent: req.GrossPaidAmountCent, RefundAmountCent: req.RefundAmountCent, MarkupAmountCent: req.MarkupAmountCent, GrossProfitByParty: partyInputs})
result, err := CalculateSettlement(Rule{Mode: rule.Mode, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, PaymentFeeBPS: rule.PaymentFeeBPS, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS}, SettlementInput{GrossPaidAmountCent: req.GrossPaidAmountCent, RefundAmountCent: req.RefundAmountCent, MarkupAmountCent: req.MarkupAmountCent, GrossProfitByParty: partyInputs})
if err != nil {
return nil, err
}
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
financial := model.DistributionOrderFinancial{OrderID: orderID, SiteID: req.SiteID, RuleVersionID: req.RuleVersionID, GrossPaidAmountCent: result.GrossPaidAmountCent, RefundAmountCent: result.RefundAmountCent, NetPaidAmountCent: result.NetPaidAmountCent, PaymentFeeBPS: rule.PaymentFeeBPS, PaymentFeeCent: result.PaymentFeeCent, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: result.MarkupAmountCent, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, SettlementStatus: "pending"}
if err := tx.Where("order_id = ?", orderID).Assign(financial).FirstOrCreate(&financial).Error; err != nil {
return err
}
if err := tx.Where("order_id = ?", orderID).Delete(&model.DistributionOrderParty{}).Error; err != nil {
financial := model.DistributionOrderFinancial{OrderID: orderID, SiteID: req.SiteID, RuleVersionID: req.RuleVersionID, GrossPaidAmountCent: result.GrossPaidAmountCent, RefundAmountCent: result.RefundAmountCent, NetPaidAmountCent: result.NetPaidAmountCent, PaymentFeeBPS: rule.PaymentFeeBPS, PaymentFeeCent: result.PaymentFeeCent, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: result.MarkupAmountCent, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, RoundingAdjustmentCent: SplitRemainderCent(result.SplitBaseAmountCent, SplitSettlement(Rule{Mode: rule.Mode}, result.SplitBaseAmountCent, result.MarkupAmountCent)), SettlementStatus: "pending"}
if err := tx.Create(&financial).Error; err != nil {
return err
}
for idx, party := range req.Parties {
@@ -416,10 +569,16 @@ func (r *Repository) CreateFinancialSnapshot(ctx context.Context, orderID uint64
return err
}
}
return tx.Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{"distribution_site_id": req.SiteID, "distribution_rule_version_id": req.RuleVersionID}).Error
if err := tx.Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{"distribution_site_id": req.SiteID, "distribution_rule_version_id": req.RuleVersionID}).Error; err != nil {
return err
}
return auditlog.Append(tx, auditlog.Entry{
ActorType: "admin", ActorID: adminID, Action: "distribution_financial_snapshot_create", BizType: "distribution_order", BizID: &orderID,
Detail: map[string]any{"site_id": req.SiteID, "rule_version_id": req.RuleVersionID, "gross_paid_amount_cent": req.GrossPaidAmountCent, "refund_amount_cent": req.RefundAmountCent, "parties": len(req.Parties)},
})
})
if err != nil {
return nil, err
}
return r.FindOrder(ctx, orderID)
return r.FindOrder(ctx, adminID, orderID)
}
@@ -16,7 +16,7 @@ func distributionTestDB(t *testing.T) *gorm.DB {
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.ChatConversation{}, &model.RentalOrder{}, &model.DistributionSite{}, &model.DistributionSiteMember{}, &model.DistributionRuleVersion{}, &model.DistributionOrderFinancial{}, &model.DistributionOrderParty{}, &model.DistributionSiteAccount{}, &model.DistributionSitePaymentChannel{}, &model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{}); err != nil {
if err := db.AutoMigrate(&model.ChatConversation{}, &model.RentalOrder{}, &model.AuditLog{}, &model.PaymentMerchantConfig{}, &model.DistributionSite{}, &model.DistributionSiteMember{}, &model.DistributionRuleVersion{}, &model.DistributionOrderFinancial{}, &model.DistributionOrderParty{}, &model.DistributionSiteAccount{}, &model.DistributionSitePaymentChannel{}, &model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{}); err != nil {
t.Fatal(err)
}
return db
@@ -38,10 +38,14 @@ func TestDistributionOrderSnapshotAndQueries(t *testing.T) {
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
_, err := repo.CreateFinancialSnapshot(t.Context(), order.ID, CreateFinancialSnapshotRequest{SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10001, RefundAmountCent: 1000, MarkupAmountCent: 300, Parties: []SnapshotPartyRequest{{PartyRole: "distributor_receive", PartyType: "site", PartyID: site.ID, IncomeAmountCent: 1000, CostAmountCent: 400}}})
_, err := repo.CreateFinancialSnapshot(t.Context(), 0, order.ID, CreateFinancialSnapshotRequest{SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10001, RefundAmountCent: 1000, MarkupAmountCent: 300, Parties: []SnapshotPartyRequest{{PartyRole: "distributor_receive", PartyType: "site", PartyID: site.ID, IncomeAmountCent: 1000, CostAmountCent: 400}}})
if err != nil {
t.Fatal(err)
}
// 重复补数必须被拒绝,避免覆盖真实财务快照。
if _, err := repo.CreateFinancialSnapshot(t.Context(), 0, order.ID, CreateFinancialSnapshotRequest{SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10001}); err != ErrOrderSnapshotExists {
t.Fatalf("duplicate snapshot err = %v", err)
}
page, err := repo.ListOrders(t.Context(), DistributionOrderListQuery{SiteID: site.ID, Kind: "receive", Page: 1, Size: 20})
if err != nil {
t.Fatal(err)
@@ -49,7 +53,7 @@ func TestDistributionOrderSnapshotAndQueries(t *testing.T) {
if page.Total != 1 || len(page.Items) != 1 || page.Items[0].PaymentFeeCent != 45 || page.Items[0].NetPaidAmountCent != 9001 {
t.Fatalf("unexpected page: %#v", page)
}
if _, err := repo.FindOrder(t.Context(), order.ID); err != nil {
if _, err := repo.FindOrder(t.Context(), 0, order.ID); err != nil {
t.Fatal(err)
}
}
@@ -61,27 +65,75 @@ func TestDistributionPaymentChannelAndSettlementBatch(t *testing.T) {
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
channel, err := repo.BindPaymentChannel(t.Context(), site.ID, BindPaymentChannelRequest{ConfigID: 9, Priority: 1})
if err := db.Create(&model.PaymentMerchantConfig{Name: "拉卡拉", Provider: "lkl", MerchantID: "M-1", PayWay: "ZFBZF"}).Error; err != nil {
t.Fatal(err)
}
if _, err := repo.BindPaymentChannel(t.Context(), site.ID, BindPaymentChannelRequest{ConfigID: 404, Priority: 1}); err != ErrPaymentConfigNotFound {
t.Fatalf("unknown config err = %v", err)
}
channel, err := repo.BindPaymentChannel(t.Context(), site.ID, BindPaymentChannelRequest{ConfigID: 1, Priority: 1})
if err != nil {
t.Fatal(err)
}
items, err := repo.ListPaymentChannels(t.Context(), site.ID)
if err != nil || len(items) != 1 || items[0].ConfigID != 9 {
if err != nil || len(items) != 1 || items[0].ConfigID != 1 {
t.Fatalf("channels = %#v, err=%v", items, err)
}
if err := repo.UnbindPaymentChannel(t.Context(), channel.ID); err != nil {
if err := repo.UnbindPaymentChannel(t.Context(), 0, channel.ID); err != nil {
t.Fatal(err)
}
// 准备一笔待结算订单:财务快照 + 参与方。
rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", PaymentFeeBPS: 50, EffectiveFrom: time.Now().Add(-2 * time.Hour)}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
order := model.RentalOrder{OrderNo: "D-2", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed", 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: 9950, SplitBaseAmountCent: 9900, SettlementStatus: "pending"}
if err := db.Create(&financial).Error; err != nil {
t.Fatal(err)
}
parties := []model.DistributionOrderParty{
{OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: "site", PartyID: site.ID, IncomeAmountCent: 350},
{OrderID: order.ID, SiteID: site.ID, PartyRole: "platform_sell", PartyType: "platform", IncomeAmountCent: 400},
}
if err := db.Create(&parties).Error; err != nil {
t.Fatal(err)
}
start := time.Now().Add(-time.Hour)
batch, err := repo.CreateSettlementBatch(t.Context(), 1, CreateSettlementBatchRequest{SiteID: site.ID, PeriodStart: start, PeriodEnd: time.Now()})
batch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{SiteID: site.ID, PeriodStart: start, PeriodEnd: time.Now().Add(time.Hour)})
if err != nil {
t.Fatal(err)
}
settled, err := repo.MarkSettlementBatch(t.Context(), batch.ID)
if batch.TotalAmountCent != 750 {
t.Fatalf("batch total = %d, want 750", batch.TotalAmountCent)
}
var batchItems []model.DistributionSettlementItem
if err := db.Where("batch_id = ?", batch.ID).Find(&batchItems).Error; err != nil || len(batchItems) != 2 {
t.Fatalf("batch items = %d, err=%v", len(batchItems), err)
}
// 空批次不允许结算。
emptyBatch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{SiteID: site.ID, PeriodStart: start.Add(-48 * time.Hour), PeriodEnd: start.Add(-24 * time.Hour)})
if err != nil {
t.Fatal(err)
}
if _, err := repo.MarkSettlementBatch(t.Context(), 0, emptyBatch.ID); err != ErrSettlementBatchEmpty {
t.Fatalf("empty batch settle err = %v", err)
}
settled, err := repo.MarkSettlementBatch(t.Context(), 0, batch.ID)
if err != nil || settled.Status != "settled" {
t.Fatalf("settled = %#v, err=%v", settled, err)
}
if _, err := repo.MarkSettlementBatch(t.Context(), batch.ID); err != ErrBatchAlreadySettled {
var settledFinancial model.DistributionOrderFinancial
if err := db.Where("order_id = ?", order.ID).First(&settledFinancial).Error; err != nil || settledFinancial.SettlementStatus != "settled" || settledFinancial.SettledAt == nil {
t.Fatalf("financial after settle = %#v, err=%v", settledFinancial, err)
}
if _, err := repo.MarkSettlementBatch(t.Context(), 0, batch.ID); err != ErrBatchAlreadySettled {
t.Fatalf("second settle err = %v", err)
}
}
@@ -93,6 +145,10 @@ func TestScopeSiteIDRestrictsBoundMember(t *testing.T) {
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
other := model.DistributionSite{SiteCode: "other", SiteName: "其他站", Domain: "other.example.com"}
if err := db.Create(&other).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.DistributionSiteMember{SiteID: site.ID, AdminUserID: 7, MemberType: "customer_service", Status: "active"}).Error; err != nil {
t.Fatal(err)
}
@@ -105,4 +161,19 @@ func TestScopeSiteIDRestrictsBoundMember(t *testing.T) {
if got, err := repo.ScopeSiteID(t.Context(), 99, 123); err != nil || got != 123 {
t.Fatalf("global scope = %d, err=%v", got, err)
}
// 被限制到站点 A 的成员不能通过资源 ID 跨站点访问。
if _, err := repo.FindSite(t.Context(), 7, other.ID); err != ErrSiteAccessDenied {
t.Fatalf("FindSite cross-site err = %v", err)
}
if _, err := repo.UpdateAccountDisplay(t.Context(), 7, 999, "enabled"); err != ErrAccountDisplayNotFound {
t.Fatalf("UpdateAccountDisplay missing err = %v", err)
}
display := model.DistributionSiteAccount{SiteID: other.ID, AccountID: 5, ListingID: 6, DisplayStatus: "enabled"}
if err := db.Create(&display).Error; err != nil {
t.Fatal(err)
}
if _, err := repo.UpdateAccountDisplay(t.Context(), 7, display.ID, "disabled"); err != ErrSiteAccessDenied {
t.Fatalf("UpdateAccountDisplay cross-site err = %v", err)
}
}
@@ -61,11 +61,11 @@ func (s *Service) ScopeSiteID(ctx context.Context, adminID, requested uint64) (u
}
return s.repo.ScopeSiteID(ctx, adminID, requested)
}
func (s *Service) FindSite(ctx context.Context, id uint64) (*SiteDTO, error) {
func (s *Service) FindSite(ctx context.Context, adminID, id uint64) (*SiteDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.FindSite(ctx, id)
return s.repo.FindSite(ctx, adminID, id)
}
func (s *Service) CreateSite(ctx context.Context, req CreateSiteRequest) (*SiteDTO, error) {
if err := s.available(); err != nil {
@@ -73,17 +73,17 @@ func (s *Service) CreateSite(ctx context.Context, req CreateSiteRequest) (*SiteD
}
return s.repo.CreateSite(ctx, req)
}
func (s *Service) UpdateSite(ctx context.Context, id uint64, req UpdateSiteRequest) (*SiteDTO, error) {
func (s *Service) UpdateSite(ctx context.Context, adminID, id uint64, req UpdateSiteRequest) (*SiteDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.UpdateSite(ctx, id, req)
return s.repo.UpdateSite(ctx, adminID, id, req)
}
func (s *Service) ListRules(ctx context.Context, siteID *uint64, mode string) ([]RuleDTO, error) {
func (s *Service) ListRules(ctx context.Context, adminID uint64, siteID *uint64, mode string) ([]RuleDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListRules(ctx, siteID, mode)
return s.repo.ListRules(ctx, adminID, siteID, mode)
}
func (s *Service) CreateRule(ctx context.Context, adminID uint64, req CreateRuleRequest) (*RuleDTO, error) {
if err := s.available(); err != nil {
@@ -91,11 +91,11 @@ func (s *Service) CreateRule(ctx context.Context, adminID uint64, req CreateRule
}
return s.repo.CreateRule(ctx, adminID, req)
}
func (s *Service) UpdateRuleStatus(ctx context.Context, id uint64, status string) (*RuleDTO, error) {
func (s *Service) UpdateRuleStatus(ctx context.Context, adminID, id uint64, status string) (*RuleDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.UpdateRuleStatus(ctx, id, status)
return s.repo.UpdateRuleStatus(ctx, adminID, id, status)
}
func (s *Service) ListOrders(ctx context.Context, query DistributionOrderListQuery) (*DistributionOrderPage, error) {
@@ -104,11 +104,11 @@ func (s *Service) ListOrders(ctx context.Context, query DistributionOrderListQue
}
return s.repo.ListOrders(ctx, query)
}
func (s *Service) FindOrder(ctx context.Context, id uint64) (*DistributionOrderDTO, error) {
func (s *Service) FindOrder(ctx context.Context, adminID, id uint64) (*DistributionOrderDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.FindOrder(ctx, id)
return s.repo.FindOrder(ctx, adminID, id)
}
func (s *Service) ListAccountDisplays(ctx context.Context, siteID uint64, status string) ([]AccountDisplayDTO, error) {
if err := s.available(); err != nil {
@@ -116,11 +116,11 @@ func (s *Service) ListAccountDisplays(ctx context.Context, siteID uint64, status
}
return s.repo.ListAccountDisplays(ctx, siteID, status)
}
func (s *Service) UpdateAccountDisplay(ctx context.Context, id uint64, status string) (*AccountDisplayDTO, error) {
func (s *Service) UpdateAccountDisplay(ctx context.Context, adminID, id uint64, status string) (*AccountDisplayDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.UpdateAccountDisplay(ctx, id, status)
return s.repo.UpdateAccountDisplay(ctx, adminID, id, status)
}
func (s *Service) ListPaymentChannels(ctx context.Context, siteID uint64) ([]PaymentChannelDTO, error) {
if err := s.available(); err != nil {
@@ -134,11 +134,11 @@ func (s *Service) BindPaymentChannel(ctx context.Context, siteID uint64, req Bin
}
return s.repo.BindPaymentChannel(ctx, siteID, req)
}
func (s *Service) UnbindPaymentChannel(ctx context.Context, id uint64) error {
func (s *Service) UnbindPaymentChannel(ctx context.Context, adminID, id uint64) error {
if err := s.available(); err != nil {
return err
}
return s.repo.UnbindPaymentChannel(ctx, id)
return s.repo.UnbindPaymentChannel(ctx, adminID, id)
}
func (s *Service) ListSettlementBatches(ctx context.Context, siteID uint64) ([]SettlementBatchDTO, error) {
if err := s.available(); err != nil {
@@ -152,16 +152,16 @@ func (s *Service) CreateSettlementBatch(ctx context.Context, adminID uint64, req
}
return s.repo.CreateSettlementBatch(ctx, adminID, req)
}
func (s *Service) MarkSettlementBatch(ctx context.Context, id uint64) (*SettlementBatchDTO, error) {
func (s *Service) MarkSettlementBatch(ctx context.Context, adminID, id uint64) (*SettlementBatchDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.MarkSettlementBatch(ctx, id)
return s.repo.MarkSettlementBatch(ctx, adminID, id)
}
func (s *Service) CreateFinancialSnapshot(ctx context.Context, orderID uint64, req CreateFinancialSnapshotRequest) (*DistributionOrderDTO, error) {
func (s *Service) CreateFinancialSnapshot(ctx context.Context, adminID, orderID uint64, req CreateFinancialSnapshotRequest) (*DistributionOrderDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.CreateFinancialSnapshot(ctx, orderID, req)
return s.repo.CreateFinancialSnapshot(ctx, adminID, orderID, req)
}
@@ -0,0 +1,34 @@
-- +goose Up
-- 分销后台权限:站点、规则、订单、账号展示、支付通道、结算批次。
INSERT INTO permissions (code, name, resource, action) VALUES
('distribution:site_view', '查看分销网站', 'distribution_site', 'view'),
('distribution:site_manage', '管理分销网站', 'distribution_site', 'manage'),
('distribution:rule_view', '查看分销规则', 'distribution_rule', 'view'),
('distribution:rule_manage', '管理分销规则', 'distribution_rule', 'manage'),
('distribution:order_view', '查看分销订单', 'distribution_order', 'view'),
('distribution:account_view', '查看分销账号展示', 'distribution_account', 'view'),
('distribution:account_display', '管理分销账号展示', 'distribution_account', 'display'),
('distribution:payment_view', '查看分销支付通道', 'distribution_payment', 'view'),
('distribution:payment_manage', '管理分销支付通道', 'distribution_payment', 'manage'),
('distribution:settlement_view', '查看分销结算', 'distribution_settlement', 'view'),
('distribution:settlement_manage', '管理分销结算', 'distribution_settlement', 'manage')
ON DUPLICATE KEY UPDATE
name = VALUES(name),
resource = VALUES(resource),
action = VALUES(action);
INSERT IGNORE INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.code LIKE 'distribution:%'
WHERE r.code IN ('super_admin', 'ops', 'finance');
-- +goose Down
DELETE rp
FROM role_permissions rp
JOIN permissions p ON p.id = rp.permission_id
WHERE p.code LIKE 'distribution:%';
DELETE FROM permissions WHERE code LIKE 'distribution:%';