feat(admin-order): 订单管理列表恢复号主实际收入列
在金额左侧展示已结账/已仲裁订单的到手金额;整页一条批量 SQL 取最新结账行,避免 N+1。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -125,6 +125,9 @@ type AdminOrderListItemDTO struct {
|
|||||||
SettlementMode string `json:"settlement_mode"`
|
SettlementMode string `json:"settlement_mode"`
|
||||||
SettlementStatus string `json:"settlement_status"`
|
SettlementStatus string `json:"settlement_status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
// OwnerIncomeAmountCent 为号主实际到手金额(分),仅在已结账/已仲裁订单上返回;
|
||||||
|
// 用指针区分「未结账(null)」与「到手 0 元」。数据来自结账表最新一行。
|
||||||
|
OwnerIncomeAmountCent *int64 `json:"owner_income_amount_cent,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminActionsDTO struct {
|
type AdminActionsDTO struct {
|
||||||
|
|||||||
@@ -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))
|
items := make([]AdminOrderListItemDTO, 0, len(rows))
|
||||||
for _, row := range 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{
|
return &AdminOrderListPaginatedResult{
|
||||||
@@ -218,38 +238,57 @@ func (r *Repository) adminOrderCountQuery(ctx context.Context, query AdminOrderQ
|
|||||||
return r.adminOrderJoinQuery(ctx)
|
return r.adminOrderJoinQuery(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// latestAdminCheckouts loads one latest checkout per order so the admin order
|
// latestOwnerIncomeByOrder 批量读取每单「最新一次结账」的号主实际到手金额(分)。
|
||||||
// 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) {
|
// 后台订单列表专用:一次查询覆盖整页,不会退化为逐行查询的 N+1。
|
||||||
result := make(map[uint64]model.OrderCheckout, len(rows))
|
// 只取结账表两列,不加载 content / evidence_urls 等大字段。
|
||||||
if len(rows) == 0 {
|
//
|
||||||
|
// 语义:多轮还价是原地更新同一行,但订单可能经历多次结账流程,
|
||||||
|
// 因此按 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
|
return result, nil
|
||||||
}
|
}
|
||||||
orderIDs := make([]uint64, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
orderIDs = append(orderIDs, row.ID)
|
|
||||||
}
|
|
||||||
latest := r.db.WithContext(ctx).
|
latest := r.db.WithContext(ctx).
|
||||||
Table("order_checkouts").
|
Table("order_checkouts").
|
||||||
Select("MAX(id) AS id").
|
Select("order_id, MAX(id) AS latest_id").
|
||||||
Where("order_id IN ?", orderIDs).
|
Where("order_id IN ?", orderIDs).
|
||||||
Group("order_id")
|
Group("order_id")
|
||||||
var checkouts []model.OrderCheckout
|
var rows []struct {
|
||||||
|
OrderID uint64
|
||||||
|
OwnerIncomeAmountCent int64
|
||||||
|
}
|
||||||
if err := r.db.WithContext(ctx).
|
if err := r.db.WithContext(ctx).
|
||||||
Table("order_checkouts AS oc").
|
Table("(?) AS latest", latest).
|
||||||
Joins("JOIN (?) AS latest ON latest.id = oc.id", latest).
|
Select("oc.order_id, oc.owner_income_amount_cent").
|
||||||
Find(&checkouts).Error; err != nil {
|
Joins("STRAIGHT_JOIN order_checkouts AS oc ON oc.id = latest.latest_id").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, checkout := range checkouts {
|
for _, row := range rows {
|
||||||
result[checkout.OrderID] = checkout
|
result[row.OrderID] = row.OwnerIncomeAmountCent
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldAttachAdminCheckout(row orderRow) bool {
|
// adminListItemNeedsOwnerIncome 判断列表行是否需要附带号主实际收入。
|
||||||
return shouldAttachCheckout(row.Status) ||
|
// 仅已结账/已仲裁时库中金额为最终值;协商中的金额会随后续还价变化,
|
||||||
row.SettlementStatus == settlementStatusSettled ||
|
// 不作为列表结论展示(协商中的实时金额走详情接口)。
|
||||||
|
func adminListItemNeedsOwnerIncome(row orderRow) bool {
|
||||||
|
return row.SettlementStatus == settlementStatusSettled ||
|
||||||
row.SettlementStatus == settlementStatusArbitrated
|
row.SettlementStatus == settlementStatusArbitrated
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,15 +454,6 @@ func effectiveDisputeInitiatorType(row model.Dispute) string {
|
|||||||
return "user"
|
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 {
|
func (r *Repository) latestCheckoutDTOForUser(ctx context.Context, orderID uint64, userID uint64, order model.RentalOrder) *CheckoutDTO {
|
||||||
var checkout model.OrderCheckout
|
var checkout model.OrderCheckout
|
||||||
if err := r.db.WithContext(ctx).Where("order_id = ?", orderID).Order("id DESC").First(&checkout).Error; err != nil {
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -11,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"gorm.io/datatypes"
|
"gorm.io/datatypes"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// setupOrderTestDB 创建订单测试数据库。
|
// setupOrderTestDB 创建订单测试数据库。
|
||||||
@@ -2157,6 +2160,8 @@ func createOpenPlatformCheckout(t *testing.T, db *gorm.DB, order model.RentalOrd
|
|||||||
return checkout
|
return checkout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestListAdminUsesFinalizedCheckoutOwnerIncome 校验后台订单列表带回「号主实际收入」,
|
||||||
|
// 且金额取自结账表最终行(已结账/已仲裁时才是定论值)。
|
||||||
func TestListAdminUsesFinalizedCheckoutOwnerIncome(t *testing.T) {
|
func TestListAdminUsesFinalizedCheckoutOwnerIncome(t *testing.T) {
|
||||||
db := setupOrderTestDB(t)
|
db := setupOrderTestDB(t)
|
||||||
repo := NewRepository(db)
|
repo := NewRepository(db)
|
||||||
@@ -2181,10 +2186,82 @@ func TestListAdminUsesFinalizedCheckoutOwnerIncome(t *testing.T) {
|
|||||||
if len(result.Items) != 1 {
|
if len(result.Items) != 1 {
|
||||||
t.Fatalf("ListAdmin() returned %d items, want 1", len(result.Items))
|
t.Fatalf("ListAdmin() returned %d items, want 1", len(result.Items))
|
||||||
}
|
}
|
||||||
// 订单列表已最小化为 AdminOrderListItemDTO,结账明细(含号主到手金额)由订单详情接口返回。
|
|
||||||
if result.Items[0].SettlementStatus != settlementStatusSettled {
|
if result.Items[0].SettlementStatus != settlementStatusSettled {
|
||||||
t.Fatalf("settlement status = %q, want %q", 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) {
|
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)
|
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) {
|
function isPlatformManaged(row: AdminOrderListItem) {
|
||||||
return row.handoff_mode === 'platform' || row.settlement_mode === 'platform_managed'
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -323,6 +328,14 @@ function isPlatformManaged(row: AdminOrderListItem) {
|
|||||||
<el-table-column label="结算" width="110">
|
<el-table-column label="结算" width="110">
|
||||||
<template #default="{ row }">{{ settlementStatusLabel(row.settlement_status) }}</template>
|
<template #default="{ row }">{{ settlementStatusLabel(row.settlement_status) }}</template>
|
||||||
</el-table-column>
|
</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">
|
<el-table-column label="金额" width="130" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="stacked-cell money-cell">
|
<div class="stacked-cell money-cell">
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ export interface AdminOrderListItem {
|
|||||||
settlement_mode: string
|
settlement_mode: string
|
||||||
settlement_status: SettlementStatus
|
settlement_status: SettlementStatus
|
||||||
created_at: string
|
created_at: string
|
||||||
|
// 已结账/已仲裁时返回号主实际到手金额(分);未结账为 undefined,与到手 0 元可区分。
|
||||||
|
owner_income_amount_cent?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SellerHandoffMetrics {
|
export interface SellerHandoffMetrics {
|
||||||
|
|||||||
Reference in New Issue
Block a user