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:
@@ -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