perf(distribution): 优化分销订单全量与分站慢查询,消除全表扫描与每行EXISTS子查询

- 数据库迁移:新增 000083 迁移,为 distribution_order_parties 添加 (site_id, order_id) 联合覆盖索引;
- 查询优化:重构 ListOrders,利用覆盖索引派生表预收敛分销订单候选集,替代在 3.7 万行 rental_orders 上的 OR EXISTS 逐行子查询全表扫描;
- 边界修复:超管全量分销订单(site_id = 0)增加分销范围收敛,防止全平台普通非分销订单混杂和 5 表级联扫表;
- 统计优化:同步改造 GetDashboardAnalytics 与 ListDistributionUsers,彻底消除大盘统计与客户画像聚合的慢查询;
- 验证方式:新增 TestListOrdersPerformanceAndScoping 单元测试,全量运行 ./scripts/check.sh 门禁验证通过。
This commit is contained in:
yml2213
2026-10-10 15:17:25 +08:00
parent 4ea08e355b
commit 95435faf46
3 changed files with 203 additions and 21 deletions
@@ -1063,6 +1063,22 @@ func ruleDTO(row model.DistributionRuleVersion) RuleDTO {
return RuleDTO{ID: row.ID, SiteID: row.SiteID, Mode: row.Mode, Version: row.Version, Status: row.Status, PlatformReceiveRatio: row.PlatformReceiveRatio, PlatformSellRatio: row.PlatformSellRatio, DistributorReceiveRatio: row.DistributorReceiveRatio, DistributorSellRatio: row.DistributorSellRatio, DistributorARatio: row.DistributorARatio, DistributorBRatio: row.DistributorBRatio, ServiceFeeValue: row.ServiceFeeValue, ServiceFeeUnit: row.ServiceFeeUnit, PaymentFeeBPS: row.PaymentFeeBPS, TaxEnabled: row.TaxEnabled, TaxRateBPS: row.TaxRateBPS, EffectiveFrom: row.EffectiveFrom, EffectiveTo: row.EffectiveTo, CreatedBy: row.CreatedBy, CreatedAt: row.CreatedAt}
}
const (
siteOrderCandidatesSubquery = "(SELECT id FROM rental_orders WHERE distribution_site_id = ? UNION SELECT order_id AS id FROM distribution_order_parties WHERE site_id = ? OR party_id = ?)"
allDistributionOrderCandidatesSubquery = "(SELECT id FROM rental_orders WHERE distribution_site_id > 0 OR distribution_mode <> '' UNION SELECT order_id AS id FROM distribution_order_parties)"
)
func (r *Repository) joinDistributionOrders(db *gorm.DB, siteID uint64, orderTableAlias string) *gorm.DB {
alias := orderTableAlias
if alias == "" {
alias = "rental_orders"
}
if siteID > 0 {
return db.Joins("JOIN "+siteOrderCandidatesSubquery+" AS matched ON matched.id = "+alias+".id", siteID, siteID, siteID)
}
return db.Joins("JOIN " + allDistributionOrderCandidatesSubquery + " AS matched ON matched.id = " + alias + ".id")
}
func (r *Repository) ListOrders(ctx context.Context, query DistributionOrderListQuery) (*DistributionOrderPage, error) {
page, size := query.Page, query.Size
if page < 1 {
@@ -1074,15 +1090,13 @@ func (r *Repository) ListOrders(ctx context.Context, query DistributionOrderList
if size > 100 {
size = 100
}
base := r.db.WithContext(ctx).Table("rental_orders ro").
base := r.db.WithContext(ctx).Table("rental_orders ro")
base = r.joinDistributionOrders(base, query.SiteID, "ro")
base = base.
Joins("LEFT JOIN distribution_order_financials f ON f.order_id = ro.id").
Joins("LEFT JOIN rental_listings rl ON rl.id = ro.listing_id").
Joins("LEFT JOIN game_accounts ga ON ga.id = ro.account_id").
Joins("LEFT JOIN users u ON u.id = ro.renter_id")
if query.SiteID > 0 {
base = base.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 = ?)))", query.SiteID, query.SiteID, query.SiteID)
}
if query.Status != "" {
base = base.Where("ro.status = ?", query.Status)
}
@@ -3354,14 +3368,17 @@ 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).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").
todayOrdersQ := r.db.WithContext(ctx).Table("rental_orders ro")
todayOrdersQ = r.joinDistributionOrders(todayOrdersQ, siteID, "ro")
_ = todayOrdersQ.Where("ro.created_at >= ?", todayStr+" 00:00:00").
Count(&todayOrders).Error
var todayGMV int64
_ = r.db.WithContext(ctx).Table("rental_orders ro").
todayGMVQ := r.db.WithContext(ctx).Table("rental_orders ro")
todayGMVQ = r.joinDistributionOrders(todayGMVQ, siteID, "ro")
_ = todayGMVQ.
Joins("JOIN distribution_order_financials f ON f.order_id = ro.id").
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").
Where("ro.created_at >= ?", todayStr+" 00:00:00").
Select("COALESCE(SUM(f.gross_paid_amount_cent), 0)").Scan(&todayGMV).Error
var todayProfit int64
@@ -3383,9 +3400,11 @@ func (r *Repository) GetDashboardAnalytics(ctx context.Context, adminID, siteID
Where("distribution_site_id = ?", siteID).Count(&totalUserCount).Error
var totalOrderUserCount int64
_ = r.db.WithContext(ctx).Table("rental_orders").
Where("(distribution_site_id = ? OR EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = rental_orders.id AND (p.site_id = ? OR p.party_id = ?))) AND status NOT IN ('cancelled', 'closed')", siteID, siteID, siteID).
Distinct("renter_id").Count(&totalOrderUserCount).Error
totalOrderUserQ := r.db.WithContext(ctx).Table("rental_orders ro")
totalOrderUserQ = r.joinDistributionOrders(totalOrderUserQ, siteID, "ro")
_ = totalOrderUserQ.
Where("ro.status NOT IN ('cancelled', 'closed')").
Distinct("ro.renter_id").Count(&totalOrderUserCount).Error
var wallet model.WalletAccount
_ = r.db.WithContext(ctx).Where("user_id = 0 AND distribution_site_id = ?", siteID).First(&wallet).Error
@@ -3401,9 +3420,11 @@ func (r *Repository) GetDashboardAnalytics(ctx context.Context, adminID, siteID
dateFormatExpr := "DATE_FORMAT(ro.created_at, '%Y-%m-%d')"
dateFormatPartyExpr := "DATE_FORMAT(created_at, '%Y-%m-%d')"
selectTrendSQL := fmt.Sprintf("%s AS date_str, COUNT(ro.id) AS orders, COALESCE(SUM(f.gross_paid_amount_cent), 0) AS gmv", dateFormatExpr)
_ = r.db.WithContext(ctx).Table("rental_orders ro").
trendQ := r.db.WithContext(ctx).Table("rental_orders ro")
trendQ = r.joinDistributionOrders(trendQ, siteID, "ro")
_ = trendQ.
Joins("JOIN distribution_order_financials f ON f.order_id = ro.id").
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, startDate+" 00:00:00").
Where("ro.created_at >= ?", startDate+" 00:00:00").
Select(selectTrendSQL).
Group(dateFormatExpr).
Order("date_str ASC").
@@ -3503,14 +3524,18 @@ func (r *Repository) ListDistributionUsers(ctx context.Context, adminID, siteID
_ = r.db.WithContext(ctx).Model(&model.User{}).
Where("distribution_site_id = ?", siteID).Count(&totalUsersCount).Error
_ = r.db.WithContext(ctx).Table("rental_orders").
Where("(distribution_site_id = ? OR EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = rental_orders.id AND (p.site_id = ? OR p.party_id = ?))) AND status NOT IN ('cancelled', 'closed')", siteID, siteID, siteID).
Distinct("renter_id").Count(&orderUsersCount).Error
orderUsersQ := r.db.WithContext(ctx).Table("rental_orders ro")
orderUsersQ = r.joinDistributionOrders(orderUsersQ, siteID, "ro")
_ = orderUsersQ.
Where("ro.status NOT IN ('cancelled', 'closed')").
Distinct("ro.renter_id").Count(&orderUsersCount).Error
orderSubquery := r.db.Table("rental_orders").
Select("renter_id, COUNT(*) AS ord_count, COALESCE(SUM(rent_amount_cent), 0) AS ord_spent, MAX(created_at) AS last_ord_at").
Where("(distribution_site_id = ? OR EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = rental_orders.id AND (p.site_id = ? OR p.party_id = ?))) AND status NOT IN ('cancelled', 'closed')", siteID, siteID, siteID).
Group("renter_id")
subq := r.db.Table("rental_orders ro")
subq = r.joinDistributionOrders(subq, siteID, "ro")
orderSubquery := subq.
Select("ro.renter_id, COUNT(*) AS ord_count, COALESCE(SUM(ro.rent_amount_cent), 0) AS ord_spent, MAX(ro.created_at) AS last_ord_at").
Where("ro.status NOT IN ('cancelled', 'closed')").
Group("ro.renter_id")
baseQuery := r.db.WithContext(ctx).Table("users AS u").
Joins("LEFT JOIN (?) AS ord ON ord.renter_id = u.id", orderSubquery)
@@ -1640,3 +1640,151 @@ func TestListDistributionUsersAndAnalytics(t *testing.T) {
analytics.TodayUserCount, analytics.TotalUserCount, analytics.TotalOrderUserCount)
}
}
func TestListOrdersPerformanceAndScoping(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
site1 := model.DistributionSite{SiteName: "站1", SiteCode: "site1", Domain: "s1.test", Status: "active"}
site2 := model.DistributionSite{SiteName: "站2", SiteCode: "site2", Domain: "s2.test", Status: "active"}
if err := db.Create(&site1).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&site2).Error; err != nil {
t.Fatal(err)
}
u := model.User{Phone: "13900000001", Nickname: "买家"}
if err := db.Create(&u).Error; err != nil {
t.Fatal(err)
}
// 1. 普通非分销订单(主站自营,不属于任何分销)
normalOrder := model.RentalOrder{
OrderNo: "RO_NORMAL_001",
RenterID: u.ID,
Status: "completed",
DistributionSiteID: nil,
DistributionMode: "",
}
if err := db.Create(&normalOrder).Error; err != nil {
t.Fatal(err)
}
// 2. 分销站1的订单
site1ID := site1.ID
distOrder1 := model.RentalOrder{
OrderNo: "RO_DIST_001",
RenterID: u.ID,
Status: "completed",
DistributionSiteID: &site1ID,
DistributionMode: "site_self_sell",
}
if err := db.Create(&distOrder1).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.DistributionOrderParty{
OrderID: distOrder1.ID,
SiteID: site1.ID,
PartyRole: "distributor_sell",
PartyType: "site",
PartyID: site1.ID,
}).Error; err != nil {
t.Fatal(err)
}
// 3. 分销站2作为收号方的跨站订单(下单站点为0或主站,但分销站2为参与方)
distOrder2 := model.RentalOrder{
OrderNo: "RO_DIST_002",
RenterID: u.ID,
Status: "renting",
DistributionSiteID: nil,
DistributionMode: "two_party_distributor_receive",
}
if err := db.Create(&distOrder2).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.DistributionOrderParty{
OrderID: distOrder2.ID,
SiteID: site2.ID,
PartyRole: "distributor_receive",
PartyType: "site",
PartyID: site2.ID,
}).Error; err != nil {
t.Fatal(err)
}
// A. 超管查询全部分销订单 (SiteID = 0)
// 预期:只查出 2 笔分销订单,普通主站订单 normalOrder 绝不能混入!
allPage, err := repo.ListOrders(t.Context(), DistributionOrderListQuery{
SiteID: 0,
Page: 1,
Size: 10,
})
if err != nil {
t.Fatalf("ListOrders SiteID=0 failed: %v", err)
}
if allPage.Total != 2 {
t.Fatalf("expected 2 distribution orders for superadmin, got %d (normal order must be excluded)", allPage.Total)
}
if len(allPage.Items) != 2 {
t.Fatalf("expected 2 items, got %d", len(allPage.Items))
}
// B. 分销商1查询其订单 (SiteID = site1.ID)
// 预期:只查出 distOrder1
site1Page, err := repo.ListOrders(t.Context(), DistributionOrderListQuery{
SiteID: site1.ID,
Page: 1,
Size: 10,
})
if err != nil {
t.Fatalf("ListOrders SiteID=1 failed: %v", err)
}
if site1Page.Total != 1 || site1Page.Items[0].OrderID != distOrder1.ID {
t.Fatalf("expected order %d for site1, got total=%d items=%+v", distOrder1.ID, site1Page.Total, site1Page.Items)
}
// C. 分销商2查询其订单 (SiteID = site2.ID)
// 预期:查出 distOrder2(通过参与方命中)
site2Page, err := repo.ListOrders(t.Context(), DistributionOrderListQuery{
SiteID: site2.ID,
Page: 1,
Size: 10,
})
if err != nil {
t.Fatalf("ListOrders SiteID=2 failed: %v", err)
}
if site2Page.Total != 1 || site2Page.Items[0].OrderID != distOrder2.ID {
t.Fatalf("expected order %d for site2, got total=%d items=%+v", distOrder2.ID, site2Page.Total, site2Page.Items)
}
// D. 按 Kind="sold" 过滤
soldPage, err := repo.ListOrders(t.Context(), DistributionOrderListQuery{
SiteID: site1.ID,
Kind: "sold",
Page: 1,
Size: 10,
})
if err != nil {
t.Fatalf("ListOrders Kind=sold failed: %v", err)
}
if soldPage.Total != 1 || soldPage.Items[0].OrderID != distOrder1.ID {
t.Fatalf("expected 1 sold order for site1, got %d", soldPage.Total)
}
// E. 按 Kind="receive" 过滤
recvPage, err := repo.ListOrders(t.Context(), DistributionOrderListQuery{
SiteID: site2.ID,
Kind: "receive",
Page: 1,
Size: 10,
})
if err != nil {
t.Fatalf("ListOrders Kind=receive failed: %v", err)
}
if recvPage.Total != 1 || recvPage.Items[0].OrderID != distOrder2.ID {
t.Fatalf("expected 1 receive order for site2, got %d", recvPage.Total)
}
}