线上客服 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 均通过。
125 lines
4.2 KiB
TypeScript
125 lines
4.2 KiB
TypeScript
import { Pool } from 'pg'
|
|
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
|
|
|
|
export function getDb(): Pool {
|
|
if (!poolInstance) {
|
|
const pool = new Pool({
|
|
connectionString: String(runtimeConfig.database?.url || '').trim(),
|
|
ssl: runtimeConfig.database?.ssl ? { rejectUnauthorized: false } : false,
|
|
max: Number(runtimeConfig.database?.maxConnections || 10),
|
|
idleTimeoutMillis: Number(runtimeConfig.database?.idleTimeoutMs || 30_000),
|
|
connectionTimeoutMillis: Number(runtimeConfig.database?.connectionTimeoutMs || 5_000),
|
|
statement_timeout: Number(runtimeConfig.database?.statementTimeoutMs || 15_000),
|
|
})
|
|
pool.on('error', (error) => {
|
|
void logWarn('[db/pool]', '数据库连接池出现空闲连接错误', {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
})
|
|
})
|
|
// 参数个数自检:参数比占位符多时 PostgreSQL 只会回一句难定位的 bind 报错,
|
|
// 这里在发往数据库之前就把出错的 SQL 片段带出来(见 sql-arity.ts)。
|
|
pool.on('connect', (client) => guardSqlParamArity(client))
|
|
poolInstance = pool
|
|
}
|
|
|
|
return poolInstance
|
|
}
|
|
|
|
export async function query(text: string, params?: unknown[]): Promise<QueryResult<any>>
|
|
export async function query<T extends QueryResultRow>(
|
|
text: string,
|
|
params?: unknown[],
|
|
): Promise<QueryResult<T>>
|
|
export async function query<T extends QueryResultRow>(
|
|
text: string,
|
|
params: unknown[] = [],
|
|
): Promise<QueryResult<T>> {
|
|
const pool = getDb()
|
|
const startedAt = process.hrtime.bigint()
|
|
try {
|
|
const result = await pool.query<T>(text, params)
|
|
logSlowQuery(text, startedAt, result.rowCount ?? 0)
|
|
return result
|
|
} catch (error) {
|
|
logSlowQuery(text, startedAt, 0, error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
function logSlowQuery(text: string, startedAt: bigint, rowCount: number, error?: unknown) {
|
|
const thresholdMs = Number(runtimeConfig.database?.slowQueryThresholdMs ?? 500)
|
|
if (thresholdMs <= 0) return
|
|
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000
|
|
if (durationMs < thresholdMs && !error) return
|
|
void logWarn('[db/query]', error ? '数据库 SQL 执行失败' : '数据库慢查询', {
|
|
durationMs: Math.round(durationMs * 100) / 100,
|
|
thresholdMs,
|
|
rowCount,
|
|
sql: fingerprintSql(text),
|
|
error: error instanceof Error ? error.message : undefined,
|
|
})
|
|
}
|
|
|
|
/** 只保留可聚合的 SQL 形状,参数值始终不写入日志。 */
|
|
function fingerprintSql(text: string) {
|
|
return String(text || '')
|
|
.replace(/--[^\r\n]*/g, ' ')
|
|
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
.replace(/\$\d+/g, '?')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.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()
|
|
|
|
try {
|
|
await client.query('BEGIN')
|
|
const result = await fn(client)
|
|
await client.query('COMMIT')
|
|
return result
|
|
} catch (error) {
|
|
await client.query('ROLLBACK')
|
|
throw error
|
|
} finally {
|
|
client.release()
|
|
}
|
|
}
|
|
|
|
export async function closeDb(): Promise<void> {
|
|
if (!poolInstance) {
|
|
return
|
|
}
|
|
|
|
const pool = poolInstance
|
|
poolInstance = null
|
|
await pool.end()
|
|
}
|