feat: add local auth billing and usage management
This commit is contained in:
1 parent
f642b5e71f
commit
196fdde83f
119 files changed
+15695
-2650
No files matched your search
@@ -0,0 +1,609 @@
|
||||
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)
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user