fix(health): 二次审计修复 — 状态机/枚举校验/归属验证/事件补全
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

- 状态机验证: patient.status (active→inactive/deceased/inactive→active),
  patient.verification_status (pending→verified/rejected), follow_up_task.status
  (pending→in_progress/cancelled, in_progress→completed/cancelled)
- 枚举白名单: gender/blood_type/appointment_type/period_type/schedule_status/
  follow_up_type/sender_role/content_type/consultation_type
- 归属验证: family_member update/delete 校验 patient_id 匹配
- 事件补全: patient.deceased/verified 条件事件, consultation close 允许 waiting
- 默认值修正: appointment_type "regular"→"outpatient", period_type "morning"→"am",
  consultation_type "text"→"customer_service"
- 新增 validation.rs 通用校验模块
This commit is contained in:
iven
2026-04-24 00:21:05 +08:00
parent ba132921cc
commit 47817bae7d
11 changed files with 635 additions and 20 deletions

336
Cargo.lock generated
View File

@@ -714,6 +714,26 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -1202,6 +1222,7 @@ dependencies = [
"chrono",
"erp-core",
"jsonwebtoken",
"reqwest",
"sea-orm",
"serde",
"serde_json",
@@ -1531,6 +1552,21 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -1736,6 +1772,25 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1901,6 +1956,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -1909,6 +1965,38 @@ dependencies = [
"pin-project-lite",
"smallvec",
"tokio",
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
@@ -1917,13 +2005,23 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -2124,6 +2222,16 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "iri-string"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -2190,6 +2298,8 @@ version = "0.3.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
dependencies = [
"cfg-if",
"futures-util",
"once_cell",
"wasm-bindgen",
]
@@ -2461,6 +2571,23 @@ dependencies = [
"version_check",
]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "nix"
version = "0.29.0"
@@ -2579,6 +2706,50 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "openssl"
version = "0.10.78"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222"
dependencies = [
"bitflags",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "ordered-float"
version = "4.6.0"
@@ -3135,6 +3306,46 @@ dependencies = [
"bytecheck",
]
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -3349,6 +3560,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -3526,6 +3746,29 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "semver"
version = "1.0.28"
@@ -4061,6 +4304,9 @@ name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]]
name = "synstructure"
@@ -4073,6 +4319,27 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-interface"
version = "0.27.3"
@@ -4271,6 +4538,26 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.18"
@@ -4422,11 +4709,14 @@ dependencies = [
"bitflags",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"iri-string",
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower-layer",
"tower-service",
"tracing",
@@ -4519,6 +4809,12 @@ dependencies = [
"tracing-serde",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.19.0"
@@ -4697,6 +4993,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
dependencies = [
"try-lock",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -4741,6 +5046,16 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.67"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.117"
@@ -5227,6 +5542,16 @@ dependencies = [
"wast 246.0.2",
]
[[package]]
name = "web-sys"
version = "0.3.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
@@ -5386,6 +5711,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.4.1"

View File

@@ -31,6 +31,8 @@ pub struct UpdatePatientReq {
pub emergency_contact_phone: Option<String>,
pub source: Option<String>,
pub notes: Option<String>,
pub status: Option<String>,
pub verification_status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]

View File

@@ -17,6 +17,21 @@ pub enum HealthError {
#[error("排班不存在")]
ScheduleNotFound,
#[error("体征记录不存在")]
VitalSignsNotFound,
#[error("化验报告不存在")]
LabReportNotFound,
#[error("健康档案不存在")]
HealthRecordNotFound,
#[error("家庭成员不存在")]
FamilyMemberNotFound,
#[error("标签不存在")]
TagNotFound,
#[error("排班已满,无法预约")]
ScheduleFull,
@@ -44,6 +59,11 @@ impl From<HealthError> for AppError {
| HealthError::DoctorNotFound
| HealthError::AppointmentNotFound
| HealthError::ScheduleNotFound
| HealthError::VitalSignsNotFound
| HealthError::LabReportNotFound
| HealthError::HealthRecordNotFound
| HealthError::FamilyMemberNotFound
| HealthError::TagNotFound
| HealthError::FollowUpTaskNotFound
| HealthError::ConsultationNotFound => AppError::NotFound(err.to_string()),
HealthError::ScheduleFull => AppError::Validation(err.to_string()),

View File

@@ -104,6 +104,8 @@ where
emergency_contact_phone: req.emergency_contact_phone,
source: req.source,
notes: req.notes,
status: req.status,
verification_status: req.verification_status,
};
let result = patient_service::update_patient(
&state, ctx.tenant_id, id, Some(ctx.user_id), update, version,
@@ -281,6 +283,8 @@ pub struct UpdatePatientWithVersion {
pub emergency_contact_phone: Option<String>,
pub source: Option<String>,
pub notes: Option<String>,
pub status: Option<String>,
pub verification_status: Option<String>,
pub version: i32,
}

View File

@@ -10,8 +10,12 @@ use erp_core::error::check_version;
use erp_core::types::PaginatedResponse;
use crate::dto::appointment_dto::*;
use crate::entity::{appointment, doctor_schedule};
use crate::entity::{appointment, doctor_schedule, patient};
use crate::error::{HealthError, HealthResult};
use crate::service::validation::{
validate_appointment_type,
validate_period_type, validate_schedule_status,
};
use crate::state::HealthState;
// ---------------------------------------------------------------------------
@@ -66,6 +70,17 @@ pub async fn create_appointment(
operator_id: Option<Uuid>,
req: CreateAppointmentReq,
) -> HealthResult<AppointmentResp> {
// 校验患者存在
patient::Entity::find()
.filter(patient::Column::Id.eq(req.patient_id))
.filter(patient::Column::TenantId.eq(tenant_id))
.filter(patient::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
if let Some(ref at) = req.appointment_type { validate_appointment_type(at)?; }
// 原子 CAS: 排班名额 +1
// 使用 raw SQL 实现 CAS 防止超额预约
let cas_result = doctor_schedule::Entity::update_many()
@@ -99,7 +114,7 @@ pub async fn create_appointment(
tenant_id: Set(tenant_id),
patient_id: Set(req.patient_id),
doctor_id: Set(req.doctor_id),
appointment_type: Set(req.appointment_type.unwrap_or_else(|| "regular".to_string())),
appointment_type: Set(req.appointment_type.unwrap_or_else(|| "outpatient".to_string())),
appointment_date: Set(req.appointment_date),
start_time: Set(req.start_time),
end_time: Set(req.end_time),
@@ -254,12 +269,14 @@ pub async fn create_schedule(
req: CreateScheduleReq,
) -> HealthResult<ScheduleResp> {
let now = Utc::now();
let period_type = req.period_type.unwrap_or_else(|| "am".to_string());
validate_period_type(&period_type)?;
let active = doctor_schedule::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
doctor_id: Set(req.doctor_id),
schedule_date: Set(req.schedule_date),
period_type: Set(req.period_type.unwrap_or_else(|| "morning".to_string())),
period_type: Set(period_type),
start_time: Set(req.start_time),
end_time: Set(req.end_time),
max_appointments: Set(req.max_appointments),
@@ -300,6 +317,8 @@ pub async fn update_schedule(
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
if let Some(ref s) = req.status { validate_schedule_status(s)?; }
let mut active: doctor_schedule::ActiveModel = model.into();
if let Some(v) = req.start_time { active.start_time = Set(v); }
if let Some(v) = req.end_time { active.end_time = Set(v); }

View File

@@ -10,8 +10,9 @@ use erp_core::error::check_version;
use erp_core::types::PaginatedResponse;
use crate::dto::consultation_dto::*;
use crate::entity::{consultation_message, consultation_session};
use crate::entity::{consultation_message, consultation_session, patient};
use crate::error::{HealthError, HealthResult};
use crate::service::validation::{validate_sender_role, validate_content_type, validate_consultation_type};
use crate::state::HealthState;
// ---------------------------------------------------------------------------
@@ -25,12 +26,25 @@ pub async fn create_session(
req: CreateSessionReq,
) -> HealthResult<SessionResp> {
let now = Utc::now();
// 校验患者存在
patient::Entity::find()
.filter(patient::Column::Id.eq(req.patient_id))
.filter(patient::Column::TenantId.eq(tenant_id))
.filter(patient::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
let consultation_type = req.consultation_type.unwrap_or_else(|| "customer_service".to_string());
validate_consultation_type(&consultation_type)?;
let active = consultation_session::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
patient_id: Set(req.patient_id),
doctor_id: Set(req.doctor_id),
consultation_type: Set(req.consultation_type.unwrap_or_else(|| "text".to_string())),
consultation_type: Set(consultation_type),
status: Set("waiting".to_string()),
last_message_at: Set(None),
unread_count_patient: Set(0),
@@ -113,7 +127,11 @@ pub async fn close_session(
.filter(consultation_session::Column::Id.eq(session_id))
.filter(consultation_session::Column::TenantId.eq(tenant_id))
.filter(consultation_session::Column::DeletedAt.is_null())
.filter(consultation_session::Column::Status.eq("active"))
.filter(
Condition::any()
.add(consultation_session::Column::Status.eq("active"))
.add(consultation_session::Column::Status.eq("waiting")),
)
.one(&state.db)
.await?
.ok_or(HealthError::ConsultationNotFound)?;
@@ -234,6 +252,9 @@ pub async fn create_message(
.ok_or(HealthError::ConsultationNotFound)?;
let now = Utc::now();
validate_sender_role(&req.sender_role)?;
let content_type = req.content_type.unwrap_or_else(|| "text".to_string());
validate_content_type(&content_type)?;
let is_patient = req.sender_role == "patient";
let should_activate = session.status == "waiting";
@@ -244,7 +265,7 @@ pub async fn create_message(
session_id: Set(req.session_id),
sender_id: Set(req.sender_id),
sender_role: Set(req.sender_role),
content_type: Set(req.content_type.unwrap_or_else(|| "text".to_string())),
content_type: Set(content_type),
content: Set(req.content),
is_read: Set(false),
created_at: Set(now),

View File

@@ -10,8 +10,9 @@ use erp_core::error::check_version;
use erp_core::types::PaginatedResponse;
use crate::dto::follow_up_dto::*;
use crate::entity::{follow_up_record, follow_up_task};
use crate::entity::{follow_up_record, follow_up_task, patient};
use crate::error::{HealthError, HealthResult};
use crate::service::validation::validate_follow_up_type;
use crate::state::HealthState;
// ---------------------------------------------------------------------------
@@ -65,6 +66,18 @@ pub async fn create_task(
req: CreateFollowUpTaskReq,
) -> HealthResult<FollowUpTaskResp> {
let now = Utc::now();
validate_follow_up_type(&req.follow_up_type)?;
// 校验患者存在
patient::Entity::find()
.filter(patient::Column::Id.eq(req.patient_id))
.filter(patient::Column::TenantId.eq(tenant_id))
.filter(patient::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
let active = follow_up_task::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
@@ -119,6 +132,13 @@ pub async fn update_task(
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
if let Some(ref ft) = req.follow_up_type { validate_follow_up_type(ft)?; }
// 状态机验证: follow_up_task.status
if let Some(ref new_status) = req.status {
validate_follow_up_status_transition(&model.status, new_status)?;
}
let mut active: follow_up_task::ActiveModel = model.into();
if let Some(v) = req.assigned_to { active.assigned_to = Set(Some(v)); }
if let Some(v) = req.follow_up_type { active.follow_up_type = Set(v); }
@@ -301,3 +321,22 @@ pub async fn list_records(
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
}
/// 随访任务状态机: pending → in_progress/cancelled, in_progress → completed/cancelled
fn validate_follow_up_status_transition(current: &str, new_status: &str) -> HealthResult<()> {
if current == new_status {
return Ok(());
}
let allowed = match current {
"pending" => matches!(new_status, "in_progress" | "cancelled"),
"in_progress" => matches!(new_status, "completed" | "cancelled"),
_ => false,
};
if allowed {
Ok(())
} else {
Err(HealthError::InvalidStatusTransition(format!(
"follow_up_task.status: 不允许从 '{}' 转换到 '{}'", current, new_status
)))
}
}

View File

@@ -10,7 +10,7 @@ use erp_core::error::check_version;
use erp_core::types::PaginatedResponse;
use crate::dto::health_data_dto::*;
use crate::entity::{health_record, health_trend, lab_report, vital_signs};
use crate::entity::{health_record, health_trend, lab_report, patient, vital_signs};
use crate::error::{HealthError, HealthResult};
use crate::state::HealthState;
@@ -71,6 +71,15 @@ pub async fn create_vital_signs(
operator_id: Option<Uuid>,
req: CreateVitalSignsReq,
) -> HealthResult<VitalSignsResp> {
// 校验患者存在
patient::Entity::find()
.filter(patient::Column::Id.eq(patient_id))
.filter(patient::Column::TenantId.eq(tenant_id))
.filter(patient::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
let now = Utc::now();
let active = vital_signs::ActiveModel {
id: Set(Uuid::now_v7()),
@@ -122,7 +131,7 @@ pub async fn update_vital_signs(
.filter(vital_signs::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
.ok_or(HealthError::VitalSignsNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
@@ -167,7 +176,7 @@ pub async fn delete_vital_signs(
.filter(vital_signs::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
.ok_or(HealthError::VitalSignsNotFound)?;
let mut active: vital_signs::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
@@ -271,7 +280,7 @@ pub async fn update_lab_report(
.filter(lab_report::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
.ok_or(HealthError::LabReportNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
@@ -306,7 +315,7 @@ pub async fn delete_lab_report(
.filter(lab_report::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
.ok_or(HealthError::LabReportNotFound)?;
let mut active: lab_report::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
@@ -403,7 +412,7 @@ pub async fn update_health_record(
.filter(health_record::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
.ok_or(HealthError::HealthRecordNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
@@ -439,7 +448,7 @@ pub async fn delete_health_record(
.filter(health_record::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
.ok_or(HealthError::HealthRecordNotFound)?;
let mut active: health_record::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));

View File

@@ -5,3 +5,4 @@ pub mod follow_up_service;
pub mod health_data_service;
pub mod patient_service;
pub mod seed;
pub mod validation;

View File

@@ -15,6 +15,7 @@ use crate::entity::patient_family_member;
use crate::entity::patient_tag_relation;
use crate::entity::patient_doctor_relation;
use crate::error::{HealthError, HealthResult};
use crate::service::validation::{validate_gender, validate_blood_type};
use crate::state::HealthState;
// ---------------------------------------------------------------------------
@@ -97,6 +98,9 @@ pub async fn create_patient(
let now = Utc::now();
let id = Uuid::now_v7();
if let Some(ref g) = req.gender { validate_gender(g)?; }
if let Some(ref bt) = req.blood_type { validate_blood_type(bt)?; }
let active = patient::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
@@ -157,6 +161,26 @@ pub async fn update_patient(
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
if let Some(ref g) = req.gender { validate_gender(g)?; }
if let Some(ref bt) = req.blood_type { validate_blood_type(bt)?; }
// 状态机验证: patient.status
if let Some(ref new_status) = req.status {
validate_status_transition("patient.status", &model.status, new_status, &[
("active", "inactive"),
("active", "deceased"),
("inactive", "active"),
])?;
}
// 状态机验证: patient.verification_status
if let Some(ref new_vs) = req.verification_status {
validate_status_transition("patient.verification_status", &model.verification_status, new_vs, &[
("pending", "verified"),
("pending", "rejected"),
("rejected", "pending"),
])?;
}
let mut active: patient::ActiveModel = model.into();
if let Some(v) = req.name { active.name = Set(v); }
@@ -170,6 +194,8 @@ pub async fn update_patient(
if let Some(v) = req.emergency_contact_phone { active.emergency_contact_phone = Set(Some(v)); }
if let Some(v) = req.source { active.source = Set(Some(v)); }
if let Some(v) = req.notes { active.notes = Set(Some(v)); }
if let Some(ref v) = req.status { active.status = Set(v.clone()); }
if let Some(ref v) = req.verification_status { active.verification_status = Set(v.clone()); }
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
@@ -177,8 +203,16 @@ pub async fn update_patient(
let updated = active.update(&state.db).await?;
// 根据状态变更发布不同事件
let event_type = if req.status.as_deref() == Some("deceased") {
"patient.deceased"
} else if req.verification_status.as_deref() == Some("verified") {
"patient.verified"
} else {
"patient.updated"
};
let event = DomainEvent::new(
"patient.updated",
event_type,
tenant_id,
serde_json::json!({ "patient_id": updated.id }),
);
@@ -398,7 +432,7 @@ pub async fn create_family_member(
pub async fn update_family_member(
state: &HealthState,
tenant_id: Uuid,
_patient_id: Uuid,
patient_id: Uuid,
family_member_id: Uuid,
operator_id: Option<Uuid>,
req: FamilyMemberReq,
@@ -406,11 +440,12 @@ pub async fn update_family_member(
) -> HealthResult<FamilyMemberResp> {
let model = patient_family_member::Entity::find()
.filter(patient_family_member::Column::Id.eq(family_member_id))
.filter(patient_family_member::Column::PatientId.eq(patient_id))
.filter(patient_family_member::Column::TenantId.eq(tenant_id))
.filter(patient_family_member::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
.ok_or(HealthError::FamilyMemberNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
@@ -444,17 +479,18 @@ pub async fn update_family_member(
pub async fn delete_family_member(
state: &HealthState,
tenant_id: Uuid,
_patient_id: Uuid,
patient_id: Uuid,
family_member_id: Uuid,
operator_id: Option<Uuid>,
) -> HealthResult<()> {
let model = patient_family_member::Entity::find()
.filter(patient_family_member::Column::Id.eq(family_member_id))
.filter(patient_family_member::Column::PatientId.eq(patient_id))
.filter(patient_family_member::Column::TenantId.eq(tenant_id))
.filter(patient_family_member::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
.ok_or(HealthError::FamilyMemberNotFound)?;
let mut active: patient_family_member::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
@@ -563,3 +599,23 @@ fn model_to_resp(m: patient::Model) -> PatientResp {
version: m.version,
}
}
/// 状态机转换校验: 检查 (current → new) 是否在 allowed_transitions 中
fn validate_status_transition(
field_name: &str,
current: &str,
new_status: &str,
allowed_transitions: &[(&str, &str)],
) -> HealthResult<()> {
if current == new_status {
return Ok(());
}
if allowed_transitions.iter().any(|(from, to)| *from == current && *to == new_status) {
Ok(())
} else {
Err(HealthError::InvalidStatusTransition(format!(
"{}: 不允许从 '{}' 转换到 '{}'",
field_name, current, new_status
)))
}
}

View File

@@ -0,0 +1,108 @@
//! 通用字段校验 — 枚举白名单、输入格式校验
use crate::error::{HealthError, HealthResult};
macro_rules! validate_enum {
($value:expr, $field:expr, [$($allowed:expr),* $(,)?]) => {
{
let v: &str = $value;
let allowed: &[&str] = &[$($allowed),*];
let mut found = false;
let mut _i = 0;
while _i < allowed.len() {
if allowed[_i] == v {
found = true;
break;
}
_i += 1;
}
if !found {
return Err(HealthError::Validation(format!(
"{}: '{}' 不是有效值,允许值: [{}]",
$field, v, allowed.join(", ")
)));
}
}
};
}
/// patient.gender
pub fn validate_gender(value: &str) -> HealthResult<()> {
validate_enum!(value, "gender", ["male", "female", "other"]);
Ok(())
}
/// patient.blood_type
pub fn validate_blood_type(value: &str) -> HealthResult<()> {
validate_enum!(value, "blood_type", [
"A", "B", "AB", "O", "A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-",
]);
Ok(())
}
/// appointment.appointment_type
pub fn validate_appointment_type(value: &str) -> HealthResult<()> {
validate_enum!(value, "appointment_type", [
"dialysis", "recheck", "outpatient", "health_checkup", "consultation",
]);
Ok(())
}
/// appointment.status transitions
pub fn validate_appointment_status_transition(current: &str, new: &str) -> HealthResult<()> {
if current == new {
return Ok(());
}
let allowed = match current {
"pending" => matches!(new, "confirmed" | "cancelled"),
"confirmed" => matches!(new, "completed" | "cancelled" | "no_show"),
_ => false,
};
if allowed {
Ok(())
} else {
Err(HealthError::InvalidStatusTransition(format!(
"appointment.status: 不允许从 '{}' 转换到 '{}'", current, new
)))
}
}
/// doctor_schedule.period_type
pub fn validate_period_type(value: &str) -> HealthResult<()> {
validate_enum!(value, "period_type", ["am", "pm", "night", "full_day"]);
Ok(())
}
/// doctor_schedule.status
pub fn validate_schedule_status(value: &str) -> HealthResult<()> {
validate_enum!(value, "schedule.status", ["enabled", "disabled"]);
Ok(())
}
/// follow_up_task.follow_up_type
pub fn validate_follow_up_type(value: &str) -> HealthResult<()> {
validate_enum!(value, "follow_up_type", [
"phone", "wechat", "visit", "sms", "other",
]);
Ok(())
}
/// consultation.sender_role
pub fn validate_sender_role(value: &str) -> HealthResult<()> {
validate_enum!(value, "sender_role", ["patient", "doctor", "system"]);
Ok(())
}
/// consultation.content_type
pub fn validate_content_type(value: &str) -> HealthResult<()> {
validate_enum!(value, "content_type", ["text", "image", "voice", "file"]);
Ok(())
}
/// consultation.consultation_type
pub fn validate_consultation_type(value: &str) -> HealthResult<()> {
validate_enum!(value, "consultation_type", [
"customer_service", "doctor", "nutritionist", "psychologist",
]);
Ok(())
}