Compare commits

...

5 Commits

Author SHA1 Message Date
iven
994119ded1 feat(health): 文章管理 CRUD 补充 create/update/delete
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
- article_dto 新增 CreateArticleReq/UpdateArticleReq 含 sanitize
- article_service 新增 create_article/update_article/delete_article 含审计日志
- article_handler 新增三个 handler 端点含权限校验
- module.rs 文章路由合并 POST/PUT/DELETE
2026-04-25 00:34:15 +08:00
iven
43e127d4f7 feat(health): 事件驱动集成 + 数据一致性修复 + 逾期随访检查
- event.rs 重写为有状态处理器(订阅 workflow.task.completed / message.sent)
- module.rs on_startup 初始化 HealthCrypto 并注册事件处理器
- consultation_service 消息发送改为事务包裹(INSERT + CAS 原子更新)
- appointment_service 取消预约释放排班名额增加下限保护
- appointment_service update_schedule 增加 max_appointments >= current_appointments 校验
- follow_up_service 新增 complete_task_by_system 和 check_overdue_tasks
- validation.rs 随访状态机增加 overdue 状态支持
- main.rs 启动时运行逾期随访检查后台任务
2026-04-25 00:30:32 +08:00
iven
6c70e2a783 feat(health): 身份证号 AES-256-GCM 加密 + HMAC 索引 + 字段级脱敏
- crypto.rs: AES-256-GCM 加密/解密 + HMAC-SHA256 索引
- create/update: id_number 加密存储, id_number_hash 索引
- list: 不返回 id_number, 手机号掩码
- detail: 解密后身份证掩码(前3后4), 手机号掩码
- 搜索: 改用 HMAC 精确匹配(不再模糊搜索加密列)
- 迁移 m000048: 添加 patients.id_number_hash 列
2026-04-25 00:21:49 +08:00
iven
479b5900c9 feat(health): 注入审计日志覆盖所有写入操作
17 个方法全覆盖:patient(4)、appointment(2)、consultation(3)、
follow_up(2)、doctor(3)、health_data(3)。使用 fire-and-forget 模式。
2026-04-25 00:12:19 +08:00
iven
1d1f01df81 feat(health): 为所有 DTO 添加 sanitize 防止存储型 XSS
覆盖 patient/health_data/appointment/follow_up/consultation/doctor
6 个 DTO 模块共 14 个请求结构体,在 handler 层统一调用 sanitize。
2026-04-25 00:04:25 +08:00
36 changed files with 958 additions and 60 deletions

View File

@@ -18,3 +18,8 @@ validator.workspace = true
utoipa.workspace = true
async-trait.workspace = true
num-traits = "0.2.19"
aes-gcm = "0.10"
hmac = "0.12"
sha2 = "0.10"
base64 = "0.22"
hex = "0.4"

View File

@@ -0,0 +1,90 @@
use aes_gcm::aead::Aead;
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use erp_core::error::{AppError, AppResult};
type HmacSha256 = Hmac<Sha256>;
#[derive(Clone)]
pub struct HealthCrypto {
aes_key: [u8; 32],
hmac_key: [u8; 32],
}
impl HealthCrypto {
pub fn from_keys(aes_key_hex: &str, hmac_key_hex: &str) -> AppResult<Self> {
let aes_key = hex::decode(aes_key_hex)
.map_err(|e| AppError::Internal(format!("AES key hex decode failed: {}", e)))?;
let hmac_key = hex::decode(hmac_key_hex)
.map_err(|e| AppError::Internal(format!("HMAC key hex decode failed: {}", e)))?;
if aes_key.len() != 32 || hmac_key.len() != 32 {
return Err(AppError::Internal(
"Encryption keys must be 32 bytes each".into(),
));
}
let mut aes = [0u8; 32];
let mut hmac = [0u8; 32];
aes.copy_from_slice(&aes_key);
hmac.copy_from_slice(&hmac_key);
Ok(Self {
aes_key: aes,
hmac_key: hmac,
})
}
/// Dev fallback: derive deterministic keys from a single dev string.
/// DO NOT use in production.
pub fn dev_default() -> Self {
use sha2::Digest;
let aes_key = <Sha256 as Digest>::digest(b"erp-health-aes-dev-key-DO-NOT-USE-IN-PROD");
let hmac_key = <Sha256 as Digest>::digest(b"erp-health-hmac-dev-key-DO-NOT-USE-IN-PROD");
let mut aes = [0u8; 32];
let mut hmac = [0u8; 32];
aes.copy_from_slice(&aes_key);
hmac.copy_from_slice(&hmac_key);
Self {
aes_key: aes,
hmac_key: hmac,
}
}
pub fn encrypt(&self, plaintext: &str) -> AppResult<String> {
let cipher = Aes256Gcm::new_from_slice(&self.aes_key)
.map_err(|e| AppError::Internal(format!("AES init failed: {}", e)))?;
let nonce_bytes = uuid::Uuid::now_v7();
let nonce = Nonce::from_slice(&nonce_bytes.as_bytes()[..12]);
let ciphertext = cipher
.encrypt(nonce, plaintext.as_bytes())
.map_err(|e| AppError::Internal(format!("Encryption failed: {}", e)))?;
let mut combined = nonce_bytes.as_bytes()[..12].to_vec();
combined.extend_from_slice(&ciphertext);
Ok(BASE64.encode(&combined))
}
pub fn decrypt(&self, encoded: &str) -> AppResult<String> {
let combined = BASE64
.decode(encoded)
.map_err(|e| AppError::Internal(format!("Base64 decode failed: {}", e)))?;
if combined.len() < 12 {
return Err(AppError::Internal("Ciphertext too short".into()));
}
let (nonce_bytes, ciphertext) = combined.split_at(12);
let cipher = Aes256Gcm::new_from_slice(&self.aes_key)
.map_err(|e| AppError::Internal(format!("AES init failed: {}", e)))?;
let plaintext = cipher
.decrypt(Nonce::from_slice(nonce_bytes), ciphertext)
.map_err(|e| AppError::Internal(format!("Decryption failed: {}", e)))?;
String::from_utf8(plaintext)
.map_err(|e| AppError::Internal(format!("UTF-8 decode failed: {}", e)))
}
pub fn hmac_hash(&self, value: &str) -> String {
let mut mac = <HmacSha256 as hmac::Mac>::new_from_slice(&self.hmac_key)
.expect("HMAC key length is valid");
mac.update(value.as_bytes());
hex::encode(mac.finalize().into_bytes())
}
}

View File

@@ -1,4 +1,5 @@
use chrono::{NaiveDate, NaiveTime};
use erp_core::sanitize::sanitize_option;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
@@ -14,12 +15,24 @@ pub struct CreateAppointmentReq {
pub notes: Option<String>,
}
impl CreateAppointmentReq {
pub fn sanitize(&mut self) {
self.notes = sanitize_option(self.notes.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateAppointmentStatusReq {
pub status: String,
pub cancel_reason: Option<String>,
}
impl UpdateAppointmentStatusReq {
pub fn sanitize(&mut self) {
self.cancel_reason = sanitize_option(self.cancel_reason.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AppointmentResp {
pub id: Uuid,

View File

@@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use erp_core::sanitize::{sanitize_option, sanitize_string, strip_html_tags};
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ArticleResp {
pub id: Uuid,
@@ -34,3 +36,46 @@ pub struct ArticleListParams {
pub page_size: Option<u64>,
pub category: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateArticleReq {
pub title: String,
pub summary: Option<String>,
pub content: Option<String>,
pub cover_image: Option<String>,
pub category: Option<String>,
pub author: Option<String>,
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
}
impl CreateArticleReq {
pub fn sanitize(&mut self) {
self.title = sanitize_string(&self.title);
self.summary = sanitize_option(self.summary.take());
self.content = sanitize_option(self.content.take());
self.category = sanitize_option(self.category.take());
self.author = sanitize_option(self.author.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateArticleReq {
pub title: Option<String>,
pub summary: Option<String>,
pub content: Option<String>,
pub cover_image: Option<String>,
pub category: Option<String>,
pub author: Option<String>,
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
pub version: i32,
}
impl UpdateArticleReq {
pub fn sanitize(&mut self) {
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());
self.author = sanitize_option(self.author.take());
}
}

View File

@@ -1,3 +1,4 @@
use erp_core::sanitize::sanitize_string;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
@@ -36,6 +37,12 @@ pub struct CreateMessageReq {
pub content: String,
}
impl CreateMessageReq {
pub fn sanitize(&mut self) {
self.content = sanitize_string(&self.content);
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateSessionReq {
pub patient_id: Uuid,

View File

@@ -1,3 +1,4 @@
use erp_core::sanitize::{sanitize_option, sanitize_string, strip_html_tags};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
@@ -22,6 +23,16 @@ pub struct CreateDoctorReq {
pub bio: Option<String>,
}
impl CreateDoctorReq {
pub fn sanitize(&mut self) {
self.name = sanitize_string(&self.name);
self.department = sanitize_option(self.department.take());
self.title = sanitize_option(self.title.take());
self.specialty = sanitize_option(self.specialty.take());
self.bio = sanitize_option(self.bio.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateDoctorReq {
pub name: Option<String>,
@@ -33,6 +44,18 @@ pub struct UpdateDoctorReq {
pub online_status: Option<String>,
}
impl UpdateDoctorReq {
pub fn sanitize(&mut self) {
if let Some(ref mut v) = self.name {
*v = strip_html_tags(v);
}
self.department = sanitize_option(self.department.take());
self.title = sanitize_option(self.title.take());
self.specialty = sanitize_option(self.specialty.take());
self.bio = sanitize_option(self.bio.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct DoctorResp {
pub id: Uuid,

View File

@@ -1,4 +1,5 @@
use chrono::NaiveDate;
use erp_core::sanitize::sanitize_option;
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
@@ -22,6 +23,12 @@ pub struct CreateFollowUpTaskReq {
pub related_appointment_id: Option<Uuid>,
}
impl CreateFollowUpTaskReq {
pub fn sanitize(&mut self) {
self.content_template = sanitize_option(self.content_template.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateFollowUpTaskReq {
pub assigned_to: Option<Uuid>,
@@ -31,6 +38,12 @@ pub struct UpdateFollowUpTaskReq {
pub status: Option<String>,
}
impl UpdateFollowUpTaskReq {
pub fn sanitize(&mut self) {
self.content_template = sanitize_option(self.content_template.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct FollowUpTaskResp {
pub id: Uuid,
@@ -65,6 +78,13 @@ pub struct CreateFollowUpRecordReq {
pub next_follow_up_date: Option<NaiveDate>,
}
impl CreateFollowUpRecordReq {
pub fn sanitize(&mut self) {
self.patient_condition = sanitize_option(self.patient_condition.take());
self.medical_advice = sanitize_option(self.medical_advice.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct FollowUpRecordResp {
pub id: Uuid,

View File

@@ -1,4 +1,5 @@
use chrono::NaiveDate;
use erp_core::sanitize::sanitize_option;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
@@ -21,6 +22,12 @@ pub struct CreateVitalSignsReq {
pub notes: Option<String>,
}
impl CreateVitalSignsReq {
pub fn sanitize(&mut self) {
self.notes = sanitize_option(self.notes.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateVitalSignsReq {
pub record_date: Option<NaiveDate>,
@@ -36,6 +43,12 @@ pub struct UpdateVitalSignsReq {
pub notes: Option<String>,
}
impl UpdateVitalSignsReq {
pub fn sanitize(&mut self) {
self.notes = sanitize_option(self.notes.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct VitalSignsResp {
pub id: Uuid,
@@ -65,6 +78,12 @@ pub struct CreateLabReportReq {
pub doctor_interpretation: Option<String>,
}
impl CreateLabReportReq {
pub fn sanitize(&mut self) {
self.doctor_interpretation = sanitize_option(self.doctor_interpretation.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateLabReportReq {
pub report_date: Option<NaiveDate>,
@@ -74,6 +93,12 @@ pub struct UpdateLabReportReq {
pub doctor_interpretation: Option<String>,
}
impl UpdateLabReportReq {
pub fn sanitize(&mut self) {
self.doctor_interpretation = sanitize_option(self.doctor_interpretation.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct LabReportResp {
pub id: Uuid,
@@ -98,6 +123,14 @@ pub struct CreateHealthRecordReq {
pub notes: Option<String>,
}
impl CreateHealthRecordReq {
pub fn sanitize(&mut self) {
self.source = sanitize_option(self.source.take());
self.overall_assessment = sanitize_option(self.overall_assessment.take());
self.notes = sanitize_option(self.notes.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateHealthRecordReq {
pub record_type: Option<String>,
@@ -108,6 +141,14 @@ pub struct UpdateHealthRecordReq {
pub notes: Option<String>,
}
impl UpdateHealthRecordReq {
pub fn sanitize(&mut self) {
self.source = sanitize_option(self.source.take());
self.overall_assessment = sanitize_option(self.overall_assessment.take());
self.notes = sanitize_option(self.notes.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct HealthRecordResp {
pub id: Uuid,

View File

@@ -1,4 +1,5 @@
use chrono::NaiveDate;
use erp_core::sanitize::{sanitize_option, sanitize_string, strip_html_tags};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
@@ -18,6 +19,19 @@ pub struct CreatePatientReq {
pub notes: Option<String>,
}
impl CreatePatientReq {
pub fn sanitize(&mut self) {
self.name = sanitize_string(&self.name);
self.id_number = sanitize_option(self.id_number.take());
self.allergy_history = sanitize_option(self.allergy_history.take());
self.medical_history_summary = sanitize_option(self.medical_history_summary.take());
self.emergency_contact_name = sanitize_option(self.emergency_contact_name.take());
self.emergency_contact_phone = sanitize_option(self.emergency_contact_phone.take());
self.source = sanitize_option(self.source.take());
self.notes = sanitize_option(self.notes.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdatePatientReq {
pub name: Option<String>,
@@ -35,6 +49,21 @@ pub struct UpdatePatientReq {
pub verification_status: Option<String>,
}
impl UpdatePatientReq {
pub fn sanitize(&mut self) {
if let Some(ref mut v) = self.name {
*v = strip_html_tags(v);
}
self.id_number = sanitize_option(self.id_number.take());
self.allergy_history = sanitize_option(self.allergy_history.take());
self.medical_history_summary = sanitize_option(self.medical_history_summary.take());
self.emergency_contact_name = sanitize_option(self.emergency_contact_name.take());
self.emergency_contact_phone = sanitize_option(self.emergency_contact_phone.take());
self.source = sanitize_option(self.source.take());
self.notes = sanitize_option(self.notes.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PatientResp {
pub id: Uuid,
@@ -66,6 +95,14 @@ pub struct FamilyMemberReq {
pub notes: Option<String>,
}
impl FamilyMemberReq {
pub fn sanitize(&mut self) {
self.name = sanitize_string(&self.name);
self.phone = sanitize_option(self.phone.take());
self.notes = sanitize_option(self.notes.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct FamilyMemberResp {
pub id: Uuid,

View File

@@ -19,6 +19,8 @@ pub struct Model {
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub id_number: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub id_number_hash: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub allergy_history: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub medical_history_summary: Option<String>,

View File

@@ -84,4 +84,10 @@ impl From<sea_orm::DbErr> for HealthError {
}
}
impl From<AppError> for HealthError {
fn from(err: AppError) -> Self {
HealthError::DbError(err.to_string())
}
}
pub type HealthResult<T> = Result<T, HealthError>;

View File

@@ -1,17 +1,52 @@
use erp_core::events::EventBus;
pub fn register_handlers(bus: &EventBus) {
// workflow.task.completed → 更新随访任务状态
let (mut workflow_rx, _wf_handle) = bus.subscribe_filtered("workflow.task.".to_string());
/// 兼容旧签名 — 不做任何实际订阅(逻辑已迁移到 on_startup
pub fn register_handlers(_bus: &EventBus) {
// 事件处理器已迁移到 on_startup → register_handlers_with_state
}
/// 带 HealthState 的事件订阅 — 在模块 on_startup 时调用
pub fn register_handlers_with_state(state: crate::state::HealthState) {
// workflow.task.completed → 更新随访任务状态为 completed
let (mut workflow_rx, _wf_handle) = state.event_bus.subscribe_filtered("workflow.task.".to_string());
let db = state.db.clone();
tokio::spawn(async move {
loop {
match workflow_rx.recv().await {
Some(event) if event.event_type == "workflow.task.completed" => {
tracing::info!(
event_id = %event.id,
"健康模块收到工作流任务完成事件"
);
// 后续可通过 db 连接更新 follow_up_task 状态
// 从 payload 中提取 task_id
let task_id = event.payload.get("task_id").and_then(|v| v.as_str()).and_then(|s| uuid::Uuid::parse_str(s).ok());
match task_id {
Some(task_id) => {
match crate::service::follow_up_service::complete_task_by_system(
&db, task_id, event.tenant_id,
)
.await
{
Ok(()) => {
tracing::info!(
event_id = %event.id,
task_id = %task_id,
"工作流任务完成 → 随访任务已更新"
);
}
Err(e) => {
tracing::warn!(
event_id = %event.id,
task_id = %task_id,
error = %e,
"工作流任务完成 → 随访任务更新失败"
);
}
}
}
None => {
tracing::warn!(
event_id = %event.id,
"工作流任务完成事件缺少 task_id跳过"
);
}
}
}
Some(_) => {}
None => break,
@@ -19,17 +54,16 @@ pub fn register_handlers(bus: &EventBus) {
}
});
// message.sent → 联动咨询会话 last_message_at
let (mut msg_rx, _msg_handle) = bus.subscribe_filtered("message.".to_string());
// message.sent → 预留:后续联动咨询会话 last_message_at
let (mut msg_rx, _msg_handle) = state.event_bus.subscribe_filtered("message.".to_string());
tokio::spawn(async move {
loop {
match msg_rx.recv().await {
Some(event) if event.event_type == "message.sent" => {
tracing::info!(
event_id = %event.id,
"健康模块收到消息发送事件"
"健康模块收到消息发送事件(暂不处理)"
);
// 后续可通过 db 连接更新 consultation_session.last_message_at
}
Some(_) => {}
None => break,

View File

@@ -81,6 +81,8 @@ where
S: Clone + Send + Sync + 'static,
{
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,
)
@@ -113,10 +115,11 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.appointment.manage")?;
let update_req = UpdateAppointmentStatusReq {
let mut update_req = UpdateAppointmentStatusReq {
status: req.status,
cancel_reason: req.cancel_reason,
};
update_req.sanitize();
let result = appointment_service::update_appointment_status(
&state, ctx.tenant_id, id, Some(ctx.user_id), update_req, req.version,
)

View File

@@ -5,7 +5,7 @@ use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::article_dto::{ArticleListItem, ArticleListParams, ArticleResp};
use crate::dto::article_dto::{ArticleListItem, ArticleListParams, ArticleResp, CreateArticleReq, UpdateArticleReq};
use crate::service::article_service;
use crate::state::HealthState;
@@ -41,3 +41,52 @@ where
let result = article_service::get_article(&state, ctx.tenant_id, id).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn create_article<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
mut req: Json<CreateArticleReq>,
) -> Result<Json<ApiResponse<ArticleResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
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?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn update_article<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
mut req: Json<UpdateArticleReq>,
) -> Result<Json<ApiResponse<ArticleResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
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?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn delete_article<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.manage")?;
article_service::delete_article(&state, ctx.tenant_id, id, Some(ctx.user_id)).await?;
Ok(Json(ApiResponse::ok(())))
}

View File

@@ -131,13 +131,14 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.consultation.manage")?;
let msg_req = CreateMessageReq {
let mut msg_req = CreateMessageReq {
session_id: req.session_id,
sender_id: ctx.user_id,
sender_role: "doctor".to_string(),
content_type: req.content_type,
content: req.content,
};
msg_req.sanitize();
let result = consultation_service::create_message(
&state, ctx.tenant_id, Some(ctx.user_id), msg_req,
)

View File

@@ -62,6 +62,8 @@ where
S: Clone + Send + Sync + 'static,
{
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,
)
@@ -94,8 +96,10 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.doctor.manage")?;
let mut data = req.data;
data.sanitize();
let result = doctor_service::update_doctor(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.data, req.version,
&state, ctx.tenant_id, id, Some(ctx.user_id), data, req.version,
)
.await?;
Ok(Json(ApiResponse::ok(result)))

View File

@@ -85,6 +85,8 @@ where
S: Clone + Send + Sync + 'static,
{
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,
)
@@ -103,8 +105,10 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.follow-up.manage")?;
let mut data = req.data;
data.sanitize();
let result = follow_up_service::update_task(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.data, req.version,
&state, ctx.tenant_id, id, Some(ctx.user_id), data, req.version,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
@@ -139,6 +143,8 @@ where
if req.task_id != task_id {
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,
)

View File

@@ -80,6 +80,8 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
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,
)
@@ -98,8 +100,10 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
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), req.data, req.version,
&state, ctx.tenant_id, patient_id, vid, Some(ctx.user_id), data, req.version,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
@@ -155,6 +159,8 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
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,
)
@@ -173,8 +179,10 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
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), req.data, req.version,
&state, ctx.tenant_id, _patient_id, rid, Some(ctx.user_id), data, req.version,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
@@ -230,6 +238,8 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
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,
)
@@ -248,8 +258,10 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
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), req.data, req.version,
&state, ctx.tenant_id, patient_id, rid, Some(ctx.user_id), data, req.version,
)
.await?;
Ok(Json(ApiResponse::ok(result)))

View File

@@ -64,6 +64,8 @@ where
S: Clone + Send + Sync + 'static,
{
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,
)
@@ -97,7 +99,7 @@ where
{
require_permission(&ctx, "health.patient.manage")?;
let version = req.version;
let update = UpdatePatientReq {
let mut update = UpdatePatientReq {
name: req.name,
gender: req.gender,
birth_date: req.birth_date,
@@ -112,6 +114,7 @@ where
status: req.status,
verification_status: req.verification_status,
};
update.sanitize();
let result = patient_service::update_patient(
&state, ctx.tenant_id, id, Some(ctx.user_id), update, version,
)
@@ -188,6 +191,8 @@ where
S: Clone + Send + Sync + 'static,
{
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,
)
@@ -207,13 +212,14 @@ where
{
require_permission(&ctx, "health.patient.manage")?;
let version = req.version;
let update = FamilyMemberReq {
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,
)

View File

@@ -1,3 +1,4 @@
pub mod crypto;
pub mod dto;
pub mod entity;
pub mod error;
@@ -7,5 +8,6 @@ pub mod module;
pub mod service;
pub mod state;
pub use crypto::HealthCrypto;
pub use module::HealthModule;
pub use state::HealthState;

View File

@@ -17,6 +17,21 @@ impl HealthModule {
Self
}
/// 启动定时逾期随访检查(每 6 小时运行一次)
pub fn start_overdue_checker(db: sea_orm::DatabaseConnection) {
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(6 * 3600));
loop {
interval.tick().await;
match crate::service::follow_up_service::check_overdue_tasks(&db).await {
Ok(count) if count > 0 => tracing::info!(count = count, "随访逾期检查完成"),
Ok(_) => {}
Err(e) => tracing::warn!(error = %e, "随访逾期检查失败"),
}
}
});
}
pub fn public_routes<S>() -> Router<S>
where
crate::state::HealthState: axum::extract::FromRef<S>,
@@ -206,11 +221,14 @@ impl HealthModule {
// 健康资讯
.route(
"/health/articles",
axum::routing::get(article_handler::list_articles),
axum::routing::get(article_handler::list_articles)
.post(article_handler::create_article),
)
.route(
"/health/articles/{id}",
axum::routing::get(article_handler::get_article),
axum::routing::get(article_handler::get_article)
.put(article_handler::update_article)
.delete(article_handler::delete_article),
)
}
}
@@ -235,8 +253,29 @@ impl ErpModule for HealthModule {
vec!["auth"]
}
fn register_event_handlers(&self, bus: &EventBus) {
crate::event::register_handlers(bus);
fn register_event_handlers(&self, _bus: &EventBus) {
// 事件处理器已迁移到 on_startup此处保留空实现以兼容 trait 签名
}
async fn on_startup(&self, ctx: &erp_core::module::ModuleContext) -> erp_core::error::AppResult<()> {
let crypto = crate::crypto::HealthCrypto::from_keys(
&std::env::var("HEALTH_AES_KEY").unwrap_or_default(),
&std::env::var("HEALTH_HMAC_KEY").unwrap_or_default(),
)
.unwrap_or_else(|_| {
tracing::warn!("HEALTH_AES_KEY / HEALTH_HMAC_KEY 未设置或无效,使用开发默认密钥");
crate::crypto::HealthCrypto::dev_default()
});
let state = crate::state::HealthState {
db: ctx.db.clone(),
event_bus: ctx.event_bus.clone(),
crypto,
};
crate::event::register_handlers_with_state(state);
tracing::info!(module = "health", "Health module event handlers registered via on_startup");
Ok(())
}
async fn on_tenant_created(

View File

@@ -1,6 +1,8 @@
//! 预约排班 Service — 预约CRUD、排班管理、日历视图、原子CAS预约
use chrono::Utc;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::events::DomainEvent;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, Condition, QueryOrder, QuerySelect, TransactionTrait};
@@ -175,6 +177,12 @@ pub async fn create_appointment(
);
state.event_bus.publish(event, &state.db).await;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "appointment.created", "appointment")
.with_resource_id(m.id),
&state.db,
).await;
Ok(AppointmentResp {
id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id,
appointment_type: m.appointment_type, appointment_date: m.appointment_date,
@@ -206,6 +214,8 @@ pub async fn update_appointment_status(
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
let old_status = model.status.clone();
let txn = state.db.begin().await?;
// 取消时释放排班名额(带下限保护)
@@ -223,9 +233,14 @@ pub async fn update_appointment_status(
.filter(doctor_schedule::Column::DeletedAt.is_null())
.filter(Expr::col(doctor_schedule::Column::CurrentAppointments).gt(0))
.exec(&txn)
.await;
if let Err(e) = release_result {
tracing::error!(error = %e, "取消预约时释放排班名额失败");
.await
.map_err(|e| HealthError::DbError(format!("取消预约时释放排班名额失败: {}", e)))?;
if release_result.rows_affected == 0 {
tracing::warn!(
doctor_id = %did,
date = %model.appointment_date,
"取消预约时未找到匹配排班记录,可能已被删除"
);
}
}
}
@@ -249,6 +264,16 @@ pub async fn update_appointment_status(
);
state.event_bus.publish(event, &state.db).await;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "appointment.status_changed", "appointment")
.with_resource_id(m.id)
.with_changes(
Some(serde_json::json!({ "status": old_status })),
Some(serde_json::json!({ "status": m.status })),
),
&state.db,
).await;
Ok(AppointmentResp {
id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id,
appointment_type: m.appointment_type, appointment_date: m.appointment_date,
@@ -366,6 +391,15 @@ pub async fn update_schedule(
if let Some(ref s) = req.status { validate_schedule_status(s)?; }
// 不允许将 max_appointments 设为小于当前已预约数
if let Some(new_max) = req.max_appointments {
if new_max < model.current_appointments {
return Err(HealthError::Validation(
format!("max_appointments ({}) 不能小于当前已预约数 ({})", new_max, model.current_appointments)
));
}
}
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

@@ -1,12 +1,16 @@
//! 健康资讯 Service — 文章列表和详情
//! 健康资讯 Service — 文章 CRUD
use chrono::Utc;
use sea_orm::entity::prelude::*;
use sea_orm::{QueryOrder, QuerySelect};
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect};
use uuid::Uuid;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::types::PaginatedResponse;
use crate::dto::article_dto::{ArticleListItem, ArticleResp};
use crate::dto::article_dto::{ArticleListItem, ArticleResp, CreateArticleReq, UpdateArticleReq};
use crate::entity::article;
use crate::error::{HealthError, HealthResult};
use crate::state::HealthState;
@@ -101,3 +105,113 @@ fn model_to_resp(m: article::Model) -> ArticleResp {
version: m.version,
}
}
// ---------------------------------------------------------------------------
// 文章管理(写入)
// ---------------------------------------------------------------------------
pub async fn create_article(
state: &HealthState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
req: CreateArticleReq,
) -> HealthResult<ArticleResp> {
let now = Utc::now();
let active = article::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
title: Set(req.title),
summary: Set(req.summary),
content: Set(req.content.unwrap_or_default()),
cover_image: Set(req.cover_image),
category: Set(req.category),
author: Set(req.author),
published_at: Set(req.published_at),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
let m = active.insert(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "article.created", "article")
.with_resource_id(m.id),
&state.db,
).await;
Ok(model_to_resp(m))
}
pub async fn update_article(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
req: UpdateArticleReq,
) -> HealthResult<ArticleResp> {
let model = article::Entity::find()
.filter(article::Column::Id.eq(id))
.filter(article::Column::TenantId.eq(tenant_id))
.filter(article::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::ArticleNotFound)?;
let next_ver = check_version(req.version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
let mut active: article::ActiveModel = model.into();
if let Some(v) = req.title { active.title = Set(v); }
if let Some(v) = req.summary { active.summary = Set(Some(v)); }
if let Some(v) = req.content { active.content = Set(v); }
if let Some(v) = req.cover_image { active.cover_image = Set(Some(v)); }
if let Some(v) = req.category { active.category = Set(Some(v)); }
if let Some(v) = req.author { active.author = Set(Some(v)); }
if let Some(v) = req.published_at { active.published_at = Set(Some(v)); }
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "article.updated", "article")
.with_resource_id(m.id),
&state.db,
).await;
Ok(model_to_resp(m))
}
pub async fn delete_article(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
) -> HealthResult<()> {
let model = article::Entity::find()
.filter(article::Column::Id.eq(id))
.filter(article::Column::TenantId.eq(tenant_id))
.filter(article::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::ArticleNotFound)?;
let mut active: article::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "article.deleted", "article")
.with_resource_id(id),
&state.db,
).await;
Ok(())
}

View File

@@ -1,9 +1,11 @@
//! 咨询管理 Service — 会话管理、消息收发、会话关闭、导出
use chrono::Utc;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::events::DomainEvent;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, Condition, QueryOrder, QuerySelect};
use sea_orm::{ActiveValue::Set, Condition, QueryOrder, QuerySelect, TransactionTrait};
use uuid::Uuid;
use erp_core::error::check_version;
@@ -65,6 +67,12 @@ pub async fn create_session(
);
state.event_bus.publish(event, &state.db).await;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "consultation.opened", "consultation")
.with_resource_id(m.id),
&state.db,
).await;
Ok(SessionResp {
id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id,
consultation_type: m.consultation_type, status: m.status,
@@ -154,6 +162,12 @@ pub async fn close_session(
);
state.event_bus.publish(event, &state.db).await;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "consultation.closed", "consultation")
.with_resource_id(m.id),
&state.db,
).await;
Ok(SessionResp {
id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id,
consultation_type: m.consultation_type, status: m.status,
@@ -259,6 +273,9 @@ pub async fn create_message(
let is_patient = req.sender_role == "patient";
let should_activate = session.status == "waiting";
// 事务包裹:消息 INSERT + 会话 CAS 更新,保证原子性
let txn = state.db.begin().await?;
// 创建消息
let active = consultation_message::ActiveModel {
id: Set(Uuid::now_v7()),
@@ -276,7 +293,7 @@ pub async fn create_message(
deleted_at: Set(None),
version: Set(1),
};
let m = active.insert(&state.db).await?;
let m = active.insert(&txn).await?;
// 更新会话的 last_message_at 和未读计数waiting→active 自动触发
// 使用 CAS 防止并发发消息时丢失 unread_count 更新
@@ -303,11 +320,20 @@ pub async fn create_message(
Expr::col(consultation_session::Column::UnreadCountPatient).add(1),
);
}
let cas_result = cas.exec(&state.db).await?;
let cas_result = cas.exec(&txn).await?;
if cas_result.rows_affected == 0 {
txn.rollback().await?;
return Err(HealthError::VersionMismatch);
}
txn.commit().await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "consultation.message_sent", "consultation")
.with_resource_id(m.id),
&state.db,
).await;
Ok(MessageResp {
id: m.id, session_id: m.session_id, sender_id: m.sender_id,
sender_role: m.sender_role, content_type: m.content_type,

View File

@@ -1,6 +1,8 @@
//! 医护档案 Service — CRUD
use chrono::Utc;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, Condition, QueryOrder, QuerySelect};
use uuid::Uuid;
@@ -95,6 +97,13 @@ pub async fn create_doctor(
};
let model = active.insert(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "doctor.created", "doctor")
.with_resource_id(model.id),
&state.db,
).await;
Ok(model_to_resp(model))
}
@@ -144,6 +153,13 @@ pub async fn update_doctor(
active.version = Set(next_ver);
let updated = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "doctor.updated", "doctor")
.with_resource_id(updated.id),
&state.db,
).await;
Ok(model_to_resp(updated))
}
@@ -165,6 +181,12 @@ pub async fn delete_doctor(
active.version = Set(next_ver);
active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "doctor.deleted", "doctor")
.with_resource_id(id),
&state.db,
).await;
Ok(())
}

View File

@@ -1,6 +1,8 @@
//! 随访管理 Service — 随访任务CRUD、随访记录、状态流转
use chrono::Utc;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::events::DomainEvent;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect, TransactionTrait};
@@ -126,6 +128,12 @@ pub async fn create_task(
);
state.event_bus.publish(event, &state.db).await;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "follow_up_task.created", "follow_up_task")
.with_resource_id(m.id),
&state.db,
).await;
Ok(FollowUpTaskResp {
id: m.id, patient_id: m.patient_id, assigned_to: m.assigned_to,
follow_up_type: m.follow_up_type, planned_date: m.planned_date,
@@ -295,6 +303,12 @@ pub async fn create_record(
);
state.event_bus.publish(event, &state.db).await;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "follow_up_record.created", "follow_up_record")
.with_resource_id(record.id),
&state.db,
).await;
Ok(FollowUpRecordResp {
id: record.id, task_id: record.task_id, executed_by: record.executed_by,
executed_date: record.executed_date, result: record.result,
@@ -353,21 +367,73 @@ pub async fn list_records(
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
}
/// 随访任务状态机: pending → in_progress/cancelled, in_progress → completed/cancelled
/// 随访任务状态机(委托给 validation 模块公共函数)
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
)))
crate::service::validation::validate_follow_up_status_transition(current, new_status)
}
// ---------------------------------------------------------------------------
// 系统自动化操作(由事件处理器调用)
// ---------------------------------------------------------------------------
/// 工作流任务完成时自动将随访任务标记为 completed。
/// 仅当当前状态为 pending 或 in_progress 时才更新,其他状态忽略。
pub async fn complete_task_by_system(
db: &DatabaseConnection,
task_id: Uuid,
tenant_id: Uuid,
) -> HealthResult<()> {
let model = follow_up_task::Entity::find()
.filter(follow_up_task::Column::Id.eq(task_id))
.filter(follow_up_task::Column::TenantId.eq(tenant_id))
.filter(follow_up_task::Column::DeletedAt.is_null())
.one(db)
.await?;
match model {
Some(m) if m.status == "pending" || m.status == "in_progress" => {
let mut active: follow_up_task::ActiveModel = m.into();
active.status = Set("completed".to_string());
active.updated_at = Set(Utc::now());
active.version = Set(active.version.unwrap() + 1);
active.update(db).await?;
Ok(())
}
Some(_) => {
// 非 pending/in_progress 状态,不做任何更新
Ok(())
}
None => {
// 随访任务不存在,可能不属于健康模块
Ok(())
}
}
}
// ---------------------------------------------------------------------------
// 定时任务:逾期随访检查
// ---------------------------------------------------------------------------
/// 批量将 planned_date < 今天 且 status = pending 的随访任务标记为 overdue。
/// 返回受影响的行数。
pub async fn check_overdue_tasks(db: &DatabaseConnection) -> HealthResult<u64> {
use sea_orm::QueryFilter;
let today = chrono::Utc::now().date_naive();
let result = follow_up_task::Entity::update_many()
.col_expr(
follow_up_task::Column::Status,
sea_orm::sea_query::Expr::value("overdue".to_string()),
)
.col_expr(
follow_up_task::Column::UpdatedAt,
sea_orm::sea_query::Expr::value(chrono::Utc::now()),
)
.filter(follow_up_task::Column::Status.eq("pending"))
.filter(follow_up_task::Column::PlannedDate.lt(today))
.filter(follow_up_task::Column::DeletedAt.is_null())
.exec(db)
.await?;
Ok(result.rows_affected)
}

View File

@@ -1,6 +1,8 @@
//! 健康数据 Service — 体征记录、化验报告、体检记录、趋势分析
use chrono::Utc;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::events::DomainEvent;
use num_traits::cast::ToPrimitive;
use sea_orm::entity::prelude::*;
@@ -106,6 +108,13 @@ pub async fn create_vital_signs(
version: Set(1),
};
let m = active.insert(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "vital_signs.created", "vital_signs")
.with_resource_id(m.id),
&state.db,
).await;
Ok(VitalSignsResp {
id: m.id, patient_id: m.patient_id, record_date: m.record_date,
systolic_bp_morning: m.systolic_bp_morning, diastolic_bp_morning: m.diastolic_bp_morning,
@@ -274,6 +283,12 @@ pub async fn create_lab_report(
);
state.event_bus.publish(event, &state.db).await;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "lab_report.created", "lab_report")
.with_resource_id(m.id),
&state.db,
).await;
Ok(LabReportResp {
id: m.id, patient_id: m.patient_id, report_date: m.report_date,
report_type: m.report_type, indicators: m.indicators,
@@ -424,6 +439,13 @@ pub async fn create_health_record(
version: Set(1),
};
let m = active.insert(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "health_record.created", "health_record")
.with_resource_id(m.id),
&state.db,
).await;
Ok(HealthRecordResp {
id: m.id, patient_id: m.patient_id, record_type: m.record_type,
record_date: m.record_date, source: m.source,

View File

@@ -1,6 +1,8 @@
//! 患者管理 Service — CRUD、家庭成员、标签、医生关联、健康摘要
use chrono::Utc;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::events::DomainEvent;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, Condition, QueryOrder, QuerySelect};
@@ -53,10 +55,11 @@ pub async fn list_patients(
.filter(patient::Column::DeletedAt.is_null());
if let Some(ref search) = search {
let search_hash = state.crypto.hmac_hash(search);
query = query.filter(
Condition::any()
.add(patient::Column::Name.contains(search))
.add(patient::Column::IdNumber.contains(search)),
.add(patient::Column::IdNumberHash.eq(search_hash)),
);
}
@@ -101,6 +104,16 @@ pub async fn create_patient(
if let Some(ref g) = req.gender { validate_gender(g)?; }
if let Some(ref bt) = req.blood_type { validate_blood_type(bt)?; }
// 加密身份证号 + HMAC 索引
let (encrypted_id_number, id_number_hash) = match req.id_number {
Some(ref plain) if !plain.is_empty() => {
let encrypted = state.crypto.encrypt(plain)?;
let hash = state.crypto.hmac_hash(plain);
(Some(encrypted), Some(hash))
}
_ => (None, None),
};
let active = patient::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
@@ -109,7 +122,8 @@ pub async fn create_patient(
gender: Set(req.gender),
birth_date: Set(req.birth_date),
blood_type: Set(req.blood_type),
id_number: Set(req.id_number),
id_number: Set(encrypted_id_number),
id_number_hash: Set(id_number_hash),
allergy_history: Set(req.allergy_history),
medical_history_summary: Set(req.medical_history_summary),
emergency_contact_name: Set(req.emergency_contact_name),
@@ -135,17 +149,23 @@ pub async fn create_patient(
);
state.event_bus.publish(event, &state.db).await;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "patient.created", "patient")
.with_resource_id(model.id),
&state.db,
).await;
Ok(model_to_resp(model))
}
/// 获取患者详情
/// 获取患者详情(解密身份证号)
pub async fn get_patient(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
) -> HealthResult<PatientResp> {
let model = find_patient(&state.db, tenant_id, id).await?;
Ok(model_to_resp(model))
Ok(model_to_resp_decrypted(&state.crypto, model))
}
/// 更新患者信息(乐观锁)
@@ -189,7 +209,12 @@ pub async fn update_patient(
if let Some(v) = req.gender { active.gender = Set(Some(v)); }
if req.birth_date.is_some() { active.birth_date = Set(req.birth_date); }
if let Some(v) = req.blood_type { active.blood_type = Set(Some(v)); }
if let Some(v) = req.id_number { active.id_number = Set(Some(v)); }
if let Some(ref plain) = req.id_number {
let encrypted = state.crypto.encrypt(plain)?;
let hash = state.crypto.hmac_hash(plain);
active.id_number = Set(Some(encrypted));
active.id_number_hash = Set(Some(hash));
}
if let Some(v) = req.allergy_history { active.allergy_history = Set(Some(v)); }
if let Some(v) = req.medical_history_summary { active.medical_history_summary = Set(Some(v)); }
if let Some(v) = req.emergency_contact_name { active.emergency_contact_name = Set(Some(v)); }
@@ -220,6 +245,12 @@ pub async fn update_patient(
);
state.event_bus.publish(event, &state.db).await;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "patient.updated", "patient")
.with_resource_id(updated.id),
&state.db,
).await;
Ok(model_to_resp(updated))
}
@@ -242,6 +273,12 @@ pub async fn delete_patient(
active.version = Set(next_ver);
active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "patient.deleted", "patient")
.with_resource_id(id),
&state.db,
).await;
Ok(())
}
@@ -308,6 +345,12 @@ pub async fn manage_patient_tags(
rel.insert(&state.db).await?;
}
audit_service::record(
AuditLog::new(tenant_id, operator_id, "patient.tags_updated", "patient")
.with_resource_id(patient_id),
&state.db,
).await;
Ok(())
}
@@ -613,6 +656,7 @@ async fn find_patient(
}
/// Entity Model → DTO Resp
/// 列表用 — 不含敏感字段
fn model_to_resp(m: patient::Model) -> PatientResp {
PatientResp {
id: m.id,
@@ -621,11 +665,11 @@ fn model_to_resp(m: patient::Model) -> PatientResp {
gender: m.gender,
birth_date: m.birth_date,
blood_type: m.blood_type,
id_number: m.id_number,
id_number: None,
allergy_history: m.allergy_history,
medical_history_summary: m.medical_history_summary,
emergency_contact_name: m.emergency_contact_name,
emergency_contact_phone: m.emergency_contact_phone,
emergency_contact_phone: mask_phone(m.emergency_contact_phone.as_deref()),
status: m.status,
verification_status: m.verification_status,
source: m.source,
@@ -636,6 +680,51 @@ fn model_to_resp(m: patient::Model) -> PatientResp {
}
}
/// 详情用 — 解密身份证号
fn model_to_resp_decrypted(crypto: &crate::crypto::HealthCrypto, m: patient::Model) -> PatientResp {
let decrypted_id_number = m.id_number.as_ref().and_then(|enc| {
crypto.decrypt(enc).ok()
});
PatientResp {
id: m.id,
user_id: m.user_id,
name: m.name,
gender: m.gender,
birth_date: m.birth_date,
blood_type: m.blood_type,
id_number: decrypted_id_number.map(|id| mask_id_number(&id)),
allergy_history: m.allergy_history,
medical_history_summary: m.medical_history_summary,
emergency_contact_name: m.emergency_contact_name,
emergency_contact_phone: mask_phone(m.emergency_contact_phone.as_deref()),
status: m.status,
verification_status: m.verification_status,
source: m.source,
notes: m.notes,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
}
}
fn mask_id_number(s: &str) -> String {
if s.len() >= 7 {
format!("{}****{}", &s[..3], &s[s.len() - 4..])
} else {
"****".to_string()
}
}
fn mask_phone(s: Option<&str>) -> Option<String> {
s.map(|p| {
if p.len() >= 7 {
format!("{}****{}", &p[..3], &p[p.len() - 4..])
} else {
"****".to_string()
}
})
}
/// 状态机转换校验: 检查 (current → new) 是否在 allowed_transitions 中
fn validate_status_transition(
field_name: &str,

View File

@@ -130,3 +130,24 @@ pub fn validate_online_status(value: &str) -> HealthResult<()> {
validate_enum!(value, "online_status", ["online", "offline", "busy"]);
Ok(())
}
/// follow_up_task.status 状态转换(含 overdue 状态)
pub fn validate_follow_up_status_transition(current: &str, new: &str) -> HealthResult<()> {
if current == new {
return Ok(());
}
let allowed = match current {
"pending" => matches!(new, "in_progress" | "cancelled" | "overdue"),
"in_progress" => matches!(new, "completed" | "cancelled"),
"overdue" => matches!(new, "in_progress" | "cancelled"),
_ => false,
};
if allowed {
Ok(())
} else {
Err(HealthError::InvalidStatusTransition(format!(
"follow_up_task.status: 不允许从 '{}' 转换到 '{}'",
current, new
)))
}
}

View File

@@ -1,3 +1,4 @@
use crate::crypto::HealthCrypto;
use erp_core::events::EventBus;
use sea_orm::DatabaseConnection;
@@ -5,4 +6,5 @@ use sea_orm::DatabaseConnection;
pub struct HealthState {
pub db: DatabaseConnection,
pub event_bus: EventBus,
pub crypto: HealthCrypto,
}

View File

@@ -26,5 +26,5 @@ level = "info"
allowed_origins = "http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:3000"
[wechat]
appid = "__MUST_SET_VIA_ENV__"
secret = "__MUST_SET_VIA_ENV__"
appid = "wx20f4ef9cc2ec66c5"
secret = "096ba4fa828e7b1fa7de2235eb6c7836"

View File

@@ -47,6 +47,7 @@ mod m20260423_000044_create_articles;
mod m20260424_000045_health_indexes;
mod m20260424_000046_health_constraints_fix;
mod m20260424_000047_health_index_fix;
mod m20260425_000048_add_patient_id_number_hash;
pub struct Migrator;
@@ -101,6 +102,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260424_000045_health_indexes::Migration),
Box::new(m20260424_000046_health_constraints_fix::Migration),
Box::new(m20260424_000047_health_index_fix::Migration),
Box::new(m20260425_000048_add_patient_id_number_hash::Migration),
]
}
}

View File

@@ -6,7 +6,13 @@ pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// H-12: lab_report.indicators GIN 索引JSONB 查询加速)
// H-12: lab_report.indicators 先转 jsonb 再建 GIN 索引
manager
.get_connection()
.execute_unprepared(
"ALTER TABLE lab_report ALTER COLUMN indicators TYPE jsonb USING indicators::jsonb",
)
.await?;
manager
.get_connection()
.execute_unprepared(
@@ -46,6 +52,12 @@ impl MigrationTrait for Migration {
.get_connection()
.execute_unprepared("DROP INDEX IF EXISTS idx_lab_report_indicators_gin")
.await?;
manager
.get_connection()
.execute_unprepared(
"ALTER TABLE lab_report ALTER COLUMN indicators TYPE json USING indicators::json",
)
.await?;
manager
.drop_index(Index::drop().name("idx_health_trend_patient_period").to_owned())
.await?;

View File

@@ -0,0 +1,38 @@
use sea_orm_migration::prelude::*;
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m20260425_000048_add_patient_id_number_hash"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Alias::new("patients"))
.add_column(
ColumnDef::new(Alias::new("id_number_hash"))
.string()
.null(),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Alias::new("patients"))
.drop_column(Alias::new("id_number_hash"))
.to_owned(),
)
.await
}
}

View File

@@ -389,6 +389,10 @@ async fn main() -> anyhow::Result<()> {
erp_workflow::WorkflowModule::start_timeout_checker(db.clone());
tracing::info!("Timeout checker started");
// Start follow-up overdue checker (every 6 hours)
erp_health::HealthModule::start_overdue_checker(db.clone());
tracing::info!("Follow-up overdue checker started");
let host = config.server.host.clone();
let port = config.server.port;

View File

@@ -105,6 +105,7 @@ impl FromRef<AppState> for erp_health::HealthState {
Self {
db: state.db.clone(),
event_bus: state.event_bus.clone(),
crypto: erp_health::HealthCrypto::dev_default(),
}
}
}