fix(distribution): 修复收号站订单详情权限、专属支付通道路由、大盘统计与仲裁积分隔离
- 修复 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 请求头兜底支持
This commit is contained in:
@@ -124,8 +124,10 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := rentergrowth.AwardOrderCompleted(tx, &order, actualSettlement.PureCoinAmountCent, rentergrowth.SourceOrderCompleted); err != nil {
|
if order.DistributionSiteID == nil || *order.DistributionSiteID == 0 {
|
||||||
return err
|
if err := rentergrowth.AwardOrderCompleted(tx, &order, actualSettlement.PureCoinAmountCent, rentergrowth.SourceOrderCompleted); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := tx.Save(&order).Error; err != nil {
|
if err := tx.Save(&order).Error; err != nil {
|
||||||
|
|||||||
@@ -141,4 +141,10 @@ func TestArbitrationDistributionRefundUsesActualRefundWithDepositHold(t *testing
|
|||||||
t.Fatalf("分销快照 net_paid_amount_cent = %d, want %d(毛收入 %d − 实际租金退款 %d)",
|
t.Fatalf("分销快照 net_paid_amount_cent = %d, want %d(毛收入 %d − 实际租金退款 %d)",
|
||||||
snapshot.NetPaidAmountCent, wantNetPaid, order.RentAmountCent, wantRentRefundCent)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ package distribution
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
"hfb_sys/backend/internal/modules/auth"
|
"hfb_sys/backend/internal/modules/auth"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestOnboardSiteAndLoginFlow(t *testing.T) {
|
func TestOnboardSiteAndLoginFlow(t *testing.T) {
|
||||||
@@ -176,3 +179,32 @@ func TestOnboardSiteAndLoginFlow(t *testing.T) {
|
|||||||
t.Fatalf("unexpected profile: %#v", profile)
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -308,6 +308,9 @@ func (h *Handler) GetPublicSiteInfo(c *gin.Context) {
|
|||||||
if code == "" {
|
if code == "" {
|
||||||
code = strings.TrimSpace(c.Query("site_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"))
|
domain := strings.TrimSpace(c.Query("domain"))
|
||||||
if code == "" && domain == "" {
|
if code == "" && domain == "" {
|
||||||
host := c.Request.Host
|
host := c.Request.Host
|
||||||
|
|||||||
@@ -662,40 +662,101 @@ func (r *Repository) FindOrder(ctx context.Context, adminID, id uint64) (*Distri
|
|||||||
if item.OrderID == 0 {
|
if item.OrderID == 0 {
|
||||||
return nil, ErrOrderNotFound
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
var parties []model.DistributionOrderParty
|
var siteSet map[uint64]struct{}
|
||||||
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 {
|
if adminID != 0 {
|
||||||
var totalEarning int64
|
global, err := r.hasGlobalDistributionAccessWithDB(ctx, r.db, adminID)
|
||||||
var markupCent int64
|
if err != nil {
|
||||||
var splitCent int64
|
return nil, err
|
||||||
var roles []string
|
}
|
||||||
for _, p := range parties {
|
if !global {
|
||||||
if p.PartyType != PartyTypeSite {
|
var adminSiteIDs []uint64
|
||||||
continue
|
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
|
siteSet = make(map[uint64]struct{}, len(adminSiteIDs))
|
||||||
if p.PartyRole == "distributor_sell" || p.PartyRole == "distributor_b" {
|
for _, sid := range adminSiteIDs {
|
||||||
roles = append(roles, "sell")
|
siteSet[sid] = struct{}{}
|
||||||
// 退款压缩加价后收入可能小于货架加价:加价部分取两者较小值,
|
}
|
||||||
// 避免展示负的提价分成。
|
hasAccess := false
|
||||||
markup := item.ShelfMarkupCent
|
if item.SiteID > 0 {
|
||||||
if p.IncomeAmountCent < markup {
|
if _, ok := siteSet[item.SiteID]; ok {
|
||||||
markup = p.IncomeAmountCent
|
hasAccess = true
|
||||||
}
|
}
|
||||||
markupCent += markup
|
}
|
||||||
splitCent += p.IncomeAmountCent - markup
|
if !hasAccess {
|
||||||
} else if p.PartyRole == "distributor_receive" || p.PartyRole == "distributor_a" {
|
for _, p := range parties {
|
||||||
roles = append(roles, "receive")
|
if p.PartyType == PartyTypeSite {
|
||||||
splitCent += p.IncomeAmountCent
|
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
|
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
|
Where("distribution_site_id = ? AND status = 'published' AND review_status = 'approved'", siteID).Count(&activeListings).Error
|
||||||
|
|
||||||
var todayOrders int64
|
var todayOrders int64
|
||||||
_ = r.db.WithContext(ctx).Model(&model.RentalOrder{}).
|
_ = r.db.WithContext(ctx).Table("rental_orders ro").
|
||||||
Where("distribution_site_id = ? AND created_at >= ?", siteID, todayStr+" 00:00:00").Count(&todayOrders).Error
|
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
|
var todayGMV int64
|
||||||
_ = r.db.WithContext(ctx).Table("rental_orders ro").
|
_ = r.db.WithContext(ctx).Table("rental_orders ro").
|
||||||
Joins("JOIN distribution_order_financials f ON f.order_id = ro.id").
|
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
|
Select("COALESCE(SUM(f.gross_paid_amount_cent), 0)").Scan(&todayGMV).Error
|
||||||
|
|
||||||
var todayProfit int64
|
var todayProfit int64
|
||||||
@@ -2374,11 +2436,9 @@ func (r *Repository) GetDashboardAnalytics(ctx context.Context, adminID, siteID
|
|||||||
|
|
||||||
trendDays := make([]DistributionTrendPoint, 0, 7)
|
trendDays := make([]DistributionTrendPoint, 0, 7)
|
||||||
type trendRow struct {
|
type trendRow struct {
|
||||||
DateStr string `gorm:"column:date_str"`
|
DateStr string `gorm:"column:date_str"`
|
||||||
Received int64 `gorm:"column:received"`
|
Orders int64 `gorm:"column:orders"`
|
||||||
Orders int64 `gorm:"column:orders"`
|
GMV int64 `gorm:"column:gmv"`
|
||||||
GMV int64 `gorm:"column:gmv"`
|
|
||||||
Profit int64 `gorm:"column:profit"`
|
|
||||||
}
|
}
|
||||||
var tRows []trendRow
|
var tRows []trendRow
|
||||||
startDate := time.Now().AddDate(0, 0, -6).Format("2006-01-02")
|
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").
|
Order("date_str ASC").
|
||||||
Scan(&tRows).Error
|
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 {
|
type profitRow struct {
|
||||||
DateStr string `gorm:"column:date_str"`
|
DateStr string `gorm:"column:date_str"`
|
||||||
Profit int64 `gorm:"column:profit"`
|
Profit int64 `gorm:"column:profit"`
|
||||||
@@ -2417,7 +2493,7 @@ func (r *Repository) GetDashboardAnalytics(ctx context.Context, adminID, siteID
|
|||||||
tr := tRowMap[dStr]
|
tr := tRowMap[dStr]
|
||||||
trendDays = append(trendDays, DistributionTrendPoint{
|
trendDays = append(trendDays, DistributionTrendPoint{
|
||||||
Date: dStr,
|
Date: dStr,
|
||||||
ReceivedCount: tr.Received,
|
ReceivedCount: rRowMap[dStr],
|
||||||
OrderCount: tr.Orders,
|
OrderCount: tr.Orders,
|
||||||
GMVCent: tr.GMV,
|
GMVCent: tr.GMV,
|
||||||
ProfitCent: pRowMap[dStr],
|
ProfitCent: pRowMap[dStr],
|
||||||
|
|||||||
@@ -74,6 +74,31 @@ func TestDistributionOrderSnapshotAndQueries(t *testing.T) {
|
|||||||
if _, err := repo.FindOrder(t.Context(), 0, order.ID); err != nil {
|
if _, err := repo.FindOrder(t.Context(), 0, order.ID); err != nil {
|
||||||
t.Fatal(err)
|
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) {
|
func TestDistributionPaymentChannelAndSettlementBatch(t *testing.T) {
|
||||||
@@ -862,6 +887,9 @@ func TestDistributionReceivedListingsAndWallet(t *testing.T) {
|
|||||||
if analytics.TotalReceivedCount != 1 || analytics.AvailableBalanceCent != 2500 {
|
if analytics.TotalReceivedCount != 1 || analytics.AvailableBalanceCent != 2500 {
|
||||||
t.Fatalf("unexpected analytics: %+v", analytics)
|
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) {
|
func TestPlatformSellSettlementCreditsOnlySiteShare(t *testing.T) {
|
||||||
|
|||||||
@@ -16,11 +16,7 @@ import (
|
|||||||
// Start 发起订单支付,并按请求支付方式选择对应默认渠道配置。
|
// Start 发起订单支付,并按请求支付方式选择对应默认渠道配置。
|
||||||
func (r *Repository) Start(ctx context.Context, userID uint64, orderID uint64, req StartPaymentRequest, clientIP string) (*PaymentDTO, error) {
|
func (r *Repository) Start(ctx context.Context, userID uint64, orderID uint64, req StartPaymentRequest, clientIP string) (*PaymentDTO, error) {
|
||||||
req.PayWay = normalizePayWay(req.PayWay)
|
req.PayWay = normalizePayWay(req.PayWay)
|
||||||
defaultConfig, err := r.defaultRuntimeConfig(ctx, req.PayWay)
|
payment, orderRow, err := r.preparePayment(ctx, userID, orderID, req)
|
||||||
if err != nil {
|
|
||||||
return nil, ErrPaymentUnavailable
|
|
||||||
}
|
|
||||||
payment, orderRow, err := r.preparePayment(ctx, userID, orderID, req, *defaultConfig)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -122,7 +118,7 @@ func (r *Repository) Start(ctx context.Context, userID uint64, orderID uint64, r
|
|||||||
}
|
}
|
||||||
|
|
||||||
// preparePayment 创建或复用订单支付单,并写入本次命中的支付配置 ID。
|
// 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 paymentID uint64
|
||||||
var orderRow model.RentalOrder
|
var orderRow model.RentalOrder
|
||||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
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 {
|
if amountCent <= 0 {
|
||||||
return ErrPaymentCannotStart
|
return ErrPaymentCannotStart
|
||||||
}
|
}
|
||||||
|
runtimeConfig, err := r.resolveOrderRuntimeConfig(ctx, tx, row.DistributionSiteID, req.PayWay)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
var existing model.PaymentOrder
|
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").
|
Where("order_id = ? AND biz_type = ?", row.ID, "order_pay").
|
||||||
Order("id DESC").
|
Order("id DESC").
|
||||||
First(&existing).Error
|
First(&existing).Error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if canReuseOrderPayment(existing, runtimeConfig) {
|
if canReuseOrderPayment(existing, *runtimeConfig) {
|
||||||
existing.PayWay = firstNonEmpty(req.PayWay, existing.PayWay, runtimeConfig.PayWay, "ZFBZF")
|
existing.PayWay = firstNonEmpty(req.PayWay, existing.PayWay, runtimeConfig.PayWay, "ZFBZF")
|
||||||
existing.JSPayFlag = firstNonEmpty(req.JSPayFlag, existing.JSPayFlag, runtimeConfig.JSPayFlag, "2")
|
existing.JSPayFlag = firstNonEmpty(req.JSPayFlag, existing.JSPayFlag, runtimeConfig.JSPayFlag, "2")
|
||||||
existing.AmountCent = amountCent
|
existing.AmountCent = amountCent
|
||||||
@@ -165,7 +165,7 @@ func (r *Repository) preparePayment(ctx context.Context, userID uint64, orderID
|
|||||||
} else if err != gorm.ErrRecordNotFound {
|
} else if err != gorm.ErrRecordNotFound {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
payment, err := newOrderPayment(row, amountCent, req, runtimeConfig)
|
payment, err := newOrderPayment(row, amountCent, req, *runtimeConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,30 @@ func (c runtimePaymentConfig) isMockMode() bool {
|
|||||||
return c.Provider == "mock"
|
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 按支付方式获取默认启用配置,用于新支付单创建。
|
// defaultRuntimeConfig 按支付方式获取默认启用配置,用于新支付单创建。
|
||||||
func (r *Repository) defaultRuntimeConfig(ctx context.Context, payWay string) (*runtimePaymentConfig, error) {
|
func (r *Repository) defaultRuntimeConfig(ctx context.Context, payWay string) (*runtimePaymentConfig, error) {
|
||||||
if r.configRepo == nil {
|
if r.configRepo == nil {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"hfb_sys/backend/internal/database"
|
"hfb_sys/backend/internal/database"
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
ordermodule "hfb_sys/backend/internal/modules/order"
|
ordermodule "hfb_sys/backend/internal/modules/order"
|
||||||
|
"hfb_sys/backend/internal/modules/paymentconfig"
|
||||||
"hfb_sys/backend/internal/modules/rentergrowth"
|
"hfb_sys/backend/internal/modules/rentergrowth"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -824,3 +825,106 @@ func TestApplyChannelStatusKeepsTerminalPayment(t *testing.T) {
|
|||||||
t.Fatalf("status = %q, want closed", latest.Status)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,34 +27,36 @@ type WithdrawalDTO struct {
|
|||||||
|
|
||||||
// 管理员端详细 DTO
|
// 管理员端详细 DTO
|
||||||
type WithdrawalDetailDTO struct {
|
type WithdrawalDetailDTO struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
WithdrawNo string `json:"withdraw_no"`
|
WithdrawNo string `json:"withdraw_no"`
|
||||||
UserID uint64 `json:"user_id"`
|
UserID uint64 `json:"user_id"`
|
||||||
DistributionSiteID uint64 `json:"distribution_site_id"`
|
DistributionSiteID uint64 `json:"distribution_site_id"`
|
||||||
UserNickname string `json:"user_nickname"`
|
DistributionSiteName string `json:"distribution_site_name"`
|
||||||
UserPhone string `json:"user_phone"`
|
DistributionSiteCode string `json:"distribution_site_code"`
|
||||||
AmountCent int64 `json:"amount_cent"`
|
UserNickname string `json:"user_nickname"`
|
||||||
FeeCent int64 `json:"fee_cent"`
|
UserPhone string `json:"user_phone"`
|
||||||
ActualAmountCent int64 `json:"actual_amount_cent"`
|
AmountCent int64 `json:"amount_cent"`
|
||||||
PaymentAccountID *uint64 `json:"payment_account_id"`
|
FeeCent int64 `json:"fee_cent"`
|
||||||
AccountType string `json:"account_type"`
|
ActualAmountCent int64 `json:"actual_amount_cent"`
|
||||||
AccountName string `json:"account_name"`
|
PaymentAccountID *uint64 `json:"payment_account_id"`
|
||||||
AccountNo string `json:"account_no"` // 管理员可见完整账号
|
AccountType string `json:"account_type"`
|
||||||
BankName string `json:"bank_name"`
|
AccountName string `json:"account_name"`
|
||||||
BankBranch string `json:"bank_branch"`
|
AccountNo string `json:"account_no"` // 管理员可见完整账号
|
||||||
CertificateURLs []string `json:"certificate_urls"` // 收款二维码图片
|
BankName string `json:"bank_name"`
|
||||||
Status string `json:"status"`
|
BankBranch string `json:"bank_branch"`
|
||||||
ReviewedBy *uint64 `json:"reviewed_by"`
|
CertificateURLs []string `json:"certificate_urls"` // 收款二维码图片
|
||||||
ReviewedByName string `json:"reviewed_by_name"`
|
Status string `json:"status"`
|
||||||
ReviewedAt *time.Time `json:"reviewed_at"`
|
ReviewedBy *uint64 `json:"reviewed_by"`
|
||||||
ReviewRemark string `json:"review_remark"`
|
ReviewedByName string `json:"reviewed_by_name"`
|
||||||
PaidBy *uint64 `json:"paid_by"`
|
ReviewedAt *time.Time `json:"reviewed_at"`
|
||||||
PaidByName string `json:"paid_by_name"`
|
ReviewRemark string `json:"review_remark"`
|
||||||
PaidAt *time.Time `json:"paid_at"`
|
PaidBy *uint64 `json:"paid_by"`
|
||||||
PaymentProofURL string `json:"payment_proof_url"`
|
PaidByName string `json:"paid_by_name"`
|
||||||
PaymentRemark string `json:"payment_remark"`
|
PaidAt *time.Time `json:"paid_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
PaymentProofURL string `json:"payment_proof_url"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
PaymentRemark string `json:"payment_remark"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateWithdrawalRequest struct {
|
type CreateWithdrawalRequest struct {
|
||||||
|
|||||||
@@ -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{
|
return &WithdrawalDetailDTO{
|
||||||
ID: w.ID,
|
ID: w.ID,
|
||||||
WithdrawNo: w.WithdrawNo,
|
WithdrawNo: w.WithdrawNo,
|
||||||
UserID: w.UserID,
|
UserID: w.UserID,
|
||||||
DistributionSiteID: w.DistributionSiteID,
|
DistributionSiteID: w.DistributionSiteID,
|
||||||
UserNickname: user.Nickname,
|
DistributionSiteName: siteName,
|
||||||
|
DistributionSiteCode: siteCode,
|
||||||
|
UserNickname: user.Nickname,
|
||||||
UserPhone: user.Phone,
|
UserPhone: user.Phone,
|
||||||
AmountCent: w.AmountCent,
|
AmountCent: w.AmountCent,
|
||||||
FeeCent: w.FeeCent,
|
FeeCent: w.FeeCent,
|
||||||
|
|||||||
@@ -168,3 +168,33 @@ func TestWithdrawalCreateRollsBackWhenBalanceIsInsufficient(t *testing.T) {
|
|||||||
}
|
}
|
||||||
f.assertBalance(t, 100_000, 0)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export interface WithdrawalDetail {
|
|||||||
id: number
|
id: number
|
||||||
withdraw_no: string
|
withdraw_no: string
|
||||||
user_id: number
|
user_id: number
|
||||||
|
distribution_site_id?: number
|
||||||
|
distribution_site_name?: string
|
||||||
|
distribution_site_code?: string
|
||||||
user_nickname: string
|
user_nickname: string
|
||||||
user_phone: string
|
user_phone: string
|
||||||
amount_cent: number
|
amount_cent: number
|
||||||
|
|||||||
@@ -148,9 +148,20 @@ function accountTypeLabel(type: string) {
|
|||||||
<div v-if="withdrawal" class="withdrawal-detail">
|
<div v-if="withdrawal" class="withdrawal-detail">
|
||||||
<!-- 基本信息 -->
|
<!-- 基本信息 -->
|
||||||
<el-descriptions :column="2" border>
|
<el-descriptions :column="2" border>
|
||||||
<el-descriptions-item label="提现单号" :span="2">
|
<el-descriptions-item label="提现单号">
|
||||||
{{ withdrawal.withdraw_no }}
|
{{ withdrawal.withdraw_no }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="所属站点">
|
||||||
|
<el-tag v-if="withdrawal.distribution_site_id" type="warning" size="small">
|
||||||
|
{{
|
||||||
|
withdrawal.distribution_site_name || `分销站点 #${withdrawal.distribution_site_id}`
|
||||||
|
}}
|
||||||
|
<template v-if="withdrawal.distribution_site_code">
|
||||||
|
({{ withdrawal.distribution_site_code }})</template
|
||||||
|
>
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-else type="info" size="small">主站</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="状态">
|
<el-descriptions-item label="状态">
|
||||||
<el-tag :type="statusType(withdrawal.status)">
|
<el-tag :type="statusType(withdrawal.status)">
|
||||||
{{ statusLabel(withdrawal.status) }}
|
{{ statusLabel(withdrawal.status) }}
|
||||||
|
|||||||
@@ -372,7 +372,18 @@ function money(cent: number | undefined | null) {
|
|||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="withdraw-no-cell">
|
<div class="withdraw-no-cell">
|
||||||
<span class="mono-code">{{ row.withdraw_no }}</span>
|
<span class="mono-code">{{ row.withdraw_no }}</span>
|
||||||
<small class="id-tag">#ID: {{ row.id }}</small>
|
<div style="display: flex; align-items: center; gap: 6px">
|
||||||
|
<small class="id-tag">#ID: {{ row.id }}</small>
|
||||||
|
<el-tag
|
||||||
|
v-if="row.distribution_site_id"
|
||||||
|
size="small"
|
||||||
|
type="warning"
|
||||||
|
effect="light"
|
||||||
|
>
|
||||||
|
{{ row.distribution_site_name || `分站 #${row.distribution_site_id}` }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-else size="small" type="info" effect="plain"> 主站 </el-tag>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|||||||
Reference in New Issue
Block a user