refactor(db): 财务迁移收敛为 103–107
把 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,差异来自删除占位草稿)。
This commit is contained in:
@@ -88,18 +88,27 @@ const checks: Array<{
|
||||
|
||||
const failures: string[] = []
|
||||
|
||||
// 生产尚未执行 102;按产品确认,允许在发布前移除其中的机器码字段。
|
||||
// 除此之外,所有历史迁移仍必须保持字节不变。
|
||||
const allowedHistoricalMigrationChanges = new Set([
|
||||
'M apps/backend/src/db/migrations/102_admin_phone_device_auth.sql',
|
||||
])
|
||||
// 迁移基线的解析顺序:环境变量 > main > origin/main。
|
||||
// 用「已发布基线」而不是 HEAD 做比较,是因为以 HEAD 为基线时,本次提交新增的
|
||||
// 迁移也会被算成历史迁移,合并提交后必然自相矛盾。
|
||||
const MIGRATION_BASELINE_REF = resolveMigrationBaselineRef()
|
||||
|
||||
// 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))
|
||||
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 都禁止引入该写法。
|
||||
@@ -110,49 +119,31 @@ for (const file of listSourceFiles(path.join(BACKEND_ROOT, 'src'))) {
|
||||
}
|
||||
}
|
||||
|
||||
// 已应用的历史迁移必须保持字节不变,所有结构或数据修复都必须新增迁移文件。
|
||||
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(', ')}`)
|
||||
// 已发布的历史迁移必须保持字节不变,所有结构或数据修复都必须新增迁移文件。
|
||||
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)}`,
|
||||
)
|
||||
}
|
||||
} 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) {
|
||||
|
||||
Reference in New Issue
Block a user