接入分销订单支付退款与站点隔离

This commit is contained in:
yml2213
2026-09-05 10:52:36 +08:00
parent 2ffa7a89b1
commit ac67a91438
13 changed files with 299 additions and 27 deletions
@@ -17,7 +17,8 @@ type Handler struct{ service *Service }
func NewHandler(service *Service) *Handler { return &Handler{service: service} }
func (h *Handler) ListSites(c *gin.Context) {
items, err := h.service.ListSites(c.Request.Context())
adminID, _ := currentAdminID(c)
items, err := h.service.ListSitesForAdmin(c.Request.Context(), adminID)
if err != nil {
writeError(c, err)
return
@@ -130,9 +131,15 @@ func (h *Handler) ListOrders(c *gin.Context) {
if siteID < 0 {
return
}
adminID, _ := currentAdminID(c)
scoped, err := h.service.ScopeSiteID(c.Request.Context(), adminID, uint64(siteID))
if err != nil {
writeError(c, err)
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
result, err := h.service.ListOrders(c.Request.Context(), DistributionOrderListQuery{SiteID: uint64(siteID), Kind: c.Query("kind"), Status: c.Query("status"), Page: page, Size: size})
result, err := h.service.ListOrders(c.Request.Context(), DistributionOrderListQuery{SiteID: scoped, Kind: c.Query("kind"), Status: c.Query("status"), Page: page, Size: size})
if err != nil {
writeError(c, err)
return
@@ -179,7 +186,13 @@ func (h *Handler) ListAccountDisplays(c *gin.Context) {
}
return
}
items, err := h.service.ListAccountDisplays(c.Request.Context(), uint64(siteID), c.Query("status"))
adminID, _ := currentAdminID(c)
scoped, err := h.service.ScopeSiteID(c.Request.Context(), adminID, uint64(siteID))
if err != nil {
writeError(c, err)
return
}
items, err := h.service.ListAccountDisplays(c.Request.Context(), scoped, c.Query("status"))
if err != nil {
writeError(c, err)
return
@@ -211,7 +224,13 @@ func (h *Handler) ListPaymentChannels(c *gin.Context) {
response.BadRequest(c, "site_id 不正确")
return
}
items, err := h.service.ListPaymentChannels(c.Request.Context(), uint64(siteID))
adminID, _ := currentAdminID(c)
scoped, err := h.service.ScopeSiteID(c.Request.Context(), adminID, uint64(siteID))
if err != nil {
writeError(c, err)
return
}
items, err := h.service.ListPaymentChannels(c.Request.Context(), scoped)
if err != nil {
writeError(c, err)
return
@@ -225,12 +244,18 @@ func (h *Handler) BindPaymentChannel(c *gin.Context) {
response.BadRequest(c, "site_id 不正确")
return
}
adminID, _ := currentAdminID(c)
scoped, err := h.service.ScopeSiteID(c.Request.Context(), adminID, uint64(siteID))
if err != nil {
writeError(c, err)
return
}
var req BindPaymentChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "支付通道参数不正确")
return
}
item, err := h.service.BindPaymentChannel(c.Request.Context(), uint64(siteID), req)
item, err := h.service.BindPaymentChannel(c.Request.Context(), scoped, req)
if err != nil {
writeError(c, err)
return
@@ -256,7 +281,13 @@ func (h *Handler) ListSettlementBatches(c *gin.Context) {
response.BadRequest(c, "site_id 不正确")
return
}
items, err := h.service.ListSettlementBatches(c.Request.Context(), uint64(siteID))
adminID, _ := currentAdminID(c)
scoped, err := h.service.ScopeSiteID(c.Request.Context(), adminID, uint64(siteID))
if err != nil {
writeError(c, err)
return
}
items, err := h.service.ListSettlementBatches(c.Request.Context(), scoped)
if err != nil {
writeError(c, err)
return
@@ -334,6 +365,8 @@ func writeError(c *gin.Context, err error) {
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):
response.BadRequest(c, "分销规则参数不正确")
case errors.Is(err, ErrSiteAccessDenied):
response.Error(c, http.StatusForbidden, "forbidden", "无权访问该分销站点")
case errors.Is(err, ErrDependencyUnavailable):
response.ServiceUnavailable(c, "分销服务暂时不可用")
default:
@@ -0,0 +1,98 @@
package distribution
import (
"errors"
"time"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
)
// ResolveOrderSourceTx 校验站点、账号展示关系和生效规则,供正常订单创建事务写入快照引用。
func ResolveOrderSourceTx(tx *gorm.DB, siteCode string, listingID, accountID uint64, at time.Time) (*model.DistributionSite, *model.DistributionRuleVersion, error) {
if siteCode == "" {
return nil, nil, nil
}
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, nil, ErrSiteNotFound
}
var relation model.DistributionSiteAccount
if err := tx.Where("site_id = ? AND listing_id = ? AND account_id = ? AND display_status = 'enabled'", site.ID, listingID, accountID).First(&relation).Error; err != nil {
return nil, nil, ErrAccountDisplayNotFound
}
var rule model.DistributionRuleVersion
query := tx.Where("mode = ? AND status = 'active' AND effective_from <= ? AND (effective_to IS NULL OR effective_to > ?)", ModeTwoPartyDistributorReceive, at, at).
Where("site_id = ? OR site_id IS NULL", site.ID).
Order("CASE WHEN site_id IS NULL THEN 1 ELSE 0 END, effective_from DESC, id DESC")
if err := query.First(&rule).Error; err != nil {
return nil, nil, ErrRuleNotFound
}
return &site, &rule, nil
}
// SnapshotPaidOrderTx 在支付确认事务内幂等写入财务和参与方快照。
func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error {
if !tx.Migrator().HasTable(&model.DistributionOrderFinancial{}) {
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
}
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}}})
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"}
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 := []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},
}
return tx.Create(&parties).Error
}
// ApplyRefundTx 按累计成功退款金额重算手续费和分成基数,不覆盖订单创建时的规则版本。
func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
if !tx.Migrator().HasTable(&model.DistributionOrderFinancial{}) {
return nil
}
var financial model.DistributionOrderFinancial
if err := tx.Where("order_id = ?", orderID).First(&financial).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
if refundAmountCent < 0 || refundAmountCent > financial.GrossPaidAmountCent {
return ErrInvalidAmount
}
var rule model.DistributionRuleVersion
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})
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
}
@@ -21,12 +21,38 @@ var (
ErrInvalidPeriod = errors.New("invalid distribution settlement period")
ErrSettlementBatchNotFound = errors.New("distribution settlement batch not found")
ErrBatchAlreadySettled = errors.New("distribution settlement batch already settled")
ErrSiteAccessDenied = errors.New("distribution site access denied")
)
type Repository struct{ db *gorm.DB }
func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} }
func (r *Repository) ScopeSiteID(ctx context.Context, adminID, requested uint64) (uint64, error) {
if adminID == 0 {
return requested, nil
}
var members []model.DistributionSiteMember
if err := r.db.WithContext(ctx).Where("admin_user_id = ? AND status = 'active'", adminID).Find(&members).Error; err != nil {
return 0, err
}
if len(members) == 0 {
return requested, nil
}
if requested > 0 {
for _, member := range members {
if member.SiteID == requested {
return requested, nil
}
}
return 0, ErrSiteAccessDenied
}
if len(members) == 1 {
return members[0].SiteID, nil
}
return 0, 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 {
@@ -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.DistributionRuleVersion{}, &model.DistributionOrderFinancial{}, &model.DistributionOrderParty{}, &model.DistributionSiteAccount{}, &model.DistributionSitePaymentChannel{}, &model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{}); err != nil {
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 {
t.Fatal(err)
}
return db
@@ -85,3 +85,24 @@ func TestDistributionPaymentChannelAndSettlementBatch(t *testing.T) {
t.Fatalf("second settle err = %v", err)
}
}
func TestScopeSiteIDRestrictsBoundMember(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
site := model.DistributionSite{SiteCode: "scope", SiteName: "范围站", Domain: "scope.example.com"}
if err := db.Create(&site).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)
}
if got, err := repo.ScopeSiteID(t.Context(), 7, 0); err != nil || got != site.ID {
t.Fatalf("scope = %d, err=%v", got, err)
}
if _, err := repo.ScopeSiteID(t.Context(), 7, site.ID+1); err != ErrSiteAccessDenied {
t.Fatalf("access error = %v", err)
}
if got, err := repo.ScopeSiteID(t.Context(), 99, 123); err != nil || got != 123 {
t.Fatalf("global scope = %d, err=%v", got, err)
}
}
@@ -3,6 +3,8 @@ package distribution
import (
"context"
"errors"
"hfb_sys/backend/internal/model"
)
var ErrDependencyUnavailable = errors.New("distribution dependency unavailable")
@@ -23,6 +25,42 @@ func (s *Service) ListSites(ctx context.Context) ([]SiteDTO, error) {
}
return s.repo.ListSites(ctx)
}
func (s *Service) ListSitesForAdmin(ctx context.Context, adminID uint64) ([]SiteDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
items, err := s.repo.ListSites(ctx)
if err != nil {
return nil, err
}
var members []model.DistributionSiteMember
if adminID == 0 || s.repo.db == nil {
return items, nil
}
if err := s.repo.db.WithContext(ctx).Where("admin_user_id = ? AND status = 'active'", adminID).Find(&members).Error; err != nil {
return nil, err
}
if len(members) == 0 {
return items, nil
}
allowed := make(map[uint64]struct{}, len(members))
for _, member := range members {
allowed[member.SiteID] = struct{}{}
}
filtered := make([]SiteDTO, 0, len(allowed))
for _, item := range items {
if _, ok := allowed[item.ID]; ok {
filtered = append(filtered, item)
}
}
return filtered, nil
}
func (s *Service) ScopeSiteID(ctx context.Context, adminID, requested uint64) (uint64, error) {
if err := s.available(); err != nil {
return 0, err
}
return s.repo.ScopeSiteID(ctx, adminID, requested)
}
func (s *Service) FindSite(ctx context.Context, id uint64) (*SiteDTO, error) {
if err := s.available(); err != nil {
return nil, err