领取页改为统一 UID 入口;lewan 强制角色 ID 匹配后才发货核销,feifei 将 uid 拼入 H5 并去掉 claim 短链,对外仍返回本站 claimUrl。
196 lines
5.5 KiB
TypeScript
196 lines
5.5 KiB
TypeScript
/**
|
|
* 数据库迁移入口。
|
|
*
|
|
* 策略:
|
|
* - 001_init.sql:空库基线,建立完整结构
|
|
* - 002_xxx.sql 起:渐进增量迁移,禁止改已应用历史文件
|
|
* - 启动时自动 up;也可用 npm run db:migrate 手动执行
|
|
* - 新建迁移:npm run db:migrate:create -- <name>
|
|
* - 查看状态:npm run db:migrate:status
|
|
*/
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import process from 'node:process'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
import { runner, type RunnerOption } from 'node-pg-migrate'
|
|
import type { ClientConfig } from 'pg'
|
|
|
|
import { runtimeConfig } from '../config/runtime.js'
|
|
import { closeDb, query } from './client.js'
|
|
|
|
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
|
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
|
|
|
|
export async function runDatabaseMigrations() {
|
|
assertMigrationFilesOrdered()
|
|
await ensureNodePgMigrateMetadataCompatibility()
|
|
|
|
console.info(`[db:migrate] 迁移目录: ${MIGRATIONS_DIR}`)
|
|
|
|
await runner({
|
|
databaseUrl: createMigrationDatabaseConfig(),
|
|
dir: MIGRATIONS_DIR,
|
|
direction: 'up',
|
|
migrationsTable: MIGRATIONS_TABLE,
|
|
migrationsSchema: 'public',
|
|
schema: 'public',
|
|
singleTransaction: true,
|
|
checkOrder: true,
|
|
logger: {
|
|
debug: () => undefined,
|
|
info: (message) => {
|
|
console.info(`[db:migrate] ${message}`)
|
|
},
|
|
warn: (message) => {
|
|
console.warn(`[db:migrate] ${message}`)
|
|
},
|
|
error: (message) => {
|
|
if (SHORT_NUMERIC_PREFIX_MESSAGE.test(message)) {
|
|
return
|
|
}
|
|
console.error(`[db:migrate] ${message}`)
|
|
},
|
|
},
|
|
} satisfies RunnerOption)
|
|
|
|
await syncLegacyMigrationColumns()
|
|
}
|
|
|
|
function assertMigrationFilesOrdered() {
|
|
if (!fs.existsSync(MIGRATIONS_DIR)) {
|
|
throw new Error(`迁移目录不存在: ${MIGRATIONS_DIR}`)
|
|
}
|
|
|
|
const files = fs
|
|
.readdirSync(MIGRATIONS_DIR)
|
|
.filter((name) => name.endsWith('.sql'))
|
|
.sort((a, b) => a.localeCompare(b, 'en'))
|
|
|
|
if (files.length === 0) {
|
|
throw new Error(`迁移目录为空: ${MIGRATIONS_DIR}`)
|
|
}
|
|
|
|
const invalid = files.filter((name) => !MIGRATION_FILE_PATTERN.test(name))
|
|
if (invalid.length > 0) {
|
|
throw new Error(
|
|
`迁移文件命名必须为 NNN_name.sql(三位序号),非法文件: ${invalid.join(', ')}`,
|
|
)
|
|
}
|
|
|
|
const versions = files.map((name) => Number(name.slice(0, 3)))
|
|
for (let index = 1; index < versions.length; index += 1) {
|
|
const current = versions[index]
|
|
const previous = versions[index - 1]
|
|
const currentFile = files[index]
|
|
const previousFile = files[index - 1]
|
|
if (current === undefined || previous === undefined || !currentFile || !previousFile) {
|
|
continue
|
|
}
|
|
if (current < previous) {
|
|
throw new Error(`迁移序号乱序: ${previousFile} 之后出现 ${currentFile}`)
|
|
}
|
|
if (current === previous) {
|
|
throw new Error(`迁移序号重复: ${previousFile} 与 ${currentFile}`)
|
|
}
|
|
}
|
|
|
|
if (files[0] !== '001_init.sql') {
|
|
console.warn(
|
|
`[db:migrate] 警告: 首个迁移不是 001_init.sql(当前为 ${files[0]}),请确认是否刻意调整基线`,
|
|
)
|
|
}
|
|
}
|
|
|
|
async function ensureNodePgMigrateMetadataCompatibility() {
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
name VARCHAR(255),
|
|
run_on TIMESTAMPTZ,
|
|
filename TEXT,
|
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
)
|
|
`)
|
|
|
|
await query(`
|
|
ALTER TABLE ${MIGRATIONS_TABLE}
|
|
ADD COLUMN IF NOT EXISTS name VARCHAR(255),
|
|
ADD COLUMN IF NOT EXISTS run_on TIMESTAMPTZ,
|
|
ADD COLUMN IF NOT EXISTS filename TEXT,
|
|
ADD COLUMN IF NOT EXISTS applied_at TIMESTAMPTZ
|
|
`)
|
|
|
|
await query(`
|
|
ALTER TABLE ${MIGRATIONS_TABLE}
|
|
ALTER COLUMN filename DROP NOT NULL,
|
|
ALTER COLUMN applied_at SET DEFAULT NOW()
|
|
`)
|
|
|
|
await query(`
|
|
UPDATE ${MIGRATIONS_TABLE}
|
|
SET
|
|
name = COALESCE(
|
|
NULLIF(name, ''),
|
|
NULLIF(REGEXP_REPLACE(COALESCE(filename, ''), '\\.[^.]+$', ''), ''),
|
|
CONCAT('legacy_', id)
|
|
),
|
|
run_on = COALESCE(run_on, applied_at, NOW())
|
|
WHERE name IS NULL
|
|
OR name = ''
|
|
OR run_on IS NULL
|
|
`)
|
|
|
|
await query(`
|
|
ALTER TABLE ${MIGRATIONS_TABLE}
|
|
ALTER COLUMN name SET NOT NULL,
|
|
ALTER COLUMN run_on SET NOT NULL
|
|
`)
|
|
|
|
await query(`
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_schema_migrations_name
|
|
ON ${MIGRATIONS_TABLE}(name)
|
|
`)
|
|
}
|
|
|
|
async function syncLegacyMigrationColumns() {
|
|
await query(`
|
|
UPDATE ${MIGRATIONS_TABLE}
|
|
SET
|
|
filename = COALESCE(NULLIF(filename, ''), CONCAT(name, '.sql')),
|
|
applied_at = COALESCE(applied_at, run_on, NOW())
|
|
WHERE filename IS NULL
|
|
OR filename = ''
|
|
OR applied_at IS NULL
|
|
`)
|
|
}
|
|
|
|
function createMigrationDatabaseConfig(): ClientConfig {
|
|
const connectionString = String(runtimeConfig.database?.url || '').trim()
|
|
if (!connectionString) {
|
|
throw new Error('DATABASE_URL 未配置,无法执行数据库迁移')
|
|
}
|
|
|
|
return {
|
|
connectionString,
|
|
ssl: runtimeConfig.database?.ssl ? { rejectUnauthorized: false } : false,
|
|
}
|
|
}
|
|
|
|
const currentFilePath = fileURLToPath(import.meta.url)
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === currentFilePath) {
|
|
runDatabaseMigrations()
|
|
.then(async () => {
|
|
await closeDb()
|
|
})
|
|
.catch(async (error) => {
|
|
console.error(error)
|
|
await closeDb()
|
|
process.exitCode = 1
|
|
})
|
|
}
|