线上客服 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 均通过。
130 lines
3.7 KiB
TypeScript
130 lines
3.7 KiB
TypeScript
/**
|
||
* 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)}`,
|
||
)
|
||
}
|