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:
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
"#;
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user