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());
|
||||
}
|
||||
}
|
||||
|
||||
20
crates/erp-ai/src/entity/ai_feature_flags.rs
Normal file
20
crates/erp-ai/src/entity/ai_feature_flags.rs
Normal 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 {}
|
||||
20
crates/erp-ai/src/entity/ai_suggestion_feedback.rs
Normal file
20
crates/erp-ai/src/entity/ai_suggestion_feedback.rs
Normal 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 {}
|
||||
24
crates/erp-ai/src/entity/ai_usage_daily.rs
Normal file
24
crates/erp-ai/src/entity/ai_usage_daily.rs
Normal 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 {}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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 ¤t_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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
167
crates/erp-ai/src/service/feature_flag_service.rs
Normal file
167
crates/erp-ai/src/service/feature_flag_service.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user