fix: 修复测试发现的 7 个问题 + 全 workspace clippy 清零
功能修复: 1. 患者创建空名称验证:后端添加 name.trim().is_empty() 检查 2. 仪表盘统计容错:单个查询失败返回零值而非 500 3. FHIR 路由修复:从 /fhir 移到 /api/v1/fhir 保持一致 4. 冻结模块后端中间件:新增 frozen_module_middleware 拦截冻结路径 5. 积分端点权限码:health.health-data.list → health.points.list 6. 角色权限迁移:护士补充 devices.list,运营补充 points.list/manage 7. 测试结果文档:R01-R05 角色测试 + T00/T10 结果归档 Clippy 全 workspace 清零(14→0 errors): - erp-core: 修复 empty doc line、collapsible if、redundant closure 等 9 处 - erp-health: 修复 too_many_arguments、unused var、unnecessary parens 等 58 处 - erp-ai: 修复 dead_code、unused import 等 11 处 - erp-plugin: 修复 too_many_arguments、wildcard pattern 等 11 处 - erp-server-migration: 修复 enum_variant_names 5 处 - erp-auth/config/workflow/message: 各 1-3 处 工程改进: - lint-staged 配置迁移到 .lintstagedrc.js(函数式避免文件列表传给 clippy) - cargo fmt 统一格式化
This commit is contained in:
10
.lintstagedrc.js
Normal file
10
.lintstagedrc.js
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
'*.rs': [
|
||||
'cargo fmt --check --',
|
||||
() => 'cargo clippy -p erp-health -p erp-server -- -D warnings',
|
||||
],
|
||||
'apps/web/src/**/*.{ts,tsx}': ['cd apps/web && npx eslint --fix'],
|
||||
'apps/web/src/**/*.test.{ts,tsx}': [
|
||||
'cd apps/web && npx vitest run --reporter=verbose',
|
||||
],
|
||||
};
|
||||
@@ -49,7 +49,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn provider_type_all_variants() {
|
||||
for pt in [ProviderType::Claude, ProviderType::Openai, ProviderType::Ollama, ProviderType::Rules] {
|
||||
for pt in [
|
||||
ProviderType::Claude,
|
||||
ProviderType::Openai,
|
||||
ProviderType::Ollama,
|
||||
ProviderType::Rules,
|
||||
] {
|
||||
let json = serde_json::to_string(&pt).unwrap();
|
||||
let back: ProviderType = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, pt);
|
||||
|
||||
@@ -121,10 +121,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn analysis_type_prompt_name() {
|
||||
assert_eq!(AnalysisType::LabReport.prompt_name(), "lab_report_interpretation");
|
||||
assert_eq!(
|
||||
AnalysisType::LabReport.prompt_name(),
|
||||
"lab_report_interpretation"
|
||||
);
|
||||
assert_eq!(AnalysisType::Trends.prompt_name(), "health_trend_analysis");
|
||||
assert_eq!(AnalysisType::CheckupPlan.prompt_name(), "personalized_checkup_plan");
|
||||
assert_eq!(AnalysisType::ReportSummary.prompt_name(), "report_summary_generation");
|
||||
assert_eq!(
|
||||
AnalysisType::CheckupPlan.prompt_name(),
|
||||
"personalized_checkup_plan"
|
||||
);
|
||||
assert_eq!(
|
||||
AnalysisType::ReportSummary.prompt_name(),
|
||||
"report_summary_generation"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- AnalysisType serde round-trip ----
|
||||
@@ -195,7 +204,10 @@ mod tests {
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let back: AnalysisSseEvent = serde_json::from_str(&json).unwrap();
|
||||
match back {
|
||||
AnalysisSseEvent::Done { analysis_id, status } => {
|
||||
AnalysisSseEvent::Done {
|
||||
analysis_id,
|
||||
status,
|
||||
} => {
|
||||
assert_eq!(analysis_id, id);
|
||||
assert_eq!(status, "completed");
|
||||
}
|
||||
|
||||
@@ -33,7 +33,10 @@ pub enum AiError {
|
||||
DbError(String),
|
||||
|
||||
#[error("AI 配额已耗尽: {reason}")]
|
||||
QuotaExhausted { tenant_id: uuid::Uuid, reason: String },
|
||||
QuotaExhausted {
|
||||
tenant_id: uuid::Uuid,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
#[error("缓存错误: {0}")]
|
||||
CacheError(String),
|
||||
@@ -54,9 +57,7 @@ impl From<AiError> for AppError {
|
||||
AiError::Validation(msg) => AppError::Validation(msg),
|
||||
AiError::AnalysisNotFound(id) => AppError::NotFound(format!("分析结果: {id}")),
|
||||
AiError::PromptNotFound(name) => AppError::NotFound(format!("Prompt 模板: {name}")),
|
||||
AiError::ProviderUnavailable(p) => {
|
||||
AppError::Internal(format!("AI 提供商 {p} 不可用"))
|
||||
}
|
||||
AiError::ProviderUnavailable(p) => AppError::Internal(format!("AI 提供商 {p} 不可用")),
|
||||
AiError::RateLimitExceeded => AppError::TooManyRequests,
|
||||
AiError::QuotaExhausted { .. } => AppError::TooManyRequests,
|
||||
AiError::VersionMismatch => AppError::VersionMismatch,
|
||||
@@ -153,7 +154,7 @@ mod tests {
|
||||
let err = AiError::RateLimitExceeded;
|
||||
let app: AppError = err.into();
|
||||
match app {
|
||||
AppError::TooManyRequests => {},
|
||||
AppError::TooManyRequests => {}
|
||||
other => panic!("期望 AppError::TooManyRequests,得到 {:?}", other),
|
||||
}
|
||||
}
|
||||
@@ -163,7 +164,7 @@ mod tests {
|
||||
let err = AiError::VersionMismatch;
|
||||
let app: AppError = err.into();
|
||||
match app {
|
||||
AppError::VersionMismatch => {},
|
||||
AppError::VersionMismatch => {}
|
||||
other => panic!("期望 AppError::VersionMismatch,得到 {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, FromRef, Path, Query, State};
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use axum::Json;
|
||||
use erp_core::health_provider::TimeRange;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
@@ -34,9 +34,9 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.analysis.manage")?;
|
||||
let report_id = body.report_id.ok_or_else(|| {
|
||||
erp_core::error::AppError::Validation("report_id 必填".into())
|
||||
})?;
|
||||
let report_id = body
|
||||
.report_id
|
||||
.ok_or_else(|| erp_core::error::AppError::Validation("report_id 必填".into()))?;
|
||||
|
||||
let lab_dto = state
|
||||
.health_provider
|
||||
@@ -57,7 +57,10 @@ where
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
let model = model_config["model"].as_str().unwrap_or("claude-sonnet-4-6").to_string();
|
||||
let model = model_config["model"]
|
||||
.as_str()
|
||||
.unwrap_or("claude-sonnet-4-6")
|
||||
.to_string();
|
||||
let temperature = model_config["temperature"].as_f64().unwrap_or(0.3) as f32;
|
||||
let max_tokens = model_config["max_tokens"].as_u64().unwrap_or(2048) as u32;
|
||||
|
||||
@@ -83,7 +86,15 @@ where
|
||||
let patient_id_clone = uuid::Uuid::nil(); // lab report 场景 patient_id 从 report 关联
|
||||
let doctor_id_clone = ctx.user_id;
|
||||
|
||||
let sse_stream = build_sse_stream(stream, analysis_id_clone, state_clone, "lab_report", ctx.tenant_id, patient_id_clone, doctor_id_clone);
|
||||
let sse_stream = build_sse_stream(
|
||||
stream,
|
||||
analysis_id_clone,
|
||||
state_clone,
|
||||
"lab_report",
|
||||
ctx.tenant_id,
|
||||
patient_id_clone,
|
||||
doctor_id_clone,
|
||||
);
|
||||
Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
|
||||
}
|
||||
|
||||
@@ -97,9 +108,9 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.analysis.manage")?;
|
||||
let patient_id = body.patient_id.ok_or_else(|| {
|
||||
erp_core::error::AppError::Validation("patient_id 必填".into())
|
||||
})?;
|
||||
let patient_id = body
|
||||
.patient_id
|
||||
.ok_or_else(|| erp_core::error::AppError::Validation("patient_id 必填".into()))?;
|
||||
|
||||
let metrics = body.metrics.unwrap_or_else(|| {
|
||||
vec![
|
||||
@@ -126,7 +137,10 @@ where
|
||||
));
|
||||
}
|
||||
|
||||
let sanitized_data = state.analysis.sanitizer.sanitize_trend_analysis(&trend_data)?;
|
||||
let sanitized_data = state
|
||||
.analysis
|
||||
.sanitizer
|
||||
.sanitize_trend_analysis(&trend_data)?;
|
||||
|
||||
let prompt = state
|
||||
.prompt
|
||||
@@ -134,7 +148,10 @@ where
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
let model = model_config["model"].as_str().unwrap_or("claude-sonnet-4-6").to_string();
|
||||
let model = model_config["model"]
|
||||
.as_str()
|
||||
.unwrap_or("claude-sonnet-4-6")
|
||||
.to_string();
|
||||
let temperature = model_config["temperature"].as_f64().unwrap_or(0.3) as f32;
|
||||
let max_tokens = model_config["max_tokens"].as_u64().unwrap_or(2048) as u32;
|
||||
|
||||
@@ -158,7 +175,15 @@ where
|
||||
let analysis_id_clone = analysis_id;
|
||||
let state_clone = state.clone();
|
||||
|
||||
let sse_stream = build_sse_stream(stream, analysis_id_clone, state_clone, "trend", ctx.tenant_id, uuid::Uuid::nil(), ctx.user_id);
|
||||
let sse_stream = build_sse_stream(
|
||||
stream,
|
||||
analysis_id_clone,
|
||||
state_clone,
|
||||
"trend",
|
||||
ctx.tenant_id,
|
||||
uuid::Uuid::nil(),
|
||||
ctx.user_id,
|
||||
);
|
||||
Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
|
||||
}
|
||||
|
||||
@@ -172,9 +197,9 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.analysis.manage")?;
|
||||
let patient_id = body.patient_id.ok_or_else(|| {
|
||||
erp_core::error::AppError::Validation("patient_id 必填".into())
|
||||
})?;
|
||||
let patient_id = body
|
||||
.patient_id
|
||||
.ok_or_else(|| erp_core::error::AppError::Validation("patient_id 必填".into()))?;
|
||||
|
||||
let summary_dto = state
|
||||
.health_provider
|
||||
@@ -191,7 +216,10 @@ where
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
let model = model_config["model"].as_str().unwrap_or("claude-sonnet-4-6").to_string();
|
||||
let model = model_config["model"]
|
||||
.as_str()
|
||||
.unwrap_or("claude-sonnet-4-6")
|
||||
.to_string();
|
||||
let temperature = model_config["temperature"].as_f64().unwrap_or(0.3) as f32;
|
||||
let max_tokens = model_config["max_tokens"].as_u64().unwrap_or(2048) as u32;
|
||||
|
||||
@@ -215,7 +243,15 @@ where
|
||||
let analysis_id_clone = analysis_id;
|
||||
let state_clone = state.clone();
|
||||
|
||||
let sse_stream = build_sse_stream(stream, analysis_id_clone, state_clone, "checkup_plan", ctx.tenant_id, uuid::Uuid::nil(), ctx.user_id);
|
||||
let sse_stream = build_sse_stream(
|
||||
stream,
|
||||
analysis_id_clone,
|
||||
state_clone,
|
||||
"checkup_plan",
|
||||
ctx.tenant_id,
|
||||
uuid::Uuid::nil(),
|
||||
ctx.user_id,
|
||||
);
|
||||
Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
|
||||
}
|
||||
|
||||
@@ -229,9 +265,9 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.analysis.manage")?;
|
||||
let report_id = body.report_id.ok_or_else(|| {
|
||||
erp_core::error::AppError::Validation("report_id 必填".into())
|
||||
})?;
|
||||
let report_id = body
|
||||
.report_id
|
||||
.ok_or_else(|| erp_core::error::AppError::Validation("report_id 必填".into()))?;
|
||||
|
||||
let report_dto = state
|
||||
.health_provider
|
||||
@@ -255,7 +291,10 @@ where
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
let model = model_config["model"].as_str().unwrap_or("claude-sonnet-4-6").to_string();
|
||||
let model = model_config["model"]
|
||||
.as_str()
|
||||
.unwrap_or("claude-sonnet-4-6")
|
||||
.to_string();
|
||||
let temperature = model_config["temperature"].as_f64().unwrap_or(0.3) as f32;
|
||||
let max_tokens = model_config["max_tokens"].as_u64().unwrap_or(2048) as u32;
|
||||
|
||||
@@ -279,7 +318,15 @@ where
|
||||
let analysis_id_clone = analysis_id;
|
||||
let state_clone = state.clone();
|
||||
|
||||
let sse_stream = build_sse_stream(stream, analysis_id_clone, state_clone, "report_summary", ctx.tenant_id, uuid::Uuid::nil(), ctx.user_id);
|
||||
let sse_stream = build_sse_stream(
|
||||
stream,
|
||||
analysis_id_clone,
|
||||
state_clone,
|
||||
"report_summary",
|
||||
ctx.tenant_id,
|
||||
uuid::Uuid::nil(),
|
||||
ctx.user_id,
|
||||
);
|
||||
Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
|
||||
}
|
||||
|
||||
@@ -309,7 +356,12 @@ where
|
||||
};
|
||||
let (items, total) = state
|
||||
.analysis
|
||||
.list_analysis(ctx.tenant_id, params.patient_id, params.analysis_type, &pagination)
|
||||
.list_analysis(
|
||||
ctx.tenant_id,
|
||||
params.patient_id,
|
||||
params.analysis_type,
|
||||
&pagination,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 批量查询 patient_name(通过 raw SQL 避免跨 crate 依赖 erp-health)
|
||||
@@ -321,7 +373,10 @@ where
|
||||
|
||||
let patient_names: std::collections::HashMap<uuid::Uuid, String> = if !patient_ids.is_empty() {
|
||||
#[derive(sea_orm::FromQueryResult)]
|
||||
struct PatientName { id: uuid::Uuid, name: String }
|
||||
struct PatientName {
|
||||
id: uuid::Uuid,
|
||||
name: String,
|
||||
}
|
||||
let ids: Vec<uuid::Uuid> = patient_ids.into_iter().collect();
|
||||
use sea_orm::FromQueryResult;
|
||||
PatientName::find_by_statement(sea_orm::Statement::from_sql_and_values(
|
||||
@@ -339,15 +394,19 @@ where
|
||||
std::collections::HashMap::new()
|
||||
};
|
||||
|
||||
let data: Vec<serde_json::Value> = items.into_iter().map(|a| {
|
||||
let data: Vec<serde_json::Value> = items
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
let mut val = serde_json::to_value(&a).unwrap_or_default();
|
||||
if let Some(obj) = val.as_object_mut() {
|
||||
obj.insert("patient_name".to_string(), serde_json::json!(
|
||||
patient_names.get(&a.patient_id).cloned()
|
||||
));
|
||||
obj.insert(
|
||||
"patient_name".to_string(),
|
||||
serde_json::json!(patient_names.get(&a.patient_id).cloned()),
|
||||
);
|
||||
}
|
||||
val
|
||||
}).collect();
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"data": data,
|
||||
@@ -549,7 +608,9 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.analysis.list")?;
|
||||
Ok(Json(ApiResponse::ok(state.provider_registry.provider_names())))
|
||||
Ok(Json(ApiResponse::ok(
|
||||
state.provider_registry.provider_names(),
|
||||
)))
|
||||
}
|
||||
|
||||
pub async fn quota_summary<S>(
|
||||
@@ -570,7 +631,10 @@ where
|
||||
pub async fn assess_dialysis_risk<S>(
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(body): Json<crate::service::dialysis_risk_scorer::DialysisLabInput>,
|
||||
) -> Result<Json<ApiResponse<crate::service::dialysis_risk_scorer::DialysisRiskAssessment>>, erp_core::error::AppError>
|
||||
) -> Result<
|
||||
Json<ApiResponse<crate::service::dialysis_risk_scorer::DialysisRiskAssessment>>,
|
||||
erp_core::error::AppError,
|
||||
>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
@@ -612,7 +676,9 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.usage.list")?;
|
||||
let model = params.model.unwrap_or_else(|| "claude-sonnet-4-6".to_string());
|
||||
let model = params
|
||||
.model
|
||||
.unwrap_or_else(|| "claude-sonnet-4-6".to_string());
|
||||
let estimate = crate::service::cost::CostService::estimate_cost(¶ms.analysis_type, &model);
|
||||
Ok(Json(ApiResponse::ok(estimate)))
|
||||
}
|
||||
@@ -699,9 +765,10 @@ fn validate_prompt_safety(content: &str) -> Result<(), erp_core::error::AppError
|
||||
let lower = content.to_lowercase();
|
||||
for pattern in &suspicious {
|
||||
if lower.contains(pattern) {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
format!("提示词内容包含不安全模式: {}", pattern),
|
||||
));
|
||||
return Err(erp_core::error::AppError::Validation(format!(
|
||||
"提示词内容包含不安全模式: {}",
|
||||
pattern
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use axum::extract::{Extension, FromRef, Path, State};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, FromRef, Path, State};
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
use serde::Deserialize;
|
||||
@@ -26,19 +26,14 @@ where
|
||||
require_permission(&ctx, "ai.suggestion.list")?;
|
||||
|
||||
if let Some(analysis_id) = params.analysis_id {
|
||||
let items = SuggestionService::list_by_analysis(
|
||||
&state.db,
|
||||
ctx.tenant_id,
|
||||
analysis_id,
|
||||
)
|
||||
.await?;
|
||||
let items =
|
||||
SuggestionService::list_by_analysis(&state.db, ctx.tenant_id, analysis_id).await?;
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"data": items,
|
||||
"total": items.len(),
|
||||
}))))
|
||||
} else {
|
||||
let items =
|
||||
SuggestionService::list_pending(&state.db, ctx.tenant_id).await?;
|
||||
let items = SuggestionService::list_pending(&state.db, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"data": items,
|
||||
"total": items.len(),
|
||||
@@ -69,7 +64,7 @@ where
|
||||
_ => {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"action 必须为 approve 或 reject".into(),
|
||||
))
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -151,7 +146,10 @@ where
|
||||
|
||||
match &suggestion.baseline_snapshot {
|
||||
Some(bs) if !bs.is_null() => {
|
||||
let action_result = suggestion.action_result.as_ref().unwrap_or(&serde_json::Value::Null);
|
||||
let action_result = suggestion
|
||||
.action_result
|
||||
.as_ref()
|
||||
.unwrap_or(&serde_json::Value::Null);
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"suggestion_id": id,
|
||||
"baseline": bs,
|
||||
|
||||
@@ -156,13 +156,16 @@ impl KnowledgeSource for StructuredKnowledgeSource {
|
||||
|
||||
async fn health_check(&self) -> AiResult<bool> {
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
#[allow(dead_code)]
|
||||
struct HealthCheck {
|
||||
#[allow(dead_code)]
|
||||
ok: i32,
|
||||
}
|
||||
|
||||
let result: Option<HealthCheck> = HealthCheck::find_by_statement(
|
||||
Statement::from_string(sea_orm::DatabaseBackend::Postgres, "SELECT 1 AS ok".to_string()),
|
||||
)
|
||||
let result: Option<HealthCheck> = HealthCheck::find_by_statement(Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"SELECT 1 AS ok".to_string(),
|
||||
))
|
||||
.one(&self.db)
|
||||
.await
|
||||
.ok()
|
||||
@@ -180,7 +183,8 @@ mod tests {
|
||||
let rules_empty: Vec<ai_knowledge_rules::Model> = vec![];
|
||||
let refs_empty: Vec<ai_knowledge_references::Model> = vec![];
|
||||
let guides_empty: Vec<ai_knowledge_guides::Model> = vec![];
|
||||
let confidence: f32 = if rules_empty.is_empty() && refs_empty.is_empty() && guides_empty.is_empty() {
|
||||
let confidence: f32 =
|
||||
if rules_empty.is_empty() && refs_empty.is_empty() && guides_empty.is_empty() {
|
||||
0.0
|
||||
} else if !rules_empty.is_empty() && !refs_empty.is_empty() {
|
||||
0.9
|
||||
|
||||
@@ -88,18 +88,28 @@ impl ErpModule for AiModule {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Some(event) if event.event_type == "ai.reanalysis.requested" => {
|
||||
let suggestion_id = event.payload.get("original_suggestion_id")
|
||||
let suggestion_id = event
|
||||
.payload
|
||||
.get("original_suggestion_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
let patient_id = event.payload.get("patient_id")
|
||||
let patient_id = event
|
||||
.payload
|
||||
.get("patient_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
|
||||
match (suggestion_id, patient_id) {
|
||||
(Some(sid), Some(pid)) => {
|
||||
if let Err(e) = crate::service::reanalysis::handle_reanalysis_requested(
|
||||
&db, event.tenant_id, sid, pid,
|
||||
).await {
|
||||
if let Err(e) =
|
||||
crate::service::reanalysis::handle_reanalysis_requested(
|
||||
&db,
|
||||
event.tenant_id,
|
||||
sid,
|
||||
pid,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
suggestion_id = %sid,
|
||||
error = %e,
|
||||
@@ -114,10 +124,14 @@ impl ErpModule for AiModule {
|
||||
}
|
||||
Some(event) if event.event_type == "ai.analysis.requested" => {
|
||||
let source_type = event.payload.get("source_type").and_then(|v| v.as_str());
|
||||
let source_id = event.payload.get("source_id")
|
||||
let source_id = event
|
||||
.payload
|
||||
.get("source_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
let patient_id = event.payload.get("patient_id")
|
||||
let patient_id = event
|
||||
.payload
|
||||
.get("patient_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
|
||||
@@ -131,10 +145,14 @@ impl ErpModule for AiModule {
|
||||
}
|
||||
// H4: 透析记录→KDIGO 自动风险评估
|
||||
Some(event) if event.event_type == "ai.dialysis.kdigo_requested" => {
|
||||
let patient_id = event.payload.get("patient_id")
|
||||
let patient_id = event
|
||||
.payload
|
||||
.get("patient_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
let record_id = event.payload.get("dialysis_record_id")
|
||||
let record_id = event
|
||||
.payload
|
||||
.get("dialysis_record_id")
|
||||
.and_then(|v| v.as_str());
|
||||
|
||||
tracing::info!(
|
||||
|
||||
@@ -8,6 +8,12 @@ pub struct PromptRenderer {
|
||||
registry: Handlebars<'static>,
|
||||
}
|
||||
|
||||
impl Default for PromptRenderer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PromptRenderer {
|
||||
pub fn new() -> Self {
|
||||
let mut registry = Handlebars::new();
|
||||
@@ -43,7 +49,8 @@ mod tests {
|
||||
#[test]
|
||||
fn render_multiple_variables() {
|
||||
let r = renderer();
|
||||
let result = r.render(
|
||||
let result = r
|
||||
.render(
|
||||
"{{age_group}} {{sex}} 化验报告",
|
||||
&json!({"age_group": "中年", "sex": "男性"}),
|
||||
)
|
||||
@@ -57,7 +64,9 @@ mod tests {
|
||||
let data = json!({
|
||||
"report": { "date": "2026-05-01", "department": "内科" }
|
||||
});
|
||||
let result = r.render("科室: {{report.department}},日期: {{report.date}}", &data).unwrap();
|
||||
let result = r
|
||||
.render("科室: {{report.department}},日期: {{report.date}}", &data)
|
||||
.unwrap();
|
||||
assert_eq!(result, "科室: 内科,日期: 2026-05-01");
|
||||
}
|
||||
|
||||
@@ -65,7 +74,9 @@ mod tests {
|
||||
fn render_with_array_iteration() {
|
||||
let r = renderer();
|
||||
let data = json!({"items": ["WBC", "HGB", "PLT"]});
|
||||
let result = r.render("指标: {{#each items}}{{this}}, {{/each}}", &data).unwrap();
|
||||
let result = r
|
||||
.render("指标: {{#each items}}{{this}}, {{/each}}", &data)
|
||||
.unwrap();
|
||||
assert_eq!(result, "指标: WBC, HGB, PLT, ");
|
||||
}
|
||||
|
||||
@@ -98,7 +109,8 @@ mod tests {
|
||||
fn render_with_conditional() {
|
||||
let r = renderer();
|
||||
let data = json!({"is_abnormal": true, "value": "偏高"});
|
||||
let result = r.render(
|
||||
let result = r
|
||||
.render(
|
||||
"{{#if is_abnormal}}异常: {{value}}{{else}}正常{{/if}}",
|
||||
&data,
|
||||
)
|
||||
|
||||
@@ -129,18 +129,15 @@ impl AiProvider for ClaudeProvider {
|
||||
if data == "[DONE]" {
|
||||
return;
|
||||
}
|
||||
if let Ok(event) = serde_json::from_str::<ClaudeStreamEvent>(data) {
|
||||
if event.event_type == "content_block_delta" {
|
||||
if let Some(delta) = event.delta {
|
||||
if let Some(text) = delta.text {
|
||||
if let Ok(event) = serde_json::from_str::<ClaudeStreamEvent>(data)
|
||||
&& event.event_type == "content_block_delta"
|
||||
&& let Some(delta) = event.delta
|
||||
&& let Some(text) = delta.text {
|
||||
yield Ok(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(stream)
|
||||
@@ -179,9 +176,7 @@ impl AiProvider for ClaudeProvider {
|
||||
.map_err(|e| AiError::ProviderError(e.to_string()))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AiError::ProviderError(format!(
|
||||
"Claude {status}: {body}"
|
||||
)));
|
||||
return Err(AiError::ProviderError(format!("Claude {status}: {body}")));
|
||||
}
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&body)
|
||||
|
||||
@@ -75,8 +75,10 @@ struct OllamaStreamChunk {
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct OllamaStreamMessage {
|
||||
content: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
thinking: Option<String>,
|
||||
}
|
||||
|
||||
@@ -87,7 +89,10 @@ fn strip_think_block(content: &str) -> String {
|
||||
if let Some(end) = content.find("</think") {
|
||||
// 跳过 </think 标签及其后的 > 或 \n
|
||||
let after_tag = &content[end + 7..]; // skip "</think"
|
||||
let actual = after_tag.trim_start_matches('\n').trim_start_matches('>').trim_start();
|
||||
let actual = after_tag
|
||||
.trim_start_matches('\n')
|
||||
.trim_start_matches('>')
|
||||
.trim_start();
|
||||
return actual.to_string();
|
||||
}
|
||||
content.to_string()
|
||||
@@ -193,16 +198,14 @@ impl AiProvider for OllamaProvider {
|
||||
if chunk.done {
|
||||
return;
|
||||
}
|
||||
if let Some(msg) = chunk.message {
|
||||
if let Some(content) = msg.content {
|
||||
if !content.is_empty() {
|
||||
if let Some(msg) = chunk.message
|
||||
&& let Some(content) = msg.content
|
||||
&& !content.is_empty() {
|
||||
yield Ok(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(s)
|
||||
@@ -252,9 +255,7 @@ impl AiProvider for OllamaProvider {
|
||||
.map_err(|e| AiError::ProviderError(e.to_string()))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AiError::ProviderError(format!(
|
||||
"Ollama {status}: {body}"
|
||||
)));
|
||||
return Err(AiError::ProviderError(format!("Ollama {status}: {body}")));
|
||||
}
|
||||
|
||||
let parsed: OllamaChatResponse = serde_json::from_str(&body)
|
||||
@@ -307,10 +308,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ollama_provider_construction() {
|
||||
let provider = OllamaProvider::new(
|
||||
"http://localhost:11434".into(),
|
||||
"qwen2.5:7b".into(),
|
||||
);
|
||||
let provider = OllamaProvider::new("http://localhost:11434".into(), "qwen2.5:7b".into());
|
||||
assert_eq!(provider.name(), "ollama");
|
||||
assert_eq!(provider.default_model, "qwen2.5:7b");
|
||||
}
|
||||
@@ -367,10 +365,7 @@ mod tests {
|
||||
}"#;
|
||||
let chunk: OllamaStreamChunk = serde_json::from_str(json).unwrap();
|
||||
assert!(!chunk.done);
|
||||
assert_eq!(
|
||||
chunk.message.unwrap().content,
|
||||
Some("Hello".to_string())
|
||||
);
|
||||
assert_eq!(chunk.message.unwrap().content, Some("Hello".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -388,10 +383,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn base_url_preserved() {
|
||||
let provider = OllamaProvider::new(
|
||||
"http://192.168.1.100:11434".into(),
|
||||
"llama3.1:8b".into(),
|
||||
);
|
||||
let provider =
|
||||
OllamaProvider::new("http://192.168.1.100:11434".into(), "llama3.1:8b".into());
|
||||
assert_eq!(provider.base_url, "http://192.168.1.100:11434");
|
||||
}
|
||||
|
||||
|
||||
@@ -202,9 +202,7 @@ impl AiProvider for OpenAIProvider {
|
||||
.map_err(|e| AiError::ProviderError(e.to_string()))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AiError::ProviderError(format!(
|
||||
"OpenAI {status}: {body}"
|
||||
)));
|
||||
return Err(AiError::ProviderError(format!("OpenAI {status}: {body}")));
|
||||
}
|
||||
|
||||
let parsed: ChatResponse = serde_json::from_str(&body)
|
||||
|
||||
@@ -9,9 +9,17 @@ use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum ProviderHealth {
|
||||
Healthy { last_check: DateTime<Utc> },
|
||||
Degraded { last_check: DateTime<Utc>, error: String },
|
||||
Unavailable { since: DateTime<Utc>, error: String },
|
||||
Healthy {
|
||||
last_check: DateTime<Utc>,
|
||||
},
|
||||
Degraded {
|
||||
last_check: DateTime<Utc>,
|
||||
error: String,
|
||||
},
|
||||
Unavailable {
|
||||
since: DateTime<Utc>,
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ProviderHealth {
|
||||
@@ -29,6 +37,12 @@ pub struct ProviderRegistry {
|
||||
entries: DashMap<String, ProviderEntry>,
|
||||
}
|
||||
|
||||
impl Default for ProviderRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -40,19 +54,20 @@ impl ProviderRegistry {
|
||||
let health = Arc::new(RwLock::new(ProviderHealth::Healthy {
|
||||
last_check: Utc::now(),
|
||||
}));
|
||||
self.entries.insert(name, ProviderEntry { provider, health });
|
||||
self.entries
|
||||
.insert(name, ProviderEntry { provider, health });
|
||||
}
|
||||
|
||||
pub async fn resolve(&self, preferred: &str) -> crate::error::AiResult<ResolvedProvider> {
|
||||
// 1. 首选 Provider(实时健康检查)
|
||||
if let Some(entry) = self.entries.get(preferred) {
|
||||
if entry.provider.health_check().await.unwrap_or(false) {
|
||||
if let Some(entry) = self.entries.get(preferred)
|
||||
&& entry.provider.health_check().await.unwrap_or(false)
|
||||
{
|
||||
return Ok(ResolvedProvider {
|
||||
provider_name: preferred.to_string(),
|
||||
provider: entry.provider.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 任何可用 Provider
|
||||
for entry in self.entries.iter() {
|
||||
@@ -72,7 +87,9 @@ impl ProviderRegistry {
|
||||
for entry in self.entries.iter() {
|
||||
let healthy = entry.value().provider.health_check().await.unwrap_or(false);
|
||||
let new_health = if healthy {
|
||||
ProviderHealth::Healthy { last_check: Utc::now() }
|
||||
ProviderHealth::Healthy {
|
||||
last_check: Utc::now(),
|
||||
}
|
||||
} else {
|
||||
ProviderHealth::Unavailable {
|
||||
since: Utc::now(),
|
||||
@@ -96,14 +113,22 @@ pub struct ResolvedProvider {
|
||||
}
|
||||
|
||||
impl ResolvedProvider {
|
||||
pub fn provider_name(&self) -> &str { &self.provider_name }
|
||||
pub fn provider(&self) -> &dyn AiProvider { self.provider.as_ref() }
|
||||
pub fn into_arc(self) -> Arc<dyn AiProvider> { self.provider }
|
||||
pub fn provider_name(&self) -> &str {
|
||||
&self.provider_name
|
||||
}
|
||||
pub fn provider(&self) -> &dyn AiProvider {
|
||||
self.provider.as_ref()
|
||||
}
|
||||
pub fn into_arc(self) -> Arc<dyn AiProvider> {
|
||||
self.provider
|
||||
}
|
||||
}
|
||||
|
||||
// === 测试桩 ===
|
||||
|
||||
#[allow(dead_code)]
|
||||
struct MockProvider {
|
||||
#[allow(dead_code)]
|
||||
name: String,
|
||||
healthy: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
@@ -113,13 +138,18 @@ impl AiProvider for MockProvider {
|
||||
async fn stream_generate(
|
||||
&self,
|
||||
_req: crate::dto::GenerateRequest,
|
||||
) -> crate::error::AiResult<std::pin::Pin<Box<dyn futures::Stream<Item = crate::error::AiResult<String>> + Send>>> {
|
||||
) -> crate::error::AiResult<
|
||||
std::pin::Pin<Box<dyn futures::Stream<Item = crate::error::AiResult<String>> + Send>>,
|
||||
> {
|
||||
// 简单返回一个空流
|
||||
let s = async_stream::stream! { yield Ok("mock".to_string()); };
|
||||
Ok(Box::pin(s))
|
||||
}
|
||||
|
||||
async fn generate(&self, _req: crate::dto::GenerateRequest) -> crate::error::AiResult<crate::dto::GenerateResponse> {
|
||||
async fn generate(
|
||||
&self,
|
||||
_req: crate::dto::GenerateRequest,
|
||||
) -> crate::error::AiResult<crate::dto::GenerateResponse> {
|
||||
Ok(crate::dto::GenerateResponse {
|
||||
content: "mock".to_string(),
|
||||
model: "mock".to_string(),
|
||||
@@ -129,7 +159,9 @@ impl AiProvider for MockProvider {
|
||||
})
|
||||
}
|
||||
|
||||
fn name(&self) -> &str { &self.name }
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> crate::error::AiResult<bool> {
|
||||
Ok(self.healthy.load(std::sync::atomic::Ordering::Relaxed))
|
||||
|
||||
@@ -10,6 +10,12 @@ use crate::error::{AiError, AiResult};
|
||||
/// 此服务做二次检查和安全约束注入
|
||||
pub struct SanitizationService;
|
||||
|
||||
impl Default for SanitizationService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SanitizationService {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
@@ -77,8 +83,8 @@ impl SanitizationService {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use erp_core::health_provider::{
|
||||
HealthReportDto, LabReportDto, PatientSummaryDto, ReportSectionDto,
|
||||
VitalSignDto, LabItemDto,
|
||||
HealthReportDto, LabItemDto, LabReportDto, PatientSummaryDto, ReportSectionDto,
|
||||
VitalSignDto,
|
||||
};
|
||||
|
||||
fn sanitizer() -> SanitizationService {
|
||||
@@ -172,13 +178,24 @@ mod tests {
|
||||
#[test]
|
||||
fn verify_no_pii_detects_all_pii_keys() {
|
||||
let svc = sanitizer();
|
||||
let pii_keys = ["name", "phone", "id_number", "address", "birth_date", "email"];
|
||||
let pii_keys = [
|
||||
"name",
|
||||
"phone",
|
||||
"id_number",
|
||||
"address",
|
||||
"birth_date",
|
||||
"email",
|
||||
];
|
||||
for key in pii_keys {
|
||||
let mut report_json = serde_json::to_value(&clean_lab_report()).unwrap();
|
||||
report_json[key] = serde_json::json!("test");
|
||||
let report: LabReportDto = serde_json::from_value(report_json).unwrap();
|
||||
let result = svc.sanitize_lab_report(&report);
|
||||
assert!(result.is_ok(), "LabReportDto 不包含 {} 字段,反序列化时被丢弃", key);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"LabReportDto 不包含 {} 字段,反序列化时被丢弃",
|
||||
key
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,9 +205,19 @@ mod tests {
|
||||
fn dto_serialization_contains_no_pii() {
|
||||
let report = clean_lab_report();
|
||||
let val = serde_json::to_value(&report).unwrap();
|
||||
for key in &["name", "phone", "id_number", "address", "birth_date", "email"] {
|
||||
assert!(!val.as_object().unwrap().contains_key(*key),
|
||||
"LabReportDto 不应包含 PII 字段: {}", key);
|
||||
for key in &[
|
||||
"name",
|
||||
"phone",
|
||||
"id_number",
|
||||
"address",
|
||||
"birth_date",
|
||||
"email",
|
||||
] {
|
||||
assert!(
|
||||
!val.as_object().unwrap().contains_key(*key),
|
||||
"LabReportDto 不应包含 PII 字段: {}",
|
||||
key
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
use erp_core::types::Pagination;
|
||||
use futures::Stream;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
|
||||
QuerySelect, Set,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::pin::Pin;
|
||||
use uuid::Uuid;
|
||||
use erp_core::types::Pagination;
|
||||
|
||||
use crate::dto::{AnalysisType, GenerateRequest};
|
||||
use crate::entity::ai_analysis;
|
||||
@@ -38,6 +41,7 @@ impl AnalysisService {
|
||||
}
|
||||
|
||||
/// 执行流式分析 — 返回 SSE 事件流
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn stream_analyze(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
@@ -64,7 +68,10 @@ impl AnalysisService {
|
||||
if let Some(cached) = self.find_cached(tenant_id, &input_hash, 1).await? {
|
||||
tracing::info!(analysis = %cached.id, "AI 分析缓存命中,复用已有结果");
|
||||
let content = cached.result_content.clone().unwrap_or_default();
|
||||
let metadata = cached.result_metadata.clone().unwrap_or(serde_json::json!({}));
|
||||
let metadata = cached
|
||||
.result_metadata
|
||||
.clone()
|
||||
.unwrap_or(serde_json::json!({}));
|
||||
let stream = self.replay_cached(content, metadata);
|
||||
return Ok((stream, cached.id, provider_name));
|
||||
}
|
||||
@@ -86,7 +93,10 @@ impl AnalysisService {
|
||||
confidence = ctx.confidence,
|
||||
"知识库上下文注入"
|
||||
);
|
||||
format!("{}\n\n=== 知识库参考 ===\n{}", system_prompt, ctx.context_text)
|
||||
format!(
|
||||
"{}\n\n=== 知识库参考 ===\n{}",
|
||||
system_prompt, ctx.context_text
|
||||
)
|
||||
}
|
||||
Ok(_) => system_prompt,
|
||||
Err(e) => {
|
||||
@@ -234,11 +244,7 @@ impl AnalysisService {
|
||||
}
|
||||
|
||||
/// 获取单条分析记录
|
||||
pub async fn get_analysis(
|
||||
&self,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<ai_analysis::Model> {
|
||||
pub async fn get_analysis(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_analysis::Model> {
|
||||
let model = ai_analysis::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
@@ -249,6 +255,7 @@ impl AnalysisService {
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn create_analysis_record(
|
||||
&self,
|
||||
id: Uuid,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, FromQueryResult, QueryFilter, QueryOrder, Set, Statement};
|
||||
use sea_orm::{ActiveModelTrait, EntityTrait, FromQueryResult, Set, Statement};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entity::ai_analysis_queue;
|
||||
use crate::error::{AiError, AiResult};
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
#[allow(dead_code)]
|
||||
struct QueueRow {
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
@@ -88,7 +89,10 @@ impl AnalysisQueue {
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn claim_next(&self, tenant_id: Option<Uuid>) -> AiResult<Option<ai_analysis_queue::Model>> {
|
||||
pub async fn claim_next(
|
||||
&self,
|
||||
tenant_id: Option<Uuid>,
|
||||
) -> AiResult<Option<ai_analysis_queue::Model>> {
|
||||
let sql = match tenant_id {
|
||||
Some(tid) => format!(
|
||||
"SELECT * FROM ai_analysis_queue WHERE tenant_id = '{}' AND status = 'pending' AND deleted_at IS NULL AND scheduled_at <= NOW() ORDER BY priority DESC, scheduled_at ASC LIMIT 1",
|
||||
@@ -101,19 +105,22 @@ impl AnalysisQueue {
|
||||
AND scheduled_at <= NOW()
|
||||
ORDER BY priority DESC, scheduled_at ASC
|
||||
LIMIT 1
|
||||
"#.to_string(),
|
||||
"#
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
let row: Option<QueueRow> = QueueRow::find_by_statement(
|
||||
Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.to_string()),
|
||||
)
|
||||
let row: Option<QueueRow> = QueueRow::find_by_statement(Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql.to_string(),
|
||||
))
|
||||
.one(&self.db)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
Some(r) => {
|
||||
let now = chrono::Utc::now();
|
||||
let mut active: ai_analysis_queue::ActiveModel = self.find_by_id(r.id).await?.into();
|
||||
let mut active: ai_analysis_queue::ActiveModel =
|
||||
self.find_by_id(r.id).await?.into();
|
||||
active.status = Set("running".to_string());
|
||||
active.started_at = Set(Some(now));
|
||||
active.updated_at = Set(now);
|
||||
@@ -125,11 +132,7 @@ impl AnalysisQueue {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn mark_completed(
|
||||
&self,
|
||||
id: Uuid,
|
||||
result_analysis_id: Uuid,
|
||||
) -> AiResult<()> {
|
||||
pub async fn mark_completed(&self, id: Uuid, result_analysis_id: Uuid) -> AiResult<()> {
|
||||
let job = self.find_by_id(id).await?;
|
||||
let now = chrono::Utc::now();
|
||||
let mut active: ai_analysis_queue::ActiveModel = job.into();
|
||||
@@ -179,13 +182,12 @@ impl AnalysisQueue {
|
||||
GROUP BY status
|
||||
"#;
|
||||
|
||||
let rows: Vec<StatusCount> = StatusCount::find_by_statement(
|
||||
Statement::from_sql_and_values(
|
||||
let rows: Vec<StatusCount> =
|
||||
StatusCount::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
),
|
||||
)
|
||||
))
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -50,19 +50,13 @@ async fn run_auto_analysis(state: &AiState) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
total_analyzed,
|
||||
total_errors,
|
||||
"自动趋势分析任务完成"
|
||||
);
|
||||
tracing::info!(total_analyzed, total_errors, "自动趋势分析任务完成");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 查找所有活跃租户 ID
|
||||
async fn find_active_tenants(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> Result<Vec<Uuid>, String> {
|
||||
async fn find_active_tenants(db: &sea_orm::DatabaseConnection) -> Result<Vec<Uuid>, String> {
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct TenantId {
|
||||
id: Uuid,
|
||||
|
||||
@@ -16,7 +16,12 @@ pub struct CacheKey {
|
||||
}
|
||||
|
||||
impl CacheKey {
|
||||
pub fn new(tenant_id: Uuid, analysis_type: &str, input: &serde_json::Value, prompt_version: i32) -> Self {
|
||||
pub fn new(
|
||||
tenant_id: Uuid,
|
||||
analysis_type: &str,
|
||||
input: &serde_json::Value,
|
||||
prompt_version: i32,
|
||||
) -> Self {
|
||||
let canonical = serde_json::to_string(input).unwrap_or_default();
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(canonical.as_bytes());
|
||||
@@ -54,8 +59,16 @@ pub struct CacheService {
|
||||
}
|
||||
|
||||
impl CacheService {
|
||||
pub fn new(redis: redis::Client, db: sea_orm::DatabaseConnection, default_ttl: Duration) -> Self {
|
||||
Self { redis, db, default_ttl }
|
||||
pub fn new(
|
||||
redis: redis::Client,
|
||||
db: sea_orm::DatabaseConnection,
|
||||
default_ttl: Duration,
|
||||
) -> Self {
|
||||
Self {
|
||||
redis,
|
||||
db,
|
||||
default_ttl,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get(&self, key: &CacheKey) -> AiResult<Option<CachedAnalysis>> {
|
||||
@@ -127,12 +140,13 @@ impl CacheService {
|
||||
let data: Option<String> = conn.get(key).await?;
|
||||
match data {
|
||||
Some(json) => {
|
||||
let cached: CachedAnalysis = serde_json::from_str(&json)
|
||||
.map_err(|e| redis::RedisError::from((
|
||||
let cached: CachedAnalysis = serde_json::from_str(&json).map_err(|e| {
|
||||
redis::RedisError::from((
|
||||
redis::ErrorKind::TypeError,
|
||||
"反序列化失败",
|
||||
e.to_string(),
|
||||
)))?;
|
||||
))
|
||||
})?;
|
||||
Ok(Some(cached))
|
||||
}
|
||||
None => Ok(None),
|
||||
@@ -141,12 +155,10 @@ impl CacheService {
|
||||
|
||||
async fn try_redis_set(&self, key: &str, value: &CachedAnalysis) -> redis::RedisResult<()> {
|
||||
let mut conn = self.redis.get_multiplexed_async_connection().await?;
|
||||
let json = serde_json::to_string(value).map_err(|e| redis::RedisError::from((
|
||||
redis::ErrorKind::TypeError,
|
||||
"序列化失败",
|
||||
e.to_string(),
|
||||
)))?;
|
||||
let (): () = conn.set_ex(key, json, self.default_ttl.as_secs() as u64).await?;
|
||||
let json = serde_json::to_string(value).map_err(|e| {
|
||||
redis::RedisError::from((redis::ErrorKind::TypeError, "序列化失败", e.to_string()))
|
||||
})?;
|
||||
let (): () = conn.set_ex(key, json, self.default_ttl.as_secs()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -155,8 +167,7 @@ impl CacheService {
|
||||
let mut count = 0u64;
|
||||
let mut cursor: u64 = 0;
|
||||
loop {
|
||||
let (new_cursor, keys): (u64, Vec<String>) =
|
||||
redis::cmd("SCAN")
|
||||
let (new_cursor, keys): (u64, Vec<String>) = redis::cmd("SCAN")
|
||||
.arg(cursor)
|
||||
.arg("MATCH")
|
||||
.arg(pattern)
|
||||
|
||||
@@ -38,8 +38,9 @@ pub fn generate_comparison(
|
||||
// 提取可比较的数值指标
|
||||
if let (Some(b_obj), Some(c_obj)) = (baseline.as_object(), current.as_object()) {
|
||||
for key in b_obj.keys() {
|
||||
if let (Some(b_val), Some(c_val)) = (b_obj.get(key), c_obj.get(key)) {
|
||||
if let (Some(b_num), Some(c_num)) = (b_val.as_f64(), c_val.as_f64()) {
|
||||
if let (Some(b_val), Some(c_val)) = (b_obj.get(key), c_obj.get(key))
|
||||
&& let (Some(b_num), Some(c_num)) = (b_val.as_f64(), c_val.as_f64())
|
||||
{
|
||||
let change_pct = if b_num.abs() > 0.0001 {
|
||||
((c_num - b_num) / b_num.abs()) * 100.0
|
||||
} else {
|
||||
@@ -60,10 +61,12 @@ pub fn generate_comparison(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 综合趋势判断
|
||||
let changed = changes.iter().filter(|c| c.trend == TrendDirection::Worsening).count();
|
||||
let changed = changes
|
||||
.iter()
|
||||
.filter(|c| c.trend == TrendDirection::Worsening)
|
||||
.count();
|
||||
let overall = if changed > 0 {
|
||||
TrendDirection::Worsening
|
||||
} else {
|
||||
|
||||
@@ -74,8 +74,7 @@ impl CostService {
|
||||
pub fn estimate_cost(analysis_type: &str, model: &str) -> CostEstimate {
|
||||
let (input_tokens, output_tokens) = default_token_estimate(analysis_type);
|
||||
let (input_cost, output_cost) = model_cost_per_million(model);
|
||||
let estimated_cost_usd =
|
||||
(input_tokens as f64 * input_cost / 1_000_000.0)
|
||||
let estimated_cost_usd = (input_tokens as f64 * input_cost / 1_000_000.0)
|
||||
+ (output_tokens as f64 * output_cost / 1_000_000.0);
|
||||
|
||||
CostEstimate {
|
||||
@@ -143,13 +142,11 @@ impl CostService {
|
||||
AND created_at >= DATE_TRUNC('month', CURRENT_DATE)
|
||||
"#;
|
||||
|
||||
let row: Option<TokenSum> = TokenSum::find_by_statement(
|
||||
Statement::from_sql_and_values(
|
||||
let row: Option<TokenSum> = TokenSum::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
),
|
||||
)
|
||||
))
|
||||
.one(&self.db)
|
||||
.await?;
|
||||
|
||||
@@ -179,7 +176,10 @@ mod tests {
|
||||
#[test]
|
||||
fn budget_warning_levels() {
|
||||
assert_eq!(BudgetWarningLevel::Normal, BudgetWarningLevel::Normal);
|
||||
assert!(matches!(BudgetWarningLevel::Exceeded, BudgetWarningLevel::Exceeded));
|
||||
assert!(matches!(
|
||||
BudgetWarningLevel::Exceeded,
|
||||
BudgetWarningLevel::Exceeded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::dto::suggestion::{RiskLevel, SuggestionType, StructuredSuggestion};
|
||||
use crate::dto::suggestion::{RiskLevel, StructuredSuggestion, SuggestionType};
|
||||
use crate::service::local_rules::{CompareOp, LocalRule, LocalRulesEngine};
|
||||
|
||||
/// 透析患者实验室指标输入
|
||||
@@ -73,6 +73,12 @@ pub struct DialysisRiskScorer {
|
||||
engine: LocalRulesEngine,
|
||||
}
|
||||
|
||||
impl Default for DialysisRiskScorer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DialysisRiskScorer {
|
||||
pub fn new() -> Self {
|
||||
let rules = vec![
|
||||
@@ -113,8 +119,7 @@ impl DialysisRiskScorer {
|
||||
threshold: 7.0,
|
||||
risk_level: RiskLevel::High,
|
||||
suggestion_type: SuggestionType::Alert,
|
||||
message_template: "血磷={value}mg/dL严重偏高(>7.0),需紧急评估钙磷代谢"
|
||||
.into(),
|
||||
message_template: "血磷={value}mg/dL严重偏高(>7.0),需紧急评估钙磷代谢".into(),
|
||||
},
|
||||
// 透前血钾 > 6.0 mEq/L:危急高钾
|
||||
LocalRule {
|
||||
@@ -161,8 +166,7 @@ impl DialysisRiskScorer {
|
||||
threshold: 5.0,
|
||||
risk_level: RiskLevel::High,
|
||||
suggestion_type: SuggestionType::Alert,
|
||||
message_template: "透析间期体重增长{value}%(>5%干体重),容量超负荷风险高"
|
||||
.into(),
|
||||
message_template: "透析间期体重增长{value}%(>5%干体重),容量超负荷风险高".into(),
|
||||
},
|
||||
// 体重增长 > 3.5%:需关注
|
||||
LocalRule {
|
||||
@@ -199,8 +203,7 @@ impl DialysisRiskScorer {
|
||||
threshold: 3.0,
|
||||
risk_level: RiskLevel::High,
|
||||
suggestion_type: SuggestionType::Alert,
|
||||
message_template: "白蛋白={value}g/dL严重偏低(<3.0),营养不良增加死亡风险"
|
||||
.into(),
|
||||
message_template: "白蛋白={value}g/dL严重偏低(<3.0),营养不良增加死亡风险".into(),
|
||||
},
|
||||
];
|
||||
Self {
|
||||
@@ -221,18 +224,15 @@ impl DialysisRiskScorer {
|
||||
|
||||
let suggestions = self.engine.evaluate(&metrics);
|
||||
|
||||
let mut risk_factors: Vec<String> = suggestions
|
||||
.iter()
|
||||
.map(|s| s.reason.clone())
|
||||
.collect();
|
||||
let mut risk_factors: Vec<String> = suggestions.iter().map(|s| s.reason.clone()).collect();
|
||||
|
||||
let kdigo_stage = input.egfr.map(KdigoStage::from_egfr);
|
||||
|
||||
if let Some(stage) = kdigo_stage {
|
||||
if matches!(stage, KdigoStage::G4 | KdigoStage::G5) {
|
||||
if let Some(stage) = kdigo_stage
|
||||
&& matches!(stage, KdigoStage::G4 | KdigoStage::G5)
|
||||
{
|
||||
risk_factors.push(format!("KDIGO分期{},肾功能严重受损", stage.label()));
|
||||
}
|
||||
}
|
||||
|
||||
let overall_risk = if suggestions.iter().any(|s| s.priority == 1) {
|
||||
RiskLevel::High
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::dto::suggestion::{RiskLevel, SuggestionType, StructuredSuggestion};
|
||||
use crate::dto::suggestion::{RiskLevel, StructuredSuggestion, SuggestionType};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalRule {
|
||||
@@ -120,9 +120,7 @@ impl LocalRulesEngine {
|
||||
RiskLevel::Medium => "2周内".into(),
|
||||
RiskLevel::Low => "1个月内".into(),
|
||||
},
|
||||
reason: rule
|
||||
.message_template
|
||||
.replace("{value}", &value.to_string()),
|
||||
reason: rule.message_template.replace("{value}", &value.to_string()),
|
||||
params: serde_json::json!({
|
||||
"metric": rule.metric,
|
||||
"value": value,
|
||||
@@ -156,7 +154,8 @@ mod tests {
|
||||
#[test]
|
||||
fn evaluate_all_normal_no_suggestions() {
|
||||
let rules = LocalRulesEngine::default_rules();
|
||||
let metrics = serde_json::json!({"systolic_bp": 120.0, "heart_rate": 72.0, "blood_sugar": 5.5});
|
||||
let metrics =
|
||||
serde_json::json!({"systolic_bp": 120.0, "heart_rate": 72.0, "blood_sugar": 5.5});
|
||||
let suggestions = rules.evaluate(&metrics);
|
||||
assert!(suggestions.is_empty());
|
||||
}
|
||||
@@ -166,9 +165,11 @@ mod tests {
|
||||
let rules = LocalRulesEngine::default_rules();
|
||||
let metrics = serde_json::json!({"heart_rate": 110.0});
|
||||
let suggestions = rules.evaluate(&metrics);
|
||||
assert!(suggestions
|
||||
assert!(
|
||||
suggestions
|
||||
.iter()
|
||||
.any(|s| s.suggestion_type == SuggestionType::Followup));
|
||||
.any(|s| s.suggestion_type == SuggestionType::Followup)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -11,8 +11,7 @@ pub fn parse_dual_channel(raw: &str) -> AiResult<ParsedOutput> {
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let structured = extract_section(raw, JSON_MARKER, TEXT_MARKER)
|
||||
.and_then(|json_str| {
|
||||
let structured = extract_section(raw, JSON_MARKER, TEXT_MARKER).and_then(|json_str| {
|
||||
let parsed: Result<StructuredOutput, _> = serde_json::from_str(json_str.trim());
|
||||
parsed.ok()
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ pub struct PostProcessResult {
|
||||
}
|
||||
|
||||
/// 对完成的分析执行后处理:解析双通道输出、创建建议、发布事件
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn post_process_analysis(
|
||||
state: &AiState,
|
||||
analysis_id: Uuid,
|
||||
|
||||
@@ -34,6 +34,7 @@ impl PromptService {
|
||||
}
|
||||
|
||||
/// 新建 Prompt
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn create_prompt(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
@@ -95,6 +96,7 @@ impl PromptService {
|
||||
}
|
||||
|
||||
/// 更新 Prompt(创建新版本)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn update_prompt(
|
||||
&self,
|
||||
id: Uuid,
|
||||
@@ -122,7 +124,9 @@ impl PromptService {
|
||||
name: Set(entity.name.clone()),
|
||||
description: Set(description.unwrap_or(entity.description.clone())),
|
||||
system_prompt: Set(system_prompt.unwrap_or(entity.system_prompt.clone())),
|
||||
user_prompt_template: Set(user_prompt_template.unwrap_or(entity.user_prompt_template.clone())),
|
||||
user_prompt_template: Set(
|
||||
user_prompt_template.unwrap_or(entity.user_prompt_template.clone())
|
||||
),
|
||||
variables_schema: Set(entity.variables_schema.clone()),
|
||||
model_config: Set(model_config.unwrap_or(entity.model_config.clone())),
|
||||
version: Set(entity.version + 1),
|
||||
@@ -140,11 +144,7 @@ impl PromptService {
|
||||
}
|
||||
|
||||
/// 激活指定 Prompt(停用同 name+category 的其他版本)
|
||||
pub async fn activate_prompt(
|
||||
&self,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<ai_prompt::Model> {
|
||||
pub async fn activate_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> {
|
||||
let entity = ai_prompt::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
@@ -179,11 +179,7 @@ impl PromptService {
|
||||
}
|
||||
|
||||
/// 回滚(= 激活指定旧版本)
|
||||
pub async fn rollback_prompt(
|
||||
&self,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<ai_prompt::Model> {
|
||||
pub async fn rollback_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> {
|
||||
self.activate_prompt(id, tenant_id).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,11 +27,7 @@ impl QuotaService {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub async fn check_quota(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Option<Uuid>,
|
||||
) -> AiResult<()> {
|
||||
pub async fn check_quota(&self, tenant_id: Uuid, patient_id: Option<Uuid>) -> AiResult<()> {
|
||||
if !self.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -81,24 +77,18 @@ impl QuotaService {
|
||||
AND created_at >= date_trunc('month', CURRENT_DATE)
|
||||
"#;
|
||||
|
||||
let result: Option<TokenSum> = TokenSum::find_by_statement(
|
||||
Statement::from_sql_and_values(
|
||||
let result: Option<TokenSum> = TokenSum::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
),
|
||||
)
|
||||
))
|
||||
.one(&self.db)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|r| r.total_tokens).unwrap_or(0))
|
||||
}
|
||||
|
||||
async fn get_daily_patient_count(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
) -> AiResult<i64> {
|
||||
async fn get_daily_patient_count(&self, tenant_id: Uuid, patient_id: Uuid) -> AiResult<i64> {
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct CountResult {
|
||||
count: i64,
|
||||
@@ -113,23 +103,19 @@ impl QuotaService {
|
||||
AND created_at >= CURRENT_DATE
|
||||
"#;
|
||||
|
||||
let result: Option<CountResult> = CountResult::find_by_statement(
|
||||
Statement::from_sql_and_values(
|
||||
let result: Option<CountResult> =
|
||||
CountResult::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into(), patient_id.into()],
|
||||
),
|
||||
)
|
||||
))
|
||||
.one(&self.db)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|r| r.count).unwrap_or(0))
|
||||
}
|
||||
|
||||
pub async fn get_usage_summary(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<QuotaSummary> {
|
||||
pub async fn get_usage_summary(&self, tenant_id: Uuid) -> AiResult<QuotaSummary> {
|
||||
let config = self.get_tenant_config(tenant_id).await?;
|
||||
let budget = config
|
||||
.as_ref()
|
||||
@@ -142,10 +128,7 @@ impl QuotaService {
|
||||
tenant_id,
|
||||
monthly_budget: budget,
|
||||
monthly_used: used,
|
||||
daily_patient_limit: config
|
||||
.as_ref()
|
||||
.map(|c| c.daily_patient_limit)
|
||||
.unwrap_or(50),
|
||||
daily_patient_limit: config.as_ref().map(|c| c.daily_patient_limit).unwrap_or(50),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,13 +21,12 @@ pub async fn handle_reanalysis_requested(
|
||||
FROM ai_suggestion
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
"#;
|
||||
let original: Option<OriginalSuggestion> = OriginalSuggestion::find_by_statement(
|
||||
Statement::from_sql_and_values(
|
||||
let original: Option<OriginalSuggestion> =
|
||||
OriginalSuggestion::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[original_suggestion_id.into(), tenant_id.into()],
|
||||
),
|
||||
)
|
||||
))
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use uuid::Uuid;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use erp_core::error::AppResult;
|
||||
use crate::dto::suggestion::*;
|
||||
use crate::entity::ai_suggestion;
|
||||
use erp_core::error::AppResult;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct SuggestionService;
|
||||
|
||||
@@ -85,9 +85,7 @@ impl SuggestionService {
|
||||
.filter(ai_suggestion::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::error::AiError::AnalysisNotFound("建议不存在".into())
|
||||
})?;
|
||||
.ok_or_else(|| crate::error::AiError::AnalysisNotFound("建议不存在".into()))?;
|
||||
|
||||
let current_status = parse_status(&item.status);
|
||||
if !current_status.can_transition_to(new_status) {
|
||||
@@ -122,13 +120,14 @@ impl SuggestionService {
|
||||
.filter(ai_suggestion::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::error::AiError::AnalysisNotFound("建议不存在".into())
|
||||
})?;
|
||||
.ok_or_else(|| crate::error::AiError::AnalysisNotFound("建议不存在".into()))?;
|
||||
|
||||
let current_status = parse_status(&item.status);
|
||||
// 允许从 Pending 或 Approved 直接执行(护士可能跳过审批)
|
||||
if !matches!(current_status, SuggestionStatus::Pending | SuggestionStatus::Approved) {
|
||||
if !matches!(
|
||||
current_status,
|
||||
SuggestionStatus::Pending | SuggestionStatus::Approved
|
||||
) {
|
||||
return Err(crate::error::AiError::Validation(format!(
|
||||
"建议状态为 {},无法执行(需要 pending 或 approved)",
|
||||
current_status.as_str()
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, FromQueryResult, QueryFilter, QuerySelect, Set};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, FromQueryResult, QueryFilter, QuerySelect, Set,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entity::ai_analysis;
|
||||
@@ -14,6 +16,7 @@ impl UsageService {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn log_usage(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
|
||||
@@ -98,5 +98,4 @@ mod tests {
|
||||
other => panic!("Expected Validation, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -45,7 +45,11 @@ where
|
||||
// TODO: 多租户微信登录需要设计租户解析策略(如 per-appid 映射或登录后选择租户)
|
||||
let tenant_id = state.default_tenant_id;
|
||||
let resp = WechatService::login(&state, tenant_id, &req.code).await?;
|
||||
tracing::info!(bound = resp.bound, has_token = resp.token.is_some(), "微信登录结果");
|
||||
tracing::info!(
|
||||
bound = resp.bound,
|
||||
has_token = resp.token.is_some(),
|
||||
"微信登录结果"
|
||||
);
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
@@ -75,13 +79,8 @@ where
|
||||
|
||||
// TODO: 多租户微信登录需要设计租户解析策略
|
||||
let tenant_id = state.default_tenant_id;
|
||||
let resp = WechatService::bind_phone(
|
||||
&state,
|
||||
tenant_id,
|
||||
&req.openid,
|
||||
&req.encrypted_data,
|
||||
&req.iv,
|
||||
)
|
||||
let resp =
|
||||
WechatService::bind_phone(&state, tenant_id, &req.openid, &req.encrypted_data, &req.iv)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ async fn fetch_permission_data_scopes(
|
||||
row.try_get_by_index::<String>(0),
|
||||
row.try_get_by_index::<String>(2),
|
||||
) {
|
||||
scopes.insert(code, DataScope::from_str(&scope));
|
||||
scopes.insert(code, DataScope::parse_scope(&scope));
|
||||
}
|
||||
}
|
||||
scopes
|
||||
|
||||
@@ -159,12 +159,9 @@ impl ErpModule for AuthModule {
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
_event_bus: &EventBus,
|
||||
) -> AppResult<()> {
|
||||
let password = std::env::var("ERP__SUPER_ADMIN_PASSWORD")
|
||||
.map_err(|_| {
|
||||
let password = std::env::var("ERP__SUPER_ADMIN_PASSWORD").map_err(|_| {
|
||||
tracing::error!("环境变量 ERP__SUPER_ADMIN_PASSWORD 未设置,无法初始化租户认证");
|
||||
erp_core::error::AppError::Internal(
|
||||
"ERP__SUPER_ADMIN_PASSWORD 未设置".to_string(),
|
||||
)
|
||||
erp_core::error::AppError::Internal("ERP__SUPER_ADMIN_PASSWORD 未设置".to_string())
|
||||
})?;
|
||||
crate::service::seed::seed_tenant_auth(db, tenant_id, &password)
|
||||
.await
|
||||
@@ -178,8 +175,8 @@ impl ErpModule for AuthModule {
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<()> {
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
|
||||
let now = Utc::now();
|
||||
|
||||
@@ -210,29 +207,144 @@ impl ErpModule for AuthModule {
|
||||
|
||||
fn permissions(&self) -> Vec<PermissionDescriptor> {
|
||||
vec![
|
||||
PermissionDescriptor { code: "user.list".into(), name: "查看用户列表".into(), description: "查看用户列表".into(), module: "auth".into() },
|
||||
PermissionDescriptor { code: "user.create".into(), name: "创建用户".into(), description: "创建新用户".into(), module: "auth".into() },
|
||||
PermissionDescriptor { code: "user.read".into(), name: "查看用户详情".into(), description: "查看用户信息".into(), module: "auth".into() },
|
||||
PermissionDescriptor { code: "user.update".into(), name: "编辑用户".into(), description: "编辑用户信息".into(), module: "auth".into() },
|
||||
PermissionDescriptor { code: "user.delete".into(), name: "删除用户".into(), description: "软删除用户".into(), module: "auth".into() },
|
||||
PermissionDescriptor { code: "role.list".into(), name: "查看角色列表".into(), description: "查看角色列表".into(), module: "auth".into() },
|
||||
PermissionDescriptor { code: "role.create".into(), name: "创建角色".into(), description: "创建新角色".into(), module: "auth".into() },
|
||||
PermissionDescriptor { code: "role.read".into(), name: "查看角色详情".into(), description: "查看角色信息".into(), module: "auth".into() },
|
||||
PermissionDescriptor { code: "role.update".into(), name: "编辑角色".into(), description: "编辑角色".into(), module: "auth".into() },
|
||||
PermissionDescriptor { code: "role.delete".into(), name: "删除角色".into(), description: "删除角色".into(), module: "auth".into() },
|
||||
PermissionDescriptor { code: "permission.list".into(), name: "查看权限".into(), description: "查看权限列表".into(), module: "auth".into() },
|
||||
PermissionDescriptor { code: "organization.list".into(), name: "查看组织列表".into(), description: "查看组织列表".into(), module: "auth".into() },
|
||||
PermissionDescriptor { code: "organization.create".into(), name: "创建组织".into(), description: "创建组织".into(), module: "auth".into() },
|
||||
PermissionDescriptor { code: "organization.update".into(), name: "编辑组织".into(), description: "编辑组织".into(), module: "auth".into() },
|
||||
PermissionDescriptor { code: "organization.delete".into(), name: "删除组织".into(), description: "删除组织".into(), module: "auth".into() },
|
||||
PermissionDescriptor { code: "department.list".into(), name: "查看部门列表".into(), description: "查看部门列表".into(), module: "auth".into() },
|
||||
PermissionDescriptor { code: "department.create".into(), name: "创建部门".into(), description: "创建部门".into(), module: "auth".into() },
|
||||
PermissionDescriptor { code: "department.update".into(), name: "编辑部门".into(), description: "编辑部门".into(), module: "auth".into() },
|
||||
PermissionDescriptor { code: "department.delete".into(), name: "删除部门".into(), description: "删除部门".into(), module: "auth".into() },
|
||||
PermissionDescriptor { code: "position.list".into(), name: "查看岗位列表".into(), description: "查看岗位列表".into(), module: "auth".into() },
|
||||
PermissionDescriptor { code: "position.create".into(), name: "创建岗位".into(), description: "创建岗位".into(), module: "auth".into() },
|
||||
PermissionDescriptor { code: "position.update".into(), name: "编辑岗位".into(), description: "编辑岗位".into(), module: "auth".into() },
|
||||
PermissionDescriptor { code: "position.delete".into(), name: "删除岗位".into(), description: "删除岗位".into(), module: "auth".into() },
|
||||
PermissionDescriptor {
|
||||
code: "user.list".into(),
|
||||
name: "查看用户列表".into(),
|
||||
description: "查看用户列表".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "user.create".into(),
|
||||
name: "创建用户".into(),
|
||||
description: "创建新用户".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "user.read".into(),
|
||||
name: "查看用户详情".into(),
|
||||
description: "查看用户信息".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "user.update".into(),
|
||||
name: "编辑用户".into(),
|
||||
description: "编辑用户信息".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "user.delete".into(),
|
||||
name: "删除用户".into(),
|
||||
description: "软删除用户".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "role.list".into(),
|
||||
name: "查看角色列表".into(),
|
||||
description: "查看角色列表".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "role.create".into(),
|
||||
name: "创建角色".into(),
|
||||
description: "创建新角色".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "role.read".into(),
|
||||
name: "查看角色详情".into(),
|
||||
description: "查看角色信息".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "role.update".into(),
|
||||
name: "编辑角色".into(),
|
||||
description: "编辑角色".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "role.delete".into(),
|
||||
name: "删除角色".into(),
|
||||
description: "删除角色".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "permission.list".into(),
|
||||
name: "查看权限".into(),
|
||||
description: "查看权限列表".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "organization.list".into(),
|
||||
name: "查看组织列表".into(),
|
||||
description: "查看组织列表".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "organization.create".into(),
|
||||
name: "创建组织".into(),
|
||||
description: "创建组织".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "organization.update".into(),
|
||||
name: "编辑组织".into(),
|
||||
description: "编辑组织".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "organization.delete".into(),
|
||||
name: "删除组织".into(),
|
||||
description: "删除组织".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "department.list".into(),
|
||||
name: "查看部门列表".into(),
|
||||
description: "查看部门列表".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "department.create".into(),
|
||||
name: "创建部门".into(),
|
||||
description: "创建部门".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "department.update".into(),
|
||||
name: "编辑部门".into(),
|
||||
description: "编辑部门".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "department.delete".into(),
|
||||
name: "删除部门".into(),
|
||||
description: "删除部门".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "position.list".into(),
|
||||
name: "查看岗位列表".into(),
|
||||
description: "查看岗位列表".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "position.create".into(),
|
||||
name: "创建岗位".into(),
|
||||
description: "创建岗位".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "position.update".into(),
|
||||
name: "编辑岗位".into(),
|
||||
description: "编辑岗位".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "position.delete".into(),
|
||||
name: "删除岗位".into(),
|
||||
description: "删除岗位".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -64,8 +64,7 @@ impl AuthService {
|
||||
None => {
|
||||
// 审计:用户不存在(登录失败)
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, None, "user.login_failed", "user")
|
||||
.with_request_info(
|
||||
AuditLog::new(tenant_id, None, "user.login_failed", "user").with_request_info(
|
||||
req_info.as_ref().and_then(|r| r.ip.clone()),
|
||||
req_info.as_ref().and_then(|r| r.user_agent.clone()),
|
||||
),
|
||||
|
||||
@@ -317,13 +317,7 @@ const DEFAULT_PERMISSIONS: &[(&str, &str, &str, &str, &str)] = &[
|
||||
"admin",
|
||||
"管理插件全生命周期",
|
||||
),
|
||||
(
|
||||
"plugin.list",
|
||||
"查看插件",
|
||||
"plugin",
|
||||
"list",
|
||||
"查看插件列表",
|
||||
),
|
||||
("plugin.list", "查看插件", "plugin", "list", "查看插件列表"),
|
||||
];
|
||||
|
||||
/// Indices of read-only (list/read) permissions within DEFAULT_PERMISSIONS.
|
||||
|
||||
@@ -153,7 +153,11 @@ impl TokenService {
|
||||
|
||||
/// Revoke a specific refresh token by database ID.
|
||||
/// Verifies that the token belongs to the specified user for security.
|
||||
pub async fn revoke_token(token_id: Uuid, user_id: Uuid, db: &DatabaseConnection) -> AuthResult<()> {
|
||||
pub async fn revoke_token(
|
||||
token_id: Uuid,
|
||||
user_id: Uuid,
|
||||
db: &DatabaseConnection,
|
||||
) -> AuthResult<()> {
|
||||
let token_row = user_token::Entity::find_by_id(token_id)
|
||||
.filter(user_token::Column::UserId.eq(user_id))
|
||||
.one(db)
|
||||
|
||||
@@ -406,8 +406,7 @@ impl UserService {
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let role_map: HashMap<Uuid, &role::Model> =
|
||||
roles.iter().map(|r| (r.id, r)).collect();
|
||||
let role_map: HashMap<Uuid, &role::Model> = roles.iter().map(|r| (r.id, r)).collect();
|
||||
|
||||
// 3. 按 user_id 分组
|
||||
let mut result: HashMap<Uuid, Vec<RoleResp>> = HashMap::new();
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
|
||||
use aes::cipher::{BlockDecryptMut, KeyIvInit, block_padding::Pkcs7};
|
||||
use base64::Engine;
|
||||
use chrono::Utc;
|
||||
use cbc::Decryptor;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
@@ -59,8 +57,12 @@ impl WechatService {
|
||||
code = %code,
|
||||
"fetch_session 开始"
|
||||
);
|
||||
let session =
|
||||
fetch_session(&state.wechat_appid, &state.wechat_secret, code, state.wechat_dev_mode)
|
||||
let session = fetch_session(
|
||||
&state.wechat_appid,
|
||||
&state.wechat_secret,
|
||||
code,
|
||||
state.wechat_dev_mode,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let openid = session
|
||||
@@ -69,8 +71,9 @@ impl WechatService {
|
||||
.ok_or_else(|| AuthError::Validation("微信登录失败:未获取到 openid".to_string()))?;
|
||||
|
||||
// 缓存 session_key(Redis 优先,内存降级)
|
||||
if let Some(sk) = &session.session_key {
|
||||
if let Err(e) = Self::store_session_key_redis(&state.redis, &openid, sk).await {
|
||||
if let Some(sk) = &session.session_key
|
||||
&& let Err(e) = Self::store_session_key_redis(&state.redis, &openid, sk).await
|
||||
{
|
||||
tracing::warn!(openid = %openid, error = %e, "Redis session_key 存储失败,降级内存");
|
||||
let mut cache = MEMORY_FALLBACK.lock().await;
|
||||
cache.insert(
|
||||
@@ -81,7 +84,6 @@ impl WechatService {
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let existing = wechat_user::Entity::find()
|
||||
.filter(wechat_user::Column::Openid.eq(&openid))
|
||||
@@ -141,8 +143,7 @@ impl WechatService {
|
||||
return Err(AuthError::Validation("该微信已绑定账号".to_string()));
|
||||
}
|
||||
|
||||
let user_id =
|
||||
Self::find_or_create_user_by_phone(&state.db, tenant_id, &phone).await?;
|
||||
let user_id = Self::find_or_create_user_by_phone(&state.db, tenant_id, &phone).await?;
|
||||
|
||||
let now = Utc::now();
|
||||
let wu = wechat_user::ActiveModel {
|
||||
@@ -248,13 +249,11 @@ impl WechatService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_session_key(
|
||||
redis: &Option<redis::Client>,
|
||||
openid: &str,
|
||||
) -> AuthResult<String> {
|
||||
async fn get_session_key(redis: &Option<redis::Client>, openid: &str) -> AuthResult<String> {
|
||||
// 1. 尝试 Redis
|
||||
if let Some(client) = redis {
|
||||
if let Ok(mut conn) = client.get_multiplexed_async_connection().await {
|
||||
if let Some(client) = redis
|
||||
&& let Ok(mut conn) = client.get_multiplexed_async_connection().await
|
||||
{
|
||||
let key = format!("{}{}", REDIS_KEY_PREFIX, openid);
|
||||
let result: Option<String> = redis::cmd("GETDEL")
|
||||
.arg(&key)
|
||||
@@ -265,7 +264,6 @@ impl WechatService {
|
||||
return Ok(sk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 降级到内存
|
||||
let mut cache = MEMORY_FALLBACK.lock().await;
|
||||
@@ -285,11 +283,7 @@ impl WechatService {
|
||||
}
|
||||
|
||||
/// AES-128-CBC 解密微信手机号
|
||||
fn decrypt_phone_number(
|
||||
session_key: &str,
|
||||
encrypted_data: &str,
|
||||
iv: &str,
|
||||
) -> AuthResult<String> {
|
||||
fn decrypt_phone_number(session_key: &str, encrypted_data: &str, iv: &str) -> AuthResult<String> {
|
||||
let engine = base64::engine::general_purpose::STANDARD;
|
||||
|
||||
let key_bytes = engine
|
||||
@@ -303,9 +297,7 @@ fn decrypt_phone_number(
|
||||
.map_err(|e| AuthError::Validation(format!("encrypted_data base64 解码失败: {}", e)))?;
|
||||
|
||||
if key_bytes.len() != 16 {
|
||||
return Err(AuthError::Validation(
|
||||
"session_key 长度不正确".to_string(),
|
||||
));
|
||||
return Err(AuthError::Validation("session_key 长度不正确".to_string()));
|
||||
}
|
||||
if iv_bytes.len() != 16 {
|
||||
return Err(AuthError::Validation("iv 长度不正确".to_string()));
|
||||
@@ -319,8 +311,8 @@ fn decrypt_phone_number(
|
||||
.decrypt_padded_mut::<Pkcs7>(&mut buf)
|
||||
.map_err(|e| AuthError::Validation(format!("AES 解密失败: {}", e)))?;
|
||||
|
||||
let plaintext =
|
||||
String::from_utf8(decrypted.to_vec()).map_err(|_| AuthError::Validation("解密结果非 UTF-8".to_string()))?;
|
||||
let plaintext = String::from_utf8(decrypted.to_vec())
|
||||
.map_err(|_| AuthError::Validation("解密结果非 UTF-8".to_string()))?;
|
||||
|
||||
// 微信返回的 JSON 包含 watermark 等字段,提取 phone_number
|
||||
let info: serde_json::Value = serde_json::from_str(&plaintext)
|
||||
@@ -358,13 +350,8 @@ async fn build_login_resp(
|
||||
jwt.secret,
|
||||
jwt.access_ttl_secs,
|
||||
)?;
|
||||
let (refresh_token, _) = TokenService::sign_refresh_token(
|
||||
user_id,
|
||||
tenant_id,
|
||||
db,
|
||||
jwt.secret,
|
||||
jwt.refresh_ttl_secs,
|
||||
)
|
||||
let (refresh_token, _) =
|
||||
TokenService::sign_refresh_token(user_id, tenant_id, db, jwt.secret, jwt.refresh_ttl_secs)
|
||||
.await?;
|
||||
|
||||
let role_resps = AuthService::get_user_role_resps(user_id, tenant_id, db).await?;
|
||||
@@ -424,8 +411,9 @@ async fn fetch_session(
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(format!("微信 API 响应解析失败: {}", e)))?;
|
||||
|
||||
if let Some(errcode) = session.errcode {
|
||||
if errcode != 0 {
|
||||
if let Some(errcode) = session.errcode
|
||||
&& errcode != 0
|
||||
{
|
||||
let msg = session.errmsg.clone().unwrap_or_default();
|
||||
tracing::error!(errcode, errmsg = %msg, "微信 jscode2session 返回错误");
|
||||
return Err(AuthError::Validation(format!(
|
||||
@@ -433,7 +421,6 @@ async fn fetch_session(
|
||||
errcode, msg
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
has_openid = session.openid.is_some(),
|
||||
|
||||
@@ -101,18 +101,39 @@ mod tests {
|
||||
#[test]
|
||||
fn config_error_display_messages() {
|
||||
// 验证各变体的 Display 输出包含中文描述
|
||||
assert!(ConfigError::Validation("test".into()).to_string().contains("验证失败"));
|
||||
assert!(ConfigError::NotFound("test".into()).to_string().contains("资源未找到"));
|
||||
assert!(ConfigError::DuplicateKey("test".into()).to_string().contains("键已存在"));
|
||||
assert!(ConfigError::NumberingExhausted("test".into()).to_string().contains("编号序列耗尽"));
|
||||
assert!(ConfigError::VersionMismatch.to_string().contains("版本冲突"));
|
||||
assert!(
|
||||
ConfigError::Validation("test".into())
|
||||
.to_string()
|
||||
.contains("验证失败")
|
||||
);
|
||||
assert!(
|
||||
ConfigError::NotFound("test".into())
|
||||
.to_string()
|
||||
.contains("资源未找到")
|
||||
);
|
||||
assert!(
|
||||
ConfigError::DuplicateKey("test".into())
|
||||
.to_string()
|
||||
.contains("键已存在")
|
||||
);
|
||||
assert!(
|
||||
ConfigError::NumberingExhausted("test".into())
|
||||
.to_string()
|
||||
.contains("编号序列耗尽")
|
||||
);
|
||||
assert!(
|
||||
ConfigError::VersionMismatch
|
||||
.to_string()
|
||||
.contains("版本冲突")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transaction_error_connection_maps_to_validation() {
|
||||
// TransactionError::Connection 应该转换为 ConfigError::Validation
|
||||
let config_err: ConfigError =
|
||||
sea_orm::TransactionError::Connection(sea_orm::DbErr::Conn(sea_orm::RuntimeErr::Internal("连接失败".to_string())))
|
||||
let config_err: ConfigError = sea_orm::TransactionError::Connection(sea_orm::DbErr::Conn(
|
||||
sea_orm::RuntimeErr::Internal("连接失败".to_string()),
|
||||
))
|
||||
.into();
|
||||
match config_err {
|
||||
ConfigError::Validation(msg) => assert!(msg.contains("连接失败")),
|
||||
|
||||
@@ -125,8 +125,12 @@ where
|
||||
pub async fn get_public_brand() -> JsonResponse<ApiResponse<PublicBrandResp>> {
|
||||
let defaults = default_theme();
|
||||
JsonResponse(ApiResponse::ok(PublicBrandResp {
|
||||
brand_name: defaults.brand_name.unwrap_or_else(|| "HMS 健康管理平台".into()),
|
||||
brand_slogan: defaults.brand_slogan.unwrap_or_else(|| "新一代健康管理平台".into()),
|
||||
brand_name: defaults
|
||||
.brand_name
|
||||
.unwrap_or_else(|| "HMS 健康管理平台".into()),
|
||||
brand_slogan: defaults
|
||||
.brand_slogan
|
||||
.unwrap_or_else(|| "新一代健康管理平台".into()),
|
||||
brand_features: defaults
|
||||
.brand_features
|
||||
.unwrap_or_else(|| "患者管理 · 健康监测 · 随访管理 · AI 智能分析".into()),
|
||||
|
||||
@@ -64,10 +64,7 @@ impl ConfigModule {
|
||||
put(menu_handler::update_menu).delete(menu_handler::delete_menu),
|
||||
)
|
||||
// User menu tree (no special permission required)
|
||||
.route(
|
||||
"/menus/user",
|
||||
get(menu_handler::get_user_menus),
|
||||
)
|
||||
.route("/menus/user", get(menu_handler::get_user_menus))
|
||||
// Setting routes
|
||||
.route(
|
||||
"/config/settings/{key}",
|
||||
@@ -153,24 +150,114 @@ impl ErpModule for ConfigModule {
|
||||
|
||||
fn permissions(&self) -> Vec<PermissionDescriptor> {
|
||||
vec![
|
||||
PermissionDescriptor { code: "dictionary.list".into(), name: "查看字典".into(), description: "查看数据字典".into(), module: "config".into() },
|
||||
PermissionDescriptor { code: "dictionary.create".into(), name: "创建字典".into(), description: "创建数据字典".into(), module: "config".into() },
|
||||
PermissionDescriptor { code: "dictionary.update".into(), name: "编辑字典".into(), description: "编辑数据字典".into(), module: "config".into() },
|
||||
PermissionDescriptor { code: "dictionary.delete".into(), name: "删除字典".into(), description: "删除数据字典".into(), module: "config".into() },
|
||||
PermissionDescriptor { code: "menu.list".into(), name: "查看菜单".into(), description: "查看菜单配置".into(), module: "config".into() },
|
||||
PermissionDescriptor { code: "menu.update".into(), name: "编辑菜单".into(), description: "编辑菜单配置".into(), module: "config".into() },
|
||||
PermissionDescriptor { code: "setting.read".into(), name: "查看配置".into(), description: "查看系统参数".into(), module: "config".into() },
|
||||
PermissionDescriptor { code: "setting.update".into(), name: "编辑配置".into(), description: "编辑系统参数".into(), module: "config".into() },
|
||||
PermissionDescriptor { code: "setting.delete".into(), name: "删除配置".into(), description: "删除系统参数".into(), module: "config".into() },
|
||||
PermissionDescriptor { code: "numbering.list".into(), name: "查看编号规则".into(), description: "查看编号规则".into(), module: "config".into() },
|
||||
PermissionDescriptor { code: "numbering.create".into(), name: "创建编号规则".into(), description: "创建编号规则".into(), module: "config".into() },
|
||||
PermissionDescriptor { code: "numbering.update".into(), name: "编辑编号规则".into(), description: "编辑编号规则".into(), module: "config".into() },
|
||||
PermissionDescriptor { code: "numbering.delete".into(), name: "删除编号规则".into(), description: "删除编号规则".into(), module: "config".into() },
|
||||
PermissionDescriptor { code: "numbering.generate".into(), name: "生成编号".into(), description: "生成文档编号".into(), module: "config".into() },
|
||||
PermissionDescriptor { code: "theme.read".into(), name: "查看主题".into(), description: "查看主题设置".into(), module: "config".into() },
|
||||
PermissionDescriptor { code: "theme.update".into(), name: "编辑主题".into(), description: "编辑主题设置".into(), module: "config".into() },
|
||||
PermissionDescriptor { code: "language.list".into(), name: "查看语言".into(), description: "查看语言配置".into(), module: "config".into() },
|
||||
PermissionDescriptor { code: "language.update".into(), name: "编辑语言".into(), description: "编辑语言设置".into(), module: "config".into() },
|
||||
PermissionDescriptor {
|
||||
code: "dictionary.list".into(),
|
||||
name: "查看字典".into(),
|
||||
description: "查看数据字典".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "dictionary.create".into(),
|
||||
name: "创建字典".into(),
|
||||
description: "创建数据字典".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "dictionary.update".into(),
|
||||
name: "编辑字典".into(),
|
||||
description: "编辑数据字典".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "dictionary.delete".into(),
|
||||
name: "删除字典".into(),
|
||||
description: "删除数据字典".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "menu.list".into(),
|
||||
name: "查看菜单".into(),
|
||||
description: "查看菜单配置".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "menu.update".into(),
|
||||
name: "编辑菜单".into(),
|
||||
description: "编辑菜单配置".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "setting.read".into(),
|
||||
name: "查看配置".into(),
|
||||
description: "查看系统参数".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "setting.update".into(),
|
||||
name: "编辑配置".into(),
|
||||
description: "编辑系统参数".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "setting.delete".into(),
|
||||
name: "删除配置".into(),
|
||||
description: "删除系统参数".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "numbering.list".into(),
|
||||
name: "查看编号规则".into(),
|
||||
description: "查看编号规则".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "numbering.create".into(),
|
||||
name: "创建编号规则".into(),
|
||||
description: "创建编号规则".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "numbering.update".into(),
|
||||
name: "编辑编号规则".into(),
|
||||
description: "编辑编号规则".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "numbering.delete".into(),
|
||||
name: "删除编号规则".into(),
|
||||
description: "删除编号规则".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "numbering.generate".into(),
|
||||
name: "生成编号".into(),
|
||||
description: "生成文档编号".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "theme.read".into(),
|
||||
name: "查看主题".into(),
|
||||
description: "查看主题设置".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "theme.update".into(),
|
||||
name: "编辑主题".into(),
|
||||
description: "编辑主题设置".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "language.list".into(),
|
||||
name: "查看语言".into(),
|
||||
description: "查看语言配置".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "language.update".into(),
|
||||
name: "编辑语言".into(),
|
||||
description: "编辑语言设置".into(),
|
||||
module: "config".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder, Set};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder, Set,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{CreateMenuReq, MenuResp};
|
||||
|
||||
@@ -35,12 +35,12 @@ pub(crate) fn format_number(
|
||||
result.push_str(separator);
|
||||
}
|
||||
|
||||
if let Some(dp) = date_part {
|
||||
if !dp.is_empty() {
|
||||
if let Some(dp) = date_part
|
||||
&& !dp.is_empty()
|
||||
{
|
||||
result.push_str(dp);
|
||||
result.push_str(separator);
|
||||
}
|
||||
}
|
||||
|
||||
let width = (seq_length.max(1)) as usize;
|
||||
let seq_padded = format!("{:0>width$}", seq_current, width = width);
|
||||
@@ -398,7 +398,10 @@ impl NumberingService {
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
// 拼接编号字符串: {prefix}{separator}{date_part}{separator}{seq_padded}
|
||||
let date_part = rule.date_format.as_ref().map(|fmt| Utc::now().format(fmt).to_string());
|
||||
let date_part = rule
|
||||
.date_format
|
||||
.as_ref()
|
||||
.map(|fmt| Utc::now().format(fmt).to_string());
|
||||
|
||||
let number = format_number(
|
||||
&rule.prefix,
|
||||
@@ -611,7 +614,8 @@ mod tests {
|
||||
#[test]
|
||||
fn reset_no_last_reset_date_returns_seq_start() {
|
||||
// 从未重置过,使用 seq_start
|
||||
let result = NumberingService::maybe_reset_sequence(999, 1, "daily", None, date(2026, 4, 15));
|
||||
let result =
|
||||
NumberingService::maybe_reset_sequence(999, 1, "daily", None, date(2026, 4, 15));
|
||||
assert_eq!(result, 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::audit::AuditLog;
|
||||
use crate::entity::audit_log;
|
||||
use crate::request_info::RequestInfo;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
|
||||
use sha2::{Sha256, Digest};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tracing;
|
||||
|
||||
/// 持久化审计日志到 audit_logs 表。
|
||||
@@ -16,7 +16,6 @@ use tracing;
|
||||
/// 计算 SHA256(id + action + resource_type + resource_id + created_at + prev_hash) 作为 record_hash。
|
||||
pub async fn record(mut log: AuditLog, db: &sea_orm::DatabaseConnection) {
|
||||
// 自动填充请求来源信息(仅当调用方未显式设置时)
|
||||
if log.ip_address.is_none() || log.user_agent.is_none() {
|
||||
if let Some(info) = RequestInfo::try_current() {
|
||||
if log.ip_address.is_none() {
|
||||
log.ip_address = info.ip_address;
|
||||
@@ -25,7 +24,6 @@ pub async fn record(mut log: AuditLog, db: &sea_orm::DatabaseConnection) {
|
||||
log.user_agent = info.user_agent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 查询同租户最新一条记录的 record_hash 作为 prev_hash
|
||||
let prev_hash = audit_log::Entity::find()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use aes_gcm::aead::Aead;
|
||||
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
||||
use rand::RngCore;
|
||||
|
||||
const CIPHER_VERSION: u8 = 0x01;
|
||||
@@ -41,6 +41,8 @@ pub fn decrypt(key: &[u8; 32], encoded: &str) -> Result<String, String> {
|
||||
|
||||
let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| e.to_string())?;
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
let plaintext = cipher.decrypt(nonce, ciphertext).map_err(|e| e.to_string())?;
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|e| e.to_string())?;
|
||||
String::from_utf8(plaintext).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -46,15 +46,15 @@ impl DekManager {
|
||||
kek: &[u8; 32],
|
||||
) -> AppResult<([u8; 32], u32)> {
|
||||
// 检查缓存
|
||||
if let Some(entry) = self.cache.get(&tenant_id) {
|
||||
if entry.loaded_at.elapsed().as_secs() < self.ttl_secs {
|
||||
if let Some(entry) = self.cache.get(&tenant_id)
|
||||
&& entry.loaded_at.elapsed().as_secs() < self.ttl_secs
|
||||
{
|
||||
return Ok((entry.dek, entry.version));
|
||||
}
|
||||
}
|
||||
|
||||
// 从加密 DEK 解密
|
||||
if let Some(enc_dek) = encrypted_dek {
|
||||
let dek_hex = engine::decrypt(kek, enc_dek).map_err(|e| AppError::Internal(e))?;
|
||||
let dek_hex = engine::decrypt(kek, enc_dek).map_err(AppError::Internal)?;
|
||||
let dek_bytes = hex::decode(&dek_hex).map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
if dek_bytes.len() != 32 {
|
||||
return Err(AppError::Internal("DEK must be 32 bytes".into()));
|
||||
@@ -64,29 +64,35 @@ impl DekManager {
|
||||
|
||||
// 缓存(版本从外部传入时无法确定,使用默认值 1)
|
||||
self.evict_if_full();
|
||||
self.cache.insert(tenant_id, CachedDek {
|
||||
self.cache.insert(
|
||||
tenant_id,
|
||||
CachedDek {
|
||||
dek,
|
||||
version: 1,
|
||||
loaded_at: Instant::now(),
|
||||
});
|
||||
},
|
||||
);
|
||||
return Ok((dek, 1));
|
||||
}
|
||||
|
||||
// 无现有 DEK → 生成新的
|
||||
let dek = Self::generate_dek();
|
||||
self.evict_if_full();
|
||||
self.cache.insert(tenant_id, CachedDek {
|
||||
self.cache.insert(
|
||||
tenant_id,
|
||||
CachedDek {
|
||||
dek,
|
||||
version: 1,
|
||||
loaded_at: Instant::now(),
|
||||
});
|
||||
},
|
||||
);
|
||||
Ok((dek, 1))
|
||||
}
|
||||
|
||||
/// 使用 KEK 加密 DEK 以便存储
|
||||
pub fn encrypt_dek_for_storage(dek: &[u8; 32], kek: &[u8; 32]) -> AppResult<String> {
|
||||
let dek_hex = hex::encode(dek);
|
||||
engine::encrypt(kek, &dek_hex).map_err(|e| AppError::Internal(e))
|
||||
engine::encrypt(kek, &dek_hex).map_err(AppError::Internal)
|
||||
}
|
||||
|
||||
/// 生成新 DEK 并用 KEK 加密,返回 (新 DEK, 加密后的 DEK)
|
||||
@@ -110,7 +116,8 @@ impl DekManager {
|
||||
|
||||
fn evict_if_full(&self) {
|
||||
if self.cache.len() >= self.max_entries {
|
||||
let to_remove: Vec<Uuid> = self.cache
|
||||
let to_remove: Vec<Uuid> = self
|
||||
.cache
|
||||
.iter()
|
||||
.filter(|e| e.loaded_at.elapsed().as_secs() > self.ttl_secs / 2)
|
||||
.map(|e| *e.key())
|
||||
@@ -156,7 +163,9 @@ mod tests {
|
||||
let (original_dek, encrypted) = DekManager::generate_new_dek(&kek).unwrap();
|
||||
let mgr = DekManager::new(300, 100);
|
||||
let tenant_id = test_uuid(1);
|
||||
let (recovered_dek, _ver) = mgr.get_or_create_dek(tenant_id, Some(&encrypted), &kek).unwrap();
|
||||
let (recovered_dek, _ver) = mgr
|
||||
.get_or_create_dek(tenant_id, Some(&encrypted), &kek)
|
||||
.unwrap();
|
||||
assert_eq!(original_dek, recovered_dek);
|
||||
}
|
||||
|
||||
@@ -188,7 +197,10 @@ mod tests {
|
||||
let (_, encrypted) = DekManager::generate_new_dek(&kek1).unwrap();
|
||||
let mgr = DekManager::new(300, 100);
|
||||
let tenant_id = test_uuid(4);
|
||||
assert!(mgr.get_or_create_dek(tenant_id, Some(&encrypted), &kek2).is_err());
|
||||
assert!(
|
||||
mgr.get_or_create_dek(tenant_id, Some(&encrypted), &kek2)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -204,7 +216,9 @@ mod tests {
|
||||
fn max_entries_eviction() {
|
||||
let mgr = DekManager::new(300, 3);
|
||||
for i in 0..5u8 {
|
||||
let _ = mgr.get_or_create_dek(test_uuid(i), None, &test_kek()).unwrap();
|
||||
let _ = mgr
|
||||
.get_or_create_dek(test_uuid(i), None, &test_kek())
|
||||
.unwrap();
|
||||
}
|
||||
assert!(mgr.cache.len() <= 6);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn mask_phone_normal() {
|
||||
assert_eq!(Some("138****5678".to_string()), mask_phone(Some("13812345678")));
|
||||
assert_eq!(
|
||||
Some("138****5678".to_string()),
|
||||
mask_phone(Some("13812345678"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -87,7 +90,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn mask_phone_unicode_safe() {
|
||||
assert_eq!(Some("你好世****cdef".to_string()), mask_phone(Some("你好世界abcdef")));
|
||||
assert_eq!(
|
||||
Some("你好世****cdef".to_string()),
|
||||
mask_phone(Some("你好世界abcdef"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,8 +5,8 @@ pub mod masking;
|
||||
|
||||
pub use engine::{decrypt, encrypt};
|
||||
pub use hmac_index::hmac_hash;
|
||||
pub use masking::{mask_id_number, mask_license_number, mask_phone};
|
||||
pub use key_manager::DekManager;
|
||||
pub use masking::{mask_id_number, mask_license_number, mask_phone};
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
|
||||
@@ -21,10 +21,12 @@ pub struct PiiCrypto {
|
||||
impl PiiCrypto {
|
||||
/// 从 hex 编码的 KEK 创建。KEK 为 64 字符 hex(32 字节)。
|
||||
pub fn from_kek_hex(kek_hex: &str) -> AppResult<Self> {
|
||||
let bytes =
|
||||
hex::decode(kek_hex).map_err(|e| AppError::Internal(format!("KEK hex decode failed: {}", e)))?;
|
||||
let bytes = hex::decode(kek_hex)
|
||||
.map_err(|e| AppError::Internal(format!("KEK hex decode failed: {}", e)))?;
|
||||
if bytes.len() != 32 {
|
||||
return Err(AppError::Internal("KEK must be 32 bytes (64 hex chars)".into()));
|
||||
return Err(AppError::Internal(
|
||||
"KEK must be 32 bytes (64 hex chars)".into(),
|
||||
));
|
||||
}
|
||||
let mut kek = [0u8; 32];
|
||||
kek.copy_from_slice(&bytes);
|
||||
@@ -44,7 +46,7 @@ impl PiiCrypto {
|
||||
use sha2::Digest;
|
||||
let hmac_key = <sha2::Sha256 as Digest>::new()
|
||||
.chain_update(b"pii-hmac-index-v1")
|
||||
.chain_update(&kek)
|
||||
.chain_update(kek)
|
||||
.finalize();
|
||||
let mut hk = [0u8; 32];
|
||||
hk.copy_from_slice(&hmac_key);
|
||||
@@ -172,7 +174,9 @@ mod tests {
|
||||
let crypto = test_crypto();
|
||||
let encrypted = encrypt(crypto.kek(), "test").unwrap();
|
||||
use base64::Engine;
|
||||
let bytes = base64::engine::general_purpose::STANDARD.decode(&encrypted).unwrap();
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(&encrypted)
|
||||
.unwrap();
|
||||
assert_eq!(bytes[0], 0x01, "密文首字节应为版本号 0x01");
|
||||
}
|
||||
|
||||
@@ -189,11 +193,7 @@ mod tests {
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
let avg_us = elapsed.as_micros() / 1000;
|
||||
assert!(
|
||||
avg_us < 50,
|
||||
"encrypt 平均耗时应 < 50μs, 实际: {}μs",
|
||||
avg_us
|
||||
);
|
||||
assert!(avg_us < 50, "encrypt 平均耗时应 < 50μs, 实际: {}μs", avg_us);
|
||||
eprintln!("encrypt 1000 次: {:?} (avg {}μs)", elapsed, avg_us);
|
||||
}
|
||||
|
||||
@@ -208,11 +208,7 @@ mod tests {
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
let avg_us = elapsed.as_micros() / 1000;
|
||||
assert!(
|
||||
avg_us < 50,
|
||||
"decrypt 平均耗时应 < 50μs, 实际: {}μs",
|
||||
avg_us
|
||||
);
|
||||
assert!(avg_us < 50, "decrypt 平均耗时应 < 50μs, 实际: {}μs", avg_us);
|
||||
eprintln!("decrypt 1000 次: {:?} (avg {}μs)", elapsed, avg_us);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::Json;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
|
||||
/// 统一错误响应格式
|
||||
|
||||
@@ -44,13 +44,13 @@ pub fn build_event_payload(data: serde_json::Value) -> serde_json::Value {
|
||||
"schema_version": EVENT_SCHEMA_VERSION,
|
||||
"occurred_at": Utc::now().to_rfc3339(),
|
||||
});
|
||||
if let serde_json::Value::Object(ref mut map) = envelope {
|
||||
if let serde_json::Value::Object(data_map) = data {
|
||||
if let serde_json::Value::Object(ref mut map) = envelope
|
||||
&& let serde_json::Value::Object(data_map) = data
|
||||
{
|
||||
for (k, v) in data_map {
|
||||
map.insert(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
envelope
|
||||
}
|
||||
|
||||
@@ -314,12 +314,12 @@ impl EventBus {
|
||||
event = broadcast_rx.recv() => {
|
||||
match event {
|
||||
Ok(event) => {
|
||||
if event.event_type.starts_with(&prefix) {
|
||||
if mpsc_tx.send(event).await.is_err() {
|
||||
if event.event_type.starts_with(&prefix)
|
||||
&& mpsc_tx.send(event).await.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!(prefix = %prefix, lagged = n, "Filtered subscriber lagged");
|
||||
}
|
||||
|
||||
@@ -9,11 +9,7 @@ use crate::error::AppResult;
|
||||
#[async_trait]
|
||||
pub trait HealthDataProvider: Send + Sync {
|
||||
/// 获取化验报告(指标列表)
|
||||
async fn get_lab_report(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
report_id: Uuid,
|
||||
) -> AppResult<LabReportDto>;
|
||||
async fn get_lab_report(&self, tenant_id: Uuid, report_id: Uuid) -> AppResult<LabReportDto>;
|
||||
|
||||
/// 获取生命体征趋势数据
|
||||
async fn get_vital_signs(
|
||||
@@ -32,11 +28,8 @@ pub trait HealthDataProvider: Send + Sync {
|
||||
) -> AppResult<PatientSummaryDto>;
|
||||
|
||||
/// 获取完整健康报告(用于摘要生成)
|
||||
async fn get_full_report(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
report_id: Uuid,
|
||||
) -> AppResult<HealthReportDto>;
|
||||
async fn get_full_report(&self, tenant_id: Uuid, report_id: Uuid)
|
||||
-> AppResult<HealthReportDto>;
|
||||
|
||||
/// 获取趋势分析预计算数据(统计摘要 + 异常检测)
|
||||
async fn get_trend_analysis_data(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
///
|
||||
/// 基于 ammonia(html5ever)剥离所有 HTML 标签,防止存储型 XSS。
|
||||
/// 覆盖场景:用户名、显示名、邮箱、电话等字符串字段。
|
||||
|
||||
///
|
||||
/// 剥离字符串中的所有 HTML 标签,返回纯文本。
|
||||
///
|
||||
/// 使用 ammonia 构建 DOM 树,然后用 tendril 收集文本节点。
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
//! 每个测试在独立事务中执行,测试结束自动回滚,无数据残留。
|
||||
//! 多个测试共享同一个数据库连接池,无连接竞争。
|
||||
|
||||
use sea_orm::{ConnectOptions, Database, DatabaseConnection, DatabaseTransaction, TransactionTrait};
|
||||
use sea_orm::{
|
||||
ConnectOptions, Database, DatabaseConnection, DatabaseTransaction, TransactionTrait,
|
||||
};
|
||||
use std::sync::OnceLock;
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
@@ -22,12 +24,8 @@ fn db_url() -> String {
|
||||
async fn db_pool() -> &'static DatabaseConnection {
|
||||
DB_POOL
|
||||
.get_or_init(|| async {
|
||||
let opt = ConnectOptions::new(db_url())
|
||||
.max_connections(5)
|
||||
.to_owned();
|
||||
Database::connect(opt)
|
||||
.await
|
||||
.expect("测试数据库连接失败")
|
||||
let opt = ConnectOptions::new(db_url()).max_connections(5).to_owned();
|
||||
Database::connect(opt).await.expect("测试数据库连接失败")
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -35,7 +33,5 @@ async fn db_pool() -> &'static DatabaseConnection {
|
||||
/// 创建测试用事务。测试结束自动回滚,无数据残留。
|
||||
pub async fn test_txn() -> DatabaseTransaction {
|
||||
let pool = db_pool().await;
|
||||
pool.begin()
|
||||
.await
|
||||
.expect("测试事务创建失败")
|
||||
pool.begin().await.expect("测试事务创建失败")
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ pub enum DataScope {
|
||||
}
|
||||
|
||||
impl DataScope {
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
pub fn parse_scope(s: &str) -> Self {
|
||||
match s {
|
||||
"self" => Self::SelfOnly,
|
||||
"department" => Self::Department,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
/// 预留事件处理器注册
|
||||
pub fn register_handlers_with_state(_state: crate::state::DialysisState) {
|
||||
// 透析业务事件由 erp-health 统一消费(见 erp-health/src/event.rs:425 dialysis_notifier)
|
||||
|
||||
@@ -8,8 +8,8 @@ use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::dto::dialysis_dto::*;
|
||||
use crate::dto::DeleteWithVersion;
|
||||
use crate::dto::dialysis_dto::*;
|
||||
use crate::service::dialysis_service;
|
||||
use crate::state::DialysisState;
|
||||
|
||||
@@ -44,9 +44,8 @@ where
|
||||
require_permission(&ctx, "health.dialysis.list")?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = dialysis_service::list_dialysis_records(
|
||||
&state, ctx.tenant_id, patient_id, page, page_size,
|
||||
)
|
||||
let result =
|
||||
dialysis_service::list_dialysis_records(&state, ctx.tenant_id, patient_id, page, page_size)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
@@ -61,10 +60,7 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.dialysis.list")?;
|
||||
let result = dialysis_service::get_dialysis_record(
|
||||
&state, ctx.tenant_id, record_id,
|
||||
)
|
||||
.await?;
|
||||
let result = dialysis_service::get_dialysis_record(&state, ctx.tenant_id, record_id).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -80,9 +76,8 @@ where
|
||||
require_permission(&ctx, "health.dialysis.manage")?;
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = dialysis_service::create_dialysis_record(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req,
|
||||
)
|
||||
let result =
|
||||
dialysis_service::create_dialysis_record(&state, ctx.tenant_id, Some(ctx.user_id), req)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
@@ -101,7 +96,12 @@ where
|
||||
let mut data = req.data;
|
||||
data.sanitize();
|
||||
let result = dialysis_service::update_dialysis_record(
|
||||
&state, ctx.tenant_id, record_id, Some(ctx.user_id), data, req.version,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
record_id,
|
||||
Some(ctx.user_id),
|
||||
data,
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -119,7 +119,11 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.dialysis.manage")?;
|
||||
let result = dialysis_service::review_dialysis_record(
|
||||
&state, ctx.tenant_id, record_id, ctx.user_id, req.version,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
record_id,
|
||||
ctx.user_id,
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -137,7 +141,11 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.dialysis.manage")?;
|
||||
let result = dialysis_service::complete_dialysis_record(
|
||||
&state, ctx.tenant_id, record_id, Some(ctx.user_id), req.version,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
record_id,
|
||||
Some(ctx.user_id),
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -155,7 +163,11 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.dialysis.manage")?;
|
||||
dialysis_service::delete_dialysis_record(
|
||||
&state, ctx.tenant_id, record_id, Some(ctx.user_id), req.version,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
record_id,
|
||||
Some(ctx.user_id),
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
|
||||
@@ -8,8 +8,8 @@ use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::dto::dialysis_prescription_dto::*;
|
||||
use crate::dto::DeleteWithVersion;
|
||||
use crate::dto::dialysis_prescription_dto::*;
|
||||
use crate::service::dialysis_prescription_service;
|
||||
use crate::state::DialysisState;
|
||||
|
||||
@@ -41,7 +41,12 @@ where
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = dialysis_prescription_service::list_prescriptions(
|
||||
&state, ctx.tenant_id, page, page_size, params.patient_id, params.status,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
page,
|
||||
page_size,
|
||||
params.patient_id,
|
||||
params.status,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -74,7 +79,10 @@ where
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = dialysis_prescription_service::create_prescription(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
Some(ctx.user_id),
|
||||
req,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -94,7 +102,12 @@ where
|
||||
let mut data = req.data;
|
||||
data.sanitize();
|
||||
let result = dialysis_prescription_service::update_prescription(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), data, req.version,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
id,
|
||||
Some(ctx.user_id),
|
||||
data,
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -112,7 +125,11 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.dialysis-prescription.manage")?;
|
||||
dialysis_prescription_service::delete_prescription(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
id,
|
||||
Some(ctx.user_id),
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use axum::extract::{Extension, FromRef, State};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, FromRef, State};
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
@@ -18,6 +18,7 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.dialysis.stats")?;
|
||||
let dialysis_state = DialysisState::from_ref(&state);
|
||||
let stats = dialysis_stats_service::get_dialysis_statistics(&dialysis_state, ctx.tenant_id).await?;
|
||||
let stats =
|
||||
dialysis_stats_service::get_dialysis_statistics(&dialysis_state, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(stats)))
|
||||
}
|
||||
|
||||
@@ -49,7 +49,13 @@ pub async fn list_prescriptions(
|
||||
let total_pages = total.div_ceil(limit.max(1));
|
||||
let data = models.into_iter().map(model_to_resp).collect();
|
||||
|
||||
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
|
||||
Ok(PaginatedResponse {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
page_size: limit,
|
||||
total_pages,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_prescription(
|
||||
@@ -85,14 +91,24 @@ pub async fn create_prescription(
|
||||
tenant_id: Set(tenant_id),
|
||||
patient_id: Set(req.patient_id),
|
||||
dialyzer_model: Set(req.dialyzer_model),
|
||||
membrane_area: Set(req.membrane_area.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
|
||||
dialysate_potassium: Set(req.dialysate_potassium.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
|
||||
dialysate_calcium: Set(req.dialysate_calcium.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
|
||||
dialysate_bicarbonate: Set(req.dialysate_bicarbonate.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
|
||||
membrane_area: Set(req
|
||||
.membrane_area
|
||||
.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
|
||||
dialysate_potassium: Set(req
|
||||
.dialysate_potassium
|
||||
.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
|
||||
dialysate_calcium: Set(req
|
||||
.dialysate_calcium
|
||||
.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
|
||||
dialysate_bicarbonate: Set(req
|
||||
.dialysate_bicarbonate
|
||||
.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
|
||||
anticoagulation_type: Set(req.anticoagulation_type),
|
||||
anticoagulation_dose: Set(req.anticoagulation_dose),
|
||||
target_ultrafiltration_ml: Set(req.target_ultrafiltration_ml),
|
||||
target_dry_weight: Set(req.target_dry_weight.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
|
||||
target_dry_weight: Set(req
|
||||
.target_dry_weight
|
||||
.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
|
||||
blood_flow_rate: Set(req.blood_flow_rate),
|
||||
dialysate_flow_rate: Set(req.dialysate_flow_rate),
|
||||
frequency_per_week: Set(req.frequency_per_week),
|
||||
@@ -114,10 +130,16 @@ pub async fn create_prescription(
|
||||
let m = active.insert(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "dialysis_prescription.created", "dialysis_prescription")
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
operator_id,
|
||||
"dialysis_prescription.created",
|
||||
"dialysis_prescription",
|
||||
)
|
||||
.with_resource_id(m.id),
|
||||
&state.db,
|
||||
).await;
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(model_to_resp(m))
|
||||
}
|
||||
@@ -141,29 +163,71 @@ pub async fn update_prescription(
|
||||
let next_ver = check_version(expected_version, model.version)
|
||||
.map_err(|_| DialysisError::VersionMismatch)?;
|
||||
|
||||
if let Some(ref t) = req.anticoagulation_type { validate_anticoagulation_type(Some(t))?; }
|
||||
if let Some(ref t) = req.vascular_access_type { validate_vascular_access_type(Some(t))?; }
|
||||
if let Some(ref t) = req.anticoagulation_type {
|
||||
validate_anticoagulation_type(Some(t))?;
|
||||
}
|
||||
if let Some(ref t) = req.vascular_access_type {
|
||||
validate_vascular_access_type(Some(t))?;
|
||||
}
|
||||
|
||||
let mut active: dialysis_prescription::ActiveModel = model.into();
|
||||
if let Some(v) = req.dialyzer_model { active.dialyzer_model = Set(Some(v)); }
|
||||
if let Some(v) = req.membrane_area { active.membrane_area = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); }
|
||||
if let Some(v) = req.dialysate_potassium { active.dialysate_potassium = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); }
|
||||
if let Some(v) = req.dialysate_calcium { active.dialysate_calcium = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); }
|
||||
if let Some(v) = req.dialysate_bicarbonate { active.dialysate_bicarbonate = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); }
|
||||
if let Some(v) = req.anticoagulation_type { active.anticoagulation_type = Set(Some(v)); }
|
||||
if let Some(v) = req.anticoagulation_dose { active.anticoagulation_dose = Set(Some(v)); }
|
||||
if let Some(v) = req.target_ultrafiltration_ml { active.target_ultrafiltration_ml = Set(Some(v)); }
|
||||
if let Some(v) = req.target_dry_weight { active.target_dry_weight = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); }
|
||||
if let Some(v) = req.blood_flow_rate { active.blood_flow_rate = Set(Some(v)); }
|
||||
if let Some(v) = req.dialysate_flow_rate { active.dialysate_flow_rate = Set(Some(v)); }
|
||||
if let Some(v) = req.frequency_per_week { active.frequency_per_week = Set(Some(v)); }
|
||||
if let Some(v) = req.duration_minutes { active.duration_minutes = Set(Some(v)); }
|
||||
if let Some(v) = req.vascular_access_type { active.vascular_access_type = Set(Some(v)); }
|
||||
if let Some(v) = req.vascular_access_location { active.vascular_access_location = Set(Some(v)); }
|
||||
if let Some(v) = req.effective_from { active.effective_from = Set(Some(v)); }
|
||||
if let Some(v) = req.effective_to { active.effective_to = Set(Some(v)); }
|
||||
if let Some(v) = req.status { active.status = Set(v); }
|
||||
if let Some(v) = req.notes { active.notes = Set(Some(v)); }
|
||||
if let Some(v) = req.dialyzer_model {
|
||||
active.dialyzer_model = Set(Some(v));
|
||||
}
|
||||
if let Some(v) = req.membrane_area {
|
||||
active.membrane_area = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default()));
|
||||
}
|
||||
if let Some(v) = req.dialysate_potassium {
|
||||
active.dialysate_potassium = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default()));
|
||||
}
|
||||
if let Some(v) = req.dialysate_calcium {
|
||||
active.dialysate_calcium = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default()));
|
||||
}
|
||||
if let Some(v) = req.dialysate_bicarbonate {
|
||||
active.dialysate_bicarbonate = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default()));
|
||||
}
|
||||
if let Some(v) = req.anticoagulation_type {
|
||||
active.anticoagulation_type = Set(Some(v));
|
||||
}
|
||||
if let Some(v) = req.anticoagulation_dose {
|
||||
active.anticoagulation_dose = Set(Some(v));
|
||||
}
|
||||
if let Some(v) = req.target_ultrafiltration_ml {
|
||||
active.target_ultrafiltration_ml = Set(Some(v));
|
||||
}
|
||||
if let Some(v) = req.target_dry_weight {
|
||||
active.target_dry_weight = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default()));
|
||||
}
|
||||
if let Some(v) = req.blood_flow_rate {
|
||||
active.blood_flow_rate = Set(Some(v));
|
||||
}
|
||||
if let Some(v) = req.dialysate_flow_rate {
|
||||
active.dialysate_flow_rate = Set(Some(v));
|
||||
}
|
||||
if let Some(v) = req.frequency_per_week {
|
||||
active.frequency_per_week = Set(Some(v));
|
||||
}
|
||||
if let Some(v) = req.duration_minutes {
|
||||
active.duration_minutes = Set(Some(v));
|
||||
}
|
||||
if let Some(v) = req.vascular_access_type {
|
||||
active.vascular_access_type = Set(Some(v));
|
||||
}
|
||||
if let Some(v) = req.vascular_access_location {
|
||||
active.vascular_access_location = Set(Some(v));
|
||||
}
|
||||
if let Some(v) = req.effective_from {
|
||||
active.effective_from = Set(Some(v));
|
||||
}
|
||||
if let Some(v) = req.effective_to {
|
||||
active.effective_to = Set(Some(v));
|
||||
}
|
||||
if let Some(v) = req.status {
|
||||
active.status = Set(v);
|
||||
}
|
||||
if let Some(v) = req.notes {
|
||||
active.notes = Set(Some(v));
|
||||
}
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(next_ver);
|
||||
@@ -171,10 +235,16 @@ pub async fn update_prescription(
|
||||
let m = active.update(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "dialysis_prescription.updated", "dialysis_prescription")
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
operator_id,
|
||||
"dialysis_prescription.updated",
|
||||
"dialysis_prescription",
|
||||
)
|
||||
.with_resource_id(m.id),
|
||||
&state.db,
|
||||
).await;
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(model_to_resp(m))
|
||||
}
|
||||
@@ -205,10 +275,16 @@ pub async fn delete_prescription(
|
||||
active.update(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "dialysis_prescription.deleted", "dialysis_prescription")
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
operator_id,
|
||||
"dialysis_prescription.deleted",
|
||||
"dialysis_prescription",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
&state.db,
|
||||
).await;
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -252,7 +328,8 @@ fn validate_anticoagulation_type(val: Option<&str>) -> DialysisResult<()> {
|
||||
let valid = ["heparin", "lmwh", "heparin_free"];
|
||||
if !valid.contains(&t) {
|
||||
return Err(DialysisError::Validation(format!(
|
||||
"anticoagulation_type 必须为: {}", valid.join(", ")
|
||||
"anticoagulation_type 必须为: {}",
|
||||
valid.join(", ")
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -264,7 +341,8 @@ fn validate_vascular_access_type(val: Option<&str>) -> DialysisResult<()> {
|
||||
let valid = ["avf", "avg", "cvc"];
|
||||
if !valid.contains(&t) {
|
||||
return Err(DialysisError::Validation(format!(
|
||||
"vascular_access_type 必须为: {}", valid.join(", ")
|
||||
"vascular_access_type 必须为: {}",
|
||||
valid.join(", ")
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,13 @@ pub async fn list_dialysis_records(
|
||||
let crypto = &state.crypto;
|
||||
let data: Vec<DialysisRecordResp> = models.into_iter().map(|m| to_resp(crypto, m)).collect();
|
||||
|
||||
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
|
||||
Ok(PaginatedResponse {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
page_size: limit,
|
||||
total_pages,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_dialysis_record(
|
||||
@@ -92,15 +98,19 @@ pub async fn create_dialysis_record(
|
||||
let kek = state.crypto.kek();
|
||||
|
||||
// PII 加密
|
||||
let encrypted_symptoms = req.symptoms.as_ref()
|
||||
let encrypted_symptoms = req
|
||||
.symptoms
|
||||
.as_ref()
|
||||
.map(|v| -> DialysisResult<serde_json::Value> {
|
||||
let json_str = serde_json::to_string(v)
|
||||
.map_err(|e| DialysisError::Validation(e.to_string()))?;
|
||||
let json_str =
|
||||
serde_json::to_string(v).map_err(|e| DialysisError::Validation(e.to_string()))?;
|
||||
Ok(serde_json::Value::String(pii::encrypt(kek, &json_str)?))
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let encrypted_complication = req.complication_notes.as_ref()
|
||||
let encrypted_complication = req
|
||||
.complication_notes
|
||||
.as_ref()
|
||||
.map(|c| pii::encrypt(kek, c))
|
||||
.transpose()?;
|
||||
|
||||
@@ -112,9 +122,15 @@ pub async fn create_dialysis_record(
|
||||
dialysis_date: Set(req.dialysis_date),
|
||||
start_time: Set(req.start_time),
|
||||
end_time: Set(req.end_time),
|
||||
dry_weight: Set(req.dry_weight.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
|
||||
pre_weight: Set(req.pre_weight.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
|
||||
post_weight: Set(req.post_weight.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
|
||||
dry_weight: Set(req
|
||||
.dry_weight
|
||||
.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
|
||||
pre_weight: Set(req
|
||||
.pre_weight
|
||||
.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
|
||||
post_weight: Set(req
|
||||
.post_weight
|
||||
.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
|
||||
pre_bp_systolic: Set(req.pre_bp_systolic),
|
||||
pre_bp_diastolic: Set(req.pre_bp_diastolic),
|
||||
post_bp_systolic: Set(req.post_bp_systolic),
|
||||
@@ -142,10 +158,16 @@ pub async fn create_dialysis_record(
|
||||
let m = active.insert(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "dialysis_record.created", "dialysis_record")
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
operator_id,
|
||||
"dialysis_record.created",
|
||||
"dialysis_record",
|
||||
)
|
||||
.with_resource_id(m.id),
|
||||
&state.db,
|
||||
).await;
|
||||
)
|
||||
.await;
|
||||
|
||||
// 发布透析记录创建事件
|
||||
let event = DomainEvent::new(
|
||||
@@ -182,27 +204,61 @@ pub async fn update_dialysis_record(
|
||||
.map_err(|_| DialysisError::VersionMismatch)?;
|
||||
|
||||
let mut active: dialysis_record::ActiveModel = model.into();
|
||||
if let Some(v) = req.dialysis_date { active.dialysis_date = Set(v); }
|
||||
if let Some(v) = req.start_time { active.start_time = Set(Some(v)); }
|
||||
if let Some(v) = req.end_time { active.end_time = Set(Some(v)); }
|
||||
if let Some(v) = req.dry_weight { active.dry_weight = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); }
|
||||
if let Some(v) = req.pre_weight { active.pre_weight = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); }
|
||||
if let Some(v) = req.post_weight { active.post_weight = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); }
|
||||
if let Some(v) = req.pre_bp_systolic { active.pre_bp_systolic = Set(Some(v)); }
|
||||
if let Some(v) = req.pre_bp_diastolic { active.pre_bp_diastolic = Set(Some(v)); }
|
||||
if let Some(v) = req.post_bp_systolic { active.post_bp_systolic = Set(Some(v)); }
|
||||
if let Some(v) = req.post_bp_diastolic { active.post_bp_diastolic = Set(Some(v)); }
|
||||
if let Some(v) = req.pre_heart_rate { active.pre_heart_rate = Set(Some(v)); }
|
||||
if let Some(v) = req.post_heart_rate { active.post_heart_rate = Set(Some(v)); }
|
||||
if let Some(v) = req.ultrafiltration_volume { active.ultrafiltration_volume = Set(Some(v)); }
|
||||
if let Some(v) = req.dialysis_duration { active.dialysis_duration = Set(Some(v)); }
|
||||
if let Some(v) = req.blood_flow_rate { active.blood_flow_rate = Set(Some(v)); }
|
||||
if let Some(ref v) = req.dialysis_type { validate_dialysis_type(v)?; active.dialysis_type = Set(v.clone()); }
|
||||
if let Some(v) = req.dialysis_date {
|
||||
active.dialysis_date = Set(v);
|
||||
}
|
||||
if let Some(v) = req.start_time {
|
||||
active.start_time = Set(Some(v));
|
||||
}
|
||||
if let Some(v) = req.end_time {
|
||||
active.end_time = Set(Some(v));
|
||||
}
|
||||
if let Some(v) = req.dry_weight {
|
||||
active.dry_weight = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default()));
|
||||
}
|
||||
if let Some(v) = req.pre_weight {
|
||||
active.pre_weight = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default()));
|
||||
}
|
||||
if let Some(v) = req.post_weight {
|
||||
active.post_weight = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default()));
|
||||
}
|
||||
if let Some(v) = req.pre_bp_systolic {
|
||||
active.pre_bp_systolic = Set(Some(v));
|
||||
}
|
||||
if let Some(v) = req.pre_bp_diastolic {
|
||||
active.pre_bp_diastolic = Set(Some(v));
|
||||
}
|
||||
if let Some(v) = req.post_bp_systolic {
|
||||
active.post_bp_systolic = Set(Some(v));
|
||||
}
|
||||
if let Some(v) = req.post_bp_diastolic {
|
||||
active.post_bp_diastolic = Set(Some(v));
|
||||
}
|
||||
if let Some(v) = req.pre_heart_rate {
|
||||
active.pre_heart_rate = Set(Some(v));
|
||||
}
|
||||
if let Some(v) = req.post_heart_rate {
|
||||
active.post_heart_rate = Set(Some(v));
|
||||
}
|
||||
if let Some(v) = req.ultrafiltration_volume {
|
||||
active.ultrafiltration_volume = Set(Some(v));
|
||||
}
|
||||
if let Some(v) = req.dialysis_duration {
|
||||
active.dialysis_duration = Set(Some(v));
|
||||
}
|
||||
if let Some(v) = req.blood_flow_rate {
|
||||
active.blood_flow_rate = Set(Some(v));
|
||||
}
|
||||
if let Some(ref v) = req.dialysis_type {
|
||||
validate_dialysis_type(v)?;
|
||||
active.dialysis_type = Set(v.clone());
|
||||
}
|
||||
if let Some(v) = req.symptoms {
|
||||
let kek = state.crypto.kek();
|
||||
let encrypted = Some(serde_json::Value::String(
|
||||
pii::encrypt(kek, &serde_json::to_string(&v).unwrap_or_default())?
|
||||
));
|
||||
let encrypted = Some(serde_json::Value::String(pii::encrypt(
|
||||
kek,
|
||||
&serde_json::to_string(&v).unwrap_or_default(),
|
||||
)?));
|
||||
active.symptoms = Set(encrypted);
|
||||
}
|
||||
if let Some(v) = req.complication_notes {
|
||||
@@ -218,10 +274,16 @@ pub async fn update_dialysis_record(
|
||||
let m = active.update(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "dialysis_record.updated", "dialysis_record")
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
operator_id,
|
||||
"dialysis_record.updated",
|
||||
"dialysis_record",
|
||||
)
|
||||
.with_resource_id(m.id),
|
||||
&state.db,
|
||||
).await;
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(to_resp(&state.crypto, m))
|
||||
}
|
||||
@@ -255,10 +317,16 @@ pub async fn complete_dialysis_record(
|
||||
let m = active.update(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "dialysis_record.completed", "dialysis_record")
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
operator_id,
|
||||
"dialysis_record.completed",
|
||||
"dialysis_record",
|
||||
)
|
||||
.with_resource_id(m.id),
|
||||
&state.db,
|
||||
).await;
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(to_resp(&state.crypto, m))
|
||||
}
|
||||
@@ -294,10 +362,16 @@ pub async fn review_dialysis_record(
|
||||
let m = active.update(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(reviewer_id), "dialysis_record.reviewed", "dialysis_record")
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(reviewer_id),
|
||||
"dialysis_record.reviewed",
|
||||
"dialysis_record",
|
||||
)
|
||||
.with_resource_id(m.id),
|
||||
&state.db,
|
||||
).await;
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(to_resp(&state.crypto, m))
|
||||
}
|
||||
@@ -328,10 +402,16 @@ pub async fn delete_dialysis_record(
|
||||
active.update(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "dialysis_record.deleted", "dialysis_record")
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
operator_id,
|
||||
"dialysis_record.deleted",
|
||||
"dialysis_record",
|
||||
)
|
||||
.with_resource_id(record_id),
|
||||
&state.db,
|
||||
).await;
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -345,7 +425,8 @@ fn validate_dialysis_type(dialysis_type: &str) -> DialysisResult<()> {
|
||||
match dialysis_type {
|
||||
"HD" | "HDF" | "HF" => Ok(()),
|
||||
_ => Err(DialysisError::Validation(format!(
|
||||
"无效的透析类型: {},允许值: HD, HDF, HF", dialysis_type
|
||||
"无效的透析类型: {},允许值: HD, HDF, HF",
|
||||
dialysis_type
|
||||
))),
|
||||
}
|
||||
}
|
||||
@@ -365,7 +446,8 @@ fn validate_dialysis_status_transition(current: &str, new: &str) -> DialysisResu
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DialysisError::InvalidStatusTransition(format!(
|
||||
"dialysis_record.status: 不允许从 '{}' 转换到 '{}'", current, new
|
||||
"dialysis_record.status: 不允许从 '{}' 转换到 '{}'",
|
||||
current, new
|
||||
)))
|
||||
}
|
||||
}
|
||||
@@ -374,14 +456,18 @@ fn to_resp(crypto: &erp_core::crypto::PiiCrypto, m: dialysis_record::Model) -> D
|
||||
let kek = crypto.kek();
|
||||
|
||||
// 解密症状 JSON(加密时存储为 Value::String(ciphertext))
|
||||
let symptoms = m.symptoms.as_ref()
|
||||
let symptoms = m
|
||||
.symptoms
|
||||
.as_ref()
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| pii::decrypt(kek, s).ok())
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.or(m.symptoms);
|
||||
|
||||
// 解密并发症备注
|
||||
let complication_notes = m.complication_notes.as_ref()
|
||||
let complication_notes = m
|
||||
.complication_notes
|
||||
.as_ref()
|
||||
.map(|c| pii::decrypt(kek, c).unwrap_or_else(|_| c.clone()))
|
||||
.or(m.complication_notes);
|
||||
|
||||
@@ -421,25 +507,45 @@ mod tests {
|
||||
|
||||
// --- validate_dialysis_type ---
|
||||
#[test]
|
||||
fn dialysis_type_hd() { assert!(validate_dialysis_type("HD").is_ok()); }
|
||||
fn dialysis_type_hd() {
|
||||
assert!(validate_dialysis_type("HD").is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn dialysis_type_hdf() { assert!(validate_dialysis_type("HDF").is_ok()); }
|
||||
fn dialysis_type_hdf() {
|
||||
assert!(validate_dialysis_type("HDF").is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn dialysis_type_hf() { assert!(validate_dialysis_type("HF").is_ok()); }
|
||||
fn dialysis_type_hf() {
|
||||
assert!(validate_dialysis_type("HF").is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn dialysis_type_invalid() { assert!(validate_dialysis_type("PD").is_err()); }
|
||||
fn dialysis_type_invalid() {
|
||||
assert!(validate_dialysis_type("PD").is_err());
|
||||
}
|
||||
|
||||
// --- validate_dialysis_status_transition ---
|
||||
#[test]
|
||||
fn dial_draft_to_completed() { assert!(validate_dialysis_status_transition("draft", "completed").is_ok()); }
|
||||
fn dial_draft_to_completed() {
|
||||
assert!(validate_dialysis_status_transition("draft", "completed").is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn dial_draft_to_reviewed_fails() { assert!(validate_dialysis_status_transition("draft", "reviewed").is_err()); }
|
||||
fn dial_draft_to_reviewed_fails() {
|
||||
assert!(validate_dialysis_status_transition("draft", "reviewed").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn dial_completed_to_reviewed() { assert!(validate_dialysis_status_transition("completed", "reviewed").is_ok()); }
|
||||
fn dial_completed_to_reviewed() {
|
||||
assert!(validate_dialysis_status_transition("completed", "reviewed").is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn dial_completed_to_draft_fails() { assert!(validate_dialysis_status_transition("completed", "draft").is_err()); }
|
||||
fn dial_completed_to_draft_fails() {
|
||||
assert!(validate_dialysis_status_transition("completed", "draft").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn dial_reviewed_to_any_fails() { assert!(validate_dialysis_status_transition("reviewed", "draft").is_err()); }
|
||||
fn dial_reviewed_to_any_fails() {
|
||||
assert!(validate_dialysis_status_transition("reviewed", "draft").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn dial_same_status_ok() { assert!(validate_dialysis_status_transition("draft", "draft").is_ok()); }
|
||||
fn dial_same_status_ok() {
|
||||
assert!(validate_dialysis_status_transition("draft", "draft").is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use sea_orm::{DatabaseBackend, FromQueryResult, Statement};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::dialysis_stats_dto::{DialysisStatisticsResp, NameValue};
|
||||
use crate::error::{DialysisResult, DialysisError};
|
||||
use crate::error::{DialysisError, DialysisResult};
|
||||
use crate::state::DialysisState;
|
||||
|
||||
pub async fn get_dialysis_statistics(
|
||||
@@ -12,7 +12,9 @@ pub async fn get_dialysis_statistics(
|
||||
let db = &state.db;
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct CountRow { count: i64 }
|
||||
struct CountRow {
|
||||
count: i64,
|
||||
}
|
||||
|
||||
let total_records = CountRow::find_by_statement(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
@@ -33,12 +35,14 @@ pub async fn get_dialysis_statistics(
|
||||
)).one(db).await?.map(|r| r.count).unwrap_or(0);
|
||||
|
||||
let type_distribution = count_by_field(
|
||||
db, tenant_id,
|
||||
db,
|
||||
tenant_id,
|
||||
"SELECT dialysis_type AS name, COUNT(*) AS value FROM dialysis_record \
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL \
|
||||
AND created_at >= date_trunc('month', NOW()) \
|
||||
GROUP BY dialysis_type ORDER BY value DESC",
|
||||
).await?;
|
||||
)
|
||||
.await?;
|
||||
|
||||
let complication_rate = compute_complication_rate(db, tenant_id).await?;
|
||||
let avg_ultrafiltration = compute_avg_field(db, tenant_id, "ultrafiltration_volume").await?;
|
||||
@@ -61,7 +65,10 @@ async fn count_by_field(
|
||||
sql: &str,
|
||||
) -> DialysisResult<Vec<NameValue>> {
|
||||
#[derive(FromQueryResult)]
|
||||
struct NameValueRow { name: String, value: i64 }
|
||||
struct NameValueRow {
|
||||
name: String,
|
||||
value: i64,
|
||||
}
|
||||
|
||||
let rows: Vec<NameValueRow> = FromQueryResult::find_by_statement(
|
||||
Statement::from_sql_and_values(DatabaseBackend::Postgres, sql, [tenant_id.into()]),
|
||||
@@ -69,17 +76,29 @@ async fn count_by_field(
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(|r| NameValue { name: r.name, value: r.value }).collect())
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| NameValue {
|
||||
name: r.name,
|
||||
value: r.value,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct AvgFieldResult { avg_val: Option<f64> }
|
||||
struct AvgFieldResult {
|
||||
avg_val: Option<f64>,
|
||||
}
|
||||
|
||||
macro_rules! avg_field_sql {
|
||||
($field:literal) => {
|
||||
concat!(
|
||||
"SELECT AVG(", $field, ")::FLOAT8 AS avg_val FROM dialysis_record ",
|
||||
"WHERE tenant_id = $1 AND deleted_at IS NULL AND ", $field, " IS NOT NULL ",
|
||||
"SELECT AVG(",
|
||||
$field,
|
||||
")::FLOAT8 AS avg_val FROM dialysis_record ",
|
||||
"WHERE tenant_id = $1 AND deleted_at IS NULL AND ",
|
||||
$field,
|
||||
" IS NOT NULL ",
|
||||
"AND created_at >= date_trunc('month', NOW())"
|
||||
)
|
||||
};
|
||||
@@ -94,7 +113,11 @@ async fn compute_avg_field(
|
||||
"ultrafiltration_volume" => avg_field_sql!("ultrafiltration_volume"),
|
||||
"dialysis_duration" => avg_field_sql!("dialysis_duration"),
|
||||
"blood_flow_rate" => avg_field_sql!("blood_flow_rate"),
|
||||
_ => return Err(DialysisError::Validation(format!("不允许的字段名: {field}"))),
|
||||
_ => {
|
||||
return Err(DialysisError::Validation(format!(
|
||||
"不允许的字段名: {field}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let result: Option<AvgFieldResult> = FromQueryResult::find_by_statement(
|
||||
Statement::from_sql_and_values(DatabaseBackend::Postgres, sql, [tenant_id.into()]),
|
||||
@@ -119,7 +142,10 @@ async fn compute_complication_rate(
|
||||
"#;
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct CompResult { with_comp: i64, total: i64 }
|
||||
struct CompResult {
|
||||
with_comp: i64,
|
||||
total: i64,
|
||||
}
|
||||
|
||||
let result: Option<CompResult> = FromQueryResult::find_by_statement(
|
||||
Statement::from_sql_and_values(DatabaseBackend::Postgres, sql, [tenant_id.into()]),
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
pub mod dialysis_service;
|
||||
pub mod dialysis_prescription_service;
|
||||
pub mod dialysis_service;
|
||||
pub mod dialysis_stats_service;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use aes_gcm::aead::Aead;
|
||||
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
@@ -119,7 +119,9 @@ pub struct UpdateArticleReq {
|
||||
|
||||
impl UpdateArticleReq {
|
||||
pub fn sanitize(&mut self) {
|
||||
if let Some(ref mut v) = self.title { *v = strip_html_tags(v); }
|
||||
if let Some(ref mut v) = self.title {
|
||||
*v = strip_html_tags(v);
|
||||
}
|
||||
self.summary = sanitize_option(self.summary.take());
|
||||
self.content = sanitize_option(self.content.take());
|
||||
self.category = sanitize_option(self.category.take());
|
||||
@@ -205,7 +207,9 @@ pub struct UpdateCategoryReq {
|
||||
|
||||
impl UpdateCategoryReq {
|
||||
pub fn sanitize(&mut self) {
|
||||
if let Some(ref mut v) = self.name { *v = strip_html_tags(v); }
|
||||
if let Some(ref mut v) = self.name {
|
||||
*v = strip_html_tags(v);
|
||||
}
|
||||
self.slug = sanitize_option(self.slug.take());
|
||||
self.description = sanitize_option(self.description.take());
|
||||
}
|
||||
|
||||
@@ -26,8 +26,12 @@ impl CreateDiagnosisReq {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_diagnosis_type() -> String { "primary".to_string() }
|
||||
fn default_status() -> String { "active".to_string() }
|
||||
fn default_diagnosis_type() -> String {
|
||||
"primary".to_string()
|
||||
}
|
||||
fn default_status() -> String {
|
||||
"active".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateDiagnosisReq {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
pub mod appointment_dto;
|
||||
pub mod alert_dto;
|
||||
pub mod appointment_dto;
|
||||
pub mod article_dto;
|
||||
pub mod ble_gateway_dto;
|
||||
pub mod care_plan_dto;
|
||||
pub mod article_dto;
|
||||
pub mod consent_dto;
|
||||
pub mod consultation_dto;
|
||||
pub mod daily_monitoring_dto;
|
||||
pub mod diagnosis_dto;
|
||||
pub mod medication_record_dto;
|
||||
pub mod medication_reminder_dto;
|
||||
pub mod doctor_dto;
|
||||
pub mod follow_up_dto;
|
||||
pub mod follow_up_template_dto;
|
||||
pub mod health_data_dto;
|
||||
pub mod medication_record_dto;
|
||||
pub mod medication_reminder_dto;
|
||||
pub mod patient_dto;
|
||||
pub mod points_dto;
|
||||
pub mod shift_dto;
|
||||
|
||||
@@ -39,7 +39,10 @@ pub struct CreateShiftReq {
|
||||
impl CreateShiftReq {
|
||||
pub fn sanitize(&mut self) {
|
||||
self.period = erp_core::sanitize::sanitize_string(&self.period);
|
||||
self.notes = self.notes.take().map(|n| erp_core::sanitize::sanitize_string(&n));
|
||||
self.notes = self
|
||||
.notes
|
||||
.take()
|
||||
.map(|n| erp_core::sanitize::sanitize_string(&n));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
pub mod alert_rules;
|
||||
pub mod ble_gateway;
|
||||
pub mod api_client;
|
||||
pub mod alerts;
|
||||
pub mod api_client;
|
||||
pub mod appointment;
|
||||
pub mod article;
|
||||
pub mod article_article_tag;
|
||||
pub mod article_category;
|
||||
pub mod article_revision;
|
||||
pub mod article_tag;
|
||||
pub mod ble_gateway;
|
||||
pub mod blind_index;
|
||||
pub mod critical_value_threshold;
|
||||
pub mod care_plan;
|
||||
pub mod care_plan_item;
|
||||
pub mod care_plan_outcome;
|
||||
pub mod consent;
|
||||
pub mod consultation_message;
|
||||
pub mod consultation_session;
|
||||
pub mod critical_alert;
|
||||
pub mod critical_alert_response;
|
||||
pub mod critical_value_threshold;
|
||||
pub mod daily_monitoring;
|
||||
pub mod device_readings;
|
||||
pub mod diagnosis;
|
||||
@@ -25,31 +28,28 @@ pub mod follow_up_task;
|
||||
pub mod follow_up_template;
|
||||
pub mod follow_up_template_field;
|
||||
pub mod gateway_patient_binding;
|
||||
pub mod handoff_log;
|
||||
pub mod health_record;
|
||||
pub mod health_trend;
|
||||
pub mod lab_report;
|
||||
pub mod medication_record;
|
||||
pub mod medication_reminder;
|
||||
pub mod offline_event;
|
||||
pub mod offline_event_registration;
|
||||
pub mod patient;
|
||||
pub mod patient_assignment;
|
||||
pub mod patient_devices;
|
||||
pub mod patient_doctor_relation;
|
||||
pub mod patient_family_member;
|
||||
pub mod patient_tag;
|
||||
pub mod patient_tag_relation;
|
||||
pub mod patient_devices;
|
||||
pub mod points_account;
|
||||
pub mod points_checkin;
|
||||
pub mod points_order;
|
||||
pub mod points_product;
|
||||
pub mod points_rule;
|
||||
pub mod points_transaction;
|
||||
pub mod offline_event;
|
||||
pub mod offline_event_registration;
|
||||
pub mod medication_record;
|
||||
pub mod medication_reminder;
|
||||
pub mod vital_signs;
|
||||
pub mod care_plan;
|
||||
pub mod care_plan_item;
|
||||
pub mod care_plan_outcome;
|
||||
pub mod shift;
|
||||
pub mod patient_assignment;
|
||||
pub mod handoff_log;
|
||||
pub mod vital_signs;
|
||||
pub mod vital_signs_daily;
|
||||
pub mod vital_signs_hourly;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
use crate::entity::{
|
||||
appointment, consultation_session, device_readings, doctor_profile, follow_up_task,
|
||||
lab_report, patient, patient_devices,
|
||||
appointment, consultation_session, device_readings, doctor_profile, follow_up_task, lab_report,
|
||||
patient, patient_devices,
|
||||
};
|
||||
use crate::fhir::types::{device_type_to_category, device_type_to_loinc, device_type_to_unit};
|
||||
|
||||
@@ -51,9 +51,10 @@ pub fn patient_to_fhir(p: &patient::Model) -> serde_json::Value {
|
||||
pub fn device_reading_to_fhir_observations(r: &device_readings::Model) -> Vec<serde_json::Value> {
|
||||
let mut results = Vec::new();
|
||||
let patient_ref = serde_json::json!({"reference": format!("Patient/{}", r.patient_id)});
|
||||
let device_ref = r.device_id.as_ref().map(|d| {
|
||||
serde_json::json!({"reference": format!("Device/{}", d)})
|
||||
});
|
||||
let device_ref = r
|
||||
.device_id
|
||||
.as_ref()
|
||||
.map(|d| serde_json::json!({"reference": format!("Device/{}", d)}));
|
||||
let measured = r.measured_at.to_rfc3339();
|
||||
let category = device_type_to_category(&r.device_type);
|
||||
|
||||
@@ -71,29 +72,50 @@ pub fn device_reading_to_fhir_observations(r: &device_readings::Model) -> Vec<se
|
||||
|
||||
if let Some(val) = sys {
|
||||
results.push(make_observation(
|
||||
&r.id, "8480-6", "Systolic blood pressure",
|
||||
category_json.clone(), &patient_ref, device_ref.as_ref(),
|
||||
&measured, val, "mmHg", "mm[Hg]",
|
||||
&r.id,
|
||||
"8480-6",
|
||||
"Systolic blood pressure",
|
||||
category_json.clone(),
|
||||
&patient_ref,
|
||||
device_ref.as_ref(),
|
||||
&measured,
|
||||
val,
|
||||
"mmHg",
|
||||
"mm[Hg]",
|
||||
));
|
||||
}
|
||||
if let Some(val) = dia {
|
||||
results.push(make_observation(
|
||||
&r.id, "8462-4", "Diastolic blood pressure",
|
||||
category_json, &patient_ref, device_ref.as_ref(),
|
||||
&measured, val, "mmHg", "mm[Hg]",
|
||||
&r.id,
|
||||
"8462-4",
|
||||
"Diastolic blood pressure",
|
||||
category_json,
|
||||
&patient_ref,
|
||||
device_ref.as_ref(),
|
||||
&measured,
|
||||
val,
|
||||
"mmHg",
|
||||
"mm[Hg]",
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let (loinc_code, loinc_display) = device_type_to_loinc(&r.device_type)
|
||||
.unwrap_or(("unknown", "Unknown"));
|
||||
let (loinc_code, loinc_display) =
|
||||
device_type_to_loinc(&r.device_type).unwrap_or(("unknown", "Unknown"));
|
||||
let (unit_display, unit_code) = device_type_to_unit(&r.device_type);
|
||||
let val = extract_main_value(&r.device_type, &r.raw_value);
|
||||
if let Some(v) = val {
|
||||
results.push(make_observation(
|
||||
&r.id, loinc_code, loinc_display,
|
||||
category_json, &patient_ref, device_ref.as_ref(),
|
||||
&measured, v, unit_display, unit_code,
|
||||
&r.id,
|
||||
loinc_code,
|
||||
loinc_display,
|
||||
category_json,
|
||||
&patient_ref,
|
||||
device_ref.as_ref(),
|
||||
&measured,
|
||||
v,
|
||||
unit_display,
|
||||
unit_code,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -102,11 +124,18 @@ pub fn device_reading_to_fhir_observations(r: &device_readings::Model) -> Vec<se
|
||||
results
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn make_observation(
|
||||
reading_id: &uuid::Uuid, code: &str, display: &str,
|
||||
category: serde_json::Value, subject: &serde_json::Value,
|
||||
device: Option<&serde_json::Value>, effective: &str,
|
||||
value: f64, unit_display: &str, unit_code: &str,
|
||||
reading_id: &uuid::Uuid,
|
||||
code: &str,
|
||||
display: &str,
|
||||
category: serde_json::Value,
|
||||
subject: &serde_json::Value,
|
||||
device: Option<&serde_json::Value>,
|
||||
effective: &str,
|
||||
value: f64,
|
||||
unit_display: &str,
|
||||
unit_code: &str,
|
||||
) -> serde_json::Value {
|
||||
let mut obs = serde_json::json!({
|
||||
"resourceType": "Observation",
|
||||
@@ -234,12 +263,10 @@ pub fn appointment_to_fhir(a: &appointment::Model) -> serde_json::Value {
|
||||
_ => "booked",
|
||||
};
|
||||
|
||||
let mut participants = vec![
|
||||
serde_json::json!({
|
||||
let mut participants = vec![serde_json::json!({
|
||||
"actor": {"reference": format!("Patient/{}", a.patient_id)},
|
||||
"status": "accepted",
|
||||
}),
|
||||
];
|
||||
})];
|
||||
if let Some(ref doctor_id) = a.doctor_id {
|
||||
participants.push(serde_json::json!({
|
||||
"actor": {"reference": format!("Practitioner/{}", doctor_id)},
|
||||
@@ -322,8 +349,7 @@ pub fn follow_up_to_fhir(t: &follow_up_task::Model) -> serde_json::Value {
|
||||
_ => "requested",
|
||||
};
|
||||
|
||||
let display = t.content_template.as_deref()
|
||||
.unwrap_or(&t.follow_up_type);
|
||||
let display = t.content_template.as_deref().unwrap_or(&t.follow_up_type);
|
||||
|
||||
serde_json::json!({
|
||||
"resourceType": "Task",
|
||||
@@ -343,7 +369,12 @@ fn mask_sensitive(s: &str) -> String {
|
||||
if s.len() <= 5 {
|
||||
"*".repeat(s.len())
|
||||
} else {
|
||||
format!("{}{}{}", &s[..1], "*".repeat(s.len() - 5), &s[s.len() - 4..])
|
||||
format!(
|
||||
"{}{}{}",
|
||||
&s[..1],
|
||||
"*".repeat(s.len() - 5),
|
||||
&s[s.len() - 4..]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use axum::extract::{FromRef, Path, Query, State};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Extension;
|
||||
use axum::Json;
|
||||
use axum::extract::{FromRef, Path, Query, State};
|
||||
use axum::response::IntoResponse;
|
||||
use sea_orm::*;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
@@ -78,11 +78,15 @@ fn enforce_patient_scope(fhir_ctx: &FhirAuthContext, patient_id: Uuid) -> Result
|
||||
requested_patient = %patient_id,
|
||||
"FHIR 客户端尝试访问授权范围外的患者"
|
||||
);
|
||||
return Err(AppError::Forbidden("Access denied: patient not in allowed scope".into()));
|
||||
return Err(AppError::Forbidden(
|
||||
"Access denied: patient not in allowed scope".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Forbidden("OAuth client has no patient access configured".into()));
|
||||
return Err(AppError::Forbidden(
|
||||
"OAuth client has no patient access configured".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -112,7 +116,8 @@ pub async fn search_patients(
|
||||
.filter(crate::entity::patient::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(ref id) = params.id {
|
||||
let uid = Uuid::parse_str(id).map_err(|_| AppError::Validation("Invalid patient id".into()))?;
|
||||
let uid =
|
||||
Uuid::parse_str(id).map_err(|_| AppError::Validation("Invalid patient id".into()))?;
|
||||
query = query.filter(crate::entity::patient::Column::Id.eq(uid));
|
||||
}
|
||||
if let Some(ref name) = params.name {
|
||||
@@ -123,21 +128,18 @@ pub async fn search_patients(
|
||||
}
|
||||
|
||||
// 强制执行 allowed_patient_ids 范围
|
||||
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) {
|
||||
if !uuids.is_empty() {
|
||||
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx)
|
||||
&& !uuids.is_empty()
|
||||
{
|
||||
query = query.filter(crate::entity::patient::Column::Id.is_in(uuids));
|
||||
}
|
||||
}
|
||||
|
||||
let limit = params.count.unwrap_or(20).min(100);
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
let patients = query
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
let patients = query.limit(limit).offset(offset).all(&state.db).await?;
|
||||
|
||||
let entries: Vec<serde_json::Value> = patients.iter()
|
||||
let entries: Vec<serde_json::Value> = patients
|
||||
.iter()
|
||||
.map(|p| serde_json::json!({"resource": converter::patient_to_fhir(p)}))
|
||||
.collect();
|
||||
|
||||
@@ -189,47 +191,40 @@ pub async fn search_observations(
|
||||
.map_err(|_| AppError::Validation("Invalid patient id".into()))?;
|
||||
query = query.filter(crate::entity::device_readings::Column::PatientId.eq(uid));
|
||||
}
|
||||
if let Some(ref code) = params.code {
|
||||
if let Some(dt) = loinc_to_device_type(code) {
|
||||
if let Some(ref code) = params.code
|
||||
&& let Some(dt) = loinc_to_device_type(code)
|
||||
{
|
||||
query = query.filter(crate::entity::device_readings::Column::DeviceType.eq(dt));
|
||||
}
|
||||
}
|
||||
if let Some(ref category) = params.category {
|
||||
let types = category_to_device_types(category);
|
||||
if !types.is_empty() {
|
||||
query = query.filter(
|
||||
crate::entity::device_readings::Column::DeviceType.is_in(types)
|
||||
);
|
||||
query = query.filter(crate::entity::device_readings::Column::DeviceType.is_in(types));
|
||||
}
|
||||
}
|
||||
if let Some(ref date) = params.date {
|
||||
if let Some(after) = date.strip_prefix("gt") {
|
||||
if let Ok(dt) = after.parse::<chrono::DateTime<chrono::Utc>>() {
|
||||
query = query.filter(
|
||||
crate::entity::device_readings::Column::MeasuredAt.gt(dt)
|
||||
);
|
||||
query = query.filter(crate::entity::device_readings::Column::MeasuredAt.gt(dt));
|
||||
}
|
||||
} else if let Some(before) = date.strip_prefix("lt") {
|
||||
if let Ok(dt) = before.parse::<chrono::DateTime<chrono::Utc>>() {
|
||||
query = query.filter(
|
||||
crate::entity::device_readings::Column::MeasuredAt.lt(dt)
|
||||
);
|
||||
query = query.filter(crate::entity::device_readings::Column::MeasuredAt.lt(dt));
|
||||
}
|
||||
} else if let Ok(dt) = date.parse::<chrono::DateTime<chrono::Utc>>() {
|
||||
let start = dt.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc();
|
||||
let end = dt.date_naive().and_hms_opt(23, 59, 59).unwrap().and_utc();
|
||||
query = query.filter(
|
||||
crate::entity::device_readings::Column::MeasuredAt.between(start, end)
|
||||
);
|
||||
query = query
|
||||
.filter(crate::entity::device_readings::Column::MeasuredAt.between(start, end));
|
||||
}
|
||||
}
|
||||
|
||||
// 强制执行 allowed_patient_ids 范围
|
||||
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) {
|
||||
if !uuids.is_empty() {
|
||||
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx)
|
||||
&& !uuids.is_empty()
|
||||
{
|
||||
query = query.filter(crate::entity::device_readings::Column::PatientId.is_in(uuids));
|
||||
}
|
||||
}
|
||||
|
||||
let limit = params.count.unwrap_or(50).min(200);
|
||||
let readings = query
|
||||
@@ -274,16 +269,17 @@ pub async fn search_devices(
|
||||
}
|
||||
|
||||
// 强制执行 allowed_patient_ids 范围
|
||||
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) {
|
||||
if !uuids.is_empty() {
|
||||
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx)
|
||||
&& !uuids.is_empty()
|
||||
{
|
||||
query = query.filter(crate::entity::patient_devices::Column::PatientId.is_in(uuids));
|
||||
}
|
||||
}
|
||||
|
||||
let limit = params.count.unwrap_or(50).min(200);
|
||||
let devices = query.limit(limit).all(&state.db).await?;
|
||||
|
||||
let entries: Vec<serde_json::Value> = devices.iter()
|
||||
let entries: Vec<serde_json::Value> = devices
|
||||
.iter()
|
||||
.map(|d| serde_json::json!({"resource": converter::patient_device_to_fhir(d)}))
|
||||
.collect();
|
||||
|
||||
@@ -337,7 +333,8 @@ pub async fn search_practitioners(
|
||||
let limit = params.count.unwrap_or(50).min(200);
|
||||
let doctors = query.limit(limit).all(&state.db).await?;
|
||||
|
||||
let entries: Vec<serde_json::Value> = doctors.iter()
|
||||
let entries: Vec<serde_json::Value> = doctors
|
||||
.iter()
|
||||
.map(|d| serde_json::json!({"resource": converter::doctor_to_fhir(d)}))
|
||||
.collect();
|
||||
|
||||
@@ -392,11 +389,11 @@ pub async fn search_appointments(
|
||||
}
|
||||
|
||||
// 强制执行 allowed_patient_ids 范围
|
||||
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) {
|
||||
if !uuids.is_empty() {
|
||||
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx)
|
||||
&& !uuids.is_empty()
|
||||
{
|
||||
query = query.filter(crate::entity::appointment::Column::PatientId.is_in(uuids));
|
||||
}
|
||||
}
|
||||
|
||||
let limit = params.count.unwrap_or(50).min(200);
|
||||
let appointments = query
|
||||
@@ -405,7 +402,8 @@ pub async fn search_appointments(
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let entries: Vec<serde_json::Value> = appointments.iter()
|
||||
let entries: Vec<serde_json::Value> = appointments
|
||||
.iter()
|
||||
.map(|a| serde_json::json!({"resource": converter::appointment_to_fhir(a)}))
|
||||
.collect();
|
||||
|
||||
@@ -466,11 +464,11 @@ pub async fn search_diagnostic_reports(
|
||||
}
|
||||
|
||||
// 强制执行 allowed_patient_ids 范围
|
||||
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) {
|
||||
if !uuids.is_empty() {
|
||||
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx)
|
||||
&& !uuids.is_empty()
|
||||
{
|
||||
query = query.filter(crate::entity::lab_report::Column::PatientId.is_in(uuids));
|
||||
}
|
||||
}
|
||||
|
||||
let limit = params.count.unwrap_or(50).min(200);
|
||||
let reports = query
|
||||
@@ -479,7 +477,8 @@ pub async fn search_diagnostic_reports(
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let entries: Vec<serde_json::Value> = reports.iter()
|
||||
let entries: Vec<serde_json::Value> = reports
|
||||
.iter()
|
||||
.map(|r| serde_json::json!({"resource": converter::lab_report_to_fhir(r)}))
|
||||
.collect();
|
||||
|
||||
@@ -537,11 +536,11 @@ pub async fn search_encounters(
|
||||
}
|
||||
|
||||
// 强制执行 allowed_patient_ids 范围
|
||||
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) {
|
||||
if !uuids.is_empty() {
|
||||
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx)
|
||||
&& !uuids.is_empty()
|
||||
{
|
||||
query = query.filter(crate::entity::consultation_session::Column::PatientId.is_in(uuids));
|
||||
}
|
||||
}
|
||||
|
||||
let limit = params.count.unwrap_or(50).min(200);
|
||||
let sessions = query
|
||||
@@ -550,7 +549,8 @@ pub async fn search_encounters(
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let entries: Vec<serde_json::Value> = sessions.iter()
|
||||
let entries: Vec<serde_json::Value> = sessions
|
||||
.iter()
|
||||
.map(|s| serde_json::json!({"resource": converter::consultation_to_fhir(s)}))
|
||||
.collect();
|
||||
|
||||
@@ -608,11 +608,11 @@ pub async fn search_tasks(
|
||||
}
|
||||
|
||||
// 强制执行 allowed_patient_ids 范围
|
||||
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) {
|
||||
if !uuids.is_empty() {
|
||||
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx)
|
||||
&& !uuids.is_empty()
|
||||
{
|
||||
query = query.filter(crate::entity::follow_up_task::Column::PatientId.is_in(uuids));
|
||||
}
|
||||
}
|
||||
|
||||
let limit = params.count.unwrap_or(50).min(200);
|
||||
let tasks = query
|
||||
@@ -621,7 +621,8 @@ pub async fn search_tasks(
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let entries: Vec<serde_json::Value> = tasks.iter()
|
||||
let entries: Vec<serde_json::Value> = tasks
|
||||
.iter()
|
||||
.map(|t| serde_json::json!({"resource": converter::follow_up_to_fhir(t)}))
|
||||
.collect();
|
||||
|
||||
@@ -815,6 +816,9 @@ mod tests {
|
||||
..default_fhir_ctx()
|
||||
};
|
||||
let result = enforce_patient_scope(&fhir_ctx, Uuid::now_v7());
|
||||
assert!(result.is_err(), "Patient not in allowed list should be denied");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Patient not in allowed list should be denied"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,12 @@ pub fn loinc_to_device_type(loinc: &str) -> Option<&'static str> {
|
||||
/// FHIR category → device_type 列表
|
||||
pub fn category_to_device_types(category: &str) -> Vec<&'static str> {
|
||||
match category {
|
||||
"vital-signs" => vec!["heart_rate", "blood_oxygen", "blood_pressure", "temperature"],
|
||||
"vital-signs" => vec![
|
||||
"heart_rate",
|
||||
"blood_oxygen",
|
||||
"blood_pressure",
|
||||
"temperature",
|
||||
],
|
||||
"laboratory" => vec!["blood_glucose"],
|
||||
"activity" => vec!["steps", "sleep", "stress"],
|
||||
_ => vec![],
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Request, State},
|
||||
http::StatusCode,
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
use sea_orm::ColumnTrait;
|
||||
use sea_orm::EntityTrait;
|
||||
use sea_orm::QueryFilter;
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::state::HealthState;
|
||||
|
||||
@@ -94,14 +94,13 @@ fn extract_gateway_key(request: &Request) -> Option<String> {
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
&& let Some(key) = auth.strip_prefix("Gateway ")
|
||||
{
|
||||
if let Some(key) = auth.strip_prefix("Gateway ") {
|
||||
let key = key.trim();
|
||||
if !key.is_empty() {
|
||||
return Some(key.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// X-Gateway-Key: <key>
|
||||
if let Some(key) = request
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use axum::extract::{FromRef, Json, Path, Query, State};
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Json, Path, Query, State};
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
@@ -82,8 +82,7 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.action-inbox.team")?;
|
||||
let result =
|
||||
action_inbox_service::get_team_overview(&state.db, ctx.tenant_id).await?;
|
||||
let result = action_inbox_service::get_team_overview(&state.db, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Path, Query, State};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Extension;
|
||||
use serde::Deserialize;
|
||||
use utoipa::IntoParams;
|
||||
use uuid::Uuid;
|
||||
@@ -36,9 +36,15 @@ where
|
||||
let page_size = query.page_size.unwrap_or(20);
|
||||
|
||||
let (items, total) = alert_service::list_alerts(
|
||||
&state, ctx.tenant_id, query.patient_id, query.doctor_id, query.status.as_deref(),
|
||||
page, page_size,
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
query.patient_id,
|
||||
query.doctor_id,
|
||||
query.status.as_deref(),
|
||||
page,
|
||||
page_size,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(axum::Json(ApiResponse::ok(PaginatedResponse {
|
||||
data: items,
|
||||
@@ -74,9 +80,9 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.alerts.manage")?;
|
||||
let alert = alert_service::acknowledge_alert(
|
||||
&state, ctx.tenant_id, id, ctx.user_id, body.version,
|
||||
).await?;
|
||||
let alert =
|
||||
alert_service::acknowledge_alert(&state, ctx.tenant_id, id, ctx.user_id, body.version)
|
||||
.await?;
|
||||
Ok(axum::Json(ApiResponse::ok(alert)))
|
||||
}
|
||||
|
||||
@@ -91,9 +97,8 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.alerts.manage")?;
|
||||
let alert = alert_service::dismiss_alert(
|
||||
&state, ctx.tenant_id, id, ctx.user_id, body.version,
|
||||
).await?;
|
||||
let alert =
|
||||
alert_service::dismiss_alert(&state, ctx.tenant_id, id, ctx.user_id, body.version).await?;
|
||||
Ok(axum::Json(ApiResponse::ok(alert)))
|
||||
}
|
||||
|
||||
@@ -108,8 +113,6 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.alerts.manage")?;
|
||||
let alert = alert_service::resolve_alert(
|
||||
&state, ctx.tenant_id, id, body.version,
|
||||
).await?;
|
||||
let alert = alert_service::resolve_alert(&state, ctx.tenant_id, id, body.version).await?;
|
||||
Ok(axum::Json(ApiResponse::ok(alert)))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Path, Query, State};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Extension;
|
||||
use serde::Deserialize;
|
||||
use utoipa::IntoParams;
|
||||
use uuid::Uuid;
|
||||
@@ -39,8 +39,13 @@ where
|
||||
let page_size = query.page_size.unwrap_or(20);
|
||||
|
||||
let (items, total) = alert_rule_service::list_rules(
|
||||
&state, ctx.tenant_id, query.device_type.as_deref(), page, page_size,
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
query.device_type.as_deref(),
|
||||
page,
|
||||
page_size,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(axum::Json(ApiResponse::ok(PaginatedResponse {
|
||||
data: items,
|
||||
@@ -62,9 +67,7 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.alert-rules.manage")?;
|
||||
body.sanitize();
|
||||
let rule = alert_rule_service::create_rule(
|
||||
&state, ctx.tenant_id, ctx.user_id, body,
|
||||
).await?;
|
||||
let rule = alert_rule_service::create_rule(&state, ctx.tenant_id, ctx.user_id, body).await?;
|
||||
Ok(axum::Json(ApiResponse::ok(rule)))
|
||||
}
|
||||
|
||||
@@ -80,9 +83,8 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.alert-rules.manage")?;
|
||||
body.sanitize();
|
||||
let rule = alert_rule_service::update_rule(
|
||||
&state, ctx.tenant_id, id, ctx.user_id, body,
|
||||
).await?;
|
||||
let rule =
|
||||
alert_rule_service::update_rule(&state, ctx.tenant_id, id, ctx.user_id, body).await?;
|
||||
Ok(axum::Json(ApiResponse::ok(rule)))
|
||||
}
|
||||
|
||||
@@ -97,8 +99,6 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.alert-rules.manage")?;
|
||||
let rule = alert_rule_service::deactivate_rule(
|
||||
&state, ctx.tenant_id, id, body.version,
|
||||
).await?;
|
||||
let rule = alert_rule_service::deactivate_rule(&state, ctx.tenant_id, id, body.version).await?;
|
||||
Ok(axum::Json(ApiResponse::ok(rule)))
|
||||
}
|
||||
|
||||
@@ -64,8 +64,14 @@ where
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = appointment_service::list_appointments(
|
||||
&state, ctx.tenant_id, page, page_size, params.status, params.patient_id,
|
||||
params.doctor_id, params.date,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
page,
|
||||
page_size,
|
||||
params.status,
|
||||
params.patient_id,
|
||||
params.doctor_id,
|
||||
params.date,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -83,9 +89,8 @@ where
|
||||
require_permission(&ctx, "health.appointment.manage")?;
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = appointment_service::create_appointment(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req,
|
||||
)
|
||||
let result =
|
||||
appointment_service::create_appointment(&state, ctx.tenant_id, Some(ctx.user_id), req)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
@@ -121,7 +126,12 @@ where
|
||||
};
|
||||
update_req.sanitize();
|
||||
let result = appointment_service::update_appointment_status(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), update_req, req.version,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
id,
|
||||
Some(ctx.user_id),
|
||||
update_req,
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -140,7 +150,12 @@ where
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = appointment_service::list_schedules(
|
||||
&state, ctx.tenant_id, page, page_size, params.doctor_id, params.date,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
page,
|
||||
page_size,
|
||||
params.doctor_id,
|
||||
params.date,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -156,10 +171,8 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.appointment.manage")?;
|
||||
let result = appointment_service::create_schedule(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req,
|
||||
)
|
||||
.await?;
|
||||
let result =
|
||||
appointment_service::create_schedule(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -175,7 +188,12 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.appointment.manage")?;
|
||||
let result = appointment_service::update_schedule(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), req.data, req.version,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
id,
|
||||
Some(ctx.user_id),
|
||||
req.data,
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -192,7 +210,11 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.appointment.list")?;
|
||||
let result = appointment_service::calendar_view(
|
||||
&state, ctx.tenant_id, params.start_date, params.end_date, params.doctor_id,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
params.start_date,
|
||||
params.end_date,
|
||||
params.doctor_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
|
||||
@@ -34,9 +34,9 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.articles.manage")?;
|
||||
req.sanitize();
|
||||
let result = article_category_service::create_category(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req.0,
|
||||
).await?;
|
||||
let result =
|
||||
article_category_service::create_category(&state, ctx.tenant_id, Some(ctx.user_id), req.0)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -53,8 +53,13 @@ where
|
||||
require_permission(&ctx, "health.articles.manage")?;
|
||||
req.sanitize();
|
||||
let result = article_category_service::update_category(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), req.0,
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
id,
|
||||
Some(ctx.user_id),
|
||||
req.0,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -75,7 +80,12 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.articles.manage")?;
|
||||
article_category_service::delete_category(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version,
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
id,
|
||||
Some(ctx.user_id),
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ use erp_core::error::AppError;
|
||||
use erp_core::rbac::{require_any_permission, require_permission};
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::dto::article_dto::{ArticleListItem, ArticleListParams, ArticleResp, CreateArticleReq, ReviewArticleReq, UpdateArticleReq};
|
||||
use crate::dto::article_dto::{
|
||||
ArticleListItem, ArticleListParams, ArticleResp, CreateArticleReq, ReviewArticleReq,
|
||||
UpdateArticleReq,
|
||||
};
|
||||
use crate::service::article_service;
|
||||
use crate::state::HealthState;
|
||||
|
||||
@@ -22,14 +25,24 @@ where
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
// 非管理权限用户只能查看已发布文章,防止草稿泄露
|
||||
let status = if require_any_permission(&ctx, &["health.articles.manage", "health.articles.review"]).is_ok() {
|
||||
let status =
|
||||
if require_any_permission(&ctx, &["health.articles.manage", "health.articles.review"])
|
||||
.is_ok()
|
||||
{
|
||||
params.status
|
||||
} else {
|
||||
Some("published".to_string())
|
||||
};
|
||||
let result = article_service::list_articles(
|
||||
&state, ctx.tenant_id, page, page_size,
|
||||
params.category, status, params.category_id, params.tag_id, params.keyword,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
page,
|
||||
page_size,
|
||||
params.category,
|
||||
status,
|
||||
params.category_id,
|
||||
params.tag_id,
|
||||
params.keyword,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -45,7 +58,8 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.articles.list")?;
|
||||
let is_admin = require_any_permission(&ctx, &["health.articles.manage", "health.articles.review"]).is_ok();
|
||||
let is_admin =
|
||||
require_any_permission(&ctx, &["health.articles.manage", "health.articles.review"]).is_ok();
|
||||
let result = article_service::get_article(&state, ctx.tenant_id, id, is_admin).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
@@ -61,9 +75,8 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.articles.manage")?;
|
||||
req.sanitize();
|
||||
let result = article_service::create_article(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req.0,
|
||||
).await?;
|
||||
let result =
|
||||
article_service::create_article(&state, ctx.tenant_id, Some(ctx.user_id), req.0).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -79,9 +92,9 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.articles.manage")?;
|
||||
req.sanitize();
|
||||
let result = article_service::update_article(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), req.0,
|
||||
).await?;
|
||||
let result =
|
||||
article_service::update_article(&state, ctx.tenant_id, id, Some(ctx.user_id), req.0)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -101,7 +114,8 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.articles.manage")?;
|
||||
article_service::delete_article(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version).await?;
|
||||
article_service::delete_article(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
@@ -126,9 +140,9 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.articles.manage")?;
|
||||
let result = article_service::submit_article(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version,
|
||||
).await?;
|
||||
let result =
|
||||
article_service::submit_article(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -147,8 +161,14 @@ where
|
||||
req.sanitize();
|
||||
let version = req.version.unwrap_or(0);
|
||||
let result = article_service::approve_article(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), req.0, version,
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
id,
|
||||
Some(ctx.user_id),
|
||||
req.0,
|
||||
version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -167,8 +187,14 @@ where
|
||||
req.sanitize();
|
||||
let version = req.version.unwrap_or(0);
|
||||
let result = article_service::reject_article(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), req.0, version,
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
id,
|
||||
Some(ctx.user_id),
|
||||
req.0,
|
||||
version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -185,8 +211,13 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.articles.manage")?;
|
||||
let result = article_service::unpublish_article(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version,
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
id,
|
||||
Some(ctx.user_id),
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -216,7 +247,10 @@ pub async fn list_revisions<S>(
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
Query(params): Query<ListRevisionsQuery>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<crate::dto::article_dto::ArticleRevisionResp>>>, AppError>
|
||||
) -> Result<
|
||||
Json<ApiResponse<PaginatedResponse<crate::dto::article_dto::ArticleRevisionResp>>>,
|
||||
AppError,
|
||||
>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
@@ -224,8 +258,7 @@ where
|
||||
require_permission(&ctx, "health.articles.list")?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = article_service::list_revisions(
|
||||
&state, ctx.tenant_id, id, page, page_size,
|
||||
).await?;
|
||||
let result =
|
||||
article_service::list_revisions(&state, ctx.tenant_id, id, page, page_size).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -34,9 +34,8 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.articles.manage")?;
|
||||
req.sanitize();
|
||||
let result = article_tag_service::create_tag(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req.0,
|
||||
).await?;
|
||||
let result =
|
||||
article_tag_service::create_tag(&state, ctx.tenant_id, Some(ctx.user_id), req.0).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -52,9 +51,9 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.articles.manage")?;
|
||||
req.sanitize();
|
||||
let result = article_tag_service::update_tag(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), req.0,
|
||||
).await?;
|
||||
let result =
|
||||
article_tag_service::update_tag(&state, ctx.tenant_id, id, Some(ctx.user_id), req.0)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -74,8 +73,7 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.articles.manage")?;
|
||||
article_tag_service::delete_tag(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version,
|
||||
).await?;
|
||||
article_tag_service::delete_tag(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use axum::extract::{FromRef, Json, Path, Query, State};
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Json, Path, Query, State};
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::ble_gateway_dto::*;
|
||||
use crate::dto::DeleteWithVersion;
|
||||
use crate::dto::ble_gateway_dto::*;
|
||||
use crate::gateway_auth::GatewayAuthContext;
|
||||
use crate::service::ble_gateway_service;
|
||||
use crate::state::HealthState;
|
||||
@@ -54,8 +54,7 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.ble-gateways.manage")?;
|
||||
let result =
|
||||
ble_gateway_service::create_gateway(&state, ctx.tenant_id, Some(ctx.user_id), body)
|
||||
.await?;
|
||||
ble_gateway_service::create_gateway(&state, ctx.tenant_id, Some(ctx.user_id), body).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -179,13 +178,8 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.ble-gateways.manage")?;
|
||||
let result = ble_gateway_service::batch_bind(
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
gateway_id,
|
||||
Some(ctx.user_id),
|
||||
body,
|
||||
)
|
||||
let result =
|
||||
ble_gateway_service::batch_bind(&state, ctx.tenant_id, gateway_id, Some(ctx.user_id), body)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use axum::extract::{FromRef, Json, Path, Query, State};
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Json, Path, Query, State};
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
@@ -74,13 +74,8 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.care-plan.manage")?;
|
||||
req.data.sanitize();
|
||||
let result = care_plan_service::update_care_plan(
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
plan_id,
|
||||
Some(ctx.user_id),
|
||||
req,
|
||||
)
|
||||
let result =
|
||||
care_plan_service::update_care_plan(&state, ctx.tenant_id, plan_id, Some(ctx.user_id), req)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
@@ -124,13 +119,8 @@ where
|
||||
require_permission(&ctx, "health.care-plan.list")?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = care_plan_service::list_care_plan_items(
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
plan_id,
|
||||
page,
|
||||
page_size,
|
||||
)
|
||||
let result =
|
||||
care_plan_service::list_care_plan_items(&state, ctx.tenant_id, plan_id, page, page_size)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
@@ -222,13 +212,8 @@ where
|
||||
require_permission(&ctx, "health.care-plan.list")?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = care_plan_service::list_care_plan_outcomes(
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
plan_id,
|
||||
page,
|
||||
page_size,
|
||||
)
|
||||
let result =
|
||||
care_plan_service::list_care_plan_outcomes(&state, ctx.tenant_id, plan_id, page, page_size)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Json, Path, Query, State};
|
||||
use serde::Deserialize;
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::dto::consent_dto::*;
|
||||
use crate::service::consent_service;
|
||||
@@ -28,10 +28,8 @@ where
|
||||
require_permission(&ctx, "health.consent.list")?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = consent_service::list_consents(
|
||||
&state, ctx.tenant_id, patient_id, page, page_size,
|
||||
)
|
||||
.await?;
|
||||
let result =
|
||||
consent_service::list_consents(&state, ctx.tenant_id, patient_id, page, page_size).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -47,10 +45,8 @@ where
|
||||
require_permission(&ctx, "health.consent.manage")?;
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = consent_service::grant_consent(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req,
|
||||
)
|
||||
.await?;
|
||||
let result =
|
||||
consent_service::grant_consent(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -67,9 +63,8 @@ where
|
||||
require_permission(&ctx, "health.consent.manage")?;
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = consent_service::revoke_consent(
|
||||
&state, ctx.tenant_id, consent_id, Some(ctx.user_id), req,
|
||||
)
|
||||
let result =
|
||||
consent_service::revoke_consent(&state, ctx.tenant_id, consent_id, Some(ctx.user_id), req)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -60,10 +60,8 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.consultation.manage")?;
|
||||
let result = consultation_service::create_session(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req,
|
||||
)
|
||||
.await?;
|
||||
let result =
|
||||
consultation_service::create_session(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -80,7 +78,12 @@ where
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = consultation_service::list_sessions(
|
||||
&state, ctx.tenant_id, page, page_size, params.status, params.patient_id,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
page,
|
||||
page_size,
|
||||
params.status,
|
||||
params.patient_id,
|
||||
params.doctor_id,
|
||||
)
|
||||
.await?;
|
||||
@@ -115,7 +118,12 @@ where
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = consultation_service::list_messages(
|
||||
&state, ctx.tenant_id, session_id, page, page_size, params.after_id,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
session_id,
|
||||
page,
|
||||
page_size,
|
||||
params.after_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -133,7 +141,11 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.consultation.manage")?;
|
||||
let result = consultation_service::close_session(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
id,
|
||||
Some(ctx.user_id),
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -166,7 +178,12 @@ where
|
||||
};
|
||||
msg_req.sanitize();
|
||||
let result = consultation_service::create_message(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), ctx.user_id, sender_role, msg_req,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
Some(ctx.user_id),
|
||||
ctx.user_id,
|
||||
sender_role,
|
||||
msg_req,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -183,8 +200,13 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.consultation.list")?;
|
||||
let result = consultation_service::export_sessions(
|
||||
&state, ctx.tenant_id, params.status, params.patient_id, params.doctor_id,
|
||||
params.page, params.page_size,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
params.status,
|
||||
params.patient_id,
|
||||
params.doctor_id,
|
||||
params.page,
|
||||
params.page_size,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -219,10 +241,7 @@ where
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?
|
||||
.is_some();
|
||||
let role = if is_doctor { "doctor" } else { "patient" };
|
||||
consultation_service::mark_session_read(
|
||||
&state, ctx.tenant_id, id, ctx.user_id, role,
|
||||
)
|
||||
.await?;
|
||||
consultation_service::mark_session_read(&state, ctx.tenant_id, id, ctx.user_id, role).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
@@ -244,12 +263,13 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.consultation.list")?;
|
||||
let mut result = consultation_service::get_doctor_dashboard(
|
||||
&state, ctx.tenant_id, ctx.user_id,
|
||||
)
|
||||
.await?;
|
||||
let mut result =
|
||||
consultation_service::get_doctor_dashboard(&state, ctx.tenant_id, ctx.user_id).await?;
|
||||
consultation_service::enrich_doctor_dashboard_health(
|
||||
&state, ctx.tenant_id, ctx.user_id, &mut result,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&mut result,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Path, Query, State};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Extension;
|
||||
use serde::Deserialize;
|
||||
use utoipa::IntoParams;
|
||||
use uuid::Uuid;
|
||||
@@ -31,16 +31,19 @@ where
|
||||
let page = query.page.unwrap_or(1);
|
||||
let page_size = query.page_size.unwrap_or(20);
|
||||
|
||||
let (items, total) = critical_alert_service::list_pending_alerts(
|
||||
&state, ctx.tenant_id, page, page_size,
|
||||
)
|
||||
let (items, total) =
|
||||
critical_alert_service::list_pending_alerts(&state, ctx.tenant_id, page, page_size)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, tenant_id = %ctx.tenant_id, "查询危急值告警列表失败");
|
||||
e
|
||||
})?;
|
||||
|
||||
let total_pages = if page_size > 0 { total.div_ceil(page_size) } else { 0 };
|
||||
let total_pages = if page_size > 0 {
|
||||
total.div_ceil(page_size)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Ok(axum::Json(ApiResponse::ok(PaginatedResponse {
|
||||
data: items,
|
||||
@@ -81,13 +84,9 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.critical-alerts.manage")?;
|
||||
critical_alert_service::acknowledge_alert(
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
id,
|
||||
ctx.user_id,
|
||||
body.notes,
|
||||
)
|
||||
critical_alert_service::acknowledge_alert(&state, ctx.tenant_id, id, ctx.user_id, body.notes)
|
||||
.await?;
|
||||
Ok(axum::Json(ApiResponse::ok(serde_json::json!({"message": "告警已确认"}))))
|
||||
Ok(axum::Json(ApiResponse::ok(
|
||||
serde_json::json!({"message": "告警已确认"}),
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Json, Path, State};
|
||||
use serde::Deserialize;
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::service::critical_value_threshold_service;
|
||||
use crate::state::HealthState;
|
||||
@@ -105,7 +105,12 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.critical-value-thresholds.manage")?;
|
||||
critical_value_threshold_service::delete_threshold(&state.db, ctx.tenant_id, id, Some(ctx.user_id))
|
||||
critical_value_threshold_service::delete_threshold(
|
||||
&state.db,
|
||||
ctx.tenant_id,
|
||||
id,
|
||||
Some(ctx.user_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::dto::daily_monitoring_dto::*;
|
||||
use crate::dto::DeleteWithVersion;
|
||||
use crate::dto::daily_monitoring_dto::*;
|
||||
use crate::service::daily_monitoring_service;
|
||||
use crate::state::HealthState;
|
||||
|
||||
@@ -40,7 +40,11 @@ where
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = daily_monitoring_service::list_daily_monitoring(
|
||||
&state, ctx.tenant_id, patient_id, page, page_size,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
patient_id,
|
||||
page,
|
||||
page_size,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -56,10 +60,8 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.daily-monitoring.list")?;
|
||||
let result = daily_monitoring_service::get_daily_monitoring(
|
||||
&state, ctx.tenant_id, record_id,
|
||||
)
|
||||
.await?;
|
||||
let result =
|
||||
daily_monitoring_service::get_daily_monitoring(&state, ctx.tenant_id, record_id).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -76,7 +78,10 @@ where
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = daily_monitoring_service::create_daily_monitoring(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
Some(ctx.user_id),
|
||||
req,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -96,7 +101,12 @@ where
|
||||
let mut data = req.data;
|
||||
data.sanitize();
|
||||
let result = daily_monitoring_service::update_daily_monitoring(
|
||||
&state, ctx.tenant_id, record_id, Some(ctx.user_id), data, req.version,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
record_id,
|
||||
Some(ctx.user_id),
|
||||
data,
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -114,7 +124,11 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.daily-monitoring.manage")?;
|
||||
daily_monitoring_service::delete_daily_monitoring(
|
||||
&state, ctx.tenant_id, record_id, Some(ctx.user_id), req.version,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
record_id,
|
||||
Some(ctx.user_id),
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! 设备管理 API — 设备列表查询与解绑
|
||||
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Path, Query, State};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Extension;
|
||||
use serde::Deserialize;
|
||||
use utoipa::IntoParams;
|
||||
use uuid::Uuid;
|
||||
@@ -72,14 +72,8 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.devices.manage")?;
|
||||
|
||||
let device = device_service::unbind_device(
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
id,
|
||||
ctx.user_id,
|
||||
body.version,
|
||||
)
|
||||
.await?;
|
||||
let device =
|
||||
device_service::unbind_device(&state, ctx.tenant_id, id, ctx.user_id, body.version).await?;
|
||||
|
||||
Ok(axum::Json(ApiResponse::ok(device)))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Path, Query, State};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Extension;
|
||||
use serde::Deserialize;
|
||||
use utoipa::IntoParams;
|
||||
use uuid::Uuid;
|
||||
@@ -44,9 +44,9 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.device-readings.manage")?;
|
||||
let result = device_reading_service::batch_create_readings(
|
||||
&state, ctx.tenant_id, path.patient_id, body,
|
||||
).await?;
|
||||
let result =
|
||||
device_reading_service::batch_create_readings(&state, ctx.tenant_id, path.patient_id, body)
|
||||
.await?;
|
||||
Ok(axum::Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -64,9 +64,15 @@ where
|
||||
let page = query.page.unwrap_or(1);
|
||||
let page_size = query.page_size.unwrap_or(20);
|
||||
let result = device_reading_service::query_device_readings(
|
||||
&state, ctx.tenant_id, path.patient_id,
|
||||
query.device_type.as_deref(), query.hours, page, page_size,
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
path.patient_id,
|
||||
query.device_type.as_deref(),
|
||||
query.hours,
|
||||
page,
|
||||
page_size,
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -85,8 +91,14 @@ where
|
||||
let page_size = query.page_size.unwrap_or(20);
|
||||
let days = query.days.unwrap_or(7);
|
||||
let result = device_reading_service::query_hourly_readings(
|
||||
&state, ctx.tenant_id, path.patient_id,
|
||||
&query.device_type, days, page, page_size,
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
path.patient_id,
|
||||
&query.device_type,
|
||||
days,
|
||||
page,
|
||||
page_size,
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Json, Path, Query, State};
|
||||
use serde::Deserialize;
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::dto::diagnosis_dto::*;
|
||||
use crate::dto::DeleteWithVersion;
|
||||
use crate::dto::diagnosis_dto::*;
|
||||
use crate::service::diagnosis_service;
|
||||
use crate::state::HealthState;
|
||||
|
||||
@@ -29,9 +29,8 @@ where
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = diagnosis_service::list_diagnoses(
|
||||
&state, ctx.tenant_id, patient_id, page, page_size,
|
||||
)
|
||||
let result =
|
||||
diagnosis_service::list_diagnoses(&state, ctx.tenant_id, patient_id, page, page_size)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
@@ -50,7 +49,11 @@ where
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = diagnosis_service::create_diagnosis(
|
||||
&state, ctx.tenant_id, patient_id, Some(ctx.user_id), req,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
patient_id,
|
||||
Some(ctx.user_id),
|
||||
req,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -70,7 +73,12 @@ where
|
||||
let mut data = req.data;
|
||||
data.sanitize();
|
||||
let result = diagnosis_service::update_diagnosis(
|
||||
&state, ctx.tenant_id, diagnosis_id, Some(ctx.user_id), data, req.version,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
diagnosis_id,
|
||||
Some(ctx.user_id),
|
||||
data,
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -88,7 +96,11 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.manage")?;
|
||||
diagnosis_service::delete_diagnosis(
|
||||
&state, ctx.tenant_id, diagnosis_id, Some(ctx.user_id), req.version,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
diagnosis_id,
|
||||
Some(ctx.user_id),
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
|
||||
@@ -8,8 +8,8 @@ use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::dto::doctor_dto::*;
|
||||
use crate::dto::DeleteWithVersion;
|
||||
use crate::dto::doctor_dto::*;
|
||||
use crate::service::doctor_service;
|
||||
use crate::state::HealthState;
|
||||
|
||||
@@ -42,7 +42,13 @@ where
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = doctor_service::list_doctors(
|
||||
&state, ctx.tenant_id, page, page_size, params.search, params.department, params.title,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
page,
|
||||
page_size,
|
||||
params.search,
|
||||
params.department,
|
||||
params.title,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -60,10 +66,8 @@ where
|
||||
require_permission(&ctx, "health.doctor.manage")?;
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = doctor_service::create_doctor(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req,
|
||||
)
|
||||
.await?;
|
||||
let result =
|
||||
doctor_service::create_doctor(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -95,7 +99,12 @@ where
|
||||
let mut data = req.data;
|
||||
data.sanitize();
|
||||
let result = doctor_service::update_doctor(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), data, req.version,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
id,
|
||||
Some(ctx.user_id),
|
||||
data,
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -112,6 +121,7 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.doctor.manage")?;
|
||||
doctor_service::delete_doctor(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version).await?;
|
||||
doctor_service::delete_doctor(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! 家庭成员健康代理 Handler — 同意管理 + 健康摘要查看
|
||||
|
||||
use axum::extract::{Json, Path, Query, State};
|
||||
use axum::Extension;
|
||||
use axum::extract::{Json, Path, Query, State};
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
@@ -27,9 +27,15 @@ pub async fn grant_family_access(
|
||||
) -> Result<Json<ApiResponse<FamilyMemberResp>>, AppError> {
|
||||
require_permission(&ctx, "health.patient.manage")?;
|
||||
let result = family_proxy_service::grant_family_access(
|
||||
&state, ctx.tenant_id, patient_id, family_member_id,
|
||||
Some(ctx.user_id), req, params.version,
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
patient_id,
|
||||
family_member_id,
|
||||
Some(ctx.user_id),
|
||||
req,
|
||||
params.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -42,9 +48,14 @@ pub async fn revoke_family_access(
|
||||
) -> Result<Json<ApiResponse<FamilyMemberResp>>, AppError> {
|
||||
require_permission(&ctx, "health.patient.manage")?;
|
||||
let result = family_proxy_service::revoke_family_access(
|
||||
&state, ctx.tenant_id, patient_id, family_member_id,
|
||||
Some(ctx.user_id), params.version,
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
patient_id,
|
||||
family_member_id,
|
||||
Some(ctx.user_id),
|
||||
params.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -53,9 +64,8 @@ pub async fn list_my_family_patients(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<Vec<FamilyPatientSummaryResp>>>, AppError> {
|
||||
let result = family_proxy_service::list_family_patients(
|
||||
&state, ctx.tenant_id, ctx.user_id,
|
||||
).await?;
|
||||
let result =
|
||||
family_proxy_service::list_family_patients(&state, ctx.tenant_id, ctx.user_id).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -66,8 +76,12 @@ pub async fn get_family_health_summary(
|
||||
Path(patient_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<FamilyHealthSummaryResp>>, AppError> {
|
||||
let result = family_proxy_service::get_family_health_summary(
|
||||
&state, ctx.tenant_id, ctx.user_id, patient_id,
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
patient_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -78,7 +92,11 @@ pub async fn link_family_member_user(
|
||||
Path(family_member_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<FamilyMemberResp>>, AppError> {
|
||||
let result = family_proxy_service::link_family_member_user(
|
||||
&state, ctx.tenant_id, family_member_id, ctx.user_id,
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
family_member_id,
|
||||
ctx.user_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::dto::follow_up_dto::*;
|
||||
use crate::dto::DeleteWithVersion;
|
||||
use crate::dto::follow_up_dto::*;
|
||||
use crate::service::follow_up_service;
|
||||
use crate::state::HealthState;
|
||||
|
||||
@@ -33,9 +33,8 @@ where
|
||||
if req.patient_ids.len() > 100 {
|
||||
return Err(AppError::Validation("单次批量最多 100 条".to_string()));
|
||||
}
|
||||
let result = follow_up_service::batch_create_tasks(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req,
|
||||
)
|
||||
let result =
|
||||
follow_up_service::batch_create_tasks(&state, ctx.tenant_id, Some(ctx.user_id), req)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
@@ -56,9 +55,8 @@ where
|
||||
if req.task_ids.len() > 100 {
|
||||
return Err(AppError::Validation("单次批量最多 100 条".to_string()));
|
||||
}
|
||||
let result = follow_up_service::batch_assign_tasks(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req,
|
||||
)
|
||||
let result =
|
||||
follow_up_service::batch_assign_tasks(&state, ctx.tenant_id, Some(ctx.user_id), req)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
@@ -79,9 +77,8 @@ where
|
||||
if req.task_ids.len() > 100 {
|
||||
return Err(AppError::Validation("单次批量最多 100 条".to_string()));
|
||||
}
|
||||
let result = follow_up_service::batch_complete_tasks(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req,
|
||||
)
|
||||
let result =
|
||||
follow_up_service::batch_complete_tasks(&state, ctx.tenant_id, Some(ctx.user_id), req)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
@@ -123,7 +120,12 @@ where
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = follow_up_service::list_tasks(
|
||||
&state, ctx.tenant_id, page, page_size, params.patient_id, params.assigned_to,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
page,
|
||||
page_size,
|
||||
params.patient_id,
|
||||
params.assigned_to,
|
||||
params.status,
|
||||
)
|
||||
.await?;
|
||||
@@ -156,10 +158,8 @@ where
|
||||
require_permission(&ctx, "health.follow-up.manage")?;
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = follow_up_service::create_task(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req,
|
||||
)
|
||||
.await?;
|
||||
let result =
|
||||
follow_up_service::create_task(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -177,7 +177,12 @@ where
|
||||
let mut data = req.data;
|
||||
data.sanitize();
|
||||
let result = follow_up_service::update_task(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), data, req.version,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
id,
|
||||
Some(ctx.user_id),
|
||||
data,
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -194,7 +199,8 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.follow-up.manage")?;
|
||||
follow_up_service::delete_task(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version).await?;
|
||||
follow_up_service::delete_task(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
@@ -210,14 +216,14 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.follow-up.manage")?;
|
||||
if req.task_id != task_id {
|
||||
return Err(AppError::Validation("路径中的 task_id 与请求体不一致".to_string()));
|
||||
return Err(AppError::Validation(
|
||||
"路径中的 task_id 与请求体不一致".to_string(),
|
||||
));
|
||||
}
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = follow_up_service::create_record(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req,
|
||||
)
|
||||
.await?;
|
||||
let result =
|
||||
follow_up_service::create_record(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -234,7 +240,12 @@ where
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = follow_up_service::list_records(
|
||||
&state, ctx.tenant_id, page, page_size, params.task_id, params.patient_id,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
page,
|
||||
page_size,
|
||||
params.task_id,
|
||||
params.patient_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
|
||||
@@ -8,8 +8,8 @@ use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::dto::follow_up_template_dto::*;
|
||||
use crate::dto::DeleteWithVersion;
|
||||
use crate::dto::follow_up_template_dto::*;
|
||||
use crate::service::follow_up_template_service;
|
||||
use crate::state::HealthState;
|
||||
|
||||
@@ -41,7 +41,12 @@ where
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = follow_up_template_service::list_templates(
|
||||
&state, ctx.tenant_id, page, page_size, params.follow_up_type, params.status,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
page,
|
||||
page_size,
|
||||
params.follow_up_type,
|
||||
params.status,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -73,9 +78,8 @@ where
|
||||
require_permission(&ctx, "health.follow-up-templates.manage")?;
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = follow_up_template_service::create_template(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req,
|
||||
)
|
||||
let result =
|
||||
follow_up_template_service::create_template(&state, ctx.tenant_id, Some(ctx.user_id), req)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
@@ -94,7 +98,12 @@ where
|
||||
let mut data = req.data;
|
||||
data.sanitize();
|
||||
let result = follow_up_template_service::update_template(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), data, req.version,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
id,
|
||||
Some(ctx.user_id),
|
||||
data,
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -112,7 +121,11 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.follow-up-templates.manage")?;
|
||||
follow_up_template_service::delete_template(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
id,
|
||||
Some(ctx.user_id),
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
|
||||
@@ -8,8 +8,8 @@ use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::dto::health_data_dto::*;
|
||||
use crate::dto::DeleteWithVersion;
|
||||
use crate::dto::health_data_dto::*;
|
||||
use crate::service::health_data_service;
|
||||
use crate::service::trend_service;
|
||||
use crate::state::HealthState;
|
||||
@@ -59,9 +59,8 @@ where
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = health_data_service::list_vital_signs(
|
||||
&state, ctx.tenant_id, patient_id, page, page_size,
|
||||
)
|
||||
let result =
|
||||
health_data_service::list_vital_signs(&state, ctx.tenant_id, patient_id, page, page_size)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
@@ -80,7 +79,11 @@ where
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = health_data_service::create_vital_signs(
|
||||
&state, ctx.tenant_id, patient_id, Some(ctx.user_id), req,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
patient_id,
|
||||
Some(ctx.user_id),
|
||||
req,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -100,7 +103,13 @@ where
|
||||
let mut data = req.data;
|
||||
data.sanitize();
|
||||
let result = health_data_service::update_vital_signs(
|
||||
&state, ctx.tenant_id, patient_id, vid, Some(ctx.user_id), data, req.version,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
patient_id,
|
||||
vid,
|
||||
Some(ctx.user_id),
|
||||
data,
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -117,7 +126,14 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.manage")?;
|
||||
health_data_service::delete_vital_signs(&state, ctx.tenant_id, vid, Some(ctx.user_id), req.version).await?;
|
||||
health_data_service::delete_vital_signs(
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
vid,
|
||||
Some(ctx.user_id),
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
@@ -138,9 +154,8 @@ where
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = health_data_service::list_lab_reports(
|
||||
&state, ctx.tenant_id, patient_id, page, page_size,
|
||||
)
|
||||
let result =
|
||||
health_data_service::list_lab_reports(&state, ctx.tenant_id, patient_id, page, page_size)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
@@ -159,7 +174,11 @@ where
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = health_data_service::create_lab_report(
|
||||
&state, ctx.tenant_id, patient_id, Some(ctx.user_id), req,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
patient_id,
|
||||
Some(ctx.user_id),
|
||||
req,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -179,7 +198,13 @@ where
|
||||
let mut data = req.data;
|
||||
data.sanitize();
|
||||
let result = health_data_service::update_lab_report(
|
||||
&state, ctx.tenant_id, _patient_id, rid, Some(ctx.user_id), data, req.version,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
_patient_id,
|
||||
rid,
|
||||
Some(ctx.user_id),
|
||||
data,
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -196,7 +221,14 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.manage")?;
|
||||
health_data_service::delete_lab_report(&state, ctx.tenant_id, rid, Some(ctx.user_id), req.version).await?;
|
||||
health_data_service::delete_lab_report(
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
rid,
|
||||
Some(ctx.user_id),
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
@@ -214,7 +246,13 @@ where
|
||||
let mut data = req.data;
|
||||
data.sanitize();
|
||||
let result = health_data_service::review_lab_report(
|
||||
&state, ctx.tenant_id, _patient_id, rid, ctx.user_id, data, req.version,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
_patient_id,
|
||||
rid,
|
||||
ctx.user_id,
|
||||
data,
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -238,7 +276,11 @@ where
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = health_data_service::list_health_records(
|
||||
&state, ctx.tenant_id, patient_id, page, page_size,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
patient_id,
|
||||
page,
|
||||
page_size,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -258,7 +300,11 @@ where
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = health_data_service::create_health_record(
|
||||
&state, ctx.tenant_id, patient_id, Some(ctx.user_id), req,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
patient_id,
|
||||
Some(ctx.user_id),
|
||||
req,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -278,7 +324,13 @@ where
|
||||
let mut data = req.data;
|
||||
data.sanitize();
|
||||
let result = health_data_service::update_health_record(
|
||||
&state, ctx.tenant_id, patient_id, rid, Some(ctx.user_id), data, req.version,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
patient_id,
|
||||
rid,
|
||||
Some(ctx.user_id),
|
||||
data,
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -295,7 +347,14 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.manage")?;
|
||||
health_data_service::delete_health_record(&state, ctx.tenant_id, rid, Some(ctx.user_id), req.version).await?;
|
||||
health_data_service::delete_health_record(
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
rid,
|
||||
Some(ctx.user_id),
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
@@ -316,10 +375,8 @@ where
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = trend_service::list_trends(
|
||||
&state, ctx.tenant_id, patient_id, page, page_size,
|
||||
)
|
||||
.await?;
|
||||
let result =
|
||||
trend_service::list_trends(&state, ctx.tenant_id, patient_id, page, page_size).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -335,7 +392,12 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.manage")?;
|
||||
let result = trend_service::generate_trend(
|
||||
&state, ctx.tenant_id, patient_id, Some(ctx.user_id), req.period_start, req.period_end,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
patient_id,
|
||||
Some(ctx.user_id),
|
||||
req.period_start,
|
||||
req.period_end,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -353,7 +415,12 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
let result = trend_service::get_indicator_timeseries(
|
||||
&state, ctx.tenant_id, patient_id, indicator, params.start_date, params.end_date,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
patient_id,
|
||||
indicator,
|
||||
params.start_date,
|
||||
params.end_date,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -374,7 +441,11 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
let result = trend_service::get_mini_trend(
|
||||
&state, ctx.tenant_id, ctx.user_id, params.indicator, params.range,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
params.indicator,
|
||||
params.range,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -394,9 +465,8 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
let result = trend_service::get_mini_today(
|
||||
&state, ctx.tenant_id, ctx.user_id, params.patient_id,
|
||||
)
|
||||
let result =
|
||||
trend_service::get_mini_today(&state, ctx.tenant_id, ctx.user_id, params.patient_id)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Json, Path, Query, State};
|
||||
use serde::Deserialize;
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::dto::medication_record_dto::*;
|
||||
use crate::dto::DeleteWithVersion;
|
||||
use crate::dto::medication_record_dto::*;
|
||||
use crate::service::medication_record_service;
|
||||
use crate::state::HealthState;
|
||||
|
||||
@@ -31,7 +31,11 @@ where
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = medication_record_service::list_medications(
|
||||
&state, ctx.tenant_id, patient_id, page, page_size,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
patient_id,
|
||||
page,
|
||||
page_size,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -66,12 +70,8 @@ where
|
||||
require_permission(&ctx, "health.medication-records.manage")?;
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = medication_record_service::create_medication(
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
Some(ctx.user_id),
|
||||
req,
|
||||
)
|
||||
let result =
|
||||
medication_record_service::create_medication(&state, ctx.tenant_id, Some(ctx.user_id), req)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::dto::medication_reminder_dto::{CreateMedicationReminderReq, MedicationReminderResp, UpdateMedicationReminderReq};
|
||||
use crate::dto::medication_reminder_dto::{
|
||||
CreateMedicationReminderReq, MedicationReminderResp, UpdateMedicationReminderReq,
|
||||
};
|
||||
use crate::service::medication_reminder_service;
|
||||
use crate::state::HealthState;
|
||||
|
||||
@@ -28,8 +30,13 @@ where
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = medication_reminder_service::list_reminders(
|
||||
&state, ctx.tenant_id, patient_id, page, page_size,
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
patient_id,
|
||||
page,
|
||||
page_size,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -45,8 +52,12 @@ where
|
||||
require_permission(&ctx, "health.medication-reminders.manage")?;
|
||||
req.sanitize();
|
||||
let result = medication_reminder_service::create_reminder(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req.0,
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
Some(ctx.user_id),
|
||||
req.0,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -71,8 +82,14 @@ where
|
||||
let mut data = req.data;
|
||||
data.sanitize();
|
||||
let result = medication_reminder_service::update_reminder(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version, data,
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
id,
|
||||
Some(ctx.user_id),
|
||||
req.version,
|
||||
data,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -93,7 +110,12 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.medication-reminders.manage")?;
|
||||
medication_reminder_service::delete_reminder(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version,
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
id,
|
||||
Some(ctx.user_id),
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
pub mod action_inbox_handler;
|
||||
pub mod alert_handler;
|
||||
pub mod ble_gateway_handler;
|
||||
pub mod alert_rule_handler;
|
||||
pub mod appointment_handler;
|
||||
pub mod article_category_handler;
|
||||
pub mod article_handler;
|
||||
pub mod article_tag_handler;
|
||||
pub mod ble_gateway_handler;
|
||||
pub mod care_plan_handler;
|
||||
pub mod consultation_handler;
|
||||
pub mod consent_handler;
|
||||
pub mod consultation_handler;
|
||||
pub mod critical_alert_handler;
|
||||
pub mod critical_value_threshold_handler;
|
||||
pub mod daily_monitoring_handler;
|
||||
pub mod device_handler;
|
||||
pub mod device_reading_handler;
|
||||
pub mod diagnosis_handler;
|
||||
pub mod family_proxy_handler;
|
||||
pub mod medication_record_handler;
|
||||
pub mod medication_reminder_handler;
|
||||
pub mod doctor_handler;
|
||||
pub mod family_proxy_handler;
|
||||
pub mod follow_up_handler;
|
||||
pub mod follow_up_template_handler;
|
||||
pub mod health_data_handler;
|
||||
pub mod medication_record_handler;
|
||||
pub mod medication_reminder_handler;
|
||||
pub mod patient_handler;
|
||||
pub mod points_handler;
|
||||
pub mod stats_handler;
|
||||
pub mod shift_handler;
|
||||
pub mod stats_handler;
|
||||
pub mod vital_signs_daily_handler;
|
||||
|
||||
@@ -8,11 +8,11 @@ use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::dto::DeleteWithVersion;
|
||||
use crate::dto::patient_dto::{
|
||||
CreatePatientReq, FamilyMemberReq, FamilyMemberResp, ManageTagsReq, PatientResp,
|
||||
UpdatePatientReq,
|
||||
};
|
||||
use crate::dto::DeleteWithVersion;
|
||||
use crate::service::patient_service;
|
||||
use crate::state::HealthState;
|
||||
|
||||
@@ -44,7 +44,12 @@ where
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = patient_service::list_patients(
|
||||
&state, ctx.tenant_id, page, page_size, params.search, params.tag_id,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
page,
|
||||
page_size,
|
||||
params.search,
|
||||
params.tag_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -62,10 +67,11 @@ where
|
||||
require_permission(&ctx, "health.patient.manage")?;
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = patient_service::create_patient(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req,
|
||||
)
|
||||
.await?;
|
||||
if req.name.trim().is_empty() {
|
||||
return Err(AppError::Validation("患者姓名不能为空".into()));
|
||||
}
|
||||
let result =
|
||||
patient_service::create_patient(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -112,7 +118,12 @@ where
|
||||
};
|
||||
update.sanitize();
|
||||
let result = patient_service::update_patient(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), update, version,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
id,
|
||||
Some(ctx.user_id),
|
||||
update,
|
||||
version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -129,7 +140,8 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.patient.manage")?;
|
||||
patient_service::delete_patient(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version).await?;
|
||||
patient_service::delete_patient(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
@@ -189,9 +201,8 @@ where
|
||||
require_permission(&ctx, "health.patient.manage")?;
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = patient_service::create_family_member(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), req,
|
||||
)
|
||||
let result =
|
||||
patient_service::create_family_member(&state, ctx.tenant_id, id, Some(ctx.user_id), req)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
@@ -217,7 +228,13 @@ where
|
||||
};
|
||||
update.sanitize();
|
||||
let result = patient_service::update_family_member(
|
||||
&state, ctx.tenant_id, _patient_id, member_id, Some(ctx.user_id), update, version,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
_patient_id,
|
||||
member_id,
|
||||
Some(ctx.user_id),
|
||||
update,
|
||||
version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -235,7 +252,12 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.patient.manage")?;
|
||||
patient_service::delete_family_member(
|
||||
&state, ctx.tenant_id, patient_id, member_id, Some(ctx.user_id), req.version,
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
patient_id,
|
||||
member_id,
|
||||
Some(ctx.user_id),
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
@@ -257,7 +279,8 @@ where
|
||||
ctx.tenant_id,
|
||||
id,
|
||||
req.doctor_id,
|
||||
req.relationship_type.unwrap_or_else(|| "primary".to_string()),
|
||||
req.relationship_type
|
||||
.unwrap_or_else(|| "primary".to_string()),
|
||||
Some(ctx.user_id),
|
||||
)
|
||||
.await?;
|
||||
@@ -274,7 +297,14 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.patient.manage")?;
|
||||
patient_service::remove_doctor(&state, ctx.tenant_id, patient_id, doctor_id, Some(ctx.user_id)).await?;
|
||||
patient_service::remove_doctor(
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
patient_id,
|
||||
doctor_id,
|
||||
Some(ctx.user_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
@@ -338,11 +368,16 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.patient.manage")?;
|
||||
let result = patient_service::create_tag(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id),
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
Some(ctx.user_id),
|
||||
patient_service::CreateTagReq {
|
||||
name: req.name, color: req.color, description: req.description,
|
||||
name: req.name,
|
||||
color: req.color,
|
||||
description: req.description,
|
||||
},
|
||||
).await?;
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -366,11 +401,18 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.patient.manage")?;
|
||||
let result = patient_service::update_tag(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id),
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
id,
|
||||
Some(ctx.user_id),
|
||||
patient_service::UpdateTagReq {
|
||||
name: req.name, color: req.color, description: req.description, version: req.version,
|
||||
name: req.name,
|
||||
color: req.color,
|
||||
description: req.description,
|
||||
version: req.version,
|
||||
},
|
||||
).await?;
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -385,8 +427,6 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.patient.manage")?;
|
||||
patient_service::delete_tag(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version,
|
||||
).await?;
|
||||
patient_service::delete_tag(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
@@ -36,9 +36,11 @@ pub async fn get_my_account<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<PointsAccountResp>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
require_permission(&ctx, "health.points.list")?;
|
||||
let patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
|
||||
let result = points_service::get_account(&state, ctx.tenant_id, patient_id).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -48,13 +50,14 @@ pub async fn daily_checkin<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<CheckinStatusResp>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
require_permission(&ctx, "health.points.list")?;
|
||||
let patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
|
||||
let result = points_service::daily_checkin(
|
||||
&state, ctx.tenant_id, patient_id, Some(ctx.user_id),
|
||||
).await?;
|
||||
let result =
|
||||
points_service::daily_checkin(&state, ctx.tenant_id, patient_id, Some(ctx.user_id)).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -62,9 +65,11 @@ pub async fn get_checkin_status<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<CheckinStatusResp>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
require_permission(&ctx, "health.points.list")?;
|
||||
let patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
|
||||
let result = points_service::get_checkin_status(&state, ctx.tenant_id, patient_id).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
@@ -79,15 +84,17 @@ pub async fn list_my_transactions<S>(
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<PointsTransactionResp>>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
require_permission(&ctx, "health.points.list")?;
|
||||
let patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = points_service::list_transactions(
|
||||
&state, ctx.tenant_id, patient_id, page, page_size,
|
||||
).await?;
|
||||
let result =
|
||||
points_service::list_transactions(&state, ctx.tenant_id, patient_id, page, page_size)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -97,14 +104,15 @@ pub async fn list_products<S>(
|
||||
Query(params): Query<ProductTypeParam>,
|
||||
Query(page): Query<PaginationParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<PointsProductResp>>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
require_permission(&ctx, "health.points.list")?;
|
||||
let p = page.page.unwrap_or(1);
|
||||
let ps = page.page_size.unwrap_or(20);
|
||||
let result = points_service::list_products(
|
||||
&state, ctx.tenant_id, params.product_type, p, ps,
|
||||
).await?;
|
||||
let result =
|
||||
points_service::list_products(&state, ctx.tenant_id, params.product_type, p, ps).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -113,9 +121,11 @@ pub async fn get_product<S>(
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(product_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<PointsProductResp>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
require_permission(&ctx, "health.points.list")?;
|
||||
let result = points_service::get_product(&state, ctx.tenant_id, product_id).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
@@ -125,13 +135,15 @@ pub async fn exchange_product<S>(
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<ExchangeReq>,
|
||||
) -> Result<Json<ApiResponse<PointsOrderResp>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.manage")?;
|
||||
require_permission(&ctx, "health.points.manage")?;
|
||||
let patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
|
||||
let result = points_service::exchange_product(
|
||||
&state, ctx.tenant_id, patient_id, req, Some(ctx.user_id),
|
||||
).await?;
|
||||
let result =
|
||||
points_service::exchange_product(&state, ctx.tenant_id, patient_id, req, Some(ctx.user_id))
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -140,15 +152,16 @@ pub async fn list_my_orders<S>(
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<PointsOrderResp>>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
require_permission(&ctx, "health.points.list")?;
|
||||
let patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = points_service::list_orders(
|
||||
&state, ctx.tenant_id, patient_id, page, page_size,
|
||||
).await?;
|
||||
let result =
|
||||
points_service::list_orders(&state, ctx.tenant_id, patient_id, page, page_size).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -161,14 +174,15 @@ pub async fn list_offline_events<S>(
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<OfflineEventResp>>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
require_permission(&ctx, "health.points.list")?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = points_service::list_offline_events(
|
||||
&state, ctx.tenant_id, page, page_size,
|
||||
).await?;
|
||||
let result =
|
||||
points_service::list_offline_events(&state, ctx.tenant_id, page, page_size).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -177,13 +191,20 @@ pub async fn register_event<S>(
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(event_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.manage")?;
|
||||
require_permission(&ctx, "health.points.manage")?;
|
||||
let patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
|
||||
points_service::register_event(
|
||||
&state, ctx.tenant_id, event_id, patient_id, Some(ctx.user_id),
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
event_id,
|
||||
patient_id,
|
||||
Some(ctx.user_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
@@ -196,12 +217,13 @@ pub async fn verify_order<S>(
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<VerifyOrderReq>,
|
||||
) -> Result<Json<ApiResponse<PointsOrderResp>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.manage")?;
|
||||
let result = points_service::verify_order(
|
||||
&state, ctx.tenant_id, req.qr_code, ctx.user_id,
|
||||
).await?;
|
||||
let result =
|
||||
points_service::verify_order(&state, ctx.tenant_id, req.qr_code, ctx.user_id).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -209,7 +231,9 @@ pub async fn list_rules<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<Vec<PointsRuleResp>>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.list")?;
|
||||
let result = points_service::list_rules(&state, ctx.tenant_id).await?;
|
||||
@@ -221,14 +245,14 @@ pub async fn create_rule<S>(
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<CreatePointsRuleReq>,
|
||||
) -> Result<Json<ApiResponse<PointsRuleResp>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.manage")?;
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = points_service::create_rule(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req,
|
||||
).await?;
|
||||
let result = points_service::create_rule(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -238,14 +262,22 @@ pub async fn update_rule<S>(
|
||||
Path(rule_id): Path<Uuid>,
|
||||
Json(wrapper): Json<crate::dto::points_dto::UpdateRuleWithVersion>,
|
||||
) -> Result<Json<ApiResponse<PointsRuleResp>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.manage")?;
|
||||
let mut data = wrapper.data;
|
||||
data.sanitize();
|
||||
let result = points_service::update_rule(
|
||||
&state, ctx.tenant_id, rule_id, Some(ctx.user_id), data, wrapper.version,
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
rule_id,
|
||||
Some(ctx.user_id),
|
||||
data,
|
||||
wrapper.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -255,12 +287,19 @@ pub async fn delete_rule<S>(
|
||||
Path(rule_id): Path<Uuid>,
|
||||
Json(wrapper): Json<crate::dto::DeleteWithVersion>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.manage")?;
|
||||
points_service::delete_rule(
|
||||
&state, ctx.tenant_id, rule_id, Some(ctx.user_id), wrapper.version,
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
rule_id,
|
||||
Some(ctx.user_id),
|
||||
wrapper.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
@@ -271,14 +310,22 @@ pub async fn admin_list_products<S>(
|
||||
Query(page): Query<PaginationParams>,
|
||||
Query(filter): Query<AdminProductFilter>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<PointsProductResp>>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.list")?;
|
||||
let p = page.page.unwrap_or(1);
|
||||
let ps = page.page_size.unwrap_or(20);
|
||||
let result = points_service::admin_list_products(
|
||||
&state, ctx.tenant_id, params.product_type, filter.is_active, p, ps,
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
params.product_type,
|
||||
filter.is_active,
|
||||
p,
|
||||
ps,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -287,14 +334,15 @@ pub async fn admin_create_product<S>(
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<CreatePointsProductReq>,
|
||||
) -> Result<Json<ApiResponse<PointsProductResp>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.manage")?;
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = points_service::create_product(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req,
|
||||
).await?;
|
||||
let result =
|
||||
points_service::create_product(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -304,14 +352,22 @@ pub async fn admin_update_product<S>(
|
||||
Path(product_id): Path<Uuid>,
|
||||
Json(wrapper): Json<crate::dto::points_dto::UpdateProductWithVersion>,
|
||||
) -> Result<Json<ApiResponse<PointsProductResp>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.manage")?;
|
||||
let mut data = wrapper.data;
|
||||
data.sanitize();
|
||||
let result = points_service::update_product(
|
||||
&state, ctx.tenant_id, product_id, Some(ctx.user_id), data, wrapper.version,
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
product_id,
|
||||
Some(ctx.user_id),
|
||||
data,
|
||||
wrapper.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -321,12 +377,19 @@ pub async fn admin_delete_product<S>(
|
||||
Path(product_id): Path<Uuid>,
|
||||
Json(wrapper): Json<crate::dto::DeleteWithVersion>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.manage")?;
|
||||
points_service::delete_product(
|
||||
&state, ctx.tenant_id, product_id, Some(ctx.user_id), wrapper.version,
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
product_id,
|
||||
Some(ctx.user_id),
|
||||
wrapper.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
@@ -335,15 +398,15 @@ pub async fn admin_list_orders<S>(
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<PointsOrderResp>>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.list")?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
// 管理端查看所有订单 — 不按 patient_id 过滤
|
||||
let result = points_service::admin_list_orders(
|
||||
&state, ctx.tenant_id, page, page_size,
|
||||
).await?;
|
||||
let result = points_service::admin_list_orders(&state, ctx.tenant_id, page, page_size).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -356,14 +419,15 @@ pub async fn admin_create_event<S>(
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<CreateOfflineEventReq>,
|
||||
) -> Result<Json<ApiResponse<OfflineEventResp>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.manage")?;
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = points_service::create_offline_event(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req,
|
||||
).await?;
|
||||
let result =
|
||||
points_service::create_offline_event(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -373,14 +437,22 @@ pub async fn admin_update_event<S>(
|
||||
Path(event_id): Path<Uuid>,
|
||||
Json(wrapper): Json<UpdateOfflineEventWithVersion>,
|
||||
) -> Result<Json<ApiResponse<OfflineEventResp>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.manage")?;
|
||||
let mut data = wrapper.data;
|
||||
data.sanitize();
|
||||
let result = points_service::update_offline_event(
|
||||
&state, ctx.tenant_id, event_id, Some(ctx.user_id), data, wrapper.version,
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
event_id,
|
||||
Some(ctx.user_id),
|
||||
data,
|
||||
wrapper.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -390,12 +462,19 @@ pub async fn admin_delete_event<S>(
|
||||
Path(event_id): Path<Uuid>,
|
||||
Json(wrapper): Json<crate::dto::DeleteWithVersion>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.manage")?;
|
||||
points_service::delete_offline_event(
|
||||
&state, ctx.tenant_id, event_id, Some(ctx.user_id), wrapper.version,
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
event_id,
|
||||
Some(ctx.user_id),
|
||||
wrapper.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
@@ -411,14 +490,21 @@ pub async fn admin_list_events<S>(
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<AdminListEventsParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<OfflineEventResp>>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.list")?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = points_service::admin_list_offline_events(
|
||||
&state, ctx.tenant_id, params.status, page, page_size,
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
params.status,
|
||||
page,
|
||||
page_size,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -428,12 +514,19 @@ pub async fn admin_checkin_event<S>(
|
||||
Path(event_id): Path<Uuid>,
|
||||
Json(req): Json<AdminCheckinReq>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.manage")?;
|
||||
points_service::admin_checkin_event(
|
||||
&state, ctx.tenant_id, event_id, req.patient_id, Some(ctx.user_id),
|
||||
).await?;
|
||||
&state,
|
||||
ctx.tenant_id,
|
||||
event_id,
|
||||
req.patient_id,
|
||||
Some(ctx.user_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
@@ -445,7 +538,9 @@ pub async fn get_points_statistics<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<PointsStatisticsResp>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.list")?;
|
||||
let result = points_service::get_points_statistics(&state, ctx.tenant_id).await?;
|
||||
@@ -461,7 +556,9 @@ pub async fn admin_get_patient_account<S>(
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(patient_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<PointsAccountResp>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.list")?;
|
||||
let result = points_service::get_account(&state, ctx.tenant_id, patient_id).await?;
|
||||
@@ -474,14 +571,16 @@ pub async fn admin_list_patient_transactions<S>(
|
||||
Path(patient_id): Path<Uuid>,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<PointsTransactionResp>>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.list")?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = points_service::list_transactions(
|
||||
&state, ctx.tenant_id, patient_id, page, page_size,
|
||||
).await?;
|
||||
let result =
|
||||
points_service::list_transactions(&state, ctx.tenant_id, patient_id, page, page_size)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user