Files
NianAIGC/scripts/import-legacy-accounts.mjs

136 lines
9.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: [] }; }
}