feat(consignment): 补齐寄卖前端权限门控,收口调拨状态与运营体验

权限门控(P1-8 剩余):
- 新增 resolveAdminPermission(sessionPermissions, code):会话接口的最新权限列表优先,
  接口异常才回退登录时的本地快照;空数组也判无权限,避免"刚被收回权限仍能点"。
- 寄售仓库面板:consignment.manage 控制手动入库/编辑/作废/退单回库/匹配开关(无权限时开关禁用)。
- 寄卖提报面板:consignment.review 控制审核入库/驳回。
- 接单平台页:无 consignment.view 不渲染两个寄卖 tab,并把 URL/localStorage 的旧选择拉回工单页。

数据约束(P2-19 剩余):
- 迁移 080:allocation_status 历史非法值归位「待安排」(备注留痕)后加 CHECK(待安排/已安排),
  并顺手修正 077 遗留的"收集链接/玩家提交"权限描述文案。
- 编辑接口拒绝直接改 allocationStatus(400 consignment_allocation_status_update_forbidden)。

运营体验:
- 两个面板补 isError 错误态与重试;审核通过/驳回加 confirmLoading 与提交中守卫(防双提交)。
- 待审徽标改用后端真实总数(原来只统计当前页);编辑弹窗押金不再兜底 ¥100(会误写押金)。
- 履约拦截弹窗冷静期文案不再硬编码"3 天",改为按商品规则设定。
- 核实并撤回"倒计时时区偏移"误报:deadlineAt 为带 Z 的 ISO,前端无偏移问题。

验证:后端 412 例(409 通过)、前端 21 例(新增 5 例权限解析)全绿,
typecheck / lint / format / check:sql / build 通过;迁移 001→080 在全新空库跑通并在 dev 库应用。
This commit is contained in:
yml2213
2026-09-16 20:39:52 +08:00
parent 5d11bf2012
commit 563e548b2e
10 changed files with 346 additions and 61 deletions
@@ -0,0 +1,51 @@
-- 080_consignment_allocation_status_and_copy.sql —— 寄卖收口:调拨状态加约束 + 修正下线渠道遗留文案
--
-- 背景:
-- 1) consignment_inventories.allocation_status 自 077 起是自由文本,系统只会写「待安排 / 已安排」,
-- 但没有任何约束挡住别的值进来;列表角标与筛选口径依赖这两个值,必须锁死。
-- 2) 077 写入的权限描述还写着「收集链接」(该渠道已在 079 整体下线)与「玩家提交」,
-- 需要在权限说明里改成实际口径(打手通过接单平台提报)。
-- 历史数据不做兼容处理(测试阶段),仅把非法值归位到「待安排」并在备注留痕。
-- 1) 历史非法值归位(先归位再加约束,否则 ADD CONSTRAINT 会因为存量脏数据直接失败)
UPDATE consignment_inventories
SET
remark = remark || ' [allocation_status 非法值归位: ' || allocation_status || ']',
allocation_status = '待安排',
updated_at = NOW()
WHERE allocation_status NOT IN ('待安排', '已安排');
-- 2) 调拨状态约束:与系统写入口径一致,重复执行安全
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'consignment_inventories_allocation_status_check'
AND conrelid = 'consignment_inventories'::regclass
) THEN
ALTER TABLE consignment_inventories
DROP CONSTRAINT consignment_inventories_allocation_status_check;
END IF;
END
$$;
ALTER TABLE consignment_inventories
ADD CONSTRAINT consignment_inventories_allocation_status_check
CHECK (allocation_status IN ('待安排', '已安排'));
COMMENT ON COLUMN consignment_inventories.allocation_status IS
'调拨状态:待安排 在库待匹配 / 已安排 已被工单占用;由系统在占库、回库时写入,编辑接口不可改';
-- 3) 权限描述修正(收集链接渠道已下线,提报来源改为接单平台打手端)
INSERT INTO admin_permissions (permission_key, permission_name, permission_group, description)
VALUES
('consignment.view', '查看寄卖库存', '寄卖管理', '查看寄售仓库与寄卖提报单'),
('consignment.manage', '管理寄卖库存', '寄卖管理', '录入、修改、下架寄售物品(状态与调拨由系统动作变更)'),
('consignment.review', '审核寄卖提报', '寄卖管理', '审核接单平台提报的寄卖申请并入库'),
('consignment.fulfill', '寄卖内部履约', '寄卖管理', '使用内部寄卖库存直接履约工单、指派打手与撤销重选')
ON CONFLICT (permission_key) DO UPDATE SET
permission_name = EXCLUDED.permission_name,
permission_group = EXCLUDED.permission_group,
description = EXCLUDED.description,
updated_at = NOW();
@@ -261,6 +261,14 @@ export async function updateAdminConsignmentInventory(
errorCode: 'consignment_status_update_forbidden',
})
}
// 调拨状态由系统在占库/回库时写入,前端也从不展示该字段的编辑入口;
// 开放自由文本会让"待安排/已安排"之外的值进库,导致列表角标与筛选口径失真。
if (payload.allocationStatus !== undefined) {
throw createHttpError('编辑接口不支持直接修改调拨状态', {
statusCode: 400,
errorCode: 'consignment_allocation_status_update_forbidden',
})
}
const updated = await updateConsignmentInventory(id, {
itemName: payload.itemName !== undefined ? String(payload.itemName).trim() : undefined,
system: payload.system !== undefined ? String(payload.system).trim() : undefined,
@@ -68,6 +68,7 @@ import {
returnConsignmentInventoryToStockWithClient,
} from '../../repositories/worker-platform/consignment-repo.js'
import { ADMIN_PERMISSION_KEYS } from '../admin/admin-rbac-service.js'
import { updateAdminConsignmentInventory } from './consignment-admin-service.js'
import {
assertConsignmentRevocableWorkOrderStatus,
resolveRevocableConsignmentInventoryId,
@@ -2027,3 +2028,23 @@ test('内部履约的电子凭证核销失败要写事件留痕(不能静默
assert.ok(src.includes('consignment_fulfill_voucher_consume_failed'))
assert.ok(src.includes('if (!voucherConsume.ok)'))
})
test('编辑接口拒绝直接改调拨状态,避免自由文本进库破坏角标口径', async () => {
await assert.rejects(
() =>
updateAdminConsignmentInventory(1, {
allocationStatus: '随便写的状态',
}),
(error: unknown) =>
(error as { errorCode?: string }).errorCode ===
'consignment_allocation_status_update_forbidden',
)
})
test('编辑接口仍然拒绝直接改物品状态(回归)', async () => {
await assert.rejects(
() => updateAdminConsignmentInventory(1, { status: 'archived' }),
(error: unknown) =>
(error as { errorCode?: string }).errorCode === 'consignment_status_update_forbidden',
)
})
@@ -6,7 +6,7 @@ import { useLocation, useSearchParams } from 'react-router'
import PageHeader from '@/components/admin/PageHeader'
import type { WorkOrder } from '@/types/worker-platform'
import { fetchAdminWorkerPlatformSummary } from '@/services/admin'
import { getAdminRole } from '@/utils/admin-auth'
import { getAdminRole, hasAdminPermission } from '@/utils/admin-auth'
import AfterSalesPanel, { type AfterSalesPanelHandle } from './panels/AfterSalesPanel'
import AnnouncementPanel from './panels/AnnouncementPanel'
import CancelRequestsPanel from './panels/CancelRequestsPanel'
@@ -36,11 +36,18 @@ export default function AdminWorkerPlatformPage() {
const isAdmin = role === 'admin'
const isSupport = role === 'support'
const isFinance = role === 'finance'
const canViewConsignment = hasAdminPermission('consignment.view')
const isConsignmentTab =
activeTab === 'consignment-inventory' || activeTab === 'consignment-submissions'
// 没有 consignment.view 时这两个 tab 不渲染,把 URL/localStorage 里的旧选择也拉回工单页,
// 否则会落在一个空的 tab 内容上。
const visibleActiveTab = isFinance
? 'finance'
: !isAdmin && activeTab === 'notifications'
? 'orders'
: activeTab
: !canViewConsignment && isConsignmentTab
? 'orders'
: activeTab
useEffect(() => {
const tab = searchParams.get('tab')
@@ -79,6 +86,23 @@ export default function AdminWorkerPlatformPage() {
children: <AfterSalesPanel ref={afterSalesPanelRef} />,
}
// 寄卖相关 tab 按权限渲染:后端所有寄卖接口都挂了 requireAdminPermission,
// 没有 consignment.view 的人看到 tab 只会一路 403。
const consignmentTabs = canViewConsignment
? [
{
key: 'consignment-inventory',
label: '寄售仓库',
children: <ConsignmentInventoryPanel />,
},
{
key: 'consignment-submissions',
label: '寄卖提报审核',
children: <ConsignmentSubmissionsPanel />,
},
]
: []
return (
<section className="page-stack">
<PageHeader
@@ -96,16 +120,7 @@ export default function AdminWorkerPlatformPage() {
: isSupport
? [
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
{
key: 'consignment-inventory',
label: '寄售仓库',
children: <ConsignmentInventoryPanel />,
},
{
key: 'consignment-submissions',
label: '寄卖提报审核',
children: <ConsignmentSubmissionsPanel />,
},
...consignmentTabs,
{
key: 'cancel-requests',
label: '打手退单审核',
@@ -116,16 +131,7 @@ export default function AdminWorkerPlatformPage() {
]
: [
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
{
key: 'consignment-inventory',
label: '寄售仓库',
children: <ConsignmentInventoryPanel />,
},
{
key: 'consignment-submissions',
label: '寄卖提报审核',
children: <ConsignmentSubmissionsPanel />,
},
...consignmentTabs,
{
key: 'cancel-requests',
label: '打手退单审核',
@@ -10,6 +10,7 @@ import {
} from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import {
Alert,
App,
Badge,
Button,
@@ -46,6 +47,8 @@ import {
} from '@/services/admin/consignment'
import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform'
import type { ConsignmentInventory } from '@/types/consignment'
import { fetchAdminSession } from '@/services/admin/auth'
import { resolveAdminPermission } from '@/utils/admin-auth'
const { Text } = Typography
@@ -113,6 +116,16 @@ export default function ConsignmentInventoryPanel() {
const [inboundForm] = Form.useForm()
const [editForm] = Form.useForm()
// 写操作按钮统一按 consignment.manage 门控:后端全部挂了 requireAdminPermission,
// 前端不隐藏会让无权限的人看到按钮却每次点击都 403。
const adminSessionQuery = useQuery({
queryKey: ['admin-session'],
queryFn: fetchAdminSession,
retry: false,
})
const sessionPermissions = adminSessionQuery.data?.data.user.permissions
const canManageConsignment = resolveAdminPermission(sessionPermissions, 'consignment.manage')
// 查询活跃打手列表用于指派与关联
const workersQuery = useQuery({
queryKey: ['admin-worker-users-consignment-options'],
@@ -444,11 +457,17 @@ export default function ConsignmentInventoryPanel() {
width: 110,
render: (_, record) => (
<Tooltip
title={record.allowMatching ? '当前允许系统与工单匹配' : '已禁止匹配,不进入候选池'}
title={
!canManageConsignment
? '无寄卖管理权限,无法修改匹配开关'
: record.allowMatching
? '当前允许系统与工单匹配'
: '已禁止匹配,不进入候选池'
}
>
<Switch
checked={record.allowMatching}
disabled={record.status !== 'in_stock'}
disabled={record.status !== 'in_stock' || !canManageConsignment}
checkedChildren="开"
unCheckedChildren="关"
onChange={(checked) => handleToggleMatching(record, checked)}
@@ -503,7 +522,7 @@ export default function ConsignmentInventoryPanel() {
>
详情
</Button>
{record.status === 'in_stock' ? (
{record.status === 'in_stock' && canManageConsignment ? (
<>
<Button
size="small"
@@ -514,7 +533,10 @@ export default function ConsignmentInventoryPanel() {
...record,
costPrice: record.costPriceFen / 100,
workerId: record.workerId || undefined,
depositAmount: record.depositAmountFen ? record.depositAmountFen / 100 : 100,
// 没有押金就留空,不能兜底成 100:保存会把"无押金"悄悄写成 ¥100
depositAmount: record.depositAmountFen
? record.depositAmountFen / 100
: undefined,
loginMethod: (record.credentialsJson as any)?.loginMethod,
account: (record.credentialsJson as any)?.account,
password: (record.credentialsJson as any)?.password,
@@ -535,7 +557,7 @@ export default function ConsignmentInventoryPanel() {
</Button>
</>
) : null}
{record.status === 'consumed' ? (
{record.status === 'consumed' && canManageConsignment ? (
<Button
size="small"
type="primary"
@@ -691,32 +713,51 @@ export default function ConsignmentInventoryPanel() {
<Button icon={<ReloadOutlined />} onClick={refresh}>
刷新
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
inboundForm.resetFields()
inboundForm.setFieldsValue({
system: '苹果',
serverZone: '微信区',
series: '暗潮',
diamondLevel: 1,
brillianceCount: 0,
count: 1,
allowMatching: true,
itemName: '苹果微信暗潮1钻',
})
setInboundModalOpen(true)
}}
>
手动入库
</Button>
{canManageConsignment ? (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
inboundForm.resetFields()
inboundForm.setFieldsValue({
system: '苹果',
serverZone: '微信区',
series: '暗潮',
diamondLevel: 1,
brillianceCount: 0,
count: 1,
allowMatching: true,
itemName: '苹果微信暗潮1钻',
})
setInboundModalOpen(true)
}}
>
手动入库
</Button>
) : null}
</Space>
</Space>
</Card>
{/* 库存表格 */}
<Card size="small">
{inventoriesQuery.isError ? (
<Alert
type="error"
showIcon
style={{ marginBottom: 12 }}
message="读取寄卖库存失败"
description={
(inventoriesQuery.error as any)?.message ||
'请检查网络或稍后重试;也可能是当前账号缺少 consignment.view 权限。'
}
action={
<Button size="small" onClick={() => void inventoriesQuery.refetch()}>
重试
</Button>
}
/>
) : null}
<Table
rowKey="id"
columns={columns}
@@ -6,6 +6,7 @@ import {
} from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import {
Alert,
Badge,
Button,
Card,
@@ -33,8 +34,10 @@ import {
fetchAdminConsignmentSubmissions,
rejectAdminConsignmentSubmission,
} from '@/services/admin/consignment'
import { fetchAdminSession } from '@/services/admin/auth'
import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform'
import type { ConsignmentSubmission } from '@/types/consignment'
import { resolveAdminPermission } from '@/utils/admin-auth'
const { Text } = Typography
@@ -51,6 +54,17 @@ export default function ConsignmentSubmissionsPanel() {
const [viewingSubmission, setViewingSubmission] = useState<ConsignmentSubmission | null>(null)
const [rejectNote, setRejectNote] = useState('')
const [approveForm] = Form.useForm()
const [approving, setApproving] = useState(false)
const [rejecting, setRejecting] = useState(false)
// 通过/驳回要 consignment.review:后端接口已挂权限,前端不隐藏会让人点了必 403
const adminSessionQuery = useQuery({
queryKey: ['admin-session'],
queryFn: fetchAdminSession,
retry: false,
})
const sessionPermissions = adminSessionQuery.data?.data.user.permissions
const canReviewConsignment = resolveAdminPermission(sessionPermissions, 'consignment.review')
const workersQuery = useQuery({
queryKey: ['admin-worker-users-consignment-options'],
@@ -82,10 +96,23 @@ export default function ConsignmentSubmissionsPanel() {
const submissions = submissionsQuery.data?.data.items || []
const submissionTotal = submissionsQuery.data?.data.total || 0
const pendingCount = submissions.filter((s) => s.status === 'pending_review').length
// 待审徽标必须用后端总数:原来只统计当前页(pageSize=20 时最多显示 20,且翻页就变)。
const pendingTotalQuery = useQuery({
queryKey: ['admin-consignment-submissions-pending-total'],
queryFn: () =>
fetchAdminConsignmentSubmissions({
status: 'pending_review',
page: 1,
pageSize: 1,
}),
refetchInterval: 60_000,
})
const pendingCount = pendingTotalQuery.data?.data.total || 0
async function handleApproveSubmit() {
if (!approveModalItem) return
if (!approveModalItem || approving) return
setApproving(true)
try {
const values = await approveForm.validateFields()
const costPriceFen = Math.round(Number(values.costPrice || 0) * 100)
@@ -118,23 +145,28 @@ export default function ConsignmentSubmissionsPanel() {
message.success('审核通过!已正式存入寄售库存待匹配!')
setApproveModalItem(null)
await submissionsQuery.refetch()
await Promise.all([submissionsQuery.refetch(), pendingTotalQuery.refetch()])
} catch (err: any) {
if (err.errorFields) return
message.error(err.message || '审核入库失败')
} finally {
setApproving(false)
}
}
async function handleRejectConfirm() {
if (!rejectModalItem) return
if (!rejectModalItem || rejecting) return
setRejecting(true)
try {
await rejectAdminConsignmentSubmission(rejectModalItem.id, { reviewNote: rejectNote })
message.success('已驳回该提报申请')
setRejectModalItem(null)
setRejectNote('')
await submissionsQuery.refetch()
await Promise.all([submissionsQuery.refetch(), pendingTotalQuery.refetch()])
} catch (err: any) {
message.error(err.message || '驳回失败')
} finally {
setRejecting(false)
}
}
@@ -256,7 +288,7 @@ export default function ConsignmentSubmissionsPanel() {
>
详情
</Button>
{record.status === 'pending_review' ? (
{record.status === 'pending_review' && canReviewConsignment ? (
<>
<Button
size="small"
@@ -338,6 +370,23 @@ export default function ConsignmentSubmissionsPanel() {
</Card>
<Card size="small">
{submissionsQuery.isError ? (
<Alert
type="error"
showIcon
style={{ marginBottom: 12 }}
message="读取寄卖提报单失败"
description={
(submissionsQuery.error as any)?.message ||
'请检查网络或稍后重试;也可能是当前账号缺少 consignment.view 权限。'
}
action={
<Button size="small" onClick={() => void submissionsQuery.refetch()}>
重试
</Button>
}
/>
) : null}
<Table
rowKey="id"
columns={submissionColumns}
@@ -363,6 +412,7 @@ export default function ConsignmentSubmissionsPanel() {
width={680}
onCancel={() => setApproveModalItem(null)}
onOk={handleApproveSubmit}
confirmLoading={approving}
okText="审核通过并正式入库"
okType="primary"
destroyOnClose
@@ -519,6 +569,7 @@ export default function ConsignmentSubmissionsPanel() {
open={Boolean(rejectModalItem)}
onCancel={() => setRejectModalItem(null)}
onOk={handleRejectConfirm}
confirmLoading={rejecting}
okText="确认驳回"
okType="danger"
>
@@ -2913,8 +2913,8 @@ export default function WorkOrdersPanel() {
</div>
<div style={{ color: '#389e0d', marginTop: 4 }}>
若使用内部寄售库存履约,<strong>无需向外部打手支付佣金(打手支出 ¥0.00)</strong>
。系统会立刻占用库存,并跳过「待打手提交资料」,直接进入默认 3
天赠送冷静期;冷静期结束后再用库存账号赠送并验收。
。系统会立刻占用库存,并跳过「待打手提交资料」,直接进入该商品规则设定的赠送冷静期(未单独配置时为
72 小时);冷静期结束后再用库存账号赠送并验收。
</div>
</div>
}
+19
View File
@@ -58,6 +58,25 @@ export function hasAdminPermission(permission: string) {
return getAdminPermissions().includes(String(permission || '').trim())
}
/**
* 解析管理端权限:会话接口返回的权限列表优先,缺失时回退到登录时写入本地的权限快照。
*
* 为什么需要两层:
* - 本地快照可能过期(管理员改权限后该账号没重新登录),页面拿到 `/admin/auth/session`
* 的最新列表后必须以它为准;
* - 会话接口失败时不能让整个面板的按钮全消失,所以保留本地回退。
*/
export function resolveAdminPermission(
sessionPermissions: string[] | null | undefined,
permission: string,
): boolean {
const code = String(permission || '').trim()
if (!code) return false
// 空数组也算"拿到了最新列表",此时判定为无权限,不回退本地快照。
if (Array.isArray(sessionPermissions)) return sessionPermissions.includes(code)
return hasAdminPermission(code)
}
export function hasAdminRole(role: 'admin' | 'operator' | 'support' | 'finance') {
if (!hasAdminSession()) {
return false
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { resolveAdminPermission } from '../src/utils/admin-auth.ts'
type StorageLike = {
getItem: (key: string) => string | null
setItem: (key: string, value: string) => void
removeItem: (key: string) => void
}
function installLocalStorage(entries: Record<string, string>) {
const store = new Map(Object.entries(entries))
const storage: StorageLike = {
getItem: (key) => (store.has(key) ? (store.get(key) as string) : null),
setItem: (key, value) => {
store.set(key, value)
},
removeItem: (key) => {
store.delete(key)
},
}
;(globalThis as { localStorage?: StorageLike }).localStorage = storage
return store
}
function installAdminSessionSnapshot(permissions: string[]) {
return installLocalStorage({
'order-site-admin-token': 'token',
'order-site-admin-token-expires-at': new Date(Date.now() + 60_000).toISOString(),
'order-site-admin-permissions': JSON.stringify(permissions),
})
}
test('会话接口返回的权限列表优先于本地快照(管理员刚收回权限的场景)', () => {
installAdminSessionSnapshot(['consignment.manage'])
// 本地快照里有 manage,但会话接口的最新列表没有 → 以会话为准
assert.equal(resolveAdminPermission(['consignment.view'], 'consignment.manage'), false)
assert.equal(resolveAdminPermission(['consignment.view'], 'consignment.view'), true)
})
test('会话返回空权限数组时判定为无权限,不回退本地快照', () => {
installAdminSessionSnapshot(['consignment.manage', 'consignment.review'])
assert.equal(resolveAdminPermission([], 'consignment.manage'), false)
assert.equal(resolveAdminPermission([], 'consignment.review'), false)
})
test('会话权限缺失(接口失败/未返回)时回退本地快照', () => {
installAdminSessionSnapshot(['consignment.review'])
assert.equal(resolveAdminPermission(undefined, 'consignment.review'), true)
assert.equal(resolveAdminPermission(null, 'consignment.manage'), false)
})
test('没有登录会话时即使快照里有权限也不放行', () => {
installLocalStorage({ 'order-site-admin-permissions': JSON.stringify(['consignment.manage']) })
assert.equal(resolveAdminPermission(undefined, 'consignment.manage'), false)
})
test('空权限名一律拒绝(避免调用方漏传参数时误放行)', () => {
installAdminSessionSnapshot(['consignment.manage'])
assert.equal(resolveAdminPermission(['consignment.manage'], ''), false)
assert.equal(resolveAdminPermission([''], 'consignment.manage'), false)
})
+26 -6
View File
@@ -19,7 +19,8 @@
| `6b940796` | 样式修复(cooldownMinutes prefer-const) | — |
| `e828e677` | 权限打通(19 接口改 `requireAdminPermission`)、指派占库改条件更新、撤销守卫 | 新增 4 个回归测试 |
| `632dddc4` | 上一轮:H5 渠道整体下线 + 凭据字段修复 + 状态机封闭(撤销守卫 / 库存绑定唯一性 / 生命周期解绑)+ 押金归属取台账 | 迁移 `079`、`consignment-*` 五个文件、`work-order-*` 三个仓库、两个前端面板 |
| *(工作区未提交)* | 本轮 · 批次 C+D:指派部分成功补偿、凭证核销如实反馈、空规格不放行 + 履约/指派服务端规格复检 | `consignment-fulfillment-service.ts`、`consignment-matching-service.ts`、`consignment-repo.ts`、履约路由、`WorkOrdersPanel.tsx` |
| `5d11bf20` | 批次 C+D:指派部分成功补偿、凭证核销如实反馈、空规格不放行 + 履约/指派服务端规格复检 | `consignment-fulfillment-service.ts`、`consignment-matching-service.ts`、`consignment-repo.ts`、履约路由、`WorkOrdersPanel.tsx` |
| 本批提交(批次 E,同一提交内含本文档,故不写自身 hash) | 前端四权限门控收口、`allocation_status` 约束与权限文案修正、面板错误态/防双提交/真实待审计数 | `admin-auth.ts`、两个寄卖面板、`AdminWorkerPlatformPage.tsx`、迁移 `080` |
**结论**:功能主干 3 天连成闭环(库存 → 提报审核 → 匹配 → 内部现货 / 打手押金两条履约 → 赠送冷静期 → 验收结算 → 撤销回库),但最近三次提交全是**上线后回填正确性**(权限被静默清除、并发占库、撤销资金挂账)。
当前主要矛盾不是"缺功能",而是:**状态机不封闭 + 库存绑定无唯一性约束 + 工单终态对寄卖零感知**(本轮已处理);入口侧"H5 不可提交 / 手工录入丢凭据"两条断链已通过"下线 H5 + 修字段名"解决。
@@ -273,10 +274,29 @@
前端在"部分匹配"候选上先 `modal.confirm` 危险确认,再带 `allowPartial: true` 重试,高配充低配的合法玩法保留但不可误触。
3. ⏳ 匹配下推 SQL + 批量匹配接口 + 工单列表角标(P2-8)未做:当前仍是"取全量在库 → 内存比对"。
### 批次 E · 权限门控与运营体验(P1/P2)
1. 前端四权限门控(P1-8 剩余部分:库存/提报面板尚无 `consignment.view/manage/review` 门控);打手端菜单收口(P1-9 剩余)。
2. 报表按文档 §六补齐节省赏金等指标(P2-7);`allocation_status` CHECK 约束(P2-19 剩余)。
3. 面板错误态、双提交、徽标、搜索、押金回显、文案/时区等体验项(P2-1~P2-18)。
### 批次 E · 权限门控与运营体验(P1/P2)—— 🔶 部分完成(本轮)
1. ✅ **前端四权限门控(P1-8 剩余部分)**:
- 新增 `resolveAdminPermission(sessionPermissions, code)`(`utils/admin-auth.ts`):会话接口的最新权限列表优先,
接口异常时才回退登录时的本地快照;**空数组也判定为无权限**,不回退(管理员刚收回权限的场景)。
- 寄售仓库面板:`consignment.manage` 控制"手动入库 / 编辑 / 作废 / 退单回库 / 匹配开关"(无权限时开关禁用并给 tooltip 说明)。
- 寄卖提报面板:`consignment.review` 控制"审核入库 / 驳回"。
- `AdminWorkerPlatformPage`:无 `consignment.view` 时不渲染"寄售仓库 / 寄卖提报审核"两个 tab,
并把 URL 或 localStorage 里的旧选择拉回工单页(否则会落在空白 tab 上)。
2. ✅ **`allocation_status` 收口(P2-19 剩余)**:迁移 `080` 先把历史非法值归位到「待安排」(备注留痕)再加
`CHECK (allocation_status IN ('待安排','已安排'))`;编辑接口新增拒绝 `allocationStatus`(400
`consignment_allocation_status_update_forbidden`),与"编辑接口不可改 status"同口径。顺带修正 077 遗留的
"收集链接/玩家提交"权限描述文案(该渠道已在 079 下线)。
3. ✅ **体验项(部分)**:两个面板加 `isError` 错误态(含重试按钮);审核通过/驳回弹窗加 `confirmLoading` 与
提交中守卫(防双提交);待审徽标改用后端真实总数(原来只统计当前页,翻页就变);编辑弹窗押金不再兜底 `¥100`
(会把"无押金"悄悄写成 100 元);履约拦截弹窗的冷静期文案不再硬编码"3 天",改为"按商品规则设定(未配置时 72 小时)"。
4. ✅ **核实并撤回一条误报**:原审计提到"倒计时时区偏移"——`deadlineAt` 由 node-pg 解析 `timestamptz` 后
以带 `Z` 的 ISO 字符串下发,前端 `new Date(deadlineAt)` 无偏移问题,已用真实库数据确认,不需要改。
5. ⏳ 未做:
- 报表补齐(P2-7:节省赏金 / 出库单数 / 批次回款);
- 搜索字段后端不支持(面板承诺按联系方式/游戏 ID 搜索,后端只搜部分字段);
- 打手端"按账号状态隐藏寄卖入口":后端 `POST /consignment/publish` 已强制 `requireActiveWorker`,
前端要隐藏入口需要 `/worker/me` 暴露账号状态字段,本次未动接口。
---
@@ -285,4 +305,4 @@
1. **撤销边界**:✅ 定案 —— **已验收一律禁止撤销**,历史数据不做兼容(用户:"这是测试阶段,不用管历史记录")。买家退款/纠纷走售后流程;若日后确有"验收后退款"场景,另开"售后回收库存"流程(回库或作废 + 押金重算)。
2. **`worker_id` 拆分**:✅ 定案 —— 直接新增 `assigned_worker_id` 并让 `worker_id` 固定为卖家(迁移 `079`,无兼容期,不回填历史)。
3. **押金策略**:⏳ 未定 —— `pending_review` 超期自动驳回退押金的时限(如 7 天)、以及打手自助撤回是否允许,涉及运营对账口径,需业务确认。
4. **批次顺序**:✅ 定案 —— A(入口可用)→ B(资金安全)→ C/D(资金与货物一致性、防错发)均已落地;E(权限门控与运营体验)为下一批。
4. **批次顺序**:✅ 定案 —— A(入口可用)→ B(资金安全)→ C/D(资金与货物一致性、防错发)已完成;E(权限门控与运营体验)本轮完成权限与约束部分,报表/搜索/打手端入口为剩余项。