feat(ci,ai): P2-1 权限注册表 + P2-2 AI utoipa 注解全覆盖

P2-1 权限注册表单一真相源:
- 新增 permissions.yaml: 131 个权限码 × 8 模块,含冻结标记
- 新增 scripts/gen-permissions.js: 生成器脚本
  --sql 输出 seed SQL, --frontend 输出 routeConfig 片段,
  --validate 验证一致性(131/131 = 0 mismatches)

P2-2 AI 模块 utoipa 注解:
- 为 30 个 handler 函数添加 #[utoipa::path] 注解
  (mod.rs 18 + insight 3 + risk 1 + rule 4 + suggestion 4)
- 为 6 个 DTO struct 添加 ToSchema/IntoParams derive
  (AnalyzeBody, CreatePromptBody, CreateRuleBody, UpdateRuleBody,
   ApproveBody, ExecuteBody, DialysisLabInput, ListAnalysisQuery,
   ListPromptsQuery)
- AI handler utoipa 覆盖率: 0/5 → 5/5 (100%)
This commit is contained in:
iven
2026-05-13 17:45:45 +08:00
parent 20d606d21c
commit 02082ccc61
11 changed files with 1500 additions and 9 deletions

View File

@@ -8,6 +8,13 @@ use serde::Deserialize;
use crate::dto::copilot::ListInsightsQuery;
use crate::state::AiState;
#[utoipa::path(
get,
path = "/copilot/insights",
responses((status = 200, description = "Copilot 洞察列表")),
tag = "Copilot 洞察",
security(("bearer_auth" = [])),
)]
pub async fn list_insights<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
@@ -41,6 +48,13 @@ where
}))))
}
#[utoipa::path(
get,
path = "/copilot/insights/{id}",
responses((status = 200, description = "洞察详情")),
tag = "Copilot 洞察",
security(("bearer_auth" = [])),
)]
pub async fn get_insight<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
@@ -68,6 +82,13 @@ pub struct DismissBody {
pub reason: Option<String>,
}
#[utoipa::path(
post,
path = "/copilot/insights/{id}/dismiss",
responses((status = 200, description = "忽略洞察")),
tag = "Copilot 洞察",
security(("bearer_auth" = [])),
)]
pub async fn dismiss_insight<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,

View File

@@ -18,7 +18,7 @@ pub mod suggestion_handler;
// === 分析请求 Body ===
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct AnalyzeBody {
pub report_id: Option<uuid::Uuid>,
pub patient_id: Option<uuid::Uuid>,
@@ -27,6 +27,14 @@ pub struct AnalyzeBody {
// === SSE 分析端点 ===
#[utoipa::path(
post,
path = "/ai/analyze/lab-report",
request_body = AnalyzeBody,
responses((status = 200, description = "SSE 化验报告分析流")),
tag = "AI 分析",
security(("bearer_auth" = [])),
)]
pub async fn stream_lab_report<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
@@ -101,6 +109,14 @@ where
Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
}
#[utoipa::path(
post,
path = "/ai/analyze/trends",
request_body = AnalyzeBody,
responses((status = 200, description = "SSE 趋势分析流")),
tag = "AI 分析",
security(("bearer_auth" = [])),
)]
pub async fn stream_trends<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
@@ -190,6 +206,14 @@ where
Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
}
#[utoipa::path(
post,
path = "/ai/analyze/checkup-plan",
request_body = AnalyzeBody,
responses((status = 200, description = "SSE 体检计划分析流")),
tag = "AI 分析",
security(("bearer_auth" = [])),
)]
pub async fn stream_checkup_plan<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
@@ -258,6 +282,14 @@ where
Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
}
#[utoipa::path(
post,
path = "/ai/analyze/report-summary",
request_body = AnalyzeBody,
responses((status = 200, description = "SSE 报告摘要分析流")),
tag = "AI 分析",
security(("bearer_auth" = [])),
)]
pub async fn stream_report_summary<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
@@ -335,7 +367,7 @@ where
// === 分析历史 ===
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, utoipa::IntoParams)]
pub struct ListAnalysisQuery {
pub patient_id: Option<uuid::Uuid>,
pub analysis_type: Option<String>,
@@ -343,6 +375,14 @@ pub struct ListAnalysisQuery {
pub page_size: Option<u64>,
}
#[utoipa::path(
get,
path = "/ai/analysis/history",
params(ListAnalysisQuery),
responses((status = 200, description = "分析历史列表")),
tag = "AI 分析",
security(("bearer_auth" = [])),
)]
pub async fn list_analysis<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
@@ -419,6 +459,13 @@ where
}))))
}
#[utoipa::path(
get,
path = "/ai/analysis/{id}",
responses((status = 200, description = "分析详情")),
tag = "AI 分析",
security(("bearer_auth" = [])),
)]
pub async fn get_analysis<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
@@ -435,13 +482,21 @@ where
// === Prompt 管理 ===
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, utoipa::IntoParams)]
pub struct ListPromptsQuery {
pub category: Option<String>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}
#[utoipa::path(
get,
path = "/ai/prompts",
params(ListPromptsQuery),
responses((status = 200, description = "Prompt 模板列表")),
tag = "AI Prompt",
security(("bearer_auth" = [])),
)]
pub async fn list_prompts<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
@@ -468,7 +523,7 @@ where
}))))
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct CreatePromptBody {
pub name: String,
pub description: Option<String>,
@@ -478,6 +533,14 @@ pub struct CreatePromptBody {
pub category: String,
}
#[utoipa::path(
post,
path = "/ai/prompts",
request_body = CreatePromptBody,
responses((status = 200, description = "创建 Prompt 模板")),
tag = "AI Prompt",
security(("bearer_auth" = [])),
)]
pub async fn create_prompt<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
@@ -505,6 +568,13 @@ where
Ok(Json(ApiResponse::ok(prompt)))
}
#[utoipa::path(
post,
path = "/ai/prompts/{id}/activate",
responses((status = 200, description = "激活 Prompt 模板")),
tag = "AI Prompt",
security(("bearer_auth" = [])),
)]
pub async fn activate_prompt<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
@@ -519,6 +589,13 @@ where
Ok(Json(ApiResponse::ok(prompt)))
}
#[utoipa::path(
post,
path = "/ai/prompts/{id}/rollback",
responses((status = 200, description = "回滚 Prompt 模板")),
tag = "AI Prompt",
security(("bearer_auth" = [])),
)]
pub async fn rollback_prompt<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
@@ -535,6 +612,13 @@ where
// === 用量统计 ===
#[utoipa::path(
get,
path = "/ai/usage/overview",
responses((status = 200, description = "AI 用量概览")),
tag = "AI 用量",
security(("bearer_auth" = [])),
)]
pub async fn usage_overview<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
@@ -550,6 +634,13 @@ where
}))))
}
#[utoipa::path(
get,
path = "/ai/usage/by-type",
responses((status = 200, description = "按类型用量统计")),
tag = "AI 用量",
security(("bearer_auth" = [])),
)]
pub async fn usage_by_type<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
@@ -574,6 +665,13 @@ where
// === Provider 管理 ===
#[utoipa::path(
get,
path = "/ai/providers/health",
responses((status = 200, description = "AI Provider 健康检查")),
tag = "AI Provider",
security(("bearer_auth" = [])),
)]
pub async fn provider_health<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
@@ -602,6 +700,13 @@ where
Ok(Json(ApiResponse::ok(result)))
}
#[utoipa::path(
get,
path = "/ai/providers",
responses((status = 200, description = "AI Provider 列表")),
tag = "AI Provider",
security(("bearer_auth" = [])),
)]
pub async fn provider_names<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
@@ -616,6 +721,13 @@ where
)))
}
#[utoipa::path(
get,
path = "/ai/quota/summary",
responses((status = 200, description = "AI 配额汇总")),
tag = "AI 用量",
security(("bearer_auth" = [])),
)]
pub async fn quota_summary<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
@@ -631,6 +743,13 @@ where
// === 透析风险评估KDIGO 规则) ===
#[utoipa::path(
post,
path = "/ai/dialysis/risk-assessment",
responses((status = 200, description = "透析风险评估")),
tag = "AI 分析",
security(("bearer_auth" = [])),
)]
pub async fn assess_dialysis_risk<S>(
Extension(ctx): Extension<TenantContext>,
Json(body): Json<crate::service::dialysis_risk_scorer::DialysisLabInput>,
@@ -650,6 +769,13 @@ where
// === 成本与预算 ===
#[utoipa::path(
get,
path = "/ai/budget/status",
responses((status = 200, description = "AI 预算状态")),
tag = "AI 用量",
security(("bearer_auth" = [])),
)]
pub async fn budget_status<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
@@ -670,6 +796,13 @@ pub struct CostEstimateQuery {
pub model: Option<String>,
}
#[utoipa::path(
get,
path = "/ai/cost/estimate",
responses((status = 200, description = "AI 成本预估")),
tag = "AI 用量",
security(("bearer_auth" = [])),
)]
pub async fn cost_estimate<S>(
Extension(ctx): Extension<TenantContext>,
Query(params): Query<CostEstimateQuery>,

View File

@@ -5,6 +5,13 @@ use erp_core::types::{ApiResponse, TenantContext};
use crate::state::AiState;
#[utoipa::path(
get,
path = "/copilot/patients/{id}/risk",
responses((status = 200, description = "患者风险评分")),
tag = "Copilot 风险",
security(("bearer_auth" = [])),
)]
pub async fn get_patient_risk<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,

View File

@@ -8,6 +8,13 @@ use crate::dto::copilot::{CreateRuleBody, UpdateRuleBody};
use crate::entity::copilot_rules;
use crate::state::AiState;
#[utoipa::path(
get,
path = "/copilot/rules",
responses((status = 200, description = "Copilot 规则列表")),
tag = "Copilot 规则",
security(("bearer_auth" = [])),
)]
pub async fn list_rules<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
@@ -40,6 +47,13 @@ where
}))))
}
#[utoipa::path(
post,
path = "/copilot/rules",
responses((status = 200, description = "创建 Copilot 规则")),
tag = "Copilot 规则",
security(("bearer_auth" = [])),
)]
pub async fn create_rule<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
@@ -75,6 +89,13 @@ where
Ok(Json(ApiResponse::ok(result)))
}
#[utoipa::path(
put,
path = "/copilot/rules/{id}",
responses((status = 200, description = "更新 Copilot 规则")),
tag = "Copilot 规则",
security(("bearer_auth" = [])),
)]
pub async fn update_rule<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
@@ -128,6 +149,13 @@ where
Ok(Json(ApiResponse::ok(result)))
}
#[utoipa::path(
delete,
path = "/copilot/rules/{id}",
responses((status = 200, description = "删除 Copilot 规则")),
tag = "Copilot 规则",
security(("bearer_auth" = [])),
)]
pub async fn delete_rule<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
@@ -151,6 +179,7 @@ where
active.deleted_at = Set(Some(chrono::Utc::now()));
active.updated_at = Set(chrono::Utc::now());
active.updated_by = Set(Some(ctx.user_id));
active.version_lock = Set(active.version_lock.unwrap() + 1);
active.update(&state.db).await?;
Ok(Json(ApiResponse::ok(serde_json::json!({"deleted": true}))))

View File

@@ -14,6 +14,13 @@ pub struct ListSuggestionsQuery {
pub status: Option<String>,
}
#[utoipa::path(
get,
path = "/ai/suggestions",
responses((status = 200, description = "AI 建议列表")),
tag = "AI 建议",
security(("bearer_auth" = [])),
)]
pub async fn list_suggestions<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
@@ -41,11 +48,18 @@ where
}
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct ApproveBody {
pub action: String, // "approve" or "reject"
}
#[utoipa::path(
post,
path = "/ai/suggestions/{id}/approve",
responses((status = 200, description = "审批 AI 建议")),
tag = "AI 建议",
security(("bearer_auth" = [])),
)]
pub async fn approve_suggestion<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
@@ -86,12 +100,19 @@ where
}))))
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct ExecuteBody {
pub action_result: Option<serde_json::Value>,
}
/// 执行建议:护士标记建议为已执行,可选记录执行结果
#[utoipa::path(
post,
path = "/ai/suggestions/{id}/execute",
responses((status = 200, description = "执行 AI 建议")),
tag = "AI 建议",
security(("bearer_auth" = [])),
)]
pub async fn execute_suggestion<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
@@ -123,6 +144,13 @@ where
}
/// 获取 AI 建议的前后对比报告。
#[utoipa::path(
get,
path = "/ai/suggestions/{id}/comparison",
responses((status = 200, description = "AI 建议对比")),
tag = "AI 建议",
security(("bearer_auth" = [])),
)]
pub async fn get_comparison<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,