后端:
- validation.rs: 新增 validate_id_number(含加权校验位)和 validate_phone(1[3-9]\d{9})
- patient_dto.rs: CreatePatientReq/UpdatePatientReq/FamilyMemberReq 添加 Validate derive
- patient_handler.rs: create/update/family_member handler 调用格式校验
前端:
- PatientList/PatientDetail/FamilyMembersTab: Form.Item 添加 pattern rules + maxLength
测试:15 个新测试用例全部通过
563 lines
17 KiB
Rust
563 lines
17 KiB
Rust
use axum::Extension;
|
|
use axum::extract::{FromRef, Json, Path, Query, State};
|
|
use serde::Deserialize;
|
|
use utoipa::IntoParams;
|
|
use uuid::Uuid;
|
|
use validator::Validate;
|
|
|
|
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::{
|
|
BatchImportPatientReq, BatchResultResp, BindByPhoneReq, BindResultResp, CreatePatientReq,
|
|
FamilyMemberReq, FamilyMemberResp, ManageTagsReq, PatientResp, ReferPatientReq,
|
|
ReferResultResp, UpdatePatientReq,
|
|
};
|
|
use crate::service::patient_service;
|
|
use crate::state::HealthState;
|
|
|
|
#[derive(Debug, Deserialize, IntoParams)]
|
|
pub struct PatientListParams {
|
|
pub page: Option<u64>,
|
|
pub page_size: Option<u64>,
|
|
pub search: Option<String>,
|
|
pub tag_id: Option<Uuid>,
|
|
}
|
|
|
|
/// 分配医生请求
|
|
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
|
pub struct AssignDoctorReq {
|
|
pub doctor_id: Uuid,
|
|
pub relationship_type: Option<String>,
|
|
}
|
|
|
|
pub async fn list_patients<S>(
|
|
State(state): State<HealthState>,
|
|
Extension(ctx): Extension<TenantContext>,
|
|
Query(params): Query<PatientListParams>,
|
|
) -> Result<Json<ApiResponse<PaginatedResponse<PatientResp>>>, AppError>
|
|
where
|
|
HealthState: FromRef<S>,
|
|
S: Clone + Send + Sync + 'static,
|
|
{
|
|
require_permission(&ctx, "health.patient.list")?;
|
|
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,
|
|
)
|
|
.await?;
|
|
Ok(Json(ApiResponse::ok(result)))
|
|
}
|
|
|
|
pub async fn create_patient<S>(
|
|
State(state): State<HealthState>,
|
|
Extension(ctx): Extension<TenantContext>,
|
|
Json(req): Json<CreatePatientReq>,
|
|
) -> Result<Json<ApiResponse<PatientResp>>, AppError>
|
|
where
|
|
HealthState: FromRef<S>,
|
|
S: Clone + Send + Sync + 'static,
|
|
{
|
|
require_permission(&ctx, "health.patient.manage")?;
|
|
let mut req = req;
|
|
req.validate()
|
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
|
req.sanitize();
|
|
if req.name.trim().is_empty() {
|
|
return Err(AppError::Validation("患者姓名不能为空".into()));
|
|
}
|
|
if let Some(ref id_num) = req.id_number {
|
|
crate::service::validation::validate_id_number(id_num)
|
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
|
}
|
|
if let Some(ref phone) = req.emergency_contact_phone {
|
|
crate::service::validation::validate_phone(phone)
|
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
|
}
|
|
if let Some(ref bd) = req.birth_date
|
|
&& *bd > chrono::Utc::now().date_naive()
|
|
{
|
|
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)))
|
|
}
|
|
|
|
pub async fn get_patient<S>(
|
|
State(state): State<HealthState>,
|
|
Extension(ctx): Extension<TenantContext>,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<Json<ApiResponse<PatientResp>>, AppError>
|
|
where
|
|
HealthState: FromRef<S>,
|
|
S: Clone + Send + Sync + 'static,
|
|
{
|
|
require_permission(&ctx, "health.patient.list")?;
|
|
let result = patient_service::get_patient(&state, ctx.tenant_id, id).await?;
|
|
Ok(Json(ApiResponse::ok(result)))
|
|
}
|
|
|
|
pub async fn update_patient<S>(
|
|
State(state): State<HealthState>,
|
|
Extension(ctx): Extension<TenantContext>,
|
|
Path(id): Path<Uuid>,
|
|
Json(req): Json<UpdatePatientWithVersion>,
|
|
) -> Result<Json<ApiResponse<PatientResp>>, AppError>
|
|
where
|
|
HealthState: FromRef<S>,
|
|
S: Clone + Send + Sync + 'static,
|
|
{
|
|
require_permission(&ctx, "health.patient.manage")?;
|
|
let version = req.version;
|
|
let mut update = UpdatePatientReq {
|
|
name: req.name,
|
|
gender: req.gender,
|
|
birth_date: req.birth_date,
|
|
blood_type: req.blood_type,
|
|
id_number: req.id_number,
|
|
allergy_history: req.allergy_history,
|
|
medical_history_summary: req.medical_history_summary,
|
|
emergency_contact_name: req.emergency_contact_name,
|
|
emergency_contact_phone: req.emergency_contact_phone,
|
|
source: req.source,
|
|
notes: req.notes,
|
|
status: req.status,
|
|
verification_status: req.verification_status,
|
|
};
|
|
update
|
|
.validate()
|
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
|
update.sanitize();
|
|
if let Some(ref id_num) = update.id_number {
|
|
crate::service::validation::validate_id_number(id_num)
|
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
|
}
|
|
if let Some(ref phone) = update.emergency_contact_phone {
|
|
crate::service::validation::validate_phone(phone)
|
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
|
}
|
|
if let Some(ref bd) = update.birth_date
|
|
&& *bd > chrono::Utc::now().date_naive()
|
|
{
|
|
return Err(AppError::Validation("出生日期不能是未来日期".into()));
|
|
}
|
|
let result = patient_service::update_patient(
|
|
&state,
|
|
ctx.tenant_id,
|
|
id,
|
|
Some(ctx.user_id),
|
|
update,
|
|
version,
|
|
)
|
|
.await?;
|
|
Ok(Json(ApiResponse::ok(result)))
|
|
}
|
|
|
|
pub async fn delete_patient<S>(
|
|
State(state): State<HealthState>,
|
|
Extension(ctx): Extension<TenantContext>,
|
|
Path(id): Path<Uuid>,
|
|
Json(req): Json<DeleteWithVersion>,
|
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
|
where
|
|
HealthState: FromRef<S>,
|
|
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?;
|
|
Ok(Json(ApiResponse::ok(())))
|
|
}
|
|
|
|
pub async fn manage_patient_tags<S>(
|
|
State(state): State<HealthState>,
|
|
Extension(ctx): Extension<TenantContext>,
|
|
Path(id): Path<Uuid>,
|
|
Json(req): Json<ManageTagsReq>,
|
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
|
where
|
|
HealthState: FromRef<S>,
|
|
S: Clone + Send + Sync + 'static,
|
|
{
|
|
require_permission(&ctx, "health.patient.manage")?;
|
|
patient_service::manage_patient_tags(&state, ctx.tenant_id, id, req, Some(ctx.user_id)).await?;
|
|
Ok(Json(ApiResponse::ok(())))
|
|
}
|
|
|
|
pub async fn get_health_summary<S>(
|
|
State(state): State<HealthState>,
|
|
Extension(ctx): Extension<TenantContext>,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError>
|
|
where
|
|
HealthState: FromRef<S>,
|
|
S: Clone + Send + Sync + 'static,
|
|
{
|
|
require_permission(&ctx, "health.patient.list")?;
|
|
let result = patient_service::get_health_summary(&state, ctx.tenant_id, id).await?;
|
|
Ok(Json(ApiResponse::ok(result)))
|
|
}
|
|
|
|
pub async fn list_family_members<S>(
|
|
State(state): State<HealthState>,
|
|
Extension(ctx): Extension<TenantContext>,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<Json<ApiResponse<Vec<FamilyMemberResp>>>, AppError>
|
|
where
|
|
HealthState: FromRef<S>,
|
|
S: Clone + Send + Sync + 'static,
|
|
{
|
|
require_permission(&ctx, "health.patient.list")?;
|
|
let result = patient_service::list_family_members(&state, ctx.tenant_id, id).await?;
|
|
Ok(Json(ApiResponse::ok(result)))
|
|
}
|
|
|
|
pub async fn create_family_member<S>(
|
|
State(state): State<HealthState>,
|
|
Extension(ctx): Extension<TenantContext>,
|
|
Path(id): Path<Uuid>,
|
|
Json(req): Json<FamilyMemberReq>,
|
|
) -> Result<Json<ApiResponse<FamilyMemberResp>>, AppError>
|
|
where
|
|
HealthState: FromRef<S>,
|
|
S: Clone + Send + Sync + 'static,
|
|
{
|
|
require_permission(&ctx, "health.patient.manage")?;
|
|
let mut req = req;
|
|
req.validate()
|
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
|
req.sanitize();
|
|
if let Some(ref phone) = req.phone {
|
|
crate::service::validation::validate_phone(phone)
|
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
|
}
|
|
let result =
|
|
patient_service::create_family_member(&state, ctx.tenant_id, id, Some(ctx.user_id), req)
|
|
.await?;
|
|
Ok(Json(ApiResponse::ok(result)))
|
|
}
|
|
|
|
pub async fn update_family_member<S>(
|
|
State(state): State<HealthState>,
|
|
Extension(ctx): Extension<TenantContext>,
|
|
Path((_patient_id, member_id)): Path<(Uuid, Uuid)>,
|
|
Json(req): Json<FamilyMemberUpdateWithVersion>,
|
|
) -> Result<Json<ApiResponse<FamilyMemberResp>>, AppError>
|
|
where
|
|
HealthState: FromRef<S>,
|
|
S: Clone + Send + Sync + 'static,
|
|
{
|
|
require_permission(&ctx, "health.patient.manage")?;
|
|
let version = req.version;
|
|
let mut update = FamilyMemberReq {
|
|
name: req.name,
|
|
relationship: req.relationship,
|
|
phone: req.phone,
|
|
birth_date: req.birth_date,
|
|
notes: req.notes,
|
|
};
|
|
update.sanitize();
|
|
let result = patient_service::update_family_member(
|
|
&state,
|
|
ctx.tenant_id,
|
|
_patient_id,
|
|
member_id,
|
|
Some(ctx.user_id),
|
|
update,
|
|
version,
|
|
)
|
|
.await?;
|
|
Ok(Json(ApiResponse::ok(result)))
|
|
}
|
|
|
|
pub async fn delete_family_member<S>(
|
|
State(state): State<HealthState>,
|
|
Extension(ctx): Extension<TenantContext>,
|
|
Path((patient_id, member_id)): Path<(Uuid, Uuid)>,
|
|
Json(req): Json<DeleteWithVersion>,
|
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
|
where
|
|
HealthState: FromRef<S>,
|
|
S: Clone + Send + Sync + 'static,
|
|
{
|
|
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,
|
|
)
|
|
.await?;
|
|
Ok(Json(ApiResponse::ok(())))
|
|
}
|
|
|
|
pub async fn assign_doctor<S>(
|
|
State(state): State<HealthState>,
|
|
Extension(ctx): Extension<TenantContext>,
|
|
Path(id): Path<Uuid>,
|
|
Json(req): Json<AssignDoctorReq>,
|
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
|
where
|
|
HealthState: FromRef<S>,
|
|
S: Clone + Send + Sync + 'static,
|
|
{
|
|
require_permission(&ctx, "health.patient.manage")?;
|
|
patient_service::assign_doctor(
|
|
&state,
|
|
ctx.tenant_id,
|
|
id,
|
|
req.doctor_id,
|
|
req.relationship_type
|
|
.unwrap_or_else(|| "primary".to_string()),
|
|
Some(ctx.user_id),
|
|
)
|
|
.await?;
|
|
Ok(Json(ApiResponse::ok(())))
|
|
}
|
|
|
|
pub async fn remove_doctor<S>(
|
|
State(state): State<HealthState>,
|
|
Extension(ctx): Extension<TenantContext>,
|
|
Path((patient_id, doctor_id)): Path<(Uuid, Uuid)>,
|
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
|
where
|
|
HealthState: FromRef<S>,
|
|
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?;
|
|
Ok(Json(ApiResponse::ok(())))
|
|
}
|
|
|
|
// 带版本号的更新请求包装
|
|
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
|
pub struct UpdatePatientWithVersion {
|
|
pub name: Option<String>,
|
|
pub gender: Option<String>,
|
|
pub birth_date: Option<chrono::NaiveDate>,
|
|
pub blood_type: Option<String>,
|
|
pub id_number: Option<String>,
|
|
pub allergy_history: Option<String>,
|
|
pub medical_history_summary: Option<String>,
|
|
pub emergency_contact_name: Option<String>,
|
|
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,
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
|
pub struct FamilyMemberUpdateWithVersion {
|
|
pub name: String,
|
|
pub relationship: String,
|
|
pub phone: Option<String>,
|
|
pub birth_date: Option<chrono::NaiveDate>,
|
|
pub notes: Option<String>,
|
|
pub version: i32,
|
|
}
|
|
|
|
pub async fn list_tags<S>(
|
|
State(state): State<HealthState>,
|
|
Extension(ctx): Extension<TenantContext>,
|
|
) -> Result<Json<ApiResponse<Vec<crate::dto::patient_dto::TagResp>>>, AppError>
|
|
where
|
|
HealthState: FromRef<S>,
|
|
S: Clone + Send + Sync + 'static,
|
|
{
|
|
require_permission(&ctx, "health.patient.list")?;
|
|
let tags = patient_service::list_tags(&state, ctx.tenant_id).await?;
|
|
Ok(Json(ApiResponse::ok(tags)))
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize, utoipa::ToSchema, validator::Validate)]
|
|
pub struct CreateTagReq {
|
|
#[validate(length(min = 1, max = 255, message = "标签名称不能为空且不超过255个字符"))]
|
|
pub name: String,
|
|
pub color: Option<String>,
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
pub async fn create_tag<S>(
|
|
State(state): State<HealthState>,
|
|
Extension(ctx): Extension<TenantContext>,
|
|
Json(req): Json<CreateTagReq>,
|
|
) -> Result<Json<ApiResponse<patient_service::TagResp>>, AppError>
|
|
where
|
|
HealthState: FromRef<S>,
|
|
S: Clone + Send + Sync + 'static,
|
|
{
|
|
require_permission(&ctx, "health.patient.manage")?;
|
|
req.validate()
|
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
|
let result = patient_service::create_tag(
|
|
&state,
|
|
ctx.tenant_id,
|
|
Some(ctx.user_id),
|
|
patient_service::CreateTagReq {
|
|
name: req.name,
|
|
color: req.color,
|
|
description: req.description,
|
|
},
|
|
)
|
|
.await?;
|
|
Ok(Json(ApiResponse::ok(result)))
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize, utoipa::ToSchema, validator::Validate)]
|
|
pub struct UpdateTagWithVersion {
|
|
#[validate(length(min = 1, max = 255, message = "标签名称不能为空且不超过255个字符"))]
|
|
pub name: Option<String>,
|
|
pub color: Option<String>,
|
|
pub description: Option<String>,
|
|
pub version: i32,
|
|
}
|
|
|
|
pub async fn update_tag<S>(
|
|
State(state): State<HealthState>,
|
|
Extension(ctx): Extension<TenantContext>,
|
|
Path(id): Path<Uuid>,
|
|
Json(req): Json<UpdateTagWithVersion>,
|
|
) -> Result<Json<ApiResponse<patient_service::TagResp>>, AppError>
|
|
where
|
|
HealthState: FromRef<S>,
|
|
S: Clone + Send + Sync + 'static,
|
|
{
|
|
require_permission(&ctx, "health.patient.manage")?;
|
|
let result = patient_service::update_tag(
|
|
&state,
|
|
ctx.tenant_id,
|
|
id,
|
|
Some(ctx.user_id),
|
|
patient_service::UpdateTagReq {
|
|
name: req.name,
|
|
color: req.color,
|
|
description: req.description,
|
|
version: req.version,
|
|
},
|
|
)
|
|
.await?;
|
|
Ok(Json(ApiResponse::ok(result)))
|
|
}
|
|
|
|
pub async fn delete_tag<S>(
|
|
State(state): State<HealthState>,
|
|
Extension(ctx): Extension<TenantContext>,
|
|
Path(id): Path<Uuid>,
|
|
Json(req): Json<DeleteWithVersion>,
|
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
|
where
|
|
HealthState: FromRef<S>,
|
|
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?;
|
|
Ok(Json(ApiResponse::ok(())))
|
|
}
|
|
|
|
/// 批量导入患者
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/health/patients/import",
|
|
request_body = BatchImportPatientReq,
|
|
responses(
|
|
(status = 200, description = "批量导入结果"),
|
|
),
|
|
tag = "患者管理",
|
|
security(("bearer_auth" = [])),
|
|
)]
|
|
pub async fn batch_import_patients<S>(
|
|
State(state): State<HealthState>,
|
|
Extension(ctx): Extension<TenantContext>,
|
|
Json(req): Json<BatchImportPatientReq>,
|
|
) -> Result<Json<ApiResponse<BatchResultResp>>, AppError>
|
|
where
|
|
HealthState: FromRef<S>,
|
|
S: Clone + Send + Sync + 'static,
|
|
{
|
|
require_permission(&ctx, "health.patient.manage")?;
|
|
req.validate()
|
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
|
let result =
|
|
patient_service::batch_import_patients(&state, ctx.tenant_id, Some(ctx.user_id), req)
|
|
.await?;
|
|
Ok(Json(ApiResponse::ok(result)))
|
|
}
|
|
|
|
/// 患者通过手机号自助绑定
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/health/patients/bind-by-phone",
|
|
request_body = BindByPhoneReq,
|
|
responses(
|
|
(status = 200, description = "绑定成功"),
|
|
(status = 404, description = "未找到匹配患者"),
|
|
),
|
|
tag = "患者管理",
|
|
security(("bearer_auth" = [])),
|
|
)]
|
|
pub async fn bind_by_phone<S>(
|
|
State(state): State<HealthState>,
|
|
Extension(ctx): Extension<TenantContext>,
|
|
Json(req): Json<BindByPhoneReq>,
|
|
) -> Result<Json<ApiResponse<BindResultResp>>, AppError>
|
|
where
|
|
HealthState: FromRef<S>,
|
|
S: Clone + Send + Sync + 'static,
|
|
{
|
|
// 患者自己绑定,只需认证,不需要特殊权限
|
|
req.validate()
|
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
|
let result = patient_service::bind_by_phone(&state, ctx.tenant_id, ctx.user_id, req).await?;
|
|
Ok(Json(ApiResponse::ok(result)))
|
|
}
|
|
|
|
/// 患者转诊
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/health/patients/{id}/refer",
|
|
request_body = ReferPatientReq,
|
|
responses(
|
|
(status = 200, description = "转诊成功"),
|
|
(status = 404, description = "患者或医生不存在"),
|
|
),
|
|
tag = "患者管理",
|
|
security(("bearer_auth" = [])),
|
|
)]
|
|
pub async fn refer_patient<S>(
|
|
State(state): State<HealthState>,
|
|
Extension(ctx): Extension<TenantContext>,
|
|
Path(id): Path<Uuid>,
|
|
Json(req): Json<ReferPatientReq>,
|
|
) -> Result<Json<ApiResponse<ReferResultResp>>, AppError>
|
|
where
|
|
HealthState: FromRef<S>,
|
|
S: Clone + Send + Sync + 'static,
|
|
{
|
|
require_permission(&ctx, "health.patient.manage")?;
|
|
req.validate()
|
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
|
let result =
|
|
patient_service::refer_patient(&state, ctx.tenant_id, id, req, Some(ctx.user_id)).await?;
|
|
Ok(Json(ApiResponse::ok(result)))
|
|
}
|