feat(admin-order): 订单管理列表恢复号主实际收入列

在金额左侧展示已结账/已仲裁订单的到手金额;整页一条批量 SQL 取最新结账行,避免 N+1。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-09-17 18:13:36 +08:00
co-authored by Cursor
parent 54985b7c36
commit ff8c35ebad
5 changed files with 318 additions and 30 deletions
+3
View File
@@ -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 {
+59 -29
View File
@@ -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 {
@@ -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)
}
}
@@ -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
}
</script>
<template>
@@ -323,6 +328,14 @@ function isPlatformManaged(row: AdminOrderListItem) {
<el-table-column label="结算" width="110">
<template #default="{ row }">{{ settlementStatusLabel(row.settlement_status) }}</template>
</el-table-column>
<el-table-column label="号主实际收入" width="130" align="right">
<template #default="{ row }">
<strong v-if="ownerActualIncome(row) !== null" class="owner-income-cell">
¥{{ money(amountYuan(ownerActualIncome(row))) }}
</strong>
<span v-else class="owner-income-empty">-</span>
</template>
</el-table-column>
<el-table-column label="金额" width="130" align="right">
<template #default="{ row }">
<div class="stacked-cell money-cell">
@@ -117,6 +117,8 @@ export interface AdminOrderListItem {
settlement_mode: string
settlement_status: SettlementStatus
created_at: string
// 已结账/已仲裁时返回号主实际到手金额(分);未结账为 undefined,与到手 0 元可区分。
owner_income_amount_cent?: number
}
export interface SellerHandoffMetrics {