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);
}

View File

@@ -0,0 +1,37 @@
import { readFileSync, readdirSync } from "node:fs";
const directory = new URL("../deploy/ack/", import.meta.url);
const files = readdirSync(directory).filter((name) => name.endsWith(".yaml")).sort();
for (const file of files) {
const text = readFileSync(new URL(file, directory), "utf8");
assert(text.includes("apiVersion:"), `${file}: missing apiVersion`);
assert(text.includes("kind:"), `${file}: missing kind`);
assert(!text.includes("server-snippet"), `${file}: must not depend on disabled snippet annotations`);
}
const migrationJob = read("migration-job.yaml");
assert(migrationJob.includes("name: ZHINIAN_DATA_BACKEND\n value: postgres"), "migration Job must select postgres");
assert(migrationJob.includes("name: DATABASE_APP_ROLE"), "migration Job must provision the Web role");
assert(migrationJob.includes("secretName: zhinian-rds-ca"), "migration Job must mount the RDS CA");
const web = read("web.yaml");
assert(/^\s*replicas: 1\s*$/m.test(web), "Web must default to one replica until object storage is shared");
assert(web.includes("path: /api/ready"), "Web must use database-aware readiness");
const ingress = read("ingress.yaml");
assert(ingress.includes("path: /api/internal/worker"), "Ingress must intercept the internal worker prefix");
assert(ingress.includes("name: zhinian-public-deny"), "Ingress must route the internal prefix away from Web");
const service = read("service.yaml");
assert(service.includes("name: zhinian-public-deny"), "selectorless deny Service is required");
console.log(`ACK manifest assertions passed (${files.length} files)`);
function read(file) {
return readFileSync(new URL(file, directory), "utf8");
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}

View File

@@ -2,88 +2,77 @@ 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 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);
let pool;
try {
await main();
} catch (error) {
console.error(`迁移失败:${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
} finally {
await closePostgresPool(pool);
}
console.log(`已迁移 ${accounts.length} 个账号及其历史归属。`);
async function main() {
const inputPath = process.argv[2];
if (!inputPath) throw new Error("用法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) throw new Error("迁移文件中的 accounts 不能为空。");
const organizations = Array.isArray(input.organizations) ? input.organizations : [];
if (getScriptDataBackend() === "postgres") {
pool = createPostgresPool({ applicationName: "zhinian-import-legacy-accounts" });
await migratePostgres(accounts, organizations, pool);
} else {
await migrateLocal(accounts, organizations);
}
console.log(`已迁移 ${accounts.length} 个账号及其历史归属。`);
}
async function migrateSupabase(accounts, organizations, supabase) {
async function migratePostgres(accounts, organizations, databasePool) {
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);
await databasePool.query(
"INSERT INTO platform_organizations (id,name,status,archive_owner_id) VALUES ($1,$2,$3,$4) ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name,status=EXCLUDED.status,archive_owner_id=EXCLUDED.archive_owner_id,updated_at=now() RETURNING id",
[String(organization.id), String(organization.name), organization.status === "disabled" ? "disabled" : "active", `archive:${organization.id}`]
);
}
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);
const client = await databasePool.connect();
try {
await client.query("BEGIN");
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`legacy-account:${record.phone}`]);
const existing = (await client.query("SELECT id FROM platform_users WHERE phone=$1 FOR UPDATE", [record.phone])).rows[0];
const userId = existing?.id || `user_${randomBytes(8).toString("hex")}`;
const now = new Date().toISOString();
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,legacy_subject,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,0,NULL,1,$9,$10,$10) ON CONFLICT (id) DO UPDATE SET phone=EXCLUDED.phone,display_name=EXCLUDED.display_name,role=EXCLUDED.role,organization_id=EXCLUDED.organization_id,status=EXCLUDED.status,password_hash=EXCLUDED.password_hash,password_salt=EXCLUDED.password_salt,failed_login_count=0,locked_until=NULL,session_version=platform_users.session_version+1,legacy_subject=EXCLUDED.legacy_subject,updated_at=EXCLUDED.updated_at RETURNING id",
[userId, record.phone, record.displayName, record.role, record.organizationId || null, "active", credential.hash, credential.salt, record.legacyOwnerId, now]
);
for (const table of ["assets", "generation_jobs", "projects", "image_templates"]) {
await client.query(`UPDATE ${table} SET owner_id=$2 WHERE owner_id=$1`, [record.legacyOwnerId, userId]);
}
await client.query("UPDATE usage_events SET owner_id=$2, account_username=$3, account_display_name=$4, organization_id=$5 WHERE owner_id=$1", [record.legacyOwnerId, userId, record.phone, record.displayName, record.organizationId || null]);
await client.query(
"INSERT INTO platform_account_migrations (id,legacy_owner_id,legacy_phone,platform_user_id) VALUES ($1,$2,$3,$4) ON CONFLICT (legacy_owner_id) DO UPDATE SET id=EXCLUDED.id,legacy_phone=EXCLUDED.legacy_phone,platform_user_id=EXCLUDED.platform_user_id,created_at=now() RETURNING id",
[`migration_${randomBytes(8).toString("hex")}`, record.legacyOwnerId, record.phone, userId]
);
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
}
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 });
@@ -92,106 +81,42 @@ async function migrateLocal(accounts, organizations) {
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 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);
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
};
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
});
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 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);
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;
}
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) Object.assign(event, { ownerId: userId, accountUsername: account.phone, accountDisplayName: account.displayName, organizationId: account.organizationId });
}
function normalizeAccount(account) {
if (!account?.legacyOwnerId || !account?.phone || !account?.password || !account?.displayName) {
fail("每个账号必须提供 legacyOwnerId、phone、displayName 和 password。");
}
if (!account?.legacyOwnerId || !account?.phone || !account?.password || !account?.displayName) throw new Error("每个账号必须提供 legacyOwnerId、phone、displayName 和 password。");
const phone = String(account.phone).trim().replace(/[\s()-]/g, "");
if (!/^\+?[0-9]{6,20}$/.test(phone)) fail(`手机号格式不正确:${phone}`);
if (!/^\+?[0-9]{6,20}$/.test(phone)) throw new Error(`手机号格式不正确:${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
};
if (role !== "super_admin" && !account.organizationId) throw new Error(`普通账号缺少 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 hashPassword(value) { const salt = randomBytes(16).toString("hex"); return { salt, hash: scryptSync(value, salt, 64).toString("hex") }; }
function loadEnvFile(path) {
if (!existsSync(path)) return;
@@ -202,25 +127,9 @@ function loadEnvFile(path) {
}
}
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: [] };
}
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: [] }; }
}

View File

@@ -0,0 +1,204 @@
import { createHash } from "node:crypto";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
closePostgresPool,
createPostgresPool,
getScriptDataBackend,
quotePostgresIdentifier
} from "./postgres-client.mjs";
loadEnvFile(".env");
loadEnvFile(".env.local");
const MIGRATION_LOCK_ID = "7308731946202608";
const migrationsDirectory = fileURLToPath(new URL("../database/migrations/", import.meta.url));
let pool;
let client;
let locked = false;
try {
if (getScriptDataBackend() !== "postgres") {
throw new Error("Database migrations require ZHINIAN_DATA_BACKEND=postgres");
}
const applicationRole = process.env.DATABASE_APP_ROLE?.trim();
if (!applicationRole && process.env.NODE_ENV === "production") {
throw new Error("DATABASE_APP_ROLE is required in production so application privileges can be provisioned");
}
if (applicationRole) quotePostgresIdentifier(applicationRole);
pool = createPostgresPool({ applicationName: "zhinian-migrate" });
client = await pool.connect();
await client.query("SELECT pg_advisory_lock($1)", [MIGRATION_LOCK_ID]);
locked = true;
await client.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version text PRIMARY KEY,
checksum text NOT NULL,
applied_at timestamptz NOT NULL DEFAULT now()
)
`);
const migrations = await discoverMigrations(migrationsDirectory);
const { rows: appliedRows } = await client.query("SELECT version, checksum FROM schema_migrations");
const applied = new Map(appliedRows.map((row) => [row.version, row.checksum]));
const discoveredVersions = new Set(migrations.map((migration) => migration.version));
for (const version of applied.keys()) {
if (!discoveredVersions.has(version)) {
throw new Error(`Applied migration ${version} is missing from the migration directory; refusing to continue`);
}
}
for (const migration of migrations) {
const recordedChecksum = applied.get(migration.version);
if (recordedChecksum && recordedChecksum !== migration.checksum) {
throw new Error(`Applied migration ${migration.version} has changed; refusing to continue`);
}
}
for (const migration of migrations) {
if (applied.has(migration.version)) continue;
await client.query("BEGIN");
try {
await client.query(migration.sql);
await client.query(
"INSERT INTO schema_migrations(version, checksum) VALUES ($1, $2)",
[migration.version, migration.checksum]
);
await client.query("COMMIT");
console.log(`Applied migration ${migration.version}`);
} catch (error) {
await client.query("ROLLBACK");
throw error;
}
}
if (applicationRole) {
await provisionApplicationRole(client, applicationRole);
await verifyApplicationRole(client, applicationRole);
console.log("Provisioned PostgreSQL privileges for the configured application role");
}
console.log(`Database migrations are current (${migrations.length} discovered)`);
} catch (error) {
console.error(`Database migration failed: ${safeErrorMessage(error)}`);
process.exitCode = 1;
} finally {
if (client) {
if (locked) {
try {
await client.query("SELECT pg_advisory_unlock($1)", [MIGRATION_LOCK_ID]);
} catch {
// Closing the session below also releases the advisory lock.
}
}
client.release();
}
await closePostgresPool(pool);
}
async function provisionApplicationRole(client, role) {
const quotedRole = quotePostgresIdentifier(role);
const applicationTables = applicationRoleTablePrivileges();
const managedTableNames = applicationTables.map(([table]) => `public.${quotePostgresIdentifier(table)}`).join(", ");
await client.query("BEGIN");
try {
await client.query("REVOKE CREATE ON SCHEMA public FROM PUBLIC");
await client.query(`GRANT USAGE ON SCHEMA public TO ${quotedRole}`);
await client.query(`REVOKE ALL ON TABLE ${managedTableNames} FROM ${quotedRole}`);
for (const [table, privileges] of applicationTables) {
await client.query(
`GRANT ${privileges} ON TABLE public.${quotePostgresIdentifier(table)} TO ${quotedRole}`
);
}
await client.query("REVOKE ALL ON FUNCTION public.claim_generation_jobs(text, integer, integer) FROM PUBLIC");
await client.query(
"REVOKE ALL ON FUNCTION public.billing_post_wallet_entry(text, text, text, text, text, bigint, text, text, text, jsonb) FROM PUBLIC"
);
await client.query(
`GRANT EXECUTE ON FUNCTION public.claim_generation_jobs(text, integer, integer) TO ${quotedRole}`
);
await client.query(
`GRANT EXECUTE ON FUNCTION public.billing_post_wallet_entry(text, text, text, text, text, bigint, text, text, text, jsonb) TO ${quotedRole}`
);
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
throw error;
}
}
async function verifyApplicationRole(client, role) {
const checks = applicationRoleTablePrivileges().map(([table, privileges]) => [
`public.${table}`,
privileges.replaceAll(" ", "")
]);
const result = await client.query(
`SELECT
bool_and(has_table_privilege($1, table_name, privileges)) AS tables_ready,
has_function_privilege($1, 'public.claim_generation_jobs(text,integer,integer)', 'EXECUTE') AS claim_ready,
has_function_privilege(
$1,
'public.billing_post_wallet_entry(text,text,text,text,text,bigint,text,text,text,jsonb)',
'EXECUTE'
) AS billing_ready
FROM unnest($2::text[], $3::text[]) AS required(table_name, privileges)`,
[role, checks.map(([table]) => table), checks.map(([, privileges]) => privileges)]
);
const status = result.rows[0];
if (!status?.tables_ready || !status.claim_ready || !status.billing_ready) {
throw new Error("Application role privilege verification failed");
}
}
function applicationRoleTablePrivileges() {
return [
["assets", "SELECT, INSERT, DELETE"],
["generation_jobs", "SELECT, INSERT, UPDATE, DELETE"],
["usage_events", "SELECT, INSERT, UPDATE"],
["projects", "SELECT, UPDATE"],
["image_templates", "SELECT, INSERT, UPDATE, DELETE"],
["platform_organizations", "SELECT, INSERT, UPDATE, DELETE"],
["platform_users", "SELECT, INSERT, UPDATE, DELETE"],
["platform_account_migrations", "SELECT, INSERT, UPDATE"],
["billing_price_rules", "SELECT, INSERT, UPDATE"],
["billing_wallets", "SELECT, INSERT, UPDATE"],
["billing_ledger", "SELECT, INSERT"]
];
}
async function discoverMigrations(directory) {
const files = readdirSync(directory, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith(".sql"))
.map((entry) => entry.name)
.sort((left, right) => left.localeCompare(right, "en"));
const migrations = [];
for (const file of files) {
const sql = await readFile(join(directory, file), "utf8");
migrations.push({
version: basename(file, ".sql"),
checksum: createHash("sha256").update(sql).digest("hex"),
sql
});
}
return 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, "");
}
}
function safeErrorMessage(error) {
if (!(error instanceof Error)) return "Unknown error";
let message = error.message;
const connectionString = process.env.DATABASE_URL?.trim();
if (connectionString) message = message.replaceAll(connectionString, "[redacted DATABASE_URL]");
return message.replace(/postgres(?:ql)?:\/\/[^\s@]+@/gi, "postgresql://[redacted]@");
}

View File

@@ -0,0 +1,9 @@
import type { Pool } from "pg";
export function getScriptDataBackend(env?: NodeJS.ProcessEnv): "local" | "postgres";
export function createPostgresPool(options?: {
env?: NodeJS.ProcessEnv;
applicationName?: string;
}): Pool;
export function closePostgresPool(pool?: Pool): Promise<void>;
export function quotePostgresIdentifier(value: string): string;

View File

@@ -0,0 +1,85 @@
import { readFileSync } from "node:fs";
import pg from "pg";
const { Pool } = pg;
export function getScriptDataBackend(env = process.env) {
const backend = env.ZHINIAN_DATA_BACKEND?.trim().toLowerCase();
if (backend === "local" || backend === "postgres") return backend;
throw new Error("ZHINIAN_DATA_BACKEND must be explicitly set to 'local' or 'postgres'");
}
export function createPostgresPool({ env = process.env, applicationName = "zhinian-script" } = {}) {
const connectionString = env.DATABASE_URL?.trim();
if (!connectionString) throw new Error("DATABASE_URL is required when ZHINIAN_DATA_BACKEND=postgres");
assertConnectionStringContract(connectionString);
const config = {
connectionString,
max: positiveInteger(env, "DATABASE_POOL_MAX", 10),
idleTimeoutMillis: nonNegativeInteger(env, "DATABASE_IDLE_TIMEOUT_MS", 30_000),
connectionTimeoutMillis: positiveInteger(env, "DATABASE_CONNECTION_TIMEOUT_MS", 10_000),
statement_timeout: positiveInteger(env, "DATABASE_STATEMENT_TIMEOUT_MS", 30_000),
application_name: applicationName
};
const sslMode = env.DATABASE_SSL_MODE?.trim().toLowerCase() || "disable";
if (sslMode === "verify-full") {
const caPath = env.DATABASE_CA_CERT_PATH?.trim();
if (!caPath) throw new Error("DATABASE_CA_CERT_PATH is required when DATABASE_SSL_MODE=verify-full");
config.ssl = { ca: readFileSync(caPath, "utf8"), rejectUnauthorized: true };
} else if (sslMode !== "disable") {
throw new Error("DATABASE_SSL_MODE must be 'disable' or 'verify-full'");
}
return new Pool(config);
}
export async function closePostgresPool(pool) {
if (pool) await pool.end();
}
export function quotePostgresIdentifier(value) {
if (typeof value !== "string" || !/^[a-z_][a-z0-9_]{0,62}$/.test(value)) {
throw new Error("PostgreSQL identifier must match [a-z_][a-z0-9_]{0,62}");
}
return `"${value}"`;
}
function assertConnectionStringContract(connectionString) {
let parsed;
try {
parsed = new URL(connectionString);
} catch {
throw new Error("DATABASE_URL must be a valid PostgreSQL connection URI");
}
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
throw new Error("DATABASE_URL must use the postgres:// or postgresql:// scheme");
}
const sslParameters = [...parsed.searchParams.keys()].filter((key) => key.toLowerCase().startsWith("ssl"));
if (sslParameters.length > 0) {
throw new Error(
`DATABASE_URL must not contain SSL query parameters (${sslParameters.join(", ")}); use DATABASE_SSL_MODE and DATABASE_CA_CERT_PATH`
);
}
}
function positiveInteger(env, name, fallback) {
const value = integer(env, name, fallback);
if (value <= 0) throw new Error(`${name} must be a positive integer`);
return value;
}
function nonNegativeInteger(env, name, fallback) {
const value = integer(env, name, fallback);
if (value < 0) throw new Error(`${name} must be a non-negative integer`);
return value;
}
function integer(env, name, fallback) {
const raw = env[name]?.trim();
if (!raw) return fallback;
const value = Number(raw);
if (!Number.isSafeInteger(value)) throw new Error(`${name} must be an integer`);
return value;
}

View File

@@ -4,6 +4,7 @@ const baseUrl = (process.env.ZHINIAN_WORKER_BASE_URL || process.env.NEXT_PUBLIC_
const token = (process.env.ZHINIAN_INTERNAL_WORKER_TOKEN || "").trim();
const intervalMs = positiveInt(process.env.ZHINIAN_WORKER_INTERVAL_MS, 5000);
const limit = positiveInt(process.env.ZHINIAN_WORKER_BATCH_SIZE, 3);
const requestTimeoutMs = positiveInt(process.env.ZHINIAN_WORKER_REQUEST_TIMEOUT_MS, 120000);
const once = process.argv.includes("--once");
const workerId = process.env.ZHINIAN_WORKER_ID || `worker-${Math.random().toString(16).slice(2)}`;
@@ -19,7 +20,8 @@ async function tick() {
"Content-Type": "application/json",
...(token ? { "X-Zhinian-Worker-Token": token } : {})
},
body: JSON.stringify({ workerId, limit })
body: JSON.stringify({ workerId, limit }),
signal: AbortSignal.timeout(requestTimeoutMs)
});
const text = await response.text();
if (!response.ok) throw new Error(`Worker tick failed: ${response.status} ${text}`);