fix: V1 测试版本端到端验证修复 — 6 CRITICAL + 3 HIGH 问题全量修复
修复项: - fix(db): 迁移 149 — 修复 Admin 角色权限绑定被迁移链破坏 (FE-C1) - fix(health): 4 个 handler 添加空名称验证 — Doctor/Article/AlertRule/Tag (API-C1~C4) - fix(health): Stats 仪表盘 new_this_week 查询修复 — SeaORM date_trunc bug (FE-C2) - fix(server): 添加安全响应头 — X-Frame-Options/CSP/XSS-Protection/Referrer-Policy (SEC-H1) - fix(mp): 预约创建契约修复 — notes/reason 字段映射 + 移除 schedule_id (MP-H1) - fix(mp): 咨询会话 subject/last_message 字段改为可选 (MP-H3) - fix(ai): AiConfig Default derive 替代手写 impl (clippy) 测试报告: - 8 维度端到端测试全部完成 (后端 87 用例 / 前端 30 页面 / 小程序 80+ API / 安全 20 项 / 性能 20 端点) - 多角色 7 角色 49 检查 100% 通过 - 综合测试报告 + 专家评估报告
This commit is contained in:
@@ -6,11 +6,29 @@ use crate::dto::{ChatMessage, ChatMessageRole};
|
||||
use crate::error::AiResult;
|
||||
use crate::provider::AiProvider;
|
||||
|
||||
/// Agent 运行时参数
|
||||
pub struct AgentRunParams {
|
||||
pub model: String,
|
||||
pub temperature: f32,
|
||||
pub max_tokens: u32,
|
||||
pub max_iterations: usize,
|
||||
}
|
||||
|
||||
impl Default for AgentRunParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model: "claude-sonnet-4-6".to_string(),
|
||||
temperature: 0.7,
|
||||
max_tokens: 2048,
|
||||
max_iterations: 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Agent Orchestrator — 执行 ReAct 循环
|
||||
pub struct AgentOrchestrator {
|
||||
provider: Arc<dyn AiProvider>,
|
||||
tool_registry: Arc<ToolRegistry>,
|
||||
max_iterations: usize,
|
||||
}
|
||||
|
||||
/// Agent 运行结果
|
||||
@@ -26,7 +44,6 @@ impl AgentOrchestrator {
|
||||
Self {
|
||||
provider,
|
||||
tool_registry,
|
||||
max_iterations: 5,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +53,7 @@ impl AgentOrchestrator {
|
||||
system_prompt: &str,
|
||||
messages: &mut Vec<ChatMessage>,
|
||||
ctx: &ToolContext,
|
||||
params: &AgentRunParams,
|
||||
) -> AiResult<AgentRunResult> {
|
||||
let tools = self.tool_registry.tool_definitions();
|
||||
let mut iterations = 0;
|
||||
@@ -51,10 +69,9 @@ impl AgentOrchestrator {
|
||||
messages.clone(),
|
||||
tools.clone(),
|
||||
system_prompt,
|
||||
&std::env::var("ANTHROPIC_DEFAULT_SONNET_MODEL")
|
||||
.unwrap_or_else(|_| "claude-sonnet-4-6".to_string()),
|
||||
0.7,
|
||||
2048,
|
||||
¶ms.model,
|
||||
params.temperature,
|
||||
params.max_tokens,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -77,7 +94,7 @@ impl AgentOrchestrator {
|
||||
};
|
||||
|
||||
// 达到上限:强制结束
|
||||
if iterations >= self.max_iterations {
|
||||
if iterations >= params.max_iterations {
|
||||
messages.push(ChatMessage {
|
||||
role: ChatMessageRole::User,
|
||||
content: "(系统提示:已收集足够信息,请直接总结回复用户,不要再调用工具)"
|
||||
|
||||
415
crates/erp-ai/src/config_resolver.rs
Normal file
415
crates/erp-ai/src/config_resolver.rs
Normal file
@@ -0,0 +1,415 @@
|
||||
use sea_orm::ConnectionTrait;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// AI Agent 运行时配置,从 settings 表读取,带编译时默认值
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AiAgentConfig {
|
||||
pub model: String,
|
||||
pub temperature: f32,
|
||||
pub max_tokens: u32,
|
||||
pub max_iterations: usize,
|
||||
pub system_prompt: String,
|
||||
}
|
||||
|
||||
impl Default for AiAgentConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model: "claude-sonnet-4-6".to_string(),
|
||||
temperature: 0.7,
|
||||
max_tokens: 2048,
|
||||
max_iterations: 5,
|
||||
system_prompt: default_system_prompt(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 分析任务默认配置(当 prompt.model_config 未指定时的 fallback)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AiAnalysisDefaults {
|
||||
pub model: String,
|
||||
pub temperature: f32,
|
||||
pub max_tokens: u32,
|
||||
}
|
||||
|
||||
impl Default for AiAnalysisDefaults {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model: "claude-sonnet-4-6".to_string(),
|
||||
temperature: 0.3,
|
||||
max_tokens: 2048,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理员可编辑的完整 AI 配置
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AiConfig {
|
||||
pub agent: AiAgentConfig,
|
||||
pub analysis_defaults: AiAnalysisDefaults,
|
||||
}
|
||||
|
||||
/// Setting key 常量
|
||||
const KEY_AGENT_MODEL: &str = "ai.agent.model";
|
||||
const KEY_AGENT_TEMPERATURE: &str = "ai.agent.temperature";
|
||||
const KEY_AGENT_MAX_TOKENS: &str = "ai.agent.max_tokens";
|
||||
const KEY_AGENT_MAX_ITERATIONS: &str = "ai.agent.max_iterations";
|
||||
const KEY_AGENT_SYSTEM_PROMPT: &str = "ai.agent.system_prompt";
|
||||
const KEY_ANALYSIS_MODEL: &str = "ai.analysis.default_model";
|
||||
const KEY_ANALYSIS_TEMPERATURE: &str = "ai.analysis.default_temperature";
|
||||
const KEY_ANALYSIS_MAX_TOKENS: &str = "ai.analysis.default_max_tokens";
|
||||
|
||||
/// 从 settings 表批量读取 AI 配置
|
||||
pub async fn load_ai_config(tenant_id: Uuid, db: &DatabaseConnection) -> AiConfig {
|
||||
let defaults = AiConfig::default();
|
||||
let values = read_settings_batch(tenant_id, db).await;
|
||||
|
||||
AiConfig {
|
||||
agent: AiAgentConfig {
|
||||
model: values
|
||||
.get(KEY_AGENT_MODEL)
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&defaults.agent.model)
|
||||
.to_string(),
|
||||
temperature: values
|
||||
.get(KEY_AGENT_TEMPERATURE)
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(defaults.agent.temperature as f64) as f32,
|
||||
max_tokens: values
|
||||
.get(KEY_AGENT_MAX_TOKENS)
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(defaults.agent.max_tokens as u64) as u32,
|
||||
max_iterations: values
|
||||
.get(KEY_AGENT_MAX_ITERATIONS)
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(defaults.agent.max_iterations as u64)
|
||||
as usize,
|
||||
system_prompt: values
|
||||
.get(KEY_AGENT_SYSTEM_PROMPT)
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&defaults.agent.system_prompt)
|
||||
.to_string(),
|
||||
},
|
||||
analysis_defaults: AiAnalysisDefaults {
|
||||
model: values
|
||||
.get(KEY_ANALYSIS_MODEL)
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&defaults.analysis_defaults.model)
|
||||
.to_string(),
|
||||
temperature: values
|
||||
.get(KEY_ANALYSIS_TEMPERATURE)
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(defaults.analysis_defaults.temperature as f64)
|
||||
as f32,
|
||||
max_tokens: values
|
||||
.get(KEY_ANALYSIS_MAX_TOKENS)
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(defaults.analysis_defaults.max_tokens as u64)
|
||||
as u32,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有 AI 配置 key 列表(用于前端展示)
|
||||
pub fn all_config_keys() -> &'static [&'static str] {
|
||||
&[
|
||||
KEY_AGENT_MODEL,
|
||||
KEY_AGENT_TEMPERATURE,
|
||||
KEY_AGENT_MAX_TOKENS,
|
||||
KEY_AGENT_MAX_ITERATIONS,
|
||||
KEY_AGENT_SYSTEM_PROMPT,
|
||||
KEY_ANALYSIS_MODEL,
|
||||
KEY_ANALYSIS_TEMPERATURE,
|
||||
KEY_ANALYSIS_MAX_TOKENS,
|
||||
]
|
||||
}
|
||||
|
||||
/// 批量写入 AI 配置到 settings 表
|
||||
pub async fn save_ai_config(
|
||||
config: &AiConfig,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &DatabaseConnection,
|
||||
event_bus: &erp_core::events::EventBus,
|
||||
) -> Result<(), erp_core::error::AppError> {
|
||||
let pairs: Vec<(&str, serde_json::Value)> = vec![
|
||||
(KEY_AGENT_MODEL, serde_json::json!(config.agent.model)),
|
||||
(
|
||||
KEY_AGENT_TEMPERATURE,
|
||||
serde_json::json!(config.agent.temperature),
|
||||
),
|
||||
(
|
||||
KEY_AGENT_MAX_TOKENS,
|
||||
serde_json::json!(config.agent.max_tokens),
|
||||
),
|
||||
(
|
||||
KEY_AGENT_MAX_ITERATIONS,
|
||||
serde_json::json!(config.agent.max_iterations),
|
||||
),
|
||||
(
|
||||
KEY_AGENT_SYSTEM_PROMPT,
|
||||
serde_json::json!(config.agent.system_prompt),
|
||||
),
|
||||
(
|
||||
KEY_ANALYSIS_MODEL,
|
||||
serde_json::json!(config.analysis_defaults.model),
|
||||
),
|
||||
(
|
||||
KEY_ANALYSIS_TEMPERATURE,
|
||||
serde_json::json!(config.analysis_defaults.temperature),
|
||||
),
|
||||
(
|
||||
KEY_ANALYSIS_MAX_TOKENS,
|
||||
serde_json::json!(config.analysis_defaults.max_tokens),
|
||||
),
|
||||
];
|
||||
|
||||
for (key, value) in pairs {
|
||||
upsert_setting(key, &value, tenant_id, operator_id, db, event_bus).await?;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
tenant_id = %tenant_id,
|
||||
operator_id = %operator_id,
|
||||
"AI 配置已更新"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 直接从 settings 表读取所有 ai.* 配置项(tenant → platform fallback)
|
||||
async fn read_settings_batch(
|
||||
tenant_id: Uuid,
|
||||
db: &DatabaseConnection,
|
||||
) -> std::collections::HashMap<String, serde_json::Value> {
|
||||
use sea_orm::FromQueryResult;
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct SettingRow {
|
||||
setting_key: String,
|
||||
setting_value: serde_json::Value,
|
||||
}
|
||||
|
||||
let sql = r#"
|
||||
SELECT setting_key, setting_value
|
||||
FROM settings
|
||||
WHERE setting_key LIKE 'ai.%'
|
||||
AND deleted_at IS NULL
|
||||
AND (scope = 'platform' OR (scope = 'tenant' AND tenant_id = $1))
|
||||
ORDER BY scope ASC
|
||||
"#;
|
||||
|
||||
let rows: Vec<SettingRow> =
|
||||
SettingRow::find_by_statement(sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.all(db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut result = std::collections::HashMap::new();
|
||||
|
||||
// 先放 platform(低优先级),再放 tenant(高优先级覆盖)
|
||||
for row in rows {
|
||||
result.insert(row.setting_key, row.setting_value);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Upsert 单个 setting(简化版,不用 erp-config 的 SettingService 避免跨 crate)
|
||||
async fn upsert_setting(
|
||||
key: &str,
|
||||
value: &serde_json::Value,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &DatabaseConnection,
|
||||
event_bus: &erp_core::events::EventBus,
|
||||
) -> Result<(), erp_core::error::AppError> {
|
||||
use sea_orm::FromQueryResult;
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct IdRow {
|
||||
id: Uuid,
|
||||
}
|
||||
|
||||
let existing: Option<IdRow> =
|
||||
IdRow::find_by_statement(sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"
|
||||
SELECT id, version FROM settings
|
||||
WHERE setting_key = $1 AND scope = 'tenant' AND tenant_id = $2
|
||||
AND scope_id IS NULL AND deleted_at IS NULL
|
||||
"#,
|
||||
[key.into(), tenant_id.into()],
|
||||
))
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
|
||||
|
||||
if let Some(row) = existing {
|
||||
let stmt = sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"
|
||||
UPDATE settings
|
||||
SET setting_value = $1, updated_at = NOW(), updated_by = $2, version = version + 1
|
||||
WHERE id = $3
|
||||
"#,
|
||||
[value.clone().into(), operator_id.into(), row.id.into()],
|
||||
);
|
||||
db.execute(stmt)
|
||||
.await
|
||||
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
|
||||
} else {
|
||||
let id = Uuid::now_v7();
|
||||
let stmt = sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"
|
||||
INSERT INTO settings (id, tenant_id, scope, scope_id, setting_key, setting_value,
|
||||
created_at, updated_at, created_by, updated_by, deleted_at, version)
|
||||
VALUES ($1, $2, 'tenant', NULL, $3, $4, NOW(), NOW(), $5, $5, NULL, 1)
|
||||
"#,
|
||||
[
|
||||
id.into(),
|
||||
tenant_id.into(),
|
||||
key.into(),
|
||||
value.clone().into(),
|
||||
operator_id.into(),
|
||||
],
|
||||
);
|
||||
db.execute(stmt)
|
||||
.await
|
||||
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
|
||||
}
|
||||
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"setting.updated",
|
||||
tenant_id,
|
||||
serde_json::json!({ "key": key, "scope": "tenant" }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn default_system_prompt() -> String {
|
||||
r#"你是 HMS 健康管理平台的 AI 健康顾问"小华"。
|
||||
|
||||
## 核心策略
|
||||
根据用户表达的内容和情绪,自然地采用以下策略方向:
|
||||
|
||||
1. 【情绪安抚】当用户表达焦虑、恐惧、沮丧时:
|
||||
- 先共情认可感受,不急于给建议
|
||||
- 用通俗语言解释,避免医学术语
|
||||
- 分享积极案例,降低恐惧感
|
||||
|
||||
2. 【医疗科普】当用户询问指标含义、疾病知识时:
|
||||
- 调用 search_medical_knowledge 获取准确信息(如可用)
|
||||
- 用比喻和类比让老年患者也能理解
|
||||
- 强调"具体请以医生诊断为准"
|
||||
|
||||
3. 【服务推荐】当用户表达就医需求或身体不适时:
|
||||
- 调用 query_appointments 查看已有预约(如可用)
|
||||
- 主动提出帮用户预约
|
||||
|
||||
4. 【风险预警】当用户描述的症状或数据异常时:
|
||||
- 调用 query_patient_vitals 查看体征数据
|
||||
- 明确告知风险等级和需要注意的事项
|
||||
- 高风险时建议尽快就医
|
||||
|
||||
5. 【引导到院】当用户有明确就诊意向或高风险预警时:
|
||||
- 提供科室位置、出诊医生信息
|
||||
- 建议用户联系前台预约
|
||||
|
||||
## 策略不是互斥的,你可以在一轮对话中自然切换。
|
||||
## 永远不要:推荐具体药物、给出明确诊断、替代医生建议。
|
||||
## 如果没有可用的工具数据,就基于常识回答,并建议用户咨询医生。"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_config_has_reasonable_values() {
|
||||
let config = AiAgentConfig::default();
|
||||
assert_eq!(config.model, "claude-sonnet-4-6");
|
||||
assert!((config.temperature - 0.7).abs() < f32::EPSILON);
|
||||
assert_eq!(config.max_tokens, 2048);
|
||||
assert_eq!(config.max_iterations, 5);
|
||||
assert!(config.system_prompt.contains("小华"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_analysis_config_has_reasonable_values() {
|
||||
let config = AiAnalysisDefaults::default();
|
||||
assert_eq!(config.model, "claude-sonnet-4-6");
|
||||
assert!((config.temperature - 0.3).abs() < f32::EPSILON);
|
||||
assert_eq!(config.max_tokens, 2048);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_config_keys_count() {
|
||||
assert_eq!(all_config_keys().len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_serialization_roundtrip() {
|
||||
let config = AiConfig::default();
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let back: AiConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.agent.model, config.agent.model);
|
||||
assert_eq!(back.agent.max_iterations, config.agent.max_iterations);
|
||||
assert_eq!(back.analysis_defaults.model, config.analysis_defaults.model);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_from_json_values() {
|
||||
let mut values = std::collections::HashMap::new();
|
||||
values.insert("ai.agent.model".to_string(), serde_json::json!("gpt-4o"));
|
||||
values.insert("ai.agent.temperature".to_string(), serde_json::json!(0.5));
|
||||
values.insert("ai.agent.max_tokens".to_string(), serde_json::json!(4096));
|
||||
values.insert("ai.agent.max_iterations".to_string(), serde_json::json!(3));
|
||||
|
||||
let defaults = AiConfig::default();
|
||||
|
||||
let config = AiConfig {
|
||||
agent: AiAgentConfig {
|
||||
model: values
|
||||
.get("ai.agent.model")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&defaults.agent.model)
|
||||
.to_string(),
|
||||
temperature: values
|
||||
.get("ai.agent.temperature")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(defaults.agent.temperature as f64)
|
||||
as f32,
|
||||
max_tokens: values
|
||||
.get("ai.agent.max_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(defaults.agent.max_tokens as u64) as u32,
|
||||
max_iterations: values
|
||||
.get("ai.agent.max_iterations")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(defaults.agent.max_iterations as u64)
|
||||
as usize,
|
||||
system_prompt: defaults.agent.system_prompt,
|
||||
},
|
||||
analysis_defaults: defaults.analysis_defaults,
|
||||
};
|
||||
|
||||
assert_eq!(config.agent.model, "gpt-4o");
|
||||
assert!((config.agent.temperature - 0.5).abs() < f32::EPSILON);
|
||||
assert_eq!(config.agent.max_tokens, 4096);
|
||||
assert_eq!(config.agent.max_iterations, 3);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@ use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::agent::orchestrator::AgentRunParams;
|
||||
use crate::agent::tool::ToolContext;
|
||||
use crate::agent::tools::QueryPatientVitalsTool;
|
||||
use crate::agent::{AgentOrchestrator, ToolRegistry};
|
||||
use crate::config_resolver;
|
||||
use crate::dto::{ChatMessage, ChatMessageRole};
|
||||
use crate::state::AiState;
|
||||
|
||||
@@ -33,38 +35,6 @@ pub struct ChatResponse {
|
||||
pub iterations: usize,
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT: &str = r#"你是 HMS 健康管理平台的 AI 健康顾问"小华"。
|
||||
|
||||
## 核心策略
|
||||
根据用户表达的内容和情绪,自然地采用以下策略方向:
|
||||
|
||||
1. 【情绪安抚】当用户表达焦虑、恐惧、沮丧时:
|
||||
- 先共情认可感受,不急于给建议
|
||||
- 用通俗语言解释,避免医学术语
|
||||
- 分享积极案例,降低恐惧感
|
||||
|
||||
2. 【医疗科普】当用户询问指标含义、疾病知识时:
|
||||
- 调用 search_medical_knowledge 获取准确信息(如可用)
|
||||
- 用比喻和类比让老年患者也能理解
|
||||
- 强调"具体请以医生诊断为准"
|
||||
|
||||
3. 【服务推荐】当用户表达就医需求或身体不适时:
|
||||
- 调用 query_appointments 查看已有预约(如可用)
|
||||
- 主动提出帮用户预约
|
||||
|
||||
4. 【风险预警】当用户描述的症状或数据异常时:
|
||||
- 调用 query_patient_vitals 查看体征数据
|
||||
- 明确告知风险等级和需要注意的事项
|
||||
- 高风险时建议尽快就医
|
||||
|
||||
5. 【引导到院】当用户有明确就诊意向或高风险预警时:
|
||||
- 提供科室位置、出诊医生信息
|
||||
- 建议用户联系前台预约
|
||||
|
||||
## 策略不是互斥的,你可以在一轮对话中自然切换。
|
||||
## 永远不要:推荐具体药物、给出明确诊断、替代医生建议。
|
||||
## 如果没有可用的工具数据,就基于常识回答,并建议用户咨询医生。"#;
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/ai/chat",
|
||||
@@ -96,6 +66,9 @@ where
|
||||
|
||||
let ai_state = AiState::from_ref(&state);
|
||||
|
||||
// 从 settings 表加载 AI 配置(替代硬编码)
|
||||
let config = config_resolver::load_ai_config(ctx.tenant_id, &ai_state.db).await;
|
||||
|
||||
// 构建 Agent 消息历史
|
||||
let mut messages = vec![];
|
||||
|
||||
@@ -152,18 +125,34 @@ where
|
||||
health_provider: ai_state.health_provider.clone(),
|
||||
};
|
||||
|
||||
let run_params = AgentRunParams {
|
||||
model: config.agent.model,
|
||||
temperature: config.agent.temperature,
|
||||
max_tokens: config.agent.max_tokens,
|
||||
max_iterations: config.agent.max_iterations,
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
tenant_id = %ctx.tenant_id,
|
||||
user_id = %ctx.user_id,
|
||||
patient_id = ?body.patient_id,
|
||||
msg_len = message.len(),
|
||||
model = %run_params.model,
|
||||
temperature = run_params.temperature,
|
||||
max_tokens = run_params.max_tokens,
|
||||
max_iterations = run_params.max_iterations,
|
||||
"AI Agent chat request"
|
||||
);
|
||||
|
||||
// 执行 Agent ReAct 循环
|
||||
let orchestrator = AgentOrchestrator::new(provider_arc, std::sync::Arc::new(registry));
|
||||
let result = orchestrator
|
||||
.run(SYSTEM_PROMPT, &mut messages, &tool_ctx)
|
||||
.run(
|
||||
&config.agent.system_prompt,
|
||||
&mut messages,
|
||||
&tool_ctx,
|
||||
&run_params,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "AI Agent run failed");
|
||||
|
||||
135
crates/erp-ai/src/handler/config_handler.rs
Normal file
135
crates/erp-ai/src/handler/config_handler.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, FromRef, State};
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::config_resolver;
|
||||
use crate::state::AiState;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/ai/config",
|
||||
responses((status = 200, description = "获取 AI 配置")),
|
||||
tag = "AI 配置",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn get_config<S>(
|
||||
State(state): State<S>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<config_resolver::AiConfig>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.config.read")?;
|
||||
let ai_state = AiState::from_ref(&state);
|
||||
let config = config_resolver::load_ai_config(ctx.tenant_id, &ai_state.db).await;
|
||||
Ok(Json(ApiResponse::ok(config)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdateConfigBody {
|
||||
pub config: config_resolver::AiConfig,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/ai/config",
|
||||
request_body = UpdateConfigBody,
|
||||
responses((status = 200, description = "更新 AI 配置")),
|
||||
tag = "AI 配置",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn update_config<S>(
|
||||
State(state): State<S>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(body): Json<UpdateConfigBody>,
|
||||
) -> Result<Json<ApiResponse<config_resolver::AiConfig>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.config.manage")?;
|
||||
|
||||
let ai_state = AiState::from_ref(&state);
|
||||
|
||||
// 验证配置值范围
|
||||
validate_config(&body.config)?;
|
||||
|
||||
config_resolver::save_ai_config(
|
||||
&body.config,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&ai_state.db,
|
||||
&ai_state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 返回保存后的配置
|
||||
let config = config_resolver::load_ai_config(ctx.tenant_id, &ai_state.db).await;
|
||||
Ok(Json(ApiResponse::ok(config)))
|
||||
}
|
||||
|
||||
/// 获取 AI 配置的默认值(用于前端初始化表单)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/ai/config/defaults",
|
||||
responses((status = 200, description = "AI 配置默认值")),
|
||||
tag = "AI 配置",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn get_config_defaults<S>(
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<config_resolver::AiConfig>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.config.read")?;
|
||||
Ok(Json(ApiResponse::ok(config_resolver::AiConfig::default())))
|
||||
}
|
||||
|
||||
fn validate_config(config: &config_resolver::AiConfig) -> Result<(), erp_core::error::AppError> {
|
||||
if config.agent.model.trim().is_empty() {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"Agent 模型名称不能为空".into(),
|
||||
));
|
||||
}
|
||||
if config.agent.temperature < 0.0 || config.agent.temperature > 2.0 {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"Agent 温度参数必须在 0.0 ~ 2.0 之间".into(),
|
||||
));
|
||||
}
|
||||
if config.agent.max_tokens == 0 || config.agent.max_tokens > 65536 {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"Agent 最大 token 数必须在 1 ~ 65536 之间".into(),
|
||||
));
|
||||
}
|
||||
if config.agent.max_iterations == 0 || config.agent.max_iterations > 20 {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"Agent 最大迭代次数必须在 1 ~ 20 之间".into(),
|
||||
));
|
||||
}
|
||||
if config.agent.system_prompt.trim().is_empty() {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"Agent 系统提示词不能为空".into(),
|
||||
));
|
||||
}
|
||||
if config.analysis_defaults.model.trim().is_empty() {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"分析默认模型名称不能为空".into(),
|
||||
));
|
||||
}
|
||||
if config.analysis_defaults.temperature < 0.0 || config.analysis_defaults.temperature > 2.0 {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"分析默认温度参数必须在 0.0 ~ 2.0 之间".into(),
|
||||
));
|
||||
}
|
||||
if config.analysis_defaults.max_tokens == 0 || config.analysis_defaults.max_tokens > 65536 {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"分析默认最大 token 数必须在 1 ~ 65536 之间".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -8,10 +8,12 @@ use futures::StreamExt;
|
||||
use serde::Deserialize;
|
||||
use std::convert::Infallible;
|
||||
|
||||
use crate::config_resolver;
|
||||
use crate::dto::{AnalysisSseEvent, AnalysisType};
|
||||
use crate::state::AiState;
|
||||
|
||||
pub mod chat_handler;
|
||||
pub mod config_handler;
|
||||
pub mod insight_handler;
|
||||
pub mod risk_handler;
|
||||
pub mod rule_handler;
|
||||
@@ -19,6 +21,32 @@ pub mod suggestion_handler;
|
||||
|
||||
// === 分析请求 Body ===
|
||||
|
||||
/// 从 prompt.model_config 解析模型参数,缺失字段用 AI 配置默认值填充
|
||||
async fn resolve_model_config(
|
||||
model_config: &serde_json::Value,
|
||||
tenant_id: uuid::Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> (String, f32, u32) {
|
||||
let defaults = config_resolver::load_ai_config(tenant_id, db).await;
|
||||
let analysis = &defaults.analysis_defaults;
|
||||
|
||||
let model = model_config
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&analysis.model)
|
||||
.to_string();
|
||||
let temperature = model_config
|
||||
.get("temperature")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(analysis.temperature as f64) as f32;
|
||||
let max_tokens = model_config
|
||||
.get("max_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(analysis.max_tokens as u64) as u32;
|
||||
|
||||
(model, temperature, max_tokens)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AnalyzeBody {
|
||||
pub report_id: Option<uuid::Uuid>,
|
||||
@@ -69,12 +97,8 @@ where
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
let model = model_config["model"]
|
||||
.as_str()
|
||||
.unwrap_or("claude-sonnet-4-6")
|
||||
.to_string();
|
||||
let temperature = model_config["temperature"].as_f64().unwrap_or(0.3) as f32;
|
||||
let max_tokens = model_config["max_tokens"].as_u64().unwrap_or(2048) as u32;
|
||||
let (model, temperature, max_tokens) =
|
||||
resolve_model_config(model_config, ctx.tenant_id, &state.db).await;
|
||||
|
||||
let (stream, analysis_id, _provider_name) = state
|
||||
.analysis
|
||||
@@ -168,12 +192,8 @@ where
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
let model = model_config["model"]
|
||||
.as_str()
|
||||
.unwrap_or("claude-sonnet-4-6")
|
||||
.to_string();
|
||||
let temperature = model_config["temperature"].as_f64().unwrap_or(0.3) as f32;
|
||||
let max_tokens = model_config["max_tokens"].as_u64().unwrap_or(2048) as u32;
|
||||
let (model, temperature, max_tokens) =
|
||||
resolve_model_config(model_config, ctx.tenant_id, &state.db).await;
|
||||
|
||||
let (stream, analysis_id, _) = state
|
||||
.analysis
|
||||
@@ -244,12 +264,8 @@ where
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
let model = model_config["model"]
|
||||
.as_str()
|
||||
.unwrap_or("claude-sonnet-4-6")
|
||||
.to_string();
|
||||
let temperature = model_config["temperature"].as_f64().unwrap_or(0.3) as f32;
|
||||
let max_tokens = model_config["max_tokens"].as_u64().unwrap_or(2048) as u32;
|
||||
let (model, temperature, max_tokens) =
|
||||
resolve_model_config(model_config, ctx.tenant_id, &state.db).await;
|
||||
|
||||
let (stream, analysis_id, _) = state
|
||||
.analysis
|
||||
@@ -327,12 +343,8 @@ where
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
let model = model_config["model"]
|
||||
.as_str()
|
||||
.unwrap_or("claude-sonnet-4-6")
|
||||
.to_string();
|
||||
let temperature = model_config["temperature"].as_f64().unwrap_or(0.3) as f32;
|
||||
let max_tokens = model_config["max_tokens"].as_u64().unwrap_or(2048) as u32;
|
||||
let (model, temperature, max_tokens) =
|
||||
resolve_model_config(model_config, ctx.tenant_id, &state.db).await;
|
||||
|
||||
let (stream, analysis_id, _) = state
|
||||
.analysis
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod agent;
|
||||
pub mod config;
|
||||
pub mod config_resolver;
|
||||
pub mod copilot;
|
||||
pub mod dto;
|
||||
pub mod entity;
|
||||
|
||||
@@ -107,6 +107,19 @@ impl ErpModule for AiModule {
|
||||
description: "向 AI 客服发送消息".into(),
|
||||
module: "ai".into(),
|
||||
},
|
||||
// AI 配置管理权限
|
||||
PermissionDescriptor {
|
||||
code: "ai.config.read".into(),
|
||||
name: "查看 AI 配置".into(),
|
||||
description: "查看 AI 模型和参数配置".into(),
|
||||
module: "ai".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "ai.config.manage".into(),
|
||||
name: "管理 AI 配置".into(),
|
||||
description: "修改 AI 模型、温度、Token 等参数配置".into(),
|
||||
module: "ai".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "ai.chat.session.list".into(),
|
||||
name: "查看 AI 会话列表".into(),
|
||||
@@ -385,6 +398,18 @@ impl AiModule {
|
||||
"/ai/chat",
|
||||
axum::routing::post(crate::handler::chat_handler::chat),
|
||||
)
|
||||
.route(
|
||||
"/ai/config",
|
||||
axum::routing::get(crate::handler::config_handler::get_config),
|
||||
)
|
||||
.route(
|
||||
"/ai/config",
|
||||
axum::routing::put(crate::handler::config_handler::update_config),
|
||||
)
|
||||
.route(
|
||||
"/ai/config/defaults",
|
||||
axum::routing::get(crate::handler::config_handler::get_config_defaults),
|
||||
)
|
||||
.route(
|
||||
"/ai/analyze/lab-report",
|
||||
axum::routing::post(crate::handler::stream_lab_report),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use async_trait::async_trait;
|
||||
use erp_ai::agent::orchestrator::AgentRunParams;
|
||||
use erp_ai::agent::orchestrator::AgentRunResult;
|
||||
use erp_ai::agent::tool::{AgentTool, DisplayHint, ToolContext, ToolResult};
|
||||
use erp_ai::agent::tools::QueryPatientVitalsTool;
|
||||
@@ -215,7 +216,7 @@ async fn test_agent_direct_reply_no_tool_call() {
|
||||
|
||||
let ctx = make_tool_ctx(None);
|
||||
let result = orchestrator
|
||||
.run("你是助手", &mut messages, &ctx)
|
||||
.run("你是助手", &mut messages, &ctx, &AgentRunParams::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -240,7 +241,7 @@ async fn test_agent_tool_call_flow() {
|
||||
|
||||
let ctx = make_tool_ctx(Some(Uuid::now_v7()));
|
||||
let result = orchestrator
|
||||
.run("你是助手", &mut messages, &ctx)
|
||||
.run("你是助手", &mut messages, &ctx, &AgentRunParams::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -26,7 +26,10 @@ pub async fn safe_aggregate<T: Default, E: std::fmt::Display>(
|
||||
label: &str,
|
||||
) -> T {
|
||||
match fut.await {
|
||||
Ok(v) => v,
|
||||
Ok(v) => {
|
||||
tracing::debug!("聚合子查询 [{label}] 成功");
|
||||
v
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("聚合子查询 [{label}] 失败,使用默认值: {e}");
|
||||
T::default()
|
||||
|
||||
@@ -67,6 +67,9 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.alert-rules.manage")?;
|
||||
body.sanitize();
|
||||
if body.name.trim().is_empty() {
|
||||
return Err(AppError::Validation("规则名称不能为空".into()));
|
||||
}
|
||||
let rule = alert_rule_service::create_rule(&state, ctx.tenant_id, ctx.user_id, body).await?;
|
||||
Ok(axum::Json(ApiResponse::ok(rule)))
|
||||
}
|
||||
|
||||
@@ -108,6 +108,9 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.articles.manage")?;
|
||||
req.sanitize();
|
||||
if req.title.trim().is_empty() {
|
||||
return Err(AppError::Validation("文章标题不能为空".into()));
|
||||
}
|
||||
let result =
|
||||
article_service::create_article(&state, ctx.tenant_id, Some(ctx.user_id), req.0).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
|
||||
@@ -34,6 +34,9 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.articles.manage")?;
|
||||
req.sanitize();
|
||||
if req.name.trim().is_empty() {
|
||||
return Err(AppError::Validation("标签名称不能为空".into()));
|
||||
}
|
||||
let result =
|
||||
article_tag_service::create_tag(&state, ctx.tenant_id, Some(ctx.user_id), req.0).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
|
||||
@@ -66,6 +66,9 @@ where
|
||||
require_permission(&ctx, "health.doctor.manage")?;
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
if req.name.trim().is_empty() {
|
||||
return Err(AppError::Validation("医生姓名不能为空".into()));
|
||||
}
|
||||
let result =
|
||||
doctor_service::create_doctor(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
//! 统计 Service — 健康数据统计
|
||||
|
||||
use sea_orm::{
|
||||
ColumnTrait, EntityTrait, FromQueryResult, PaginatorTrait, QueryFilter, sea_query::Expr,
|
||||
ColumnTrait, DatabaseBackend, EntityTrait, FromQueryResult, PaginatorTrait, QueryFilter,
|
||||
Statement,
|
||||
};
|
||||
|
||||
use erp_core::error::AppResult;
|
||||
|
||||
use crate::dto::stats_dto::*;
|
||||
use crate::entity::{appointment, lab_report, patient, vital_signs};
|
||||
use crate::entity::{appointment, lab_report, patient};
|
||||
use crate::state::HealthState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -26,14 +27,7 @@ pub async fn get_lab_report_statistics(
|
||||
.count(db)
|
||||
.await?;
|
||||
|
||||
let this_month = lab_report::Entity::find()
|
||||
.filter(lab_report::Column::TenantId.eq(tenant_id))
|
||||
.filter(lab_report::Column::DeletedAt.is_null())
|
||||
.filter(
|
||||
Expr::col(lab_report::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())")),
|
||||
)
|
||||
.count(db)
|
||||
.await?;
|
||||
let this_month = count_lab_reports_since(db, tenant_id, "date_trunc('month', NOW())").await?;
|
||||
|
||||
let pending_review = lab_report::Entity::find()
|
||||
.filter(lab_report::Column::TenantId.eq(tenant_id))
|
||||
@@ -63,7 +57,7 @@ pub async fn get_lab_report_statistics(
|
||||
|
||||
Ok(LabReportStatisticsResp {
|
||||
total_reports: total_reports as i64,
|
||||
this_month: this_month as i64,
|
||||
this_month,
|
||||
type_distribution,
|
||||
abnormal_items,
|
||||
pending_review: pending_review as i64,
|
||||
@@ -83,14 +77,7 @@ pub async fn get_appointment_statistics(
|
||||
.count(db)
|
||||
.await?;
|
||||
|
||||
let this_month = appointment::Entity::find()
|
||||
.filter(appointment::Column::TenantId.eq(tenant_id))
|
||||
.filter(appointment::Column::DeletedAt.is_null())
|
||||
.filter(
|
||||
Expr::col(appointment::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())")),
|
||||
)
|
||||
.count(db)
|
||||
.await?;
|
||||
let this_month = count_appointments_since(db, tenant_id, "date_trunc('month', NOW())").await?;
|
||||
|
||||
let status_distribution = count_by_field(
|
||||
db,
|
||||
@@ -112,15 +99,7 @@ pub async fn get_appointment_statistics(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cancelled = appointment::Entity::find()
|
||||
.filter(appointment::Column::TenantId.eq(tenant_id))
|
||||
.filter(appointment::Column::DeletedAt.is_null())
|
||||
.filter(
|
||||
Expr::col(appointment::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())")),
|
||||
)
|
||||
.filter(appointment::Column::Status.eq("cancelled"))
|
||||
.count(db)
|
||||
.await?;
|
||||
let cancelled = count_appointments_cancelled(db, tenant_id).await?;
|
||||
|
||||
let cancel_rate = if this_month > 0 {
|
||||
(cancelled as f64 / this_month as f64) * 100.0
|
||||
@@ -130,7 +109,7 @@ pub async fn get_appointment_statistics(
|
||||
|
||||
Ok(AppointmentStatisticsResp {
|
||||
total_appointments: total_appointments as i64,
|
||||
this_month: this_month as i64,
|
||||
this_month,
|
||||
status_distribution,
|
||||
type_distribution,
|
||||
cancel_rate,
|
||||
@@ -149,14 +128,8 @@ pub async fn get_vital_signs_report_rate(
|
||||
.count(db)
|
||||
.await?;
|
||||
|
||||
let total_records = vital_signs::Entity::find()
|
||||
.filter(vital_signs::Column::TenantId.eq(tenant_id))
|
||||
.filter(vital_signs::Column::DeletedAt.is_null())
|
||||
.filter(
|
||||
Expr::col(vital_signs::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())")),
|
||||
)
|
||||
.count(db)
|
||||
.await?;
|
||||
let total_records =
|
||||
count_vital_signs_since(db, tenant_id, "date_trunc('month', NOW())").await?;
|
||||
|
||||
let reported_patients = count_distinct_patients_vital_signs(db, tenant_id).await?;
|
||||
|
||||
@@ -166,13 +139,13 @@ pub async fn get_vital_signs_report_rate(
|
||||
0.0
|
||||
};
|
||||
|
||||
let daily_trend = compute_daily_report_rate(db, tenant_id).await?;
|
||||
let daily_trend = compute_daily_report_rate(db, tenant_id, total_patients).await?;
|
||||
|
||||
Ok(VitalSignsReportRateResp {
|
||||
total_patients: total_patients as i64,
|
||||
reported_patients: reported_patients as i64,
|
||||
report_rate,
|
||||
total_records: total_records as i64,
|
||||
total_records,
|
||||
daily_trend,
|
||||
})
|
||||
}
|
||||
@@ -196,25 +169,111 @@ pub async fn get_health_data_stats(
|
||||
// 辅助查询
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct CountRow {
|
||||
count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct NameValueRow {
|
||||
name: String,
|
||||
value: i64,
|
||||
}
|
||||
|
||||
/// 使用原始 SQL 查询指定时间之后的化验报告数量
|
||||
async fn count_lab_reports_since(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
date_expr: &str,
|
||||
) -> AppResult<i64> {
|
||||
let sql = format!(
|
||||
"SELECT COUNT(*)::int8 AS count FROM lab_report \
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL \
|
||||
AND created_at >= {date_expr}"
|
||||
);
|
||||
let row: Option<CountRow> = FromQueryResult::find_by_statement(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
&sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.one(db)
|
||||
.await?;
|
||||
Ok(row.map(|r| r.count).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// 使用原始 SQL 查询指定时间之后的预约数量
|
||||
async fn count_appointments_since(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
date_expr: &str,
|
||||
) -> AppResult<i64> {
|
||||
let sql = format!(
|
||||
"SELECT COUNT(*)::int8 AS count FROM appointment \
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL \
|
||||
AND created_at >= {date_expr}"
|
||||
);
|
||||
let row: Option<CountRow> = FromQueryResult::find_by_statement(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
&sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.one(db)
|
||||
.await?;
|
||||
Ok(row.map(|r| r.count).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// 本月已取消的预约数
|
||||
async fn count_appointments_cancelled(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
) -> AppResult<i64> {
|
||||
let sql = r#"
|
||||
SELECT COUNT(*)::int8 AS count FROM appointment
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||
AND created_at >= date_trunc('month', NOW())
|
||||
AND status = 'cancelled'
|
||||
"#;
|
||||
let row: Option<CountRow> = FromQueryResult::find_by_statement(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.one(db)
|
||||
.await?;
|
||||
Ok(row.map(|r| r.count).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// 使用原始 SQL 查询指定时间之后的体征记录数量
|
||||
async fn count_vital_signs_since(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
date_expr: &str,
|
||||
) -> AppResult<i64> {
|
||||
let sql = format!(
|
||||
"SELECT COUNT(*)::int8 AS count FROM vital_signs \
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL \
|
||||
AND created_at >= {date_expr}"
|
||||
);
|
||||
let row: Option<CountRow> = FromQueryResult::find_by_statement(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
&sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.one(db)
|
||||
.await?;
|
||||
Ok(row.map(|r| r.count).unwrap_or(0))
|
||||
}
|
||||
|
||||
async fn count_by_field(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
sql: &str,
|
||||
) -> AppResult<Vec<NameValue>> {
|
||||
let rows: Vec<NameValueRow> =
|
||||
sea_orm::FromQueryResult::find_by_statement(sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.all(db)
|
||||
.await?;
|
||||
let rows: Vec<NameValueRow> = FromQueryResult::find_by_statement(
|
||||
Statement::from_sql_and_values(DatabaseBackend::Postgres, sql, [tenant_id.into()]),
|
||||
)
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
@@ -246,14 +305,11 @@ async fn count_abnormal_lab_items(
|
||||
total: Option<i64>,
|
||||
}
|
||||
|
||||
let result: Option<AbnormalCount> =
|
||||
sea_orm::FromQueryResult::find_by_statement(sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.one(db)
|
||||
.await?;
|
||||
let result: Option<AbnormalCount> = FromQueryResult::find_by_statement(
|
||||
Statement::from_sql_and_values(DatabaseBackend::Postgres, sql, [tenant_id.into()]),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
Ok(result.and_then(|r| r.total).unwrap_or(0))
|
||||
}
|
||||
@@ -274,14 +330,11 @@ async fn count_distinct_patients_vital_signs(
|
||||
cnt: i64,
|
||||
}
|
||||
|
||||
let result: Option<DistinctCount> =
|
||||
sea_orm::FromQueryResult::find_by_statement(sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.one(db)
|
||||
.await?;
|
||||
let result: Option<DistinctCount> = FromQueryResult::find_by_statement(
|
||||
Statement::from_sql_and_values(DatabaseBackend::Postgres, sql, [tenant_id.into()]),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|r| r.cnt as u64).unwrap_or(0))
|
||||
}
|
||||
@@ -289,6 +342,7 @@ async fn count_distinct_patients_vital_signs(
|
||||
async fn compute_daily_report_rate(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
total_patients: u64,
|
||||
) -> AppResult<Vec<DailyReportRate>> {
|
||||
let sql = r#"
|
||||
SELECT d::date::text AS date,
|
||||
@@ -313,20 +367,13 @@ async fn compute_daily_report_rate(
|
||||
total: i64,
|
||||
}
|
||||
|
||||
let rows: Vec<DailyRow> =
|
||||
sea_orm::FromQueryResult::find_by_statement(sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
let total_patients = patient::Entity::find()
|
||||
.filter(patient::Column::TenantId.eq(tenant_id))
|
||||
.filter(patient::Column::DeletedAt.is_null())
|
||||
.count(db)
|
||||
.await?;
|
||||
let rows: Vec<DailyRow> = FromQueryResult::find_by_statement(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
//! 统计 Service — 基础运营统计辅助查询
|
||||
|
||||
use sea_orm::{
|
||||
ColumnTrait, EntityTrait, FromQueryResult, PaginatorTrait, QueryFilter, sea_query::Expr,
|
||||
ColumnTrait, DatabaseBackend, EntityTrait, FromQueryResult, PaginatorTrait, QueryFilter,
|
||||
Statement,
|
||||
};
|
||||
|
||||
use erp_core::error::AppResult;
|
||||
|
||||
use crate::dto::stats_dto::*;
|
||||
use crate::entity::{consultation_session, patient, points_transaction};
|
||||
use crate::entity::{consultation_session, patient};
|
||||
use crate::state::HealthState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -26,34 +27,16 @@ pub async fn get_patient_statistics(
|
||||
.count(db)
|
||||
.await?;
|
||||
|
||||
let new_this_month = patient::Entity::find()
|
||||
.filter(patient::Column::TenantId.eq(tenant_id))
|
||||
.filter(patient::Column::DeletedAt.is_null())
|
||||
.filter(Expr::col(patient::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())")))
|
||||
.count(db)
|
||||
.await?;
|
||||
|
||||
let new_this_week = patient::Entity::find()
|
||||
.filter(patient::Column::TenantId.eq(tenant_id))
|
||||
.filter(patient::Column::DeletedAt.is_null())
|
||||
.filter(Expr::col(patient::Column::CreatedAt).gte(Expr::cust("date_trunc('week', NOW())")))
|
||||
.count(db)
|
||||
.await?;
|
||||
|
||||
let active_this_month = points_transaction::Entity::find()
|
||||
.filter(points_transaction::Column::TenantId.eq(tenant_id))
|
||||
.filter(
|
||||
Expr::col(points_transaction::Column::CreatedAt)
|
||||
.gte(Expr::cust("date_trunc('month', NOW())")),
|
||||
)
|
||||
.count(db)
|
||||
.await?;
|
||||
// 使用原始 SQL 避免 SeaORM Expr::cust 在 date_trunc('week',...) 下生成不兼容 SQL
|
||||
let new_this_month = count_patients_since(db, tenant_id, "date_trunc('month', NOW())").await?;
|
||||
let new_this_week = count_patients_since(db, tenant_id, "date_trunc('week', NOW())").await?;
|
||||
let active_this_month = count_active_patients(db, tenant_id).await?;
|
||||
|
||||
Ok(PatientStatisticsResp {
|
||||
total_patients: total as i64,
|
||||
new_this_month: new_this_month as i64,
|
||||
new_this_week: new_this_week as i64,
|
||||
active_this_month: active_this_month as i64,
|
||||
new_this_month,
|
||||
new_this_week,
|
||||
active_this_month,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -76,15 +59,7 @@ pub async fn get_consultation_statistics(
|
||||
.count(db)
|
||||
.await?;
|
||||
|
||||
let this_month = consultation_session::Entity::find()
|
||||
.filter(consultation_session::Column::TenantId.eq(tenant_id))
|
||||
.filter(consultation_session::Column::DeletedAt.is_null())
|
||||
.filter(
|
||||
Expr::col(consultation_session::Column::CreatedAt)
|
||||
.gte(Expr::cust("date_trunc('month', NOW())")),
|
||||
)
|
||||
.count(db)
|
||||
.await?;
|
||||
let this_month = count_consultations_since(db, tenant_id, "date_trunc('month', NOW())").await?;
|
||||
|
||||
let avg_response_time_minutes = match compute_avg_response_time(db, tenant_id).await {
|
||||
Ok(v) => v,
|
||||
@@ -98,7 +73,7 @@ pub async fn get_consultation_statistics(
|
||||
total_sessions: total_sessions as i64,
|
||||
pending_reply: pending_reply as i64,
|
||||
avg_response_time_minutes,
|
||||
this_month: this_month as i64,
|
||||
this_month,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -165,6 +140,73 @@ pub async fn get_follow_up_statistics(
|
||||
// 辅助查询
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct CountRow {
|
||||
count: i64,
|
||||
}
|
||||
|
||||
/// 查询指定日期条件之后创建的患者数量(使用原始 SQL 避免 SeaORM date_trunc 兼容问题)
|
||||
async fn count_patients_since(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
date_expr: &str,
|
||||
) -> AppResult<i64> {
|
||||
let sql = format!(
|
||||
"SELECT COUNT(*)::int8 AS count FROM patient \
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL \
|
||||
AND created_at >= {date_expr}"
|
||||
);
|
||||
let row: Option<CountRow> = FromQueryResult::find_by_statement(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
&sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.one(db)
|
||||
.await?;
|
||||
Ok(row.map(|r| r.count).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// 查询本月活跃患者数(有积分交易记录的患者)
|
||||
async fn count_active_patients(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
) -> AppResult<i64> {
|
||||
let sql = r#"
|
||||
SELECT COUNT(*)::int8 AS count FROM points_transaction
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||
AND created_at >= date_trunc('month', NOW())
|
||||
"#;
|
||||
let row: Option<CountRow> = FromQueryResult::find_by_statement(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.one(db)
|
||||
.await?;
|
||||
Ok(row.map(|r| r.count).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// 查询指定日期条件之后创建的咨询会话数量
|
||||
async fn count_consultations_since(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
date_expr: &str,
|
||||
) -> AppResult<i64> {
|
||||
let sql = format!(
|
||||
"SELECT COUNT(*)::int8 AS count FROM consultation_session \
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL \
|
||||
AND created_at >= {date_expr}"
|
||||
);
|
||||
let row: Option<CountRow> = FromQueryResult::find_by_statement(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
&sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.one(db)
|
||||
.await?;
|
||||
Ok(row.map(|r| r.count).unwrap_or(0))
|
||||
}
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct AvgResponseTime {
|
||||
avg_minutes: Option<f64>,
|
||||
|
||||
@@ -150,6 +150,8 @@ mod m20260513_000145_seed_missing_permissions;
|
||||
mod m20260515_000146_seed_menu_permissions_phase2;
|
||||
mod m20260516_000147_seed_ai_chat_permission;
|
||||
mod m20260518_000148_create_ai_chat_tables;
|
||||
mod m20260518_000149_fix_admin_permissions;
|
||||
mod m20260518_000150_seed_ai_config_permission;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -307,6 +309,8 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260515_000146_seed_menu_permissions_phase2::Migration),
|
||||
Box::new(m20260516_000147_seed_ai_chat_permission::Migration),
|
||||
Box::new(m20260518_000148_create_ai_chat_tables::Migration),
|
||||
Box::new(m20260518_000149_fix_admin_permissions::Migration),
|
||||
Box::new(m20260518_000150_seed_ai_config_permission::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
//! 修复 admin 角色权限绑定
|
||||
//!
|
||||
//! 根因链:
|
||||
//! 1. m20260506_000126 对部分角色执行了软删除(SET deleted_at = NOW())
|
||||
//! 2. m20260508_000131 执行 `DELETE FROM role_permissions WHERE deleted_at IS NOT NULL`
|
||||
//! 物理删除了所有被软删除的记录
|
||||
//! 3. m20260508_000131 只重新分配了 doctor/nurse/operator 的权限,遗漏了 admin 角色
|
||||
//! 4. 后续的 assign_permissions API 调用可能在内部先软删除再 INSERT,
|
||||
//! INSERT 失败时 admin 权限全部丢失
|
||||
//!
|
||||
//! 本迁移:
|
||||
//! - Step 1: 恢复所有被软删除的 admin role_permissions(deleted_at IS NOT NULL → NULL)
|
||||
//! - Step 2: 插入所有缺失的 admin role_permissions(ON CONFLICT DO NOTHING 保证幂等)
|
||||
//!
|
||||
//! 覆盖范围:全系统 128 个权限码(auth/config/workflow/message/plugin/health/ai/copilot/points)
|
||||
|
||||
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();
|
||||
|
||||
// ================================================================
|
||||
// Step 1: 恢复被软删除的 admin role_permissions
|
||||
// ================================================================
|
||||
// 如果 admin 的某些权限记录仍然存在但被软删除了,恢复它们
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
UPDATE role_permissions rp
|
||||
SET deleted_at = NULL, updated_at = NOW(), version = rp.version + 1
|
||||
FROM roles r
|
||||
WHERE rp.role_id = r.id
|
||||
AND r.code = 'admin'
|
||||
AND r.deleted_at IS NULL
|
||||
AND rp.deleted_at IS NOT NULL
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// ================================================================
|
||||
// Step 2: 插入缺失的 admin role_permissions
|
||||
// ================================================================
|
||||
// 将 permissions 表中所有未被软删除的权限绑定到 admin 角色
|
||||
// ON CONFLICT (role_id, permission_id) DO NOTHING — 已存在(含刚恢复的)的跳过
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope,
|
||||
created_at, updated_at, created_by, updated_by,
|
||||
deleted_at, version)
|
||||
SELECT r.id, p.id, r.tenant_id, 'all',
|
||||
NOW(), NOW(), r.id, r.id,
|
||||
NULL, 1
|
||||
FROM roles r
|
||||
JOIN permissions p ON p.tenant_id = r.tenant_id AND p.deleted_at IS NULL
|
||||
WHERE r.code = 'admin' AND r.deleted_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM role_permissions rp
|
||||
WHERE rp.role_id = r.id
|
||||
AND rp.permission_id = p.id
|
||||
AND rp.deleted_at IS NULL
|
||||
)
|
||||
ON CONFLICT (role_id, permission_id) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 不回滚 — 这是修复性迁移,admin 应该始终拥有全部权限
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! 新增 ai.config.read / ai.config.manage 权限码 + AI 配置管理菜单
|
||||
//!
|
||||
//! AI 配置(模型/温度/Token/迭代次数/系统提示词)需管理员在前端可视化管理。
|
||||
|
||||
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-00000000-00000000-000000000000";
|
||||
|
||||
// 注册 ai.config.read 和 ai.config.manage 权限到所有租户
|
||||
for (code, name, desc) in [
|
||||
("ai.config.read", "查看 AI 配置", "查看 AI 模型和参数配置"),
|
||||
(
|
||||
"ai.config.manage",
|
||||
"管理 AI 配置",
|
||||
"修改 AI 模型、温度、Token 等参数配置",
|
||||
),
|
||||
] {
|
||||
db.execute_unprepared(&format!(
|
||||
r#"
|
||||
INSERT INTO permissions (id, tenant_id, code, name, resource, action, description,
|
||||
created_at, updated_at, created_by, updated_by, deleted_at, version)
|
||||
SELECT gen_random_uuid(), t.id, '{code}', '{name}', 'ai', '{code}', '{desc}',
|
||||
NOW(), NOW(), '{sys}', '{sys}', NULL, 1
|
||||
FROM tenant t
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM permissions p
|
||||
WHERE p.code = '{code}' AND p.tenant_id = t.id AND p.deleted_at IS NULL
|
||||
)
|
||||
"#
|
||||
)).await?;
|
||||
|
||||
// 绑定到管理员角色
|
||||
db.execute_unprepared(&format!(
|
||||
r#"
|
||||
INSERT INTO role_permissions (id, tenant_id, role_id, permission_id, created_at, updated_at, created_by, updated_by, deleted_at, version)
|
||||
SELECT gen_random_uuid(), t.id, r.id, p.id, NOW(), NOW(), '{sys}', '{sys}', NULL, 1
|
||||
FROM tenant t
|
||||
JOIN roles r ON r.tenant_id = t.id AND r.code = 'admin' AND r.deleted_at IS NULL
|
||||
JOIN permissions p ON p.tenant_id = t.id AND p.code = '{code}' AND p.deleted_at IS NULL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM role_permissions rp
|
||||
WHERE rp.role_id = r.id AND rp.permission_id = p.id AND rp.deleted_at IS NULL
|
||||
)
|
||||
"#
|
||||
)).await?;
|
||||
}
|
||||
|
||||
// 添加 AI 配置管理菜单
|
||||
db.execute_unprepared(&format!(
|
||||
r#"
|
||||
INSERT INTO menus (id, tenant_id, parent_id, title, path, icon, sort_order, visible,
|
||||
menu_type, permission, created_at, updated_at, created_by, updated_by, deleted_at, version)
|
||||
SELECT gen_random_uuid(), t.id,
|
||||
(SELECT m.id FROM menus m WHERE m.tenant_id = t.id AND m.path = '/health/ai-prompts' AND m.deleted_at IS NULL LIMIT 1),
|
||||
'AI 配置', '/health/ai-config', 'SettingOutlined', 60, true,
|
||||
'menu', 'ai.config.read',
|
||||
NOW(), NOW(), '{sys}', '{sys}', NULL, 1
|
||||
FROM tenant t
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM menus m
|
||||
WHERE m.path = '/health/ai-config' AND m.tenant_id = t.id AND m.deleted_at IS NULL
|
||||
)
|
||||
"#
|
||||
)).await?;
|
||||
|
||||
// 菜单绑定 admin 角色
|
||||
db.execute_unprepared(&format!(
|
||||
r#"
|
||||
INSERT INTO menu_roles (id, menu_id, role_id, created_at, updated_at, created_by, updated_by, deleted_at, version)
|
||||
SELECT gen_random_uuid(), m.id, r.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 = '/health/ai-config' 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(())
|
||||
}
|
||||
}
|
||||
@@ -786,7 +786,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
.layer(axum::middleware::from_fn(
|
||||
middleware::metrics::metrics_middleware,
|
||||
))
|
||||
.layer(cors);
|
||||
.layer(cors)
|
||||
.layer(axum::middleware::from_fn(security_headers_middleware));
|
||||
|
||||
// Start Prometheus metrics exporter on a separate port
|
||||
let metrics_port = state.config.server.metrics_port;
|
||||
@@ -904,6 +905,30 @@ fn build_cors_layer(allowed_origins: &str) -> tower_http::cors::CorsLayer {
|
||||
.allow_credentials(true)
|
||||
}
|
||||
|
||||
async fn security_headers_middleware(
|
||||
req: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
use axum::http::{HeaderValue, header};
|
||||
|
||||
let mut response = next.run(req).await;
|
||||
let headers = response.headers_mut();
|
||||
headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
|
||||
headers.insert(
|
||||
header::X_CONTENT_TYPE_OPTIONS,
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
headers.insert(
|
||||
header::HeaderName::from_static("x-xss-protection"),
|
||||
HeaderValue::from_static("1; mode=block"),
|
||||
);
|
||||
headers.insert(
|
||||
header::HeaderName::from_static("referrer-policy"),
|
||||
HeaderValue::from_static("strict-origin-when-cross-origin"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
tokio::signal::ctrl_c()
|
||||
|
||||
Reference in New Issue
Block a user