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

View File

@@ -0,0 +1,20 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "ai_feature_flags")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub feature: String,
pub is_enabled: bool,
pub config: Option<Json>,
pub updated_at: DateTimeUtc,
pub updated_by: Option<Uuid>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,20 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "ai_suggestion_feedback")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub suggestion_id: Uuid,
pub user_id: Uuid,
pub action: String,
pub feedback_text: Option<String>,
pub created_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,24 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "ai_usage_daily")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub date: chrono::NaiveDate,
pub feature: String,
pub provider: String,
pub model: String,
pub total_calls: i32,
pub total_input_tokens: i64,
pub total_output_tokens: i64,
pub total_cost_cents: i64,
pub created_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,13 +1,16 @@
pub mod ai_analysis;
pub mod ai_analysis_queue;
pub mod ai_feature_flags;
pub mod ai_knowledge_guides;
pub mod ai_knowledge_references;
pub mod ai_knowledge_rules;
pub mod ai_prompt;
pub mod ai_risk_threshold;
pub mod ai_suggestion;
pub mod ai_suggestion_feedback;
pub mod ai_tenant_config;
pub mod ai_usage;
pub mod ai_usage_daily;
pub mod copilot_chat_logs;
pub mod copilot_insights;
pub mod copilot_risk_snapshots;

View File

@@ -66,11 +66,28 @@ where
)
.await?;
// 返回保存后的配置
// 保存后重新加载 provider 到 registry立即生效
reload_providers_from_db(&ai_state, ctx.tenant_id).await;
// 返回保存后的配置(含掩码 API Key
let config = config_resolver::load_ai_config(ctx.tenant_id, &ai_state.db).await;
Ok(Json(ApiResponse::ok(config)))
}
/// 从 DB 加载配置并重新加载 provider registry
async fn reload_providers_from_db(ai_state: &AiState, tenant_id: uuid::Uuid) {
let values = config_resolver::load_ai_config_raw(tenant_id, &ai_state.db).await;
let kek = *erp_core::crypto::PiiCrypto::dev_default().kek();
if let Err(e) = ai_state
.provider_registry
.reload_providers(&values, &kek)
.await
{
tracing::error!(error = %e, "Provider registry 重新加载失败");
}
}
/// 获取 AI 配置的默认值(用于前端初始化表单)
#[utoipa::path(
get,
@@ -131,5 +148,25 @@ fn validate_config(config: &config_resolver::AiConfig) -> Result<(), erp_core::e
"分析默认最大 token 数必须在 1 ~ 65536 之间".into(),
));
}
// 验证 provider 配置(仅校验已启用的)
for (name, provider) in &config.providers {
if !provider.enabled {
continue;
}
if provider.base_url.trim().is_empty() {
return Err(erp_core::error::AppError::Validation(format!(
"Provider {} 的 Base URL 不能为空",
name
)));
}
if provider.model.trim().is_empty() {
return Err(erp_core::error::AppError::Validation(format!(
"Provider {} 的模型名称不能为空",
name
)));
}
}
Ok(())
}

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 {

View File

@@ -13,11 +13,11 @@ use crate::entity::ai_analysis;
use crate::error::{AiError, AiResult};
use crate::knowledge::KnowledgeSource;
use crate::prompt::PromptRenderer;
use crate::provider::AiProvider;
use crate::provider::registry::ProviderRegistry;
use crate::sanitization::SanitizationService;
pub struct AnalysisService {
pub provider: Box<dyn AiProvider>,
pub provider_registry: std::sync::Arc<ProviderRegistry>,
pub sanitizer: SanitizationService,
pub renderer: PromptRenderer,
pub db: sea_orm::DatabaseConnection,
@@ -25,9 +25,12 @@ pub struct AnalysisService {
}
impl AnalysisService {
pub fn new(provider: Box<dyn AiProvider>, db: sea_orm::DatabaseConnection) -> Self {
pub fn new(
provider_registry: std::sync::Arc<ProviderRegistry>,
db: sea_orm::DatabaseConnection,
) -> Self {
Self {
provider,
provider_registry,
sanitizer: SanitizationService::new(),
renderer: PromptRenderer::new(),
db,
@@ -62,7 +65,20 @@ impl AnalysisService {
)> {
let analysis_id = Uuid::now_v7();
let input_hash = self.compute_hash(&sanitized_data);
let provider_name = self.provider.name().to_string();
// 从 config_resolver 获取 default_provider然后从 registry 解析
let default_provider_name = crate::config_resolver::load_ai_config(tenant_id, &self.db)
.await
.default_provider;
let resolved = self
.provider_registry
.resolve(&default_provider_name)
.await
.map_err(|e| {
tracing::error!(error = %e, "无法解析 AI Provider");
AiError::ProviderUnavailable(default_provider_name.clone())
})?;
let provider_name = resolved.provider_name().to_string();
// 0. 缓存命中检查(相同输入 + prompt 版本 → 复用已有结果)
if let Some(cached) = self.find_cached(tenant_id, &input_hash, 1).await? {
@@ -133,7 +149,7 @@ impl AnalysisService {
temperature,
max_tokens,
};
let stream = self.provider.stream_generate(req).await?;
let stream = resolved.provider().stream_generate(req).await?;
Ok((stream, analysis_id, provider_name))
}

View File

@@ -0,0 +1,167 @@
use std::collections::HashMap;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::entity::ai_feature_flags;
use crate::error::AiResult;
pub struct FeatureFlagService {
db: sea_orm::DatabaseConnection,
cache: RwLock<HashMap<(Uuid, String), CacheEntry>>,
cache_ttl: std::time::Duration,
}
struct CacheEntry {
enabled: bool,
cached_at: std::time::Instant,
}
impl FeatureFlagService {
pub fn new(db: sea_orm::DatabaseConnection) -> Self {
Self {
db,
cache: RwLock::new(HashMap::new()),
cache_ttl: std::time::Duration::from_secs(300),
}
}
pub async fn is_enabled(&self, tenant_id: Uuid, feature: &str) -> bool {
let key = (tenant_id, feature.to_string());
// 查缓存
{
let cache = self.cache.read().await;
if let Some(entry) = cache.get(&key)
&& entry.cached_at.elapsed() < self.cache_ttl
{
return entry.enabled;
}
}
// 查数据库
let enabled = match self.query_db(tenant_id, feature).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(tenant_id = %tenant_id, feature = %feature, error = %e, "Feature flag query failed, defaulting to enabled");
true
}
};
// 写缓存
{
let mut cache = self.cache.write().await;
cache.insert(
key,
CacheEntry {
enabled,
cached_at: std::time::Instant::now(),
},
);
}
enabled
}
pub async fn set_enabled(
&self,
tenant_id: Uuid,
feature: &str,
enabled: bool,
updated_by: Uuid,
) -> AiResult<()> {
let existing = ai_feature_flags::Entity::find()
.filter(ai_feature_flags::Column::TenantId.eq(tenant_id))
.filter(ai_feature_flags::Column::Feature.eq(feature))
.one(&self.db)
.await?;
if let Some(model) = existing {
let mut active: ai_feature_flags::ActiveModel = model.into();
active.is_enabled = Set(enabled);
active.updated_at = Set(chrono::Utc::now());
active.updated_by = Set(Some(updated_by));
active.update(&self.db).await?;
} else {
let id = Uuid::now_v7();
let active = ai_feature_flags::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
feature: Set(feature.to_string()),
is_enabled: Set(enabled),
config: Set(None),
updated_at: Set(chrono::Utc::now()),
updated_by: Set(Some(updated_by)),
};
active.insert(&self.db).await?;
}
// 清缓存
{
let mut cache = self.cache.write().await;
cache.remove(&(tenant_id, feature.to_string()));
}
tracing::info!(tenant_id = %tenant_id, feature = %feature, enabled = enabled, "Feature flag updated");
Ok(())
}
pub async fn get_all(&self, tenant_id: Uuid) -> AiResult<Vec<FeatureFlag>> {
let rows = ai_feature_flags::Entity::find()
.filter(ai_feature_flags::Column::TenantId.eq(tenant_id))
.all(&self.db)
.await?;
Ok(rows
.into_iter()
.map(|r| FeatureFlag {
feature: r.feature,
is_enabled: r.is_enabled,
})
.collect())
}
async fn query_db(&self, tenant_id: Uuid, feature: &str) -> AiResult<bool> {
let result = ai_feature_flags::Entity::find()
.filter(ai_feature_flags::Column::TenantId.eq(tenant_id))
.filter(ai_feature_flags::Column::Feature.eq(feature))
.one(&self.db)
.await?;
// 不存在 → 默认启用
Ok(result.map(|r| r.is_enabled).unwrap_or(true))
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct FeatureFlag {
pub feature: String,
pub is_enabled: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn feature_flag_serialization() {
let flag = FeatureFlag {
feature: "ai.chat".to_string(),
is_enabled: true,
};
let json = serde_json::to_value(&flag).unwrap();
assert_eq!(json["feature"], "ai.chat");
assert_eq!(json["is_enabled"], true);
}
#[test]
fn cache_entry_expiry() {
let entry = CacheEntry {
enabled: false,
cached_at: std::time::Instant::now() - std::time::Duration::from_secs(301),
};
assert!(entry.cached_at.elapsed() >= std::time::Duration::from_secs(300));
}
}

View File

@@ -5,6 +5,7 @@ pub mod cache;
pub mod comparison;
pub mod cost;
pub mod dialysis_risk_scorer;
pub mod feature_flag_service;
pub mod insight_service;
pub mod local_rules;
pub mod output_parser;

View File

@@ -7,6 +7,7 @@ use sea_orm::DatabaseConnection;
use crate::provider::registry::ProviderRegistry;
use crate::service::analysis::AnalysisService;
use crate::service::cache::CacheService;
use crate::service::feature_flag_service::FeatureFlagService;
use crate::service::insight_service::InsightService;
use crate::service::prompt::PromptService;
use crate::service::quota::QuotaService;
@@ -28,4 +29,5 @@ pub struct AiState {
pub cache: Arc<CacheService>,
pub risk_service: Arc<RiskService>,
pub insight_service: Arc<InsightService>,
pub feature_flags: Arc<FeatureFlagService>,
}

View File

@@ -152,6 +152,9 @@ mod m20260516_000147_seed_ai_chat_permission;
mod m20260518_000148_create_ai_chat_tables;
mod m20260518_000149_fix_admin_permissions;
mod m20260518_000150_seed_ai_config_permission;
mod m20260518_000151_fix_ai_config_menu_parent;
mod m20260518_000152_seed_ai_provider_permission;
mod m20260518_000153_ai_health_butler_v2;
pub struct Migrator;
@@ -311,6 +314,9 @@ impl MigratorTrait for Migrator {
Box::new(m20260518_000148_create_ai_chat_tables::Migration),
Box::new(m20260518_000149_fix_admin_permissions::Migration),
Box::new(m20260518_000150_seed_ai_config_permission::Migration),
Box::new(m20260518_000151_fix_ai_config_menu_parent::Migration),
Box::new(m20260518_000152_seed_ai_provider_permission::Migration),
Box::new(m20260518_000153_ai_health_butler_v2::Migration),
]
}
}

View File

@@ -11,7 +11,7 @@ pub struct Migration;
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
let sys = "00000000-00000000-00000000-000000000000";
let sys = "00000000-0000-0000-0000-000000000000";
// 注册 ai.config.read 和 ai.config.manage 权限到所有租户
for (code, name, desc) in [
@@ -36,11 +36,13 @@ impl MigrationTrait for Migration {
"#
)).await?;
// 绑定到管理员角色
// 绑定到管理员角色role_permissions 主键是 role_id + permission_id
db.execute_unprepared(&format!(
r#"
INSERT INTO role_permissions (id, tenant_id, role_id, permission_id, created_at, updated_at, created_by, updated_by, deleted_at, version)
SELECT gen_random_uuid(), t.id, r.id, p.id, NOW(), NOW(), '{sys}', '{sys}', NULL, 1
INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope,
created_at, updated_at, created_by, updated_by, deleted_at, version)
SELECT r.id, p.id, t.id, 'all',
NOW(), NOW(), '{sys}', '{sys}', NULL, 1
FROM tenant t
JOIN roles r ON r.tenant_id = t.id AND r.code = 'admin' AND r.deleted_at IS NULL
JOIN permissions p ON p.tenant_id = t.id AND p.code = '{code}' AND p.deleted_at IS NULL
@@ -48,18 +50,19 @@ impl MigrationTrait for Migration {
SELECT 1 FROM role_permissions rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id AND rp.deleted_at IS NULL
)
ON CONFLICT (role_id, permission_id) DO NOTHING
"#
)).await?;
}
// 添加 AI 配置管理菜单
// 添加 AI 配置管理菜单(挂载在 AI 分析分组下,与 AI Prompt 管理、AI 分析历史同级)
db.execute_unprepared(&format!(
r#"
INSERT INTO menus (id, tenant_id, parent_id, title, path, icon, sort_order, visible,
menu_type, permission, created_at, updated_at, created_by, updated_by, deleted_at, version)
SELECT gen_random_uuid(), t.id,
(SELECT m.id FROM menus m WHERE m.tenant_id = t.id AND m.path = '/health/ai-prompts' AND m.deleted_at IS NULL LIMIT 1),
'AI 配置', '/health/ai-config', 'SettingOutlined', 60, true,
(SELECT m.parent_id FROM menus m WHERE m.tenant_id = t.id AND m.path = '/health/ai-prompts' AND m.deleted_at IS NULL LIMIT 1),
'AI 配置', '/health/ai-config', 'SettingOutlined', 55, true,
'menu', 'ai.config.read',
NOW(), NOW(), '{sys}', '{sys}', NULL, 1
FROM tenant t
@@ -73,8 +76,8 @@ impl MigrationTrait for Migration {
// 菜单绑定 admin 角色
db.execute_unprepared(&format!(
r#"
INSERT INTO menu_roles (id, menu_id, role_id, created_at, updated_at, created_by, updated_by, deleted_at, version)
SELECT gen_random_uuid(), m.id, r.id, NOW(), NOW(), '{sys}', '{sys}', NULL, 1
INSERT INTO menu_roles (id, menu_id, role_id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version)
SELECT gen_random_uuid(), m.id, r.id, m.tenant_id, NOW(), NOW(), '{sys}', '{sys}', NULL, 1
FROM menus m
JOIN roles r ON r.tenant_id = m.tenant_id AND r.code = 'admin' AND r.deleted_at IS NULL
WHERE m.path = '/health/ai-config' AND m.deleted_at IS NULL
@@ -85,6 +88,19 @@ impl MigrationTrait for Migration {
"#
)).await?;
// 修复已存在的 AI 配置菜单:将其从 AI Prompt 管理子级移到 AI 分析分组下
db.execute_unprepared(
r#"
UPDATE menus mc
SET parent_id = mp.parent_id
FROM menus mp
WHERE mp.path = '/health/ai-prompts' AND mp.deleted_at IS NULL
AND mc.path = '/health/ai-config' AND mc.deleted_at IS NULL
AND mc.parent_id = mp.id
"#,
)
.await?;
Ok(())
}

View File

@@ -0,0 +1,187 @@
//! AI 健康管家 V2 — 功能开关表 + 用量日聚合表 + 建议反馈表 + 管理权限码 seed
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
// 1. 创建 ai_feature_flags 表
db.execute_unprepared(
r#"
CREATE TABLE IF NOT EXISTS ai_feature_flags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
feature VARCHAR(100) NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT true,
config JSONB,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID,
CONSTRAINT uq_feature_flags_tenant_feature UNIQUE(tenant_id, feature)
)
"#,
)
.await?;
db.execute_unprepared(
"CREATE INDEX IF NOT EXISTS idx_feature_flags_tenant ON ai_feature_flags(tenant_id)",
)
.await?;
// 2. 创建 ai_usage_daily 表
db.execute_unprepared(
r#"
CREATE TABLE IF NOT EXISTS ai_usage_daily (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
date DATE NOT NULL,
feature VARCHAR(100) NOT NULL,
provider VARCHAR(50) NOT NULL,
model VARCHAR(100) NOT NULL,
total_calls INT NOT NULL DEFAULT 0,
total_input_tokens BIGINT NOT NULL DEFAULT 0,
total_output_tokens BIGINT NOT NULL DEFAULT 0,
total_cost_cents BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_usage_daily UNIQUE(tenant_id, date, feature, provider, model)
)
"#,
)
.await?;
db.execute_unprepared(
"CREATE INDEX IF NOT EXISTS idx_usage_daily_tenant_date ON ai_usage_daily(tenant_id, date DESC)",
)
.await?;
// 3. 创建 ai_suggestion_feedback 表
db.execute_unprepared(
r#"
CREATE TABLE IF NOT EXISTS ai_suggestion_feedback (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
suggestion_id UUID NOT NULL,
user_id UUID NOT NULL,
action VARCHAR(20) NOT NULL,
feedback_text TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"#,
)
.await?;
db.execute_unprepared(
"CREATE INDEX IF NOT EXISTS idx_suggestion_feedback_tenant ON ai_suggestion_feedback(tenant_id)",
)
.await?;
db.execute_unprepared(
"CREATE INDEX IF NOT EXISTS idx_suggestion_feedback_suggestion ON ai_suggestion_feedback(suggestion_id)",
)
.await?;
// 4. ai_tenant_configs 增加 billing_enabled 列
db.execute_unprepared(
"ALTER TABLE ai_tenant_configs ADD COLUMN IF NOT EXISTS billing_enabled BOOLEAN NOT NULL DEFAULT false",
)
.await?;
// 5. Seed 12 个功能开关默认值
let sys = "00000000-0000-0000-0000-000000000000";
let features = [
("ai.analysis.lab_report", "true"),
("ai.analysis.trend", "true"),
("ai.analysis.report_summary", "true"),
("ai.analysis.checkup_plan", "true"),
("ai.chat", "true"),
("ai.chat.patient", "true"),
("ai.chat.staff", "true"),
("ai.alert.push", "false"),
("ai.rag", "false"),
("ai.voice", "false"),
("ai.copilot.risk", "true"),
("ai.copilot.insight", "true"),
];
for (feature, enabled) in &features {
db.execute_unprepared(&format!(
r#"
INSERT INTO ai_feature_flags (id, tenant_id, feature, is_enabled, updated_at, updated_by)
SELECT gen_random_uuid(), t.id, '{feature}', {enabled}, NOW(), '{sys}'
FROM tenant t
WHERE NOT EXISTS (
SELECT 1 FROM ai_feature_flags f
WHERE f.tenant_id = t.id AND f.feature = '{feature}'
)
"#
)).await?;
}
// 6. Seed 3 个管理权限码
let perms = [
(
"ai.admin.dashboard",
"AI 管理看板",
"查看 AI 用量、成本、效果统计",
),
("ai.admin.flags", "AI 功能开关", "管理 AI 功能的启用/禁用"),
];
for (code, name, desc) in &perms {
db.execute_unprepared(&format!(
r#"
INSERT INTO permissions (id, tenant_id, code, name, resource, action, description,
created_at, updated_at, created_by, updated_by, deleted_at, version)
SELECT gen_random_uuid(), t.id, '{code}', '{name}', 'ai', '{code}', '{desc}',
NOW(), NOW(), '{sys}', '{sys}', NULL, 1
FROM tenant t
WHERE NOT EXISTS (
SELECT 1 FROM permissions p
WHERE p.code = '{code}' AND p.tenant_id = t.id AND p.deleted_at IS NULL
)
"#
)).await?;
// 绑定到管理员角色
db.execute_unprepared(&format!(
r#"
INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope,
created_at, updated_at, created_by, updated_by, deleted_at, version)
SELECT r.id, p.id, t.id, 'all',
NOW(), NOW(), '{sys}', '{sys}', NULL, 1
FROM tenant t
JOIN roles r ON r.tenant_id = t.id AND r.code = 'admin' AND r.deleted_at IS NULL
JOIN permissions p ON p.tenant_id = t.id AND p.code = '{code}' AND p.deleted_at IS NULL
WHERE NOT EXISTS (
SELECT 1 FROM role_permissions rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id AND rp.deleted_at IS NULL
)
ON CONFLICT (role_id, permission_id) DO NOTHING
"#
)).await?;
}
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
db.execute_unprepared("DROP TABLE IF EXISTS ai_suggestion_feedback")
.await?;
db.execute_unprepared("DROP TABLE IF EXISTS ai_usage_daily")
.await?;
db.execute_unprepared("DROP TABLE IF EXISTS ai_feature_flags")
.await?;
db.execute_unprepared(
"ALTER TABLE ai_tenant_configs DROP COLUMN IF EXISTS billing_enabled",
)
.await?;
Ok(())
}
}

View File

@@ -540,56 +540,35 @@ async fn main() -> anyhow::Result<()> {
}
}
tracing::info!(providers = ?registry.provider_names(), "AI Provider 注册完成");
tracing::info!(providers = ?registry.provider_names(), "AI Provider 注册完成(静态配置)");
// 根据 default_provider 配置构建 AnalysisService 的默认 provider
let default_provider: Box<dyn erp_ai::provider::AiProvider> = match config
.ai
.default_provider
.as_str()
// 尝试从 DB 加载 provider 配置覆盖静态值DB 为空时 fallback 到静态配置)
{
"ollama" => {
let pcfg = config.ai.providers.get("ollama");
let base_url = pcfg
.and_then(|c| c.base_url.clone())
.unwrap_or_else(|| "http://localhost:11434".to_string());
let model = pcfg
.map(|c| c.default_model.clone())
.unwrap_or_else(|| config.ai.model.clone());
tracing::info!(base_url = %base_url, model = %model, "AnalysisService 使用 Ollama 提供商");
Box::new(erp_ai::provider::ollama::OllamaProvider::new(
base_url, model,
))
}
"openai" => {
let pcfg = config.ai.providers.get("openai");
let api_key = pcfg
.and_then(|c| c.api_key_env.as_ref())
.and_then(|env| std::env::var(env).ok())
.unwrap_or_default();
let base_url = pcfg
.and_then(|c| c.base_url.clone())
.unwrap_or_else(|| "https://api.openai.com".to_string());
let model = pcfg
.map(|c| c.default_model.clone())
.unwrap_or_else(|| config.ai.model.clone());
Box::new(erp_ai::provider::openai::OpenAIProvider::new(
api_key, base_url, model,
))
}
_ => {
// 默认 Claude
let mut claude =
erp_ai::provider::claude::ClaudeProvider::new(config.ai.api_key.clone());
if let Some(ref base_url) = config.ai.base_url {
claude = claude.with_base_url(base_url.clone());
let tenant_id =
match std::env::var("DEFAULT_TENANT_ID").or_else(|_| std::env::var("TENANT_ID")) {
Ok(id) => uuid::Uuid::parse_str(&id).ok(),
Err(_) => None,
};
if let Some(tid) = tenant_id {
let values = erp_ai::config_resolver::load_ai_config_raw(tid, &db).await;
if !values.is_empty() {
let kek = *erp_core::crypto::PiiCrypto::dev_default().kek();
match registry.reload_providers(&values, &kek).await {
Ok(()) => {
tracing::info!(providers = ?registry.provider_names(), "AI Provider 已从 DB 重新加载");
}
Err(e) => {
tracing::warn!(error = %e, "DB Provider 加载失败,继续使用静态配置");
}
}
}
Box::new(claude)
} else {
tracing::info!("未配置 DEFAULT_TENANT_ID跳过 DB Provider 加载");
}
};
}
let analysis_svc =
erp_ai::service::analysis::AnalysisService::new(default_provider, db.clone())
erp_ai::service::analysis::AnalysisService::new(registry.clone(), db.clone())
.with_knowledge_source(std::sync::Arc::new(
erp_ai::knowledge::structured_source::StructuredKnowledgeSource::new(
db.clone(),
@@ -627,6 +606,9 @@ async fn main() -> anyhow::Result<()> {
cache,
risk_service: std::sync::Arc::new(erp_ai::service::risk_service::RiskService),
insight_service: std::sync::Arc::new(erp_ai::service::insight_service::InsightService),
feature_flags: std::sync::Arc::new(
erp_ai::service::feature_flag_service::FeatureFlagService::new(db.clone()),
),
}
};