# 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 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`,缺失时降级 | ✓ 安全 | | 断路器 | 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%** | 错误处理架构优秀,日志和前端重连有差距 |