把 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,差异来自删除占位草稿)。
187 lines
6.5 KiB
TypeScript
187 lines
6.5 KiB
TypeScript
/**
|
||
* 数据库 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
|
||
}
|