From 30ae35d3a59013b54fdb45c814a8266b337bca9c Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 3 Oct 2026 16:03:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(consignment):=20=E6=92=A4=E9=94=80=E5=AF=84?= =?UTF-8?q?=E5=94=AE=E5=B1=A5=E7=BA=A6=E5=9B=9E=E5=BA=93=E5=88=86=E6=94=AF?= =?UTF-8?q?=E5=BF=85=20500=20=E2=80=94=E2=80=94=20SQL=20=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E6=AF=94=E5=8D=A0=E4=BD=8D=E7=AC=A6=E5=A4=9A=E4=B8=80=E4=B8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 线上客服 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 均通过。 --- apps/backend/src/db/client.ts | 24 ++++ apps/backend/src/db/sql-arity.test.ts | 54 ++++++++ apps/backend/src/db/sql-arity.ts | 129 ++++++++++++++++++ .../consignment-revoke-inventory-sql.test.ts | 67 +++++++++ .../consignment-revoke-inventory-sql.ts | 73 ++++++++++ .../consignment-fulfillment-service.ts | 46 ++----- .../consignment-revoke-policy.test.ts | 13 +- 7 files changed, 369 insertions(+), 37 deletions(-) create mode 100644 apps/backend/src/db/sql-arity.test.ts create mode 100644 apps/backend/src/db/sql-arity.ts create mode 100644 apps/backend/src/repositories/worker-platform/consignment-revoke-inventory-sql.test.ts create mode 100644 apps/backend/src/repositories/worker-platform/consignment-revoke-inventory-sql.ts diff --git a/apps/backend/src/db/client.ts b/apps/backend/src/db/client.ts index 6e90c564..9b731631 100644 --- a/apps/backend/src/db/client.ts +++ b/apps/backend/src/db/client.ts @@ -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(fn: (client: PoolClient) => Promise): Promise { const client = await getDb().connect() diff --git a/apps/backend/src/db/sql-arity.test.ts b/apps/backend/src/db/sql-arity.test.ts new file mode 100644 index 00000000..47de2769 --- /dev/null +++ b/apps/backend/src/db/sql-arity.test.ts @@ -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)) +}) diff --git a/apps/backend/src/db/sql-arity.ts b/apps/backend/src/db/sql-arity.ts new file mode 100644 index 00000000..be1c2a48 --- /dev/null +++ b/apps/backend/src/db/sql-arity.ts @@ -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)}`, + ) +} diff --git a/apps/backend/src/repositories/worker-platform/consignment-revoke-inventory-sql.test.ts b/apps/backend/src/repositories/worker-platform/consignment-revoke-inventory-sql.test.ts new file mode 100644 index 00000000..1a9987a8 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/consignment-revoke-inventory-sql.test.ts @@ -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(' [已确认加好友作废下架: ')) +}) diff --git a/apps/backend/src/repositories/worker-platform/consignment-revoke-inventory-sql.ts b/apps/backend/src/repositories/worker-platform/consignment-revoke-inventory-sql.ts new file mode 100644 index 00000000..0ef6237e --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/consignment-revoke-inventory-sql.ts @@ -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, + } +} diff --git a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts index a8b6e796..63e1a7c8 100644 --- a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts @@ -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(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(invSql, invParams) inv = invRes.rows[0] || null if (!inv) { throw createHttpError('该现货已被其他操作解绑或占用,请刷新工单后重试', { diff --git a/apps/backend/src/services/worker-platform/consignment-revoke-policy.test.ts b/apps/backend/src/services/worker-platform/consignment-revoke-policy.test.ts index 00c016f3..be325973 100644 --- a/apps/backend/src/services/worker-platform/consignment-revoke-policy.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-revoke-policy.test.ts @@ -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('已确认加好友作废下架')) })