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

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