From 563e548b2e51befd4ea6a53c3ed20e83f25d490f Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 16 Sep 2026 20:39:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(consignment):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E5=AF=84=E5=8D=96=E5=89=8D=E7=AB=AF=E6=9D=83=E9=99=90=E9=97=A8?= =?UTF-8?q?=E6=8E=A7=EF=BC=8C=E6=94=B6=E5=8F=A3=E8=B0=83=E6=8B=A8=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E4=B8=8E=E8=BF=90=E8=90=A5=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 权限门控(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 库应用。 --- ...consignment_allocation_status_and_copy.sql | 51 +++++++++++ .../consignment-admin-service.ts | 8 ++ .../worker-platform-service.test.ts | 21 +++++ .../pages/admin/AdminWorkerPlatformPage.tsx | 50 +++++----- .../panels/ConsignmentInventoryPanel.tsx | 91 ++++++++++++++----- .../panels/ConsignmentSubmissionsPanel.tsx | 63 +++++++++++-- .../pages/admin/panels/WorkOrdersPanel.tsx | 4 +- apps/frontend/src/utils/admin-auth.ts | 19 ++++ .../tests/consignment-permissions.test.ts | 68 ++++++++++++++ docs/寄卖系统优化方案.md | 32 +++++-- 10 files changed, 346 insertions(+), 61 deletions(-) create mode 100644 apps/backend/src/db/migrations/080_consignment_allocation_status_and_copy.sql create mode 100644 apps/frontend/tests/consignment-permissions.test.ts diff --git a/apps/backend/src/db/migrations/080_consignment_allocation_status_and_copy.sql b/apps/backend/src/db/migrations/080_consignment_allocation_status_and_copy.sql new file mode 100644 index 00000000..8df8c48e --- /dev/null +++ b/apps/backend/src/db/migrations/080_consignment_allocation_status_and_copy.sql @@ -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(); diff --git a/apps/backend/src/services/worker-platform/consignment-admin-service.ts b/apps/backend/src/services/worker-platform/consignment-admin-service.ts index 0b8a9931..19ee5dc6 100644 --- a/apps/backend/src/services/worker-platform/consignment-admin-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-admin-service.ts @@ -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, diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 6c6c6e1c..31471d8e 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -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', + ) +}) diff --git a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx index a720c784..ce8f5703 100644 --- a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx +++ b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx @@ -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: , } + // 寄卖相关 tab 按权限渲染:后端所有寄卖接口都挂了 requireAdminPermission, + // 没有 consignment.view 的人看到 tab 只会一路 403。 + const consignmentTabs = canViewConsignment + ? [ + { + key: 'consignment-inventory', + label: '寄售仓库', + children: , + }, + { + key: 'consignment-submissions', + label: '寄卖提报审核', + children: , + }, + ] + : [] + return (
}, - { - key: 'consignment-inventory', - label: '寄售仓库', - children: , - }, - { - key: 'consignment-submissions', - label: '寄卖提报审核', - children: , - }, + ...consignmentTabs, { key: 'cancel-requests', label: '打手退单审核', @@ -116,16 +131,7 @@ export default function AdminWorkerPlatformPage() { ] : [ { key: 'orders', label: '接单工单', children: }, - { - key: 'consignment-inventory', - label: '寄售仓库', - children: , - }, - { - key: 'consignment-submissions', - label: '寄卖提报审核', - children: , - }, + ...consignmentTabs, { key: 'cancel-requests', label: '打手退单审核', diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx index 4f407c35..ed081fa6 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx @@ -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) => ( handleToggleMatching(record, checked)} @@ -503,7 +522,7 @@ export default function ConsignmentInventoryPanel() { > 详情 - {record.status === 'in_stock' ? ( + {record.status === 'in_stock' && canManageConsignment ? ( <> - + {canManageConsignment ? ( + + ) : null} {/* 库存表格 */} + {inventoriesQuery.isError ? ( + void inventoriesQuery.refetch()}> + 重试 + + } + /> + ) : 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() { > 详情 - {record.status === 'pending_review' ? ( + {record.status === 'pending_review' && canReviewConsignment ? ( <> + } + /> + ) : null}
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" > diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 841d2591..00f2ff8f 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -2913,8 +2913,8 @@ export default function WorkOrdersPanel() {
若使用内部寄售库存履约,无需向外部打手支付佣金(打手支出 ¥0.00) - 。系统会立刻占用库存,并跳过「待打手提交资料」,直接进入默认 3 - 天赠送冷静期;冷静期结束后再用库存账号赠送并验收。 + 。系统会立刻占用库存,并跳过「待打手提交资料」,直接进入该商品规则设定的赠送冷静期(未单独配置时为 + 72 小时);冷静期结束后再用库存账号赠送并验收。
} diff --git a/apps/frontend/src/utils/admin-auth.ts b/apps/frontend/src/utils/admin-auth.ts index 99e404f3..29f4a338 100644 --- a/apps/frontend/src/utils/admin-auth.ts +++ b/apps/frontend/src/utils/admin-auth.ts @@ -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 diff --git a/apps/frontend/tests/consignment-permissions.test.ts b/apps/frontend/tests/consignment-permissions.test.ts new file mode 100644 index 00000000..c82d71ab --- /dev/null +++ b/apps/frontend/tests/consignment-permissions.test.ts @@ -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) { + 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) +}) diff --git a/docs/寄卖系统优化方案.md b/docs/寄卖系统优化方案.md index 1553549e..89dc2f66 100644 --- a/docs/寄卖系统优化方案.md +++ b/docs/寄卖系统优化方案.md @@ -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(权限门控与运营体验)本轮完成权限与约束部分,报表/搜索/打手端入口为剩余项。