From 6867431a72582db406a92d5d43789ba4057456a0 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Mon, 9 Nov 2026 14:09:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(db):=20=E5=BD=92=E4=B8=80=E5=8C=96=E5=AF=84?= =?UTF-8?q?=E5=8D=96=E8=BF=81=E7=A7=BB=E6=97=A7=E5=91=BD=E5=90=8D=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=83=A8=E7=BD=B2=E8=BF=81=E7=A7=BB=E9=A1=BA?= =?UTF-8?q?=E5=BA=8F=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/db/migrate.test.ts | 90 +++++++++++++++++++++++++++++ apps/backend/src/db/migrate.ts | 69 ++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 apps/backend/src/db/migrate.test.ts diff --git a/apps/backend/src/db/migrate.test.ts b/apps/backend/src/db/migrate.test.ts new file mode 100644 index 00000000..33870a74 --- /dev/null +++ b/apps/backend/src/db/migrate.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { planLegacyConsignmentMigrationCleanup } from './migrate.js' + +const MAIN_MIGRATIONS = [ + '147_internal_sku_product_routing', + '148_fulfillment_execution_truth', + '149_finance_derived_scope', + '150_voucher_consume_order_received_scope', + '151_sales_daily_facts_on_account_channel', +] + +test('迁移归一化:生产旧命名 147–152 全部清理,152 未登记时不额外删除', () => { + const stale = planLegacyConsignmentMigrationCleanup([ + '001_init', + '146_voucher_consume_fallback_channel', + '147_consignment_platform', + '148_consignment_sms_logs', + '149_consignment_inventory_category_backfill', + '150_work_order_consignment_original_reward', + '151_work_order_internal_matching_deadline_guard', + '152_consignment_internal_match_cleanup', + ]) + + assert.deepEqual(stale, [ + '147_consignment_platform', + '148_consignment_sms_logs', + '149_consignment_inventory_category_backfill', + '150_work_order_consignment_original_reward', + '151_work_order_internal_matching_deadline_guard', + '152_consignment_internal_match_cleanup', + ]) +}) + +test('迁移归一化:dev 顺延后的 152–158 一并清理(含已登记的 152)', () => { + const stale = planLegacyConsignmentMigrationCleanup([ + '001_init', + '146_voucher_consume_fallback_channel', + '152_consignment_platform', + '153_consignment_sms_logs', + '154_consignment_inventory_category_backfill', + '155_work_order_consignment_original_reward', + '156_work_order_internal_matching_deadline_guard', + '157_consignment_internal_match_cleanup', + '158_consignment_match_log_snapshot', + ]) + + assert.deepEqual(stale, [ + '153_consignment_sms_logs', + '154_consignment_inventory_category_backfill', + '155_work_order_consignment_original_reward', + '156_work_order_internal_matching_deadline_guard', + '157_consignment_internal_match_cleanup', + '158_consignment_match_log_snapshot', + '152_consignment_platform', + ]) +}) + +test('迁移归一化:main 147–151 与 152 都已登记时不做任何清理', () => { + const stale = planLegacyConsignmentMigrationCleanup([ + '001_init', + '146_voucher_consume_fallback_channel', + ...MAIN_MIGRATIONS, + '152_consignment_platform', + ]) + + assert.deepEqual(stale, []) +}) + +test('迁移归一化:main 已执行、只剩旧寄卖记录时只清理旧名字', () => { + const stale = planLegacyConsignmentMigrationCleanup([ + '001_init', + ...MAIN_MIGRATIONS, + '147_consignment_platform', + '148_consignment_sms_logs', + ]) + + assert.deepEqual(stale, ['147_consignment_platform', '148_consignment_sms_logs']) +}) + +test('迁移归一化:152 已登记但 main 147–151 缺失时,152 也要重跑', () => { + const stale = planLegacyConsignmentMigrationCleanup([ + '001_init', + '146_voucher_consume_fallback_channel', + '152_consignment_platform', + ]) + + assert.deepEqual(stale, ['152_consignment_platform']) +}) diff --git a/apps/backend/src/db/migrate.ts b/apps/backend/src/db/migrate.ts index 0b5b23f2..34b0f48a 100644 --- a/apps/backend/src/db/migrate.ts +++ b/apps/backend/src/db/migrate.ts @@ -25,9 +25,42 @@ const MIGRATIONS_TABLE = 'schema_migrations' const SHORT_NUMERIC_PREFIX_MESSAGE = /^Can't determine timestamp for \d+$/ const MIGRATION_FILE_PATTERN = /^(\d{3})_.+\.sql$/i +/** 合并后的寄卖迁移文件名(不含 .sql);main 147–151 保持原编号。 */ +const CONSOLIDATED_CONSIGNMENT_MIGRATION = '152_consignment_platform' +const MAIN_MIGRATIONS_BEFORE_CONSIGNMENT = [ + '147_internal_sku_product_routing', + '148_fulfillment_execution_truth', + '149_finance_derived_scope', + '150_voucher_consume_order_received_scope', + '151_sales_daily_facts_on_account_channel', +] as const + +/** + * 历史上寄卖迁移用过两套编号,且都已被部分环境登记过: + * - 147_consignment_platform … 152_consignment_internal_match_cleanup(生产当前状态) + * - 152_consignment_platform … 158_consignment_match_log_snapshot(dev 顺延后、合并前) + * 合并后统一为 152_consignment_platform,这些旧名字必须从 schema_migrations 清掉。 + */ +const LEGACY_CONSIGNMENT_MIGRATION_NAMES = [ + '147_consignment_platform', + '148_consignment_sms_logs', + '149_consignment_inventory_category_backfill', + '150_work_order_consignment_original_reward', + '151_work_order_internal_matching_deadline_guard', + '152_consignment_internal_match_cleanup', + '153_consignment_match_log_snapshot', + '153_consignment_sms_logs', + '154_consignment_inventory_category_backfill', + '155_work_order_consignment_original_reward', + '156_work_order_internal_matching_deadline_guard', + '157_consignment_internal_match_cleanup', + '158_consignment_match_log_snapshot', +] as const + export async function runDatabaseMigrations() { assertMigrationFilesOrdered() await ensureNodePgMigrateMetadataCompatibility() + await normalizeLegacyConsignmentMigrationNames() console.info(`[db:migrate] 迁移目录: ${MIGRATIONS_DIR}`) @@ -129,6 +162,42 @@ function assertMigrationFilesOrdered() { } } +/** + * 计算需要从 schema_migrations 清理的旧寄卖迁移记录(纯函数,便于单测)。 + * + * node-pg-migrate 的 checkOrder 是按位置逐项比对「已执行列表(ORDER BY run_on, id)」和 + * 「本地迁移文件列表」,因此只改名/改 run_on 都不够:只要 152 已登记而 main 147–151 未登记, + * 152 就会占据第 147 个位置,和 147_internal_sku_product_routing 比对失败。 + * 唯一稳妥的做法是删掉旧记录,让 147–152 按新文件顺序重新执行;152 内容幂等。 + */ +export function planLegacyConsignmentMigrationCleanup(appliedNames: readonly string[]): string[] { + const applied = new Set(appliedNames) + const staleNames: string[] = LEGACY_CONSIGNMENT_MIGRATION_NAMES.filter((name) => + applied.has(name), + ) + + const mainMissing = MAIN_MIGRATIONS_BEFORE_CONSIGNMENT.some((name) => !applied.has(name)) + if (mainMissing && applied.has(CONSOLIDATED_CONSIGNMENT_MIGRATION)) { + staleNames.push(CONSOLIDATED_CONSIGNMENT_MIGRATION) + } + + return staleNames +} + +async function normalizeLegacyConsignmentMigrationNames() { + const { rows } = await query<{ name: string }>(`SELECT name FROM ${MIGRATIONS_TABLE}`) + const staleNames = planLegacyConsignmentMigrationCleanup(rows.map((row) => row.name)) + if (staleNames.length === 0) { + return + } + + await query(`DELETE FROM ${MIGRATIONS_TABLE} WHERE name = ANY($1::text[])`, [staleNames]) + console.warn( + `[db:migrate] 检测到旧寄卖迁移命名,已清理 ${staleNames.length} 条历史记录: ${staleNames.join(', ')};` + + '将按 147–152 的新顺序重新执行(152 内容幂等,不会重复建表/加列)', + ) +} + async function ensureNodePgMigrateMetadataCompatibility() { await query(` CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (