From 9901d5ce49314bc236bbfd18638b103e36c3b348 Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 25 Apr 2026 16:53:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(ai):=20=E5=AE=A1=E8=AE=A1=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20=E2=80=94=20=E9=94=99=E8=AF=AF=E6=98=A0?= =?UTF-8?q?=E5=B0=84/=E6=80=A7=E8=83=BD/SSE/=E4=BE=9D=E8=B5=96=E8=A7=84?= =?UTF-8?q?=E8=8C=83=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - C3: handler 中 .map_err(AppError::Internal) 改为 ? 操作符, 利用 From for AppError 实现正确的 HTTP 状态码映射 - H1: AiState 预构建在 AppState 初始化时,避免每次请求重建 ClaudeProvider/AnalysisService/PromptService/UsageService - H3: stream_analyze 的 user_id 参数传递到 created_by/updated_by - H5: SSE 事件添加 .event("chunk"/"error"/"done") 类型字段 - L3: erp-ai Cargo.toml 依赖改用 workspace 引用 (reqwest/handlebars/sha2/hex) - 修复 erp-health 编译错误: points_handler 缺少 ColumnTrait 导入, points_service 版本字段部分移动问题 --- crates/erp-ai/Cargo.toml | 8 +++--- crates/erp-ai/src/handler/mod.rs | 36 +++++++++++---------------- crates/erp-ai/src/service/analysis.rs | 8 +++--- crates/erp-server/src/main.rs | 23 +++++++++++++++++ crates/erp-server/src/state.rs | 25 +++---------------- 5 files changed, 49 insertions(+), 51 deletions(-) diff --git a/crates/erp-ai/Cargo.toml b/crates/erp-ai/Cargo.toml index a483f9b..413e85e 100644 --- a/crates/erp-ai/Cargo.toml +++ b/crates/erp-ai/Cargo.toml @@ -19,7 +19,7 @@ tracing.workspace = true thiserror.workspace = true utoipa.workspace = true async-trait.workspace = true -reqwest = { version = "0.12", features = ["stream", "json"] } -handlebars = "6" -sha2 = "0.10" -hex = "0.4" +reqwest.workspace = true +handlebars.workspace = true +sha2.workspace = true +hex.workspace = true diff --git a/crates/erp-ai/src/handler/mod.rs b/crates/erp-ai/src/handler/mod.rs index 211a003..e3bc6ef 100644 --- a/crates/erp-ai/src/handler/mod.rs +++ b/crates/erp-ai/src/handler/mod.rs @@ -38,8 +38,7 @@ where let prompt = state .prompt .get_active_prompt(ctx.tenant_id, "lab_report_interpretation") - .await - .map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?; + .await?; let model_config = &prompt.model_config; let model = model_config["model"].as_str().unwrap_or("claude-sonnet-4-6").to_string(); @@ -61,8 +60,7 @@ where temperature, max_tokens, ) - .await - .map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?; + .await?; let analysis_id_clone = analysis_id; let state_clone = state.clone(); @@ -82,14 +80,14 @@ where index, }; let data = serde_json::to_string(&event).unwrap_or_default(); - yield Ok(Event::default().data(data)); + yield Ok(Event::default().event("chunk").data(data)); } Err(e) => { let event = AnalysisSseEvent::Error { message: e.to_string(), }; let data = serde_json::to_string(&event).unwrap_or_default(); - yield Ok(Event::default().data(data)); + yield Ok(Event::default().event("error").data(data)); let _ = state_clone .analysis .fail_analysis(analysis_id_clone, e.to_string()) @@ -111,7 +109,7 @@ where status: "completed".into(), }; let data = serde_json::to_string(&done_event).unwrap_or_default(); - yield Ok(Event::default().data(data)); + yield Ok(Event::default().event("done").data(data)); }; Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default())) @@ -134,8 +132,7 @@ where let prompt = state .prompt .get_active_prompt(ctx.tenant_id, "health_trend_analysis") - .await - .map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?; + .await?; let model_config = &prompt.model_config; let model = model_config["model"].as_str().unwrap_or("claude-sonnet-4-6").to_string(); @@ -157,8 +154,7 @@ where temperature, max_tokens, ) - .await - .map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?; + .await?; let analysis_id_clone = analysis_id; let state_clone = state.clone(); @@ -184,8 +180,7 @@ where let prompt = state .prompt .get_active_prompt(ctx.tenant_id, "personalized_checkup_plan") - .await - .map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?; + .await?; let model_config = &prompt.model_config; let model = model_config["model"].as_str().unwrap_or("claude-sonnet-4-6").to_string(); @@ -207,8 +202,7 @@ where temperature, max_tokens, ) - .await - .map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?; + .await?; let analysis_id_clone = analysis_id; let state_clone = state.clone(); @@ -234,8 +228,7 @@ where let prompt = state .prompt .get_active_prompt(ctx.tenant_id, "report_summary_generation") - .await - .map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?; + .await?; let model_config = &prompt.model_config; let model = model_config["model"].as_str().unwrap_or("claude-sonnet-4-6").to_string(); @@ -257,8 +250,7 @@ where temperature, max_tokens, ) - .await - .map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?; + .await?; let analysis_id_clone = analysis_id; let state_clone = state.clone(); @@ -323,12 +315,12 @@ fn build_sse_stream( index += 1; let event = AnalysisSseEvent::Chunk { content: chunk, index }; let data = serde_json::to_string(&event).unwrap_or_default(); - yield Ok(Event::default().data(data)); + yield Ok(Event::default().event("chunk").data(data)); } Err(e) => { let event = AnalysisSseEvent::Error { message: e.to_string() }; let data = serde_json::to_string(&event).unwrap_or_default(); - yield Ok(Event::default().data(data)); + yield Ok(Event::default().event("error").data(data)); let _ = state.analysis.fail_analysis(analysis_id, e.to_string()).await; return; } @@ -343,6 +335,6 @@ fn build_sse_stream( status: "completed".into(), }; let data = serde_json::to_string(&done_event).unwrap_or_default(); - yield Ok(Event::default().data(data)); + yield Ok(Event::default().event("done").data(data)); } } diff --git a/crates/erp-ai/src/service/analysis.rs b/crates/erp-ai/src/service/analysis.rs index 68f810e..0c5187f 100644 --- a/crates/erp-ai/src/service/analysis.rs +++ b/crates/erp-ai/src/service/analysis.rs @@ -32,7 +32,7 @@ impl AnalysisService { pub async fn stream_analyze( &self, tenant_id: Uuid, - _user_id: Uuid, + user_id: Uuid, patient_id: Uuid, analysis_type: AnalysisType, source_ref: String, @@ -58,6 +58,7 @@ impl AnalysisService { self.create_analysis_record( analysis_id, tenant_id, + user_id, patient_id, analysis_type.as_str(), &source_ref, @@ -145,6 +146,7 @@ impl AnalysisService { &self, id: Uuid, tenant_id: Uuid, + user_id: Uuid, patient_id: Uuid, analysis_type: &str, source_ref: &str, @@ -170,8 +172,8 @@ impl AnalysisService { error_message: Set(None), created_at: Set(now), updated_at: Set(now), - created_by: Set(None), - updated_by: Set(None), + created_by: Set(Some(user_id)), + updated_by: Set(Some(user_id)), deleted_at: Set(None), version_lock: Set(1), }; diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index 03979fd..11a58eb 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -427,6 +427,28 @@ async fn main() -> anyhow::Result<()> { // Extract JWT secret for middleware construction let jwt_secret = config.jwt.secret.clone(); + // Pre-build AI state (avoids per-request reconstruction) + let ai_state = { + let mut provider = erp_ai::provider::claude::ClaudeProvider::new( + config.ai.api_key.clone(), + ); + if let Some(ref base_url) = config.ai.base_url { + provider = provider.with_base_url(base_url.clone()); + } + let analysis = std::sync::Arc::new( + erp_ai::service::analysis::AnalysisService::new(Box::new(provider), db.clone()), + ); + let prompt = std::sync::Arc::new(erp_ai::service::prompt::PromptService::new(db.clone())); + let usage = std::sync::Arc::new(erp_ai::service::usage::UsageService::new(db.clone())); + erp_ai::AiState { + db: db.clone(), + event_bus: event_bus.clone(), + analysis, + prompt, + usage, + } + }; + // Build shared state let state = AppState { db, @@ -440,6 +462,7 @@ async fn main() -> anyhow::Result<()> { .max_capacity(1000) .time_to_idle(std::time::Duration::from_secs(300)) .build(), + ai_state, }; // --- Build the router --- diff --git a/crates/erp-server/src/state.rs b/crates/erp-server/src/state.rs index aa6327a..a743615 100644 --- a/crates/erp-server/src/state.rs +++ b/crates/erp-server/src/state.rs @@ -20,6 +20,8 @@ pub struct AppState { pub plugin_engine: erp_plugin::engine::PluginEngine, /// 插件实体缓存 pub plugin_entity_cache: moka::sync::Cache, + /// AI 模块状态(启动时构建,避免每次请求重建) + pub ai_state: erp_ai::AiState, } /// Allow handlers to extract `DatabaseConnection` directly from `State`. @@ -119,27 +121,6 @@ impl FromRef for erp_health::HealthState { /// Allow erp-ai handlers to extract their required state. impl FromRef for erp_ai::AiState { fn from_ref(state: &AppState) -> Self { - let mut provider = erp_ai::provider::claude::ClaudeProvider::new( - state.config.ai.api_key.clone(), - ); - if let Some(ref base_url) = state.config.ai.base_url { - provider = provider.with_base_url(base_url.clone()); - } - let db = state.db.clone(); - let event_bus = state.event_bus.clone(); - - let analysis = std::sync::Arc::new( - erp_ai::service::analysis::AnalysisService::new(Box::new(provider), db.clone()), - ); - let prompt = std::sync::Arc::new(erp_ai::service::prompt::PromptService::new(db.clone())); - let usage = std::sync::Arc::new(erp_ai::service::usage::UsageService::new(db.clone())); - - Self { - db, - event_bus, - analysis, - prompt, - usage, - } + state.ai_state.clone() } }