Files
NianAIGC/lib/server/account-store.ts

610 lines
25 KiB
TypeScript

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,
PlatformOrganization,
PlatformRole,
PlatformUserRecord,
OrganizationStatus
} from "@/lib/types";
import { hashLocalPassword, verifyLocalPassword } from "@/lib/server/auth/password";
import { createId } from "@/lib/server/ids";
import { reassignOwnerData } from "@/lib/server/data-store";
import { dataDir, DEFAULT_OWNER_ID, ensureRuntimeDirs } from "@/lib/server/runtime";
const STORE_FILE = "platform-accounts.json";
const MAX_LOGIN_FAILURES = 5;
const LOCK_DURATION_MS = 15 * 60 * 1000;
let localWriteQueue: Promise<unknown> = Promise.resolve();
type AccountState = {
users: PlatformUserRecord[];
organizations: PlatformOrganization[];
migrations: AccountMigration[];
};
export type PlatformUserFilters = {
organizationId?: string;
role?: PlatformRole;
includeDisabled?: boolean;
};
export type CreatePlatformUserInput = {
phone: string;
displayName: string;
password: string;
role: PlatformRole;
organizationId?: string;
legacySubject?: string;
};
export type UpdatePlatformUserInput = Partial<Pick<PlatformUserRecord, "displayName" | "role" | "organizationId" | "status">> & {
password?: string;
clearLoginLock?: boolean;
};
export class AccountStoreError extends Error {
status: number;
constructor(message: string, status = 400) {
super(message);
this.name = "AccountStoreError";
this.status = status;
}
}
export class AccountLoginError extends AccountStoreError {
constructor(message = "手机号或密码错误。", status = 401) {
super(message, status);
this.name = "AccountLoginError";
}
}
export function normalizePhone(value: string): string {
return value.trim().replace(/[\s()-]/g, "");
}
export function isValidPhone(value: string): boolean {
return /^\+?[0-9]{6,20}$/.test(normalizePhone(value));
}
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);
}
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);
}
const state = await readLocalState();
return state.organizations
.filter((organization) => options.includeDisabled || organization.status === "active")
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
}
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;
}
const state = await readLocalState();
return state.organizations.find((organization) => organization.id === id) || null;
}
export async function createPlatformOrganization(name: string): Promise<PlatformOrganization> {
const normalizedName = name.trim();
if (!normalizedName) throw new AccountStoreError("组织名称不能为空。", 400);
const now = new Date().toISOString();
const id = createId("org");
const organization: PlatformOrganization = {
id,
name: normalizedName,
status: "active",
archiveOwnerId: `archive:${id}`,
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);
}
return mutateLocalState((state) => {
if (state.organizations.some((item) => item.name === normalizedName)) throw new AccountStoreError("组织名称已存在。", 409);
state.organizations.push(organization);
return organization;
});
}
export async function updatePlatformOrganization(id: string, patch: { name?: string; status?: OrganizationStatus }): Promise<PlatformOrganization> {
const nextPatch: Record<string, unknown> = { updated_at: new Date().toISOString() };
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);
}
return mutateLocalState((state) => {
const organization = state.organizations.find((item) => item.id === id);
if (!organization) throw new AccountStoreError("组织不存在。", 404);
if (patch.name !== undefined && state.organizations.some((item) => item.id !== id && item.name === patch.name?.trim())) {
throw new AccountStoreError("组织名称已存在。", 409);
}
if (patch.name !== undefined) organization.name = patch.name.trim();
if (patch.status !== undefined) organization.status = patch.status;
organization.updatedAt = new Date().toISOString();
return organization;
});
}
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);
return;
}
await mutateLocalState((state) => {
state.organizations = state.organizations.filter((organization) => organization.id !== id);
});
}
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);
}
const state = await readLocalState();
return state.users
.filter((user) => !filters.organizationId || user.organizationId === filters.organizationId)
.filter((user) => !filters.role || user.role === filters.role)
.filter((user) => filters.includeDisabled || user.status === "active")
.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
}
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;
}
const state = await readLocalState();
const user = state.users.find((item) => item.id === id) || null;
if (user && !options.includeDisabled && user.status !== "active") return null;
return user;
}
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;
}
const state = await readLocalState();
const user = state.users.find((item) => item.phone === normalizedPhone) || null;
if (user && !options.includeDisabled && user.status !== "active") return null;
return user;
}
export async function createPlatformUser(input: CreatePlatformUserInput): Promise<PlatformUserRecord> {
const phone = normalizePhone(input.phone);
if (!isValidPhone(phone)) throw new AccountStoreError("手机号格式不正确。", 400);
if (!input.displayName.trim()) throw new AccountStoreError("显示名称不能为空。", 400);
if (input.password.length < 8) throw new AccountStoreError("初始密码至少需要 8 位。", 400);
const organization = input.organizationId ? await getPlatformOrganization(input.organizationId) : null;
if (input.organizationId && (!organization || organization.status !== "active")) {
throw new AccountStoreError("账号归属的组织不存在或已停用。", 400);
}
if (input.role !== "super_admin" && (!organization || organization.status !== "active")) {
throw new AccountStoreError("普通账号必须归属有效组织。", 400);
}
const existing = await findPlatformUserByPhone(phone, { includeDisabled: true });
if (existing) throw new AccountStoreError("该手机号已创建账号。", 409);
const password = await hashLocalPassword(input.password);
const now = new Date().toISOString();
const user: PlatformUserRecord = {
id: createId("user"),
phone,
displayName: input.displayName.trim(),
role: input.role,
organizationId: input.organizationId,
status: "active",
passwordHash: password.hash,
passwordSalt: password.salt,
failedLoginCount: 0,
sessionVersion: 1,
legacySubject: input.legacySubject,
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);
}
return mutateLocalState((state) => {
state.users.push(user);
return user;
});
}
export async function updatePlatformUser(id: string, patch: UpdatePlatformUserInput): Promise<PlatformUserRecord> {
const current = await getPlatformUserById(id, { includeDisabled: true });
if (!current) throw new AccountStoreError("账号不存在。", 404);
const nextOrganizationId = patch.organizationId !== undefined ? patch.organizationId : current.organizationId;
const nextRole = patch.role || current.role;
const organization = nextOrganizationId ? await getPlatformOrganization(nextOrganizationId) : null;
if (nextOrganizationId && (!organization || organization.status !== "active")) {
throw new AccountStoreError("账号归属的组织不存在或已停用。", 400);
}
if (nextRole !== "super_admin") {
if (!organization || organization.status !== "active") throw new AccountStoreError("普通账号必须归属有效组织。", 400);
}
if (patch.password !== undefined && patch.password.length < 8) throw new AccountStoreError("新密码至少需要 8 位。", 400);
const nextPassword = patch.password ? await hashLocalPassword(patch.password) : null;
const next: PlatformUserRecord = {
...current,
displayName: patch.displayName?.trim() || current.displayName,
role: nextRole,
organizationId: nextOrganizationId,
status: patch.status || current.status,
passwordHash: nextPassword?.hash || current.passwordHash,
passwordSalt: nextPassword?.salt || current.passwordSalt,
failedLoginCount: patch.clearLoginLock ? 0 : current.failedLoginCount,
lockedUntil: patch.clearLoginLock ? undefined : current.lockedUntil,
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);
}
return mutateLocalState((state) => {
const index = state.users.findIndex((item) => item.id === id);
if (index < 0) throw new AccountStoreError("账号不存在。", 404);
state.users[index] = next;
return next;
});
}
export async function deletePlatformUser(id: string): Promise<void> {
const user = await getPlatformUserById(id, { includeDisabled: true });
if (!user) throw new AccountStoreError("账号不存在。", 404);
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);
return;
}
await mutateLocalState((state) => {
state.users = state.users.filter((item) => item.id !== id);
});
}
export async function authenticatePlatformUser(phone: string, password: string): Promise<PlatformUserRecord> {
const user = await findPlatformUserByPhone(phone, { includeDisabled: true });
if (!user) throw new AccountLoginError();
if (user.status !== "active") throw new AccountLoginError("账号已停用,请联系管理员。", 403);
if (user.role !== "super_admin" && user.organizationId) {
const organization = await getPlatformOrganization(user.organizationId);
if (!organization || organization.status !== "active") throw new AccountLoginError("所属组织已停用,请联系管理员。", 403);
}
if (user.lockedUntil && user.lockedUntil > new Date().toISOString()) {
throw new AccountLoginError("登录失败次数过多,请 15 分钟后再试。", 423);
}
const valid = await verifyLocalPassword(password, user.passwordHash, user.passwordSalt);
if (!valid) {
const failedLoginCount = user.failedLoginCount + 1;
const lockedUntil = failedLoginCount >= MAX_LOGIN_FAILURES ? new Date(Date.now() + LOCK_DURATION_MS).toISOString() : undefined;
await updateLoginState(user.id, {
failedLoginCount: lockedUntil ? 0 : failedLoginCount,
lockedUntil
});
if (lockedUntil) throw new AccountLoginError("登录失败次数过多,请 15 分钟后再试。", 423);
throw new AccountLoginError();
}
const now = new Date().toISOString();
await updateLoginState(user.id, { failedLoginCount: 0, lockedUntil: null, lastLoginAt: now });
const refreshed = await getPlatformUserById(user.id, { includeDisabled: true });
if (!refreshed) throw new AccountLoginError();
return refreshed;
}
export async function changeOwnPassword(userId: string, currentPassword: string, nextPassword: string): Promise<PlatformUserRecord> {
const user = await getPlatformUserById(userId, { includeDisabled: true });
if (!user || user.status !== "active") throw new AccountStoreError("账号不存在或已停用。", 404);
if (!await verifyLocalPassword(currentPassword, user.passwordHash, user.passwordSalt)) {
throw new AccountStoreError("当前密码不正确。", 400);
}
if (!nextPassword || nextPassword.length < 8) throw new AccountStoreError("新密码至少需要 8 位。", 400);
return updatePlatformUser(userId, { password: nextPassword });
}
export async function upsertAccountMigration(input: Omit<AccountMigration, "id" | "createdAt">): Promise<AccountMigration> {
const migration: AccountMigration = {
...input,
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);
}
return mutateLocalState((state) => {
const index = state.migrations.findIndex((item) => item.legacyOwnerId === input.legacyOwnerId);
if (index >= 0) state.migrations[index] = migration;
else state.migrations.push(migration);
return migration;
});
}
async function updateLoginState(id: string, patch: { failedLoginCount: number; lockedUntil?: string | null; lastLoginAt?: string }) {
const values = {
failed_login_count: patch.failedLoginCount,
locked_until: patch.lockedUntil || null,
...(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);
return;
}
await mutateLocalState((state) => {
const user = state.users.find((item) => item.id === id);
if (!user) return;
user.failedLoginCount = patch.failedLoginCount;
user.lockedUntil = patch.lockedUntil || undefined;
if (patch.lastLoginAt) user.lastLoginAt = patch.lastLoginAt;
user.updatedAt = new Date().toISOString();
});
}
async function readLocalState(): Promise<AccountState> {
await ensureRuntimeDirs();
const path = join(dataDir(), STORE_FILE);
try {
return normalizeState(JSON.parse(await readFile(path, "utf8")));
} catch {
const state = normalizeState({});
await writeLocalState(state);
return state;
}
}
async function writeLocalState(state: AccountState): Promise<void> {
await ensureRuntimeDirs();
const path = join(dataDir(), STORE_FILE);
const temp = `${path}.${createId("tmp")}.tmp`;
await writeFile(temp, JSON.stringify(state, null, 2));
await rename(temp, path);
}
async function mutateLocalState<T>(mutator: (state: AccountState) => T): Promise<T> {
const run = localWriteQueue.then(async () => {
const state = await readLocalState();
const result = mutator(state);
await writeLocalState(state);
return result;
});
localWriteQueue = run.catch(() => undefined);
return run;
}
function normalizeState(raw: Partial<AccountState>): AccountState {
const organizations = Array.isArray(raw.organizations) ? raw.organizations.map(normalizeOrganization) : [];
const users = Array.isArray(raw.users) ? raw.users.map(normalizeUser) : [];
if (!users.length && !authRequiredByEnv()) {
const now = new Date().toISOString();
const organization: PlatformOrganization = {
id: "org-demo",
name: "演示组织",
status: "active",
archiveOwnerId: "archive:org-demo",
createdAt: now,
updatedAt: now
};
organizations.push(organization);
users.push({
id: DEFAULT_OWNER_ID,
phone: "13800000000",
displayName: "智念演示用户",
role: "super_admin",
organizationId: organization.id,
status: "active",
passwordHash: "",
passwordSalt: "",
failedLoginCount: 0,
sessionVersion: 1,
createdAt: now,
updatedAt: now
});
}
return {
users,
organizations,
migrations: Array.isArray(raw.migrations) ? raw.migrations.map(normalizeMigration) : []
};
}
function authRequiredByEnv(): boolean {
const disabled = process.env.ZHINIAN_AUTH_DISABLED?.trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(disabled || "")) return false;
const explicit = process.env.ZHINIAN_AUTH_REQUIRED?.trim().toLowerCase();
if (["0", "false", "no", "off"].includes(explicit || "")) return false;
return true;
}
function normalizeOrganization(value: PlatformOrganization): PlatformOrganization {
const now = new Date().toISOString();
return {
id: String(value.id),
name: String(value.name || "未命名组织"),
status: value.status === "disabled" ? "disabled" : "active",
archiveOwnerId: String(value.archiveOwnerId || `archive:${value.id}`),
createdAt: String(value.createdAt || now),
updatedAt: String(value.updatedAt || now)
};
}
function normalizeUser(value: PlatformUserRecord): PlatformUserRecord {
const now = new Date().toISOString();
return {
id: String(value.id),
phone: normalizePhone(String(value.phone || "")),
displayName: String(value.displayName || "未命名用户"),
role: value.role === "super_admin" || value.role === "organization_admin" ? value.role : "user",
organizationId: value.organizationId ? String(value.organizationId) : undefined,
status: value.status === "disabled" ? "disabled" : "active",
passwordHash: String(value.passwordHash || ""),
passwordSalt: String(value.passwordSalt || ""),
failedLoginCount: Number(value.failedLoginCount || 0),
lockedUntil: value.lockedUntil ? String(value.lockedUntil) : undefined,
sessionVersion: Number(value.sessionVersion || 1),
lastLoginAt: value.lastLoginAt ? String(value.lastLoginAt) : undefined,
legacySubject: value.legacySubject ? String(value.legacySubject) : undefined,
createdAt: String(value.createdAt || now),
updatedAt: String(value.updatedAt || now)
};
}
function normalizeMigration(value: AccountMigration): AccountMigration {
return {
id: String(value.id),
legacyOwnerId: String(value.legacyOwnerId),
legacyPhone: value.legacyPhone ? normalizePhone(String(value.legacyPhone)) : undefined,
platformUserId: String(value.platformUserId),
createdAt: String(value.createdAt || new Date().toISOString())
};
}
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 organizationToRow(organization: PlatformOrganization) {
return {
id: organization.id,
name: organization.name,
status: organization.status,
archive_owner_id: organization.archiveOwnerId,
created_at: organization.createdAt,
updated_at: organization.updatedAt
};
}
function organizationFromRow(row: Record<string, unknown>): PlatformOrganization {
return {
id: String(row.id),
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)
};
}
function userToRow(user: PlatformUserRecord) {
return {
id: user.id,
phone: user.phone,
display_name: user.displayName,
role: user.role,
organization_id: user.organizationId || null,
status: user.status,
password_hash: user.passwordHash,
password_salt: user.passwordSalt,
failed_login_count: user.failedLoginCount,
locked_until: user.lockedUntil || null,
session_version: user.sessionVersion,
last_login_at: user.lastLoginAt || null,
legacy_subject: user.legacySubject || null,
created_at: user.createdAt,
updated_at: user.updatedAt
};
}
function userFromRow(row: Record<string, unknown>): PlatformUserRecord {
return normalizeUser({
id: String(row.id),
phone: String(row.phone || ""),
displayName: String(row.display_name || ""),
role: row.role as PlatformRole,
organizationId: row.organization_id ? String(row.organization_id) : undefined,
status: row.status as AccountStatus,
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,
sessionVersion: Number(row.session_version || 1),
lastLoginAt: row.last_login_at ? String(row.last_login_at) : undefined,
legacySubject: row.legacy_subject ? String(row.legacy_subject) : undefined,
createdAt: String(row.created_at),
updatedAt: String(row.updated_at)
});
}
function migrationToRow(migration: AccountMigration) {
return {
id: migration.id,
legacy_owner_id: migration.legacyOwnerId,
legacy_phone: migration.legacyPhone || null,
platform_user_id: migration.platformUserId,
created_at: migration.createdAt
};
}
function migrationFromRow(row: Record<string, unknown>): AccountMigration {
return {
id: String(row.id),
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)
};
}