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:
iven
2026-05-18 22:55:40 +08:00
parent d623f8b2ff
commit bf37acc681
18 changed files with 2065 additions and 68 deletions

View File

@@ -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());
}
}