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:
iven
2026-05-18 10:24:40 +08:00
parent 38b0d91407
commit d623f8b2ff
36 changed files with 5564 additions and 189 deletions

View File

@@ -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,
&params.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: "(系统提示:已收集足够信息,请直接总结回复用户,不要再调用工具)"

View 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);
}
}

View File

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

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

View File

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

View File

@@ -1,5 +1,6 @@
pub mod agent;
pub mod config;
pub mod config_resolver;
pub mod copilot;
pub mod dto;
pub mod entity;

View File

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