fix(worker-platform): 按工单号搜不到彻底收口 —— 显式单号检索不再被任何排他筛选吞掉

## 修的是什么

线上反复复发的「后台接单平台按工单号搜不到、某些状态尤其明显」,根因不是一处,
而是同一类问题在 5 个地方各留了一半(历次修复都只补了一处,所以总复发):

1. 状态筛选走运营态派生:拼单母单一旦有人参与,open → in_progress /
   pending_acceptance,于是「单号 + 待接单」永远为空(没人参与时反而搜得到),
   这正是「有人接单后就搜不到」;非拼单单子被接单后同理。
2. 「单号 + 接单人员 / 分类 / 打手等级 / 创建时间」照样过滤掉目标单
   (接单人员是 INNER JOIN 可见集合,不在集合里整行消失)。
   d6523635 只在面板查询按钮里清了 状态/赠送阶段/验收模式,这三个没清。
3. 待办通知跳转(buildNotificationActionPath 带 ?workOrderId=)设下的
   「定位工单」是 wo.id = X 排他条件,不手点「取消定位」就一直生效,
   之后搜任何别的单号都是空 —— 这条路径历次修复从未涉及。
4. AdminLayout 把服务端 operation='remove' 直接判成 mismatch,
   绕过了「显式单号不删行」的护栏。
5. 打手端「我的订单」默认页签固定「代练中」,搜索不联动页签、
   实时补丁又拿状态当可证伪条件删行 → 打手端同类问题(此前只修了关键词匹配)。

## 修法(收口到单一真源,不再按按钮打补丁)

后端:
- 新增 isExplicitOrderNoLookupKeyword()(工单号 / 平台单号 / SUB·INV·AS·DT);
- 后台列表筛选抽成纯函数 resolveAdminWorkOrderListFilters():显式单号检索时
  不下推 状态/VIP待补图/验收模式/赠送阶段/接单人员/分类/打手等级/创建时间/定位工单;
- 打手端 resolveWorkerMyOrderStatusFilter():只放掉状态与 VIP 待补图,
  **严格保留 workerSharingId 可见范围**(否则打手能搜到别人的单)。

前端:
- admin-realtime-work-orders.ts:显式单号检索直接判 unknown(含服务端 remove),
  remove 的短路移交给判定函数;
- WorkOrdersPanel:单号检索时清空全部排他筛选并提示「已按单号精准检索,自动忽略…」,
  通知定位工单时也清掉其它筛选,避免残留;
- WorkerOrdersPage:单号检索自动切「全部订单」;WorkerLayout 走新的
  resolveWorkerMyOrderRealtimeVerdict(),与后台同策。

刻意不改(避免越权/召回不可用单):抢单大厅(候选必须是 open)、
寄卖手动匹配候选(只做未接单整单)。

## 验证

后端 npm run check 全绿:682 pass / 3 skip(新增 6 项);
前端 npm run check 全绿:138 pass(新增 6 项,含「单号检索 + 实时事件不得删行」,
以及「服务端 remove 不得绕过单号护栏」的回归);format/lint/typecheck/build 通过。
无数据库迁移。
This commit is contained in:
yml2213
2026-10-12 15:48:30 +08:00
parent 966b804bfc
commit fe4d02d136
13 changed files with 443 additions and 26 deletions
@@ -7,7 +7,9 @@ import {
sumPendingUnfreezeByOrderIds,
type ConsignmentInventoryRow,
} from '../../repositories/worker-platform/index.js'
import type { ListInput } from '../../repositories/worker-platform/types.js'
import type { JsonObject } from '../../types/json.js'
import { isExplicitOrderNoLookupKeyword } from '../../utils/platform-order-id.js'
import {
normalizeDateString,
normalizePage,
@@ -63,9 +65,37 @@ export async function getAdminWorkOrderEvents(workOrderId: number | string) {
return { items: await mapWorkOrderEvents(events, { includeWorkerSummary: true }) }
}
export async function listAdminWorkOrders(query: JsonObject = {}) {
const page = normalizePage(query.page)
const pageSize = normalizePageSize(query.pageSize)
/**
* 后台接单工单列表的筛选条件(纯函数,便于单测)。
*
* 「显式单号检索」是**排他**的:用户粘进来的是唯一单号,表达的是「我就要这一单」,
* 任何其它筛选都不该把它藏起来。线上反复出现的「按工单号搜不到 / 某些状态搜不到」
* 有三条成因,全部在这里收口:
* 1. 拼单母单一旦有人参与,运营态就从 open 派生为 in_progress / pending_acceptance
* (见 adminOperationalStatusExpression),于是「单号 + 待接单」必然搜不到——
* 没人参与时反而搜得到,这正是「有人接单后就搜不到」的现象;
* 2. 「单号 + 接单人员 / 分类 / 打手等级 / 创建时间 / 验收模式 / 赠送阶段」这类
* 排他筛选同样会把目标单过滤掉(前端搜索按钮只清了其中一部分);
* 3. 待办通知跳转留下的「定位工单」(workOrderId) 会一直生效,
* 之后搜任何别的单号都会被它挡掉(界面上只有一个小 Alert,极易忽略)。
* 所以单号检索时只保留关键字,其余筛选一律不下推。
*/
export function resolveAdminWorkOrderListFilters(query: JsonObject = {}): ListInput {
const keyword = String(query.keyword || '').trim()
if (isExplicitOrderNoLookupKeyword(keyword)) {
// 只按单号查:其余字段显式给空值,保证统计缓存指纹与列表口径一致。
return {
statuses: [],
workOrderId: 0,
categoryId: 0,
keyword,
workerSharingId: 0,
workerLevelIds: [],
vipEvidencePending: false,
useOperationalStatus: true,
}
}
const workOrderId = normalizeOptionalId(query.workOrderId ?? query.work_order_id) || 0
const categoryId = normalizeOptionalId(query.categoryId ?? query.category_id) || 0
const workerId = normalizeOptionalId(query.workerId ?? query.worker_id) || 0
@@ -78,11 +108,11 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
const createdTo = normalizeDateString(query.createdTo)
const acceptanceMode = String(query.acceptanceMode || '').trim()
const giftPhase = String(query.giftPhase || '').trim()
const filters = {
return {
statuses,
workOrderId,
categoryId,
keyword: String(query.keyword || '').trim(),
keyword,
workerSharingId: workerId,
workerLevelIds,
vipEvidencePending,
@@ -92,6 +122,12 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
...(createdFrom ? { createdFrom } : {}),
...(createdTo ? { createdTo } : {}),
}
}
export async function listAdminWorkOrders(query: JsonObject = {}) {
const page = normalizePage(query.page)
const pageSize = normalizePageSize(query.pageSize)
const filters = resolveAdminWorkOrderListFilters(query)
const [{ items }, statistics] = await Promise.all([
listWorkOrders({ page, pageSize, includeTotal: false, ...filters }),
workOrderStatisticsCache.getOrSet(buildWorkOrderStatisticsCacheKey(filters), () =>
@@ -0,0 +1,98 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { resolveAdminWorkOrderListFilters } from './admin-work-order-query-service.js'
import { resolveWorkerMyOrderStatusFilter } from './worker-my-order-service.js'
/**
* 这组用例锁的是线上反复复发的那个坑:**按工单号搜索却搜不到**。
*
* 生产现象(客服/管理后台「接单平台 → 接单工单」):
* - 拼单母单没人参与时按单号能搜到,一旦有人接单/参与就搜不到;
* - 非拼单的单子在某些状态下同样搜不到;
* - 修过几次都没断根(历次只在弹窗按钮和实时补丁上打补丁)。
*
* 根因不止一个,但都在「排他筛选把唯一单号藏起来」这一类里:
* 1. 拼单母单有份额后运营态由 open 派生为 in_progress / pending_acceptance,
* 「单号 + 待接单」组合必然为空;
* 2. 「单号 + 接单人员 / 分类 / 打手等级 / 创建时间 / 验收模式 / 赠送阶段」直接过滤掉目标单;
* 3. 待办通知跳转留下的「定位工单」会长期生效,之后搜任何单号都被它挡住。
*
* 因此规则统一收口:**显式单号检索时,除了可见范围与分页,不再下推任何筛选**。
*/
test('后台:显式单号检索时忽略状态等排他筛选,只按单号查', () => {
const filters = resolveAdminWorkOrderListFilters({
keyword: 'WO26101213031939',
status: 'open',
workerId: '7',
categoryId: '12',
workerLevelIds: '1,2',
acceptanceMode: 'internal_consignment',
giftPhase: 'gift_ready',
createdFrom: '2026-10-01',
createdTo: '2026-10-12',
workOrderId: '123',
vipEvidencePending: '1',
})
assert.equal(filters.keyword, 'WO26101213031939')
// 状态:拼单母单一旦有份额,运营态就不再是 open —— 必须放掉
assert.deepEqual(filters.statuses, [])
assert.equal(filters.vipEvidencePending, false)
// 接单人员:目标单可能根本不在这名打手的可见集合里
assert.equal(filters.workerSharingId, 0)
// 分类 / 等级 / 验收模式 / 赠送阶段 / 创建时间 / 定位工单
assert.equal(filters.categoryId, 0)
assert.deepEqual(filters.workerLevelIds, [])
assert.equal(filters.acceptanceMode, undefined)
assert.equal(filters.giftPhase, undefined)
assert.equal(filters.createdFrom, undefined)
assert.equal(filters.createdTo, undefined)
assert.equal(filters.workOrderId, 0)
})
test('后台:平台单号与关联单号同样享受单号检索的排他豁免', () => {
for (const keyword of ['2627502003065030', 'SUB26100301127318', 'INV26092619393769']) {
const filters = resolveAdminWorkOrderListFilters({ keyword, status: 'open', workerId: '7' })
assert.deepEqual(filters.statuses, [], `${keyword} 的状态筛选应被忽略`)
assert.equal(filters.workerSharingId, 0, `${keyword} 的接单人员筛选应被忽略`)
}
})
test('后台:普通关键词(商品名/昵称)仍按原样下推全部筛选', () => {
const filters = resolveAdminWorkOrderListFilters({
keyword: '暗潮',
status: 'open,in_progress',
workerId: '7',
categoryId: '12',
workerLevelIds: '1,2',
acceptanceMode: 'friend_gift',
giftPhase: 'friend_countdown',
createdFrom: '2026-10-01',
createdTo: '2026-10-12',
workOrderId: '123',
vipEvidencePending: '1',
})
assert.deepEqual(filters.statuses, ['open', 'in_progress'])
assert.equal(filters.vipEvidencePending, true)
assert.equal(filters.workerSharingId, 7)
assert.equal(filters.categoryId, 12)
assert.deepEqual(filters.workerLevelIds, [1, 2])
assert.equal(filters.acceptanceMode, 'friend_gift')
assert.equal(filters.giftPhase, 'friend_countdown')
assert.equal(filters.createdFrom, '2026-10-01')
assert.equal(filters.createdTo, '2026-10-12')
assert.equal(filters.workOrderId, 123)
})
test('打手端:显式单号检索忽略状态页签,但可见范围必须保留', () => {
const explicit = resolveWorkerMyOrderStatusFilter('WO26101213031939', ['in_progress'], true)
assert.deepEqual(explicit.statuses, [])
assert.equal(explicit.vipEvidencePending, false)
// 普通关键词保持原状态筛选(否则「代练中」页签会突然显示别的状态)
const fuzzy = resolveWorkerMyOrderStatusFilter('暗潮', ['in_progress'], true)
assert.deepEqual(fuzzy.statuses, ['in_progress'])
assert.equal(fuzzy.vipEvidencePending, true)
})
@@ -23,6 +23,7 @@ import {
} from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { isExplicitOrderNoLookupKeyword } from '../../utils/platform-order-id.js'
import { nowIso } from '../../utils/time.js'
import { normalizePage, normalizePageSize } from '../admin/admin-query-utils.js'
import { getWorkerAnnouncementConfig } from './worker-announcement-config-service.js'
@@ -61,6 +62,27 @@ import {
* 否则逾期结算会被跳过。
*/
/**
* 打手「我的订单」的状态筛选项(纯函数,便于单测)。
*
* 管理后台的「显式单号检索」规则同样适用:粘进来的工单号就是要找的那一单,
* 前端的状态页签(默认停在「代练中」)不该把它挡掉——打手按工单号查自己的单,
* 却因为单据已流转到「待验收 / 已验收 / 问题单」而搜不到,线上同样出现过。
*
* 注意:这里**只**放掉状态与 VIP 待补图这类展示筛选,
* 可见范围 workerSharingId 必须保留,否则打手能搜到别人的单。
*/
export function resolveWorkerMyOrderStatusFilter(
keyword: string,
statuses: string[],
vipEvidencePending: boolean,
): { statuses: string[]; vipEvidencePending: boolean } {
if (isExplicitOrderNoLookupKeyword(keyword)) {
return { statuses: [], vipEvidencePending: false }
}
return { statuses, vipEvidencePending }
}
export async function listWorkerMyOrders(query: JsonObject = {}, session: WorkerSession) {
requireActiveWorkerSession(session)
const worker = await getRequiredWorker(session.workerId)
@@ -70,21 +92,26 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
const permissions = resolveWorkerPermissions(worker)
const statuses = normalizeStatuses(query.status)
const keyword = String(query.keyword || '').trim()
const effectiveStatusFilter = resolveWorkerMyOrderStatusFilter(
keyword,
statuses,
vipEvidencePending,
)
const cacheKey = buildMyOrderCacheKey(worker.id, 'list', [
page,
pageSize,
statuses,
effectiveStatusFilter.statuses,
keyword,
vipEvidencePending,
effectiveStatusFilter.vipEvidencePending,
])
return myOrderListCache.getOrSet(cacheKey, async () => {
const { items, total } = await listWorkOrders({
page,
pageSize,
statuses,
statuses: effectiveStatusFilter.statuses,
keyword,
workerSharingId: worker.id,
vipEvidencePending,
vipEvidencePending: effectiveStatusFilter.vipEvidencePending,
sort: 'worker_claimed_at_desc',
})
const workOrderIds = items.map((item) => Number(item.id))
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import {
isExplicitOrderNoLookupKeyword,
isPlatformOrderIdLookupKeyword,
isWorkOrderNoLookupKeyword,
resolveRelatedWorkOrderLookupKind,
@@ -59,3 +60,34 @@ test('关联单号识别不得误伤工单号、平台单号与普通关键词',
assert.equal(resolveRelatedWorkOrderLookupKind(''), null)
assert.equal(resolveRelatedWorkOrderLookupKind(null), null)
})
/**
* 显式单号检索的总判定。后端用它决定「不下推排他筛选」,前端用它决定「不删行」,
* 两边覆盖的范围必须一致,否则又会出现「后台搜不到 / 行被删掉」这类只修一半的问题。
*/
test('显式单号检索覆盖工单号 / 平台单号 / 关联单号', () => {
for (const orderNo of [
'WO26101213031939',
'wo26101213031939',
' WO26101213031939 ',
'WOb320ee0b39b9',
'20247230034021572',
'260915-091435834962130',
'SUB26100301127318',
'INV26092619393769',
'ASde06bcb25aca',
'DTb946808bcd5b',
]) {
assert.equal(isExplicitOrderNoLookupKeyword(orderNo), true, `${orderNo} 应识别为显式单号`)
}
})
test('显式单号检索不得误判商品名 / 昵称 / 短词', () => {
for (const keyword of ['暗潮', '无限潮汐', '皮肤店', 'WO12', '12345', 'SUBWAY', '', null]) {
assert.equal(
isExplicitOrderNoLookupKeyword(keyword),
false,
`${String(keyword)} 不应识别为显式单号`,
)
}
})
@@ -15,6 +15,24 @@ export function isWorkOrderNoLookupKeyword(value: unknown): boolean {
return /^WO[0-9a-zA-Z]{4,}$/i.test(trimmed)
}
/**
* 是否为「显式单号检索」——工单号 / 平台单号 / 关联单号(SUB/INV/AS/DT)。
*
* 语义:用户粘进来的是唯一单号,表达的是「我就要这一单」。
* 前端(utils/admin-realtime-work-orders.ts 的 isExplicitOrderNoSearch)用它保证
* 实时快照补丁不把正在看的行删掉;后端用它保证排他性筛选不把这单藏起来。
* 两侧必须覆盖同一批形态,改这里就要同步改那边(反之亦然)。
*/
export function isExplicitOrderNoLookupKeyword(value: unknown): boolean {
const trimmed = String(value || '').trim()
if (!trimmed) return false
return (
isWorkOrderNoLookupKeyword(trimmed) ||
isPlatformOrderIdLookupKeyword(trimmed) ||
resolveRelatedWorkOrderLookupKind(trimmed) !== null
)
}
/**
* 关联单号类型:号码本身不在 work_orders 上,必须经关联表反查工单 id。
* - consignment_submission: 寄售线索号 SUB... → 产出的库存 → 被工单消耗后的工单