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:
yml2213
2026-10-25 11:38:12 +08:00
parent 465a9f65dc
commit 472769a977
13 changed files with 206 additions and 333 deletions
+44 -53
View File
@@ -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) {