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:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user