136 lines
9.2 KiB
JavaScript
136 lines
9.2 KiB
JavaScript
import { existsSync, readFileSync } from "node:fs";
|
||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||
import { join } from "node:path";
|
||
import { randomBytes, scryptSync } from "node:crypto";
|
||
import { closePostgresPool, createPostgresPool, getScriptDataBackend } from "./postgres-client.mjs";
|
||
|
||
loadEnvFile(".env");
|
||
loadEnvFile(".env.local");
|
||
|
||
let pool;
|
||
try {
|
||
await main();
|
||
} catch (error) {
|
||
console.error(`迁移失败:${error instanceof Error ? error.message : String(error)}`);
|
||
process.exitCode = 1;
|
||
} finally {
|
||
await closePostgresPool(pool);
|
||
}
|
||
|
||
async function main() {
|
||
const inputPath = process.argv[2];
|
||
if (!inputPath) throw new Error("用法:npm run migrate:accounts -- path/to/legacy-accounts.json");
|
||
const input = JSON.parse(await readFile(inputPath, "utf8"));
|
||
const accounts = Array.isArray(input.accounts) ? input.accounts : [];
|
||
if (!accounts.length) throw new Error("迁移文件中的 accounts 不能为空。");
|
||
const organizations = Array.isArray(input.organizations) ? input.organizations : [];
|
||
if (getScriptDataBackend() === "postgres") {
|
||
pool = createPostgresPool({ applicationName: "zhinian-import-legacy-accounts" });
|
||
await migratePostgres(accounts, organizations, pool);
|
||
} else {
|
||
await migrateLocal(accounts, organizations);
|
||
}
|
||
console.log(`已迁移 ${accounts.length} 个账号及其历史归属。`);
|
||
}
|
||
|
||
async function migratePostgres(accounts, organizations, databasePool) {
|
||
for (const organization of organizations) {
|
||
if (!organization?.id || !organization?.name) continue;
|
||
await databasePool.query(
|
||
"INSERT INTO platform_organizations (id,name,status,archive_owner_id) VALUES ($1,$2,$3,$4) ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name,status=EXCLUDED.status,archive_owner_id=EXCLUDED.archive_owner_id,updated_at=now() RETURNING id",
|
||
[String(organization.id), String(organization.name), organization.status === "disabled" ? "disabled" : "active", `archive:${organization.id}`]
|
||
);
|
||
}
|
||
for (const account of accounts) {
|
||
const record = normalizeAccount(account);
|
||
const credential = hashPassword(record.password);
|
||
const client = await databasePool.connect();
|
||
try {
|
||
await client.query("BEGIN");
|
||
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`legacy-account:${record.phone}`]);
|
||
const existing = (await client.query("SELECT id FROM platform_users WHERE phone=$1 FOR UPDATE", [record.phone])).rows[0];
|
||
const userId = existing?.id || `user_${randomBytes(8).toString("hex")}`;
|
||
const now = new Date().toISOString();
|
||
await client.query(
|
||
"INSERT INTO platform_users (id,phone,display_name,role,organization_id,status,password_hash,password_salt,failed_login_count,locked_until,session_version,legacy_subject,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,0,NULL,1,$9,$10,$10) ON CONFLICT (id) DO UPDATE SET phone=EXCLUDED.phone,display_name=EXCLUDED.display_name,role=EXCLUDED.role,organization_id=EXCLUDED.organization_id,status=EXCLUDED.status,password_hash=EXCLUDED.password_hash,password_salt=EXCLUDED.password_salt,failed_login_count=0,locked_until=NULL,session_version=platform_users.session_version+1,legacy_subject=EXCLUDED.legacy_subject,updated_at=EXCLUDED.updated_at RETURNING id",
|
||
[userId, record.phone, record.displayName, record.role, record.organizationId || null, "active", credential.hash, credential.salt, record.legacyOwnerId, now]
|
||
);
|
||
for (const table of ["assets", "generation_jobs", "projects", "image_templates"]) {
|
||
await client.query(`UPDATE ${table} SET owner_id=$2 WHERE owner_id=$1`, [record.legacyOwnerId, userId]);
|
||
}
|
||
await client.query("UPDATE usage_events SET owner_id=$2, account_username=$3, account_display_name=$4, organization_id=$5 WHERE owner_id=$1", [record.legacyOwnerId, userId, record.phone, record.displayName, record.organizationId || null]);
|
||
await client.query(
|
||
"INSERT INTO platform_account_migrations (id,legacy_owner_id,legacy_phone,platform_user_id) VALUES ($1,$2,$3,$4) ON CONFLICT (legacy_owner_id) DO UPDATE SET id=EXCLUDED.id,legacy_phone=EXCLUDED.legacy_phone,platform_user_id=EXCLUDED.platform_user_id,created_at=now() RETURNING id",
|
||
[`migration_${randomBytes(8).toString("hex")}`, record.legacyOwnerId, record.phone, userId]
|
||
);
|
||
await client.query("COMMIT");
|
||
} catch (error) {
|
||
await client.query("ROLLBACK");
|
||
throw error;
|
||
} finally {
|
||
client.release();
|
||
}
|
||
}
|
||
}
|
||
|
||
async function migrateLocal(accounts, organizations) {
|
||
const dataDirectory = process.env.ZHINIAN_DATA_DIR || join(process.cwd(), ".runtime", "data");
|
||
await mkdir(dataDirectory, { recursive: true });
|
||
const path = join(dataDirectory, "platform-accounts.json");
|
||
const state = await readState(path);
|
||
const now = new Date().toISOString();
|
||
for (const organization of organizations) {
|
||
if (!organization?.id || !organization?.name) continue;
|
||
const next = { id: String(organization.id), name: String(organization.name), status: organization.status === "disabled" ? "disabled" : "active", archiveOwnerId: `archive:${organization.id}`, createdAt: now, updatedAt: now };
|
||
const index = state.organizations.findIndex((item) => item.id === next.id);
|
||
if (index >= 0) state.organizations[index] = { ...state.organizations[index], ...next }; else state.organizations.push(next);
|
||
}
|
||
for (const account of accounts) {
|
||
const record = normalizeAccount(account);
|
||
const credential = hashPassword(record.password);
|
||
let user = state.users.find((item) => item.phone === record.phone);
|
||
if (!user) {
|
||
user = { id: `user_${randomBytes(8).toString("hex")}`, phone: record.phone, displayName: record.displayName, role: record.role, organizationId: record.organizationId, status: "active", failedLoginCount: 0, sessionVersion: 1, createdAt: now, updatedAt: now };
|
||
state.users.push(user);
|
||
}
|
||
Object.assign(user, { displayName: record.displayName, role: record.role, organizationId: record.organizationId, status: "active", passwordHash: credential.hash, passwordSalt: credential.salt, failedLoginCount: 0, lockedUntil: undefined, sessionVersion: (user.sessionVersion || 1) + 1, legacySubject: record.legacyOwnerId, updatedAt: now });
|
||
reassignLocalOwner(state, record.legacyOwnerId, user.id, record);
|
||
const migration = { id: `migration_${randomBytes(8).toString("hex")}`, legacyOwnerId: record.legacyOwnerId, legacyPhone: record.phone, platformUserId: user.id, createdAt: now };
|
||
const mappingIndex = state.migrations.findIndex((item) => item.legacyOwnerId === record.legacyOwnerId);
|
||
if (mappingIndex >= 0) state.migrations[mappingIndex] = migration; else state.migrations.push(migration);
|
||
}
|
||
await writeFile(path, JSON.stringify(state, null, 2));
|
||
}
|
||
|
||
function reassignLocalOwner(state, legacyOwnerId, userId, account) {
|
||
for (const collection of [state.assets, state.generationJobs, state.projects, state.imageTemplates]) for (const item of collection) if (item.ownerId === legacyOwnerId) item.ownerId = userId;
|
||
for (const event of state.usageEvents) if (event.ownerId === legacyOwnerId) Object.assign(event, { ownerId: userId, accountUsername: account.phone, accountDisplayName: account.displayName, organizationId: account.organizationId });
|
||
}
|
||
|
||
function normalizeAccount(account) {
|
||
if (!account?.legacyOwnerId || !account?.phone || !account?.password || !account?.displayName) throw new Error("每个账号必须提供 legacyOwnerId、phone、displayName 和 password。");
|
||
const phone = String(account.phone).trim().replace(/[\s()-]/g, "");
|
||
if (!/^\+?[0-9]{6,20}$/.test(phone)) throw new Error(`手机号格式不正确:${phone}`);
|
||
const role = account.role === "super_admin" || account.role === "organization_admin" ? account.role : "user";
|
||
if (role !== "super_admin" && !account.organizationId) throw new Error(`普通账号缺少 organizationId:${phone}`);
|
||
return { legacyOwnerId: String(account.legacyOwnerId), phone, displayName: String(account.displayName).trim(), password: String(account.password), role, organizationId: account.organizationId ? String(account.organizationId) : undefined };
|
||
}
|
||
|
||
function hashPassword(value) { const salt = randomBytes(16).toString("hex"); return { salt, hash: scryptSync(value, salt, 64).toString("hex") }; }
|
||
|
||
function loadEnvFile(path) {
|
||
if (!existsSync(path)) return;
|
||
for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
|
||
const match = line.match(/^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)\s*$/);
|
||
if (!match || process.env[match[1]]) continue;
|
||
process.env[match[1]] = match[2].replace(/^['"]|['"]$/g, "");
|
||
}
|
||
}
|
||
|
||
async function readState(path) {
|
||
try {
|
||
const raw = JSON.parse(await readFile(path, "utf8"));
|
||
return { users: Array.isArray(raw.users) ? raw.users : [], organizations: Array.isArray(raw.organizations) ? raw.organizations : [], migrations: Array.isArray(raw.migrations) ? raw.migrations : [], assets: Array.isArray(raw.assets) ? raw.assets : [], generationJobs: Array.isArray(raw.generationJobs) ? raw.generationJobs : [], usageEvents: Array.isArray(raw.usageEvents) ? raw.usageEvents : [], projects: Array.isArray(raw.projects) ? raw.projects : [], imageTemplates: Array.isArray(raw.imageTemplates) ? raw.imageTemplates : [] };
|
||
} catch { return { users: [], organizations: [], migrations: [], assets: [], generationJobs: [], usageEvents: [], projects: [], imageTemplates: [] }; }
|
||
}
|