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

227 lines
9.0 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 { 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: [] };
}
}