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

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