fix(admin): 超管看不到下游订单——新增平台侧跨商户订单视图

问题:管理员首页 Dashboard 显示平台总量(37 单,跨全部商户),点进「发货订单」
却只有 29 单,两个数字互相矛盾且界面上没有任何解释。

根因是「平台管理员」在两套接口里语义不同:
- /api/dashboard:isPlatformAdmin 只看 JWT 角色 → 平台视角,跨商户聚合
- /api/merchant/*:Tenant 中间件强制落到单个 merchant_id → 商户成员视角。
  管理员唯一的成员关系是自营商户,于是被静默框定在商户 1
且 34 条 /platform/* 路由中没有任何订单入口,前端也没有商户切换器或上下文提示。

修复(不改动商户侧隔离语义):
- 新增 GET /api/platform/orders,support 按商户/状态/来源/关键字筛选,缺省为全部商户
- Service 抽出共用的 orderFilters 集中校验,ListOrders 与 ListPlatformOrders 并行;
  ListOrders 仍强制单商户,避免新增平台路径时顺手放宽商户侧可见范围
- FulfillmentOrder 增加 Merchant 关联字段,仅平台侧 Preload,商户侧响应结构不变
- 前端新增「下游订单(全部商户)」页与菜单项,「发货订单」更名为「发货订单(当前商户)」
- README 补充两个订单入口的说明,避免后来者重踩

验证:单测覆盖跨商户可见性、按商户筛选、筛选校验、以及商户侧隔离不回归;
实测新接口返回 37 单(自营 29 + 测试商户 8)且带商户信息,商户侧仍为 29 单且无 merchant 字段。
This commit is contained in:
yml2213
2026-10-06 20:28:41 +08:00
parent 97948e2ca6
commit 8c12d1d448
11 changed files with 522 additions and 14 deletions
+9
View File
@@ -19,6 +19,7 @@ const MerchantCenter = lazy(() => import('./pages/MerchantCenter'))
const MerchantRecharge = lazy(() => import('./pages/MerchantRecharge'))
const AlertSettings = lazy(() => import('./pages/AlertSettings'))
const PlatformMerchants = lazy(() => import('./pages/PlatformMerchants'))
const PlatformOrders = lazy(() => import('./pages/PlatformOrders'))
const PlatformProducts = lazy(() => import('./pages/PlatformProducts'))
const PlatformUsers = lazy(() => import('./pages/PlatformUsers'))
const PlatformRock = lazy(() => import('./pages/PlatformRock'))
@@ -92,6 +93,14 @@ function AppRoutes() {
<Route path="merchant-members" element={<MerchantPermissionRoute permission="members:manage"><MerchantCenter fixedTab="members" title="成员" /></MerchantPermissionRoute>} />
<Route path="merchant-callbacks" element={<MerchantPermissionRoute permission="callbacks:manage"><MerchantCenter fixedTab="callbacks" title="回调" /></MerchantPermissionRoute>} />
<Route path="merchant-api-keys" element={<MerchantPermissionRoute permission="api:manage"><MerchantCenter fixedTab="api" title="API 密钥" /></MerchantPermissionRoute>} />
<Route
path="platform-orders"
element={
<AdminRoute>
<PlatformOrders />
</AdminRoute>
}
/>
<Route
path="platform-merchants"
element={
+3
View File
@@ -207,6 +207,9 @@ export const platformApi = {
request.delete(`/platform/merchant-accounts/${userId}`).then((r) => r.data.data),
merchants: (params?: Record<string, unknown>) =>
request.get('/platform/merchants', { params }).then((r) => r.data.data as PageResult<Merchant>),
// 平台侧跨商户订单视图:不传 merchant_id 即全部商户
orders: (params?: Record<string, unknown>) =>
request.get('/platform/orders', { params }).then((r) => r.data.data as PageResult<FulfillmentOrder>),
createMerchant: (data: {
code: string
name: string
+4 -1
View File
@@ -88,7 +88,10 @@ const adminSections: SidebarSection[] = [
key: 'orders',
label: '订单管理',
icon: <OrderedListOutlined />,
children: [{ key: 'fulfillment-orders', label: '发货订单', path: '/merchant-orders' }],
children: [
{ key: 'platform-orders', label: '下游订单(全部商户)', path: '/platform-orders' },
{ key: 'fulfillment-orders', label: '发货订单(当前商户)', path: '/merchant-orders' },
],
},
{
key: 'staff',
+250
View File
@@ -0,0 +1,250 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button, Card, Col, Empty, Input, Row, Select, Space, Table, Tag, Typography, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import { platformApi } from '../api'
import { PageHeader } from '../components/PageHeader'
import type { FulfillmentOrder, Merchant, PageResult } from '../types'
import { formatDateTime } from '../utils/time'
import { money, orderStatusMap, orderStatusOptions, pageConfig } from './merchantCenterUtils'
const { Text } = Typography
const sourceOptions = [
{ value: '', label: '全部来源' },
{ value: 'api', label: '开放接口' },
{ value: 'manual', label: '人工下单' },
{ value: 'test', label: '联调测试' },
]
const sourceText: Record<string, string> = {
api: '开放接口',
manual: '人工下单',
test: '联调测试',
}
interface OrderFilters {
merchantId?: number
orderStatus?: string
orderSource?: string
keyword?: string
}
const emptyPage: PageResult<FulfillmentOrder> = { list: [], total: 0, page: 1, size: 20 }
function orderStatusTag(value: string) {
const item = orderStatusMap[value] || { color: 'default', text: value }
return <Tag color={item.color}>{item.text}</Tag>
}
/**
* 平台侧「下游订单」页。
*
* 存在的理由:商户后台的「发货订单」走 /api/merchant/orders,服务端强制按单个商户隔离,
* 而管理员的默认商户上下文是自营商户——于是首页 Dashboard 显示平台总量(跨商户),
* 订单页却只显示自营商户的量,两个数字对不上且无法解释。本页走 /api/platform/orders,
* 不依赖 X-Merchant-ID,管理员可以在这里看到全部下游商户的订单。
*/
export default function PlatformOrders() {
const [data, setData] = useState<PageResult<FulfillmentOrder>>(emptyPage)
const [merchants, setMerchants] = useState<Merchant[]>([])
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(1)
const [size, setSize] = useState(20)
// 已提交的筛选条件即唯一数据源,由 effect 驱动查询;
// 关键字另存草稿,避免每敲一个字就打一次接口。
const [filters, setFilters] = useState<OrderFilters>({})
const [keywordDraft, setKeywordDraft] = useState('')
const load = useCallback(async () => {
const params: Record<string, unknown> = { page, size }
if (filters.merchantId) params.merchant_id = filters.merchantId
if (filters.orderStatus) params.order_status = filters.orderStatus
if (filters.orderSource) params.order_source = filters.orderSource
if (filters.keyword) params.keyword = filters.keyword
setLoading(true)
try {
setData(await platformApi.orders(params))
} catch (e) {
message.error(e instanceof Error ? e.message : '加载下游订单失败')
} finally {
setLoading(false)
}
}, [page, size, filters])
useEffect(() => {
void load()
}, [load])
useEffect(() => {
platformApi
.merchants({ page: 1, size: 200 })
.then((res) => setMerchants(res.list || []))
.catch(() => setMerchants([]))
}, [])
// 改筛选条件时回到第一页,避免停留在越界页码上看到空列表。
const updateFilters = useCallback((patch: Partial<OrderFilters>) => {
setPage(1)
setFilters((prev) => ({ ...prev, ...patch }))
}, [])
const submitKeyword = useCallback(() => {
updateFilters({ keyword: keywordDraft.trim() || undefined })
}, [keywordDraft, updateFilters])
const columns: ColumnsType<FulfillmentOrder> = useMemo(
() => [
{
title: '所属商户',
dataIndex: 'merchant_id',
width: 220,
render: (_, record) => (
<Space direction="vertical" size={0}>
<Text strong>{record.merchant?.name || `商户 #${record.merchant_id}`}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{record.merchant?.code || '-'}</Text>
</Space>
),
},
{
title: '订单号 / 商户单号',
dataIndex: 'order_no',
width: 260,
render: (_, record) => (
<Space direction="vertical" size={0}>
<Text copyable={{ text: record.order_no }} strong style={{ fontFamily: 'monospace', fontSize: 12 }}>
{record.order_no}
</Text>
<Text type="secondary" style={{ fontSize: 12, fontFamily: 'monospace' }}>
{record.client_order_no || '-'}
</Text>
</Space>
),
},
{
title: '商品',
dataIndex: 'product_name',
width: 190,
render: (_, record) => (
<Space direction="vertical" size={0}>
<Text>{record.product_name}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{record.product_sku}</Text>
</Space>
),
},
{
title: '金额',
dataIndex: 'amount',
width: 120,
align: 'right',
render: (value) => money(value),
},
{
title: '状态',
dataIndex: 'order_status',
width: 100,
render: (value) => orderStatusTag(value),
},
{
title: '来源',
dataIndex: 'order_source',
width: 100,
render: (value) => <Tag>{sourceText[value] || value}</Tag>,
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 175,
render: formatDateTime,
},
{
title: '上游单号',
dataIndex: 'provider_order_no',
width: 200,
render: (value) => value
? <Text copyable={{ text: value }} style={{ fontFamily: 'monospace', fontSize: 12 }}>{value}</Text>
: <Text type="secondary">-</Text>,
},
],
[],
)
return (
<div>
<PageHeader
title="下游订单"
subtitle="平台视角:跨全部下游商户,不随当前商户上下文变化。商户后台的「发货订单」只显示单个商户。"
breadcrumbs={[{ title: '下游订单' }]}
extra={
<Button icon={<ReloadOutlined />} loading={loading} onClick={() => void load()}>
刷新
</Button>
}
/>
<Card style={{ marginBottom: 16, borderRadius: 10, border: '1px solid #e2e8f0' }} styles={{ body: { padding: '16px 20px' } }}>
<Row gutter={[12, 12]} align="middle">
<Col xs={24} md={6}>
<Select
allowClear
style={{ width: '100%' }}
placeholder="全部商户"
value={filters.merchantId}
onChange={(value) => updateFilters({ merchantId: value })}
options={merchants.map((item) => ({ value: item.id, label: `${item.name}(${item.code})` }))}
/>
</Col>
<Col xs={24} md={6}>
<Select
style={{ width: '100%' }}
value={filters.orderStatus || ''}
onChange={(value) => updateFilters({ orderStatus: value || undefined })}
options={orderStatusOptions.map((item) => ({
value: item.value,
label: item.value === '' ? '全部状态' : item.label,
}))}
/>
</Col>
<Col xs={24} md={5}>
<Select
style={{ width: '100%' }}
value={filters.orderSource || ''}
onChange={(value) => updateFilters({ orderSource: value || undefined })}
options={sourceOptions}
/>
</Col>
<Col xs={24} md={7}>
<Space.Compact style={{ width: '100%' }}>
<Input
allowClear
placeholder="订单号 / 商户单号"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
value={keywordDraft}
onChange={(e) => setKeywordDraft(e.target.value)}
onPressEnter={submitKeyword}
/>
<Button type="primary" onClick={submitKeyword}>查询</Button>
</Space.Compact>
</Col>
</Row>
</Card>
<Card style={{ borderRadius: 10, border: '1px solid #e2e8f0' }} styles={{ body: { padding: 0 } }}>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data.list}
locale={{ emptyText: <Empty description="没有符合条件的下游订单" /> }}
scroll={{ x: 1370 }}
pagination={pageConfig(data, (nextPage, nextSize) => {
setPage(nextPage)
setSize(nextSize)
})}
/>
</Card>
</div>
)
}
+6
View File
@@ -21,6 +21,9 @@ export interface Merchant {
created_at: string
}
// 平台侧跨商户订单列表里标识订单归属所需的商户信息(后端 Preload 的 Merchant 子集)
export type MerchantSummary = Pick<Merchant, 'id' | 'code' | 'name' | 'status'>
export interface MerchantMember {
id: number
merchant_id: number
@@ -132,6 +135,9 @@ export interface WalletLedgerEntry {
export interface FulfillmentOrder {
id: number
merchant_id: number
// merchant 仅平台侧跨商户列表会返回;商户侧响应不含该字段(后端 omitempty)
merchant?: MerchantSummary | null
order_no: string
client_order_no: string
product_sku: string