From ac67a91438e786708f04ed7fc55572d96e68ceb9 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 4 Sep 2026 15:48:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E5=88=86=E9=94=80=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E6=94=AF=E4=BB=98=E9=80=80=E6=AC=BE=E4=B8=8E=E7=AB=99?= =?UTF-8?q?=E7=82=B9=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/model/chat.go | 25 ++--- .../internal/modules/chat/listing_group.go | 28 +++++- .../internal/modules/distribution/handler.go | 45 +++++++-- .../modules/distribution/lifecycle.go | 98 +++++++++++++++++++ .../modules/distribution/repository.go | 26 +++++ .../modules/distribution/repository_test.go | 23 ++++- .../internal/modules/distribution/service.go | 38 +++++++ backend/internal/modules/order/dto.go | 3 +- backend/internal/modules/order/lifecycle.go | 10 ++ .../modules/payment/channel_status.go | 4 + backend/internal/modules/payment/refund.go | 6 +- .../000063_distribution_qrcode_site.sql | 13 +++ docs/分销网管理后台设计方案.md | 7 +- 13 files changed, 299 insertions(+), 27 deletions(-) create mode 100644 backend/internal/modules/distribution/lifecycle.go create mode 100644 backend/migrations/000063_distribution_qrcode_site.sql diff --git a/backend/internal/model/chat.go b/backend/internal/model/chat.go index afe25c1..ab1d0d4 100644 --- a/backend/internal/model/chat.go +++ b/backend/internal/model/chat.go @@ -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 { diff --git a/backend/internal/modules/chat/listing_group.go b/backend/internal/modules/chat/listing_group.go index 308b1c4..a58d039 100644 --- a/backend/internal/modules/chat/listing_group.go +++ b/backend/internal/modules/chat/listing_group.go @@ -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 diff --git a/backend/internal/modules/distribution/handler.go b/backend/internal/modules/distribution/handler.go index c0313ca..31c975a 100644 --- a/backend/internal/modules/distribution/handler.go +++ b/backend/internal/modules/distribution/handler.go @@ -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: diff --git a/backend/internal/modules/distribution/lifecycle.go b/backend/internal/modules/distribution/lifecycle.go new file mode 100644 index 0000000..bc4a417 --- /dev/null +++ b/backend/internal/modules/distribution/lifecycle.go @@ -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 +} diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index ed5251f..29c5537 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -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 { diff --git a/backend/internal/modules/distribution/repository_test.go b/backend/internal/modules/distribution/repository_test.go index bf98979..4658acf 100644 --- a/backend/internal/modules/distribution/repository_test.go +++ b/backend/internal/modules/distribution/repository_test.go @@ -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) + } +} diff --git a/backend/internal/modules/distribution/service.go b/backend/internal/modules/distribution/service.go index 881aa7b..7650f23 100644 --- a/backend/internal/modules/distribution/service.go +++ b/backend/internal/modules/distribution/service.go @@ -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 diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index 7583b34..07fbbaa 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -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 { diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go index 50573b5..6b60bc7 100644 --- a/backend/internal/modules/order/lifecycle.go +++ b/backend/internal/modules/order/lifecycle.go @@ -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 } diff --git a/backend/internal/modules/payment/channel_status.go b/backend/internal/modules/payment/channel_status.go index 535ee6d..83f5eea 100644 --- a/backend/internal/modules/payment/channel_status.go +++ b/backend/internal/modules/payment/channel_status.go @@ -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 + } } } diff --git a/backend/internal/modules/payment/refund.go b/backend/internal/modules/payment/refund.go index 5c76870..e9558ec 100644 --- a/backend/internal/modules/payment/refund.go +++ b/backend/internal/modules/payment/refund.go @@ -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 { diff --git a/backend/migrations/000063_distribution_qrcode_site.sql b/backend/migrations/000063_distribution_qrcode_site.sql new file mode 100644 index 0000000..bb36f56 --- /dev/null +++ b/backend/migrations/000063_distribution_qrcode_site.sql @@ -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; diff --git a/docs/分销网管理后台设计方案.md b/docs/分销网管理后台设计方案.md index 03e3f84..5ccc081 100644 --- a/docs/分销网管理后台设计方案.md +++ b/docs/分销网管理后台设计方案.md @@ -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`,分销站点建群时优先从专属池取码;未绑定站点或旧环境自动回退全局池。 +- 已绑定分销站点的后台成员在订单、账号、支付通道和结算查询中强制使用成员范围;无站点成员关系的主站管理员保留全局视图。 -当前仍需在下一迭代接入真实支付回调、退款/订单完成结算、钱包流水、客服二维码池和成员级站点隔离;在这些链路接通前,财务快照接口只作为后台受控的幂等写入入口。 +当前仍需在下一迭代接入分销钱包流水、完整订单完成结算和客服消息发送权限;财务快照、支付成功和退款更新已具备幂等基础。