docs(ai): 修复 spec 审查问题 — 路由模式/HealthDataProvider/AiState/缓存/权限
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

修复 2 CRITICAL + 4 HIGH + 3 MEDIUM:
- CRITICAL: 路由注册改为 public_routes/protected_routes 静态方法
- CRITICAL: 补全 HealthDataProvider trait + DTO 定义 + 注入机制
- HIGH: 修复 async_trait + impl Stream 不兼容 (改用 Pin<Box<dyn Stream>>)
- HIGH: 新增 AiConfig 配置段 + config.toml 定义
- HIGH: sanitized_input 改为 AES-256 加密 + 90 天自动清理
- HIGH: 新增 futures/tokio-stream/async-stream 依赖说明
- MEDIUM: 事件改为 DomainEvent 字符串模式
- MEDIUM: 权限码改为 .list/.manage 实体命名模式
- MEDIUM: 补全 AiState + FromRef 注入模式
- MEDIUM: 补全缓存设计 (Redis/键格式/TTL/Hash算法)
This commit is contained in:
iven
2026-04-25 12:34:34 +08:00
parent 9fabe39897
commit db626d27b8

View File

@@ -86,9 +86,13 @@ crates/erp-ai/
```rust
/// AI 提供商 trait
/// 使用 Pin<Box<dyn Stream>> 避免 async_trait + impl Trait 不兼容问题
#[async_trait]
pub trait AiProvider: Send + Sync {
async fn stream_generate(&self, req: GenerateRequest) -> Result<impl Stream<Item = Chunk>>;
async fn stream_generate(
&self,
req: GenerateRequest,
) -> Result<Pin<Box<dyn Stream<Item = Result<Chunk>> + Send>>>;
async fn generate(&self, req: GenerateRequest) -> Result<GenerateResponse>;
fn name(&self) -> &str;
async fn health_check(&self) -> Result<bool>;
@@ -96,44 +100,266 @@ pub trait AiProvider: Send + Sync {
/// 分析管道 trait (Phase 1: RequestPipeline; Phase 3: + AsyncPipeline)
pub trait AnalysisPipeline: Send + Sync {
fn analyze(&self, ctx: AnalysisContext) -> impl Stream<Item = AnalysisChunk>;
fn analyze(
&self,
ctx: AnalysisContext,
) -> Pin<Box<dyn Stream<Item = AnalysisChunk> + Send>>;
}
/// 数据脱敏 trait
/// 数据脱敏 trait — 输入为 HealthDataProvider 返回的 DTO非原始 SeaORM Entity
pub trait DataSanitizer: Send + Sync {
fn sanitize(&self, data: &serde_json::Value) -> Result<SanitizedData>;
fn sanitize(&self, data: &HealthAnalysisInput) -> Result<SanitizedData>;
}
/// 分析上下文
pub struct AnalysisContext {
pub trigger: AnalysisTrigger,
pub tenant_id: Uuid,
pub user_id: Uuid,
pub analysis_type: AnalysisType,
pub source_ref: String,
}
pub enum AnalysisTrigger {
Request, // Phase 1: 患者主动请求
Event, // Phase 3: 异步事件触发 (预留)
}
```
### 3.3 erp-core 扩展
### 3.3 路由注册(遵循现有 public_routes / protected_routes 模式)
```rust
/// 新增到 erp-core erp-health 实现
/// erp-ai 模块注册(参照 erp-health 的注册方式)
pub struct AiModule;
impl AiModule {
pub fn public_routes<S>() -> Router<S>
where
S: Clone + Send + Sync + 'static,
AiState: FromRef<S>,
{
Router::new()
.nest("/api/v1/ai/analyze", analyze::public_routes::<S>())
}
pub fn protected_routes<S>() -> Router<S>
where
S: Clone + Send + Sync + 'static,
AiState: FromRef<S>,
{
Router::new()
.nest("/api/v1/ai/analyze", analyze::protected_routes::<S>())
.nest("/api/v1/ai/analysis", analysis::routes::<S>())
.nest("/api/v1/ai/prompts", prompt::routes::<S>())
.nest("/api/v1/ai/usage", usage::routes::<S>())
.nest("/api/v1/ai/admin", admin::routes::<S>())
}
}
// erp-server/src/main.rs 中合并路由:
// let app = Router::new()
// .merge(AuthModule::public_routes())
// // ...
// .merge(AiModule::public_routes())
// .merge(AuthModule::protected_routes())
// // ...
// .merge(AiModule::protected_routes())
```
### 3.4 AiState 注入(遵循现有 FromRef 模式)
```rust
/// erp-ai 的 Axum State
pub struct AiState {
pub db: DatabaseConnection,
pub event_bus: EventBus,
pub provider_registry: ProviderRegistry,
pub sanitizer: Arc<dyn DataSanitizer>,
pub prompt_manager: Arc<dyn PromptManager>,
pub redis: deadpool_redis::Pool,
pub config: AiConfig,
}
// erp-server/src/state.rs 中新增:
// impl FromRef<AppState> for AiState { ... }
```
### 3.5 HealthDataProvider trait 与数据 DTO
```rust
// === erp-core 中新增 ===
/// 健康数据提供者 trait由 erp-health 实现
/// 通过 AppState 中的 Arc<dyn HealthDataProvider> 注入到 erp-ai
#[async_trait]
pub trait HealthDataProvider: Send + Sync {
async fn get_report_by_id(&self, tenant_id: Uuid, report_id: Uuid) -> AppResult<LabReport>;
async fn get_vital_signs(&self, tenant_id: Uuid, patient_id: Uuid, metrics: &[String], range: TimeRange) -> AppResult<Vec<VitalSign>>;
async fn get_patient_summary(&self, tenant_id: Uuid, patient_id: Uuid) -> AppResult<PatientSummary>;
async fn get_full_report(&self, tenant_id: Uuid, report_id: Uuid) -> AppResult<HealthReport>;
/// 获取化验报告(指标列表)
async fn get_lab_report(
&self,
tenant_id: Uuid,
report_id: Uuid,
) -> AppResult<LabReportDto>;
/// 获取生命体征趋势数据
async fn get_vital_signs(
&self,
tenant_id: Uuid,
patient_id: Uuid,
metrics: &[String],
range: TimeRange,
) -> AppResult<Vec<VitalSignDto>>;
/// 获取患者摘要(用于个性化方案,不含 PII 的 DTO
async fn get_patient_summary(
&self,
tenant_id: Uuid,
patient_id: Uuid,
) -> AppResult<PatientSummaryDto>;
/// 获取完整健康报告(用于摘要生成)
async fn get_full_report(
&self,
tenant_id: Uuid,
report_id: Uuid,
) -> AppResult<HealthReportDto>;
}
/// 新增事件类型
pub enum AiEvent {
AnalysisCompleted { analysis_id: Uuid, patient_id: Uuid, analysis_type: String },
AnalysisFailed { analysis_id: Uuid, error: String },
// === DTO 定义erp-core 中,已脱去 PII 的数据传输对象)===
// 注意: erp-health 实现此 trait 时,返回的 DTO 不包含患者姓名、身份证号等 PII
// 而是只包含 age_group / sex / 科室 / 检验指标 / 诊断编码 等医疗数据
pub struct LabReportDto {
pub age_group: String, // "40-50"
pub sex: String, // "male" / "female"
pub department: String, // 科室
pub report_date: String, // 报告日期
pub items: Vec<LabItemDto>, // 检验项目列表
}
/// 新增权限码
pub struct LabItemDto {
pub name: String, // 指标名称
pub value: f64, // 检测值
pub unit: String, // 单位
pub reference_range: String, // 参考范围
pub is_abnormal: bool, // 是否异常
}
pub struct VitalSignDto {
pub metric: String, // 指标名
pub values: Vec<(String, f64)>, // (日期, 值) 时间序列
pub unit: String,
}
pub struct PatientSummaryDto {
pub age_group: String,
pub sex: String,
pub chronic_conditions: Vec<String>, // 慢性病编码
pub medications: Vec<String>, // 当前用药
pub family_history: Vec<String>, // 家族史
pub last_checkup_date: String,
}
pub struct HealthReportDto {
pub age_group: String,
pub sex: String,
pub department: String,
pub report_date: String,
pub sections: Vec<ReportSectionDto>,
}
pub struct ReportSectionDto {
pub title: String,
pub findings: Vec<String>,
pub abnormal_items: Vec<String>,
}
```
### 3.6 事件定义(遵循现有 DomainEvent 字符串模式)
```rust
// AI 事件使用现有 DomainEvent 结构event_type 为字符串
// 而非独立的 AiEvent enum
// ai.analysis.completed 事件 payload:
// {
// "analysis_id": "uuid",
// "patient_id": "uuid",
// "analysis_type": "lab_report",
// "status": "completed",
// "model_used": "claude-sonnet-4-6"
// }
// ai.analysis.failed 事件 payload:
// {
// "analysis_id": "uuid",
// "patient_id": "uuid",
// "analysis_type": "lab_report",
// "error": "provider timeout"
// }
```
### 3.7 权限码(遵循 .list / .manage 实体命名模式)
```rust
/// 权限码遵循 CLAUDE.md 规范: 每个实体声明 .list + .manage
pub const AI_PERMISSIONS: &[&str] = &[
"ai.analysis.request",
"ai.analysis.view",
"ai.analysis.manage",
"ai.prompt.list",
"ai.prompt.manage",
"ai.usage.view",
"ai.admin.provider",
"ai.analysis.list", // 查看分析历史列表
"ai.analysis.manage", // 请求新分析 / 管理分析结果
"ai.prompt.list", // 查看 Prompt 列表
"ai.prompt.manage", // 创建/编辑/激活/回滚 Prompt
"ai.usage.list", // 查看用量统计
"ai.provider.manage", // 管理提供商配置
];
```
### 3.8 配置结构(遵循现有 AppConfig 模式)
```toml
# config/default.toml 中新增 [ai] 段
[ai]
default_provider = "claude"
cache_ttl_seconds = 604800 # 7 天
rate_limit_patient_daily = 10
rate_limit_admin_hourly = 100
sanitized_input_retention_days = 90 # 脱敏输入保留 90 天
[ai.providers.claude]
api_key_env = "ANTHROPIC_API_KEY" # 从环境变量读取
model = "claude-sonnet-4-6"
max_tokens = 2048
temperature = 0.3
[ai.providers.openai]
enabled = false
api_key_env = "OPENAI_API_KEY"
model = "gpt-4o"
```
```rust
// erp-server/src/config.rs 新增:
#[derive(Debug, Deserialize)]
pub struct AiConfig {
pub default_provider: String,
pub cache_ttl_seconds: u64,
pub rate_limit_patient_daily: u32,
pub rate_limit_admin_hourly: u32,
pub sanitized_input_retention_days: u32,
pub providers: HashMap<String, ProviderConfig>,
}
```
### 3.9 流式依赖
```toml
# erp-ai/Cargo.toml 新增依赖
[dependencies]
futures = "0.3"
tokio-stream = "0.1"
async-stream = "0.3"
# axum 已内置 SSE 支持: axum::response::sse::{Event, Sse}
```
---
## 4. 数据流设计
@@ -211,7 +437,7 @@ data: {"analysis_id": "uuid-xxx", "status": "completed"}
| prompt_version | INT | Prompt 版本号 |
| model_used | VARCHAR(100) | 实际模型 |
| input_data_hash | VARCHAR(64) | SHA-256缓存键 |
| sanitized_input | JSONB | 脱敏输入快照(审计 |
| sanitized_input | JSONB | 脱敏输入快照(AES-256 加密存储90天自动清理 |
| result_content | TEXT | AI 完整输出 |
| result_metadata | JSONB | tokens/耗时/模型信息 |
| status | VARCHAR(20) | pending / streaming / completed / failed |
@@ -250,17 +476,30 @@ data: {"analysis_id": "uuid-xxx", "status": "completed"}
2. **输入验证**: schema 校验,租户隔离校验
3. **Prompt 注入防护**: JSON 序列化注入,不做字符串拼接
4. **输出过滤**: 正则扫描处方药推荐/诊断结论,强制追加免责声明
5. **审计日志**: sanitized_input 保留脱敏快照
5. **审计日志**: sanitized_input AES-256 加密存储90 天自动清理
### 6.3 降级策略
### 6.3 缓存设计
**缓存位置:** Redis
**缓存键:** `ai:cache:{tenant_id}:{analysis_type}:{input_data_hash}:{prompt_version}`
**TTL:** 7 天(可配置 `ai.cache_ttl_seconds`
**Hash 计算:** 对 HealthDataProvider 返回的 DTO 做 canonical JSON 序列化后 SHA-256
**失效策略:** TTL 自然过期Prompt 版本切换后旧缓存键自然不同
```
缓存命中 → 直接返回
缓存未命中 + AI 可用 → 正常 SSE
缓存未命中 + AI 不可用 + 有旧版本 → 返回旧结果 + 标注
缓存命中 (Redis GET) → 直接返回完整 result_content (非流式,秒级)
缓存未命中 + AI 可用 → 正常 SSE 流式分析 → 完成后写入 Redis
缓存未命中 + AI 不可用 + 有旧版本 → 返回旧结果 + 标注"基于历史版本"
缓存未命中 + AI 不可用 + 无历史 → 本地规则引擎50 条常见指标)
```
### 6.4 sanitized_input 数据生命周期
- 写入时: AES-256-GCM 加密后存储为 JSONB密钥从环境变量加载
- 查询时: 仅管理端有权限解密查看,且需 `ai.analysis.manage` 权限
- 清理: 后台定时任务每日扫描,超过 `sanitized_input_retention_days`(默认 90 天)的记录自动清空此字段
- 审计: 清理操作本身记录审计日志
---
## 7. API 设计
@@ -367,13 +606,18 @@ data: {"analysis_id": "uuid-xxx", "status": "completed"}
| 区域 | 文件 | 操作 |
|------|------|------|
| erp-core | `src/trait.rs` | 新增 HealthDataProvider trait |
| erp-core | `src/event.rs` | 新增 ai.* 事件类型 |
| `Cargo.toml` (workspace root) | `[workspace].members` + `[workspace.dependencies]` | 添加 erp-ai |
| erp-core | `src/health_provider.rs` | 新增 HealthDataProvider trait + DTO 定义 |
| erp-core | `src/events.rs` | 新增 ai.analysis.* 事件类型常量 |
| erp-core | `src/permission.rs` | 新增 ai.* 权限码 |
| erp-health | `src/ai_provider.rs` | 新增 HealthDataProvider impl |
| erp-health | `src/health_provider_impl.rs` | 新增 HealthDataProvider trait impl |
| erp-ai | 整个 crate | 新建 |
| erp-server | `src/main.rs` | 注册 AiModule |
| erp-server/migration | 新增 migration | 3 张表 |
| erp-server | `src/main.rs` | 合并 AiModule::public/protected_routes |
| erp-server | `src/state.rs` | 新增 `impl FromRef<AppState> for AiState` |
| erp-server | `src/config.rs` | 新增 AiConfig 结构 |
| erp-server | `config/default.toml` | 新增 `[ai]` 配置段 |
| erp-server/migration | 新增 migration | 3 张表 (ai_prompts, ai_analysis_results, ai_usage_logs) |
| erp-server/Cargo.toml | dependencies | 添加 erp-ai 依赖 |
| apps/miniprogram | 报告详情页 + 解读页 | 新增/修改 |
| apps/web | Prompt 管理页面 | Phase 2 新增 |