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

@@ -110,6 +110,119 @@ impl ProviderRegistry {
pub fn get_provider(&self, name: &str) -> Option<Arc<dyn AiProvider>> {
self.entries.get(name).map(|e| e.provider.clone())
}
/// 从 DB 配置重新加载所有 provider原子替换
///
/// 读取 settings 表中的 provider 配置,解密 API Key
/// 构造新的 provider 实例并原子替换 registry 中的条目。
/// 跳过未启用的 provider从 registry 中移除)。
pub async fn reload_providers(
&self,
values: &std::collections::HashMap<String, serde_json::Value>,
kek: &[u8; 32],
) -> Result<(), String> {
use crate::config_resolver::{AiProviderConfig, decrypt_api_key};
let provider_configs = [
("claude", AiProviderConfig::claude_default()),
("openai", AiProviderConfig::openai_default()),
("ollama", AiProviderConfig::ollama_default()),
];
// 收集需要移除的 provider不在配置中或未启用
let mut enabled_names = Vec::new();
for (name, default) in &provider_configs {
let enabled_key = format!("ai.provider.{}.enabled", name);
let enabled = values
.get(&enabled_key)
.and_then(|v| v.as_bool())
.unwrap_or(default.enabled);
if !enabled {
tracing::info!(provider = name, "Provider 未启用,跳过注册");
continue;
}
let base_url_key = format!("ai.provider.{}.base_url", name);
let base_url = values
.get(&base_url_key)
.and_then(|v| v.as_str())
.unwrap_or(&default.base_url)
.to_string();
let model_key = format!("ai.provider.{}.model", name);
let model = values
.get(&model_key)
.and_then(|v| v.as_str())
.unwrap_or(&default.model)
.to_string();
let api_key = if name != &"ollama" {
let api_key_key = format!("ai.provider.{}.api_key", name);
let raw = values
.get(&api_key_key)
.and_then(|v| v.as_str())
.unwrap_or("");
if raw.is_empty() {
String::new()
} else {
decrypt_api_key(raw, kek).unwrap_or_else(|_| raw.to_string())
}
} else {
String::new()
};
match *name {
"claude" => {
if !api_key.is_empty() {
let provider = crate::provider::claude::ClaudeProvider::new(api_key)
.with_base_url(base_url);
self.register(name.to_string(), Arc::new(provider));
enabled_names.push(name.to_string());
} else {
tracing::warn!("Claude provider 缺少 API Key跳过注册");
}
}
"openai" => {
if !api_key.is_empty() {
let provider = crate::provider::openai::OpenAIProvider::new(
api_key,
base_url,
model.clone(),
);
self.register(name.to_string(), Arc::new(provider));
enabled_names.push(name.to_string());
} else {
tracing::warn!("OpenAI provider 缺少 API Key跳过注册");
}
}
"ollama" => {
let provider =
crate::provider::ollama::OllamaProvider::new(base_url, model.clone());
self.register(name.to_string(), Arc::new(provider));
enabled_names.push(name.to_string());
}
_ => {}
}
}
// 移除未启用的 provider
let current_names: Vec<String> = self.provider_names();
for name in &current_names {
if !enabled_names.contains(name) {
self.entries.remove(name);
tracing::info!(provider = name, "已移除未启用的 provider");
}
}
tracing::info!(
providers = ?self.provider_names(),
"Provider registry 已重新加载"
);
Ok(())
}
}
pub struct ResolvedProvider {