Files
order_site/apps/backend/src/db/sql-arity.ts
T
yml2213 30ae35d3a5 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 均通过。
2026-10-03 16:03:14 +08:00

130 lines
3.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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)}`,
)
}