diff --git a/backend/internal/modules/chat/listing_group.go b/backend/internal/modules/chat/listing_group.go index a58d039..beb4166 100644 --- a/backend/internal/modules/chat/listing_group.go +++ b/backend/internal/modules/chat/listing_group.go @@ -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) } diff --git a/backend/internal/modules/chat/qrcode_test.go b/backend/internal/modules/chat/qrcode_test.go index ce7e065..cae31e5 100644 --- a/backend/internal/modules/chat/qrcode_test.go +++ b/backend/internal/modules/chat/qrcode_test.go @@ -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{}, diff --git a/backend/internal/modules/distribution/dto.go b/backend/internal/modules/distribution/dto.go index c8fab62..046132d 100644 --- a/backend/internal/modules/distribution/dto.go +++ b/backend/internal/modules/distribution/dto.go @@ -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 { diff --git a/backend/internal/modules/distribution/handler.go b/backend/internal/modules/distribution/handler.go index 31c975a..9e3a431 100644 --- a/backend/internal/modules/distribution/handler.go +++ b/backend/internal/modules/distribution/handler.go @@ -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", "无权访问该分销站点") diff --git a/backend/internal/modules/distribution/lifecycle.go b/backend/internal/modules/distribution/lifecycle.go index bc4a417..8702844 100644 --- a/backend/internal/modules/distribution/lifecycle.go +++ b/backend/internal/modules/distribution/lifecycle.go @@ -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 } diff --git a/backend/internal/modules/distribution/pricing.go b/backend/internal/modules/distribution/pricing.go index 9c1ed3e..07a6830 100644 --- a/backend/internal/modules/distribution/pricing.go +++ b/backend/internal/modules/distribution/pricing.go @@ -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 +} diff --git a/backend/internal/modules/distribution/pricing_test.go b/backend/internal/modules/distribution/pricing_test.go index 7abd1dd..06afa42 100644 --- a/backend/internal/modules/distribution/pricing_test.go +++ b/backend/internal/modules/distribution/pricing_test.go @@ -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) + } +} diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index 29c5537..1812c3f 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -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) } diff --git a/backend/internal/modules/distribution/repository_test.go b/backend/internal/modules/distribution/repository_test.go index 4658acf..de7df68 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.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) + } } diff --git a/backend/internal/modules/distribution/service.go b/backend/internal/modules/distribution/service.go index 7650f23..ae7a964 100644 --- a/backend/internal/modules/distribution/service.go +++ b/backend/internal/modules/distribution/service.go @@ -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) } diff --git a/backend/migrations/000066_distribution_permissions.sql b/backend/migrations/000066_distribution_permissions.sql new file mode 100644 index 0000000..e3188f3 --- /dev/null +++ b/backend/migrations/000066_distribution_permissions.sql @@ -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:%';