docs(ai): 修复 spec 审查问题 — 路由模式/HealthDataProvider/AiState/缓存/权限
修复 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:
@@ -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 新增 |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user