fix(ai): AI 对话全链路修复 + 菜单配置 + 会话消息持久化

- 修复 ai_tenant_config Entity 表名错误(复数→单数)导致 budget_status 500
- 修复 ai_usage 表 SQL 引用不存在的 deleted_at 列
- 修复 risk_service SQL 列名/表名与实际数据库 schema 不匹配
- chat_handler provider 选择改为配置优先(default_provider→fallback chain)
- 新增 Ollama 非 FC provider 的 generate() 降级路径
- 新增 GET /ai/chat/sessions/{id}/messages 端点
- 前端 ChatPage 切换会话时从后端加载历史消息
- AiConfigPage 新增 default_provider 和 system_prompt 配置字段
- 迁移 000155-000156:AI 菜单调整 + AI 客服菜单 + 角色绑定
- 配额检查错误处理区分配额耗尽和 DB 异常
This commit is contained in:
iven
2026-05-19 21:36:01 +08:00
parent 8fbe1543cb
commit c6d4e76b62
12 changed files with 514 additions and 122 deletions

View File

@@ -105,4 +105,14 @@ export const aiChatApi = {
closeSession: async (sessionId: string): Promise<void> => {
await client.post(`/ai/chat/sessions/${sessionId}/close`);
},
getSessionMessages: async (sessionId: string): Promise<Array<{
id: string;
role: string;
content: string | null;
created_at: string;
}>> => {
const resp = await client.get(`/ai/chat/sessions/${sessionId}/messages`);
return resp.data.data;
},
};

View File

@@ -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<string, AiProviderConfig>;
}
export const aiConfigApi = {

View File

@@ -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 () => {

View File

@@ -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<string, string> = {
claude: 'Claude (Anthropic)',
openai: 'OpenAI 兼容',
ollama: 'Ollama (本地)',
};
export default function AiConfigPage() {
const [config, setConfig] = useState<AiConfig | null>(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<string, AiProviderConfig> = {};
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: (
<Card
title="AI 客服 Agent 参数"
@@ -204,6 +232,7 @@ export default function AiConfigPage() {
{
key: 'analysis',
label: '分析任务默认配置',
forceRender: true,
children: (
<Card
title="AI 分析任务默认参数"
@@ -255,6 +284,93 @@ export default function AiConfigPage() {
</Card>
),
},
{
key: 'providers',
label: '模型供应商配置',
forceRender: true,
children: (
<Card
title="AI 模型供应商"
size="small"
style={cardStyle}
extra="配置各 AI 供应商的连接参数,保存后立即生效"
>
<Form.Item
label="默认供应商"
name="defaultProvider"
extra="系统优先使用的 AI 供应商"
>
<Select style={{ width: 240 }}>
<Select.Option value="claude">Claude (Anthropic)</Select.Option>
<Select.Option value="openai">OpenAI </Select.Option>
<Select.Option value="ollama">Ollama ()</Select.Option>
</Select>
</Form.Item>
<Divider style={{ margin: '8px 0 16px' }} />
<Collapse
items={(['claude', 'openai', 'ollama'] as const).map((name) => ({
key: name,
forceRender: true,
label: (
<Space>
<Form.Item
name={`provider_${name}_enabled`}
valuePropName="checked"
noStyle
>
<Switch size="small" />
</Form.Item>
<span>{PROVIDER_LABELS[name]}</span>
{config?.providers?.[name]?.enabled && (
<Text type="success"></Text>
)}
</Space>
),
children: (
<>
<Form.Item
label="Base URL"
name={`provider_${name}_base_url`}
>
<Input placeholder={
name === 'claude' ? 'https://api.anthropic.com' :
name === 'openai' ? 'https://api.openai.com' :
'http://localhost:11434'
} />
</Form.Item>
{name !== 'ollama' && (
<Form.Item
label="API Key"
name={`provider_${name}_api_key`}
extra="已保存的 Key 会显示为掩码格式(****xxxx输入新值即可覆盖"
>
<Input.Password
placeholder="sk-..."
visibilityToggle
/>
</Form.Item>
)}
<Form.Item
label="默认模型"
name={`provider_${name}_model`}
>
<Input placeholder={
name === 'claude' ? 'claude-sonnet-4-6' :
name === 'openai' ? 'gpt-4o' :
'qwen3:8b'
} />
</Form.Item>
</>
),
}))}
/>
</Card>
),
},
]}
/>
@@ -263,14 +379,15 @@ export default function AiConfigPage() {
<Button icon={<UndoOutlined />} onClick={handleReset}>
</Button>
<AuthButton
permission="ai.config.manage"
type="primary"
icon={<SaveOutlined />}
loading={saving}
onClick={handleSave}
>
<AuthButton code="ai.config.manage">
<Button
type="primary"
icon={<SaveOutlined />}
loading={saving}
onClick={handleSave}
>
</Button>
</AuthButton>
</Space>
</div>
@@ -278,3 +395,24 @@ export default function AiConfigPage() {
</div>
);
}
const PROVIDER_DEFAULTS: Record<string, { base_url: string; model: string }> = {
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<string, AiProviderConfig> | undefined,
): Record<string, unknown> {
if (!providers) return {};
const fields: Record<string, unknown> = {};
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;
}

View File

@@ -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,

View File

@@ -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<String>,
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<S>(
Extension(ctx): Extension<TenantContext>,
State(state): State<S>,
axum::extract::Path(session_id): axum::extract::Path<uuid::Uuid>,
) -> Result<Json<ApiResponse<Vec<MessageResponse>>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
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<MessageResponse> = 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)))
}

View File

@@ -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),

View File

@@ -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)
"#;

View File

@@ -175,66 +175,52 @@ impl RiskService {
) -> AppResult<serde_json::Value> {
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<i32>,
diastolic_bp_morning: Option<i32>,
heart_rate: Option<i32>,
blood_sugar: Option<f64>,
weight: Option<f64>,
spo2: Option<i32>,
body_temperature: Option<f64>,
device_type: String,
avg_val: Option<f64>,
}
let vital: Option<VitalRow> = VitalRow::find_by_statement(
sea_orm::Statement::from_sql_and_values(
let vitals: Vec<VitalRow> =
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> = LabAbnormal::find_by_statement(
sea_orm::Statement::from_sql_and_values(
let lab_abnormals: Vec<LabAbnormal> =
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));
}
}

View File

@@ -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),
]
}
}

View File

@@ -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(())
}
}

View File

@@ -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(())
}
}