diff --git a/AGENTS.md b/AGENTS.md index e80852e..1fd627f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ ```bash npm install npm run dev # Vite dev server, 0.0.0.0:3001 -npm run server:dev # NestJS API dev server, 0.0.0.0:3000 +npm run server:dev # build and start NestJS API, 0.0.0.0:3000 npm run server:build # tsc -p server/tsconfig.json npm run lint # tsc --noEmit npm run test # vitest run @@ -93,15 +93,15 @@ npm run test:e2e 详细清单见 `docs/features.md`。处理需求时应区分: - 真实可用:本地初始化、字段绑定、JSON 导出等。 -- 真实集成:后端 Session 登录、Dashboard API、报告 API、模板 API、用户/部门 API、设置 API、签名文件 API、AI 代理、讯飞语音代理、浏览器打印/PDF、视频抽帧。它们有真实代码路径,但依赖后端服务、浏览器能力、权限、有效密钥、网络或人工保存。 -- 前端演示:页面权限仍以体验控制为主,不能作为生产安全边界。 +- 真实集成:后端 Session 登录、Dashboard API、报告 API、模板 API、用户/部门 API、设置 API、签名文件 API、审计日志 API、AI 代理、讯飞语音代理、浏览器打印/PDF、视频抽帧。它们有真实代码路径,但依赖后端服务、浏览器能力、权限、有效密钥、网络或人工保存。 +- 前端体验控制:页面级角色守卫和菜单隐藏已接入 Auth Context,但不能替代后端 API 权限。 ## 当前安全边界 本项目不能按生产安全系统理解: - 后端账号使用 Argon2 哈希;开发回退模式下 `localStorage.users` 仍保留兼容缓存和旧演示密码字段。 -- 权限判断在前端,不能抵御绕过。 +- 页面级权限在前端用于体验控制,不能抵御绕过;生产安全边界以后端 API 权限校验为准。 - 报告和模板 HTML 保存时已做服务端白名单清洗;前端仍使用 HTML 渲染,继续修改时要留意 XSS 和打印兼容。 - AI Key 和讯飞语音密钥已由后端代理使用,普通用户读取系统设置时不会返回真实密钥。 - 视频和关键帧文件已优先进入后端文件资源;报告保存时通过 `ReportMedia` 关系表关联,新建报告保存前仍依赖浏览器对象 URL 预览。 @@ -119,11 +119,12 @@ npm run test:e2e ├── package-lock.json # npm 锁文件 ├── prisma.config.ts # Prisma 7 CLI 配置 ├── vite.config.ts # Vite/Tailwind/React/Vitest 配置 -├── playwright.config.ts # Playwright E2E 配置,默认复用/启动 3001 端口 +├── playwright.config.ts # Playwright E2E 配置,启动/复用 3001 前端和 3100 测试 API ├── tsconfig.json # TypeScript 配置 ├── index.html # Vite HTML 入口 ├── e2e/ -│ ├── helpers.ts # E2E localStorage 种子数据工具 +│ ├── helpers.ts # E2E 真实 API 登录和造数工具 +│ ├── audit-and-route-guards.spec.ts │ ├── login.spec.ts │ ├── report-permissions.spec.ts │ ├── report-revision.spec.ts @@ -137,6 +138,7 @@ npm run test:e2e │ ├── index.css # 全局样式、Tailwind 主题和组件类 │ ├── types.ts # User/Report/Template/SystemSettings/FormField 等类型和默认配置 │ ├── api/ +│ │ ├── audit.ts # 审计日志 API 封装 │ │ ├── client.ts # 前端 API client,统一 envelope/错误/Cookie │ │ ├── dashboard.ts # 工作台统计 API 封装 │ │ ├── reports.ts # 报告列表、详情、保存、删除 API 封装 @@ -159,6 +161,7 @@ npm run test:e2e │ │ └── Sidebar.test.tsx │ ├── pages/ │ │ ├── Login.tsx # 登录和默认数据初始化 +│ │ ├── AuditLogs.tsx # 审计日志查看 │ │ ├── Dashboard.tsx # 工作台统计和快捷入口 │ │ ├── ReportEditor.tsx # 核心报告编辑器、抽帧、AI、语音、保存 │ │ ├── ReportManage.tsx # 报告列表、筛选、历史、导出、删除 @@ -186,7 +189,7 @@ npm run test:e2e │ ├── src/ │ │ ├── main.ts # NestJS API 启动入口 │ │ ├── app.module.ts -│ │ ├── audit/ # 审计日志写入服务 +│ │ ├── audit/ # 审计日志写入和查询 API │ │ ├── auth/ # 登录、me、logout 接口 │ │ ├── dashboard/ # 工作台统计 API │ │ ├── reports/ # 报告 API、DTO、metadata 映射和测试 @@ -332,7 +335,7 @@ PostgreSQL 数据模型。当前覆盖 `Tenant`、`Department`、`User`、`UserS - 报告管理按角色过滤 - 管理员本部门报告范围和医生本人报告范围 - 模板可用范围,含部门模板和医生个人模板 -- Playwright E2E 覆盖登录、报告权限、报告修订版本和医生个人模板 +- Playwright E2E 通过真实后端 API seed 覆盖登录、报告权限、报告修订版本、医生个人模板、路由守卫和审计日志 - 后端权限策略覆盖报告、模板、用户管理和管理员创建规则 - 后端 Dashboard 统计按角色范围过滤 - 后端报告 metadata 兼容映射和 `ReportMedia` 视频/关键帧组装 @@ -341,7 +344,7 @@ PostgreSQL 数据模型。当前覆盖 `Tenant`、`Department`、`User`、`UserS - 后端系统设置 schema 校验 - 后端 AI 入参和讯飞语音代理帧处理 - 后端 HTTP 集成测试覆盖 API prefix、登录 session、受保护 API actor 传递 -- 后端真实 PostgreSQL 集成测试覆盖 Auth、Dashboard、Reports、ReportMedia、Templates、Files、HTML 清洗和审计核心服务 +- 后端真实 PostgreSQL 集成测试覆盖 Auth、Dashboard、Reports、ReportMedia、Templates、Files、HTML 清洗、审计写入和审计查询权限 - 存储封装 - 默认模板结构和字段契约 - 打印入口 diff --git a/README.md b/README.md index 4602e85..da112de 100644 --- a/README.md +++ b/README.md @@ -80,14 +80,14 @@ npm run prisma:seed | `manager` | `123456` | 管理员 | | `0001` | `123456` | 医生 | -开发模式下,默认数据会在首次进入登录页时初始化到浏览器 `localStorage`,用于离线回退和旧 E2E 基线。 +开发模式下,默认数据会在首次进入登录页时初始化到浏览器 `localStorage`,用于离线回退和旧数据兼容;当前 Playwright E2E 已改为真实后端 API seed。 账号认证由后端 `POST /api/auth/login` 完成;登录成功后前端会同步一份 `currentUser` 到 `localStorage`,供迁移期页面继续读取角色、部门和模板权限。生产构建默认不通过本地缓存恢复登录态。 ## 常用命令 ```bash npm run dev # 启动开发服务器,端口 3001 -npm run server:dev # 启动 NestJS API,端口 3000 +npm run server:dev # 编译并启动 NestJS API,默认端口 3000 npm run server:build # 构建后端 TypeScript npm run lint # TypeScript 类型检查 npm run test # Vitest 测试 @@ -239,7 +239,7 @@ docker-compose down ## 当前限制 - 前端登录、工作台统计、报告读写、报告媒体引用、模板读写、字段库、模板图片资源、视频/关键帧文件、用户管理、部门模板授权、系统设置、签名文件、AI 对话和语音听写已接入真实后端 Session/API/代理。 -- 当前后端已有认证、数据库 Session、健康检查、Dashboard API、报告 API、模板 API、字段库 API、通用文件 API、用户/部门 API、设置 API、签名文件 API、AI 代理、语音代理、权限策略、HTML 清洗、审计日志和数据模型。 +- 当前后端已有认证、数据库 Session、健康检查、Dashboard API、报告 API、模板 API、字段库 API、通用文件 API、用户/部门 API、设置 API、签名文件 API、AI 代理、语音代理、权限策略、HTML 清洗、审计日志查询和数据模型。 - 后端账号使用 Argon2 哈希;开发模式前端仍会初始化 `localStorage.users`,其中保留演示密码字段供旧页面兼容。 - 权限判断主要在前端,不能作为生产安全边界。 - 报告和模板 HTML 保存时已做服务端白名单清洗,但渲染仍使用 HTML,需要继续做安全评审。 diff --git a/docs/api-contract.md b/docs/api-contract.md index 1444546..2de0cf9 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -43,7 +43,7 @@ - `POST /api/files` - `DELETE /api/files/:id` -报告媒体关系表、数据库 Session 和第一版审计日志已实现;查看日志、第三方调用摘要和后端导出接口仍是后续项。 +报告媒体关系表、数据库 Session 和第一版审计日志已实现;审计日志查询 API 已提供给超级管理员和管理员使用;第三方调用摘要和后端导出接口仍是后续项。 ## 通用约定 @@ -643,6 +643,48 @@ interface FieldLibraryDTO { - 模板图片资源当前登录用户可读取,删除权限限制为超级管理员、管理员或 owner。 - 签名文件已先行通过 `POST /api/users/:id/signature` 实现。 +## Audit API + +### `GET /api/audit-logs` + +查询审计日志。超级管理员可查看当前租户全部日志;管理员可查看本部门或自己作为操作者的日志;医生不可访问。 + +查询参数: + +```text +page=1 +pageSize=50 +action=report.complete +targetType=Report +actor=admin +``` + +返回: + +```json +{ + "data": { + "items": [ + { + "id": "log_xxx", + "actorUsername": "admin", + "actorName": "超级管理员", + "actorRole": "super", + "action": "report.complete", + "targetType": "Report", + "targetId": "report_xxx", + "departmentId": "dept_xxx", + "metadata": { "title": "报告标题" }, + "createdAt": "2026-05-02T00:00:00.000Z" + } + ], + "total": 1, + "page": 1, + "pageSize": 50 + } +} +``` + ## 后续测试落点 后端骨架建立后,应把本文档转为真实测试: diff --git a/docs/backendization-plan.md b/docs/backendization-plan.md index f28dc45..b66ff36 100644 --- a/docs/backendization-plan.md +++ b/docs/backendization-plan.md @@ -35,7 +35,7 @@ | 密码 | Argon2 哈希 | 禁止明文密码进入前端或日志。 | | 文件存储 | 本地文件目录 + `files` 表,预留 MinIO/S3 兼容接口 | 开发和院内部署先降低成本;生产可切换对象存储。 | | 输入校验 | Zod 或 NestJS DTO 校验 | 所有 API 入参都必须校验,尤其是 HTML、文件、权限相关参数。 | -| 测试 | Vitest/Testing Library + Playwright + 后端单元/集成测试 | 保留现有前端测试;新增后端 policy/service/API 测试;E2E 后续从 localStorage seed 改为 API/test DB seed。 | +| 测试 | Vitest/Testing Library + Playwright + 后端单元/集成测试 | 保留现有前端测试;新增后端 policy/service/API 测试;E2E 已从 localStorage seed 改为真实后端 API seed。 | | 部署 | Docker Compose | 前端静态资源、NestJS API、PostgreSQL、可选 Redis/MinIO 分服务部署。 | 暂不建议第一阶段使用微服务、复杂租户系统或后端抽帧服务。抽帧可以先继续留在前端 canvas,生成关键帧后上传后端文件接口。 @@ -46,7 +46,7 @@ - `server/src/main.ts`:NestJS 启动入口,设置 `/api` 前缀、CORS、Cookie Session、数据库 Session Store 和统一错误响应。 - `server/src/dashboard`:`GET /api/dashboard/stats`,按角色范围统计报告、模板、用户和趋势。 -- `server/src/audit`:第一版审计服务,记录登录、报告、模板、用户、设置和文件修改。 +- `server/src/audit`:审计服务和查询 API,记录并查看登录、报告、模板、用户、设置和文件修改。 - `server/src/health`:`GET /api/health` 健康检查。 - `server/src/auth`:`POST /api/auth/login`、`GET /api/auth/me`、`POST /api/auth/logout` 认证接口。 - `server/src/reports`:`GET/POST/PATCH/DELETE /api/reports` 报告接口,含角色范围过滤、历史版本、软删除和报告媒体关系同步。 @@ -327,6 +327,7 @@ 5. 审计日志 - 登录、编辑、完成、删除、重置、权限变更等关键操作写日志。 + - 当前已提供审计日志查询页面/API;超级管理员看全部,管理员看本部门或自己相关日志。 - 报告导出不要求水印、导出原因、审批或专门导出审计。 ## 权限模型建议 @@ -398,10 +399,17 @@ ### E2E 测试 -建议新增 Playwright: +当前 Playwright 已通过真实后端 API seed 覆盖: -- 管理员登录、创建医生、分配模板。 -- 医生登录、新建报告、保存草稿、完成报告。 +- 默认登录进入工作台。 +- 报告按超级管理员、管理员、医生范围过滤。 +- 已完成报告再次完成后递增修订版本并保留历史。 +- 医生保存个人模板。 +- 医生直进管理页被路由守卫拦截,超级管理员可查看审计日志。 + +后续可继续增加: + +- 管理员创建医生、分配模板的完整 UI 流程。 - 报告管理搜索、查看、导出。 - 模板新增、字段插入、报告套用模板。 - 视频上传和抽帧可用性。 diff --git a/docs/deployment.md b/docs/deployment.md index 1b6e0f1..8405b30 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -89,4 +89,4 @@ docker-compose up -d --build ## 部署边界 -当前后端已承载登录认证、数据库 Session、Dashboard、报告、报告媒体关系、模板、字段库、通用文件/签名文件、视频/关键帧文件、用户管理、部门模板授权、系统设置、AI 代理、语音代理、HTML 清洗和第一版审计。生产化前还需要补齐查看日志、第三方代理调用摘要、限流、备份恢复、对象存储和更完整的旧数据迁移。 +当前后端已承载登录认证、数据库 Session、Dashboard、报告、报告媒体关系、模板、字段库、通用文件/签名文件、视频/关键帧文件、用户管理、部门模板授权、系统设置、AI 代理、语音代理、HTML 清洗和审计日志查询。生产化前还需要补齐第三方代理调用摘要、限流、备份恢复、对象存储和更完整的旧数据迁移。 diff --git a/docs/features.md b/docs/features.md index 17795c9..1aef072 100644 --- a/docs/features.md +++ b/docs/features.md @@ -20,7 +20,7 @@ | 默认数据初始化 | 真实可用 | 登录页初始化默认用户、模板、字段、图片资源和系统设置。 | | 登录态恢复/退出 | 真实集成 | `AuthProvider` 调用 `GET /api/auth/me` 恢复会话,`POST /api/auth/logout` 退出;后端 Session 已持久化到 `AppSession` 表,生产构建默认不再用本地缓存恢复。 | | 角色导航 | 真实可用 | `Sidebar` 优先读取 Auth Context,回退 `currentUser.role` 显示菜单。 | -| 页面级权限跳转 | 前端演示 | 页面读取 `currentUser` 后跳转或隐藏功能;可被浏览器侧数据修改绕过。 | +| 页面级权限跳转 | 真实可用/前端体验控制 | 路由统一通过 Auth Context 按角色阻止医生进入模板管理、用户管理和审计日志;这只是体验层,生产安全仍以后端 API 权限为准。 | | 工作台统计 | 真实集成 | `Dashboard` 调用 `GET /api/dashboard/stats`,后端按角色范围统计报告、模板、用户和趋势;只有开发/显式回退模式下 API 不可用才回退本地统计。 | | 报告基本信息表单 | 真实可用 | `ReportEditor` 管理 `reportData`,支持文本、日期、时间、单选、多选。 | | 正文智能字段绑定 | 真实可用 | 模板 HTML 的 `data-bind` 字段与表单双向同步。 | @@ -46,8 +46,9 @@ | 讯飞语音听写 | 真实集成 | 前端使用麦克风采集音频并连接 `/api/speech/iat`;后端读取讯飞配置、生成鉴权 URL、补齐首帧 APPID/业务参数并转发 IAT 结果。需要浏览器权限、有效配置和网络。 | | AI/语音密钥管理 | 真实集成 | AI Key 和讯飞 APIKey/APISecret 均由后端代理读取和使用;普通用户读取设置时不返回真实密钥。 | | 系统设置 | 真实集成 | `SystemSettings` 优先调用 `/api/settings/system` 读取、保存和重置抽帧、默认模板、AI Provider、语音配置;只有开发/显式回退模式下 API 不可用才回退本地缓存。 | +| 审计日志查看 | 真实集成 | 超级管理员和管理员可进入审计日志页,调用 `GET /api/audit-logs` 查看登录、报告、模板、用户、部门、设置和文件等操作;管理员只看本部门或自己相关日志。 | | Docker/Nginx 静态部署 | 真实可用 | 可构建静态文件并用 Nginx 托管 SPA。 | -| 后端服务 | 后端骨架 | 已新增 NestJS API:健康检查、认证接口、数据库 Session、Dashboard API、报告 API、报告媒体关系、模板 API、字段库 API、用户/部门 API、设置 API、通用文件/签名文件 API、视频/关键帧文件上传、AI 代理、讯飞语音代理、HTML 清洗、审计日志、Prisma/PostgreSQL 数据模型、默认 seed 和权限策略。 | +| 后端服务 | 后端骨架 | 已新增 NestJS API:健康检查、认证接口、数据库 Session、Dashboard API、报告 API、报告媒体关系、模板 API、字段库 API、用户/部门 API、设置 API、通用文件/签名文件 API、视频/关键帧文件上传、AI 代理、讯飞语音代理、HTML 清洗、审计日志查询、Prisma/PostgreSQL 数据模型、默认 seed 和权限策略。 | | 讯飞语音配置字段 | 真实可用 | 初始化、类型和系统设置统一使用 `xfSpeechConfig`。 | ## 测试覆盖对应 @@ -57,6 +58,7 @@ | `src/pages/Login.test.tsx` | 默认数据初始化、默认账号展示、后端禁用账号错误展示。 | | `src/components/Sidebar.test.tsx` | 角色导航过滤。 | | `src/api/client.test.ts` | API envelope 解包、Cookie credentials 和错误 envelope。 | +| `src/api/audit.test.ts` | 审计日志列表 API 封装和查询参数。 | | `src/api/dashboard.test.ts` | Dashboard 统计 API 封装和响应校验。 | | `src/api/speech.test.ts` | 语音 WebSocket 代理地址生成,含同源和显式 API Base URL。 | | `src/api/library.test.ts` | 字段库 API 读取和更新封装。 | diff --git a/docs/modules/auth-and-users.md b/docs/modules/auth-and-users.md index a14f066..0fe73f4 100644 --- a/docs/modules/auth-and-users.md +++ b/docs/modules/auth-and-users.md @@ -25,6 +25,8 @@ 后端角色 `doctor` 会映射为当前前端历史类型中的 `user`。前端会保留本地签名和已有模板授权字段,避免迁移期丢失医生个人模板或签名。 +路由层使用 Auth Context 做统一角色守卫:医生不能直接进入模板管理、用户管理和审计日志页面;超级管理员和管理员可进入这些管理页。该守卫只负责前端体验,生产安全边界仍以后端 API 权限校验为准。 + ## 默认账号 | 用户ID | 密码 | 角色 | 说明 | diff --git a/docs/progress.md b/docs/progress.md index 95057f8..e21ebd1 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -23,15 +23,17 @@ - 报告已增加修订版本号,已完成报告再次保存会递增版本。 - 报告编辑器已支持医生保存“我的个人模板”。 - 讯飞语音配置初始化字段已统一为 `xfSpeechConfig`。 -- 已新增 Playwright E2E,用 localStorage 种子锁定登录、报告权限、报告修订版本和医生个人模板流程。 +- Playwright E2E 已改为启动真实后端 API,并通过 API seed 锁定登录、报告权限、报告修订版本、医生个人模板、路由守卫和审计日志流程。 +- 已新增审计日志查询 API 和审计日志页面;超级管理员可看全部,管理员可看本部门或自己相关日志。 +- 页面级权限已收敛到 Auth Context 路由守卫,医生不能直进模板管理、用户管理和审计日志页面。 - 已新增 API 契约草案,为后端化接口、权限过滤和响应格式提供基线。 - 已启动后端化第一阶段:新增 NestJS API 骨架、Prisma/PostgreSQL 数据模型、默认 seed、健康检查、认证接口和后端权限策略测试。 - 已完成前端认证闭环第一步:新增 API client、Auth Context、后端用户兼容映射,`Login`/`Sidebar` 已接入后端认证并同步 `currentUser`。 ## 已知风险 -- 前端仍有部分体验控制和兼容缓存依赖浏览器本地数据;后端已经承载主业务数据和第一版审计。 -- 后端认证、Dashboard API、报告 API、报告媒体关系、模板 API、字段库 API、用户/部门 API、设置 API、通用文件/签名文件 API、AI 代理和语音代理已可用;审计和 HTML 清洗已有第一版,导出/查看审计仍待加强。 +- 前端仍有部分体验控制和兼容缓存依赖浏览器本地数据;后端已经承载主业务数据和第一版审计查询。 +- 后端认证、Dashboard API、报告 API、报告媒体关系、模板 API、字段库 API、用户/部门 API、设置 API、通用文件/签名文件 API、AI 代理、语音代理和审计日志 API 已可用;第三方调用摘要、限流和后端导出仍待加强。 - 本地存储仍可能包含病历兼容缓存、旧演示密码字段、模板图片和关键帧,不适合生产;历史浏览器数据中也可能残留旧版语音服务密钥。 - `systemSettings` 的混淆存储不是加密。 - 旧版本曾在前端默认配置中包含服务密钥痕迹;当前源码默认值已清空,但生产化前仍应轮换曾经暴露过的第三方密钥。 @@ -42,11 +44,10 @@ ## 建议下一步 -1. 把 E2E 数据准备从 `localStorage` seed 迁移到 API seed 或测试数据库 seed。 -2. 逐步替换前端数据流:继续减少字段库、模板图片和报告编辑器草稿之外的本地回退依赖。 -3. 补查看日志、第三方代理调用摘要、后端导出 API 和限流;报告导出不要求专门导出审计。 -4. 增加数据迁移:为 `localStorage` 或后端数据增加版本号和迁移脚本。 -5. 增加生产运维能力:备份恢复、对象存储、监控告警和密钥轮换流程。 +1. 逐步替换前端数据流:继续减少字段库、模板图片和报告编辑器草稿之外的本地回退依赖。 +2. 补第三方代理调用摘要、后端导出 API 和限流;报告导出不要求专门导出审计。 +3. 增加数据迁移:为 `localStorage` 或后端数据增加版本号和迁移脚本。 +4. 增加生产运维能力:备份恢复、对象存储、监控告警和密钥轮换流程。 ## 维护记录 @@ -69,3 +70,4 @@ | 2026-05-02 | 报告编辑器视频和关键帧优先上传 Files API,新增真实 PostgreSQL 服务集成测试。 | | 2026-05-02 | 新增 `ReportMedia` 表和迁移,报告视频/关键帧引用从 `Report.metadata` 拆出。 | | 2026-05-02 | 新增 Dashboard API、数据库 Session Store、审计服务、HTML 白名单清洗、本地回退开关和 Docker 上传目录 volume,清理 Gemini 旧依赖。 | +| 2026-05-02 | 新增审计日志查询 API/页面、Auth Context 路由角色守卫,并把 Playwright E2E 改为真实后端 API seed。 | diff --git a/docs/security.md b/docs/security.md index c432328..b4d23bd 100644 --- a/docs/security.md +++ b/docs/security.md @@ -20,5 +20,5 @@ 3. 增加文件服务:报告图片、视频和关键帧后续可切换对象存储或院内文件服务;签名、模板图片、视频和关键帧已完成第一版后端文件资源。 4. 完善 API 代理:AI 和语音已完成第一版后端代理,后续应补限流、审计、错误分级和第三方调用隔离测试。 5. 增强 HTML 清洗:当前报告和模板保存已有第一版白名单过滤,后续需覆盖 AI 返回、导入文件、旧数据迁移和绕过测试。 -6. 增加审计日志:当前登录、报告/模板/用户/设置/文件修改已有第一版审计,后续补查看日志、打印/导出错误追踪和第三方代理调用摘要;报告导出不要求专门导出审计。 +6. 增强审计日志:当前登录、报告/模板/用户/设置/文件修改已有审计写入和查询页面;后续补打印/导出错误追踪和第三方代理调用摘要。报告导出不要求专门导出审计。 7. 增加数据备份与恢复:避免浏览器清理缓存造成业务数据丢失。 diff --git a/docs/testing.md b/docs/testing.md index 83bd37b..e02bdfa 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -10,7 +10,7 @@ npm run server:build npm run build ``` -当前单元/组件测试框架为 Vitest,运行环境为 jsdom。React 页面测试使用 Testing Library。端到端测试使用 Playwright,当前仍以 localStorage 种子数据锁定迁移期前端基线行为;登录相关测试会 mock Auth API。后端使用 Vitest 覆盖权限策略、schema、DTO 映射、Nest HTTP 层集成测试和真实 PostgreSQL 服务集成测试,NestJS API 构建用 `npm run server:build` 验证。 +当前单元/组件测试框架为 Vitest,运行环境为 jsdom。React 页面测试使用 Testing Library。端到端测试使用 Playwright,当前会启动/复用 Vite 前端和 NestJS 后端,并通过真实 Auth/Reports/Templates/Users/Audit API 准备数据。后端使用 Vitest 覆盖权限策略、schema、DTO 映射、Nest HTTP 层集成测试和真实 PostgreSQL 服务集成测试,NestJS API 构建用 `npm run server:build` 验证。 ## 测试范围 @@ -19,11 +19,12 @@ npm run build | 数据初始化 | 登录页首次加载会创建默认用户、模板、字段和系统设置。 | | 前端 API client | 统一解包 `{ data }` 响应、携带 Cookie credentials、识别后端错误 envelope。 | | 前端 Dashboard API | 工作台统计封装会请求 `/api/dashboard/stats` 并校验响应结构。 | +| 前端审计 API | 审计日志列表封装会请求 `/api/audit-logs` 并校验响应结构。 | | 前端语音代理地址 | 根据当前页面来源或 `VITE_API_BASE_URL` 生成 `/api/speech/iat` WebSocket 地址。 | | 前端字段库和文件 API | 字段库读取/更新、通用文件列表/上传封装。 | | Auth 兼容映射 | 后端 `doctor` 角色会映射为当前前端使用的 `user`,并保留本地签名和模板授权。 | -| 权限展示 | 侧边栏会按角色显示或隐藏模板管理、用户管理等入口。 | -| 报告权限 | 医生只看到本人报告,管理员看到本部门报告,超级管理员看到全部本地报告。 | +| 权限展示 | 侧边栏和路由守卫会按角色显示或阻止模板管理、用户管理、审计日志等入口。 | +| 报告权限 | 医生只看到本人报告,管理员看到本部门报告,超级管理员看到全部后端报告。 | | 后端报告映射 | Report API 使用 `metadata` 兼容前端扩展字段,使用 `ReportMedia` 组装视频/关键帧兼容字段。 | | 模板权限 | 医生可使用本部门授权模板和个人模板,不能使用他人个人模板。 | | 后端模板映射 | Template API 返回前端可消费的 `Template` 结构,并生成权限策略资源。 | @@ -41,6 +42,7 @@ npm run build | E2E 权限过滤 | Playwright 验证超级管理员、管理员、医生在报告管理页的可见范围。 | | E2E 报告修订 | Playwright 验证已完成报告再次完成保存后 `revision` 递增并保留历史。 | | E2E 个人模板 | Playwright 验证医生可保存个人模板且模板仅归属本人。 | +| E2E 路由守卫和审计日志 | Playwright 验证医生不能直进管理页,超级管理员可查看审计日志。 | | 后端权限策略 | Vitest 验证报告、模板、用户和管理员创建权限策略。 | ## Mock 边界 @@ -48,7 +50,7 @@ npm run build 测试中 mock 了以下浏览器或外部边界: - `fetch('/logo_square.png')`:用于登录初始化图片资源。 -- `/api/auth/me`、`/api/auth/login`:组件测试和登录 E2E 中使用 mock,避免单元/E2E 依赖真实数据库。 +- `/api/auth/me`、`/api/auth/login`:组件测试中使用 mock,避免单元测试依赖真实数据库;Playwright E2E 使用真实后端 API。 - `window.alert` / `window.confirm`:避免测试阻塞。 - `document.execCommand`:当前富文本编辑依赖浏览器命令。 - `URL.createObjectURL` / `URL.revokeObjectURL`:用于文件、视频和导出。 @@ -64,6 +66,7 @@ AI 第三方接口、讯飞语音上游 WebSocket、麦克风权限和真实视 | 后端登录禁用账号错误 | 已覆盖 | `Login.test.tsx` | | API client 响应/错误处理 | 已覆盖 | `api/client.test.ts` | | Dashboard API 封装 | 已覆盖 | `api/dashboard.test.ts` | +| 审计日志 API 封装 | 已覆盖 | `api/audit.test.ts` | | 语音 WebSocket 代理地址 | 已覆盖 | `api/speech.test.ts` | | 字段库 API 封装 | 已覆盖 | `api/library.test.ts` | | 通用文件 API 封装 | 已覆盖 | `api/files.test.ts` | @@ -81,6 +84,7 @@ AI 第三方接口、讯飞语音上游 WebSocket、麦克风权限和真实视 | 报告权限 E2E | 已覆盖 | `e2e/report-permissions.spec.ts` | | 报告修订版本 E2E | 已覆盖 | `e2e/report-revision.spec.ts` | | 医生个人模板 E2E | 已覆盖 | `e2e/personal-template.spec.ts` | +| 路由守卫和审计日志 E2E | 已覆盖 | `e2e/audit-and-route-guards.spec.ts` | | 后端报告/模板/用户权限策略 | 已覆盖 | `server/src/permissions/permissions.policy.test.ts` | | 后端报告兼容映射 | 已覆盖 | `server/src/reports/report.mapper.test.ts` | | 后端模板兼容映射 | 已覆盖 | `server/src/templates/template.mapper.test.ts` | @@ -91,7 +95,7 @@ AI 第三方接口、讯飞语音上游 WebSocket、麦克风权限和真实视 | 后端字段库 schema | 已覆盖 | `server/src/library/library.schemas.test.ts` | | 后端文件 schema | 已覆盖 | `server/src/files/files.schemas.test.ts` | | 后端 HTTP 集成 | 已覆盖 | `server/src/http.integration.test.ts` | -| 后端真实数据库集成 | 已覆盖 | `server/src/database.integration.test.ts`,含 Dashboard 角色范围、报告媒体关系表同步、报告 HTML 清洗和审计写入。 | +| 后端真实数据库集成 | 已覆盖 | `server/src/database.integration.test.ts`,含 Dashboard 角色范围、报告媒体关系表同步、报告 HTML 清洗、审计写入和审计查询权限。 | | 后端健康检查和认证 API | 已覆盖 | HTTP 集成测试覆盖健康检查、登录 session 和未登录保护;真实数据库集成覆盖 Argon2 登录、禁用账号和数据库 Session Store。 | | 模板编辑器深度交互 | 待 E2E | 依赖 contentEditable 和 execCommand。 | | 报告编辑器完整流程 | 部分覆盖 | 已覆盖保存修订版本和个人模板;模板切换、字段同步仍待补。 | @@ -107,4 +111,4 @@ AI 第三方接口、讯飞语音上游 WebSocket、麦克风权限和真实视 npx playwright install chromium ``` -当前 E2E 测试不会依赖真实后端数据库:登录 E2E mock 认证 API,其余业务 E2E 通过 `e2e/helpers.ts` 写入 localStorage 种子数据。后端化后应保留这些用户流程测试,并逐步把数据准备方式从 localStorage seed 改为 API seed 或测试数据库 seed。 +当前 E2E 测试依赖真实后端数据库。Playwright 会在 `3100` 端口启动后端 API,执行 Prisma migrate/seed,然后在 `3001` 启动 Vite,并把 `/api` 代理到 `3100`。`e2e/helpers.ts` 通过真实 API 登录、创建用户/部门/报告和查询模板,不再写入 localStorage 种子数据。 diff --git a/e2e/audit-and-route-guards.spec.ts b/e2e/audit-and-route-guards.spec.ts new file mode 100644 index 0000000..561ff39 --- /dev/null +++ b/e2e/audit-and-route-guards.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from '@playwright/test'; +import { createReportByApi, loginByApi, uniqueId } from './helpers'; + +test('route guards block doctors from admin pages and super users can view audit logs', async ({ page }) => { + await loginByApi(page, '0001'); + await page.goto('/user-manage'); + await page.waitForURL('**/dashboard'); + await expect(page.getByRole('heading', { name: '工作台概览' })).toBeVisible(); + + const title = `审计验证报告 ${uniqueId('audit')}`; + await createReportByApi(page.request, { + title, + content: `

${title}

`, + status: 'completed', + }); + + await loginByApi(page, 'admin'); + await page.goto('/audit-logs'); + await expect(page.getByRole('heading', { name: '审计日志' })).toBeVisible(); + await expect(page.locator('tbody').getByText('完成报告').first()).toBeVisible(); +}); diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 26a0720..1c812d5 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -1,77 +1,83 @@ -import { Page } from '@playwright/test'; +import { expect, type APIRequestContext, type Page } from '@playwright/test'; -export const baseUsers = [ - { - username: 'admin', - password: '123456', - role: 'super', - name: '超级管理员', - department: 'admin', - status: 'active', - visibleTemplates: ['dept_surgery'], - manageableTemplates: ['dept_surgery'], - }, - { - username: 'manager', - password: '123456', - role: 'admin', - name: '管理员', - department: '外科', - status: 'active', - visibleTemplates: ['dept_surgery'], - manageableTemplates: ['dept_surgery'], - }, - { - username: '0001', - password: '123456', - role: 'user', - name: '张医生', - department: '外科', - status: 'active', - visibleTemplates: ['dept_surgery'], - manageableTemplates: [], - }, - { - username: '0002', - password: '123456', - role: 'user', - name: '李医生', - department: '内科', - status: 'active', - visibleTemplates: ['dept_internal'], - manageableTemplates: [], - }, -]; +export const uniqueId = (prefix: string) => + `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; -export const baseTemplates = [ - { - id: 'dept_surgery', - name: '外科部门模板', - desc: 'E2E 外科模板', - content: '

默认内容

', - createdAt: '2026-05-01T00:00:00.000Z', - author: 'admin', - scope: 'department', - department: '外科', - }, - { - id: 'dept_internal', - name: '内科部门模板', - desc: 'E2E 内科模板', - content: '

内科模板

', - createdAt: '2026-05-01T00:00:00.000Z', - author: 'admin', - scope: 'department', - department: '内科', - }, -]; +const toFrontendUser = (user: any) => ({ + id: user.id, + username: user.username, + role: user.role === 'doctor' ? 'user' : user.role, + name: user.name, + departmentId: user.departmentId, + department: user.departmentName, + status: user.status, + signature: user.signature, + signatureFileId: user.signatureFileId, + visibleTemplates: [], + manageableTemplates: [], +}); -export const seedLocalStorage = async (page: Page, data: Record) => { - await page.addInitScript((seed) => { +export const apiRequest = async ( + request: APIRequestContext, + method: 'get' | 'post' | 'patch' | 'delete', + path: string, + data?: Record, +) => { + const response = await request[method](path, data ? { data } : undefined); + const text = await response.text(); + expect(response.ok(), `${method.toUpperCase()} ${path} failed: ${text}`).toBe(true); + return text ? (JSON.parse(text).data as T) : (null as T); +}; + +export const loginByApi = async (page: Page, username: string, password = '123456') => { + await page.goto('/'); + await page.evaluate(() => { window.localStorage.clear(); window.sessionStorage.clear(); - for (const [key, value] of Object.entries(seed)) { - window.localStorage.setItem(key, JSON.stringify(value)); - } - }, data); + }); + await page.request.post('/api/auth/logout').catch(() => undefined); + const data = await apiRequest<{ user: any }>(page.request, 'post', '/api/auth/login', { username, password }); + await page.evaluate((user) => { + window.localStorage.setItem('currentUser', JSON.stringify(user)); + }, toFrontendUser(data.user)); + return data.user; }; + +export const createUserByApi = ( + request: APIRequestContext, + body: { + username: string; + name: string; + role: 'admin' | 'user'; + department?: string; + departmentId?: string; + visibleTemplates?: string[]; + manageableTemplates?: string[]; + }, +) => + apiRequest<{ user: any }>(request, 'post', '/api/users', { + password: '123456', + status: 'active', + ...body, + }).then((data) => data.user); + +export const createDepartmentByApi = (request: APIRequestContext, name: string, code: string) => + apiRequest<{ department: any }>(request, 'post', '/api/departments', { name, code }).then((data) => data.department); + +export const createReportByApi = ( + request: APIRequestContext, + body: { + title: string; + patientName?: string; + hospitalId?: string; + content?: string; + status?: 'draft' | 'completed'; + }, +) => + apiRequest<{ report: any }>(request, 'post', '/api/reports', { + patientName: 'E2E患者', + hospitalId: uniqueId('H'), + content: '

E2E报告内容

', + status: 'completed', + ...body, + }).then((data) => data.report); diff --git a/e2e/login.spec.ts b/e2e/login.spec.ts index 84235b0..76bc982 100644 --- a/e2e/login.spec.ts +++ b/e2e/login.spec.ts @@ -1,37 +1,11 @@ import { expect, test } from '@playwright/test'; test('default quick login enters dashboard', async ({ page }) => { - await page.route('**/api/auth/me', async (route) => { - await route.fulfill({ - status: 401, - contentType: 'application/json', - body: JSON.stringify({ error: { code: 'UNAUTHORIZED', message: '未登录' } }), - }); - }); - await page.route('**/api/auth/login', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - data: { - user: { - id: 'u-admin', - username: 'admin', - role: 'super', - name: '超级管理员', - tenantId: 'tenant-demo', - departmentId: 'dept-admin', - departmentName: 'admin', - status: 'active', - createdAt: '2026-05-01T00:00:00.000Z', - updatedAt: '2026-05-01T00:00:00.000Z', - }, - }, - }), - }); - }); - await page.goto('/'); + await page.evaluate(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + }); await page.getByText('admin / 123456').click(); diff --git a/e2e/personal-template.spec.ts b/e2e/personal-template.spec.ts index 64dbeea..f55d853 100644 --- a/e2e/personal-template.spec.ts +++ b/e2e/personal-template.spec.ts @@ -1,17 +1,13 @@ import { expect, test } from '@playwright/test'; -import { baseTemplates, baseUsers, seedLocalStorage } from './helpers'; +import { apiRequest, loginByApi, uniqueId } from './helpers'; test('doctor can save current report as a personal template visible only to self', async ({ page }) => { - await seedLocalStorage(page, { - users: baseUsers, - templates: baseTemplates, - currentUser: baseUsers[2], - reports: [], - }); + await loginByApi(page, '0001'); + const templateName = `我的测试模板 ${uniqueId('tpl')}`; page.on('dialog', async (dialog) => { if (dialog.type() === 'prompt') { - await dialog.accept('我的测试模板'); + await dialog.accept(templateName); return; } await dialog.accept(); @@ -22,11 +18,9 @@ test('doctor can save current report as a personal template visible only to self await page.getByRole('button', { name: '保存为我的模板' }).click(); await expect.poll(async () => { - return page.evaluate(() => { - const templates = JSON.parse(window.localStorage.getItem('templates') || '[]'); - return templates.some((template: any) => template.name === '我的测试模板' && template.scope === 'personal' && template.ownerUser === '0001'); - }); + const data = await apiRequest<{ items: any[] }>(page.request, 'get', '/api/templates?access=use'); + return data.items.some((template) => template.name === templateName && template.scope === 'personal' && template.ownerUser === '0001'); }).toBe(true); - await expect(page.locator('option:not([disabled])', { hasText: '我的测试模板' })).toHaveCount(1); + await expect(page.locator('option:not([disabled])', { hasText: templateName })).toHaveCount(1); }); diff --git a/e2e/report-permissions.spec.ts b/e2e/report-permissions.spec.ts index 8d0eff7..96e0bf4 100644 --- a/e2e/report-permissions.spec.ts +++ b/e2e/report-permissions.spec.ts @@ -1,82 +1,60 @@ import { expect, test } from '@playwright/test'; -import { baseTemplates, baseUsers, seedLocalStorage } from './helpers'; - -const reports = [ - { - id: 'RPT_SURGERY_SELF', - title: '外科本人报告', - patientName: '患者甲', - hospitalId: 'H001', - department: '外科', - content: '

外科本人报告

', - author: '0001', - authorName: '张医生', - createdAt: '2026-05-01', - status: 'completed', - revision: 1, - }, - { - id: 'RPT_SURGERY_OTHER', - title: '外科他人报告', - patientName: '患者乙', - hospitalId: 'H002', - department: '外科', - content: '

外科他人报告

', - author: '0003', - authorName: '王医生', - createdAt: '2026-05-01', - status: 'completed', - revision: 1, - }, - { - id: 'RPT_INTERNAL', - title: '内科报告', - patientName: '患者丙', - hospitalId: 'H003', - department: '内科', - content: '

内科报告

', - author: '0002', - authorName: '李医生', - createdAt: '2026-05-01', - status: 'completed', - revision: 1, - }, -]; +import { + createDepartmentByApi, + createReportByApi, + createUserByApi, + loginByApi, + uniqueId, +} from './helpers'; test('admin only sees department reports, doctor only sees own reports, super sees all', async ({ page }) => { - await seedLocalStorage(page, { - users: baseUsers, - templates: baseTemplates, - reports, - currentUser: baseUsers[1], + const suffix = uniqueId('perm'); + const ownTitle = `外科本人报告 ${suffix}`; + const otherSurgeryTitle = `外科他人报告 ${suffix}`; + const internalTitle = `内科报告 ${suffix}`; + + await loginByApi(page, 'admin'); + const internalDepartment = await createDepartmentByApi(page.request, `内科E2E ${suffix}`, `internal_${suffix}`); + const internalAdmin = await createUserByApi(page.request, { + username: `internal_admin_${suffix}`, + name: '内科E2E管理员', + role: 'admin', + departmentId: internalDepartment.id, }); + const otherSurgeryDoctor = await createUserByApi(page.request, { + username: `surgery_doctor_${suffix}`, + name: '外科E2E医生', + role: 'user', + department: '外科', + }); + + await loginByApi(page, '0001'); + await createReportByApi(page.request, { title: ownTitle, content: `

${ownTitle}

` }); + + await loginByApi(page, otherSurgeryDoctor.username); + await createReportByApi(page.request, { title: otherSurgeryTitle, content: `

${otherSurgeryTitle}

` }); + + await loginByApi(page, internalAdmin.username); + await createReportByApi(page.request, { title: internalTitle, content: `

${internalTitle}

` }); + + await loginByApi(page, 'manager'); await page.goto('/report-manage'); - await expect(page.getByText('外科本人报告')).toBeVisible(); - await expect(page.getByText('外科他人报告')).toBeVisible(); - await expect(page.getByText('内科报告')).not.toBeVisible(); + await expect(page.getByText(ownTitle)).toBeVisible(); + await expect(page.getByText(otherSurgeryTitle)).toBeVisible(); + await expect(page.getByText(internalTitle)).not.toBeVisible(); - await seedLocalStorage(page, { - users: baseUsers, - templates: baseTemplates, - reports, - currentUser: baseUsers[2], - }); + await loginByApi(page, '0001'); await page.goto('/report-manage'); - await expect(page.getByText('外科本人报告')).toBeVisible(); - await expect(page.getByText('外科他人报告')).not.toBeVisible(); - await expect(page.getByText('内科报告')).not.toBeVisible(); + await expect(page.getByText(ownTitle)).toBeVisible(); + await expect(page.getByText(otherSurgeryTitle)).not.toBeVisible(); + await expect(page.getByText(internalTitle)).not.toBeVisible(); - await seedLocalStorage(page, { - users: baseUsers, - templates: baseTemplates, - reports, - currentUser: baseUsers[0], - }); + await loginByApi(page, 'admin'); await page.goto('/report-manage'); - await expect(page.getByText('外科本人报告')).toBeVisible(); - await expect(page.getByText('外科他人报告')).toBeVisible(); - await expect(page.getByText('内科报告')).toBeVisible(); + await expect(page.getByText(ownTitle)).toBeVisible(); + await expect(page.getByText(otherSurgeryTitle)).toBeVisible(); + await expect(page.getByText(internalTitle)).toBeVisible(); }); diff --git a/e2e/report-revision.spec.ts b/e2e/report-revision.spec.ts index 963a324..99992b9 100644 --- a/e2e/report-revision.spec.ts +++ b/e2e/report-revision.spec.ts @@ -1,39 +1,27 @@ import { expect, test } from '@playwright/test'; -import { baseTemplates, baseUsers, seedLocalStorage } from './helpers'; +import { apiRequest, createReportByApi, loginByApi, uniqueId } from './helpers'; test('editing a completed report increments revision and preserves history', async ({ page }) => { - await seedLocalStorage(page, { - users: baseUsers, - templates: baseTemplates, - currentUser: baseUsers[2], - reports: [ - { - id: 'RPT_DONE', - title: '已完成报告', - patientName: '患者甲', - hospitalId: 'H001', - department: '外科', - content: '

旧报告内容

', - author: '0001', - authorName: '张医生', - createdAt: '2026-05-01', - updatedAt: '2026-05-01T08:00:00.000Z', - status: 'completed', - revision: 1, - history: [], - }, - ], + await loginByApi(page, '0001'); + const title = `已完成报告 ${uniqueId('revision')}`; + const created = await createReportByApi(page.request, { + title, + patientName: '患者甲', + hospitalId: uniqueId('H'), + content: '

旧报告内容

', + status: 'completed', }); - await page.goto('/report-editor?id=RPT_DONE'); - await expect(page.getByText('编辑报告: RPT_DONE')).toBeVisible(); + await page.goto(`/report-editor?id=${created.id}`); + await expect(page.getByText(`编辑报告: ${created.id}`)).toBeVisible(); + await page.getByRole('textbox', { name: '患者姓名' }).fill('患者甲'); + await page.getByRole('textbox', { name: '住院号' }).fill(created.hospitalId); await page.getByRole('button', { name: '完成报告' }).click(); await page.waitForURL('**/report-manage'); - const report = await page.evaluate(() => { - return JSON.parse(window.localStorage.getItem('reports') || '[]')[0]; - }); + const data = await apiRequest<{ report: any }>(page.request, 'get', `/api/reports/${created.id}`); + const report = data.report; expect(report.revision).toBe(2); expect(report.history).toHaveLength(1); diff --git a/package.json b/package.json index b4610d6..b52b121 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite --port=3001 --host=0.0.0.0", - "server:dev": "tsx watch server/src/main.ts", + "server:dev": "npm run server:build && node server/dist/main.js", "server:build": "tsc -p server/tsconfig.json", "server:start": "node server/dist/main.js", "build": "vite build", diff --git a/playwright.config.ts b/playwright.config.ts index 7af878f..40f8ebb 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -8,12 +8,20 @@ export default defineConfig({ baseURL: 'http://localhost:3001', trace: 'on-first-retry', }, - webServer: { - command: 'npm run dev', - url: 'http://localhost:3001', - reuseExistingServer: true, - timeout: 120_000, - }, + webServer: [ + { + command: 'sh -c \'DATABASE_URL="${DATABASE_URL:-postgresql://surclaw:surclaw_dev_password@localhost:5433/surclaw?schema=public}" npx prisma migrate deploy --schema server/prisma/schema.prisma && DATABASE_URL="${DATABASE_URL:-postgresql://surclaw:surclaw_dev_password@localhost:5433/surclaw?schema=public}" npm run prisma:seed && API_PORT=3100 DATABASE_URL="${DATABASE_URL:-postgresql://surclaw:surclaw_dev_password@localhost:5433/surclaw?schema=public}" npm run server:dev\'', + url: 'http://localhost:3100/api/health', + reuseExistingServer: true, + timeout: 120_000, + }, + { + command: 'VITE_API_PROXY_TARGET=http://localhost:3100 npm run dev', + url: 'http://localhost:3001', + reuseExistingServer: true, + timeout: 120_000, + }, + ], projects: [ { name: 'chromium', diff --git a/server/src/audit/audit.controller.ts b/server/src/audit/audit.controller.ts new file mode 100644 index 0000000..7541498 --- /dev/null +++ b/server/src/audit/audit.controller.ts @@ -0,0 +1,19 @@ +import { Controller, Get, Query, Req } from '@nestjs/common'; +import type { Request } from 'express'; +import { AuthService } from '../auth/auth.service.js'; +import { getSessionUser } from '../auth/session-user.js'; +import { AuditService } from './audit.service.js'; + +@Controller('audit-logs') +export class AuditController { + constructor( + private readonly authService: AuthService, + private readonly auditService: AuditService, + ) {} + + @Get() + async list(@Req() request: Request, @Query() query: unknown) { + const actor = await getSessionUser(request, this.authService); + return { data: await this.auditService.list(actor, query) }; + } +} diff --git a/server/src/audit/audit.module.ts b/server/src/audit/audit.module.ts index 234bc79..eb96d25 100644 --- a/server/src/audit/audit.module.ts +++ b/server/src/audit/audit.module.ts @@ -1,10 +1,13 @@ import { Global, Module } from '@nestjs/common'; import { PrismaModule } from '../prisma/prisma.module.js'; +import { AuthModule } from '../auth/auth.module.js'; +import { AuditController } from './audit.controller.js'; import { AuditService } from './audit.service.js'; @Global() @Module({ - imports: [PrismaModule], + imports: [PrismaModule, AuthModule], + controllers: [AuditController], providers: [AuditService], exports: [AuditService], }) diff --git a/server/src/audit/audit.schemas.ts b/server/src/audit/audit.schemas.ts new file mode 100644 index 0000000..c964dd3 --- /dev/null +++ b/server/src/audit/audit.schemas.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const listAuditLogsQuerySchema = z.object({ + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(50), + action: z.string().trim().optional(), + targetType: z.string().trim().optional(), + actor: z.string().trim().optional(), +}); + +export type ListAuditLogsQuery = z.infer; diff --git a/server/src/audit/audit.service.ts b/server/src/audit/audit.service.ts index 274a2f7..14079c8 100644 --- a/server/src/audit/audit.service.ts +++ b/server/src/audit/audit.service.ts @@ -1,7 +1,8 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; import type { Prisma } from '@prisma/client'; import type { SafeUser } from '../auth/auth.types.js'; import { PrismaService } from '../prisma/prisma.service.js'; +import { listAuditLogsQuerySchema } from './audit.schemas.js'; export interface AuditInput { actor?: SafeUser | null; @@ -18,6 +19,84 @@ export interface AuditInput { export class AuditService { constructor(private readonly prisma: PrismaService) {} + async list(actor: SafeUser, rawQuery: unknown) { + if (actor.role === 'doctor') { + throw new ForbiddenException('无权查看审计日志'); + } + + const parsed = listAuditLogsQuerySchema.safeParse(rawQuery); + if (!parsed.success) { + throw new BadRequestException(parsed.error.issues.map((issue) => issue.message).join(';')); + } + const query = parsed.data; + const where: Prisma.AuditLogWhereInput = { + tenantId: actor.tenantId, + }; + + if (actor.role === 'admin') { + where.OR = [ + { departmentId: actor.departmentId }, + { actorUserId: actor.id }, + ]; + } + + if (query.action) { + where.action = query.action; + } + if (query.targetType) { + where.targetType = query.targetType; + } + if (query.actor) { + where.actor = { + OR: [ + { username: { contains: query.actor, mode: 'insensitive' } }, + { name: { contains: query.actor, mode: 'insensitive' } }, + ], + }; + } + + const skip = (query.page - 1) * query.pageSize; + const [items, total] = await Promise.all([ + this.prisma.auditLog.findMany({ + where, + include: { + actor: { + select: { + id: true, + username: true, + name: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + skip, + take: query.pageSize, + }), + this.prisma.auditLog.count({ where }), + ]); + + return { + items: items.map((item) => ({ + id: item.id, + actorUserId: item.actorUserId, + actorUsername: item.actor?.username ?? null, + actorName: item.actor?.name ?? null, + actorRole: item.actorRole, + action: item.action, + targetType: item.targetType, + targetId: item.targetId, + departmentId: item.departmentId, + ip: item.ip, + userAgent: item.userAgent, + metadata: item.metadata, + createdAt: item.createdAt.toISOString(), + })), + total, + page: query.page, + pageSize: query.pageSize, + }; + } + async record(input: AuditInput) { const actor = input.actor || null; const tenantId = actor?.tenantId; diff --git a/server/src/database.integration.test.ts b/server/src/database.integration.test.ts index 93bad74..ba705e6 100644 --- a/server/src/database.integration.test.ts +++ b/server/src/database.integration.test.ts @@ -171,6 +171,10 @@ describe('Prisma-backed service integration', () => { expect(managerStats.totalCount).toBeGreaterThanOrEqual(1); expect(managerStats.trend).toHaveLength(7); expect(managerStats.templateCount).toBeGreaterThanOrEqual(0); + + const auditList = await auditService.list(superActor, { action: 'report.complete', page: 1, pageSize: 10 }); + expect(auditList.items.map((item) => item.targetId)).toContain(ownReport.id); + await expect(auditService.list(doctorActor, {})).rejects.toThrow('无权查看审计日志'); }); it('stores department and personal templates with real permission filtering', async () => { diff --git a/server/src/prisma/prisma.module.ts b/server/src/prisma/prisma.module.ts index efb7a8d..4e86982 100644 --- a/server/src/prisma/prisma.module.ts +++ b/server/src/prisma/prisma.module.ts @@ -1,8 +1,10 @@ import { Global, Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { PrismaService } from './prisma.service.js'; @Global() @Module({ + imports: [ConfigModule], providers: [PrismaService], exports: [PrismaService], }) diff --git a/server/src/prisma/prisma.service.ts b/server/src/prisma/prisma.service.ts index 57a7bfc..e79bd19 100644 --- a/server/src/prisma/prisma.service.ts +++ b/server/src/prisma/prisma.service.ts @@ -8,8 +8,8 @@ import { Pool } from 'pg'; export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { private readonly pool: Pool; - constructor(configService: ConfigService) { - const connectionString = configService.get('DATABASE_URL'); + constructor(configService?: ConfigService) { + const connectionString = configService?.get('DATABASE_URL') ?? process.env.DATABASE_URL; if (!connectionString) { throw new Error('DATABASE_URL is required to start the API server'); } diff --git a/src/App.tsx b/src/App.tsx index cc9423d..574c3e6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,9 +8,11 @@ import ReportView from './pages/ReportView'; import TemplateManage from './pages/TemplateManage'; import UserManage from './pages/UserManage'; import SystemSettings from './pages/SystemSettings'; +import AuditLogs from './pages/AuditLogs'; import { AuthProvider, useAuth } from './auth/AuthContext'; +import type { User } from './types'; -function RequireAuth({ children }: { children: ReactElement }) { +function RequireAuth({ children, roles }: { children: ReactElement; roles?: User['role'][] }) { const { user, isLoading } = useAuth(); if (isLoading && !user) { @@ -21,6 +23,10 @@ function RequireAuth({ children }: { children: ReactElement }) { return ; } + if (roles && !roles.includes(user.role)) { + return ; + } + return children; } @@ -34,8 +40,9 @@ export default function App() { } /> } /> } /> - } /> - } /> + } /> + } /> + } /> } /> } /> diff --git a/src/api/audit.test.ts b/src/api/audit.test.ts new file mode 100644 index 0000000..751f802 --- /dev/null +++ b/src/api/audit.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest'; +import { listAuditLogs } from './audit'; + +describe('audit api', () => { + it('loads audit logs with query params', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: () => Promise.resolve({ + data: { + items: [{ id: 'log-1', action: 'report.create', targetType: 'Report', createdAt: '2026-05-02T00:00:00.000Z' }], + total: 1, + page: 2, + pageSize: 20, + }, + }), + text: () => Promise.resolve(''), + } as Response); + + await expect(listAuditLogs({ page: 2, pageSize: 20, action: 'report.create' })).resolves.toMatchObject({ + total: 1, + page: 2, + }); + expect(fetch).toHaveBeenCalledWith('/api/audit-logs?page=2&pageSize=20&action=report.create', expect.objectContaining({ credentials: 'include' })); + }); +}); diff --git a/src/api/audit.ts b/src/api/audit.ts new file mode 100644 index 0000000..e57f1ec --- /dev/null +++ b/src/api/audit.ts @@ -0,0 +1,47 @@ +import { apiRequest } from './client'; + +export interface AuditLogItem { + id: string; + actorUserId?: string | null; + actorUsername?: string | null; + actorName?: string | null; + actorRole?: string | null; + action: string; + targetType: string; + targetId?: string | null; + departmentId?: string | null; + ip?: string | null; + userAgent?: string | null; + metadata?: unknown; + createdAt: string; +} + +export interface AuditLogListResponse { + items: AuditLogItem[]; + total: number; + page: number; + pageSize: number; +} + +export interface AuditLogQuery { + page?: number; + pageSize?: number; + action?: string; + targetType?: string; + actor?: string; +} + +export const listAuditLogs = async (query: AuditLogQuery = {}) => { + const params = new URLSearchParams(); + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined && value !== '') { + params.set(key, String(value)); + } + }); + const suffix = params.toString() ? `?${params}` : ''; + const response = await apiRequest(`/api/audit-logs${suffix}`); + if (!response || !Array.isArray(response.items)) { + throw new Error('Invalid audit logs response'); + } + return response; +}; diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx index 842e868..8f124b7 100644 --- a/src/components/Sidebar.test.tsx +++ b/src/components/Sidebar.test.tsx @@ -23,6 +23,7 @@ describe('Sidebar permissions', () => { expect(screen.getByText('模板管理')).toBeInTheDocument(); expect(screen.getByText('用户管理')).toBeInTheDocument(); + expect(screen.getByText('审计日志')).toBeInTheDocument(); expect(screen.getByText('系统设置')).toBeInTheDocument(); }); @@ -32,6 +33,7 @@ describe('Sidebar permissions', () => { expect(screen.getByText('工作台')).toBeInTheDocument(); expect(screen.queryByText('模板管理')).not.toBeInTheDocument(); expect(screen.queryByText('用户管理')).not.toBeInTheDocument(); + expect(screen.queryByText('审计日志')).not.toBeInTheDocument(); expect(screen.getByText('系统设置')).toBeInTheDocument(); }); }); diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 82bf17c..f345cbd 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -7,7 +7,8 @@ import { Layout, Users, Settings, - LogOut + LogOut, + ShieldCheck, } from 'lucide-react'; import { User } from '../types'; import { storage } from '../utils/storage'; @@ -35,6 +36,7 @@ export default function Sidebar() { { path: '/report-manage', icon: , title: '报告管理', roles: ['super', 'admin', 'user'] }, { path: '/template-manage', icon: , title: '模板管理', roles: ['super', 'admin'] }, { path: '/user-manage', icon: , title: '用户管理', roles: ['super', 'admin'] }, + { path: '/audit-logs', icon: , title: '审计日志', roles: ['super', 'admin'] }, { path: '/system-settings', icon: , title: '系统设置', roles: ['super', 'admin', 'user'] }, ]; diff --git a/src/pages/AuditLogs.tsx b/src/pages/AuditLogs.tsx new file mode 100644 index 0000000..620d57b --- /dev/null +++ b/src/pages/AuditLogs.tsx @@ -0,0 +1,223 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import Sidebar from '../components/Sidebar'; +import { listAuditLogs, type AuditLogItem } from '../api/audit'; +import { Search, ShieldCheck } from 'lucide-react'; + +const actionLabels: Record = { + 'auth.login': '登录', + 'auth.logout': '退出', + 'report.create': '创建报告', + 'report.complete': '完成报告', + 'report.update': '更新报告', + 'report.delete': '删除报告', + 'template.create': '创建模板', + 'template.update': '更新模板', + 'template.delete': '删除模板', + 'user.create': '创建用户', + 'user.update': '更新用户', + 'user.delete': '删除用户', + 'department.create': '创建部门', + 'department.update': '更新部门', + 'department.delete': '删除部门', + 'department.template_permissions.update': '更新部门模板授权', + 'settings.system.update': '更新系统设置', + 'settings.default_template.update': '更新默认模板', + 'settings.system.reset': '重置系统设置', + 'file.upload': '上传文件', + 'file.delete': '删除文件', + 'user.signature.upload': '上传签名', + 'user.signature.delete': '删除签名', +}; + +const targetLabels: Record = { + User: '用户', + Report: '报告', + Template: '模板', + Department: '部门', + SystemSetting: '系统设置', + FileResource: '文件', +}; + +const formatTime = (iso: string) => { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + const pad = (value: number) => String(value).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`; +}; + +const describeMetadata = (metadata: unknown) => { + if (!metadata || typeof metadata !== 'object') return '-'; + const entries = Object.entries(metadata as Record) + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .slice(0, 4); + if (!entries.length) return '-'; + return entries.map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(', ') : String(value)}`).join(';'); +}; + +export default function AuditLogs() { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [action, setAction] = useState(''); + const [targetType, setTargetType] = useState(''); + const [actor, setActor] = useState(''); + const [pendingActor, setPendingActor] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + const pageSize = 20; + + useEffect(() => { + setIsLoading(true); + setError(''); + void listAuditLogs({ page, pageSize, action, targetType, actor }) + .then((response) => { + setItems(response.items); + setTotal(response.total); + }) + .catch((err) => { + setError(err instanceof Error ? err.message : '审计日志加载失败'); + setItems([]); + setTotal(0); + }) + .finally(() => setIsLoading(false)); + }, [action, actor, page, targetType]); + + const totalPages = useMemo(() => Math.max(1, Math.ceil(total / pageSize)), [total]); + + const submitActorSearch = (event: React.FormEvent) => { + event.preventDefault(); + setPage(1); + setActor(pendingActor.trim()); + }; + + const resetFilters = () => { + setAction(''); + setTargetType(''); + setActor(''); + setPendingActor(''); + setPage(1); + }; + + return ( +
+ +
+
+
+

审计日志

+

查看登录、报告、模板、用户、设置和文件等关键操作记录。

+
+
+ + 共 {total} 条 +
+
+ +
+ + + + +
+ + setPendingActor(event.target.value)} + placeholder="搜索操作者" + className="input-field pl-9 w-56" + /> + + + +
+ +
+ + + + + + + + + + + + {items.map((item) => ( + + + + + + + + ))} + {!isLoading && items.length === 0 && ( + + + + )} + {isLoading && ( + + + + )} + +
时间操作者操作对象详情
{formatTime(item.createdAt)} +
{item.actorName || item.actorUsername || '系统'}
+
{item.actorUsername || item.actorRole || '-'}
+
+ + {actionLabels[item.action] || item.action} + + +
{targetLabels[item.targetType] || item.targetType}
+
{item.targetId || '-'}
+
+ {describeMetadata(item.metadata)} +
+ {error || '暂无审计日志'} +
正在加载...
+
+ + +
+
+ ); +}