Files
order_site/apps/backend/scripts/check-sql-guard.ts
T
yml2213 6bf28489e0 fix(finance): 重写履约路由迁移与统一渠道配置
重写 120/121 迁移,统一内部 SKU、渠道 id、config_json 和旧规则转换。

后台渠道配置改读统一渠道表,卡券与人工配置不再写旧规则。

增加安全渠道切换,并排除已关闭、失败或过期的历史任务载体。

按生产 119 基线重跑迁移、完整履约财务链路和严格预检。
2026-11-02 15:29:13 +08:00

196 lines
6.9 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()
// 生产当前已发布到 119;120+ 仍允许在部署前重写。部署 120 后应将此值提升到 120。
const PUBLISHED_MIGRATION_MAX_VERSION = Math.max(
0,
Math.trunc(Number(process.env.PUBLISHED_MIGRATION_MAX_VERSION) || 119),
)
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]) => {
if (!status || !file?.endsWith('.sql') || status === 'A') return false
const version = Number(path.basename(file).slice(0, 3))
return Number.isFinite(version) && version <= PUBLISHED_MIGRATION_MAX_VERSION
})
.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
}