fix(worker-platform): 工单号前缀改 WK 并在检索侧容错 O/0,彻底解决「按工单号搜不到」

## 线上定位到的真正根因

客服搜 `WO26101213031939` 搜不到,但用同单的平台号 `2628501763808879` 搜得到。
到生产库核对后确认:

- 库里真实值 `work_order_no = WO26101213031939`(hex 574f… = 字母 O),状态 pending_acceptance;
- 用字母 O 的 SQL 分支 → 1 行;用数字 0(`W026101213031939`)→ 0 行;
- 全库 127,906 单**全部**以 `WO` 开头、`W0` 开头 0 单、字母 `o` 只出现在第 2 位。

即:前缀里的字母 O 被输成数字 0(`WO` / `W0` 在界面字体里几乎无法区分),
落进通配符分支后必然为空 —— 与前面几轮的「筛选把单藏起来」是两个独立成因,
这也解释了为什么改筛选逻辑一直没解决。

## 修法

1. 生成端换前缀:generateWorkOrderNo 产出 `WK…`(K 与 0 不会看混),从源头消掉歧义;
2. 检索端同时兼容三种写法(历史 WO 号无需迁移):
   - isWorkOrderNoLookupKeyword 放宽为 `^W[oOkK0]…`;
   - 新增 buildWorkOrderNoKeywordVariants:W0→[W0, WO, WK]、WO→[WO, W0]、WK→[WK],
     SQL 分支对每个候选下发「等值 + 前缀」条件(仍是索引条件,不会退化成全表扫);
   - 前端 isExplicitOrderNoSearch 同步(否则面板不清理筛选、实时补丁护栏也不生效);
3. 顺手加一层归一化:NFKC(全角→半角)+ 去零宽字符,前后端同一口径
   (normalizeLookupKeyword / normalizeSearchText),覆盖「粘贴带不可见字符」这类同族问题;
4. 寄卖匹配弹窗的占位提示更新为 WK... / WO...。

## 验证

- 后端 npm run check 全绿:692 tests(689 pass / 3 skip),新增
  work-order-keyword-lookup.test.ts 直接断言 SQL 参数与「不得出现前置通配符」;
- 前端 npm run check 全绿:139 tests;
- 既有用例 worker-platform-repo.test.ts 的期望值同步为新形态(两条候选条件)。
This commit is contained in:
yml2213
2026-10-12 16:51:08 +08:00
parent 56ca479c6b
commit cb064d7cd5
14 changed files with 251 additions and 41 deletions
@@ -152,15 +152,20 @@ test('平台单号检索只生成可走索引的等值/前缀条件,不得出
assert.deepEqual(query.params, ['2627401680611492'])
})
test('工单号检索大小写不敏感且只走索引,不得出现前置通配符', () => {
test('工单号检索大小写不敏感且只走索引,不得出现前置通配符(含 W0/WK 前缀容错)', () => {
const query = buildWorkOrderWhere({ keyword: 'WO26100113008697' })
assert.match(
query.whereClause,
/\(LOWER\(wo\.work_order_no\) = LOWER\(\$1\) OR LOWER\(wo\.work_order_no\) LIKE LOWER\(\$1\) \|\| '%'\)/,
)
// 前缀位的 O 与 0 视觉上分不清,两种写法都要下发;两者仍是等值/前缀条件(可走索引)
assert.match(
query.whereClause,
/\(LOWER\(wo\.work_order_no\) = LOWER\(\$2\) OR LOWER\(wo\.work_order_no\) LIKE LOWER\(\$2\) \|\| '%'\)/,
)
assert.doesNotMatch(query.whereClause, /ILIKE\s*'%'/)
assert.deepEqual(query.params, ['WO26100113008697'])
assert.deepEqual(query.params, ['WO26100113008697', 'W026100113008697'])
})
test('SUB/INV/AS/DT 关联单号经关联表反查工单,不回落到通配符', () => {
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { buildWorkOrderWhere } from './work-order-query-repo.js'
/**
* 锁住「按工单号搜索」的前缀容错。
*
* 生产事故:一个订单用平台号 2628501763808879 搜得到,用工单号搜不到——客服把
* `WO26101213031939` 输成了 `W026101213031939`(字母 O 看成数字 0,字体里几乎一样)。
* 生成端已改为 `WK` 前缀(不再有歧义),历史 `WO…` 号继续兼容,
* 检索侧对 W0 / WO / WK 三种写法都要命中,且必须保持可走索引。
*/
function keywordConditions(keyword: string) {
const { whereClause, params } = buildWorkOrderWhere({ keyword } as never)
return { whereClause: whereClause.replace(/\s+/g, ' '), params }
}
test('输入 W0… 时同时匹配 W0 / WO / WK 三种写法', () => {
const { whereClause, params } = keywordConditions('W026101213031939')
assert.deepEqual(params, ['W026101213031939', 'WO26101213031939', 'WK26101213031939'])
// 三个候选各自都是「等值 + 前缀」两个条件
assert.equal((whereClause.match(/LOWER\(wo\.work_order_no\) = LOWER\(\$\d+\)/g) || []).length, 3)
assert.equal((whereClause.match(/LIKE LOWER\(\$\d+\) \|\| '%'/g) || []).length, 3)
// 关键:不得出现前置通配符(会让 B-Tree 失效、退化成全表扫)
assert.ok(!whereClause.includes("'%' ||"), '工单号分支不能出现前置通配符')
})
test('输入 WO… 时补一个 W0… 候选(历史号被误读的情况)', () => {
const { whereClause, params } = keywordConditions('WO26101213031939')
assert.deepEqual(params, ['WO26101213031939', 'W026101213031939'])
assert.ok(whereClause.includes('LOWER(wo.work_order_no)'))
})
test('输入 WK… 时前缀无歧义,只查一种写法', () => {
const { whereClause, params } = keywordConditions('WK26101213031939')
assert.deepEqual(params, ['WK26101213031939'])
assert.equal((whereClause.match(/LOWER\(wo\.work_order_no\) = LOWER\(\$\d+\)/g) || []).length, 1)
})
test('关键词归一化:全角 / 零宽字符不影响命中', () => {
const withZeroWidth = keywordConditions('WK26101213031939\u200b')
assert.deepEqual(withZeroWidth.params, ['WK26101213031939'])
const fullWidth = keywordConditions('WK26101213031939')
assert.deepEqual(fullWidth.params, ['WK26101213031939'])
})
test('普通关键词仍走模糊搜索,不受单号容错影响', () => {
const { whereClause, params } = keywordConditions('暗潮')
assert.deepEqual(params, ['%暗潮%'])
assert.ok(whereClause.includes('LIKE LOWER($1)'))
assert.ok(!whereClause.includes('work_order_no) = LOWER'), '商品名不应误入工单号等值分支')
})
@@ -7,8 +7,10 @@ import {
WORK_ORDER_GIFT_PHASE,
} from '../../domain/work-order-acceptance-mode.js'
import {
buildWorkOrderNoKeywordVariants,
isPlatformOrderIdLookupKeyword,
isWorkOrderNoLookupKeyword,
normalizeLookupKeyword,
resolveRelatedWorkOrderLookupKind,
type RelatedWorkOrderLookupKind,
} from '../../utils/platform-order-id.js'
@@ -1094,7 +1096,8 @@ export function buildWorkOrderWhere({
const filters: string[] = []
const params: unknown[] = []
const joins: string[] = []
const normalizedKeyword = String(keyword || '').trim()
// 归一化:NFKC(全角→半角)+ 去零宽字符 + trim,避免"贴对了却搜不到"。
const normalizedKeyword = normalizeLookupKeyword(keyword)
if (createdFrom) {
params.push(createdFrom)
filters.push(
@@ -1249,13 +1252,18 @@ export function buildWorkOrderWhere({
`(wo.platform_order_id = ${paramIndex} OR wo.platform_order_id LIKE ${paramIndex} || '%')`,
)
} else if (isWorkOrderNoLookupKeyword(normalizedKeyword)) {
params.push(normalizedKeyword)
const paramIndex = `$${params.length}`
// 工单号含随机小写段(10.2 万行里 7.4 万行非全大写),必须 LOWER() 比较;
// 配套迁移 090 的 idx_work_orders_work_order_no_lower_pattern 才能让前缀检索走范围扫描。
filters.push(
`(LOWER(wo.work_order_no) = LOWER(${paramIndex}) OR LOWER(wo.work_order_no) LIKE LOWER(${paramIndex}) || '%')`,
// 工单号同时匹配 `WK…`(现役)/`WO…`(历史)/`W0…`(把 O 看成 0)三种写法:
// 单号检索的入口是人手输入,前缀位上的 O/0 极易混淆(生产真实投诉)。
// 候选写法都是「等值 + 前缀」,仍然走 idx_work_orders_work_order_no_lower_pattern,
// 不会像通配符那样退化成全表扫。
const variantConditions = buildWorkOrderNoKeywordVariants(normalizedKeyword).map(
(variant) => {
params.push(variant)
const variantParam = `$${params.length}`
return `(LOWER(wo.work_order_no) = LOWER(${variantParam}) OR LOWER(wo.work_order_no) LIKE LOWER(${variantParam}) || '%')`
},
)
filters.push(`(${variantConditions.join(' OR ')})`)
} else if (relatedLookupKind) {
// 关联单号(SUB/INV/AS/DT)本身不在 work_orders 上,必须先经关联表反查工单 id。
// 四条链路都命中唯一索引,属于索引驱动的等值查询;查不到时直接返回空集,
@@ -9,7 +9,10 @@ import {
} 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 {
isExplicitOrderNoLookupKeyword,
normalizeLookupKeyword,
} from '../../utils/platform-order-id.js'
import {
normalizeDateString,
normalizePage,
@@ -81,7 +84,7 @@ export async function getAdminWorkOrderEvents(workOrderId: number | string) {
* 所以单号检索时只保留关键字,其余筛选一律不下推。
*/
export function resolveAdminWorkOrderListFilters(query: JsonObject = {}): ListInput {
const keyword = String(query.keyword || '').trim()
const keyword = normalizeLookupKeyword(query.keyword)
if (isExplicitOrderNoLookupKeyword(keyword)) {
// 只按单号查:其余字段显式给空值,保证统计缓存指纹与列表口径一致。
return {
@@ -6,6 +6,7 @@ import {
listWorkOrders,
} from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js'
import { normalizeLookupKeyword } from '../../utils/platform-order-id.js'
import { normalizePage, normalizePageSize } from '../admin/admin-query-utils.js'
import { refreshStoredFileUrl } from '../file-storage/file-storage-service.js'
import {
@@ -44,7 +45,7 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
const permissions = resolveWorkerPermissions(worker)
const visibleDelaySeconds = Number(permissions.visibleDelaySeconds || 0)
const hallConfig = getWorkerHallConfig()
const keyword = String(query.keyword || '').trim()
const keyword = normalizeLookupKeyword(query.keyword)
const cacheKey = JSON.stringify([worker.id, page, pageSize, keyword, categoryId || 0])
return hallListCache.getOrSet(cacheKey, async () => {
const visibleAfterIso =
@@ -23,7 +23,10 @@ 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 {
isExplicitOrderNoLookupKeyword,
normalizeLookupKeyword,
} 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'
@@ -91,7 +94,7 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
const vipEvidencePending = normalizeBoolean(query.vipEvidencePending, false)
const permissions = resolveWorkerPermissions(worker)
const statuses = normalizeStatuses(query.status)
const keyword = String(query.keyword || '').trim()
const keyword = normalizeLookupKeyword(query.keyword)
const effectiveStatusFilter = resolveWorkerMyOrderStatusFilter(
keyword,
statuses,
@@ -2,9 +2,11 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildWorkOrderNoKeywordVariants,
isExplicitOrderNoLookupKeyword,
isPlatformOrderIdLookupKeyword,
isWorkOrderNoLookupKeyword,
normalizeLookupKeyword,
resolveRelatedWorkOrderLookupKind,
} from './platform-order-id.js'
@@ -19,12 +21,24 @@ test('识别拼多多带连字符的 order_sn', () => {
assert.equal(isPlatformOrderIdLookupKeyword('260912-123456789012345'), true)
})
test('识别统一内部工单号 (WO...)', () => {
test('识别统一内部工单号 (WK... 现役 / WO... 历史 / W0... 误输)', () => {
// 现役前缀
assert.equal(isWorkOrderNoLookupKeyword('WK26101213031939'), true)
assert.equal(isWorkOrderNoLookupKeyword('wk26101213031939'), true)
// 历史前缀
assert.equal(isWorkOrderNoLookupKeyword('WOb320ee0b39b9'), true)
assert.equal(isWorkOrderNoLookupKeyword('wob320ee0b39b9'), true)
assert.equal(isWorkOrderNoLookupKeyword('woB320EE0B39B9'), true)
assert.equal(isWorkOrderNoLookupKeyword('WO26091216358821'), true)
// 把前缀里的 O 看成 0 的误输(线上真实案例:用平台号搜得到、用工单号搜不到)
assert.equal(isWorkOrderNoLookupKeyword('W026101213031939'), true)
// 全角与零宽字符
assert.equal(isWorkOrderNoLookupKeyword('WK26101213031939'), true)
assert.equal(isWorkOrderNoLookupKeyword('WK26101213031939\u200b'), true)
// 负例
assert.equal(isWorkOrderNoLookupKeyword('WO12'), false)
assert.equal(isWorkOrderNoLookupKeyword('WK1'), false)
assert.equal(isWorkOrderNoLookupKeyword('20247230034021572'), false)
assert.equal(isWorkOrderNoLookupKeyword('WK'), false)
})
test('拒绝普通关键词', () => {
@@ -91,3 +105,32 @@ test('显式单号检索不得误判商品名 / 昵称 / 短词', () => {
)
}
})
/**
* 工单号检索的候选写法。前缀位是唯一可能出现 O/0 混淆的地方(生成器产出 `WO/WK` + 数字),
* 所以只在这一位展开候选,且每个候选都是等值/前缀条件,保持可走索引。
*/
test('工单号候选写法:W0 同时展开 WO 与 WK,WO 补 W0,WK 不再展开', () => {
assert.deepEqual(buildWorkOrderNoKeywordVariants('W026101213031939'), [
'W026101213031939',
'WO26101213031939',
'WK26101213031939',
])
assert.deepEqual(buildWorkOrderNoKeywordVariants('WO26101213031939'), [
'WO26101213031939',
'W026101213031939',
])
assert.deepEqual(buildWorkOrderNoKeywordVariants('WK26101213031939'), ['WK26101213031939'])
// 大小写与前缀字母保持原样(SQL 侧统一 LOWER 比较)
assert.deepEqual(buildWorkOrderNoKeywordVariants('wk2601'), ['wk2601'])
assert.deepEqual(buildWorkOrderNoKeywordVariants('w02601'), ['w02601', 'wO2601', 'wK2601'])
assert.deepEqual(buildWorkOrderNoKeywordVariants(''), [])
})
test('关键词归一化:全角转半角、去掉零宽字符、去首尾空白', () => {
assert.equal(normalizeLookupKeyword(' WK26101213031939 '), 'WK26101213031939')
assert.equal(normalizeLookupKeyword('WK26101213031939\u200b\u200d\ufeff'), 'WK26101213031939')
assert.equal(normalizeLookupKeyword('WO26101213031939\u00a0'), 'WO26101213031939')
assert.equal(normalizeLookupKeyword(null), '')
assert.equal(normalizeLookupKeyword(undefined), '')
})
+56 -13
View File
@@ -1,18 +1,61 @@
const ZERO_WIDTH_CHARS = /[\u200b-\u200d\ufeff]/g
/**
* 检索关键词归一化:NFKC(全角字母/数字 → 半角)+ 去掉零宽字符 + trim。
*
* 为什么要做:单号常被从网页、Excel、聊天记录里复制过来,很容易夹带
* 零宽字符(U+200B/FEFF 等)或全角字母数字——它们肉眼不可见,
* 但会让等值/前缀匹配全部落空,表现就是「明明贴对了却搜不到」。
*/
export function normalizeLookupKeyword(value: unknown): string {
return String(value ?? '')
.normalize('NFKC')
.replace(ZERO_WIDTH_CHARS, '')
.trim()
}
/**
* Detect platform order IDs that should use indexed equality/prefix lookup.
* Covers pure numeric IDs (快手等) and PDD style `digits-digits` (e.g. 260915-091435834962130).
*/
export function isPlatformOrderIdLookupKeyword(value: unknown): boolean {
const trimmed = String(value || '').trim()
return /^\d{6,}(-\d{6,})?$/.test(trimmed)
return /^\d{6,}(-\d{6,})?$/.test(normalizeLookupKeyword(value))
}
/**
* 识别内部统一工单号 (以 WO 开头且至少 4 位字符,如 WOb320ee0b39b9、WO26091216358821)。
* 识别内部统一工单号(前缀 + 至少 4 位字符,如 WK26101213031939、WO26091216358821)。
*
* 前缀刻意放宽为 `W[oOkK0]`:
* - 现役前缀是 `WK`(生成端 utils/random.generateWorkOrderNo),与数字 0 视觉不会混;
* - 历史前缀是 `WO`,而字母 O 与数字 0 在多数界面字体里几乎无法区分,
* 用户手输/转抄时极易写成 `W0…`(线上真实案例)。
* 实测生产 12.7 万单全部以 `WO` 开头、字母 `o` 只出现在第 2 位,
* 所以把 `W0…` 也认成工单号不会误伤别的东西。
*/
export function isWorkOrderNoLookupKeyword(value: unknown): boolean {
const trimmed = String(value || '').trim()
return /^WO[0-9a-zA-Z]{4,}$/i.test(trimmed)
return /^W[oOkK0][0-9a-zA-Z]{4,}$/i.test(normalizeLookupKeyword(value))
}
/**
* 工单号检索的候选写法:第 2 位上的 O / 0 / K 互为可能,全部匹配。
*
* - 输入 `W0…` → 可能是 `WO…`(历史号,用户把 O 看成 0)也可能是 `WK…`(现役号),两个都试;
* - 输入 `WO…` → 只补 `W0…`(0 与 O 视觉混淆);
* - 输入 `WK…` → 前缀无歧义,不补。
*
* 只在第 2 位做替换:生成器产出的是 `WK/WO` + 数字,历史 hex 形态号码里 `o` 也只在第 2 位。
* 所有候选都是等值/前缀条件,仍然走 idx_work_orders_work_order_no_lower_pattern,
* 不会像通配符那样退化成全表扫。
*/
export function buildWorkOrderNoKeywordVariants(value: unknown): string[] {
const normalized = normalizeLookupKeyword(value)
if (!normalized) return []
const first = normalized.slice(0, 1)
const second = normalized.slice(1, 2)
const rest = normalized.slice(2)
const alternates = second === '0' ? ['O', 'K'] : second === 'o' || second === 'O' ? ['0'] : []
return [normalized, ...alternates.map((letter) => `${first}${letter}${rest}`)]
}
/**
@@ -24,12 +67,12 @@ export function isWorkOrderNoLookupKeyword(value: unknown): boolean {
* 两侧必须覆盖同一批形态,改这里就要同步改那边(反之亦然)。
*/
export function isExplicitOrderNoLookupKeyword(value: unknown): boolean {
const trimmed = String(value || '').trim()
if (!trimmed) return false
const normalized = normalizeLookupKeyword(value)
if (!normalized) return false
return (
isWorkOrderNoLookupKeyword(trimmed) ||
isPlatformOrderIdLookupKeyword(trimmed) ||
resolveRelatedWorkOrderLookupKind(trimmed) !== null
isWorkOrderNoLookupKeyword(normalized) ||
isPlatformOrderIdLookupKeyword(normalized) ||
resolveRelatedWorkOrderLookupKind(normalized) !== null
)
}
@@ -71,10 +114,10 @@ const RELATED_WORK_ORDER_PATTERNS: Array<{
export function resolveRelatedWorkOrderLookupKind(
value: unknown,
): RelatedWorkOrderLookupKind | null {
const trimmed = String(value || '').trim()
if (!trimmed) return null
const normalized = normalizeLookupKeyword(value)
if (!normalized) return null
for (const item of RELATED_WORK_ORDER_PATTERNS) {
if (item.pattern.test(trimmed)) {
if (item.pattern.test(normalized)) {
return item.kind
}
}
+10 -3
View File
@@ -9,8 +9,15 @@ export function randomId(prefix: string, bytes = 6): string {
}
/**
* Generate formatted internal work order number
* 生成系统标准内部工单号,如:WO26091216358821 (WO + 年月日 + 4位时分 + 4位随机)
* 生成统一工单号,形如 `WK26101213031939`。
*
* 前缀用 `WK` 而不是 `WO`:字母 O 与数字 0 在绝大多数界面字体里几乎无法区分,
* 客服/运营手输或转抄时会把 `WO…` 写成 `W0…`,线上真实出现过「按工单号搜不到」
* 的反复投诉(一个订单用平台号搜得到,用工单号搜不到)。
* `WK` 与 `0` 视觉上不会混淆,从生成端消掉这个歧义。
*
* 历史号码(WO…)无需迁移,检索侧继续兼容:见 utils/platform-order-id.ts
* 的 isWorkOrderNoLookupKeyword / buildWorkOrderNoKeywordVariants(W O W 0 W K 都认)。
*/
export function generateWorkOrderNo(date: Date = new Date()): string {
const yy = String(date.getFullYear()).slice(-2)
@@ -19,7 +26,7 @@ export function generateWorkOrderNo(date: Date = new Date()): string {
const hh = String(date.getHours()).padStart(2, '0')
const min = String(date.getMinutes()).padStart(2, '0')
const rand = Math.floor(1000 + Math.random() * 9000)
return `WO${yy}${mm}${dd}${hh}${min}${rand}`
return `WK${yy}${mm}${dd}${hh}${min}${rand}`
}
/**