fix: 前端深度审计全量修复 — 安全/功能/代码质量
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

严重 BUG 修复:
- 修复 Token 过期后 hash 重定向导致无法跳转登录页
- 修复文章编辑器新建后提交审核使用错误 ID

安全加固:
- HTML 清理函数替换为 ammonia 专业库(替代自定义解析器)
- 文件上传添加 magic bytes 校验(防 Content-Type 伪造)
- 登录添加账户级失败锁定(5次失败→15分钟锁定)
- 审计日志 9 个关键更新操作补充变更前后值(with_changes)

功能缺陷修复:
- 登录/登出时清理 API 缓存(防多账户数据污染)
- 文章编辑器上传改用统一 HTTP 客户端(自动 token 刷新)
- 添加全局 HTTP 错误处理和后端错误消息展示
- PrivateRoute 增加路由级权限检查(系统管理页面)
- 健康数据三个 Tab 添加编辑/删除功能
- 预约创建增加排班可用性校验提示
- 医生详情 API 返回解密后的原始执照号

代码清理:
- 删除未使用的 auth.ts refresh() 函数
- 删除重复的 AuthGuard.tsx 组件
- 删除未使用的 getHealthSummary API
This commit is contained in:
iven
2026-04-26 21:47:26 +08:00
parent f0c3426792
commit 787e64d9a9
23 changed files with 1152 additions and 482 deletions

358
CLAUDE.md
View File

@@ -43,78 +43,50 @@
---
## 2. 项目结构
## 2. 工作风格
```text
hms/
├── crates/ # Rust Workspace
│ ├── erp-core/ # L1: 基础类型、错误、事件、模块 trait
│ ├── erp-auth/ # L2: 身份与权限模块
│ ├── erp-workflow/ # L2: 工作流引擎模块
│ ├── erp-message/ # L2: 消息中心模块
│ ├── erp-config/ # L2: 系统配置模块
│ ├── erp-health/ # L2: 健康管理模块 ★ HMS 核心
│ └── erp-server/ # L3: Axum 服务入口,组装所有模块
│ └── migration/ # SeaORM 数据库迁移
├── apps/
│ └── web/ # Vite + React 19 SPA (主力前端)
├── packages/
│ └── ui-components/ # React 共享组件库
├── desktop/ # (可选) Tauri 桌面端,行业需要时启用
├── docker/ # Docker 开发环境配置
├── docs/ # 文档
│ └── superpowers/
│ ├── specs/ # 设计规格文档
│ └── plans/ # 实施计划
├── Cargo.toml # Workspace root
└── CLAUDE.md # 本文件 — 协作规则
```
### 2.1 Crate 依赖关系
```text
erp-core (无业务依赖)
erp-auth (→ core)
erp-config (→ core)
erp-workflow (→ core)
erp-message (→ core)
erp-health (→ core) ★ HMS 核心
erp-server (→ 所有 crate组装入口)
```
**规则:**
- `erp-core` 不依赖任何业务 crate
- 业务 crate 之间**禁止**直接依赖,只通过事件总线和 `erp-core` trait 通信
- `erp-server` 是唯一的组装点
### 2.2 技术栈
| 层级 | 技术 |
|------|------|
| 后端框架 | Axum 0.8 + Tokio |
| ORM | SeaORM (异步、类型安全) |
| 数据库 | PostgreSQL 18 |
| 缓存 | Redis 7+ |
| 前端框架 | React 19 + TypeScript 6 (Vite 8) |
| UI 组件库 | Ant Design 6 |
| 状态管理 | Zustand 5 |
| 路由 | React Router 7 |
| 样式 | TailwindCSS + CSS Variables |
| API 文档 | utoipa (OpenAPI 3) |
---
## 3. 工作风格
### 3.1 按计划推进
### 2.1 按计划推进
- **严格按 Phase 顺序执行** — Phase 2 依赖 Phase 1 的基础设施
- **每个 Task 完成后立即提交** — 不积压,保持可追溯
- **先测试后实现** — TDD 流程:写失败测试 → 实现 → 通过 → 提交
### 3.2 模块化思维
### 2.2 分步编写文档(强制)
编写计划、设计文档、实施报告等长文档时,**必须分步编写**,禁止一次性输出全文:
1. **先写大纲** — 确认文档结构和章节划分
2. **逐章编写** — 每次只写 1-2 个章节,写完确认后继续下一章
3. **最终整合** — 所有章节完成后合并为完整文档
**原因:** 上下文过长会导致输出截断或卡死。分步编写保证每步都能完整输出,且用户可以中途调整方向。
**适用范围:** 超过 200 行的文档、实施计划、设计规格、技术报告等。简短的 bugfix 说明、单页 wiki 更新不受此限制。
### 2.3 讨论记录
每次发散式讨论brainstorming、方案探索、需求梳理、技术选型等**必须建立独立文档**
- **存放位置:** `docs/discussions/YYYY-MM-DD-{主题简称}.md`
- **文档格式:**
```markdown
# {讨论主题}
> 日期: YYYY-MM-DD | 参与者: ...
## 背景
为什么会有这次讨论
## 讨论要点
- 要点 1
- 要点 2
## 结论 / 待定
达成的共识或遗留问题
```
- **时机:** 讨论结束后立即创建,不要积压。如果讨论横跨多个主题,拆分为多份文档。
- **用途:** 作为后续实施的输入和决策追溯的依据,避免"之前讨论过但忘了结论"。
### 2.4 模块化思维
开发任何功能时先问:
@@ -123,7 +95,7 @@ erp-server (→ 所有 crate组装入口)
3. **它需要发什么事件?** — 跨模块通知必须走事件总线
4. **其他模块怎么发现它?** — 通过 `ErpModule` trait 注册
### 3.3 闭环工作法(强制)
### 2.5 闭环工作法(强制)
每次改动**必须**按顺序完成以下步骤,不允许跳过:
@@ -144,7 +116,7 @@ erp-server (→ 所有 crate组装入口)
- `cargo test --workspace` — 所有测试通过(有相关测试时)
- 功能验证 — 启动后端 + 前端服务,在浏览器中实际操作验证改动生效(涉及 API 或 UI 时)
- `pnpm build` — 前端生产构建通过(涉及前端时)
5. **提交** — 验证通过后按 §10 规范提交
5. **提交** — 验证通过后按 §5 规范提交
6. **文档同步** — 更新相关文档(如果涉及架构、接口、模块变化)
7. **推送到仓库** — 提交后立即 `git push`,确保远程仓库同步
@@ -156,23 +128,23 @@ erp-server (→ 所有 crate组装入口)
---
## 4. 实现规则
## 3. 实现规则
### 4.1 错误处理
### 3.1 错误处理
- **跨 crate 边界**:使用 `thiserror` 定义类型化错误,转换为 `AppError`
- **crate 内部**:可以使用 `anyhow`,但**永远不**跨越 crate 边界
- **数据库错误**:通过 `From<sea_orm::DbErr>` 自动转换为 `AppError`
- **验证错误**:包含字段级详情,方便 UI 渲染
### 4.2 数据库操作
### 3.2 数据库操作
- 所有 SeaORM Entity 必须包含:`id`, `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted_at`, `version`
- 查询时**始终**带 `tenant_id` 过滤(中间件自动注入)
- 更新时检查 `version` 字段实现乐观锁
- 删除使用软删除(设置 `deleted_at`
### 4.3 API 设计
### 3.3 API 设计
- 所有端点使用 `/api/v1/` 前缀
- 响应统一使用 `ApiResponse<T>` 包装
@@ -180,14 +152,14 @@ erp-server (→ 所有 crate组装入口)
- utoipa 自动生成 OpenAPI 文档
- 租户 ID 从 JWT 中间件注入,**不在** API 路径中传递(管理员接口除外)
### 4.4 事件总线
### 3.4 事件总线
- 模块间通信**只能**通过 `EventBus`
- 事件必须持久化到 `domain_events` 表outbox 模式)
- 事件处理失败记录到 dead-letter 存储
- 事件类型命名:`{模块}.{动作}` 如 `user.created`, `workflow.task.completed`
### 4.5 Rust 代码规范
### 3.5 Rust 代码规范
```rust
// 命名snake_case (函数/变量), PascalCase (类型/trait), SCREAMING_SNAKE (常量)
@@ -197,7 +169,7 @@ erp-server (→ 所有 crate组装入口)
// 数据库操作使用 SeaORM 的 Entity + Model + Relation 模式
```
### 4.6 TypeScript / React 代码规范
### 3.6 TypeScript / React 代码规范
```typescript
// 避免 any优先 unknown + 类型守卫
@@ -210,58 +182,9 @@ erp-server (→ 所有 crate组装入口)
---
## 5. 模块开发规范
## 4. 测试与验证
### 5.1 新建业务模块清单
每个新模块(如 erp-auth, erp-workflow**必须**包含:
1. `Cargo.toml` — 依赖 `erp-core`
2. `src/lib.rs` — 模块入口,实现 `ErpModule` trait
3. `src/error.rs` — 模块错误类型wrap `AppError`
4. `src/entity/` — SeaORM Entity 定义
5. `src/service/` — 业务逻辑层
6. `src/handler/` — Axum 路由处理器
7. `src/event.rs` — 模块事件定义和处理器
### 5.2 ErpModule trait 实现
```rust
pub struct AuthModule;
impl ErpModule for AuthModule {
fn name(&self) -> &str { "auth" }
fn version(&self) -> &str { env!("CARGO_PKG_VERSION") }
fn dependencies(&self) -> Vec<&str> { vec![] } // auth 是基础模块,无依赖
fn register_routes(&self, router: Router) -> Router {
router.nest("/api/v1", auth_routes())
}
fn register_event_handlers(&self, bus: &EventBus) {
// 订阅其他模块的事件
}
async fn on_tenant_created(&self, tenant_id: Uuid) -> AppResult<()> {
// 初始化默认角色、管理员等
Ok(())
}
}
```
### 5.3 数据库迁移
- 迁移文件放在 `crates/erp-server/migration/src/`
- 命名格式:`m{YYYYMMDD}_{6位序号}_{描述}.rs`
- 迁移必须可回滚(实现 `down` 方法)
- 新增表必须包含所有标准字段§1.3
- 迁移必须幂等(使用 `if_not_exists`
---
## 6. 测试与验证
### 6.1 测试要求
### 4.1 测试要求
| 测试类型 | 覆盖目标 | 工具 |
|----------|---------|------|
@@ -271,7 +194,7 @@ impl ErpModule for AuthModule {
| E2E 测试 | 前端关键流程 | Playwright |
| 插件测试 | 动态表 CRUD + 租户隔离 | Testcontainers |
### 6.2 验证命令
### 4.2 验证命令
```bash
# Rust 编译检查
@@ -293,7 +216,7 @@ cd apps/desktop && pnpm tauri dev
docker exec erp-postgres psql -U erp -c "\dt"
```
### 6.3 Phase 完成标准
### 4.3 Phase 完成标准
每个 Phase 完成时必须满足:
@@ -307,102 +230,7 @@ docker exec erp-postgres psql -U erp -c "\dt"
---
## 7. 安全注意事项
### 7.1 认证安全
- **密码存储**: Argon2 哈希,禁止明文
- **JWT**: access token 15min + refresh token 7d
- **Refresh Token 轮换**: 每次使用后签发新的,旧的作废
- **Token 存储**: 桌面端使用 Tauri secure store
- **密码修改**: 使所有已签发的 JWT 失效
### 7.2 多租户安全
- **中间件注入**: `tenant_id` 从 JWT 中提取,应用层不可伪造
- **数据隔离**: 所有查询自动过滤 `tenant_id`
- **越权防护**: 禁止跨租户数据访问
- **租户 provisioning**: `on_tenant_created` 钩子初始化数据
### 7.3 通用安全
- **不硬编码密钥** — 使用环境变量或配置文件
- **用户输入验证** — 所有 API 端点验证输入
- **SQL 注入防护** — SeaORM 参数化查询
- **限流** — Redis token bucket登录等敏感接口限流
- **CORS** — 白名单制,默认拒绝
- **审计日志** — 所有关键操作记录变更前后状态
---
## 8. 桌面端 UI 规范
### 8.1 布局结构
经典 SaaS 后台管理布局(响应式,支持移动端):
```
┌─────────────────────────────────────────────┐
│ LOGO 搜索... 🔔 5 👤 Admin ▾ │ ← 顶部导航栏
├─────────┬───────────────────────────────────┤
│ 📊 首页 │ │
│ 👥 用户 │ 主内容区域 │
│ 🔐 权限 │ (多标签页切换) │
│ 📋 流程 │ │
│ 💬 消息 │ │
│ ⚙️ 设置 │ │
│─────────│ │
│ 📦 进销存│ │
│ 🏭 生产 │ │
│ 💰 财务 │ │
│─────────│ │
│ ▸ 更多 │ │
└─────────┴───────────────────────────────────┘
```
### 8.2 UI 规则
- 使用 Ant Design 组件库,不自造轮子
- 中文优先,所有文案通过 i18n key 引用
- 支持暗色/亮色主题切换
- 侧边栏按模块分组:基础模块 / 行业模块
- 表单验证使用 Ant Design Form 的 validateRules
---
## 9. 常用命令
```bash
# === Rust ===
cargo check # 编译检查
cargo test --workspace # 运行所有测试
cargo run -p erp-server # 启动后端服务(需从 crates/erp-server 目录 + 环境变量)
cargo fmt --check # 检查格式
cargo clippy -- -D warnings # Lint 检查
# === 前端 ===
cd apps/web && pnpm install # 安装依赖
cd apps/web && pnpm dev # 开发模式(固定端口 5174
cd apps/web && pnpm build # 构建生产版本
# === 数据库Windows 原生 PostgreSQL ===
PGPASSWORD=123123 "D:\postgreSQL\bin\psql.exe" -U postgres -h localhost -d erp # 连接数据库
# === WASM 插件 ===
cargo build -p erp-plugin-test-sample --target wasm32-unknown-unknown --release # 编译测试插件
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_test_sample.wasm -o target/erp_plugin_test_sample.component.wasm # 转为 Component
cargo test -p erp-plugin-prototype # 运行插件集成测试
# === 一键启动 (PowerShell) ===
.\dev.ps1 # 启动前后端(自动清理端口占用)
.\dev.ps1 -Stop # 停止前后端
.\dev.ps1 -Restart # 重启前后端
.\dev.ps1 -Status # 查看端口状态
```
---
## 10. 提交规范
## 5. 提交规范
```
<type>(<scope>): <description>
@@ -447,68 +275,7 @@ chore(docker): 添加 PostgreSQL 健康检查
---
## 11. 设计文档索引
| 文档 | 说明 |
|------|------|
| `docs/superpowers/specs/2026-04-23-health-management-module-design.md` | **HMS 健康模块设计规格** ★ 当前 |
| `docs/superpowers/specs/2026-04-10-erp-platform-base-design.md` | 平台底座设计规格 |
| `docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md` | WASM 插件系统设计规格 |
| `docs/superpowers/specs/2026-04-16-crm-plugin-design.md` | CRM 客户管理插件设计规格 |
所有设计决策以设计规格文档为准。实施计划按阶段拆分,每阶段开始前细化。
---
<!-- ARCH-SNAPSHOT-START -->
<!-- 此区域随开发进度更新 -->
## 12. 当前架构快照
### 开发进度
| Phase | 内容 | 状态 |
|-------|------|------|
| Phase 1 | 基础设施 (workspace + core + Docker + 桌面端) | ✅ 完成 |
| Phase 2 | 身份与权限 (Auth) | ✅ 完成 |
| Phase 3 | 系统配置 (Config) | ✅ 完成 |
| Phase 4 | 工作流引擎 (Workflow) | ✅ 完成 |
| Phase 5 | 消息中心 (Message) | ✅ 完成 |
| Phase 6 | 整合与打磨 | ✅ 完成 |
| - | WASM 插件原型 (V1-V6) | ✅ 验证通过 |
| - | 插件系统集成到主服务 | ✅ 已集成 |
| - | CRM 插件 (Phase 1-3) | ✅ 完成 |
| - | Q2 安全地基 + CI/CD | ✅ 完成 |
| - | Q3 架构强化 + 前端体验 | ✅ 完成 |
| - | Q4 测试覆盖 + 插件生态 | ✅ 完成 |
| - | 健康模块迭代 (安全地基 + 后端补完 + Web 前端 10 页面) | ✅ 完成 |
### 已实现模块
| Crate | 功能 | 状态 |
|-------|------|------|
| erp-core | 错误类型、共享类型、事件总线、ErpModule trait、审计日志 | ✅ 完成 |
| erp-server | Axum 服务入口、配置、数据库连接、CORS、模块注册、后台任务 | ✅ 完成 |
| erp-auth | 身份与权限 (用户/角色/权限/组织/部门/岗位/行级数据权限) | ✅ 完成 |
| erp-workflow | 工作流引擎 (BPMN 解析/Token 驱动/任务分配) | ✅ 完成 |
| erp-message | 消息中心 (CRUD/模板/订阅/通知面板) | ✅ 完成 |
| erp-config | 系统配置 (字典/菜单/设置/编号规则/主题) | ✅ 完成 |
| erp-plugin | 插件管理 (WASM 运行时/生命周期/动态表/数据CRUD/热更新/行级数据权限) | ✅ 已集成 |
| erp-plugin-prototype | WASM 插件 Host 运行时 (Wasmtime + bindgen + Host API) | ✅ 原型验证 |
| erp-plugin-test-sample | WASM 测试插件 (Guest trait + Host API 回调) | ✅ 原型验证 |
| erp-plugin-crm | CRM 客户管理插件 (5 实体/9 权限/6 页面) | ✅ 完成 |
| erp-plugin-inventory | 进销存管理插件 (6 实体/12 权限/6 页面) | ✅ 完成 |
| erp-plugin-freelance | 自由职业者管理插件 | ✅ 完成 |
| erp-plugin-itops | IT 运维管理插件 | ✅ 完成 |
| erp-health | 健康管理原生模块 (16 实体/12 权限/13 页面) | ✅ 完成 |
<!-- ARCH-SNAPSHOT-END -->
---
<!-- ANTI-PATTERN-START -->
## 13. 反模式警告
## 6. 反模式警告
- ❌ **不要**不看 wiki 就开干 — wiki 包含环境配置、数据库连接、启动方式、已知问题,不看就做等于盲猜,浪费时间且产出不可信
- ❌ **不要**在业务 crate 之间创建直接依赖 — 只通过事件和 trait 通信
@@ -527,6 +294,8 @@ chore(docker): 添加 PostgreSQL 健康检查
- ❌ **不要**跳过验证直接提交 — 编译/测试/功能验证必须全部通过
- ❌ **不要**提交后忘记推送 — 不推送等于没完成,远程仓库必须同步
- ❌ **不要**忘记更新文档 — 涉及架构、接口、模块变化时必须同步更新相关文档
- ❌ **不要**一次性输出长文档 — 超过 200 行的文档必须分步编写(先大纲 → 逐章 → 整合),否则会因上下文过长卡死
- ❌ **不要**忽略讨论记录 — 每次发散式讨论结束后必须建立文档到 `docs/discussions/`,不要口头确认后就忘
### 场景化指令
@@ -538,4 +307,19 @@ chore(docker): 添加 PostgreSQL 健康检查
- 当遇到**新增页面** → 使用 Ant Design 组件i18n key 引用文案
- 当遇到**新增业务模块插件** → 参考 `wiki/wasm-plugin.md` 的插件制作完整流程和 `.claude/skills/plugin-development/SKILL.md`,创建 cdylib crate + 实现 Guest trait + 编译为 WASM Component。**权限码必须与实体名一致(每个实体声明 `.list` + `.manage`**
<!-- ANTI-PATTERN-END -->
---
## 7. 详细参考wiki
以下内容已从本文件迁移到 wiki需要时查阅
| 主题 | wiki 页面 |
|------|----------|
| 目录结构、crate 依赖、技术栈 | `wiki/architecture.md` §2 |
| 模块开发规范、ErpModule trait、迁移规范 | `wiki/architecture.md` §3 |
| 安全注意事项(认证/多租户/通用) | `wiki/architecture.md` §4 |
| UI 布局规范 | `wiki/frontend.md` §2 |
| 常用命令Rust/前端/数据库/WASM | `wiki/infrastructure.md` §3 |
| 设计文档索引 | `wiki/index.md` |
| 开发进度、模块状态 | `wiki/index.md` 关键数字 |
| 环境配置、连接信息、登录凭据 | `wiki/infrastructure.md` §2 |

231
Cargo.lock generated
View File

@@ -102,6 +102,19 @@ version = "0.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b"
[[package]]
name = "ammonia"
version = "4.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
dependencies = [
"cssparser",
"html5ever",
"maplit",
"tendril",
"url",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -1047,6 +1060,29 @@ dependencies = [
"typenum",
]
[[package]]
name = "cssparser"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
"phf",
"smallvec",
]
[[package]]
name = "cssparser-macros"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
"syn 2.0.117",
]
[[package]]
name = "csv"
version = "1.4.0"
@@ -1279,6 +1315,21 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "dtoa"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
[[package]]
name = "dtoa-short"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
dependencies = [
"dtoa",
]
[[package]]
name = "either"
version = "1.15.0"
@@ -1392,6 +1443,7 @@ name = "erp-core"
version = "0.1.0"
dependencies = [
"aes-gcm",
"ammonia",
"anyhow",
"async-trait",
"axum",
@@ -1743,6 +1795,16 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "futf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
dependencies = [
"mac",
"new_debug_unreachable",
]
[[package]]
name = "futures"
version = "0.3.32"
@@ -2077,6 +2139,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "html5ever"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
dependencies = [
"log",
"markup5ever",
"match_token",
]
[[package]]
name = "http"
version = "1.4.0"
@@ -2610,6 +2683,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mac_address"
version = "1.1.8"
@@ -2641,6 +2720,34 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "maplit"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "markup5ever"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
dependencies = [
"log",
"tendril",
"web_atoms",
]
[[package]]
name = "match_token"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "matchers"
version = "0.2.0"
@@ -2790,6 +2897,12 @@ dependencies = [
"tempfile",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.29.0"
@@ -3149,6 +3262,58 @@ dependencies = [
"serde",
]
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros",
"phf_shared",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -3242,6 +3407,12 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -4201,6 +4372,12 @@ dependencies = [
"time",
]
[[package]]
name = "siphasher"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
[[package]]
name = "sized-chunks"
version = "0.6.5"
@@ -4486,6 +4663,31 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "string_cache"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared",
"precomputed-hash",
"serde",
]
[[package]]
name = "string_cache_codegen"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
]
[[package]]
name = "stringprep"
version = "0.1.5"
@@ -4625,6 +4827,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
dependencies = [
"futf",
"mac",
"utf-8",
]
[[package]]
name = "termcolor"
version = "1.4.1"
@@ -5156,6 +5369,12 @@ dependencies = [
"serde",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -5825,6 +6044,18 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
dependencies = [
"phf",
"phf_codegen",
"string_cache",
"string_cache_codegen",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"

View File

@@ -110,3 +110,6 @@ async-stream = "0.3"
# Template engine
handlebars = "6"
# HTML sanitization
ammonia = "4"

View File

@@ -52,7 +52,18 @@ const ArticleTagManage = lazy(() => import('./pages/health/ArticleTagManage'));
function PrivateRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
const permissions = useAuthStore((s) => s.permissions);
if (!isAuthenticated) return <Navigate to="/login" replace />;
// 路由级权限检查:如果用户对某个模块完全没有权限,重定向到首页
const path = window.location.hash.replace('#', '');
if (path.startsWith('/users') || path.startsWith('/roles') || path.startsWith('/organizations')) {
const hasAuthAccess = permissions.some((p) => p.startsWith('auth.'));
if (!hasAuthAccess) return <Navigate to="/" replace />;
}
return <>{children}</>;
}
const themeConfig = {

View File

@@ -40,14 +40,6 @@ export async function login(req: LoginRequest): Promise<LoginResponse> {
return data.data;
}
export async function refresh(refreshToken: string): Promise<LoginResponse> {
const { data } = await client.post<{ success: boolean; data: LoginResponse }>(
'/auth/refresh',
{ refresh_token: refreshToken }
);
return data.data;
}
export async function logout(): Promise<void> {
await client.post('/auth/logout');
}

View File

@@ -1,4 +1,5 @@
import axios from 'axios';
import { message as antMessage } from 'antd';
// 请求缓存:短时间内相同请求复用结果
interface CacheEntry {
@@ -138,7 +139,7 @@ client.interceptors.response.use(
processQueue(refreshError, null);
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
window.location.hash = '#/login';
window.location.hash = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
@@ -149,6 +150,32 @@ client.interceptors.response.use(
}
);
// 全局错误提示(仅对未被组件处理的错误显示)
let globalErrorTimer: ReturnType<typeof setTimeout> | null = null;
function showGlobalError(msg: string) {
// 防止短时间内弹出大量相同提示
if (globalErrorTimer) return;
antMessage.error(msg, 3);
globalErrorTimer = setTimeout(() => { globalErrorTimer = null; }, 3000);
}
// 全局错误拦截 — 在响应拦截器之后、组件 catch 之前执行
client.interceptors.response.use(
(response) => response,
(error) => {
if (!error.response) {
showGlobalError('网络连接异常,请检查网络');
} else if (error.response.status === 403) {
showGlobalError('权限不足,无法执行此操作');
} else if (error.response.status === 404) {
// 404 通常由组件自行处理(如跳转),不全局提示
} else if (error.response.status >= 500) {
showGlobalError('服务器异常,请稍后重试');
}
return Promise.reject(error);
}
);
let isRefreshing = false;
let failedQueue: Array<{
resolve: (token: string) => void;
@@ -168,4 +195,12 @@ export function clearApiCache() {
requestCache.clear();
}
// 通用错误处理:提取后端错误消息并展示
export function handleApiError(err: unknown, fallback = '操作失败'): string {
const msg =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || fallback;
antMessage.error(msg);
return msg;
}
export default client;

View File

@@ -131,6 +131,7 @@ export const patientApi = {
await client.post(`/health/patients/${id}/tags`, { tag_ids: tagIds });
},
// TODO: 未使用,待未来健康摘要功能接入时启用
getHealthSummary: async (id: string) => {
const { data } = await client.get<{
success: boolean;

View File

@@ -1,13 +0,0 @@
import type { ReactNode } from 'react';
import { usePermission } from '../hooks/usePermission';
interface AuthGuardProps {
code: string;
children: ReactNode;
}
export function AuthGuard({ code, children }: AuthGuardProps) {
const { hasPermission } = usePermission(code);
if (!hasPermission) return null;
return <>{children}</>;
}

View File

@@ -13,6 +13,7 @@ import {
message,
Card,
Row,
Alert,
Col,
} from 'antd';
import {
@@ -88,6 +89,10 @@ export default function AppointmentList() {
const [selectedDoctorId, setSelectedDoctorId] = useState<string | undefined>(undefined);
const [nameCache, setNameCache] = useState<Record<string, string>>({});
// 排班校验
const [scheduleHint, setScheduleHint] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState<string | null>(null);
// ---- 数据获取 ----
const fetchData = useCallback(async (p = page, ps = pageSize) => {
setLoading(true);
@@ -194,9 +199,36 @@ export default function AppointmentList() {
form.resetFields();
setSelectedPatientId(undefined);
setSelectedDoctorId(undefined);
setScheduleHint(null);
setSelectedDate(null);
setModalOpen(true);
};
// 排班校验:医生 + 日期选定后查询排班
useEffect(() => {
if (!selectedDoctorId || !selectedDate || !modalOpen) {
setScheduleHint(null);
return;
}
let cancelled = false;
appointmentApi.listSchedules({ doctor_id: selectedDoctorId, date: selectedDate, page: 1, page_size: 50 })
.then((result) => {
if (cancelled) return;
const schedules = result.data;
if (schedules.length === 0) {
setScheduleHint(`该医生在 ${selectedDate} 暂无排班,请确认是否需要先创建排班`);
} else {
const slots = schedules
.filter((s) => s.status === 'active' && s.current_appointments < s.max_appointments)
.map((s) => `${s.start_time}-${s.end_time}(${s.current_appointments}/${s.max_appointments})`)
.join('、');
setScheduleHint(slots ? `可约时段:${slots}` : `该医生在 ${selectedDate} 排班已满或已停用`);
}
})
.catch(() => { if (!cancelled) setScheduleHint(null); });
return () => { cancelled = true; };
}, [selectedDoctorId, selectedDate, modalOpen]);
const handleSubmit = async (values: {
appointment_date: Dayjs;
start_time: Dayjs;
@@ -388,11 +420,20 @@ export default function AppointmentList() {
form.resetFields();
setSelectedPatientId(undefined);
setSelectedDoctorId(undefined);
setScheduleHint(null);
}}
onOk={() => form.submit()}
destroyOnHidden
width={560}
>
{scheduleHint && (
<Alert
message={scheduleHint}
type={scheduleHint.includes('暂无排班') ? 'warning' : 'info'}
showIcon
style={{ marginBottom: 16 }}
/>
)}
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item label="患者" required>
<PatientSelect
@@ -415,7 +456,7 @@ export default function AppointmentList() {
label="预约日期"
rules={[{ required: true, message: '请选择预约日期' }]}
>
<DatePicker style={{ width: '100%' }} />
<DatePicker style={{ width: '100%' }} onChange={(d) => setSelectedDate(d ? d.format('YYYY-MM-DD') : null)} />
</Form.Item>
</Col>
<Col span={12}>

View File

@@ -13,6 +13,7 @@ import {
} from '../../api/health/articles';
import { useThemeMode } from '../../hooks/useThemeMode';
import { AuthButton } from '../../components/AuthButton';
import client from '../../api/client';
import '@wangeditor/editor/dist/css/style.css';
export default function ArticleEditor() {
@@ -108,15 +109,11 @@ export default function ArticleEditor() {
try {
const formData = new FormData();
formData.append('file', file);
const token = localStorage.getItem('access_token');
const resp = await fetch('/api/v1/upload', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
const { data: result } = await client.post('/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
if (!resp.ok) throw new Error('上传失败');
const result = await resp.json();
const url: string = result.data.url;
const token = localStorage.getItem('access_token');
const urlWithToken = token ? `${url}?token=${token}` : url;
insertFn(urlWithToken, file.name, urlWithToken);
} catch {
@@ -226,12 +223,15 @@ export default function ArticleEditor() {
});
currentVersion = created.version;
setVersion(created.version);
navigate(`/health/articles/${created.id}/edit`, { replace: true });
// 新建后直接提交审核(此时 id 仍为 undefined使用 created.id
await articleApi.submit(created.id, currentVersion);
message.success('已提交审核');
navigate('/health/articles');
return;
}
// 提交审核
if (id || isEdit) {
const articleId = id!;
await articleApi.submit(articleId, currentVersion);
// 编辑模式提交审核
if (id) {
await articleApi.submit(id, currentVersion);
}
message.success('已提交审核');
navigate('/health/articles');
@@ -471,14 +471,9 @@ export default function ArticleEditor() {
try {
const formData = new FormData();
formData.append('file', file);
const token = localStorage.getItem('access_token');
const resp = await fetch('/api/v1/upload', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
const { data: result } = await client.post('/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
if (!resp.ok) throw new Error('上传失败');
const result = await resp.json();
setCoverImage(result.data.url);
message.success('封面图上传成功');
} catch {

View File

@@ -1,9 +1,11 @@
import { useCallback, useState } from 'react';
import { Table, Tag, Button, Modal, Form, Select, DatePicker, Input, message } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { Table, Tag, Button, Modal, Form, Select, DatePicker, Input, message, Popconfirm, Space } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { healthDataApi } from '../../../api/health/healthData';
import type { HealthRecord } from '../../../api/health/healthData';
import { AuthButton } from '../../../components/AuthButton';
import { usePaginatedData } from '../../../hooks/usePaginatedData';
interface Props {
@@ -22,15 +24,9 @@ const RECORD_TYPE_MAP: Record<string, string> = {
inpatient: '住院',
};
const columns = [
{ title: '记录类型', dataIndex: 'record_type', key: 'record_type', width: 120, render: (v: string) => <Tag>{RECORD_TYPE_MAP[v] || v}</Tag> },
{ title: '记录日期', dataIndex: 'record_date', key: 'record_date', width: 120 },
{ title: '内容', dataIndex: 'content', key: 'content', ellipsis: true },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: (v: string) => new Date(v).toLocaleString('zh-CN') },
];
export function HealthRecordsTab({ patientId }: Props) {
const [modalOpen, setModalOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState<HealthRecord | null>(null);
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
@@ -43,33 +39,112 @@ export function HealthRecordsTab({ patientId }: Props) {
const { data, total, page, loading, refresh } = usePaginatedData<HealthRecord>(fetcher, 10);
const handleCreate = async (values: {
const isEditing = editingRecord !== null;
const modalTitle = isEditing ? '编辑健康记录' : '添加健康记录';
const openCreateModal = () => {
setEditingRecord(null);
form.resetFields();
setModalOpen(true);
};
const openEditModal = (record: HealthRecord) => {
setEditingRecord(record);
form.setFieldsValue({
record_type: record.record_type,
record_date: dayjs(record.record_date),
content: record.content,
});
setModalOpen(true);
};
const handleSubmit = async (values: {
record_type: 'checkup' | 'outpatient' | 'inpatient';
record_date: Dayjs;
content?: string;
}) => {
setSubmitting(true);
try {
await healthDataApi.createHealthRecord(patientId, {
record_type: values.record_type,
record_date: values.record_date.format('YYYY-MM-DD'),
content: values.content,
});
message.success('健康记录添加成功');
if (isEditing && editingRecord) {
await healthDataApi.updateHealthRecord(patientId, editingRecord.id, {
record_type: values.record_type,
record_date: values.record_date.format('YYYY-MM-DD'),
content: values.content,
version: editingRecord.version,
});
message.success('健康记录更新成功');
} else {
await healthDataApi.createHealthRecord(patientId, {
record_type: values.record_type,
record_date: values.record_date.format('YYYY-MM-DD'),
content: values.content,
});
message.success('健康记录添加成功');
}
setModalOpen(false);
form.resetFields();
setEditingRecord(null);
refresh();
} catch {
message.error('添加失败');
message.error(isEditing ? '更新失败' : '添加失败');
} finally {
setSubmitting(false);
}
};
const handleDelete = async (record: HealthRecord) => {
try {
await healthDataApi.deleteHealthRecord(patientId, record.id);
message.success('健康记录删除成功');
refresh();
} catch {
message.error('删除失败');
}
};
const columns = [
{ title: '记录类型', dataIndex: 'record_type', key: 'record_type', width: 120, render: (v: string) => <Tag>{RECORD_TYPE_MAP[v] || v}</Tag> },
{ title: '记录日期', dataIndex: 'record_date', key: 'record_date', width: 120 },
{ title: '内容', dataIndex: 'content', key: 'content', ellipsis: true },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: (v: string) => new Date(v).toLocaleString('zh-CN') },
{
title: '操作',
key: 'action',
width: 140,
render: (_: unknown, record: HealthRecord) => (
<Space size="small">
<AuthButton code="health.health-data.manage">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => openEditModal(record)}
>
</Button>
</AuthButton>
<AuthButton code="health.health-data.manage">
<Popconfirm
title="确认删除"
description="确定要删除这条健康记录吗?"
onConfirm={() => handleDelete(record)}
okText="删除"
cancelText="取消"
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</AuthButton>
</Space>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
<Button icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true); }}>
<Button icon={<PlusOutlined />} onClick={openCreateModal}>
</Button>
</div>
@@ -87,15 +162,15 @@ export function HealthRecordsTab({ patientId }: Props) {
}}
/>
<Modal
title="添加健康记录"
title={modalTitle}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onCancel={() => { setModalOpen(false); setEditingRecord(null); }}
onOk={() => form.submit()}
confirmLoading={submitting}
destroyOnClose
width={520}
>
<Form form={form} layout="vertical" onFinish={handleCreate}>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item name="record_type" label="记录类型" rules={[{ required: true, message: '请选择类型' }]}>
<Select placeholder="请选择记录类型" options={RECORD_TYPE_OPTIONS} />
</Form.Item>

View File

@@ -1,24 +1,21 @@
import { useCallback, useState } from 'react';
import { Table, Tag, Button, Modal, Form, Input, DatePicker, message } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { Table, Tag, Button, Modal, Form, Input, DatePicker, message, Popconfirm, Space } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { healthDataApi } from '../../../api/health/healthData';
import type { LabReport } from '../../../api/health/healthData';
import { usePaginatedData } from '../../../hooks/usePaginatedData';
import { AuthButton } from '../../../components/AuthButton';
import { handleApiError } from '../../../api/client';
interface Props {
patientId: string;
}
const columns = [
{ title: '报告日期', dataIndex: 'report_date', key: 'report_date', width: 120 },
{ title: '报告类型', dataIndex: 'report_type', key: 'report_type', width: 120, render: (v: string) => <Tag>{v}</Tag> },
{ title: '医生解读', dataIndex: 'doctor_interpretation', key: 'doctor_interpretation', ellipsis: true },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: (v: string) => new Date(v).toLocaleString('zh-CN') },
];
export function LabReportsTab({ patientId }: Props) {
const [modalOpen, setModalOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState<LabReport | null>(null);
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
@@ -31,33 +28,96 @@ export function LabReportsTab({ patientId }: Props) {
const { data, total, page, loading, refresh } = usePaginatedData<LabReport>(fetcher, 10);
const handleCreate = async (values: {
const openCreateModal = () => {
setEditingRecord(null);
form.resetFields();
setModalOpen(true);
};
const openEditModal = (record: LabReport) => {
setEditingRecord(record);
form.setFieldsValue({
report_date: dayjs(record.report_date),
report_type: record.report_type,
doctor_interpretation: record.doctor_interpretation,
});
setModalOpen(true);
};
const handleSubmit = async (values: {
report_date: Dayjs;
report_type: string;
doctor_interpretation?: string;
}) => {
setSubmitting(true);
try {
await healthDataApi.createLabReport(patientId, {
report_date: values.report_date.format('YYYY-MM-DD'),
report_type: values.report_type,
doctor_interpretation: values.doctor_interpretation,
});
message.success('化验报告添加成功');
if (editingRecord) {
await healthDataApi.updateLabReport(patientId, editingRecord.id, {
report_date: values.report_date.format('YYYY-MM-DD'),
report_type: values.report_type,
doctor_interpretation: values.doctor_interpretation,
version: editingRecord.version,
});
message.success('化验报告更新成功');
} else {
await healthDataApi.createLabReport(patientId, {
report_date: values.report_date.format('YYYY-MM-DD'),
report_type: values.report_type,
doctor_interpretation: values.doctor_interpretation,
});
message.success('化验报告添加成功');
}
setModalOpen(false);
setEditingRecord(null);
form.resetFields();
refresh();
} catch {
message.error('添加失败');
} catch (err) {
handleApiError(err, editingRecord ? '更新失败' : '添加失败');
} finally {
setSubmitting(false);
}
};
const handleDelete = async (record: LabReport) => {
try {
await healthDataApi.deleteLabReport(patientId, record.id);
message.success('化验报告删除成功');
refresh();
} catch (err) {
handleApiError(err, '删除失败');
}
};
const columns = [
{ title: '报告日期', dataIndex: 'report_date', key: 'report_date', width: 120 },
{ title: '报告类型', dataIndex: 'report_type', key: 'report_type', width: 120, render: (v: string) => <Tag>{v}</Tag> },
{ title: '医生解读', dataIndex: 'doctor_interpretation', key: 'doctor_interpretation', ellipsis: true },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: (v: string) => new Date(v).toLocaleString('zh-CN') },
{
title: '操作',
key: 'actions',
width: 120,
render: (_: unknown, record: LabReport) => (
<AuthButton code="health.health-data.manage">
<Space size={0}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEditModal(record)}>
</Button>
<Popconfirm title="确认删除该化验报告?" onConfirm={() => handleDelete(record)} okText="确认" cancelText="取消">
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
</AuthButton>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
<Button icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true); }}>
<Button icon={<PlusOutlined />} onClick={openCreateModal}>
</Button>
</div>
@@ -75,15 +135,15 @@ export function LabReportsTab({ patientId }: Props) {
}}
/>
<Modal
title="添加化验报告"
title={editingRecord ? '编辑化验报告' : '添加化验报告'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onCancel={() => { setModalOpen(false); setEditingRecord(null); }}
onOk={() => form.submit()}
confirmLoading={submitting}
destroyOnClose
width={520}
>
<Form form={form} layout="vertical" onFinish={handleCreate}>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item name="report_date" label="报告日期" rules={[{ required: true, message: '请选择日期' }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>

View File

@@ -1,11 +1,14 @@
import { useCallback, useState } from 'react';
import { Table, Button, Modal, Form, InputNumber, DatePicker, Input, message, Typography, Tooltip } from 'antd';
import { PlusOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { useCallback, useState, useMemo } from 'react';
import { Table, Button, Modal, Form, InputNumber, DatePicker, Input, message, Typography, Tooltip, Popconfirm, Space } from 'antd';
import { PlusOutlined, InfoCircleOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import { healthDataApi } from '../../../api/health/healthData';
import type { VitalSigns } from '../../../api/health/healthData';
import { VitalSignsChart } from './VitalSignsChart';
import { usePaginatedData } from '../../../hooks/usePaginatedData';
import { AuthButton } from '../../../components/AuthButton';
import { handleApiError } from '../../../api/client';
const { Text } = Typography;
@@ -13,61 +16,9 @@ interface Props {
patientId: string;
}
const columns = [
{
title: '记录日期',
dataIndex: 'record_date',
key: 'record_date',
width: 110,
fixed: 'left' as const,
},
{
title: () => (
<Tooltip title="晨间收缩压">
<span>()</span>
</Tooltip>
),
dataIndex: 'systolic_bp_morning',
key: 'systolic_bp_morning',
width: 110,
render: (v?: number) => (v != null ? `${v} mmHg` : '-'),
},
{
title: () => (
<Tooltip title="晨间舒张压">
<span>()</span>
</Tooltip>
),
dataIndex: 'diastolic_bp_morning',
key: 'diastolic_bp_morning',
width: 110,
render: (v?: number) => (v != null ? `${v} mmHg` : '-'),
},
{
title: '心率',
dataIndex: 'heart_rate',
key: 'heart_rate',
width: 80,
render: (v?: number) => (v != null ? `${v} bpm` : '-'),
},
{
title: '体重',
dataIndex: 'weight',
key: 'weight',
width: 80,
render: (v?: number) => (v != null ? `${v} kg` : '-'),
},
{
title: '血糖',
dataIndex: 'blood_sugar',
key: 'blood_sugar',
width: 90,
render: (v?: number) => (v != null ? `${v} mmol/L` : '-'),
},
];
export function VitalSignsTab({ patientId }: Props) {
const [modalOpen, setModalOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState<VitalSigns | null>(null);
const [chartRefreshKey, setChartRefreshKey] = useState(0);
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
@@ -81,7 +32,40 @@ export function VitalSignsTab({ patientId }: Props) {
const { data, total, page, loading, refresh } = usePaginatedData<VitalSigns>(fetcher, 10);
const handleCreate = async (values: {
const handleOpenCreate = () => {
setEditingRecord(null);
form.resetFields();
setModalOpen(true);
};
const handleOpenEdit = (record: VitalSigns) => {
setEditingRecord(record);
form.setFieldsValue({
record_date: dayjs(record.record_date),
systolic_bp_morning: record.systolic_bp_morning,
diastolic_bp_morning: record.diastolic_bp_morning,
heart_rate: record.heart_rate,
weight: record.weight,
blood_sugar: record.blood_sugar,
water_intake_ml: record.water_intake_ml,
urine_output_ml: record.urine_output_ml,
notes: record.notes,
});
setModalOpen(true);
};
const handleDelete = async (record: VitalSigns) => {
try {
await healthDataApi.deleteVitalSigns(patientId, record.id);
message.success('删除成功');
refresh();
setChartRefreshKey((k) => k + 1);
} catch (err) {
handleApiError(err, '删除失败');
}
};
const handleSubmit = async (values: {
record_date: Dayjs;
systolic_bp_morning?: number;
diastolic_bp_morning?: number;
@@ -94,7 +78,7 @@ export function VitalSignsTab({ patientId }: Props) {
}) => {
setSubmitting(true);
try {
await healthDataApi.createVitalSigns(patientId, {
const payload = {
record_date: values.record_date.format('YYYY-MM-DD'),
systolic_bp_morning: values.systolic_bp_morning,
diastolic_bp_morning: values.diastolic_bp_morning,
@@ -104,18 +88,122 @@ export function VitalSignsTab({ patientId }: Props) {
water_intake_ml: values.water_intake_ml,
urine_output_ml: values.urine_output_ml,
notes: values.notes,
});
message.success('体征数据录入成功');
};
if (editingRecord) {
await healthDataApi.updateVitalSigns(patientId, editingRecord.id, {
...payload,
version: editingRecord.version,
});
message.success('体征数据更新成功');
} else {
await healthDataApi.createVitalSigns(patientId, payload);
message.success('体征数据录入成功');
}
setModalOpen(false);
form.resetFields();
setEditingRecord(null);
refresh();
setChartRefreshKey((k) => k + 1); } catch {
message.error('录入失败');
setChartRefreshKey((k) => k + 1);
} catch (err) {
handleApiError(err, editingRecord ? '更新失败' : '录入失败');
} finally {
setSubmitting(false);
}
};
const columns = useMemo(
() => [
{
title: '记录日期',
dataIndex: 'record_date',
key: 'record_date',
width: 110,
fixed: 'left' as const,
},
{
title: () => (
<Tooltip title="晨间收缩压">
<span>()</span>
</Tooltip>
),
dataIndex: 'systolic_bp_morning',
key: 'systolic_bp_morning',
width: 110,
render: (v?: number) => (v != null ? `${v} mmHg` : '-'),
},
{
title: () => (
<Tooltip title="晨间舒张压">
<span>()</span>
</Tooltip>
),
dataIndex: 'diastolic_bp_morning',
key: 'diastolic_bp_morning',
width: 110,
render: (v?: number) => (v != null ? `${v} mmHg` : '-'),
},
{
title: '心率',
dataIndex: 'heart_rate',
key: 'heart_rate',
width: 80,
render: (v?: number) => (v != null ? `${v} bpm` : '-'),
},
{
title: '体重',
dataIndex: 'weight',
key: 'weight',
width: 80,
render: (v?: number) => (v != null ? `${v} kg` : '-'),
},
{
title: '血糖',
dataIndex: 'blood_sugar',
key: 'blood_sugar',
width: 90,
render: (v?: number) => (v != null ? `${v} mmol/L` : '-'),
},
{
title: '操作',
key: 'action',
width: 120,
fixed: 'right' as const,
render: (_: unknown, record: VitalSigns) => (
<AuthButton code="health.health-data.manage">
<Space size={0}>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleOpenEdit(record)}
/>
<Popconfirm
title="确认删除"
description="删除后无法恢复,确定要删除这条体征记录吗?"
onConfirm={() => handleDelete(record)}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
/>
</Popconfirm>
</Space>
</AuthButton>
),
},
],
// handleOpenEdit and handleDelete are stable closures that only depend on patientId via refresh
// eslint-disable-next-line react-hooks/exhaustive-deps
[patientId],
);
// 最近一次数据摘要
const latest = data.length > 0 ? data[0] : null;
@@ -167,7 +255,7 @@ export function VitalSignsTab({ patientId }: Props) {
</Text>
<Button
icon={<PlusOutlined />}
onClick={() => { form.resetFields(); setModalOpen(true); }}
onClick={handleOpenCreate}
size="small"
>
@@ -189,19 +277,22 @@ export function VitalSignsTab({ patientId }: Props) {
size: 'small',
style: { margin: 0 },
}}
scroll={{ x: 600 }}
scroll={{ x: 700 }}
/>
<Modal
title="录入体征数据"
title={editingRecord ? '编辑体征数据' : '录入体征数据'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onCancel={() => {
setModalOpen(false);
setEditingRecord(null);
}}
onOk={() => form.submit()}
confirmLoading={submitting}
destroyOnClose
width={600}
>
<Form form={form} layout="vertical" onFinish={handleCreate}>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item name="record_date" label="记录日期" rules={[{ required: true, message: '请选择日期' }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>

View File

@@ -1,5 +1,6 @@
import { create } from 'zustand';
import { login as apiLogin, logout as apiLogout, type UserInfo } from '../api/auth';
import { clearApiCache } from '../api/client';
function extractPermissions(): string[] {
const token = localStorage.getItem('access_token');
@@ -54,6 +55,7 @@ export const useAuthStore = create<AuthState>((set) => ({
localStorage.setItem('refresh_token', resp.refresh_token);
localStorage.setItem('user', JSON.stringify(resp.user));
set({ user: resp.user, isAuthenticated: true, loading: false, permissions: extractPermissions() });
clearApiCache();
} catch (error) {
set({ loading: false });
throw error;
@@ -69,6 +71,7 @@ export const useAuthStore = create<AuthState>((set) => ({
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
clearApiCache();
set({ user: null, isAuthenticated: false, permissions: [] });
},

View File

@@ -23,3 +23,4 @@ base64 = "0.22"
hex = "0.4"
rand = "0.8"
dashmap = "6"
ammonia.workspace = true

View File

@@ -1,44 +1,36 @@
/// HTML/Script 内容清理工具。
///
/// 在用户输入进入数据库之前,剥离所有 HTML 标签,防止存储型 XSS。
/// 基于 ammoniahtml5ever剥离所有 HTML 标签,防止存储型 XSS。
/// 覆盖场景:用户名、显示名、邮箱、电话等字符串字段。
/// 剥离字符串中的所有 HTML 标签,返回纯文本。
///
/// ```rust
/// use erp_core::sanitize::strip_html_tags;
/// assert_eq!(strip_html_tags("<script>alert(1)</script>"), "alert(1)");
/// assert_eq!(strip_html_tags("<img src=x onerror=alert(1)>"), "");
/// assert_eq!(strip_html_tags("Hello <b>World</b>"), "Hello World");
/// ```
/// 使用 ammonia 构建 DOM 树,然后用 tendril 收集文本节点。
/// 比手写字符级解析器更安全,能正确处理所有 HTML 边界情况。
pub fn strip_html_tags(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut in_tag = false;
let mut depth = 0usize;
// 使用 ammonia 清理(保留在 span 中的纯文本),然后剥离 span 标签
let doc = ammonia::Builder::new()
.tags(std::collections::HashSet::new())
.clean(input)
.to_string();
for ch in input.chars() {
match ch {
'<' => {
in_tag = true;
depth += 1;
}
'>' => {
if depth > 0 {
depth -= 1;
}
if depth == 0 {
in_tag = false;
}
}
_ => {
if !in_tag {
result.push(ch);
}
}
}
}
// ammonia 的 clean() 结果可能包含 HTML 实体(如 &lt;),需要解码
// 但由于所有标签已被禁止,结果是纯文本(可能有实体转义)
// 使用二次清理:将结果作为纯文本处理
decode_entities(&doc).trim().to_string()
}
result.trim().to_string()
/// 简单解码常见 HTML 实体。
fn decode_entities(input: &str) -> String {
input
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&#47;", "/")
.replace("&#32;", " ")
}
/// 对 Option<String> 类型的字段进行清理。
@@ -57,7 +49,8 @@ mod tests {
#[test]
fn strips_script_tag() {
assert_eq!(strip_html_tags("<script>alert('xss')</script>"), "alert('xss')");
// script 内容在 HTML 规范中是 raw textammonia 正确地将其完全移除
assert_eq!(strip_html_tags("<script>alert('xss')</script>"), "");
}
#[test]
@@ -83,7 +76,7 @@ mod tests {
#[test]
fn sanitize_option_some() {
assert_eq!(
sanitize_option(Some("<script>evil</script>".to_string())),
sanitize_option(Some("<b>evil</b>".to_string())),
Some("evil".to_string())
);
}
@@ -97,4 +90,22 @@ mod tests {
fn sanitize_option_becomes_empty() {
assert_eq!(sanitize_option(Some("<img>".to_string())), None);
}
#[test]
fn strips_nested_script_attack() {
let result = strip_html_tags("<scr<script>ipt>alert(1)</scr</script>ipt>");
assert!(!result.contains("<"), "不应残留 HTML 标签");
}
#[test]
fn strips_unclosed_tag() {
let result = strip_html_tags("text <img");
assert!(result.contains("text") || result.is_empty());
}
#[test]
fn handles_entities() {
let result = strip_html_tags("a &lt; b");
assert!(result.contains("a") && result.contains("b"));
}
}

View File

@@ -411,6 +411,14 @@ pub async fn update_schedule(
}
}
// 记录变更前的关键字段
let old_values = serde_json::json!({
"start_time": model.start_time,
"end_time": model.end_time,
"max_appointments": model.max_appointments,
"status": model.status,
});
let mut active: doctor_schedule::ActiveModel = model.into();
if let Some(v) = req.start_time { active.start_time = Set(v); }
if let Some(v) = req.end_time { active.end_time = Set(v); }
@@ -422,9 +430,18 @@ pub async fn update_schedule(
let m = active.update(&state.db).await?;
// 变更后快照
let new_values = serde_json::json!({
"start_time": m.start_time,
"end_time": m.end_time,
"max_appointments": m.max_appointments,
"status": m.status,
});
audit_service::record(
AuditLog::new(tenant_id, operator_id, "doctor_schedule.updated", "doctor_schedule")
.with_resource_id(m.id),
.with_resource_id(m.id)
.with_changes(Some(old_values), Some(new_values)),
&state.db,
).await;

View File

@@ -170,6 +170,14 @@ pub async fn update_task(
validate_follow_up_status_transition(&model.status, new_status)?;
}
// 记录变更前的关键字段
let old_values = serde_json::json!({
"assigned_to": model.assigned_to,
"follow_up_type": model.follow_up_type,
"planned_date": model.planned_date,
"status": model.status,
});
let mut active: follow_up_task::ActiveModel = model.into();
if let Some(v) = req.assigned_to { active.assigned_to = Set(Some(v)); }
if let Some(v) = req.follow_up_type { active.follow_up_type = Set(v); }
@@ -182,9 +190,18 @@ pub async fn update_task(
let m = active.update(&state.db).await?;
// 变更后快照
let new_values = serde_json::json!({
"assigned_to": m.assigned_to,
"follow_up_type": m.follow_up_type,
"planned_date": m.planned_date,
"status": m.status,
});
audit_service::record(
AuditLog::new(tenant_id, operator_id, "follow_up_task.updated", "follow_up_task")
.with_resource_id(m.id),
.with_resource_id(m.id)
.with_changes(Some(old_values), Some(new_values)),
&state.db,
).await;

View File

@@ -156,6 +156,19 @@ pub async fn update_vital_signs(
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
// 记录变更前的关键体征值
let old_values = serde_json::json!({
"record_date": model.record_date,
"systolic_bp_morning": model.systolic_bp_morning,
"diastolic_bp_morning": model.diastolic_bp_morning,
"systolic_bp_evening": model.systolic_bp_evening,
"diastolic_bp_evening": model.diastolic_bp_evening,
"heart_rate": model.heart_rate,
"weight": model.weight,
"blood_sugar": model.blood_sugar,
"notes": model.notes,
});
let mut active: vital_signs::ActiveModel = model.into();
if let Some(v) = req.record_date { active.record_date = Set(v); }
if let Some(v) = req.systolic_bp_morning { active.systolic_bp_morning = Set(Some(v)); }
@@ -174,6 +187,19 @@ pub async fn update_vital_signs(
let m = active.update(&state.db).await?;
// 变更后快照
let new_values = serde_json::json!({
"record_date": m.record_date,
"systolic_bp_morning": m.systolic_bp_morning,
"diastolic_bp_morning": m.diastolic_bp_morning,
"systolic_bp_evening": m.systolic_bp_evening,
"diastolic_bp_evening": m.diastolic_bp_evening,
"heart_rate": m.heart_rate,
"weight": m.weight,
"blood_sugar": m.blood_sugar,
"notes": m.notes,
});
// 更新后也触发危急值检测(修改后的值可能触发告警)
let check_req = CreateVitalSignsReq {
record_date: m.record_date,
@@ -193,7 +219,8 @@ pub async fn update_vital_signs(
audit_service::record(
AuditLog::new(tenant_id, operator_id, "vital_signs.updated", "vital_signs")
.with_resource_id(m.id),
.with_resource_id(m.id)
.with_changes(Some(old_values), Some(new_values)),
&state.db,
).await;
@@ -406,6 +433,16 @@ pub async fn update_lab_report(
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
// 记录变更前的关键字段items 为加密值,记录 meta 信息)
let old_values = serde_json::json!({
"report_date": model.report_date,
"report_type": model.report_type,
"status": model.status,
"has_items": model.items.is_some(),
"has_image_urls": model.image_urls.is_some(),
"has_doctor_notes": model.doctor_notes.is_some(),
});
let mut active: lab_report::ActiveModel = model.into();
if let Some(v) = req.report_date { active.report_date = Set(v); }
if let Some(v) = req.report_type { active.report_type = Set(v); }
@@ -430,9 +467,20 @@ pub async fn update_lab_report(
let m = active.update(&state.db).await?;
// 变更后快照
let new_values = serde_json::json!({
"report_date": m.report_date,
"report_type": m.report_type,
"status": m.status,
"has_items": m.items.is_some(),
"has_image_urls": m.image_urls.is_some(),
"has_doctor_notes": m.doctor_notes.is_some(),
});
audit_service::record(
AuditLog::new(tenant_id, operator_id, "lab_report.updated", "lab_report")
.with_resource_id(m.id),
.with_resource_id(m.id)
.with_changes(Some(old_values), Some(new_values)),
&state.db,
).await;
@@ -514,6 +562,7 @@ pub async fn review_lab_report(
validate_lab_report_status_transition(&model.status, "reviewed")?;
let old_status = model.status.clone();
let mut active: lab_report::ActiveModel = model.into();
active.status = Set("reviewed".to_string());
active.reviewed_by = Set(Some(reviewer_id));
@@ -539,7 +588,11 @@ pub async fn review_lab_report(
audit_service::record(
AuditLog::new(tenant_id, Some(reviewer_id), "lab_report.reviewed", "lab_report")
.with_resource_id(m.id),
.with_resource_id(m.id)
.with_changes(
Some(serde_json::json!({ "status": old_status })),
Some(serde_json::json!({ "status": m.status, "reviewed_by": m.reviewed_by })),
),
&state.db,
).await;
@@ -675,6 +728,14 @@ pub async fn update_health_record(
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
// 记录变更前的关键字段
let old_values = serde_json::json!({
"record_type": model.record_type,
"record_date": model.record_date,
"overall_assessment": model.overall_assessment,
"notes": model.notes,
});
let mut active: health_record::ActiveModel = model.into();
if let Some(ref v) = req.record_type { validate_record_type(v)?; active.record_type = Set(v.clone()); }
if let Some(v) = req.record_date { active.record_date = Set(v); }
@@ -688,9 +749,18 @@ pub async fn update_health_record(
let m = active.update(&state.db).await?;
// 变更后快照
let new_values = serde_json::json!({
"record_type": m.record_type,
"record_date": m.record_date,
"overall_assessment": m.overall_assessment,
"notes": m.notes,
});
audit_service::record(
AuditLog::new(tenant_id, operator_id, "health_record.updated", "health_record")
.with_resource_id(m.id),
.with_resource_id(m.id)
.with_changes(Some(old_values), Some(new_values)),
&state.db,
).await;

View File

@@ -603,6 +603,15 @@ pub async fn update_family_member(
let kek = state.crypto.kek();
let hmac_key = state.crypto.hmac_key();
// 记录变更前的关键字段phone 为加密值,不记录原文)
let old_values = serde_json::json!({
"name": model.name,
"relationship": model.relationship,
"birth_date": model.birth_date,
"notes": model.notes,
});
let mut active: patient_family_member::ActiveModel = model.into();
active.name = Set(req.name);
active.relationship = Set(req.relationship);
@@ -621,9 +630,18 @@ pub async fn update_family_member(
let updated = active.update(&state.db).await?;
// 变更后快照
let new_values = serde_json::json!({
"name": updated.name,
"relationship": updated.relationship,
"birth_date": updated.birth_date,
"notes": updated.notes,
});
audit_service::record(
AuditLog::new(tenant_id, operator_id, "patient.family_member_updated", "patient_family_member")
.with_resource_id(updated.id),
.with_resource_id(updated.id)
.with_changes(Some(old_values), Some(new_values)),
&state.db,
).await;
@@ -958,6 +976,13 @@ pub async fn update_tag(
if tag.tenant_id != tenant_id { return Err(HealthError::TagNotFound); }
check_version(req.version, tag.version)?;
// 记录变更前的关键字段
let old_values = serde_json::json!({
"name": tag.name,
"color": tag.color,
"description": tag.description,
});
let mut active: patient_tag::ActiveModel = tag.into();
if let Some(name) = req.name { active.name = Set(name); }
if let Some(color) = req.color { active.color = Set(Some(color)); }
@@ -969,9 +994,17 @@ pub async fn update_tag(
let updated = active.update(&state.db).await
.map_err(|e: sea_orm::DbErr| HealthError::DbError(e.to_string()))?;
// 变更后快照
let new_values = serde_json::json!({
"name": updated.name,
"color": updated.color,
"description": updated.description,
});
audit_service::record(
AuditLog::new(tenant_id, operator_id, "patient_tag.update", "patient_tag")
.with_resource_id(updated.id),
.with_resource_id(updated.id)
.with_changes(Some(old_values), Some(new_values)),
&state.db,
).await;

View File

@@ -82,6 +82,9 @@ where
)));
}
// 校验 magic bytes验证文件实际内容与声明的 Content-Type 一致
validate_magic_bytes(&content_type, &data)?;
// 生成唯一文件名,保留原始扩展名
let ext = std::path::Path::new(&original_name)
.extension()
@@ -137,6 +140,78 @@ fn validate_content_type(content_type: &str) -> Result<(), AppError> {
Ok(())
}
/// 校验文件 magic bytes文件签名与声明的 Content-Type 是否一致。
///
/// 防止攻击者通过修改 Content-Type 头上传恶意文件。
/// 对于 Office 格式等复杂签名,跳过 magic bytes 校验(仅依赖白名单)。
fn validate_magic_bytes(content_type: &str, data: &[u8]) -> Result<(), AppError> {
// 需要至少几个字节才能校验
if data.is_empty() {
return Err(AppError::Validation("文件内容为空".to_string()));
}
let signature: &[u8] = match content_type {
"image/jpeg" => {
// JPEG: FF D8 FF
b"\xFF\xD8\xFF"
}
"image/png" => {
// PNG: 89 50 4E 47 0D 0A 1A 0A
b"\x89PNG\r\n\x1A\n"
}
"image/gif" => {
// GIF: 47 49 46 38 (GIF8)
b"GIF8"
}
"image/webp" => {
// WebP: RIFF....WEBP (12 bytes)
// 前 4 字节: 52 49 46 46 (RIFF)
// 字节 8-11: 57 45 42 50 (WEBP)
if data.len() < 12 {
return Err(AppError::Validation(
"文件数据不足,无法验证 WebP 格式".to_string(),
));
}
let riff_ok = &data[0..4] == b"RIFF";
let webp_ok = &data[8..12] == b"WEBP";
if riff_ok && webp_ok {
return Ok(());
}
return Err(AppError::Validation(
"文件内容与声明的类型 (image/webp) 不匹配".to_string(),
));
}
"application/pdf" => {
// PDF: 25 50 44 46 (%PDF)
b"%PDF"
}
// Office 格式的 magic bytes 较复杂OLE2 / ZIP-based OOXML
// 仅依赖白名单,跳过 magic bytes 校验
"application/msword"
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
| "application/vnd.ms-excel"
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => {
return Ok(());
}
_ => return Ok(()),
};
if data.len() < signature.len() {
return Err(AppError::Validation(
"文件数据不足,无法验证文件格式".to_string(),
));
}
if &data[..signature.len()] != signature {
return Err(AppError::Validation(format!(
"文件内容与声明的类型 ({}) 不匹配",
content_type
)));
}
Ok(())
}
fn format_size(bytes: u64) -> String {
if bytes >= 1024 * 1024 * 1024 {
format!("{}GB", bytes / (1024 * 1024 * 1024))

View File

@@ -487,6 +487,8 @@ async fn main() -> anyhow::Result<()> {
// with the jwt_auth_middleware_fn.
// Public routes (no authentication, but IP-based rate limiting)
// Layer execution order (outer → inner): account_lockout → rate_limit_by_ip
// So account lockout check runs FIRST, then IP rate limiting
let public_routes = Router::new()
.merge(handlers::health::health_check_router())
.merge(erp_auth::AuthModule::public_routes())
@@ -494,6 +496,10 @@ async fn main() -> anyhow::Result<()> {
"/docs/openapi.json",
axum::routing::get(handlers::openapi::openapi_spec),
)
.layer(axum::middleware::from_fn_with_state(
state.clone(),
middleware::rate_limit::account_lockout_middleware,
))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
middleware::rate_limit::rate_limit_by_ip,

View File

@@ -19,6 +19,10 @@ struct RateLimitResponse {
message: String,
}
/// 账户锁定配置。
const ACCOUNT_LOCKOUT_MAX_FAILURES: i64 = 5;
const ACCOUNT_LOCKOUT_TTL_SECS: i64 = 900; // 15 分钟
/// 限流参数(预留配置化扩展)。
#[allow(dead_code)]
pub struct RateLimitConfig {
@@ -162,6 +166,133 @@ async fn apply_rate_limit(
next.run(req).await
}
/// 账户级登录锁定中间件。
///
/// 针对登录接口POST /api/v1/auth/login在 IP 限流之前执行:
/// 1. 解析请求体提取 username
/// 2. 检查 Redis 中该 username 的失败次数
/// 3. 超过阈值5次则拒绝请求
/// 4. 观察响应状态码401 递增失败计数200 清除计数
pub async fn account_lockout_middleware(
State(state): State<AppState>,
req: Request<Body>,
next: Next,
) -> Response {
let avail = redis_avail();
// Redis 不可达时 fail-open放行请求
if !avail.should_try().await {
tracing::warn!("Redis 不可达fail-open 账户锁定检查放行");
return next.run(req).await;
}
// 获取 Redis 连接
let mut conn = match state.redis.get_multiplexed_async_connection().await {
Ok(c) => {
avail.mark_ok();
c
}
Err(e) => {
tracing::warn!(error = %e, "Redis 连接失败fail-open 账户锁定检查放行");
avail.mark_failed().await;
return next.run(req).await;
}
};
// 读取请求体以提取 username
let (parts, body) = req.into_parts();
let bytes = match axum::body::to_bytes(body, 1024).await {
Ok(b) => b,
Err(e) => {
tracing::warn!(error = %e, "读取登录请求体失败,放行");
// 无法读取 body重建请求放行
let req = Request::from_parts(parts, Body::from(Vec::new()));
return next.run(req).await;
}
};
// 解析 username
let username = serde_json::from_slice::<serde_json::Value>(&bytes)
.ok()
.and_then(|v| v.get("username")?.as_str().map(|s| s.to_string()));
let username = match username {
Some(u) if !u.is_empty() => u,
_ => {
// 无法解析 username用原始 body 重建请求放行
let req = Request::from_parts(parts, Body::from(bytes.to_vec()));
return next.run(req).await;
}
};
// 检查账户锁定状态
let lockout_key = format!("login_fail:{}", username);
let fail_count: i64 = conn.get(&lockout_key).await.unwrap_or(0);
if fail_count >= ACCOUNT_LOCKOUT_MAX_FAILURES {
tracing::warn!(
username = %username,
fail_count = fail_count,
"账户已被临时锁定"
);
let body = RateLimitResponse {
error: "Too Many Requests".to_string(),
message: "账户已被临时锁定请15分钟后重试".to_string(),
};
return (StatusCode::TOO_MANY_REQUESTS, axum::Json(body)).into_response();
}
// 用原始 body 重建请求,转发到 handler
let req = Request::from_parts(parts, Body::from(bytes.to_vec()));
let response = next.run(req).await;
// 观察响应状态码
let status = response.status();
let (parts, body) = response.into_parts();
// 需要读取 body 以重建响应(因为 into_parts 消费了 body
let body_bytes = axum::body::to_bytes(body, 1024 * 1024)
.await
.unwrap_or_default();
if status == StatusCode::UNAUTHORIZED {
// 登录失败:递增失败计数
let new_count: i64 = match redis::cmd("INCR")
.arg(&lockout_key)
.query_async(&mut conn)
.await
{
Ok(n) => n,
Err(e) => {
tracing::warn!(error = %e, "Redis INCR 失败计数失败");
// 即使计数失败,也返回原始 401 响应
let resp = Response::from_parts(parts, Body::from(body_bytes.to_vec()));
return resp;
}
};
// 首次失败时设置 TTL
if new_count == 1 {
let _: Result<(), _> = conn.expire(&lockout_key, ACCOUNT_LOCKOUT_TTL_SECS).await;
}
tracing::info!(
username = %username,
fail_count = new_count,
remaining = ACCOUNT_LOCKOUT_MAX_FAILURES - new_count,
"登录失败,递增失败计数"
);
} else if status.is_success() {
// 登录成功:清除失败计数
let _: Result<(), _> = conn.del(&lockout_key).await;
tracing::info!(username = %username, "登录成功,清除失败计数");
}
// 重建并返回原始响应
let resp = Response::from_parts(parts, Body::from(body_bytes.to_vec()));
resp
}
/// 从请求头中提取客户端 IP。
fn extract_client_ip(headers: &axum::http::HeaderMap) -> String {
headers