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:
yml2213
2026-09-11 10:31:14 +08:00
parent 6acdead0f0
commit 9ae763dee6
15 changed files with 427 additions and 84 deletions
@@ -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())
}
}
@@ -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
@@ -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],
@@ -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) {