diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index a16aaed..e4f9a81 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -129,7 +129,8 @@ type AdminOrderListItemDTO struct { SettlementStatus string `json:"settlement_status"` CreatedAt time.Time `json:"created_at"` // OwnerIncomeAmountCent 为号主实际到手金额(分),仅在已结账/已仲裁订单上返回; - // 用指针区分「未结账(null)」与「到手 0 元」。数据来自结账表最新一行。 + // 用指针区分「未结账(null)」与「到手 0 元」。平台代管订单取线下结算金额, + // 其他订单取号主钱包最终入账合计。 OwnerIncomeAmountCent *int64 `json:"owner_income_amount_cent,omitempty"` } diff --git a/backend/internal/modules/order/queries.go b/backend/internal/modules/order/queries.go index 6e3fb73..4edf154 100644 --- a/backend/internal/modules/order/queries.go +++ b/backend/internal/modules/order/queries.go @@ -199,7 +199,7 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*Adm } } if len(incomeOrderIDs) > 0 { - ownerIncome, err = r.latestOwnerIncomeByOrder(ctx, incomeOrderIDs) + ownerIncome, err = r.ownerActualIncomeByOrder(ctx, incomeOrderIDs) if err != nil { return nil, err } @@ -255,48 +255,42 @@ func (r *Repository) adminOrderCountQuery(ctx context.Context, query AdminOrderQ return r.adminOrderJoinQuery(ctx) } -// latestOwnerIncomeByOrder 批量读取每单「最新一次结账」的号主实际到手金额(分)。 +// ownerActualIncomeByOrder 批量读取每单号主最终实际结算金额(分)。 // -// 后台订单列表专用:一次查询覆盖整页,不会退化为逐行查询的 N+1。 -// 只取结账表两列,不加载 content / evidence_urls 等大字段。 +// 结账行只能代表协商结果,不能代表仲裁后的最终落账: +// - 平台代管订单:最终金额写入 rental_orders.offline_settlement_amount_cent; +// - 普通钱包订单:最终金额以 wallet_ledger 中号主的实际入账合计为准。 // -// 语义:多轮还价是原地更新同一行,但订单可能经历多次结账流程, -// 因此按 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) { +// 仲裁可能不更新历史结账行,甚至 dispute.checkout_id 为空,因此不能继续取 +// order_checkouts 的 owner_income_amount_cent,否则列表会显示仲裁前金额。 +func (r *Repository) ownerActualIncomeByOrder(ctx context.Context, orderIDs []uint64) (map[uint64]int64, error) { result := make(map[uint64]int64, len(orderIDs)) if len(orderIDs) == 0 { return result, nil } - latest := r.db.WithContext(ctx). - Table("order_checkouts"). - Select("order_id, MAX(id) AS latest_id"). - Where("order_id IN ?", orderIDs). - Group("order_id") var rows []struct { - OrderID uint64 + ID uint64 OwnerIncomeAmountCent int64 } if err := r.db.WithContext(ctx). - 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"). + Table("rental_orders AS o"). + Select(`o.id, + CASE WHEN o.settlement_mode = 'platform_managed' + THEN COALESCE(o.offline_settlement_amount_cent, 0) + ELSE COALESCE(SUM(wl.amount_cent), 0) + END AS owner_income_amount_cent`). + Joins(`LEFT JOIN wallet_ledger AS wl + ON wl.order_id = o.id + AND wl.user_id = o.owner_id + AND wl.direction = 'in' + AND wl.biz_type IN ?`, []string{walletBizOwnerIncome, walletBizDepositCompensation, "arbitration_owner_income"}). + Where("o.id IN ?", orderIDs). + Group("o.id, o.settlement_mode, o.offline_settlement_amount_cent"). Scan(&rows).Error; err != nil { return nil, err } for _, row := range rows { - result[row.OrderID] = row.OwnerIncomeAmountCent + result[row.ID] = row.OwnerIncomeAmountCent } return result, nil } diff --git a/backend/internal/modules/order/repository_integration_test.go b/backend/internal/modules/order/repository_integration_test.go index 0f7e6d7..c26b514 100644 --- a/backend/internal/modules/order/repository_integration_test.go +++ b/backend/internal/modules/order/repository_integration_test.go @@ -2160,23 +2160,25 @@ func createOpenPlatformCheckout(t *testing.T, db *gorm.DB, order model.RentalOrd return checkout } -// TestListAdminUsesFinalizedCheckoutOwnerIncome 校验后台订单列表带回「号主实际收入」, -// 且金额取自结账表最终行(已结账/已仲裁时才是定论值)。 -func TestListAdminUsesFinalizedCheckoutOwnerIncome(t *testing.T) { +// TestListAdminPlatformManagedArbitrationUsesOfflineSettlement 校验平台代管订单仲裁后 +// 列表读取线下实际结算金额,不能继续展示旧结账行中的金额。 +func TestListAdminPlatformManagedArbitrationUsesOfflineSettlement(t *testing.T) { db := setupOrderTestDB(t) repo := NewRepository(db) adminID := uint64(7002) _, renter, order := createPlatformManagedCheckoutOrder(t, db, adminID) order.Status = orderStatusCompleted - order.SettlementStatus = settlementStatusSettled + order.SettlementStatus = settlementStatusArbitrated + order.OfflineSettlementStatus = offlineSettlementStatusSettled + order.OfflineSettlementAmountCent = 382 if err := db.Save(&order).Error; err != nil { - t.Fatalf("save completed order failed: %v", err) + t.Fatalf("save arbitrated order failed: %v", err) } checkout := createOpenPlatformCheckout(t, db, order, renter.ID) - checkout.Status = checkoutStatusAccepted + // 模拟仲裁前遗留的结账金额,列表不能把它当成最终收入。 checkout.OwnerIncomeAmountCent = 6000 if err := db.Save(&checkout).Error; err != nil { - t.Fatalf("save finalized checkout failed: %v", err) + t.Fatalf("save stale checkout failed: %v", err) } result, err := repo.ListAdmin(t.Context(), AdminOrderQuery{Page: 1, PageSize: 20}) @@ -2186,16 +2188,15 @@ func TestListAdminUsesFinalizedCheckoutOwnerIncome(t *testing.T) { if len(result.Items) != 1 { t.Fatalf("ListAdmin() returned %d items, want 1", len(result.Items)) } - if result.Items[0].SettlementStatus != settlementStatusSettled { - t.Fatalf("settlement status = %q, want %q", result.Items[0].SettlementStatus, settlementStatusSettled) + if result.Items[0].SettlementStatus != settlementStatusArbitrated { + t.Fatalf("settlement status = %q, want %q", result.Items[0].SettlementStatus, settlementStatusArbitrated) } - // 列表需直接展示号主实际收入,供客服在列表页核对结算。 income := result.Items[0].OwnerIncomeAmountCent if income == nil { - t.Fatalf("owner income = nil, want 6000 for settled order") + t.Fatalf("owner income = nil, want 382 for platform-managed arbitrated order") } - if *income != 6000 { - t.Fatalf("owner income = %d, want 6000", *income) + if *income != 382 { + t.Fatalf("owner income = %d, want 382", *income) } } @@ -2227,28 +2228,54 @@ func TestListAdminOmitsOwnerIncomeWhileNegotiating(t *testing.T) { } } -// TestListAdminOwnerIncomeUsesLatestCheckout 校验一单存在多行结账记录时取最新一行(MAX(id))。 -func TestListAdminOwnerIncomeUsesLatestCheckout(t *testing.T) { +// TestListAdminWalletArbitrationUsesWalletIncome 校验普通钱包订单仲裁后列表汇总 +// 号主实际钱包入账,而不是读取仲裁前遗留的结账金额。 +func TestListAdminWalletArbitrationUsesWalletIncome(t *testing.T) { db := setupOrderTestDB(t) repo := NewRepository(db) adminID := uint64(7004) - _, renter, order := createPlatformManagedCheckoutOrder(t, db, adminID) + owner, renter, order := createPlatformManagedCheckoutOrder(t, db, adminID) order.Status = orderStatusCompleted - order.SettlementStatus = settlementStatusSettled + order.SettlementStatus = settlementStatusArbitrated + order.SettlementMode = settlementModeOwnerWallet + order.OfflineSettlementStatus = offlineSettlementStatusNone + order.OfflineSettlementAmountCent = 0 if err := db.Save(&order).Error; err != nil { - t.Fatalf("save completed order failed: %v", err) + t.Fatalf("save arbitrated 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) + checkout := createOpenPlatformCheckout(t, db, order, renter.ID) + checkout.OwnerIncomeAmountCent = 56013 + if err := db.Save(&checkout).Error; err != nil { + t.Fatalf("save stale 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) + + orderID := order.ID + ledgers := []model.WalletLedger{ + { + LedgerNo: "WL-ARBITRATION-OWNER", + UserID: owner.ID, + OrderID: &orderID, + Direction: "in", + AmountCent: 38613, + BalanceType: "available", + BizType: "arbitration_owner_income", + BizNo: order.OrderNo, + Remark: "仲裁按实际消耗结算给号主", + }, + { + LedgerNo: "WL-ARBITRATION-OTHER-USER", + UserID: renter.ID, + OrderID: &orderID, + Direction: "in", + AmountCent: 999999, + BalanceType: "available", + BizType: "arbitration_owner_income", + BizNo: order.OrderNo, + Remark: "不应计入号主收入", + }, + } + if err := db.Create(&ledgers).Error; err != nil { + t.Fatalf("create wallet ledgers failed: %v", err) } result, err := repo.ListAdmin(t.Context(), AdminOrderQuery{Page: 1, PageSize: 20}) @@ -2259,8 +2286,36 @@ func TestListAdminOwnerIncomeUsesLatestCheckout(t *testing.T) { 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) + if income == nil || *income != 38613 { + t.Fatalf("owner income = %v, want 38613 from arbitration wallet income", income) + } +} + +// TestListAdminArbitratedWithoutPayoutReturnsZero 校验仲裁已经形成终态但无实际入账时, +// 列表返回明确的 0 元,而不是因为缺少结账行显示为未知(null)。 +func TestListAdminArbitratedWithoutPayoutReturnsZero(t *testing.T) { + db := setupOrderTestDB(t) + repo := NewRepository(db) + adminID := uint64(7005) + _, _, order := createPlatformManagedCheckoutOrder(t, db, adminID) + order.Status = orderStatusClosed + order.SettlementStatus = settlementStatusArbitrated + order.OfflineSettlementStatus = offlineSettlementStatusNone + order.OfflineSettlementAmountCent = 0 + if err := db.Save(&order).Error; err != nil { + t.Fatalf("save arbitrated order 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 != 0 { + t.Fatalf("owner income = %v, want explicit zero for arbitrated order without payout", income) } } @@ -2513,23 +2568,24 @@ func seedSettledAdminOrders(t *testing.T, db *gorm.DB, from, to int) { 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}`)), + 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, + OfflineSettlementAmountCent: int64(1000 + i), + 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) @@ -2600,26 +2656,28 @@ func (c *sqlCaptureLogger) Trace(_ context.Context, _ time.Time, fc func() (stri c.statements = append(c.statements, sql) } -// TestLatestOwnerIncomeQueryIsSingleBatchStatement 锁定号主实际收入的取数形态: -// 整页一条语句、用 STRAIGHT_JOIN 固定「先物化最新结账 id,再按主键取金额」的顺序。 -// 该形态对应 EXPLAIN:覆盖索引扫描 + 主键单行查找,不随行数产生额外查询。 -func TestLatestOwnerIncomeQueryIsSingleBatchStatement(t *testing.T) { +// TestOwnerActualIncomeQueryIsSingleBatchStatement 锁定号主实际收入的取数形态: +// 整页一条语句,平台代管读取线下结算金额,其他订单汇总钱包最终入账。 +func TestOwnerActualIncomeQueryIsSingleBatchStatement(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 _, err := repo.ownerActualIncomeByOrder(t.Context(), []uint64{11, 12, 13}); err != nil { + t.Fatalf("ownerActualIncomeByOrder() 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, "LEFT JOIN wallet_ledger AS wl") { + t.Fatalf("缺少钱包入账汇总,实际 SQL:\n%s", sql) + } + if !strings.Contains(sql, "offline_settlement_amount_cent") { + t.Fatalf("缺少平台代管线下结算金额,实际 SQL:\n%s", sql) } if !strings.Contains(sql, "GROUP BY") { - t.Fatalf("缺少按 order_id 分组的 MAX(id) 子查询,实际 SQL:\n%s", sql) + t.Fatalf("缺少按订单分组汇总,实际 SQL:\n%s", sql) } if strings.Contains(sql, "SELECT *") { t.Fatalf("应只取所需列,避免读取 content/evidence_urls 大字段,实际 SQL:\n%s", sql) diff --git a/scripts/check-sql-guard.sh b/scripts/check-sql-guard.sh index 86c5ab7..5874ea9 100755 --- a/scripts/check-sql-guard.sh +++ b/scripts/check-sql-guard.sh @@ -42,6 +42,8 @@ require_pattern "backend/internal/modules/pickup/repository.go" "if pageSize > 1 require_pattern "backend/internal/modules/order/queries.go" "o.order_no = ?" "订单列表订单号唯一索引直达" require_pattern "backend/internal/modules/order/queries.go" "l.listing_no = ?" "订单列表商品号唯一索引直达" require_pattern "backend/internal/modules/order/queries.go" "o.owner_id = (SELECT id FROM users WHERE phone = ?)" "订单列表手机号解析用户索引直达" +require_pattern "backend/internal/modules/order/queries.go" "LEFT JOIN wallet_ledger AS wl" "订单列表号主实际收入取最终钱包入账" +require_pattern "backend/internal/modules/order/queries.go" "offline_settlement_amount_cent" "订单列表平台代管收入取线下结算金额" require_pattern "backend/internal/modules/order/queries.go" "page == 1 && len(rows) < pageSize" "订单列表首页面单扫描短路" require_pattern "backend/internal/modules/pickup/repository.go" "p.pickup_no = ?" "提号列表提号单号唯一索引直达"