docs: 审计报告(8 份) + 讨论记录(4 份)
审计报告: 基线快照/功能清单/后端完整性/事件系统/参数配置/ 差距模式/错误处理/测试覆盖/审计总结报告 讨论记录: 设备管线/端到端测试/三端审计/工作台重构
This commit is contained in:
227
docs/audits/06-error-handling.md
Normal file
227
docs/audits/06-error-handling.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# HMS 功能审计 — Phase 6: 错误处理与降级审计
|
||||
|
||||
> 日期: 2026-04-30 | 审计范围: 错误处理、降级策略、日志完整性
|
||||
|
||||
## 总览
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| AppError 变体 | 8 个 |
|
||||
| 领域错误变体 | HealthError 26 + AiError 11 |
|
||||
| Handler 错误映射机制 | 统一 IntoResponse trait |
|
||||
| 生产 unwrap() | 18 处(13 低风险 + 2 中风险 + 3 无害) |
|
||||
| 审计日志 | 140 处(33 文件)+ SHA256 哈希链 |
|
||||
| 运行时 tracing 日志 | 11 处(health service 层) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 错误处理覆盖率
|
||||
|
||||
### 1.1 AppError 枚举(8 变体)
|
||||
|
||||
| 变体 | HTTP 状态码 | 触发场景 |
|
||||
|------|-----------|---------|
|
||||
| `NotFound(String)` | 404 | 资源不存在 |
|
||||
| `Validation(String)` | 400 | 输入验证失败 |
|
||||
| `Unauthorized` | 401 | 未认证 |
|
||||
| `Forbidden(String)` | 403 | 无权限 |
|
||||
| `Conflict(String)` | 409 | 唯一约束冲突 |
|
||||
| `VersionMismatch` | 409 | 乐观锁版本不匹配 |
|
||||
| `TooManyRequests` | 429 | 速率限制 |
|
||||
| `Internal(String)` | 500 | 内部错误(消息对外隐藏) |
|
||||
|
||||
### 1.2 领域错误映射
|
||||
|
||||
**HealthError → AppError**(26 变体):
|
||||
|
||||
| 映射规则 | 变体示例 | 目标 AppError |
|
||||
|---------|---------|-------------|
|
||||
| 资源不存在 | PatientNotFound, DoctorNotFound, ScheduleNotFound | NotFound |
|
||||
| 状态转换无效 | InvalidStatusTransition, AppointmentAlreadyCancelled | Validation |
|
||||
| 容量限制 | ScheduleFull | Validation |
|
||||
| 乐观锁 | VersionMismatch | VersionMismatch (409) |
|
||||
| 数据库错误 | DbError | Internal |
|
||||
| 加密错误 | EncryptionError, DecryptionError | Internal |
|
||||
|
||||
**AiError → AppError**(11 变体):
|
||||
|
||||
| 映射规则 | 变体示例 | 目标 AppError |
|
||||
|---------|---------|-------------|
|
||||
| 资源不存在 | AnalysisNotFound, PromptNotFound | NotFound |
|
||||
| 验证失败 | Validation, SanitizationError, TemplateError | Validation |
|
||||
| 提供商不可用 | ProviderUnavailable | Internal |
|
||||
| 提供商错误 | ProviderError | Internal |
|
||||
| 速率限制 | RateLimitExceeded | TooManyRequests (429) |
|
||||
|
||||
### 1.3 Handler 错误传播模式
|
||||
|
||||
所有 handler 函数统一使用 `?` 运算符自动传播错误:
|
||||
|
||||
```
|
||||
Handler → require_permission()? → service.do_work()? → Ok(Json(ApiResponse::ok(result)))
|
||||
```
|
||||
|
||||
无需手动 match/map,`From<DomainError> for AppError` 自动转换。SeaORM 的 `DbErr` 也有智能映射(RecordNotFound → 404, duplicate key → 409)。
|
||||
|
||||
### 1.4 PII 加密错误处理
|
||||
|
||||
所有加密/解密失败通过 `AppError::Internal` 返回。关键设计:
|
||||
- 错误细节仅通过 `tracing::error!` 记录到日志
|
||||
- HTTP 响应体统一替换为 `"内部错误"`,不泄露加密实现细节
|
||||
- 防止通过错误消息推断加密方案
|
||||
|
||||
### 1.5 SSE 端点错误处理
|
||||
|
||||
**预流阶段**(连接建立前):
|
||||
- 权限/验证/数据获取全部通过 `?` 传播
|
||||
- AI Provider 不可用时返回标准 JSON 错误响应(500),**不会挂起连接**
|
||||
|
||||
**流中阶段**(连接建立后):
|
||||
- Provider 断连:发送 SSE `error` 事件 → 标记分析记录为 `failed` → 发布 `ai.analysis.failed` 事件 → 优雅终止流
|
||||
- 序列化错误:使用 `unwrap_or_default()` 避免 panic
|
||||
|
||||
**结论:SSE 端点不会挂起。** Provider 不可用时有完整的错误传播和清理机制。
|
||||
|
||||
### 1.6 生产代码 unwrap() 风险
|
||||
|
||||
| 类别 | 数量 | 风险 | 建议 |
|
||||
|------|------|------|------|
|
||||
| `active.version.unwrap() + 1` | 13 | LOW | 改用 `expect("version from DB must be set")` |
|
||||
| `PluginHost::db.unwrap()` | 1 | **MEDIUM** | 改用 `ok_or(AppError::Internal(...))` |
|
||||
| 信号量 `acquire().unwrap()` | 1 | **MEDIUM** | 改用 `map_err` 处理关闭场景 |
|
||||
| Response builder `.unwrap()` | 3 | LOW | 可接受,硬编码值不会失败 |
|
||||
| `unwrap_or`/`unwrap_or_default` | ~20 | NONE | 安全模式 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 降级策略评估
|
||||
|
||||
### 2.1 Redis 不可用
|
||||
|
||||
| 场景 | 降级策略 | 评估 |
|
||||
|------|---------|------|
|
||||
| IP 限流 | **Fail-Open**:Redis 不可达时限流被旁路,请求正常放行 | ✓ 安全(宁可放过不可误杀) |
|
||||
| 账户锁定 | **可配置**:`ERP__RATE_LIMIT__FAIL_CLOSE` 环境变量控制,默认 Fail-Open,生产可切 Fail-Close | ✓ 灵活 |
|
||||
| 微信会话存储 | **优雅降级**:`AuthState.redis` 为 `Option<redis::Client>`,缺失时降级 | ✓ 安全 |
|
||||
| 断路器 | 30 秒冷却期,避免重复连接失败阻塞请求 | ✓ 成熟 |
|
||||
|
||||
**风险点**:`AppState.redis` 是 `redis::Client`(非 `Option`),仅 `AuthState` 用 `Option` 包裹。但没有全局 Redis 健康检查端点。
|
||||
|
||||
### 2.2 AI Provider (Claude) 不可用
|
||||
|
||||
| 阶段 | 行为 | 评估 |
|
||||
|------|------|------|
|
||||
| 预流(连接前) | 返回 500 JSON 错误,不挂起 | ✓ |
|
||||
| 流中(连接后) | 发送 SSE error 事件 → 标记失败 → 发布事件 → 终止流 | ✓ |
|
||||
| 分析记录 | 状态更新为 `failed`,保留错误信息 | ✓ |
|
||||
|
||||
**结论:AI Provider 不可用不会导致连接挂起。** 有完整的错误传播和清理机制。
|
||||
|
||||
### 2.3 EventBus 满载
|
||||
|
||||
| 层级 | 机制 | 评估 |
|
||||
|------|------|------|
|
||||
| 内存层 | broadcast channel 容量 1024,慢消费者收到 `Lagged` 错误 | ✓ |
|
||||
| 持久化层 | `domain_events` 表 outbox 模式,事件不丢失 | ✓ |
|
||||
| 兜底轮询 | 30 秒间隔扫描 `pending` 事件 | ✓ |
|
||||
| 最大重试 | 5 次,超限进入 `dead_letter_events` 表 | ✓ |
|
||||
|
||||
**结论:事件不会丢失。** broadcast channel 满载时实时性受影响(最多 30 秒延迟),但 outbox relay 保证最终交付。
|
||||
|
||||
### 2.4 前端 SSE 重连
|
||||
|
||||
| 维度 | 实现 | 评估 |
|
||||
|------|------|------|
|
||||
| 重连机制 | 浏览器原生 EventSource 自动重连 | 基本可用 |
|
||||
| 指数退避 | 无(浏览器默认 ~3 秒固定间隔) | ⚠️ 缺失 |
|
||||
| 最大重连次数 | 无(原生无限重试) | ⚠️ 缺失 |
|
||||
| 连接状态 UI | `useAlertSSE` 暴露 `connected` 状态但未在界面显示 | ⚠️ 缺失 |
|
||||
| Keep-alive | 后端 `Sse::new().keep_alive(KeepAlive::default())` | ✓ |
|
||||
|
||||
---
|
||||
|
||||
## 3. 日志完整性
|
||||
|
||||
### 3.1 运行时 tracing 日志密度
|
||||
|
||||
**erp-health/src/service/ 目录**(核心业务层):
|
||||
|
||||
| 日志级别 | 调用次数 | 分布文件 |
|
||||
|---------|---------|---------|
|
||||
| `tracing::info!` | 4 | points_service(1), seed(3) |
|
||||
| `tracing::warn!` | 6 | appointment(1), device_reading(1), health_data(3), points(1), seed(1) |
|
||||
| `tracing::error!` | 0 | — |
|
||||
| `tracing::debug!` | 1 | points(1) |
|
||||
| **总计** | **11** | **5/26 文件** |
|
||||
|
||||
**关键缺口**:
|
||||
|
||||
| 文件 | 行数 | tracing 调用 | 问题 |
|
||||
|------|------|-------------|------|
|
||||
| patient_service.rs | 949 | 0 | 患者创建/更新/删除无运行时日志 |
|
||||
| appointment_service.rs | 590 | 1 | 仅取消预约边缘场景有日志 |
|
||||
| consultation_service.rs | ~400 | 0 | 咨询会话/消息操作无运行时日志 |
|
||||
| follow_up_service.rs | ~500 | 0 | 随访任务操作无运行时日志 |
|
||||
|
||||
**对比**:基础设施层日志丰富:
|
||||
- `erp-core/events.rs`:EventBus 有完整的生命周期日志
|
||||
- `erp-server/rate_limit.rs`:限流有 warn/error 日志
|
||||
- `erp-server/outbox.rs`:Outbox relay 有完整的处理日志
|
||||
|
||||
### 3.2 审计日志覆盖
|
||||
|
||||
审计日志通过 `audit_service::record` 实现,全局 140 处调用分布在 33 个文件中。
|
||||
|
||||
**Health 模块审计覆盖**:
|
||||
|
||||
| 文件 | 审计调用数 | 覆盖操作 |
|
||||
|------|----------|---------|
|
||||
| patient_service.rs | 12 | 创建/更新/删除患者、标签管理、家庭成员 CRUD、医生分配 |
|
||||
| points_service.rs | 12 | 积分发放/兑换/过期 |
|
||||
| health_data_service.rs | 10 | 体征/化验/体检记录 CRUD + 审核 |
|
||||
| article_service.rs | 7 | 文章 CRUD |
|
||||
| follow_up_service.rs | 7 | 随访任务 CRUD |
|
||||
| appointment_service.rs | 4 | 预约创建/状态变更/排班管理 |
|
||||
| 其余 7 个文件 | 各 2-3 | 咨询/诊断/同意/医生/透析/设备/告警 |
|
||||
|
||||
**审计日志特性**:
|
||||
|
||||
| 特性 | 实现 |
|
||||
|------|------|
|
||||
| 结构化字段 | tenant_id, user_id, action, resource_type, resource_id, ip_address, user_agent |
|
||||
| 变更追踪 | 更新操作记录 old_value/new_value 快照 |
|
||||
| 哈希链 | SHA256 链式签名,可验证完整性 |
|
||||
| 请求来源 | task_local 自动注入 IP + User-Agent |
|
||||
| 写入模式 | Fire-and-forget(失败仅 warn,不影响业务) |
|
||||
|
||||
**审计日志风险点**:
|
||||
- Fire-and-forget 意味着写入失败是静默的,无重试或告警
|
||||
- 哈希链在并发写入时可能出现短暂链断裂(查询和写入非原子操作)
|
||||
|
||||
### 3.3 日志分层评估
|
||||
|
||||
| 层级 | tracing 日志 | 审计日志 | 评估 |
|
||||
|------|-------------|---------|------|
|
||||
| Handler 层 | — | — | 依赖 service 层 |
|
||||
| Service 层(health) | 11 处 | 70+ 处 | 运行时日志不足,审计日志完善 |
|
||||
| Service 层(infrastructure) | 丰富 | — | 日志密度高 |
|
||||
| EventBus | 完整 | — | 发布/消费全链路可追踪 |
|
||||
| 加密层 | — | — | 仅错误时 tracing::error |
|
||||
|
||||
---
|
||||
|
||||
## 4. 评分
|
||||
|
||||
| 检查项 | 评分 | 说明 |
|
||||
|--------|------|------|
|
||||
| 错误变体覆盖 | 95% | 8 个 AppError + 37 个领域错误,覆盖全面 |
|
||||
| Handler 错误传播 | 100% | 统一 `?` + IntoResponse,无手动 match |
|
||||
| PII 错误安全 | 100% | Internal 错误消息对外隐藏,仅日志记录 |
|
||||
| SSE 挂起风险 | 100% | Provider 不可用不会挂起,有完整清理 |
|
||||
| unwrap() 安全性 | 95% | 2 处中等风险(PluginHost::db + 信号量) |
|
||||
| Redis 降级 | 90% | Fail-Open 断路器 + 可配置,缺健康检查端点 |
|
||||
| EventBus 降级 | 95% | Outbox 兜底 + 死信队列,事件不丢失 |
|
||||
| 前端重连 | 60% | 依赖原生 EventSource,缺指数退避和 UI 反馈 |
|
||||
| 运行时日志 | 30% | health service 仅 11 处 tracing,运维盲区大 |
|
||||
| 审计日志 | 95% | 140 处覆盖所有写操作 + 哈希链验证 |
|
||||
| **综合评分** | **76%** | 错误处理架构优秀,日志和前端重连有差距 |
|
||||
Reference in New Issue
Block a user