fix(consignment): 撤销寄售履约回库分支必 500 —— SQL 参数比占位符多一个

线上客服 kf_13 撤销 WO26092609475746 时返回 500 internal_error,
真实库报:bind message supplies 5 parameters, but prepared statement "" requires 4。

根因:解绑现货的两条分支共用一个 5 元参数数组,但回库/取消分支的 SQL 只用到
$1..$4(第 5 个「备注前缀」只有作废分支才用)。于是运营只要选
「退单回库并恢复工单为未指派」或「退单回库并取消工单」就必定 500,
而假 client 的单测与类型检查都看不到这类错。

修复:
- 解绑 SQL 与参数抽到 consignment-revoke-inventory-sql.ts 一起构造,
  回库/取消分支只传 4 个参数,作废分支才多带备注前缀($5);
- 单测锁定「占位符个数 == 参数个数」,覆盖 reopen / archive_inventory /
  已确认加好友自动作废三条分支;
- db 层加参数个数自检(db/sql-arity.ts + db/client.ts):参数比占位符多时
  提前抛出带 SQL 片段的错误,不再让 pg 的 bind 报错裸奔,
  并跳过字符串/注释/dollar-quote 里的 $n,避免误杀。

验证:本地影子库端到端跑通三条分支(工单 100140 + 现货 INV26091917439822),
并复现修复前的 bind 报错;全量单测 588 通过,typecheck/lint/build 均通过。
This commit is contained in:
yml2213
2026-10-03 16:03:14 +08:00
parent 8ecb8a879a
commit 30ae35d3a5
7 changed files with 369 additions and 37 deletions
+24
View File
@@ -3,6 +3,7 @@ import type { PoolClient, QueryResult, QueryResultRow } from 'pg'
import { runtimeConfig } from '../config/runtime.js'
import { logWarn } from '../utils/logger.js'
import { assertSqlParamArity } from './sql-arity.js'
let poolInstance: Pool | null = null
@@ -21,6 +22,9 @@ export function getDb(): Pool {
error: error instanceof Error ? error.message : String(error),
})
})
// 参数个数自检:参数比占位符多时 PostgreSQL 只会回一句难定位的 bind 报错,
// 这里在发往数据库之前就把出错的 SQL 片段带出来(见 sql-arity.ts)。
pool.on('connect', (client) => guardSqlParamArity(client))
poolInstance = pool
}
@@ -73,6 +77,26 @@ function fingerprintSql(text: string) {
.slice(0, 500)
}
/**
* 给池里的每条物理连接挂上「参数个数自检」。
*
* pg 的 client.query 是原型方法,这里绑一份原方法后挂到实例属性上包装,
* 事务里直接调 client.query 的仓库代码同样会被拦到。
*/
function guardSqlParamArity(client: PoolClient) {
const originalQuery = client.query.bind(client)
client.query = ((...args: unknown[]) => {
const [first, second] = args
if (first && typeof first === 'object') {
const config = first as { text?: unknown; values?: unknown }
assertSqlParamArity(config.text, config.values)
} else if (typeof second !== 'function') {
assertSqlParamArity(first, second)
}
return (originalQuery as unknown as (...inner: unknown[]) => unknown)(...args)
}) as unknown as PoolClient['query']
}
export async function withTransaction<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await getDb().connect()
+54
View File
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { assertSqlParamArity, maxSqlPlaceholderIndex } from './sql-arity.js'
test('统计最大占位符序号', () => {
assert.equal(maxSqlPlaceholderIndex('SELECT 1'), 0)
assert.equal(maxSqlPlaceholderIndex('SELECT * FROM t WHERE id = $1'), 1)
assert.equal(maxSqlPlaceholderIndex('INSERT INTO t (a, b, c) VALUES ($1, $2, $3)'), 3)
// 只用到 $1/$4 时,PostgreSQL 要求 4 个参数($2/$3 空着)
assert.equal(maxSqlPlaceholderIndex('UPDATE t SET a = $1 WHERE b = $4'), 4)
})
test('字符串字面量、注释、dollar-quote 里的 $n 不算占位符', () => {
assert.equal(maxSqlPlaceholderIndex("SELECT note FROM t WHERE note = '$1'"), 0)
assert.equal(maxSqlPlaceholderIndex("SELECT note FROM t WHERE note = 'it''s $9'"), 0)
assert.equal(maxSqlPlaceholderIndex("SELECT e FROM t WHERE e = E'\\$7'"), 0)
assert.equal(maxSqlPlaceholderIndex('SELECT 1 -- $8\nWHERE id = $1'), 1)
assert.equal(maxSqlPlaceholderIndex('SELECT 1 /* $9 */ WHERE id = $2'), 2)
assert.equal(maxSqlPlaceholderIndex('SELECT "col$5" FROM t WHERE id = $1'), 1)
assert.equal(maxSqlPlaceholderIndex('SELECT $$ $6 $$ FROM t WHERE id = $1'), 1)
assert.equal(maxSqlPlaceholderIndex('SELECT $tag$ $7 $tag$ FROM t WHERE id = $1'), 1)
})
test('参数比占位符多时必须提前报错(撤销回库分支的线上 500)', () => {
const reopenSql = `
UPDATE consignment_inventories
SET remark = remark || ' [撤销回库: ' || $1 || ']', updated_at = $2
WHERE id = $3 AND status = 'consumed' AND bound_work_order_id = $4
`
assert.throws(
() => assertSqlParamArity(reopenSql, ['原因', '时间', 23, 100140, ' [交付异常作废: ']),
/提供了 5 个参数,语句最多只用到 \$4/,
)
// 对齐参数后放行;作废分支带 $5 时 5 个参数同样放行
assert.doesNotThrow(() => assertSqlParamArity(reopenSql, ['原因', '时间', 23, 100140]))
assert.doesNotThrow(() =>
assertSqlParamArity('UPDATE t SET remark = $1 || $5, updated_at = $2 WHERE id = $3', [
'r',
'n',
1,
2,
'p',
]),
)
})
test('占位符比参数多时不在这一层拦(交给 PostgreSQL 报 no parameter)', () => {
// SQL 文本里出现字面量 $9 时容易误判,宁可漏报也不错杀正常查询
assert.doesNotThrow(() => assertSqlParamArity('SELECT * FROM t WHERE id = $1 AND x = $9', [1]))
assert.doesNotThrow(() => assertSqlParamArity('SELECT 1', []))
assert.doesNotThrow(() => assertSqlParamArity('BEGIN', undefined))
})
+129
View File
@@ -0,0 +1,129 @@
/**
* SQL 占位符与参数个数自检。
*
* node-pg 会把参数数组原样交给 PostgreSQL:参数比占位符多时,服务端直接报
* `bind message supplies 5 parameters, but prepared statement "" requires 4`。
* 这类错是"只在真实库上才暴露的 500"——假 client 的单测跑不出来,
* 类型检查也看不出来(撤销寄售履约回库分支就是这样炸的)。
* 所以在 db 层做一次常数级自检,把原始 pg 报错换成能直接定位的提示。
*/
/**
* 统计 SQL 里最大的 `$n` 占位符序号(没有占位符返回 0)。
*
* 字符串字面量、注释、dollar-quote 里的 `$n` 不参与绑定,必须跳过:
* 否则 `WHERE note = '$1'` 这类写法会被误判成占位符。
*/
export function maxSqlPlaceholderIndex(text: string): number {
let max = 0
let i = 0
const length = text.length
while (i < length) {
const char = text[i]
const next = text[i + 1]
// 行注释
if (char === '-' && next === '-') {
const end = text.indexOf('\n', i + 2)
i = end === -1 ? length : end + 1
continue
}
// 块注释(PG 允许嵌套)
if (char === '/' && next === '*') {
let depth = 1
i += 2
while (i < length && depth > 0) {
if (text[i] === '/' && text[i + 1] === '*') {
depth += 1
i += 2
} else if (text[i] === '*' && text[i + 1] === '/') {
depth -= 1
i += 2
} else {
i += 1
}
}
continue
}
// 单引号字符串:E'' 里反斜杠也能转义,普通字符串里只有 '' 才是转义
if (char === "'") {
const prefix = text[i - 1]
const escapeString =
(prefix === 'E' || prefix === 'e') && !/[A-Za-z0-9_]/.test(text[i - 2] || ' ')
i += 1
while (i < length) {
if (escapeString && text[i] === '\\') {
i += 2
continue
}
if (text[i] === "'") {
if (text[i + 1] === "'") {
i += 2
continue
}
i += 1
break
}
i += 1
}
continue
}
// 双引号标识符:"列名" 里的 $n 不是占位符
if (char === '"') {
i += 1
while (i < length) {
if (text[i] === '"') {
if (text[i + 1] === '"') {
i += 2
continue
}
i += 1
break
}
i += 1
}
continue
}
// dollar-quote:$$...$$ 或 $tag$...$tag$
if (char === '$') {
const tagMatch = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(text.slice(i))
if (tagMatch) {
const closer = tagMatch[0]
const end = text.indexOf(closer, i + closer.length)
i = end === -1 ? length : end + closer.length
continue
}
const digits = /^\$(\d+)/.exec(text.slice(i))
if (digits) {
max = Math.max(max, Number(digits[1]))
i += digits[0].length
continue
}
}
i += 1
}
return max
}
/**
* 参数比占位符多时提前报错(PostgreSQL 同样会报,只是信息难定位)。
*
* 反方向(占位符比参数多)不在这里拦:SQL 文本里出现字面量 `$5` 时容易误判,
* 交给 PostgreSQL 自己报 "there is no parameter $5"。
*/
export function assertSqlParamArity(text: unknown, params: unknown): void {
if (typeof text !== 'string' || !Array.isArray(params) || params.length === 0) return
const max = maxSqlPlaceholderIndex(text)
if (max >= params.length) return
throw new Error(
`SQL 参数个数与占位符不匹配:提供了 ${params.length} 个参数,语句最多只用到 $${max}。` +
`SQL: ${text.replace(/\s+/g, ' ').trim().slice(0, 200)}`,
)
}
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { buildConsignmentRevokeInventoryUpdate } from './consignment-revoke-inventory-sql.js'
const base = {
reason: '买家申请退款',
now: '2026-10-03T07:50:00.000Z',
inventoryId: 23,
workOrderId: 100140,
}
function maxPlaceholder(sql: string) {
return Math.max(0, ...[...sql.matchAll(/\$(\d+)/g)].map((match) => Number(match[1])))
}
test('回库分支只带 4 个参数:占位符个数必须与参数个数一致', () => {
// 线上 500 复盘:两条分支共用一个 5 元参数数组,回库分支 SQL 只用到 $4,
// PostgreSQL 报 "bind message supplies 5 parameters, but prepared statement "" requires 4",
// 运营点「退单回库并恢复工单为未指派 / 退单回库并取消工单」必炸。
const { sql, params } = buildConsignmentRevokeInventoryUpdate({
...base,
archiveOnly: false,
autoForfeit: false,
})
assert.equal(maxPlaceholder(sql), 4)
assert.equal(params.length, 4)
assert.ok(sql.includes("status = 'in_stock'"))
assert.ok(sql.includes('consumed_at = NULL'))
assert.ok(sql.includes("status = 'consumed' AND bound_work_order_id = $4"))
// 回库分支不允许出现第 5 个占位符,否则参数个数又会和作废分支串味
assert.ok(!sql.includes('$5'))
})
test('作废分支带 5 个参数(多一个备注前缀),占位符个数同样要对齐', () => {
for (const autoForfeit of [true, false]) {
const { sql, params } = buildConsignmentRevokeInventoryUpdate({
...base,
archiveOnly: true,
autoForfeit,
})
assert.equal(maxPlaceholder(sql), 5)
assert.equal(params.length, 5)
assert.ok(sql.includes("status = 'archived'"))
assert.ok(sql.includes('allow_matching = FALSE'))
assert.ok(sql.includes("status = 'consumed' AND bound_work_order_id = $4"))
}
})
test('作废备注前缀区分「已确认加好友」与「交付异常」', () => {
const gift = buildConsignmentRevokeInventoryUpdate({
...base,
archiveOnly: true,
autoForfeit: true,
})
const manual = buildConsignmentRevokeInventoryUpdate({
...base,
archiveOnly: true,
autoForfeit: false,
})
assert.ok(gift.params.includes(' [已确认加好友作废下架: '))
assert.ok(manual.params.includes(' [交付异常作废: '))
assert.ok(!manual.params.includes(' [已确认加好友作废下架: '))
})
@@ -0,0 +1,73 @@
/**
* 「撤销寄售履约」解绑现货的 SQL 构造。
*
* 两条分支的参数个数天然不同:作废分支多一个备注前缀($5),回库/取消分支只用 $1..$4。
* 之前两条分支共用一个 5 元参数数组,于是回库时 PostgreSQL 直接报
* `bind message supplies 5 parameters, but prepared statement "" requires 4`——
* 运营只要选「退单回库并恢复工单为未指派 / 退单回库并取消工单」就必定 500。
* 现在 SQL 与参数在一起构造,并由单测锁定"占位符个数 == 参数个数"。
*/
export interface ConsignmentRevokeInventoryUpdate {
sql: string
params: unknown[]
}
export function buildConsignmentRevokeInventoryUpdate(input: {
/** 只作废下架(已确认加好友的自动作废,或运营显式选择「报废该现货」) */
archiveOnly: boolean
/** 已确认加好友导致的自动作废:备注前缀与运营手动作废分开,便于对账 */
autoForfeit: boolean
reason: string
now: string
inventoryId: number
workOrderId: number | string
/** 回库备注文案,默认「撤销回库」 */
restockLabel?: string
}): ConsignmentRevokeInventoryUpdate {
// 必须带「仍被本工单占用」条件:否则会把别的工单正在使用的现货抢过来解绑,
// 造成同一件现货被多张工单履约。
const bindingGuard = `WHERE id = $3 AND status = 'consumed' AND bound_work_order_id = $4`
const baseParams: unknown[] = [input.reason, input.now, input.inventoryId, input.workOrderId]
if (input.archiveOnly) {
return {
sql: `
UPDATE consignment_inventories
SET
status = 'archived',
allow_matching = FALSE,
bound_order_id = NULL,
bound_work_order_id = NULL,
bound_platform_order_id = '',
shop_name = '',
remark = remark || ($5::text || $1 || ']'),
updated_at = $2
${bindingGuard}
RETURNING *
`,
params: [...baseParams, input.autoForfeit ? ' [已确认加好友作废下架: ' : ' [交付异常作废: '],
}
}
const restockLabel = input.restockLabel || '撤销回库'
return {
sql: `
UPDATE consignment_inventories
SET
status = 'in_stock',
allocation_status = '待安排',
assigned_worker_id = NULL,
bound_order_id = NULL,
bound_work_order_id = NULL,
bound_platform_order_id = '',
shop_name = '',
consumed_at = NULL,
remark = remark || ' [${restockLabel}: ' || $1 || ']',
updated_at = $2
${bindingGuard}
RETURNING *
`,
params: baseParams,
}
}
@@ -39,6 +39,7 @@ import type {
WorkerDepositHandling,
} from '../../domain/consignment-deposit-policy.js'
import { settleWorkOrderDepositWithClient } from '../../repositories/worker-platform/work-order-deposit-repo.js'
import { buildConsignmentRevokeInventoryUpdate } from '../../repositories/worker-platform/consignment-revoke-inventory-sql.js'
import { nowIso } from '../../utils/time.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
@@ -1003,44 +1004,19 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
// 都只能作废下架,否则这件现货会回到在库池被下一个工单再次占用(二次出货)。
const autoForfeit = isWorkOrderGiftDepositForfeitable(lockedOrder)
const archiveOnly = autoForfeit || input.action === 'archive_inventory'
const invSql = archiveOnly
? `
UPDATE consignment_inventories
SET
status = 'archived',
allow_matching = FALSE,
bound_order_id = NULL,
bound_work_order_id = NULL,
bound_platform_order_id = '',
shop_name = '',
remark = remark || ($5::text || $1 || ']'),
updated_at = $2
WHERE id = $3 AND status = 'consumed' AND bound_work_order_id = $4
RETURNING *
`
: `
UPDATE consignment_inventories
SET
status = 'in_stock',
allocation_status = '待安排',
assigned_worker_id = NULL,
bound_order_id = NULL,
bound_work_order_id = NULL,
bound_platform_order_id = '',
shop_name = '',
consumed_at = NULL,
remark = remark || ' [撤销回库: ' || $1 || ']',
updated_at = $2
WHERE id = $3 AND status = 'consumed' AND bound_work_order_id = $4
RETURNING *
`
const invRes = await client.query<ConsignmentInventoryRow>(invSql, [
// SQL 与参数必须一起构造:作废分支要 5 个参数,回库分支只用 4 个。
// 之前两条分支共用 5 元参数数组,回库时 PostgreSQL 直接报
// "bind message supplies 5 parameters, but prepared statement requires 4",
// 于是「退单回库」这条最常用的路径必定 500。
const { sql: invSql, params: invParams } = buildConsignmentRevokeInventoryUpdate({
archiveOnly,
autoForfeit,
reason,
now,
inventoryId,
lockedOrder.id,
autoForfeit ? ' [已确认加好友作废下架: ' : ' [交付异常作废: ',
])
workOrderId: lockedOrder.id,
})
const invRes = await client.query<ConsignmentInventoryRow>(invSql, invParams)
inv = invRes.rows[0] || null
if (!inv) {
throw createHttpError('该现货已被其他操作解绑或占用,请刷新工单后重试', {
@@ -34,6 +34,15 @@ test('撤销寄售履约的现货去向由押金规则与运营选择共同决
test('已确认加好友后作废现货:必须关掉匹配开关并留痕', async () => {
const source = await readServiceSource()
assert.ok(source.includes('allow_matching = FALSE'))
assert.ok(source.includes('已确认加好友作废下架'))
// 解绑 SQL 已抽到 consignment-revoke-inventory-sql.ts(参数个数由该模块自己的单测锁定)
const sqlSource = await readFile(
new URL(
'../../repositories/worker-platform/consignment-revoke-inventory-sql.ts',
import.meta.url,
),
'utf8',
)
assert.ok(source.includes('buildConsignmentRevokeInventoryUpdate('))
assert.ok(sqlSource.includes('allow_matching = FALSE'))
assert.ok(sqlSource.includes('已确认加好友作废下架'))
})