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 { createClient } from "@supabase/supabase-js"; loadEnvFile(".env"); loadEnvFile(".env.local"); const inputPath = process.argv[2]; if (!inputPath) fail("用法: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) fail("迁移文件中的 accounts 不能为空。"); const organizations = Array.isArray(input.organizations) ? input.organizations : []; const supabase = getSupabase(); if (supabase) { await migrateSupabase(accounts, organizations, supabase); } else { await migrateLocal(accounts, organizations); } console.log(`已迁移 ${accounts.length} 个账号及其历史归属。`); async function migrateSupabase(accounts, organizations, supabase) { for (const organization of organizations) { if (!organization?.id || !organization?.name) continue; const { error } = await supabase.from("platform_organizations").upsert({ id: String(organization.id), name: String(organization.name), status: organization.status === "disabled" ? "disabled" : "active", archive_owner_id: `archive:${organization.id}` }, { onConflict: "id" }); if (error) fail(error.message); } for (const account of accounts) { const record = normalizeAccount(account); const { data: existing, error: lookupError } = await supabase.from("platform_users").select("id").eq("phone", record.phone).maybeSingle(); if (lookupError) fail(lookupError.message); const userId = existing?.id || `user_${randomBytes(8).toString("hex")}`; const credential = hashPassword(record.password); const now = new Date().toISOString(); const { error: userError } = await supabase.from("platform_users").upsert({ id: userId, phone: record.phone, display_name: record.displayName, role: record.role, organization_id: record.organizationId || null, status: "active", password_hash: credential.hash, password_salt: credential.salt, failed_login_count: 0, locked_until: null, session_version: 1, legacy_subject: record.legacyOwnerId, updated_at: now }, { onConflict: "id" }); if (userError) fail(userError.message); await reassignSupabaseOwner(supabase, record.legacyOwnerId, userId, record); const { error: mappingError } = await supabase.from("platform_account_migrations").upsert({ id: `migration_${randomBytes(8).toString("hex")}`, legacy_owner_id: record.legacyOwnerId, legacy_phone: record.phone, platform_user_id: userId }, { onConflict: "legacy_owner_id" }); if (mappingError) fail(mappingError.message); } } async function reassignSupabaseOwner(supabase, legacyOwnerId, userId, account) { for (const table of ["assets", "generation_jobs", "projects", "image_templates"] ) { const { error } = await supabase.from(table).update({ owner_id: userId }).eq("owner_id", legacyOwnerId); if (error) fail(error.message); } const usagePatch = { owner_id: userId, account_username: account.phone, account_display_name: account.displayName, organization_id: account.organizationId || null }; const { error } = await supabase.from("usage_events").update(usagePatch).eq("owner_id", legacyOwnerId); if (error) fail(error.message); } 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) continue; event.ownerId = userId; event.accountUsername = account.phone; event.accountDisplayName = account.displayName; event.organizationId = account.organizationId; } } function normalizeAccount(account) { if (!account?.legacyOwnerId || !account?.phone || !account?.password || !account?.displayName) { fail("每个账号必须提供 legacyOwnerId、phone、displayName 和 password。"); } const phone = String(account.phone).trim().replace(/[\s()-]/g, ""); if (!/^\+?[0-9]{6,20}$/.test(phone)) fail(`手机号格式不正确:${phone}`); const role = account.role === "super_admin" || account.role === "organization_admin" ? account.role : "user"; if (role !== "super_admin" && !account.organizationId) fail(`普通账号缺少 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 getSupabase() { const url = process.env.NEXT_PUBLIC_SUPABASE_URL; const key = process.env.SUPABASE_SERVICE_ROLE_KEY; return url && key ? createClient(url, key, { auth: { persistSession: false } }) : null; } 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, ""); } } function fail(message) { console.error(`迁移失败:${message}`); process.exit(1); } 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: [] }; } }