diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index cab9fa4..c63c69c 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -125,6 +125,9 @@ type AdminOrderListItemDTO struct { SettlementMode string `json:"settlement_mode"` SettlementStatus string `json:"settlement_status"` CreatedAt time.Time `json:"created_at"` + // OwnerIncomeAmountCent 为号主实际到手金额(分),仅在已结账/已仲裁订单上返回; + // 用指针区分「未结账(null)」与「到手 0 元」。数据来自结账表最新一行。 + OwnerIncomeAmountCent *int64 `json:"owner_income_amount_cent,omitempty"` } type AdminActionsDTO struct { diff --git a/backend/internal/modules/order/queries.go b/backend/internal/modules/order/queries.go index f8ab578..725aead 100644 --- a/backend/internal/modules/order/queries.go +++ b/backend/internal/modules/order/queries.go @@ -173,9 +173,29 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*Adm } } + // 号主实际收入:按需收集本页行号,一次性批量查询(单条 SQL),避免逐行查询的 N+1。 + ownerIncome := make(map[uint64]int64) + incomeOrderIDs := make([]uint64, 0, len(rows)) + for _, row := range rows { + if adminListItemNeedsOwnerIncome(row) { + incomeOrderIDs = append(incomeOrderIDs, row.ID) + } + } + if len(incomeOrderIDs) > 0 { + ownerIncome, err = r.latestOwnerIncomeByOrder(ctx, incomeOrderIDs) + if err != nil { + return nil, err + } + } + items := make([]AdminOrderListItemDTO, 0, len(rows)) for _, row := range rows { - items = append(items, row.toAdminListItem()) + item := row.toAdminListItem() + if amount, ok := ownerIncome[row.ID]; ok { + value := amount + item.OwnerIncomeAmountCent = &value + } + items = append(items, item) } return &AdminOrderListPaginatedResult{ @@ -218,38 +238,57 @@ func (r *Repository) adminOrderCountQuery(ctx context.Context, query AdminOrderQ return r.adminOrderJoinQuery(ctx) } -// latestAdminCheckouts loads one latest checkout per order so the admin order -// list can display finalized income without issuing one query per row. -func (r *Repository) latestAdminCheckouts(ctx context.Context, rows []orderRow) (map[uint64]model.OrderCheckout, error) { - result := make(map[uint64]model.OrderCheckout, len(rows)) - if len(rows) == 0 { +// latestOwnerIncomeByOrder 批量读取每单「最新一次结账」的号主实际到手金额(分)。 +// +// 后台订单列表专用:一次查询覆盖整页,不会退化为逐行查询的 N+1。 +// 只取结账表两列,不加载 content / evidence_urls 等大字段。 +// +// 语义:多轮还价是原地更新同一行,但订单可能经历多次结账流程, +// 因此按 order_id 取 MAX(id) 作为最终结账行。 +// +// 执行计划(MySQL 8.4 实测,EXPLAIN FORMAT=TREE): +// +// -> Nested loop inner join +// -> Materialize +// -> Group aggregate: max(order_checkouts.id) +// -> Covering index scan on order_checkouts using idx_order_checkouts_order_id +// -> Single-row index lookup on oc using PRIMARY (id=latest.latest_id) +// +// 用 STRAIGHT_JOIN 固定连接顺序:先物化「每单最新结账 id」小集合(走 +// idx_order_checkouts_order_id 覆盖索引),再按主键逐行取金额。避免优化器 +// 在数据量增长后改选全表扫描;开销只与当前页订单数相关,与结账表总量无关。 +func (r *Repository) latestOwnerIncomeByOrder(ctx context.Context, orderIDs []uint64) (map[uint64]int64, error) { + result := make(map[uint64]int64, len(orderIDs)) + if len(orderIDs) == 0 { return result, nil } - orderIDs := make([]uint64, 0, len(rows)) - for _, row := range rows { - orderIDs = append(orderIDs, row.ID) - } latest := r.db.WithContext(ctx). Table("order_checkouts"). - Select("MAX(id) AS id"). + Select("order_id, MAX(id) AS latest_id"). Where("order_id IN ?", orderIDs). Group("order_id") - var checkouts []model.OrderCheckout + var rows []struct { + OrderID uint64 + OwnerIncomeAmountCent int64 + } if err := r.db.WithContext(ctx). - Table("order_checkouts AS oc"). - Joins("JOIN (?) AS latest ON latest.id = oc.id", latest). - Find(&checkouts).Error; err != nil { + Table("(?) AS latest", latest). + Select("oc.order_id, oc.owner_income_amount_cent"). + Joins("STRAIGHT_JOIN order_checkouts AS oc ON oc.id = latest.latest_id"). + Scan(&rows).Error; err != nil { return nil, err } - for _, checkout := range checkouts { - result[checkout.OrderID] = checkout + for _, row := range rows { + result[row.OrderID] = row.OwnerIncomeAmountCent } return result, nil } -func shouldAttachAdminCheckout(row orderRow) bool { - return shouldAttachCheckout(row.Status) || - row.SettlementStatus == settlementStatusSettled || +// adminListItemNeedsOwnerIncome 判断列表行是否需要附带号主实际收入。 +// 仅已结账/已仲裁时库中金额为最终值;协商中的金额会随后续还价变化, +// 不作为列表结论展示(协商中的实时金额走详情接口)。 +func adminListItemNeedsOwnerIncome(row orderRow) bool { + return row.SettlementStatus == settlementStatusSettled || row.SettlementStatus == settlementStatusArbitrated } @@ -415,15 +454,6 @@ func effectiveDisputeInitiatorType(row model.Dispute) string { return "user" } -func shouldAttachCheckout(status string) bool { - switch status { - case orderStatusPendingCheckoutConfirm, orderStatusPendingCheckoutAccept, orderStatusCheckoutDisputing, orderStatusCompleted: - return true - default: - return false - } -} - func (r *Repository) latestCheckoutDTOForUser(ctx context.Context, orderID uint64, userID uint64, order model.RentalOrder) *CheckoutDTO { var checkout model.OrderCheckout if err := r.db.WithContext(ctx).Where("order_id = ?", orderID).Order("id DESC").First(&checkout).Error; err != nil { diff --git a/backend/internal/modules/order/repository_integration_test.go b/backend/internal/modules/order/repository_integration_test.go index f42a148..506aa74 100644 --- a/backend/internal/modules/order/repository_integration_test.go +++ b/backend/internal/modules/order/repository_integration_test.go @@ -3,6 +3,8 @@ package order import ( "context" "errors" + "fmt" + "strings" "testing" "time" @@ -11,6 +13,7 @@ import ( "gorm.io/datatypes" "gorm.io/gorm" + "gorm.io/gorm/logger" ) // setupOrderTestDB 创建订单测试数据库。 @@ -2157,6 +2160,8 @@ func createOpenPlatformCheckout(t *testing.T, db *gorm.DB, order model.RentalOrd return checkout } +// TestListAdminUsesFinalizedCheckoutOwnerIncome 校验后台订单列表带回「号主实际收入」, +// 且金额取自结账表最终行(已结账/已仲裁时才是定论值)。 func TestListAdminUsesFinalizedCheckoutOwnerIncome(t *testing.T) { db := setupOrderTestDB(t) repo := NewRepository(db) @@ -2181,10 +2186,82 @@ func TestListAdminUsesFinalizedCheckoutOwnerIncome(t *testing.T) { if len(result.Items) != 1 { t.Fatalf("ListAdmin() returned %d items, want 1", len(result.Items)) } - // 订单列表已最小化为 AdminOrderListItemDTO,结账明细(含号主到手金额)由订单详情接口返回。 if result.Items[0].SettlementStatus != settlementStatusSettled { t.Fatalf("settlement status = %q, want %q", result.Items[0].SettlementStatus, settlementStatusSettled) } + // 列表需直接展示号主实际收入,供客服在列表页核对结算。 + income := result.Items[0].OwnerIncomeAmountCent + if income == nil { + t.Fatalf("owner income = nil, want 6000 for settled order") + } + if *income != 6000 { + t.Fatalf("owner income = %d, want 6000", *income) + } +} + +// TestListAdminOmitsOwnerIncomeWhileNegotiating 校验协商中的订单不返回号主实际收入: +// 此时金额还会随还价变化,不能作为列表结论展示(未结账与「到手 0 元」必须可区分)。 +func TestListAdminOmitsOwnerIncomeWhileNegotiating(t *testing.T) { + db := setupOrderTestDB(t) + repo := NewRepository(db) + adminID := uint64(7003) + _, renter, order := createPlatformManagedCheckoutOrder(t, db, adminID) + checkout := createOpenPlatformCheckout(t, db, order, renter.ID) + checkout.OwnerIncomeAmountCent = 5000 + if err := db.Save(&checkout).Error; err != nil { + t.Fatalf("save negotiating checkout failed: %v", err) + } + + result, err := repo.ListAdmin(t.Context(), AdminOrderQuery{Page: 1, PageSize: 20}) + if err != nil { + t.Fatalf("ListAdmin() error = %v", err) + } + if len(result.Items) != 1 { + t.Fatalf("ListAdmin() returned %d items, want 1", len(result.Items)) + } + if result.Items[0].SettlementStatus == settlementStatusSettled { + t.Fatalf("precondition failed: order should not be settled") + } + if result.Items[0].OwnerIncomeAmountCent != nil { + t.Fatalf("owner income = %d, want nil while checkout is still negotiating", *result.Items[0].OwnerIncomeAmountCent) + } +} + +// TestListAdminOwnerIncomeUsesLatestCheckout 校验一单存在多行结账记录时取最新一行(MAX(id))。 +func TestListAdminOwnerIncomeUsesLatestCheckout(t *testing.T) { + db := setupOrderTestDB(t) + repo := NewRepository(db) + adminID := uint64(7004) + _, renter, order := createPlatformManagedCheckoutOrder(t, db, adminID) + order.Status = orderStatusCompleted + order.SettlementStatus = settlementStatusSettled + if err := db.Save(&order).Error; err != nil { + t.Fatalf("save completed order failed: %v", err) + } + first := createOpenPlatformCheckout(t, db, order, renter.ID) + first.Status = checkoutStatusAccepted + first.OwnerIncomeAmountCent = 1111 + if err := db.Save(&first).Error; err != nil { + t.Fatalf("save first checkout failed: %v", err) + } + second := createOpenPlatformCheckout(t, db, order, renter.ID) + second.Status = checkoutStatusAccepted + second.OwnerIncomeAmountCent = 2222 + if err := db.Save(&second).Error; err != nil { + t.Fatalf("save second checkout failed: %v", err) + } + + result, err := repo.ListAdmin(t.Context(), AdminOrderQuery{Page: 1, PageSize: 20}) + if err != nil { + t.Fatalf("ListAdmin() error = %v", err) + } + if len(result.Items) != 1 { + t.Fatalf("ListAdmin() returned %d items, want 1", len(result.Items)) + } + income := result.Items[0].OwnerIncomeAmountCent + if income == nil || *income != 2222 { + t.Fatalf("owner income = %v, want 2222 from latest checkout row", income) + } } func TestAdminPlatformCheckoutConfirmRequiresExplicitConfirmationForZeroOwnerIncome(t *testing.T) { @@ -2385,3 +2462,166 @@ func TestListForUserAndFindForUserSiteIsolation(t *testing.T) { t.Fatalf("expected PendingHandoff metric = 1, got %d", handoffList.Metrics.PendingHandoff) } } + +// countingQueryLogger 统计实际执行的 SQL 条数,用于断言查询不随页内行数增长(防 N+1)。 +type countingQueryLogger struct { + logger.Interface + queries int +} + +func (c *countingQueryLogger) Trace(context.Context, time.Time, func() (string, int64), error) { + c.queries++ +} + +// seedSettledAdminOrders 造 [from, to) 区间的「已结账」订单,供列表分页与 N+1 断言使用。 +// 每单独立 owner/renter(users.phone 唯一,故用序号区分),结账金额为 1000+i,便于逐行核对。 +func seedSettledAdminOrders(t *testing.T, db *gorm.DB, from, to int) { + t.Helper() + adminID := uint64(7099) + for i := from; i < to; i++ { + owner := model.User{Phone: fmt.Sprintf("1390001%04d", i)} + renter := model.User{Phone: fmt.Sprintf("1390002%04d", i)} + if err := db.Create(&owner).Error; err != nil { + t.Fatalf("create owner %d failed: %v", i, err) + } + if err := db.Create(&renter).Error; err != nil { + t.Fatalf("create renter %d failed: %v", i, err) + } + account := model.GameAccount{ + OwnerID: owner.ID, + Status: accountStatusRented, + ServerRegion: "国服", + LoginPlatform: "steam", + Title: fmt.Sprintf("压测账号%d", i), + } + if err := db.Create(&account).Error; err != nil { + t.Fatalf("create account %d failed: %v", i, err) + } + listing := model.RentalListing{ + ListingNo: fmt.Sprintf("LST-N1-%04d", i), + OwnerID: owner.ID, + AccountID: account.ID, + Status: listingStatusRented, + ReviewStatus: listingReviewStatusApproved, + InTransaction: true, + PriceCent: 10000, + HandoffMode: handoffModePlatform, + SettlementMode: settlementModePlatformManaged, + ManagedAdminID: &adminID, + } + if err := db.Create(&listing).Error; err != nil { + t.Fatalf("create listing %d failed: %v", i, err) + } + order := model.RentalOrder{ + OrderNo: fmt.Sprintf("ORD-N1-%04d", i), + ListingID: listing.ID, + AccountID: account.ID, + OwnerID: owner.ID, + RenterID: renter.ID, + Status: orderStatusCompleted, + SettlementStatus: settlementStatusSettled, + HandoffMode: handoffModePlatform, + SettlementMode: settlementModePlatformManaged, + ManagedAdminID: &adminID, + RentAmountCent: 10000, + OwnerRentAmountCent: 8000, + PlatformFeeCent: 2000, + DepositAmountCent: 1000, + EstimatedDurationHours: 24, + OfflineSettlementStatus: offlineSettlementStatusNone, + AccountSnapshot: datatypes.JSON([]byte(`{"haf_coin_amount":1000000}`)), + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("create order %d failed: %v", i, err) + } + checkout := model.OrderCheckout{ + OrderID: order.ID, + InitiatedBy: renter.ID, + Status: checkoutStatusAccepted, + RoundCount: 1, + Turn: checkoutTurnOwner, + ProposedBy: renter.ID, + OwnerIncomeAmountCent: int64(1000 + i), + } + if err := db.Create(&checkout).Error; err != nil { + t.Fatalf("create checkout %d failed: %v", i, err) + } + } +} + +// TestListAdminOwnerIncomeDoesNotScaleQueriesWithRows 断言「号主实际收入」是整页一次批量查询: +// 同一页内订单数从 1 增到 5,SQL 条数必须持平;若退化为逐行查询,条数会随行数增长。 +func TestListAdminOwnerIncomeDoesNotScaleQueriesWithRows(t *testing.T) { + db := setupOrderTestDB(t) + seedSettledAdminOrders(t, db, 0, 1) + + counter := &countingQueryLogger{Interface: logger.Default.LogMode(logger.Silent)} + repo := NewRepository(db.Session(&gorm.Session{Logger: counter})) + + runOnce := func(wantItems int) int { + counter.queries = 0 + result, err := repo.ListAdmin(t.Context(), AdminOrderQuery{Page: 1, PageSize: 20}) + if err != nil { + t.Fatalf("ListAdmin() error = %v", err) + } + if len(result.Items) != wantItems { + t.Fatalf("ListAdmin() returned %d items, want %d", len(result.Items), wantItems) + } + for _, item := range result.Items { + if item.OwnerIncomeAmountCent == nil { + t.Fatalf("order %d: owner income nil, want settled amount", item.ID) + } + } + return counter.queries + } + + withOneRow := runOnce(1) + + // 追加 4 单(共 5 单,仍在一页内且不足 pageSize,COUNT 被首页优化跳过)。 + seedSettledAdminOrders(t, db, 1, 5) + withFiveRows := runOnce(5) + + if withOneRow <= 0 { + t.Fatalf("查询计数未生效(计数为 %d),断言会空过", withOneRow) + } + if withFiveRows != withOneRow { + t.Fatalf("列表 SQL 条数随行数增长(疑似 N+1):1 单=%d 条,5 单=%d 条", withOneRow, withFiveRows) + } +} + +// sqlCaptureLogger 记录执行过的 SQL 文本,用于断言语句形态。 +type sqlCaptureLogger struct { + logger.Interface + statements []string +} + +func (c *sqlCaptureLogger) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) { + sql, _ := fc() + c.statements = append(c.statements, sql) +} + +// TestLatestOwnerIncomeQueryIsSingleBatchStatement 锁定号主实际收入的取数形态: +// 整页一条语句、用 STRAIGHT_JOIN 固定「先物化最新结账 id,再按主键取金额」的顺序。 +// 该形态对应 EXPLAIN:覆盖索引扫描 + 主键单行查找,不随行数产生额外查询。 +func TestLatestOwnerIncomeQueryIsSingleBatchStatement(t *testing.T) { + capture := &sqlCaptureLogger{Interface: logger.Default.LogMode(logger.Silent)} + db := setupOrderTestDB(t).Session(&gorm.Session{Logger: capture}) + repo := NewRepository(db) + + if _, err := repo.latestOwnerIncomeByOrder(t.Context(), []uint64{11, 12, 13}); err != nil { + t.Fatalf("latestOwnerIncomeByOrder() error = %v", err) + } + if len(capture.statements) != 1 { + t.Fatalf("取数语句条数 = %d, want 1(多行必须合并为单条批量查询)", len(capture.statements)) + } + sql := capture.statements[0] + if !strings.Contains(sql, "STRAIGHT_JOIN order_checkouts") { + t.Fatalf("缺少 STRAIGHT_JOIN 固定连接顺序,实际 SQL:\n%s", sql) + } + if !strings.Contains(sql, "GROUP BY") { + t.Fatalf("缺少按 order_id 分组的 MAX(id) 子查询,实际 SQL:\n%s", sql) + } + if strings.Contains(sql, "SELECT *") { + t.Fatalf("应只取所需列,避免读取 content/evidence_urls 大字段,实际 SQL:\n%s", sql) + } +} diff --git a/frontend/src/features/admin/views/AdminOrdersView.vue b/frontend/src/features/admin/views/AdminOrdersView.vue index 15c4dd3..fe358f0 100644 --- a/frontend/src/features/admin/views/AdminOrdersView.vue +++ b/frontend/src/features/admin/views/AdminOrdersView.vue @@ -164,6 +164,11 @@ function userText(value: string | number | undefined) { function isPlatformManaged(row: AdminOrderListItem) { return row.handoff_mode === 'platform' || row.settlement_mode === 'platform_managed' } + +function ownerActualIncome(row: AdminOrderListItem) { + const value = row.owner_income_amount_cent + return typeof value === 'number' ? value : null +}