Files
NianAIGC/scripts/bootstrap-admin.mjs

151 lines
5.5 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 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)) fail("请通过 --phone 或 ZHINIAN_BOOTSTRAP_ADMIN_PHONE 提供有效手机号。");
if (password.length < 8) fail("请通过 --password 或 ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD 提供至少 8 位密码。");
const credential = hashPassword(password);
const now = new Date().toISOString();
const supabase = getSupabase();
if (supabase) {
const { data: existing, error: lookupError } = await supabase.from("platform_users").select("id, role, password_hash").eq("role", "super_admin").limit(1).maybeSingle();
if (lookupError) fail(lookupError.message);
const { data: phoneOwner, error: phoneLookupError } = await supabase.from("platform_users").select("id").eq("phone", phone).limit(1).maybeSingle();
if (phoneLookupError) fail(phoneLookupError.message);
if (phoneOwner && phoneOwner.id !== existing?.id) fail("该手机号已经绑定其他账号,不能初始化为超级管理员。");
if (existing && existing.password_hash) fail("平台已经存在超级管理员,初始化已停止。");
if (existing) {
const { error } = await supabase.from("platform_users").update({
phone,
display_name: displayName,
password_hash: credential.hash,
password_salt: credential.salt,
status: "active",
failed_login_count: 0,
locked_until: null,
session_version: 1,
updated_at: now
}).eq("id", existing.id);
if (error) fail(error.message);
console.log(`已初始化超级管理员:${phone}(${existing.id})`);
} else {
const user = {
id: `user_${randomBytes(8).toString("hex")}`,
phone,
display_name: displayName,
role: "super_admin",
organization_id: null,
status: "active",
password_hash: credential.hash,
password_salt: credential.salt,
failed_login_count: 0,
locked_until: null,
session_version: 1,
created_at: now,
updated_at: now
};
const { error } = await supabase.from("platform_users").insert(user);
if (error) fail(error.message);
console.log(`已初始化超级管理员:${phone}(${user.id})`);
}
} else {
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) fail("该手机号已经绑定其他账号,不能初始化为超级管理员。");
if (existing && existing.passwordHash) fail("平台已经存在超级管理员,初始化已停止。");
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 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 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;
const text = requireFile(path);
for (const line of text.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 requireFile(path) {
return readFileSync(path, "utf8");
}
function fail(message) {
console.error(`初始化失败:${message}`);
process.exit(1);
}