Initialize backendized SurClaw report system

- Add React/Vite frontend for login, dashboard, reports, templates, users, settings, AI, speech, and media workflows.

- Add NestJS/Prisma/PostgreSQL backend with auth, dashboard stats, reports, templates, users, departments, settings, files, AI, speech, audit logs, and HTML sanitization.

- Add Prisma schema, migrations, seed data, persistent app sessions, Docker/Nginx deployment files, and upload volume configuration.

- Add Vitest, Playwright, backend integration tests, and project documentation for requirements, design, permissions, API contracts, testing, deployment, security, and progress.

- Configure production local fallback switch and remove unused Gemini direct dependency/env wiring.
This commit is contained in:
2026-05-02 01:37:20 +08:00
commit 014aca8619
162 changed files with 27116 additions and 0 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
dist
server/dist
coverage
test-results
playwright-report
.git
.env
.env.*
!.env.example
*.log

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
# Backend API development defaults.
API_PORT=3000
CORS_ORIGIN="http://localhost:3001"
DATABASE_URL="postgresql://surclaw:surclaw_dev_password@localhost:5433/surclaw?schema=public"
SESSION_SECRET="change-me-in-production"
SESSION_COOKIE_SECURE="false"
FILE_STORAGE_DIR="./uploads"
VITE_API_PROXY_TARGET="http://localhost:3000"
VITE_ENABLE_LOCAL_FALLBACK="true"

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
node_modules/
build/
dist/
server/dist/
coverage/
test-results/
playwright-report/
.DS_Store
*.log
.env*
!.env.example

369
AGENTS.md Normal file
View File

@@ -0,0 +1,369 @@
# AI 编码助手项目说明
本文件面向 AI 编码助手。阅读者应假设对该项目一无所知。以下信息基于当前仓库实际文件、脚本和源码;不要把早期设计目标当作已实现事实。任何代码和功能修改都要落实到文档和测试上。如果生成 git commit 信息,要逐个列点把所有修改都列上,重要的、大的修改放前面,不重要的、小的修改列在后面。
## 项目一句话
这是一个“手术图文病历报告系统”。当前登录认证已接入 NestJS Session API、PostgreSQL 用户表和数据库 Session Store工作台统计、报告、报告媒体、模板、字段库、模板图片资源、视频/关键帧文件、用户、部门权限、系统设置、签名文件、AI 对话和讯飞语音听写已优先接入后端 API/代理。开发模式仍保留本地兼容回退,不能把前端权限和本地缓存当作生产安全边界。
## 当前技术栈
- React 19
- TypeScript 5.8
- Vite 6
- Tailwind CSS 4
- React Router DOM 7
- Lucide React
- Vitest + jsdom + Testing Library
- Playwright
- NestJS + Prisma + PostgreSQL 后端骨架
- Docker + Nginx
## 运行脚本
```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:build # tsc -p server/tsconfig.json
npm run lint # tsc --noEmit
npm run test # vitest run
npm run test:e2e # playwright test
npm run build # vite build
npm run prisma:generate
npm run prisma:migrate
npm run prisma:seed
```
修改代码后至少运行:
```bash
npm run lint
npm run test
```
如果改动构建、依赖、路由、样式或部署相关内容,也运行:
```bash
npm run build
```
如果改动后端源码、Prisma schema、认证、权限或 API 契约,也运行:
```bash
npm run server:build
npm run prisma:generate
```
后端化、权限或关键用户流程变更时也运行:
```bash
npm run test:e2e
```
## 默认账号
| 用户ID | 密码 | 角色 |
| --- | --- | --- |
| `admin` | `123456` | 超级管理员 |
| `manager` | `123456` | 管理员 |
| `0001` | `123456` | 医生 |
默认数据由 `src/pages/Login.tsx` 首次进入时初始化。
## 主要业务脉络
1. 用户进入 `/` 登录页。
2. 登录页初始化默认用户、模板、字段、图片资源和系统设置。
3. 登录页通过 `AuthContext` 调用后端 `/api/auth/login`,后端写入 HttpOnly Session Cookie。
4. 登录成功后前端把后端安全用户 DTO 映射成兼容 `User`,写入 `localStorage.currentUser`,跳转到 `/dashboard`
5. `AuthProvider` 启动时通过 `/api/auth/me` 恢复会话;侧边栏优先读取 Auth Context回退 `currentUser.role` 展示不同菜单。
6. 报告编辑器使用模板 HTML 生成报告正文,通过 `data-bind` 字段把侧边表单和正文智能字段同步。
7. 视频上传后使用浏览器 `<video>``<canvas>` 预览/抽帧,同时优先通过 `/api/files` 上传视频和关键帧文件,关键帧可插入报告图片占位符。
8. AI 面板把消息和图片上下文发送到 `/api/ai/chat`,后端读取系统设置中的共用 OpenAI 兼容 Provider 并代理调用 `/chat/completions`,返回内容可注入 AI 可编辑区域。
9. 语音听写把麦克风音频发送到 `/api/speech/iat` WebSocket后端读取系统设置中的讯飞配置、生成鉴权 URL 并转发讯飞 IAT 结果。
10. 报告保存优先调用 `POST/PATCH /api/reports`,后端保存到 PostgreSQL仅开发/显式回退模式下 API 不可用时回退 `localStorage.reports`
11. 报告管理页优先调用 `GET /api/reports`,后端按角色过滤报告;前端支持搜索、筛选、历史恢复、打印和 JSON 导出。
12. 模板管理页优先调用 `GET/POST/PATCH/DELETE /api/templates` 管理模板 HTML调用 `/api/library/fields` 维护字段库,并通过 `/api/files` 上传模板图片资源。
13. 用户管理页优先调用 `GET/PATCH /api/users` 等接口维护用户、角色、部门和模板授权;签名图片通过 `/api/users/:id/signature` 上传为后端文件资源。
14. 系统设置页优先调用 `GET/PATCH /api/settings/system` 维护抽帧策略、默认模板、AI Provider 和讯飞语音配置。
## 功能真实性判断
详细清单见 `docs/features.md`。处理需求时应区分:
- 真实可用本地初始化、字段绑定、JSON 导出等。
- 真实集成:后端 Session 登录、Dashboard API、报告 API、模板 API、用户/部门 API、设置 API、签名文件 API、AI 代理、讯飞语音代理、浏览器打印/PDF、视频抽帧。它们有真实代码路径但依赖后端服务、浏览器能力、权限、有效密钥、网络或人工保存。
- 前端演示:页面权限仍以体验控制为主,不能作为生产安全边界。
## 当前安全边界
本项目不能按生产安全系统理解:
- 后端账号使用 Argon2 哈希;开发回退模式下 `localStorage.users` 仍保留兼容缓存和旧演示密码字段。
- 权限判断在前端,不能抵御绕过。
- 报告和模板 HTML 保存时已做服务端白名单清洗;前端仍使用 HTML 渲染,继续修改时要留意 XSS 和打印兼容。
- AI Key 和讯飞语音密钥已由后端代理使用,普通用户读取系统设置时不会返回真实密钥。
- 视频和关键帧文件已优先进入后端文件资源;报告保存时通过 `ReportMedia` 关系表关联,新建报告保存前仍依赖浏览器对象 URL 预览。
- `VITE_ENABLE_LOCAL_FALLBACK` 控制生产构建是否允许本地兼容回退;开发模式默认允许,生产默认关闭。
涉及生产化、后端化、用户化时,优先阅读 `docs/backendization-plan.md``docs/security.md`
## 文件结构
```text
.
├── AGENTS.md # AI 编码助手上下文和协作约定
├── README.md # 面向开发者的项目说明
├── package.json # 依赖与 npm scripts
├── package-lock.json # npm 锁文件
├── prisma.config.ts # Prisma 7 CLI 配置
├── vite.config.ts # Vite/Tailwind/React/Vitest 配置
├── playwright.config.ts # Playwright E2E 配置,默认复用/启动 3001 端口
├── tsconfig.json # TypeScript 配置
├── index.html # Vite HTML 入口
├── e2e/
│ ├── helpers.ts # E2E localStorage 种子数据工具
│ ├── login.spec.ts
│ ├── report-permissions.spec.ts
│ ├── report-revision.spec.ts
│ └── personal-template.spec.ts
├── public/
│ ├── favicon.ico
│ └── logo_square.png
├── src/
│ ├── App.tsx # 路由表
│ ├── main.tsx # React 挂载入口
│ ├── index.css # 全局样式、Tailwind 主题和组件类
│ ├── types.ts # User/Report/Template/SystemSettings/FormField 等类型和默认配置
│ ├── api/
│ │ ├── client.ts # 前端 API client统一 envelope/错误/Cookie
│ │ ├── dashboard.ts # 工作台统计 API 封装
│ │ ├── reports.ts # 报告列表、详情、保存、删除 API 封装
│ │ ├── templates.ts # 模板列表、详情、保存、删除 API 封装
│ │ ├── users.ts # 用户、部门和模板授权 API 封装
│ │ ├── settings.ts # 系统设置 API 封装
│ │ ├── files.ts # 签名文件 API 封装
│ │ ├── library.ts # 字段库 API 封装
│ │ ├── ai.ts # AI 模型列表和对话代理 API 封装
│ │ ├── speech.ts # 讯飞语音 WebSocket 代理地址封装
│ │ └── client.test.ts
│ ├── auth/
│ │ ├── AuthContext.tsx # 登录态、login/logout/me、currentUser 兼容同步
│ │ ├── backendUser.ts # 后端安全用户 DTO 到前端 User 的映射
│ │ └── backendUser.test.ts
│ ├── config/
│ │ └── runtime.ts # 本地兼容回退开关
│ ├── components/
│ │ ├── Sidebar.tsx
│ │ └── Sidebar.test.tsx
│ ├── pages/
│ │ ├── Login.tsx # 登录和默认数据初始化
│ │ ├── Dashboard.tsx # 工作台统计和快捷入口
│ │ ├── ReportEditor.tsx # 核心报告编辑器、抽帧、AI、语音、保存
│ │ ├── ReportManage.tsx # 报告列表、筛选、历史、导出、删除
│ │ ├── ReportView.tsx # 报告查看和打印
│ │ ├── TemplateManage.tsx# 模板、字段库、图片资源、导入导出
│ │ ├── UserManage.tsx # 用户、角色、部门、模板权限、签名
│ │ ├── SystemSettings.tsx# 抽帧、AI、语音、默认模板设置
│ │ ├── Login.test.tsx
│ │ └── ReportManage.test.tsx
│ ├── utils/
│ │ ├── storage.ts # localStorage/sessionStorage 封装
│ │ ├── permissions.ts # 前端角色/报告/模板可见性判断
│ │ ├── defaultContent.ts # 默认手术记录 HTML 模板
│ │ ├── print.ts # iframe 打印工具
│ │ ├── storage.test.ts
│ │ ├── permissions.test.ts
│ │ ├── defaultContent.test.ts
│ │ └── print.test.ts
│ └── test/
│ └── setup.ts # Vitest/jsdom 全局 mock
├── server/
│ ├── prisma/
│ │ ├── schema.prisma # PostgreSQL 数据模型
│ │ └── seed.ts # 默认医院、部门和账号 seed
│ ├── src/
│ │ ├── main.ts # NestJS API 启动入口
│ │ ├── app.module.ts
│ │ ├── audit/ # 审计日志写入服务
│ │ ├── auth/ # 登录、me、logout 接口
│ │ ├── dashboard/ # 工作台统计 API
│ │ ├── reports/ # 报告 API、DTO、metadata 映射和测试
│ │ ├── templates/ # 模板 API、DTO、权限映射和测试
│ │ ├── users/ # 用户、部门和模板授权 API、DTO 映射和测试
│ │ ├── settings/ # 系统设置 API 和 schema 测试
│ │ ├── files/ # 签名文件上传和受控读取 API
│ │ ├── library/ # 字段库 API、schema 和测试
│ │ ├── ai/ # OpenAI 兼容模型列表和对话代理 API
│ │ ├── speech/ # 讯飞 IAT WebSocket 代理和帧处理测试
│ │ ├── health/ # GET /api/health
│ │ ├── permissions/ # 后端权限策略和测试
│ │ ├── prisma/ # PrismaService
│ │ └── session/ # express-session 的 Prisma Store
│ └── tsconfig.json
├── docs/
│ ├── README.md
│ ├── requirements.md
│ ├── design.md
│ ├── permissions.md
│ ├── api-contract.md
│ ├── features.md
│ ├── testing.md
│ ├── data-storage.md
│ ├── deployment.md
│ ├── security.md
│ ├── progress.md
│ ├── backendization-plan.md
│ └── modules/
│ ├── auth-and-users.md
│ ├── report-editor.md
│ ├── report-management.md
│ ├── template-management.md
│ └── system-settings.md
├── Dockerfile
├── Dockerfile.server
├── docker-compose.yaml
└── nginx.conf
```
`node_modules/``dist/` 是生成物或依赖目录,不应作为源码结构维护。
## 关键源码说明
### `src/types.ts`
定义核心数据结构:
- `User`
- `Report`
- `CapturedFrame`
- `Template`
- `SystemSettings`
- `AiProviderConfig`
- `XfSpeechConfig`
- `FormField`
也包含默认字段、默认 AI Provider 和默认讯飞配置。
### `src/utils/storage.ts`
封装浏览器存储。常规键使用 JSON`systemSettings` 做 XOR + Base64 混淆并兼容历史明文 JSON。注意这不是安全加密。
### `src/utils/permissions.ts`
集中封装当前前端权限判断。报告范围规则为:医生看本人报告,管理员看本部门报告,超级管理员和超时管理员看全部报告。模板范围规则为:超级管理员/超时管理员可见全部模板,医生和管理员可见本部门模板以及本人个人模板。
### `src/utils/defaultContent.ts`
默认报告模板 HTML。包含
- `.field-value[data-bind]` 智能字段
- `.image-placeholder` 图片占位符
- `.ai-region[data-ai-id]` AI 可编辑区域
### `src/pages/ReportEditor.tsx`
最大、最复杂的文件。包含:
- 报告表单状态
- `contentEditable` 富文本正文
- 字段双向同步
- 图片占位符填充
- 视频上传、自动抽帧、手动截帧
- AI 对话和 AI 区域改写
- 讯飞语音听写
- 草稿保存和恢复
- 报告保存、完成、历史记录
- 打印和导出
修改它时要非常小心,优先补测试或文档说明。
### `src/api/client.ts`
统一前端 API 请求封装:
- 默认请求相对路径 `/api/...`
- Vite 开发模式通过 `VITE_API_PROXY_TARGET` 代理 API默认 `http://localhost:3000`
- 请求带 `credentials: 'include'`,用于后端 HttpOnly Session Cookie。
- 解包后端 `{ data }` 响应,错误时抛出 `ApiError`
### `src/auth/AuthContext.tsx` 和 `src/auth/backendUser.ts`
前端认证入口。`AuthProvider` 提供 `login``logout``refresh`,并在登录或恢复会话后把后端用户同步成当前前端 `User`
- 后端 `doctor` 映射为前端 `user`
- 后端 `departmentName` 映射为前端 `department`
- 保留本地签名等迁移期字段;模板授权优先来自后端部门权限。
### `server/src`
后端第一阶段骨架。当前已实现健康检查、认证接口、报告 API、报告媒体关系、模板 API、字段库 API、用户/部门 API、设置 API、通用文件/签名文件 API、AI 代理、讯飞语音代理、PrismaService 和权限策略前端登录、报告、模板、字段库、模板图片资源、用户管理、系统设置、签名上传、AI 对话和语音听写已接入后端接口/代理。新增后端功能时优先按模块拆分:`auth``reports``templates``library``users``files``settings``ai``speech`
### `server/prisma/schema.prisma`
PostgreSQL 数据模型。当前覆盖 `Tenant``Department``User``UserSession``AppSession``Report``ReportMedia``ReportHistory``Template`、模板部门授权、`FileResource``SystemSetting``AuditLog`。Prisma 7 连接配置在根目录 `prisma.config.ts` 中。
## 数据存储键
详见 `docs/data-storage.md`。常见键:
- `currentUser`
- `users`
- `templates`
- `reports`
- `systemSettings`
- `formFieldsConfig`
- `imageAssets`
- `customTimeFormats`
- `multiSelectOptions`
- `anesthesiaOptions`
- `reportEditorDraft_${username}`
- `sessionStorage.restore_${reportId}`
## 测试现状
当前测试覆盖:
- 登录默认初始化和后端禁用账号错误展示
- 前端 API client 响应/错误处理、Dashboard API 封装和语音代理地址生成
- 后端用户 DTO 到前端兼容用户映射
- 角色菜单过滤
- 报告管理按角色过滤
- 管理员本部门报告范围和医生本人报告范围
- 模板可用范围,含部门模板和医生个人模板
- Playwright E2E 覆盖登录、报告权限、报告修订版本和医生个人模板
- 后端权限策略覆盖报告、模板、用户管理和管理员创建规则
- 后端 Dashboard 统计按角色范围过滤
- 后端报告 metadata 兼容映射和 `ReportMedia` 视频/关键帧组装
- 后端模板 DTO 和权限资源映射
- 后端用户 DTO 和部门模板授权映射
- 后端系统设置 schema 校验
- 后端 AI 入参和讯飞语音代理帧处理
- 后端 HTTP 集成测试覆盖 API prefix、登录 session、受保护 API actor 传递
- 后端真实 PostgreSQL 集成测试覆盖 Auth、Dashboard、Reports、ReportMedia、Templates、Files、HTML 清洗和审计核心服务
- 存储封装
- 默认模板结构和字段契约
- 打印入口
测试文档见 `docs/testing.md`
## 文档维护规则
如果改动功能、数据结构、脚本、部署、安全边界或测试:
- 更新对应 `docs/` 文档。
- 如功能真实性变化,更新 `docs/features.md`
- 如后端化方向变化,更新 `docs/backendization-plan.md`
- 如新增/修改测试,更新 `docs/testing.md`
- 如运行方式、端口、账号、部署变化,更新 `README.md`
## 开发注意事项
- 优先使用现有风格和现有工具,不要引入大框架替换,除非任务明确需要。
- 不要把前端权限当作安全边界。
- 不要继续扩大前端内置密钥的使用面。
- 不要把大量 Base64 图片或视频数据长期作为生产方案。
- 报告/模板 HTML 改动要考虑 XSS、打印样式、字段绑定和历史兼容。
- 当前 `document.execCommand` 虽然过时,但编辑器依赖它;替换编辑器需要单独规划。
- 生成物 `dist/` 和依赖目录 `node_modules/` 不要手工维护。

14
Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

21
Dockerfile.server Normal file
View File

@@ -0,0 +1,21 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run prisma:generate
RUN npm run server:build
# Production stage
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/server/dist ./server/dist
COPY --from=builder /app/server/prisma ./server/prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
EXPOSE 3000
CMD ["node", "server/dist/main.js"]

257
README.md Normal file
View File

@@ -0,0 +1,257 @@
# 手术图文病历报告系统
手术图文病历报告系统是一个面向医院/科室场景的前端应用,用于撰写手术图文报告、管理报告模板、维护用户权限、从手术视频抽取关键帧,并通过 AI 辅助生成或改写报告内容。
当前系统已开始后端化:登录认证已接入 NestJS Session API 和数据库 Session Store工作台统计、报告、报告媒体、模板、字段库、模板图片资源、视频/关键帧文件、用户、部门权限、系统设置、签名文件、AI 对话和讯飞语音听写已优先接入后端 API/代理。开发模式仍保留 `localStorage` 兼容回退,生产构建默认关闭本地回退。
## 功能概览
- 登录和角色导航:后端 Session 登录,前端按超级管理员、管理员、医生三类角色展示入口。
- 工作台:后端统计报告数量、用户数量、模板数量和近期报告趋势。
- 图文报告生成:模板化报告正文、智能字段绑定、富文本编辑、图片占位符、报告草稿、完成报告。
- 视频关键帧:本地预览视频、按百分比自动抽帧、手动截帧,视频和关键帧优先上传为后端文件资源后插入报告。
- AI 辅助撰写:通过后端 `/api/ai/chat` 代理 OpenAI 兼容接口,对报告内容进行对话或指定区域改写。
- 语音输入:通过后端 `/api/speech/iat` WebSocket 代理讯飞 IAT 听写,把识别文本写入 AI 输入框。
- 报告管理:后端权限过滤的列表/详情/保存/删除支持搜索、筛选、查看、编辑、历史恢复、打印、JSON 导出。
- 模板管理:后端权限过滤的模板新增/编辑/删除,字段库和模板图片资源优先走后端 API支持 AI 可编辑区域、导入导出。
- 用户管理:后端用户/部门权限 API支持用户、角色、部门、模板授权、账号状态和电子签名文件。
- 系统设置:后端 Settings API支持抽帧策略、默认模板、AI Provider、讯飞语音配置。
更细的功能真实性判断见 [docs/features.md](./docs/features.md)。
## 技术栈
- React 19
- TypeScript 5.8
- Vite 6
- Tailwind CSS 4
- React Router DOM 7
- Lucide React
- Vitest + jsdom + Testing Library
- Playwright
- NestJS + Prisma + PostgreSQL 后端骨架
- Docker + Nginx
## 快速开始
```bash
npm install
cp .env.example .env
npm run prisma:generate
npm run prisma:migrate
npm run prisma:seed
npm run server:dev
npm run dev
```
开发服务默认监听:
```text
http://localhost:3001
```
`npm run dev` 实际使用 `vite --port=3001 --host=0.0.0.0`,局域网内也可以通过机器 IP 访问。
开发模式下 Vite 会把 `/api` 代理到 `VITE_API_PROXY_TARGET`,默认是 `http://localhost:3000`。如果只启动 Docker Compose 中的 API再用本机 Vite 前端调试,可把该变量改成 `http://localhost:3002`
后端 API 开发服务默认监听:
```bash
cp .env.example .env
npm run prisma:generate
npm run server:dev
```
```text
http://localhost:3000/api/health
```
如果需要真实数据库,先启动 PostgreSQL 并配置 `DATABASE_URL`,再执行:
```bash
npm run prisma:migrate
npm run prisma:seed
```
## 默认测试账号
| 用户ID | 密码 | 角色 |
| --- | --- | --- |
| `admin` | `123456` | 超级管理员 |
| `manager` | `123456` | 管理员 |
| `0001` | `123456` | 医生 |
开发模式下,默认数据会在首次进入登录页时初始化到浏览器 `localStorage`,用于离线回退和旧 E2E 基线。
账号认证由后端 `POST /api/auth/login` 完成;登录成功后前端会同步一份 `currentUser``localStorage`,供迁移期页面继续读取角色、部门和模板权限。生产构建默认不通过本地缓存恢复登录态。
## 常用命令
```bash
npm run dev # 启动开发服务器,端口 3001
npm run server:dev # 启动 NestJS API端口 3000
npm run server:build # 构建后端 TypeScript
npm run lint # TypeScript 类型检查
npm run test # Vitest 测试
npm run test:e2e # Playwright 端到端测试
npm run build # 生产构建
npm run preview # 预览构建产物
npm run clean # 删除 dist
npm run prisma:generate # 生成 Prisma Client
npm run prisma:migrate # 执行 Prisma 开发迁移
npm run prisma:seed # 写入默认医院、部门和账号
```
建议提交前运行:
```bash
npm run lint
npm run test
npm run build
```
## 环境变量
复制示例文件:
```bash
cp .env.example .env.local
```
当前环境变量:
- `API_PORT`NestJS API 监听端口,本地直接运行默认 `3000`Docker Compose 暴露到宿主机的默认端口是 `3002`
- `CORS_ORIGIN`:允许携带 Cookie 访问 API 的前端来源。
- `DATABASE_URL`PostgreSQL 连接串。Docker Compose 暴露到宿主机的默认端口是 `5433`,容器内部仍使用 `db:5432`
- `SESSION_SECRET`:后端 Session Cookie 签名密钥,生产环境必须替换。
- `SESSION_COOKIE_SECURE`:是否只通过 HTTPS 发送 Session Cookie。本地 HTTP/Compose 默认 `false`
- `FILE_STORAGE_DIR`后端文件存储目录。Docker Compose 默认挂载到 `/app/uploads`
- `VITE_API_PROXY_TARGET`Vite 开发服务器的 `/api` 代理目标。直接运行 `npm run server:dev` 时用 `http://localhost:3000`;连接 Docker Compose API 时用 `http://localhost:3002`
- `VITE_ENABLE_LOCAL_FALLBACK`:是否允许生产构建继续使用浏览器本地兼容回退。开发模式默认启用,生产默认关闭。
注意:当前 AI/语音密钥由后端设置读取并在代理中使用,普通用户读取设置时不返回真实密钥。视频和关键帧文件已优先写入后端,报告内媒体引用通过 `ReportMedia` 关系表保存。
## 项目结构
```text
.
├── AGENTS.md # AI 编码助手项目上下文
├── README.md # 项目入口说明
├── package.json # 依赖和脚本
├── vite.config.ts # Vite、React、Tailwind、Vitest 配置
├── prisma.config.ts # Prisma 7 CLI 配置
├── playwright.config.ts # Playwright E2E 配置
├── tsconfig.json # TypeScript 配置
├── index.html # Vite HTML 入口
├── e2e/ # Playwright 端到端测试
├── public/ # favicon、Logo 等静态资源
├── src/
│ ├── App.tsx # 路由定义
│ ├── main.tsx # React 入口
│ ├── index.css # 全局样式和 Tailwind 主题
│ ├── types.ts # 核心类型和默认配置
│ ├── components/ # 公共组件
│ ├── pages/ # 页面模块
│ ├── utils/ # 存储、打印、默认模板等工具
│ └── test/ # 测试初始化
├── server/
│ ├── prisma/ # Prisma schema 和 seed
│ ├── src/ # NestJS API 源码
│ └── tsconfig.json # 后端构建配置
├── docs/ # 需求、设计、功能盘点、测试、后端化方案等文档
├── Dockerfile
├── Dockerfile.server
├── docker-compose.yaml
└── nginx.conf
```
核心页面:
- `src/pages/Login.tsx`:登录和默认数据初始化。
- `src/pages/Dashboard.tsx`:工作台。
- `src/pages/ReportEditor.tsx`报告编辑器、抽帧、AI、语音、保存。
- `src/pages/ReportManage.tsx`:报告列表、筛选、历史和导出。
- `src/pages/ReportView.tsx`:报告查看和打印。
- `src/pages/TemplateManage.tsx`:模板和字段库管理。
- `src/pages/UserManage.tsx`:用户、角色、部门和模板权限。
- `src/pages/SystemSettings.tsx`抽帧、AI、语音和默认模板设置。
## 文档
- [docs/README.md](./docs/README.md):文档入口。
- [docs/requirements.md](./docs/requirements.md):需求文档。
- [docs/design.md](./docs/design.md):设计文档。
- [docs/features.md](./docs/features.md):功能真实性盘点。
- [docs/testing.md](./docs/testing.md):测试文档。
- [docs/data-storage.md](./docs/data-storage.md):本地数据存储说明。
- [docs/security.md](./docs/security.md):安全边界和风险。
- [docs/backendization-plan.md](./docs/backendization-plan.md):后端化、用户化改造方案。
## 测试
当前测试使用 Vitest已覆盖
- 登录页默认数据初始化和后端禁用账号错误展示。
- API client、语音代理地址和后端用户映射。
- 侧边栏角色菜单过滤。
- 报告管理按角色过滤报告。
- 本地存储封装和系统设置兼容。
- 默认报告模板结构和字段配置。
- 打印导出入口。
- 后端权限策略、AI 入参和语音代理帧处理。
运行:
```bash
npm run test
```
更多说明见 [docs/testing.md](./docs/testing.md)。
## Docker 部署
项目内置静态部署配置:
```bash
docker-compose up -d --build
```
默认访问:
```text
前端: http://localhost:4002
API: http://localhost:3002/api/health
数据库: localhost:5433
```
停止:
```bash
docker-compose down
```
部署说明:
- `web` 服务使用 Nginx 托管前端 `dist/`
- `api` 服务运行 NestJS 后端,并把上传文件目录挂载到 `uploads_data` volume。
- `db` 服务运行 PostgreSQL 16。
- `nginx.conf` 已配置 SPA 路由回退和 `/api` 反向代理。
- `nginx.conf` 已支持 `/api/speech/iat` WebSocket upgrade。
## 当前限制
- 前端登录、工作台统计、报告读写、报告媒体引用、模板读写、字段库、模板图片资源、视频/关键帧文件、用户管理、部门模板授权、系统设置、签名文件、AI 对话和语音听写已接入真实后端 Session/API/代理。
- 当前后端已有认证、数据库 Session、健康检查、Dashboard API、报告 API、模板 API、字段库 API、通用文件 API、用户/部门 API、设置 API、签名文件 API、AI 代理、语音代理、权限策略、HTML 清洗、审计日志和数据模型。
- 后端账号使用 Argon2 哈希;开发模式前端仍会初始化 `localStorage.users`,其中保留演示密码字段供旧页面兼容。
- 权限判断主要在前端,不能作为生产安全边界。
- 报告和模板 HTML 保存时已做服务端白名单清洗,但渲染仍使用 HTML需要继续做安全评审。
- AI Key 和讯飞语音密钥已由后端代理使用;普通用户读取设置时不会拿到真实密钥。
- 视频和关键帧已优先上传后端文件资源,报告保存时通过 `ReportMedia` 关系表关联;新建报告保存前仍依赖本地预览对象。
生产化方向见 [docs/backendization-plan.md](./docs/backendization-plan.md)。
## 后续方向
项目下一阶段建议后端化和用户化:
- 继续增强报告媒体模型,例如断点续传、转码、后端抽帧和独立媒体审计。
- 查看日志、第三方代理调用摘要、错误追踪和更细粒度操作审计。报告导出按权限控制,不要求专门导出审计。
- 后端 PDF/JSON 导出、对象存储、限流和备份恢复。

48
docker-compose.yaml Normal file
View File

@@ -0,0 +1,48 @@
services:
db:
image: postgres:16-alpine
container_name: tuwen_db
restart: unless-stopped
environment:
POSTGRES_DB: surclaw
POSTGRES_USER: surclaw
POSTGRES_PASSWORD: surclaw_dev_password
ports:
- "5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
api:
build:
context: .
dockerfile: Dockerfile.server
container_name: tuwen_api
restart: unless-stopped
environment:
API_PORT: 3000
CORS_ORIGIN: http://localhost:4002,http://localhost:3001
DATABASE_URL: postgresql://surclaw:surclaw_dev_password@db:5432/surclaw?schema=public
SESSION_SECRET: change-me-in-production
SESSION_COOKIE_SECURE: "false"
FILE_STORAGE_DIR: /app/uploads
ports:
- "3002:3000"
depends_on:
- db
volumes:
- uploads_data:/app/uploads
web:
build:
context: .
dockerfile: Dockerfile
container_name: tuwen_web
restart: unless-stopped
ports:
- "4002:80"
depends_on:
- api
volumes:
postgres_data:
uploads_data:

34
docs/README.md Normal file
View File

@@ -0,0 +1,34 @@
# 项目文档入口
本文档目录基于当前仓库源码整理,目标是帮助新接手者快速理解系统边界、核心流程和后续维护重点。本文档只描述已经在代码中能看到的事实;规划项会明确标为待办。
## 文档结构
- [需求文档](./requirements.md):业务目标、用户角色、功能需求和非功能约束。
- [设计文档](./design.md):前端架构、路由、状态、数据模型和关键设计取舍。
- [权限设计](./permissions.md):已确定的角色、部门、报告、模板和 AI 上下文权限规则。
- [进度文档](./progress.md):当前完成度、已知风险和下一步建议。
- [功能盘点](./features.md):逐项区分真实可用、外部真实集成、前端演示和预留功能。
- [测试文档](./testing.md):测试命令、覆盖范围和待补端到端测试。
- [API 契约草案](./api-contract.md):后端化前的接口语义、权限过滤和响应格式约定。
- [后端化与用户化改造方案](./backendization-plan.md)前后端拆分、数据模型、API、权限、安全和迁移阶段。
- [数据与存储](./data-storage.md)`localStorage` / `sessionStorage` 键、数据生命周期和迁移点。
- [部署运行](./deployment.md):本地开发、后端 API、构建、Docker Compose 和 Nginx 部署方式。
- [安全说明](./security.md):当前安全边界、敏感信息风险和生产化建议。
## 模块文档
- [认证与用户权限](./modules/auth-and-users.md)
- [报告编辑器](./modules/report-editor.md)
- [报告管理与查看](./modules/report-management.md)
- [模板管理](./modules/template-management.md)
- [系统设置](./modules/system-settings.md)
## 快速事实
- 技术栈React 19、TypeScript、Vite 6、Tailwind CSS 4、React Router DOM 7、NestJS、Prisma、PostgreSQL。
- 应用类型:前端 SPA + NestJS API。登录、报告、模板、用户管理、部门模板授权、系统设置和签名文件已接入后端其余主业务页面仍处于迁移期。
- 数据层:认证用户、报告、报告媒体关系、模板、字段库、模板图片资源、视频/关键帧文件、用户管理、部门模板授权、系统设置和签名文件来自 PostgreSQL/后端文件目录。
- 本地开发端口:前端 `3001`,后端 API `3000`Docker Compose 暴露 API 到宿主机 `3002`
- 默认账号:`admin / 123456``manager / 123456``0001 / 123456`
- 核心代码目录:`src/pages``src/components``src/utils``src/types.ts`

660
docs/api-contract.md Normal file
View File

@@ -0,0 +1,660 @@
# API 契约草案
本文档定义后端化时的接口语义和权限边界。当前项目已实现认证、报告、模板、用户/部门、设置和签名文件接口,其余接口仍用于指导后续后端化。
当前后端骨架已实现:
- `GET /api/health`
- `POST /api/auth/login`
- `GET /api/auth/me`
- `POST /api/auth/logout`
- `GET /api/reports`
- `POST /api/reports`
- `GET /api/reports/:id`
- `PATCH /api/reports/:id`
- `DELETE /api/reports/:id`
- `GET /api/templates`
- `POST /api/templates`
- `GET /api/templates/:id`
- `PATCH /api/templates/:id`
- `DELETE /api/templates/:id`
- `GET /api/users`
- `POST /api/users`
- `GET /api/users/:id`
- `PATCH /api/users/:id`
- `DELETE /api/users/:id`
- `GET /api/departments`
- `POST /api/departments`
- `PATCH /api/departments/:id`
- `DELETE /api/departments/:id`
- `PATCH /api/departments/:id/template-permissions`
- `GET /api/settings/system`
- `PATCH /api/settings/system`
- `POST /api/settings/system/reset`
- `POST /api/users/:id/signature`
- `DELETE /api/users/:id/signature`
- `GET /api/files/:id/content`
- `GET /api/ai/models`
- `POST /api/ai/chat`
- `GET /api/speech/iat` WebSocket
- `GET /api/library/fields`
- `PATCH /api/library/fields`
- `GET /api/files`
- `POST /api/files`
- `DELETE /api/files/:id`
报告媒体关系表、数据库 Session 和第一版审计日志已实现;查看日志、第三方调用摘要和后端导出接口仍是后续项。
## 通用约定
### Base URL
```text
/api
```
### 认证
建议使用短期 Access Token + HttpOnly Refresh Cookie或完整 HttpOnly Session Cookie。前端不应保存密码、完整用户列表或第三方服务密钥。
### 响应格式
成功:
```json
{
"data": {},
"requestId": "req_xxx"
}
```
失败:
```json
{
"error": {
"code": "FORBIDDEN",
"message": "无权访问该资源"
},
"requestId": "req_xxx"
}
```
### 常用状态码
| 状态码 | 含义 |
| --- | --- |
| `200` | 请求成功。 |
| `201` | 创建成功。 |
| `204` | 删除或无响应体操作成功。 |
| `400` | 请求参数错误。 |
| `401` | 未登录或登录态失效。 |
| `403` | 已登录但无权限。 |
| `404` | 资源不存在,或当前用户无权感知该资源。 |
| `409` | 唯一约束或状态冲突。 |
| `422` | 业务校验失败。 |
## 核心类型
### User
```ts
type UserRole = 'super' | 'admin' | 'doctor';
interface UserDTO {
id: string;
username: string;
role: UserRole;
name: string;
departmentId: string;
departmentName?: string;
department?: string;
status: 'active' | 'inactive';
phone?: string;
email?: string;
signatureFileId?: string;
signature?: string;
visibleTemplates?: string[];
manageableTemplates?: string[];
createdAt: string;
updatedAt: string;
}
```
后端不得返回 `password_hash`
### Report
```ts
interface ReportDTO {
id: string;
title: string;
patientName: string;
hospitalId: string;
departmentId: string;
departmentName: string;
authorId: string;
authorName: string;
status: 'draft' | 'completed';
revision: number;
content: string;
videos?: Array<{
id: string;
name: string;
url: string;
duration: number;
fileId?: string;
}>;
capturedFrames?: Array<{
id: number;
videoIndex: number;
videoName: string;
time: number;
timeFormatted: string;
dataUrl: string;
fileId?: string;
}>;
createdAt: string;
updatedAt: string;
}
```
已完成报告再次修改时,后端必须递增 `revision` 并创建历史版本。`videos``capturedFrames` 是前端兼容 DTO 字段,后端持久化到 `ReportMedia` 并关联 `FileResource`
### Template
```ts
interface TemplateDTO {
id: string;
name: string;
description?: string;
scope: 'department' | 'personal';
departmentId?: string;
ownerUserId?: string;
content: string;
fields: FormFieldDTO[];
canUse: boolean;
canManage: boolean;
createdAt: string;
updatedAt: string;
}
```
医生个人模板必须只对本人可见和可用。
## Auth API
### `POST /api/auth/login`
请求:
```json
{
"username": "0001",
"password": "123456"
}
```
响应:
```json
{
"data": {
"user": {
"id": "usr_0001",
"username": "0001",
"role": "doctor",
"name": "张医生",
"departmentId": "dept_surgery",
"departmentName": "外科",
"status": "active"
}
}
}
```
规则:
- 禁用用户返回 `403 FORBIDDEN`
- 用户名或密码错误返回 `401 UNAUTHORIZED`
- 前端通过 HttpOnly Session Cookie 维持登录态,不保存 access token。
### `GET /api/auth/me`
返回当前登录用户。前端启动后用它恢复登录态,并把后端 `doctor` 角色映射为当前前端兼容类型 `user`
### `POST /api/auth/logout`
清除服务端会话或 refresh cookie。
## Reports API
### `GET /api/reports`
查询参数:
```text
q?: string
status?: draft | completed
dateRange?: today | week | month
page?: number
pageSize?: number
```
权限过滤:
- 超级管理员:返回全部报告。
- 管理员:只返回本部门报告。
- 医生:只返回本人报告。
响应:
```json
{
"data": {
"items": [],
"total": 0,
"page": 1,
"pageSize": 20
}
}
```
### `POST /api/reports`
创建报告。后端写入 `authorId``departmentId``revision = 1`
当前实现接收前端兼容 Report 对象,核心字段进入 `Report` 表,视频/关键帧引用进入 `ReportMedia` 表,其他扩展字段进入 `metadata`
### `GET /api/reports/:id`
权限:
- 超级管理员可读所有报告。
- 管理员可读本部门报告。
- 医生可读本人报告。
无权限当前返回 `403`
### `PATCH /api/reports/:id`
规则:
- 超级管理员可编辑任何报告。
- 管理员可编辑本部门报告。
- 医生可编辑本人报告。
- 如果原报告状态为 `completed`,每次保存递增 `revision`
- 每次保存创建历史版本。
### `POST /api/reports/:id/complete`
把报告标记为完成。完成前必须校验患者姓名、住院号等必填字段。
当前实现暂未单独开放该接口,前端通过 `PATCH /api/reports/:id` 传入 `status = completed` 完成报告。
### `DELETE /api/reports/:id`
规则:
- 超级管理员可删除全部报告。
- 管理员可删除本部门报告。
- 医生可删除本人报告,包括已完成报告。
建议先做软删除。
当前实现已做软删除,写入 `deletedAt``deletedBy`
### `GET /api/reports/:id/history`
返回报告历史版本:
```json
{
"data": [
{
"id": "hist_1",
"revision": 1,
"content": "<p>旧内容</p>",
"updatedBy": "张医生",
"updatedAt": "2026-05-01T08:00:00.000Z",
"action": "complete_report"
}
]
}
```
### `GET /api/reports/:id/export.json`
返回结构化报告 JSON。权限与查看报告一致。当前确定不需要水印、导出原因、审批或专门导出审计现阶段仍主要由前端 Blob 导出。
## Templates API
### `GET /api/templates`
权限过滤:
- 超级管理员:全部模板。
- 管理员:本部门模板和被授权模板。
- 医生:本部门授权模板和自己的个人模板。
当前实现支持查询参数 `access=use | manage`,分别返回当前用户可使用或可管理的模板。
### `POST /api/templates`
创建模板。
规则:
- 超级管理员可创建部门模板并授权给部门。
- 管理员可创建或修改本部门模板。
- 医生只能创建个人模板,`scope` 必须为 `personal``ownerUserId` 必须是本人。
当前实现由后端根据 Session 用户写入归属部门或归属用户;前端不能伪造 `ownerUserId`
### `PATCH /api/templates/:id`
规则:
- 超级管理员可编辑任何模板。
- 管理员可编辑本部门模板。
- 医生只能编辑自己的个人模板。
### `DELETE /api/templates/:id`
规则同编辑模板。删除部门模板时需确认是否已有报告引用;建议软删除。
当前实现会先把引用该模板的报告 `templateId` 置空,再删除模板。
### `POST /api/templates/:id/copy`
医生复制部门模板为个人模板。
请求:
```json
{
"name": "我的腹腔镜模板"
}
```
响应返回新的 `TemplateDTO``scope = personal`
## Users API
### `GET /api/users`
权限:
- 超级管理员:全部用户。
- 管理员:自己和本部门医生。
- 医生:只返回自己。
当前实现返回前端兼容 `User` 结构,其中后端 `doctor` 会映射为前端历史角色 `user`,并带上由部门模板授权计算出来的 `visibleTemplates/manageableTemplates`
### `POST /api/users`
规则:
- 只有超级管理员可创建管理员。
- 一个部门只能有一个管理员,冲突返回 `409`
- 管理员只能创建本部门医生。
- 创建医生前,该部门必须已有启用管理员。
- 后端使用 Argon2 保存密码哈希,不返回明文密码。
### `PATCH /api/users/:id`
规则:
- 超级管理员可改任何用户。
- 管理员可改本部门医生的基础信息、密码和状态。
- 医生只能改自己的基础资料。
- 只有超级管理员可修改角色、部门和管理员模板授权。
### `DELETE /api/users/:id`
规则:
- 禁止删除当前登录用户。
- 禁止删除默认超级管理员 `admin`
- 有报告、模板、审计等业务关联时返回 `409`,建议改为禁用账号。
## Departments API
### `GET /api/departments`
权限:
- 超级管理员:全部部门。
- 管理员和医生:本人所在部门。
### `POST /api/departments`
只有超级管理员可创建部门。
### `PATCH /api/departments/:id`
只有超级管理员可修改部门名称或编码。
### `DELETE /api/departments/:id`
只有超级管理员可删除无业务关联的部门;不能删除当前登录用户所在部门。
### `PATCH /api/departments/:id/template-permissions`
请求:
```json
{
"visibleTemplates": ["tpl_a"],
"manageableTemplates": ["tpl_b"]
}
```
规则:
- 只有超级管理员可维护部门模板授权。
- `manageableTemplates` 会自动包含使用权。
- 后端写入 `template_department_permissions.canUse/canManage`
## Settings API
### `GET /api/settings/system`
返回当前用户可用的系统设置。当前实现会合并全局设置和个人默认模板,并返回前端兼容 `SystemSettings` 对象。
注意AI 对话和讯飞语音均已改为后端代理。普通用户读取设置时不返回 AI Key、讯飞 APIKey 或讯飞 APISecret超级管理员仍可在设置页维护全局共用 Provider Key 和语音配置。
### `PATCH /api/settings/system`
规则:
- 超级管理员可修改全局设置、AI Provider、语音配置。
- 医生可修改个人默认模板。
- 管理员可修改个人默认模板。
- 非超级管理员提交的其他字段会被忽略或拒绝,只保留个人默认模板。
### `POST /api/settings/system/reset`
只有超级管理员可重置全局系统设置。
## Signature Files API
### `POST /api/users/:id/signature`
请求:
```json
{
"dataUrl": "data:image/jpeg;base64,...",
"filename": "signature.jpg"
}
```
规则:
- 用户本人、本部门管理员、超级管理员可上传或替换签名。
- 当前支持 JPG、PNG、WebP大小限制 1MB。
- 后端写入 `FileResource.kind = SIGNATURE`,并把 `User.signatureFileId` 指向新文件。
响应:
```json
{
"data": {
"file": {
"id": "file_xxx",
"url": "/api/files/file_xxx/content",
"mimeType": "image/jpeg",
"size": 12345
}
}
}
```
### `DELETE /api/users/:id/signature`
清除用户签名并删除后端文件资源。权限同上传。
### `GET /api/files/:id/content`
返回文件二进制内容。当前已实现签名文件读取权限:
- 本人可读自己的签名。
- 本部门管理员可读本部门用户签名。
- 超级管理员可读全部签名。
- 报告相关文件预留继承报告权限。
## AI API
### `GET /api/ai/models`
后端读取全局共用 AI Provider 配置,请求 OpenAI 兼容 `/models` 并返回模型 ID 列表。前端“测试连接”使用该接口,不再直接携带 API Key 请求第三方服务。
### `POST /api/ai/chat`
规则:
- 后端托管第三方模型密钥。
- 请求上下文只能包含当前报告内容和当前报告内用户有权访问的图片/关键帧。
- 不允许跨部门检索报告作为上下文。
- 当前实现接收 OpenAI 兼容 `messages`、温度等参数,后端会用全局 Provider 的 `modelName` 覆盖请求中的 `model`,所有用户共用同一套 key。
## Speech API
### `GET /api/speech/iat` WebSocket
浏览器通过当前登录 Session Cookie 发起 WebSocket upgrade。后端使用同一套 Session 中间件校验登录态,读取 Settings API 中的 `xfSpeechConfig`,连接讯飞 IAT WebSocket 并转发音频帧和识别结果。
客户端发送的首帧只需要包含音频 `data`
```json
{
"data": {
"status": 0,
"format": "audio/L16;rate=16000",
"encoding": "raw",
"audio": "base64-pcm"
}
}
```
后端会在首帧补齐:
```json
{
"common": { "app_id": "server-side-app-id" },
"business": { "language": "zh_cn", "domain": "iat", "accent": "mandarin" }
}
```
规则:
- 未登录的 upgrade 返回 `401`
- 未配置 APPID/APIKey/APISecret 时,代理向客户端返回错误消息并关闭连接。
- 前端不得保存或拼接讯飞鉴权 URL。
- 上游讯飞返回的识别消息原样转发给客户端。
## Files API
### `GET /api/files`
查询参数:
```text
kind?: TEMPLATE_ASSET | VIDEO | FRAME | REPORT_EXPORT
```
返回当前租户内可读文件。`TEMPLATE_ASSET` 当前作为模板图片资源,登录用户可读取。
### `POST /api/files`
通用文件上传接口。当前已用于模板图片资源、视频和关键帧,后续继续承载报告图片和导出文件。
请求:
```json
{
"kind": "TEMPLATE_ASSET",
"filename": "logo.png",
"dataUrl": "data:image/png;base64,...",
"reportId": "optional_report_id"
}
```
返回:
```json
{
"data": {
"file": {
"id": "file_xxx",
"filename": "logo.png",
"mimeType": "image/png",
"size": 1234,
"url": "/api/files/file_xxx/content",
"createdAt": "2026-05-02T00:00:00.000Z"
}
}
}
```
### `DELETE /api/files/:id`
删除当前用户可管理的文件。超级管理员可删全部,同 owner 可删自己的文件,管理员可删除模板图片资源。
## Library API
### `GET /api/library/fields`
返回字段库:
```ts
interface FieldLibraryDTO {
formFields: FormField[];
customTimeFormats: string[];
multiSelectOptions: Record<string, string[]>;
anesthesiaOptions: string[];
}
```
### `PATCH /api/library/fields`
保存字段库。当前第一版保存为租户全局 `SystemSetting.key = fieldLibrary`,前端仍会同步本地兼容缓存。
文件权限继承业务对象:
- 报告文件继承报告权限。
- 模板图片资源当前登录用户可读取,删除权限限制为超级管理员、管理员或 owner。
- 签名文件已先行通过 `POST /api/users/:id/signature` 实现。
## 后续测试落点
后端骨架建立后,应把本文档转为真实测试:
- Auth/HTTP 集成测试。
- RBAC policy 单元测试。
- Reports API 按角色过滤测试。
- Report revision 递增测试。
- Templates API 部门模板和个人模板测试。
- Settings API 保存和默认模板测试。
- Signature Files API 上传和读取权限测试。
- Library API 字段库保存测试。
- Generic Files API 上传、列表和删除测试。
- AI 代理上下文隔离测试。
- Speech WebSocket 未登录拒绝、首帧注入和上游错误转发测试。

461
docs/backendization-plan.md Normal file
View File

@@ -0,0 +1,461 @@
# 后端化与用户化改造方案
本文基于当前迁移期版本整理需要改动的范围。当前系统已完成主业务后端化,目标是继续把开发回退、审计、安全、部署和运维能力收紧为“多用户、可审计、可部署、可维护”的前后端系统。
## 改造目标
当前系统已经有较完整的页面、业务流程和后端 API。仍需关注的迁移期边界包括
- 用户、密码、权限、工作台统计、报告、报告媒体关系、模板、字段库、模板图片、用户和部门权限已优先接入后端 API`localStorage.users` 仅作为开发回退兼容缓存存在。
- 设置和部分兼容缓存仍存在 `localStorage`;生产构建默认关闭本地回退。
- 签名、模板图片资源、视频和关键帧文件已优先进入后端文件资源;报告媒体引用已通过 `ReportMedia` 关系表保存。
- AI Key 和讯飞语音密钥已进入后端代理链路。
- 权限判断主要在前端页面里完成。
后端化后应达到:
- 登录认证由后端完成。
- 所有数据以用户、部门、医院/租户为边界存储。
- 权限在后端强校验,前端只做展示控制。
- 文件和图片从 Base64 本地存储迁移到后端文件服务或对象存储。
- AI 与语音等密钥由后端托管,前端不接触真实密钥。
- 报告查看、编辑、删除等关键行为有审计记录;报告导出按权限控制,不要求专门导出审计。
## 技术栈决策
后端化第一阶段建议采用 TypeScript 全栈方案,优先保证权限边界、模块结构、数据迁移和测试可维护。
| 层级 | 选择 | 说明 |
| --- | --- | --- |
| 前端 | React 19 + TypeScript + Vite + Tailwind CSS | 保留现有页面主体,逐步把 `localStorage` 读写替换为 API 调用。 |
| 后端框架 | NestJS + TypeScript | 适合按认证、报告、模板、用户、设置、文件、AI 等模块拆分Guard/Interceptor/Service 结构适合集中权限控制和审计。 |
| 数据库 | PostgreSQL | 适合报告、历史版本、权限关系、审计日志等关系型数据;支持事务、索引和后续统计。 |
| ORM/迁移 | Prisma | 类型生成清晰,迁移和 seed 成本低,便于前后端共享 DTO 思路。 |
| 认证 | HttpOnly Cookie SessionAccess Token 仅作为可选扩展 | 前端不直接持久化敏感 token后端维护会话、退出和失效。第一阶段可用数据库会话表后续再引入 Redis。 |
| 密码 | Argon2 哈希 | 禁止明文密码进入前端或日志。 |
| 文件存储 | 本地文件目录 + `files` 表,预留 MinIO/S3 兼容接口 | 开发和院内部署先降低成本;生产可切换对象存储。 |
| 输入校验 | Zod 或 NestJS DTO 校验 | 所有 API 入参都必须校验,尤其是 HTML、文件、权限相关参数。 |
| 测试 | Vitest/Testing Library + Playwright + 后端单元/集成测试 | 保留现有前端测试;新增后端 policy/service/API 测试E2E 后续从 localStorage seed 改为 API/test DB seed。 |
| 部署 | Docker Compose | 前端静态资源、NestJS API、PostgreSQL、可选 Redis/MinIO 分服务部署。 |
暂不建议第一阶段使用微服务、复杂租户系统或后端抽帧服务。抽帧可以先继续留在前端 canvas生成关键帧后上传后端文件接口。
## 当前后端骨架
已新增 `server/` 目录作为后端化第一阶段基础:
- `server/src/main.ts`NestJS 启动入口,设置 `/api` 前缀、CORS、Cookie Session、数据库 Session Store 和统一错误响应。
- `server/src/dashboard``GET /api/dashboard/stats`,按角色范围统计报告、模板、用户和趋势。
- `server/src/audit`:第一版审计服务,记录登录、报告、模板、用户、设置和文件修改。
- `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` 报告接口,含角色范围过滤、历史版本、软删除和报告媒体关系同步。
- `server/src/templates``GET/POST/PATCH/DELETE /api/templates` 模板接口,含 `access=use/manage`、部门模板、部门授权和个人模板。
- `server/src/users``GET/POST/PATCH/DELETE /api/users``/api/departments` 接口,含用户范围过滤、管理员唯一性、医生创建约束和部门模板授权。
- `server/src/settings``GET/PATCH /api/settings/system` 和重置接口,保存全局设置与个人默认模板。
- `server/src/files`:通用文件上传、列表、删除、受控读取接口;签名文件会额外关联 `User.signatureFileId`
- `server/src/library``GET/PATCH /api/library/fields` 字段库接口,保存字段、时间格式和选项库。
- `server/src/ai``GET /api/ai/models``POST /api/ai/chat`,使用全局共用 Provider Key 代理 OpenAI 兼容接口。
- `server/src/speech``GET /api/speech/iat` WebSocket 代理,使用 Session 校验用户,后端生成讯飞鉴权 URL 并转发音频帧和识别结果。
- `server/src/prisma`PrismaService使用 Prisma 7 的 PostgreSQL driver adapter。
- `server/src/permissions`:报告、模板、用户和管理员创建权限策略,已配套单元测试。
- `server/prisma/schema.prisma`PostgreSQL 数据模型,覆盖租户、部门、用户、业务 Session、`AppSession`、报告、报告媒体、历史、模板、文件、设置和审计日志。
- `server/prisma/seed.ts`:写入默认医院、部门和 `admin``manager``0001` 三个账号。
当前前端登录、Dashboard、报告读写、报告媒体关系、模板读写、字段库、模板图片资源、视频/关键帧文件、用户管理、部门模板授权、系统设置、签名文件、AI 对话代理和语音听写代理已接入后端认证/API。真实 PostgreSQL 服务集成测试已覆盖 Auth、Dashboard、Reports、ReportMedia、Templates 和 Files 核心服务,下一步应逐步替换剩余开发回退数据访问。
## 总体架构建议
### 前端
保留当前 React + Vite 页面主体,逐步把 `storage.get/set` 替换成 API 调用。
建议新增:
- `src/api/`:统一 API client。
- `src/auth/`登录态、Token、权限上下文。
- `src/hooks/`:数据获取和变更 hook。
- `src/services/`报告、模板、用户、系统设置、文件、AI 服务封装。
### 后端
建议先采用 NestJS 单体后端,避免一开始拆太复杂:
- Node.js + NestJS + TypeScript。
- PostgreSQL + Prisma。
- Redis 可选,用于会话、限流、任务状态。
- 对象存储可选,院内部署可先用本地文件目录,生产建议 MinIO/S3 兼容存储。
### 数据边界
后端需要引入明确的组织边界:
- `tenant`:医院或部署实例。
- `department`:科室。
- `user`:用户账号。
- `role`:超级管理员、管理员、医生。
- `permission`:模板权限、报告权限、系统配置权限。
如果项目只给单医院内网使用,可以先不做复杂租户模型,但数据库字段建议预留 `tenant_id`
## 数据模型改造
### 用户与认证
新增表:
- `users`
- `departments`
- `roles` 或直接使用枚举字段
- `user_sessions` 或使用 JWT + refresh token
- `user_template_permissions`
当前 `User` 需要变化:
- `password` 改为 `password_hash`,禁止前端返回。
- `signature` 从 Base64 改为 `signature_file_id``signature_url`
- `visibleTemplates/manageableTemplates` 拆成权限关联表。
- `status` 保留。
- 增加 `last_login_at``created_by``updated_by`
### 报告
新增表:
- `reports`
- `report_histories`
- `report_media`
- `report_exports` 可选
当前 `Report` 需要变化:
- `content` 仍可保存 HTML但必须后端清洗。
- `videos.url` 不再长期依赖浏览器对象 URL后端保存 `ReportMedia.fileId/url` 并返回受控文件 URL。
- `capturedFrames.dataUrl` 改成 `ReportMedia.fileId/url` 派生出的图片 URL。
- `author/authorName` 改成 `author_id`,展示时 join 用户表。
- `createdAt/updatedAt` 统一为 ISO 时间戳。
- `status` 保留 `draft/completed`
- `history` 拆到 `report_histories`
### 模板与字段
新增表:
- `templates`
- `template_versions` 可选
- `form_fields`
- `template_fields` 可选
- `image_assets`
当前 `Template` 需要变化:
- `content` 保存 HTML但导入和保存时后端清洗。
- `fields` 不建议长期嵌在模板对象里,可拆出字段配置版本。
- `author` 改成 `author_id`
- 增加 `department_id``tenant_id``is_global``status`
### 系统设置
新增表:
- `system_settings`
- `ai_provider_configs`
- `speech_provider_configs`
当前 `SystemSettings` 需要变化:
- AI Key、讯飞密钥只存后端前端只看到是否已配置和脱敏信息普通用户请求设置时不返回真实密钥。
- 抽帧设置可以按全局、科室或用户默认值分层。
- 默认模板可以改成用户级配置 `user_preferences.default_template_id`
### 文件资源
新增表:
- `files`
字段建议:
- `id`
- `tenant_id`
- `owner_id`
- `kind``signature``template_asset``video``frame``report_export`
- `filename`
- `mime_type`
- `size`
- `storage_key`
- `checksum`
- `created_at`
## API 改造清单
### 认证
- `POST /api/auth/login`
- `POST /api/auth/logout`
- `POST /api/auth/refresh`
- `GET /api/auth/me`
- `PATCH /api/auth/password`
### 用户
- `GET /api/users`
- `POST /api/users`
- `GET /api/users/:id`
- `PATCH /api/users/:id`
- `DELETE /api/users/:id`
- `POST /api/users/:id/signature`(待实现)
- `GET /api/departments`
- `POST /api/departments`
- `PATCH /api/departments/:id`
- `DELETE /api/departments/:id`
- `PATCH /api/departments/:id/template-permissions`
### 模板
- `GET /api/templates`
- `POST /api/templates`
- `GET /api/templates/:id`
- `PATCH /api/templates/:id`
- `DELETE /api/templates/:id`
- `POST /api/templates/import`
- `GET /api/templates/:id/export`
- `POST /api/templates/batch-export`
- `GET /api/form-fields`
- `PUT /api/form-fields`
### 报告
- `GET /api/reports`
- `POST /api/reports`
- `GET /api/reports/:id`
- `PATCH /api/reports/:id`
- `DELETE /api/reports/:id`
- `POST /api/reports/:id/complete`
- `GET /api/reports/:id/history`
- `POST /api/reports/:id/restore`
- `GET /api/reports/:id/export.json`
- `POST /api/reports/export`
### 文件与视频
- `POST /api/files`
- `GET /api/files`
- `GET /api/files/:id`
- `DELETE /api/files/:id`
当前第一版已经保留前端 canvas 抽帧,并把视频和生成的关键帧通过通用文件 API 上传为 `VIDEO` / `FRAME` 文件资源。后续如果要做后端视频服务,再新增专用 `videos` 模块,用于转码、异步抽帧、断点续传和媒体引用关系维护。
### AI 与语音
- `POST /api/ai/chat`
- `GET /api/ai/providers`
- `PATCH /api/ai/providers/:id`
- `POST /api/ai/providers/:id/test`
- `GET /api/speech/config`
- `PATCH /api/speech/config`
- `POST /api/speech/token``GET /api/speech/ws-auth`
前端不再直接调用第三方 AI 接口,不再保存真实 API Key。
### 系统设置
- `GET /api/settings`
- `PATCH /api/settings`
- `POST /api/settings/reset`
清空浏览器本地数据只作为开发/显式回退模式能力;如果未来需要“重置全部后端数据”,应作为独立维护接口谨慎保留,只允许超级管理员在维护模式下执行,并写审计日志。
## 前端改造清单
### 第一批必须改
1. 新增 API Client
- 统一 base URL、认证头、错误处理、401 刷新或跳登录。
- 当前已新增 `src/api/client.ts`,支持 `{ data }` envelope 解包、Cookie credentials 和错误 envelope。
2. 替换登录逻辑
- 登录调用 `/api/auth/login`
- 当前用户来自 `/api/auth/me`
- 当前已完成登录 API 接入;默认模板、字段、图片和本地用户初始化仍暂时保留,供未迁移页面使用。
3. 替换 `currentUser`
-`localStorage.currentUser` 改成 Auth Context 或全局 store。
- 本地只保存 token 或 session 标识。
- 当前已新增 Auth Context但仍会同步 `currentUser` 兼容旧页面;后续页面完成 API 迁移后再移除该缓存依赖。
4. 替换报告数据
- `ReportManage``GET /api/reports`
- `ReportView``GET /api/reports/:id`
- `ReportEditor` 保存用 `POST/PATCH /api/reports`
- 历史恢复用后端历史 API。
- 当前前三项已接入;历史恢复仍通过返回的兼容 `history``sessionStorage.restore_${reportId}` 进入编辑器。
5. 替换模板数据
- `TemplateManage` 全部从 API 获取和保存。
- 模板权限由后端返回可见/可管理范围。
- 当前已接入模板列表、创建、编辑、保存内容、删除、医生个人模板、字段库和模板图片资源。
6. 替换用户管理
- `UserManage` 调用用户 API。
- 密码不再回显,也不进入前端用户对象。
- 当前已接入用户列表、新增、编辑、删除、部门模板授权和签名文件上传。
7. 替换系统设置
- 当前已接入 Settings API保存全局设置和个人默认模板。
- 后续应只展示脱敏密钥;保存时发送新密钥,后端加密存储并由代理使用。
### 第二批建议改
1. 文件上传
- 签名、模板图片、视频和关键帧已完成第一版文件 API报告媒体引用已通过 `ReportMedia` 正规化,报告正文内手动插入图片仍需改为上传文件。
- 报告 HTML 中引用文件 URL 或受控下载 URL。
2. 报告内容清洗
- 前端仍可编辑 HTML。
- 保存前后端做白名单清洗。
- 渲染时避免直接信任历史 HTML。
3. 草稿同步
- 当前草稿在本地,可改成服务端 draft autosave。
- 防止换浏览器或清缓存丢草稿。
4. AI 代理
- `ReportEditor``fetch(chat/completions)` 改成 `/api/ai/chat`
- 后端记录请求摘要和错误,避免前端暴露密钥。
- 当前已接入 `/api/ai/models``/api/ai/chat`,所有用户共用全局 Provider Key仍待第三方调用摘要和错误追踪落库。
5. 审计日志
- 登录、编辑、完成、删除、重置、权限变更等关键操作写日志。
- 报告导出不要求水印、导出原因、审批或专门导出审计。
## 权限模型建议
### 角色能力
| 能力 | 超级管理员 | 管理员 | 医生 |
| --- | --- | --- | --- |
| 管理全局设置 | 是 | 否 | 否 |
| 管理 AI/语音密钥 | 是 | 否 | 否 |
| 管理全部用户 | 是 | 否 | 否 |
| 管理同部门医生 | 是 | 是 | 否 |
| 管理模板 | 全部 | 授权模板 | 否 |
| 使用模板 | 全部 | 可见模板 | 可见模板 |
| 查看报告 | 全部 | 本部门 | 本人 |
| 编辑报告 | 全部 | 本部门 | 本人,包括已完成报告 |
| 删除报告 | 全部 | 本部门,包括已完成报告 | 本人,包括已完成报告 |
| 导出报告 | 全部 | 本部门 | 本人 |
已确定权限规则以 [权限设计](./permissions.md) 为准:
- 超级管理员全权限,可修改任何数据。
- 超级管理员默认属于 `admin` 部门。
- 管理员只能看、改、删本部门报告。
- 医生完成报告后可以继续修改,也可以删除本人已完成报告。
- 完成报告后的每次修改必须增加修订版本号。
- 模板按部门授权;管理员可修改本部门模板;医生可复制/新建个人模板。
- 一个部门只能有一个管理员,且只有超级管理员能创建或授权管理员。
- AI 只能接收当前报告内容作为上下文。
### 后端强校验
每个 API 都必须根据当前用户进行后端权限判断。前端菜单隐藏只是体验优化,不能作为安全依据。
## 安全改造清单
- 密码使用 bcrypt/argon2 哈希。
- Token 使用 HttpOnly Cookie 或短期 access token + refresh token。
- API 增加 CSRF 或 SameSite 策略。
- HTML 内容用 DOMPurify 或服务端白名单清洗。
- 文件上传限制 MIME、大小、扩展名并做病毒扫描或隔离策略。
- AI/语音密钥后端加密存储。
- 敏感日志脱敏,禁止记录完整病历到普通日志。
- 报告导出当前不要求水印、原因、审批或专门审计;后续重点是权限控制、错误追踪和后端导出能力。
- 对报告、模板、用户操作增加审计日志。
- 增加数据库备份和恢复策略。
## 测试改造清单
### 后端测试
- Auth登录、登出、刷新、密码错误、禁用用户。
- RBAC不同角色访问用户、模板、报告、设置 API。
- Reports新增、编辑、完成、历史、删除、导出。
- Templates新增、导入、导出、权限过滤、字段配置。
- Files上传、读取、权限、大小限制。
- Settings密钥脱敏、配置保存、抽帧设置。
- AI Proxy请求构造、错误处理、密钥不下发。
### 前端测试
保留现有 Vitest 测试,并新增:
- API client 401/错误处理。
- Auth Context 登录态恢复。
- 报告列表 API 数据渲染。
- 模板权限数据渲染。
- 用户管理表单 API 提交。
### E2E 测试
建议新增 Playwright
- 管理员登录、创建医生、分配模板。
- 医生登录、新建报告、保存草稿、完成报告。
- 报告管理搜索、查看、导出。
- 模板新增、字段插入、报告套用模板。
- 视频上传和抽帧可用性。
## 迁移步骤建议
### 阶段 0冻结当前功能
- 保留现有 `localStorage` 版本作为 baseline。
- 当前 Vitest 测试继续作为前端契约测试。
- 增加功能验收清单,避免后端化时漏功能。
### 阶段 1后端骨架
- 建后端项目、数据库迁移、基础表结构。
- 实现登录、当前用户、用户列表、模板列表、报告列表。
- 前端新增 API client但先不大规模替换页面。
### 阶段 2认证和用户后端化
- 替换 `Login``Sidebar``UserManage`
- 移除前端默认账号初始化。
- 前端不再保存密码和完整用户列表。
### 阶段 3模板和报告后端化
- 替换 `TemplateManage``ReportManage``ReportView``ReportEditor` 保存逻辑。
- 报告历史改为后端历史表。
- 字段配置由后端提供。
### 阶段 4文件和关键帧后端化
- 签名、图片资源、关键帧、视频统一上传。
- 报告 HTML 中从 Data URL 改为文件 URL 或受控资源地址。
### 阶段 5AI/语音代理与审计
- 前端调用后端 AI API。
- 后端托管 AI 密钥;语音密钥仍待代理化。
- 增加查看日志、第三方代理调用摘要和错误追踪;报告导出不要求专门审计,后端导出能力另行实现。
### 阶段 6生产化
- 部署数据库、对象存储、后端服务。
- 配置备份、监控、日志、权限审计。
- 做安全扫描、数据迁移演练和院内验收。
## 需要优先决策的问题
1. 后端技术栈Express、NestJS、Fastify还是已有院内后端技术
2. 数据库PostgreSQL、MySQL还是院内指定数据库
3. 部署方式:单机 Docker Compose还是院内 Kubernetes/服务器?
4. 文件存储本地磁盘、MinIO、S3 兼容,还是院内文件系统?
5. 租户模型:只支持单医院,还是要支持多医院/多院区?
6. 报告权限:管理员是否只能看同部门,还是可以按模板/患者/手术组授权?
7. 完成报告后是否允许医生修改?是否需要审批或留痕版本?
8. AI 和语音是否必须在院内网代理,是否允许公网访问第三方模型?

67
docs/data-storage.md Normal file
View File

@@ -0,0 +1,67 @@
# 数据与存储
## 存储封装
`src/utils/storage.ts` 提供统一读写:
- `storage.get<T>(key, fallback)`:从 `localStorage` 读取 JSON。
- `storage.set<T>(key, value)`:写入 JSON。
- `storage.remove(key)`:删除本地键。
- `storage.getSession/setSession/removeSession`:读写 `sessionStorage`
`systemSettings` 会经过简单 XOR + Base64 混淆,并兼容读取历史明文 JSON。当前系统设置已优先写入后端 `SystemSetting` 表。本地兼容回退由 `VITE_ENABLE_LOCAL_FALLBACK` 控制,开发模式默认启用,生产构建默认关闭。
## localStorage 键
| 键 | 数据 | 来源/用途 |
| --- | --- | --- |
| `currentUser` | `User` | 当前登录用户兼容缓存。登录认证来自后端 Session缓存用于尚未后端化的页面。 |
| `users` | `User[]` | 用户兼容缓存。登录成功和用户管理页会用后端用户信息同步用户、角色、部门、模板授权和签名 URL旧签名 Data URL 和旧演示密码字段可能仍暂存在本地。 |
| `templates` | `Template[]` | 模板兼容缓存。模板读写已优先走后端 Templates API缓存用于离线回退、旧数据和未迁移页面。 |
| `reports` | `Report[]` | 报告兼容缓存。报告读写已优先走后端 Reports API缓存用于离线回退、旧数据和未迁移页面。 |
| `systemSettings` | `SystemSettings` | 抽帧、AI、语音和默认模板配置的兼容缓存主路径优先走 `/api/settings/system`。 |
| `formFieldsConfig` | `FormField[]` | 字段库兼容缓存;主路径优先走 `/api/library/fields`。 |
| `imageAssets` | `{ id; name; dataUrl }[]` | 模板/报告可插入图片资源兼容缓存;模板图片主路径优先走 `/api/files?kind=TEMPLATE_ASSET`。 |
| `customTimeFormats` | `string[]` | 模板字段可选时间格式兼容缓存;主路径优先走 `/api/library/fields`。 |
| `multiSelectOptions` | `Record<string, string[]>` | 医生、助手、麻醉师等多选字段选项兼容缓存;主路径优先走 `/api/library/fields`。 |
| `anesthesiaOptions` | `string[]` | 麻醉方式选项兼容缓存;主路径优先走 `/api/library/fields`。 |
| `reportEditorDraft_${username}` | 草稿对象 | 某用户当前未提交编辑器草稿。 |
## sessionStorage 键
| 键 | 数据 | 用途 |
| --- | --- | --- |
| `restore_${reportId}` | HTML 字符串 | 从历史版本恢复时临时传递内容给编辑器。 |
## 初始化流程
开发模式下,登录页 `Login` 首次加载时会补齐以下迁移期数据:
- 默认模板 `surgery`
- 默认用户:`admin``manager``0001`
- 默认表单字段 `DEFAULT_FORM_FIELDS`
- 默认图片资源 `logo_square.png`
- 默认系统设置包括抽帧位置、AI Provider、自动插入策略和语音配置。
真实账号认证不再读取 `users.password`,而是调用后端 `/api/auth/login`。用户管理页已优先调用 `/api/users`,但 `users.password` 字段仍可能存在于旧本地缓存和开发回退数据中,后续应随迁移脚本移除。
## 数据生命周期
- 报告保存为草稿或完成态时优先写入后端;开发回退开启时会同步 `reports` 缓存API 不可用时才写入本地 `reports`
- 新建报告成功保存后会清理当前用户草稿。
- 编辑已有报告时会把旧内容推入 `history`
- 开发回退模式下,系统设置页“重置全部数据”会执行 `localStorage.clear()` 并刷新页面;生产构建默认阻止把该操作误认为后端数据重置。
## 迁移注意
- 旧版 AI 设置可能是扁平字段,`SystemSettings` 页面会迁移到 `aiProviders` 结构。
- 个人模板通过 `Template.scope = "personal"``Template.ownerUser` 归属医生本人;部门模板通过 `Template.scope = "department"``Template.department` 标记部门。
- 后端模板使用 `templates.scope``owner_department_id``owner_user_id``template_department_permissions` 表达同样权限;前端字段作为兼容 DTO 返回。
- 后端用户使用 `users.department_id``users.role``template_department_permissions` 计算前端兼容的 `visibleTemplates/manageableTemplates`
- 后端设置使用 `SystemSetting.scope = global` 保存全局设置,使用 `scope = user:<id>` 保存个人默认模板。
- 后端字段库使用 `SystemSetting.scope = global``key = fieldLibrary` 保存字段、时间格式和选项库。
- 后端签名文件使用 `FileResource.kind = SIGNATURE``User.signatureFileId` 关联,文件内容由 `/api/files/:id/content` 受控读取。
- 后端模板图片资源通过 `POST /api/files``kind = TEMPLATE_ASSET` 写入 `FileResource`,模板和报告编辑器优先读取 `/api/files?kind=TEMPLATE_ASSET`
- 报告修订版本通过 `Report.revision` 保存;已完成报告每次再次保存会递增版本,并把旧版本写入 `Report.history`
- 后端视频和关键帧文件通过 `POST /api/files``kind = VIDEO/FRAME` 写入 `FileResource`;报告保存时通过 `ReportMedia` 关系表保存视频/关键帧的 `fileId/url`、排序和抽帧信息。`Report.metadata` 只保留患者等前端扩展字段,旧数据里的媒体数组仍可作为兼容回退读取。
- 报告日期目前部分逻辑使用 `createdAt` 的日期字符串,部分逻辑会按 `Date` 解析 ISO 字符串,后续建议统一为 ISO 时间戳加展示格式化。

92
docs/deployment.md Normal file
View File

@@ -0,0 +1,92 @@
# 部署运行
## 本地开发
前端:
```bash
npm install
npm run dev
```
开发服务监听 `0.0.0.0:3001`
开发模式下 Vite 会把 `/api` 代理到 `VITE_API_PROXY_TARGET`,默认 `http://localhost:3000`
后端:
```bash
cp .env.example .env
npm run prisma:generate
npm run server:dev
```
本地直接运行 API 默认监听 `0.0.0.0:3000`,健康检查为:
```text
http://localhost:3000/api/health
```
如需连接真实 PostgreSQL
```bash
npm run prisma:migrate
npm run prisma:seed
```
## 质量检查
```bash
npm run lint
npm run test
npm run server:build
npm run build
```
当前 `lint` 实际执行 `tsc --noEmit`,用于 TypeScript 类型检查。
## 环境变量
复制示例文件:
```bash
cp .env.example .env.local
```
AI 和语音密钥由后端 Settings API 保存并由代理使用,前端不再注入 Gemini 旧环境变量。
后端新增变量:
- `API_PORT`API 监听端口。本地直接运行默认 `3000`Docker Compose 暴露到宿主机的默认端口是 `3002`
- `CORS_ORIGIN`:允许跨域携带 Cookie 的前端来源。
- `DATABASE_URL`PostgreSQL 连接串。Docker Compose 暴露到宿主机的默认端口是 `5433`,容器内部仍使用 `db:5432`
- `SESSION_SECRET`Session Cookie 签名密钥。
- `SESSION_COOKIE_SECURE`:是否只通过 HTTPS 发送 Session Cookie。本地 HTTP/Compose 默认 `false`,生产 HTTPS 应设为 `true`
- `FILE_STORAGE_DIR`后端文件目录。Docker Compose 默认 `/app/uploads`,并挂载到 `uploads_data` volume。
- `VITE_API_PROXY_TARGET`:前端开发服务器 `/api` 代理目标。直接运行后端用 `http://localhost:3000`;连接 Docker Compose API 用 `http://localhost:3002`
- `VITE_ENABLE_LOCAL_FALLBACK`:生产构建是否允许本地兼容回退。开发模式默认启用,生产默认关闭。
## Docker 部署
```bash
docker-compose up -d --build
```
默认通过 Nginx 暴露 `http://localhost:4002`
当前 Compose 服务:
- `web`:前端静态站点,暴露 `http://localhost:4002`
- `api`NestJS API暴露 `http://localhost:3002`
- `db`PostgreSQL 16暴露 `localhost:5433`
- `uploads_data`:后端文件持久化 volume。
构建流程:
- `Dockerfile` 使用 Node 构建 `dist/`
- 运行阶段使用 `nginx:alpine` 托管静态文件。
- `Dockerfile.server` 构建并运行 NestJS API。
- `nginx.conf` 已配置 SPA 路由回退和 `/api` 反向代理。
## 部署边界
当前后端已承载登录认证、数据库 Session、Dashboard、报告、报告媒体关系、模板、字段库、通用文件/签名文件、视频/关键帧文件、用户管理、部门模板授权、系统设置、AI 代理、语音代理、HTML 清洗和第一版审计。生产化前还需要补齐查看日志、第三方代理调用摘要、限流、备份恢复、对象存储和更完整的旧数据迁移。

105
docs/design.md Normal file
View File

@@ -0,0 +1,105 @@
# 设计文档
## 总体架构
应用是一个 Vite 构建的 React SPA。入口文件为 `src/main.tsx`,路由集中在 `src/App.tsx`。页面级功能位于 `src/pages`,公共侧边栏位于 `src/components/Sidebar.tsx`,数据类型集中在 `src/types.ts`,浏览器存储封装在 `src/utils/storage.ts`
```text
src/
App.tsx 路由定义
main.tsx React 挂载入口
index.css Tailwind 与全局样式
api/ 前端 API client
auth/ 登录态、后端用户兼容映射
types.ts 核心数据类型和默认配置
components/Sidebar.tsx 主导航与登出
pages/ 业务页面
utils/ 存储、打印、默认模板内容
```
## 路由设计
| 路由 | 页面 | 用途 |
| --- | --- | --- |
| `/` | `Login` | 登录和首次数据初始化。 |
| `/dashboard` | `Dashboard` | 工作台统计和快捷入口。 |
| `/report-editor` | `ReportEditor` | 新建报告,或通过 `?id=` 编辑报告。 |
| `/report-manage` | `ReportManage` | 报告检索、历史、导出和删除。 |
| `/report-view/:id` | `ReportView` | 报告只读查看和打印。 |
| `/template-manage` | `TemplateManage` | 模板和字段库维护。 |
| `/user-manage` | `UserManage` | 用户、角色、部门和模板权限管理。 |
| `/system-settings` | `SystemSettings` | 抽帧、AI、语音和默认模板设置。 |
未匹配路由会重定向到登录页。除登录页外,其余路由由 `RequireAuth` 包裹:优先等待 `AuthProvider` 从后端 `/api/auth/me` 恢复会话;没有后端会话和本地兼容缓存时回到 `/`
## 数据设计
核心类型在 `src/types.ts`
- `User`:用户账号、角色、部门、状态、模板权限和签名。
- `Report`报告元信息、患者信息、HTML 正文、视频、关键帧、作者、状态和历史版本。
- `Template`模板名称、描述、HTML 内容、作者和字段配置。
- `FormField`:表单字段定义,支持文本、单选、多选、日期、时间、签名、图片等类型。
- `SystemSettings`抽帧策略、默认模板、AI Provider、语音配置和自动插入设置。
登录认证、报告读写、报告媒体引用、模板读写、字段库、模板图片资源、视频/关键帧文件、用户管理、部门模板授权、系统设置和签名文件已有服务端校验。报告媒体通过 `ReportMedia` 关系表关联 `FileResource`,页面逻辑仍承担一部分迁移期回退约束。
## 状态与持久化
应用使用 React 本地状态管理页面交互。认证态由后端 Session Cookie 维持,前端 `AuthProvider` 会把当前用户同步到 `localStorage.currentUser` 作为迁移期兼容缓存。报告读写已优先走后端 Reports API并同步 `localStorage.reports` 作为开发/显式回退缓存;模板读写已优先走后端 Templates API并同步 `localStorage.templates` 作为开发/显式回退缓存;字段库已优先走 Library API并同步 `formFieldsConfig` 等兼容缓存;模板图片资源已优先走 Files API并同步 `imageAssets` 兼容缓存;用户和部门模板授权已优先走 Users/Departments API并同步 `localStorage.users` 作为兼容缓存;系统设置已优先走 Settings API并同步 `localStorage.systemSettings` 作为开发/显式回退缓存。`storage.get/set` 对常规键做 JSON 读写,对 `systemSettings` 做简单 XOR + Base64 存储;这不是安全加密,只能降低直接可读性。生产构建默认关闭本地回退。
报告编辑器额外使用 `reportEditorDraft_${username}` 保存未提交草稿,使用 `sessionStorage``restore_${reportId}` 暂存历史版本恢复内容。
## 权限设计
当前迁移期权限检查分两层:登录、报告、模板、用户和部门模板授权由后端校验;页面入口和部分业务按钮权限仍保留前端 UI 展示层:
- 未登录用户跳回 `/`
- 医生在报告管理和查看页只能访问本人报告。
- 模板管理不允许医生进入。
- 用户管理只允许超级管理员和管理员进入;后端再次校验可管理范围。
- 管理员只能管理同部门医生或自己,不能创建管理员或跨部门修改用户。
前端入口隐藏不能作为生产级安全边界,所有已后端化 API 都必须以服务端校验为准。
后端化后的目标权限模型以“角色 + 部门 + 模板授权”为主,详细规则见 [权限设计](./permissions.md)。当前已确定规则:
- 超级管理员拥有全权限,可以查看和修改所有用户、报告、模板、系统配置和权限配置。
- 管理员只能查看、编辑和删除本部门报告。
- 医生默认只能查看本人报告。
- 医生完成报告后仍允许修改,也可以删除本人已完成报告;完成后的每次修改必须保留历史版本并递增修订版本号。
- 模板按部门授权;管理员可以修改本部门模板;医生不可修改部门模板,但可以复制/新建个人模板。
- 一个部门只能有一个管理员,且只有超级管理员能创建或授权管理员。
- AI 只能接收当前报告内容作为上下文,不读取跨部门报告。
- 后端必须对每个 API 做强权限校验,前端菜单隐藏只作为体验优化。
后端查询层需要把权限规则落到数据过滤:
- 报告列表:超级管理员不加部门限制;管理员按 `department_id` 过滤;医生按 `author_id` 过滤。
- 报告详情/编辑/删除:读取报告后再次校验角色、部门和作者关系。
- 模板列表:超级管理员返回全部模板;管理员返回本部门模板和被授权模板;医生返回本部门授权模板和个人模板。
- 用户列表:超级管理员返回全部用户;管理员返回自己和本部门医生;医生只能读取自己。
- 部门模板授权:超级管理员维护部门可用/可管理模板;管理员和医生只读取本人部门。
## 报告编辑设计
报告正文是一个 `contentEditable` HTML 编辑器。模板中通过 `.field-value[data-bind="fieldKey"]` 标记智能字段,侧边表单更新时同步到正文;正文内字段被编辑时也会反向更新表单状态。
图片占位符通过 `.image-placeholder` 表示:
- `data-mode="frame"` 或无 `manual` 标记的占位符可接收关键帧。
- `data-mode="manual"` 用于 Logo、签名等静态图片不接收视频关键帧拖入。
AI 可编辑区域通过 `.ai-region[data-ai-id]` 和内部 `.ai-content` 定位。AI 返回 `updatedHtml` 后,系统先显示差异确认,再注入目标区域。
## 打印导出设计
`src/utils/print.ts` 通过隐藏 iframe 写入报告 HTML 和打印样式然后调用浏览器打印。PDF 导出依赖浏览器“另存为 PDF”不是服务端生成 PDF。
JSON 导出使用 Blob 下载,报告导出主要包含元信息和 `DEFAULT_FORM_FIELDS` 中定义的字段。
## 外部服务设计
- AI 服务通过后端 `/api/ai/chat``/api/ai/models` 代理到 OpenAI 兼容 `/chat/completions``/models` 接口。
- 语音听写通过后端 `/api/speech/iat` WebSocket 代理到讯飞 IAT 接口,前端只采集 PCM 音频并发送到本系统。
- 当前 AI Key 和讯飞语音密钥均由后端代理使用;普通用户读取系统设置时不会返回真实密钥。

79
docs/features.md Normal file
View File

@@ -0,0 +1,79 @@
# 功能盘点
本文按当前源码筛选功能状态,区分“真实可用”“真实集成但依赖外部条件”“前端演示/非生产安全”和“预留或未使用”。
## 状态定义
| 状态 | 含义 |
| --- | --- |
| 真实可用 | 在当前应用内已有完整交互和持久化;可能是本地存储或已接入后端。 |
| 真实集成 | 已调用浏览器能力或第三方接口,但需要浏览器权限、有效密钥、网络或用户手动操作。 |
| 前端演示 | UI 和流程存在,但安全边界或数据层只是浏览器本地模拟,不等于生产实现。 |
| 后端骨架 | 服务入口、数据模型或策略已存在,但尚未接入前端主流程或补齐完整业务 API。 |
| 预留/未使用 | 依赖、环境变量或字段存在,但当前功能链路未实际使用或命名未统一。 |
## 功能矩阵
| 功能 | 状态 | 证据/说明 |
| --- | --- | --- |
| 登录 | 真实集成 | `Login` 通过 `AuthContext` 调用 `POST /api/auth/login`,后端用 Session Cookie 维持登录态;成功后同步 `currentUser` 兼容旧页面。 |
| 默认数据初始化 | 真实可用 | 登录页初始化默认用户、模板、字段、图片资源和系统设置。 |
| 登录态恢复/退出 | 真实集成 | `AuthProvider` 调用 `GET /api/auth/me` 恢复会话,`POST /api/auth/logout` 退出;后端 Session 已持久化到 `AppSession` 表,生产构建默认不再用本地缓存恢复。 |
| 角色导航 | 真实可用 | `Sidebar` 优先读取 Auth Context回退 `currentUser.role` 显示菜单。 |
| 页面级权限跳转 | 前端演示 | 页面读取 `currentUser` 后跳转或隐藏功能;可被浏览器侧数据修改绕过。 |
| 工作台统计 | 真实集成 | `Dashboard` 调用 `GET /api/dashboard/stats`,后端按角色范围统计报告、模板、用户和趋势;只有开发/显式回退模式下 API 不可用才回退本地统计。 |
| 报告基本信息表单 | 真实可用 | `ReportEditor` 管理 `reportData`,支持文本、日期、时间、单选、多选。 |
| 正文智能字段绑定 | 真实可用 | 模板 HTML 的 `data-bind` 字段与表单双向同步。 |
| 富文本编辑 | 真实可用 | 使用 `contentEditable``document.execCommand`。实现可用,但 API 过时。 |
| 报告草稿 | 真实可用 | 保存到 `reportEditorDraft_${username}`。 |
| 保存/完成报告 | 真实集成 | `ReportEditor` 优先调用 `POST/PATCH /api/reports`,后端写入 PostgreSQL、清洗 HTML、保留历史版本、写审计并在已完成报告再次修改时递增 `revision`;只有开发/显式回退模式下 API 不可用才回退本地保存。 |
| 报告历史恢复 | 真实可用 | 管理页写 `sessionStorage.restore_${reportId}`,编辑器读取恢复。 |
| 报告管理筛选 | 真实集成 | `ReportManage` 优先调用 `GET /api/reports`,后端按超级管理员/管理员/医生过滤;前端继续支持搜索、状态和时间筛选;只有开发/显式回退模式下 API 不可用才回退本地报告。 |
| 报告查看 | 真实集成 | `ReportView` 优先调用 `GET /api/reports/:id`,后端校验查看权限;页面渲染报告 HTML只有开发/显式回退模式下 API 不可用才回退本地权限检查。 |
| PDF 导出 | 真实集成 | 通过隐藏 iframe 或 `window.print()` 调用浏览器打印,用户手动保存为 PDF不是后端 PDF 生成。 |
| JSON 导出 | 真实可用 | Blob 下载结构化报告字段或模板包。 |
| 模板管理 | 真实集成 | `TemplateManage` 优先调用 `/api/templates?access=manage`,新增/编辑/删除会写后端并清洗 HTML字段库优先调用 `/api/library/fields`,模板图片资源优先调用 `/api/files`。导入导出仍主要在前端处理。 |
| 模板权限 | 真实集成 | 后端按部门模板、部门授权和个人模板过滤 `access=use/manage`;迁移期仍同步 `localStorage.templates`,仅在开发/显式回退模式下作为回退。 |
| 我的个人模板 | 真实集成 | 医生在报告编辑器中保存个人模板时优先调用 `POST /api/templates`,后端把模板归属当前用户;只有开发/显式回退模式下 API 不可用才回退本地模板。 |
| 用户管理 | 真实集成 | `UserManage` 优先调用 `/api/users` 增删改查,后端校验超级管理员/管理员范围、管理员唯一性和医生创建约束;只有开发/显式回退模式下 API 不可用才保留本地回退。 |
| 部门模板授权 | 真实集成 | 后端提供 `/api/departments``/api/departments/:id/template-permissions`,超级管理员可通过管理员模板权限更新部门授权。 |
| 电子签名 | 真实集成 | 用户管理页上传后压缩为 Data URL再调用 `/api/users/:id/signature` 写入后端文件资源;报告中有对应绑定字段时通过受控文件 URL 展示。只有开发/显式回退模式下 API 不可用才保留本地签名回退。 |
| 视频上传 | 真实集成 | 使用浏览器 File API 和对象 URL即时预览并优先通过 `/api/files``kind = VIDEO` 写入后端文件资源。 |
| 自动抽帧 | 真实集成 | 使用 `<video>` + `<canvas>` 按百分比截取 JPEG关键帧优先通过 `/api/files``kind = FRAME` 写入后端文件资源。 |
| 关键帧插入 | 真实集成 | 关键帧可点击插入或拖入图片占位符;上传成功后编辑器会把插入图片从 Data URL 替换为受控文件 URL。 |
| AI 辅助撰写 | 真实集成 | 前端调用 `/api/ai/chat`,后端使用全局共用 Provider Key 代理 OpenAI 兼容 `/chat/completions`;需要有效 Provider 配置、模型和网络。 |
| AI 差异确认 | 真实可用 | 使用 `diff` 生成左右差异,确认后写入 AI 区域。 |
| 讯飞语音听写 | 真实集成 | 前端使用麦克风采集音频并连接 `/api/speech/iat`;后端读取讯飞配置、生成鉴权 URL、补齐首帧 APPID/业务参数并转发 IAT 结果。需要浏览器权限、有效配置和网络。 |
| AI/语音密钥管理 | 真实集成 | AI Key 和讯飞 APIKey/APISecret 均由后端代理读取和使用;普通用户读取设置时不返回真实密钥。 |
| 系统设置 | 真实集成 | `SystemSettings` 优先调用 `/api/settings/system` 读取、保存和重置抽帧、默认模板、AI Provider、语音配置只有开发/显式回退模式下 API 不可用才回退本地缓存。 |
| Docker/Nginx 静态部署 | 真实可用 | 可构建静态文件并用 Nginx 托管 SPA。 |
| 后端服务 | 后端骨架 | 已新增 NestJS API健康检查、认证接口、数据库 Session、Dashboard API、报告 API、报告媒体关系、模板 API、字段库 API、用户/部门 API、设置 API、通用文件/签名文件 API、视频/关键帧文件上传、AI 代理、讯飞语音代理、HTML 清洗、审计日志、Prisma/PostgreSQL 数据模型、默认 seed 和权限策略。 |
| 讯飞语音配置字段 | 真实可用 | 初始化、类型和系统设置统一使用 `xfSpeechConfig`。 |
## 测试覆盖对应
| 测试文件 | 覆盖范围 |
| --- | --- |
| `src/pages/Login.test.tsx` | 默认数据初始化、默认账号展示、后端禁用账号错误展示。 |
| `src/components/Sidebar.test.tsx` | 角色导航过滤。 |
| `src/api/client.test.ts` | API envelope 解包、Cookie credentials 和错误 envelope。 |
| `src/api/dashboard.test.ts` | Dashboard 统计 API 封装和响应校验。 |
| `src/api/speech.test.ts` | 语音 WebSocket 代理地址生成,含同源和显式 API Base URL。 |
| `src/api/library.test.ts` | 字段库 API 读取和更新封装。 |
| `src/api/files.test.ts` | 通用文件列表和上传 API 封装。 |
| `src/auth/backendUser.test.ts` | 后端用户 DTO 到前端兼容用户的角色和模板权限映射。 |
| `src/pages/ReportManage.test.tsx` | 医生/管理员报告可见范围。 |
| `src/utils/permissions.test.ts` | 报告权限、管理员本部门范围、医生个人模板和部门模板范围。 |
| `src/utils/storage.test.ts` | 本地存储、系统设置混淆兼容、会话恢复键、默认 Provider 不携带内置 Key 的契约。 |
| `src/utils/defaultContent.test.ts` | 默认模板结构、智能字段、图片占位符、AI 区域、字段和 Provider 配置。 |
| `src/utils/print.test.ts` | 浏览器打印导出入口。 |
| `server/src/permissions/permissions.policy.test.ts` | 后端报告、模板、用户管理和管理员创建权限策略。 |
| `server/src/reports/report.mapper.test.ts` | 后端 Report 与前端兼容 Report 对象的 metadata、ReportMedia 和历史输出。 |
| `server/src/templates/template.mapper.test.ts` | 后端 Template 与前端兼容 Template 对象、模板权限资源的映射。 |
| `server/src/users/users.mapper.test.ts` | 后端 User 与前端兼容 User 对象、部门模板授权字段的映射。 |
| `server/src/settings/settings.schemas.test.ts` | 后端系统设置 schema 默认值和非法抽帧参数校验。 |
| `server/src/ai/ai.schemas.test.ts` | 后端 AI 代理入参 schema含多模态消息与空消息校验。 |
| `server/src/speech/xf-frame.test.ts` | 后端讯飞代理首帧补齐 APPID/业务参数,后续帧和异常 payload 保持兼容。 |
| `server/src/http.integration.test.ts` | Nest HTTP 层集成测试,覆盖 API prefix、登录会话、未登录保护、受保护接口 actor 传递。 |
外部 AI、讯飞语音上游、真实浏览器视频抽帧和打印成 PDF 属于浏览器/第三方边界单元测试中不直连外部服务AI/语音代理已有 schema 或帧处理测试,后续需要 HTTP/WebSocket 集成测试和人工验收补充端到端验证。

View File

@@ -0,0 +1,74 @@
# 模块文档:认证与用户权限
## 涉及文件
- `src/pages/Login.tsx`
- `src/api/client.ts`
- `src/api/users.ts`
- `src/api/files.ts`
- `src/auth/AuthContext.tsx`
- `src/auth/backendUser.ts`
- `src/pages/UserManage.tsx`
- `src/components/Sidebar.tsx`
- `src/types.ts`
- `src/utils/storage.ts`
- `server/src/users`
- `server/src/files`
## 登录流程
`Login` 页面挂载时仍执行迁移期初始化,补齐默认模板、默认用户、字段库、图片资源和系统设置。提交登录表单后,前端通过 `AuthContext.login` 调用 `POST /api/auth/login`,后端验证 Argon2 密码哈希并写入 HttpOnly Session Cookie。
登录成功后,`AuthContext` 会把后端返回的安全用户 DTO 转成当前前端 `User` 结构,并同步到 `localStorage.currentUser``localStorage.users`。这是为了兼容字段、设置、用户管理和开发回退逻辑;生产构建默认不会用本地缓存恢复登录态。
`AuthProvider` 启动时会调用 `GET /api/auth/me` 恢复后端会话,退出登录调用 `POST /api/auth/logout` 并清理本地 `currentUser`
后端角色 `doctor` 会映射为当前前端历史类型中的 `user`。前端会保留本地签名和已有模板授权字段,避免迁移期丢失医生个人模板或签名。
## 默认账号
| 用户ID | 密码 | 角色 | 说明 |
| --- | --- | --- | --- |
| `admin` | `123456` | 超级管理员 | 初始最高权限。 |
| `manager` | `123456` | 管理员 | 外科管理员。 |
| `0001` | `123456` | 医生 | 外科医生账号。 |
## 权限规则
- `super`:可查看和管理全部用户、模板、报告、系统配置。
- `admin`:可管理同部门医生或自己;不可创建超级管理员;新增用户默认是医生。
- `user`:只能管理本人报告;不能进入模板管理和用户管理。
用户管理中还有这些约束:
- 不允许删除默认超级管理员 `admin`
- 不允许删除当前登录用户。
- 一个部门只能存在一个管理员。
- 创建医生前,该部门必须已有管理员。
- 禁用管理员类账号需要授权密钥。
`UserManage` 页面当前优先调用后端 `Users/Departments` API。只有开发模式或显式开启 `VITE_ENABLE_LOCAL_FALLBACK=true`API 不可用才保留本地列表回退,便于迁移期演示和离线开发;生产安全边界以服务端校验为准。
## 用户与部门 API
- `GET /api/users`:超级管理员返回全部用户;管理员返回自己和本部门医生;医生只返回自己。
- `POST /api/users`:超级管理员可创建管理员或医生;管理员只能创建本部门医生;后端校验一个部门只能有一个管理员,且创建医生前部门必须已有管理员。
- `PATCH /api/users/:id`:超级管理员可改角色、部门、状态和模板授权;管理员只能改本部门医生的基础信息、密码和状态;医生只能改自己的基础资料。
- `DELETE /api/users/:id`:禁止删除当前账号和默认超级管理员;有业务数据关联时返回冲突,建议改为禁用。
- `GET /api/departments`:超级管理员返回全部部门;管理员/医生返回本人部门。
- `PATCH /api/departments/:id/template-permissions`:超级管理员维护部门可用和可管理模板授权。
## 模板权限
用户有两个模板权限字段:
- `manageableTemplates`:可管理的模板 ID。
- `visibleTemplates`:新建报告时可使用的模板 ID。
超级管理员拥有全部模板。管理员可被超级管理员分配部门可管理模板;医生可见模板来自本部门可用模板和自己的个人模板。当前前端仍沿用 `visibleTemplates/manageableTemplates` 字段展示,但后端数据来源是 `TemplateDepartmentPermission`
## 签名
用户管理支持上传签名图片,前端会压缩为 JPEG Data URL再调用 `POST /api/users/:id/signature` 写入后端 `FileResource` 和本地文件目录。后端把 `signatureFileId` 写回用户表,并通过 `/api/files/:id/content` 提供受控读取。报告模板中若存在 `surgeonSignature` 绑定字段,报告编辑器会尝试用当前用户签名 URL 渲染。
只有本地回退开启时API 不可用才会保留本地 `User.signature` 作为迁移期回退;模板图片、关键帧和视频已迁移到同一文件资源体系,后续应继续正规化报告媒体关系。

View File

@@ -0,0 +1,98 @@
# 模块文档:报告编辑器
## 涉及文件
- `src/pages/ReportEditor.tsx`
- `src/api/reports.ts`
- `src/api/ai.ts`
- `src/api/speech.ts`
- `src/api/library.ts`
- `src/api/files.ts`
- `src/utils/defaultContent.ts`
- `src/utils/print.ts`
- `src/types.ts`
## 模块职责
报告编辑器是系统核心模块,负责新建/编辑报告、字段同步、富文本排版、视频关键帧处理、AI 辅助撰写、语音输入、草稿恢复和报告保存。
## 页面模式
- `/report-editor`:新建报告。
- `/report-editor?id=RPT_xxx`:编辑已有报告。
- `/report-editor?id=RPT_xxx&restore=1`:从历史版本恢复内容后进入编辑器。
## 草稿机制
编辑器会在卸载、页面隐藏和部分操作后写入 `reportEditorDraft_${username}`。草稿包含正文 HTML、表单数据、视频列表、关键帧、当前标签页、模板 ID 和 AI 聊天上下文。
保存报告后会清理草稿。
## 智能字段
模板中的智能字段形如:
```html
<span class="field-value" data-bind="patientName" contenteditable="true"></span>
```
编辑器会把侧边表单状态同步到正文,也会在用户直接编辑正文中的绑定字段时反向更新表单。日期和时间字段会按字段配置做格式化和解析。
字段库优先从 `/api/library/fields` 读取,成功后同步 `formFieldsConfig``multiSelectOptions``anesthesiaOptions` 兼容缓存;只有开发模式或显式开启 `VITE_ENABLE_LOCAL_FALLBACK=true`API 不可用才继续读取本地缓存。
## 图片占位符
`.image-placeholder` 用于在模板或报告中保留图片位置。
- 普通占位符可点击选择图片,也可接收视频关键帧。
- `data-mode="manual"` 用于 Logo、签名等静态位置不允许拖入关键帧。
- 已填充图片后会增加 `.has-image`
模板图片资源优先从 `/api/files?kind=TEMPLATE_ASSET` 读取只有本地回退开启时API 不可用才继续读取本地 `imageAssets`
## 视频与抽帧
用户上传视频后,系统使用浏览器对象 URL 进行即时预览,同时优先通过 `POST /api/files``kind = VIDEO` 上传后端文件资源。抽帧逻辑按系统设置中的 `framePositions` 百分比定位视频时间,绘制到 canvas 后转为 JPEG并优先以 `kind = FRAME` 上传后端文件资源。
支持:
- 自动抽帧。
- 手动截帧。
- 点击关键帧跳转视频时间。
- 拖拽关键帧到报告占位符。
- 自动把选中的抽帧序号插入空置图片占位符。
报告保存前会等待当前视频和关键帧上传完成,并把 `fileId/url`、排序和抽帧信息通过 Reports API 同步到后端 `ReportMedia` 关系表。只有本地回退开启时API 不可用才保留本地对象 URL/Data URL 回退。
## AI 撰写
AI 面板支持两种模式:
- 对话模式:根据当前报告内容和图片上下文回答问题。
- 修改模式:选中 `.ai-region` 后要求模型返回 JSON其中包含 `reply``updatedHtml`
模型返回 HTML 后,系统会清理换行和 `<br>`,生成差异预览。用户确认后才写入目标 `.ai-content`
当前 AI 调用使用后端代理:
- `/api/ai/chat`
- 后端读取全局共用 Provider Key 并代理到 OpenAI 兼容 `/chat/completions`
- 支持图片以 `image_url` 内容传入
## 语音输入
讯飞听写通过后端 WebSocket 代理:
- 前端连接 `/api/speech/iat`,不再生成讯飞鉴权 URL也不读取 APPID/APIKey/APISecret。
- 浏览器采集麦克风音频,转换为 16k PCM 后发送音频帧。
- 后端读取 Settings API 中的 `xfSpeechConfig`,连接讯飞 IAT上游首帧由后端补齐 `common.app_id` 和默认 `business` 参数。
- 识别结果由后端转发回前端,并追加到 AI 输入框。
## 保存规则
- 保存草稿不强制患者姓名和住院号。
- 完成报告要求患者姓名和住院号。
- 保存时优先调用 `POST /api/reports``PATCH /api/reports/:id`;后端会先做 HTML 白名单清洗,再写入 PostgreSQL 并维护历史版本。
- 编辑已有已完成报告时,后端会把旧内容追加到历史记录并递增 `revision`
- 只有本地回退开启时API 不可用才保留原有本地 `localStorage.reports` 保存逻辑;生产构建默认关闭这条路径。
- 完成报告后跳转到报告管理页。

View File

@@ -0,0 +1,53 @@
# 模块文档:报告管理与查看
## 涉及文件
- `src/pages/ReportManage.tsx`
- `src/pages/ReportView.tsx`
- `src/api/reports.ts`
- `server/src/reports/*`
- `src/utils/print.ts`
- `src/types.ts`
## 报告管理
`ReportManage` 优先通过 `src/api/reports.ts` 调用 `GET /api/reports` 读取报告列表,后端根据当前 Session 用户做权限过滤:
- 医生只显示本人创建的报告。
- 管理员只显示本部门报告。
- 超级管理员显示全部报告。
前端仍支持按标题、患者姓名、住院号搜索,按状态和时间筛选。只有开发模式或显式开启 `VITE_ENABLE_LOCAL_FALLBACK=true`API 不可用才会回退读取 `localStorage.reports`,用于迁移期旧数据和离线测试。
## 操作能力
- 查看:进入 `/report-view/:id`
- 编辑:进入 `/report-editor?id=...`
- 删除:优先调用 `DELETE /api/reports/:id` 做后端软删除再同步本地兼容缓存只有本地回退开启时API 不可用才会从本地 `reports` 中移除。
- 历史版本:查看 `Report.history`,可恢复某个历史内容到编辑器。
- 导出 PDF调用浏览器打印。
- 导出 JSON下载结构化字段数据。
- 批量导出和批量删除:基于表格选中项操作。
医生只能编辑或删除自己的报告。
## 历史恢复
历史版本恢复时,管理页把目标 HTML 写入 `sessionStorage.restore_${reportId}`,再跳转到编辑器。编辑器读取后填入正文并移除 session 键。
## 报告查看
`ReportView` 优先通过 `GET /api/reports/:id` 获取报告,后端检查查看权限。只有本地回退开启时,失败才会回退本地报告和前端权限判断。报告正文通过 `dangerouslySetInnerHTML` 渲染,保存侧已由后端做第一版 HTML 白名单清洗,页面提供浏览器打印入口。
## 后端报告模型
后端 `Report` 表保存标题、患者姓名、住院号、HTML 正文、作者、部门、状态和修订版本。`Report.metadata` 暂存患者扩展字段;视频和关键帧文件内容写入 `FileResource`,报告中的媒体引用、排序和抽帧信息写入 `ReportMedia` 关系表。Reports API 返回时仍组装成前端兼容的 `videos``capturedFrames` 字段。
## 导出 JSON 内容
当前报告 JSON 导出主要包含:
- `meta`:报告 ID、标题、创建/更新时间、作者和状态。
- `fields`:按 `DEFAULT_FORM_FIELDS` 提取的报告字段。
报告正文 HTML 和视频帧数据不在这个结构化字段导出范围内。

View File

@@ -0,0 +1,60 @@
# 模块文档:系统设置
## 涉及文件
- `src/pages/SystemSettings.tsx`
- `src/api/settings.ts`
- `src/types.ts`
- `src/utils/storage.ts`
- `server/src/settings`
## 模块职责
系统设置负责维护视频抽帧、AI 接口、讯飞语音和默认模板配置。超级管理员可以看到全部设置;其他用户主要配置默认报告模板。
页面优先调用 `GET /api/settings/system` 读取设置,保存时调用 `PATCH /api/settings/system`。只有开发模式或显式开启 `VITE_ENABLE_LOCAL_FALLBACK=true`API 不可用才保留 `localStorage.systemSettings` 回退。后端使用 `SystemSetting` 表保存全局设置,并用 `scope = user:<id>` 保存个人默认模板。
## 视频抽帧配置
配置字段包括:
- `frameCount`:抽取帧数。
- `framePositions`:每帧对应的视频进度百分比。
- `frameMode`:调整帧数时使用整体均匀抽取或保持当前抽帧。
- `autoInsertFrames`:是否自动插入关键帧。
- `autoInsertDelay`:自动插入延迟。
- `autoInsertFrameIndices`:哪些抽帧序号参与自动插入。
保存时会对 `framePositions` 排序,并把 `frameCount` 同步为位置数组长度。
## AI 接口配置
`activeAiProvider` 指向当前服务商,`aiProviders` 保存各服务商配置。默认服务商包括:
- Kimi
- DeepSeek
- OpenAI
- Custom
系统通过后端 `/api/ai/models` 测试连接和获取模型列表,通过 `/api/ai/chat` 在报告编辑器中生成内容。后端读取全局共用 Provider Key 并代理 OpenAI 兼容 `/models``/chat/completions`,普通用户读取设置时不会拿到 AI Key。
## 讯飞语音配置
`xfSpeechConfig` 保存讯飞 WebSocket IAT 所需的 APPID、APIKey 和 APISecret。报告编辑器只连接本系统 `/api/speech/iat`,由后端读取这些配置、生成讯飞鉴权 URL 并转发音频和识别结果。普通用户读取设置时不会拿到 APIKey/APISecret。
## 默认模板
`defaultTemplate` 指向模板 ID。新建报告时如果当前用户可见该模板编辑器会自动加载对应模板内容否则回退到默认内置报告内容。
## 重置能力
超级管理员可执行:
- 恢复系统设置出厂设置:优先调用 `POST /api/settings/system/reset`,只有本地回退开启时失败才重置本地 `systemSettings`
- 重置全部数据:仅本地回退开启时执行 `localStorage.clear()` 并刷新;生产构建默认阻止把本地清空误认为后端数据重置。
## 注意事项
- `systemSettings` 的本地混淆不等于安全加密;当前仅作为开发/显式本地回退模式下 API 不可用时的兼容缓存。
- 初始化、类型和系统设置页面已统一使用 `xfSpeechConfig`;当前由后端语音代理使用。
- 默认 API Key 或语音密钥不应留在生产前端代码中。

View File

@@ -0,0 +1,88 @@
# 模块文档:模板管理
## 涉及文件
- `src/pages/TemplateManage.tsx`
- `src/api/templates.ts`
- `src/api/library.ts`
- `src/api/files.ts`
- `server/src/templates/*`
- `server/src/library/*`
- `server/src/files/*`
- `src/utils/defaultContent.ts`
- `src/utils/print.ts`
- `src/types.ts`
## 模块职责
模板管理用于维护报告模板 HTML、字段库、图片资源、AI 可编辑区域和模板导入导出。医生不可进入该模块。
## 模板列表
模板列表优先通过 `GET /api/templates?access=manage` 读取,后端根据当前 Session 用户过滤可管理模板:
- 超级管理员可管理全部模板。
- 管理员可管理本部门模板或被授权可管理模板。
- 医生不能进入模板管理页,但可以通过报告编辑器新建自己的个人模板。
只有开发模式或显式开启 `VITE_ENABLE_LOCAL_FALLBACK=true`API 不可用才回退读取 `localStorage.templates`用于迁移期旧数据和离线测试。API 返回空列表会按真实空数据展示,不再自动把本地模板当作生产数据。
支持:
- 新增模板。
- 编辑模板名称和描述。
- 删除或批量删除模板。
- 保存当前模板内容。
- 打印模板预览。
- 单个导出和批量导出 JSON。
- 从 JSON 模板包导入。
新增、编辑、保存内容和删除模板会优先调用后端 `POST/PATCH/DELETE /api/templates`,后端会对模板 HTML 做白名单清洗;成功后同步 `localStorage.templates` 作为兼容缓存。只有本地回退开启时API 失败才允许写本地模板。
## 模板内容编辑
模板正文是 `contentEditable` HTML。工具栏支持常见富文本命令、表格、图片占位符和 AI 区域插入。编辑器内部维护 undo/redo 栈,并根据内容高度扩展 A4 页面高度。
## 字段库
字段定义优先通过 `GET/PATCH /api/library/fields` 读写,后端保存到 `SystemSetting.key = fieldLibrary``formFieldsConfig` 继续作为迁移期兼容缓存,并仅在本地回退开启且 API 不可用时作为回退。字段支持:
- 显示/隐藏。
- 系统锁定字段保护。
- 文本、单选、多选、日期、时间、签名、图片等类型。
- 选项维护。
- 时间格式和默认值。
- 下划线样式。
插入字段时,模板会写入带 `data-bind``.field-value`,报告编辑器按该 key 进行表单同步。
## 图片资源
模板图片资源优先通过通用文件 API 上传和读取:
- `GET /api/files?kind=TEMPLATE_ASSET`
- `POST /api/files`
- `DELETE /api/files/:id`
页面仍把资源同步到 `imageAssets` 作为迁移期兼容缓存。只有本地回退开启时API 不可用的上传图片才会回退为本地 Data URL。
## 后端模板模型
后端 `Template` 表保存模板名称、描述、HTML 正文、字段 JSON、模板范围、归属部门或归属用户。部门授权由 `TemplateDepartmentPermission` 表维护,支持 `canUse``canManage`。医生个人模板使用 `scope = PERSONAL``ownerUserId` 归属本人。
## 导入导出格式
单模板导出大致结构:
```json
{
"version": "1.0",
"type": "surclaw_template_package",
"title": "模板名称",
"description": "模板描述",
"content": "模板 HTML",
"fields": []
}
```
批量导出使用 `type: "surclaw_template_package_batch"`,包含 `templates` 数组。当前导入逻辑只接受单模板包。

280
docs/permissions.md Normal file
View File

@@ -0,0 +1,280 @@
# 权限设计
本文档描述后端化后的目标权限模型。当前版本登录认证已接入后端,但角色和部分页面级过滤仍主要在前端执行,不能作为生产安全边界;后端化后必须由服务端统一鉴权和授权。
## 已确定规则
- 超级管理员拥有全权限,可以查看和修改系统内任何数据。
- 超级管理员默认属于 `admin` 部门,但查询、管理和授权不受部门限制。
- 管理员只能查看、编辑和删除本部门报告。
- 医生默认只能查看、编辑和删除本人报告。
- 医生完成报告后仍允许修改,也允许删除本人已完成报告。
- 完成报告后的每次修改都需要增加修订版本号,方便医疗文书追踪。
- 模板按部门授权。
- 管理员可以修改本部门模板,也可以使用被授权模板。
- 医生不可以直接修改部门模板,但可以复制/新建自己的模板并修改。
- 只有超级管理员能创建或授权管理员。
- 一个部门只能有一个管理员。
- 不设置患者维度权限。
- 部门之间不存在上级/下级关系。
- 不需要临时授权或会诊授权。
- 不允许同一用户属于多个部门。
- AI 代理只能接收当前报告内容作为上下文,不能读取跨部门报告。
- 报告导出不需要水印、导出原因、审批或专门导出审计。
- 前端菜单隐藏只是体验优化,所有权限必须在后端 API 层强校验。
## 角色定义
| 角色 | 代码建议 | 权限定位 |
| --- | --- | --- |
| 超级管理员 | `super` | 系统最高权限,跨部门、跨模板、跨用户管理。 |
| 管理员 | `admin` | 部门级管理者,只管理本部门业务数据;每个部门最多一个管理员。 |
| 医生 | `doctor` 或沿用 `user` | 普通报告创建者,主要管理本人报告,可复制或新建个人模板。 |
当前前端代码使用 `user` 表示医生。后端化时可以继续沿用 `user`,也可以迁移为更清晰的 `doctor`。如果迁移,需要同步前端类型、接口返回和历史数据。
## 数据边界
后端数据至少需要以下边界字段:
- `tenant_id`:医院或部署实例。单医院也建议预留。
- `department_id`:科室。
- `user_id`:用户。
- `role`:用户角色。
报告、模板、用户、文件、操作日志都应带上 `tenant_id`。报告必须带 `department_id`。模板可以带拥有部门,也可以通过授权表关联部门。
组织规则:
- 每个用户只能属于一个部门。
- 超级管理员默认部门为 `admin`
- 部门没有上下级层级。
- 一个部门最多一个管理员,后端创建/修改管理员时必须校验唯一性。
## 报告权限
### 查看报告
| 角色 | 规则 |
| --- | --- |
| 超级管理员 | 可查看所有报告。 |
| 管理员 | 只能查看本部门报告。 |
| 医生 | 只能查看本人报告。 |
建议后端查询规则:
```text
super: WHERE tenant_id = current.tenant_id
admin: WHERE tenant_id = current.tenant_id AND department_id = current.department_id
doctor: WHERE tenant_id = current.tenant_id AND author_id = current.user_id
```
### 新建报告
| 角色 | 规则 |
| --- | --- |
| 超级管理员 | 可为任意部门创建报告,默认部门为 `admin`,也可在表单中选择目标部门。 |
| 管理员 | 可在本部门创建报告。 |
| 医生 | 可为自己创建报告,部门取当前用户部门。 |
建议新建报告时由后端写入 `author_id``department_id``tenant_id`,不要完全信任前端传入。
### 编辑报告
| 角色 | 规则 |
| --- | --- |
| 超级管理员 | 可编辑任何报告。 |
| 管理员 | 可编辑本部门报告。 |
| 医生 | 可编辑本人报告,包括已完成报告。 |
完成报告后仍允许修改。后端必须:
- 每次保存创建 `report_histories` 记录。
- 已完成报告每次修改递增修订版本号。
- 记录修改人、修改时间、修改动作和旧版本内容或旧版本引用。
### 删除报告
| 角色 | 规则 |
| --- | --- |
| 超级管理员 | 可删除任何报告。 |
| 管理员 | 可删除本部门报告,包括已完成报告。 |
| 医生 | 可删除本人报告,包括已完成报告。 |
实现建议:
- 后端可以先做软删除,保留 `deleted_at``deleted_by`,避免误删后无法恢复。
- 如果未来接入正式病历归档流程,再考虑把“删除”改为“作废/归档”。
### 导出报告
| 角色 | 规则 |
| --- | --- |
| 超级管理员 | 可导出所有报告。 |
| 管理员 | 可导出本部门报告。 |
| 医生 | 可导出本人报告。 |
当前确定:报告导出不需要水印、导出原因、审批或专门导出审计。
## 模板权限
已确定:模板按部门授权。
建议数据模型:
- `templates`:模板主体。
- `departments`:部门。
- `template_department_permissions`:模板与部门授权关系。
- `user_templates` 或在 `templates` 中增加 `owner_user_id`:医生复制/新建的个人模板。
建议权限规则:
| 角色 | 查看/使用模板 | 管理模板 |
| --- | --- | --- |
| 超级管理员 | 全部模板 | 全部模板,可授权给部门。 |
| 管理员 | 本部门模板和被授权模板 | 可修改本部门模板,可使用被授权模板。 |
| 医生 | 本部门被授权模板、自己的个人模板 | 不可修改部门模板;可复制/新建并修改自己的个人模板。 |
新建报告时,后端必须校验当前用户是否有权使用该模板。
模板授权粒度建议:
- 部门授权模板用于“部门可见/可使用”。
- 模板归属部门用于判断管理员是否可以管理。
- 医生个人模板只归属创建医生本人,不自动成为部门模板。
## 用户权限
| 角色 | 用户管理权限 |
| --- | --- |
| 超级管理员 | 创建、编辑、禁用、删除所有用户,分配部门和角色,创建或授权管理员。 |
| 管理员 | 管理本部门医生;不能创建管理员;不能跨部门管理用户。 |
| 医生 | 只能查看和修改自己的基础资料,密码修改走单独接口。 |
管理员规则:
- 只有超级管理员能创建或授权管理员。
- 一个部门只能有一个管理员。
- 管理员不能创建超级管理员。
## 系统设置权限
| 设置项 | 超级管理员 | 管理员 | 医生 |
| --- | --- | --- | --- |
| AI Provider 和密钥 | 可管理 | 不可管理 | 不可管理 |
| 讯飞语音密钥 | 可管理 | 不可管理 | 不可管理 |
| 全局抽帧策略 | 可管理 | 不可管理 | 不可管理 |
| 部门默认模板 | 可管理全部部门 | 可管理本部门 | 不可管理 |
| 个人默认模板 | 可配置自己默认值 | 可配置自己默认值 | 可配置自己默认值 |
建议把设置拆为:
- 全局设置:超级管理员维护。
- 部门设置:超级管理员或本部门管理员维护。
- 用户偏好:每个用户自己维护。
## 文件权限
文件包括签名、模板图片、报告图片、视频、关键帧、导出文件。
建议规则:
- 签名文件:用户本人、本部门管理员、超级管理员可访问。
- 报告相关文件:继承报告权限。
- 模板图片:继承模板权限。
- 视频文件:继承报告权限,且建议限制下载或设置过期访问 URL。
- 导出文件:继承报告导出权限;不要求专门导出审计。
## 操作记录与历史
虽然报告导出不需要专门审计,但以下写操作仍建议记录基础操作日志,便于排查和追踪:
- 登录成功/失败。
- 新建、保存、完成、修改已完成报告。
- 删除报告。
- 新建、修改、删除模板。
- 模板部门授权变更。
- 新建、禁用、删除、改角色用户。
- 修改 AI/语音密钥。
- 修改系统设置。
报告历史是业务必需项:
- 报告每次保存保留历史版本。
- 已完成报告每次修改递增修订版本号。
- 历史记录应包含修改人、修改时间、修改动作、旧内容或旧内容引用。
日志建议包含:
- `tenant_id`
- `actor_user_id`
- `actor_role`
- `action`
- `target_type`
- `target_id`
- `department_id`
- `ip`
- `user_agent`
- `created_at`
- `metadata`
敏感病历正文和完整密钥不应写入普通日志;报告正文版本应放在报告历史表或对象存储中。
## AI 权限
AI 代理只能接收当前报告内容作为上下文:
- 不允许读取跨部门报告。
- 不允许自动检索其他报告作为上下文。
- 不需要患者维度权限。
- AI 请求由后端代理发出,前端不接触真实 API Key。
如果用户在当前报告中选择了图片、关键帧或正文片段,这些内容必须先通过当前报告权限校验。
## API 权限落点
后端每个接口都要做权限校验。建议先在服务层写统一 guard/policy
- `canViewReport(user, report)`
- `canCreateReport(user, departmentId)`
- `canEditReport(user, report)`
- `canDeleteReport(user, report)`
- `canExportReport(user, report)`
- `canUseTemplate(user, template)`
- `canManageTemplate(user, template)`
- `canCreatePersonalTemplate(user)`
- `canManageUser(actor, targetUser)`
- `canCreateAdmin(actor, departmentId)`
- `canManageSettings(user, scope, departmentId?)`
列表接口不能只在前端过滤,必须在数据库查询条件中限制可见范围。
## 已关闭问题
以下问题已经确认,不再作为阻塞项:
| 问题 | 结论 |
| --- | --- |
| 管理员是否可以编辑本部门医生创建的报告? | 可以。 |
| 管理员是否可以删除本部门已完成报告? | 可以。 |
| 医生是否可以删除本人已完成报告? | 可以。 |
| 管理员是否可以创建其他管理员? | 不可以,只有超级管理员能创建/授权管理员。 |
| 一个部门是否只能有一个管理员? | 是。 |
| 管理员是否可以管理本部门模板内容? | 可以修改本部门模板,也可以使用被授权模板。 |
| 模板授权粒度如何处理? | 部门模板按部门授权;医生不可改部门模板,但可复制/新建个人模板。 |
| 超级管理员是否属于某个部门? | 默认属于 `admin` 部门。 |
| 是否需要患者维度权限? | 不需要。 |
| 报告完成后修改是否需要修订版本号? | 需要。 |
| 报告导出是否需要水印、原因、审批或专门审计? | 不需要。 |
| AI 能否读取跨部门报告作为上下文? | 不能,只能接收当前报告内容。 |
| 部门是否有上下级关系? | 没有。 |
| 是否需要临时授权或会诊授权? | 不需要。 |
| 是否允许同一用户属于多个部门? | 不允许。 |
## 后续实现注意
- 数据库应对“一个部门一个管理员”设置唯一约束或事务校验。
- 报告列表、模板列表、用户列表必须在 SQL 查询层过滤权限。
- 医生个人模板和部门模板需要在数据模型上区分。
- 已完成报告的修改需要版本号和历史记录,不应只覆盖最新内容。
- 删除报告建议先做软删除,后续如有病历归档要求再调整为作废流程。

74
docs/progress.md Normal file
View File

@@ -0,0 +1,74 @@
# 进度文档
## 当前状态
项目已具备一套可运行的迁移期闭环:登录认证、数据库 Session、工作台统计、报告读写、报告媒体关系、模板读写、字段库、模板图片资源、视频/关键帧文件、用户管理、部门模板授权、系统设置、签名文件、AI 对话代理和讯飞语音代理已接入后端。
- 登录已接入后端 Session APISession 已持久化到 `AppSession` 表;默认业务数据初始化仅作为开发本地回退存在。
- 工作台统计已接入 `GET /api/dashboard/stats`
- 报告编辑、模板选择、字段绑定、富文本编辑已实现。
- 视频上传、自动抽帧、手动截帧、关键帧插入已实现;视频和关键帧优先上传到后端 Files API。
- AI 对话、AI 区域改写、差异确认和调用日志已实现AI 对话已改为后端 `/api/ai/chat` 代理。
- 讯飞语音听写入口已实现,并已改为后端 `/api/speech/iat` WebSocket 代理。
- 报告管理、查看、历史恢复、打印、JSON/PDF 导出已实现。
- 报告 API 已实现列表、详情、创建、保存、完成修订、历史记录和软删除;`ReportManage``ReportView``ReportEditor` 已优先调用后端,只有开发/显式回退模式下才保留本地回退。
- 模板管理、字段库、模板导入导出已实现;模板 API 已支持可用/可管理列表、详情、创建、更新、删除和个人模板;字段库已优先接入 `/api/library/fields`
- 用户管理、部门管理员约束和部门模板授权已优先接入后端 Users/Departments API签名上传和模板图片资源已通过 Files API 写入后端文件资源。
- 系统设置、抽帧策略、AI Provider、语音参数和默认模板已优先接入 Settings API只有开发/显式回退模式下才保留本地缓存回退。
- Docker/Nginx 静态部署配置已存在。
- 开发端口已调整为 `3001`
- 已补充 Vitest 测试框架和核心功能单元/组件测试。
- 已补充功能盘点,区分真实功能、外部集成、前端演示和预留项。
- 前端权限 mock 已对齐目标规则:超级管理员全部、管理员本部门、医生本人。
- 报告已增加修订版本号,已完成报告再次保存会递增版本。
- 报告编辑器已支持医生保存“我的个人模板”。
- 讯飞语音配置初始化字段已统一为 `xfSpeechConfig`
- 已新增 Playwright E2E用 localStorage 种子锁定登录、报告权限、报告修订版本和医生个人模板流程。
- 已新增 API 契约草案,为后端化接口、权限过滤和响应格式提供基线。
- 已启动后端化第一阶段:新增 NestJS API 骨架、Prisma/PostgreSQL 数据模型、默认 seed、健康检查、认证接口和后端权限策略测试。
- 已完成前端认证闭环第一步:新增 API client、Auth Context、后端用户兼容映射`Login`/`Sidebar` 已接入后端认证并同步 `currentUser`
## 已知风险
- 前端仍有部分体验控制和兼容缓存依赖浏览器本地数据;后端已经承载主业务数据和第一版审计。
- 后端认证、Dashboard API、报告 API、报告媒体关系、模板 API、字段库 API、用户/部门 API、设置 API、通用文件/签名文件 API、AI 代理和语音代理已可用;审计和 HTML 清洗已有第一版,导出/查看审计仍待加强。
- 本地存储仍可能包含病历兼容缓存、旧演示密码字段、模板图片和关键帧,不适合生产;历史浏览器数据中也可能残留旧版语音服务密钥。
- `systemSettings` 的混淆存储不是加密。
- 旧版本曾在前端默认配置中包含服务密钥痕迹;当前源码默认值已清空,但生产化前仍应轮换曾经暴露过的第三方密钥。
- 报告正文和模板正文保存时已做服务端白名单清洗,但仍以 HTML 字符串存储并通过 `dangerouslySetInnerHTML` 渲染,需要持续安全测试。
- 大视频和大量 Base64 图片会快速占满浏览器存储空间。
- `document.execCommand` 已是过时 API但当前编辑器大量依赖它。
- README 曾与源码账号不一致,当前已同步为 `0001 / 123456`
## 建议下一步
1. 补后端认证自动化集成测试健康检查、登录、me、logout、密码错误、禁用账号。
2. 补报告 API 自动化集成测试:按角色列表过滤、无权详情、创建、完成修订、软删除。
3. 补模板 API 自动化集成测试:按 `access=use/manage` 过滤、管理员创建部门模板、医生创建个人模板、无权删除。
4. 补用户/部门 API 自动化集成测试:用户列表范围、管理员唯一性、医生创建约束、部门模板授权。
5. 补设置/签名文件 API 自动化集成测试:设置保存、非超级管理员默认模板、签名上传和受控读取。
6. 逐步替换前端数据流:继续减少字段库、模板图片和报告编辑器草稿之外的本地回退依赖。
7. 补查看日志、第三方代理调用摘要、后端导出 API 和限流;报告导出不要求专门导出审计。
8. 增加数据迁移:为 `localStorage` 或后端数据增加版本号和迁移脚本。
## 维护记录
| 日期 | 事项 |
| --- | --- |
| 2026-05-01 | 新增 `docs/` 文档结构,梳理当前需求、设计、模块和风险。 |
| 2026-05-01 | 本地开发端口从 `3000` 调整为 `3001`。 |
| 2026-05-01 | 新增功能盘点和测试文档,补充 Vitest 测试覆盖核心功能契约。 |
| 2026-05-01 | 对齐前端权限 mock、报告修订版本和医生个人模板统一 `xfSpeechConfig` 字段。 |
| 2026-05-01 | 新增 Playwright E2E 和 API 契约草案,锁定后端化前的关键前端行为。 |
| 2026-05-01 | 新增 NestJS + Prisma 后端骨架、PostgreSQL schema、默认 seed、健康检查、认证接口和后端权限策略测试。 |
| 2026-05-01 | 前端新增 API client、Auth Context 和后端用户兼容映射,登录/退出/恢复会话接入后端认证。 |
| 2026-05-01 | 新增后端 Reports API、`Report.metadata` 兼容字段和报告前端 API 接入,报告读写优先走后端。 |
| 2026-05-01 | 新增后端 Templates API、默认模板 seed 和模板前端 API 接入,模板读写优先走后端。 |
| 2026-05-01 | 新增后端 Users/Departments API 和用户管理页 API 接入,部门模板授权优先由后端维护。 |
| 2026-05-02 | 新增后端 Settings API、签名文件 API 和前端接入,系统设置与电子签名优先走后端。 |
| 2026-05-02 | 新增后端 AI Proxy报告编辑器和模型测试改为调用 `/api/ai/chat``/api/ai/models`。 |
| 2026-05-02 | 新增后端讯飞语音 WebSocket Proxy报告编辑器改为调用 `/api/speech/iat`,普通用户读取设置时不返回语音密钥。 |
| 2026-05-02 | 新增后端字段库 API、通用文件 API 和 HTTP 集成测试,模板图片资源优先写入后端文件资源。 |
| 2026-05-02 | 报告编辑器视频和关键帧优先上传 Files API新增真实 PostgreSQL 服务集成测试。 |
| 2026-05-02 | 新增 `ReportMedia` 表和迁移,报告视频/关键帧引用从 `Report.metadata` 拆出。 |
| 2026-05-02 | 新增 Dashboard API、数据库 Session Store、审计服务、HTML 白名单清洗、本地回退开关和 Docker 上传目录 volume清理 Gemini 旧依赖。 |

75
docs/requirements.md Normal file
View File

@@ -0,0 +1,75 @@
# 需求文档
## 项目目标
手术图文病历报告系统用于医院或科室内网场景,提供手术记录的图文报告撰写、模板维护、报告管理、用户权限控制、视频关键帧抽取和 AI 辅助撰写能力。
当前实现处于后端化迁移期:登录认证、数据库 Session、工作台统计、报告、模板、字段库、模板图片资源、视频/关键帧文件、用户管理、部门模板授权、系统设置、签名文件、AI 对话和语音听写已接入后端,并已有第一版写操作审计。它适合演示、单机试用或迁移期验证,尚未具备完整生产运维能力。
## 用户角色
| 角色 | 代码值 | 当前能力 |
| --- | --- | --- |
| 超级管理员 | `super` | 管理全部用户、模板、系统设置和全部报告。 |
| 管理员 | `admin` | 管理同部门医生、管理授权模板、查看管理范围内功能。 |
| 医生 | `user` | 新建和管理本人报告,使用授权模板,配置默认模板。 |
## 功能需求
### 登录与初始化
- 首次进入登录页时初始化默认模板、默认用户、表单字段配置、图片资源和系统设置。
- 支持默认测试账号快捷登录。
- 登录调用后端 `/api/auth/login`,后端通过 Session Cookie 维持登录态。
- 用户状态为 `inactive` 时后端禁止登录,前端展示禁用提示。
### 图文报告生成
- 支持选择默认或可见模板创建报告。
- 支持基本信息表单与正文中的 `data-bind` 智能字段双向同步。
- 支持富文本编辑、表格、图片占位符和 AI 可编辑区域。
- 支持上传视频,按系统设置的百分比位置自动抽帧。
- 支持手动截帧、拖拽关键帧到报告占位符、自动插入选定关键帧。
- 支持 AI 对话和 AI 区域改写,改写前显示差异确认。
- 支持通过后端代理进行讯飞语音听写,把识别文本追加到 AI 输入框。
- 支持保存草稿、完成报告、打印和导出。
### 报告管理
- 支持按标题、患者姓名、住院号搜索。
- 支持按状态和时间范围筛选。
- 医生只能看到本人报告;管理员和超级管理员可看到更多报告。
- 支持查看、编辑、删除、历史版本恢复。
- 支持单份或批量导出 PDF/JSON。
### 模板管理
- 支持模板新增、编辑、删除、批量删除。
- 支持模板内容富文本编辑、智能字段插入、图片占位符、AI 可编辑区域。
- 支持模板导入导出 JSON 包。
- 支持表单字段库维护,包括字段显示、选项、时间格式、默认值和下划线样式。
- 新增模板后会同步当前用户或部门用户的模板权限。
### 用户管理
- 超级管理员可管理系统用户,并给管理员分配可管理模板。
- 管理员只能管理同部门医生或自己。
- 同一部门只能存在一个管理员,后端创建或修改管理员时会校验。
- 支持上传用户签名图片到后端文件资源,报告中可通过 `surgeonSignature` 字段展示。
- 禁用管理员类账号需要授权密钥。
### 系统设置
- 超级管理员可配置抽帧数量、抽帧百分比、抽帧计算模式和自动插入策略。
- 超级管理员可配置 AI 服务商、接口地址、API Key 和模型名。
- 超级管理员可配置讯飞语音听写参数。
- 所有角色可设置默认报告模板。
- 超级管理员可恢复系统设置;清空全部本地数据仅作为开发/显式本地回退模式下的浏览器缓存操作,不代表清空后端业务数据。
## 非功能需求与约束
- 当前版本对报告、报告媒体引用、模板、字段库、模板图片、视频/关键帧文件、用户、部门权限、系统设置和签名支持多端后端同步。
- 视频仍以对象 URL 方式在浏览器内即时预览,同时后台优先上传服务器;大文件上传、断点续传和独立媒体关系表尚未完成。
- 报告内容使用 HTML 字符串存储,存在 XSS 和内容污染风险。
- 后端账号密码已使用哈希存储AI/语音密钥已由后端代理使用;但开发回退模式和历史浏览器缓存中仍可能存在 `localStorage.users` 演示密码字段或旧版密钥,不满足生产安全要求。
- 医疗病历属于敏感数据,生产化前必须补齐完整服务端授权、审计、加密、备份和文件存储。

24
docs/security.md Normal file
View File

@@ -0,0 +1,24 @@
# 安全说明
## 当前安全边界
本项目当前处于后端化迁移期。登录认证、数据库 Session、工作台统计、报告、报告媒体关系、模板、字段库、模板图片资源、视频/关键帧文件、用户、部门模板授权、系统设置、签名文件、AI 对话和讯飞语音听写已接入后端 Session/API/代理,后端账号密码使用 Argon2 哈希。客户端权限只能防止普通误操作,不能抵御有意绕过。
## 主要风险
- 开发模式 `localStorage.users` 仍保留兼容缓存和旧演示密码字段;生产构建默认关闭本地回退。
- AI Key 和讯飞语音密钥已由后端代理使用;普通用户读取系统设置时不会返回真实密钥。超级管理员仍可维护全局密钥,应避免把密钥写入源码、日志或文档。
- 旧版本源码中存在默认服务密钥痕迹,应视为已暴露;当前默认值已清空,但生产化前仍需轮换曾经暴露过的第三方密钥。
- 报告和模板 HTML 保存时已做服务端白名单清洗,但仍直接渲染 HTML需要继续做绕过测试和打印兼容测试。
- 浏览器存储没有审计、备份、权限隔离和加密能力。
- 医疗病历数据属于敏感数据,不能直接用于公网生产。
## 生产化建议
1. 完善服务端权限校验:报告、模板、用户、设置和文件 API 必须全部按角色、部门和作者强校验。
2. 增加服务端数据层:视频和关键帧文件已完成第一版 Files API报告媒体引用已拆到 `ReportMedia`;字段库和模板图片资源已完成第一版 API。
3. 增加文件服务:报告图片、视频和关键帧后续可切换对象存储或院内文件服务;签名、模板图片、视频和关键帧已完成第一版后端文件资源。
4. 完善 API 代理AI 和语音已完成第一版后端代理,后续应补限流、审计、错误分级和第三方调用隔离测试。
5. 增强 HTML 清洗:当前报告和模板保存已有第一版白名单过滤,后续需覆盖 AI 返回、导入文件、旧数据迁移和绕过测试。
6. 增加审计日志:当前登录、报告/模板/用户/设置/文件修改已有第一版审计,后续补查看日志、打印/导出错误追踪和第三方代理调用摘要;报告导出不要求专门导出审计。
7. 增加数据备份与恢复:避免浏览器清理缓存造成业务数据丢失。

110
docs/testing.md Normal file
View File

@@ -0,0 +1,110 @@
# 测试文档
## 测试命令
```bash
npm run lint
npm run test
npm run test:e2e
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` 验证。
## 测试范围
| 范围 | 当前覆盖 |
| --- | --- |
| 数据初始化 | 登录页首次加载会创建默认用户、模板、字段和系统设置。 |
| 前端 API client | 统一解包 `{ data }` 响应、携带 Cookie credentials、识别后端错误 envelope。 |
| 前端 Dashboard API | 工作台统计封装会请求 `/api/dashboard/stats` 并校验响应结构。 |
| 前端语音代理地址 | 根据当前页面来源或 `VITE_API_BASE_URL` 生成 `/api/speech/iat` WebSocket 地址。 |
| 前端字段库和文件 API | 字段库读取/更新、通用文件列表/上传封装。 |
| Auth 兼容映射 | 后端 `doctor` 角色会映射为当前前端使用的 `user`,并保留本地签名和模板授权。 |
| 权限展示 | 侧边栏会按角色显示或隐藏模板管理、用户管理等入口。 |
| 报告权限 | 医生只看到本人报告,管理员看到本部门报告,超级管理员看到全部本地报告。 |
| 后端报告映射 | Report API 使用 `metadata` 兼容前端扩展字段,使用 `ReportMedia` 组装视频/关键帧兼容字段。 |
| 模板权限 | 医生可使用本部门授权模板和个人模板,不能使用他人个人模板。 |
| 后端模板映射 | Template API 返回前端可消费的 `Template` 结构,并生成权限策略资源。 |
| 后端用户映射 | Users API 返回前端可消费的 `User` 结构,并把部门模板授权映射成 `visibleTemplates/manageableTemplates`。 |
| 后端设置校验 | Settings API 使用 schema 校验抽帧、AI Provider 和语音配置。 |
| 后端 AI 代理入参 | AI Proxy 使用 schema 校验 OpenAI 兼容消息和多模态内容。 |
| 后端语音代理帧处理 | Speech Proxy 对首个讯飞 IAT 音频帧补齐 APPID 和默认业务参数,后续帧保持兼容。 |
| 后端字段库和文件 schema | Library/Files API 校验字段库和通用文件上传 payload。 |
| 后端 HTTP 集成 | Nest HTTP 层覆盖 API prefix、登录会话、未登录保护、受保护接口 actor 传递。 |
| 后端真实数据库集成 | 使用 Prisma/PostgreSQL 覆盖 Auth、Dashboard、Reports、ReportMedia、Templates、Files、HTML 清洗和审计核心服务。 |
| 存储封装 | 常规 JSON 存储、`systemSettings` 混淆、历史明文兼容、session 恢复键。 |
| 默认模板契约 | 智能字段、图片占位符、AI 区域、默认字段和 AI Provider 配置。 |
| 打印入口 | `printDocument` 会创建隐藏 iframe、写入报告 HTML 并调用浏览器打印。 |
| E2E 登录流程 | Playwright 验证默认快捷登录进入工作台。 |
| E2E 权限过滤 | Playwright 验证超级管理员、管理员、医生在报告管理页的可见范围。 |
| E2E 报告修订 | Playwright 验证已完成报告再次完成保存后 `revision` 递增并保留历史。 |
| E2E 个人模板 | Playwright 验证医生可保存个人模板且模板仅归属本人。 |
| 后端权限策略 | Vitest 验证报告、模板、用户和管理员创建权限策略。 |
## Mock 边界
测试中 mock 了以下浏览器或外部边界:
- `fetch('/logo_square.png')`:用于登录初始化图片资源。
- `/api/auth/me``/api/auth/login`:组件测试和登录 E2E 中使用 mock避免单元/E2E 依赖真实数据库。
- `window.alert` / `window.confirm`:避免测试阻塞。
- `document.execCommand`:当前富文本编辑依赖浏览器命令。
- `URL.createObjectURL` / `URL.revokeObjectURL`:用于文件、视频和导出。
- iframe `contentWindow.print`:用于验证打印入口,不生成真实 PDF。
AI 第三方接口、讯飞语音上游 WebSocket、麦克风权限和真实视频 canvas 抽帧没有在自动化测试中直连原因是它们依赖第三方服务、硬件权限或真实媒体解码。AI 代理已有 schema 测试,语音代理已有首帧处理测试,后续应增加隔离测试密钥的 HTTP/WebSocket 集成测试。
## 测试清单
| 功能 | 测试状态 | 说明 |
| --- | --- | --- |
| 登录默认数据初始化 | 已覆盖 | `Login.test.tsx` |
| 后端登录禁用账号错误 | 已覆盖 | `Login.test.tsx` |
| API client 响应/错误处理 | 已覆盖 | `api/client.test.ts` |
| Dashboard API 封装 | 已覆盖 | `api/dashboard.test.ts` |
| 语音 WebSocket 代理地址 | 已覆盖 | `api/speech.test.ts` |
| 字段库 API 封装 | 已覆盖 | `api/library.test.ts` |
| 通用文件 API 封装 | 已覆盖 | `api/files.test.ts` |
| 后端用户到前端用户映射 | 已覆盖 | `auth/backendUser.test.ts` |
| 角色菜单权限 | 已覆盖 | `Sidebar.test.tsx` |
| 报告列表角色过滤 | 已覆盖 | `ReportManage.test.tsx` |
| 报告权限工具 | 已覆盖 | `permissions.test.ts` |
| 模板权限和个人模板 | 已覆盖 | `permissions.test.ts` |
| 本地存储读写与容错 | 已覆盖 | `storage.test.ts` |
| 系统设置混淆兼容 | 已覆盖 | `storage.test.ts` |
| 默认报告模板结构 | 已覆盖 | `defaultContent.test.ts` |
| 默认字段和 AI Provider | 已覆盖 | `defaultContent.test.ts` |
| 打印导出入口 | 已覆盖 | `print.test.ts` |
| 默认快捷登录 | 已覆盖 | `e2e/login.spec.ts` |
| 报告权限 E2E | 已覆盖 | `e2e/report-permissions.spec.ts` |
| 报告修订版本 E2E | 已覆盖 | `e2e/report-revision.spec.ts` |
| 医生个人模板 E2E | 已覆盖 | `e2e/personal-template.spec.ts` |
| 后端报告/模板/用户权限策略 | 已覆盖 | `server/src/permissions/permissions.policy.test.ts` |
| 后端报告兼容映射 | 已覆盖 | `server/src/reports/report.mapper.test.ts` |
| 后端模板兼容映射 | 已覆盖 | `server/src/templates/template.mapper.test.ts` |
| 后端用户兼容映射 | 已覆盖 | `server/src/users/users.mapper.test.ts` |
| 后端系统设置 schema | 已覆盖 | `server/src/settings/settings.schemas.test.ts` |
| 后端 AI 代理 schema | 已覆盖 | `server/src/ai/ai.schemas.test.ts` |
| 后端语音代理首帧处理 | 已覆盖 | `server/src/speech/xf-frame.test.ts` |
| 后端字段库 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 清洗和审计写入。 |
| 后端健康检查和认证 API | 已覆盖 | HTTP 集成测试覆盖健康检查、登录 session 和未登录保护;真实数据库集成覆盖 Argon2 登录、禁用账号和数据库 Session Store。 |
| 模板编辑器深度交互 | 待 E2E | 依赖 contentEditable 和 execCommand。 |
| 报告编辑器完整流程 | 部分覆盖 | 已覆盖保存修订版本和个人模板;模板切换、字段同步仍待补。 |
| 视频抽帧 | 待 E2E/人工 | 依赖真实视频解码和 canvas。 |
| AI 撰写 | 待集成测试 | 需要隔离外部模型服务。 |
| 讯飞语音听写 | 部分覆盖/待集成测试 | 已覆盖后端首帧处理;完整链路仍需要 WebSocket 集成测试、麦克风权限和测试凭证。 |
## Playwright 说明
首次运行如果提示浏览器缺失,执行:
```bash
npx playwright install chromium
```
当前 E2E 测试不会依赖真实后端数据库:登录 E2E mock 认证 API其余业务 E2E 通过 `e2e/helpers.ts` 写入 localStorage 种子数据。后端化后应保留这些用户流程测试,并逐步把数据准备方式从 localStorage seed 改为 API seed 或测试数据库 seed。

77
e2e/helpers.ts Normal file
View File

@@ -0,0 +1,77 @@
import { 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 baseTemplates = [
{
id: 'dept_surgery',
name: '外科部门模板',
desc: 'E2E 外科模板',
content: '<p><span class="field-value" data-bind="patientName" contenteditable="true"></span></p><div class="ai-region" data-ai-id="手术步骤"><div class="ai-content"><p>默认内容</p></div></div>',
createdAt: '2026-05-01T00:00:00.000Z',
author: 'admin',
scope: 'department',
department: '外科',
},
{
id: 'dept_internal',
name: '内科部门模板',
desc: 'E2E 内科模板',
content: '<p>内科模板</p>',
createdAt: '2026-05-01T00:00:00.000Z',
author: 'admin',
scope: 'department',
department: '内科',
},
];
export const seedLocalStorage = async (page: Page, data: Record<string, unknown>) => {
await page.addInitScript((seed) => {
window.localStorage.clear();
window.sessionStorage.clear();
for (const [key, value] of Object.entries(seed)) {
window.localStorage.setItem(key, JSON.stringify(value));
}
}, data);
};

40
e2e/login.spec.ts Normal file
View File

@@ -0,0 +1,40 @@
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.getByText('admin / 123456').click();
await expect(page.getByRole('heading', { name: '工作台概览' })).toBeVisible();
await expect(page.getByText('超级管理员')).toBeVisible();
});

View File

@@ -0,0 +1,32 @@
import { expect, test } from '@playwright/test';
import { baseTemplates, baseUsers, seedLocalStorage } 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: [],
});
page.on('dialog', async (dialog) => {
if (dialog.type() === 'prompt') {
await dialog.accept('我的测试模板');
return;
}
await dialog.accept();
});
await page.goto('/report-editor');
await expect(page.getByRole('button', { name: '保存为我的模板' })).toBeVisible();
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');
});
}).toBe(true);
await expect(page.locator('option:not([disabled])', { hasText: '我的测试模板' })).toHaveCount(1);
});

View File

@@ -0,0 +1,82 @@
import { expect, test } from '@playwright/test';
import { baseTemplates, baseUsers, seedLocalStorage } from './helpers';
const reports = [
{
id: 'RPT_SURGERY_SELF',
title: '外科本人报告',
patientName: '患者甲',
hospitalId: 'H001',
department: '外科',
content: '<p>外科本人报告</p>',
author: '0001',
authorName: '张医生',
createdAt: '2026-05-01',
status: 'completed',
revision: 1,
},
{
id: 'RPT_SURGERY_OTHER',
title: '外科他人报告',
patientName: '患者乙',
hospitalId: 'H002',
department: '外科',
content: '<p>外科他人报告</p>',
author: '0003',
authorName: '王医生',
createdAt: '2026-05-01',
status: 'completed',
revision: 1,
},
{
id: 'RPT_INTERNAL',
title: '内科报告',
patientName: '患者丙',
hospitalId: 'H003',
department: '内科',
content: '<p>内科报告</p>',
author: '0002',
authorName: '李医生',
createdAt: '2026-05-01',
status: 'completed',
revision: 1,
},
];
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],
});
await page.goto('/report-manage');
await expect(page.getByText('外科本人报告')).toBeVisible();
await expect(page.getByText('外科他人报告')).toBeVisible();
await expect(page.getByText('内科报告')).not.toBeVisible();
await seedLocalStorage(page, {
users: baseUsers,
templates: baseTemplates,
reports,
currentUser: baseUsers[2],
});
await page.goto('/report-manage');
await expect(page.getByText('外科本人报告')).toBeVisible();
await expect(page.getByText('外科他人报告')).not.toBeVisible();
await expect(page.getByText('内科报告')).not.toBeVisible();
await seedLocalStorage(page, {
users: baseUsers,
templates: baseTemplates,
reports,
currentUser: baseUsers[0],
});
await page.goto('/report-manage');
await expect(page.getByText('外科本人报告')).toBeVisible();
await expect(page.getByText('外科他人报告')).toBeVisible();
await expect(page.getByText('内科报告')).toBeVisible();
});

View File

@@ -0,0 +1,41 @@
import { expect, test } from '@playwright/test';
import { baseTemplates, baseUsers, seedLocalStorage } 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: '<p>旧报告内容</p>',
author: '0001',
authorName: '张医生',
createdAt: '2026-05-01',
updatedAt: '2026-05-01T08:00:00.000Z',
status: 'completed',
revision: 1,
history: [],
},
],
});
await page.goto('/report-editor?id=RPT_DONE');
await expect(page.getByText('编辑报告: RPT_DONE')).toBeVisible();
await page.getByRole('button', { name: '完成报告' }).click();
await page.waitForURL('**/report-manage');
const report = await page.evaluate(() => {
return JSON.parse(window.localStorage.getItem('reports') || '[]')[0];
});
expect(report.revision).toBe(2);
expect(report.history).toHaveLength(1);
expect(report.history[0].revision).toBe(1);
});

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Google AI Studio App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

34
nginx.conf Normal file
View File

@@ -0,0 +1,34 @@
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://api:3000/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

7929
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

71
package.json Normal file
View File

@@ -0,0 +1,71 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3001 --host=0.0.0.0",
"server:dev": "tsx watch server/src/main.ts",
"server:build": "tsc -p server/tsconfig.json",
"server:start": "node server/dist/main.js",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist",
"lint": "tsc --noEmit",
"test": "vitest run",
"test:e2e": "playwright test",
"prisma:generate": "prisma generate --schema server/prisma/schema.prisma",
"prisma:migrate": "prisma migrate dev --schema server/prisma/schema.prisma",
"prisma:seed": "prisma db seed --schema server/prisma/schema.prisma"
},
"dependencies": {
"@nestjs/common": "^11.1.19",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.19",
"@nestjs/platform-express": "^11.1.19",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"argon2": "^0.44.0",
"cookie-parser": "^1.4.7",
"diff": "^9.0.0",
"express": "^4.21.2",
"express-session": "^1.19.0",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"pg": "^8.20.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.14.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"sanitize-html": "^2.17.3",
"vite": "^6.2.0",
"ws": "^8.20.0",
"zod": "^4.4.1"
},
"devDependencies": {
"@nestjs/testing": "^11.1.19",
"@playwright/test": "^1.59.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^4.17.21",
"@types/express-session": "^1.19.0",
"@types/node": "^22.14.0",
"@types/pg": "^8.20.0",
"@types/sanitize-html": "^2.16.1",
"@types/supertest": "^7.2.0",
"@types/ws": "^8.18.1",
"autoprefixer": "^10.4.21",
"jsdom": "^29.1.1",
"prisma": "^7.8.0",
"supertest": "^7.2.2",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.0",
"vitest": "^4.1.5"
}
}

23
playwright.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
reporter: [['list']],
use: {
baseURL: 'http://localhost:3001',
trace: 'on-first-retry',
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:3001',
reuseExistingServer: true,
timeout: 120_000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

13
prisma.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import 'dotenv/config';
import { defineConfig } from 'prisma/config';
export default defineConfig({
schema: 'server/prisma/schema.prisma',
migrations: {
path: 'server/prisma/migrations',
seed: 'tsx server/prisma/seed.ts',
},
datasource: {
url: process.env.DATABASE_URL,
},
});

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

BIN
public/logo_square.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -0,0 +1,299 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('SUPER', 'ADMIN', 'DOCTOR');
-- CreateEnum
CREATE TYPE "UserStatus" AS ENUM ('ACTIVE', 'INACTIVE');
-- CreateEnum
CREATE TYPE "ReportStatus" AS ENUM ('DRAFT', 'COMPLETED');
-- CreateEnum
CREATE TYPE "TemplateScope" AS ENUM ('DEPARTMENT', 'PERSONAL');
-- CreateEnum
CREATE TYPE "FileKind" AS ENUM ('SIGNATURE', 'TEMPLATE_ASSET', 'VIDEO', 'FRAME', 'REPORT_EXPORT');
-- CreateTable
CREATE TABLE "Tenant" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Tenant_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Department" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Department_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"departmentId" TEXT NOT NULL,
"username" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"role" "UserRole" NOT NULL,
"name" TEXT NOT NULL,
"status" "UserStatus" NOT NULL DEFAULT 'ACTIVE',
"phone" TEXT,
"email" TEXT,
"signatureFileId" TEXT,
"lastLoginAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserSession" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Template" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"content" TEXT NOT NULL,
"fields" JSONB NOT NULL DEFAULT '[]',
"scope" "TemplateScope" NOT NULL,
"ownerDepartmentId" TEXT,
"ownerUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Template_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TemplateDepartmentPermission" (
"id" TEXT NOT NULL,
"templateId" TEXT NOT NULL,
"departmentId" TEXT NOT NULL,
"canUse" BOOLEAN NOT NULL DEFAULT true,
"canManage" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TemplateDepartmentPermission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Report" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"departmentId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"templateId" TEXT,
"title" TEXT NOT NULL,
"patientName" TEXT NOT NULL,
"hospitalId" TEXT NOT NULL,
"status" "ReportStatus" NOT NULL DEFAULT 'DRAFT',
"revision" INTEGER NOT NULL DEFAULT 1,
"content" TEXT NOT NULL,
"deletedAt" TIMESTAMP(3),
"deletedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Report_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ReportHistory" (
"id" TEXT NOT NULL,
"reportId" TEXT NOT NULL,
"revision" INTEGER NOT NULL,
"content" TEXT NOT NULL,
"action" TEXT NOT NULL,
"updatedById" TEXT NOT NULL,
"updatedBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ReportHistory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FileResource" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"ownerId" TEXT,
"reportId" TEXT,
"kind" "FileKind" NOT NULL,
"filename" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"storageKey" TEXT NOT NULL,
"checksum" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FileResource_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SystemSetting" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"departmentId" TEXT,
"key" TEXT NOT NULL,
"value" JSONB NOT NULL,
"secretValue" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SystemSetting_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"actorUserId" TEXT,
"actorRole" TEXT,
"action" TEXT NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT,
"departmentId" TEXT,
"ip" TEXT,
"userAgent" TEXT,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Tenant_code_key" ON "Tenant"("code");
-- CreateIndex
CREATE UNIQUE INDEX "Department_tenantId_code_key" ON "Department"("tenantId", "code");
-- CreateIndex
CREATE INDEX "User_tenantId_departmentId_role_idx" ON "User"("tenantId", "departmentId", "role");
-- CreateIndex
CREATE UNIQUE INDEX "User_tenantId_username_key" ON "User"("tenantId", "username");
-- CreateIndex
CREATE UNIQUE INDEX "UserSession_tokenHash_key" ON "UserSession"("tokenHash");
-- CreateIndex
CREATE INDEX "Template_tenantId_scope_idx" ON "Template"("tenantId", "scope");
-- CreateIndex
CREATE INDEX "Template_tenantId_ownerDepartmentId_idx" ON "Template"("tenantId", "ownerDepartmentId");
-- CreateIndex
CREATE INDEX "Template_tenantId_ownerUserId_idx" ON "Template"("tenantId", "ownerUserId");
-- CreateIndex
CREATE UNIQUE INDEX "TemplateDepartmentPermission_templateId_departmentId_key" ON "TemplateDepartmentPermission"("templateId", "departmentId");
-- CreateIndex
CREATE INDEX "Report_tenantId_departmentId_status_idx" ON "Report"("tenantId", "departmentId", "status");
-- CreateIndex
CREATE INDEX "Report_tenantId_authorId_idx" ON "Report"("tenantId", "authorId");
-- CreateIndex
CREATE INDEX "Report_tenantId_deletedAt_idx" ON "Report"("tenantId", "deletedAt");
-- CreateIndex
CREATE INDEX "ReportHistory_reportId_revision_idx" ON "ReportHistory"("reportId", "revision");
-- CreateIndex
CREATE INDEX "FileResource_tenantId_kind_idx" ON "FileResource"("tenantId", "kind");
-- CreateIndex
CREATE INDEX "FileResource_tenantId_reportId_idx" ON "FileResource"("tenantId", "reportId");
-- CreateIndex
CREATE UNIQUE INDEX "SystemSetting_tenantId_scope_departmentId_key_key" ON "SystemSetting"("tenantId", "scope", "departmentId", "key");
-- CreateIndex
CREATE INDEX "AuditLog_tenantId_action_idx" ON "AuditLog"("tenantId", "action");
-- CreateIndex
CREATE INDEX "AuditLog_tenantId_actorUserId_idx" ON "AuditLog"("tenantId", "actorUserId");
-- CreateIndex
CREATE INDEX "AuditLog_tenantId_targetType_targetId_idx" ON "AuditLog"("tenantId", "targetType", "targetId");
-- AddForeignKey
ALTER TABLE "Department" ADD CONSTRAINT "Department_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserSession" ADD CONSTRAINT "UserSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_ownerDepartmentId_fkey" FOREIGN KEY ("ownerDepartmentId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TemplateDepartmentPermission" ADD CONSTRAINT "TemplateDepartmentPermission_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TemplateDepartmentPermission" ADD CONSTRAINT "TemplateDepartmentPermission_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Report" ADD CONSTRAINT "Report_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Report" ADD CONSTRAINT "Report_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Report" ADD CONSTRAINT "Report_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Report" ADD CONSTRAINT "Report_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReportHistory" ADD CONSTRAINT "ReportHistory_reportId_fkey" FOREIGN KEY ("reportId") REFERENCES "Report"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FileResource" ADD CONSTRAINT "FileResource_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FileResource" ADD CONSTRAINT "FileResource_reportId_fkey" FOREIGN KEY ("reportId") REFERENCES "Report"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SystemSetting" ADD CONSTRAINT "SystemSetting_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SystemSetting" ADD CONSTRAINT "SystemSetting_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- Add a flexible compatibility payload for the existing frontend report object.
ALTER TABLE "Report" ADD COLUMN "metadata" JSONB NOT NULL DEFAULT '{}';

View File

@@ -0,0 +1,130 @@
-- Split report video/keyframe references out of Report.metadata.
CREATE TYPE "ReportMediaKind" AS ENUM ('VIDEO', 'FRAME');
CREATE TABLE "ReportMedia" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"reportId" TEXT NOT NULL,
"fileId" TEXT,
"kind" "ReportMediaKind" NOT NULL,
"clientId" TEXT NOT NULL,
"name" TEXT,
"url" TEXT,
"time" DOUBLE PRECISION,
"videoIndex" INTEGER,
"videoName" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ReportMedia_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "ReportMedia_tenantId_reportId_kind_idx" ON "ReportMedia"("tenantId", "reportId", "kind");
CREATE INDEX "ReportMedia_tenantId_fileId_idx" ON "ReportMedia"("tenantId", "fileId");
ALTER TABLE "ReportMedia" ADD CONSTRAINT "ReportMedia_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ReportMedia" ADD CONSTRAINT "ReportMedia_reportId_fkey" FOREIGN KEY ("reportId") REFERENCES "Report"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ReportMedia" ADD CONSTRAINT "ReportMedia_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "FileResource"("id") ON DELETE SET NULL ON UPDATE CASCADE;
INSERT INTO "ReportMedia" (
"id",
"tenantId",
"reportId",
"fileId",
"kind",
"clientId",
"name",
"url",
"sortOrder",
"metadata",
"createdAt",
"updatedAt"
)
SELECT
concat('rm_', r."id", '_video_', video."ordinality"),
r."tenantId",
r."id",
f."id",
'VIDEO'::"ReportMediaKind",
COALESCE(video."value"->>'id', concat('video-', video."ordinality")),
video."value"->>'name',
video."value"->>'url',
(video."ordinality" - 1)::integer,
jsonb_strip_nulls(jsonb_build_object(
'duration', video."value"->'duration',
'legacyFileId', video."value"->>'fileId'
)),
r."createdAt",
r."updatedAt"
FROM "Report" r
CROSS JOIN LATERAL jsonb_array_elements(
CASE
WHEN jsonb_typeof(r."metadata"->'videos') = 'array' THEN r."metadata"->'videos'
ELSE '[]'::jsonb
END
) WITH ORDINALITY AS video("value", "ordinality")
LEFT JOIN "FileResource" f
ON f."id" = video."value"->>'fileId'
AND f."tenantId" = r."tenantId";
INSERT INTO "ReportMedia" (
"id",
"tenantId",
"reportId",
"fileId",
"kind",
"clientId",
"url",
"time",
"videoIndex",
"videoName",
"sortOrder",
"metadata",
"createdAt",
"updatedAt"
)
SELECT
concat('rm_', r."id", '_frame_', frame."ordinality"),
r."tenantId",
r."id",
f."id",
'FRAME'::"ReportMediaKind",
COALESCE(frame."value"->>'id', concat('frame-', frame."ordinality")),
frame."value"->>'dataUrl',
CASE
WHEN jsonb_typeof(frame."value"->'time') = 'number' THEN (frame."value"->>'time')::double precision
ELSE NULL
END,
CASE
WHEN jsonb_typeof(frame."value"->'videoIndex') = 'number' THEN (frame."value"->>'videoIndex')::integer
ELSE NULL
END,
frame."value"->>'videoName',
(frame."ordinality" - 1)::integer,
jsonb_strip_nulls(jsonb_build_object(
'timeFormatted', frame."value"->>'timeFormatted',
'isManual', frame."value"->'isManual',
'manualOrder', frame."value"->'manualOrder',
'legacyFileId', frame."value"->>'fileId'
)),
r."createdAt",
r."updatedAt"
FROM "Report" r
CROSS JOIN LATERAL jsonb_array_elements(
CASE
WHEN jsonb_typeof(r."metadata"->'capturedFrames') = 'array' THEN r."metadata"->'capturedFrames'
ELSE '[]'::jsonb
END
) WITH ORDINALITY AS frame("value", "ordinality")
LEFT JOIN "FileResource" f
ON f."id" = frame."value"->>'fileId'
AND f."tenantId" = r."tenantId";
UPDATE "FileResource" f
SET "reportId" = media."reportId"
FROM "ReportMedia" media
WHERE f."id" = media."fileId"
AND f."tenantId" = media."tenantId"
AND f."reportId" IS NULL;

View File

@@ -0,0 +1,12 @@
-- Persist Express sessions outside the Node.js process.
CREATE TABLE "AppSession" (
"id" TEXT NOT NULL,
"data" JSONB NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AppSession_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "AppSession_expiresAt_idx" ON "AppSession"("expiresAt");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

280
server/prisma/schema.prisma Normal file
View File

@@ -0,0 +1,280 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
enum UserRole {
SUPER
ADMIN
DOCTOR
}
enum UserStatus {
ACTIVE
INACTIVE
}
enum ReportStatus {
DRAFT
COMPLETED
}
enum TemplateScope {
DEPARTMENT
PERSONAL
}
enum FileKind {
SIGNATURE
TEMPLATE_ASSET
VIDEO
FRAME
REPORT_EXPORT
}
enum ReportMediaKind {
VIDEO
FRAME
}
model Tenant {
id String @id @default(cuid())
name String
code String @unique
departments Department[]
users User[]
reports Report[]
reportMedia ReportMedia[]
templates Template[]
files FileResource[]
auditLogs AuditLog[]
settings SystemSetting[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Department {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
name String
code String
users User[]
reports Report[]
templates Template[] @relation("TemplateOwnerDepartment")
permissions TemplateDepartmentPermission[]
settings SystemSetting[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, code])
}
model User {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
departmentId String
department Department @relation(fields: [departmentId], references: [id])
username String
passwordHash String
role UserRole
name String
status UserStatus @default(ACTIVE)
phone String?
email String?
signatureFileId String?
reports Report[] @relation("ReportAuthor")
sessions UserSession[]
personalTemplates Template[] @relation("PersonalTemplates")
auditLogs AuditLog[]
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, username])
@@index([tenantId, departmentId, role])
}
model UserSession {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tokenHash String @unique
expiresAt DateTime
createdAt DateTime @default(now())
}
model AppSession {
id String @id
data Json
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([expiresAt])
}
model Template {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
name String
description String?
content String
fields Json @default("[]")
scope TemplateScope
ownerDepartmentId String?
ownerDepartment Department? @relation("TemplateOwnerDepartment", fields: [ownerDepartmentId], references: [id])
ownerUserId String?
ownerUser User? @relation("PersonalTemplates", fields: [ownerUserId], references: [id])
permissions TemplateDepartmentPermission[]
reports Report[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId, scope])
@@index([tenantId, ownerDepartmentId])
@@index([tenantId, ownerUserId])
}
model TemplateDepartmentPermission {
id String @id @default(cuid())
templateId String
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
departmentId String
department Department @relation(fields: [departmentId], references: [id], onDelete: Cascade)
canUse Boolean @default(true)
canManage Boolean @default(false)
createdAt DateTime @default(now())
@@unique([templateId, departmentId])
}
model Report {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
departmentId String
department Department @relation(fields: [departmentId], references: [id])
authorId String
author User @relation("ReportAuthor", fields: [authorId], references: [id])
templateId String?
template Template? @relation(fields: [templateId], references: [id])
title String
patientName String
hospitalId String
status ReportStatus @default(DRAFT)
revision Int @default(1)
content String
metadata Json @default("{}")
histories ReportHistory[]
files FileResource[]
media ReportMedia[]
deletedAt DateTime?
deletedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId, departmentId, status])
@@index([tenantId, authorId])
@@index([tenantId, deletedAt])
}
model ReportHistory {
id String @id @default(cuid())
reportId String
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
revision Int
content String
action String
updatedById String
updatedBy String
createdAt DateTime @default(now())
@@index([reportId, revision])
}
model FileResource {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
ownerId String?
reportId String?
report Report? @relation(fields: [reportId], references: [id], onDelete: Cascade)
reportMedia ReportMedia[]
kind FileKind
filename String
mimeType String
size Int
storageKey String
checksum String?
createdAt DateTime @default(now())
@@index([tenantId, kind])
@@index([tenantId, reportId])
}
model ReportMedia {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
reportId String
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
fileId String?
file FileResource? @relation(fields: [fileId], references: [id], onDelete: SetNull)
kind ReportMediaKind
clientId String
name String?
url String?
time Float?
videoIndex Int?
videoName String?
sortOrder Int @default(0)
metadata Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId, reportId, kind])
@@index([tenantId, fileId])
}
model SystemSetting {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
scope String
departmentId String?
department Department? @relation(fields: [departmentId], references: [id])
key String
value Json
secretValue String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, scope, departmentId, key])
}
model AuditLog {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
actorUserId String?
actor User? @relation(fields: [actorUserId], references: [id])
actorRole String?
action String
targetType String
targetId String?
departmentId String?
ip String?
userAgent String?
metadata Json?
createdAt DateTime @default(now())
@@index([tenantId, action])
@@index([tenantId, actorUserId])
@@index([tenantId, targetType, targetId])
}

153
server/prisma/seed.ts Normal file
View File

@@ -0,0 +1,153 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import argon2 from 'argon2';
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL is required to seed the database');
}
const prisma = new PrismaClient({
adapter: new PrismaPg({
connectionString: process.env.DATABASE_URL,
}),
});
const defaultTemplateContent = `
<h1 style="text-align:center;">手术记录</h1>
<p>患者姓名:<span class="field-value" data-bind="patientName" contenteditable="true"></span></p>
<p>住院号:<span class="field-value" data-bind="hospitalId" contenteditable="true"></span></p>
<p>手术名称:<span class="field-value" data-bind="title" contenteditable="true"></span></p>
<div class="ai-region" data-ai-id="手术步骤" data-ai-title="手术步骤">
<div class="ai-content"><p>请在此处填写手术步骤。</p></div>
</div>
`;
const main = async () => {
const tenant = await prisma.tenant.upsert({
where: { code: 'default' },
update: {},
create: {
code: 'default',
name: '默认医院',
},
});
const adminDepartment = await prisma.department.upsert({
where: { tenantId_code: { tenantId: tenant.id, code: 'admin' } },
update: {},
create: {
tenantId: tenant.id,
code: 'admin',
name: '管理部门',
},
});
const surgeryDepartment = await prisma.department.upsert({
where: { tenantId_code: { tenantId: tenant.id, code: 'surgery' } },
update: {},
create: {
tenantId: tenant.id,
code: 'surgery',
name: '外科',
},
});
const passwordHash = await argon2.hash('123456');
const adminUser = await prisma.user.upsert({
where: { tenantId_username: { tenantId: tenant.id, username: 'admin' } },
update: {},
create: {
tenantId: tenant.id,
departmentId: adminDepartment.id,
username: 'admin',
passwordHash,
role: 'SUPER',
name: '超级管理员',
},
});
await prisma.user.upsert({
where: { tenantId_username: { tenantId: tenant.id, username: 'manager' } },
update: {},
create: {
tenantId: tenant.id,
departmentId: surgeryDepartment.id,
username: 'manager',
passwordHash,
role: 'ADMIN',
name: '科室管理员',
},
});
await prisma.user.upsert({
where: { tenantId_username: { tenantId: tenant.id, username: '0001' } },
update: {},
create: {
tenantId: tenant.id,
departmentId: surgeryDepartment.id,
username: '0001',
passwordHash,
role: 'DOCTOR',
name: '张医生',
},
});
const defaultTemplate = await prisma.template.upsert({
where: { id: 'tpl_default_surgery' },
update: {},
create: {
id: 'tpl_default_surgery',
tenantId: tenant.id,
name: '腹腔镜胆囊切除术报告',
description: '标准手术记录模板',
content: defaultTemplateContent,
fields: [],
scope: 'DEPARTMENT',
ownerDepartmentId: surgeryDepartment.id,
ownerUserId: null,
},
});
await prisma.templateDepartmentPermission.upsert({
where: {
templateId_departmentId: {
templateId: defaultTemplate.id,
departmentId: surgeryDepartment.id,
},
},
update: {
canUse: true,
canManage: true,
},
create: {
templateId: defaultTemplate.id,
departmentId: surgeryDepartment.id,
canUse: true,
canManage: true,
},
});
await prisma.auditLog.create({
data: {
tenantId: tenant.id,
actorUserId: adminUser.id,
actorRole: 'super',
action: 'seed.default_template',
targetType: 'template',
targetId: defaultTemplate.id,
departmentId: surgeryDepartment.id,
metadata: { name: defaultTemplate.name },
},
}).catch(() => {});
};
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (error) => {
console.error(error);
await prisma.$disconnect();
process.exit(1);
});

View File

@@ -0,0 +1,25 @@
import { Body, Controller, Get, Post, Req } from '@nestjs/common';
import type { Request } from 'express';
import { AuthService } from '../auth/auth.service.js';
import { getSessionUser } from '../auth/session-user.js';
import { AiService } from './ai.service.js';
@Controller('ai')
export class AiController {
constructor(
private readonly authService: AuthService,
private readonly aiService: AiService,
) {}
@Get('models')
async listModels(@Req() request: Request) {
const actor = await getSessionUser(request, this.authService);
return { data: await this.aiService.listModels(actor) };
}
@Post('chat')
async chat(@Req() request: Request, @Body() body: unknown) {
const actor = await getSessionUser(request, this.authService);
return { data: await this.aiService.chat(actor, body) };
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module.js';
import { PrismaModule } from '../prisma/prisma.module.js';
import { SettingsModule } from '../settings/settings.module.js';
import { AiController } from './ai.controller.js';
import { AiService } from './ai.service.js';
@Module({
imports: [AuthModule, PrismaModule, SettingsModule],
controllers: [AiController],
providers: [AiService],
})
export class AiModule {}

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { aiChatSchema } from './ai.schemas';
describe('AI schemas', () => {
it('accepts OpenAI-compatible chat payloads with multimodal content', () => {
const parsed = aiChatSchema.parse({
model: 'ignored-by-proxy',
messages: [
{ role: 'system', content: 'system prompt' },
{
role: 'user',
content: [
{ type: 'image_url', image_url: { url: 'data:image/png;base64,AA==' } },
{ type: 'text', text: '生成报告' },
],
},
],
temperature: 0.3,
});
expect(parsed.messages).toHaveLength(2);
expect(parsed.temperature).toBe(0.3);
});
it('rejects empty message arrays', () => {
expect(() => aiChatSchema.parse({ messages: [] })).toThrow();
});
});

View File

@@ -0,0 +1,12 @@
import { z } from 'zod';
export const aiChatSchema = z.object({
messages: z.array(z.unknown()).min(1, '消息不能为空'),
model: z.string().optional(),
temperature: z.number().optional(),
top_p: z.number().optional(),
presence_penalty: z.number().optional(),
frequency_penalty: z.number().optional(),
}).passthrough();
export type AiChatInput = z.infer<typeof aiChatSchema>;

126
server/src/ai/ai.service.ts Normal file
View File

@@ -0,0 +1,126 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import type { SafeUser } from '../auth/auth.types.js';
import { SettingsService } from '../settings/settings.service.js';
import { aiChatSchema } from './ai.schemas.js';
interface AiProvider {
endpoint: string;
apiKey: string;
modelName: string;
}
@Injectable()
export class AiService {
constructor(private readonly settingsService: SettingsService) {}
async listModels(actor: SafeUser) {
const provider = await this.getActiveProvider(actor);
const response = await this.fetchProvider(`${provider.endpoint}/models`, {
method: 'GET',
headers: this.headers(provider),
});
const payload = await this.parseProviderResponse(response);
if (!response.ok) {
throw new BadRequestException(this.formatProviderError(response.status, payload));
}
const models = Array.isArray((payload as { data?: unknown[] }).data)
? ((payload as { data: Array<{ id?: string }> }).data)
.map((model) => model.id)
.filter((id): id is string => Boolean(id))
: [];
return {
models,
provider: {
endpoint: provider.endpoint,
modelName: provider.modelName,
},
raw: payload,
};
}
async chat(actor: SafeUser, rawInput: unknown) {
const result = aiChatSchema.safeParse(rawInput);
if (!result.success) {
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(''));
}
const provider = await this.getActiveProvider(actor);
const input = result.data;
const payload = {
...input,
model: provider.modelName || input.model,
};
const response = await this.fetchProvider(`${provider.endpoint}/chat/completions`, {
method: 'POST',
headers: this.headers(provider),
body: JSON.stringify(payload),
});
const responsePayload = await this.parseProviderResponse(response);
if (!response.ok) {
throw new BadRequestException(this.formatProviderError(response.status, responsePayload));
}
return responsePayload;
}
private async getActiveProvider(actor: SafeUser): Promise<AiProvider> {
const settings = await this.settingsService.getSystemSettings(actor, { includeSecrets: true });
const activeProvider = settings.activeAiProvider || 'kimi';
const provider = settings.aiProviders?.[activeProvider];
const endpoint = provider?.endpoint?.replace(/\/+$/, '') || '';
const apiKey = provider?.apiKey || '';
const modelName = provider?.modelName || '';
if (!endpoint) {
throw new BadRequestException('尚未配置 AI 接口地址');
}
if (!apiKey) {
throw new BadRequestException('尚未配置 AI API Key');
}
if (!modelName) {
throw new BadRequestException('尚未配置 AI 模型名称');
}
return { endpoint, apiKey, modelName };
}
private headers(provider: AiProvider) {
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${provider.apiKey}`,
};
}
private async parseProviderResponse(response: Response) {
const text = await response.text();
if (!text) return null;
try {
return JSON.parse(text) as unknown;
} catch {
return { message: text };
}
}
private async fetchProvider(url: string, init: RequestInit) {
try {
return await fetch(url, init);
} catch (error) {
throw new BadRequestException(`AI 服务连接失败:${error instanceof Error ? error.message : String(error)}`);
}
}
private formatProviderError(status: number, payload: unknown) {
const message =
typeof payload === 'object' && payload !== null && 'error' in payload
? JSON.stringify((payload as { error: unknown }).error)
: typeof payload === 'object' && payload !== null && 'message' in payload
? String((payload as { message: unknown }).message)
: JSON.stringify(payload);
return `AI 服务请求失败:${status}${message ? ` - ${message}` : ''}`;
}
}

37
server/src/app.module.ts Normal file
View File

@@ -0,0 +1,37 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AiModule } from './ai/ai.module.js';
import { AuditModule } from './audit/audit.module.js';
import { AuthModule } from './auth/auth.module.js';
import { DashboardModule } from './dashboard/dashboard.module.js';
import { FilesModule } from './files/files.module.js';
import { HealthModule } from './health/health.module.js';
import { LibraryModule } from './library/library.module.js';
import { PermissionsModule } from './permissions/permissions.module.js';
import { PrismaModule } from './prisma/prisma.module.js';
import { ReportsModule } from './reports/reports.module.js';
import { SettingsModule } from './settings/settings.module.js';
import { SpeechModule } from './speech/speech.module.js';
import { TemplatesModule } from './templates/templates.module.js';
import { UsersModule } from './users/users.module.js';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
AuditModule,
HealthModule,
DashboardModule,
LibraryModule,
AuthModule,
ReportsModule,
TemplatesModule,
UsersModule,
SettingsModule,
FilesModule,
AiModule,
SpeechModule,
PermissionsModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,11 @@
import { Global, Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module.js';
import { AuditService } from './audit.service.js';
@Global()
@Module({
imports: [PrismaModule],
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}

View File

@@ -0,0 +1,46 @@
import { 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';
export interface AuditInput {
actor?: SafeUser | null;
action: string;
targetType: string;
targetId?: string | null;
departmentId?: string | null;
metadata?: Record<string, unknown>;
ip?: string | null;
userAgent?: string | null;
}
@Injectable()
export class AuditService {
constructor(private readonly prisma: PrismaService) {}
async record(input: AuditInput) {
const actor = input.actor || null;
const tenantId = actor?.tenantId;
if (!tenantId) return;
await this.prisma.auditLog.create({
data: {
tenantId,
actorUserId: actor.id,
actorRole: actor.role,
action: input.action,
targetType: input.targetType,
targetId: input.targetId || null,
departmentId: input.departmentId || actor.departmentId || null,
ip: input.ip || null,
userAgent: input.userAgent || null,
metadata: input.metadata ? (cleanJson(input.metadata) as Prisma.InputJsonValue) : undefined,
},
}).catch(() => {
// Auditing must not break the clinical workflow. Operational alerts can watch failed inserts.
});
}
}
const cleanJson = (value: Record<string, unknown>) =>
JSON.parse(JSON.stringify(value)) as Record<string, unknown>;

View File

@@ -0,0 +1,91 @@
import {
Body,
Controller,
Get,
HttpCode,
InternalServerErrorException,
Post,
Req,
UnauthorizedException,
} from '@nestjs/common';
import type { Request } from 'express';
import { AuditService } from '../audit/audit.service.js';
import { AuthService } from './auth.service.js';
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly audit?: AuditService,
) {}
@Post('login')
@HttpCode(200)
async login(@Body() body: unknown, @Req() request: Request) {
const user = await this.authService.login(body);
request.session.userId = user.id;
await this.saveSession(request);
await this.audit?.record({
actor: user,
action: 'auth.login',
targetType: 'User',
targetId: user.id,
metadata: { username: user.username },
ip: request.ip,
userAgent: request.get('user-agent'),
});
return {
data: {
user,
},
};
}
@Get('me')
async me(@Req() request: Request) {
if (!request.session.userId) {
throw new UnauthorizedException('未登录');
}
return {
data: {
user: await this.authService.findMe(request.session.userId),
},
};
}
@Post('logout')
@HttpCode(200)
async logout(@Req() request: Request) {
const actor = request.session.userId
? await this.authService.findMe(request.session.userId).catch(() => null)
: null;
await new Promise<void>((resolve, reject) => {
request.session.destroy((error) => {
if (error) reject(error);
else resolve();
});
});
await this.audit?.record({
actor,
action: 'auth.logout',
targetType: 'User',
targetId: actor?.id,
ip: request.ip,
userAgent: request.get('user-agent'),
});
return { data: null };
}
private async saveSession(request: Request) {
await new Promise<void>((resolve, reject) => {
request.session.save((error) => {
if (error) reject(error);
else resolve();
});
}).catch(() => {
throw new InternalServerErrorException('保存登录态失败');
});
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller.js';
import { AuthService } from './auth.service.js';
@Module({
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const loginSchema = z.object({
username: z.string().trim().min(1, '用户名不能为空'),
password: z.string().min(1, '密码不能为空'),
});
export type LoginInput = z.infer<typeof loginSchema>;

View File

@@ -0,0 +1,86 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import argon2 from 'argon2';
import { PrismaService } from '../prisma/prisma.service.js';
import { loginSchema, type LoginInput } from './auth.schemas.js';
import type { SafeUser } from './auth.types.js';
@Injectable()
export class AuthService {
constructor(private readonly prisma: PrismaService) {}
async login(input: unknown) {
const result = loginSchema.safeParse(input);
if (!result.success) {
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(''));
}
const user = await this.findUserForLogin(result.data);
if (!user) {
throw new UnauthorizedException('用户名或密码错误');
}
const isPasswordValid = await argon2.verify(user.passwordHash, result.data.password);
if (!isPasswordValid) {
throw new UnauthorizedException('用户名或密码错误');
}
if (user.status !== 'ACTIVE') {
throw new ForbiddenException('账号已禁用');
}
await this.prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
return this.toSafeUser(user);
}
async findMe(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: { department: true },
});
if (!user || user.status !== 'ACTIVE') {
throw new UnauthorizedException('登录态已失效');
}
return this.toSafeUser(user);
}
private findUserForLogin(input: LoginInput) {
return this.prisma.user.findFirst({
where: { username: input.username },
include: { department: true },
});
}
private toSafeUser(user: Awaited<ReturnType<AuthService['findUserForLogin']>>): SafeUser {
if (!user) {
throw new UnauthorizedException('登录态已失效');
}
return {
id: user.id,
username: user.username,
role: user.role.toLowerCase() as SafeUser['role'],
name: user.name,
tenantId: user.tenantId,
departmentId: user.departmentId,
departmentName: user.department.name,
status: user.status.toLowerCase() as SafeUser['status'],
phone: user.phone ?? undefined,
email: user.email ?? undefined,
signatureFileId: user.signatureFileId ?? undefined,
signature: user.signatureFileId ? `/api/files/${user.signatureFileId}/content` : undefined,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
};
}
}

View File

@@ -0,0 +1,18 @@
import type { UserRole, UserStatus } from '@prisma/client';
export interface SafeUser {
id: string;
username: string;
role: Lowercase<UserRole>;
name: string;
tenantId: string;
departmentId: string;
departmentName: string;
status: Lowercase<UserStatus>;
phone?: string;
email?: string;
signatureFileId?: string;
signature?: string;
createdAt: string;
updatedAt: string;
}

View File

@@ -0,0 +1,15 @@
import { UnauthorizedException } from '@nestjs/common';
import type { Request } from 'express';
import { AuthService } from './auth.service.js';
import type { SafeUser } from './auth.types.js';
export const getSessionUser = async (
request: Request,
authService: AuthService,
): Promise<SafeUser> => {
if (!request.session.userId) {
throw new UnauthorizedException('未登录');
}
return authService.findMe(request.session.userId);
};

View File

@@ -0,0 +1,54 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
} from '@nestjs/common';
import type { Response } from 'express';
@Catch()
export class ApiExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse<Response>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const payload = exception instanceof HttpException ? exception.getResponse() : undefined;
const message =
typeof payload === 'object' && payload !== null && 'message' in payload
? String((payload as { message: unknown }).message)
: exception instanceof Error
? exception.message
: '服务器内部错误';
response.status(status).json({
error: {
code: this.resolveCode(status),
message,
},
requestId: response.getHeader('x-request-id') ?? undefined,
});
}
private resolveCode(status: number) {
switch (status) {
case HttpStatus.BAD_REQUEST:
return 'BAD_REQUEST';
case HttpStatus.UNAUTHORIZED:
return 'UNAUTHORIZED';
case HttpStatus.FORBIDDEN:
return 'FORBIDDEN';
case HttpStatus.NOT_FOUND:
return 'NOT_FOUND';
case HttpStatus.CONFLICT:
return 'CONFLICT';
case HttpStatus.UNPROCESSABLE_ENTITY:
return 'VALIDATION_ERROR';
default:
return 'INTERNAL_SERVER_ERROR';
}
}
}

View File

@@ -0,0 +1,68 @@
import sanitizeHtml from 'sanitize-html';
const allowedTags = [
...sanitizeHtml.defaults.allowedTags,
'img',
'span',
'div',
'section',
'article',
'header',
'footer',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
'colgroup',
'col',
];
const allowedAttributes: sanitizeHtml.IOptions['allowedAttributes'] = {
...sanitizeHtml.defaults.allowedAttributes,
'*': [
'class',
'style',
'title',
'data-bind',
'data-ai-id',
'data-mode',
'data-placeholder-id',
'contenteditable',
'colspan',
'rowspan',
'align',
],
img: ['src', 'alt', 'width', 'height', 'class', 'style', 'data-*'],
a: ['href', 'name', 'target', 'rel'],
};
export const sanitizeReportHtml = (html: string) =>
sanitizeHtml(html || '', {
allowedTags,
allowedAttributes,
allowedSchemes: ['http', 'https', 'data'],
allowedSchemesByTag: {
img: ['http', 'https', 'data'],
},
allowedStyles: {
'*': {
color: [/^#[0-9a-f]{3,8}$/iu, /^rgb\(/iu, /^rgba\(/iu, /^[a-z]+$/iu],
'background-color': [/^#[0-9a-f]{3,8}$/iu, /^rgb\(/iu, /^rgba\(/iu, /^[a-z]+$/iu],
'font-size': [/^\d+(\.\d+)?(px|pt|em|rem|%)$/u],
'font-weight': [/^\d+$/u, /^(normal|bold|bolder|lighter)$/u],
'text-align': [/^(left|right|center|justify)$/u],
width: [/^\d+(\.\d+)?(px|%|em|rem)$/u],
height: [/^\d+(\.\d+)?(px|%|em|rem)$/u],
margin: [/^[\d.\spxemrem%auto-]+$/u],
padding: [/^[\d.\spxemrem%-]+$/u],
border: [/^[\w\s#().,%/-]+$/u],
'border-collapse': [/^collapse$/u],
display: [/^(block|inline|inline-block|flex|table|table-row|table-cell)$/u],
},
},
transformTags: {
a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer' }),
},
});

View File

@@ -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 { DashboardService } from './dashboard.service.js';
@Controller('dashboard')
export class DashboardController {
constructor(
private readonly authService: AuthService,
private readonly dashboardService: DashboardService,
) {}
@Get('stats')
async stats(@Req() request: Request, @Query('range') range?: string) {
const actor = await getSessionUser(request, this.authService);
return { data: { stats: await this.dashboardService.getStats(actor, { range }) } };
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module.js';
import { DashboardController } from './dashboard.controller.js';
import { DashboardService } from './dashboard.service.js';
@Module({
imports: [AuthModule],
controllers: [DashboardController],
providers: [DashboardService],
})
export class DashboardModule {}

View File

@@ -0,0 +1,110 @@
import { Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import type { SafeUser } from '../auth/auth.types.js';
import { canUseTemplate } from '../permissions/permissions.policy.js';
import { PrismaService } from '../prisma/prisma.service.js';
import { templateInclude, toTemplateResource } from '../templates/template.mapper.js';
export interface DashboardStats {
totalCount: number;
monthCount: number;
templateCount: number;
userCount: number;
todayCount: number;
trend: number[];
trendLabels: string[];
trendFullDates: string[];
maxTrend: number;
}
@Injectable()
export class DashboardService {
constructor(private readonly prisma: PrismaService) {}
async getStats(actor: SafeUser, query: { range?: string } = {}): Promise<DashboardStats> {
const daysCount = query.range === '1month' ? 30 : 7;
const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const trendStart = new Date(startOfToday);
trendStart.setDate(trendStart.getDate() - (daysCount - 1));
const reportWhere: Prisma.ReportWhereInput = {
tenantId: actor.tenantId,
deletedAt: null,
...this.visibilityWhere(actor),
};
const [totalCount, monthCount, todayCount, trendReports, userCount, templates] = await this.prisma.$transaction([
this.prisma.report.count({ where: reportWhere }),
this.prisma.report.count({ where: { ...reportWhere, createdAt: { gte: startOfMonth } } }),
this.prisma.report.count({ where: { ...reportWhere, createdAt: { gte: startOfToday } } }),
this.prisma.report.findMany({
where: { ...reportWhere, createdAt: { gte: trendStart } },
select: { createdAt: true },
}),
this.prisma.user.count({ where: this.visibleUserWhere(actor) }),
this.prisma.template.findMany({
where: { tenantId: actor.tenantId },
include: templateInclude,
}),
]);
const trendFullDates: string[] = [];
const trendLabels: string[] = [];
const trend = Array.from({ length: daysCount }, (_, index) => {
const day = new Date(trendStart);
day.setDate(trendStart.getDate() + index);
const key = toDateKey(day);
trendFullDates.push(key);
trendLabels.push(daysCount === 7 ? `${day.getMonth() + 1}/${day.getDate()}` : `${day.getDate()}`);
return trendReports.filter((report) => toDateKey(report.createdAt) === key).length;
});
return {
totalCount,
monthCount,
todayCount,
templateCount: templates.filter((template) => canUseTemplate(this.actorToPolicy(actor), toTemplateResource(template))).length,
userCount,
trend,
trendLabels,
trendFullDates,
maxTrend: Math.max(...trend, 1),
};
}
private visibilityWhere(actor: SafeUser): Prisma.ReportWhereInput {
if (actor.role === 'super') return {};
if (actor.role === 'admin') return { departmentId: actor.departmentId };
return { authorId: actor.id };
}
private visibleUserWhere(actor: SafeUser): Prisma.UserWhereInput {
if (actor.role === 'super') return { tenantId: actor.tenantId };
if (actor.role === 'admin') {
return {
tenantId: actor.tenantId,
OR: [
{ id: actor.id },
{ departmentId: actor.departmentId, role: 'DOCTOR' },
],
};
}
return { tenantId: actor.tenantId, id: actor.id };
}
private actorToPolicy(actor: SafeUser) {
return {
id: actor.id,
tenantId: actor.tenantId,
departmentId: actor.departmentId,
role: actor.role,
};
}
}
const toDateKey = (date: Date) => {
const pad = (value: number) => value.toString().padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
};

View File

@@ -0,0 +1,254 @@
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client';
import argon2 from 'argon2';
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AuditService } from './audit/audit.service.js';
import { AuthService } from './auth/auth.service.js';
import type { SafeUser } from './auth/auth.types.js';
import { DashboardService } from './dashboard/dashboard.service.js';
import { FilesService } from './files/files.service.js';
import { ReportsService } from './reports/reports.service.js';
import { TemplatesService } from './templates/templates.service.js';
const databaseUrl =
process.env.DATABASE_URL ||
'postgresql://surclaw:surclaw_dev_password@localhost:5433/surclaw?schema=public';
describe('Prisma-backed service integration', () => {
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: databaseUrl }),
});
const tenantCode = `test_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const userPrefix = tenantCode.replace(/[^a-zA-Z0-9_]/g, '_');
let tenantId = '';
let adminDepartmentId = '';
let surgeryDepartmentId = '';
let otherDepartmentId = '';
let superActor: SafeUser;
let doctorActor: SafeUser;
let managerActor: SafeUser;
let otherManagerActor: SafeUser;
let tempDir = '';
beforeAll(async () => {
tempDir = await mkdtemp(path.join(os.tmpdir(), 'surclaw-files-'));
process.env.FILE_STORAGE_DIR = tempDir;
await prisma.$connect();
const tenant = await prisma.tenant.create({
data: { code: tenantCode, name: '测试医院' },
});
tenantId = tenant.id;
const [adminDepartment, surgeryDepartment, otherDepartment] = await Promise.all([
prisma.department.create({ data: { tenantId, code: 'admin', name: '管理部门' } }),
prisma.department.create({ data: { tenantId, code: 'surgery', name: '外科' } }),
prisma.department.create({ data: { tenantId, code: 'internal', name: '内科' } }),
]);
adminDepartmentId = adminDepartment.id;
surgeryDepartmentId = surgeryDepartment.id;
otherDepartmentId = otherDepartment.id;
const passwordHash = await argon2.hash('123456');
const [superUser, managerUser, doctorUser, otherManagerUser] = await Promise.all([
prisma.user.create({
data: { tenantId, departmentId: adminDepartmentId, username: `${userPrefix}_admin`, passwordHash, role: 'SUPER', name: '超级管理员' },
}),
prisma.user.create({
data: { tenantId, departmentId: surgeryDepartmentId, username: `${userPrefix}_manager`, passwordHash, role: 'ADMIN', name: '外科管理员' },
}),
prisma.user.create({
data: { tenantId, departmentId: surgeryDepartmentId, username: `${userPrefix}_doctor`, passwordHash, role: 'DOCTOR', name: '张医生' },
}),
prisma.user.create({
data: { tenantId, departmentId: otherDepartmentId, username: `${userPrefix}_manager2`, passwordHash, role: 'ADMIN', name: '内科管理员' },
}),
]);
superActor = toActor(superUser, adminDepartment.name);
managerActor = toActor(managerUser, surgeryDepartment.name);
doctorActor = toActor(doctorUser, surgeryDepartment.name);
otherManagerActor = toActor(otherManagerUser, otherDepartment.name);
});
afterAll(async () => {
if (tenantId) {
await prisma.tenant.delete({ where: { id: tenantId } }).catch(() => {});
}
await prisma.$disconnect();
if (tempDir) {
await rm(tempDir, { recursive: true, force: true });
}
});
it('authenticates real hashed users and rejects disabled accounts', async () => {
const authService = new AuthService(prisma as never);
const loggedIn = await authService.login({ username: `${userPrefix}_admin`, password: '123456' });
expect(loggedIn.username).toBe(`${userPrefix}_admin`);
expect(loggedIn.role).toBe('super');
await prisma.user.update({
where: { tenantId_username: { tenantId, username: `${userPrefix}_doctor` } },
data: { status: 'INACTIVE' },
});
await expect(authService.login({ username: `${userPrefix}_doctor`, password: '123456' })).rejects.toThrow('账号已禁用');
await prisma.user.update({
where: { tenantId_username: { tenantId, username: `${userPrefix}_doctor` } },
data: { status: 'ACTIVE' },
});
});
it('filters reports by role and keeps completed report revisions', async () => {
const auditService = new AuditService(prisma as never);
const reportsService = new ReportsService(prisma as never, auditService);
const filesService = new FilesService(prisma as never);
const videoFile = await filesService.uploadFile(doctorActor, {
kind: 'VIDEO',
filename: 'operation.mp4',
dataUrl: 'data:video/mp4;base64,AA==',
});
const frameFile = await filesService.uploadFile(doctorActor, {
kind: 'FRAME',
filename: 'frame.png',
dataUrl: 'data:image/png;base64,AA==',
});
const ownReport = await reportsService.create(doctorActor, {
title: '外科报告',
patientName: '患者甲',
hospitalId: 'H001',
content: '<p>draft</p><script>alert(1)</script>',
status: 'completed',
videos: [{ id: 'video-1', name: 'operation.mp4', duration: 18, url: 'blob:local', fileId: videoFile.id }],
capturedFrames: [{
id: 1001,
videoIndex: 0,
videoName: 'operation.mp4',
time: 2,
timeFormatted: '0:02',
dataUrl: 'data:image/png;base64,AA==',
fileId: frameFile.id,
}],
});
expect(ownReport.videos?.[0]).toMatchObject({ id: 'video-1', fileId: videoFile.id, url: `/api/files/${videoFile.id}/content` });
expect(ownReport.capturedFrames?.[0]).toMatchObject({ id: 1001, fileId: frameFile.id, dataUrl: `/api/files/${frameFile.id}/content` });
expect(ownReport.content).not.toContain('<script>');
const mediaRows = await prisma.reportMedia.findMany({ where: { reportId: ownReport.id }, orderBy: { kind: 'desc' } });
expect(mediaRows.map((row) => row.kind).sort()).toEqual(['FRAME', 'VIDEO']);
const persistedReport = await prisma.report.findUniqueOrThrow({ where: { id: ownReport.id } });
expect(persistedReport.metadata).not.toHaveProperty('videos');
expect(persistedReport.metadata).not.toHaveProperty('capturedFrames');
await expect(prisma.auditLog.count({ where: { tenantId, action: 'report.complete', targetId: ownReport.id } })).resolves.toBe(1);
await reportsService.create(otherManagerActor, {
title: '内科报告',
patientName: '患者乙',
hospitalId: 'H002',
content: '<p>other</p>',
status: 'draft',
});
const managerList = await reportsService.list(managerActor, {});
expect(managerList.items.map((item) => item.id)).toContain(ownReport.id);
expect(managerList.items.every((item) => item.department === '外科')).toBe(true);
const doctorList = await reportsService.list(doctorActor, {});
expect(doctorList.items.map((item) => item.id)).toEqual([ownReport.id]);
const updated = await reportsService.update(doctorActor, ownReport.id, {
title: '外科报告',
patientName: '患者甲',
hospitalId: 'H001',
content: '<p>updated</p>',
status: 'completed',
});
expect(updated.revision).toBe(2);
expect(updated.history).toHaveLength(1);
const dashboardService = new DashboardService(prisma as never);
const managerStats = await dashboardService.getStats(managerActor, { range: '7days' });
expect(managerStats.totalCount).toBeGreaterThanOrEqual(1);
expect(managerStats.trend).toHaveLength(7);
expect(managerStats.templateCount).toBeGreaterThanOrEqual(0);
});
it('stores department and personal templates with real permission filtering', async () => {
const templatesService = new TemplatesService(prisma as never);
const departmentTemplate = await templatesService.create(managerActor, {
name: '外科部门模板',
desc: '部门模板',
content: '<p>template</p>',
fields: [],
scope: 'department',
});
const personalTemplate = await templatesService.create(doctorActor, {
name: '我的模板',
desc: '个人模板',
content: '<p>personal</p>',
fields: [],
scope: 'personal',
});
const doctorUseList = await templatesService.list(doctorActor, { access: 'use' });
expect(doctorUseList.items.map((item) => item.id)).toEqual(
expect.arrayContaining([departmentTemplate.id, personalTemplate.id]),
);
const otherUseList = await templatesService.list(otherManagerActor, { access: 'use' });
expect(otherUseList.items.map((item) => item.id)).not.toContain(personalTemplate.id);
});
it('uploads, reads and deletes report files through FileResource', async () => {
const reportsService = new ReportsService(prisma as never);
const filesService = new FilesService(prisma as never);
const report = await reportsService.create(doctorActor, {
title: '含图片报告',
patientName: '患者丙',
hospitalId: 'H003',
content: '<p>file</p>',
status: 'draft',
});
const file = await filesService.uploadFile(doctorActor, {
kind: 'FRAME',
filename: 'frame.png',
dataUrl: 'data:image/png;base64,AA==',
reportId: report.id,
});
expect(file.url).toContain(`/api/files/${file.id}/content`);
const read = await filesService.readFile(doctorActor, file.id);
expect(read.file.mimeType).toBe('image/png');
expect(read.buffer.length).toBe(1);
await filesService.deleteFile(doctorActor, file.id);
await expect(filesService.readFile(doctorActor, file.id)).rejects.toThrow('文件不存在');
});
});
const toActor = (
user: {
id: string;
username: string;
role: string;
name: string;
tenantId: string;
departmentId: string;
status: string;
createdAt: Date;
updatedAt: Date;
},
departmentName: string,
): SafeUser => ({
id: user.id,
username: user.username,
role: user.role.toLowerCase() as SafeUser['role'],
name: user.name,
tenantId: user.tenantId,
departmentId: user.departmentId,
departmentName,
status: user.status.toLowerCase() as SafeUser['status'],
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
});

View File

@@ -0,0 +1,56 @@
import { Body, Controller, Delete, Get, Param, Post, Query, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { AuthService } from '../auth/auth.service.js';
import { getSessionUser } from '../auth/session-user.js';
import { FilesService } from './files.service.js';
@Controller()
export class FilesController {
constructor(
private readonly authService: AuthService,
private readonly filesService: FilesService,
) {}
@Get('files')
async listFiles(@Req() request: Request, @Query('kind') kind?: string) {
const actor = await getSessionUser(request, this.authService);
return { data: await this.filesService.listFiles(actor, { kind }) };
}
@Post('files')
async uploadFile(@Req() request: Request, @Body() body: unknown) {
const actor = await getSessionUser(request, this.authService);
return { data: { file: await this.filesService.uploadFile(actor, body) } };
}
@Delete('files/:id')
async deleteFile(@Req() request: Request, @Param('id') id: string) {
const actor = await getSessionUser(request, this.authService);
await this.filesService.deleteFile(actor, id);
return { data: null };
}
@Post('users/:id/signature')
async uploadSignature(@Req() request: Request, @Param('id') id: string, @Body() body: unknown) {
const actor = await getSessionUser(request, this.authService);
return { data: { file: await this.filesService.uploadSignature(actor, id, body) } };
}
@Delete('users/:id/signature')
async deleteSignature(@Req() request: Request, @Param('id') id: string) {
const actor = await getSessionUser(request, this.authService);
await this.filesService.deleteSignature(actor, id);
return { data: null };
}
@Get('files/:id/content')
async getFileContent(
@Req() request: Request,
@Param('id') id: string,
@Res() response: Response,
) {
const actor = await getSessionUser(request, this.authService);
const { file, buffer } = await this.filesService.readFile(actor, id);
response.type(file.mimeType).send(buffer);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module.js';
import { PrismaModule } from '../prisma/prisma.module.js';
import { FilesController } from './files.controller.js';
import { FilesService } from './files.service.js';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [FilesController],
providers: [FilesService],
})
export class FilesModule {}

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { fileUploadSchema, signatureUploadSchema } from './files.schemas.js';
describe('files schemas', () => {
it('accepts generic template asset uploads', () => {
const parsed = fileUploadSchema.parse({
dataUrl: 'data:image/png;base64,AA==',
filename: 'asset.png',
kind: 'TEMPLATE_ASSET',
});
expect(parsed.kind).toBe('TEMPLATE_ASSET');
});
it('keeps signatures on the dedicated signature schema only', () => {
expect(signatureUploadSchema.parse({ dataUrl: 'data:image/png;base64,AA==' }).dataUrl).toContain('data:');
expect(() => fileUploadSchema.parse({ dataUrl: 'data:image/png;base64,AA==', kind: 'SIGNATURE' })).toThrow();
});
});

View File

@@ -0,0 +1,19 @@
import { z } from 'zod';
export const signatureUploadSchema = z.object({
dataUrl: z.string().startsWith('data:', '签名图片格式不正确'),
filename: z.string().trim().optional(),
}).passthrough();
export type SignatureUploadInput = z.infer<typeof signatureUploadSchema>;
export const fileKindSchema = z.enum(['SIGNATURE', 'TEMPLATE_ASSET', 'VIDEO', 'FRAME', 'REPORT_EXPORT']);
export const fileUploadSchema = z.object({
dataUrl: z.string().startsWith('data:', '文件必须是 base64 Data URL'),
filename: z.string().trim().min(1).optional(),
kind: fileKindSchema.exclude(['SIGNATURE']).default('TEMPLATE_ASSET'),
reportId: z.string().trim().optional(),
}).passthrough();
export type FileUploadInput = z.infer<typeof fileUploadSchema>;

View File

@@ -0,0 +1,351 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { randomUUID, createHash } from 'node:crypto';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { AuditService } from '../audit/audit.service.js';
import type { SafeUser } from '../auth/auth.types.js';
import { canManageUser, canViewReport, isAdmin, isSuper, type AppRole } from '../permissions/permissions.policy.js';
import { PrismaService } from '../prisma/prisma.service.js';
import { fileUploadSchema, signatureUploadSchema } from './files.schemas.js';
const MIME_EXTENSIONS: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
'video/mp4': 'mp4',
'video/webm': 'webm',
'application/json': 'json',
};
const FILE_KIND_DIRECTORIES: Record<string, string> = {
TEMPLATE_ASSET: 'template-assets',
VIDEO: 'videos',
FRAME: 'frames',
REPORT_EXPORT: 'report-exports',
};
@Injectable()
export class FilesService {
constructor(
private readonly prisma: PrismaService,
private readonly audit?: AuditService,
) {}
async uploadFile(actor: SafeUser, rawInput: unknown) {
const result = fileUploadSchema.safeParse(rawInput);
if (!result.success) {
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(''));
}
const report = result.data.reportId
? await this.prisma.report.findFirst({ where: { id: result.data.reportId, tenantId: actor.tenantId } })
: null;
if (result.data.reportId && !report) {
throw new NotFoundException('报告不存在');
}
if (report && !canViewReport(this.actorToPolicy(actor), report)) {
throw new ForbiddenException('无权为此报告上传文件');
}
const decoded = this.decodeDataUrl(result.data.dataUrl, { label: '文件', maxBytes: this.maxBytesForKind(result.data.kind) });
const id = randomUUID();
const extension = MIME_EXTENSIONS[decoded.mimeType] || 'bin';
const directory = FILE_KIND_DIRECTORIES[result.data.kind] || 'files';
const storageKey = path.posix.join(directory, actor.tenantId, actor.id, `${id}.${extension}`);
const absolutePath = this.resolveStoragePath(storageKey);
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, decoded.buffer);
const file = await this.prisma.fileResource.create({
data: {
id,
tenantId: actor.tenantId,
ownerId: actor.id,
reportId: report?.id,
kind: result.data.kind,
filename: result.data.filename || `${result.data.kind.toLowerCase()}.${extension}`,
mimeType: decoded.mimeType,
size: decoded.buffer.length,
storageKey,
checksum: createHash('sha256').update(decoded.buffer).digest('hex'),
},
});
await this.audit?.record({
actor,
action: 'file.upload',
targetType: 'FileResource',
targetId: file.id,
metadata: { kind: file.kind, filename: file.filename, reportId: file.reportId },
});
return this.toFileDto(file);
}
async listFiles(actor: SafeUser, query: { kind?: string } = {}) {
const files = await this.prisma.fileResource.findMany({
where: {
tenantId: actor.tenantId,
...(query.kind ? { kind: query.kind as never } : {}),
OR: [
{ kind: 'TEMPLATE_ASSET' },
{ ownerId: actor.id },
...(isSuper(this.actorToPolicy(actor)) ? [{}] : []),
],
},
orderBy: { createdAt: 'desc' },
take: 200,
});
return { items: files.map((file) => this.toFileDto(file)) };
}
async deleteFile(actor: SafeUser, id: string) {
const file = await this.prisma.fileResource.findFirst({ where: { id, tenantId: actor.tenantId } });
if (!file) {
throw new NotFoundException('文件不存在');
}
if (!this.canManageFile(actor, file)) {
throw new ForbiddenException('无权删除此文件');
}
await this.removeStoredFile(actor.tenantId, file.id);
await this.audit?.record({
actor,
action: 'file.delete',
targetType: 'FileResource',
targetId: file.id,
metadata: { kind: file.kind, filename: file.filename },
});
return null;
}
async uploadSignature(actor: SafeUser, userId: string, rawInput: unknown) {
const result = signatureUploadSchema.safeParse(rawInput);
if (!result.success) {
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(''));
}
const target = await this.findUser(actor.tenantId, userId);
if (!canManageUser(this.actorToPolicy(actor), this.userToPolicy(target))) {
throw new ForbiddenException('无权修改此用户签名');
}
const decoded = this.decodeDataUrl(result.data.dataUrl, { label: '签名图片', allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'], maxBytes: 1024 * 1024 });
const id = randomUUID();
const extension = MIME_EXTENSIONS[decoded.mimeType];
const storageKey = path.posix.join('signatures', actor.tenantId, target.id, `${id}.${extension}`);
const absolutePath = this.resolveStoragePath(storageKey);
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, decoded.buffer);
if (target.signatureFileId) {
await this.removeStoredFile(actor.tenantId, target.signatureFileId).catch(() => {});
}
const file = await this.prisma.fileResource.create({
data: {
id,
tenantId: actor.tenantId,
ownerId: target.id,
kind: 'SIGNATURE',
filename: result.data.filename || `signature.${extension}`,
mimeType: decoded.mimeType,
size: decoded.buffer.length,
storageKey,
checksum: createHash('sha256').update(decoded.buffer).digest('hex'),
},
});
await this.prisma.user.update({
where: { id: target.id },
data: { signatureFileId: file.id },
});
await this.audit?.record({
actor,
action: 'user.signature.upload',
targetType: 'User',
targetId: target.id,
departmentId: target.departmentId,
metadata: { fileId: file.id, filename: file.filename },
});
return this.toFileDto(file);
}
async deleteSignature(actor: SafeUser, userId: string) {
const target = await this.findUser(actor.tenantId, userId);
if (!canManageUser(this.actorToPolicy(actor), this.userToPolicy(target))) {
throw new ForbiddenException('无权删除此用户签名');
}
if (target.signatureFileId) {
await this.removeStoredFile(actor.tenantId, target.signatureFileId).catch(() => {});
}
await this.prisma.user.update({
where: { id: target.id },
data: { signatureFileId: null },
});
await this.audit?.record({
actor,
action: 'user.signature.delete',
targetType: 'User',
targetId: target.id,
departmentId: target.departmentId,
});
return null;
}
async readFile(actor: SafeUser, id: string) {
const file = await this.prisma.fileResource.findFirst({
where: { id, tenantId: actor.tenantId },
include: {
report: true,
},
});
if (!file) {
throw new NotFoundException('文件不存在');
}
await this.ensureCanReadFile(actor, file);
return {
file,
buffer: await readFile(this.resolveStoragePath(file.storageKey)),
};
}
private async ensureCanReadFile(
actor: SafeUser,
file: Awaited<ReturnType<FilesService['findFileForRead']>>,
) {
if (!file) throw new NotFoundException('文件不存在');
if (isSuper(this.actorToPolicy(actor))) return;
if (file.kind === 'SIGNATURE') {
if (file.ownerId === actor.id) return;
if (isAdmin(this.actorToPolicy(actor))) {
const owner = file.ownerId
? await this.prisma.user.findFirst({ where: { id: file.ownerId, tenantId: actor.tenantId } })
: null;
if (owner?.departmentId === actor.departmentId) return;
}
}
if (file.kind === 'TEMPLATE_ASSET') return;
if (file.report && canViewReport(this.actorToPolicy(actor), file.report)) return;
if (file.ownerId === actor.id) return;
throw new ForbiddenException('无权读取此文件');
}
private async findFileForRead(tenantId: string, id: string) {
return this.prisma.fileResource.findFirst({
where: { id, tenantId },
include: { report: true },
});
}
private async findUser(tenantId: string, id: string) {
const user = await this.prisma.user.findFirst({
where: { tenantId, OR: [{ id }, { username: id }] },
});
if (!user) {
throw new NotFoundException('用户不存在');
}
return user;
}
private async removeStoredFile(tenantId: string, id: string) {
const file = await this.prisma.fileResource.findFirst({ where: { id, tenantId } });
if (!file) return;
await rm(this.resolveStoragePath(file.storageKey), { force: true });
await this.prisma.fileResource.delete({ where: { id: file.id } });
}
private decodeDataUrl(
dataUrl: string,
options: { label: string; allowedMimeTypes?: string[]; maxBytes: number },
) {
const match = /^data:([^;,]+);base64,(.+)$/u.exec(dataUrl);
if (!match) {
throw new BadRequestException(`${options.label}必须是 base64 Data URL`);
}
const mimeType = match[1];
if (!MIME_EXTENSIONS[mimeType] || (options.allowedMimeTypes && !options.allowedMimeTypes.includes(mimeType))) {
throw new BadRequestException(`${options.label}类型不支持`);
}
const buffer = Buffer.from(match[2], 'base64');
if (buffer.length === 0) {
throw new BadRequestException(`${options.label}不能为空`);
}
if (buffer.length > options.maxBytes) {
throw new BadRequestException(`${options.label}大小超过限制`);
}
return { mimeType, buffer };
}
private maxBytesForKind(kind: string) {
if (kind === 'VIDEO') return 1024 * 1024 * 200;
if (kind === 'REPORT_EXPORT') return 1024 * 1024 * 10;
return 1024 * 1024 * 8;
}
private canManageFile(
actor: SafeUser,
file: { tenantId: string; ownerId: string | null; kind: string },
) {
if (file.tenantId !== actor.tenantId) return false;
if (isSuper(this.actorToPolicy(actor))) return true;
if (file.ownerId === actor.id) return true;
return file.kind === 'TEMPLATE_ASSET' && isAdmin(this.actorToPolicy(actor));
}
private resolveStoragePath(storageKey: string) {
const baseDir = path.resolve(process.env.FILE_STORAGE_DIR || path.join(process.cwd(), 'uploads'));
const absolutePath = path.resolve(baseDir, storageKey);
if (!absolutePath.startsWith(baseDir)) {
throw new BadRequestException('文件路径不合法');
}
return absolutePath;
}
private toFileDto(file: { id: string; filename: string; mimeType: string; size: number; createdAt: Date }) {
return {
id: file.id,
filename: file.filename,
mimeType: file.mimeType,
size: file.size,
url: `/api/files/${file.id}/content`,
createdAt: file.createdAt.toISOString(),
};
}
private actorToPolicy(actor: SafeUser) {
return {
id: actor.id,
tenantId: actor.tenantId,
departmentId: actor.departmentId,
role: actor.role,
};
}
private userToPolicy(user: { id: string; tenantId: string; departmentId: string; role: string }) {
return {
id: user.id,
tenantId: user.tenantId,
departmentId: user.departmentId,
role: (user.role.toLowerCase() === 'doctor' ? 'doctor' : user.role.toLowerCase()) as AppRole,
};
}
}

View File

@@ -0,0 +1,15 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
getHealth() {
return {
data: {
status: 'ok',
service: 'surclaw-api',
timestamp: new Date().toISOString(),
},
};
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller.js';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View File

@@ -0,0 +1,169 @@
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import type { NextFunction, Request, Response } from 'express';
import request from 'supertest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthController } from './auth/auth.controller.js';
import { AuthService } from './auth/auth.service.js';
import type { SafeUser } from './auth/auth.types.js';
import { ApiExceptionFilter } from './common/api-exception.filter.js';
import { FilesController } from './files/files.controller.js';
import { FilesService } from './files/files.service.js';
import { HealthController } from './health/health.controller.js';
import { LibraryController } from './library/library.controller.js';
import { LibraryService } from './library/library.service.js';
import { ReportsController } from './reports/reports.controller.js';
import { ReportsService } from './reports/reports.service.js';
import { TemplatesController } from './templates/templates.controller.js';
import { TemplatesService } from './templates/templates.service.js';
const actor: SafeUser = {
id: 'usr_admin',
username: 'admin',
role: 'super',
name: '系统管理员',
tenantId: 'tenant_1',
departmentId: 'dept_1',
departmentName: '外科',
status: 'active',
createdAt: '2026-05-02T00:00:00.000Z',
updatedAt: '2026-05-02T00:00:00.000Z',
};
describe('HTTP API integration', () => {
let app: INestApplication;
const authService = {
login: vi.fn(async () => actor),
findMe: vi.fn(async () => actor),
};
const reportsService = {
list: vi.fn(async () => ({ items: [{ id: 'rpt_1' }], total: 1 })),
get: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
};
const templatesService = {
list: vi.fn(async () => ({ items: [{ id: 'tpl_1' }], total: 1 })),
get: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
};
const libraryService = {
getFieldLibrary: vi.fn(async () => ({ formFields: [], customTimeFormats: [], multiSelectOptions: {}, anesthesiaOptions: [] })),
updateFieldLibrary: vi.fn(async (_actor, body) => body),
};
const filesService = {
listFiles: vi.fn(async () => ({ items: [] })),
uploadFile: vi.fn(async () => ({ id: 'file_1', filename: 'a.png', mimeType: 'image/png', size: 1, url: '/api/files/file_1/content', createdAt: '2026-05-02T00:00:00.000Z' })),
deleteFile: vi.fn(async () => null),
uploadSignature: vi.fn(),
deleteSignature: vi.fn(),
readFile: vi.fn(),
};
beforeEach(async () => {
vi.clearAllMocks();
const moduleRef = await Test.createTestingModule({
controllers: [
HealthController,
AuthController,
ReportsController,
TemplatesController,
LibraryController,
FilesController,
],
providers: [
{ provide: AuthService, useValue: authService },
{ provide: ReportsService, useValue: reportsService },
{ provide: TemplatesService, useValue: templatesService },
{ provide: LibraryService, useValue: libraryService },
{ provide: FilesService, useValue: filesService },
],
}).compile();
(moduleRef.get(AuthController) as unknown as { authService: unknown }).authService = authService;
(moduleRef.get(ReportsController) as unknown as { authService: unknown; reportsService: unknown }).authService = authService;
(moduleRef.get(ReportsController) as unknown as { authService: unknown; reportsService: unknown }).reportsService = reportsService;
(moduleRef.get(TemplatesController) as unknown as { authService: unknown; templatesService: unknown }).authService = authService;
(moduleRef.get(TemplatesController) as unknown as { authService: unknown; templatesService: unknown }).templatesService = templatesService;
(moduleRef.get(LibraryController) as unknown as { authService: unknown; libraryService: unknown }).authService = authService;
(moduleRef.get(LibraryController) as unknown as { authService: unknown; libraryService: unknown }).libraryService = libraryService;
(moduleRef.get(FilesController) as unknown as { authService: unknown; filesService: unknown }).authService = authService;
(moduleRef.get(FilesController) as unknown as { authService: unknown; filesService: unknown }).filesService = filesService;
app = moduleRef.createNestApplication();
app.setGlobalPrefix('api');
app.useGlobalFilters(new ApiExceptionFilter());
const sessions = new Map<string, { userId?: string }>();
app.use((req: Request, res: Response, next: NextFunction) => {
const cookie = req.headers.cookie || '';
const existingId = /surclaw_test_sid=([^;]+)/u.exec(cookie)?.[1];
const id = existingId || `sid_${Math.random().toString(36).slice(2)}`;
const data = sessions.get(id) || {};
sessions.set(id, data);
req.session = Object.assign(data, {
save: (callback: (error?: unknown) => void) => {
sessions.set(id, data);
res.setHeader('Set-Cookie', `surclaw_test_sid=${id}; Path=/; HttpOnly`);
callback();
},
destroy: (callback: (error?: unknown) => void) => {
sessions.delete(id);
res.setHeader('Set-Cookie', 'surclaw_test_sid=; Path=/; Max-Age=0');
callback();
},
}) as Request['session'];
next();
});
await app.init();
});
afterEach(async () => {
await app.close();
});
it('serves health checks through the API prefix', async () => {
await request(app.getHttpServer())
.get('/api/health')
.expect(200)
.expect((response) => {
expect(response.body.data.service).toBe('surclaw-api');
});
});
it('persists login session cookies for subsequent API calls', async () => {
const agent = request.agent(app.getHttpServer());
await agent.post('/api/auth/login').send({ username: 'admin', password: '123456' }).expect(200);
await agent.get('/api/auth/me').expect(200).expect((response) => {
expect(response.body.data.user.username).toBe('admin');
});
});
it('rejects protected APIs without a session', async () => {
await request(app.getHttpServer())
.get('/api/reports')
.expect(401)
.expect((response) => {
expect(response.body.error.code).toBe('UNAUTHORIZED');
});
});
it('passes the session actor into reports, templates, library and files APIs', async () => {
const agent = request.agent(app.getHttpServer());
await agent.post('/api/auth/login').send({ username: 'admin', password: '123456' }).expect(200);
await agent.get('/api/reports').expect(200);
expect(reportsService.list).toHaveBeenCalledWith(expect.objectContaining({ id: actor.id }), expect.any(Object));
await agent.get('/api/templates?access=manage').expect(200);
expect(templatesService.list).toHaveBeenCalledWith(expect.objectContaining({ id: actor.id }), expect.any(Object));
await agent.patch('/api/library/fields').send({ formFields: [] }).expect(200);
expect(libraryService.updateFieldLibrary).toHaveBeenCalledWith(expect.objectContaining({ id: actor.id }), { formFields: [] });
await agent.post('/api/files').send({ dataUrl: 'data:image/png;base64,AA==', filename: 'a.png', kind: 'TEMPLATE_ASSET' }).expect(201);
expect(filesService.uploadFile).toHaveBeenCalledWith(expect.objectContaining({ id: actor.id }), expect.objectContaining({ filename: 'a.png' }));
});
});

View File

@@ -0,0 +1,25 @@
import { Body, Controller, Get, Patch, Req } from '@nestjs/common';
import type { Request } from 'express';
import { AuthService } from '../auth/auth.service.js';
import { getSessionUser } from '../auth/session-user.js';
import { LibraryService } from './library.service.js';
@Controller('library')
export class LibraryController {
constructor(
private readonly authService: AuthService,
private readonly libraryService: LibraryService,
) {}
@Get('fields')
async getFieldLibrary(@Req() request: Request) {
const actor = await getSessionUser(request, this.authService);
return { data: { library: await this.libraryService.getFieldLibrary(actor) } };
}
@Patch('fields')
async updateFieldLibrary(@Req() request: Request, @Body() body: unknown) {
const actor = await getSessionUser(request, this.authService);
return { data: { library: await this.libraryService.updateFieldLibrary(actor, body) } };
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module.js';
import { PrismaModule } from '../prisma/prisma.module.js';
import { LibraryController } from './library.controller.js';
import { LibraryService } from './library.service.js';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [LibraryController],
providers: [LibraryService],
exports: [LibraryService],
})
export class LibraryModule {}

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { fieldLibrarySchema } from './library.schemas.js';
describe('fieldLibrarySchema', () => {
it('accepts form field and option library payloads', () => {
const parsed = fieldLibrarySchema.parse({
formFields: [{
key: 'patientName',
label: '患者姓名',
category: '填空',
type: 'text',
visibleInForm: true,
isSystemLocked: true,
}],
customTimeFormats: ['YYYY-MM-DD'],
multiSelectOptions: { surgeon: ['张医生'] },
anesthesiaOptions: ['全麻'],
});
expect(parsed.formFields[0].key).toBe('patientName');
expect(parsed.multiSelectOptions.surgeon).toEqual(['张医生']);
});
it('rejects invalid field types', () => {
expect(() => fieldLibrarySchema.parse({
formFields: [{
key: 'bad',
label: '坏字段',
category: '填空',
type: 'unknown',
visibleInForm: true,
isSystemLocked: false,
}],
})).toThrow();
});
});

View File

@@ -0,0 +1,26 @@
import { z } from 'zod';
const fieldTypeSchema = z.enum(['text', 'single_select', 'multi_select', 'time', 'date', 'signature', 'image']);
export const formFieldSchema = z.object({
key: z.string().trim().min(1),
label: z.string().trim().min(1),
category: z.string().trim().min(1),
type: fieldTypeSchema,
visibleInForm: z.boolean(),
isSystemLocked: z.boolean(),
options: z.array(z.string()).optional(),
timeFormat: z.string().optional(),
timeDefault: z.enum(['current', 'specific']).optional(),
fixedTimeValue: z.string().optional(),
hasUnderline: z.boolean().optional(),
});
export const fieldLibrarySchema = z.object({
formFields: z.array(formFieldSchema).default([]),
customTimeFormats: z.array(z.string()).default([]),
multiSelectOptions: z.record(z.string(), z.array(z.string())).default({}),
anesthesiaOptions: z.array(z.string()).default([]),
});
export type FieldLibraryInput = z.infer<typeof fieldLibrarySchema>;

View File

@@ -0,0 +1,98 @@
import { 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 { fieldLibrarySchema, type FieldLibraryInput } from './library.schemas.js';
const DEFAULT_FIELD_LIBRARY: FieldLibraryInput = {
formFields: [],
customTimeFormats: ['YYYY-MM-DD', 'YYYY年MM月DD日', 'MM-DD', 'MM月DD日', 'HH:mm', 'hh:mm A'],
multiSelectOptions: {},
anesthesiaOptions: ['全麻', '局麻', '腰麻', '硬膜外麻醉', '静脉麻醉', '吸入麻醉'],
};
@Injectable()
export class LibraryService {
constructor(private readonly prisma: PrismaService) {}
async getFieldLibrary(actor: SafeUser) {
const saved = await this.getSettingValue(actor.tenantId, 'global', 'fieldLibrary');
return this.normalize(saved);
}
async updateFieldLibrary(actor: SafeUser, rawInput: unknown) {
const current = await this.getFieldLibrary(actor);
const input = this.normalize(rawInput);
const next = this.normalize({
...current,
...input,
});
await this.setSettingValue(actor.tenantId, 'global', 'fieldLibrary', this.toJson(next));
return next;
}
private normalize(input: unknown): FieldLibraryInput {
const result = fieldLibrarySchema.safeParse({
...DEFAULT_FIELD_LIBRARY,
...(this.isObject(input) ? input : {}),
});
if (!result.success) {
return DEFAULT_FIELD_LIBRARY;
}
return {
...result.data,
customTimeFormats: Array.from(new Set(result.data.customTimeFormats.filter(Boolean))),
anesthesiaOptions: Array.from(new Set(result.data.anesthesiaOptions.filter(Boolean))),
multiSelectOptions: Object.fromEntries(
Object.entries(result.data.multiSelectOptions).map(([key, values]) => [
key,
Array.from(new Set(values.filter(Boolean))),
]),
),
};
}
private async getSettingValue(tenantId: string, scope: string, key: string) {
const setting = await this.prisma.systemSetting.findFirst({
where: { tenantId, scope, departmentId: null, key },
});
return setting?.value;
}
private async setSettingValue(
tenantId: string,
scope: string,
key: string,
value: Prisma.InputJsonValue,
) {
const existing = await this.prisma.systemSetting.findFirst({
where: { tenantId, scope, departmentId: null, key },
});
if (existing) {
await this.prisma.systemSetting.update({
where: { id: existing.id },
data: { value },
});
return;
}
await this.prisma.systemSetting.create({
data: {
tenantId,
scope,
key,
value,
},
});
}
private toJson(value: unknown): Prisma.InputJsonValue {
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
}
private isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
}

49
server/src/main.ts Normal file
View File

@@ -0,0 +1,49 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import cookieParser from 'cookie-parser';
import session from 'express-session';
import { AppModule } from './app.module.js';
import { AuthService } from './auth/auth.service.js';
import { ApiExceptionFilter } from './common/api-exception.filter.js';
import { PrismaService } from './prisma/prisma.service.js';
import { PrismaSessionStore } from './session/prisma-session.store.js';
import { attachSpeechProxy } from './speech/speech.gateway.js';
import { SpeechService } from './speech/speech.service.js';
const bootstrap = async () => {
const app = await NestFactory.create(AppModule);
const port = Number(process.env.API_PORT ?? 3000);
app.setGlobalPrefix('api');
app.useGlobalFilters(new ApiExceptionFilter());
app.enableCors({
origin: process.env.CORS_ORIGIN?.split(',') ?? ['http://localhost:3001'],
credentials: true,
});
app.use(cookieParser());
const sessionMiddleware = session({
name: 'surclaw.sid',
secret: process.env.SESSION_SECRET ?? 'dev-only-session-secret-change-me',
store: new PrismaSessionStore(app.get(PrismaService)),
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: process.env.SESSION_COOKIE_SECURE === 'true',
maxAge: 1000 * 60 * 60 * 8,
},
});
app.use(sessionMiddleware);
await app.listen(port, '0.0.0.0');
attachSpeechProxy(
app.getHttpServer(),
sessionMiddleware,
app.get(AuthService),
app.get(SpeechService),
);
};
void bootstrap();

View File

@@ -0,0 +1,4 @@
import { Module } from '@nestjs/common';
@Module({})
export class PermissionsModule {}

View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest';
import {
canCreateAdmin,
canManageTemplate,
canManageUser,
canUseTemplate,
canViewReport,
type Actor,
} from './permissions.policy';
const superAdmin: Actor = {
id: 'u-super',
tenantId: 'tenant-a',
departmentId: 'dept-admin',
role: 'super',
};
const admin: Actor = {
id: 'u-admin',
tenantId: 'tenant-a',
departmentId: 'dept-surgery',
role: 'admin',
};
const doctor: Actor = {
id: 'u-doctor',
tenantId: 'tenant-a',
departmentId: 'dept-surgery',
role: 'doctor',
};
describe('backend permission policy', () => {
it('limits reports by role and department', () => {
const departmentReport = {
tenantId: 'tenant-a',
departmentId: 'dept-surgery',
authorId: 'u-other',
};
const otherDepartmentReport = {
tenantId: 'tenant-a',
departmentId: 'dept-internal',
authorId: 'u-other',
};
const ownReport = {
tenantId: 'tenant-a',
departmentId: 'dept-surgery',
authorId: 'u-doctor',
};
expect(canViewReport(superAdmin, otherDepartmentReport)).toBe(true);
expect(canViewReport(admin, departmentReport)).toBe(true);
expect(canViewReport(admin, otherDepartmentReport)).toBe(false);
expect(canViewReport(doctor, ownReport)).toBe(true);
expect(canViewReport(doctor, departmentReport)).toBe(false);
});
it('keeps tenant boundaries hard', () => {
expect(
canViewReport(superAdmin, {
tenantId: 'tenant-b',
departmentId: 'dept-surgery',
authorId: 'u-doctor',
}),
).toBe(false);
});
it('allows department templates and personal templates by owner', () => {
expect(
canUseTemplate(doctor, {
tenantId: 'tenant-a',
scope: 'department',
ownerDepartmentId: 'dept-surgery',
}),
).toBe(true);
expect(
canUseTemplate(doctor, {
tenantId: 'tenant-a',
scope: 'personal',
ownerUserId: 'u-doctor',
}),
).toBe(true);
expect(
canUseTemplate(doctor, {
tenantId: 'tenant-a',
scope: 'personal',
ownerUserId: 'u-other',
}),
).toBe(false);
});
it('allows only super admins and matching department admins to manage department templates', () => {
const departmentTemplate = {
tenantId: 'tenant-a',
scope: 'department' as const,
ownerDepartmentId: 'dept-surgery',
};
expect(canManageTemplate(superAdmin, departmentTemplate)).toBe(true);
expect(canManageTemplate(admin, departmentTemplate)).toBe(true);
expect(canManageTemplate(doctor, departmentTemplate)).toBe(false);
});
it('keeps admin creation restricted to super admins', () => {
expect(canCreateAdmin(superAdmin)).toBe(true);
expect(canCreateAdmin(admin)).toBe(false);
});
it('lets department admins manage only doctors in their department', () => {
expect(canManageUser(admin, doctor)).toBe(true);
expect(canManageUser(admin, admin)).toBe(true);
expect(canManageUser(admin, { ...doctor, departmentId: 'dept-other' })).toBe(false);
expect(canManageUser(admin, { ...doctor, role: 'admin' })).toBe(false);
});
});

View File

@@ -0,0 +1,85 @@
export type AppRole = 'super' | 'admin' | 'doctor' | 'user';
export interface Actor {
id: string;
tenantId: string;
departmentId: string;
role: AppRole;
}
export interface ReportResource {
tenantId: string;
departmentId: string;
authorId: string;
}
export interface TemplateResource {
tenantId: string;
scope: 'department' | 'personal';
ownerDepartmentId?: string | null;
ownerUserId?: string | null;
permittedDepartmentIds?: string[];
manageableDepartmentIds?: string[];
}
const normalizeRole = (role: AppRole) => (role === 'user' ? 'doctor' : role);
export const isSuper = (actor: Actor) => normalizeRole(actor.role) === 'super';
export const isAdmin = (actor: Actor) => normalizeRole(actor.role) === 'admin';
export const isDoctor = (actor: Actor) => normalizeRole(actor.role) === 'doctor';
export const canViewReport = (actor: Actor, report: ReportResource) => {
if (actor.tenantId !== report.tenantId) return false;
if (isSuper(actor)) return true;
if (isAdmin(actor)) return actor.departmentId === report.departmentId;
return actor.id === report.authorId;
};
export const canEditReport = canViewReport;
export const canDeleteReport = canViewReport;
export const canExportReport = canViewReport;
export const canUseTemplate = (actor: Actor, template: TemplateResource) => {
if (actor.tenantId !== template.tenantId) return false;
if (isSuper(actor)) return true;
if (template.scope === 'personal') {
return template.ownerUserId === actor.id;
}
return (
template.ownerDepartmentId === actor.departmentId ||
template.permittedDepartmentIds?.includes(actor.departmentId) === true
);
};
export const canManageTemplate = (actor: Actor, template: TemplateResource) => {
if (actor.tenantId !== template.tenantId) return false;
if (isSuper(actor)) return true;
if (template.scope === 'personal') {
return template.ownerUserId === actor.id;
}
return (
isAdmin(actor) &&
(template.ownerDepartmentId === actor.departmentId ||
template.manageableDepartmentIds?.includes(actor.departmentId) === true)
);
};
export const canManageUser = (actor: Actor, target: Actor) => {
if (actor.tenantId !== target.tenantId) return false;
if (actor.id === target.id) return true;
if (isSuper(actor)) return true;
if (isAdmin(actor)) {
return target.departmentId === actor.departmentId && isDoctor(target);
}
return false;
};
export const canCreateAdmin = (actor: Actor) => isSuper(actor);

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service.js';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@@ -0,0 +1,36 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client';
import { Pool } from 'pg';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly pool: Pool;
constructor(configService: ConfigService) {
const connectionString = configService.get<string>('DATABASE_URL');
if (!connectionString) {
throw new Error('DATABASE_URL is required to start the API server');
}
const pool = new Pool({
connectionString,
});
super({
adapter: new PrismaPg(pool),
});
this.pool = pool;
}
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
await this.pool.end();
}
}

View File

@@ -0,0 +1,158 @@
import { describe, expect, it } from 'vitest';
import { extractReportMetadata, toFrontendReport } from './report.mapper';
describe('report mapper', () => {
it('keeps frontend compatibility fields in metadata and restores response shape', () => {
expect(extractReportMetadata({
id: 'ignored',
title: '腹腔镜手术',
patientName: '患者',
hospitalId: 'H001',
patientGender: '女',
videos: [{ id: 'v1', name: 'video.mp4' }],
})).toEqual({
patientGender: '女',
});
const now = new Date('2026-05-01T00:00:00.000Z');
const mapped = toFrontendReport({
id: 'r1',
tenantId: 't1',
departmentId: 'd1',
authorId: 'u1',
templateId: null,
title: '腹腔镜手术',
patientName: '患者',
hospitalId: 'H001',
status: 'COMPLETED',
revision: 2,
content: '<p>报告</p>',
metadata: { patientGender: '女' },
deletedAt: null,
deletedBy: null,
createdAt: now,
updatedAt: now,
media: [
{
id: 'm1',
tenantId: 't1',
reportId: 'r1',
fileId: 'f1',
kind: 'VIDEO',
clientId: 'v1',
name: 'video.mp4',
url: 'blob:local',
time: null,
videoIndex: null,
videoName: null,
sortOrder: 0,
metadata: { duration: 12 },
createdAt: now,
updatedAt: now,
file: {
id: 'f1',
tenantId: 't1',
ownerId: 'u1',
reportId: 'r1',
kind: 'VIDEO',
filename: 'video.mp4',
mimeType: 'video/mp4',
size: 100,
storageKey: 'videos/t1/u1/f1.mp4',
checksum: null,
createdAt: now,
},
},
{
id: 'm2',
tenantId: 't1',
reportId: 'r1',
fileId: 'f2',
kind: 'FRAME',
clientId: '101',
name: null,
url: 'data:image/jpeg;base64,old',
time: 3.5,
videoIndex: 0,
videoName: 'video.mp4',
sortOrder: 0,
metadata: { timeFormatted: '0:03', isManual: true, manualOrder: 1 },
createdAt: now,
updatedAt: now,
file: {
id: 'f2',
tenantId: 't1',
ownerId: 'u1',
reportId: 'r1',
kind: 'FRAME',
filename: 'frame.jpg',
mimeType: 'image/jpeg',
size: 100,
storageKey: 'frames/t1/u1/f2.jpg',
checksum: null,
createdAt: now,
},
},
],
author: {
id: 'u1',
tenantId: 't1',
departmentId: 'd1',
username: '0001',
passwordHash: '',
role: 'DOCTOR',
name: '张医生',
status: 'ACTIVE',
phone: null,
email: null,
signatureFileId: null,
lastLoginAt: null,
createdAt: now,
updatedAt: now,
},
department: {
id: 'd1',
tenantId: 't1',
name: '外科',
code: 'surgery',
createdAt: now,
updatedAt: now,
},
histories: [
{
id: 'h1',
reportId: 'r1',
revision: 1,
content: '<p>旧报告</p>',
action: 'complete_report',
updatedById: 'u1',
updatedBy: '张医生',
createdAt: now,
},
],
});
expect(mapped).toMatchObject({
id: 'r1',
patientGender: '女',
author: '0001',
authorName: '张医生',
department: '外科',
status: 'completed',
revision: 2,
videos: [{ id: 'v1', name: 'video.mp4', url: '/api/files/f1/content', duration: 12, fileId: 'f1' }],
capturedFrames: [{
id: 101,
videoIndex: 0,
videoName: 'video.mp4',
time: 3.5,
timeFormatted: '0:03',
dataUrl: '/api/files/f2/content',
fileId: 'f2',
isManual: true,
manualOrder: 1,
}],
history: [{ revision: 1, action: 'complete_report' }],
});
});
});

View File

@@ -0,0 +1,199 @@
import type { Prisma } from '@prisma/client';
const MEDIA_METADATA_KEYS = new Set(['videos', 'capturedFrames']);
const FRONTEND_CANONICAL_KEYS = new Set([
'id',
'title',
'patientName',
'hospitalId',
'content',
'author',
'authorName',
'department',
'createdAt',
'updatedAt',
'status',
'revision',
'history',
...MEDIA_METADATA_KEYS,
]);
export const reportInclude = {
author: true,
department: true,
histories: { orderBy: { createdAt: 'asc' } },
media: {
include: { file: true },
orderBy: [{ kind: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
},
} satisfies Prisma.ReportInclude;
type ReportWithRelations = Prisma.ReportGetPayload<{ include: typeof reportInclude }>;
export interface ExtractedReportMedia {
videos: Array<{
clientId: string;
fileId?: string;
name?: string;
url?: string;
duration?: number;
sortOrder: number;
}>;
frames: Array<{
clientId: string;
fileId?: string;
url?: string;
time?: number;
timeFormatted?: string;
videoIndex?: number;
videoName?: string;
isManual?: boolean;
manualOrder?: number;
sortOrder: number;
}>;
hasPayload: boolean;
}
export const extractReportMetadata = (input: Record<string, unknown>) => {
const metadata: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input)) {
if (!FRONTEND_CANONICAL_KEYS.has(key)) {
metadata[key] = value;
}
}
return JSON.parse(JSON.stringify(metadata)) as Record<string, unknown>;
};
export const sanitizeReportMetadata = (metadata: unknown) => {
const next =
metadata && typeof metadata === 'object' && !Array.isArray(metadata)
? { ...(metadata as Record<string, unknown>) }
: {};
for (const key of MEDIA_METADATA_KEYS) {
delete next[key];
}
return next;
};
export const extractReportMedia = (input: Record<string, unknown>): ExtractedReportMedia => {
const rawVideos = Array.isArray(input.videos) ? input.videos : [];
const rawFrames = Array.isArray(input.capturedFrames) ? input.capturedFrames : [];
return {
videos: rawVideos.map((item, index) => {
const video = objectRecord(item);
return {
clientId: stringValue(video.id) || `video-${index + 1}`,
fileId: stringValue(video.fileId) || undefined,
name: stringValue(video.name) || undefined,
url: stringValue(video.url) || undefined,
duration: numberValue(video.duration),
sortOrder: index,
};
}),
frames: rawFrames.map((item, index) => {
const frame = objectRecord(item);
return {
clientId: stringValue(frame.id) || `frame-${index + 1}`,
fileId: stringValue(frame.fileId) || undefined,
url: stringValue(frame.dataUrl) || undefined,
time: numberValue(frame.time),
timeFormatted: stringValue(frame.timeFormatted) || undefined,
videoIndex: integerValue(frame.videoIndex),
videoName: stringValue(frame.videoName) || undefined,
isManual: booleanValue(frame.isManual),
manualOrder: integerValue(frame.manualOrder),
sortOrder: index,
};
}),
hasPayload: Object.prototype.hasOwnProperty.call(input, 'videos')
|| Object.prototype.hasOwnProperty.call(input, 'capturedFrames'),
};
};
export const toFrontendReport = (report: ReportWithRelations) => {
const metadata = sanitizeReportMetadata(report.metadata);
const legacyMetadata =
report.metadata && typeof report.metadata === 'object' && !Array.isArray(report.metadata)
? (report.metadata as Record<string, unknown>)
: {};
const videos = report.media.filter((item) => item.kind === 'VIDEO').map((item, index) => ({
id: item.clientId,
name: item.name || item.file?.filename || `视频 ${index + 1}`,
url: item.fileId ? `/api/files/${item.fileId}/content` : (item.url || ''),
duration: numberValue(objectRecord(item.metadata).duration) ?? 0,
...(item.fileId ? { fileId: item.fileId } : {}),
}));
const capturedFrames = report.media.filter((item) => item.kind === 'FRAME').map((item, index) => {
const frameMetadata = objectRecord(item.metadata);
return {
id: numericClientId(item.clientId, index + 1),
videoIndex: item.videoIndex ?? 0,
videoName: item.videoName || '',
time: item.time ?? 0,
timeFormatted: stringValue(frameMetadata.timeFormatted) || formatSeconds(item.time ?? 0),
dataUrl: item.fileId ? `/api/files/${item.fileId}/content` : (item.url || ''),
...(item.fileId ? { fileId: item.fileId } : {}),
...(booleanValue(frameMetadata.isManual) !== undefined ? { isManual: booleanValue(frameMetadata.isManual) } : {}),
...(integerValue(frameMetadata.manualOrder) !== undefined ? { manualOrder: integerValue(frameMetadata.manualOrder) } : {}),
};
});
return {
...metadata,
id: report.id,
title: report.title,
patientName: report.patientName,
hospitalId: report.hospitalId,
content: report.content,
author: report.author.username,
authorName: report.author.name,
department: report.department.name,
createdAt: report.createdAt.toISOString(),
updatedAt: report.updatedAt.toISOString(),
status: report.status.toLowerCase(),
revision: report.revision,
videos: videos.length > 0 ? videos : (Array.isArray(legacyMetadata.videos) ? legacyMetadata.videos : []),
capturedFrames: capturedFrames.length > 0
? capturedFrames
: (Array.isArray(legacyMetadata.capturedFrames) ? legacyMetadata.capturedFrames : []),
history: report.histories.map((history) => ({
content: history.content,
updatedAt: history.createdAt.toISOString(),
updatedBy: history.updatedBy,
action: history.action === 'complete_report' ? 'complete_report' : 'save_draft',
revision: history.revision,
})),
};
};
const objectRecord = (value: unknown): Record<string, unknown> =>
value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
const stringValue = (value: unknown) => {
if (typeof value === 'string') return value;
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
return '';
};
const numberValue = (value: unknown) =>
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
const integerValue = (value: unknown) =>
typeof value === 'number' && Number.isInteger(value) ? value : undefined;
const booleanValue = (value: unknown) =>
typeof value === 'boolean' ? value : undefined;
const numericClientId = (clientId: string, fallback: number) => {
const parsed = Number(clientId);
return Number.isFinite(parsed) ? parsed : fallback;
};
const formatSeconds = (seconds: number) => {
const safeSeconds = Number.isFinite(seconds) ? Math.max(0, Math.floor(seconds)) : 0;
const minutes = Math.floor(safeSeconds / 60);
const rest = safeSeconds % 60;
return `${minutes}:${rest.toString().padStart(2, '0')}`;
};

View File

@@ -0,0 +1,44 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, 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 { ReportsService } from './reports.service.js';
@Controller('reports')
export class ReportsController {
constructor(
private readonly authService: AuthService,
private readonly reportsService: ReportsService,
) {}
@Get()
async list(@Req() request: Request, @Query() query: unknown) {
const actor = await getSessionUser(request, this.authService);
return { data: await this.reportsService.list(actor, query) };
}
@Get(':id')
async get(@Req() request: Request, @Param('id') id: string) {
const actor = await getSessionUser(request, this.authService);
return { data: { report: await this.reportsService.get(actor, id) } };
}
@Post()
async create(@Req() request: Request, @Body() body: unknown) {
const actor = await getSessionUser(request, this.authService);
return { data: { report: await this.reportsService.create(actor, body) } };
}
@Patch(':id')
async update(@Req() request: Request, @Param('id') id: string, @Body() body: unknown) {
const actor = await getSessionUser(request, this.authService);
return { data: { report: await this.reportsService.update(actor, id, body) } };
}
@Delete(':id')
async remove(@Req() request: Request, @Param('id') id: string) {
const actor = await getSessionUser(request, this.authService);
await this.reportsService.remove(actor, id);
return { data: null };
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module.js';
import { ReportsController } from './reports.controller.js';
import { ReportsService } from './reports.service.js';
@Module({
imports: [AuthModule],
controllers: [ReportsController],
providers: [ReportsService],
})
export class ReportsModule {}

View File

@@ -0,0 +1,30 @@
import { z } from 'zod';
export const reportStatusSchema = z.enum(['draft', 'completed']);
const reportBaseSchema = z.object({
title: z.string().trim().min(1, '报告标题不能为空'),
patientName: z.string().trim().min(1, '患者姓名不能为空'),
hospitalId: z.string().trim().min(1, '住院号不能为空'),
content: z.string().default(''),
status: reportStatusSchema.default('draft'),
templateId: z.string().trim().optional(),
}).passthrough();
export const createReportSchema = reportBaseSchema;
export const updateReportSchema = reportBaseSchema.partial().extend({
status: reportStatusSchema.optional(),
}).passthrough();
export const listReportsQuerySchema = z.object({
q: z.string().trim().optional(),
status: reportStatusSchema.optional(),
dateRange: z.enum(['today', 'week', 'month']).optional(),
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().positive().max(100).default(50),
});
export type CreateReportInput = z.infer<typeof createReportSchema>;
export type UpdateReportInput = z.infer<typeof updateReportSchema>;
export type ListReportsQuery = z.infer<typeof listReportsQuerySchema>;

View File

@@ -0,0 +1,371 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { Prisma, ReportStatus } from '@prisma/client';
import { AuditService } from '../audit/audit.service.js';
import type { SafeUser } from '../auth/auth.types.js';
import { sanitizeReportHtml } from '../common/html-sanitizer.js';
import { canDeleteReport, canEditReport, canViewReport } from '../permissions/permissions.policy.js';
import { PrismaService } from '../prisma/prisma.service.js';
import {
extractReportMedia,
extractReportMetadata,
reportInclude,
sanitizeReportMetadata,
toFrontendReport,
type ExtractedReportMedia,
} from './report.mapper.js';
import {
createReportSchema,
listReportsQuerySchema,
updateReportSchema,
type ListReportsQuery,
} from './reports.schemas.js';
@Injectable()
export class ReportsService {
constructor(
private readonly prisma: PrismaService,
private readonly audit?: AuditService,
) {}
async list(actor: SafeUser, rawQuery: unknown) {
const query = this.parseQuery(rawQuery);
const where: Prisma.ReportWhereInput = {
tenantId: actor.tenantId,
deletedAt: null,
...this.visibilityWhere(actor),
...this.searchWhere(query),
};
const [items, total] = await this.prisma.$transaction([
this.prisma.report.findMany({
where,
include: reportInclude,
orderBy: { updatedAt: 'desc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
this.prisma.report.count({ where }),
]);
return {
items: items.map(toFrontendReport),
total,
page: query.page,
pageSize: query.pageSize,
};
}
async get(actor: SafeUser, id: string) {
const report = await this.findReport(id, actor.tenantId);
if (!canViewReport(this.actorToPolicy(actor), report)) {
throw new ForbiddenException('无权查看此报告');
}
return toFrontendReport(report);
}
async create(actor: SafeUser, rawInput: unknown) {
const result = createReportSchema.safeParse(rawInput);
if (!result.success) {
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(''));
}
const input = result.data;
const status = this.toDbStatus(input.status);
const metadata = extractReportMetadata(input);
const media = extractReportMedia(input);
const content = sanitizeReportHtml(input.content);
const report = await this.prisma.$transaction(async (tx) => {
const created = await tx.report.create({
data: {
tenantId: actor.tenantId,
departmentId: actor.departmentId,
authorId: actor.id,
templateId: input.templateId || null,
title: input.title,
patientName: input.patientName,
hospitalId: input.hospitalId,
content,
status,
revision: 1,
metadata: metadata as Prisma.InputJsonValue,
},
});
await this.replaceReportMedia(tx, actor, created.id, media);
return tx.report.findUniqueOrThrow({
where: { id: created.id },
include: reportInclude,
});
});
await this.audit?.record({
actor,
action: status === 'COMPLETED' ? 'report.complete' : 'report.create',
targetType: 'Report',
targetId: report.id,
metadata: { status: report.status, revision: report.revision },
});
return toFrontendReport(report);
}
async update(actor: SafeUser, id: string, rawInput: unknown) {
const result = updateReportSchema.safeParse(rawInput);
if (!result.success) {
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(''));
}
const old = await this.findReport(id, actor.tenantId);
if (!canEditReport(this.actorToPolicy(actor), old)) {
throw new ForbiddenException('无权修改此报告');
}
const input = result.data;
const nextStatus = input.status ? this.toDbStatus(input.status) : old.status;
const nextRevision = old.status === 'COMPLETED' ? old.revision + 1 : old.revision;
const media = extractReportMedia(input);
const content = input.content === undefined ? old.content : sanitizeReportHtml(input.content);
const metadata = {
...sanitizeReportMetadata(old.metadata),
...extractReportMetadata(input),
} as Prisma.InputJsonValue;
const updated = await this.prisma.$transaction(async (tx) => {
await tx.reportHistory.create({
data: {
reportId: old.id,
revision: old.revision,
content: old.content,
action: nextStatus === 'COMPLETED' ? 'complete_report' : 'save_draft',
updatedById: actor.id,
updatedBy: actor.name,
},
});
await tx.report.update({
where: { id },
data: {
title: input.title ?? old.title,
patientName: input.patientName ?? old.patientName,
hospitalId: input.hospitalId ?? old.hospitalId,
content,
status: nextStatus,
revision: nextRevision,
metadata,
},
});
if (media.hasPayload) {
await this.replaceReportMedia(tx, actor, old.id, media);
}
return tx.report.findUniqueOrThrow({
where: { id },
include: reportInclude,
});
});
await this.audit?.record({
actor,
action: nextStatus === 'COMPLETED' ? 'report.complete' : 'report.update',
targetType: 'Report',
targetId: updated.id,
metadata: { status: updated.status, revision: updated.revision },
});
return toFrontendReport(updated);
}
async remove(actor: SafeUser, id: string) {
const report = await this.findReport(id, actor.tenantId);
if (!canDeleteReport(this.actorToPolicy(actor), report)) {
throw new ForbiddenException('无权删除此报告');
}
await this.prisma.report.update({
where: { id },
data: {
deletedAt: new Date(),
deletedBy: actor.id,
},
});
await this.audit?.record({
actor,
action: 'report.delete',
targetType: 'Report',
targetId: report.id,
metadata: { title: report.title, status: report.status },
});
return null;
}
private parseQuery(rawQuery: unknown): ListReportsQuery {
const result = listReportsQuerySchema.safeParse(rawQuery);
if (!result.success) {
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(''));
}
return result.data;
}
private visibilityWhere(actor: SafeUser): Prisma.ReportWhereInput {
if (actor.role === 'super') return {};
if (actor.role === 'admin') return { departmentId: actor.departmentId };
return { authorId: actor.id };
}
private searchWhere(query: ListReportsQuery): Prisma.ReportWhereInput {
const filters: Prisma.ReportWhereInput[] = [];
if (query.status) {
filters.push({ status: this.toDbStatus(query.status) });
}
if (query.q) {
filters.push({
OR: [
{ title: { contains: query.q, mode: 'insensitive' } },
{ patientName: { contains: query.q, mode: 'insensitive' } },
{ hospitalId: { contains: query.q, mode: 'insensitive' } },
],
});
}
const since = this.resolveDateRange(query.dateRange);
if (since) {
filters.push({ createdAt: { gte: since } });
}
return filters.length > 0 ? { AND: filters } : {};
}
private resolveDateRange(dateRange?: ListReportsQuery['dateRange']) {
if (!dateRange) return null;
const now = new Date();
if (dateRange === 'today') {
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
}
if (dateRange === 'week') {
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
}
return new Date(now.getFullYear(), now.getMonth(), 1);
}
private toDbStatus(status: 'draft' | 'completed'): ReportStatus {
return status === 'completed' ? 'COMPLETED' : 'DRAFT';
}
private actorToPolicy(actor: SafeUser) {
return {
id: actor.id,
tenantId: actor.tenantId,
departmentId: actor.departmentId,
role: actor.role,
};
}
private findReport(id: string, tenantId: string) {
return this.prisma.report.findFirst({
where: { id, tenantId, deletedAt: null },
include: reportInclude,
}).then((report) => {
if (!report) {
throw new NotFoundException('报告不存在');
}
return report;
});
}
private async replaceReportMedia(
tx: Prisma.TransactionClient,
actor: SafeUser,
reportId: string,
media: ExtractedReportMedia,
) {
const rows: Prisma.ReportMediaCreateManyInput[] = [
...media.videos.map((video): Prisma.ReportMediaCreateManyInput => ({
tenantId: actor.tenantId,
reportId,
fileId: video.fileId,
kind: 'VIDEO',
clientId: video.clientId,
name: video.name,
url: video.url,
sortOrder: video.sortOrder,
metadata: cleanJson({ duration: video.duration ?? 0 }) as Prisma.InputJsonValue,
})),
...media.frames.map((frame): Prisma.ReportMediaCreateManyInput => ({
tenantId: actor.tenantId,
reportId,
fileId: frame.fileId,
kind: 'FRAME',
clientId: frame.clientId,
url: frame.url,
time: frame.time,
videoIndex: frame.videoIndex,
videoName: frame.videoName,
sortOrder: frame.sortOrder,
metadata: cleanJson({
timeFormatted: frame.timeFormatted,
isManual: frame.isManual,
manualOrder: frame.manualOrder,
}) as Prisma.InputJsonValue,
})),
];
await this.ensureMediaFilesCanBeLinked(tx, actor, reportId, rows);
await tx.reportMedia.deleteMany({ where: { tenantId: actor.tenantId, reportId } });
if (rows.length > 0) {
await tx.reportMedia.createMany({ data: rows });
}
}
private async ensureMediaFilesCanBeLinked(
tx: Prisma.TransactionClient,
actor: SafeUser,
reportId: string,
rows: Prisma.ReportMediaCreateManyInput[],
) {
const ids = [...new Set(rows.map((row) => row.fileId).filter((id): id is string => typeof id === 'string' && id.length > 0))];
if (ids.length === 0) return;
const files = await tx.fileResource.findMany({
where: { tenantId: actor.tenantId, id: { in: ids } },
select: { id: true, kind: true, reportId: true },
});
const fileById = new Map(files.map((file) => [file.id, file]));
for (const row of rows) {
if (!row.fileId) continue;
const file = fileById.get(row.fileId);
if (!file) {
throw new BadRequestException('报告媒体文件不存在');
}
if (file.kind !== row.kind) {
throw new BadRequestException('报告媒体文件类型不匹配');
}
if (file.reportId && file.reportId !== reportId) {
throw new BadRequestException('报告媒体文件已关联其他报告');
}
}
await tx.fileResource.updateMany({
where: {
tenantId: actor.tenantId,
id: { in: ids },
reportId: null,
},
data: { reportId },
});
}
}
const cleanJson = (value: Record<string, unknown>) =>
JSON.parse(JSON.stringify(value)) as Record<string, unknown>;

View File

@@ -0,0 +1,72 @@
import session from 'express-session';
import type { PrismaService } from '../prisma/prisma.service.js';
type SessionData = session.SessionData & { cookie?: session.Cookie };
export class PrismaSessionStore extends session.Store {
constructor(private readonly prisma: PrismaService) {
super();
}
get(sid: string, callback: (err: unknown, session?: session.SessionData | null) => void) {
this.prisma.appSession.findUnique({ where: { id: sid } })
.then((stored) => {
if (!stored || stored.expiresAt.getTime() <= Date.now()) {
if (stored) void this.destroy(sid, () => undefined);
callback(null, null);
return;
}
callback(null, stored.data as unknown as SessionData);
})
.catch((error) => callback(error));
}
set(sid: string, sess: session.SessionData, callback?: (err?: unknown) => void) {
const expiresAt = this.resolveExpiresAt(sess);
this.prisma.appSession.upsert({
where: { id: sid },
create: {
id: sid,
data: JSON.parse(JSON.stringify(sess)),
expiresAt,
},
update: {
data: JSON.parse(JSON.stringify(sess)),
expiresAt,
},
})
.then(() => callback?.())
.catch((error) => callback?.(error));
}
destroy(sid: string, callback?: (err?: unknown) => void) {
this.prisma.appSession.delete({ where: { id: sid } })
.then(() => callback?.())
.catch((error) => {
if (isNotFound(error)) callback?.();
else callback?.(error);
});
}
touch(sid: string, sess: session.SessionData, callback?: () => void) {
this.prisma.appSession.update({
where: { id: sid },
data: {
expiresAt: this.resolveExpiresAt(sess),
data: JSON.parse(JSON.stringify(sess)),
},
})
.then(() => callback?.())
.catch(() => callback?.());
}
private resolveExpiresAt(sess: session.SessionData) {
const cookie = (sess as SessionData).cookie;
if (cookie?.expires) return new Date(cookie.expires);
const maxAge = typeof cookie?.maxAge === 'number' ? cookie.maxAge : 1000 * 60 * 60 * 8;
return new Date(Date.now() + maxAge);
}
}
const isNotFound = (error: unknown) =>
typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2025';

View File

@@ -0,0 +1,31 @@
import { Body, Controller, Get, Patch, Post, Req } from '@nestjs/common';
import type { Request } from 'express';
import { AuthService } from '../auth/auth.service.js';
import { getSessionUser } from '../auth/session-user.js';
import { SettingsService } from './settings.service.js';
@Controller('settings')
export class SettingsController {
constructor(
private readonly authService: AuthService,
private readonly settingsService: SettingsService,
) {}
@Get('system')
async getSystem(@Req() request: Request) {
const actor = await getSessionUser(request, this.authService);
return { data: { settings: await this.settingsService.getSystemSettings(actor) } };
}
@Patch('system')
async updateSystem(@Req() request: Request, @Body() body: unknown) {
const actor = await getSessionUser(request, this.authService);
return { data: { settings: await this.settingsService.updateSystemSettings(actor, body) } };
}
@Post('system/reset')
async resetSystem(@Req() request: Request) {
const actor = await getSessionUser(request, this.authService);
return { data: { settings: await this.settingsService.resetSystemSettings(actor) } };
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module.js';
import { PrismaModule } from '../prisma/prisma.module.js';
import { SettingsController } from './settings.controller.js';
import { SettingsService } from './settings.service.js';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [SettingsController],
providers: [SettingsService],
exports: [SettingsService],
})
export class SettingsModule {}

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { systemSettingsSchema } from './settings.schemas';
describe('settings schemas', () => {
it('normalizes partial system settings with safe defaults', () => {
const result = systemSettingsSchema.parse({
frameCount: 2,
framePositions: [75, 25],
activeAiProvider: 'custom',
aiProviders: {
custom: {
endpoint: 'https://example.test/v1',
modelName: 'demo-model',
},
},
});
expect(result.frameCount).toBe(2);
expect(result.framePositions).toEqual([75, 25]);
expect(result.frameMode).toBe('keep');
expect(result.aiProviders.custom.apiKey).toBe('');
});
it('rejects invalid frame positions', () => {
expect(() => systemSettingsSchema.parse({
frameCount: 1,
framePositions: [120],
activeAiProvider: 'kimi',
aiProviders: {},
})).toThrow();
});
});

View File

@@ -0,0 +1,28 @@
import { z } from 'zod';
export const aiProviderSchema = z.object({
endpoint: z.string().default(''),
apiKey: z.string().default(''),
modelName: z.string().default(''),
}).passthrough();
export const xfSpeechConfigSchema = z.object({
appId: z.string().default(''),
apiKey: z.string().default(''),
apiSecret: z.string().default(''),
}).passthrough();
export const systemSettingsSchema = z.object({
frameCount: z.number().int().min(1).max(100).default(12),
framePositions: z.array(z.number().min(0).max(100)).default([]),
defaultTemplate: z.string().optional(),
frameMode: z.enum(['uniform', 'keep']).default('keep'),
autoInsertFrames: z.boolean().optional(),
autoInsertFrameIndices: z.array(z.number().int().min(0)).optional(),
autoInsertDelay: z.number().min(0).optional(),
activeAiProvider: z.string().default('kimi'),
aiProviders: z.record(z.string(), aiProviderSchema).default({}),
xfSpeechConfig: xfSpeechConfigSchema.optional(),
}).passthrough();
export type SystemSettingsInput = z.infer<typeof systemSettingsSchema>;

View File

@@ -0,0 +1,212 @@
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { AuditService } from '../audit/audit.service.js';
import type { SafeUser } from '../auth/auth.types.js';
import { isSuper } from '../permissions/permissions.policy.js';
import { PrismaService } from '../prisma/prisma.service.js';
import { systemSettingsSchema, type SystemSettingsInput } from './settings.schemas.js';
const DEFAULT_AI_PROVIDERS = {
kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: '', modelName: 'moonshot-v1-32k-vision-preview' },
deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-chat' },
openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o' },
custom: { endpoint: '', apiKey: '', modelName: '' },
};
const DEFAULT_SETTINGS: SystemSettingsInput = {
frameCount: 12,
framePositions: [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85, 96.3, 98.6],
defaultTemplate: '',
frameMode: 'keep',
activeAiProvider: 'kimi',
aiProviders: DEFAULT_AI_PROVIDERS,
autoInsertFrames: true,
autoInsertDelay: 1,
autoInsertFrameIndices: [0, 2, 4, 6, 8, 10],
xfSpeechConfig: { appId: '', apiKey: '', apiSecret: '' },
};
@Injectable()
export class SettingsService {
constructor(
private readonly prisma: PrismaService,
private readonly audit?: AuditService,
) {}
async getSystemSettings(actor: SafeUser, options: { includeSecrets?: boolean } = {}) {
const globalSettings = await this.getSettingValue(actor.tenantId, 'global', 'systemSettings');
const userDefaultTemplate = await this.getSettingValue(
actor.tenantId,
this.userScope(actor.id),
'defaultTemplate',
);
const merged = this.normalize({
...DEFAULT_SETTINGS,
...(this.isObject(globalSettings) ? globalSettings : {}),
});
if (typeof userDefaultTemplate === 'string') {
merged.defaultTemplate = userDefaultTemplate;
}
return options.includeSecrets || isSuper(this.actorToPolicy(actor))
? merged
: this.redactSecrets(merged);
}
async updateSystemSettings(actor: SafeUser, rawInput: unknown) {
const result = systemSettingsSchema.safeParse(rawInput);
if (!result.success) {
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(''));
}
const input = this.normalize(result.data);
if (isSuper(this.actorToPolicy(actor))) {
await this.setSettingValue(actor.tenantId, 'global', 'systemSettings', this.toJson(input));
if (input.defaultTemplate !== undefined) {
await this.setSettingValue(
actor.tenantId,
this.userScope(actor.id),
'defaultTemplate',
input.defaultTemplate || '',
);
}
await this.audit?.record({
actor,
action: 'settings.system.update',
targetType: 'SystemSetting',
targetId: 'systemSettings',
metadata: { scope: 'global', defaultTemplate: input.defaultTemplate },
});
return this.getSystemSettings(actor);
}
if (input.defaultTemplate === undefined) {
throw new ForbiddenException('只能修改个人默认模板');
}
await this.setSettingValue(
actor.tenantId,
this.userScope(actor.id),
'defaultTemplate',
input.defaultTemplate || '',
);
await this.audit?.record({
actor,
action: 'settings.default_template.update',
targetType: 'SystemSetting',
targetId: 'defaultTemplate',
metadata: { defaultTemplate: input.defaultTemplate },
});
return this.getSystemSettings(actor);
}
async resetSystemSettings(actor: SafeUser) {
if (!isSuper(this.actorToPolicy(actor))) {
throw new ForbiddenException('只有超级管理员可以重置系统设置');
}
await this.setSettingValue(actor.tenantId, 'global', 'systemSettings', this.toJson(DEFAULT_SETTINGS));
await this.audit?.record({
actor,
action: 'settings.system.reset',
targetType: 'SystemSetting',
targetId: 'systemSettings',
metadata: { scope: 'global' },
});
return this.getSystemSettings(actor);
}
private normalize(input: SystemSettingsInput): SystemSettingsInput {
const aiProviders = {
...DEFAULT_AI_PROVIDERS,
...(input.aiProviders || {}),
};
const framePositions = [...(input.framePositions || DEFAULT_SETTINGS.framePositions)]
.map((value) => Math.round(value * 10) / 10)
.sort((a, b) => a - b);
return {
...DEFAULT_SETTINGS,
...input,
framePositions,
frameCount: framePositions.length || input.frameCount || DEFAULT_SETTINGS.frameCount,
frameMode: input.frameMode || 'keep',
activeAiProvider: input.activeAiProvider || 'kimi',
aiProviders,
xfSpeechConfig: input.xfSpeechConfig || DEFAULT_SETTINGS.xfSpeechConfig,
};
}
private redactSecrets(settings: SystemSettingsInput): SystemSettingsInput {
const aiProviders = Object.fromEntries(
Object.entries(settings.aiProviders || {}).map(([key, provider]) => [
key,
{ ...provider, apiKey: '' },
]),
);
return {
...settings,
aiProviders,
xfSpeechConfig: settings.xfSpeechConfig
? { ...settings.xfSpeechConfig, apiKey: '', apiSecret: '' }
: settings.xfSpeechConfig,
};
}
private async getSettingValue(tenantId: string, scope: string, key: string) {
const setting = await this.prisma.systemSetting.findFirst({
where: { tenantId, scope, departmentId: null, key },
});
return setting?.value;
}
private async setSettingValue(
tenantId: string,
scope: string,
key: string,
value: Prisma.InputJsonValue,
) {
const existing = await this.prisma.systemSetting.findFirst({
where: { tenantId, scope, departmentId: null, key },
});
if (existing) {
await this.prisma.systemSetting.update({
where: { id: existing.id },
data: { value },
});
return;
}
await this.prisma.systemSetting.create({
data: {
tenantId,
scope,
key,
value,
},
});
}
private toJson(value: unknown): Prisma.InputJsonValue {
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
}
private isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
private userScope(userId: string) {
return `user:${userId}`;
}
private actorToPolicy(actor: SafeUser) {
return {
id: actor.id,
tenantId: actor.tenantId,
departmentId: actor.departmentId,
role: actor.role,
};
}
}

View File

@@ -0,0 +1,57 @@
import type { IncomingMessage, Server } from 'node:http';
import type { Socket } from 'node:net';
import type { RequestHandler } from 'express';
import { WebSocketServer } from 'ws';
import { AuthService } from '../auth/auth.service.js';
import { SpeechService } from './speech.service.js';
interface SessionRequest extends IncomingMessage {
session?: {
userId?: string;
};
}
export const attachSpeechProxy = (
server: Server,
sessionMiddleware: RequestHandler,
authService: AuthService,
speechService: SpeechService,
) => {
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (request: SessionRequest, socket: Socket, head: Buffer) => {
const url = new URL(request.url || '/', 'http://localhost');
if (url.pathname !== '/api/speech/iat') {
return;
}
sessionMiddleware(request as never, createUpgradeResponse() as never, async () => {
try {
if (!request.session?.userId) {
rejectUpgrade(socket, 401);
return;
}
const actor = await authService.findMe(request.session.userId);
wss.handleUpgrade(request, socket, head, (client) => {
void speechService.handleIatConnection(client, actor);
});
} catch {
rejectUpgrade(socket, 401);
}
});
});
return wss;
};
const createUpgradeResponse = () => ({
getHeader: () => undefined,
setHeader: () => undefined,
writeHead: () => undefined,
});
const rejectUpgrade = (socket: Socket, status: 401 | 500) => {
socket.write(`HTTP/1.1 ${status} ${status === 401 ? 'Unauthorized' : 'Internal Server Error'}\r\n\r\n`);
socket.destroy();
};

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { SettingsModule } from '../settings/settings.module.js';
import { SpeechService } from './speech.service.js';
@Module({
imports: [SettingsModule],
providers: [SpeechService],
exports: [SpeechService],
})
export class SpeechModule {}

View File

@@ -0,0 +1,94 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { createHmac } from 'node:crypto';
import type { RawData, WebSocket } from 'ws';
import Ws from 'ws';
import type { SafeUser } from '../auth/auth.types.js';
import { SettingsService } from '../settings/settings.service.js';
import { prepareXfIatFrame } from './xf-frame.js';
@Injectable()
export class SpeechService {
constructor(private readonly settingsService: SettingsService) {}
async handleIatConnection(client: WebSocket, actor: SafeUser) {
let upstream: WebSocket | null = null;
const queued: RawData[] = [];
try {
const config = await this.getXfConfig(actor);
upstream = new Ws(this.buildXfAuthUrl(config.apiKey, config.apiSecret));
upstream.on('open', () => {
while (queued.length > 0 && upstream?.readyState === Ws.OPEN) {
upstream.send(prepareXfIatFrame(queued.shift() as RawData, config.appId));
}
});
upstream.on('message', (data) => {
if (client.readyState === Ws.OPEN) {
client.send(data);
}
});
upstream.on('error', (error) => {
this.sendClientError(client, `讯飞语音连接失败:${error.message}`);
});
upstream.on('close', () => {
if (client.readyState === Ws.OPEN || client.readyState === Ws.CONNECTING) {
client.close();
}
});
client.on('message', (data) => {
if (!upstream) return;
if (upstream.readyState === Ws.OPEN) {
upstream.send(prepareXfIatFrame(data, config.appId));
return;
}
queued.push(data);
});
client.on('close', () => {
if (upstream && (upstream.readyState === Ws.OPEN || upstream.readyState === Ws.CONNECTING)) {
upstream.close();
}
});
client.on('error', () => {
if (upstream && (upstream.readyState === Ws.OPEN || upstream.readyState === Ws.CONNECTING)) {
upstream.close();
}
});
} catch (error) {
this.sendClientError(client, error instanceof Error ? error.message : String(error));
client.close();
}
}
private async getXfConfig(actor: SafeUser) {
const settings = await this.settingsService.getSystemSettings(actor, { includeSecrets: true });
const config = settings.xfSpeechConfig;
if (!config?.appId || !config.apiKey || !config.apiSecret) {
throw new BadRequestException('尚未配置讯飞语音 APPID/APIKey/APISecret');
}
return config;
}
private buildXfAuthUrl(apiKey: string, apiSecret: string) {
const host = 'iat-api.xfyun.cn';
const date = new Date().toUTCString();
const signatureOrigin = `host: ${host}\ndate: ${date}\nGET /v2/iat HTTP/1.1`;
const signature = createHmac('sha256', apiSecret).update(signatureOrigin).digest('base64');
const authorizationOrigin = `api_key="${apiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="${signature}"`;
const authorization = Buffer.from(authorizationOrigin).toString('base64');
const params = new URLSearchParams({ authorization, date, host });
return `wss://${host}/v2/iat?${params.toString()}`;
}
private sendClientError(client: WebSocket, message: string) {
if (client.readyState === Ws.OPEN) {
client.send(JSON.stringify({ code: -1, message }));
}
}
}

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { prepareXfIatFrame } from './xf-frame.js';
describe('prepareXfIatFrame', () => {
it('adds server-side app id and default business options to the first frame', () => {
const prepared = prepareXfIatFrame(
JSON.stringify({
data: { status: 0, format: 'audio/L16;rate=16000', encoding: 'raw', audio: 'abc' },
}),
'test-app-id',
);
expect(JSON.parse(prepared as string)).toEqual({
common: { app_id: 'test-app-id' },
business: { language: 'zh_cn', domain: 'iat', accent: 'mandarin' },
data: { status: 0, format: 'audio/L16;rate=16000', encoding: 'raw', audio: 'abc' },
});
});
it('keeps later audio frames unchanged', () => {
const frame = JSON.stringify({
data: { status: 1, format: 'audio/L16;rate=16000', encoding: 'raw', audio: 'abc' },
});
expect(prepareXfIatFrame(frame, 'test-app-id')).toBe(frame);
});
it('keeps non-json payloads unchanged', () => {
expect(prepareXfIatFrame('not-json', 'test-app-id')).toBe('not-json');
});
});

Some files were not shown because too many files have changed in this diff Show More