228 lines
9.6 KiB
Markdown
228 lines
9.6 KiB
Markdown
# 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%** | 错误处理架构优秀,日志和前端重连有差距 |
|