feat: add full-stack API persistence and RBAC
This commit is contained in:
20
app/api/audit-logs/route.ts
Normal file
20
app/api/audit-logs/route.ts
Normal file
@@ -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<Response> {
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
8
app/api/auth/bootstrap/route.ts
Normal file
8
app/api/auth/bootstrap/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { getBootstrapState } from "@/modules/core/server/auth";
|
||||
import { jsonSuccess } from "@/modules/core/server/api";
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const state = await getBootstrapState();
|
||||
return jsonSuccess("Bootstrap state loaded", state);
|
||||
}
|
||||
|
||||
62
app/api/auth/login/route.ts
Normal file
62
app/api/auth/login/route.ts
Normal file
@@ -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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(source: Record<string, unknown>, key: string): string {
|
||||
const value = source[key];
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
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 });
|
||||
}
|
||||
|
||||
20
app/api/auth/logout/route.ts
Normal file
20
app/api/auth/logout/route.ts
Normal file
@@ -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<Response> {
|
||||
const account = await removeCurrentSession();
|
||||
|
||||
if (account) {
|
||||
await recordAuditLog({
|
||||
actor: account,
|
||||
action: "auth.logout",
|
||||
targetType: "session",
|
||||
result: "success",
|
||||
reason: "退出登录",
|
||||
});
|
||||
}
|
||||
|
||||
return jsonSuccess("已退出登录", {});
|
||||
}
|
||||
|
||||
88
app/api/auth/register/route.ts
Normal file
88
app/api/auth/register/route.ts
Normal file
@@ -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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(source: Record<string, unknown>, key: string): string {
|
||||
const value = source[key];
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
12
app/api/auth/session/route.ts
Normal file
12
app/api/auth/session/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||
import { jsonSuccess } from "@/modules/core/server/api";
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const context = await getCurrentAuthContext();
|
||||
|
||||
return jsonSuccess("Session loaded", {
|
||||
account: context?.account ?? null,
|
||||
permissions: context?.permissions ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
90
app/api/auth/setup/route.ts
Normal file
90
app/api/auth/setup/route.ts
Normal file
@@ -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<string, unknown>, key: string): string {
|
||||
const value = source[key];
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
99
app/api/elders/[id]/route.ts
Normal file
99
app/api/elders/[id]/route.ts
Normal file
@@ -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<Response> {
|
||||
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<Response> {
|
||||
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 });
|
||||
}
|
||||
|
||||
61
app/api/elders/route.ts
Normal file
61
app/api/elders/route.ts
Normal file
@@ -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<Response> {
|
||||
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<Response> {
|
||||
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);
|
||||
}
|
||||
|
||||
20
app/api/settings/accounts/route.ts
Normal file
20
app/api/settings/accounts/route.ts
Normal file
@@ -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<Response> {
|
||||
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)),
|
||||
});
|
||||
}
|
||||
|
||||
17
app/api/settings/roles/route.ts
Normal file
17
app/api/settings/roles/route.ts
Normal file
@@ -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<Response> {
|
||||
const auth = await requirePermission("account:read", {
|
||||
action: "role.list",
|
||||
targetType: "role",
|
||||
});
|
||||
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
return jsonSuccess("角色列表已加载", { roles: ROLE_DEFINITIONS });
|
||||
}
|
||||
|
||||
32
modules/core/server/api.ts
Normal file
32
modules/core/server/api.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export type ApiFailure = {
|
||||
success: false;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type ApiSuccess<T extends Record<string, unknown>> = {
|
||||
success: true;
|
||||
reason: string;
|
||||
} & T;
|
||||
|
||||
export type ApiResult<T extends Record<string, unknown>> = ApiSuccess<T> | ApiFailure;
|
||||
|
||||
export function jsonSuccess<T extends Record<string, unknown>>(
|
||||
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<unknown> {
|
||||
try {
|
||||
return await request.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
35
modules/core/server/audit.ts
Normal file
35
modules/core/server/audit.ts
Normal file
@@ -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<AuditLog> {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
205
modules/core/server/auth.ts
Normal file
205
modules/core/server/auth.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<AuthContext | null> {
|
||||
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<PublicAccount | null> {
|
||||
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<PermissionCheckSuccess | PermissionCheckFailure> {
|
||||
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 };
|
||||
}
|
||||
59
modules/core/server/permissions.ts
Normal file
59
modules/core/server/permissions.ts
Normal file
@@ -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);
|
||||
}
|
||||
63
modules/core/server/store.ts
Normal file
63
modules/core/server/store.ts
Normal file
@@ -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>): 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<AppData> {
|
||||
try {
|
||||
const raw = await fs.readFile(DATA_FILE, "utf8");
|
||||
const parsed = JSON.parse(raw) as Partial<AppData>;
|
||||
return normalizeData(parsed);
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
return createEmptyData();
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeData(data: AppData): Promise<void> {
|
||||
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<T>(mutator: (data: AppData) => T): Promise<T> {
|
||||
const data = await readData();
|
||||
const result = mutator(data);
|
||||
await writeData(data);
|
||||
return result;
|
||||
}
|
||||
|
||||
82
modules/core/types.ts
Normal file
82
modules/core/types.ts
Normal file
@@ -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<RoleId, string> = {
|
||||
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<Account, "passwordHash" | "passwordSalt">;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
175
modules/elders/types.ts
Normal file
175
modules/elders/types.ts
Normal file
@@ -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<Gender, string> = {
|
||||
male: "男",
|
||||
female: "女",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
export const CARE_LEVEL_LABELS: Record<CareLevel, string> = {
|
||||
"self-care": "自理",
|
||||
"semi-assisted": "半失能",
|
||||
assisted: "失能",
|
||||
intensive: "重点照护",
|
||||
};
|
||||
|
||||
export const ELDER_STATUS_LABELS: Record<ElderStatus, string> = {
|
||||
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<Elder, "id" | "createdAt" | "updatedAt">;
|
||||
|
||||
type ValidationSuccess<T> = {
|
||||
success: true;
|
||||
data: T;
|
||||
};
|
||||
|
||||
type ValidationFailure = {
|
||||
success: false;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readRequiredString(
|
||||
source: Record<string, unknown>,
|
||||
key: keyof ElderInput,
|
||||
label: string,
|
||||
): ValidationResult<string> {
|
||||
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<T extends string>(
|
||||
source: Record<string, unknown>,
|
||||
key: keyof ElderInput,
|
||||
values: readonly T[],
|
||||
label: string,
|
||||
): ValidationResult<T> {
|
||||
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<string, unknown>): ValidationResult<number> {
|
||||
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<ElderInput> {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user