fix(db): 禁止修改已发布迁移并分批兼容旧库
This commit is contained in:
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user