fix(db): 禁止修改已发布迁移并分批兼容旧库

This commit is contained in:
yml2213
2026-11-07 17:51:34 +08:00
parent 7b68d9b60a
commit 8ef8c61862
12 changed files with 253 additions and 123 deletions
-84
View File
@@ -24,69 +24,10 @@ const MIGRATIONS_DIR = path.join(CURRENT_DIR, 'migrations')
const MIGRATIONS_TABLE = 'schema_migrations'
const SHORT_NUMERIC_PREFIX_MESSAGE = /^Can't determine timestamp for \d+$/
const MIGRATION_FILE_PATTERN = /^(\d{3})_.+\.sql$/i
/**
* 合并 main 后寄卖迁移编号顺延,以及 142–148 收敛为单一文件:
* 129-131 -> 135-137(main 占用 129-134)
* 135-137 -> 142-144(main 又占用 135-141)
* 142-148 -> 142_consignment_platform(2026-11-06 合并为单文件)
* 2026-11-07 二次撞号:main 又占用 142-146,寄卖线整体顺延为 147-151
* 已跑过旧编号的环境要清掉旧记录,让新编号按正常顺序重跑(迁移本身幂等)。
*/
const RENAMED_MIGRATIONS: Array<{ legacyName: string; currentName: string }> = [
{ legacyName: '129_consignment_matching', currentName: '147_consignment_platform' },
{ legacyName: '135_consignment_matching', currentName: '147_consignment_platform' },
{ legacyName: '130_consignment_dynamic_specs', currentName: '147_consignment_platform' },
{ legacyName: '136_consignment_dynamic_specs', currentName: '147_consignment_platform' },
{
legacyName: '131_consignment_surnan_default_profile',
currentName: '147_consignment_platform',
},
{
legacyName: '137_consignment_surnan_default_profile',
currentName: '147_consignment_platform',
},
{ legacyName: '142_consignment_matching', currentName: '147_consignment_platform' },
{ legacyName: '143_consignment_dynamic_specs', currentName: '147_consignment_platform' },
{
legacyName: '144_consignment_surnan_default_profile',
currentName: '147_consignment_platform',
},
{
legacyName: '145_consignment_inventory_multi_binding',
currentName: '147_consignment_platform',
},
{ legacyName: '146_consignment_share_gift', currentName: '147_consignment_platform' },
{ legacyName: '147_consignment_match_and_rematch', currentName: '147_consignment_platform' },
{ legacyName: '148_consignment_remove_zone_help_text', currentName: '147_consignment_platform' },
/*
* 2026-11-07 二次撞号:合并 main 后 main 又占用 142-146(voucher/fulfillment 系列)。
* 寄卖迁移整体顺延:142→147、143→148、144→149、145/146→150、147(截止时间约束)→151。
* 旧库记录按下面的映射清掉后按新编号重跑,全部迁移均为幂等脚本。
*/
{ legacyName: '142_consignment_platform', currentName: '147_consignment_platform' },
{ legacyName: '143_consignment_sms_logs', currentName: '148_consignment_sms_logs' },
{
legacyName: '144_consignment_inventory_category_backfill',
currentName: '149_consignment_inventory_category_backfill',
},
{
legacyName: '145_work_order_consignment_original_reward',
currentName: '150_work_order_consignment_original_reward',
},
{
legacyName: '146_work_order_consignment_original_reward',
currentName: '150_work_order_consignment_original_reward',
},
{
legacyName: '147_work_order_internal_matching_deadline_guard',
currentName: '151_work_order_internal_matching_deadline_guard',
},
]
export async function runDatabaseMigrations() {
assertMigrationFilesOrdered()
await ensureNodePgMigrateMetadataCompatibility()
await dropRenamedMigrationRecords()
console.info(`[db:migrate] 迁移目录: ${MIGRATIONS_DIR}`)
@@ -239,31 +180,6 @@ async function ensureNodePgMigrateMetadataCompatibility() {
`)
}
/**
* 合并 main 后寄卖迁移编号顺延:清掉旧编号的已执行记录。
* 只在新文件存在、旧文件已不存在、且新编号尚未记录时执行,
* 之后由 runner 按新编号正常重跑(寄卖迁移均为幂等脚本)。
*/
async function dropRenamedMigrationRecords() {
for (const { legacyName, currentName } of RENAMED_MIGRATIONS) {
if (fs.existsSync(path.join(MIGRATIONS_DIR, `${legacyName}.sql`))) continue
if (!fs.existsSync(path.join(MIGRATIONS_DIR, `${currentName}.sql`))) continue
const result = await query(
`
DELETE FROM ${MIGRATIONS_TABLE}
WHERE name = $1
AND NOT EXISTS (
SELECT 1 FROM ${MIGRATIONS_TABLE} WHERE name = $2
)
`,
[legacyName, currentName],
)
if ((result.rowCount ?? 0) > 0) {
console.info(`[db:migrate] 迁移编号已顺延: ${legacyName} -> ${currentName}`)
}
}
}
async function syncLegacyMigrationColumns() {
await query(`
UPDATE ${MIGRATIONS_TABLE}
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { changedPublishedMigrations } from './migration-integrity.js'
test('迁移完整性:新增迁移允许,已发布迁移任何修改都拒绝', () => {
assert.deepEqual(
changedPublishedMigrations(
[
'A\tsrc/db/migrations/153_new_feature.sql',
'M\tsrc/db/migrations/146_voucher_consume_fallback_channel.sql',
'D\tsrc/db/migrations/145_fulfillment_current_attempt_latest.sql',
].join('\n'),
),
[
'M src/db/migrations/146_voucher_consume_fallback_channel.sql',
'D src/db/migrations/145_fulfillment_current_attempt_latest.sql',
],
)
})
test('迁移完整性:保护范围不由固定版本号截断', () => {
assert.deepEqual(
changedPublishedMigrations('M\tsrc/db/migrations/120_fulfillment_routing_invariants.sql'),
['M src/db/migrations/120_fulfillment_routing_invariants.sql'],
)
})
@@ -0,0 +1,19 @@
import path from 'node:path'
/**
* 从 `git diff --name-status --no-renames <published-ref> -- src/db/migrations`
* 的输出中找出被修改的已发布迁移。
*
* 基线分支中不存在的新增文件状态为 A,可以继续修改;其他状态均视为历史迁移
* 被改动。这里不维护版本号,保护范围会随基线分支自动扩展。
*/
export function changedPublishedMigrations(diffOutput: string): string[] {
return diffOutput
.split('\n')
.map((line) => line.trim().split(/\s+/))
.filter(([status, file]) => {
if (!status || status === 'A' || !file?.endsWith('.sql')) return false
return path.basename(file).match(/^\d{3}_.+\.sql$/i) !== null
})
.map(([status, file]) => `${status} ${file}`)
}
@@ -338,12 +338,10 @@ ALTER TABLE cost_exceptions
ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ;
-- 存量行(断代脚本会整体清空)给一个可用的 scope_key,避免 NOT NULL 失败。
-- 旧行可能同 reason + subject_id 但 subject_type 不同,末尾拼 id 保证唯一,
-- 否则下面的 uq_cost_exceptions_scope_open 会因重复键失败。
UPDATE cost_exceptions
SET scope_key = COALESCE(
NULLIF(scope_key, ''),
reason || '|' || COALESCE(order_item_id::text, subject_id::text) || '|legacy:' || id
reason || '|' || COALESCE(order_item_id::text, subject_id::text)
),
first_seen_at = COALESCE(first_seen_at, created_at),
last_seen_at = COALESCE(last_seen_at, created_at)
@@ -406,22 +404,9 @@ COMMENT ON TABLE finance_watermarks IS
-- 先沿用旧列名承载渠道值,本迁移末尾统一改名为 channel;
-- 这里先放宽约束,允许卡券、未分类与归档状态。
-- 断代前的销售事实仍是旧口径,可能残留 legacy_platform;新约束不允许该值。
-- 这些事实随后由 finance:cutover-v2 整体清空,这里先归入 archived(未知值归入 unclassified)。
-- 必须先删除旧约束再改值:旧约束只允许四值,写 archived 会被它自己拦下。
ALTER TABLE sales_daily_facts
DROP CONSTRAINT IF EXISTS chk_sales_daily_facts_fulfillment_mode;
UPDATE sales_daily_facts
SET fulfillment_mode = CASE
WHEN fulfillment_mode = 'legacy_platform' THEN 'archived'
ELSE 'unclassified'
END
WHERE fulfillment_mode NOT IN (
'work_order', 'affiliate_dash', 'third_party_voucher',
'manual', 'unclassified', 'archived'
);
ALTER TABLE sales_daily_facts
ADD CONSTRAINT chk_sales_daily_facts_fulfillment_mode
CHECK (
@@ -12,8 +12,10 @@ const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)),
const REPO_ROOT = path.resolve(BACKEND_ROOT, '../..')
const CLASSIFIER = path.join(REPO_ROOT, 'deploy/lib/classify-changes.sh')
const DEPLOY_SCRIPT = path.join(REPO_ROOT, 'deploy/ubuntu-deploy.sh')
const CUTOVER_PREFLIGHT = path.join(REPO_ROOT, 'deploy/sql/prepare-113-finance-cutover.sql')
const hasDeployScripts = fs.existsSync(CLASSIFIER) && fs.existsSync(DEPLOY_SCRIPT)
const hasDeployScripts =
fs.existsSync(CLASSIFIER) && fs.existsSync(DEPLOY_SCRIPT) && fs.existsSync(CUTOVER_PREFLIGHT)
function classify(files: string[]): string {
return execFileSync('bash', [CLASSIFIER], {
@@ -60,7 +62,14 @@ test('改动分类:进得了镜像的部署文件要带上对应一端', { ski
test('改动分类:进不了镜像的改动不触发重建', { skip: !hasDeployScripts }, () => {
assert.equal(classify([]), '')
assert.equal(classify(['README.md', 'docs/部署启动与数据库重建.md']), '')
assert.equal(classify(['deploy/ubuntu-deploy.sh', 'deploy/lib/classify-changes.sh']), '')
assert.equal(
classify([
'deploy/ubuntu-deploy.sh',
'deploy/lib/classify-changes.sh',
'deploy/sql/prepare-113-finance-cutover.sql',
]),
'',
)
assert.equal(classify(['deploy/docker/backend-dev.Dockerfile']), '')
assert.equal(classify(['scripts/install-git-hooks.sh', '.gitignore']), '')
// 测试文件不进 dist、生产也不跑 npm test,只改测试不该重建镜像
@@ -107,10 +116,27 @@ test('改动分类:中文文件名不会被 git 转义成"漏判 → 两端全
test('部署脚本:基础镜像只补缺、部署状态在健康检查之后才落盘', { skip: !hasDeployScripts }, () => {
const source = fs.readFileSync(DEPLOY_SCRIPT, 'utf8')
const preflight = fs.readFileSync(CUTOVER_PREFLIGHT, 'utf8')
assert.ok(source.includes('--policy missing'), 'compose pull 应使用 --policy missing')
assert.ok(source.includes('write_deploy_state'), '缺少部署状态落盘')
assert.ok(source.includes('print_timing_summary'), '缺少阶段耗时输出')
assert.ok(source.includes('--git-diff'), '改动列表应通过 classify-changes.sh --git-diff 获取')
assert.ok(
source.indexOf('prepare_database_migrations\n') < source.indexOf('run_database_migrations\n'),
'迁移前置兼容检查必须在数据库迁移之前执行',
)
assert.ok(
preflight.includes("to_jsonb(migration)->>'filename' = '113_finance_channel_cutover.sql'"),
'迁移前置脚本必须识别 113 是否已经执行',
)
assert.ok(
preflight.includes('duplicate_scope_keys'),
'迁移前置脚本必须处理 cost_exceptions 重复 scope_key',
)
assert.ok(
preflight.includes("fulfillment_mode = 'legacy_platform'"),
'迁移前置脚本必须归一 sales_daily_facts 旧渠道',
)
const healthIndex = source.indexOf('wait_for_health\n')
const stateIndex = source.indexOf('write_deploy_state\n')