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

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
+13 -12
View File
@@ -59,18 +59,19 @@ func (ChatMessage) TableName() string {
}
type ChatQrCode struct {
ID uint64 `gorm:"primaryKey" json:"id"`
ImageURL string `gorm:"size:512;not null" json:"image_url"`
GroupName string `gorm:"size:128;not null;default:''" json:"group_name"`
Status string `gorm:"size:16;not null;default:'unused'" json:"status"`
ConversationID *uint64 `gorm:"index" json:"conversation_id"`
UsedAt *time.Time `json:"used_at"`
ExpiresAt *time.Time `json:"expires_at"`
CreatedBy uint64 `gorm:"not null" json:"created_by"`
Note string `gorm:"size:255;not null;default:''" json:"note"`
WecomRenamed bool `gorm:"not null;default:false" json:"wecom_renamed"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint64 `gorm:"primaryKey" json:"id"`
ImageURL string `gorm:"size:512;not null" json:"image_url"`
GroupName string `gorm:"size:128;not null;default:''" json:"group_name"`
DistributionSiteID *uint64 `gorm:"index" json:"distribution_site_id,omitempty"`
Status string `gorm:"size:16;not null;default:'unused'" json:"status"`
ConversationID *uint64 `gorm:"index" json:"conversation_id"`
UsedAt *time.Time `json:"used_at"`
ExpiresAt *time.Time `json:"expires_at"`
CreatedBy uint64 `gorm:"not null" json:"created_by"`
Note string `gorm:"size:255;not null;default:''" json:"note"`
WecomRenamed bool `gorm:"not null;default:false" json:"wecom_renamed"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (ChatQrCode) TableName() string {
+24 -4
View File
@@ -91,7 +91,7 @@ func EnsureListingConversation(tx *gorm.DB, listing model.RentalListing, preferr
// 4. 取二维码(带行锁)
var qrcode *model.ChatQrCode
var qrcodeImageURL string
qrcodeObj, err := fetchUnusedQrCode(tx)
qrcodeObj, err := fetchUnusedQrCodeForListing(tx, listing.ID)
if err != nil {
return nil, err
}
@@ -301,12 +301,32 @@ func updateConversationLastMessage(tx *gorm.DB, conversationID uint64, message *
}
func fetchUnusedQrCode(tx *gorm.DB) (*model.ChatQrCode, error) {
return fetchUnusedQrCodeForSite(tx, nil)
}
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 {
return fetchUnusedQrCodeForSite(tx, &relation.SiteID)
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
// 兼容尚未执行分销迁移的环境,继续使用全局二维码池。
return fetchUnusedQrCodeForSite(tx, nil)
}
return fetchUnusedQrCodeForSite(tx, nil)
}
func fetchUnusedQrCodeForSite(tx *gorm.DB, siteID *uint64) (*model.ChatQrCode, error) {
var qrcode model.ChatQrCode
now := time.Now()
err := tx.Where("status = ?", QrCodeStatusUnused).
query := tx.Where("status = ?", QrCodeStatusUnused).
Where("expires_at IS NULL OR expires_at > ?", now).
Order("id ASC").
Order("id ASC")
if siteID == nil {
query = query.Where("distribution_site_id IS NULL")
} else {
query = query.Where("distribution_site_id = ?", *siteID)
}
err := query.
Limit(1).
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&qrcode).Error
@@ -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
+2 -1
View File
@@ -142,7 +142,8 @@ type ActiveDisputeDTO struct {
}
type CreateRequest struct {
ListingID uint64 `json:"listing_id" binding:"required"`
ListingID uint64 `json:"listing_id" binding:"required"`
DistributionSiteCode string `json:"distribution_site_code" binding:"omitempty,max=64"`
}
type SubmitHandoffRequest struct {
@@ -7,6 +7,7 @@ import (
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/chat"
"hfb_sys/backend/internal/modules/distribution"
"hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/modules/rentergrowth"
@@ -50,6 +51,10 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
}
rentHours := estimateOrderDurationHours(snapshot)
pricing := buildOrderPricing(listing, account)
distributionSite, distributionRule, err := distribution.ResolveOrderSourceTx(tx, req.DistributionSiteCode, listing.ID, account.ID, time.Now())
if err != nil {
return err
}
rentOriginalAmountCent := pricing.RentAmountCent
growthSnapshot, err := rentergrowth.SnapshotForUser(tx, renterID)
if err != nil {
@@ -97,6 +102,11 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
SettlementStatus: settlementStatusUnsettled,
OfflineSettlementStatus: offlineSettlementStatusNone,
}
if distributionSite != nil && distributionRule != nil {
order.DistributionSiteID = &distributionSite.ID
order.DistributionMode = distributionRule.Mode
order.DistributionRuleVersionID = &distributionRule.ID
}
if err := tx.Create(&order).Error; err != nil {
return err
}
@@ -3,6 +3,7 @@ package payment
import (
"context"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/distribution"
"time"
"gorm.io/gorm"
@@ -69,6 +70,9 @@ func (r *Repository) confirmPaid(ctx context.Context, payment *model.PaymentOrde
}
newConvID = convID
notifyFn = r.orderRepo.NotifyNewConversation
if err := distribution.SnapshotPaidOrderTx(tx, payment.OrderID, payment.AmountCent); err != nil {
return err
}
}
}
+5 -1
View File
@@ -6,6 +6,7 @@ import (
"time"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/distribution"
"hfb_sys/backend/internal/modules/rentergrowth"
"go.uber.org/zap"
@@ -429,7 +430,10 @@ func (r *Repository) updateOrderRefundStatus(ctx context.Context, orderID uint64
return err
}
}
return tx.Save(&order).Error
if err := tx.Save(&order).Error; err != nil {
return err
}
return distribution.ApplyRefundTx(tx, orderID, refundAmountCent)
})
}
func (r *Repository) markOrderRefunding(ctx context.Context, orderID uint64, refundAmountCent int64) error {
@@ -0,0 +1,13 @@
-- +goose Up
ALTER TABLE chat_qrcode_pool
ADD COLUMN distribution_site_id BIGINT UNSIGNED NULL AFTER group_name,
ADD KEY idx_chat_qrcode_distribution_site (distribution_site_id, status),
ADD CONSTRAINT fk_chat_qrcode_distribution_site FOREIGN KEY (distribution_site_id) REFERENCES distribution_sites (id);
-- +goose Down
ALTER TABLE chat_qrcode_pool
DROP FOREIGN KEY fk_chat_qrcode_distribution_site,
DROP KEY idx_chat_qrcode_distribution_site,
DROP COLUMN distribution_site_id;
+5 -2
View File
@@ -762,7 +762,7 @@ distribution.hao858.com {
后续每次确认业务规则后,优先更新第 7、15 节,再同步更新第 8、9、13、14 节,确保规则、数据、接口、实施和验收标准保持一致。
## 16. 当前实现基线(v0.3)
## 16. 当前实现基线(v0.4)
本分支已落地第一批可运行功能,主站域名为 `https://hao858.com/`,独立后台工程位于 `distribution-admin/`,子域名暂定 `https://distribution.hao858.com/`:
@@ -772,5 +772,8 @@ distribution.hao858.com {
- 管理 API 已覆盖站点、规则、售出/收号订单查询、财务快照、账号展示开关、支付通道绑定和结算批次。
- 后台权限使用 `distribution:*` 权限码;后续接入成员站点数据范围校验时,不能使用请求参数扩大范围。
- `distribution-admin` 使用 React + TypeScript + Vite + Ant Design,已提供仪表盘、站点、售出订单、收号订单及结算/支付/账号/规则/客服入口和 Docker 镜像。
- 正常订单创建可通过 `distribution_site_code` 选择站点;后端校验账号展示关系和当前生效规则后写入站点、模式和规则版本快照。支付成功自动生成财务与参与方快照,退款成功按净支付金额更新手续费和分成基数。
- 二维码池支持 `distribution_site_id`,分销站点建群时优先从专属池取码;未绑定站点或旧环境自动回退全局池。
- 已绑定分销站点的后台成员在订单、账号、支付通道和结算查询中强制使用成员范围;无站点成员关系的主站管理员保留全局视图。
当前仍需在下一迭代接入真实支付回调、退款/订单完成结算、钱包流水、客服二维码池和成员级站点隔离;在这些链路接通前,财务快照接口只作为后台受控的幂等写入入口。
当前仍需在下一迭代接入分销钱包流水、完整订单完成结算和客服消息发送权限;财务快照、支付成功和退款更新已具备幂等基础。