From 9ae763dee6e6f53aac5eee10b18892f57b82d319 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 11 Sep 2026 10:31:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(distribution):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=94=B6=E5=8F=B7=E7=AB=99=E8=AE=A2=E5=8D=95=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E6=9D=83=E9=99=90=E3=80=81=E4=B8=93=E5=B1=9E=E6=94=AF=E4=BB=98?= =?UTF-8?q?=E9=80=9A=E9=81=93=E8=B7=AF=E7=94=B1=E3=80=81=E5=A4=A7=E7=9B=98?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E4=B8=8E=E4=BB=B2=E8=A3=81=E7=A7=AF=E5=88=86?= =?UTF-8?q?=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 FindOrder 在收号站模式(two_party_distributor_receive / three_party)下的 403 权限判定,放行参与方站点成员并精准计算收益 - 串联 distribution_site_payment_channels 至 payment.Start 核心支付发起流程,分销订单优先使用绑定的专属商户通道 - 修复数据大盘 GetDashboardAnalytics 趋势中 ReceivedCount 恒为 0 的问题,并对齐今日订单与 GMV 统计 - 修复 dispute/arbitration.go 仲裁完成时向分销租客发放成长积分的泄漏风险 - 提现管理新增 distribution_site_name/distribution_site_code 字段并在后台列表与详情弹窗中区分展示分销站 - GetPublicSiteInfo 增加 X-Distribution-Site-Code 请求头兜底支持 --- .../internal/modules/dispute/arbitration.go | 6 +- ...itration_deposit_hold_distribution_test.go | 6 + .../modules/distribution/auth_onboard_test.go | 36 ++++- .../internal/modules/distribution/handler.go | 3 + .../modules/distribution/repository.go | 148 +++++++++++++----- .../modules/distribution/repository_test.go | 28 ++++ .../internal/modules/payment/payment_start.go | 18 +-- .../internal/modules/payment/repository.go | 24 +++ .../payment/repository_integration_test.go | 104 ++++++++++++ backend/internal/modules/withdrawal/dto.go | 58 +++---- .../internal/modules/withdrawal/presenter.go | 21 ++- .../withdrawal/repository_integration_test.go | 30 ++++ .../src/features/admin/api/adminWithdrawal.ts | 3 + .../components/WithdrawalDetailDialog.vue | 13 +- .../admin/views/AdminWithdrawalsView.vue | 13 +- 15 files changed, 427 insertions(+), 84 deletions(-) diff --git a/backend/internal/modules/dispute/arbitration.go b/backend/internal/modules/dispute/arbitration.go index ab5fba3..3cc9610 100644 --- a/backend/internal/modules/dispute/arbitration.go +++ b/backend/internal/modules/dispute/arbitration.go @@ -124,8 +124,10 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r return err } } - if err := rentergrowth.AwardOrderCompleted(tx, &order, actualSettlement.PureCoinAmountCent, rentergrowth.SourceOrderCompleted); err != nil { - return err + if order.DistributionSiteID == nil || *order.DistributionSiteID == 0 { + if err := rentergrowth.AwardOrderCompleted(tx, &order, actualSettlement.PureCoinAmountCent, rentergrowth.SourceOrderCompleted); err != nil { + return err + } } } if err := tx.Save(&order).Error; err != nil { diff --git a/backend/internal/modules/dispute/arbitration_deposit_hold_distribution_test.go b/backend/internal/modules/dispute/arbitration_deposit_hold_distribution_test.go index a04e557..5d8c29c 100644 --- a/backend/internal/modules/dispute/arbitration_deposit_hold_distribution_test.go +++ b/backend/internal/modules/dispute/arbitration_deposit_hold_distribution_test.go @@ -141,4 +141,10 @@ func TestArbitrationDistributionRefundUsesActualRefundWithDepositHold(t *testing t.Fatalf("分销快照 net_paid_amount_cent = %d, want %d(毛收入 %d − 实际租金退款 %d)", snapshot.NetPaidAmountCent, wantNetPaid, order.RentAmountCent, wantRentRefundCent) } + + var growthLogsCount int64 + db.Model(&model.RenterGrowthLedger{}).Where("order_id = ?", order.ID).Count(&growthLogsCount) + if growthLogsCount != 0 { + t.Fatalf("分销订单仲裁不应发放用户成长积分,但产生了 %d 条记录", growthLogsCount) + } } diff --git a/backend/internal/modules/distribution/auth_onboard_test.go b/backend/internal/modules/distribution/auth_onboard_test.go index 10944e9..98f02df 100644 --- a/backend/internal/modules/distribution/auth_onboard_test.go +++ b/backend/internal/modules/distribution/auth_onboard_test.go @@ -2,12 +2,15 @@ package distribution import ( "context" + "net/http" + "net/http/httptest" "testing" + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/auth" - - "golang.org/x/crypto/bcrypt" ) func TestOnboardSiteAndLoginFlow(t *testing.T) { @@ -176,3 +179,32 @@ func TestOnboardSiteAndLoginFlow(t *testing.T) { t.Fatalf("unexpected profile: %#v", profile) } } + +func TestGetPublicSiteInfoHeaderFallback(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + svc := NewService(repo, nil) + handler := NewHandler(svc) + + site := model.DistributionSite{ + SiteCode: "header_site", + SiteName: "头部测试站", + Domain: "header.example.com", + Status: "active", + } + if err := db.Create(&site).Error; err != nil { + t.Fatal(err) + } + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req, _ := http.NewRequest("GET", "/api/v1/distribution/public/site-info", nil) + req.Header.Set("X-Distribution-Site-Code", "header_site") + c.Request = req + + handler.GetPublicSiteInfo(c) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body: %s", w.Code, w.Body.String()) + } +} diff --git a/backend/internal/modules/distribution/handler.go b/backend/internal/modules/distribution/handler.go index c3dc224..01e271f 100644 --- a/backend/internal/modules/distribution/handler.go +++ b/backend/internal/modules/distribution/handler.go @@ -308,6 +308,9 @@ func (h *Handler) GetPublicSiteInfo(c *gin.Context) { if code == "" { code = strings.TrimSpace(c.Query("site_code")) } + if code == "" { + code = strings.TrimSpace(c.GetHeader("X-Distribution-Site-Code")) + } domain := strings.TrimSpace(c.Query("domain")) if code == "" && domain == "" { host := c.Request.Host diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index 323c9bf..baac9b9 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -662,40 +662,101 @@ func (r *Repository) FindOrder(ctx context.Context, adminID, id uint64) (*Distri if item.OrderID == 0 { return nil, ErrOrderNotFound } - if err := r.ensureSiteAccess(ctx, adminID, item.SiteID); err != nil { + var parties []model.DistributionOrderParty + if err := r.db.WithContext(ctx).Where("order_id = ?", item.OrderID).Find(&parties).Error; err != nil { return nil, err } - var parties []model.DistributionOrderParty - if err := r.db.WithContext(ctx).Where("order_id = ? AND (site_id = ? OR party_id = ?)", item.OrderID, item.SiteID, item.SiteID).Find(&parties).Error; err == nil { - var totalEarning int64 - var markupCent int64 - var splitCent int64 - var roles []string - for _, p := range parties { - if p.PartyType != PartyTypeSite { - continue + var siteSet map[uint64]struct{} + if adminID != 0 { + global, err := r.hasGlobalDistributionAccessWithDB(ctx, r.db, adminID) + if err != nil { + return nil, err + } + if !global { + var adminSiteIDs []uint64 + if err := r.db.WithContext(ctx).Model(&model.DistributionSiteMember{}). + Where("admin_user_id = ? AND status = 'active'", adminID). + Pluck("site_id", &adminSiteIDs).Error; err != nil { + return nil, err } - totalEarning += p.IncomeAmountCent - if p.PartyRole == "distributor_sell" || p.PartyRole == "distributor_b" { - roles = append(roles, "sell") - // 退款压缩加价后收入可能小于货架加价:加价部分取两者较小值, - // 避免展示负的提价分成。 - markup := item.ShelfMarkupCent - if p.IncomeAmountCent < markup { - markup = p.IncomeAmountCent + siteSet = make(map[uint64]struct{}, len(adminSiteIDs)) + for _, sid := range adminSiteIDs { + siteSet[sid] = struct{}{} + } + hasAccess := false + if item.SiteID > 0 { + if _, ok := siteSet[item.SiteID]; ok { + hasAccess = true } - markupCent += markup - splitCent += p.IncomeAmountCent - markup - } else if p.PartyRole == "distributor_receive" || p.PartyRole == "distributor_a" { - roles = append(roles, "receive") - splitCent += p.IncomeAmountCent + } + if !hasAccess { + for _, p := range parties { + if p.PartyType == PartyTypeSite { + if p.SiteID > 0 { + if _, ok := siteSet[p.SiteID]; ok { + hasAccess = true + break + } + } + if p.PartyID > 0 { + if _, ok := siteSet[p.PartyID]; ok { + hasAccess = true + break + } + } + } + } + } + if !hasAccess { + return nil, ErrSiteAccessDenied } } - item.DistributorEarningCent = totalEarning - item.DistributorMarkupCent = markupCent - item.DistributorSplitCent = splitCent - item.DistributorRole = strings.Join(roles, ",") } + + var totalEarning int64 + var markupCent int64 + var splitCent int64 + var roles []string + for _, p := range parties { + if p.PartyType != PartyTypeSite { + continue + } + if len(siteSet) > 0 { + match := false + if p.SiteID > 0 { + if _, ok := siteSet[p.SiteID]; ok { + match = true + } + } + if p.PartyID > 0 { + if _, ok := siteSet[p.PartyID]; ok { + match = true + } + } + if !match { + continue + } + } + totalEarning += p.IncomeAmountCent + if p.PartyRole == "distributor_sell" || p.PartyRole == "distributor_b" { + roles = append(roles, "sell") + // 退款压缩加价后收入可能小于货架加价:加价部分取两者较小值, + // 避免展示负的提价分成。 + markup := item.ShelfMarkupCent + if p.IncomeAmountCent < markup { + markup = p.IncomeAmountCent + } + markupCent += markup + splitCent += p.IncomeAmountCent - markup + } else if p.PartyRole == "distributor_receive" || p.PartyRole == "distributor_a" { + roles = append(roles, "receive") + splitCent += p.IncomeAmountCent + } + } + item.DistributorEarningCent = totalEarning + item.DistributorMarkupCent = markupCent + item.DistributorSplitCent = splitCent + item.DistributorRole = strings.Join(roles, ",") return &item, nil } @@ -2350,13 +2411,14 @@ func (r *Repository) GetDashboardAnalytics(ctx context.Context, adminID, siteID Where("distribution_site_id = ? AND status = 'published' AND review_status = 'approved'", siteID).Count(&activeListings).Error var todayOrders int64 - _ = r.db.WithContext(ctx).Model(&model.RentalOrder{}). - Where("distribution_site_id = ? AND created_at >= ?", siteID, todayStr+" 00:00:00").Count(&todayOrders).Error + _ = r.db.WithContext(ctx).Table("rental_orders ro"). + Where("(ro.distribution_site_id = ? OR EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = ro.id AND (p.site_id = ? OR p.party_id = ?))) AND ro.created_at >= ?", siteID, siteID, siteID, todayStr+" 00:00:00"). + Count(&todayOrders).Error var todayGMV int64 _ = r.db.WithContext(ctx).Table("rental_orders ro"). Joins("JOIN distribution_order_financials f ON f.order_id = ro.id"). - Where("ro.distribution_site_id = ? AND ro.created_at >= ?", siteID, todayStr+" 00:00:00"). + Where("(ro.distribution_site_id = ? OR EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = ro.id AND (p.site_id = ? OR p.party_id = ?))) AND ro.created_at >= ?", siteID, siteID, siteID, todayStr+" 00:00:00"). Select("COALESCE(SUM(f.gross_paid_amount_cent), 0)").Scan(&todayGMV).Error var todayProfit int64 @@ -2374,11 +2436,9 @@ func (r *Repository) GetDashboardAnalytics(ctx context.Context, adminID, siteID trendDays := make([]DistributionTrendPoint, 0, 7) type trendRow struct { - DateStr string `gorm:"column:date_str"` - Received int64 `gorm:"column:received"` - Orders int64 `gorm:"column:orders"` - GMV int64 `gorm:"column:gmv"` - Profit int64 `gorm:"column:profit"` + DateStr string `gorm:"column:date_str"` + Orders int64 `gorm:"column:orders"` + GMV int64 `gorm:"column:gmv"` } var tRows []trendRow startDate := time.Now().AddDate(0, 0, -6).Format("2006-01-02") @@ -2393,6 +2453,22 @@ func (r *Repository) GetDashboardAnalytics(ctx context.Context, adminID, siteID Order("date_str ASC"). Scan(&tRows).Error + type receivedRow struct { + DateStr string `gorm:"column:date_str"` + Received int64 `gorm:"column:received"` + } + var rRows []receivedRow + dateFormatListingExpr := "DATE_FORMAT(created_at, '%Y-%m-%d')" + _ = r.db.WithContext(ctx).Model(&model.RentalListing{}). + Where("distribution_site_id = ? AND created_at >= ?", siteID, startDate+" 00:00:00"). + Select(fmt.Sprintf("%s AS date_str, COUNT(id) AS received", dateFormatListingExpr)). + Group(dateFormatListingExpr). + Scan(&rRows).Error + rRowMap := make(map[string]int64, len(rRows)) + for _, rr := range rRows { + rRowMap[rr.DateStr] = rr.Received + } + type profitRow struct { DateStr string `gorm:"column:date_str"` Profit int64 `gorm:"column:profit"` @@ -2417,7 +2493,7 @@ func (r *Repository) GetDashboardAnalytics(ctx context.Context, adminID, siteID tr := tRowMap[dStr] trendDays = append(trendDays, DistributionTrendPoint{ Date: dStr, - ReceivedCount: tr.Received, + ReceivedCount: rRowMap[dStr], OrderCount: tr.Orders, GMVCent: tr.GMV, ProfitCent: pRowMap[dStr], diff --git a/backend/internal/modules/distribution/repository_test.go b/backend/internal/modules/distribution/repository_test.go index d5e5cfd..db45a6d 100644 --- a/backend/internal/modules/distribution/repository_test.go +++ b/backend/internal/modules/distribution/repository_test.go @@ -74,6 +74,31 @@ func TestDistributionOrderSnapshotAndQueries(t *testing.T) { if _, err := repo.FindOrder(t.Context(), 0, order.ID); err != nil { t.Fatal(err) } + + // 验证分销站成员管理员可查看本站参与的订单 + adminUser := model.AdminUser{Username: "dist_admin", Status: "active"} + if err := db.Create(&adminUser).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.DistributionSiteMember{SiteID: site.ID, AdminUserID: adminUser.ID, MemberType: "site_operator", Status: "active"}).Error; err != nil { + t.Fatal(err) + } + detail, err := repo.FindOrder(t.Context(), adminUser.ID, order.ID) + if err != nil { + t.Fatalf("FindOrder by site member failed: %v", err) + } + if detail.DistributorEarningCent != 1000 || detail.DistributorRole != "receive" { + t.Fatalf("unexpected detail for site member: %+v", detail) + } + + // 验证未关联站点的管理员无法查看 + otherAdmin := model.AdminUser{Username: "other_admin", Status: "active"} + if err := db.Create(&otherAdmin).Error; err != nil { + t.Fatal(err) + } + if _, err := repo.FindOrder(t.Context(), otherAdmin.ID, order.ID); err != ErrSiteAccessDenied { + t.Fatalf("expected ErrSiteAccessDenied, got: %v", err) + } } func TestDistributionPaymentChannelAndSettlementBatch(t *testing.T) { @@ -862,6 +887,9 @@ func TestDistributionReceivedListingsAndWallet(t *testing.T) { if analytics.TotalReceivedCount != 1 || analytics.AvailableBalanceCent != 2500 { t.Fatalf("unexpected analytics: %+v", analytics) } + if len(analytics.TrendDays) == 0 || analytics.TrendDays[len(analytics.TrendDays)-1].ReceivedCount != 1 { + t.Fatalf("expected trend today ReceivedCount=1, got: %+v", analytics.TrendDays) + } } func TestPlatformSellSettlementCreditsOnlySiteShare(t *testing.T) { diff --git a/backend/internal/modules/payment/payment_start.go b/backend/internal/modules/payment/payment_start.go index fe513f2..1137c55 100644 --- a/backend/internal/modules/payment/payment_start.go +++ b/backend/internal/modules/payment/payment_start.go @@ -16,11 +16,7 @@ import ( // Start 发起订单支付,并按请求支付方式选择对应默认渠道配置。 func (r *Repository) Start(ctx context.Context, userID uint64, orderID uint64, req StartPaymentRequest, clientIP string) (*PaymentDTO, error) { req.PayWay = normalizePayWay(req.PayWay) - defaultConfig, err := r.defaultRuntimeConfig(ctx, req.PayWay) - if err != nil { - return nil, ErrPaymentUnavailable - } - payment, orderRow, err := r.preparePayment(ctx, userID, orderID, req, *defaultConfig) + payment, orderRow, err := r.preparePayment(ctx, userID, orderID, req) if err != nil { return nil, err } @@ -122,7 +118,7 @@ func (r *Repository) Start(ctx context.Context, userID uint64, orderID uint64, r } // preparePayment 创建或复用订单支付单,并写入本次命中的支付配置 ID。 -func (r *Repository) preparePayment(ctx context.Context, userID uint64, orderID uint64, req StartPaymentRequest, runtimeConfig runtimePaymentConfig) (*model.PaymentOrder, *model.RentalOrder, error) { +func (r *Repository) preparePayment(ctx context.Context, userID uint64, orderID uint64, req StartPaymentRequest) (*model.PaymentOrder, *model.RentalOrder, error) { var paymentID uint64 var orderRow model.RentalOrder err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { @@ -139,13 +135,17 @@ func (r *Repository) preparePayment(ctx context.Context, userID uint64, orderID if amountCent <= 0 { return ErrPaymentCannotStart } + runtimeConfig, err := r.resolveOrderRuntimeConfig(ctx, tx, row.DistributionSiteID, req.PayWay) + if err != nil { + return err + } var existing model.PaymentOrder - err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + err = tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("order_id = ? AND biz_type = ?", row.ID, "order_pay"). Order("id DESC"). First(&existing).Error if err == nil { - if canReuseOrderPayment(existing, runtimeConfig) { + if canReuseOrderPayment(existing, *runtimeConfig) { existing.PayWay = firstNonEmpty(req.PayWay, existing.PayWay, runtimeConfig.PayWay, "ZFBZF") existing.JSPayFlag = firstNonEmpty(req.JSPayFlag, existing.JSPayFlag, runtimeConfig.JSPayFlag, "2") existing.AmountCent = amountCent @@ -165,7 +165,7 @@ func (r *Repository) preparePayment(ctx context.Context, userID uint64, orderID } else if err != gorm.ErrRecordNotFound { return err } - payment, err := newOrderPayment(row, amountCent, req, runtimeConfig) + payment, err := newOrderPayment(row, amountCent, req, *runtimeConfig) if err != nil { return err } diff --git a/backend/internal/modules/payment/repository.go b/backend/internal/modules/payment/repository.go index 0ce14fc..7180daf 100644 --- a/backend/internal/modules/payment/repository.go +++ b/backend/internal/modules/payment/repository.go @@ -161,6 +161,30 @@ func (c runtimePaymentConfig) isMockMode() bool { return c.Provider == "mock" } +// resolveOrderRuntimeConfig 根据订单所属分销站点配置或全局默认配置解析运行时支付配置。 +func (r *Repository) resolveOrderRuntimeConfig(ctx context.Context, tx *gorm.DB, siteID *uint64, payWay string) (*runtimePaymentConfig, error) { + if siteID != nil && *siteID > 0 { + var configID uint64 + queryDB := tx + if queryDB == nil { + queryDB = r.db + } + err := queryDB.WithContext(ctx).Table("distribution_site_payment_channels dspc"). + Joins("JOIN payment_merchant_configs pmc ON pmc.id = dspc.config_id"). + Where("dspc.site_id = ? AND dspc.status = 'active' AND pmc.status = 'active' AND (pmc.pay_way = ? OR pmc.pay_way = '')", *siteID, payWay). + Order("dspc.priority ASC, dspc.id ASC"). + Limit(1). + Pluck("pmc.id", &configID).Error + if err == nil && configID > 0 && r.configRepo != nil { + dto, err := r.configRepo.FindRuntimeByID(ctx, configID, true) + if err == nil { + return runtimeConfigFromDTO(dto), nil + } + } + } + return r.defaultRuntimeConfig(ctx, payWay) +} + // defaultRuntimeConfig 按支付方式获取默认启用配置,用于新支付单创建。 func (r *Repository) defaultRuntimeConfig(ctx context.Context, payWay string) (*runtimePaymentConfig, error) { if r.configRepo == nil { diff --git a/backend/internal/modules/payment/repository_integration_test.go b/backend/internal/modules/payment/repository_integration_test.go index 8d8a9fc..3d1dce2 100644 --- a/backend/internal/modules/payment/repository_integration_test.go +++ b/backend/internal/modules/payment/repository_integration_test.go @@ -12,6 +12,7 @@ import ( "hfb_sys/backend/internal/database" "hfb_sys/backend/internal/model" ordermodule "hfb_sys/backend/internal/modules/order" + "hfb_sys/backend/internal/modules/paymentconfig" "hfb_sys/backend/internal/modules/rentergrowth" "gorm.io/gorm" @@ -824,3 +825,106 @@ func TestApplyChannelStatusKeepsTerminalPayment(t *testing.T) { t.Fatalf("status = %q, want closed", latest.Status) } } + +func TestStartPaymentRoutesToDistributionSitePaymentChannel(t *testing.T) { + db := setupPaymentTestDB(t) + if err := db.AutoMigrate( + &model.DistributionSite{}, + &model.DistributionSitePaymentChannel{}, + &model.PaymentMerchantConfig{}, + ); err != nil { + t.Fatalf("migrate distribution payment schema failed: %v", err) + } + + configRepo := paymentconfig.NewRepository(db, &paymentconfig.MockEncryptor{}) + orderRepo := ordermodule.NewRepository(db) + repo := NewRepository(db, configRepo, orderRepo) + + defaultConfig := model.PaymentMerchantConfig{ + Name: "平台默认微信", + Provider: "mock", + MerchantID: "PLATFORM_MOCK", + PayWay: "WX", + IsDefault: true, + Status: "active", + Environment: "sandbox", + } + if err := db.Create(&defaultConfig).Error; err != nil { + t.Fatal(err) + } + + siteConfig := model.PaymentMerchantConfig{ + Name: "分销站专属微信", + Provider: "mock", + MerchantID: "SITE_MOCK_01", + PayWay: "WX", + IsDefault: false, + Status: "active", + Environment: "sandbox", + } + if err := db.Create(&siteConfig).Error; err != nil { + t.Fatal(err) + } + + site := model.DistributionSite{SiteCode: "pay_site", SiteName: "支付分站", Domain: "pay.example.com", Status: "active"} + if err := db.Create(&site).Error; err != nil { + t.Fatal(err) + } + + channel := model.DistributionSitePaymentChannel{ + SiteID: site.ID, + ConfigID: siteConfig.ID, + Priority: 1, + Status: "active", + } + if err := db.Create(&channel).Error; err != nil { + t.Fatal(err) + } + + // Order A: Belongs to distribution site + fixA := createPayableOrderFixture(t, db, "siteA", false) + fixA.Order.DistributionSiteID = &site.ID + if err := db.Save(&fixA.Order).Error; err != nil { + t.Fatal(err) + } + + // Order B: Normal platform order + fixB := createPayableOrderFixture(t, db, "siteB", false) + fixB.Order.DistributionSiteID = nil + if err := db.Save(&fixB.Order).Error; err != nil { + t.Fatal(err) + } + + // Start payment for Order A -> should route to siteConfig + dtoA, err := repo.Start(t.Context(), fixA.Renter.ID, fixA.Order.ID, StartPaymentRequest{PayWay: "WX"}, "127.0.0.1") + if err != nil { + t.Fatalf("Start Order A failed: %v", err) + } + if dtoA.PaymentConfigID != siteConfig.ID { + t.Fatalf("Order A expected PaymentConfigID=%d, got %d", siteConfig.ID, dtoA.PaymentConfigID) + } + var savedA model.PaymentOrder + if err := db.First(&savedA, dtoA.ID).Error; err != nil { + t.Fatalf("find saved payment A failed: %v", err) + } + if savedA.MerchantID != siteConfig.MerchantID { + t.Fatalf("Order A expected MerchantID=%s, got %s", siteConfig.MerchantID, savedA.MerchantID) + } + + // Start payment for Order B -> should route to defaultConfig + dtoB, err := repo.Start(t.Context(), fixB.Renter.ID, fixB.Order.ID, StartPaymentRequest{PayWay: "WX"}, "127.0.0.1") + if err != nil { + t.Fatalf("Start Order B failed: %v", err) + } + if dtoB.PaymentConfigID != defaultConfig.ID { + t.Fatalf("Order B expected PaymentConfigID=%d, got %d", defaultConfig.ID, dtoB.PaymentConfigID) + } + var savedB model.PaymentOrder + if err := db.First(&savedB, dtoB.ID).Error; err != nil { + t.Fatalf("find saved payment B failed: %v", err) + } + if savedB.MerchantID != defaultConfig.MerchantID { + t.Fatalf("Order B expected MerchantID=%s, got %s", defaultConfig.MerchantID, savedB.MerchantID) + } +} + diff --git a/backend/internal/modules/withdrawal/dto.go b/backend/internal/modules/withdrawal/dto.go index 25b390e..5ccd90e 100644 --- a/backend/internal/modules/withdrawal/dto.go +++ b/backend/internal/modules/withdrawal/dto.go @@ -27,34 +27,36 @@ type WithdrawalDTO struct { // 管理员端详细 DTO type WithdrawalDetailDTO struct { - ID uint64 `json:"id"` - WithdrawNo string `json:"withdraw_no"` - UserID uint64 `json:"user_id"` - DistributionSiteID uint64 `json:"distribution_site_id"` - UserNickname string `json:"user_nickname"` - UserPhone string `json:"user_phone"` - AmountCent int64 `json:"amount_cent"` - FeeCent int64 `json:"fee_cent"` - ActualAmountCent int64 `json:"actual_amount_cent"` - PaymentAccountID *uint64 `json:"payment_account_id"` - AccountType string `json:"account_type"` - AccountName string `json:"account_name"` - AccountNo string `json:"account_no"` // 管理员可见完整账号 - BankName string `json:"bank_name"` - BankBranch string `json:"bank_branch"` - CertificateURLs []string `json:"certificate_urls"` // 收款二维码图片 - Status string `json:"status"` - ReviewedBy *uint64 `json:"reviewed_by"` - ReviewedByName string `json:"reviewed_by_name"` - ReviewedAt *time.Time `json:"reviewed_at"` - ReviewRemark string `json:"review_remark"` - PaidBy *uint64 `json:"paid_by"` - PaidByName string `json:"paid_by_name"` - PaidAt *time.Time `json:"paid_at"` - PaymentProofURL string `json:"payment_proof_url"` - PaymentRemark string `json:"payment_remark"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `json:"id"` + WithdrawNo string `json:"withdraw_no"` + UserID uint64 `json:"user_id"` + DistributionSiteID uint64 `json:"distribution_site_id"` + DistributionSiteName string `json:"distribution_site_name"` + DistributionSiteCode string `json:"distribution_site_code"` + UserNickname string `json:"user_nickname"` + UserPhone string `json:"user_phone"` + AmountCent int64 `json:"amount_cent"` + FeeCent int64 `json:"fee_cent"` + ActualAmountCent int64 `json:"actual_amount_cent"` + PaymentAccountID *uint64 `json:"payment_account_id"` + AccountType string `json:"account_type"` + AccountName string `json:"account_name"` + AccountNo string `json:"account_no"` // 管理员可见完整账号 + BankName string `json:"bank_name"` + BankBranch string `json:"bank_branch"` + CertificateURLs []string `json:"certificate_urls"` // 收款二维码图片 + Status string `json:"status"` + ReviewedBy *uint64 `json:"reviewed_by"` + ReviewedByName string `json:"reviewed_by_name"` + ReviewedAt *time.Time `json:"reviewed_at"` + ReviewRemark string `json:"review_remark"` + PaidBy *uint64 `json:"paid_by"` + PaidByName string `json:"paid_by_name"` + PaidAt *time.Time `json:"paid_at"` + PaymentProofURL string `json:"payment_proof_url"` + PaymentRemark string `json:"payment_remark"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type CreateWithdrawalRequest struct { diff --git a/backend/internal/modules/withdrawal/presenter.go b/backend/internal/modules/withdrawal/presenter.go index a6d9335..928ec75 100644 --- a/backend/internal/modules/withdrawal/presenter.go +++ b/backend/internal/modules/withdrawal/presenter.go @@ -75,12 +75,23 @@ func (r *Repository) toDetailDTO(ctx context.Context, w model.WithdrawalRequest) } } + var siteName, siteCode string + if w.DistributionSiteID > 0 { + var site model.DistributionSite + if err := db.Select("site_name, site_code").First(&site, w.DistributionSiteID).Error; err == nil { + siteName = site.SiteName + siteCode = site.SiteCode + } + } + return &WithdrawalDetailDTO{ - ID: w.ID, - WithdrawNo: w.WithdrawNo, - UserID: w.UserID, - DistributionSiteID: w.DistributionSiteID, - UserNickname: user.Nickname, + ID: w.ID, + WithdrawNo: w.WithdrawNo, + UserID: w.UserID, + DistributionSiteID: w.DistributionSiteID, + DistributionSiteName: siteName, + DistributionSiteCode: siteCode, + UserNickname: user.Nickname, UserPhone: user.Phone, AmountCent: w.AmountCent, FeeCent: w.FeeCent, diff --git a/backend/internal/modules/withdrawal/repository_integration_test.go b/backend/internal/modules/withdrawal/repository_integration_test.go index 31cf03e..8427e98 100644 --- a/backend/internal/modules/withdrawal/repository_integration_test.go +++ b/backend/internal/modules/withdrawal/repository_integration_test.go @@ -168,3 +168,33 @@ func TestWithdrawalCreateRollsBackWhenBalanceIsInsufficient(t *testing.T) { } f.assertBalance(t, 100_000, 0) } + +func TestWithdrawalDetailIncludesDistributionSiteInfo(t *testing.T) { + f := newWithdrawalFixture(t) + if err := f.db.AutoMigrate(&model.DistributionSite{}); err != nil { + t.Fatal(err) + } + site := model.DistributionSite{SiteCode: "sub_site", SiteName: "分销子站", Domain: "sub.example.com", Status: "active"} + if err := f.db.Create(&site).Error; err != nil { + t.Fatal(err) + } + wReq := model.WithdrawalRequest{ + WithdrawNo: "WD2026TESTSITE", + UserID: f.userID, + DistributionSiteID: site.ID, + AmountCent: 5000, + ActualAmountCent: 5000, + Status: "pending", + } + if err := f.db.Create(&wReq).Error; err != nil { + t.Fatal(err) + } + + detail, err := f.repo.AdminFindByID(context.Background(), wReq.ID) + if err != nil { + t.Fatalf("AdminFindByID failed: %v", err) + } + if detail.DistributionSiteID != site.ID || detail.DistributionSiteName != "分销子站" || detail.DistributionSiteCode != "sub_site" { + t.Fatalf("unexpected detail site info: %+v", detail) + } +} diff --git a/frontend/src/features/admin/api/adminWithdrawal.ts b/frontend/src/features/admin/api/adminWithdrawal.ts index 5bcffef..5cab667 100644 --- a/frontend/src/features/admin/api/adminWithdrawal.ts +++ b/frontend/src/features/admin/api/adminWithdrawal.ts @@ -7,6 +7,9 @@ export interface WithdrawalDetail { id: number withdraw_no: string user_id: number + distribution_site_id?: number + distribution_site_name?: string + distribution_site_code?: string user_nickname: string user_phone: string amount_cent: number diff --git a/frontend/src/features/admin/components/WithdrawalDetailDialog.vue b/frontend/src/features/admin/components/WithdrawalDetailDialog.vue index 148f3cd..02795b0 100644 --- a/frontend/src/features/admin/components/WithdrawalDetailDialog.vue +++ b/frontend/src/features/admin/components/WithdrawalDetailDialog.vue @@ -148,9 +148,20 @@ function accountTypeLabel(type: string) {
- + {{ withdrawal.withdraw_no }} + + + {{ + withdrawal.distribution_site_name || `分销站点 #${withdrawal.distribution_site_id}` + }} + + + 主站 + {{ statusLabel(withdrawal.status) }} diff --git a/frontend/src/features/admin/views/AdminWithdrawalsView.vue b/frontend/src/features/admin/views/AdminWithdrawalsView.vue index 32a324f..d0d3563 100644 --- a/frontend/src/features/admin/views/AdminWithdrawalsView.vue +++ b/frontend/src/features/admin/views/AdminWithdrawalsView.vue @@ -372,7 +372,18 @@ function money(cent: number | undefined | null) {