diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index 0b8e07c..73eabbb 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -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) diff --git a/backend/internal/modules/distribution/repository_test.go b/backend/internal/modules/distribution/repository_test.go index c54c0c2..4b983c4 100644 --- a/backend/internal/modules/distribution/repository_test.go +++ b/backend/internal/modules/distribution/repository_test.go @@ -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) + } +} + diff --git a/backend/migrations/000083_distribution_order_parties_site_order_idx.sql b/backend/migrations/000083_distribution_order_parties_site_order_idx.sql new file mode 100644 index 0000000..98e9c49 --- /dev/null +++ b/backend/migrations/000083_distribution_order_parties_site_order_idx.sql @@ -0,0 +1,9 @@ +-- +goose Up + +ALTER TABLE distribution_order_parties + ADD KEY idx_distribution_order_parties_site_order (site_id, order_id); + +-- +goose Down + +ALTER TABLE distribution_order_parties + DROP KEY idx_distribution_order_parties_site_order;