Files
NianAIGC/scripts/bootstrap-admin.mjs

102 lines
5.4 KiB
JavaScript
Raw Permalink 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 args = parseArgs(process.argv.slice(2));
const phone = normalizePhone(args.phone || process.env.ZHINIAN_BOOTSTRAP_ADMIN_PHONE || "");
const password = args.password || process.env.ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD || "";
const displayName = args.name || process.env.ZHINIAN_BOOTSTRAP_ADMIN_NAME || "平台超级管理员";
if (!/^\+?[0-9]{6,20}$/.test(phone)) throw new Error("请通过 --phone 或 ZHINIAN_BOOTSTRAP_ADMIN_PHONE 提供有效手机号。");
if (password.length < 8) throw new Error("请通过 --password 或 ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD 提供至少 8 位密码。");
const credential = hashPassword(password);
const now = new Date().toISOString();
if (getScriptDataBackend() === "postgres") {
pool = createPostgresPool({ applicationName: "zhinian-bootstrap-admin" });
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("SELECT pg_advisory_xact_lock($1)", ["7308731946202609"]);
const existing = (await client.query("SELECT id, password_hash FROM platform_users WHERE role = $1 ORDER BY created_at ASC LIMIT 1 FOR UPDATE", ["super_admin"])).rows[0];
const phoneOwner = (await client.query("SELECT id FROM platform_users WHERE phone = $1 LIMIT 1", [phone])).rows[0];
if (phoneOwner && phoneOwner.id !== existing?.id) throw new Error("该手机号已经绑定其他账号,不能初始化为超级管理员。");
if (existing?.password_hash) throw new Error("平台已经存在超级管理员,初始化已停止。");
const userId = existing?.id || `user_${randomBytes(8).toString("hex")}`;
if (existing) {
await client.query("UPDATE platform_users SET phone=$2, display_name=$3, password_hash=$4, password_salt=$5, status=$6, failed_login_count=0, locked_until=NULL, session_version=1, updated_at=$7 WHERE id=$1 RETURNING id", [userId, phone, displayName, credential.hash, credential.salt, "active", now]);
} else {
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, created_at, updated_at) VALUES ($1,$2,$3,$4,NULL,$5,$6,$7,0,NULL,1,$8,$8) RETURNING id", [userId, phone, displayName, "super_admin", "active", credential.hash, credential.salt, now]);
}
await client.query("COMMIT");
console.log(`已初始化超级管理员:${phone}${userId}`);
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
return;
}
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 existing = state.users.find((user) => user.role === "super_admin");
const phoneOwner = state.users.find((user) => user.phone === phone && user.id !== existing?.id);
if (phoneOwner) throw new Error("该手机号已经绑定其他账号,不能初始化为超级管理员。");
if (existing?.passwordHash) throw new Error("平台已经存在超级管理员,初始化已停止。");
const user = existing || { id: `user_${randomBytes(8).toString("hex")}`, phone, displayName, role: "super_admin", status: "active", failedLoginCount: 0, sessionVersion: 1, createdAt: now, updatedAt: now };
Object.assign(user, { phone, displayName, passwordHash: credential.hash, passwordSalt: credential.salt, organizationId: undefined, failedLoginCount: 0, lockedUntil: undefined, sessionVersion: 1, updatedAt: now });
if (!existing) state.users.push(user);
await writeFile(path, JSON.stringify(state, null, 2));
console.log(`已初始化超级管理员:${phone}${user.id}`);
}
function hashPassword(value) {
const salt = randomBytes(16).toString("hex");
return { salt, hash: scryptSync(value, salt, 64).toString("hex") };
}
function normalizePhone(value) { return value.trim().replace(/[\s()-]/g, ""); }
function parseArgs(values) {
const result = {};
for (let index = 0; index < values.length; index += 1) {
const value = values[index];
if (!value.startsWith("--")) continue;
result[value.slice(2)] = values[index + 1] && !values[index + 1].startsWith("--") ? values[++index] : "true";
}
return result;
}
async function readState(path) {
if (!existsSync(path)) return { users: [], organizations: [], migrations: [] };
try { return JSON.parse(await readFile(path, "utf8")); } catch { return { users: [], organizations: [], migrations: [] }; }
}
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, "");
}
}