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> => { closeSession: async (sessionId: string): Promise<void> => {
await client.post(`/ai/chat/sessions/${sessionId}/close`); 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; max_tokens: number;
} }
export interface AiProviderConfig {
provider_type: string;
enabled: boolean;
base_url: string;
api_key: string;
model: string;
}
export interface AiConfig { export interface AiConfig {
agent: AiAgentConfig; agent: AiAgentConfig;
analysis_defaults: AiAnalysisDefaults; analysis_defaults: AiAnalysisDefaults;
default_provider: string;
providers: Record<string, AiProviderConfig>;
} }
export const aiConfigApi = { export const aiConfigApi = {

View File

@@ -91,9 +91,35 @@ export default function ChatPage() {
} }
}; };
const handleSelectSession = (id: string) => { const handleSelectSession = async (id: string) => {
setActiveId(id); setActiveId(id);
setMessages([]); 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 () => { const handleSend = async () => {

View File

@@ -10,13 +10,24 @@ import {
Divider, Divider,
Spin, Spin,
Tabs, Tabs,
Select,
Switch,
Collapse,
Typography,
} from 'antd'; } from 'antd';
import { SaveOutlined, UndoOutlined } from '@ant-design/icons'; 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 { AuthButton } from '../../components/AuthButton';
import { useThemeMode } from '../../hooks/useThemeMode'; import { useThemeMode } from '../../hooks/useThemeMode';
const { TextArea } = Input; const { TextArea } = Input;
const { Text } = Typography;
const PROVIDER_LABELS: Record<string, string> = {
claude: 'Claude (Anthropic)',
openai: 'OpenAI 兼容',
ollama: 'Ollama (本地)',
};
export default function AiConfigPage() { export default function AiConfigPage() {
const [config, setConfig] = useState<AiConfig | null>(null); const [config, setConfig] = useState<AiConfig | null>(null);
@@ -39,6 +50,9 @@ export default function AiConfigPage() {
analysisModel: data.analysis_defaults.model, analysisModel: data.analysis_defaults.model,
analysisTemperature: data.analysis_defaults.temperature, analysisTemperature: data.analysis_defaults.temperature,
analysisMaxTokens: data.analysis_defaults.max_tokens, analysisMaxTokens: data.analysis_defaults.max_tokens,
defaultProvider: data.default_provider || 'claude',
// Provider fields
...buildProviderFields(data.providers),
}); });
} catch { } catch {
message.error('加载 AI 配置失败'); message.error('加载 AI 配置失败');
@@ -56,6 +70,17 @@ export default function AiConfigPage() {
const values = await form.validateFields(); const values = await form.validateFields();
setSaving(true); 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 = { const updated: AiConfig = {
agent: { agent: {
model: values.agentModel, model: values.agentModel,
@@ -69,6 +94,8 @@ export default function AiConfigPage() {
temperature: values.analysisTemperature, temperature: values.analysisTemperature,
max_tokens: values.analysisMaxTokens, max_tokens: values.analysisMaxTokens,
}, },
default_provider: values.defaultProvider || 'claude',
providers,
}; };
const result = await aiConfigApi.update(updated); const result = await aiConfigApi.update(updated);
@@ -125,6 +152,7 @@ export default function AiConfigPage() {
{ {
key: 'agent', key: 'agent',
label: 'Agent 对话配置', label: 'Agent 对话配置',
forceRender: true,
children: ( children: (
<Card <Card
title="AI 客服 Agent 参数" title="AI 客服 Agent 参数"
@@ -204,6 +232,7 @@ export default function AiConfigPage() {
{ {
key: 'analysis', key: 'analysis',
label: '分析任务默认配置', label: '分析任务默认配置',
forceRender: true,
children: ( children: (
<Card <Card
title="AI 分析任务默认参数" title="AI 分析任务默认参数"
@@ -255,6 +284,93 @@ export default function AiConfigPage() {
</Card> </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 icon={<UndoOutlined />} onClick={handleReset}>
</Button> </Button>
<AuthButton <AuthButton code="ai.config.manage">
permission="ai.config.manage" <Button
type="primary" type="primary"
icon={<SaveOutlined />} icon={<SaveOutlined />}
loading={saving} loading={saving}
onClick={handleSave} onClick={handleSave}
> >
</Button>
</AuthButton> </AuthButton>
</Space> </Space>
</div> </div>
@@ -278,3 +395,24 @@ export default function AiConfigPage() {
</div> </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}; use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "ai_tenant_configs")] #[sea_orm(table_name = "ai_tenant_config")]
pub struct Model { pub struct Model {
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid, pub id: Uuid,

View File

@@ -86,14 +86,26 @@ where
.check_quota(ctx.tenant_id, body.patient_id) .check_quota(ctx.tenant_id, body.patient_id)
.await .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, tenant_id = %ctx.tenant_id,
patient_id = ?body.patient_id, patient_id = ?body.patient_id,
error = %e, error = %e,
"Quota check failed" "Quota check error"
); );
return Err(erp_core::error::AppError::Validation( return Err(erp_core::error::AppError::Internal(
"AI 使用配额已用尽,请稍后再试或联系管理员".into(), "配额检查失败,请稍后重试".into(),
)); ));
} }
@@ -155,16 +167,16 @@ where
tool_call_id: None, tool_call_id: None,
}); });
// 解析 Provider — Agent 需要 Function Calling精确获取 Claude/OpenAI // 解析 Provider — 优先使用配置的 default_provider依次 fallback 到支持 FC 的 provider
let provider_arc = ai_state let provider_arc = ai_state
.provider_registry .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("openai"))
.or_else(|| ai_state.provider_registry.get_provider("ollama"))
.ok_or_else(|| { .ok_or_else(|| {
tracing::error!("No FC-capable provider found (need claude or openai)"); tracing::error!("No AI provider available");
erp_core::error::AppError::Internal( erp_core::error::AppError::Internal("AI Agent 暂时不可用,没有可用的 AI 提供商".into())
"AI Agent 暂时不可用,需要 Claude 或 OpenAI 提供商".into(),
)
})?; })?;
// 构建全局 ToolRegistry所有已注册 Tool // 构建全局 ToolRegistry所有已注册 Tool
@@ -226,26 +238,54 @@ where
); );
let provider_name = provider_arc.name().to_string(); let provider_name = provider_arc.name().to_string();
let supports_fc = provider_name != "ollama"; // Ollama generate_with_tools 未实现
// 执行 Agent ReAct 循环(使用角色沙箱过滤后的 Tool 和 Prompt let result = if supports_fc {
let orchestrator = AgentOrchestrator::new(provider_arc, std::sync::Arc::new(registry)); // FC provider执行完整 Agent ReAct 循环
let mut result = orchestrator let orchestrator = AgentOrchestrator::new(provider_arc, std::sync::Arc::new(registry));
.run( let agent_result = orchestrator
&system_prompt, .run(
&mut messages, &system_prompt,
&tool_ctx, &mut messages,
&run_params, &tool_ctx,
Some(&sandbox.allowed_tools), &run_params,
) Some(&sandbox.allowed_tools),
.await )
.map_err(|e| { .await
tracing::error!(error = %e, "AI Agent run failed"); .map_err(|e| {
erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into()) 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() { let mut reply = result;
result.reply.push_str(sandbox.output_filter.disclaimer_text); 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(); let message_id = uuid::Uuid::now_v7().to_string();
@@ -253,13 +293,11 @@ where
tracing::info!( tracing::info!(
tenant_id = %ctx.tenant_id, tenant_id = %ctx.tenant_id,
message_id = %message_id, message_id = %message_id,
iterations = result.iterations, provider = %provider_name,
input_tokens = result.total_input_tokens, "AI chat response sent"
output_tokens = result.total_output_tokens,
"AI Agent response sent"
); );
// 记录用量的 token 消耗 // 记录用量的 token 消耗(简化模式下无法精确计量,记 0
if let Err(e) = ai_state if let Err(e) = ai_state
.usage .usage
.log_usage( .log_usage(
@@ -267,8 +305,8 @@ where
&provider_name, &provider_name,
&run_params.model, &run_params.model,
"chat", "chat",
result.total_input_tokens as u32, 0,
result.total_output_tokens as u32, 0,
0, 0,
0, 0,
false, false,
@@ -279,9 +317,9 @@ where
} }
// session_id 模式:持久化消息 // 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 { 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 if let Err(e) = ai_state
@@ -308,48 +346,23 @@ where
tenant_id: ctx.tenant_id, tenant_id: ctx.tenant_id,
session_id: sid, session_id: sid,
role: "assistant".to_string(), role: "assistant".to_string(),
content: Some(result.reply.clone()), content: Some(reply.clone()),
tool_calls: None, tool_calls: None,
tool_call_id: 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, user_id: ctx.user_id,
}) })
.await .await
{ {
tracing::warn!(error = %e, "Failed to save assistant message to session"); 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 { Ok(Json(ApiResponse::ok(ChatResponse {
reply: result.reply, reply,
message_id, message_id,
iterations: result.iterations, iterations: if supports_fc { 1 } else { 0 },
display_hints: if result.display_hints.is_empty() { display_hints: None,
None
} else {
Some(result.display_hints)
},
}))) })))
} }
@@ -512,3 +525,52 @@ where
} }
Ok(Json(ApiResponse::ok(()))) 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", "/ai/chat/sessions/{session_id}/close",
axum::routing::post(crate::handler::chat_handler::close_session), 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( .route(
"/ai/config", "/ai/config",
axum::routing::get(crate::handler::config_handler::get_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 SELECT COALESCE(SUM(input_tokens + output_tokens), 0) AS total
FROM ai_usage FROM ai_usage
WHERE tenant_id = $1 WHERE tenant_id = $1
AND deleted_at IS NULL
AND created_at >= DATE_TRUNC('month', CURRENT_DATE) AND created_at >= DATE_TRUNC('month', CURRENT_DATE)
"#; "#;

View File

@@ -175,66 +175,52 @@ impl RiskService {
) -> AppResult<serde_json::Value> { ) -> AppResult<serde_json::Value> {
use sea_orm::FromQueryResult; 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)] #[derive(FromQueryResult)]
struct VitalRow { struct VitalRow {
systolic_bp_morning: Option<i32>, device_type: String,
diastolic_bp_morning: Option<i32>, avg_val: Option<f64>,
heart_rate: Option<i32>,
blood_sugar: Option<f64>,
weight: Option<f64>,
spo2: Option<i32>,
body_temperature: Option<f64>,
} }
let vital: Option<VitalRow> = VitalRow::find_by_statement( let vitals: Vec<VitalRow> =
sea_orm::Statement::from_sql_and_values( VitalRow::find_by_statement(sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres, 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()], [tenant_id.into(), patient_id.into()],
), ))
) .all(db)
.one(db) .await?;
.await?;
// 最新化验报告异常计数(最近 90 天) // 化验报告异常计数(最近 90 天)
// lab_report 表: is_abnormal 字段在 indicators JSON 内,通过 JSON 查询统计
#[derive(FromQueryResult)] #[derive(FromQueryResult)]
struct LabAbnormal { struct LabAbnormal {
report_type: String, report_type: String,
abnormal_count: i64, abnormal_count: i64,
} }
let lab_abnormals: Vec<LabAbnormal> = LabAbnormal::find_by_statement( let lab_abnormals: Vec<LabAbnormal> =
sea_orm::Statement::from_sql_and_values( LabAbnormal::find_by_statement(sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres, 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()], [tenant_id.into(), patient_id.into()],
), ))
) .all(db)
.all(db) .await
.await?; .unwrap_or_default();
let mut data = serde_json::Map::new(); let mut data = serde_json::Map::new();
if let Some(v) = vital { for v in &vitals {
if let Some(bp_sys) = v.systolic_bp_morning { if let Some(avg) = v.avg_val {
data.insert("systolic_bp_morning".into(), serde_json::json!(bp_sys)); data.insert(format!("vital_{}", v.device_type), serde_json::json!(avg));
}
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));
} }
} }

View File

@@ -156,6 +156,8 @@ mod m20260518_000151_fix_ai_config_menu_parent;
mod m20260518_000152_seed_ai_provider_permission; mod m20260518_000152_seed_ai_provider_permission;
mod m20260518_000153_ai_health_butler_v2; mod m20260518_000153_ai_health_butler_v2;
mod m20260519_000154_seed_ai_knowledge_permissions; 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; pub struct Migrator;
@@ -319,6 +321,8 @@ impl MigratorTrait for Migrator {
Box::new(m20260518_000152_seed_ai_provider_permission::Migration), Box::new(m20260518_000152_seed_ai_provider_permission::Migration),
Box::new(m20260518_000153_ai_health_butler_v2::Migration), Box::new(m20260518_000153_ai_health_butler_v2::Migration),
Box::new(m20260519_000154_seed_ai_knowledge_permissions::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(())
}
}