Files

186 lines
6.5 KiB
TypeScript
Raw Permalink 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'
import { changedPublishedMigrations } from '../src/db/migration-integrity.js'
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}`)
}
}
// 基线分支中存在的所有迁移都必须保持字节不变;新增文件不受影响。
// 这样保护范围会随着 main 的发布自动扩展,不再依赖手工维护版本号。
if (!MIGRATION_BASELINE_REF) {
console.warn('[check-sql] 未找到迁移基线分支(main / origin/main),跳过迁移字节检查')
} else {
try {
const diffOutput = execFileSync(
'git',
['diff', '--name-status', '--no-renames', MIGRATION_BASELINE_REF, '--', 'src/db/migrations'],
{ cwd: BACKEND_ROOT, encoding: 'utf8' },
)
const changedHistoricalMigrations = changedPublishedMigrations(diffOutput)
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
}