feat: add full-stack API persistence and RBAC

This commit is contained in:
2026-07-01 06:28:18 -07:00
parent aafd9caaac
commit 914f74c5cc
18 changed files with 1148 additions and 0 deletions

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

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

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

View 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
View 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
View 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,
},
};
}