From 914f74c5cc75626706aa6eb6f1fea7db69389ab2 Mon Sep 17 00:00:00 2001 From: TalexDreamSoul Date: Wed, 1 Jul 2026 06:28:18 -0700 Subject: [PATCH] feat: add full-stack API persistence and RBAC --- app/api/audit-logs/route.ts | 20 +++ app/api/auth/bootstrap/route.ts | 8 ++ app/api/auth/login/route.ts | 62 +++++++++ app/api/auth/logout/route.ts | 20 +++ app/api/auth/register/route.ts | 88 +++++++++++++ app/api/auth/session/route.ts | 12 ++ app/api/auth/setup/route.ts | 90 +++++++++++++ app/api/elders/[id]/route.ts | 99 ++++++++++++++ app/api/elders/route.ts | 61 +++++++++ app/api/settings/accounts/route.ts | 20 +++ app/api/settings/roles/route.ts | 17 +++ modules/core/server/api.ts | 32 +++++ modules/core/server/audit.ts | 35 +++++ modules/core/server/auth.ts | 205 +++++++++++++++++++++++++++++ modules/core/server/permissions.ts | 59 +++++++++ modules/core/server/store.ts | 63 +++++++++ modules/core/types.ts | 82 ++++++++++++ modules/elders/types.ts | 175 ++++++++++++++++++++++++ 18 files changed, 1148 insertions(+) create mode 100644 app/api/audit-logs/route.ts create mode 100644 app/api/auth/bootstrap/route.ts create mode 100644 app/api/auth/login/route.ts create mode 100644 app/api/auth/logout/route.ts create mode 100644 app/api/auth/register/route.ts create mode 100644 app/api/auth/session/route.ts create mode 100644 app/api/auth/setup/route.ts create mode 100644 app/api/elders/[id]/route.ts create mode 100644 app/api/elders/route.ts create mode 100644 app/api/settings/accounts/route.ts create mode 100644 app/api/settings/roles/route.ts create mode 100644 modules/core/server/api.ts create mode 100644 modules/core/server/audit.ts create mode 100644 modules/core/server/auth.ts create mode 100644 modules/core/server/permissions.ts create mode 100644 modules/core/server/store.ts create mode 100644 modules/core/types.ts create mode 100644 modules/elders/types.ts diff --git a/app/api/audit-logs/route.ts b/app/api/audit-logs/route.ts new file mode 100644 index 0000000..7ad6586 --- /dev/null +++ b/app/api/audit-logs/route.ts @@ -0,0 +1,20 @@ +import { jsonSuccess } from "@/modules/core/server/api"; +import { requirePermission } from "@/modules/core/server/auth"; +import { readData } from "@/modules/core/server/store"; + +export async function GET(): Promise { + const auth = await requirePermission("audit:read", { + action: "audit.list", + targetType: "auditLog", + }); + + if (!auth.success) { + return auth.response; + } + + const data = await readData(); + return jsonSuccess("审计日志已加载", { + auditLogs: data.auditLogs.slice(0, 100), + }); +} + diff --git a/app/api/auth/bootstrap/route.ts b/app/api/auth/bootstrap/route.ts new file mode 100644 index 0000000..8531863 --- /dev/null +++ b/app/api/auth/bootstrap/route.ts @@ -0,0 +1,8 @@ +import { getBootstrapState } from "@/modules/core/server/auth"; +import { jsonSuccess } from "@/modules/core/server/api"; + +export async function GET(): Promise { + const state = await getBootstrapState(); + return jsonSuccess("Bootstrap state loaded", state); +} + diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..6379358 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,62 @@ +import { createSessionRecord, normalizeEmail, setSessionCookie, toPublicAccount, verifyPassword } from "@/modules/core/server/auth"; +import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; +import { recordAuditLog } from "@/modules/core/server/audit"; +import { updateData } from "@/modules/core/server/store"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(source: Record, key: string): string { + const value = source[key]; + return typeof value === "string" ? value.trim() : ""; +} + +export async function POST(request: Request): Promise { + const body = await readJsonBody(request); + if (!isRecord(body)) { + return jsonFailure("请求数据格式无效"); + } + + const email = normalizeEmail(readString(body, "email")); + const password = readString(body, "password"); + + if (!email || !password) { + return jsonFailure("邮箱和密码不能为空"); + } + + const result = await updateData((data) => { + const account = data.accounts.find((item) => item.email === email); + if (!account || account.status !== "active" || !verifyPassword(password, account)) { + return null; + } + + const session = createSessionRecord(account.id); + data.sessions.push(session); + return { account, session }; + }); + + if (!result) { + await recordAuditLog({ + action: "auth.login", + targetType: "account", + result: "failure", + reason: `登录失败:${email}`, + }); + return jsonFailure("账号或密码错误", 401); + } + + await setSessionCookie(result.session.id); + const account = toPublicAccount(result.account); + await recordAuditLog({ + actor: account, + action: "auth.login", + targetType: "account", + targetId: account.id, + result: "success", + reason: "登录成功", + }); + + return jsonSuccess("登录成功", { account }); +} + diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..304c26d --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,20 @@ +import { removeCurrentSession } from "@/modules/core/server/auth"; +import { jsonSuccess } from "@/modules/core/server/api"; +import { recordAuditLog } from "@/modules/core/server/audit"; + +export async function POST(): Promise { + const account = await removeCurrentSession(); + + if (account) { + await recordAuditLog({ + actor: account, + action: "auth.logout", + targetType: "session", + result: "success", + reason: "退出登录", + }); + } + + return jsonSuccess("已退出登录", {}); +} + diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000..d659f1b --- /dev/null +++ b/app/api/auth/register/route.ts @@ -0,0 +1,88 @@ +import { createAccountRecord, createSessionRecord, normalizeEmail, setSessionCookie } from "@/modules/core/server/auth"; +import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; +import { recordAuditLog } from "@/modules/core/server/audit"; +import { updateData } from "@/modules/core/server/store"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(source: Record, key: string): string { + const value = source[key]; + return typeof value === "string" ? value.trim() : ""; +} + +export async function POST(request: Request): Promise { + const body = await readJsonBody(request); + if (!isRecord(body)) { + return jsonFailure("请求数据格式无效"); + } + + const name = readString(body, "name"); + const email = readString(body, "email"); + const password = readString(body, "password"); + + if (!name || !email || !password) { + return jsonFailure("姓名、邮箱和密码不能为空"); + } + + if (password.length < 6) { + return jsonFailure("密码至少需要6位"); + } + + const normalizedEmail = normalizeEmail(email); + const created = await updateData((data) => { + const exists = data.accounts.some((account) => account.email === normalizedEmail); + if (exists) { + return null; + } + + const account = createAccountRecord({ + name, + email, + password, + role: "caregiver", + }); + const session = createSessionRecord(account.id); + + data.accounts.push(account); + data.sessions.push(session); + + return { account, session }; + }); + + if (!created) { + return jsonFailure("账号已存在", 409); + } + + await setSessionCookie(created.session.id); + await recordAuditLog({ + actor: { + id: created.account.id, + name: created.account.name, + email: created.account.email, + role: created.account.role, + status: created.account.status, + createdAt: created.account.createdAt, + updatedAt: created.account.updatedAt, + }, + action: "account.register", + targetType: "account", + targetId: created.account.id, + result: "success", + reason: "创建照护人员账号", + }); + + return jsonSuccess("账号已创建", { + account: { + id: created.account.id, + name: created.account.name, + email: created.account.email, + role: created.account.role, + status: created.account.status, + createdAt: created.account.createdAt, + updatedAt: created.account.updatedAt, + }, + }); +} + diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts new file mode 100644 index 0000000..97881e9 --- /dev/null +++ b/app/api/auth/session/route.ts @@ -0,0 +1,12 @@ +import { getCurrentAuthContext } from "@/modules/core/server/auth"; +import { jsonSuccess } from "@/modules/core/server/api"; + +export async function GET(): Promise { + const context = await getCurrentAuthContext(); + + return jsonSuccess("Session loaded", { + account: context?.account ?? null, + permissions: context?.permissions ?? [], + }); +} + diff --git a/app/api/auth/setup/route.ts b/app/api/auth/setup/route.ts new file mode 100644 index 0000000..c6a8b85 --- /dev/null +++ b/app/api/auth/setup/route.ts @@ -0,0 +1,90 @@ +import { createAccountRecord, createSessionRecord, setSessionCookie } from "@/modules/core/server/auth"; +import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; +import { recordAuditLog } from "@/modules/core/server/audit"; +import { updateData } from "@/modules/core/server/store"; + +function readString(source: Record, key: string): string { + const value = source[key]; + return typeof value === "string" ? value.trim() : ""; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export async function POST(request: Request): Promise { + const body = await readJsonBody(request); + if (!isRecord(body)) { + return jsonFailure("请求数据格式无效"); + } + + const name = readString(body, "name"); + const email = readString(body, "email"); + const password = readString(body, "password"); + const organization = readString(body, "organization"); + + if (!name || !email || !password || !organization) { + return jsonFailure("机构、姓名、邮箱和密码不能为空"); + } + + if (password.length < 6) { + return jsonFailure("密码至少需要6位"); + } + + const created = await updateData((data) => { + if (data.accounts.length > 0) { + return null; + } + + const account = createAccountRecord({ + name, + email, + password, + role: "admin", + organization, + }); + const session = createSessionRecord(account.id); + + data.accounts.push(account); + data.sessions.push(session); + + return { account, session }; + }); + + if (!created) { + return jsonFailure("系统已完成初始化", 409); + } + + await setSessionCookie(created.session.id); + await recordAuditLog({ + actor: { + id: created.account.id, + name: created.account.name, + email: created.account.email, + role: created.account.role, + organization: created.account.organization, + status: created.account.status, + createdAt: created.account.createdAt, + updatedAt: created.account.updatedAt, + }, + action: "account.setup", + targetType: "account", + targetId: created.account.id, + result: "success", + reason: "初始化管理员账号", + }); + + return jsonSuccess("初始化完成", { + account: { + id: created.account.id, + name: created.account.name, + email: created.account.email, + role: created.account.role, + organization: created.account.organization, + status: created.account.status, + createdAt: created.account.createdAt, + updatedAt: created.account.updatedAt, + }, + }); +} + diff --git a/app/api/elders/[id]/route.ts b/app/api/elders/[id]/route.ts new file mode 100644 index 0000000..01e4b80 --- /dev/null +++ b/app/api/elders/[id]/route.ts @@ -0,0 +1,99 @@ +import { validateElderInput } from "@/modules/elders/types"; +import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; +import { recordAuditLog } from "@/modules/core/server/audit"; +import { requirePermission } from "@/modules/core/server/auth"; +import { updateData } from "@/modules/core/server/store"; + +type RouteContext = { + params: Promise<{ + id: string; + }>; +}; + +export async function PATCH(request: Request, context: RouteContext): Promise { + const { id } = await context.params; + const auth = await requirePermission("elder:update", { + action: "elder.update", + targetType: "elder", + targetId: id, + }); + + if (!auth.success) { + return auth.response; + } + + const input = validateElderInput(await readJsonBody(request)); + if (!input.success) { + return jsonFailure(input.reason); + } + + const updated = await updateData((data) => { + const elder = data.elders.find((item) => item.id === id); + if (!elder) { + return null; + } + + const nextElder = { + ...elder, + ...input.data, + updatedAt: new Date().toISOString(), + }; + + data.elders = data.elders.map((item) => (item.id === id ? nextElder : item)); + return nextElder; + }); + + if (!updated) { + return jsonFailure("老人档案不存在", 404); + } + + await recordAuditLog({ + actor: auth.context.account, + action: "elder.update", + targetType: "elder", + targetId: updated.id, + result: "success", + reason: `更新老人档案:${updated.name}`, + }); + + return jsonSuccess("老人档案已更新", { elder: updated }); +} + +export async function DELETE(_request: Request, context: RouteContext): Promise { + const { id } = await context.params; + const auth = await requirePermission("elder:delete", { + action: "elder.delete", + targetType: "elder", + targetId: id, + }); + + if (!auth.success) { + return auth.response; + } + + const deleted = await updateData((data) => { + const elder = data.elders.find((item) => item.id === id); + if (!elder) { + return null; + } + + data.elders = data.elders.filter((item) => item.id !== id); + return elder; + }); + + if (!deleted) { + return jsonFailure("老人档案不存在", 404); + } + + await recordAuditLog({ + actor: auth.context.account, + action: "elder.delete", + targetType: "elder", + targetId: deleted.id, + result: "success", + reason: `删除老人档案:${deleted.name}`, + }); + + return jsonSuccess("老人档案已删除", { elder: deleted }); +} + diff --git a/app/api/elders/route.ts b/app/api/elders/route.ts new file mode 100644 index 0000000..c5fbc76 --- /dev/null +++ b/app/api/elders/route.ts @@ -0,0 +1,61 @@ +import { randomUUID } from "node:crypto"; + +import type { Elder } from "@/modules/elders/types"; +import { validateElderInput } from "@/modules/elders/types"; +import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; +import { recordAuditLog } from "@/modules/core/server/audit"; +import { requirePermission } from "@/modules/core/server/auth"; +import { readData, updateData } from "@/modules/core/server/store"; + +export async function GET(): Promise { + const auth = await requirePermission("elder:read", { + action: "elder.list", + targetType: "elder", + }); + + if (!auth.success) { + return auth.response; + } + + const data = await readData(); + return jsonSuccess("老人档案已加载", { elders: data.elders }); +} + +export async function POST(request: Request): Promise { + const auth = await requirePermission("elder:create", { + action: "elder.create", + targetType: "elder", + }); + + if (!auth.success) { + return auth.response; + } + + const input = validateElderInput(await readJsonBody(request)); + if (!input.success) { + return jsonFailure(input.reason); + } + + const now = new Date().toISOString(); + const elder: Elder = { + ...input.data, + id: randomUUID(), + createdAt: now, + updatedAt: now, + }; + + await updateData((data) => { + data.elders = [elder, ...data.elders]; + }); + await recordAuditLog({ + actor: auth.context.account, + action: "elder.create", + targetType: "elder", + targetId: elder.id, + result: "success", + reason: `新增老人档案:${elder.name}`, + }); + + return jsonSuccess("老人档案已创建", { elder }, 201); +} + diff --git a/app/api/settings/accounts/route.ts b/app/api/settings/accounts/route.ts new file mode 100644 index 0000000..b6d1345 --- /dev/null +++ b/app/api/settings/accounts/route.ts @@ -0,0 +1,20 @@ +import { jsonSuccess } from "@/modules/core/server/api"; +import { requirePermission, toPublicAccount } from "@/modules/core/server/auth"; +import { readData } from "@/modules/core/server/store"; + +export async function GET(): Promise { + const auth = await requirePermission("account:read", { + action: "account.list", + targetType: "account", + }); + + if (!auth.success) { + return auth.response; + } + + const data = await readData(); + return jsonSuccess("账号列表已加载", { + accounts: data.accounts.map((account) => toPublicAccount(account)), + }); +} + diff --git a/app/api/settings/roles/route.ts b/app/api/settings/roles/route.ts new file mode 100644 index 0000000..c117a50 --- /dev/null +++ b/app/api/settings/roles/route.ts @@ -0,0 +1,17 @@ +import { jsonSuccess } from "@/modules/core/server/api"; +import { requirePermission } from "@/modules/core/server/auth"; +import { ROLE_DEFINITIONS } from "@/modules/core/server/permissions"; + +export async function GET(): Promise { + const auth = await requirePermission("account:read", { + action: "role.list", + targetType: "role", + }); + + if (!auth.success) { + return auth.response; + } + + return jsonSuccess("角色列表已加载", { roles: ROLE_DEFINITIONS }); +} + diff --git a/modules/core/server/api.ts b/modules/core/server/api.ts new file mode 100644 index 0000000..8bbd76f --- /dev/null +++ b/modules/core/server/api.ts @@ -0,0 +1,32 @@ +export type ApiFailure = { + success: false; + reason: string; +}; + +export type ApiSuccess> = { + success: true; + reason: string; +} & T; + +export type ApiResult> = ApiSuccess | ApiFailure; + +export function jsonSuccess>( + reason: string, + payload: T, + status = 200, +): Response { + return Response.json({ success: true, reason, ...payload }, { status }); +} + +export function jsonFailure(reason: string, status = 400): Response { + return Response.json({ success: false, reason }, { status }); +} + +export async function readJsonBody(request: Request): Promise { + try { + return await request.json(); + } catch { + return null; + } +} + diff --git a/modules/core/server/audit.ts b/modules/core/server/audit.ts new file mode 100644 index 0000000..becd3c2 --- /dev/null +++ b/modules/core/server/audit.ts @@ -0,0 +1,35 @@ +import { randomUUID } from "node:crypto"; + +import type { AuditLog, AuditResult, PublicAccount } from "@/modules/core/types"; +import { updateData } from "@/modules/core/server/store"; + +type AuditInput = { + actor?: PublicAccount; + action: string; + targetType: string; + targetId?: string; + result: AuditResult; + reason: string; +}; + +export async function recordAuditLog(input: AuditInput): Promise { + const timestamp = new Date().toISOString(); + + return await updateData((data) => { + const nextLog: AuditLog = { + id: randomUUID(), + timestamp, + actorAccountId: input.actor?.id, + actorEmail: input.actor?.email, + action: input.action, + targetType: input.targetType, + targetId: input.targetId, + result: input.result, + reason: input.reason, + }; + + data.auditLogs = [nextLog, ...data.auditLogs].slice(0, 500); + return nextLog; + }); +} + diff --git a/modules/core/server/auth.ts b/modules/core/server/auth.ts new file mode 100644 index 0000000..01d0ec5 --- /dev/null +++ b/modules/core/server/auth.ts @@ -0,0 +1,205 @@ +import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto"; + +import { cookies } from "next/headers"; + +import type { Account, AuthContext, Permission, PublicAccount, RoleId, Session } from "@/modules/core/types"; +import { jsonFailure } from "@/modules/core/server/api"; +import { recordAuditLog } from "@/modules/core/server/audit"; +import { getPermissionsForRole, hasPermission } from "@/modules/core/server/permissions"; +import { readData, updateData } from "@/modules/core/server/store"; + +const SESSION_COOKIE = "teatea_session"; +const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; + +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +export function toPublicAccount(account: Account): PublicAccount { + return { + id: account.id, + name: account.name, + email: account.email, + role: account.role, + organization: account.organization, + status: account.status, + createdAt: account.createdAt, + updatedAt: account.updatedAt, + }; +} + +export function hashPassword(password: string, salt = randomBytes(16).toString("hex")): { + hash: string; + salt: string; +} { + return { + hash: scryptSync(password, salt, 64).toString("hex"), + salt, + }; +} + +export function verifyPassword(password: string, account: Account): boolean { + const candidate = Buffer.from(hashPassword(password, account.passwordSalt).hash, "hex"); + const stored = Buffer.from(account.passwordHash, "hex"); + + if (candidate.length !== stored.length) { + return false; + } + + return timingSafeEqual(candidate, stored); +} + +export function createAccountRecord(input: { + name: string; + email: string; + password: string; + role: RoleId; + organization?: string; +}): Account { + const now = new Date().toISOString(); + const password = hashPassword(input.password); + + return { + id: randomUUID(), + name: input.name.trim(), + email: normalizeEmail(input.email), + role: input.role, + organization: input.organization?.trim(), + passwordHash: password.hash, + passwordSalt: password.salt, + status: "active", + createdAt: now, + updatedAt: now, + }; +} + +export function createSessionRecord(accountId: string): Session { + const now = new Date(); + const expiresAt = new Date(now.getTime() + SESSION_MAX_AGE_SECONDS * 1000); + + return { + id: randomUUID(), + accountId, + createdAt: now.toISOString(), + expiresAt: expiresAt.toISOString(), + }; +} + +export async function setSessionCookie(sessionId: string): Promise { + const cookieStore = await cookies(); + cookieStore.set({ + name: SESSION_COOKIE, + value: sessionId, + httpOnly: true, + sameSite: "lax", + path: "/", + maxAge: SESSION_MAX_AGE_SECONDS, + }); +} + +export async function clearSessionCookie(): Promise { + const cookieStore = await cookies(); + cookieStore.delete(SESSION_COOKIE); +} + +export async function getBootstrapState(): Promise<{ setupRequired: boolean }> { + const data = await readData(); + return { setupRequired: data.accounts.length === 0 }; +} + +export async function getCurrentAuthContext(): Promise { + const cookieStore = await cookies(); + const sessionId = cookieStore.get(SESSION_COOKIE)?.value; + + if (!sessionId) { + return null; + } + + const data = await readData(); + const session = data.sessions.find((item) => item.id === sessionId); + if (!session || Date.parse(session.expiresAt) <= Date.now()) { + return null; + } + + const account = data.accounts.find((item) => item.id === session.accountId); + if (!account || account.status !== "active") { + return null; + } + + return { + account: toPublicAccount(account), + permissions: getPermissionsForRole(account.role), + session, + }; +} + +export async function removeCurrentSession(): Promise { + const context = await getCurrentAuthContext(); + const cookieStore = await cookies(); + const sessionId = cookieStore.get(SESSION_COOKIE)?.value; + + if (sessionId) { + await updateData((data) => { + data.sessions = data.sessions.filter((item) => item.id !== sessionId); + }); + } + + await clearSessionCookie(); + return context?.account ?? null; +} + +type PermissionCheckSuccess = { + success: true; + context: AuthContext; +}; + +type PermissionCheckFailure = { + success: false; + response: Response; +}; + +type DeniedAuditContext = { + action: string; + targetType: string; + targetId?: string; +}; + +export async function requirePermission( + permission: Permission, + auditContext: DeniedAuditContext, +): Promise { + const context = await getCurrentAuthContext(); + + if (!context) { + await recordAuditLog({ + action: auditContext.action, + targetType: auditContext.targetType, + targetId: auditContext.targetId, + result: "denied", + reason: "未登录或会话已过期", + }); + + return { + success: false, + response: jsonFailure("未登录或会话已过期", 401), + }; + } + + if (!hasPermission(context.account.role, permission)) { + await recordAuditLog({ + actor: context.account, + action: auditContext.action, + targetType: auditContext.targetType, + targetId: auditContext.targetId, + result: "denied", + reason: `缺少权限:${permission}`, + }); + + return { + success: false, + response: jsonFailure("权限不足", 403), + }; + } + + return { success: true, context }; +} diff --git a/modules/core/server/permissions.ts b/modules/core/server/permissions.ts new file mode 100644 index 0000000..3fdf086 --- /dev/null +++ b/modules/core/server/permissions.ts @@ -0,0 +1,59 @@ +import type { Permission, RoleDefinition, RoleId } from "@/modules/core/types"; +import { ROLE_LABELS } from "@/modules/core/types"; + +export const ROLE_DEFINITIONS: RoleDefinition[] = [ + { + id: "admin", + label: ROLE_LABELS.admin, + description: "管理账号、权限、审计日志与全部业务数据。", + permissions: [ + "account:read", + "account:manage", + "audit:read", + "elder:read", + "elder:create", + "elder:update", + "elder:delete", + ], + }, + { + id: "manager", + label: ROLE_LABELS.manager, + description: "处理运营业务,可查看账号与审计日志,不管理账号角色。", + permissions: ["account:read", "audit:read", "elder:read", "elder:create", "elder:update", "elder:delete"], + }, + { + id: "caregiver", + label: ROLE_LABELS.caregiver, + description: "查看老人档案并维护照护相关信息。", + permissions: ["elder:read", "elder:update"], + }, + { + id: "viewer", + label: ROLE_LABELS.viewer, + description: "只读查看老人档案。", + permissions: ["elder:read"], + }, +]; + +export function getRoleDefinition(role: RoleId): RoleDefinition { + const roleDefinition = ROLE_DEFINITIONS.find((item) => item.id === role); + if (roleDefinition) { + return roleDefinition; + } + + return { + id: "viewer", + label: ROLE_LABELS.viewer, + description: "只读查看老人档案。", + permissions: ["elder:read"], + }; +} + +export function getPermissionsForRole(role: RoleId): Permission[] { + return [...getRoleDefinition(role).permissions]; +} + +export function hasPermission(role: RoleId, permission: Permission): boolean { + return getRoleDefinition(role).permissions.includes(permission); +} diff --git a/modules/core/server/store.ts b/modules/core/server/store.ts new file mode 100644 index 0000000..2735215 --- /dev/null +++ b/modules/core/server/store.ts @@ -0,0 +1,63 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; + +import type { AppData } from "@/modules/core/types"; + +const DATA_DIR = process.env.TEATEA_DATA_DIR ?? path.join(process.cwd(), ".data"); +const DATA_FILE = path.join(DATA_DIR, "teatea.json"); + +function createEmptyData(): AppData { + return { + accounts: [], + sessions: [], + elders: [], + auditLogs: [], + }; +} + +function normalizeData(data: Partial): AppData { + return { + accounts: Array.isArray(data.accounts) ? data.accounts : [], + sessions: Array.isArray(data.sessions) ? data.sessions : [], + elders: Array.isArray(data.elders) ? data.elders : [], + auditLogs: Array.isArray(data.auditLogs) ? data.auditLogs : [], + }; +} + +function isMissingFileError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === "ENOENT" + ); +} + +export async function readData(): Promise { + try { + const raw = await fs.readFile(DATA_FILE, "utf8"); + const parsed = JSON.parse(raw) as Partial; + return normalizeData(parsed); + } catch (error) { + if (isMissingFileError(error)) { + return createEmptyData(); + } + + throw error; + } +} + +export async function writeData(data: AppData): Promise { + await fs.mkdir(DATA_DIR, { recursive: true }); + const temporaryFile = `${DATA_FILE}.tmp`; + await fs.writeFile(temporaryFile, JSON.stringify(data, null, 2), "utf8"); + await fs.rename(temporaryFile, DATA_FILE); +} + +export async function updateData(mutator: (data: AppData) => T): Promise { + const data = await readData(); + const result = mutator(data); + await writeData(data); + return result; +} + diff --git a/modules/core/types.ts b/modules/core/types.ts new file mode 100644 index 0000000..e099210 --- /dev/null +++ b/modules/core/types.ts @@ -0,0 +1,82 @@ +import type { Elder } from "@/modules/elders/types"; + +export const ROLE_IDS = ["admin", "manager", "caregiver", "viewer"] as const; +export type RoleId = (typeof ROLE_IDS)[number]; + +export const ROLE_LABELS: Record = { + admin: "系统管理员", + manager: "运营主管", + caregiver: "照护人员", + viewer: "只读访客", +}; + +export const PERMISSIONS = [ + "account:read", + "account:manage", + "audit:read", + "elder:read", + "elder:create", + "elder:update", + "elder:delete", +] as const; + +export type Permission = (typeof PERMISSIONS)[number]; + +export type RoleDefinition = { + id: RoleId; + label: string; + description: string; + permissions: Permission[]; +}; + +export type AccountStatus = "active" | "disabled"; + +export type Account = { + id: string; + name: string; + email: string; + role: RoleId; + organization?: string; + passwordHash: string; + passwordSalt: string; + status: AccountStatus; + createdAt: string; + updatedAt: string; +}; + +export type PublicAccount = Omit; + +export type Session = { + id: string; + accountId: string; + createdAt: string; + expiresAt: string; +}; + +export type AuditResult = "success" | "denied" | "failure"; + +export type AuditLog = { + id: string; + timestamp: string; + actorAccountId?: string; + actorEmail?: string; + action: string; + targetType: string; + targetId?: string; + result: AuditResult; + reason: string; +}; + +export type AppData = { + accounts: Account[]; + sessions: Session[]; + elders: Elder[]; + auditLogs: AuditLog[]; +}; + +export type AuthContext = { + account: PublicAccount; + permissions: Permission[]; + session: Session; +}; + diff --git a/modules/elders/types.ts b/modules/elders/types.ts new file mode 100644 index 0000000..010d959 --- /dev/null +++ b/modules/elders/types.ts @@ -0,0 +1,175 @@ +export const GENDER_VALUES = ["male", "female", "other"] as const; +export type Gender = (typeof GENDER_VALUES)[number]; + +export const CARE_LEVEL_VALUES = ["self-care", "semi-assisted", "assisted", "intensive"] as const; +export type CareLevel = (typeof CARE_LEVEL_VALUES)[number]; + +export const ELDER_STATUS_VALUES = ["active", "pending", "discharged"] as const; +export type ElderStatus = (typeof ELDER_STATUS_VALUES)[number]; + +export const GENDER_LABELS: Record = { + male: "男", + female: "女", + other: "其他", +}; + +export const CARE_LEVEL_LABELS: Record = { + "self-care": "自理", + "semi-assisted": "半失能", + assisted: "失能", + intensive: "重点照护", +}; + +export const ELDER_STATUS_LABELS: Record = { + active: "在院", + pending: "待入住", + discharged: "已退住", +}; + +export type Elder = { + id: string; + name: string; + gender: Gender; + age: number; + careLevel: CareLevel; + room: string; + bed: string; + status: ElderStatus; + primaryContact: string; + phone: string; + medicalNotes: string; + createdAt: string; + updatedAt: string; +}; + +export type ElderInput = Omit; + +type ValidationSuccess = { + success: true; + data: T; +}; + +type ValidationFailure = { + success: false; + reason: string; +}; + +export type ValidationResult = ValidationSuccess | ValidationFailure; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readRequiredString( + source: Record, + key: keyof ElderInput, + label: string, +): ValidationResult { + const value = source[key]; + if (typeof value !== "string" || value.trim().length === 0) { + return { success: false, reason: `${label}不能为空` }; + } + + return { success: true, data: value.trim() }; +} + +function readEnum( + source: Record, + key: keyof ElderInput, + values: readonly T[], + label: string, +): ValidationResult { + const value = source[key]; + if (typeof value !== "string") { + return { success: false, reason: `${label}无效` }; + } + + const matched = values.find((item) => item === value); + if (!matched) { + return { success: false, reason: `${label}无效` }; + } + + return { success: true, data: matched }; +} + +function readAge(source: Record): ValidationResult { + const value = source.age; + const numeric = typeof value === "number" ? value : Number(value); + + if (!Number.isInteger(numeric) || numeric < 0 || numeric > 130) { + return { success: false, reason: "年龄需为0到130之间的整数" }; + } + + return { success: true, data: numeric }; +} + +export function validateElderInput(value: unknown): ValidationResult { + if (!isRecord(value)) { + return { success: false, reason: "请求数据格式无效" }; + } + + const name = readRequiredString(value, "name", "姓名"); + if (!name.success) { + return name; + } + + const gender = readEnum(value, "gender", GENDER_VALUES, "性别"); + if (!gender.success) { + return gender; + } + + const age = readAge(value); + if (!age.success) { + return age; + } + + const careLevel = readEnum(value, "careLevel", CARE_LEVEL_VALUES, "护理等级"); + if (!careLevel.success) { + return careLevel; + } + + const room = readRequiredString(value, "room", "房间"); + if (!room.success) { + return room; + } + + const bed = readRequiredString(value, "bed", "床位"); + if (!bed.success) { + return bed; + } + + const status = readEnum(value, "status", ELDER_STATUS_VALUES, "状态"); + if (!status.success) { + return status; + } + + const primaryContact = readRequiredString(value, "primaryContact", "联系人"); + if (!primaryContact.success) { + return primaryContact; + } + + const phone = readRequiredString(value, "phone", "联系电话"); + if (!phone.success) { + return phone; + } + + const medicalNotesValue = value.medicalNotes; + const medicalNotes = typeof medicalNotesValue === "string" ? medicalNotesValue.trim() : ""; + + return { + success: true, + data: { + name: name.data, + gender: gender.data, + age: age.data, + careLevel: careLevel.data, + room: room.data, + bed: bed.data, + status: status.data, + primaryContact: primaryContact.data, + phone: phone.data, + medicalNotes, + }, + }; +} +