711 lines
31 KiB
TypeScript
711 lines
31 KiB
TypeScript
import "server-only";
|
|
|
|
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
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 { isPostgresBackend, queryDatabase, withDatabaseTransaction } from "@/lib/server/database";
|
|
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 (isPostgresBackend() ? Boolean(process.env.DATABASE_URL?.trim()) : Boolean(process.env.ZHINIAN_AUTH_SESSION_SECRET));
|
|
}
|
|
|
|
export async function listPlatformOrganizations(options: { includeDisabled?: boolean } = {}): Promise<PlatformOrganization[]> {
|
|
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
|
|
.filter((organization) => options.includeDisabled || organization.status === "active")
|
|
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
}
|
|
|
|
export async function getPlatformOrganization(id: string): Promise<PlatformOrganization | 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;
|
|
}
|
|
|
|
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
|
|
};
|
|
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);
|
|
state.organizations.push(organization);
|
|
return organization;
|
|
});
|
|
}
|
|
|
|
export async function updatePlatformOrganization(id: string, patch: { name?: string; status?: OrganizationStatus }): Promise<PlatformOrganization> {
|
|
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;
|
|
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);
|
|
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);
|
|
if (isPostgresBackend()) {
|
|
await queryDatabase("DELETE FROM platform_organizations WHERE id = $1", [id]);
|
|
return;
|
|
}
|
|
await mutateLocalState((state) => {
|
|
state.organizations = state.organizations.filter((organization) => organization.id !== id);
|
|
});
|
|
}
|
|
|
|
export async function listPlatformUsers(filters: PlatformUserFilters = {}): Promise<PlatformUserRecord[]> {
|
|
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
|
|
.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> {
|
|
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;
|
|
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);
|
|
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;
|
|
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
|
|
};
|
|
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);
|
|
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()
|
|
};
|
|
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);
|
|
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`;
|
|
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);
|
|
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> {
|
|
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)) {
|
|
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()
|
|
};
|
|
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);
|
|
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()
|
|
};
|
|
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) => {
|
|
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 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) {
|
|
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: timestampFromRow(row.created_at),
|
|
updatedAt: timestampFromRow(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 ? timestampFromRow(row.locked_until) : undefined,
|
|
sessionVersion: Number(row.session_version || 1),
|
|
lastLoginAt: row.last_login_at ? timestampFromRow(row.last_login_at) : undefined,
|
|
legacySubject: row.legacy_subject ? String(row.legacy_subject) : undefined,
|
|
createdAt: timestampFromRow(row.created_at),
|
|
updatedAt: timestampFromRow(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: timestampFromRow(row.created_at)
|
|
};
|
|
}
|
|
|
|
function timestampFromRow(value: unknown): string {
|
|
return value instanceof Date ? value.toISOString() : String(value);
|
|
}
|