2026-07-21 15:28:55 +08:00
|
|
|
|
import fs from 'node:fs';
|
|
|
|
|
|
import path from 'node:path';
|
|
|
|
|
|
import Database from 'better-sqlite3';
|
|
|
|
|
|
import pg from 'pg';
|
|
|
|
|
|
|
|
|
|
|
|
const databaseUrl = process.env.DATABASE_URL;
|
|
|
|
|
|
if (!databaseUrl) throw new Error('缺少 DATABASE_URL,迁移未执行');
|
|
|
|
|
|
|
|
|
|
|
|
const replace = process.argv.includes('--replace');
|
|
|
|
|
|
const sqlitePath = path.resolve(process.env.SQLITE_PATH || 'data/app.db');
|
|
|
|
|
|
const schemaPath = path.resolve('db/postgres/schema.sql');
|
|
|
|
|
|
if (!fs.existsSync(sqlitePath)) throw new Error(`SQLite 数据库不存在:${sqlitePath}`);
|
|
|
|
|
|
|
|
|
|
|
|
const tables = [
|
|
|
|
|
|
'operation_groups', 'users', 'projects', 'collections', 'notes', 'images', 'annotations',
|
|
|
|
|
|
'work_comments', 'work_versions', 'review_events', 'sessions', 'customer_sessions',
|
|
|
|
|
|
'audit_logs', 'api_keys', 'storage_configs',
|
|
|
|
|
|
] as const;
|
|
|
|
|
|
const booleanColumns: Record<string, Set<string>> = {
|
|
|
|
|
|
users: new Set(['must_change_password']),
|
|
|
|
|
|
projects: new Set(['customer_access_enabled']),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const sqlite = new Database(sqlitePath, { readonly: true });
|
|
|
|
|
|
const client = new pg.Client({ connectionString: databaseUrl, ssl: process.env.PGSSL === 'disable' ? false : undefined });
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await client.connect();
|
2026-07-21 19:23:58 +08:00
|
|
|
|
const schema = fs.readFileSync(schemaPath, 'utf8');
|
|
|
|
|
|
await client.query(schema);
|
2026-07-21 15:28:55 +08:00
|
|
|
|
const existing = Number((await client.query('SELECT COUNT(*)::int AS count FROM operation_groups')).rows[0].count);
|
|
|
|
|
|
if (existing && !replace) throw new Error('PostgreSQL 已有数据。确认覆盖时请显式添加 --replace');
|
|
|
|
|
|
await client.query('BEGIN');
|
|
|
|
|
|
if (replace) await client.query(`TRUNCATE ${[...tables].reverse().map((table) => `"${table}"`).join(', ')} RESTART IDENTITY CASCADE`);
|
|
|
|
|
|
|
|
|
|
|
|
for (const table of tables) {
|
|
|
|
|
|
const exists = sqlite.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
|
|
|
|
|
|
if (!exists) continue;
|
|
|
|
|
|
const rows = sqlite.prepare(`SELECT * FROM "${table}" ORDER BY id`).all() as Record<string, unknown>[];
|
|
|
|
|
|
for (const row of rows) {
|
|
|
|
|
|
const columns = Object.keys(row);
|
|
|
|
|
|
const values = columns.map((column) => booleanColumns[table]?.has(column) ? Boolean(row[column]) : row[column]);
|
|
|
|
|
|
const names = columns.map((column) => `"${column}"`).join(', ');
|
|
|
|
|
|
const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ');
|
|
|
|
|
|
await client.query(`INSERT INTO "${table}" (${names}) VALUES (${placeholders})`, values);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (rows.length) await client.query(`SELECT setval(pg_get_serial_sequence('${table}', 'id'), (SELECT MAX(id) FROM "${table}"), true)`);
|
|
|
|
|
|
}
|
2026-07-21 19:23:58 +08:00
|
|
|
|
const collectionStatusRepair = schema.match(/-- COLLECTION_STATUS_REPAIR_START([\s\S]+?)-- COLLECTION_STATUS_REPAIR_END/)?.[1];
|
|
|
|
|
|
if (!collectionStatusRepair) throw new Error('PostgreSQL schema 缺少作品交付集状态修复脚本');
|
|
|
|
|
|
await client.query(collectionStatusRepair);
|
2026-07-21 15:28:55 +08:00
|
|
|
|
await client.query('COMMIT');
|
|
|
|
|
|
process.stdout.write(`迁移完成:${tables.length} 张表已从 ${sqlitePath} 导入 PostgreSQL\n`);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
await client.query('ROLLBACK').catch(() => undefined);
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
sqlite.close();
|
|
|
|
|
|
await client.end().catch(() => undefined);
|
|
|
|
|
|
}
|