Compare commits
5 Commits
a63043f447
...
994119ded1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
994119ded1 | ||
|
|
43e127d4f7 | ||
|
|
6c70e2a783 | ||
|
|
479b5900c9 | ||
|
|
1d1f01df81 |
@@ -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"
|
||||
|
||||
90
crates/erp-health/src/crypto.rs
Normal file
90
crates/erp-health/src/crypto.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(())))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user