fix: 修复测试发现的 7 个问题 + 全 workspace clippy 清零
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

功能修复:
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:
iven
2026-05-07 23:43:14 +08:00
parent 786f57c151
commit 6d5a711d2c
323 changed files with 15662 additions and 6603 deletions

10
.lintstagedrc.js Normal file
View 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',
],
};

View File

@@ -49,7 +49,12 @@ mod tests {
#[test] #[test]
fn provider_type_all_variants() { 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 json = serde_json::to_string(&pt).unwrap();
let back: ProviderType = serde_json::from_str(&json).unwrap(); let back: ProviderType = serde_json::from_str(&json).unwrap();
assert_eq!(back, pt); assert_eq!(back, pt);

View File

@@ -121,10 +121,19 @@ mod tests {
#[test] #[test]
fn analysis_type_prompt_name() { 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::Trends.prompt_name(), "health_trend_analysis");
assert_eq!(AnalysisType::CheckupPlan.prompt_name(), "personalized_checkup_plan"); assert_eq!(
assert_eq!(AnalysisType::ReportSummary.prompt_name(), "report_summary_generation"); AnalysisType::CheckupPlan.prompt_name(),
"personalized_checkup_plan"
);
assert_eq!(
AnalysisType::ReportSummary.prompt_name(),
"report_summary_generation"
);
} }
// ---- AnalysisType serde round-trip ---- // ---- AnalysisType serde round-trip ----
@@ -195,7 +204,10 @@ mod tests {
let json = serde_json::to_string(&event).unwrap(); let json = serde_json::to_string(&event).unwrap();
let back: AnalysisSseEvent = serde_json::from_str(&json).unwrap(); let back: AnalysisSseEvent = serde_json::from_str(&json).unwrap();
match back { match back {
AnalysisSseEvent::Done { analysis_id, status } => { AnalysisSseEvent::Done {
analysis_id,
status,
} => {
assert_eq!(analysis_id, id); assert_eq!(analysis_id, id);
assert_eq!(status, "completed"); assert_eq!(status, "completed");
} }

View File

@@ -33,7 +33,10 @@ pub enum AiError {
DbError(String), DbError(String),
#[error("AI 配额已耗尽: {reason}")] #[error("AI 配额已耗尽: {reason}")]
QuotaExhausted { tenant_id: uuid::Uuid, reason: String }, QuotaExhausted {
tenant_id: uuid::Uuid,
reason: String,
},
#[error("缓存错误: {0}")] #[error("缓存错误: {0}")]
CacheError(String), CacheError(String),
@@ -54,9 +57,7 @@ impl From<AiError> for AppError {
AiError::Validation(msg) => AppError::Validation(msg), AiError::Validation(msg) => AppError::Validation(msg),
AiError::AnalysisNotFound(id) => AppError::NotFound(format!("分析结果: {id}")), AiError::AnalysisNotFound(id) => AppError::NotFound(format!("分析结果: {id}")),
AiError::PromptNotFound(name) => AppError::NotFound(format!("Prompt 模板: {name}")), AiError::PromptNotFound(name) => AppError::NotFound(format!("Prompt 模板: {name}")),
AiError::ProviderUnavailable(p) => { AiError::ProviderUnavailable(p) => AppError::Internal(format!("AI 提供商 {p} 不可用")),
AppError::Internal(format!("AI 提供商 {p} 不可用"))
}
AiError::RateLimitExceeded => AppError::TooManyRequests, AiError::RateLimitExceeded => AppError::TooManyRequests,
AiError::QuotaExhausted { .. } => AppError::TooManyRequests, AiError::QuotaExhausted { .. } => AppError::TooManyRequests,
AiError::VersionMismatch => AppError::VersionMismatch, AiError::VersionMismatch => AppError::VersionMismatch,
@@ -153,7 +154,7 @@ mod tests {
let err = AiError::RateLimitExceeded; let err = AiError::RateLimitExceeded;
let app: AppError = err.into(); let app: AppError = err.into();
match app { match app {
AppError::TooManyRequests => {}, AppError::TooManyRequests => {}
other => panic!("期望 AppError::TooManyRequests得到 {:?}", other), other => panic!("期望 AppError::TooManyRequests得到 {:?}", other),
} }
} }
@@ -163,7 +164,7 @@ mod tests {
let err = AiError::VersionMismatch; let err = AiError::VersionMismatch;
let app: AppError = err.into(); let app: AppError = err.into();
match app { match app {
AppError::VersionMismatch => {}, AppError::VersionMismatch => {}
other => panic!("期望 AppError::VersionMismatch得到 {:?}", other), other => panic!("期望 AppError::VersionMismatch得到 {:?}", other),
} }
} }

View File

@@ -1,6 +1,6 @@
use axum::Json;
use axum::extract::{Extension, FromRef, Path, Query, State}; use axum::extract::{Extension, FromRef, Path, Query, State};
use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::sse::{Event, KeepAlive, Sse};
use axum::Json;
use erp_core::health_provider::TimeRange; use erp_core::health_provider::TimeRange;
use erp_core::rbac::require_permission; use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext}; use erp_core::types::{ApiResponse, TenantContext};
@@ -34,9 +34,9 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "ai.analysis.manage")?; require_permission(&ctx, "ai.analysis.manage")?;
let report_id = body.report_id.ok_or_else(|| { let report_id = body
erp_core::error::AppError::Validation("report_id 必填".into()) .report_id
})?; .ok_or_else(|| erp_core::error::AppError::Validation("report_id 必填".into()))?;
let lab_dto = state let lab_dto = state
.health_provider .health_provider
@@ -57,7 +57,10 @@ where
.await?; .await?;
let model_config = &prompt.model_config; 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 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; 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 patient_id_clone = uuid::Uuid::nil(); // lab report 场景 patient_id 从 report 关联
let doctor_id_clone = ctx.user_id; 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())) Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
} }
@@ -97,9 +108,9 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "ai.analysis.manage")?; require_permission(&ctx, "ai.analysis.manage")?;
let patient_id = body.patient_id.ok_or_else(|| { let patient_id = body
erp_core::error::AppError::Validation("patient_id 必填".into()) .patient_id
})?; .ok_or_else(|| erp_core::error::AppError::Validation("patient_id 必填".into()))?;
let metrics = body.metrics.unwrap_or_else(|| { let metrics = body.metrics.unwrap_or_else(|| {
vec![ 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 let prompt = state
.prompt .prompt
@@ -134,7 +148,10 @@ where
.await?; .await?;
let model_config = &prompt.model_config; 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 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; 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 analysis_id_clone = analysis_id;
let state_clone = state.clone(); 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())) Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
} }
@@ -172,9 +197,9 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "ai.analysis.manage")?; require_permission(&ctx, "ai.analysis.manage")?;
let patient_id = body.patient_id.ok_or_else(|| { let patient_id = body
erp_core::error::AppError::Validation("patient_id 必填".into()) .patient_id
})?; .ok_or_else(|| erp_core::error::AppError::Validation("patient_id 必填".into()))?;
let summary_dto = state let summary_dto = state
.health_provider .health_provider
@@ -191,7 +216,10 @@ where
.await?; .await?;
let model_config = &prompt.model_config; 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 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; 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 analysis_id_clone = analysis_id;
let state_clone = state.clone(); 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())) Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
} }
@@ -229,9 +265,9 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "ai.analysis.manage")?; require_permission(&ctx, "ai.analysis.manage")?;
let report_id = body.report_id.ok_or_else(|| { let report_id = body
erp_core::error::AppError::Validation("report_id 必填".into()) .report_id
})?; .ok_or_else(|| erp_core::error::AppError::Validation("report_id 必填".into()))?;
let report_dto = state let report_dto = state
.health_provider .health_provider
@@ -255,7 +291,10 @@ where
.await?; .await?;
let model_config = &prompt.model_config; 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 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; 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 analysis_id_clone = analysis_id;
let state_clone = state.clone(); 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())) Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
} }
@@ -309,7 +356,12 @@ where
}; };
let (items, total) = state let (items, total) = state
.analysis .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?; .await?;
// 批量查询 patient_name通过 raw SQL 避免跨 crate 依赖 erp-health // 批量查询 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() { let patient_names: std::collections::HashMap<uuid::Uuid, String> = if !patient_ids.is_empty() {
#[derive(sea_orm::FromQueryResult)] #[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(); let ids: Vec<uuid::Uuid> = patient_ids.into_iter().collect();
use sea_orm::FromQueryResult; use sea_orm::FromQueryResult;
PatientName::find_by_statement(sea_orm::Statement::from_sql_and_values( PatientName::find_by_statement(sea_orm::Statement::from_sql_and_values(
@@ -339,15 +394,19 @@ where
std::collections::HashMap::new() std::collections::HashMap::new()
}; };
let data: Vec<serde_json::Value> = items.into_iter().map(|a| { let data: Vec<serde_json::Value> = items
let mut val = serde_json::to_value(&a).unwrap_or_default(); .into_iter()
if let Some(obj) = val.as_object_mut() { .map(|a| {
obj.insert("patient_name".to_string(), serde_json::json!( let mut val = serde_json::to_value(&a).unwrap_or_default();
patient_names.get(&a.patient_id).cloned() if let Some(obj) = val.as_object_mut() {
)); obj.insert(
} "patient_name".to_string(),
val serde_json::json!(patient_names.get(&a.patient_id).cloned()),
}).collect(); );
}
val
})
.collect();
Ok(Json(ApiResponse::ok(serde_json::json!({ Ok(Json(ApiResponse::ok(serde_json::json!({
"data": data, "data": data,
@@ -549,7 +608,9 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "ai.analysis.list")?; 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>( pub async fn quota_summary<S>(
@@ -570,7 +631,10 @@ where
pub async fn assess_dialysis_risk<S>( pub async fn assess_dialysis_risk<S>(
Extension(ctx): Extension<TenantContext>, Extension(ctx): Extension<TenantContext>,
Json(body): Json<crate::service::dialysis_risk_scorer::DialysisLabInput>, 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 where
AiState: FromRef<S>, AiState: FromRef<S>,
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
@@ -612,7 +676,9 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "ai.usage.list")?; 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(&params.analysis_type, &model); let estimate = crate::service::cost::CostService::estimate_cost(&params.analysis_type, &model);
Ok(Json(ApiResponse::ok(estimate))) 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(); let lower = content.to_lowercase();
for pattern in &suspicious { for pattern in &suspicious {
if lower.contains(pattern) { if lower.contains(pattern) {
return Err(erp_core::error::AppError::Validation( return Err(erp_core::error::AppError::Validation(format!(
format!("提示词内容包含不安全模式: {}", pattern), "提示词内容包含不安全模式: {}",
)); pattern
)));
} }
} }
Ok(()) Ok(())

View File

@@ -1,5 +1,5 @@
use axum::extract::{Extension, FromRef, Path, State};
use axum::Json; use axum::Json;
use axum::extract::{Extension, FromRef, Path, State};
use erp_core::rbac::require_permission; use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext}; use erp_core::types::{ApiResponse, TenantContext};
use serde::Deserialize; use serde::Deserialize;
@@ -26,19 +26,14 @@ where
require_permission(&ctx, "ai.suggestion.list")?; require_permission(&ctx, "ai.suggestion.list")?;
if let Some(analysis_id) = params.analysis_id { if let Some(analysis_id) = params.analysis_id {
let items = SuggestionService::list_by_analysis( let items =
&state.db, SuggestionService::list_by_analysis(&state.db, ctx.tenant_id, analysis_id).await?;
ctx.tenant_id,
analysis_id,
)
.await?;
Ok(Json(ApiResponse::ok(serde_json::json!({ Ok(Json(ApiResponse::ok(serde_json::json!({
"data": items, "data": items,
"total": items.len(), "total": items.len(),
})))) }))))
} else { } else {
let items = let items = SuggestionService::list_pending(&state.db, ctx.tenant_id).await?;
SuggestionService::list_pending(&state.db, ctx.tenant_id).await?;
Ok(Json(ApiResponse::ok(serde_json::json!({ Ok(Json(ApiResponse::ok(serde_json::json!({
"data": items, "data": items,
"total": items.len(), "total": items.len(),
@@ -69,7 +64,7 @@ where
_ => { _ => {
return Err(erp_core::error::AppError::Validation( return Err(erp_core::error::AppError::Validation(
"action 必须为 approve 或 reject".into(), "action 必须为 approve 或 reject".into(),
)) ));
} }
}; };
@@ -151,7 +146,10 @@ where
match &suggestion.baseline_snapshot { match &suggestion.baseline_snapshot {
Some(bs) if !bs.is_null() => { 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!({ Ok(Json(ApiResponse::ok(serde_json::json!({
"suggestion_id": id, "suggestion_id": id,
"baseline": bs, "baseline": bs,

View File

@@ -156,13 +156,16 @@ impl KnowledgeSource for StructuredKnowledgeSource {
async fn health_check(&self) -> AiResult<bool> { async fn health_check(&self) -> AiResult<bool> {
#[derive(Debug, FromQueryResult)] #[derive(Debug, FromQueryResult)]
#[allow(dead_code)]
struct HealthCheck { struct HealthCheck {
#[allow(dead_code)]
ok: i32, ok: i32,
} }
let result: Option<HealthCheck> = HealthCheck::find_by_statement( let result: Option<HealthCheck> = HealthCheck::find_by_statement(Statement::from_string(
Statement::from_string(sea_orm::DatabaseBackend::Postgres, "SELECT 1 AS ok".to_string()), sea_orm::DatabaseBackend::Postgres,
) "SELECT 1 AS ok".to_string(),
))
.one(&self.db) .one(&self.db)
.await .await
.ok() .ok()
@@ -180,13 +183,14 @@ mod tests {
let rules_empty: Vec<ai_knowledge_rules::Model> = vec![]; let rules_empty: Vec<ai_knowledge_rules::Model> = vec![];
let refs_empty: Vec<ai_knowledge_references::Model> = vec![]; let refs_empty: Vec<ai_knowledge_references::Model> = vec![];
let guides_empty: Vec<ai_knowledge_guides::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 =
0.0 if rules_empty.is_empty() && refs_empty.is_empty() && guides_empty.is_empty() {
} else if !rules_empty.is_empty() && !refs_empty.is_empty() { 0.0
0.9 } else if !rules_empty.is_empty() && !refs_empty.is_empty() {
} else { 0.9
0.7 } else {
}; 0.7
};
assert!((confidence - 0.0).abs() < 0.01); assert!((confidence - 0.0).abs() < 0.01);
} }

View File

@@ -88,18 +88,28 @@ impl ErpModule for AiModule {
loop { loop {
match rx.recv().await { match rx.recv().await {
Some(event) if event.event_type == "ai.reanalysis.requested" => { 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(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok()); .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(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok()); .and_then(|s| uuid::Uuid::parse_str(s).ok());
match (suggestion_id, patient_id) { match (suggestion_id, patient_id) {
(Some(sid), Some(pid)) => { (Some(sid), Some(pid)) => {
if let Err(e) = crate::service::reanalysis::handle_reanalysis_requested( if let Err(e) =
&db, event.tenant_id, sid, pid, crate::service::reanalysis::handle_reanalysis_requested(
).await { &db,
event.tenant_id,
sid,
pid,
)
.await
{
tracing::warn!( tracing::warn!(
suggestion_id = %sid, suggestion_id = %sid,
error = %e, error = %e,
@@ -114,10 +124,14 @@ impl ErpModule for AiModule {
} }
Some(event) if event.event_type == "ai.analysis.requested" => { 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_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(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok()); .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(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok()); .and_then(|s| uuid::Uuid::parse_str(s).ok());
@@ -131,10 +145,14 @@ impl ErpModule for AiModule {
} }
// H4: 透析记录→KDIGO 自动风险评估 // H4: 透析记录→KDIGO 自动风险评估
Some(event) if event.event_type == "ai.dialysis.kdigo_requested" => { 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(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok()); .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()); .and_then(|v| v.as_str());
tracing::info!( tracing::info!(

View File

@@ -8,6 +8,12 @@ pub struct PromptRenderer {
registry: Handlebars<'static>, registry: Handlebars<'static>,
} }
impl Default for PromptRenderer {
fn default() -> Self {
Self::new()
}
}
impl PromptRenderer { impl PromptRenderer {
pub fn new() -> Self { pub fn new() -> Self {
let mut registry = Handlebars::new(); let mut registry = Handlebars::new();
@@ -43,11 +49,12 @@ mod tests {
#[test] #[test]
fn render_multiple_variables() { fn render_multiple_variables() {
let r = renderer(); let r = renderer();
let result = r.render( let result = r
"{{age_group}} {{sex}} 化验报告", .render(
&json!({"age_group": "中年", "sex": "男性"}), "{{age_group}} {{sex}} 化验报告",
) &json!({"age_group": "中年", "sex": "男性"}),
.unwrap(); )
.unwrap();
assert_eq!(result, "中年 男性 化验报告"); assert_eq!(result, "中年 男性 化验报告");
} }
@@ -57,7 +64,9 @@ mod tests {
let data = json!({ let data = json!({
"report": { "date": "2026-05-01", "department": "内科" } "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"); assert_eq!(result, "科室: 内科,日期: 2026-05-01");
} }
@@ -65,7 +74,9 @@ mod tests {
fn render_with_array_iteration() { fn render_with_array_iteration() {
let r = renderer(); let r = renderer();
let data = json!({"items": ["WBC", "HGB", "PLT"]}); 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, "); assert_eq!(result, "指标: WBC, HGB, PLT, ");
} }
@@ -98,11 +109,12 @@ mod tests {
fn render_with_conditional() { fn render_with_conditional() {
let r = renderer(); let r = renderer();
let data = json!({"is_abnormal": true, "value": "偏高"}); let data = json!({"is_abnormal": true, "value": "偏高"});
let result = r.render( let result = r
"{{#if is_abnormal}}异常: {{value}}{{else}}正常{{/if}}", .render(
&data, "{{#if is_abnormal}}异常: {{value}}{{else}}正常{{/if}}",
) &data,
.unwrap(); )
.unwrap();
assert_eq!(result, "异常: 偏高"); assert_eq!(result, "异常: 偏高");
} }
} }

View File

@@ -129,15 +129,12 @@ impl AiProvider for ClaudeProvider {
if data == "[DONE]" { if data == "[DONE]" {
return; return;
} }
if let Ok(event) = serde_json::from_str::<ClaudeStreamEvent>(data) { if let Ok(event) = serde_json::from_str::<ClaudeStreamEvent>(data)
if event.event_type == "content_block_delta" { && event.event_type == "content_block_delta"
if let Some(delta) = event.delta { && let Some(delta) = event.delta
if let Some(text) = delta.text { && let Some(text) = delta.text {
yield Ok(text); yield Ok(text);
} }
}
}
}
} }
} }
} }
@@ -179,9 +176,7 @@ impl AiProvider for ClaudeProvider {
.map_err(|e| AiError::ProviderError(e.to_string()))?; .map_err(|e| AiError::ProviderError(e.to_string()))?;
if !status.is_success() { if !status.is_success() {
return Err(AiError::ProviderError(format!( return Err(AiError::ProviderError(format!("Claude {status}: {body}")));
"Claude {status}: {body}"
)));
} }
let parsed: serde_json::Value = serde_json::from_str(&body) let parsed: serde_json::Value = serde_json::from_str(&body)

View File

@@ -75,8 +75,10 @@ struct OllamaStreamChunk {
} }
#[derive(Deserialize)] #[derive(Deserialize)]
#[allow(dead_code)]
struct OllamaStreamMessage { struct OllamaStreamMessage {
content: Option<String>, content: Option<String>,
#[allow(dead_code)]
thinking: Option<String>, thinking: Option<String>,
} }
@@ -87,7 +89,10 @@ fn strip_think_block(content: &str) -> String {
if let Some(end) = content.find("</think") { if let Some(end) = content.find("</think") {
// 跳过 </think 标签及其后的 > 或 \n // 跳过 </think 标签及其后的 > 或 \n
let after_tag = &content[end + 7..]; // skip "</think" 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(); return actual.to_string();
} }
content.to_string() content.to_string()
@@ -193,13 +198,11 @@ impl AiProvider for OllamaProvider {
if chunk.done { if chunk.done {
return; return;
} }
if let Some(msg) = chunk.message { if let Some(msg) = chunk.message
if let Some(content) = msg.content { && let Some(content) = msg.content
if !content.is_empty() { && !content.is_empty() {
yield Ok(content); yield Ok(content);
} }
}
}
} }
} }
} }
@@ -252,9 +255,7 @@ impl AiProvider for OllamaProvider {
.map_err(|e| AiError::ProviderError(e.to_string()))?; .map_err(|e| AiError::ProviderError(e.to_string()))?;
if !status.is_success() { if !status.is_success() {
return Err(AiError::ProviderError(format!( return Err(AiError::ProviderError(format!("Ollama {status}: {body}")));
"Ollama {status}: {body}"
)));
} }
let parsed: OllamaChatResponse = serde_json::from_str(&body) let parsed: OllamaChatResponse = serde_json::from_str(&body)
@@ -307,10 +308,7 @@ mod tests {
#[test] #[test]
fn ollama_provider_construction() { fn ollama_provider_construction() {
let provider = OllamaProvider::new( let provider = OllamaProvider::new("http://localhost:11434".into(), "qwen2.5:7b".into());
"http://localhost:11434".into(),
"qwen2.5:7b".into(),
);
assert_eq!(provider.name(), "ollama"); assert_eq!(provider.name(), "ollama");
assert_eq!(provider.default_model, "qwen2.5:7b"); assert_eq!(provider.default_model, "qwen2.5:7b");
} }
@@ -367,10 +365,7 @@ mod tests {
}"#; }"#;
let chunk: OllamaStreamChunk = serde_json::from_str(json).unwrap(); let chunk: OllamaStreamChunk = serde_json::from_str(json).unwrap();
assert!(!chunk.done); assert!(!chunk.done);
assert_eq!( assert_eq!(chunk.message.unwrap().content, Some("Hello".to_string()));
chunk.message.unwrap().content,
Some("Hello".to_string())
);
} }
#[test] #[test]
@@ -388,10 +383,8 @@ mod tests {
#[test] #[test]
fn base_url_preserved() { fn base_url_preserved() {
let provider = OllamaProvider::new( let provider =
"http://192.168.1.100:11434".into(), OllamaProvider::new("http://192.168.1.100:11434".into(), "llama3.1:8b".into());
"llama3.1:8b".into(),
);
assert_eq!(provider.base_url, "http://192.168.1.100:11434"); assert_eq!(provider.base_url, "http://192.168.1.100:11434");
} }

View File

@@ -202,9 +202,7 @@ impl AiProvider for OpenAIProvider {
.map_err(|e| AiError::ProviderError(e.to_string()))?; .map_err(|e| AiError::ProviderError(e.to_string()))?;
if !status.is_success() { if !status.is_success() {
return Err(AiError::ProviderError(format!( return Err(AiError::ProviderError(format!("OpenAI {status}: {body}")));
"OpenAI {status}: {body}"
)));
} }
let parsed: ChatResponse = serde_json::from_str(&body) let parsed: ChatResponse = serde_json::from_str(&body)

View File

@@ -9,9 +9,17 @@ use tokio::sync::RwLock;
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub enum ProviderHealth { pub enum ProviderHealth {
Healthy { last_check: DateTime<Utc> }, Healthy {
Degraded { last_check: DateTime<Utc>, error: String }, last_check: DateTime<Utc>,
Unavailable { since: DateTime<Utc>, error: String }, },
Degraded {
last_check: DateTime<Utc>,
error: String,
},
Unavailable {
since: DateTime<Utc>,
error: String,
},
} }
impl ProviderHealth { impl ProviderHealth {
@@ -29,6 +37,12 @@ pub struct ProviderRegistry {
entries: DashMap<String, ProviderEntry>, entries: DashMap<String, ProviderEntry>,
} }
impl Default for ProviderRegistry {
fn default() -> Self {
Self::new()
}
}
impl ProviderRegistry { impl ProviderRegistry {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@@ -40,18 +54,19 @@ impl ProviderRegistry {
let health = Arc::new(RwLock::new(ProviderHealth::Healthy { let health = Arc::new(RwLock::new(ProviderHealth::Healthy {
last_check: Utc::now(), 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> { pub async fn resolve(&self, preferred: &str) -> crate::error::AiResult<ResolvedProvider> {
// 1. 首选 Provider实时健康检查 // 1. 首选 Provider实时健康检查
if let Some(entry) = self.entries.get(preferred) { if let Some(entry) = self.entries.get(preferred)
if entry.provider.health_check().await.unwrap_or(false) { && entry.provider.health_check().await.unwrap_or(false)
return Ok(ResolvedProvider { {
provider_name: preferred.to_string(), return Ok(ResolvedProvider {
provider: entry.provider.clone(), provider_name: preferred.to_string(),
}); provider: entry.provider.clone(),
} });
} }
// 2. 任何可用 Provider // 2. 任何可用 Provider
@@ -72,7 +87,9 @@ impl ProviderRegistry {
for entry in self.entries.iter() { for entry in self.entries.iter() {
let healthy = entry.value().provider.health_check().await.unwrap_or(false); let healthy = entry.value().provider.health_check().await.unwrap_or(false);
let new_health = if healthy { let new_health = if healthy {
ProviderHealth::Healthy { last_check: Utc::now() } ProviderHealth::Healthy {
last_check: Utc::now(),
}
} else { } else {
ProviderHealth::Unavailable { ProviderHealth::Unavailable {
since: Utc::now(), since: Utc::now(),
@@ -96,14 +113,22 @@ pub struct ResolvedProvider {
} }
impl ResolvedProvider { impl ResolvedProvider {
pub fn provider_name(&self) -> &str { &self.provider_name } pub fn provider_name(&self) -> &str {
pub fn provider(&self) -> &dyn AiProvider { self.provider.as_ref() } &self.provider_name
pub fn into_arc(self) -> Arc<dyn AiProvider> { self.provider } }
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 { struct MockProvider {
#[allow(dead_code)]
name: String, name: String,
healthy: Arc<std::sync::atomic::AtomicBool>, healthy: Arc<std::sync::atomic::AtomicBool>,
} }
@@ -113,13 +138,18 @@ impl AiProvider for MockProvider {
async fn stream_generate( async fn stream_generate(
&self, &self,
_req: crate::dto::GenerateRequest, _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()); }; let s = async_stream::stream! { yield Ok("mock".to_string()); };
Ok(Box::pin(s)) 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 { Ok(crate::dto::GenerateResponse {
content: "mock".to_string(), content: "mock".to_string(),
model: "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> { async fn health_check(&self) -> crate::error::AiResult<bool> {
Ok(self.healthy.load(std::sync::atomic::Ordering::Relaxed)) Ok(self.healthy.load(std::sync::atomic::Ordering::Relaxed))

View File

@@ -10,6 +10,12 @@ use crate::error::{AiError, AiResult};
/// 此服务做二次检查和安全约束注入 /// 此服务做二次检查和安全约束注入
pub struct SanitizationService; pub struct SanitizationService;
impl Default for SanitizationService {
fn default() -> Self {
Self::new()
}
}
impl SanitizationService { impl SanitizationService {
pub fn new() -> Self { pub fn new() -> Self {
Self Self
@@ -77,8 +83,8 @@ impl SanitizationService {
mod tests { mod tests {
use super::*; use super::*;
use erp_core::health_provider::{ use erp_core::health_provider::{
HealthReportDto, LabReportDto, PatientSummaryDto, ReportSectionDto, HealthReportDto, LabItemDto, LabReportDto, PatientSummaryDto, ReportSectionDto,
VitalSignDto, LabItemDto, VitalSignDto,
}; };
fn sanitizer() -> SanitizationService { fn sanitizer() -> SanitizationService {
@@ -172,13 +178,24 @@ mod tests {
#[test] #[test]
fn verify_no_pii_detects_all_pii_keys() { fn verify_no_pii_detects_all_pii_keys() {
let svc = sanitizer(); 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 { for key in pii_keys {
let mut report_json = serde_json::to_value(&clean_lab_report()).unwrap(); let mut report_json = serde_json::to_value(&clean_lab_report()).unwrap();
report_json[key] = serde_json::json!("test"); report_json[key] = serde_json::json!("test");
let report: LabReportDto = serde_json::from_value(report_json).unwrap(); let report: LabReportDto = serde_json::from_value(report_json).unwrap();
let result = svc.sanitize_lab_report(&report); 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() { fn dto_serialization_contains_no_pii() {
let report = clean_lab_report(); let report = clean_lab_report();
let val = serde_json::to_value(&report).unwrap(); let val = serde_json::to_value(&report).unwrap();
for key in &["name", "phone", "id_number", "address", "birth_date", "email"] { for key in &[
assert!(!val.as_object().unwrap().contains_key(*key), "name",
"LabReportDto 不应包含 PII 字段: {}", key); "phone",
"id_number",
"address",
"birth_date",
"email",
] {
assert!(
!val.as_object().unwrap().contains_key(*key),
"LabReportDto 不应包含 PII 字段: {}",
key
);
} }
} }

View File

@@ -1,9 +1,12 @@
use erp_core::types::Pagination;
use futures::Stream; 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 sha2::{Digest, Sha256};
use std::pin::Pin; use std::pin::Pin;
use uuid::Uuid; use uuid::Uuid;
use erp_core::types::Pagination;
use crate::dto::{AnalysisType, GenerateRequest}; use crate::dto::{AnalysisType, GenerateRequest};
use crate::entity::ai_analysis; use crate::entity::ai_analysis;
@@ -38,6 +41,7 @@ impl AnalysisService {
} }
/// 执行流式分析 — 返回 SSE 事件流 /// 执行流式分析 — 返回 SSE 事件流
#[allow(clippy::too_many_arguments)]
pub async fn stream_analyze( pub async fn stream_analyze(
&self, &self,
tenant_id: Uuid, tenant_id: Uuid,
@@ -64,7 +68,10 @@ impl AnalysisService {
if let Some(cached) = self.find_cached(tenant_id, &input_hash, 1).await? { if let Some(cached) = self.find_cached(tenant_id, &input_hash, 1).await? {
tracing::info!(analysis = %cached.id, "AI 分析缓存命中,复用已有结果"); tracing::info!(analysis = %cached.id, "AI 分析缓存命中,复用已有结果");
let content = cached.result_content.clone().unwrap_or_default(); 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); let stream = self.replay_cached(content, metadata);
return Ok((stream, cached.id, provider_name)); return Ok((stream, cached.id, provider_name));
} }
@@ -86,7 +93,10 @@ impl AnalysisService {
confidence = ctx.confidence, confidence = ctx.confidence,
"知识库上下文注入" "知识库上下文注入"
); );
format!("{}\n\n=== 知识库参考 ===\n{}", system_prompt, ctx.context_text) format!(
"{}\n\n=== 知识库参考 ===\n{}",
system_prompt, ctx.context_text
)
} }
Ok(_) => system_prompt, Ok(_) => system_prompt,
Err(e) => { Err(e) => {
@@ -234,11 +244,7 @@ impl AnalysisService {
} }
/// 获取单条分析记录 /// 获取单条分析记录
pub async fn get_analysis( pub async fn get_analysis(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_analysis::Model> {
&self,
id: Uuid,
tenant_id: Uuid,
) -> AiResult<ai_analysis::Model> {
let model = ai_analysis::Entity::find_by_id(id) let model = ai_analysis::Entity::find_by_id(id)
.one(&self.db) .one(&self.db)
.await? .await?
@@ -249,6 +255,7 @@ impl AnalysisService {
Ok(model) Ok(model)
} }
#[allow(clippy::too_many_arguments)]
async fn create_analysis_record( async fn create_analysis_record(
&self, &self,
id: Uuid, id: Uuid,

View File

@@ -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 uuid::Uuid;
use crate::entity::ai_analysis_queue; use crate::entity::ai_analysis_queue;
use crate::error::{AiError, AiResult}; use crate::error::{AiError, AiResult};
#[derive(Debug, FromQueryResult)] #[derive(Debug, FromQueryResult)]
#[allow(dead_code)]
struct QueueRow { struct QueueRow {
id: Uuid, id: Uuid,
tenant_id: Uuid, tenant_id: Uuid,
@@ -88,7 +89,10 @@ impl AnalysisQueue {
Ok(id) 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 { let sql = match tenant_id {
Some(tid) => format!( 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", "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() AND scheduled_at <= NOW()
ORDER BY priority DESC, scheduled_at ASC ORDER BY priority DESC, scheduled_at ASC
LIMIT 1 LIMIT 1
"#.to_string(), "#
.to_string(),
}; };
let row: Option<QueueRow> = QueueRow::find_by_statement( let row: Option<QueueRow> = QueueRow::find_by_statement(Statement::from_string(
Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.to_string()), sea_orm::DatabaseBackend::Postgres,
) sql.to_string(),
))
.one(&self.db) .one(&self.db)
.await?; .await?;
match row { match row {
Some(r) => { Some(r) => {
let now = chrono::Utc::now(); 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.status = Set("running".to_string());
active.started_at = Set(Some(now)); active.started_at = Set(Some(now));
active.updated_at = Set(now); active.updated_at = Set(now);
@@ -125,11 +132,7 @@ impl AnalysisQueue {
} }
} }
pub async fn mark_completed( pub async fn mark_completed(&self, id: Uuid, result_analysis_id: Uuid) -> AiResult<()> {
&self,
id: Uuid,
result_analysis_id: Uuid,
) -> AiResult<()> {
let job = self.find_by_id(id).await?; let job = self.find_by_id(id).await?;
let now = chrono::Utc::now(); let now = chrono::Utc::now();
let mut active: ai_analysis_queue::ActiveModel = job.into(); let mut active: ai_analysis_queue::ActiveModel = job.into();
@@ -179,15 +182,14 @@ impl AnalysisQueue {
GROUP BY status GROUP BY status
"#; "#;
let rows: Vec<StatusCount> = StatusCount::find_by_statement( let rows: Vec<StatusCount> =
Statement::from_sql_and_values( StatusCount::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres, sea_orm::DatabaseBackend::Postgres,
sql, sql,
[tenant_id.into()], [tenant_id.into()],
), ))
) .all(&self.db)
.all(&self.db) .await?;
.await?;
let mut pending = 0i64; let mut pending = 0i64;
let mut running = 0i64; let mut running = 0i64;

View File

@@ -50,19 +50,13 @@ async fn run_auto_analysis(state: &AiState) -> Result<(), String> {
} }
} }
tracing::info!( tracing::info!(total_analyzed, total_errors, "自动趋势分析任务完成");
total_analyzed,
total_errors,
"自动趋势分析任务完成"
);
Ok(()) Ok(())
} }
/// 查找所有活跃租户 ID /// 查找所有活跃租户 ID
async fn find_active_tenants( async fn find_active_tenants(db: &sea_orm::DatabaseConnection) -> Result<Vec<Uuid>, String> {
db: &sea_orm::DatabaseConnection,
) -> Result<Vec<Uuid>, String> {
#[derive(Debug, FromQueryResult)] #[derive(Debug, FromQueryResult)]
struct TenantId { struct TenantId {
id: Uuid, id: Uuid,

View File

@@ -16,7 +16,12 @@ pub struct CacheKey {
} }
impl 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 canonical = serde_json::to_string(input).unwrap_or_default();
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(canonical.as_bytes()); hasher.update(canonical.as_bytes());
@@ -54,8 +59,16 @@ pub struct CacheService {
} }
impl CacheService { impl CacheService {
pub fn new(redis: redis::Client, db: sea_orm::DatabaseConnection, default_ttl: Duration) -> Self { pub fn new(
Self { redis, db, default_ttl } 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>> { pub async fn get(&self, key: &CacheKey) -> AiResult<Option<CachedAnalysis>> {
@@ -127,12 +140,13 @@ impl CacheService {
let data: Option<String> = conn.get(key).await?; let data: Option<String> = conn.get(key).await?;
match data { match data {
Some(json) => { Some(json) => {
let cached: CachedAnalysis = serde_json::from_str(&json) let cached: CachedAnalysis = serde_json::from_str(&json).map_err(|e| {
.map_err(|e| redis::RedisError::from(( redis::RedisError::from((
redis::ErrorKind::TypeError, redis::ErrorKind::TypeError,
"反序列化失败", "反序列化失败",
e.to_string(), e.to_string(),
)))?; ))
})?;
Ok(Some(cached)) Ok(Some(cached))
} }
None => Ok(None), None => Ok(None),
@@ -141,12 +155,10 @@ impl CacheService {
async fn try_redis_set(&self, key: &str, value: &CachedAnalysis) -> redis::RedisResult<()> { async fn try_redis_set(&self, key: &str, value: &CachedAnalysis) -> redis::RedisResult<()> {
let mut conn = self.redis.get_multiplexed_async_connection().await?; let mut conn = self.redis.get_multiplexed_async_connection().await?;
let json = serde_json::to_string(value).map_err(|e| redis::RedisError::from(( let json = serde_json::to_string(value).map_err(|e| {
redis::ErrorKind::TypeError, redis::RedisError::from((redis::ErrorKind::TypeError, "序列化失败", e.to_string()))
"序列化失败", })?;
e.to_string(), let (): () = conn.set_ex(key, json, self.default_ttl.as_secs()).await?;
)))?;
let (): () = conn.set_ex(key, json, self.default_ttl.as_secs() as u64).await?;
Ok(()) Ok(())
} }
@@ -155,15 +167,14 @@ impl CacheService {
let mut count = 0u64; let mut count = 0u64;
let mut cursor: u64 = 0; let mut cursor: u64 = 0;
loop { loop {
let (new_cursor, keys): (u64, Vec<String>) = let (new_cursor, keys): (u64, Vec<String>) = redis::cmd("SCAN")
redis::cmd("SCAN") .arg(cursor)
.arg(cursor) .arg("MATCH")
.arg("MATCH") .arg(pattern)
.arg(pattern) .arg("COUNT")
.arg("COUNT") .arg(100)
.arg(100) .query_async(&mut conn)
.query_async(&mut conn) .await?;
.await?;
if !keys.is_empty() { if !keys.is_empty() {
let del_count: u64 = conn.del(&keys).await?; let del_count: u64 = conn.del(&keys).await?;
count += del_count; count += del_count;

View File

@@ -38,32 +38,35 @@ pub fn generate_comparison(
// 提取可比较的数值指标 // 提取可比较的数值指标
if let (Some(b_obj), Some(c_obj)) = (baseline.as_object(), current.as_object()) { if let (Some(b_obj), Some(c_obj)) = (baseline.as_object(), current.as_object()) {
for key in b_obj.keys() { 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_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()) { && 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 let change_pct = if b_num.abs() > 0.0001 {
} else { ((c_num - b_num) / b_num.abs()) * 100.0
0.0 } else {
}; 0.0
let trend = if change_pct.abs() > 5.0 { };
TrendDirection::Worsening let trend = if change_pct.abs() > 5.0 {
} else { TrendDirection::Worsening
TrendDirection::Stable } else {
}; TrendDirection::Stable
changes.push(MetricChange { };
metric: key.clone(), changes.push(MetricChange {
baseline_value: b_num, metric: key.clone(),
current_value: c_num, baseline_value: b_num,
change_percent: change_pct, current_value: c_num,
trend, change_percent: change_pct,
}); trend,
} });
} }
} }
} }
// 综合趋势判断 // 综合趋势判断
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 { let overall = if changed > 0 {
TrendDirection::Worsening TrendDirection::Worsening
} else { } else {

View File

@@ -74,9 +74,8 @@ impl CostService {
pub fn estimate_cost(analysis_type: &str, model: &str) -> CostEstimate { pub fn estimate_cost(analysis_type: &str, model: &str) -> CostEstimate {
let (input_tokens, output_tokens) = default_token_estimate(analysis_type); let (input_tokens, output_tokens) = default_token_estimate(analysis_type);
let (input_cost, output_cost) = model_cost_per_million(model); let (input_cost, output_cost) = model_cost_per_million(model);
let estimated_cost_usd = let estimated_cost_usd = (input_tokens as f64 * input_cost / 1_000_000.0)
(input_tokens as f64 * input_cost / 1_000_000.0) + (output_tokens as f64 * output_cost / 1_000_000.0);
+ (output_tokens as f64 * output_cost / 1_000_000.0);
CostEstimate { CostEstimate {
analysis_type: analysis_type.to_string(), analysis_type: analysis_type.to_string(),
@@ -143,13 +142,11 @@ impl CostService {
AND created_at >= DATE_TRUNC('month', CURRENT_DATE) AND created_at >= DATE_TRUNC('month', CURRENT_DATE)
"#; "#;
let row: Option<TokenSum> = TokenSum::find_by_statement( let row: Option<TokenSum> = TokenSum::find_by_statement(Statement::from_sql_and_values(
Statement::from_sql_and_values( sea_orm::DatabaseBackend::Postgres,
sea_orm::DatabaseBackend::Postgres, sql,
sql, [tenant_id.into()],
[tenant_id.into()], ))
),
)
.one(&self.db) .one(&self.db)
.await?; .await?;
@@ -179,7 +176,10 @@ mod tests {
#[test] #[test]
fn budget_warning_levels() { fn budget_warning_levels() {
assert_eq!(BudgetWarningLevel::Normal, BudgetWarningLevel::Normal); assert_eq!(BudgetWarningLevel::Normal, BudgetWarningLevel::Normal);
assert!(matches!(BudgetWarningLevel::Exceeded, BudgetWarningLevel::Exceeded)); assert!(matches!(
BudgetWarningLevel::Exceeded,
BudgetWarningLevel::Exceeded
));
} }
#[test] #[test]

View File

@@ -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}; use crate::service::local_rules::{CompareOp, LocalRule, LocalRulesEngine};
/// 透析患者实验室指标输入 /// 透析患者实验室指标输入
@@ -73,6 +73,12 @@ pub struct DialysisRiskScorer {
engine: LocalRulesEngine, engine: LocalRulesEngine,
} }
impl Default for DialysisRiskScorer {
fn default() -> Self {
Self::new()
}
}
impl DialysisRiskScorer { impl DialysisRiskScorer {
pub fn new() -> Self { pub fn new() -> Self {
let rules = vec![ let rules = vec![
@@ -113,8 +119,7 @@ impl DialysisRiskScorer {
threshold: 7.0, threshold: 7.0,
risk_level: RiskLevel::High, risk_level: RiskLevel::High,
suggestion_type: SuggestionType::Alert, suggestion_type: SuggestionType::Alert,
message_template: "血磷={value}mg/dL严重偏高>7.0),需紧急评估钙磷代谢" message_template: "血磷={value}mg/dL严重偏高>7.0),需紧急评估钙磷代谢".into(),
.into(),
}, },
// 透前血钾 > 6.0 mEq/L危急高钾 // 透前血钾 > 6.0 mEq/L危急高钾
LocalRule { LocalRule {
@@ -161,8 +166,7 @@ impl DialysisRiskScorer {
threshold: 5.0, threshold: 5.0,
risk_level: RiskLevel::High, risk_level: RiskLevel::High,
suggestion_type: SuggestionType::Alert, suggestion_type: SuggestionType::Alert,
message_template: "透析间期体重增长{value}%>5%干体重),容量超负荷风险高" message_template: "透析间期体重增长{value}%>5%干体重),容量超负荷风险高".into(),
.into(),
}, },
// 体重增长 > 3.5%:需关注 // 体重增长 > 3.5%:需关注
LocalRule { LocalRule {
@@ -199,8 +203,7 @@ impl DialysisRiskScorer {
threshold: 3.0, threshold: 3.0,
risk_level: RiskLevel::High, risk_level: RiskLevel::High,
suggestion_type: SuggestionType::Alert, suggestion_type: SuggestionType::Alert,
message_template: "白蛋白={value}g/dL严重偏低<3.0),营养不良增加死亡风险" message_template: "白蛋白={value}g/dL严重偏低<3.0),营养不良增加死亡风险".into(),
.into(),
}, },
]; ];
Self { Self {
@@ -221,17 +224,14 @@ impl DialysisRiskScorer {
let suggestions = self.engine.evaluate(&metrics); let suggestions = self.engine.evaluate(&metrics);
let mut risk_factors: Vec<String> = suggestions let mut risk_factors: Vec<String> = suggestions.iter().map(|s| s.reason.clone()).collect();
.iter()
.map(|s| s.reason.clone())
.collect();
let kdigo_stage = input.egfr.map(KdigoStage::from_egfr); let kdigo_stage = input.egfr.map(KdigoStage::from_egfr);
if let Some(stage) = kdigo_stage { if let Some(stage) = kdigo_stage
if matches!(stage, KdigoStage::G4 | KdigoStage::G5) { && matches!(stage, KdigoStage::G4 | KdigoStage::G5)
risk_factors.push(format!("KDIGO分期{},肾功能严重受损", stage.label())); {
} risk_factors.push(format!("KDIGO分期{},肾功能严重受损", stage.label()));
} }
let overall_risk = if suggestions.iter().any(|s| s.priority == 1) { let overall_risk = if suggestions.iter().any(|s| s.priority == 1) {

View File

@@ -1,4 +1,4 @@
use crate::dto::suggestion::{RiskLevel, SuggestionType, StructuredSuggestion}; use crate::dto::suggestion::{RiskLevel, StructuredSuggestion, SuggestionType};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct LocalRule { pub struct LocalRule {
@@ -120,9 +120,7 @@ impl LocalRulesEngine {
RiskLevel::Medium => "2周内".into(), RiskLevel::Medium => "2周内".into(),
RiskLevel::Low => "1个月内".into(), RiskLevel::Low => "1个月内".into(),
}, },
reason: rule reason: rule.message_template.replace("{value}", &value.to_string()),
.message_template
.replace("{value}", &value.to_string()),
params: serde_json::json!({ params: serde_json::json!({
"metric": rule.metric, "metric": rule.metric,
"value": value, "value": value,
@@ -156,7 +154,8 @@ mod tests {
#[test] #[test]
fn evaluate_all_normal_no_suggestions() { fn evaluate_all_normal_no_suggestions() {
let rules = LocalRulesEngine::default_rules(); 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); let suggestions = rules.evaluate(&metrics);
assert!(suggestions.is_empty()); assert!(suggestions.is_empty());
} }
@@ -166,9 +165,11 @@ mod tests {
let rules = LocalRulesEngine::default_rules(); let rules = LocalRulesEngine::default_rules();
let metrics = serde_json::json!({"heart_rate": 110.0}); let metrics = serde_json::json!({"heart_rate": 110.0});
let suggestions = rules.evaluate(&metrics); let suggestions = rules.evaluate(&metrics);
assert!(suggestions assert!(
.iter() suggestions
.any(|s| s.suggestion_type == SuggestionType::Followup)); .iter()
.any(|s| s.suggestion_type == SuggestionType::Followup)
);
} }
#[test] #[test]

View File

@@ -11,11 +11,10 @@ pub fn parse_dual_channel(raw: &str) -> AiResult<ParsedOutput> {
.trim() .trim()
.to_string(); .to_string();
let structured = extract_section(raw, JSON_MARKER, TEXT_MARKER) let structured = extract_section(raw, JSON_MARKER, TEXT_MARKER).and_then(|json_str| {
.and_then(|json_str| { let parsed: Result<StructuredOutput, _> = serde_json::from_str(json_str.trim());
let parsed: Result<StructuredOutput, _> = serde_json::from_str(json_str.trim()); parsed.ok()
parsed.ok() });
});
Ok(ParsedOutput { Ok(ParsedOutput {
text_content, text_content,

View File

@@ -17,6 +17,7 @@ pub struct PostProcessResult {
} }
/// 对完成的分析执行后处理:解析双通道输出、创建建议、发布事件 /// 对完成的分析执行后处理:解析双通道输出、创建建议、发布事件
#[allow(clippy::too_many_arguments)]
pub async fn post_process_analysis( pub async fn post_process_analysis(
state: &AiState, state: &AiState,
analysis_id: Uuid, analysis_id: Uuid,

View File

@@ -34,6 +34,7 @@ impl PromptService {
} }
/// 新建 Prompt /// 新建 Prompt
#[allow(clippy::too_many_arguments)]
pub async fn create_prompt( pub async fn create_prompt(
&self, &self,
tenant_id: Uuid, tenant_id: Uuid,
@@ -95,6 +96,7 @@ impl PromptService {
} }
/// 更新 Prompt创建新版本 /// 更新 Prompt创建新版本
#[allow(clippy::too_many_arguments)]
pub async fn update_prompt( pub async fn update_prompt(
&self, &self,
id: Uuid, id: Uuid,
@@ -122,7 +124,9 @@ impl PromptService {
name: Set(entity.name.clone()), name: Set(entity.name.clone()),
description: Set(description.unwrap_or(entity.description.clone())), description: Set(description.unwrap_or(entity.description.clone())),
system_prompt: Set(system_prompt.unwrap_or(entity.system_prompt.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()), variables_schema: Set(entity.variables_schema.clone()),
model_config: Set(model_config.unwrap_or(entity.model_config.clone())), model_config: Set(model_config.unwrap_or(entity.model_config.clone())),
version: Set(entity.version + 1), version: Set(entity.version + 1),
@@ -140,11 +144,7 @@ impl PromptService {
} }
/// 激活指定 Prompt停用同 name+category 的其他版本) /// 激活指定 Prompt停用同 name+category 的其他版本)
pub async fn activate_prompt( pub async fn activate_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> {
&self,
id: Uuid,
tenant_id: Uuid,
) -> AiResult<ai_prompt::Model> {
let entity = ai_prompt::Entity::find_by_id(id) let entity = ai_prompt::Entity::find_by_id(id)
.one(&self.db) .one(&self.db)
.await? .await?
@@ -179,11 +179,7 @@ impl PromptService {
} }
/// 回滚(= 激活指定旧版本) /// 回滚(= 激活指定旧版本)
pub async fn rollback_prompt( pub async fn rollback_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> {
&self,
id: Uuid,
tenant_id: Uuid,
) -> AiResult<ai_prompt::Model> {
self.activate_prompt(id, tenant_id).await self.activate_prompt(id, tenant_id).await
} }
} }

View File

@@ -27,11 +27,7 @@ impl QuotaService {
Ok(config) Ok(config)
} }
pub async fn check_quota( pub async fn check_quota(&self, tenant_id: Uuid, patient_id: Option<Uuid>) -> AiResult<()> {
&self,
tenant_id: Uuid,
patient_id: Option<Uuid>,
) -> AiResult<()> {
if !self.enabled { if !self.enabled {
return Ok(()); return Ok(());
} }
@@ -81,24 +77,18 @@ impl QuotaService {
AND created_at >= date_trunc('month', CURRENT_DATE) AND created_at >= date_trunc('month', CURRENT_DATE)
"#; "#;
let result: Option<TokenSum> = TokenSum::find_by_statement( let result: Option<TokenSum> = TokenSum::find_by_statement(Statement::from_sql_and_values(
Statement::from_sql_and_values( sea_orm::DatabaseBackend::Postgres,
sea_orm::DatabaseBackend::Postgres, sql,
sql, [tenant_id.into()],
[tenant_id.into()], ))
),
)
.one(&self.db) .one(&self.db)
.await?; .await?;
Ok(result.map(|r| r.total_tokens).unwrap_or(0)) Ok(result.map(|r| r.total_tokens).unwrap_or(0))
} }
async fn get_daily_patient_count( async fn get_daily_patient_count(&self, tenant_id: Uuid, patient_id: Uuid) -> AiResult<i64> {
&self,
tenant_id: Uuid,
patient_id: Uuid,
) -> AiResult<i64> {
#[derive(Debug, FromQueryResult)] #[derive(Debug, FromQueryResult)]
struct CountResult { struct CountResult {
count: i64, count: i64,
@@ -113,23 +103,19 @@ impl QuotaService {
AND created_at >= CURRENT_DATE AND created_at >= CURRENT_DATE
"#; "#;
let result: Option<CountResult> = CountResult::find_by_statement( let result: Option<CountResult> =
Statement::from_sql_and_values( CountResult::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres, sea_orm::DatabaseBackend::Postgres,
sql, sql,
[tenant_id.into(), patient_id.into()], [tenant_id.into(), patient_id.into()],
), ))
) .one(&self.db)
.one(&self.db) .await?;
.await?;
Ok(result.map(|r| r.count).unwrap_or(0)) Ok(result.map(|r| r.count).unwrap_or(0))
} }
pub async fn get_usage_summary( pub async fn get_usage_summary(&self, tenant_id: Uuid) -> AiResult<QuotaSummary> {
&self,
tenant_id: Uuid,
) -> AiResult<QuotaSummary> {
let config = self.get_tenant_config(tenant_id).await?; let config = self.get_tenant_config(tenant_id).await?;
let budget = config let budget = config
.as_ref() .as_ref()
@@ -142,10 +128,7 @@ impl QuotaService {
tenant_id, tenant_id,
monthly_budget: budget, monthly_budget: budget,
monthly_used: used, monthly_used: used,
daily_patient_limit: config daily_patient_limit: config.as_ref().map(|c| c.daily_patient_limit).unwrap_or(50),
.as_ref()
.map(|c| c.daily_patient_limit)
.unwrap_or(50),
}) })
} }
} }

View File

@@ -21,15 +21,14 @@ pub async fn handle_reanalysis_requested(
FROM ai_suggestion FROM ai_suggestion
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
"#; "#;
let original: Option<OriginalSuggestion> = OriginalSuggestion::find_by_statement( let original: Option<OriginalSuggestion> =
Statement::from_sql_and_values( OriginalSuggestion::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres, sea_orm::DatabaseBackend::Postgres,
sql, sql,
[original_suggestion_id.into(), tenant_id.into()], [original_suggestion_id.into(), tenant_id.into()],
), ))
) .one(db)
.one(db) .await?;
.await?;
match original { match original {
Some(orig) => { Some(orig) => {

View File

@@ -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::dto::suggestion::*;
use crate::entity::ai_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; pub struct SuggestionService;
@@ -85,9 +85,7 @@ impl SuggestionService {
.filter(ai_suggestion::Column::DeletedAt.is_null()) .filter(ai_suggestion::Column::DeletedAt.is_null())
.one(db) .one(db)
.await? .await?
.ok_or_else(|| { .ok_or_else(|| crate::error::AiError::AnalysisNotFound("建议不存在".into()))?;
crate::error::AiError::AnalysisNotFound("建议不存在".into())
})?;
let current_status = parse_status(&item.status); let current_status = parse_status(&item.status);
if !current_status.can_transition_to(new_status) { if !current_status.can_transition_to(new_status) {
@@ -122,13 +120,14 @@ impl SuggestionService {
.filter(ai_suggestion::Column::DeletedAt.is_null()) .filter(ai_suggestion::Column::DeletedAt.is_null())
.one(db) .one(db)
.await? .await?
.ok_or_else(|| { .ok_or_else(|| crate::error::AiError::AnalysisNotFound("建议不存在".into()))?;
crate::error::AiError::AnalysisNotFound("建议不存在".into())
})?;
let current_status = parse_status(&item.status); let current_status = parse_status(&item.status);
// 允许从 Pending 或 Approved 直接执行(护士可能跳过审批) // 允许从 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!( return Err(crate::error::AiError::Validation(format!(
"建议状态为 {},无法执行(需要 pending 或 approved", "建议状态为 {},无法执行(需要 pending 或 approved",
current_status.as_str() current_status.as_str()

View File

@@ -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 uuid::Uuid;
use crate::entity::ai_analysis; use crate::entity::ai_analysis;
@@ -14,6 +16,7 @@ impl UsageService {
Self { db } Self { db }
} }
#[allow(clippy::too_many_arguments)]
pub async fn log_usage( pub async fn log_usage(
&self, &self,
tenant_id: Uuid, tenant_id: Uuid,

View File

@@ -98,5 +98,4 @@ mod tests {
other => panic!("Expected Validation, got {:?}", other), other => panic!("Expected Validation, got {:?}", other),
} }
} }
} }

View File

@@ -45,7 +45,11 @@ where
// TODO: 多租户微信登录需要设计租户解析策略(如 per-appid 映射或登录后选择租户) // TODO: 多租户微信登录需要设计租户解析策略(如 per-appid 映射或登录后选择租户)
let tenant_id = state.default_tenant_id; let tenant_id = state.default_tenant_id;
let resp = WechatService::login(&state, tenant_id, &req.code).await?; 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))) Ok(Json(ApiResponse::ok(resp)))
} }
@@ -75,13 +79,8 @@ where
// TODO: 多租户微信登录需要设计租户解析策略 // TODO: 多租户微信登录需要设计租户解析策略
let tenant_id = state.default_tenant_id; let tenant_id = state.default_tenant_id;
let resp = WechatService::bind_phone( let resp =
&state, WechatService::bind_phone(&state, tenant_id, &req.openid, &req.encrypted_data, &req.iv)
tenant_id, .await?;
&req.openid,
&req.encrypted_data,
&req.iv,
)
.await?;
Ok(Json(ApiResponse::ok(resp))) Ok(Json(ApiResponse::ok(resp)))
} }

View File

@@ -163,7 +163,7 @@ async fn fetch_permission_data_scopes(
row.try_get_by_index::<String>(0), row.try_get_by_index::<String>(0),
row.try_get_by_index::<String>(2), row.try_get_by_index::<String>(2),
) { ) {
scopes.insert(code, DataScope::from_str(&scope)); scopes.insert(code, DataScope::parse_scope(&scope));
} }
} }
scopes scopes

View File

@@ -159,13 +159,10 @@ impl ErpModule for AuthModule {
db: &sea_orm::DatabaseConnection, db: &sea_orm::DatabaseConnection,
_event_bus: &EventBus, _event_bus: &EventBus,
) -> AppResult<()> { ) -> AppResult<()> {
let password = std::env::var("ERP__SUPER_ADMIN_PASSWORD") let password = std::env::var("ERP__SUPER_ADMIN_PASSWORD").map_err(|_| {
.map_err(|_| { tracing::error!("环境变量 ERP__SUPER_ADMIN_PASSWORD 未设置,无法初始化租户认证");
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) crate::service::seed::seed_tenant_auth(db, tenant_id, &password)
.await .await
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?; .map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
@@ -178,8 +175,8 @@ impl ErpModule for AuthModule {
tenant_id: Uuid, tenant_id: Uuid,
db: &sea_orm::DatabaseConnection, db: &sea_orm::DatabaseConnection,
) -> AppResult<()> { ) -> AppResult<()> {
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use chrono::Utc; use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
let now = Utc::now(); let now = Utc::now();
@@ -210,29 +207,144 @@ impl ErpModule for AuthModule {
fn permissions(&self) -> Vec<PermissionDescriptor> { fn permissions(&self) -> Vec<PermissionDescriptor> {
vec![ vec![
PermissionDescriptor { code: "user.list".into(), name: "查看用户列表".into(), description: "查看用户列表".into(), module: "auth".into() }, PermissionDescriptor {
PermissionDescriptor { code: "user.create".into(), name: "创建用户".into(), description: "创建新用户".into(), module: "auth".into() }, code: "user.list".into(),
PermissionDescriptor { code: "user.read".into(), name: "查看用户详情".into(), description: "查看用户信息".into(), module: "auth".into() }, name: "查看用户列表".into(),
PermissionDescriptor { code: "user.update".into(), name: "编辑用户".into(), description: "编辑用户信息".into(), module: "auth".into() }, description: "查看用户列表".into(),
PermissionDescriptor { code: "user.delete".into(), name: "删除用户".into(), description: "软删除用户".into(), module: "auth".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 {
PermissionDescriptor { code: "role.read".into(), name: "查看角色详情".into(), description: "查看角色信息".into(), module: "auth".into() }, code: "user.create".into(),
PermissionDescriptor { code: "role.update".into(), name: "编辑角色".into(), description: "编辑角色".into(), module: "auth".into() }, name: "创建用户".into(),
PermissionDescriptor { code: "role.delete".into(), name: "删除角色".into(), description: "删除角色".into(), module: "auth".into() }, description: "创建新用户".into(),
PermissionDescriptor { code: "permission.list".into(), name: "查看权限".into(), description: "查看权限列表".into(), module: "auth".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 {
PermissionDescriptor { code: "organization.update".into(), name: "编辑组织".into(), description: "编辑组织".into(), module: "auth".into() }, code: "user.read".into(),
PermissionDescriptor { code: "organization.delete".into(), name: "删除组织".into(), description: "删除组织".into(), module: "auth".into() }, name: "查看用户详情".into(),
PermissionDescriptor { code: "department.list".into(), name: "查看部门列表".into(), description: "查看部门列表".into(), module: "auth".into() }, description: "查看用户信息".into(),
PermissionDescriptor { code: "department.create".into(), name: "创建部门".into(), description: "创建部门".into(), module: "auth".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 {
PermissionDescriptor { code: "position.list".into(), name: "查看岗位列表".into(), description: "查看岗位列表".into(), module: "auth".into() }, code: "user.update".into(),
PermissionDescriptor { code: "position.create".into(), name: "创建岗位".into(), description: "创建岗位".into(), module: "auth".into() }, name: "编辑用户".into(),
PermissionDescriptor { code: "position.update".into(), name: "编辑岗位".into(), description: "编辑岗位".into(), module: "auth".into() }, description: "编辑用户信息".into(),
PermissionDescriptor { code: "position.delete".into(), name: "删除岗位".into(), description: "删除岗位".into(), module: "auth".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(),
},
] ]
} }

View File

@@ -64,11 +64,10 @@ impl AuthService {
None => { None => {
// 审计:用户不存在(登录失败) // 审计:用户不存在(登录失败)
audit_service::record( audit_service::record(
AuditLog::new(tenant_id, None, "user.login_failed", "user") AuditLog::new(tenant_id, None, "user.login_failed", "user").with_request_info(
.with_request_info( req_info.as_ref().and_then(|r| r.ip.clone()),
req_info.as_ref().and_then(|r| r.ip.clone()), req_info.as_ref().and_then(|r| r.user_agent.clone()),
req_info.as_ref().and_then(|r| r.user_agent.clone()), ),
),
db, db,
) )
.await; .await;

View File

@@ -317,13 +317,7 @@ const DEFAULT_PERMISSIONS: &[(&str, &str, &str, &str, &str)] = &[
"admin", "admin",
"管理插件全生命周期", "管理插件全生命周期",
), ),
( ("plugin.list", "查看插件", "plugin", "list", "查看插件列表"),
"plugin.list",
"查看插件",
"plugin",
"list",
"查看插件列表",
),
]; ];
/// Indices of read-only (list/read) permissions within DEFAULT_PERMISSIONS. /// Indices of read-only (list/read) permissions within DEFAULT_PERMISSIONS.

View File

@@ -153,7 +153,11 @@ impl TokenService {
/// Revoke a specific refresh token by database ID. /// Revoke a specific refresh token by database ID.
/// Verifies that the token belongs to the specified user for security. /// 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) let token_row = user_token::Entity::find_by_id(token_id)
.filter(user_token::Column::UserId.eq(user_id)) .filter(user_token::Column::UserId.eq(user_id))
.one(db) .one(db)

View File

@@ -406,8 +406,7 @@ impl UserService {
.unwrap_or_default() .unwrap_or_default()
}; };
let role_map: HashMap<Uuid, &role::Model> = let role_map: HashMap<Uuid, &role::Model> = roles.iter().map(|r| (r.id, r)).collect();
roles.iter().map(|r| (r.id, r)).collect();
// 3. 按 user_id 分组 // 3. 按 user_id 分组
let mut result: HashMap<Uuid, Vec<RoleResp>> = HashMap::new(); let mut result: HashMap<Uuid, Vec<RoleResp>> = HashMap::new();

View File

@@ -1,10 +1,8 @@
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit}; use aes::cipher::{BlockDecryptMut, KeyIvInit, block_padding::Pkcs7};
use base64::Engine; use base64::Engine;
use chrono::Utc;
use cbc::Decryptor; use cbc::Decryptor;
use sea_orm::{ use chrono::Utc;
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
};
use serde::Deserialize; use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::LazyLock; use std::sync::LazyLock;
@@ -59,9 +57,13 @@ impl WechatService {
code = %code, code = %code,
"fetch_session 开始" "fetch_session 开始"
); );
let session = let session = fetch_session(
fetch_session(&state.wechat_appid, &state.wechat_secret, code, state.wechat_dev_mode) &state.wechat_appid,
.await?; &state.wechat_secret,
code,
state.wechat_dev_mode,
)
.await?;
let openid = session let openid = session
.openid .openid
@@ -69,18 +71,18 @@ impl WechatService {
.ok_or_else(|| AuthError::Validation("微信登录失败:未获取到 openid".to_string()))?; .ok_or_else(|| AuthError::Validation("微信登录失败:未获取到 openid".to_string()))?;
// 缓存 session_keyRedis 优先,内存降级) // 缓存 session_keyRedis 优先,内存降级)
if let Some(sk) = &session.session_key { if let Some(sk) = &session.session_key
if let Err(e) = Self::store_session_key_redis(&state.redis, &openid, sk).await { && 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; tracing::warn!(openid = %openid, error = %e, "Redis session_key 存储失败,降级内存");
cache.insert( let mut cache = MEMORY_FALLBACK.lock().await;
openid.clone(), cache.insert(
SessionEntry { openid.clone(),
session_key: sk.clone(), SessionEntry {
created_at: Instant::now(), session_key: sk.clone(),
}, created_at: Instant::now(),
); },
} );
} }
let existing = wechat_user::Entity::find() let existing = wechat_user::Entity::find()
@@ -141,8 +143,7 @@ impl WechatService {
return Err(AuthError::Validation("该微信已绑定账号".to_string())); return Err(AuthError::Validation("该微信已绑定账号".to_string()));
} }
let user_id = let user_id = Self::find_or_create_user_by_phone(&state.db, tenant_id, &phone).await?;
Self::find_or_create_user_by_phone(&state.db, tenant_id, &phone).await?;
let now = Utc::now(); let now = Utc::now();
let wu = wechat_user::ActiveModel { let wu = wechat_user::ActiveModel {
@@ -248,22 +249,19 @@ impl WechatService {
Ok(()) Ok(())
} }
async fn get_session_key( async fn get_session_key(redis: &Option<redis::Client>, openid: &str) -> AuthResult<String> {
redis: &Option<redis::Client>,
openid: &str,
) -> AuthResult<String> {
// 1. 尝试 Redis // 1. 尝试 Redis
if let Some(client) = redis { if let Some(client) = redis
if let Ok(mut conn) = client.get_multiplexed_async_connection().await { && let Ok(mut conn) = client.get_multiplexed_async_connection().await
let key = format!("{}{}", REDIS_KEY_PREFIX, openid); {
let result: Option<String> = redis::cmd("GETDEL") let key = format!("{}{}", REDIS_KEY_PREFIX, openid);
.arg(&key) let result: Option<String> = redis::cmd("GETDEL")
.query_async::<Option<String>>(&mut conn) .arg(&key)
.await .query_async::<Option<String>>(&mut conn)
.unwrap_or(None); .await
if let Some(sk) = result { .unwrap_or(None);
return Ok(sk); if let Some(sk) = result {
} return Ok(sk);
} }
} }
@@ -285,11 +283,7 @@ impl WechatService {
} }
/// AES-128-CBC 解密微信手机号 /// AES-128-CBC 解密微信手机号
fn decrypt_phone_number( fn decrypt_phone_number(session_key: &str, encrypted_data: &str, iv: &str) -> AuthResult<String> {
session_key: &str,
encrypted_data: &str,
iv: &str,
) -> AuthResult<String> {
let engine = base64::engine::general_purpose::STANDARD; let engine = base64::engine::general_purpose::STANDARD;
let key_bytes = engine let key_bytes = engine
@@ -303,9 +297,7 @@ fn decrypt_phone_number(
.map_err(|e| AuthError::Validation(format!("encrypted_data base64 解码失败: {}", e)))?; .map_err(|e| AuthError::Validation(format!("encrypted_data base64 解码失败: {}", e)))?;
if key_bytes.len() != 16 { if key_bytes.len() != 16 {
return Err(AuthError::Validation( return Err(AuthError::Validation("session_key 长度不正确".to_string()));
"session_key 长度不正确".to_string(),
));
} }
if iv_bytes.len() != 16 { if iv_bytes.len() != 16 {
return Err(AuthError::Validation("iv 长度不正确".to_string())); return Err(AuthError::Validation("iv 长度不正确".to_string()));
@@ -319,8 +311,8 @@ fn decrypt_phone_number(
.decrypt_padded_mut::<Pkcs7>(&mut buf) .decrypt_padded_mut::<Pkcs7>(&mut buf)
.map_err(|e| AuthError::Validation(format!("AES 解密失败: {}", e)))?; .map_err(|e| AuthError::Validation(format!("AES 解密失败: {}", e)))?;
let plaintext = let plaintext = String::from_utf8(decrypted.to_vec())
String::from_utf8(decrypted.to_vec()).map_err(|_| AuthError::Validation("解密结果非 UTF-8".to_string()))?; .map_err(|_| AuthError::Validation("解密结果非 UTF-8".to_string()))?;
// 微信返回的 JSON 包含 watermark 等字段,提取 phone_number // 微信返回的 JSON 包含 watermark 等字段,提取 phone_number
let info: serde_json::Value = serde_json::from_str(&plaintext) let info: serde_json::Value = serde_json::from_str(&plaintext)
@@ -358,14 +350,9 @@ async fn build_login_resp(
jwt.secret, jwt.secret,
jwt.access_ttl_secs, jwt.access_ttl_secs,
)?; )?;
let (refresh_token, _) = TokenService::sign_refresh_token( let (refresh_token, _) =
user_id, TokenService::sign_refresh_token(user_id, tenant_id, db, jwt.secret, jwt.refresh_ttl_secs)
tenant_id, .await?;
db,
jwt.secret,
jwt.refresh_ttl_secs,
)
.await?;
let role_resps = AuthService::get_user_role_resps(user_id, tenant_id, db).await?; let role_resps = AuthService::get_user_role_resps(user_id, tenant_id, db).await?;
@@ -424,15 +411,15 @@ async fn fetch_session(
.await .await
.map_err(|e| AuthError::Validation(format!("微信 API 响应解析失败: {}", e)))?; .map_err(|e| AuthError::Validation(format!("微信 API 响应解析失败: {}", e)))?;
if let Some(errcode) = session.errcode { if let Some(errcode) = session.errcode
if errcode != 0 { && errcode != 0
let msg = session.errmsg.clone().unwrap_or_default(); {
tracing::error!(errcode, errmsg = %msg, "微信 jscode2session 返回错误"); let msg = session.errmsg.clone().unwrap_or_default();
return Err(AuthError::Validation(format!( tracing::error!(errcode, errmsg = %msg, "微信 jscode2session 返回错误");
"微信登录失败 ({}): {}", return Err(AuthError::Validation(format!(
errcode, msg "微信登录失败 ({}): {}",
))); errcode, msg
} )));
} }
tracing::info!( tracing::info!(

View File

@@ -101,19 +101,40 @@ mod tests {
#[test] #[test]
fn config_error_display_messages() { fn config_error_display_messages() {
// 验证各变体的 Display 输出包含中文描述 // 验证各变体的 Display 输出包含中文描述
assert!(ConfigError::Validation("test".into()).to_string().contains("验证失败")); assert!(
assert!(ConfigError::NotFound("test".into()).to_string().contains("资源未找到")); ConfigError::Validation("test".into())
assert!(ConfigError::DuplicateKey("test".into()).to_string().contains("键已存在")); .to_string()
assert!(ConfigError::NumberingExhausted("test".into()).to_string().contains("编号序列耗尽")); .contains("验证失败")
assert!(ConfigError::VersionMismatch.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] #[test]
fn transaction_error_connection_maps_to_validation() { fn transaction_error_connection_maps_to_validation() {
// TransactionError::Connection 应该转换为 ConfigError::Validation // TransactionError::Connection 应该转换为 ConfigError::Validation
let config_err: ConfigError = let config_err: ConfigError = sea_orm::TransactionError::Connection(sea_orm::DbErr::Conn(
sea_orm::TransactionError::Connection(sea_orm::DbErr::Conn(sea_orm::RuntimeErr::Internal("连接失败".to_string()))) sea_orm::RuntimeErr::Internal("连接失败".to_string()),
.into(); ))
.into();
match config_err { match config_err {
ConfigError::Validation(msg) => assert!(msg.contains("连接失败")), ConfigError::Validation(msg) => assert!(msg.contains("连接失败")),
other => panic!("期望 Validation实际得到 {:?}", other), other => panic!("期望 Validation实际得到 {:?}", other),

View File

@@ -125,8 +125,12 @@ where
pub async fn get_public_brand() -> JsonResponse<ApiResponse<PublicBrandResp>> { pub async fn get_public_brand() -> JsonResponse<ApiResponse<PublicBrandResp>> {
let defaults = default_theme(); let defaults = default_theme();
JsonResponse(ApiResponse::ok(PublicBrandResp { JsonResponse(ApiResponse::ok(PublicBrandResp {
brand_name: defaults.brand_name.unwrap_or_else(|| "HMS 健康管理平台".into()), brand_name: defaults
brand_slogan: defaults.brand_slogan.unwrap_or_else(|| "新一代健康管理平台".into()), .brand_name
.unwrap_or_else(|| "HMS 健康管理平台".into()),
brand_slogan: defaults
.brand_slogan
.unwrap_or_else(|| "新一代健康管理平台".into()),
brand_features: defaults brand_features: defaults
.brand_features .brand_features
.unwrap_or_else(|| "患者管理 · 健康监测 · 随访管理 · AI 智能分析".into()), .unwrap_or_else(|| "患者管理 · 健康监测 · 随访管理 · AI 智能分析".into()),

View File

@@ -64,10 +64,7 @@ impl ConfigModule {
put(menu_handler::update_menu).delete(menu_handler::delete_menu), put(menu_handler::update_menu).delete(menu_handler::delete_menu),
) )
// User menu tree (no special permission required) // User menu tree (no special permission required)
.route( .route("/menus/user", get(menu_handler::get_user_menus))
"/menus/user",
get(menu_handler::get_user_menus),
)
// Setting routes // Setting routes
.route( .route(
"/config/settings/{key}", "/config/settings/{key}",
@@ -153,24 +150,114 @@ impl ErpModule for ConfigModule {
fn permissions(&self) -> Vec<PermissionDescriptor> { fn permissions(&self) -> Vec<PermissionDescriptor> {
vec![ vec![
PermissionDescriptor { code: "dictionary.list".into(), name: "查看字典".into(), description: "查看数据字典".into(), module: "config".into() }, PermissionDescriptor {
PermissionDescriptor { code: "dictionary.create".into(), name: "创建字典".into(), description: "创建数据字典".into(), module: "config".into() }, code: "dictionary.list".into(),
PermissionDescriptor { code: "dictionary.update".into(), name: "编辑字典".into(), description: "编辑数据字典".into(), module: "config".into() }, name: "查看字典".into(),
PermissionDescriptor { code: "dictionary.delete".into(), name: "删除字典".into(), description: "删除数据字典".into(), module: "config".into() }, description: "查看数据字典".into(),
PermissionDescriptor { code: "menu.list".into(), name: "查看菜单".into(), description: "查看菜单配置".into(), module: "config".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 {
PermissionDescriptor { code: "setting.update".into(), name: "编辑配置".into(), description: "编辑系统参数".into(), module: "config".into() }, code: "dictionary.create".into(),
PermissionDescriptor { code: "setting.delete".into(), name: "删除配置".into(), description: "删除系统参数".into(), module: "config".into() }, name: "创建字典".into(),
PermissionDescriptor { code: "numbering.list".into(), name: "查看编号规则".into(), description: "查看编号规则".into(), module: "config".into() }, description: "创建数据字典".into(),
PermissionDescriptor { code: "numbering.create".into(), name: "创建编号规则".into(), description: "创建编号规则".into(), module: "config".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 {
PermissionDescriptor { code: "numbering.generate".into(), name: "生成编号".into(), description: "生成文档编号".into(), module: "config".into() }, code: "dictionary.update".into(),
PermissionDescriptor { code: "theme.read".into(), name: "查看主题".into(), description: "查看主题设置".into(), module: "config".into() }, name: "编辑字典".into(),
PermissionDescriptor { code: "theme.update".into(), name: "编辑主题".into(), description: "编辑主题设置".into(), module: "config".into() }, description: "编辑数据字典".into(),
PermissionDescriptor { code: "language.list".into(), name: "查看语言".into(), description: "查看语言配置".into(), module: "config".into() }, module: "config".into(),
PermissionDescriptor { code: "language.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(),
},
] ]
} }

View File

@@ -1,7 +1,9 @@
use std::collections::HashMap; use std::collections::HashMap;
use chrono::Utc; 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 uuid::Uuid;
use crate::dto::{CreateMenuReq, MenuResp}; use crate::dto::{CreateMenuReq, MenuResp};

View File

@@ -35,11 +35,11 @@ pub(crate) fn format_number(
result.push_str(separator); result.push_str(separator);
} }
if let Some(dp) = date_part { if let Some(dp) = date_part
if !dp.is_empty() { && !dp.is_empty()
result.push_str(dp); {
result.push_str(separator); result.push_str(dp);
} result.push_str(separator);
} }
let width = (seq_length.max(1)) as usize; let width = (seq_length.max(1)) as usize;
@@ -398,7 +398,10 @@ impl NumberingService {
.map_err(|e| ConfigError::Validation(e.to_string()))?; .map_err(|e| ConfigError::Validation(e.to_string()))?;
// 拼接编号字符串: {prefix}{separator}{date_part}{separator}{seq_padded} // 拼接编号字符串: {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( let number = format_number(
&rule.prefix, &rule.prefix,
@@ -611,7 +614,8 @@ mod tests {
#[test] #[test]
fn reset_no_last_reset_date_returns_seq_start() { fn reset_no_last_reset_date_returns_seq_start() {
// 从未重置过,使用 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); assert_eq!(result, 1);
} }

View File

@@ -2,7 +2,7 @@ use crate::audit::AuditLog;
use crate::entity::audit_log; use crate::entity::audit_log;
use crate::request_info::RequestInfo; use crate::request_info::RequestInfo;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set}; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
use sha2::{Sha256, Digest}; use sha2::{Digest, Sha256};
use tracing; use tracing;
/// 持久化审计日志到 audit_logs 表。 /// 持久化审计日志到 audit_logs 表。
@@ -16,14 +16,12 @@ use tracing;
/// 计算 SHA256(id + action + resource_type + resource_id + created_at + prev_hash) 作为 record_hash。 /// 计算 SHA256(id + action + resource_type + resource_id + created_at + prev_hash) 作为 record_hash。
pub async fn record(mut log: AuditLog, db: &sea_orm::DatabaseConnection) { 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 let Some(info) = RequestInfo::try_current() { if log.ip_address.is_none() {
if log.ip_address.is_none() { log.ip_address = info.ip_address;
log.ip_address = info.ip_address; }
} if log.user_agent.is_none() {
if log.user_agent.is_none() { log.user_agent = info.user_agent;
log.user_agent = info.user_agent;
}
} }
} }

View File

@@ -1,6 +1,6 @@
use aes_gcm::aead::Aead; use aes_gcm::aead::Aead;
use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; 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; use rand::RngCore;
const CIPHER_VERSION: u8 = 0x01; 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 cipher = Aes256Gcm::new_from_slice(key).map_err(|e| e.to_string())?;
let nonce = Nonce::from_slice(nonce_bytes); 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()) String::from_utf8(plaintext).map_err(|e| e.to_string())
} }

View File

@@ -46,15 +46,15 @@ impl DekManager {
kek: &[u8; 32], kek: &[u8; 32],
) -> AppResult<([u8; 32], u32)> { ) -> AppResult<([u8; 32], u32)> {
// 检查缓存 // 检查缓存
if let Some(entry) = self.cache.get(&tenant_id) { if let Some(entry) = self.cache.get(&tenant_id)
if entry.loaded_at.elapsed().as_secs() < self.ttl_secs { && entry.loaded_at.elapsed().as_secs() < self.ttl_secs
return Ok((entry.dek, entry.version)); {
} return Ok((entry.dek, entry.version));
} }
// 从加密 DEK 解密 // 从加密 DEK 解密
if let Some(enc_dek) = encrypted_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()))?; let dek_bytes = hex::decode(&dek_hex).map_err(|e| AppError::Internal(e.to_string()))?;
if dek_bytes.len() != 32 { if dek_bytes.len() != 32 {
return Err(AppError::Internal("DEK must be 32 bytes".into())); return Err(AppError::Internal("DEK must be 32 bytes".into()));
@@ -64,29 +64,35 @@ impl DekManager {
// 缓存(版本从外部传入时无法确定,使用默认值 1 // 缓存(版本从外部传入时无法确定,使用默认值 1
self.evict_if_full(); self.evict_if_full();
self.cache.insert(tenant_id, CachedDek { self.cache.insert(
dek, tenant_id,
version: 1, CachedDek {
loaded_at: Instant::now(), dek,
}); version: 1,
loaded_at: Instant::now(),
},
);
return Ok((dek, 1)); return Ok((dek, 1));
} }
// 无现有 DEK → 生成新的 // 无现有 DEK → 生成新的
let dek = Self::generate_dek(); let dek = Self::generate_dek();
self.evict_if_full(); self.evict_if_full();
self.cache.insert(tenant_id, CachedDek { self.cache.insert(
dek, tenant_id,
version: 1, CachedDek {
loaded_at: Instant::now(), dek,
}); version: 1,
loaded_at: Instant::now(),
},
);
Ok((dek, 1)) Ok((dek, 1))
} }
/// 使用 KEK 加密 DEK 以便存储 /// 使用 KEK 加密 DEK 以便存储
pub fn encrypt_dek_for_storage(dek: &[u8; 32], kek: &[u8; 32]) -> AppResult<String> { pub fn encrypt_dek_for_storage(dek: &[u8; 32], kek: &[u8; 32]) -> AppResult<String> {
let dek_hex = hex::encode(dek); 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) /// 生成新 DEK 并用 KEK 加密,返回 (新 DEK, 加密后的 DEK)
@@ -110,7 +116,8 @@ impl DekManager {
fn evict_if_full(&self) { fn evict_if_full(&self) {
if self.cache.len() >= self.max_entries { if self.cache.len() >= self.max_entries {
let to_remove: Vec<Uuid> = self.cache let to_remove: Vec<Uuid> = self
.cache
.iter() .iter()
.filter(|e| e.loaded_at.elapsed().as_secs() > self.ttl_secs / 2) .filter(|e| e.loaded_at.elapsed().as_secs() > self.ttl_secs / 2)
.map(|e| *e.key()) .map(|e| *e.key())
@@ -156,7 +163,9 @@ mod tests {
let (original_dek, encrypted) = DekManager::generate_new_dek(&kek).unwrap(); let (original_dek, encrypted) = DekManager::generate_new_dek(&kek).unwrap();
let mgr = DekManager::new(300, 100); let mgr = DekManager::new(300, 100);
let tenant_id = test_uuid(1); 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); assert_eq!(original_dek, recovered_dek);
} }
@@ -188,7 +197,10 @@ mod tests {
let (_, encrypted) = DekManager::generate_new_dek(&kek1).unwrap(); let (_, encrypted) = DekManager::generate_new_dek(&kek1).unwrap();
let mgr = DekManager::new(300, 100); let mgr = DekManager::new(300, 100);
let tenant_id = test_uuid(4); 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] #[test]
@@ -204,7 +216,9 @@ mod tests {
fn max_entries_eviction() { fn max_entries_eviction() {
let mgr = DekManager::new(300, 3); let mgr = DekManager::new(300, 3);
for i in 0..5u8 { 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); assert!(mgr.cache.len() <= 6);
} }

View File

@@ -57,7 +57,10 @@ mod tests {
#[test] #[test]
fn mask_phone_normal() { 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] #[test]
@@ -87,7 +90,10 @@ mod tests {
#[test] #[test]
fn mask_phone_unicode_safe() { 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] #[test]

View File

@@ -5,8 +5,8 @@ pub mod masking;
pub use engine::{decrypt, encrypt}; pub use engine::{decrypt, encrypt};
pub use hmac_index::hmac_hash; pub use hmac_index::hmac_hash;
pub use masking::{mask_id_number, mask_license_number, mask_phone};
pub use key_manager::DekManager; pub use key_manager::DekManager;
pub use masking::{mask_id_number, mask_license_number, mask_phone};
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
@@ -21,10 +21,12 @@ pub struct PiiCrypto {
impl PiiCrypto { impl PiiCrypto {
/// 从 hex 编码的 KEK 创建。KEK 为 64 字符 hex32 字节)。 /// 从 hex 编码的 KEK 创建。KEK 为 64 字符 hex32 字节)。
pub fn from_kek_hex(kek_hex: &str) -> AppResult<Self> { pub fn from_kek_hex(kek_hex: &str) -> AppResult<Self> {
let bytes = let bytes = hex::decode(kek_hex)
hex::decode(kek_hex).map_err(|e| AppError::Internal(format!("KEK hex decode failed: {}", e)))?; .map_err(|e| AppError::Internal(format!("KEK hex decode failed: {}", e)))?;
if bytes.len() != 32 { 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]; let mut kek = [0u8; 32];
kek.copy_from_slice(&bytes); kek.copy_from_slice(&bytes);
@@ -44,7 +46,7 @@ impl PiiCrypto {
use sha2::Digest; use sha2::Digest;
let hmac_key = <sha2::Sha256 as Digest>::new() let hmac_key = <sha2::Sha256 as Digest>::new()
.chain_update(b"pii-hmac-index-v1") .chain_update(b"pii-hmac-index-v1")
.chain_update(&kek) .chain_update(kek)
.finalize(); .finalize();
let mut hk = [0u8; 32]; let mut hk = [0u8; 32];
hk.copy_from_slice(&hmac_key); hk.copy_from_slice(&hmac_key);
@@ -172,7 +174,9 @@ mod tests {
let crypto = test_crypto(); let crypto = test_crypto();
let encrypted = encrypt(crypto.kek(), "test").unwrap(); let encrypted = encrypt(crypto.kek(), "test").unwrap();
use base64::Engine; 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"); assert_eq!(bytes[0], 0x01, "密文首字节应为版本号 0x01");
} }
@@ -189,11 +193,7 @@ mod tests {
} }
let elapsed = start.elapsed(); let elapsed = start.elapsed();
let avg_us = elapsed.as_micros() / 1000; let avg_us = elapsed.as_micros() / 1000;
assert!( assert!(avg_us < 50, "encrypt 平均耗时应 < 50μs, 实际: {}μs", avg_us);
avg_us < 50,
"encrypt 平均耗时应 < 50μs, 实际: {}μs",
avg_us
);
eprintln!("encrypt 1000 次: {:?} (avg {}μs)", elapsed, avg_us); eprintln!("encrypt 1000 次: {:?} (avg {}μs)", elapsed, avg_us);
} }
@@ -208,11 +208,7 @@ mod tests {
} }
let elapsed = start.elapsed(); let elapsed = start.elapsed();
let avg_us = elapsed.as_micros() / 1000; let avg_us = elapsed.as_micros() / 1000;
assert!( assert!(avg_us < 50, "decrypt 平均耗时应 < 50μs, 实际: {}μs", avg_us);
avg_us < 50,
"decrypt 平均耗时应 < 50μs, 实际: {}μs",
avg_us
);
eprintln!("decrypt 1000 次: {:?} (avg {}μs)", elapsed, avg_us); eprintln!("decrypt 1000 次: {:?} (avg {}μs)", elapsed, avg_us);
} }

View File

@@ -1,6 +1,6 @@
use axum::Json;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Serialize; use serde::Serialize;
/// 统一错误响应格式 /// 统一错误响应格式

View File

@@ -44,11 +44,11 @@ pub fn build_event_payload(data: serde_json::Value) -> serde_json::Value {
"schema_version": EVENT_SCHEMA_VERSION, "schema_version": EVENT_SCHEMA_VERSION,
"occurred_at": Utc::now().to_rfc3339(), "occurred_at": Utc::now().to_rfc3339(),
}); });
if let serde_json::Value::Object(ref mut map) = envelope { if let serde_json::Value::Object(ref mut map) = envelope
if let serde_json::Value::Object(data_map) = data { && let serde_json::Value::Object(data_map) = data
for (k, v) in data_map { {
map.insert(k, v); for (k, v) in data_map {
} map.insert(k, v);
} }
} }
envelope envelope
@@ -314,10 +314,10 @@ impl EventBus {
event = broadcast_rx.recv() => { event = broadcast_rx.recv() => {
match event { match event {
Ok(event) => { Ok(event) => {
if event.event_type.starts_with(&prefix) { if event.event_type.starts_with(&prefix)
if mpsc_tx.send(event).await.is_err() { && mpsc_tx.send(event).await.is_err()
break; {
} break;
} }
} }
Err(broadcast::error::RecvError::Lagged(n)) => { Err(broadcast::error::RecvError::Lagged(n)) => {

View File

@@ -9,11 +9,7 @@ use crate::error::AppResult;
#[async_trait] #[async_trait]
pub trait HealthDataProvider: Send + Sync { pub trait HealthDataProvider: Send + Sync {
/// 获取化验报告(指标列表) /// 获取化验报告(指标列表)
async fn get_lab_report( async fn get_lab_report(&self, tenant_id: Uuid, report_id: Uuid) -> AppResult<LabReportDto>;
&self,
tenant_id: Uuid,
report_id: Uuid,
) -> AppResult<LabReportDto>;
/// 获取生命体征趋势数据 /// 获取生命体征趋势数据
async fn get_vital_signs( async fn get_vital_signs(
@@ -32,11 +28,8 @@ pub trait HealthDataProvider: Send + Sync {
) -> AppResult<PatientSummaryDto>; ) -> AppResult<PatientSummaryDto>;
/// 获取完整健康报告(用于摘要生成) /// 获取完整健康报告(用于摘要生成)
async fn get_full_report( async fn get_full_report(&self, tenant_id: Uuid, report_id: Uuid)
&self, -> AppResult<HealthReportDto>;
tenant_id: Uuid,
report_id: Uuid,
) -> AppResult<HealthReportDto>;
/// 获取趋势分析预计算数据(统计摘要 + 异常检测) /// 获取趋势分析预计算数据(统计摘要 + 异常检测)
async fn get_trend_analysis_data( async fn get_trend_analysis_data(

View File

@@ -2,7 +2,7 @@
/// ///
/// 基于 ammoniahtml5ever剥离所有 HTML 标签,防止存储型 XSS。 /// 基于 ammoniahtml5ever剥离所有 HTML 标签,防止存储型 XSS。
/// 覆盖场景:用户名、显示名、邮箱、电话等字符串字段。 /// 覆盖场景:用户名、显示名、邮箱、电话等字符串字段。
///
/// 剥离字符串中的所有 HTML 标签,返回纯文本。 /// 剥离字符串中的所有 HTML 标签,返回纯文本。
/// ///
/// 使用 ammonia 构建 DOM 树,然后用 tendril 收集文本节点。 /// 使用 ammonia 构建 DOM 树,然后用 tendril 收集文本节点。

View File

@@ -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 std::sync::OnceLock;
use tokio::sync::OnceCell; use tokio::sync::OnceCell;
@@ -22,12 +24,8 @@ fn db_url() -> String {
async fn db_pool() -> &'static DatabaseConnection { async fn db_pool() -> &'static DatabaseConnection {
DB_POOL DB_POOL
.get_or_init(|| async { .get_or_init(|| async {
let opt = ConnectOptions::new(db_url()) let opt = ConnectOptions::new(db_url()).max_connections(5).to_owned();
.max_connections(5) Database::connect(opt).await.expect("测试数据库连接失败")
.to_owned();
Database::connect(opt)
.await
.expect("测试数据库连接失败")
}) })
.await .await
} }
@@ -35,7 +33,5 @@ async fn db_pool() -> &'static DatabaseConnection {
/// 创建测试用事务。测试结束自动回滚,无数据残留。 /// 创建测试用事务。测试结束自动回滚,无数据残留。
pub async fn test_txn() -> DatabaseTransaction { pub async fn test_txn() -> DatabaseTransaction {
let pool = db_pool().await; let pool = db_pool().await;
pool.begin() pool.begin().await.expect("测试事务创建失败")
.await
.expect("测试事务创建失败")
} }

View File

@@ -164,7 +164,7 @@ pub enum DataScope {
} }
impl DataScope { impl DataScope {
pub fn from_str(s: &str) -> Self { pub fn parse_scope(s: &str) -> Self {
match s { match s {
"self" => Self::SelfOnly, "self" => Self::SelfOnly,
"department" => Self::Department, "department" => Self::Department,

View File

@@ -1,4 +1,3 @@
/// 预留事件处理器注册 /// 预留事件处理器注册
pub fn register_handlers_with_state(_state: crate::state::DialysisState) { pub fn register_handlers_with_state(_state: crate::state::DialysisState) {
// 透析业务事件由 erp-health 统一消费(见 erp-health/src/event.rs:425 dialysis_notifier // 透析业务事件由 erp-health 统一消费(见 erp-health/src/event.rs:425 dialysis_notifier

View File

@@ -8,8 +8,8 @@ use erp_core::error::AppError;
use erp_core::rbac::require_permission; use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::dialysis_dto::*;
use crate::dto::DeleteWithVersion; use crate::dto::DeleteWithVersion;
use crate::dto::dialysis_dto::*;
use crate::service::dialysis_service; use crate::service::dialysis_service;
use crate::state::DialysisState; use crate::state::DialysisState;
@@ -44,10 +44,9 @@ where
require_permission(&ctx, "health.dialysis.list")?; require_permission(&ctx, "health.dialysis.list")?;
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = dialysis_service::list_dialysis_records( let result =
&state, ctx.tenant_id, patient_id, page, page_size, dialysis_service::list_dialysis_records(&state, ctx.tenant_id, patient_id, page, page_size)
) .await?;
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -61,10 +60,7 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.dialysis.list")?; require_permission(&ctx, "health.dialysis.list")?;
let result = dialysis_service::get_dialysis_record( let result = dialysis_service::get_dialysis_record(&state, ctx.tenant_id, record_id).await?;
&state, ctx.tenant_id, record_id,
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -80,10 +76,9 @@ where
require_permission(&ctx, "health.dialysis.manage")?; require_permission(&ctx, "health.dialysis.manage")?;
let mut req = req; let mut req = req;
req.sanitize(); req.sanitize();
let result = dialysis_service::create_dialysis_record( let result =
&state, ctx.tenant_id, Some(ctx.user_id), req, dialysis_service::create_dialysis_record(&state, ctx.tenant_id, Some(ctx.user_id), req)
) .await?;
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -101,7 +96,12 @@ where
let mut data = req.data; let mut data = req.data;
data.sanitize(); data.sanitize();
let result = dialysis_service::update_dialysis_record( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -119,7 +119,11 @@ where
{ {
require_permission(&ctx, "health.dialysis.manage")?; require_permission(&ctx, "health.dialysis.manage")?;
let result = dialysis_service::review_dialysis_record( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -137,7 +141,11 @@ where
{ {
require_permission(&ctx, "health.dialysis.manage")?; require_permission(&ctx, "health.dialysis.manage")?;
let result = dialysis_service::complete_dialysis_record( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -155,7 +163,11 @@ where
{ {
require_permission(&ctx, "health.dialysis.manage")?; require_permission(&ctx, "health.dialysis.manage")?;
dialysis_service::delete_dialysis_record( 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?; .await?;
Ok(Json(ApiResponse::ok(()))) Ok(Json(ApiResponse::ok(())))

View File

@@ -8,8 +8,8 @@ use erp_core::error::AppError;
use erp_core::rbac::require_permission; use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::dialysis_prescription_dto::*;
use crate::dto::DeleteWithVersion; use crate::dto::DeleteWithVersion;
use crate::dto::dialysis_prescription_dto::*;
use crate::service::dialysis_prescription_service; use crate::service::dialysis_prescription_service;
use crate::state::DialysisState; use crate::state::DialysisState;
@@ -41,7 +41,12 @@ where
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = dialysis_prescription_service::list_prescriptions( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -74,7 +79,10 @@ where
let mut req = req; let mut req = req;
req.sanitize(); req.sanitize();
let result = dialysis_prescription_service::create_prescription( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -94,7 +102,12 @@ where
let mut data = req.data; let mut data = req.data;
data.sanitize(); data.sanitize();
let result = dialysis_prescription_service::update_prescription( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -112,7 +125,11 @@ where
{ {
require_permission(&ctx, "health.dialysis-prescription.manage")?; require_permission(&ctx, "health.dialysis-prescription.manage")?;
dialysis_prescription_service::delete_prescription( 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?; .await?;
Ok(Json(ApiResponse::ok(()))) Ok(Json(ApiResponse::ok(())))

View File

@@ -1,5 +1,5 @@
use axum::extract::{Extension, FromRef, State};
use axum::Json; use axum::Json;
use axum::extract::{Extension, FromRef, State};
use erp_core::error::AppError; use erp_core::error::AppError;
use erp_core::rbac::require_permission; use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext}; use erp_core::types::{ApiResponse, TenantContext};
@@ -18,6 +18,7 @@ where
{ {
require_permission(&ctx, "health.dialysis.stats")?; require_permission(&ctx, "health.dialysis.stats")?;
let dialysis_state = DialysisState::from_ref(&state); 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))) Ok(Json(ApiResponse::ok(stats)))
} }

View File

@@ -49,7 +49,13 @@ pub async fn list_prescriptions(
let total_pages = total.div_ceil(limit.max(1)); let total_pages = total.div_ceil(limit.max(1));
let data = models.into_iter().map(model_to_resp).collect(); 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( pub async fn get_prescription(
@@ -85,14 +91,24 @@ pub async fn create_prescription(
tenant_id: Set(tenant_id), tenant_id: Set(tenant_id),
patient_id: Set(req.patient_id), patient_id: Set(req.patient_id),
dialyzer_model: Set(req.dialyzer_model), dialyzer_model: Set(req.dialyzer_model),
membrane_area: Set(req.membrane_area.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())), membrane_area: Set(req
dialysate_potassium: Set(req.dialysate_potassium.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())), .membrane_area
dialysate_calcium: Set(req.dialysate_calcium.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())), .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())), 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_type: Set(req.anticoagulation_type),
anticoagulation_dose: Set(req.anticoagulation_dose), anticoagulation_dose: Set(req.anticoagulation_dose),
target_ultrafiltration_ml: Set(req.target_ultrafiltration_ml), 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), blood_flow_rate: Set(req.blood_flow_rate),
dialysate_flow_rate: Set(req.dialysate_flow_rate), dialysate_flow_rate: Set(req.dialysate_flow_rate),
frequency_per_week: Set(req.frequency_per_week), frequency_per_week: Set(req.frequency_per_week),
@@ -114,10 +130,16 @@ pub async fn create_prescription(
let m = active.insert(&state.db).await?; let m = active.insert(&state.db).await?;
audit_service::record( audit_service::record(
AuditLog::new(tenant_id, operator_id, "dialysis_prescription.created", "dialysis_prescription") AuditLog::new(
.with_resource_id(m.id), tenant_id,
operator_id,
"dialysis_prescription.created",
"dialysis_prescription",
)
.with_resource_id(m.id),
&state.db, &state.db,
).await; )
.await;
Ok(model_to_resp(m)) Ok(model_to_resp(m))
} }
@@ -141,29 +163,71 @@ pub async fn update_prescription(
let next_ver = check_version(expected_version, model.version) let next_ver = check_version(expected_version, model.version)
.map_err(|_| DialysisError::VersionMismatch)?; .map_err(|_| DialysisError::VersionMismatch)?;
if let Some(ref t) = req.anticoagulation_type { validate_anticoagulation_type(Some(t))?; } if let Some(ref t) = req.anticoagulation_type {
if let Some(ref t) = req.vascular_access_type { validate_vascular_access_type(Some(t))?; } 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(); 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.dialyzer_model {
if let Some(v) = req.membrane_area { active.membrane_area = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); } active.dialyzer_model = Set(Some(v));
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.membrane_area {
if let Some(v) = req.dialysate_bicarbonate { active.dialysate_bicarbonate = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); } active.membrane_area = 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.dialysate_potassium {
if let Some(v) = req.target_ultrafiltration_ml { active.target_ultrafiltration_ml = Set(Some(v)); } active.dialysate_potassium = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default()));
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_calcium {
if let Some(v) = req.dialysate_flow_rate { active.dialysate_flow_rate = Set(Some(v)); } active.dialysate_calcium = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default()));
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.dialysate_bicarbonate {
if let Some(v) = req.vascular_access_type { active.vascular_access_type = Set(Some(v)); } active.dialysate_bicarbonate = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default()));
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.anticoagulation_type {
if let Some(v) = req.effective_to { active.effective_to = Set(Some(v)); } active.anticoagulation_type = 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.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_at = Set(Utc::now());
active.updated_by = Set(operator_id); active.updated_by = Set(operator_id);
active.version = Set(next_ver); active.version = Set(next_ver);
@@ -171,10 +235,16 @@ pub async fn update_prescription(
let m = active.update(&state.db).await?; let m = active.update(&state.db).await?;
audit_service::record( audit_service::record(
AuditLog::new(tenant_id, operator_id, "dialysis_prescription.updated", "dialysis_prescription") AuditLog::new(
.with_resource_id(m.id), tenant_id,
operator_id,
"dialysis_prescription.updated",
"dialysis_prescription",
)
.with_resource_id(m.id),
&state.db, &state.db,
).await; )
.await;
Ok(model_to_resp(m)) Ok(model_to_resp(m))
} }
@@ -205,10 +275,16 @@ pub async fn delete_prescription(
active.update(&state.db).await?; active.update(&state.db).await?;
audit_service::record( audit_service::record(
AuditLog::new(tenant_id, operator_id, "dialysis_prescription.deleted", "dialysis_prescription") AuditLog::new(
.with_resource_id(id), tenant_id,
operator_id,
"dialysis_prescription.deleted",
"dialysis_prescription",
)
.with_resource_id(id),
&state.db, &state.db,
).await; )
.await;
Ok(()) Ok(())
} }
@@ -252,7 +328,8 @@ fn validate_anticoagulation_type(val: Option<&str>) -> DialysisResult<()> {
let valid = ["heparin", "lmwh", "heparin_free"]; let valid = ["heparin", "lmwh", "heparin_free"];
if !valid.contains(&t) { if !valid.contains(&t) {
return Err(DialysisError::Validation(format!( 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"]; let valid = ["avf", "avg", "cvc"];
if !valid.contains(&t) { if !valid.contains(&t) {
return Err(DialysisError::Validation(format!( return Err(DialysisError::Validation(format!(
"vascular_access_type 必须为: {}", valid.join(", ") "vascular_access_type 必须为: {}",
valid.join(", ")
))); )));
} }
} }

View File

@@ -45,7 +45,13 @@ pub async fn list_dialysis_records(
let crypto = &state.crypto; let crypto = &state.crypto;
let data: Vec<DialysisRecordResp> = models.into_iter().map(|m| to_resp(crypto, m)).collect(); 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( pub async fn get_dialysis_record(
@@ -92,15 +98,19 @@ pub async fn create_dialysis_record(
let kek = state.crypto.kek(); let kek = state.crypto.kek();
// PII 加密 // PII 加密
let encrypted_symptoms = req.symptoms.as_ref() let encrypted_symptoms = req
.symptoms
.as_ref()
.map(|v| -> DialysisResult<serde_json::Value> { .map(|v| -> DialysisResult<serde_json::Value> {
let json_str = serde_json::to_string(v) let json_str =
.map_err(|e| DialysisError::Validation(e.to_string()))?; serde_json::to_string(v).map_err(|e| DialysisError::Validation(e.to_string()))?;
Ok(serde_json::Value::String(pii::encrypt(kek, &json_str)?)) Ok(serde_json::Value::String(pii::encrypt(kek, &json_str)?))
}) })
.transpose()?; .transpose()?;
let encrypted_complication = req.complication_notes.as_ref() let encrypted_complication = req
.complication_notes
.as_ref()
.map(|c| pii::encrypt(kek, c)) .map(|c| pii::encrypt(kek, c))
.transpose()?; .transpose()?;
@@ -112,9 +122,15 @@ pub async fn create_dialysis_record(
dialysis_date: Set(req.dialysis_date), dialysis_date: Set(req.dialysis_date),
start_time: Set(req.start_time), start_time: Set(req.start_time),
end_time: Set(req.end_time), end_time: Set(req.end_time),
dry_weight: Set(req.dry_weight.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())), dry_weight: Set(req
pre_weight: Set(req.pre_weight.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())), .dry_weight
post_weight: Set(req.post_weight.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())), .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_systolic: Set(req.pre_bp_systolic),
pre_bp_diastolic: Set(req.pre_bp_diastolic), pre_bp_diastolic: Set(req.pre_bp_diastolic),
post_bp_systolic: Set(req.post_bp_systolic), 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?; let m = active.insert(&state.db).await?;
audit_service::record( audit_service::record(
AuditLog::new(tenant_id, operator_id, "dialysis_record.created", "dialysis_record") AuditLog::new(
.with_resource_id(m.id), tenant_id,
operator_id,
"dialysis_record.created",
"dialysis_record",
)
.with_resource_id(m.id),
&state.db, &state.db,
).await; )
.await;
// 发布透析记录创建事件 // 发布透析记录创建事件
let event = DomainEvent::new( let event = DomainEvent::new(
@@ -182,27 +204,61 @@ pub async fn update_dialysis_record(
.map_err(|_| DialysisError::VersionMismatch)?; .map_err(|_| DialysisError::VersionMismatch)?;
let mut active: dialysis_record::ActiveModel = model.into(); 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.dialysis_date {
if let Some(v) = req.start_time { active.start_time = Set(Some(v)); } active.dialysis_date = Set(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.start_time {
if let Some(v) = req.pre_weight { active.pre_weight = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); } active.start_time = Set(Some(v));
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.end_time {
if let Some(v) = req.pre_bp_diastolic { active.pre_bp_diastolic = Set(Some(v)); } active.end_time = 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.dry_weight {
if let Some(v) = req.pre_heart_rate { active.pre_heart_rate = Set(Some(v)); } active.dry_weight = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default()));
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.pre_weight {
if let Some(v) = req.dialysis_duration { active.dialysis_duration = Set(Some(v)); } active.pre_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(ref v) = req.dialysis_type { validate_dialysis_type(v)?; active.dialysis_type = Set(v.clone()); } 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 { if let Some(v) = req.symptoms {
let kek = state.crypto.kek(); let kek = state.crypto.kek();
let encrypted = Some(serde_json::Value::String( let encrypted = Some(serde_json::Value::String(pii::encrypt(
pii::encrypt(kek, &serde_json::to_string(&v).unwrap_or_default())? kek,
)); &serde_json::to_string(&v).unwrap_or_default(),
)?));
active.symptoms = Set(encrypted); active.symptoms = Set(encrypted);
} }
if let Some(v) = req.complication_notes { if let Some(v) = req.complication_notes {
@@ -218,10 +274,16 @@ pub async fn update_dialysis_record(
let m = active.update(&state.db).await?; let m = active.update(&state.db).await?;
audit_service::record( audit_service::record(
AuditLog::new(tenant_id, operator_id, "dialysis_record.updated", "dialysis_record") AuditLog::new(
.with_resource_id(m.id), tenant_id,
operator_id,
"dialysis_record.updated",
"dialysis_record",
)
.with_resource_id(m.id),
&state.db, &state.db,
).await; )
.await;
Ok(to_resp(&state.crypto, m)) Ok(to_resp(&state.crypto, m))
} }
@@ -255,10 +317,16 @@ pub async fn complete_dialysis_record(
let m = active.update(&state.db).await?; let m = active.update(&state.db).await?;
audit_service::record( audit_service::record(
AuditLog::new(tenant_id, operator_id, "dialysis_record.completed", "dialysis_record") AuditLog::new(
.with_resource_id(m.id), tenant_id,
operator_id,
"dialysis_record.completed",
"dialysis_record",
)
.with_resource_id(m.id),
&state.db, &state.db,
).await; )
.await;
Ok(to_resp(&state.crypto, m)) Ok(to_resp(&state.crypto, m))
} }
@@ -294,10 +362,16 @@ pub async fn review_dialysis_record(
let m = active.update(&state.db).await?; let m = active.update(&state.db).await?;
audit_service::record( audit_service::record(
AuditLog::new(tenant_id, Some(reviewer_id), "dialysis_record.reviewed", "dialysis_record") AuditLog::new(
.with_resource_id(m.id), tenant_id,
Some(reviewer_id),
"dialysis_record.reviewed",
"dialysis_record",
)
.with_resource_id(m.id),
&state.db, &state.db,
).await; )
.await;
Ok(to_resp(&state.crypto, m)) Ok(to_resp(&state.crypto, m))
} }
@@ -328,10 +402,16 @@ pub async fn delete_dialysis_record(
active.update(&state.db).await?; active.update(&state.db).await?;
audit_service::record( audit_service::record(
AuditLog::new(tenant_id, operator_id, "dialysis_record.deleted", "dialysis_record") AuditLog::new(
.with_resource_id(record_id), tenant_id,
operator_id,
"dialysis_record.deleted",
"dialysis_record",
)
.with_resource_id(record_id),
&state.db, &state.db,
).await; )
.await;
Ok(()) Ok(())
} }
@@ -345,7 +425,8 @@ fn validate_dialysis_type(dialysis_type: &str) -> DialysisResult<()> {
match dialysis_type { match dialysis_type {
"HD" | "HDF" | "HF" => Ok(()), "HD" | "HDF" | "HF" => Ok(()),
_ => Err(DialysisError::Validation(format!( _ => 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(()) Ok(())
} else { } else {
Err(DialysisError::InvalidStatusTransition(format!( 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(); let kek = crypto.kek();
// 解密症状 JSON加密时存储为 Value::String(ciphertext) // 解密症状 JSON加密时存储为 Value::String(ciphertext)
let symptoms = m.symptoms.as_ref() let symptoms = m
.symptoms
.as_ref()
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.and_then(|s| pii::decrypt(kek, s).ok()) .and_then(|s| pii::decrypt(kek, s).ok())
.and_then(|s| serde_json::from_str(&s).ok()) .and_then(|s| serde_json::from_str(&s).ok())
.or(m.symptoms); .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())) .map(|c| pii::decrypt(kek, c).unwrap_or_else(|_| c.clone()))
.or(m.complication_notes); .or(m.complication_notes);
@@ -421,25 +507,45 @@ mod tests {
// --- validate_dialysis_type --- // --- validate_dialysis_type ---
#[test] #[test]
fn dialysis_type_hd() { assert!(validate_dialysis_type("HD").is_ok()); } fn dialysis_type_hd() {
assert!(validate_dialysis_type("HD").is_ok());
}
#[test] #[test]
fn dialysis_type_hdf() { assert!(validate_dialysis_type("HDF").is_ok()); } fn dialysis_type_hdf() {
assert!(validate_dialysis_type("HDF").is_ok());
}
#[test] #[test]
fn dialysis_type_hf() { assert!(validate_dialysis_type("HF").is_ok()); } fn dialysis_type_hf() {
assert!(validate_dialysis_type("HF").is_ok());
}
#[test] #[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 --- // --- validate_dialysis_status_transition ---
#[test] #[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] #[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] #[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] #[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] #[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] #[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());
}
} }

View File

@@ -2,7 +2,7 @@ use sea_orm::{DatabaseBackend, FromQueryResult, Statement};
use uuid::Uuid; use uuid::Uuid;
use crate::dto::dialysis_stats_dto::{DialysisStatisticsResp, NameValue}; use crate::dto::dialysis_stats_dto::{DialysisStatisticsResp, NameValue};
use crate::error::{DialysisResult, DialysisError}; use crate::error::{DialysisError, DialysisResult};
use crate::state::DialysisState; use crate::state::DialysisState;
pub async fn get_dialysis_statistics( pub async fn get_dialysis_statistics(
@@ -12,7 +12,9 @@ pub async fn get_dialysis_statistics(
let db = &state.db; let db = &state.db;
#[derive(FromQueryResult)] #[derive(FromQueryResult)]
struct CountRow { count: i64 } struct CountRow {
count: i64,
}
let total_records = CountRow::find_by_statement(Statement::from_sql_and_values( let total_records = CountRow::find_by_statement(Statement::from_sql_and_values(
DatabaseBackend::Postgres, DatabaseBackend::Postgres,
@@ -33,12 +35,14 @@ pub async fn get_dialysis_statistics(
)).one(db).await?.map(|r| r.count).unwrap_or(0); )).one(db).await?.map(|r| r.count).unwrap_or(0);
let type_distribution = count_by_field( let type_distribution = count_by_field(
db, tenant_id, db,
tenant_id,
"SELECT dialysis_type AS name, COUNT(*) AS value FROM dialysis_record \ "SELECT dialysis_type AS name, COUNT(*) AS value FROM dialysis_record \
WHERE tenant_id = $1 AND deleted_at IS NULL \ WHERE tenant_id = $1 AND deleted_at IS NULL \
AND created_at >= date_trunc('month', NOW()) \ AND created_at >= date_trunc('month', NOW()) \
GROUP BY dialysis_type ORDER BY value DESC", GROUP BY dialysis_type ORDER BY value DESC",
).await?; )
.await?;
let complication_rate = compute_complication_rate(db, tenant_id).await?; let complication_rate = compute_complication_rate(db, tenant_id).await?;
let avg_ultrafiltration = compute_avg_field(db, tenant_id, "ultrafiltration_volume").await?; let avg_ultrafiltration = compute_avg_field(db, tenant_id, "ultrafiltration_volume").await?;
@@ -61,7 +65,10 @@ async fn count_by_field(
sql: &str, sql: &str,
) -> DialysisResult<Vec<NameValue>> { ) -> DialysisResult<Vec<NameValue>> {
#[derive(FromQueryResult)] #[derive(FromQueryResult)]
struct NameValueRow { name: String, value: i64 } struct NameValueRow {
name: String,
value: i64,
}
let rows: Vec<NameValueRow> = FromQueryResult::find_by_statement( let rows: Vec<NameValueRow> = FromQueryResult::find_by_statement(
Statement::from_sql_and_values(DatabaseBackend::Postgres, sql, [tenant_id.into()]), Statement::from_sql_and_values(DatabaseBackend::Postgres, sql, [tenant_id.into()]),
@@ -69,17 +76,29 @@ async fn count_by_field(
.all(db) .all(db)
.await?; .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)] #[derive(Debug, FromQueryResult)]
struct AvgFieldResult { avg_val: Option<f64> } struct AvgFieldResult {
avg_val: Option<f64>,
}
macro_rules! avg_field_sql { macro_rules! avg_field_sql {
($field:literal) => { ($field:literal) => {
concat!( concat!(
"SELECT AVG(", $field, ")::FLOAT8 AS avg_val FROM dialysis_record ", "SELECT AVG(",
"WHERE tenant_id = $1 AND deleted_at IS NULL AND ", $field, " IS NOT NULL ", $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())" "AND created_at >= date_trunc('month', NOW())"
) )
}; };
@@ -94,7 +113,11 @@ async fn compute_avg_field(
"ultrafiltration_volume" => avg_field_sql!("ultrafiltration_volume"), "ultrafiltration_volume" => avg_field_sql!("ultrafiltration_volume"),
"dialysis_duration" => avg_field_sql!("dialysis_duration"), "dialysis_duration" => avg_field_sql!("dialysis_duration"),
"blood_flow_rate" => avg_field_sql!("blood_flow_rate"), "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( let result: Option<AvgFieldResult> = FromQueryResult::find_by_statement(
Statement::from_sql_and_values(DatabaseBackend::Postgres, sql, [tenant_id.into()]), Statement::from_sql_and_values(DatabaseBackend::Postgres, sql, [tenant_id.into()]),
@@ -119,7 +142,10 @@ async fn compute_complication_rate(
"#; "#;
#[derive(Debug, FromQueryResult)] #[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( let result: Option<CompResult> = FromQueryResult::find_by_statement(
Statement::from_sql_and_values(DatabaseBackend::Postgres, sql, [tenant_id.into()]), Statement::from_sql_and_values(DatabaseBackend::Postgres, sql, [tenant_id.into()]),

View File

@@ -1,3 +1,3 @@
pub mod dialysis_service;
pub mod dialysis_prescription_service; pub mod dialysis_prescription_service;
pub mod dialysis_service;
pub mod dialysis_stats_service; pub mod dialysis_stats_service;

View File

@@ -1,6 +1,6 @@
use aes_gcm::aead::Aead; use aes_gcm::aead::Aead;
use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; 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 hmac::{Hmac, Mac};
use sha2::Sha256; use sha2::Sha256;
use zeroize::Zeroizing; use zeroize::Zeroizing;

View File

@@ -119,7 +119,9 @@ pub struct UpdateArticleReq {
impl UpdateArticleReq { impl UpdateArticleReq {
pub fn sanitize(&mut self) { 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.summary = sanitize_option(self.summary.take());
self.content = sanitize_option(self.content.take()); self.content = sanitize_option(self.content.take());
self.category = sanitize_option(self.category.take()); self.category = sanitize_option(self.category.take());
@@ -205,7 +207,9 @@ pub struct UpdateCategoryReq {
impl UpdateCategoryReq { impl UpdateCategoryReq {
pub fn sanitize(&mut self) { 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.slug = sanitize_option(self.slug.take());
self.description = sanitize_option(self.description.take()); self.description = sanitize_option(self.description.take());
} }

View File

@@ -26,8 +26,12 @@ impl CreateDiagnosisReq {
} }
} }
fn default_diagnosis_type() -> String { "primary".to_string() } fn default_diagnosis_type() -> String {
fn default_status() -> String { "active".to_string() } "primary".to_string()
}
fn default_status() -> String {
"active".to_string()
}
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateDiagnosisReq { pub struct UpdateDiagnosisReq {

View File

@@ -1,18 +1,18 @@
pub mod appointment_dto;
pub mod alert_dto; pub mod alert_dto;
pub mod appointment_dto;
pub mod article_dto;
pub mod ble_gateway_dto; pub mod ble_gateway_dto;
pub mod care_plan_dto; pub mod care_plan_dto;
pub mod article_dto;
pub mod consent_dto; pub mod consent_dto;
pub mod consultation_dto; pub mod consultation_dto;
pub mod daily_monitoring_dto; pub mod daily_monitoring_dto;
pub mod diagnosis_dto; pub mod diagnosis_dto;
pub mod medication_record_dto;
pub mod medication_reminder_dto;
pub mod doctor_dto; pub mod doctor_dto;
pub mod follow_up_dto; pub mod follow_up_dto;
pub mod follow_up_template_dto; pub mod follow_up_template_dto;
pub mod health_data_dto; pub mod health_data_dto;
pub mod medication_record_dto;
pub mod medication_reminder_dto;
pub mod patient_dto; pub mod patient_dto;
pub mod points_dto; pub mod points_dto;
pub mod shift_dto; pub mod shift_dto;

View File

@@ -39,7 +39,10 @@ pub struct CreateShiftReq {
impl CreateShiftReq { impl CreateShiftReq {
pub fn sanitize(&mut self) { pub fn sanitize(&mut self) {
self.period = erp_core::sanitize::sanitize_string(&self.period); 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));
} }
} }

View File

@@ -1,20 +1,23 @@
pub mod alert_rules; pub mod alert_rules;
pub mod ble_gateway;
pub mod api_client;
pub mod alerts; pub mod alerts;
pub mod api_client;
pub mod appointment; pub mod appointment;
pub mod article; pub mod article;
pub mod article_article_tag; pub mod article_article_tag;
pub mod article_category; pub mod article_category;
pub mod article_revision; pub mod article_revision;
pub mod article_tag; pub mod article_tag;
pub mod ble_gateway;
pub mod blind_index; 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 consent;
pub mod consultation_message; pub mod consultation_message;
pub mod consultation_session; pub mod consultation_session;
pub mod critical_alert; pub mod critical_alert;
pub mod critical_alert_response; pub mod critical_alert_response;
pub mod critical_value_threshold;
pub mod daily_monitoring; pub mod daily_monitoring;
pub mod device_readings; pub mod device_readings;
pub mod diagnosis; pub mod diagnosis;
@@ -25,31 +28,28 @@ pub mod follow_up_task;
pub mod follow_up_template; pub mod follow_up_template;
pub mod follow_up_template_field; pub mod follow_up_template_field;
pub mod gateway_patient_binding; pub mod gateway_patient_binding;
pub mod handoff_log;
pub mod health_record; pub mod health_record;
pub mod health_trend; pub mod health_trend;
pub mod lab_report; 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;
pub mod patient_assignment;
pub mod patient_devices;
pub mod patient_doctor_relation; pub mod patient_doctor_relation;
pub mod patient_family_member; pub mod patient_family_member;
pub mod patient_tag; pub mod patient_tag;
pub mod patient_tag_relation; pub mod patient_tag_relation;
pub mod patient_devices;
pub mod points_account; pub mod points_account;
pub mod points_checkin; pub mod points_checkin;
pub mod points_order; pub mod points_order;
pub mod points_product; pub mod points_product;
pub mod points_rule; pub mod points_rule;
pub mod points_transaction; 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 shift;
pub mod patient_assignment; pub mod vital_signs;
pub mod handoff_log;
pub mod vital_signs_daily; pub mod vital_signs_daily;
pub mod vital_signs_hourly; pub mod vital_signs_hourly;

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
use crate::entity::{ use crate::entity::{
appointment, consultation_session, device_readings, doctor_profile, follow_up_task, appointment, consultation_session, device_readings, doctor_profile, follow_up_task, lab_report,
lab_report, patient, patient_devices, patient, patient_devices,
}; };
use crate::fhir::types::{device_type_to_category, device_type_to_loinc, device_type_to_unit}; 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> { pub fn device_reading_to_fhir_observations(r: &device_readings::Model) -> Vec<serde_json::Value> {
let mut results = Vec::new(); let mut results = Vec::new();
let patient_ref = serde_json::json!({"reference": format!("Patient/{}", r.patient_id)}); let patient_ref = serde_json::json!({"reference": format!("Patient/{}", r.patient_id)});
let device_ref = r.device_id.as_ref().map(|d| { let device_ref = r
serde_json::json!({"reference": format!("Device/{}", d)}) .device_id
}); .as_ref()
.map(|d| serde_json::json!({"reference": format!("Device/{}", d)}));
let measured = r.measured_at.to_rfc3339(); let measured = r.measured_at.to_rfc3339();
let category = device_type_to_category(&r.device_type); 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 { if let Some(val) = sys {
results.push(make_observation( results.push(make_observation(
&r.id, "8480-6", "Systolic blood pressure", &r.id,
category_json.clone(), &patient_ref, device_ref.as_ref(), "8480-6",
&measured, val, "mmHg", "mm[Hg]", "Systolic blood pressure",
category_json.clone(),
&patient_ref,
device_ref.as_ref(),
&measured,
val,
"mmHg",
"mm[Hg]",
)); ));
} }
if let Some(val) = dia { if let Some(val) = dia {
results.push(make_observation( results.push(make_observation(
&r.id, "8462-4", "Diastolic blood pressure", &r.id,
category_json, &patient_ref, device_ref.as_ref(), "8462-4",
&measured, val, "mmHg", "mm[Hg]", "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) let (loinc_code, loinc_display) =
.unwrap_or(("unknown", "Unknown")); device_type_to_loinc(&r.device_type).unwrap_or(("unknown", "Unknown"));
let (unit_display, unit_code) = device_type_to_unit(&r.device_type); let (unit_display, unit_code) = device_type_to_unit(&r.device_type);
let val = extract_main_value(&r.device_type, &r.raw_value); let val = extract_main_value(&r.device_type, &r.raw_value);
if let Some(v) = val { if let Some(v) = val {
results.push(make_observation( results.push(make_observation(
&r.id, loinc_code, loinc_display, &r.id,
category_json, &patient_ref, device_ref.as_ref(), loinc_code,
&measured, v, unit_display, unit_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 results
} }
#[allow(clippy::too_many_arguments)]
fn make_observation( fn make_observation(
reading_id: &uuid::Uuid, code: &str, display: &str, reading_id: &uuid::Uuid,
category: serde_json::Value, subject: &serde_json::Value, code: &str,
device: Option<&serde_json::Value>, effective: &str, display: &str,
value: f64, unit_display: &str, unit_code: &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 { ) -> serde_json::Value {
let mut obs = serde_json::json!({ let mut obs = serde_json::json!({
"resourceType": "Observation", "resourceType": "Observation",
@@ -234,12 +263,10 @@ pub fn appointment_to_fhir(a: &appointment::Model) -> serde_json::Value {
_ => "booked", _ => "booked",
}; };
let mut participants = vec![ let mut participants = vec![serde_json::json!({
serde_json::json!({ "actor": {"reference": format!("Patient/{}", a.patient_id)},
"actor": {"reference": format!("Patient/{}", a.patient_id)}, "status": "accepted",
"status": "accepted", })];
}),
];
if let Some(ref doctor_id) = a.doctor_id { if let Some(ref doctor_id) = a.doctor_id {
participants.push(serde_json::json!({ participants.push(serde_json::json!({
"actor": {"reference": format!("Practitioner/{}", doctor_id)}, "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", _ => "requested",
}; };
let display = t.content_template.as_deref() let display = t.content_template.as_deref().unwrap_or(&t.follow_up_type);
.unwrap_or(&t.follow_up_type);
serde_json::json!({ serde_json::json!({
"resourceType": "Task", "resourceType": "Task",
@@ -343,7 +369,12 @@ fn mask_sensitive(s: &str) -> String {
if s.len() <= 5 { if s.len() <= 5 {
"*".repeat(s.len()) "*".repeat(s.len())
} else { } else {
format!("{}{}{}", &s[..1], "*".repeat(s.len() - 5), &s[s.len() - 4..]) format!(
"{}{}{}",
&s[..1],
"*".repeat(s.len() - 5),
&s[s.len() - 4..]
)
} }
} }

View File

@@ -1,7 +1,7 @@
use axum::extract::{FromRef, Path, Query, State};
use axum::response::IntoResponse;
use axum::Extension; use axum::Extension;
use axum::Json; use axum::Json;
use axum::extract::{FromRef, Path, Query, State};
use axum::response::IntoResponse;
use sea_orm::*; use sea_orm::*;
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
@@ -78,11 +78,15 @@ fn enforce_patient_scope(fhir_ctx: &FhirAuthContext, patient_id: Uuid) -> Result
requested_patient = %patient_id, requested_patient = %patient_id,
"FHIR 客户端尝试访问授权范围外的患者" "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(()) Ok(())
@@ -112,7 +116,8 @@ pub async fn search_patients(
.filter(crate::entity::patient::Column::DeletedAt.is_null()); .filter(crate::entity::patient::Column::DeletedAt.is_null());
if let Some(ref id) = params.id { 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)); query = query.filter(crate::entity::patient::Column::Id.eq(uid));
} }
if let Some(ref name) = params.name { if let Some(ref name) = params.name {
@@ -123,21 +128,18 @@ pub async fn search_patients(
} }
// 强制执行 allowed_patient_ids 范围 // 强制执行 allowed_patient_ids 范围
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) { if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx)
if !uuids.is_empty() { && !uuids.is_empty()
query = query.filter(crate::entity::patient::Column::Id.is_in(uuids)); {
} query = query.filter(crate::entity::patient::Column::Id.is_in(uuids));
} }
let limit = params.count.unwrap_or(20).min(100); let limit = params.count.unwrap_or(20).min(100);
let offset = params.offset.unwrap_or(0); let offset = params.offset.unwrap_or(0);
let patients = query let patients = query.limit(limit).offset(offset).all(&state.db).await?;
.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)})) .map(|p| serde_json::json!({"resource": converter::patient_to_fhir(p)}))
.collect(); .collect();
@@ -189,46 +191,39 @@ pub async fn search_observations(
.map_err(|_| AppError::Validation("Invalid patient id".into()))?; .map_err(|_| AppError::Validation("Invalid patient id".into()))?;
query = query.filter(crate::entity::device_readings::Column::PatientId.eq(uid)); query = query.filter(crate::entity::device_readings::Column::PatientId.eq(uid));
} }
if let Some(ref code) = params.code { if let Some(ref code) = params.code
if let Some(dt) = loinc_to_device_type(code) { && let Some(dt) = loinc_to_device_type(code)
query = query.filter(crate::entity::device_readings::Column::DeviceType.eq(dt)); {
} query = query.filter(crate::entity::device_readings::Column::DeviceType.eq(dt));
} }
if let Some(ref category) = params.category { if let Some(ref category) = params.category {
let types = category_to_device_types(category); let types = category_to_device_types(category);
if !types.is_empty() { if !types.is_empty() {
query = query.filter( query = query.filter(crate::entity::device_readings::Column::DeviceType.is_in(types));
crate::entity::device_readings::Column::DeviceType.is_in(types)
);
} }
} }
if let Some(ref date) = params.date { if let Some(ref date) = params.date {
if let Some(after) = date.strip_prefix("gt") { if let Some(after) = date.strip_prefix("gt") {
if let Ok(dt) = after.parse::<chrono::DateTime<chrono::Utc>>() { if let Ok(dt) = after.parse::<chrono::DateTime<chrono::Utc>>() {
query = query.filter( query = query.filter(crate::entity::device_readings::Column::MeasuredAt.gt(dt));
crate::entity::device_readings::Column::MeasuredAt.gt(dt)
);
} }
} else if let Some(before) = date.strip_prefix("lt") { } else if let Some(before) = date.strip_prefix("lt") {
if let Ok(dt) = before.parse::<chrono::DateTime<chrono::Utc>>() { if let Ok(dt) = before.parse::<chrono::DateTime<chrono::Utc>>() {
query = query.filter( query = query.filter(crate::entity::device_readings::Column::MeasuredAt.lt(dt));
crate::entity::device_readings::Column::MeasuredAt.lt(dt)
);
} }
} else if let Ok(dt) = date.parse::<chrono::DateTime<chrono::Utc>>() { } 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 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(); let end = dt.date_naive().and_hms_opt(23, 59, 59).unwrap().and_utc();
query = query.filter( query = query
crate::entity::device_readings::Column::MeasuredAt.between(start, end) .filter(crate::entity::device_readings::Column::MeasuredAt.between(start, end));
);
} }
} }
// 强制执行 allowed_patient_ids 范围 // 强制执行 allowed_patient_ids 范围
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) { if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx)
if !uuids.is_empty() { && !uuids.is_empty()
query = query.filter(crate::entity::device_readings::Column::PatientId.is_in(uuids)); {
} query = query.filter(crate::entity::device_readings::Column::PatientId.is_in(uuids));
} }
let limit = params.count.unwrap_or(50).min(200); let limit = params.count.unwrap_or(50).min(200);
@@ -274,16 +269,17 @@ pub async fn search_devices(
} }
// 强制执行 allowed_patient_ids 范围 // 强制执行 allowed_patient_ids 范围
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) { if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx)
if !uuids.is_empty() { && !uuids.is_empty()
query = query.filter(crate::entity::patient_devices::Column::PatientId.is_in(uuids)); {
} query = query.filter(crate::entity::patient_devices::Column::PatientId.is_in(uuids));
} }
let limit = params.count.unwrap_or(50).min(200); let limit = params.count.unwrap_or(50).min(200);
let devices = query.limit(limit).all(&state.db).await?; 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)})) .map(|d| serde_json::json!({"resource": converter::patient_device_to_fhir(d)}))
.collect(); .collect();
@@ -337,7 +333,8 @@ pub async fn search_practitioners(
let limit = params.count.unwrap_or(50).min(200); let limit = params.count.unwrap_or(50).min(200);
let doctors = query.limit(limit).all(&state.db).await?; 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)})) .map(|d| serde_json::json!({"resource": converter::doctor_to_fhir(d)}))
.collect(); .collect();
@@ -392,10 +389,10 @@ pub async fn search_appointments(
} }
// 强制执行 allowed_patient_ids 范围 // 强制执行 allowed_patient_ids 范围
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) { if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx)
if !uuids.is_empty() { && !uuids.is_empty()
query = query.filter(crate::entity::appointment::Column::PatientId.is_in(uuids)); {
} query = query.filter(crate::entity::appointment::Column::PatientId.is_in(uuids));
} }
let limit = params.count.unwrap_or(50).min(200); let limit = params.count.unwrap_or(50).min(200);
@@ -405,7 +402,8 @@ pub async fn search_appointments(
.all(&state.db) .all(&state.db)
.await?; .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)})) .map(|a| serde_json::json!({"resource": converter::appointment_to_fhir(a)}))
.collect(); .collect();
@@ -466,10 +464,10 @@ pub async fn search_diagnostic_reports(
} }
// 强制执行 allowed_patient_ids 范围 // 强制执行 allowed_patient_ids 范围
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) { if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx)
if !uuids.is_empty() { && !uuids.is_empty()
query = query.filter(crate::entity::lab_report::Column::PatientId.is_in(uuids)); {
} query = query.filter(crate::entity::lab_report::Column::PatientId.is_in(uuids));
} }
let limit = params.count.unwrap_or(50).min(200); let limit = params.count.unwrap_or(50).min(200);
@@ -479,7 +477,8 @@ pub async fn search_diagnostic_reports(
.all(&state.db) .all(&state.db)
.await?; .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)})) .map(|r| serde_json::json!({"resource": converter::lab_report_to_fhir(r)}))
.collect(); .collect();
@@ -537,10 +536,10 @@ pub async fn search_encounters(
} }
// 强制执行 allowed_patient_ids 范围 // 强制执行 allowed_patient_ids 范围
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) { if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx)
if !uuids.is_empty() { && !uuids.is_empty()
query = query.filter(crate::entity::consultation_session::Column::PatientId.is_in(uuids)); {
} query = query.filter(crate::entity::consultation_session::Column::PatientId.is_in(uuids));
} }
let limit = params.count.unwrap_or(50).min(200); let limit = params.count.unwrap_or(50).min(200);
@@ -550,7 +549,8 @@ pub async fn search_encounters(
.all(&state.db) .all(&state.db)
.await?; .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)})) .map(|s| serde_json::json!({"resource": converter::consultation_to_fhir(s)}))
.collect(); .collect();
@@ -608,10 +608,10 @@ pub async fn search_tasks(
} }
// 强制执行 allowed_patient_ids 范围 // 强制执行 allowed_patient_ids 范围
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) { if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx)
if !uuids.is_empty() { && !uuids.is_empty()
query = query.filter(crate::entity::follow_up_task::Column::PatientId.is_in(uuids)); {
} query = query.filter(crate::entity::follow_up_task::Column::PatientId.is_in(uuids));
} }
let limit = params.count.unwrap_or(50).min(200); let limit = params.count.unwrap_or(50).min(200);
@@ -621,7 +621,8 @@ pub async fn search_tasks(
.all(&state.db) .all(&state.db)
.await?; .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)})) .map(|t| serde_json::json!({"resource": converter::follow_up_to_fhir(t)}))
.collect(); .collect();
@@ -815,6 +816,9 @@ mod tests {
..default_fhir_ctx() ..default_fhir_ctx()
}; };
let result = enforce_patient_scope(&fhir_ctx, Uuid::now_v7()); 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"
);
} }
} }

View File

@@ -89,7 +89,12 @@ pub fn loinc_to_device_type(loinc: &str) -> Option<&'static str> {
/// FHIR category → device_type 列表 /// FHIR category → device_type 列表
pub fn category_to_device_types(category: &str) -> Vec<&'static str> { pub fn category_to_device_types(category: &str) -> Vec<&'static str> {
match category { 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"], "laboratory" => vec!["blood_glucose"],
"activity" => vec!["steps", "sleep", "stress"], "activity" => vec!["steps", "sleep", "stress"],
_ => vec![], _ => vec![],

View File

@@ -1,15 +1,15 @@
use axum::{ use axum::{
Json,
extract::{Request, State}, extract::{Request, State},
http::StatusCode, http::StatusCode,
middleware::Next, middleware::Next,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
Json,
}; };
use sha2::{Digest, Sha256};
use uuid::Uuid;
use sea_orm::ColumnTrait; use sea_orm::ColumnTrait;
use sea_orm::EntityTrait; use sea_orm::EntityTrait;
use sea_orm::QueryFilter; use sea_orm::QueryFilter;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::state::HealthState; use crate::state::HealthState;
@@ -94,12 +94,11 @@ fn extract_gateway_key(request: &Request) -> Option<String> {
.headers() .headers()
.get("Authorization") .get("Authorization")
.and_then(|v| v.to_str().ok()) .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();
let key = key.trim(); if !key.is_empty() {
if !key.is_empty() { return Some(key.to_string());
return Some(key.to_string());
}
} }
} }

View File

@@ -1,5 +1,5 @@
use axum::extract::{FromRef, Json, Path, Query, State};
use axum::Extension; use axum::Extension;
use axum::extract::{FromRef, Json, Path, Query, State};
use erp_core::error::AppError; use erp_core::error::AppError;
use erp_core::rbac::require_permission; use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
@@ -82,8 +82,7 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.action-inbox.team")?; require_permission(&ctx, "health.action-inbox.team")?;
let result = let result = action_inbox_service::get_team_overview(&state.db, ctx.tenant_id).await?;
action_inbox_service::get_team_overview(&state.db, ctx.tenant_id).await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }

View File

@@ -1,6 +1,6 @@
use axum::Extension;
use axum::extract::{FromRef, Path, Query, State}; use axum::extract::{FromRef, Path, Query, State};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::Extension;
use serde::Deserialize; use serde::Deserialize;
use utoipa::IntoParams; use utoipa::IntoParams;
use uuid::Uuid; use uuid::Uuid;
@@ -36,9 +36,15 @@ where
let page_size = query.page_size.unwrap_or(20); let page_size = query.page_size.unwrap_or(20);
let (items, total) = alert_service::list_alerts( let (items, total) = alert_service::list_alerts(
&state, ctx.tenant_id, query.patient_id, query.doctor_id, query.status.as_deref(), &state,
page, page_size, ctx.tenant_id,
).await?; query.patient_id,
query.doctor_id,
query.status.as_deref(),
page,
page_size,
)
.await?;
Ok(axum::Json(ApiResponse::ok(PaginatedResponse { Ok(axum::Json(ApiResponse::ok(PaginatedResponse {
data: items, data: items,
@@ -74,9 +80,9 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.alerts.manage")?; require_permission(&ctx, "health.alerts.manage")?;
let alert = alert_service::acknowledge_alert( let alert =
&state, ctx.tenant_id, id, ctx.user_id, body.version, alert_service::acknowledge_alert(&state, ctx.tenant_id, id, ctx.user_id, body.version)
).await?; .await?;
Ok(axum::Json(ApiResponse::ok(alert))) Ok(axum::Json(ApiResponse::ok(alert)))
} }
@@ -91,9 +97,8 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.alerts.manage")?; require_permission(&ctx, "health.alerts.manage")?;
let alert = alert_service::dismiss_alert( let alert =
&state, ctx.tenant_id, id, ctx.user_id, body.version, alert_service::dismiss_alert(&state, ctx.tenant_id, id, ctx.user_id, body.version).await?;
).await?;
Ok(axum::Json(ApiResponse::ok(alert))) Ok(axum::Json(ApiResponse::ok(alert)))
} }
@@ -108,8 +113,6 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.alerts.manage")?; require_permission(&ctx, "health.alerts.manage")?;
let alert = alert_service::resolve_alert( let alert = alert_service::resolve_alert(&state, ctx.tenant_id, id, body.version).await?;
&state, ctx.tenant_id, id, body.version,
).await?;
Ok(axum::Json(ApiResponse::ok(alert))) Ok(axum::Json(ApiResponse::ok(alert)))
} }

View File

@@ -1,6 +1,6 @@
use axum::Extension;
use axum::extract::{FromRef, Path, Query, State}; use axum::extract::{FromRef, Path, Query, State};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::Extension;
use serde::Deserialize; use serde::Deserialize;
use utoipa::IntoParams; use utoipa::IntoParams;
use uuid::Uuid; use uuid::Uuid;
@@ -39,8 +39,13 @@ where
let page_size = query.page_size.unwrap_or(20); let page_size = query.page_size.unwrap_or(20);
let (items, total) = alert_rule_service::list_rules( let (items, total) = alert_rule_service::list_rules(
&state, ctx.tenant_id, query.device_type.as_deref(), page, page_size, &state,
).await?; ctx.tenant_id,
query.device_type.as_deref(),
page,
page_size,
)
.await?;
Ok(axum::Json(ApiResponse::ok(PaginatedResponse { Ok(axum::Json(ApiResponse::ok(PaginatedResponse {
data: items, data: items,
@@ -62,9 +67,7 @@ where
{ {
require_permission(&ctx, "health.alert-rules.manage")?; require_permission(&ctx, "health.alert-rules.manage")?;
body.sanitize(); body.sanitize();
let rule = alert_rule_service::create_rule( let rule = alert_rule_service::create_rule(&state, ctx.tenant_id, ctx.user_id, body).await?;
&state, ctx.tenant_id, ctx.user_id, body,
).await?;
Ok(axum::Json(ApiResponse::ok(rule))) Ok(axum::Json(ApiResponse::ok(rule)))
} }
@@ -80,9 +83,8 @@ where
{ {
require_permission(&ctx, "health.alert-rules.manage")?; require_permission(&ctx, "health.alert-rules.manage")?;
body.sanitize(); body.sanitize();
let rule = alert_rule_service::update_rule( let rule =
&state, ctx.tenant_id, id, ctx.user_id, body, alert_rule_service::update_rule(&state, ctx.tenant_id, id, ctx.user_id, body).await?;
).await?;
Ok(axum::Json(ApiResponse::ok(rule))) Ok(axum::Json(ApiResponse::ok(rule)))
} }
@@ -97,8 +99,6 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.alert-rules.manage")?; require_permission(&ctx, "health.alert-rules.manage")?;
let rule = alert_rule_service::deactivate_rule( let rule = alert_rule_service::deactivate_rule(&state, ctx.tenant_id, id, body.version).await?;
&state, ctx.tenant_id, id, body.version,
).await?;
Ok(axum::Json(ApiResponse::ok(rule))) Ok(axum::Json(ApiResponse::ok(rule)))
} }

View File

@@ -64,8 +64,14 @@ where
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = appointment_service::list_appointments( let result = appointment_service::list_appointments(
&state, ctx.tenant_id, page, page_size, params.status, params.patient_id, &state,
params.doctor_id, params.date, ctx.tenant_id,
page,
page_size,
params.status,
params.patient_id,
params.doctor_id,
params.date,
) )
.await?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -83,10 +89,9 @@ where
require_permission(&ctx, "health.appointment.manage")?; require_permission(&ctx, "health.appointment.manage")?;
let mut req = req; let mut req = req;
req.sanitize(); req.sanitize();
let result = appointment_service::create_appointment( let result =
&state, ctx.tenant_id, Some(ctx.user_id), req, appointment_service::create_appointment(&state, ctx.tenant_id, Some(ctx.user_id), req)
) .await?;
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -121,7 +126,12 @@ where
}; };
update_req.sanitize(); update_req.sanitize();
let result = appointment_service::update_appointment_status( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -140,7 +150,12 @@ where
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = appointment_service::list_schedules( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -156,10 +171,8 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.appointment.manage")?; require_permission(&ctx, "health.appointment.manage")?;
let result = appointment_service::create_schedule( let result =
&state, ctx.tenant_id, Some(ctx.user_id), req, appointment_service::create_schedule(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -175,7 +188,12 @@ where
{ {
require_permission(&ctx, "health.appointment.manage")?; require_permission(&ctx, "health.appointment.manage")?;
let result = appointment_service::update_schedule( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -192,7 +210,11 @@ where
{ {
require_permission(&ctx, "health.appointment.list")?; require_permission(&ctx, "health.appointment.list")?;
let result = appointment_service::calendar_view( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))

View File

@@ -34,9 +34,9 @@ where
{ {
require_permission(&ctx, "health.articles.manage")?; require_permission(&ctx, "health.articles.manage")?;
req.sanitize(); req.sanitize();
let result = article_category_service::create_category( let result =
&state, ctx.tenant_id, Some(ctx.user_id), req.0, article_category_service::create_category(&state, ctx.tenant_id, Some(ctx.user_id), req.0)
).await?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -53,8 +53,13 @@ where
require_permission(&ctx, "health.articles.manage")?; require_permission(&ctx, "health.articles.manage")?;
req.sanitize(); req.sanitize();
let result = article_category_service::update_category( let result = article_category_service::update_category(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.0, &state,
).await?; ctx.tenant_id,
id,
Some(ctx.user_id),
req.0,
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -75,7 +80,12 @@ where
{ {
require_permission(&ctx, "health.articles.manage")?; require_permission(&ctx, "health.articles.manage")?;
article_category_service::delete_category( article_category_service::delete_category(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version, &state,
).await?; ctx.tenant_id,
id,
Some(ctx.user_id),
req.version,
)
.await?;
Ok(Json(ApiResponse::ok(()))) Ok(Json(ApiResponse::ok(())))
} }

View File

@@ -5,7 +5,10 @@ use erp_core::error::AppError;
use erp_core::rbac::{require_any_permission, require_permission}; use erp_core::rbac::{require_any_permission, require_permission};
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; 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::service::article_service;
use crate::state::HealthState; use crate::state::HealthState;
@@ -22,14 +25,24 @@ where
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); 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 =
params.status if require_any_permission(&ctx, &["health.articles.manage", "health.articles.review"])
} else { .is_ok()
Some("published".to_string()) {
}; params.status
} else {
Some("published".to_string())
};
let result = article_service::list_articles( let result = article_service::list_articles(
&state, ctx.tenant_id, page, page_size, &state,
params.category, status, params.category_id, params.tag_id, params.keyword, ctx.tenant_id,
page,
page_size,
params.category,
status,
params.category_id,
params.tag_id,
params.keyword,
) )
.await?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -45,7 +58,8 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.articles.list")?; 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?; let result = article_service::get_article(&state, ctx.tenant_id, id, is_admin).await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -61,9 +75,8 @@ where
{ {
require_permission(&ctx, "health.articles.manage")?; require_permission(&ctx, "health.articles.manage")?;
req.sanitize(); req.sanitize();
let result = article_service::create_article( let result =
&state, ctx.tenant_id, Some(ctx.user_id), req.0, article_service::create_article(&state, ctx.tenant_id, Some(ctx.user_id), req.0).await?;
).await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -79,9 +92,9 @@ where
{ {
require_permission(&ctx, "health.articles.manage")?; require_permission(&ctx, "health.articles.manage")?;
req.sanitize(); req.sanitize();
let result = article_service::update_article( let result =
&state, ctx.tenant_id, id, Some(ctx.user_id), req.0, article_service::update_article(&state, ctx.tenant_id, id, Some(ctx.user_id), req.0)
).await?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -101,7 +114,8 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.articles.manage")?; 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(()))) Ok(Json(ApiResponse::ok(())))
} }
@@ -126,9 +140,9 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.articles.manage")?; require_permission(&ctx, "health.articles.manage")?;
let result = article_service::submit_article( let result =
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version, article_service::submit_article(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version)
).await?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -147,8 +161,14 @@ where
req.sanitize(); req.sanitize();
let version = req.version.unwrap_or(0); let version = req.version.unwrap_or(0);
let result = article_service::approve_article( let result = article_service::approve_article(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.0, version, &state,
).await?; ctx.tenant_id,
id,
Some(ctx.user_id),
req.0,
version,
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -167,8 +187,14 @@ where
req.sanitize(); req.sanitize();
let version = req.version.unwrap_or(0); let version = req.version.unwrap_or(0);
let result = article_service::reject_article( let result = article_service::reject_article(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.0, version, &state,
).await?; ctx.tenant_id,
id,
Some(ctx.user_id),
req.0,
version,
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -185,8 +211,13 @@ where
{ {
require_permission(&ctx, "health.articles.manage")?; require_permission(&ctx, "health.articles.manage")?;
let result = article_service::unpublish_article( let result = article_service::unpublish_article(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version, &state,
).await?; ctx.tenant_id,
id,
Some(ctx.user_id),
req.version,
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -216,7 +247,10 @@ pub async fn list_revisions<S>(
Extension(ctx): Extension<TenantContext>, Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>, Path(id): Path<uuid::Uuid>,
Query(params): Query<ListRevisionsQuery>, 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 where
HealthState: FromRef<S>, HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
@@ -224,8 +258,7 @@ where
require_permission(&ctx, "health.articles.list")?; require_permission(&ctx, "health.articles.list")?;
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = article_service::list_revisions( let result =
&state, ctx.tenant_id, id, page, page_size, article_service::list_revisions(&state, ctx.tenant_id, id, page, page_size).await?;
).await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }

View File

@@ -34,9 +34,8 @@ where
{ {
require_permission(&ctx, "health.articles.manage")?; require_permission(&ctx, "health.articles.manage")?;
req.sanitize(); req.sanitize();
let result = article_tag_service::create_tag( let result =
&state, ctx.tenant_id, Some(ctx.user_id), req.0, article_tag_service::create_tag(&state, ctx.tenant_id, Some(ctx.user_id), req.0).await?;
).await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -52,9 +51,9 @@ where
{ {
require_permission(&ctx, "health.articles.manage")?; require_permission(&ctx, "health.articles.manage")?;
req.sanitize(); req.sanitize();
let result = article_tag_service::update_tag( let result =
&state, ctx.tenant_id, id, Some(ctx.user_id), req.0, article_tag_service::update_tag(&state, ctx.tenant_id, id, Some(ctx.user_id), req.0)
).await?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -74,8 +73,7 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.articles.manage")?; require_permission(&ctx, "health.articles.manage")?;
article_tag_service::delete_tag( article_tag_service::delete_tag(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version)
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version, .await?;
).await?;
Ok(Json(ApiResponse::ok(()))) Ok(Json(ApiResponse::ok(())))
} }

View File

@@ -1,12 +1,12 @@
use axum::extract::{FromRef, Json, Path, Query, State};
use axum::Extension; use axum::Extension;
use axum::extract::{FromRef, Json, Path, Query, State};
use erp_core::error::AppError; use erp_core::error::AppError;
use erp_core::rbac::require_permission; use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext}; use erp_core::types::{ApiResponse, TenantContext};
use uuid::Uuid; use uuid::Uuid;
use crate::dto::ble_gateway_dto::*;
use crate::dto::DeleteWithVersion; use crate::dto::DeleteWithVersion;
use crate::dto::ble_gateway_dto::*;
use crate::gateway_auth::GatewayAuthContext; use crate::gateway_auth::GatewayAuthContext;
use crate::service::ble_gateway_service; use crate::service::ble_gateway_service;
use crate::state::HealthState; use crate::state::HealthState;
@@ -54,8 +54,7 @@ where
{ {
require_permission(&ctx, "health.ble-gateways.manage")?; require_permission(&ctx, "health.ble-gateways.manage")?;
let result = let result =
ble_gateway_service::create_gateway(&state, ctx.tenant_id, Some(ctx.user_id), body) ble_gateway_service::create_gateway(&state, ctx.tenant_id, Some(ctx.user_id), body).await?;
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -179,14 +178,9 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.ble-gateways.manage")?; require_permission(&ctx, "health.ble-gateways.manage")?;
let result = ble_gateway_service::batch_bind( let result =
&state, ble_gateway_service::batch_bind(&state, ctx.tenant_id, gateway_id, Some(ctx.user_id), body)
ctx.tenant_id, .await?;
gateway_id,
Some(ctx.user_id),
body,
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }

View File

@@ -1,5 +1,5 @@
use axum::extract::{FromRef, Json, Path, Query, State};
use axum::Extension; use axum::Extension;
use axum::extract::{FromRef, Json, Path, Query, State};
use erp_core::error::AppError; use erp_core::error::AppError;
use erp_core::rbac::require_permission; use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
@@ -74,14 +74,9 @@ where
{ {
require_permission(&ctx, "health.care-plan.manage")?; require_permission(&ctx, "health.care-plan.manage")?;
req.data.sanitize(); req.data.sanitize();
let result = care_plan_service::update_care_plan( let result =
&state, care_plan_service::update_care_plan(&state, ctx.tenant_id, plan_id, Some(ctx.user_id), req)
ctx.tenant_id, .await?;
plan_id,
Some(ctx.user_id),
req,
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -124,14 +119,9 @@ where
require_permission(&ctx, "health.care-plan.list")?; require_permission(&ctx, "health.care-plan.list")?;
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = care_plan_service::list_care_plan_items( let result =
&state, care_plan_service::list_care_plan_items(&state, ctx.tenant_id, plan_id, page, page_size)
ctx.tenant_id, .await?;
plan_id,
page,
page_size,
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -222,14 +212,9 @@ where
require_permission(&ctx, "health.care-plan.list")?; require_permission(&ctx, "health.care-plan.list")?;
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = care_plan_service::list_care_plan_outcomes( let result =
&state, care_plan_service::list_care_plan_outcomes(&state, ctx.tenant_id, plan_id, page, page_size)
ctx.tenant_id, .await?;
plan_id,
page,
page_size,
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }

View File

@@ -1,9 +1,9 @@
use axum::Extension; use axum::Extension;
use axum::extract::{FromRef, Json, Path, Query, State}; use axum::extract::{FromRef, Json, Path, Query, State};
use serde::Deserialize;
use erp_core::error::AppError; use erp_core::error::AppError;
use erp_core::rbac::require_permission; use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use serde::Deserialize;
use crate::dto::consent_dto::*; use crate::dto::consent_dto::*;
use crate::service::consent_service; use crate::service::consent_service;
@@ -28,10 +28,8 @@ where
require_permission(&ctx, "health.consent.list")?; require_permission(&ctx, "health.consent.list")?;
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = consent_service::list_consents( let result =
&state, ctx.tenant_id, patient_id, page, page_size, consent_service::list_consents(&state, ctx.tenant_id, patient_id, page, page_size).await?;
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -47,10 +45,8 @@ where
require_permission(&ctx, "health.consent.manage")?; require_permission(&ctx, "health.consent.manage")?;
let mut req = req; let mut req = req;
req.sanitize(); req.sanitize();
let result = consent_service::grant_consent( let result =
&state, ctx.tenant_id, Some(ctx.user_id), req, consent_service::grant_consent(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -67,9 +63,8 @@ where
require_permission(&ctx, "health.consent.manage")?; require_permission(&ctx, "health.consent.manage")?;
let mut req = req; let mut req = req;
req.sanitize(); req.sanitize();
let result = consent_service::revoke_consent( let result =
&state, ctx.tenant_id, consent_id, Some(ctx.user_id), req, consent_service::revoke_consent(&state, ctx.tenant_id, consent_id, Some(ctx.user_id), req)
) .await?;
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }

View File

@@ -60,10 +60,8 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.consultation.manage")?; require_permission(&ctx, "health.consultation.manage")?;
let result = consultation_service::create_session( let result =
&state, ctx.tenant_id, Some(ctx.user_id), req, consultation_service::create_session(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -80,7 +78,12 @@ where
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = consultation_service::list_sessions( 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, params.doctor_id,
) )
.await?; .await?;
@@ -115,7 +118,12 @@ where
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = consultation_service::list_messages( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -133,7 +141,11 @@ where
{ {
require_permission(&ctx, "health.consultation.manage")?; require_permission(&ctx, "health.consultation.manage")?;
let result = consultation_service::close_session( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -166,7 +178,12 @@ where
}; };
msg_req.sanitize(); msg_req.sanitize();
let result = consultation_service::create_message( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -183,8 +200,13 @@ where
{ {
require_permission(&ctx, "health.consultation.list")?; require_permission(&ctx, "health.consultation.list")?;
let result = consultation_service::export_sessions( let result = consultation_service::export_sessions(
&state, ctx.tenant_id, params.status, params.patient_id, params.doctor_id, &state,
params.page, params.page_size, ctx.tenant_id,
params.status,
params.patient_id,
params.doctor_id,
params.page,
params.page_size,
) )
.await?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -219,10 +241,7 @@ where
.map_err(|e| AppError::Internal(e.to_string()))? .map_err(|e| AppError::Internal(e.to_string()))?
.is_some(); .is_some();
let role = if is_doctor { "doctor" } else { "patient" }; let role = if is_doctor { "doctor" } else { "patient" };
consultation_service::mark_session_read( consultation_service::mark_session_read(&state, ctx.tenant_id, id, ctx.user_id, role).await?;
&state, ctx.tenant_id, id, ctx.user_id, role,
)
.await?;
Ok(Json(ApiResponse::ok(()))) Ok(Json(ApiResponse::ok(())))
} }
@@ -244,12 +263,13 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.consultation.list")?; require_permission(&ctx, "health.consultation.list")?;
let mut result = consultation_service::get_doctor_dashboard( let mut result =
&state, ctx.tenant_id, ctx.user_id, consultation_service::get_doctor_dashboard(&state, ctx.tenant_id, ctx.user_id).await?;
)
.await?;
consultation_service::enrich_doctor_dashboard_health( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))

View File

@@ -1,6 +1,6 @@
use axum::Extension;
use axum::extract::{FromRef, Path, Query, State}; use axum::extract::{FromRef, Path, Query, State};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::Extension;
use serde::Deserialize; use serde::Deserialize;
use utoipa::IntoParams; use utoipa::IntoParams;
use uuid::Uuid; use uuid::Uuid;
@@ -31,16 +31,19 @@ where
let page = query.page.unwrap_or(1); let page = query.page.unwrap_or(1);
let page_size = query.page_size.unwrap_or(20); let page_size = query.page_size.unwrap_or(20);
let (items, total) = critical_alert_service::list_pending_alerts( let (items, total) =
&state, ctx.tenant_id, page, page_size, critical_alert_service::list_pending_alerts(&state, ctx.tenant_id, page, page_size)
) .await
.await .map_err(|e| {
.map_err(|e| { tracing::error!(error = %e, tenant_id = %ctx.tenant_id, "查询危急值告警列表失败");
tracing::error!(error = %e, tenant_id = %ctx.tenant_id, "查询危急值告警列表失败"); e
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 { Ok(axum::Json(ApiResponse::ok(PaginatedResponse {
data: items, data: items,
@@ -81,13 +84,9 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.critical-alerts.manage")?; require_permission(&ctx, "health.critical-alerts.manage")?;
critical_alert_service::acknowledge_alert( critical_alert_service::acknowledge_alert(&state, ctx.tenant_id, id, ctx.user_id, body.notes)
&state, .await?;
ctx.tenant_id, Ok(axum::Json(ApiResponse::ok(
id, serde_json::json!({"message": "告警已确认"}),
ctx.user_id, )))
body.notes,
)
.await?;
Ok(axum::Json(ApiResponse::ok(serde_json::json!({"message": "告警已确认"}))))
} }

View File

@@ -1,9 +1,9 @@
use axum::Extension; use axum::Extension;
use axum::extract::{FromRef, Json, Path, State}; use axum::extract::{FromRef, Json, Path, State};
use serde::Deserialize;
use erp_core::error::AppError; use erp_core::error::AppError;
use erp_core::rbac::require_permission; use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext}; use erp_core::types::{ApiResponse, TenantContext};
use serde::Deserialize;
use crate::service::critical_value_threshold_service; use crate::service::critical_value_threshold_service;
use crate::state::HealthState; use crate::state::HealthState;
@@ -105,8 +105,13 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.critical-value-thresholds.manage")?; 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(
.await?; &state.db,
ctx.tenant_id,
id,
Some(ctx.user_id),
)
.await?;
Ok(Json(ApiResponse::ok(()))) Ok(Json(ApiResponse::ok(())))
} }

View File

@@ -8,8 +8,8 @@ use erp_core::error::AppError;
use erp_core::rbac::require_permission; use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::daily_monitoring_dto::*;
use crate::dto::DeleteWithVersion; use crate::dto::DeleteWithVersion;
use crate::dto::daily_monitoring_dto::*;
use crate::service::daily_monitoring_service; use crate::service::daily_monitoring_service;
use crate::state::HealthState; use crate::state::HealthState;
@@ -40,7 +40,11 @@ where
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = daily_monitoring_service::list_daily_monitoring( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -56,10 +60,8 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.daily-monitoring.list")?; require_permission(&ctx, "health.daily-monitoring.list")?;
let result = daily_monitoring_service::get_daily_monitoring( let result =
&state, ctx.tenant_id, record_id, daily_monitoring_service::get_daily_monitoring(&state, ctx.tenant_id, record_id).await?;
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -76,7 +78,10 @@ where
let mut req = req; let mut req = req;
req.sanitize(); req.sanitize();
let result = daily_monitoring_service::create_daily_monitoring( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -96,7 +101,12 @@ where
let mut data = req.data; let mut data = req.data;
data.sanitize(); data.sanitize();
let result = daily_monitoring_service::update_daily_monitoring( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -114,7 +124,11 @@ where
{ {
require_permission(&ctx, "health.daily-monitoring.manage")?; require_permission(&ctx, "health.daily-monitoring.manage")?;
daily_monitoring_service::delete_daily_monitoring( 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?; .await?;
Ok(Json(ApiResponse::ok(()))) Ok(Json(ApiResponse::ok(())))

View File

@@ -1,8 +1,8 @@
//! 设备管理 API — 设备列表查询与解绑 //! 设备管理 API — 设备列表查询与解绑
use axum::Extension;
use axum::extract::{FromRef, Path, Query, State}; use axum::extract::{FromRef, Path, Query, State};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::Extension;
use serde::Deserialize; use serde::Deserialize;
use utoipa::IntoParams; use utoipa::IntoParams;
use uuid::Uuid; use uuid::Uuid;
@@ -72,14 +72,8 @@ where
{ {
require_permission(&ctx, "health.devices.manage")?; require_permission(&ctx, "health.devices.manage")?;
let device = device_service::unbind_device( let device =
&state, device_service::unbind_device(&state, ctx.tenant_id, id, ctx.user_id, body.version).await?;
ctx.tenant_id,
id,
ctx.user_id,
body.version,
)
.await?;
Ok(axum::Json(ApiResponse::ok(device))) Ok(axum::Json(ApiResponse::ok(device)))
} }

View File

@@ -1,6 +1,6 @@
use axum::Extension;
use axum::extract::{FromRef, Path, Query, State}; use axum::extract::{FromRef, Path, Query, State};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::Extension;
use serde::Deserialize; use serde::Deserialize;
use utoipa::IntoParams; use utoipa::IntoParams;
use uuid::Uuid; use uuid::Uuid;
@@ -44,9 +44,9 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.device-readings.manage")?; require_permission(&ctx, "health.device-readings.manage")?;
let result = device_reading_service::batch_create_readings( let result =
&state, ctx.tenant_id, path.patient_id, body, device_reading_service::batch_create_readings(&state, ctx.tenant_id, path.patient_id, body)
).await?; .await?;
Ok(axum::Json(ApiResponse::ok(result))) Ok(axum::Json(ApiResponse::ok(result)))
} }
@@ -64,9 +64,15 @@ where
let page = query.page.unwrap_or(1); let page = query.page.unwrap_or(1);
let page_size = query.page_size.unwrap_or(20); let page_size = query.page_size.unwrap_or(20);
let result = device_reading_service::query_device_readings( let result = device_reading_service::query_device_readings(
&state, ctx.tenant_id, path.patient_id, &state,
query.device_type.as_deref(), query.hours, page, page_size, ctx.tenant_id,
).await?; path.patient_id,
query.device_type.as_deref(),
query.hours,
page,
page_size,
)
.await?;
Ok(axum::Json(ApiResponse::ok(result))) Ok(axum::Json(ApiResponse::ok(result)))
} }
@@ -85,8 +91,14 @@ where
let page_size = query.page_size.unwrap_or(20); let page_size = query.page_size.unwrap_or(20);
let days = query.days.unwrap_or(7); let days = query.days.unwrap_or(7);
let result = device_reading_service::query_hourly_readings( let result = device_reading_service::query_hourly_readings(
&state, ctx.tenant_id, path.patient_id, &state,
&query.device_type, days, page, page_size, ctx.tenant_id,
).await?; path.patient_id,
&query.device_type,
days,
page,
page_size,
)
.await?;
Ok(axum::Json(ApiResponse::ok(result))) Ok(axum::Json(ApiResponse::ok(result)))
} }

View File

@@ -1,12 +1,12 @@
use axum::Extension; use axum::Extension;
use axum::extract::{FromRef, Json, Path, Query, State}; use axum::extract::{FromRef, Json, Path, Query, State};
use serde::Deserialize;
use erp_core::error::AppError; use erp_core::error::AppError;
use erp_core::rbac::require_permission; use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use serde::Deserialize;
use crate::dto::diagnosis_dto::*;
use crate::dto::DeleteWithVersion; use crate::dto::DeleteWithVersion;
use crate::dto::diagnosis_dto::*;
use crate::service::diagnosis_service; use crate::service::diagnosis_service;
use crate::state::HealthState; use crate::state::HealthState;
@@ -29,10 +29,9 @@ where
require_permission(&ctx, "health.health-data.list")?; require_permission(&ctx, "health.health-data.list")?;
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = diagnosis_service::list_diagnoses( let result =
&state, ctx.tenant_id, patient_id, page, page_size, diagnosis_service::list_diagnoses(&state, ctx.tenant_id, patient_id, page, page_size)
) .await?;
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -50,7 +49,11 @@ where
let mut req = req; let mut req = req;
req.sanitize(); req.sanitize();
let result = diagnosis_service::create_diagnosis( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -70,7 +73,12 @@ where
let mut data = req.data; let mut data = req.data;
data.sanitize(); data.sanitize();
let result = diagnosis_service::update_diagnosis( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -88,7 +96,11 @@ where
{ {
require_permission(&ctx, "health.health-data.manage")?; require_permission(&ctx, "health.health-data.manage")?;
diagnosis_service::delete_diagnosis( 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?; .await?;
Ok(Json(ApiResponse::ok(()))) Ok(Json(ApiResponse::ok(())))

View File

@@ -8,8 +8,8 @@ use erp_core::error::AppError;
use erp_core::rbac::require_permission; use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::doctor_dto::*;
use crate::dto::DeleteWithVersion; use crate::dto::DeleteWithVersion;
use crate::dto::doctor_dto::*;
use crate::service::doctor_service; use crate::service::doctor_service;
use crate::state::HealthState; use crate::state::HealthState;
@@ -42,7 +42,13 @@ where
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = doctor_service::list_doctors( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -60,10 +66,8 @@ where
require_permission(&ctx, "health.doctor.manage")?; require_permission(&ctx, "health.doctor.manage")?;
let mut req = req; let mut req = req;
req.sanitize(); req.sanitize();
let result = doctor_service::create_doctor( let result =
&state, ctx.tenant_id, Some(ctx.user_id), req, doctor_service::create_doctor(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -95,7 +99,12 @@ where
let mut data = req.data; let mut data = req.data;
data.sanitize(); data.sanitize();
let result = doctor_service::update_doctor( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -112,6 +121,7 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.doctor.manage")?; 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(()))) Ok(Json(ApiResponse::ok(())))
} }

View File

@@ -1,7 +1,7 @@
//! 家庭成员健康代理 Handler — 同意管理 + 健康摘要查看 //! 家庭成员健康代理 Handler — 同意管理 + 健康摘要查看
use axum::extract::{Json, Path, Query, State};
use axum::Extension; use axum::Extension;
use axum::extract::{Json, Path, Query, State};
use erp_core::error::AppError; use erp_core::error::AppError;
use erp_core::rbac::require_permission; use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext}; use erp_core::types::{ApiResponse, TenantContext};
@@ -27,9 +27,15 @@ pub async fn grant_family_access(
) -> Result<Json<ApiResponse<FamilyMemberResp>>, AppError> { ) -> Result<Json<ApiResponse<FamilyMemberResp>>, AppError> {
require_permission(&ctx, "health.patient.manage")?; require_permission(&ctx, "health.patient.manage")?;
let result = family_proxy_service::grant_family_access( let result = family_proxy_service::grant_family_access(
&state, ctx.tenant_id, patient_id, family_member_id, &state,
Some(ctx.user_id), req, params.version, ctx.tenant_id,
).await?; patient_id,
family_member_id,
Some(ctx.user_id),
req,
params.version,
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -42,9 +48,14 @@ pub async fn revoke_family_access(
) -> Result<Json<ApiResponse<FamilyMemberResp>>, AppError> { ) -> Result<Json<ApiResponse<FamilyMemberResp>>, AppError> {
require_permission(&ctx, "health.patient.manage")?; require_permission(&ctx, "health.patient.manage")?;
let result = family_proxy_service::revoke_family_access( let result = family_proxy_service::revoke_family_access(
&state, ctx.tenant_id, patient_id, family_member_id, &state,
Some(ctx.user_id), params.version, ctx.tenant_id,
).await?; patient_id,
family_member_id,
Some(ctx.user_id),
params.version,
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -53,9 +64,8 @@ pub async fn list_my_family_patients(
State(state): State<HealthState>, State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>, Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<Vec<FamilyPatientSummaryResp>>>, AppError> { ) -> Result<Json<ApiResponse<Vec<FamilyPatientSummaryResp>>>, AppError> {
let result = family_proxy_service::list_family_patients( let result =
&state, ctx.tenant_id, ctx.user_id, family_proxy_service::list_family_patients(&state, ctx.tenant_id, ctx.user_id).await?;
).await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -66,8 +76,12 @@ pub async fn get_family_health_summary(
Path(patient_id): Path<Uuid>, Path(patient_id): Path<Uuid>,
) -> Result<Json<ApiResponse<FamilyHealthSummaryResp>>, AppError> { ) -> Result<Json<ApiResponse<FamilyHealthSummaryResp>>, AppError> {
let result = family_proxy_service::get_family_health_summary( let result = family_proxy_service::get_family_health_summary(
&state, ctx.tenant_id, ctx.user_id, patient_id, &state,
).await?; ctx.tenant_id,
ctx.user_id,
patient_id,
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -78,7 +92,11 @@ pub async fn link_family_member_user(
Path(family_member_id): Path<Uuid>, Path(family_member_id): Path<Uuid>,
) -> Result<Json<ApiResponse<FamilyMemberResp>>, AppError> { ) -> Result<Json<ApiResponse<FamilyMemberResp>>, AppError> {
let result = family_proxy_service::link_family_member_user( let result = family_proxy_service::link_family_member_user(
&state, ctx.tenant_id, family_member_id, ctx.user_id, &state,
).await?; ctx.tenant_id,
family_member_id,
ctx.user_id,
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }

View File

@@ -8,8 +8,8 @@ use erp_core::error::AppError;
use erp_core::rbac::require_permission; use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::follow_up_dto::*;
use crate::dto::DeleteWithVersion; use crate::dto::DeleteWithVersion;
use crate::dto::follow_up_dto::*;
use crate::service::follow_up_service; use crate::service::follow_up_service;
use crate::state::HealthState; use crate::state::HealthState;
@@ -33,10 +33,9 @@ where
if req.patient_ids.len() > 100 { if req.patient_ids.len() > 100 {
return Err(AppError::Validation("单次批量最多 100 条".to_string())); return Err(AppError::Validation("单次批量最多 100 条".to_string()));
} }
let result = follow_up_service::batch_create_tasks( let result =
&state, ctx.tenant_id, Some(ctx.user_id), req, follow_up_service::batch_create_tasks(&state, ctx.tenant_id, Some(ctx.user_id), req)
) .await?;
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -56,10 +55,9 @@ where
if req.task_ids.len() > 100 { if req.task_ids.len() > 100 {
return Err(AppError::Validation("单次批量最多 100 条".to_string())); return Err(AppError::Validation("单次批量最多 100 条".to_string()));
} }
let result = follow_up_service::batch_assign_tasks( let result =
&state, ctx.tenant_id, Some(ctx.user_id), req, follow_up_service::batch_assign_tasks(&state, ctx.tenant_id, Some(ctx.user_id), req)
) .await?;
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -79,10 +77,9 @@ where
if req.task_ids.len() > 100 { if req.task_ids.len() > 100 {
return Err(AppError::Validation("单次批量最多 100 条".to_string())); return Err(AppError::Validation("单次批量最多 100 条".to_string()));
} }
let result = follow_up_service::batch_complete_tasks( let result =
&state, ctx.tenant_id, Some(ctx.user_id), req, follow_up_service::batch_complete_tasks(&state, ctx.tenant_id, Some(ctx.user_id), req)
) .await?;
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -123,7 +120,12 @@ where
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = follow_up_service::list_tasks( 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, params.status,
) )
.await?; .await?;
@@ -156,10 +158,8 @@ where
require_permission(&ctx, "health.follow-up.manage")?; require_permission(&ctx, "health.follow-up.manage")?;
let mut req = req; let mut req = req;
req.sanitize(); req.sanitize();
let result = follow_up_service::create_task( let result =
&state, ctx.tenant_id, Some(ctx.user_id), req, follow_up_service::create_task(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -177,7 +177,12 @@ where
let mut data = req.data; let mut data = req.data;
data.sanitize(); data.sanitize();
let result = follow_up_service::update_task( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -194,7 +199,8 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.follow-up.manage")?; 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(()))) Ok(Json(ApiResponse::ok(())))
} }
@@ -210,14 +216,14 @@ where
{ {
require_permission(&ctx, "health.follow-up.manage")?; require_permission(&ctx, "health.follow-up.manage")?;
if req.task_id != task_id { 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; let mut req = req;
req.sanitize(); req.sanitize();
let result = follow_up_service::create_record( let result =
&state, ctx.tenant_id, Some(ctx.user_id), req, follow_up_service::create_record(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -234,7 +240,12 @@ where
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = follow_up_service::list_records( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))

View File

@@ -8,8 +8,8 @@ use erp_core::error::AppError;
use erp_core::rbac::require_permission; use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::follow_up_template_dto::*;
use crate::dto::DeleteWithVersion; use crate::dto::DeleteWithVersion;
use crate::dto::follow_up_template_dto::*;
use crate::service::follow_up_template_service; use crate::service::follow_up_template_service;
use crate::state::HealthState; use crate::state::HealthState;
@@ -41,7 +41,12 @@ where
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = follow_up_template_service::list_templates( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -73,10 +78,9 @@ where
require_permission(&ctx, "health.follow-up-templates.manage")?; require_permission(&ctx, "health.follow-up-templates.manage")?;
let mut req = req; let mut req = req;
req.sanitize(); req.sanitize();
let result = follow_up_template_service::create_template( let result =
&state, ctx.tenant_id, Some(ctx.user_id), req, follow_up_template_service::create_template(&state, ctx.tenant_id, Some(ctx.user_id), req)
) .await?;
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -94,7 +98,12 @@ where
let mut data = req.data; let mut data = req.data;
data.sanitize(); data.sanitize();
let result = follow_up_template_service::update_template( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -112,7 +121,11 @@ where
{ {
require_permission(&ctx, "health.follow-up-templates.manage")?; require_permission(&ctx, "health.follow-up-templates.manage")?;
follow_up_template_service::delete_template( 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?; .await?;
Ok(Json(ApiResponse::ok(()))) Ok(Json(ApiResponse::ok(())))

View File

@@ -8,8 +8,8 @@ use erp_core::error::AppError;
use erp_core::rbac::require_permission; use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::health_data_dto::*;
use crate::dto::DeleteWithVersion; use crate::dto::DeleteWithVersion;
use crate::dto::health_data_dto::*;
use crate::service::health_data_service; use crate::service::health_data_service;
use crate::service::trend_service; use crate::service::trend_service;
use crate::state::HealthState; use crate::state::HealthState;
@@ -59,10 +59,9 @@ where
require_permission(&ctx, "health.health-data.list")?; require_permission(&ctx, "health.health-data.list")?;
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = health_data_service::list_vital_signs( let result =
&state, ctx.tenant_id, patient_id, page, page_size, health_data_service::list_vital_signs(&state, ctx.tenant_id, patient_id, page, page_size)
) .await?;
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -80,7 +79,11 @@ where
let mut req = req; let mut req = req;
req.sanitize(); req.sanitize();
let result = health_data_service::create_vital_signs( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -100,7 +103,13 @@ where
let mut data = req.data; let mut data = req.data;
data.sanitize(); data.sanitize();
let result = health_data_service::update_vital_signs( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -117,7 +126,14 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.health-data.manage")?; 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(()))) Ok(Json(ApiResponse::ok(())))
} }
@@ -138,10 +154,9 @@ where
require_permission(&ctx, "health.health-data.list")?; require_permission(&ctx, "health.health-data.list")?;
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = health_data_service::list_lab_reports( let result =
&state, ctx.tenant_id, patient_id, page, page_size, health_data_service::list_lab_reports(&state, ctx.tenant_id, patient_id, page, page_size)
) .await?;
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -159,7 +174,11 @@ where
let mut req = req; let mut req = req;
req.sanitize(); req.sanitize();
let result = health_data_service::create_lab_report( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -179,7 +198,13 @@ where
let mut data = req.data; let mut data = req.data;
data.sanitize(); data.sanitize();
let result = health_data_service::update_lab_report( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -196,7 +221,14 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.health-data.manage")?; 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(()))) Ok(Json(ApiResponse::ok(())))
} }
@@ -214,7 +246,13 @@ where
let mut data = req.data; let mut data = req.data;
data.sanitize(); data.sanitize();
let result = health_data_service::review_lab_report( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -238,7 +276,11 @@ where
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = health_data_service::list_health_records( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -258,7 +300,11 @@ where
let mut req = req; let mut req = req;
req.sanitize(); req.sanitize();
let result = health_data_service::create_health_record( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -278,7 +324,13 @@ where
let mut data = req.data; let mut data = req.data;
data.sanitize(); data.sanitize();
let result = health_data_service::update_health_record( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -295,7 +347,14 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.health-data.manage")?; 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(()))) Ok(Json(ApiResponse::ok(())))
} }
@@ -316,10 +375,8 @@ where
require_permission(&ctx, "health.health-data.list")?; require_permission(&ctx, "health.health-data.list")?;
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = trend_service::list_trends( let result =
&state, ctx.tenant_id, patient_id, page, page_size, trend_service::list_trends(&state, ctx.tenant_id, patient_id, page, page_size).await?;
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -335,7 +392,12 @@ where
{ {
require_permission(&ctx, "health.health-data.manage")?; require_permission(&ctx, "health.health-data.manage")?;
let result = trend_service::generate_trend( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -353,7 +415,12 @@ where
{ {
require_permission(&ctx, "health.health-data.list")?; require_permission(&ctx, "health.health-data.list")?;
let result = trend_service::get_indicator_timeseries( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -374,7 +441,11 @@ where
{ {
require_permission(&ctx, "health.health-data.list")?; require_permission(&ctx, "health.health-data.list")?;
let result = trend_service::get_mini_trend( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -394,10 +465,9 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.health-data.list")?; require_permission(&ctx, "health.health-data.list")?;
let result = trend_service::get_mini_today( let result =
&state, ctx.tenant_id, ctx.user_id, params.patient_id, trend_service::get_mini_today(&state, ctx.tenant_id, ctx.user_id, params.patient_id)
) .await?;
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }

View File

@@ -1,12 +1,12 @@
use axum::Extension; use axum::Extension;
use axum::extract::{FromRef, Json, Path, Query, State}; use axum::extract::{FromRef, Json, Path, Query, State};
use serde::Deserialize;
use erp_core::error::AppError; use erp_core::error::AppError;
use erp_core::rbac::require_permission; use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use serde::Deserialize;
use crate::dto::medication_record_dto::*;
use crate::dto::DeleteWithVersion; use crate::dto::DeleteWithVersion;
use crate::dto::medication_record_dto::*;
use crate::service::medication_record_service; use crate::service::medication_record_service;
use crate::state::HealthState; use crate::state::HealthState;
@@ -31,7 +31,11 @@ where
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = medication_record_service::list_medications( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -66,13 +70,9 @@ where
require_permission(&ctx, "health.medication-records.manage")?; require_permission(&ctx, "health.medication-records.manage")?;
let mut req = req; let mut req = req;
req.sanitize(); req.sanitize();
let result = medication_record_service::create_medication( let result =
&state, medication_record_service::create_medication(&state, ctx.tenant_id, Some(ctx.user_id), req)
ctx.tenant_id, .await?;
Some(ctx.user_id),
req,
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }

View File

@@ -4,7 +4,9 @@ use erp_core::error::AppError;
use erp_core::rbac::require_permission; use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; 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::service::medication_reminder_service;
use crate::state::HealthState; use crate::state::HealthState;
@@ -28,8 +30,13 @@ where
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = medication_reminder_service::list_reminders( let result = medication_reminder_service::list_reminders(
&state, ctx.tenant_id, patient_id, page, page_size, &state,
).await?; ctx.tenant_id,
patient_id,
page,
page_size,
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -45,8 +52,12 @@ where
require_permission(&ctx, "health.medication-reminders.manage")?; require_permission(&ctx, "health.medication-reminders.manage")?;
req.sanitize(); req.sanitize();
let result = medication_reminder_service::create_reminder( let result = medication_reminder_service::create_reminder(
&state, ctx.tenant_id, Some(ctx.user_id), req.0, &state,
).await?; ctx.tenant_id,
Some(ctx.user_id),
req.0,
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -71,8 +82,14 @@ where
let mut data = req.data; let mut data = req.data;
data.sanitize(); data.sanitize();
let result = medication_reminder_service::update_reminder( let result = medication_reminder_service::update_reminder(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version, data, &state,
).await?; ctx.tenant_id,
id,
Some(ctx.user_id),
req.version,
data,
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -93,7 +110,12 @@ where
{ {
require_permission(&ctx, "health.medication-reminders.manage")?; require_permission(&ctx, "health.medication-reminders.manage")?;
medication_reminder_service::delete_reminder( medication_reminder_service::delete_reminder(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version, &state,
).await?; ctx.tenant_id,
id,
Some(ctx.user_id),
req.version,
)
.await?;
Ok(Json(ApiResponse::ok(()))) Ok(Json(ApiResponse::ok(())))
} }

View File

@@ -1,29 +1,29 @@
pub mod action_inbox_handler; pub mod action_inbox_handler;
pub mod alert_handler; pub mod alert_handler;
pub mod ble_gateway_handler;
pub mod alert_rule_handler; pub mod alert_rule_handler;
pub mod appointment_handler; pub mod appointment_handler;
pub mod article_category_handler; pub mod article_category_handler;
pub mod article_handler; pub mod article_handler;
pub mod article_tag_handler; pub mod article_tag_handler;
pub mod ble_gateway_handler;
pub mod care_plan_handler; pub mod care_plan_handler;
pub mod consultation_handler;
pub mod consent_handler; pub mod consent_handler;
pub mod consultation_handler;
pub mod critical_alert_handler; pub mod critical_alert_handler;
pub mod critical_value_threshold_handler; pub mod critical_value_threshold_handler;
pub mod daily_monitoring_handler; pub mod daily_monitoring_handler;
pub mod device_handler; pub mod device_handler;
pub mod device_reading_handler; pub mod device_reading_handler;
pub mod diagnosis_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 doctor_handler;
pub mod family_proxy_handler;
pub mod follow_up_handler; pub mod follow_up_handler;
pub mod follow_up_template_handler; pub mod follow_up_template_handler;
pub mod health_data_handler; pub mod health_data_handler;
pub mod medication_record_handler;
pub mod medication_reminder_handler;
pub mod patient_handler; pub mod patient_handler;
pub mod points_handler; pub mod points_handler;
pub mod stats_handler;
pub mod shift_handler; pub mod shift_handler;
pub mod stats_handler;
pub mod vital_signs_daily_handler; pub mod vital_signs_daily_handler;

View File

@@ -8,11 +8,11 @@ use erp_core::error::AppError;
use erp_core::rbac::require_permission; use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::DeleteWithVersion;
use crate::dto::patient_dto::{ use crate::dto::patient_dto::{
CreatePatientReq, FamilyMemberReq, FamilyMemberResp, ManageTagsReq, PatientResp, CreatePatientReq, FamilyMemberReq, FamilyMemberResp, ManageTagsReq, PatientResp,
UpdatePatientReq, UpdatePatientReq,
}; };
use crate::dto::DeleteWithVersion;
use crate::service::patient_service; use crate::service::patient_service;
use crate::state::HealthState; use crate::state::HealthState;
@@ -44,7 +44,12 @@ where
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = patient_service::list_patients( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -62,10 +67,11 @@ where
require_permission(&ctx, "health.patient.manage")?; require_permission(&ctx, "health.patient.manage")?;
let mut req = req; let mut req = req;
req.sanitize(); req.sanitize();
let result = patient_service::create_patient( if req.name.trim().is_empty() {
&state, ctx.tenant_id, Some(ctx.user_id), req, return Err(AppError::Validation("患者姓名不能为空".into()));
) }
.await?; let result =
patient_service::create_patient(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -112,7 +118,12 @@ where
}; };
update.sanitize(); update.sanitize();
let result = patient_service::update_patient( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -129,7 +140,8 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.patient.manage")?; 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(()))) Ok(Json(ApiResponse::ok(())))
} }
@@ -189,10 +201,9 @@ where
require_permission(&ctx, "health.patient.manage")?; require_permission(&ctx, "health.patient.manage")?;
let mut req = req; let mut req = req;
req.sanitize(); req.sanitize();
let result = patient_service::create_family_member( let result =
&state, ctx.tenant_id, id, Some(ctx.user_id), req, patient_service::create_family_member(&state, ctx.tenant_id, id, Some(ctx.user_id), req)
) .await?;
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -217,7 +228,13 @@ where
}; };
update.sanitize(); update.sanitize();
let result = patient_service::update_family_member( 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?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -235,7 +252,12 @@ where
{ {
require_permission(&ctx, "health.patient.manage")?; require_permission(&ctx, "health.patient.manage")?;
patient_service::delete_family_member( 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?; .await?;
Ok(Json(ApiResponse::ok(()))) Ok(Json(ApiResponse::ok(())))
@@ -257,7 +279,8 @@ where
ctx.tenant_id, ctx.tenant_id,
id, id,
req.doctor_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), Some(ctx.user_id),
) )
.await?; .await?;
@@ -274,7 +297,14 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.patient.manage")?; 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(()))) Ok(Json(ApiResponse::ok(())))
} }
@@ -338,11 +368,16 @@ where
{ {
require_permission(&ctx, "health.patient.manage")?; require_permission(&ctx, "health.patient.manage")?;
let result = patient_service::create_tag( 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 { 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))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -366,11 +401,18 @@ where
{ {
require_permission(&ctx, "health.patient.manage")?; require_permission(&ctx, "health.patient.manage")?;
let result = patient_service::update_tag( 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 { 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))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -385,8 +427,6 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
require_permission(&ctx, "health.patient.manage")?; require_permission(&ctx, "health.patient.manage")?;
patient_service::delete_tag( patient_service::delete_tag(&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(()))) Ok(Json(ApiResponse::ok(())))
} }

View File

@@ -36,9 +36,11 @@ pub async fn get_my_account<S>(
State(state): State<HealthState>, State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>, Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<PointsAccountResp>>, AppError> ) -> 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 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?; let result = points_service::get_account(&state, ctx.tenant_id, patient_id).await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -48,13 +50,14 @@ pub async fn daily_checkin<S>(
State(state): State<HealthState>, State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>, Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<CheckinStatusResp>>, AppError> ) -> 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 patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
let result = points_service::daily_checkin( let result =
&state, ctx.tenant_id, patient_id, Some(ctx.user_id), points_service::daily_checkin(&state, ctx.tenant_id, patient_id, Some(ctx.user_id)).await?;
).await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -62,9 +65,11 @@ pub async fn get_checkin_status<S>(
State(state): State<HealthState>, State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>, Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<CheckinStatusResp>>, AppError> ) -> 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 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?; let result = points_service::get_checkin_status(&state, ctx.tenant_id, patient_id).await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
@@ -79,15 +84,17 @@ pub async fn list_my_transactions<S>(
Extension(ctx): Extension<TenantContext>, Extension(ctx): Extension<TenantContext>,
Query(params): Query<PaginationParams>, Query(params): Query<PaginationParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<PointsTransactionResp>>>, AppError> ) -> 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 patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = points_service::list_transactions( let result =
&state, ctx.tenant_id, patient_id, page, page_size, points_service::list_transactions(&state, ctx.tenant_id, patient_id, page, page_size)
).await?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -97,14 +104,15 @@ pub async fn list_products<S>(
Query(params): Query<ProductTypeParam>, Query(params): Query<ProductTypeParam>,
Query(page): Query<PaginationParams>, Query(page): Query<PaginationParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<PointsProductResp>>>, AppError> ) -> 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 p = page.page.unwrap_or(1);
let ps = page.page_size.unwrap_or(20); let ps = page.page_size.unwrap_or(20);
let result = points_service::list_products( let result =
&state, ctx.tenant_id, params.product_type, p, ps, points_service::list_products(&state, ctx.tenant_id, params.product_type, p, ps).await?;
).await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -113,9 +121,11 @@ pub async fn get_product<S>(
Extension(ctx): Extension<TenantContext>, Extension(ctx): Extension<TenantContext>,
Path(product_id): Path<Uuid>, Path(product_id): Path<Uuid>,
) -> Result<Json<ApiResponse<PointsProductResp>>, AppError> ) -> 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?; let result = points_service::get_product(&state, ctx.tenant_id, product_id).await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -125,13 +135,15 @@ pub async fn exchange_product<S>(
Extension(ctx): Extension<TenantContext>, Extension(ctx): Extension<TenantContext>,
Json(req): Json<ExchangeReq>, Json(req): Json<ExchangeReq>,
) -> Result<Json<ApiResponse<PointsOrderResp>>, AppError> ) -> 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 patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
let result = points_service::exchange_product( let result =
&state, ctx.tenant_id, patient_id, req, Some(ctx.user_id), points_service::exchange_product(&state, ctx.tenant_id, patient_id, req, Some(ctx.user_id))
).await?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -140,15 +152,16 @@ pub async fn list_my_orders<S>(
Extension(ctx): Extension<TenantContext>, Extension(ctx): Extension<TenantContext>,
Query(params): Query<PaginationParams>, Query(params): Query<PaginationParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<PointsOrderResp>>>, AppError> ) -> 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 patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = points_service::list_orders( let result =
&state, ctx.tenant_id, patient_id, page, page_size, points_service::list_orders(&state, ctx.tenant_id, patient_id, page, page_size).await?;
).await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -161,14 +174,15 @@ pub async fn list_offline_events<S>(
Extension(ctx): Extension<TenantContext>, Extension(ctx): Extension<TenantContext>,
Query(params): Query<PaginationParams>, Query(params): Query<PaginationParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<OfflineEventResp>>>, AppError> ) -> 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 = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = points_service::list_offline_events( let result =
&state, ctx.tenant_id, page, page_size, points_service::list_offline_events(&state, ctx.tenant_id, page, page_size).await?;
).await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -177,13 +191,20 @@ pub async fn register_event<S>(
Extension(ctx): Extension<TenantContext>, Extension(ctx): Extension<TenantContext>,
Path(event_id): Path<Uuid>, Path(event_id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError> ) -> 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?; let patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
points_service::register_event( points_service::register_event(
&state, ctx.tenant_id, event_id, patient_id, Some(ctx.user_id), &state,
).await?; ctx.tenant_id,
event_id,
patient_id,
Some(ctx.user_id),
)
.await?;
Ok(Json(ApiResponse::ok(()))) Ok(Json(ApiResponse::ok(())))
} }
@@ -196,12 +217,13 @@ pub async fn verify_order<S>(
Extension(ctx): Extension<TenantContext>, Extension(ctx): Extension<TenantContext>,
Json(req): Json<VerifyOrderReq>, Json(req): Json<VerifyOrderReq>,
) -> Result<Json<ApiResponse<PointsOrderResp>>, AppError> ) -> 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")?; require_permission(&ctx, "health.points.manage")?;
let result = points_service::verify_order( let result =
&state, ctx.tenant_id, req.qr_code, ctx.user_id, points_service::verify_order(&state, ctx.tenant_id, req.qr_code, ctx.user_id).await?;
).await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -209,7 +231,9 @@ pub async fn list_rules<S>(
State(state): State<HealthState>, State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>, Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<Vec<PointsRuleResp>>>, AppError> ) -> 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")?; require_permission(&ctx, "health.points.list")?;
let result = points_service::list_rules(&state, ctx.tenant_id).await?; 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>, Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreatePointsRuleReq>, Json(req): Json<CreatePointsRuleReq>,
) -> Result<Json<ApiResponse<PointsRuleResp>>, AppError> ) -> 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")?; require_permission(&ctx, "health.points.manage")?;
let mut req = req; let mut req = req;
req.sanitize(); req.sanitize();
let result = points_service::create_rule( let result = points_service::create_rule(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
&state, ctx.tenant_id, Some(ctx.user_id), req,
).await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -238,14 +262,22 @@ pub async fn update_rule<S>(
Path(rule_id): Path<Uuid>, Path(rule_id): Path<Uuid>,
Json(wrapper): Json<crate::dto::points_dto::UpdateRuleWithVersion>, Json(wrapper): Json<crate::dto::points_dto::UpdateRuleWithVersion>,
) -> Result<Json<ApiResponse<PointsRuleResp>>, AppError> ) -> 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")?; require_permission(&ctx, "health.points.manage")?;
let mut data = wrapper.data; let mut data = wrapper.data;
data.sanitize(); data.sanitize();
let result = points_service::update_rule( let result = points_service::update_rule(
&state, ctx.tenant_id, rule_id, Some(ctx.user_id), data, wrapper.version, &state,
).await?; ctx.tenant_id,
rule_id,
Some(ctx.user_id),
data,
wrapper.version,
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -255,12 +287,19 @@ pub async fn delete_rule<S>(
Path(rule_id): Path<Uuid>, Path(rule_id): Path<Uuid>,
Json(wrapper): Json<crate::dto::DeleteWithVersion>, Json(wrapper): Json<crate::dto::DeleteWithVersion>,
) -> Result<Json<ApiResponse<()>>, AppError> ) -> 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")?; require_permission(&ctx, "health.points.manage")?;
points_service::delete_rule( points_service::delete_rule(
&state, ctx.tenant_id, rule_id, Some(ctx.user_id), wrapper.version, &state,
).await?; ctx.tenant_id,
rule_id,
Some(ctx.user_id),
wrapper.version,
)
.await?;
Ok(Json(ApiResponse::ok(()))) Ok(Json(ApiResponse::ok(())))
} }
@@ -271,14 +310,22 @@ pub async fn admin_list_products<S>(
Query(page): Query<PaginationParams>, Query(page): Query<PaginationParams>,
Query(filter): Query<AdminProductFilter>, Query(filter): Query<AdminProductFilter>,
) -> Result<Json<ApiResponse<PaginatedResponse<PointsProductResp>>>, AppError> ) -> 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")?; require_permission(&ctx, "health.points.list")?;
let p = page.page.unwrap_or(1); let p = page.page.unwrap_or(1);
let ps = page.page_size.unwrap_or(20); let ps = page.page_size.unwrap_or(20);
let result = points_service::admin_list_products( let result = points_service::admin_list_products(
&state, ctx.tenant_id, params.product_type, filter.is_active, p, ps, &state,
).await?; ctx.tenant_id,
params.product_type,
filter.is_active,
p,
ps,
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -287,14 +334,15 @@ pub async fn admin_create_product<S>(
Extension(ctx): Extension<TenantContext>, Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreatePointsProductReq>, Json(req): Json<CreatePointsProductReq>,
) -> Result<Json<ApiResponse<PointsProductResp>>, AppError> ) -> 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")?; require_permission(&ctx, "health.points.manage")?;
let mut req = req; let mut req = req;
req.sanitize(); req.sanitize();
let result = points_service::create_product( let result =
&state, ctx.tenant_id, Some(ctx.user_id), req, points_service::create_product(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
).await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -304,14 +352,22 @@ pub async fn admin_update_product<S>(
Path(product_id): Path<Uuid>, Path(product_id): Path<Uuid>,
Json(wrapper): Json<crate::dto::points_dto::UpdateProductWithVersion>, Json(wrapper): Json<crate::dto::points_dto::UpdateProductWithVersion>,
) -> Result<Json<ApiResponse<PointsProductResp>>, AppError> ) -> 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")?; require_permission(&ctx, "health.points.manage")?;
let mut data = wrapper.data; let mut data = wrapper.data;
data.sanitize(); data.sanitize();
let result = points_service::update_product( let result = points_service::update_product(
&state, ctx.tenant_id, product_id, Some(ctx.user_id), data, wrapper.version, &state,
).await?; ctx.tenant_id,
product_id,
Some(ctx.user_id),
data,
wrapper.version,
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -321,12 +377,19 @@ pub async fn admin_delete_product<S>(
Path(product_id): Path<Uuid>, Path(product_id): Path<Uuid>,
Json(wrapper): Json<crate::dto::DeleteWithVersion>, Json(wrapper): Json<crate::dto::DeleteWithVersion>,
) -> Result<Json<ApiResponse<()>>, AppError> ) -> 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")?; require_permission(&ctx, "health.points.manage")?;
points_service::delete_product( points_service::delete_product(
&state, ctx.tenant_id, product_id, Some(ctx.user_id), wrapper.version, &state,
).await?; ctx.tenant_id,
product_id,
Some(ctx.user_id),
wrapper.version,
)
.await?;
Ok(Json(ApiResponse::ok(()))) Ok(Json(ApiResponse::ok(())))
} }
@@ -335,15 +398,15 @@ pub async fn admin_list_orders<S>(
Extension(ctx): Extension<TenantContext>, Extension(ctx): Extension<TenantContext>,
Query(params): Query<PaginationParams>, Query(params): Query<PaginationParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<PointsOrderResp>>>, AppError> ) -> 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")?; require_permission(&ctx, "health.points.list")?;
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
// 管理端查看所有订单 — 不按 patient_id 过滤 // 管理端查看所有订单 — 不按 patient_id 过滤
let result = points_service::admin_list_orders( let result = points_service::admin_list_orders(&state, ctx.tenant_id, page, page_size).await?;
&state, ctx.tenant_id, page, page_size,
).await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -356,14 +419,15 @@ pub async fn admin_create_event<S>(
Extension(ctx): Extension<TenantContext>, Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreateOfflineEventReq>, Json(req): Json<CreateOfflineEventReq>,
) -> Result<Json<ApiResponse<OfflineEventResp>>, AppError> ) -> 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")?; require_permission(&ctx, "health.points.manage")?;
let mut req = req; let mut req = req;
req.sanitize(); req.sanitize();
let result = points_service::create_offline_event( let result =
&state, ctx.tenant_id, Some(ctx.user_id), req, points_service::create_offline_event(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
).await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -373,14 +437,22 @@ pub async fn admin_update_event<S>(
Path(event_id): Path<Uuid>, Path(event_id): Path<Uuid>,
Json(wrapper): Json<UpdateOfflineEventWithVersion>, Json(wrapper): Json<UpdateOfflineEventWithVersion>,
) -> Result<Json<ApiResponse<OfflineEventResp>>, AppError> ) -> 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")?; require_permission(&ctx, "health.points.manage")?;
let mut data = wrapper.data; let mut data = wrapper.data;
data.sanitize(); data.sanitize();
let result = points_service::update_offline_event( let result = points_service::update_offline_event(
&state, ctx.tenant_id, event_id, Some(ctx.user_id), data, wrapper.version, &state,
).await?; ctx.tenant_id,
event_id,
Some(ctx.user_id),
data,
wrapper.version,
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -390,12 +462,19 @@ pub async fn admin_delete_event<S>(
Path(event_id): Path<Uuid>, Path(event_id): Path<Uuid>,
Json(wrapper): Json<crate::dto::DeleteWithVersion>, Json(wrapper): Json<crate::dto::DeleteWithVersion>,
) -> Result<Json<ApiResponse<()>>, AppError> ) -> 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")?; require_permission(&ctx, "health.points.manage")?;
points_service::delete_offline_event( points_service::delete_offline_event(
&state, ctx.tenant_id, event_id, Some(ctx.user_id), wrapper.version, &state,
).await?; ctx.tenant_id,
event_id,
Some(ctx.user_id),
wrapper.version,
)
.await?;
Ok(Json(ApiResponse::ok(()))) Ok(Json(ApiResponse::ok(())))
} }
@@ -411,14 +490,21 @@ pub async fn admin_list_events<S>(
Extension(ctx): Extension<TenantContext>, Extension(ctx): Extension<TenantContext>,
Query(params): Query<AdminListEventsParams>, Query(params): Query<AdminListEventsParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<OfflineEventResp>>>, AppError> ) -> 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")?; require_permission(&ctx, "health.points.list")?;
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = points_service::admin_list_offline_events( let result = points_service::admin_list_offline_events(
&state, ctx.tenant_id, params.status, page, page_size, &state,
).await?; ctx.tenant_id,
params.status,
page,
page_size,
)
.await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
@@ -428,12 +514,19 @@ pub async fn admin_checkin_event<S>(
Path(event_id): Path<Uuid>, Path(event_id): Path<Uuid>,
Json(req): Json<AdminCheckinReq>, Json(req): Json<AdminCheckinReq>,
) -> Result<Json<ApiResponse<()>>, AppError> ) -> 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")?; require_permission(&ctx, "health.points.manage")?;
points_service::admin_checkin_event( points_service::admin_checkin_event(
&state, ctx.tenant_id, event_id, req.patient_id, Some(ctx.user_id), &state,
).await?; ctx.tenant_id,
event_id,
req.patient_id,
Some(ctx.user_id),
)
.await?;
Ok(Json(ApiResponse::ok(()))) Ok(Json(ApiResponse::ok(())))
} }
@@ -445,7 +538,9 @@ pub async fn get_points_statistics<S>(
State(state): State<HealthState>, State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>, Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<PointsStatisticsResp>>, AppError> ) -> 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")?; require_permission(&ctx, "health.points.list")?;
let result = points_service::get_points_statistics(&state, ctx.tenant_id).await?; 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>, Extension(ctx): Extension<TenantContext>,
Path(patient_id): Path<Uuid>, Path(patient_id): Path<Uuid>,
) -> Result<Json<ApiResponse<PointsAccountResp>>, AppError> ) -> 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")?; require_permission(&ctx, "health.points.list")?;
let result = points_service::get_account(&state, ctx.tenant_id, patient_id).await?; 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>, Path(patient_id): Path<Uuid>,
Query(params): Query<PaginationParams>, Query(params): Query<PaginationParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<PointsTransactionResp>>>, AppError> ) -> 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")?; require_permission(&ctx, "health.points.list")?;
let page = params.page.unwrap_or(1); let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20); let page_size = params.page_size.unwrap_or(20);
let result = points_service::list_transactions( let result =
&state, ctx.tenant_id, patient_id, page, page_size, points_service::list_transactions(&state, ctx.tenant_id, patient_id, page, page_size)
).await?; .await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }

Some files were not shown because too many files have changed in this diff Show More