后端: - 删除 services/admin/admin-cost-center-service.ts(4,248 行),拆为 services/cost-center/ 下 5 个模块: cost-center-shared(类型/枚举/归一化/映射/costModelScopeSql)、 cost-center-overview(总览与选项)、 product-catalog(商品与渠道映射)、 template-bootstrap(模板引导、自动发货同步、工单证据回填)、 cost-definition(成本项/版本/异常/预览/快照) - cost_items.carrier_type / cost_type 改为单一 cost_model;专属模型只对 对应履约方式生效,通用模型对所有履约方式生效 - cost_snapshots / cost_exceptions 删除载体列;按履约方式筛异常改用 IN 半连接(248k 异常下 EXISTS 逐行回表 2.7s -> 170ms) - sales_daily_facts.carrier_type -> fulfillment_mode - 人工支出按 cost_model 归类;履约方式只读 order_items.fulfillment_mode - 移除 syncOpenCostExceptionCarrierTypes:异常表已无载体列可同步 前端: - AdminCostCenterPage.tsx(2,887 行 -> 18 行转发),拆为 pages/admin/cost-center/ 下 8 个 Panel + shared + CostCenterContext - CostCarrierType 删除;新增 FulfillmentMode / ProductSource / CostModel - 销售毛利页 carrierType -> fulfillmentMode 同步补两个索引:idx_order_items_fulfillment_id(异常半连接)、 idx_work_categories_name(商品目录分类 lateral join)。
196 lines
7.1 KiB
TypeScript
196 lines
7.1 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[] = []
|
|
|
|
// 生产尚未执行 102;按产品确认,允许在发布前移除其中的机器码字段。
|
|
// 除此之外,所有历史迁移仍必须保持字节不变。
|
|
const allowedHistoricalMigrationChanges = new Set([
|
|
'M apps/backend/src/db/migrations/102_admin_phone_device_auth.sql',
|
|
])
|
|
|
|
// 2026-10-23 财务重写:103–114 从未部署到生产,整体重写为 103–107 五个迁移。
|
|
// 这一组迁移在本分支内是可重写的,护栏不再逐个校验其字节。
|
|
const REWRITABLE_MIGRATION_PREFIX = /^1(0[3-9]|1[0-4])_/
|
|
|
|
function isRewritableFinanceMigration(file: string) {
|
|
return REWRITABLE_MIGRATION_PREFIX.test(path.basename(file))
|
|
}
|
|
|
|
// 窗口总数会在 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}`)
|
|
}
|
|
}
|
|
|
|
// 已应用的历史迁移必须保持字节不变,所有结构或数据修复都必须新增迁移文件。
|
|
try {
|
|
const diffOutputs = [
|
|
execFileSync('git', ['diff', '--name-status', 'HEAD', '--', 'src/db/migrations'], {
|
|
cwd: BACKEND_ROOT,
|
|
encoding: 'utf8',
|
|
}),
|
|
execFileSync('git', ['diff', '--name-status', 'HEAD^', 'HEAD', '--', 'src/db/migrations'], {
|
|
cwd: BACKEND_ROOT,
|
|
encoding: 'utf8',
|
|
}),
|
|
]
|
|
const changedHistoricalMigrations = diffOutputs
|
|
.flatMap((output) => output.split('\n'))
|
|
.map((line) => line.trim().split(/\s+/))
|
|
.filter(([status, file]) => status && file?.endsWith('.sql') && status !== 'A')
|
|
.filter(([status, file, renamedTo]) => !isMigrationRenumber(status, file, renamedTo))
|
|
.map(([status, file]) => `${status} ${file}`)
|
|
.filter((change) => !allowedHistoricalMigrationChanges.has(change))
|
|
.filter((change) => {
|
|
const file = change.split(' ').slice(1).join(' ')
|
|
return !isRewritableFinanceMigration(file)
|
|
})
|
|
if (changedHistoricalMigrations.length > 0) {
|
|
failures.push(`历史迁移文件不可修改: ${changedHistoricalMigrations.join(', ')}`)
|
|
}
|
|
} catch (error) {
|
|
failures.push(`历史迁移完整性检查失败: ${error instanceof Error ? error.message : String(error)}`)
|
|
}
|
|
|
|
// 分支合流后把尚未上线的迁移顺延到主线版本号之后时,只允许改序号前缀,
|
|
// 文件名主体必须完全一致(例:102_product_catalog.sql -> 103_product_catalog.sql)。
|
|
function isMigrationRenumber(
|
|
status: string | undefined,
|
|
renamedFrom: string | undefined,
|
|
renamedTo: string | undefined,
|
|
) {
|
|
if (!status?.startsWith('R') || !renamedFrom || !renamedTo) return false
|
|
return stripMigrationPrefix(renamedFrom) === stripMigrationPrefix(renamedTo)
|
|
}
|
|
|
|
function stripMigrationPrefix(filePath: string) {
|
|
return path.basename(filePath).replace(/^\d{3}_/, '')
|
|
}
|
|
|
|
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
|
|
}
|