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

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