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