feat: add direct PostgreSQL and ACK deployment support

This commit is contained in:
2026-08-12 19:12:00 +08:00
parent d0192081c7
commit c44274f098
55 changed files with 3317 additions and 1064 deletions

View File

@@ -2,114 +2,79 @@ 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";
import { closePostgresPool, createPostgresPool, getScriptDataBackend } from "./postgres-client.mjs";
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 || "平台超级管理员";
let pool;
try {
await main();
} catch (error) {
console.error(`初始化失败:${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
} finally {
await closePostgresPool(pool);
}
if (!/^\+?[0-9]{6,20}$/.test(phone)) fail("请通过 --phone 或 ZHINIAN_BOOTSTRAP_ADMIN_PHONE 提供有效手机号。");
if (password.length < 8) fail("请通过 --password 或 ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD 提供至少 8 位密码。");
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();
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})`);
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;
}
} 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 (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 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 normalizePhone(value) { return value.trim().replace(/[\s()-]/g, ""); }
function parseArgs(values) {
const result = {};
@@ -123,28 +88,14 @@ function parseArgs(values) {
async function readState(path) {
if (!existsSync(path)) return { users: [], organizations: [], migrations: [] };
try {
return JSON.parse(await readFile(path, "utf8"));
} catch {
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/)) {
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 requireFile(path) {
return readFileSync(path, "utf8");
}
function fail(message) {
console.error(`初始化失败:${message}`);
process.exit(1);
}