Files
hms/crates/erp-ai/src/module.rs
iven d623f8b2ff fix: V1 测试版本端到端验证修复 — 6 CRITICAL + 3 HIGH 问题全量修复
修复项:
- fix(db): 迁移 149 — 修复 Admin 角色权限绑定被迁移链破坏 (FE-C1)
- fix(health): 4 个 handler 添加空名称验证 — Doctor/Article/AlertRule/Tag (API-C1~C4)
- fix(health): Stats 仪表盘 new_this_week 查询修复 — SeaORM date_trunc bug (FE-C2)
- fix(server): 添加安全响应头 — X-Frame-Options/CSP/XSS-Protection/Referrer-Policy (SEC-H1)
- fix(mp): 预约创建契约修复 — notes/reason 字段映射 + 移除 schedule_id (MP-H1)
- fix(mp): 咨询会话 subject/last_message 字段改为可选 (MP-H3)
- fix(ai): AiConfig Default derive 替代手写 impl (clippy)

测试报告:
- 8 维度端到端测试全部完成 (后端 87 用例 / 前端 30 页面 / 小程序 80+ API / 安全 20 项 / 性能 20 端点)
- 多角色 7 角色 49 检查 100% 通过
- 综合测试报告 + 专家评估报告
2026-05-18 10:24:40 +08:00

536 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use async_trait::async_trait;
use axum::Router;
use erp_core::module::{ErpModule, ModuleType, PermissionDescriptor};
use std::any::Any;
pub struct AiModule;
#[async_trait]
impl ErpModule for AiModule {
fn name(&self) -> &str {
"ai"
}
fn module_type(&self) -> ModuleType {
ModuleType::Builtin
}
fn dependencies(&self) -> Vec<&str> {
vec!["health"]
}
fn permissions(&self) -> Vec<PermissionDescriptor> {
vec![
PermissionDescriptor {
code: "ai.analysis.list".into(),
name: "查看分析历史".into(),
description: "查看 AI 分析结果历史记录".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.analysis.manage".into(),
name: "请求分析".into(),
description: "发起 AI 分析请求".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.prompt.list".into(),
name: "查看 Prompt".into(),
description: "查看 AI Prompt 模板列表".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.prompt.manage".into(),
name: "管理 Prompt".into(),
description: "创建/编辑/激活/回滚 Prompt 模板".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.usage.list".into(),
name: "查看用量".into(),
description: "查看 AI 用量统计".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.provider.manage".into(),
name: "管理提供商".into(),
description: "管理 AI 提供商配置".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.suggestion.list".into(),
name: "查看 AI 建议".into(),
description: "查看 AI 分析生成的建议列表".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.suggestion.manage".into(),
name: "审批 AI 建议".into(),
description: "批准或拒绝 AI 建议".into(),
module: "ai".into(),
},
// Copilot 权限
PermissionDescriptor {
code: "copilot.insights.list".into(),
name: "查看 Copilot 洞察".into(),
description: "查看 Copilot 生成的患者洞察列表".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "copilot.insights.manage".into(),
name: "管理 Copilot 洞察".into(),
description: "处理/忽略 Copilot 洞察".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "copilot.risk.view".into(),
name: "查看风险评分".into(),
description: "查看 Copilot 计算的患者风险评分".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "copilot.rules.list".into(),
name: "查看 Copilot 规则".into(),
description: "查看 Copilot 规则引擎配置".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "copilot.rules.manage".into(),
name: "管理 Copilot 规则".into(),
description: "创建/编辑/删除 Copilot 规则".into(),
module: "ai".into(),
},
// AI 客服会话权限
PermissionDescriptor {
code: "ai.chat.send".into(),
name: "AI 客服对话".into(),
description: "向 AI 客服发送消息".into(),
module: "ai".into(),
},
// AI 配置管理权限
PermissionDescriptor {
code: "ai.config.read".into(),
name: "查看 AI 配置".into(),
description: "查看 AI 模型和参数配置".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.config.manage".into(),
name: "管理 AI 配置".into(),
description: "修改 AI 模型、温度、Token 等参数配置".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.chat.session.list".into(),
name: "查看 AI 会话列表".into(),
description: "查看用户的 AI 客服会话列表".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.chat.session.manage".into(),
name: "管理 AI 会话".into(),
description: "创建/关闭 AI 客服会话".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.chat.session.history".into(),
name: "查看 AI 会话历史".into(),
description: "查看 AI 客服会话消息历史".into(),
module: "ai".into(),
},
]
}
fn as_any(&self) -> &dyn Any {
self
}
async fn on_startup(
&self,
ctx: &erp_core::module::ModuleContext,
) -> erp_core::error::AppResult<()> {
// 订阅 ai.* 前缀的所有事件reanalysis + analysis.requested
let (mut rx, _handle) = ctx.event_bus.subscribe_filtered("ai.".to_string());
let db = ctx.db.clone();
tokio::spawn(async move {
loop {
match rx.recv().await {
Some(event) if event.event_type == "ai.reanalysis.requested" => {
let suggestion_id = event
.payload
.get("original_suggestion_id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
let patient_id = event
.payload
.get("patient_id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
match (suggestion_id, patient_id) {
(Some(sid), Some(pid)) => {
if let Err(e) =
crate::service::reanalysis::handle_reanalysis_requested(
&db,
event.tenant_id,
sid,
pid,
)
.await
{
tracing::warn!(
suggestion_id = %sid,
error = %e,
"AI 再分析处理失败"
);
}
}
_ => {
tracing::warn!("ai.reanalysis.requested 事件缺少必要字段");
}
}
}
Some(event) if event.event_type == "ai.analysis.requested" => {
let source_type = event.payload.get("source_type").and_then(|v| v.as_str());
let source_id = event
.payload
.get("source_id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
let patient_id = event
.payload
.get("patient_id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
tracing::info!(
source_type = ?source_type,
source_id = ?source_id,
patient_id = ?patient_id,
tenant_id = %event.tenant_id,
"收到 AI 分析请求事件"
);
}
// H4: 透析记录→KDIGO 自动风险评估
Some(event) if event.event_type == "ai.dialysis.kdigo_requested" => {
let patient_id = event
.payload
.get("patient_id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
let record_id = event
.payload
.get("dialysis_record_id")
.and_then(|v| v.as_str());
tracing::info!(
patient_id = ?patient_id,
record_id = ?record_id,
tenant_id = %event.tenant_id,
"透析→KDIGO 自动评估触发"
);
}
Some(event) => {
tracing::debug!(
event_type = %event.event_type,
"忽略非目标 AI 事件"
);
}
None => {
tracing::info!("AI 事件订阅通道已关闭");
break;
}
}
}
});
// 订阅 erp-health 事件 → 自动入队分析
let (mut health_rx, _) = ctx.event_bus.subscribe_filtered("health_data.".to_string());
let (mut lab_rx, _) = ctx.event_bus.subscribe_filtered("lab_report.".to_string());
let (mut dialysis_rx, _) = ctx.event_bus.subscribe_filtered("dialysis.".to_string());
let queue_db = ctx.db.clone();
tokio::spawn(async move {
let queue = crate::service::analysis_queue::AnalysisQueue::new(queue_db);
loop {
tokio::select! {
event = health_rx.recv() => {
match event {
Some(e) if e.event_type == "health_data.critical_alert" => {
if let Some(pid) = e.payload.get("patient_id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok())
{
let job = crate::service::analysis_queue::AnalysisJob {
tenant_id: e.tenant_id,
patient_id: pid,
analysis_type: "trend".into(),
priority: 2,
source_event: Some(e.event_type.clone()),
source_ref: "event".into(),
created_by: None,
};
if let Err(err) = queue.enqueue(job).await {
tracing::warn!(error = %err, "健康告警→分析入队失败");
} else {
tracing::info!(tenant = %e.tenant_id, patient = %pid, "健康告警→趋势分析已入队");
}
}
}
Some(e) => {
tracing::debug!(event_type = %e.event_type, "忽略非目标 health_data 事件");
}
None => return,
}
}
event = lab_rx.recv() => {
match event {
Some(e) if e.event_type == "lab_report.uploaded" => {
if let Some(pid) = e.payload.get("patient_id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok())
{
let job = crate::service::analysis_queue::AnalysisJob {
tenant_id: e.tenant_id,
patient_id: pid,
analysis_type: "lab_report".into(),
priority: 1,
source_event: Some(e.event_type.clone()),
source_ref: "event".into(),
created_by: None,
};
if let Err(err) = queue.enqueue(job).await {
tracing::warn!(error = %err, "化验单上传→分析入队失败");
} else {
tracing::info!(tenant = %e.tenant_id, patient = %pid, "化验单上传→解读分析已入队");
}
}
}
Some(e) => {
tracing::debug!(event_type = %e.event_type, "忽略非目标 lab_report 事件");
}
None => return,
}
}
event = dialysis_rx.recv() => {
match event {
Some(e) if e.event_type == "dialysis.record.created" => {
if let Some(pid) = e.payload.get("patient_id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok())
{
let job = crate::service::analysis_queue::AnalysisJob {
tenant_id: e.tenant_id,
patient_id: pid,
analysis_type: "dialysis_risk".into(),
priority: 2,
source_event: Some(e.event_type.clone()),
source_ref: "event".into(),
created_by: None,
};
if let Err(err) = queue.enqueue(job).await {
tracing::warn!(error = %err, "透析记录→KDIGO入队失败");
} else {
tracing::info!(tenant = %e.tenant_id, patient = %pid, "透析记录→KDIGO风险评估已入队");
}
}
}
Some(e) => {
tracing::debug!(event_type = %e.event_type, "忽略非目标 dialysis 事件");
}
None => return,
}
}
}
}
});
// Copilot 事件消费者 — 订阅 health 事件触发风险评分刷新
let copilot_handles = crate::event::copilot_consumer::spawn(&ctx.db, &ctx.event_bus);
std::mem::forget(copilot_handles);
// 每日凌晨 2:00 批量刷新所有在管患者风险快照
let refresh_db = ctx.db.clone();
tokio::spawn(async move {
// 首次执行延迟到下一个凌晨 2:00简单实现延迟 6 小时后开始 24h 周期)
tokio::time::sleep(std::time::Duration::from_secs(6 * 3600)).await;
let mut interval = tokio::time::interval(std::time::Duration::from_secs(86400));
loop {
interval.tick().await;
match crate::service::risk_service::RiskService::refresh_all_patients(&refresh_db)
.await
{
Ok(count) => {
tracing::info!(patient_count = count, "每日风险快照刷新完成");
}
Err(e) => {
tracing::warn!(error = %e, "每日风险快照刷新失败");
}
}
}
});
tracing::info!(
module = "ai",
"AI 模块事件处理器已注册(监听 ai.* 事件 + Copilot 事件)"
);
Ok(())
}
}
impl AiModule {
pub fn public_routes<S>() -> Router<S>
where
crate::state::AiState: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new()
}
pub fn protected_routes<S>() -> Router<S>
where
crate::state::AiState: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new()
.route(
"/ai/chat",
axum::routing::post(crate::handler::chat_handler::chat),
)
.route(
"/ai/config",
axum::routing::get(crate::handler::config_handler::get_config),
)
.route(
"/ai/config",
axum::routing::put(crate::handler::config_handler::update_config),
)
.route(
"/ai/config/defaults",
axum::routing::get(crate::handler::config_handler::get_config_defaults),
)
.route(
"/ai/analyze/lab-report",
axum::routing::post(crate::handler::stream_lab_report),
)
.route(
"/ai/analyze/trends",
axum::routing::post(crate::handler::stream_trends),
)
.route(
"/ai/analyze/checkup-plan",
axum::routing::post(crate::handler::stream_checkup_plan),
)
.route(
"/ai/analyze/report-summary",
axum::routing::post(crate::handler::stream_report_summary),
)
.route(
"/ai/analysis/history",
axum::routing::get(crate::handler::list_analysis),
)
.route(
"/ai/analysis/{id}",
axum::routing::get(crate::handler::get_analysis),
)
.route(
"/ai/prompts",
axum::routing::get(crate::handler::list_prompts),
)
.route(
"/ai/prompts",
axum::routing::post(crate::handler::create_prompt),
)
.route(
"/ai/prompts/{id}/activate",
axum::routing::post(crate::handler::activate_prompt),
)
.route(
"/ai/prompts/{id}/rollback",
axum::routing::post(crate::handler::rollback_prompt),
)
.route(
"/ai/usage/overview",
axum::routing::get(crate::handler::usage_overview),
)
.route(
"/ai/usage/by-type",
axum::routing::get(crate::handler::usage_by_type),
)
.route(
"/ai/suggestions",
axum::routing::get(crate::handler::suggestion_handler::list_suggestions),
)
.route(
"/ai/suggestions/{id}/approve",
axum::routing::post(crate::handler::suggestion_handler::approve_suggestion),
)
.route(
"/ai/suggestions/{id}/execute",
axum::routing::post(crate::handler::suggestion_handler::execute_suggestion),
)
.route(
"/ai/suggestions/{id}/comparison",
axum::routing::get(crate::handler::suggestion_handler::get_comparison),
)
.route(
"/ai/dialysis/risk-assessment",
axum::routing::post(crate::handler::assess_dialysis_risk),
)
.route(
"/ai/providers/health",
axum::routing::get(crate::handler::provider_health),
)
.route(
"/ai/providers",
axum::routing::get(crate::handler::provider_names),
)
.route(
"/ai/quota/summary",
axum::routing::get(crate::handler::quota_summary),
)
.route(
"/ai/budget/status",
axum::routing::get(crate::handler::budget_status),
)
.route(
"/ai/cost/estimate",
axum::routing::get(crate::handler::cost_estimate),
)
// Copilot 路由
.route(
"/copilot/insights",
axum::routing::get(crate::handler::insight_handler::list_insights),
)
.route(
"/copilot/insights/{id}",
axum::routing::get(crate::handler::insight_handler::get_insight),
)
.route(
"/copilot/insights/{id}/dismiss",
axum::routing::post(crate::handler::insight_handler::dismiss_insight),
)
.route(
"/copilot/patients/{id}/risk",
axum::routing::get(crate::handler::risk_handler::get_patient_risk),
)
.route(
"/copilot/rules",
axum::routing::get(crate::handler::rule_handler::list_rules),
)
.route(
"/copilot/rules",
axum::routing::post(crate::handler::rule_handler::create_rule),
)
.route(
"/copilot/rules/{id}",
axum::routing::put(crate::handler::rule_handler::update_rule),
)
.route(
"/copilot/rules/{id}",
axum::routing::delete(crate::handler::rule_handler::delete_rule),
)
}
}