feat: add direct PostgreSQL and ACK deployment support

This commit is contained in:
brother7 committed 2026-08-12 19:20:07 +08:00
1 parent d0192081c7
commit c44274f098
55 files changed
+3317 -1064

No files matched your search

+189 -88
View File
@@ -1,6 +1,7 @@
import "server-only";
import { readFile, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
import type {
AccountMigration,
AccountStatus,
@@ -11,6 +12,7 @@ import type {
} from "@/lib/types";
import { hashLocalPassword, verifyLocalPassword } from "@/lib/server/auth/password";
import { createId } from "@/lib/server/ids";
import { isPostgresBackend, queryDatabase, withDatabaseTransaction } from "@/lib/server/database";
import { reassignOwnerData } from "@/lib/server/data-store";
import { dataDir, DEFAULT_OWNER_ID, ensureRuntimeDirs } from "@/lib/server/runtime";
@@ -71,17 +73,16 @@ export function isValidPhone(value: string): boolean {
}
export function platformAccountStoreConfigured(): boolean {
return Boolean(process.env.SUPABASE_SERVICE_ROLE_KEY && process.env.NEXT_PUBLIC_SUPABASE_URL) || Boolean(process.env.ZHINIAN_AUTH_SESSION_SECRET);
return (isPostgresBackend() ? Boolean(process.env.DATABASE_URL?.trim()) : Boolean(process.env.ZHINIAN_AUTH_SESSION_SECRET));
}
export async function listPlatformOrganizations(options: { includeDisabled?: boolean } = {}): Promise<PlatformOrganization[]> {
const supabase = getSupabaseAdmin();
if (supabase) {
let query = supabase.from("platform_organizations").select("*").order("created_at", { ascending: true });
if (!options.includeDisabled) query = query.eq("status", "active");
const { data, error } = await query;
if (error) throw new AccountStoreError(error.message, 500);
return (data || []).map(organizationFromRow);
if (isPostgresBackend()) {
const result = await queryDatabase<Record<string, unknown>>(
`SELECT * FROM platform_organizations ${options.includeDisabled ? "" : "WHERE status = $1"} ORDER BY created_at ASC`,
options.includeDisabled ? [] : ["active"]
);
return result.rows.map(organizationFromRow);
}
const state = await readLocalState();
return state.organizations
@@ -90,11 +91,9 @@ export async function listPlatformOrganizations(options: { includeDisabled?: boo
}
export async function getPlatformOrganization(id: string): Promise<PlatformOrganization | null> {
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase.from("platform_organizations").select("*").eq("id", id).maybeSingle();
if (error) throw new AccountStoreError(error.message, 500);
return data ? organizationFromRow(data) : null;
if (isPostgresBackend()) {
const result = await queryDatabase<Record<string, unknown>>("SELECT * FROM platform_organizations WHERE id = $1", [id]);
return result.rows[0] ? organizationFromRow(result.rows[0]) : null;
}
const state = await readLocalState();
return state.organizations.find((organization) => organization.id === id) || null;
@@ -113,11 +112,16 @@ export async function createPlatformOrganization(name: string): Promise<Platform
createdAt: now,
updatedAt: now
};
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase.from("platform_organizations").insert(organizationToRow(organization)).select("*").single();
if (error) throw new AccountStoreError(error.message, error.code === "23505" ? 409 : 500);
return organizationFromRow(data);
if (isPostgresBackend()) {
try {
const result = await queryDatabase<Record<string, unknown>>(
"INSERT INTO platform_organizations (id, name, status, archive_owner_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *",
[organization.id, organization.name, organization.status, organization.archiveOwnerId, organization.createdAt, organization.updatedAt]
);
return organizationFromRow(result.rows[0]);
} catch (error) {
throw databaseError(error);
}
}
return mutateLocalState((state) => {
if (state.organizations.some((item) => item.name === normalizedName)) throw new AccountStoreError("组织名称已存在。", 409);
@@ -127,18 +131,25 @@ export async function createPlatformOrganization(name: string): Promise<Platform
}
export async function updatePlatformOrganization(id: string, patch: { name?: string; status?: OrganizationStatus }): Promise<PlatformOrganization> {
const nextPatch: Record<string, unknown> = { updated_at: new Date().toISOString() };
const nextPatch: Record<string, unknown> = {};
if (patch.name !== undefined) {
const name = patch.name.trim();
if (!name) throw new AccountStoreError("组织名称不能为空。", 400);
nextPatch.name = name;
}
if (patch.status !== undefined) nextPatch.status = patch.status;
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase.from("platform_organizations").update(nextPatch).eq("id", id).select("*").single();
if (error) throw new AccountStoreError(error.message, error.code === "23505" ? 409 : 500);
return organizationFromRow(data);
if (isPostgresBackend()) {
try {
const result = await queryDatabase<Record<string, unknown>>(
"UPDATE platform_organizations SET name = COALESCE($2, name), status = COALESCE($3, status), updated_at = $4 WHERE id = $1 RETURNING *",
[id, nextPatch.name ?? null, nextPatch.status ?? null, new Date().toISOString()]
);
if (!result.rows[0]) throw new AccountStoreError("Organization not found", 404);
return organizationFromRow(result.rows[0]);
} catch (error) {
if (error instanceof AccountStoreError) throw error;
throw databaseError(error);
}
}
return mutateLocalState((state) => {
const organization = state.organizations.find((item) => item.id === id);
@@ -156,10 +167,8 @@ export async function updatePlatformOrganization(id: string, patch: { name?: str
export async function deletePlatformOrganization(id: string): Promise<void> {
const users = await listPlatformUsers({ organizationId: id, includeDisabled: true });
if (users.length) throw new AccountStoreError("组织仍有账号,不能删除。", 409);
const supabase = getSupabaseAdmin();
if (supabase) {
const { error } = await supabase.from("platform_organizations").delete().eq("id", id);
if (error) throw new AccountStoreError(error.message, 500);
if (isPostgresBackend()) {
await queryDatabase("DELETE FROM platform_organizations WHERE id = $1", [id]);
return;
}
await mutateLocalState((state) => {
@@ -168,15 +177,17 @@ export async function deletePlatformOrganization(id: string): Promise<void> {
}
export async function listPlatformUsers(filters: PlatformUserFilters = {}): Promise<PlatformUserRecord[]> {
const supabase = getSupabaseAdmin();
if (supabase) {
let query = supabase.from("platform_users").select("*").order("created_at", { ascending: false });
if (filters.organizationId) query = query.eq("organization_id", filters.organizationId);
if (filters.role) query = query.eq("role", filters.role);
if (!filters.includeDisabled) query = query.eq("status", "active");
const { data, error } = await query;
if (error) throw new AccountStoreError(error.message, 500);
return (data || []).map(userFromRow);
if (isPostgresBackend()) {
const clauses: string[] = [];
const values: unknown[] = [];
if (filters.organizationId) clauses.push(`organization_id = $${values.push(filters.organizationId)}`);
if (filters.role) clauses.push(`role = $${values.push(filters.role)}`);
if (!filters.includeDisabled) clauses.push(`status = $${values.push("active")}`);
const result = await queryDatabase<Record<string, unknown>>(
`SELECT * FROM platform_users ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY created_at DESC`,
values
);
return result.rows.map(userFromRow);
}
const state = await readLocalState();
return state.users
@@ -187,13 +198,12 @@ export async function listPlatformUsers(filters: PlatformUserFilters = {}): Prom
}
export async function getPlatformUserById(id: string, options: { includeDisabled?: boolean } = {}): Promise<PlatformUserRecord | null> {
const supabase = getSupabaseAdmin();
if (supabase) {
let query = supabase.from("platform_users").select("*").eq("id", id);
if (!options.includeDisabled) query = query.eq("status", "active");
const { data, error } = await query.maybeSingle();
if (error) throw new AccountStoreError(error.message, 500);
return data ? userFromRow(data) : null;
if (isPostgresBackend()) {
const result = await queryDatabase<Record<string, unknown>>(
`SELECT * FROM platform_users WHERE id = $1 ${options.includeDisabled ? "" : "AND status = $2"}`,
options.includeDisabled ? [id] : [id, "active"]
);
return result.rows[0] ? userFromRow(result.rows[0]) : null;
}
const state = await readLocalState();
const user = state.users.find((item) => item.id === id) || null;
@@ -203,13 +213,12 @@ export async function getPlatformUserById(id: string, options: { includeDisabled
export async function findPlatformUserByPhone(phone: string, options: { includeDisabled?: boolean } = {}): Promise<PlatformUserRecord | null> {
const normalizedPhone = normalizePhone(phone);
const supabase = getSupabaseAdmin();
if (supabase) {
let query = supabase.from("platform_users").select("*").eq("phone", normalizedPhone);
if (!options.includeDisabled) query = query.eq("status", "active");
const { data, error } = await query.maybeSingle();
if (error) throw new AccountStoreError(error.message, 500);
return data ? userFromRow(data) : null;
if (isPostgresBackend()) {
const result = await queryDatabase<Record<string, unknown>>(
`SELECT * FROM platform_users WHERE phone = $1 ${options.includeDisabled ? "" : "AND status = $2"}`,
options.includeDisabled ? [normalizedPhone] : [normalizedPhone, "active"]
);
return result.rows[0] ? userFromRow(result.rows[0]) : null;
}
const state = await readLocalState();
const user = state.users.find((item) => item.phone === normalizedPhone) || null;
@@ -248,11 +257,17 @@ export async function createPlatformUser(input: CreatePlatformUserInput): Promis
createdAt: now,
updatedAt: now
};
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase.from("platform_users").insert(userToRow(user)).select("*").single();
if (error) throw new AccountStoreError(error.message, error.code === "23505" ? 409 : 500);
return userFromRow(data);
if (isPostgresBackend()) {
try {
const row = userToRow(user);
const result = await queryDatabase<Record<string, unknown>>(
"INSERT INTO platform_users (id, phone, display_name, role, organization_id, status, password_hash, password_salt, failed_login_count, locked_until, session_version, last_login_at, legacy_subject, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *",
Object.values(row)
);
return userFromRow(result.rows[0]);
} catch (error) {
throw databaseError(error);
}
}
return mutateLocalState((state) => {
state.users.push(user);
@@ -287,11 +302,19 @@ export async function updatePlatformUser(id: string, patch: UpdatePlatformUserIn
sessionVersion: nextPassword || patch.role || patch.organizationId !== undefined || patch.status ? current.sessionVersion + 1 : current.sessionVersion,
updatedAt: new Date().toISOString()
};
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase.from("platform_users").update(userToRow(next)).eq("id", id).select("*").single();
if (error) throw new AccountStoreError(error.message, 500);
return userFromRow(data);
if (isPostgresBackend()) {
try {
const row = userToRow(next);
const result = await queryDatabase<Record<string, unknown>>(
"UPDATE platform_users SET phone=$2, display_name=$3, role=$4, organization_id=$5, status=$6, password_hash=$7, password_salt=$8, failed_login_count=$9, locked_until=$10, session_version=$11, last_login_at=$12, legacy_subject=$13, created_at=$14, updated_at=$15 WHERE id=$1 RETURNING *",
Object.values(row)
);
if (!result.rows[0]) throw new AccountStoreError("Account not found", 404);
return userFromRow(result.rows[0]);
} catch (error) {
if (error instanceof AccountStoreError) throw error;
throw databaseError(error);
}
}
return mutateLocalState((state) => {
const index = state.users.findIndex((item) => item.id === id);
@@ -307,19 +330,70 @@ export async function deletePlatformUser(id: string): Promise<void> {
if (user.role === "super_admin") throw new AccountStoreError("不能直接删除超级管理员账号。", 400);
const organization = user.organizationId ? await getPlatformOrganization(user.organizationId) : null;
const archiveOwnerId = organization?.archiveOwnerId || `archive:global`;
await reassignOwnerData(user.id, archiveOwnerId);
const supabase = getSupabaseAdmin();
if (supabase) {
const { error } = await supabase.from("platform_users").delete().eq("id", id);
if (error) throw new AccountStoreError(error.message, 500);
if (isPostgresBackend()) {
await withDatabaseTransaction(async (client) => {
for (const table of ["assets", "generation_jobs", "projects", "image_templates"] as const) {
await client.query(`UPDATE ${table} SET owner_id = $2 WHERE owner_id = $1`, [user.id, archiveOwnerId]);
}
const result = await client.query("DELETE FROM platform_users WHERE id = $1 RETURNING id", [id]);
if (!result.rowCount) throw new AccountStoreError("Account not found", 404);
});
return;
}
await reassignOwnerData(user.id, archiveOwnerId);
await mutateLocalState((state) => {
state.users = state.users.filter((item) => item.id !== id);
});
}
export async function authenticatePlatformUser(phone: string, password: string): Promise<PlatformUserRecord> {
if (isPostgresBackend()) {
const result = await withDatabaseTransaction<
{ user: PlatformUserRecord; error?: never } | { user?: never; error: AccountLoginError }
>(async (client) => {
const result = await client.query<Record<string, unknown>>(
"SELECT * FROM platform_users WHERE phone = $1 FOR UPDATE",
[normalizePhone(phone)]
);
const user = result.rows[0] ? userFromRow(result.rows[0]) : null;
if (!user) throw new AccountLoginError();
if (user.status !== "active") throw new AccountLoginError("Account is disabled.", 403);
if (user.role !== "super_admin" && user.organizationId) {
const organizationResult = await client.query<Record<string, unknown>>(
"SELECT * FROM platform_organizations WHERE id = $1",
[user.organizationId]
);
const organization = organizationResult.rows[0] ? organizationFromRow(organizationResult.rows[0]) : null;
if (!organization || organization.status !== "active") {
throw new AccountLoginError("The account organization is disabled.", 403);
}
}
if (user.lockedUntil && user.lockedUntil > new Date().toISOString()) {
throw new AccountLoginError("Too many failed login attempts. Try again in 15 minutes.", 423);
}
const valid = await verifyLocalPassword(password, user.passwordHash, user.passwordSalt);
const now = new Date().toISOString();
if (!valid) {
const failedLoginCount = user.failedLoginCount + 1;
const lockedUntil = failedLoginCount >= MAX_LOGIN_FAILURES ? new Date(Date.now() + LOCK_DURATION_MS).toISOString() : null;
await client.query(
"UPDATE platform_users SET failed_login_count=$2, locked_until=$3, updated_at=$4 WHERE id=$1",
[user.id, lockedUntil ? 0 : failedLoginCount, lockedUntil, now]
);
return { error: lockedUntil
? new AccountLoginError("Too many failed login attempts. Try again in 15 minutes.", 423)
: new AccountLoginError() };
}
const updated = await client.query<Record<string, unknown>>(
"UPDATE platform_users SET failed_login_count=0, locked_until=NULL, last_login_at=$2, updated_at=$2 WHERE id=$1 RETURNING *",
[user.id, now]
);
if (!updated.rows[0]) throw new AccountLoginError();
return { user: userFromRow(updated.rows[0]) };
});
if (result.error) throw result.error;
return result.user;
}
const user = await findPlatformUserByPhone(phone, { includeDisabled: true });
if (!user) throw new AccountLoginError();
if (user.status !== "active") throw new AccountLoginError("账号已停用,请联系管理员。", 403);
@@ -349,6 +423,27 @@ export async function authenticatePlatformUser(phone: string, password: string):
}
export async function changeOwnPassword(userId: string, currentPassword: string, nextPassword: string): Promise<PlatformUserRecord> {
if (isPostgresBackend()) {
return withDatabaseTransaction(async (client) => {
const result = await client.query<Record<string, unknown>>(
"SELECT * FROM platform_users WHERE id = $1 FOR UPDATE",
[userId]
);
const user = result.rows[0] ? userFromRow(result.rows[0]) : null;
if (!user || user.status !== "active") throw new AccountStoreError("Account not found or disabled.", 404);
if (!await verifyLocalPassword(currentPassword, user.passwordHash, user.passwordSalt)) {
throw new AccountStoreError("The current password is incorrect.", 400);
}
if (!nextPassword || nextPassword.length < 8) throw new AccountStoreError("The new password must be at least 8 characters.", 400);
const password = await hashLocalPassword(nextPassword);
const updated = await client.query<Record<string, unknown>>(
"UPDATE platform_users SET password_hash=$2, password_salt=$3, session_version=session_version+1, updated_at=$4 WHERE id=$1 RETURNING *",
[userId, password.hash, password.salt, new Date().toISOString()]
);
if (!updated.rows[0]) throw new AccountStoreError("Account not found", 404);
return userFromRow(updated.rows[0]);
});
}
const user = await getPlatformUserById(userId, { includeDisabled: true });
if (!user || user.status !== "active") throw new AccountStoreError("账号不存在或已停用。", 404);
if (!await verifyLocalPassword(currentPassword, user.passwordHash, user.passwordSalt)) {
@@ -364,11 +459,13 @@ export async function upsertAccountMigration(input: Omit<AccountMigration, "id"
id: createId("migration"),
createdAt: new Date().toISOString()
};
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase.from("platform_account_migrations").upsert(migrationToRow(migration), { onConflict: "legacy_owner_id" }).select("*").single();
if (error) throw new AccountStoreError(error.message, 500);
return migrationFromRow(data);
if (isPostgresBackend()) {
const row = migrationToRow(migration);
const result = await queryDatabase<Record<string, unknown>>(
"INSERT INTO platform_account_migrations (id, legacy_owner_id, legacy_phone, platform_user_id, created_at) VALUES ($1,$2,$3,$4,$5) 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=EXCLUDED.created_at RETURNING *",
Object.values(row)
);
return migrationFromRow(result.rows[0]);
}
return mutateLocalState((state) => {
const index = state.migrations.findIndex((item) => item.legacyOwnerId === input.legacyOwnerId);
@@ -385,10 +482,12 @@ async function updateLoginState(id: string, patch: { failedLoginCount: number; l
...(patch.lastLoginAt ? { last_login_at: patch.lastLoginAt } : {}),
updated_at: new Date().toISOString()
};
const supabase = getSupabaseAdmin();
if (supabase) {
const { error } = await supabase.from("platform_users").update(values).eq("id", id);
if (error) throw new AccountStoreError(error.message, 500);
if (isPostgresBackend()) {
const result = await queryDatabase(
"UPDATE platform_users SET failed_login_count=$2, locked_until=$3, last_login_at=COALESCE($4, last_login_at), updated_at=$5 WHERE id=$1 RETURNING id",
[id, values.failed_login_count, values.locked_until, patch.lastLoginAt ?? null, values.updated_at]
);
if (!result.rowCount) throw new AccountStoreError("Account not found", 404);
return;
}
await mutateLocalState((state) => {
@@ -519,11 +618,9 @@ function normalizeMigration(value: AccountMigration): AccountMigration {
};
}
function getSupabaseAdmin(): SupabaseClient | null {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!url || !serviceRoleKey) return null;
return createClient(url, serviceRoleKey, { auth: { persistSession: false } });
function databaseError(error: unknown): AccountStoreError {
const database = error as { code?: string; message?: string };
return new AccountStoreError(database.message || "Database operation failed", database.code === "23505" ? 409 : 500);
}
function organizationToRow(organization: PlatformOrganization) {
@@ -543,8 +640,8 @@ function organizationFromRow(row: Record<string, unknown>): PlatformOrganization
name: String(row.name || ""),
status: row.status === "disabled" ? "disabled" : "active",
archiveOwnerId: String(row.archive_owner_id || `archive:${row.id}`),
createdAt: String(row.created_at),
updatedAt: String(row.updated_at)
createdAt: timestampFromRow(row.created_at),
updatedAt: timestampFromRow(row.updated_at)
};
}
@@ -579,12 +676,12 @@ function userFromRow(row: Record<string, unknown>): PlatformUserRecord {
passwordHash: String(row.password_hash || ""),
passwordSalt: String(row.password_salt || ""),
failedLoginCount: Number(row.failed_login_count || 0),
lockedUntil: row.locked_until ? String(row.locked_until) : undefined,
lockedUntil: row.locked_until ? timestampFromRow(row.locked_until) : undefined,
sessionVersion: Number(row.session_version || 1),
lastLoginAt: row.last_login_at ? String(row.last_login_at) : undefined,
lastLoginAt: row.last_login_at ? timestampFromRow(row.last_login_at) : undefined,
legacySubject: row.legacy_subject ? String(row.legacy_subject) : undefined,
createdAt: String(row.created_at),
updatedAt: String(row.updated_at)
createdAt: timestampFromRow(row.created_at),
updatedAt: timestampFromRow(row.updated_at)
});
}
@@ -604,6 +701,10 @@ function migrationFromRow(row: Record<string, unknown>): AccountMigration {
legacyOwnerId: String(row.legacy_owner_id),
legacyPhone: row.legacy_phone ? String(row.legacy_phone) : undefined,
platformUserId: String(row.platform_user_id),
createdAt: String(row.created_at)
createdAt: timestampFromRow(row.created_at)
};
}
function timestampFromRow(value: unknown): string {
return value instanceof Date ? value.toISOString() : String(value);
}