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