/** * 数据库 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[] = [] // 窗口总数会在 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}`) } } // 已应用的历史迁移必须保持字节不变,所有结构或数据修复都必须新增迁移文件。 // // 例外(刻意保留):**删除**历史迁移是允许的,但必须同时新增迁移兜底 // (例如「迁移 096 设计作废 → 删 096、新增 097 DROP COLUMN」这种纠偏): // 只删不补会让「已应用过 096 的环境」与「全新库」结构漂移,所以这里强制配对。 try { const nameStatusOutputs = [ execFileSync('git', ['status', '--porcelain', '--', 'src/db/migrations'], { cwd: BACKEND_ROOT, encoding: 'utf8', }), execFileSync('git', ['diff', '--name-status', 'HEAD^', 'HEAD', '--', 'src/db/migrations'], { cwd: BACKEND_ROOT, encoding: 'utf8', }), ] const migrationChanges = nameStatusOutputs.flatMap((output) => output .split('\n') .map((line) => line.trimEnd()) .filter(Boolean) .map((line) => { // `git status --porcelain` = `XY path`(未跟踪为 `?? path`); // `git diff --name-status` = `S\tpath`(重命名为 `R100\told\tnew`)。两者格式不同,分别解析。 if (line.includes('\t')) { const parts = line.split('\t').map((part) => part.trim()) return { status: parts[0] || '', file: parts[parts.length - 1] || '' } } return { status: line.slice(0, 2).trim(), file: line.slice(3).trim() } }) .filter((change) => change.file.endsWith('.sql')), ) const changedHistoricalMigrations = migrationChanges .filter((change) => change.status !== 'A' && change.status !== 'D' && change.status !== '??') .map((change) => `${change.status} ${change.file}`) if (changedHistoricalMigrations.length > 0) { failures.push(`历史迁移文件不可修改: ${changedHistoricalMigrations.join(', ')}`) } const deletedMigrations = migrationChanges.filter((change) => change.status === 'D') const addedMigrations = migrationChanges.filter( (change) => change.status === 'A' || change.status === '??', ) if (deletedMigrations.length > 0 && addedMigrations.length === 0) { failures.push( `删除历史迁移必须同时新增兜底迁移: ${deletedMigrations .map((change) => change.file) .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 }