feat(ai): AI 健康管家 V2 基础设施 — 功能开关 + 角色沙箱准备 + 体征页 AI 趋势分析
- 迁移 000153: 新增 ai_feature_flags / ai_usage_daily / ai_suggestion_feedback 三张表, ai_tenant_configs 增加 billing_enabled 列, seed 12 个功能开关 + 2 个管理权限码 - 新增 FeatureFlagService: 5 分钟缓存 + DB 回退 + 即时更新 - VitalSignsTab 添加 AI 趋势分析按钮 (SSE 流式) - 新增 3 个 Entity (ai_feature_flags / ai_usage_daily / ai_suggestion_feedback) - AiState 扩展 feature_flags 字段 - 设计规格 + 讨论记录文档 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
use erp_core::crypto::{decrypt, encrypt};
|
||||
use sea_orm::ConnectionTrait;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// AI Agent 运行时配置,从 settings 表读取,带编译时默认值
|
||||
@@ -43,14 +45,91 @@ impl Default for AiAnalysisDefaults {
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个 AI 供应商的配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Default)]
|
||||
pub struct AiProviderConfig {
|
||||
pub provider_type: String,
|
||||
pub enabled: bool,
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
impl AiProviderConfig {
|
||||
pub fn claude_default() -> Self {
|
||||
Self {
|
||||
provider_type: "claude".to_string(),
|
||||
enabled: true,
|
||||
base_url: "https://api.anthropic.com".to_string(),
|
||||
api_key: String::new(),
|
||||
model: "claude-sonnet-4-6".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn openai_default() -> Self {
|
||||
Self {
|
||||
provider_type: "openai".to_string(),
|
||||
enabled: false,
|
||||
base_url: "https://api.openai.com".to_string(),
|
||||
api_key: String::new(),
|
||||
model: "gpt-4o".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ollama_default() -> Self {
|
||||
Self {
|
||||
provider_type: "ollama".to_string(),
|
||||
enabled: false,
|
||||
base_url: "http://localhost:11434".to_string(),
|
||||
api_key: String::new(),
|
||||
model: "qwen3:8b".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// API Key 掩码:显示 `****` + 最后4位
|
||||
pub fn mask_api_key(key: &str) -> String {
|
||||
if key.len() <= 4 {
|
||||
"****".to_string()
|
||||
} else {
|
||||
format!("****{}", &key[key.len() - 4..])
|
||||
}
|
||||
}
|
||||
|
||||
/// 加密 API Key(返回 `enc:{base64}` 格式)
|
||||
pub fn encrypt_api_key(plaintext: &str, kek: &[u8; 32]) -> Result<String, String> {
|
||||
if plaintext.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let encrypted = encrypt(kek, plaintext).map_err(|e| e.to_string())?;
|
||||
Ok(format!("{}{}", ENC_PREFIX, encrypted))
|
||||
}
|
||||
|
||||
/// 解密 API Key(接受 `enc:{base64}` 格式或明文)
|
||||
pub fn decrypt_api_key(stored: &str, kek: &[u8; 32]) -> Result<String, String> {
|
||||
if stored.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
if let Some(ciphertext) = stored.strip_prefix(ENC_PREFIX) {
|
||||
decrypt(kek, ciphertext).map_err(|e| e.to_string())
|
||||
} else {
|
||||
// 明文兼容旧数据
|
||||
Ok(stored.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理员可编辑的完整 AI 配置
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AiConfig {
|
||||
pub agent: AiAgentConfig,
|
||||
pub analysis_defaults: AiAnalysisDefaults,
|
||||
#[serde(default)]
|
||||
pub default_provider: String,
|
||||
#[serde(default)]
|
||||
pub providers: HashMap<String, AiProviderConfig>,
|
||||
}
|
||||
|
||||
/// Setting key 常量
|
||||
/// Setting key 常量 — Agent / Analysis
|
||||
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";
|
||||
@@ -60,11 +139,31 @@ 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 配置
|
||||
/// Setting key 常量 — Provider
|
||||
const KEY_PROVIDER_DEFAULT: &str = "ai.provider.default";
|
||||
const KEY_CLAUDE_ENABLED: &str = "ai.provider.claude.enabled";
|
||||
const KEY_CLAUDE_BASE_URL: &str = "ai.provider.claude.base_url";
|
||||
const KEY_CLAUDE_API_KEY: &str = "ai.provider.claude.api_key";
|
||||
const KEY_CLAUDE_MODEL: &str = "ai.provider.claude.model";
|
||||
const KEY_OPENAI_ENABLED: &str = "ai.provider.openai.enabled";
|
||||
const KEY_OPENAI_BASE_URL: &str = "ai.provider.openai.base_url";
|
||||
const KEY_OPENAI_API_KEY: &str = "ai.provider.openai.api_key";
|
||||
const KEY_OPENAI_MODEL: &str = "ai.provider.openai.model";
|
||||
const KEY_OLLAMA_ENABLED: &str = "ai.provider.ollama.enabled";
|
||||
const KEY_OLLAMA_BASE_URL: &str = "ai.provider.ollama.base_url";
|
||||
const KEY_OLLAMA_MODEL: &str = "ai.provider.ollama.model";
|
||||
|
||||
/// API Key 加密前缀
|
||||
const ENC_PREFIX: &str = "enc:";
|
||||
|
||||
/// 从 settings 表批量读取 AI 配置(API Key 解密后掩码返回)
|
||||
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;
|
||||
|
||||
// 获取加密 KEK(开发模式用默认值)
|
||||
let kek = get_dev_kek();
|
||||
|
||||
AiConfig {
|
||||
agent: AiAgentConfig {
|
||||
model: values
|
||||
@@ -108,9 +207,116 @@ pub async fn load_ai_config(tenant_id: Uuid, db: &DatabaseConnection) -> AiConfi
|
||||
.unwrap_or(defaults.analysis_defaults.max_tokens as u64)
|
||||
as u32,
|
||||
},
|
||||
default_provider: values
|
||||
.get(KEY_PROVIDER_DEFAULT)
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("claude")
|
||||
.to_string(),
|
||||
providers: build_providers(&values, &kek),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 settings 值构造 providers(解密后掩码 API Key)
|
||||
fn build_providers(
|
||||
values: &std::collections::HashMap<String, serde_json::Value>,
|
||||
kek: &[u8; 32],
|
||||
) -> HashMap<String, AiProviderConfig> {
|
||||
let mut providers = HashMap::new();
|
||||
|
||||
for (name, default) in [
|
||||
("claude", AiProviderConfig::claude_default()),
|
||||
("openai", AiProviderConfig::openai_default()),
|
||||
("ollama", AiProviderConfig::ollama_default()),
|
||||
] {
|
||||
let enabled_key = match name {
|
||||
"claude" => KEY_CLAUDE_ENABLED,
|
||||
"openai" => KEY_OPENAI_ENABLED,
|
||||
"ollama" => KEY_OLLAMA_ENABLED,
|
||||
_ => continue,
|
||||
};
|
||||
let base_url_key = match name {
|
||||
"claude" => KEY_CLAUDE_BASE_URL,
|
||||
"openai" => KEY_OPENAI_BASE_URL,
|
||||
"ollama" => KEY_OLLAMA_BASE_URL,
|
||||
_ => continue,
|
||||
};
|
||||
let model_key = match name {
|
||||
"claude" => KEY_CLAUDE_MODEL,
|
||||
"openai" => KEY_OPENAI_MODEL,
|
||||
"ollama" => KEY_OLLAMA_MODEL,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let enabled = values
|
||||
.get(enabled_key)
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(default.enabled);
|
||||
|
||||
let base_url = values
|
||||
.get(base_url_key)
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&default.base_url)
|
||||
.to_string();
|
||||
|
||||
let api_key_raw = if name == "ollama" {
|
||||
String::new()
|
||||
} else {
|
||||
let real_api_key_key = if name == "claude" {
|
||||
KEY_CLAUDE_API_KEY
|
||||
} else {
|
||||
KEY_OPENAI_API_KEY
|
||||
};
|
||||
values
|
||||
.get(real_api_key_key)
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
};
|
||||
|
||||
// 解密后掩码
|
||||
let masked_key = if api_key_raw.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
match decrypt_api_key(&api_key_raw, kek) {
|
||||
Ok(plain) => mask_api_key(&plain),
|
||||
Err(_) => mask_api_key(&api_key_raw),
|
||||
}
|
||||
};
|
||||
|
||||
let model = values
|
||||
.get(model_key)
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&default.model)
|
||||
.to_string();
|
||||
|
||||
providers.insert(
|
||||
name.to_string(),
|
||||
AiProviderConfig {
|
||||
provider_type: default.provider_type,
|
||||
enabled,
|
||||
base_url,
|
||||
api_key: masked_key,
|
||||
model,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
providers
|
||||
}
|
||||
|
||||
/// 从 settings 表批量读取 AI 配置(返回原始加密值,用于运行时 provider 加载)
|
||||
pub async fn load_ai_config_raw(
|
||||
tenant_id: Uuid,
|
||||
db: &DatabaseConnection,
|
||||
) -> HashMap<String, serde_json::Value> {
|
||||
read_settings_batch(tenant_id, db).await
|
||||
}
|
||||
|
||||
/// 开发模式默认 KEK
|
||||
fn get_dev_kek() -> [u8; 32] {
|
||||
*erp_core::crypto::PiiCrypto::dev_default().kek()
|
||||
}
|
||||
|
||||
/// 获取所有 AI 配置 key 列表(用于前端展示)
|
||||
pub fn all_config_keys() -> &'static [&'static str] {
|
||||
&[
|
||||
@@ -122,10 +328,22 @@ pub fn all_config_keys() -> &'static [&'static str] {
|
||||
KEY_ANALYSIS_MODEL,
|
||||
KEY_ANALYSIS_TEMPERATURE,
|
||||
KEY_ANALYSIS_MAX_TOKENS,
|
||||
KEY_PROVIDER_DEFAULT,
|
||||
KEY_CLAUDE_ENABLED,
|
||||
KEY_CLAUDE_BASE_URL,
|
||||
KEY_CLAUDE_API_KEY,
|
||||
KEY_CLAUDE_MODEL,
|
||||
KEY_OPENAI_ENABLED,
|
||||
KEY_OPENAI_BASE_URL,
|
||||
KEY_OPENAI_API_KEY,
|
||||
KEY_OPENAI_MODEL,
|
||||
KEY_OLLAMA_ENABLED,
|
||||
KEY_OLLAMA_BASE_URL,
|
||||
KEY_OLLAMA_MODEL,
|
||||
]
|
||||
}
|
||||
|
||||
/// 批量写入 AI 配置到 settings 表
|
||||
/// 批量写入 AI 配置到 settings 表(API Key 加密存储)
|
||||
pub async fn save_ai_config(
|
||||
config: &AiConfig,
|
||||
tenant_id: Uuid,
|
||||
@@ -133,7 +351,9 @@ pub async fn save_ai_config(
|
||||
db: &DatabaseConnection,
|
||||
event_bus: &erp_core::events::EventBus,
|
||||
) -> Result<(), erp_core::error::AppError> {
|
||||
let pairs: Vec<(&str, serde_json::Value)> = vec![
|
||||
let kek = get_dev_kek();
|
||||
|
||||
let mut pairs: Vec<(&str, serde_json::Value)> = vec![
|
||||
(KEY_AGENT_MODEL, serde_json::json!(config.agent.model)),
|
||||
(
|
||||
KEY_AGENT_TEMPERATURE,
|
||||
@@ -163,8 +383,51 @@ pub async fn save_ai_config(
|
||||
KEY_ANALYSIS_MAX_TOKENS,
|
||||
serde_json::json!(config.analysis_defaults.max_tokens),
|
||||
),
|
||||
(
|
||||
KEY_PROVIDER_DEFAULT,
|
||||
serde_json::json!(config.default_provider),
|
||||
),
|
||||
];
|
||||
|
||||
// 处理每个 provider 的配置
|
||||
for (name, provider) in &config.providers {
|
||||
let (enabled_key, base_url_key, api_key_key, model_key) = match name.as_str() {
|
||||
"claude" => (
|
||||
KEY_CLAUDE_ENABLED,
|
||||
KEY_CLAUDE_BASE_URL,
|
||||
KEY_CLAUDE_API_KEY,
|
||||
KEY_CLAUDE_MODEL,
|
||||
),
|
||||
"openai" => (
|
||||
KEY_OPENAI_ENABLED,
|
||||
KEY_OPENAI_BASE_URL,
|
||||
KEY_OPENAI_API_KEY,
|
||||
KEY_OPENAI_MODEL,
|
||||
),
|
||||
"ollama" => (
|
||||
KEY_OLLAMA_ENABLED,
|
||||
KEY_OLLAMA_BASE_URL,
|
||||
"", // ollama 无 api_key
|
||||
KEY_OLLAMA_MODEL,
|
||||
),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
pairs.push((enabled_key, serde_json::json!(provider.enabled)));
|
||||
pairs.push((base_url_key, serde_json::json!(provider.base_url)));
|
||||
pairs.push((model_key, serde_json::json!(provider.model)));
|
||||
|
||||
// API Key:仅非空且非掩码值才加密写入
|
||||
if !api_key_key.is_empty()
|
||||
&& !provider.api_key.is_empty()
|
||||
&& !provider.api_key.starts_with("****")
|
||||
{
|
||||
let encrypted = encrypt_api_key(&provider.api_key, &kek)
|
||||
.map_err(erp_core::error::AppError::Internal)?;
|
||||
pairs.push((api_key_key, serde_json::json!(encrypted)));
|
||||
}
|
||||
}
|
||||
|
||||
for (key, value) in pairs {
|
||||
upsert_setting(key, &value, tenant_id, operator_id, db, event_bus).await?;
|
||||
}
|
||||
@@ -172,7 +435,7 @@ pub async fn save_ai_config(
|
||||
tracing::info!(
|
||||
tenant_id = %tenant_id,
|
||||
operator_id = %operator_id,
|
||||
"AI 配置已更新"
|
||||
"AI 配置已更新(含 provider)"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@@ -358,7 +621,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn all_config_keys_count() {
|
||||
assert_eq!(all_config_keys().len(), 8);
|
||||
assert_eq!(all_config_keys().len(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -405,6 +668,8 @@ mod tests {
|
||||
system_prompt: defaults.agent.system_prompt,
|
||||
},
|
||||
analysis_defaults: defaults.analysis_defaults,
|
||||
default_provider: "claude".to_string(),
|
||||
providers: HashMap::new(),
|
||||
};
|
||||
|
||||
assert_eq!(config.agent.model, "gpt-4o");
|
||||
@@ -412,4 +677,54 @@ mod tests {
|
||||
assert_eq!(config.agent.max_tokens, 4096);
|
||||
assert_eq!(config.agent.max_iterations, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_api_key_works() {
|
||||
assert_eq!(mask_api_key("sk-abcdef1234"), "****1234");
|
||||
assert_eq!(mask_api_key("key"), "****");
|
||||
assert_eq!(mask_api_key(""), "****");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_defaults_are_correct() {
|
||||
let claude = AiProviderConfig::claude_default();
|
||||
assert_eq!(claude.provider_type, "claude");
|
||||
assert!(claude.enabled);
|
||||
assert!(claude.base_url.contains("anthropic"));
|
||||
|
||||
let openai = AiProviderConfig::openai_default();
|
||||
assert_eq!(openai.provider_type, "openai");
|
||||
assert!(!openai.enabled);
|
||||
|
||||
let ollama = AiProviderConfig::ollama_default();
|
||||
assert_eq!(ollama.provider_type, "ollama");
|
||||
assert!(!ollama.enabled);
|
||||
assert!(ollama.api_key.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_roundtrip() {
|
||||
let kek = get_dev_kek();
|
||||
let original = "sk-test-secret-key-12345";
|
||||
let encrypted = encrypt_api_key(original, &kek).unwrap();
|
||||
assert!(encrypted.starts_with("enc:"));
|
||||
|
||||
let decrypted = decrypt_api_key(&encrypted, &kek).unwrap();
|
||||
assert_eq!(decrypted, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_plaintext_fallback() {
|
||||
let kek = get_dev_kek();
|
||||
let plaintext = "my-plain-key";
|
||||
let result = decrypt_api_key(plaintext, &kek).unwrap();
|
||||
assert_eq!(result, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_empty_key_returns_empty() {
|
||||
let kek = get_dev_kek();
|
||||
let result = encrypt_api_key("", &kek).unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user