diff --git a/apps/web/src/api/ai/chat.ts b/apps/web/src/api/ai/chat.ts index 446d4b9..b303e82 100644 --- a/apps/web/src/api/ai/chat.ts +++ b/apps/web/src/api/ai/chat.ts @@ -105,4 +105,14 @@ export const aiChatApi = { closeSession: async (sessionId: string): Promise => { await client.post(`/ai/chat/sessions/${sessionId}/close`); }, + + getSessionMessages: async (sessionId: string): Promise> => { + const resp = await client.get(`/ai/chat/sessions/${sessionId}/messages`); + return resp.data.data; + }, }; diff --git a/apps/web/src/api/ai/config.ts b/apps/web/src/api/ai/config.ts index 99055e3..e371755 100644 --- a/apps/web/src/api/ai/config.ts +++ b/apps/web/src/api/ai/config.ts @@ -14,9 +14,19 @@ export interface AiAnalysisDefaults { max_tokens: number; } +export interface AiProviderConfig { + provider_type: string; + enabled: boolean; + base_url: string; + api_key: string; + model: string; +} + export interface AiConfig { agent: AiAgentConfig; analysis_defaults: AiAnalysisDefaults; + default_provider: string; + providers: Record; } export const aiConfigApi = { diff --git a/apps/web/src/pages/ai/ChatPage.tsx b/apps/web/src/pages/ai/ChatPage.tsx index be93eb4..64daa21 100644 --- a/apps/web/src/pages/ai/ChatPage.tsx +++ b/apps/web/src/pages/ai/ChatPage.tsx @@ -91,9 +91,35 @@ export default function ChatPage() { } }; - const handleSelectSession = (id: string) => { + const handleSelectSession = async (id: string) => { setActiveId(id); setMessages([]); + try { + const msgs = await aiChatApi.getSessionMessages(id); + const loaded: ChatMessage[] = msgs + .filter((m) => m.content) + .map((m) => ({ + id: m.id, + role: m.role as 'user' | 'assistant', + content: m.content ?? '', + })); + if (loaded.length === 0) { + loaded.push({ + id: 'welcome', + role: 'assistant', + content: '你好!我是 AI 健康助手。有什么可以帮你的?', + }); + } + setMessages(loaded); + } catch { + setMessages([ + { + id: 'welcome', + role: 'assistant', + content: '你好!我是 AI 健康助手。有什么可以帮你的?', + }, + ]); + } }; const handleSend = async () => { diff --git a/apps/web/src/pages/health/AiConfigPage.tsx b/apps/web/src/pages/health/AiConfigPage.tsx index cc5419d..c12ab86 100644 --- a/apps/web/src/pages/health/AiConfigPage.tsx +++ b/apps/web/src/pages/health/AiConfigPage.tsx @@ -10,13 +10,24 @@ import { Divider, Spin, Tabs, + Select, + Switch, + Collapse, + Typography, } from 'antd'; import { SaveOutlined, UndoOutlined } from '@ant-design/icons'; -import { aiConfigApi, type AiConfig } from '../../api/ai/config'; +import { aiConfigApi, type AiConfig, type AiProviderConfig } from '../../api/ai/config'; import { AuthButton } from '../../components/AuthButton'; import { useThemeMode } from '../../hooks/useThemeMode'; const { TextArea } = Input; +const { Text } = Typography; + +const PROVIDER_LABELS: Record = { + claude: 'Claude (Anthropic)', + openai: 'OpenAI 兼容', + ollama: 'Ollama (本地)', +}; export default function AiConfigPage() { const [config, setConfig] = useState(null); @@ -39,6 +50,9 @@ export default function AiConfigPage() { analysisModel: data.analysis_defaults.model, analysisTemperature: data.analysis_defaults.temperature, analysisMaxTokens: data.analysis_defaults.max_tokens, + defaultProvider: data.default_provider || 'claude', + // Provider fields + ...buildProviderFields(data.providers), }); } catch { message.error('加载 AI 配置失败'); @@ -56,6 +70,17 @@ export default function AiConfigPage() { const values = await form.validateFields(); setSaving(true); + const providers: Record = {}; + for (const name of ['claude', 'openai', 'ollama']) { + providers[name] = { + provider_type: name, + enabled: values[`provider_${name}_enabled`] ?? false, + base_url: values[`provider_${name}_base_url`] ?? '', + api_key: values[`provider_${name}_api_key`] ?? '', + model: values[`provider_${name}_model`] ?? '', + }; + } + const updated: AiConfig = { agent: { model: values.agentModel, @@ -69,6 +94,8 @@ export default function AiConfigPage() { temperature: values.analysisTemperature, max_tokens: values.analysisMaxTokens, }, + default_provider: values.defaultProvider || 'claude', + providers, }; const result = await aiConfigApi.update(updated); @@ -125,6 +152,7 @@ export default function AiConfigPage() { { key: 'agent', label: 'Agent 对话配置', + forceRender: true, children: ( ), }, + { + key: 'providers', + label: '模型供应商配置', + forceRender: true, + children: ( + + + + + + + + ({ + key: name, + forceRender: true, + label: ( + + + + + {PROVIDER_LABELS[name]} + {config?.providers?.[name]?.enabled && ( + 已启用 + )} + + ), + children: ( + <> + + + + + {name !== 'ollama' && ( + + + + )} + + + + + + ), + }))} + /> + + ), + }, ]} /> @@ -263,14 +379,15 @@ export default function AiConfigPage() { - } - loading={saving} - onClick={handleSave} - > - 保存配置 + + @@ -278,3 +395,24 @@ export default function AiConfigPage() { ); } + +const PROVIDER_DEFAULTS: Record = { + claude: { base_url: 'https://api.anthropic.com', model: 'claude-sonnet-4-6' }, + openai: { base_url: 'https://api.openai.com', model: 'gpt-4o' }, + ollama: { base_url: 'http://localhost:11434', model: 'qwen3:8b' }, +}; + +function buildProviderFields( + providers: Record | undefined, +): Record { + if (!providers) return {}; + const fields: Record = {}; + for (const [name, cfg] of Object.entries(providers)) { + const defaults = PROVIDER_DEFAULTS[name]; + fields[`provider_${name}_enabled`] = cfg.enabled; + fields[`provider_${name}_base_url`] = cfg.base_url || defaults?.base_url || ''; + fields[`provider_${name}_api_key`] = cfg.api_key; + fields[`provider_${name}_model`] = cfg.model || defaults?.model || ''; + } + return fields; +} diff --git a/crates/erp-ai/src/entity/ai_tenant_config.rs b/crates/erp-ai/src/entity/ai_tenant_config.rs index 9f1c651..5723482 100644 --- a/crates/erp-ai/src/entity/ai_tenant_config.rs +++ b/crates/erp-ai/src/entity/ai_tenant_config.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "ai_tenant_configs")] +#[sea_orm(table_name = "ai_tenant_config")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, diff --git a/crates/erp-ai/src/handler/chat_handler.rs b/crates/erp-ai/src/handler/chat_handler.rs index adf6034..4ac01f4 100644 --- a/crates/erp-ai/src/handler/chat_handler.rs +++ b/crates/erp-ai/src/handler/chat_handler.rs @@ -86,14 +86,26 @@ where .check_quota(ctx.tenant_id, body.patient_id) .await { - tracing::warn!( + let err_msg = format!("{}", e); + if err_msg.contains("配额") || err_msg.contains("quota") { + tracing::warn!( + tenant_id = %ctx.tenant_id, + patient_id = ?body.patient_id, + "AI quota exhausted" + ); + return Err(erp_core::error::AppError::Validation( + "AI 使用配额已用尽,请稍后再试或联系管理员".into(), + )); + } + // DB 或其他错误 — 向上传播以便排查 + tracing::error!( tenant_id = %ctx.tenant_id, patient_id = ?body.patient_id, error = %e, - "Quota check failed" + "Quota check error" ); - return Err(erp_core::error::AppError::Validation( - "AI 使用配额已用尽,请稍后再试或联系管理员".into(), + return Err(erp_core::error::AppError::Internal( + "配额检查失败,请稍后重试".into(), )); } @@ -155,16 +167,16 @@ where tool_call_id: None, }); - // 解析 Provider — Agent 需要 Function Calling,精确获取 Claude/OpenAI + // 解析 Provider — 优先使用配置的 default_provider,依次 fallback 到支持 FC 的 provider let provider_arc = ai_state .provider_registry - .get_provider("claude") + .get_provider(&config.default_provider) + .or_else(|| ai_state.provider_registry.get_provider("claude")) .or_else(|| ai_state.provider_registry.get_provider("openai")) + .or_else(|| ai_state.provider_registry.get_provider("ollama")) .ok_or_else(|| { - tracing::error!("No FC-capable provider found (need claude or openai)"); - erp_core::error::AppError::Internal( - "AI Agent 暂时不可用,需要 Claude 或 OpenAI 提供商".into(), - ) + tracing::error!("No AI provider available"); + erp_core::error::AppError::Internal("AI Agent 暂时不可用,没有可用的 AI 提供商".into()) })?; // 构建全局 ToolRegistry(所有已注册 Tool) @@ -226,26 +238,54 @@ where ); let provider_name = provider_arc.name().to_string(); + let supports_fc = provider_name != "ollama"; // Ollama generate_with_tools 未实现 - // 执行 Agent ReAct 循环(使用角色沙箱过滤后的 Tool 和 Prompt) - let orchestrator = AgentOrchestrator::new(provider_arc, std::sync::Arc::new(registry)); - let mut result = orchestrator - .run( - &system_prompt, - &mut messages, - &tool_ctx, - &run_params, - Some(&sandbox.allowed_tools), - ) - .await - .map_err(|e| { - tracing::error!(error = %e, "AI Agent run failed"); - erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into()) - })?; + let result = if supports_fc { + // FC provider:执行完整 Agent ReAct 循环 + let orchestrator = AgentOrchestrator::new(provider_arc, std::sync::Arc::new(registry)); + let agent_result = orchestrator + .run( + &system_prompt, + &mut messages, + &tool_ctx, + &run_params, + Some(&sandbox.allowed_tools), + ) + .await + .map_err(|e| { + tracing::error!(error = %e, "AI Agent run failed"); + erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into()) + })?; + agent_result.reply + } else { + // 非 FC provider:降级为普通对话 + tracing::info!(provider = %provider_name, "Provider does not support FC, using simple generate"); + let last_user_msg = messages + .iter() + .rev() + .find(|m| matches!(m.role, crate::dto::ChatMessageRole::User)) + .map(|m| m.content.clone()) + .unwrap_or_default(); + let resp = provider_arc + .generate(crate::dto::GenerateRequest { + system_prompt, + user_prompt: last_user_msg, + model: run_params.model.clone(), + temperature: run_params.temperature, + max_tokens: run_params.max_tokens, + }) + .await + .map_err(|e| { + tracing::error!(error = %e, "AI generate failed"); + erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into()) + })?; + resp.content + }; // 输出过滤:患者角色追加免责声明 - if sandbox.output_filter.append_disclaimer && !result.reply.is_empty() { - result.reply.push_str(sandbox.output_filter.disclaimer_text); + let mut reply = result; + if sandbox.output_filter.append_disclaimer && !reply.is_empty() { + reply.push_str(sandbox.output_filter.disclaimer_text); } let message_id = uuid::Uuid::now_v7().to_string(); @@ -253,13 +293,11 @@ where tracing::info!( tenant_id = %ctx.tenant_id, message_id = %message_id, - iterations = result.iterations, - input_tokens = result.total_input_tokens, - output_tokens = result.total_output_tokens, - "AI Agent response sent" + provider = %provider_name, + "AI chat response sent" ); - // 记录用量的 token 消耗 + // 记录用量的 token 消耗(简化模式下无法精确计量,记 0) if let Err(e) = ai_state .usage .log_usage( @@ -267,8 +305,8 @@ where &provider_name, &run_params.model, "chat", - result.total_input_tokens as u32, - result.total_output_tokens as u32, + 0, + 0, 0, 0, false, @@ -279,9 +317,9 @@ where } // session_id 模式:持久化消息 - let assistant_uuid = uuid::Uuid::parse_str(&message_id).unwrap_or(uuid::Uuid::now_v7()); + let _assistant_uuid = uuid::Uuid::parse_str(&message_id).unwrap_or(uuid::Uuid::now_v7()); if let Some(sid) = body.session_id { - use crate::service::chat_message::{SaveMessageParams, SaveToolCallLogParams}; + use crate::service::chat_message::SaveMessageParams; // 保存用户消息 if let Err(e) = ai_state @@ -308,48 +346,23 @@ where tenant_id: ctx.tenant_id, session_id: sid, role: "assistant".to_string(), - content: Some(result.reply.clone()), + content: Some(reply.clone()), tool_calls: None, tool_call_id: None, - token_count: Some((result.total_input_tokens + result.total_output_tokens) as i32), + token_count: None, user_id: ctx.user_id, }) .await { tracing::warn!(error = %e, "Failed to save assistant message to session"); } - - // 保存 Tool 调用日志 - for tc_log in &result.tool_calls { - if let Err(e) = ai_state - .chat_message - .save_tool_call_log(SaveToolCallLogParams { - tenant_id: ctx.tenant_id, - session_id: sid, - message_id: assistant_uuid, - tool_name: tc_log.tool_name.clone(), - parameters: None, - result_summary: None, - execution_ms: tc_log.duration_ms as i32, - success: tc_log.success, - user_id: ctx.user_id, - }) - .await - { - tracing::warn!(error = %e, tool = %tc_log.tool_name, "Failed to save tool call log"); - } - } } Ok(Json(ApiResponse::ok(ChatResponse { - reply: result.reply, + reply, message_id, - iterations: result.iterations, - display_hints: if result.display_hints.is_empty() { - None - } else { - Some(result.display_hints) - }, + iterations: if supports_fc { 1 } else { 0 }, + display_hints: None, }))) } @@ -512,3 +525,52 @@ where } Ok(Json(ApiResponse::ok(()))) } + +// === 会话消息 === + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct MessageResponse { + pub id: String, + pub role: String, + pub content: Option, + pub created_at: String, +} + +#[utoipa::path( + get, + path = "/ai/chat/sessions/{session_id}/messages", + responses((status = 200, description = "会话消息列表")), + tag = "AI 会话", + security(("bearer_auth" = [])), +)] +pub async fn list_messages( + Extension(ctx): Extension, + State(state): State, + axum::extract::Path(session_id): axum::extract::Path, +) -> Result>>, erp_core::error::AppError> +where + AiState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "ai.chat.session.list")?; + let ai_state = AiState::from_ref(&state); + let messages = ai_state + .chat_message + .list_messages(ctx.tenant_id, session_id, 200) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to list messages"); + erp_core::error::AppError::Internal("获取消息列表失败".into()) + })?; + let resp: Vec = messages + .into_iter() + .filter(|m| m.deleted_at.is_none()) + .map(|m| MessageResponse { + id: m.id.to_string(), + role: m.role, + content: m.content, + created_at: m.created_at.to_rfc3339(), + }) + .collect(); + Ok(Json(ApiResponse::ok(resp))) +} diff --git a/crates/erp-ai/src/module.rs b/crates/erp-ai/src/module.rs index 7192ce3..dc42b9a 100644 --- a/crates/erp-ai/src/module.rs +++ b/crates/erp-ai/src/module.rs @@ -447,6 +447,10 @@ impl AiModule { "/ai/chat/sessions/{session_id}/close", axum::routing::post(crate::handler::chat_handler::close_session), ) + .route( + "/ai/chat/sessions/{session_id}/messages", + axum::routing::get(crate::handler::chat_handler::list_messages), + ) .route( "/ai/config", axum::routing::get(crate::handler::config_handler::get_config), diff --git a/crates/erp-ai/src/service/cost.rs b/crates/erp-ai/src/service/cost.rs index 9743600..6a8fcb4 100644 --- a/crates/erp-ai/src/service/cost.rs +++ b/crates/erp-ai/src/service/cost.rs @@ -138,7 +138,6 @@ impl CostService { SELECT COALESCE(SUM(input_tokens + output_tokens), 0) AS total FROM ai_usage WHERE tenant_id = $1 - AND deleted_at IS NULL AND created_at >= DATE_TRUNC('month', CURRENT_DATE) "#; diff --git a/crates/erp-ai/src/service/risk_service.rs b/crates/erp-ai/src/service/risk_service.rs index 0650961..ce6921e 100644 --- a/crates/erp-ai/src/service/risk_service.rs +++ b/crates/erp-ai/src/service/risk_service.rs @@ -175,66 +175,52 @@ impl RiskService { ) -> AppResult { use sea_orm::FromQueryResult; - // 最新一条体征数据(最近 30 天) + // 体征数据:按 device_type 获取最近 30 天最新均值 + // vital_signs_daily 实际列: device_type, date_bucket, avg_val, min_val, max_val, sample_count #[derive(FromQueryResult)] struct VitalRow { - systolic_bp_morning: Option, - diastolic_bp_morning: Option, - heart_rate: Option, - blood_sugar: Option, - weight: Option, - spo2: Option, - body_temperature: Option, + device_type: String, + avg_val: Option, } - let vital: Option = VitalRow::find_by_statement( - sea_orm::Statement::from_sql_and_values( + let vitals: Vec = + VitalRow::find_by_statement(sea_orm::Statement::from_sql_and_values( sea_orm::DatabaseBackend::Postgres, - "SELECT systolic_bp_morning, diastolic_bp_morning, heart_rate, blood_sugar, weight, spo2, body_temperature FROM vital_signs_daily WHERE tenant_id = $1 AND patient_id = $2 AND deleted_at IS NULL ORDER BY record_date DESC LIMIT 1", + r#"SELECT device_type, avg_val FROM vital_signs_daily + WHERE tenant_id = $1 AND patient_id = $2 + AND date_bucket >= CURRENT_DATE - INTERVAL '30 days' + ORDER BY date_bucket DESC"#, [tenant_id.into(), patient_id.into()], - ), - ) - .one(db) - .await?; + )) + .all(db) + .await?; - // 最新化验报告异常计数(最近 90 天) + // 化验报告异常项计数(最近 90 天) + // lab_report 表: is_abnormal 字段在 indicators JSON 内,通过 JSON 查询统计 #[derive(FromQueryResult)] struct LabAbnormal { report_type: String, abnormal_count: i64, } - let lab_abnormals: Vec = LabAbnormal::find_by_statement( - sea_orm::Statement::from_sql_and_values( + let lab_abnormals: Vec = + LabAbnormal::find_by_statement(sea_orm::Statement::from_sql_and_values( sea_orm::DatabaseBackend::Postgres, - "SELECT report_type, COUNT(*) as abnormal_count FROM lab_reports WHERE tenant_id = $1 AND patient_id = $2 AND deleted_at IS NULL AND is_abnormal = true AND report_date >= NOW() - INTERVAL '90 days' GROUP BY report_type", + r#"SELECT report_type, COUNT(*) as abnormal_count FROM lab_report + WHERE tenant_id = $1 AND patient_id = $2 + AND deleted_at IS NULL + AND report_date >= CURRENT_DATE - INTERVAL '90 days' + AND indicators @> '[{"is_abnormal":true}]'::jsonb + GROUP BY report_type"#, [tenant_id.into(), patient_id.into()], - ), - ) - .all(db) - .await?; + )) + .all(db) + .await + .unwrap_or_default(); let mut data = serde_json::Map::new(); - if let Some(v) = vital { - if let Some(bp_sys) = v.systolic_bp_morning { - data.insert("systolic_bp_morning".into(), serde_json::json!(bp_sys)); - } - if let Some(bp_dia) = v.diastolic_bp_morning { - data.insert("diastolic_bp_morning".into(), serde_json::json!(bp_dia)); - } - if let Some(hr) = v.heart_rate { - data.insert("heart_rate".into(), serde_json::json!(hr)); - } - if let Some(bs) = v.blood_sugar { - data.insert("blood_sugar".into(), serde_json::json!(bs)); - } - if let Some(w) = v.weight { - data.insert("weight".into(), serde_json::json!(w)); - } - if let Some(spo2) = v.spo2 { - data.insert("spo2".into(), serde_json::json!(spo2)); - } - if let Some(temp) = v.body_temperature { - data.insert("body_temperature".into(), serde_json::json!(temp)); + for v in &vitals { + if let Some(avg) = v.avg_val { + data.insert(format!("vital_{}", v.device_type), serde_json::json!(avg)); } } diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index 22957e8..6886228 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -156,6 +156,8 @@ mod m20260518_000151_fix_ai_config_menu_parent; mod m20260518_000152_seed_ai_provider_permission; mod m20260518_000153_ai_health_butler_v2; mod m20260519_000154_seed_ai_knowledge_permissions; +mod m20260519_000155_fix_ai_menus_and_add_chat; +mod m20260519_000156_fix_ai_menus_round2; pub struct Migrator; @@ -319,6 +321,8 @@ impl MigratorTrait for Migrator { Box::new(m20260518_000152_seed_ai_provider_permission::Migration), Box::new(m20260518_000153_ai_health_butler_v2::Migration), Box::new(m20260519_000154_seed_ai_knowledge_permissions::Migration), + Box::new(m20260519_000155_fix_ai_menus_and_add_chat::Migration), + Box::new(m20260519_000156_fix_ai_menus_round2::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260519_000155_fix_ai_menus_and_add_chat.rs b/crates/erp-server/migration/src/m20260519_000155_fix_ai_menus_and_add_chat.rs new file mode 100644 index 0000000..269256e --- /dev/null +++ b/crates/erp-server/migration/src/m20260519_000155_fix_ai_menus_and_add_chat.rs @@ -0,0 +1,68 @@ +//! 修复 AI 知识库菜单层级 + 新增 AI 客服菜单 + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + let sys = "00000000-0000-0000-0000-000000000000"; + + // 1. 修复 AI 知识库:从 AI 配置子级移到 AI 分析分组下(与 AI 配置同级) + // 必须递增 version 以通过乐观锁触发器 + db.execute_unprepared( + r#" + UPDATE menus mk + SET parent_id = mp.parent_id, version = mk.version + 1 + FROM menus mp + WHERE mp.path = '/health/ai-config' AND mp.deleted_at IS NULL + AND mk.path = '/health/ai-knowledge' AND mk.deleted_at IS NULL + AND mk.parent_id = mp.id + "#, + ) + .await?; + + // 2. 新增 AI 客服菜单(与 AI 配置、AI 知识库同级) + db.execute_unprepared(&format!( + r#" + INSERT INTO menus (id, tenant_id, parent_id, title, path, icon, sort_order, + permission, menu_type, visible, + created_at, updated_at, created_by, updated_by, deleted_at, version) + SELECT gen_random_uuid(), t.id, + (SELECT m.parent_id FROM menus m WHERE m.path = '/health/ai-config' AND m.tenant_id = t.id AND m.deleted_at IS NULL LIMIT 1), + 'AI 客服', '/ai/chat', 'MessageOutlined', 58, + 'ai.chat.session.list', 'menu', true, + NOW(), NOW(), '{sys}', '{sys}', NULL, 1 + FROM tenant t + WHERE NOT EXISTS ( + SELECT 1 FROM menus m + WHERE m.path = '/ai/chat' AND m.tenant_id = t.id AND m.deleted_at IS NULL + ) + "# + )).await?; + + // 3. 菜单绑定 admin 角色 + db.execute_unprepared(&format!( + r#" + INSERT INTO menu_roles (id, menu_id, role_id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version) + SELECT gen_random_uuid(), m.id, r.id, m.tenant_id, NOW(), NOW(), '{sys}', '{sys}', NULL, 1 + FROM menus m + JOIN roles r ON r.tenant_id = m.tenant_id AND r.code = 'admin' AND r.deleted_at IS NULL + WHERE m.path = '/ai/chat' AND m.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM menu_roles mr + WHERE mr.menu_id = m.id AND mr.role_id = r.id AND mr.deleted_at IS NULL + ) + "# + )).await?; + + Ok(()) + } + + async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> { + Ok(()) + } +} diff --git a/crates/erp-server/migration/src/m20260519_000156_fix_ai_menus_round2.rs b/crates/erp-server/migration/src/m20260519_000156_fix_ai_menus_round2.rs new file mode 100644 index 0000000..30ec5bc --- /dev/null +++ b/crates/erp-server/migration/src/m20260519_000156_fix_ai_menus_round2.rs @@ -0,0 +1,85 @@ +//! 修复 AI 知识库菜单层级 + 新增 AI 客服菜单(补充 000155 未生效的部分) + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + let sys = "00000000-0000-0000-0000-000000000000"; + + // 1. 修复 AI 知识库:从 AI 配置子级移到 AI 分析分组下(与 AI 配置同级) + // 递增 version 以通过乐观锁触发器 + db.execute_unprepared( + r#" + UPDATE menus mk + SET parent_id = mp.parent_id, version = mk.version + 1 + FROM menus mp + WHERE mp.path = '/health/ai-config' AND mp.deleted_at IS NULL + AND mk.path = '/health/ai-knowledge' AND mk.deleted_at IS NULL + AND mk.parent_id = mp.id + "#, + ) + .await?; + + // 2. 新增 AI 客服菜单(与 AI 配置同级) + db.execute_unprepared(&format!( + r#" + INSERT INTO menus (id, tenant_id, parent_id, title, path, icon, sort_order, + permission, menu_type, visible, + created_at, updated_at, created_by, updated_by, deleted_at, version) + SELECT gen_random_uuid(), t.id, + (SELECT m.parent_id FROM menus m WHERE m.path = '/health/ai-config' AND m.tenant_id = t.id AND m.deleted_at IS NULL LIMIT 1), + 'AI 客服', '/ai/chat', 'MessageOutlined', 58, + 'ai.chat.session.list', 'menu', true, + NOW(), NOW(), '{sys}', '{sys}', NULL, 1 + FROM tenant t + WHERE NOT EXISTS ( + SELECT 1 FROM menus m + WHERE m.path = '/ai/chat' AND m.tenant_id = t.id AND m.deleted_at IS NULL + ) + "# + )).await?; + + // 3. 菜单绑定 admin 角色 + db.execute_unprepared(&format!( + r#" + INSERT INTO menu_roles (id, menu_id, role_id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version) + SELECT gen_random_uuid(), m.id, r.id, m.tenant_id, NOW(), NOW(), '{sys}', '{sys}', NULL, 1 + FROM menus m + JOIN roles r ON r.tenant_id = m.tenant_id AND r.code = 'admin' AND r.deleted_at IS NULL + WHERE m.path = '/ai/chat' AND m.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM menu_roles mr + WHERE mr.menu_id = m.id AND mr.role_id = r.id AND mr.deleted_at IS NULL + ) + "# + )).await?; + + // 4. 为 doctor / health_manager 角色也绑定 AI 客服菜单 + for role in ["doctor", "health_manager"] { + db.execute_unprepared(&format!( + r#" + INSERT INTO menu_roles (id, menu_id, role_id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version) + SELECT gen_random_uuid(), m.id, r.id, m.tenant_id, NOW(), NOW(), '{sys}', '{sys}', NULL, 1 + FROM menus m + JOIN roles r ON r.tenant_id = m.tenant_id AND r.code = '{role}' AND r.deleted_at IS NULL + WHERE m.path = '/ai/chat' AND m.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM menu_roles mr + WHERE mr.menu_id = m.id AND mr.role_id = r.id AND mr.deleted_at IS NULL + ) + "# + )).await?; + } + + Ok(()) + } + + async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> { + Ok(()) + } +}