fix(migrations): 已应用的迁移文件不能删除(否则服务起不来)

上一轮纠偏时我把迁移 096(`claim_submission_json`)从文件系统删掉了,只留新的 097 去 DROP 列。
结果 `node-pg-migrate` 的排序校验直接拒绝启动:

  Not run migration 097_drop_claim_submission is preceding already run migration 096_...

因为数据库里已记录 096 已执行、而文件系统里没有它。后端因此进入重试循环、`/health/ready` 一直 503
(生产同样会中招,属于启动阻塞)。

正确做法:**保留 096 文件**(结构演进留在历史里),用 097 承担「删列」这一步;
两边的 `schema_migrations` 记录都完整,已应用 096 的环境与全新库都能正常迁移。

同时**还原 `check-sql-guard.ts` 为严格版**:既然不需要删迁移了,就不该放宽守卫
(原规则「历史迁移只能新增、不可修改/删除」继续生效,实测通过)。

实测:恢复后启动日志正常执行 `097_drop_claim_submission (UP)`、`/health/ready` 200、
`fulfillment_tasks.claim_submission_json` 已删除;`npm run check` 671/668/0 全绿。
This commit is contained in:
yml2213
2026-10-09 19:10:31 +08:00
parent 10d1449b8d
commit 687bb40db1
2 changed files with 27 additions and 38 deletions
+7 -38
View File
@@ -97,13 +97,9 @@ for (const file of listSourceFiles(path.join(BACKEND_ROOT, 'src'))) {
}
// 已应用的历史迁移必须保持字节不变,所有结构或数据修复都必须新增迁移文件。
//
// 例外(刻意保留):**删除**历史迁移是允许的,但必须同时新增迁移兜底
// (例如「迁移 096 设计作废 → 删 096、新增 097 DROP COLUMN」这种纠偏):
// 只删不补会让「已应用过 096 的环境」与「全新库」结构漂移,所以这里强制配对。
try {
const nameStatusOutputs = [
execFileSync('git', ['status', '--porcelain', '--', 'src/db/migrations'], {
const diffOutputs = [
execFileSync('git', ['diff', '--name-status', 'HEAD', '--', 'src/db/migrations'], {
cwd: BACKEND_ROOT,
encoding: 'utf8',
}),
@@ -112,41 +108,14 @@ try {
encoding: 'utf8',
}),
]
const migrationChanges = nameStatusOutputs.flatMap((output) =>
output
.split('\n')
.map((line) => line.trimEnd())
.filter(Boolean)
.map((line) => {
// `git status --porcelain` = `XY path`(未跟踪为 `?? path`);
// `git diff --name-status` = `S\tpath`(重命名为 `R100\told\tnew`)。两者格式不同,分别解析。
if (line.includes('\t')) {
const parts = line.split('\t').map((part) => part.trim())
return { status: parts[0] || '', file: parts[parts.length - 1] || '' }
}
return { status: line.slice(0, 2).trim(), file: line.slice(3).trim() }
})
.filter((change) => change.file.endsWith('.sql')),
)
const changedHistoricalMigrations = migrationChanges
.filter((change) => change.status !== 'A' && change.status !== 'D' && change.status !== '??')
.map((change) => `${change.status} ${change.file}`)
const changedHistoricalMigrations = diffOutputs
.flatMap((output) => output.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(`历史迁移文件不可修改: ${changedHistoricalMigrations.join(', ')}`)
}
const deletedMigrations = migrationChanges.filter((change) => change.status === 'D')
const addedMigrations = migrationChanges.filter(
(change) => change.status === 'A' || change.status === '??',
)
if (deletedMigrations.length > 0 && addedMigrations.length === 0) {
failures.push(
`删除历史迁移必须同时新增兜底迁移: ${deletedMigrations
.map((change) => change.file)
.join(', ')}`,
)
}
} catch (error) {
failures.push(`历史迁移完整性检查失败: ${error instanceof Error ? error.message : String(error)}`)
}
@@ -0,0 +1,20 @@
-- 096_fulfillment_tasks_claim_submission.sql —— 皮肤自动发货「买家领取页」提交内容落库。
--
-- 背景:抖音码速达的皮肤类商品改为「自动发货 + 领取链接」,买家在 /claim-skin/:token
-- 提交游戏账号后,我们要把账号存下来交给运营发货(并防重复提交)。
--
-- 为什么单开一列而不是塞进 work_orders.material_json:
-- material_json 是发给打手的工单素材(还挂了搜索索引与统计),买家游戏账号属于隐私数据,
-- 写进去等于对外泄露(仓库既有隐私红线,见 douyin-mashida/order-mapper.ts 的 buyerWords 注释)。
-- fulfillment_tasks 只被履约/后台读取,工单侧(worker-platform)明确不 JOIN 该表,
-- 因此把账号留在任务自身上是最小暴露面的做法。
--
-- 幂等:全部 IF NOT EXISTS。
ALTER TABLE fulfillment_tasks
-- 买家领取页提交内容:{"gameAccount":"...","submittedAt":"...","source":"claim_skin"};
-- 空对象 {} 表示尚未提交(待领取)。
ADD COLUMN IF NOT EXISTS claim_submission_json JSONB NOT NULL DEFAULT '{}'::jsonb;
COMMENT ON COLUMN fulfillment_tasks.claim_submission_json IS
'买家领取页提交内容(gameAccount/submittedAt);隐私字段,禁止写入 work_orders.material_json';