接入分销订单支付退款与站点隔离
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user