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]
fn provider_type_all_variants() {
for pt in [ProviderType::Claude, ProviderType::Openai, ProviderType::Ollama, ProviderType::Rules] {
for pt in [
ProviderType::Claude,
ProviderType::Openai,
ProviderType::Ollama,
ProviderType::Rules,
] {
let json = serde_json::to_string(&pt).unwrap();
let back: ProviderType = serde_json::from_str(&json).unwrap();
assert_eq!(back, pt);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -16,7 +16,12 @@ pub struct CacheKey {
}
impl CacheKey {
pub fn new(tenant_id: Uuid, analysis_type: &str, input: &serde_json::Value, prompt_version: i32) -> Self {
pub fn new(
tenant_id: Uuid,
analysis_type: &str,
input: &serde_json::Value,
prompt_version: i32,
) -> Self {
let canonical = serde_json::to_string(input).unwrap_or_default();
let mut hasher = Sha256::new();
hasher.update(canonical.as_bytes());
@@ -54,8 +59,16 @@ pub struct CacheService {
}
impl CacheService {
pub fn new(redis: redis::Client, db: sea_orm::DatabaseConnection, default_ttl: Duration) -> Self {
Self { redis, db, default_ttl }
pub fn new(
redis: redis::Client,
db: sea_orm::DatabaseConnection,
default_ttl: Duration,
) -> Self {
Self {
redis,
db,
default_ttl,
}
}
pub async fn get(&self, key: &CacheKey) -> AiResult<Option<CachedAnalysis>> {
@@ -127,12 +140,13 @@ impl CacheService {
let data: Option<String> = conn.get(key).await?;
match data {
Some(json) => {
let cached: CachedAnalysis = serde_json::from_str(&json)
.map_err(|e| redis::RedisError::from((
let cached: CachedAnalysis = serde_json::from_str(&json).map_err(|e| {
redis::RedisError::from((
redis::ErrorKind::TypeError,
"反序列化失败",
e.to_string(),
)))?;
))
})?;
Ok(Some(cached))
}
None => Ok(None),
@@ -141,12 +155,10 @@ impl CacheService {
async fn try_redis_set(&self, key: &str, value: &CachedAnalysis) -> redis::RedisResult<()> {
let mut conn = self.redis.get_multiplexed_async_connection().await?;
let json = serde_json::to_string(value).map_err(|e| redis::RedisError::from((
redis::ErrorKind::TypeError,
"序列化失败",
e.to_string(),
)))?;
let (): () = conn.set_ex(key, json, self.default_ttl.as_secs() as u64).await?;
let json = serde_json::to_string(value).map_err(|e| {
redis::RedisError::from((redis::ErrorKind::TypeError, "序列化失败", e.to_string()))
})?;
let (): () = conn.set_ex(key, json, self.default_ttl.as_secs()).await?;
Ok(())
}
@@ -155,15 +167,14 @@ impl CacheService {
let mut count = 0u64;
let mut cursor: u64 = 0;
loop {
let (new_cursor, keys): (u64, Vec<String>) =
redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(pattern)
.arg("COUNT")
.arg(100)
.query_async(&mut conn)
.await?;
let (new_cursor, keys): (u64, Vec<String>) = redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(pattern)
.arg("COUNT")
.arg(100)
.query_async(&mut conn)
.await?;
if !keys.is_empty() {
let del_count: u64 = conn.del(&keys).await?;
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()) {
for key in b_obj.keys() {
if let (Some(b_val), Some(c_val)) = (b_obj.get(key), c_obj.get(key)) {
if let (Some(b_num), Some(c_num)) = (b_val.as_f64(), c_val.as_f64()) {
let change_pct = if b_num.abs() > 0.0001 {
((c_num - b_num) / b_num.abs()) * 100.0
} else {
0.0
};
let trend = if change_pct.abs() > 5.0 {
TrendDirection::Worsening
} else {
TrendDirection::Stable
};
changes.push(MetricChange {
metric: key.clone(),
baseline_value: b_num,
current_value: c_num,
change_percent: change_pct,
trend,
});
}
if let (Some(b_val), Some(c_val)) = (b_obj.get(key), c_obj.get(key))
&& let (Some(b_num), Some(c_num)) = (b_val.as_f64(), c_val.as_f64())
{
let change_pct = if b_num.abs() > 0.0001 {
((c_num - b_num) / b_num.abs()) * 100.0
} else {
0.0
};
let trend = if change_pct.abs() > 5.0 {
TrendDirection::Worsening
} else {
TrendDirection::Stable
};
changes.push(MetricChange {
metric: key.clone(),
baseline_value: b_num,
current_value: c_num,
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 {
TrendDirection::Worsening
} else {

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,15 +21,14 @@ pub async fn handle_reanalysis_requested(
FROM ai_suggestion
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
"#;
let original: Option<OriginalSuggestion> = OriginalSuggestion::find_by_statement(
Statement::from_sql_and_values(
let original: Option<OriginalSuggestion> =
OriginalSuggestion::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[original_suggestion_id.into(), tenant_id.into()],
),
)
.one(db)
.await?;
))
.one(db)
.await?;
match original {
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::entity::ai_suggestion;
use erp_core::error::AppResult;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use uuid::Uuid;
pub struct SuggestionService;
@@ -85,9 +85,7 @@ impl SuggestionService {
.filter(ai_suggestion::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| {
crate::error::AiError::AnalysisNotFound("建议不存在".into())
})?;
.ok_or_else(|| crate::error::AiError::AnalysisNotFound("建议不存在".into()))?;
let current_status = parse_status(&item.status);
if !current_status.can_transition_to(new_status) {
@@ -122,13 +120,14 @@ impl SuggestionService {
.filter(ai_suggestion::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| {
crate::error::AiError::AnalysisNotFound("建议不存在".into())
})?;
.ok_or_else(|| crate::error::AiError::AnalysisNotFound("建议不存在".into()))?;
let current_status = parse_status(&item.status);
// 允许从 Pending 或 Approved 直接执行(护士可能跳过审批)
if !matches!(current_status, SuggestionStatus::Pending | SuggestionStatus::Approved) {
if !matches!(
current_status,
SuggestionStatus::Pending | SuggestionStatus::Approved
) {
return Err(crate::error::AiError::Validation(format!(
"建议状态为 {},无法执行(需要 pending 或 approved",
current_status.as_str()

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

View File

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

View File

@@ -45,7 +45,11 @@ where
// TODO: 多租户微信登录需要设计租户解析策略(如 per-appid 映射或登录后选择租户)
let tenant_id = state.default_tenant_id;
let resp = WechatService::login(&state, tenant_id, &req.code).await?;
tracing::info!(bound = resp.bound, has_token = resp.token.is_some(), "微信登录结果");
tracing::info!(
bound = resp.bound,
has_token = resp.token.is_some(),
"微信登录结果"
);
Ok(Json(ApiResponse::ok(resp)))
}
@@ -75,13 +79,8 @@ where
// TODO: 多租户微信登录需要设计租户解析策略
let tenant_id = state.default_tenant_id;
let resp = WechatService::bind_phone(
&state,
tenant_id,
&req.openid,
&req.encrypted_data,
&req.iv,
)
.await?;
let resp =
WechatService::bind_phone(&state, tenant_id, &req.openid, &req.encrypted_data, &req.iv)
.await?;
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>(2),
) {
scopes.insert(code, DataScope::from_str(&scope));
scopes.insert(code, DataScope::parse_scope(&scope));
}
}
scopes

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,9 @@
use std::collections::HashMap;
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder, Set};
use sea_orm::{
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder, Set,
};
use uuid::Uuid;
use crate::dto::{CreateMenuReq, MenuResp};

View File

@@ -35,11 +35,11 @@ pub(crate) fn format_number(
result.push_str(separator);
}
if let Some(dp) = date_part {
if !dp.is_empty() {
result.push_str(dp);
result.push_str(separator);
}
if let Some(dp) = date_part
&& !dp.is_empty()
{
result.push_str(dp);
result.push_str(separator);
}
let width = (seq_length.max(1)) as usize;
@@ -398,7 +398,10 @@ impl NumberingService {
.map_err(|e| ConfigError::Validation(e.to_string()))?;
// 拼接编号字符串: {prefix}{separator}{date_part}{separator}{seq_padded}
let date_part = rule.date_format.as_ref().map(|fmt| Utc::now().format(fmt).to_string());
let date_part = rule
.date_format
.as_ref()
.map(|fmt| Utc::now().format(fmt).to_string());
let number = format_number(
&rule.prefix,
@@ -611,7 +614,8 @@ mod tests {
#[test]
fn reset_no_last_reset_date_returns_seq_start() {
// 从未重置过,使用 seq_start
let result = NumberingService::maybe_reset_sequence(999, 1, "daily", None, date(2026, 4, 15));
let result =
NumberingService::maybe_reset_sequence(999, 1, "daily", None, date(2026, 4, 15));
assert_eq!(result, 1);
}

View File

@@ -2,7 +2,7 @@ use crate::audit::AuditLog;
use crate::entity::audit_log;
use crate::request_info::RequestInfo;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
use sha2::{Sha256, Digest};
use sha2::{Digest, Sha256};
use tracing;
/// 持久化审计日志到 audit_logs 表。
@@ -16,14 +16,12 @@ use tracing;
/// 计算 SHA256(id + action + resource_type + resource_id + created_at + prev_hash) 作为 record_hash。
pub async fn record(mut log: AuditLog, db: &sea_orm::DatabaseConnection) {
// 自动填充请求来源信息(仅当调用方未显式设置时)
if log.ip_address.is_none() || log.user_agent.is_none() {
if let Some(info) = RequestInfo::try_current() {
if log.ip_address.is_none() {
log.ip_address = info.ip_address;
}
if log.user_agent.is_none() {
log.user_agent = info.user_agent;
}
if let Some(info) = RequestInfo::try_current() {
if log.ip_address.is_none() {
log.ip_address = info.ip_address;
}
if log.user_agent.is_none() {
log.user_agent = info.user_agent;
}
}

View File

@@ -1,6 +1,6 @@
use aes_gcm::aead::Aead;
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use rand::RngCore;
const CIPHER_VERSION: u8 = 0x01;
@@ -41,6 +41,8 @@ pub fn decrypt(key: &[u8; 32], encoded: &str) -> Result<String, String> {
let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| e.to_string())?;
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = cipher.decrypt(nonce, ciphertext).map_err(|e| e.to_string())?;
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|e| e.to_string())?;
String::from_utf8(plaintext).map_err(|e| e.to_string())
}

View File

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

View File

@@ -57,7 +57,10 @@ mod tests {
#[test]
fn mask_phone_normal() {
assert_eq!(Some("138****5678".to_string()), mask_phone(Some("13812345678")));
assert_eq!(
Some("138****5678".to_string()),
mask_phone(Some("13812345678"))
);
}
#[test]
@@ -87,7 +90,10 @@ mod tests {
#[test]
fn mask_phone_unicode_safe() {
assert_eq!(Some("你好世****cdef".to_string()), mask_phone(Some("你好世界abcdef")));
assert_eq!(
Some("你好世****cdef".to_string()),
mask_phone(Some("你好世界abcdef"))
);
}
#[test]

View File

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

View File

@@ -1,6 +1,6 @@
use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
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,
"occurred_at": Utc::now().to_rfc3339(),
});
if let serde_json::Value::Object(ref mut map) = envelope {
if let serde_json::Value::Object(data_map) = data {
for (k, v) in data_map {
map.insert(k, v);
}
if let serde_json::Value::Object(ref mut map) = envelope
&& let serde_json::Value::Object(data_map) = data
{
for (k, v) in data_map {
map.insert(k, v);
}
}
envelope
@@ -314,10 +314,10 @@ impl EventBus {
event = broadcast_rx.recv() => {
match event {
Ok(event) => {
if event.event_type.starts_with(&prefix) {
if mpsc_tx.send(event).await.is_err() {
break;
}
if event.event_type.starts_with(&prefix)
&& mpsc_tx.send(event).await.is_err()
{
break;
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {

View File

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

View File

@@ -2,7 +2,7 @@
///
/// 基于 ammoniahtml5ever剥离所有 HTML 标签,防止存储型 XSS。
/// 覆盖场景:用户名、显示名、邮箱、电话等字符串字段。
///
/// 剥离字符串中的所有 HTML 标签,返回纯文本。
///
/// 使用 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 tokio::sync::OnceCell;
@@ -22,12 +24,8 @@ fn db_url() -> String {
async fn db_pool() -> &'static DatabaseConnection {
DB_POOL
.get_or_init(|| async {
let opt = ConnectOptions::new(db_url())
.max_connections(5)
.to_owned();
Database::connect(opt)
.await
.expect("测试数据库连接失败")
let opt = ConnectOptions::new(db_url()).max_connections(5).to_owned();
Database::connect(opt).await.expect("测试数据库连接失败")
})
.await
}
@@ -35,7 +33,5 @@ async fn db_pool() -> &'static DatabaseConnection {
/// 创建测试用事务。测试结束自动回滚,无数据残留。
pub async fn test_txn() -> DatabaseTransaction {
let pool = db_pool().await;
pool.begin()
.await
.expect("测试事务创建失败")
pool.begin().await.expect("测试事务创建失败")
}

View File

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

View File

@@ -1,4 +1,3 @@
/// 预留事件处理器注册
pub fn register_handlers_with_state(_state: crate::state::DialysisState) {
// 透析业务事件由 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::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::dialysis_dto::*;
use crate::dto::DeleteWithVersion;
use crate::dto::dialysis_dto::*;
use crate::service::dialysis_service;
use crate::state::DialysisState;
@@ -44,10 +44,9 @@ where
require_permission(&ctx, "health.dialysis.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let result = dialysis_service::list_dialysis_records(
&state, ctx.tenant_id, patient_id, page, page_size,
)
.await?;
let result =
dialysis_service::list_dialysis_records(&state, ctx.tenant_id, patient_id, page, page_size)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
@@ -61,10 +60,7 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.dialysis.list")?;
let result = dialysis_service::get_dialysis_record(
&state, ctx.tenant_id, record_id,
)
.await?;
let result = dialysis_service::get_dialysis_record(&state, ctx.tenant_id, record_id).await?;
Ok(Json(ApiResponse::ok(result)))
}
@@ -80,10 +76,9 @@ where
require_permission(&ctx, "health.dialysis.manage")?;
let mut req = req;
req.sanitize();
let result = dialysis_service::create_dialysis_record(
&state, ctx.tenant_id, Some(ctx.user_id), req,
)
.await?;
let result =
dialysis_service::create_dialysis_record(&state, ctx.tenant_id, Some(ctx.user_id), req)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
@@ -101,7 +96,12 @@ where
let mut data = req.data;
data.sanitize();
let result = dialysis_service::update_dialysis_record(
&state, ctx.tenant_id, record_id, Some(ctx.user_id), data, req.version,
&state,
ctx.tenant_id,
record_id,
Some(ctx.user_id),
data,
req.version,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
@@ -119,7 +119,11 @@ where
{
require_permission(&ctx, "health.dialysis.manage")?;
let result = dialysis_service::review_dialysis_record(
&state, ctx.tenant_id, record_id, ctx.user_id, req.version,
&state,
ctx.tenant_id,
record_id,
ctx.user_id,
req.version,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
@@ -137,7 +141,11 @@ where
{
require_permission(&ctx, "health.dialysis.manage")?;
let result = dialysis_service::complete_dialysis_record(
&state, ctx.tenant_id, record_id, Some(ctx.user_id), req.version,
&state,
ctx.tenant_id,
record_id,
Some(ctx.user_id),
req.version,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
@@ -155,7 +163,11 @@ where
{
require_permission(&ctx, "health.dialysis.manage")?;
dialysis_service::delete_dialysis_record(
&state, ctx.tenant_id, record_id, Some(ctx.user_id), req.version,
&state,
ctx.tenant_id,
record_id,
Some(ctx.user_id),
req.version,
)
.await?;
Ok(Json(ApiResponse::ok(())))

View File

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

View File

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

View File

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

View File

@@ -45,7 +45,13 @@ pub async fn list_dialysis_records(
let crypto = &state.crypto;
let data: Vec<DialysisRecordResp> = models.into_iter().map(|m| to_resp(crypto, m)).collect();
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
Ok(PaginatedResponse {
data,
total,
page,
page_size: limit,
total_pages,
})
}
pub async fn get_dialysis_record(
@@ -92,15 +98,19 @@ pub async fn create_dialysis_record(
let kek = state.crypto.kek();
// PII 加密
let encrypted_symptoms = req.symptoms.as_ref()
let encrypted_symptoms = req
.symptoms
.as_ref()
.map(|v| -> DialysisResult<serde_json::Value> {
let json_str = serde_json::to_string(v)
.map_err(|e| DialysisError::Validation(e.to_string()))?;
let json_str =
serde_json::to_string(v).map_err(|e| DialysisError::Validation(e.to_string()))?;
Ok(serde_json::Value::String(pii::encrypt(kek, &json_str)?))
})
.transpose()?;
let encrypted_complication = req.complication_notes.as_ref()
let encrypted_complication = req
.complication_notes
.as_ref()
.map(|c| pii::encrypt(kek, c))
.transpose()?;
@@ -112,9 +122,15 @@ pub async fn create_dialysis_record(
dialysis_date: Set(req.dialysis_date),
start_time: Set(req.start_time),
end_time: Set(req.end_time),
dry_weight: Set(req.dry_weight.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
pre_weight: Set(req.pre_weight.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
post_weight: Set(req.post_weight.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
dry_weight: Set(req
.dry_weight
.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
pre_weight: Set(req
.pre_weight
.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
post_weight: Set(req
.post_weight
.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
pre_bp_systolic: Set(req.pre_bp_systolic),
pre_bp_diastolic: Set(req.pre_bp_diastolic),
post_bp_systolic: Set(req.post_bp_systolic),
@@ -142,10 +158,16 @@ pub async fn create_dialysis_record(
let m = active.insert(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "dialysis_record.created", "dialysis_record")
.with_resource_id(m.id),
AuditLog::new(
tenant_id,
operator_id,
"dialysis_record.created",
"dialysis_record",
)
.with_resource_id(m.id),
&state.db,
).await;
)
.await;
// 发布透析记录创建事件
let event = DomainEvent::new(
@@ -182,27 +204,61 @@ pub async fn update_dialysis_record(
.map_err(|_| DialysisError::VersionMismatch)?;
let mut active: dialysis_record::ActiveModel = model.into();
if let Some(v) = req.dialysis_date { active.dialysis_date = Set(v); }
if let Some(v) = req.start_time { active.start_time = Set(Some(v)); }
if let Some(v) = req.end_time { active.end_time = Set(Some(v)); }
if let Some(v) = req.dry_weight { active.dry_weight = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); }
if let Some(v) = req.pre_weight { active.pre_weight = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); }
if let Some(v) = req.post_weight { active.post_weight = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); }
if let Some(v) = req.pre_bp_systolic { active.pre_bp_systolic = Set(Some(v)); }
if let Some(v) = req.pre_bp_diastolic { active.pre_bp_diastolic = Set(Some(v)); }
if let Some(v) = req.post_bp_systolic { active.post_bp_systolic = Set(Some(v)); }
if let Some(v) = req.post_bp_diastolic { active.post_bp_diastolic = Set(Some(v)); }
if let Some(v) = req.pre_heart_rate { active.pre_heart_rate = Set(Some(v)); }
if let Some(v) = req.post_heart_rate { active.post_heart_rate = Set(Some(v)); }
if let Some(v) = req.ultrafiltration_volume { active.ultrafiltration_volume = Set(Some(v)); }
if let Some(v) = req.dialysis_duration { active.dialysis_duration = Set(Some(v)); }
if let Some(v) = req.blood_flow_rate { active.blood_flow_rate = Set(Some(v)); }
if let Some(ref v) = req.dialysis_type { validate_dialysis_type(v)?; active.dialysis_type = Set(v.clone()); }
if let Some(v) = req.dialysis_date {
active.dialysis_date = Set(v);
}
if let Some(v) = req.start_time {
active.start_time = Set(Some(v));
}
if let Some(v) = req.end_time {
active.end_time = Set(Some(v));
}
if let Some(v) = req.dry_weight {
active.dry_weight = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default()));
}
if let Some(v) = req.pre_weight {
active.pre_weight = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default()));
}
if let Some(v) = req.post_weight {
active.post_weight = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default()));
}
if let Some(v) = req.pre_bp_systolic {
active.pre_bp_systolic = Set(Some(v));
}
if let Some(v) = req.pre_bp_diastolic {
active.pre_bp_diastolic = Set(Some(v));
}
if let Some(v) = req.post_bp_systolic {
active.post_bp_systolic = Set(Some(v));
}
if let Some(v) = req.post_bp_diastolic {
active.post_bp_diastolic = Set(Some(v));
}
if let Some(v) = req.pre_heart_rate {
active.pre_heart_rate = Set(Some(v));
}
if let Some(v) = req.post_heart_rate {
active.post_heart_rate = Set(Some(v));
}
if let Some(v) = req.ultrafiltration_volume {
active.ultrafiltration_volume = Set(Some(v));
}
if let Some(v) = req.dialysis_duration {
active.dialysis_duration = Set(Some(v));
}
if let Some(v) = req.blood_flow_rate {
active.blood_flow_rate = Set(Some(v));
}
if let Some(ref v) = req.dialysis_type {
validate_dialysis_type(v)?;
active.dialysis_type = Set(v.clone());
}
if let Some(v) = req.symptoms {
let kek = state.crypto.kek();
let encrypted = Some(serde_json::Value::String(
pii::encrypt(kek, &serde_json::to_string(&v).unwrap_or_default())?
));
let encrypted = Some(serde_json::Value::String(pii::encrypt(
kek,
&serde_json::to_string(&v).unwrap_or_default(),
)?));
active.symptoms = Set(encrypted);
}
if let Some(v) = req.complication_notes {
@@ -218,10 +274,16 @@ pub async fn update_dialysis_record(
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "dialysis_record.updated", "dialysis_record")
.with_resource_id(m.id),
AuditLog::new(
tenant_id,
operator_id,
"dialysis_record.updated",
"dialysis_record",
)
.with_resource_id(m.id),
&state.db,
).await;
)
.await;
Ok(to_resp(&state.crypto, m))
}
@@ -255,10 +317,16 @@ pub async fn complete_dialysis_record(
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "dialysis_record.completed", "dialysis_record")
.with_resource_id(m.id),
AuditLog::new(
tenant_id,
operator_id,
"dialysis_record.completed",
"dialysis_record",
)
.with_resource_id(m.id),
&state.db,
).await;
)
.await;
Ok(to_resp(&state.crypto, m))
}
@@ -294,10 +362,16 @@ pub async fn review_dialysis_record(
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, Some(reviewer_id), "dialysis_record.reviewed", "dialysis_record")
.with_resource_id(m.id),
AuditLog::new(
tenant_id,
Some(reviewer_id),
"dialysis_record.reviewed",
"dialysis_record",
)
.with_resource_id(m.id),
&state.db,
).await;
)
.await;
Ok(to_resp(&state.crypto, m))
}
@@ -328,10 +402,16 @@ pub async fn delete_dialysis_record(
active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "dialysis_record.deleted", "dialysis_record")
.with_resource_id(record_id),
AuditLog::new(
tenant_id,
operator_id,
"dialysis_record.deleted",
"dialysis_record",
)
.with_resource_id(record_id),
&state.db,
).await;
)
.await;
Ok(())
}
@@ -345,7 +425,8 @@ fn validate_dialysis_type(dialysis_type: &str) -> DialysisResult<()> {
match dialysis_type {
"HD" | "HDF" | "HF" => Ok(()),
_ => Err(DialysisError::Validation(format!(
"无效的透析类型: {},允许值: HD, HDF, HF", dialysis_type
"无效的透析类型: {},允许值: HD, HDF, HF",
dialysis_type
))),
}
}
@@ -365,7 +446,8 @@ fn validate_dialysis_status_transition(current: &str, new: &str) -> DialysisResu
Ok(())
} else {
Err(DialysisError::InvalidStatusTransition(format!(
"dialysis_record.status: 不允许从 '{}' 转换到 '{}'", current, new
"dialysis_record.status: 不允许从 '{}' 转换到 '{}'",
current, new
)))
}
}
@@ -374,14 +456,18 @@ fn to_resp(crypto: &erp_core::crypto::PiiCrypto, m: dialysis_record::Model) -> D
let kek = crypto.kek();
// 解密症状 JSON加密时存储为 Value::String(ciphertext)
let symptoms = m.symptoms.as_ref()
let symptoms = m
.symptoms
.as_ref()
.and_then(|v| v.as_str())
.and_then(|s| pii::decrypt(kek, s).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.or(m.symptoms);
// 解密并发症备注
let complication_notes = m.complication_notes.as_ref()
let complication_notes = m
.complication_notes
.as_ref()
.map(|c| pii::decrypt(kek, c).unwrap_or_else(|_| c.clone()))
.or(m.complication_notes);
@@ -421,25 +507,45 @@ mod tests {
// --- validate_dialysis_type ---
#[test]
fn dialysis_type_hd() { assert!(validate_dialysis_type("HD").is_ok()); }
fn dialysis_type_hd() {
assert!(validate_dialysis_type("HD").is_ok());
}
#[test]
fn dialysis_type_hdf() { assert!(validate_dialysis_type("HDF").is_ok()); }
fn dialysis_type_hdf() {
assert!(validate_dialysis_type("HDF").is_ok());
}
#[test]
fn dialysis_type_hf() { assert!(validate_dialysis_type("HF").is_ok()); }
fn dialysis_type_hf() {
assert!(validate_dialysis_type("HF").is_ok());
}
#[test]
fn dialysis_type_invalid() { assert!(validate_dialysis_type("PD").is_err()); }
fn dialysis_type_invalid() {
assert!(validate_dialysis_type("PD").is_err());
}
// --- validate_dialysis_status_transition ---
#[test]
fn dial_draft_to_completed() { assert!(validate_dialysis_status_transition("draft", "completed").is_ok()); }
fn dial_draft_to_completed() {
assert!(validate_dialysis_status_transition("draft", "completed").is_ok());
}
#[test]
fn dial_draft_to_reviewed_fails() { assert!(validate_dialysis_status_transition("draft", "reviewed").is_err()); }
fn dial_draft_to_reviewed_fails() {
assert!(validate_dialysis_status_transition("draft", "reviewed").is_err());
}
#[test]
fn dial_completed_to_reviewed() { assert!(validate_dialysis_status_transition("completed", "reviewed").is_ok()); }
fn dial_completed_to_reviewed() {
assert!(validate_dialysis_status_transition("completed", "reviewed").is_ok());
}
#[test]
fn dial_completed_to_draft_fails() { assert!(validate_dialysis_status_transition("completed", "draft").is_err()); }
fn dial_completed_to_draft_fails() {
assert!(validate_dialysis_status_transition("completed", "draft").is_err());
}
#[test]
fn dial_reviewed_to_any_fails() { assert!(validate_dialysis_status_transition("reviewed", "draft").is_err()); }
fn dial_reviewed_to_any_fails() {
assert!(validate_dialysis_status_transition("reviewed", "draft").is_err());
}
#[test]
fn dial_same_status_ok() { assert!(validate_dialysis_status_transition("draft", "draft").is_ok()); }
fn dial_same_status_ok() {
assert!(validate_dialysis_status_transition("draft", "draft").is_ok());
}
}

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
use aes_gcm::aead::Aead;
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use zeroize::Zeroizing;

View File

@@ -119,7 +119,9 @@ pub struct UpdateArticleReq {
impl UpdateArticleReq {
pub fn sanitize(&mut self) {
if let Some(ref mut v) = self.title { *v = strip_html_tags(v); }
if let Some(ref mut v) = self.title {
*v = strip_html_tags(v);
}
self.summary = sanitize_option(self.summary.take());
self.content = sanitize_option(self.content.take());
self.category = sanitize_option(self.category.take());
@@ -205,7 +207,9 @@ pub struct UpdateCategoryReq {
impl UpdateCategoryReq {
pub fn sanitize(&mut self) {
if let Some(ref mut v) = self.name { *v = strip_html_tags(v); }
if let Some(ref mut v) = self.name {
*v = strip_html_tags(v);
}
self.slug = sanitize_option(self.slug.take());
self.description = sanitize_option(self.description.take());
}

View File

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

View File

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

View File

@@ -39,7 +39,10 @@ pub struct CreateShiftReq {
impl CreateShiftReq {
pub fn sanitize(&mut self) {
self.period = erp_core::sanitize::sanitize_string(&self.period);
self.notes = self.notes.take().map(|n| erp_core::sanitize::sanitize_string(&n));
self.notes = self
.notes
.take()
.map(|n| erp_core::sanitize::sanitize_string(&n));
}
}

View File

@@ -1,20 +1,23 @@
pub mod alert_rules;
pub mod ble_gateway;
pub mod api_client;
pub mod alerts;
pub mod api_client;
pub mod appointment;
pub mod article;
pub mod article_article_tag;
pub mod article_category;
pub mod article_revision;
pub mod article_tag;
pub mod ble_gateway;
pub mod blind_index;
pub mod critical_value_threshold;
pub mod care_plan;
pub mod care_plan_item;
pub mod care_plan_outcome;
pub mod consent;
pub mod consultation_message;
pub mod consultation_session;
pub mod critical_alert;
pub mod critical_alert_response;
pub mod critical_value_threshold;
pub mod daily_monitoring;
pub mod device_readings;
pub mod diagnosis;
@@ -25,31 +28,28 @@ pub mod follow_up_task;
pub mod follow_up_template;
pub mod follow_up_template_field;
pub mod gateway_patient_binding;
pub mod handoff_log;
pub mod health_record;
pub mod health_trend;
pub mod lab_report;
pub mod medication_record;
pub mod medication_reminder;
pub mod offline_event;
pub mod offline_event_registration;
pub mod patient;
pub mod patient_assignment;
pub mod patient_devices;
pub mod patient_doctor_relation;
pub mod patient_family_member;
pub mod patient_tag;
pub mod patient_tag_relation;
pub mod patient_devices;
pub mod points_account;
pub mod points_checkin;
pub mod points_order;
pub mod points_product;
pub mod points_rule;
pub mod points_transaction;
pub mod offline_event;
pub mod offline_event_registration;
pub mod medication_record;
pub mod medication_reminder;
pub mod vital_signs;
pub mod care_plan;
pub mod care_plan_item;
pub mod care_plan_outcome;
pub mod shift;
pub mod patient_assignment;
pub mod handoff_log;
pub mod vital_signs;
pub mod vital_signs_daily;
pub mod vital_signs_hourly;

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,10 @@ use erp_core::error::AppError;
use erp_core::rbac::{require_any_permission, require_permission};
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::article_dto::{ArticleListItem, ArticleListParams, ArticleResp, CreateArticleReq, ReviewArticleReq, UpdateArticleReq};
use crate::dto::article_dto::{
ArticleListItem, ArticleListParams, ArticleResp, CreateArticleReq, ReviewArticleReq,
UpdateArticleReq,
};
use crate::service::article_service;
use crate::state::HealthState;
@@ -22,14 +25,24 @@ where
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
// 非管理权限用户只能查看已发布文章,防止草稿泄露
let status = if require_any_permission(&ctx, &["health.articles.manage", "health.articles.review"]).is_ok() {
params.status
} else {
Some("published".to_string())
};
let status =
if require_any_permission(&ctx, &["health.articles.manage", "health.articles.review"])
.is_ok()
{
params.status
} else {
Some("published".to_string())
};
let result = article_service::list_articles(
&state, ctx.tenant_id, page, page_size,
params.category, status, params.category_id, params.tag_id, params.keyword,
&state,
ctx.tenant_id,
page,
page_size,
params.category,
status,
params.category_id,
params.tag_id,
params.keyword,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
@@ -45,7 +58,8 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.list")?;
let is_admin = require_any_permission(&ctx, &["health.articles.manage", "health.articles.review"]).is_ok();
let is_admin =
require_any_permission(&ctx, &["health.articles.manage", "health.articles.review"]).is_ok();
let result = article_service::get_article(&state, ctx.tenant_id, id, is_admin).await?;
Ok(Json(ApiResponse::ok(result)))
}
@@ -61,9 +75,8 @@ where
{
require_permission(&ctx, "health.articles.manage")?;
req.sanitize();
let result = article_service::create_article(
&state, ctx.tenant_id, Some(ctx.user_id), req.0,
).await?;
let result =
article_service::create_article(&state, ctx.tenant_id, Some(ctx.user_id), req.0).await?;
Ok(Json(ApiResponse::ok(result)))
}
@@ -79,9 +92,9 @@ where
{
require_permission(&ctx, "health.articles.manage")?;
req.sanitize();
let result = article_service::update_article(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.0,
).await?;
let result =
article_service::update_article(&state, ctx.tenant_id, id, Some(ctx.user_id), req.0)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
@@ -101,7 +114,8 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.manage")?;
article_service::delete_article(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version).await?;
article_service::delete_article(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version)
.await?;
Ok(Json(ApiResponse::ok(())))
}
@@ -126,9 +140,9 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.manage")?;
let result = article_service::submit_article(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version,
).await?;
let result =
article_service::submit_article(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
@@ -147,8 +161,14 @@ where
req.sanitize();
let version = req.version.unwrap_or(0);
let result = article_service::approve_article(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.0, version,
).await?;
&state,
ctx.tenant_id,
id,
Some(ctx.user_id),
req.0,
version,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
@@ -167,8 +187,14 @@ where
req.sanitize();
let version = req.version.unwrap_or(0);
let result = article_service::reject_article(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.0, version,
).await?;
&state,
ctx.tenant_id,
id,
Some(ctx.user_id),
req.0,
version,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
@@ -185,8 +211,13 @@ where
{
require_permission(&ctx, "health.articles.manage")?;
let result = article_service::unpublish_article(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version,
).await?;
&state,
ctx.tenant_id,
id,
Some(ctx.user_id),
req.version,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
@@ -216,7 +247,10 @@ pub async fn list_revisions<S>(
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
Query(params): Query<ListRevisionsQuery>,
) -> Result<Json<ApiResponse<PaginatedResponse<crate::dto::article_dto::ArticleRevisionResp>>>, AppError>
) -> Result<
Json<ApiResponse<PaginatedResponse<crate::dto::article_dto::ArticleRevisionResp>>>,
AppError,
>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
@@ -224,8 +258,7 @@ where
require_permission(&ctx, "health.articles.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let result = article_service::list_revisions(
&state, ctx.tenant_id, id, page, page_size,
).await?;
let result =
article_service::list_revisions(&state, ctx.tenant_id, id, page, page_size).await?;
Ok(Json(ApiResponse::ok(result)))
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
use axum::Extension;
use axum::extract::{FromRef, Json, Path, Query, State};
use serde::Deserialize;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use serde::Deserialize;
use crate::dto::medication_record_dto::*;
use crate::dto::DeleteWithVersion;
use crate::dto::medication_record_dto::*;
use crate::service::medication_record_service;
use crate::state::HealthState;
@@ -31,7 +31,11 @@ where
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let result = medication_record_service::list_medications(
&state, ctx.tenant_id, patient_id, page, page_size,
&state,
ctx.tenant_id,
patient_id,
page,
page_size,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
@@ -66,13 +70,9 @@ where
require_permission(&ctx, "health.medication-records.manage")?;
let mut req = req;
req.sanitize();
let result = medication_record_service::create_medication(
&state,
ctx.tenant_id,
Some(ctx.user_id),
req,
)
.await?;
let result =
medication_record_service::create_medication(&state, ctx.tenant_id, Some(ctx.user_id), req)
.await?;
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::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::medication_reminder_dto::{CreateMedicationReminderReq, MedicationReminderResp, UpdateMedicationReminderReq};
use crate::dto::medication_reminder_dto::{
CreateMedicationReminderReq, MedicationReminderResp, UpdateMedicationReminderReq,
};
use crate::service::medication_reminder_service;
use crate::state::HealthState;
@@ -28,8 +30,13 @@ where
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let result = medication_reminder_service::list_reminders(
&state, ctx.tenant_id, patient_id, page, page_size,
).await?;
&state,
ctx.tenant_id,
patient_id,
page,
page_size,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
@@ -45,8 +52,12 @@ where
require_permission(&ctx, "health.medication-reminders.manage")?;
req.sanitize();
let result = medication_reminder_service::create_reminder(
&state, ctx.tenant_id, Some(ctx.user_id), req.0,
).await?;
&state,
ctx.tenant_id,
Some(ctx.user_id),
req.0,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
@@ -71,8 +82,14 @@ where
let mut data = req.data;
data.sanitize();
let result = medication_reminder_service::update_reminder(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version, data,
).await?;
&state,
ctx.tenant_id,
id,
Some(ctx.user_id),
req.version,
data,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
@@ -93,7 +110,12 @@ where
{
require_permission(&ctx, "health.medication-reminders.manage")?;
medication_reminder_service::delete_reminder(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version,
).await?;
&state,
ctx.tenant_id,
id,
Some(ctx.user_id),
req.version,
)
.await?;
Ok(Json(ApiResponse::ok(())))
}

View File

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

View File

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

View File

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

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