Files
order_site/apps/backend/scripts/check-sql-guard.ts
T
yml2213 472769a977 refactor(db): 财务迁移收敛为 103–107
把 103–112 十个迁移收敛为五个,删除在「生产 102 → 当前」路径上的空操作与补丁:

- 108(下线人工支出登记)与 109(隔离历史快照主体)在生产库和重建库上都是空操作,
  口径注释并入 104;
- 111 是给 104 自身种子打的补丁,合并后 104 直接写入 active 0 元占位;
- 112 的 cost_exceptions.resolution_note 并入 104,退款表独立为 106;
- 106(备用金编号可读化)与 110(积分钱包)合并为 107(人工履约资金层);
- 107(履约方式列)并入 105,履约方式本就是销售事实的维度键。

配套调整:
- check-sql-guard 改为以 main 为基线校验迁移字节,删除 103–114 的永久豁免;
  未合并分支上的新迁移仍可重写,已发布迁移不可修改。
- 本地库原地改造脚本移入 docs/archive,禁止对新环境执行。

验证:清掉 schema_migrations 里 103–112 的记录后重跑,升级结果与全新构建的
schema 完全一致(pg_dump --schema-only 仅随机 token 不同),种子数据一致;
升级前后业务数据量不变(成本版本 713 → 712,差异来自删除占位草稿)。
2026-10-25 11:38:12 +08:00

187 lines
6.5 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 约束检查。
* 该脚本检查关键防线是否被后续重构移除,不尝试替代 EXPLAIN 和生产监控。
*/
import fs from 'node:fs'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
const BACKEND_ROOT = path.resolve(SCRIPT_DIR, '..')
const checks: Array<{
name: string
file: string
patterns?: string[]
forbiddenPatterns?: string[]
}> = [
{
name: '慢查询阈值配置',
file: path.join(BACKEND_ROOT, 'src/config/env-overrides.ts'),
patterns: ['DATABASE_SLOW_QUERY_THRESHOLD_MS', 'slowQueryThresholdMs'],
},
{
name: '慢查询日志与参数脱敏',
file: path.join(BACKEND_ROOT, 'src/db/client.ts'),
patterns: ['logSlowQuery', 'fingerprintSql', '参数值始终不写入日志'],
},
{
name: '高频 pending 查询索引',
file: path.join(BACKEND_ROOT, 'src/db/migrations/058_database_query_guardrails.sql'),
patterns: [
'idx_worker_cancel_requests_pending_worker_order',
'idx_worker_feedbacks_pending_worker_order',
'idx_work_order_events_order_type_id',
],
},
{
name: '打手订单拼单查询限定当前页',
file: path.join(BACKEND_ROOT, 'src/repositories/worker-platform/work-order-share-repo.ts'),
patterns: ['workOrderIds: number[]', 'wos.work_order_id = ANY($2::bigint[])'],
},
{
name: '接单工单宽详情分页禁止窗口总数',
file: path.join(BACKEND_ROOT, 'src/repositories/worker-platform/work-order-query-repo.ts'),
patterns: ['countWorkOrdersWithWhere', 'includeTotal'],
forbiddenPatterns: ['COUNT(*) OVER' + '()', 'LIST_WORK_ORDER_SELECT'],
},
{
name: '数据保留清理扫描索引',
file: path.join(BACKEND_ROOT, 'src/db/migrations/067_data_retention_indexes.sql'),
patterns: [
'idx_orders_updated_at',
'idx_kuaishou_industry_vouchers_updated_at',
'idx_task_events_created_at',
'idx_webhook_events_created_at',
'DROP INDEX IF EXISTS idx_work_product_match_logs_created_at',
],
},
{
name: '打手超时事件统计索引',
file: path.join(BACKEND_ROOT, 'src/db/migrations/068_timeout_event_worker_index.sql'),
patterns: [
'idx_work_order_events_timeout_worker_id',
"payload_json->>'workerId'",
"WHERE event_type LIKE 'timeout_%'",
],
},
{
name: '接单工单搜索表达式索引重建',
file: path.join(BACKEND_ROOT, 'src/db/migrations/073_rebuild_work_order_search_index.sql'),
patterns: [
'DROP INDEX IF EXISTS idx_work_orders_search_text_trgm',
'CREATE INDEX idx_work_orders_search_text_trgm',
"material_json->'collect'->'fields'->>'gameId'",
],
},
{
name: '任务与订单平台订单号检索索引',
file: path.join(BACKEND_ROOT, 'src/db/migrations/074_admin_order_and_task_lookup_indexes.sql'),
patterns: [
'idx_fulfillment_tasks_platform_order_id',
'idx_fulfillment_tasks_task_no_pattern',
'idx_orders_platform_order_id',
],
},
]
const failures: string[] = []
// 迁移基线的解析顺序:环境变量 > main > origin/main。
// 用「已发布基线」而不是 HEAD 做比较,是因为以 HEAD 为基线时,本次提交新增的
// 迁移也会被算成历史迁移,合并提交后必然自相矛盾。
const MIGRATION_BASELINE_REF = resolveMigrationBaselineRef()
function resolveMigrationBaselineRef(): string | null {
const candidates = [process.env.MIGRATION_BASELINE_REF, 'main', 'origin/main'].filter(
(value): value is string => Boolean(value && value.trim()),
)
for (const ref of candidates) {
try {
execFileSync('git', ['rev-parse', '--verify', '--quiet', ref], {
cwd: BACKEND_ROOT,
stdio: 'ignore',
})
return ref
} catch {
// 该基线不存在,尝试下一个
}
}
return null
}
// 窗口总数会在 LIMIT/OFFSET 之前消费全部候选行;全后端 SQL 都禁止引入该写法。
const countWindowPattern = /COUNT\s*\(\s*\*\s*\)\s*OVER\s*\(/i
for (const file of listSourceFiles(path.join(BACKEND_ROOT, 'src'))) {
if (countWindowPattern.test(fs.readFileSync(file, 'utf8'))) {
failures.push(`禁止 COUNT(*) OVER() 窗口分页: ${file}`)
}
}
// 已发布的历史迁移必须保持字节不变,所有结构或数据修复都必须新增迁移文件。
if (!MIGRATION_BASELINE_REF) {
console.warn('[check-sql] 未找到迁移基线分支(main / origin/main),跳过迁移字节检查')
} else {
try {
const diffOutput = execFileSync(
'git',
['diff', '--name-status', MIGRATION_BASELINE_REF, '--', 'src/db/migrations'],
{ cwd: BACKEND_ROOT, encoding: 'utf8' },
)
const changedHistoricalMigrations = diffOutput
.split('\n')
.map((line) => line.trim().split(/\s+/))
.filter(([status, file]) => status && file?.endsWith('.sql') && status !== 'A')
.map(([status, file]) => `${status} ${file}`)
if (changedHistoricalMigrations.length > 0) {
failures.push(
`已发布迁移文件不可修改(基线 ${MIGRATION_BASELINE_REF}): ${changedHistoricalMigrations.join(', ')}`,
)
}
} catch (error) {
failures.push(
`历史迁移完整性检查失败: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
for (const check of checks) {
if (!fs.existsSync(check.file)) {
failures.push(`${check.name}: 文件不存在 ${check.file}`)
continue
}
const source = fs.readFileSync(check.file, 'utf8')
for (const pattern of check.patterns || []) {
if (!source.includes(pattern)) {
failures.push(`${check.name}: 缺少约束 ${pattern} (${check.file})`)
}
}
for (const pattern of check.forbiddenPatterns || []) {
if (source.includes(pattern)) {
failures.push(`${check.name}: 出现禁止 SQL ${pattern} (${check.file})`)
}
}
}
if (failures.length > 0) {
console.error('[sql-guard] SQL 约束检查失败')
for (const failure of failures) console.error(`[sql-guard] ${failure}`)
process.exitCode = 1
} else {
console.info('[sql-guard] SQL 约束检查通过')
}
function listSourceFiles(directory: string): string[] {
const files: string[] = []
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name)
if (entry.isDirectory()) {
files.push(...listSourceFiles(file))
} else if (entry.isFile() && /\.(?:ts|sql)$/.test(entry.name)) {
files.push(file)
}
}
return files
}