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()