151 lines
5.5 KiB
JavaScript
151 lines
5.5 KiB
JavaScript
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);
|
||
}
|