# HMS 健康管理模块全面迭代 — 实施计划 > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 修复 4 个 V1 阻塞项,完成 erp-health 后端补完和 Web 前端 10 页面开发。 **Architecture:** 安全地基先行(sanitize/审计/加密/脱敏)→ 后端补完(事件处理器/数据一致性/逾期检测/测试)→ Web 前端(API 层 + 10 页面)→ 端到端验证。严格按阶段顺序,每阶段独立可验证。 **Tech Stack:** Rust/Axum/SeaORM (后端) · React 19/Ant Design 6/Zustand 5 (前端) · PostgreSQL 18 · Taro 4 (小程序) **设计文档:** `docs/superpowers/specs/2026-04-24-health-module-iteration-design.md` --- ## Chunk 1: 安全省基(阶段 1,1.5-2 周) ### Task 1: DTO sanitize 全覆盖 **Files:** - Modify: `crates/erp-health/src/dto/patient_dto.rs` - Modify: `crates/erp-health/src/dto/health_data_dto.rs` - Modify: `crates/erp-health/src/dto/appointment_dto.rs` - Modify: `crates/erp-health/src/dto/follow_up_dto.rs` - Modify: `crates/erp-health/src/dto/consultation_dto.rs` - Modify: `crates/erp-health/src/dto/doctor_dto.rs` - Modify: `crates/erp-health/src/handler/patient_handler.rs` (调用 sanitize) - Modify: `crates/erp-health/src/handler/health_data_handler.rs` - Modify: `crates/erp-health/src/handler/appointment_handler.rs` - Modify: `crates/erp-health/src/handler/follow_up_handler.rs` - Modify: `crates/erp-health/src/handler/consultation_handler.rs` - Modify: `crates/erp-health/src/handler/doctor_handler.rs` **参考模式:** `crates/erp-auth/src/dto.rs` 第 94-119 行的 sanitize 实现。 **可用函数:** `erp_core::sanitize::{strip_html_tags, sanitize_option, sanitize_string}` - [ ] **Step 1: 为 patient_dto.rs 添加 sanitize** 在每个请求 DTO 的 impl 块中添加 `sanitize(&mut self)` 方法: ```rust use erp_core::sanitize::{strip_html_tags, sanitize_option, sanitize_string}; impl CreatePatientReq { pub fn sanitize(&mut self) { self.name = sanitize_string(&self.name); self.notes = sanitize_option(self.notes.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.source = sanitize_option(self.source.take()); } } 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.notes = sanitize_option(self.notes.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()); } } impl FamilyMemberReq { pub fn sanitize(&mut self) { self.name = sanitize_string(&self.name); self.notes = sanitize_option(self.notes.take()); } } ``` - [ ] **Step 2: 为 health_data_dto.rs 添加 sanitize** ```rust impl CreateVitalSignsReq { pub fn sanitize(&mut self) { self.notes = sanitize_option(self.notes.take()); } } impl CreateLabReportReq { pub fn sanitize(&mut self) { self.doctor_interpretation = sanitize_option(self.doctor_interpretation.take()); } } 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()); } } ``` - [ ] **Step 3: 为 appointment_dto.rs 添加 sanitize** ```rust impl CreateAppointmentReq { pub fn sanitize(&mut self) { self.notes = sanitize_option(self.notes.take()); } } impl UpdateAppointmentStatusReq { pub fn sanitize(&mut self) { self.cancel_reason = sanitize_option(self.cancel_reason.take()); } } impl UpdateVitalSignsReq { pub fn sanitize(&mut self) { self.notes = sanitize_option(self.notes.take()); } } impl UpdateLabReportReq { pub fn sanitize(&mut self) { self.doctor_interpretation = sanitize_option(self.doctor_interpretation.take()); } } 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()); } } ``` - [ ] **Step 4: 为 follow_up_dto.rs 添加 sanitize** ```rust impl CreateFollowUpTaskReq { pub fn sanitize(&mut self) { self.content_template = sanitize_option(self.content_template.take()); } } impl UpdateFollowUpTaskReq { pub fn sanitize(&mut self) { self.content_template = sanitize_option(self.content_template.take()); } } 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()); } } ``` - [ ] **Step 5: 为 consultation_dto.rs 添加 sanitize** ```rust impl CreateMessageReq { pub fn sanitize(&mut self) { self.content = sanitize_string(&self.content); } } ``` - [ ] **Step 6: 为 doctor_dto.rs 添加 sanitize** ```rust 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()); } } 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()); } } ``` - [ ] **Step 7: 在所有 handler 中调用 sanitize** 在每个 handler 的 create/update 方法中,在调用 service 之前添加 `req.sanitize()`。示例: ```rust // patient_handler.rs async fn create_patient(/* ... */) -> AppResult>> { let mut req = req.0; req.sanitize(); // ← 新增 let result = patient_service::create_patient(&state.health, tenant_id, user_id, req).await?; Ok(Json(ApiResponse::success(result))) } ``` 所有需要修改的 handler 方法: - `patient_handler`: create_patient, update_patient, create_family_member, update_family_member - `health_data_handler`: create_vital_signs, update_vital_signs, create_lab_report, update_lab_report, create_health_record, update_health_record - `appointment_handler`: create_appointment, update_appointment_status - `follow_up_handler`: create_task, update_task, create_record - `consultation_handler`: create_message - `doctor_handler`: create_doctor, update_doctor - [ ] **Step 8: 验证编译** Run: `cargo check` Expected: 无错误 - [ ] **Step 9: 提交** ```bash git add crates/erp-health/src/dto/ crates/erp-health/src/handler/ git commit -m "feat(health): 为所有 DTO 添加 sanitize 防止存储型 XSS" ``` --- ### Task 2: 审计日志注入 **Files:** - Modify: `crates/erp-health/src/service/patient_service.rs` - Modify: `crates/erp-health/src/service/appointment_service.rs` - Modify: `crates/erp-health/src/service/consultation_service.rs` - Modify: `crates/erp-health/src/service/follow_up_service.rs` - Modify: `crates/erp-health/src/service/doctor_service.rs` - Modify: `crates/erp-health/src/service/health_data_service.rs` **参考模式:** `crates/erp-auth/src/service/auth_service.rs` 第 168-177 行。 **注意:** 使用现有 `erp_core::audit_service::record(log, db)` 的 fire-and-forget 模式。未来如需事务保证再升级为 `record_in_txn`。 - [ ] **Step 1: 在 patient_service.rs 注入审计日志** 在 create_patient、update_patient、delete_patient、manage_patient_tags 方法中添加审计调用。每个方法在业务操作成功后、返回结果前插入: ```rust use erp_core::audit::AuditLog; use erp_core::audit_service; // create_patient 末尾(return 之前) audit_service::record( AuditLog::new(tenant_id, operator_id, "patient.created", "patient") .with_resource_id(m.id), &state.db, ).await; ``` 覆盖清单(共 4 个操作): | 方法 | action | |------|--------| | create_patient | `patient.created` | | update_patient | `patient.updated` | | delete_patient | `patient.deleted` | | manage_patient_tags | `patient.tags_updated` | - [ ] **Step 2: 在 appointment_service.rs 注入审计日志** 覆盖清单(共 2 个操作): | 方法 | action | |------|--------| | create_appointment | `appointment.created` | | update_appointment_status | `appointment.status_changed` | `update_appointment_status` 中添加变更前后值摘要: ```rust 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; ``` - [ ] **Step 3: 在 consultation_service.rs 注入审计日志** 覆盖清单(共 3 个操作): | 方法 | action | |------|--------| | create_session | `consultation.opened` | | close_session | `consultation.closed` | | create_message | `consultation.message_sent` | - [ ] **Step 4: 在 follow_up_service.rs 注入审计日志** 覆盖清单(共 2 个操作): | 方法 | action | |------|--------| | create_task | `follow_up_task.created` | | create_record | `follow_up_record.created` | - [ ] **Step 5: 在 doctor_service.rs 和 health_data_service.rs 注入审计日志** doctor_service 覆盖清单(共 3 个操作): | 方法 | action | |------|--------| | create_doctor | `doctor.created` | | update_doctor | `doctor.updated` | | delete_doctor | `doctor.deleted` | health_data_service 覆盖清单(共 3 个操作): | 方法 | action | |------|--------| | create_vital_signs | `vital_signs.created` | | create_lab_report | `lab_report.created` | | create_health_record | `health_record.created` | - [ ] **Step 6: 验证编译** Run: `cargo check` Expected: 无错误 - [ ] **Step 7: 提交** ```bash git add crates/erp-health/src/service/ git commit -m "feat(health): 注入审计日志覆盖所有写入操作" ``` --- ### Task 3: 身份证号加密存储 **Files:** - Create: `crates/erp-health/src/crypto.rs` - Modify: `crates/erp-health/src/lib.rs` (添加 mod crypto) - Modify: `crates/erp-health/src/service/patient_service.rs` - Modify: `crates/erp-health/src/state.rs` (添加 crypto 字段) - Modify: `crates/erp-health/src/module.rs` (初始化 crypto + 注册事件处理器) - Modify: `crates/erp-health/Cargo.toml` (添加 aes-gcm 依赖) - Modify: `crates/erp-server/src/state.rs` (更新 FromRef 添加 crypto 字段) - Modify: `crates/erp-server/src/main.rs` (注入 crypto 到 HealthState) - [ ] **Step 1: 添加 Cargo.toml 依赖** ```toml # crates/erp-health/Cargo.toml [dependencies] 新增 aes-gcm = "0.10" hmac = "0.12" sha2 = "0.10" base64 = "0.22" hex = "0.4" ``` - [ ] **Step 2: 创建 crypto.rs** ```rust use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; use aes_gcm::aead::Aead; use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use hmac::{Hmac, Mac}; use sha2::Sha256; use erp_core::error::AppResult; use erp_core::error::AppError; type HmacSha256 = Hmac; 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 { 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".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 }) } pub fn encrypt(&self, plaintext: &str) -> AppResult { 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()); let ciphertext = cipher.encrypt(nonce, plaintext.as_bytes()) .map_err(|e| AppError::Internal(format!("Encryption failed: {}", e)))?; // 格式: base64(nonce_12byte + ciphertext) 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 { 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::new_from_slice(&self.hmac_key) .expect("HMAC key length is valid"); mac.update(value.as_bytes()); hex::encode(mac.finalize().into_bytes()) } } ``` - [ ] **Step 3: 更新 state.rs** ```rust // state.rs use crate::crypto::HealthCrypto; #[derive(Clone)] pub struct HealthState { pub db: sea_orm::DatabaseConnection, pub event_bus: erp_core::events::EventBus, pub crypto: HealthCrypto, } ``` - [ ] **Step 4: 更新 module.rs 初始化 crypto(同时注册事件处理器)** 在 `module.rs` 中实现 `on_startup`,一次性初始化 crypto 和注册事件处理器: ```rust async fn on_startup(&self, ctx: &erp_core::module::ModuleContext) -> erp_core::error::AppResult<()> { let state = HealthState { db: ctx.db.clone(), event_bus: ctx.event_bus.clone(), crypto: crate::crypto::HealthCrypto::from_keys( &std::env::var("ERP__HEALTH__ENCRYPTION_KEY").unwrap_or_default(), &std::env::var("ERP__HEALTH__HMAC_KEY").unwrap_or_default(), )?, }; crate::event::register_handlers_with_state(state); Ok(()) } fn register_event_handlers(&self, _bus: &EventBus) { // 已迁移到 on_startup,此处为空 } ``` **同时必须更新** `crates/erp-server/src/state.rs` 中 `FromRef for erp_health::HealthState` 的实现,添加 `crypto` 字段。需要从环境变量或 AppState 配置中获取密钥。 - [ ] **Step 5: 修改 patient_service.rs — 加密存储 + HMAC 索引** - `create_patient`: 加密 `id_number`,同时写入 `id_number_hash = crypto.hmac_hash(明文)` - `update_patient`: 同上 - `get_patient`: 解密 `id_number` 后返回 - `list_patients`: 不返回 `id_number`(使用新的 `PatientListResp`,见 Task 4) 需要新增数据库迁移:添加 `id_number_hash` 列(后续迁移任务中执行)。 - [ ] **Step 5: 创建数据库迁移 — 添加 id_number_hash 列** 在 `crates/erp-server/migration/src/` 创建新迁移文件 `m20260424_000001_add_id_number_hash.rs`: ```rust use sea_orm_migration::prelude::*; pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { "m20260424_000001_add_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::String(Alias::new("id_number_hash")).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 } } ``` 并在 migration 的 `mod.rs` 中注册。 - [ ] **Step 6: 验证编译** Run: `cargo check` Expected: 无错误 - [ ] **Step 7: 在 config/default.toml 中标记加密密钥** ```toml [health] encryption_key = "__MUST_SET_VIA_ENV__" hmac_key = "__MUST_SET_VIA_ENV__" ``` - [ ] **Step 8: 提交** ```bash git add crates/erp-health/ crates/erp-server/ docker/ git commit -m "feat(health): 身份证号 AES-256-GCM 加密 + HMAC 索引" ``` --- ### Task 4: 字段级脱敏 — 拆分 PatientResp **Files:** - Modify: `crates/erp-health/src/dto/patient_dto.rs` - Modify: `crates/erp-health/src/service/patient_service.rs` - Modify: `crates/erp-health/src/handler/patient_handler.rs` - [ ] **Step 1: 新增 PatientListResp(不含敏感字段)和 PatientDetailResp(掩码)** ```rust // patient_dto.rs 新增 /// 列表用 — 不含敏感字段 #[derive(Debug, Clone, Serialize, ToSchema)] pub struct PatientListResp { pub id: Uuid, pub name: String, pub gender: Option, pub birth_date: Option, pub blood_type: Option, pub status: String, pub verification_status: String, pub source: Option, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, pub version: i32, } /// 详情用 — 敏感字段掩码 pub type PatientDetailResp = PatientResp; // 保留全字段,handler 中掩码处理 /// 掩码辅助函数 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: &str) -> String { if s.len() >= 7 { format!("{}****{}", &s[..3], &s[s.len()-4..]) } else { "****".to_string() } } ``` - [ ] **Step 2: 修改 patient_service — list 返回 PatientListResp** 将 `list_patients` 返回类型从 `PaginatedResponse` 改为 `PaginatedResponse`,转换时只包含安全字段。 - [ ] **Step 3: 修改 patient_service — get 返回脱敏后的 PatientDetailResp** `get_patient` 返回 `PatientResp`,但在 handler 层对 `id_number` 和 `emergency_contact_phone` 做掩码处理。 - [ ] **Step 4: 验证编译** Run: `cargo check` Expected: 无错误 - [ ] **Step 5: 提交** ```bash git add crates/erp-health/src/ git commit -m "feat(health): 患者列表脱敏 — 拆分 PatientListResp/PatientDetailResp" ``` --- ## Chunk 1 里程碑验收 - [ ] `cargo check` 全 workspace 编译通过 - [ ] 所有 DTO 的字符串输入字段都经过 sanitize - [ ] 所有写入操作(15+ 方法)均有审计日志 - [ ] 身份证号加密存储 + HMAC 索引可用 - [ ] 列表接口不返回身份证号,详情接口掩码显示 --- ## Chunk 2: 后端补完(阶段 2,1.5 周) ### Task 5: 事件处理器实现 **Files:** - Modify: `crates/erp-health/src/event.rs` - Modify: `crates/erp-health/src/module.rs` - Modify: `crates/erp-health/src/service/follow_up_service.rs` (新增 update_task_status_by_system) **问题:** `event.rs` 中两个事件处理器只有 `tracing::info`,无 db 连接。需要迁移到 `on_startup`。 - [ ] **Step 1: 改写 event.rs — 新增 register_handlers_with_state** ```rust // event.rs — 完整替换 use erp_core::events::EventBus; use sea_orm::DatabaseConnection; use crate::state::HealthState; /// 旧版注册(无 db),保留空实现以兼容 trait pub fn register_handlers(_bus: &EventBus) { // 已迁移到 register_handlers_with_state } /// 新版注册 — 带 db 连接,在 on_startup 中调用 pub fn register_handlers_with_state(state: HealthState) { let db = state.db.clone(); // workflow.task.completed → 更新随访任务状态 let mut workflow_rx = state.event_bus.subscribe_filtered("workflow.task.".to_string()).0; tokio::spawn(async move { loop { match workflow_rx.recv().await { Some(event) if event.event_type == "workflow.task.completed" => { if let Some(payload) = event.payload.get("task_id") { if let Some(task_id_str) = payload.as_str() { if let Ok(task_id) = uuid::Uuid::parse_str(task_id_str) { match crate::service::follow_up_service::complete_task_by_system(&db, task_id, event.tenant_id).await { Ok(_) => tracing::info!(task_id = %task_id, "随访任务通过工作流事件完成"), Err(e) => tracing::warn!(error = %e, "工作流事件触发随访任务完成失败"), } } } } } Some(_) => {} None => break, } } }); let db2 = state.db.clone(); let mut msg_rx = state.event_bus.subscribe_filtered("message.".to_string()).0; tokio::spawn(async move { loop { match msg_rx.recv().await { Some(event) if event.event_type == "message.sent" => { // 查找与消息发送者/接收者关联的咨询会话,更新 last_message_at tracing::info!(event_id = %event.id, "健康模块收到消息事件(待实现关联逻辑)"); } Some(_) => {} None => break, } } }); } ``` - [ ] **Step 2: 修改 module.rs — register_event_handlers 改为空,on_startup 注册** ```rust // module.rs fn register_event_handlers(&self, _bus: &EventBus) { // 迁移到 on_startup } async fn on_startup(&self, ctx: &erp_core::module::ModuleContext) -> erp_core::error::AppResult<()> { let state = HealthState { db: ctx.db.clone(), event_bus: ctx.event_bus.clone(), crypto: crate::crypto::HealthCrypto::from_keys( &std::env::var("ERP__HEALTH__ENCRYPTION_KEY").unwrap_or_default(), &std::env::var("ERP__HEALTH__HMAC_KEY").unwrap_or_default(), )?, }; crate::event::register_handlers_with_state(state); Ok(()) } ``` - [ ] **Step 3: 在 follow_up_service.rs 新增 complete_task_by_system** ```rust /// 供事件处理器调用的系统级状态更新(跳过 version 检查,由系统触发) pub async fn complete_task_by_system( db: &DatabaseConnection, task_id: Uuid, tenant_id: Uuid, ) -> HealthResult<()> { use crate::entity::follow_up_task; 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?.ok_or(HealthError::FollowUpTaskNotFound)?; if model.status != "pending" && model.status != "in_progress" { return Ok(()); // 已完成/已取消,忽略 } let mut active: follow_up_task::ActiveModel = model.into(); active.status = Set("completed".to_string()); active.updated_at = Set(Utc::now()); active.update(db).await?; Ok(()) } ``` - [ ] **Step 4: 验证编译** Run: `cargo check` Expected: 无错误 - [ ] **Step 5: 提交** ```bash git add crates/erp-health/src/ git commit -m "feat(health): 事件处理器实现 — workflow→随访、message→咨询联动" ``` --- ### Task 6: 数据一致性修复 **Files:** - Modify: `crates/erp-health/src/service/appointment_service.rs` - Modify: `crates/erp-health/src/service/consultation_service.rs` - [ ] **Step 1: 排班名额保护 — update_schedule 增加校验** 在 `appointment_service.rs` 的 `update_schedule` 方法中,`let next_ver = ...` 之后添加: ```rust 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) )); } } ``` - [ ] **Step 2: 取消预约名额释放 — 失败时回滚整个事务** 当前 `update_appointment_status` 中取消时名额释放失败只 log error。修改为:名额释放失败时回滚整个事务。 将 `if let Err(e) = release_result` 改为 `let release_result = ... ?;`,让错误传播导致事务回滚: ```rust if req.status == "cancelled" { if let Some(did) = model.doctor_id { doctor_schedule::Entity::update_many() // ... 同现有代码 ... .exec(&txn) .await?; // ← 改为 ? 传播错误,不再 if let Err } } ``` - [ ] **Step 3: 咨询消息原子性 — 消息 INSERT + session CAS 放同一事务** 当前 `create_message` 中消息 INSERT 和 session CAS 分别独立执行。修改为事务包裹: ```rust pub async fn create_message(/* ... */) -> HealthResult { // ... 校验会话(同现有代码)... let txn = state.db.begin().await?; // 在事务内 INSERT 消息 let m = active.insert(&txn).await?; // 在事务内 CAS 更新 session let cas_result = cas.exec(&txn).await?; if cas_result.rows_affected == 0 { txn.rollback().await?; return Err(HealthError::VersionMismatch); } txn.commit().await?; // ... } ``` - [ ] **Step 4: 验证编译** Run: `cargo check` Expected: 无错误 - [ ] **Step 5: 提交** ```bash git add crates/erp-health/src/service/ git commit -m "fix(health): 数据一致性修复 — 排班名额保护/取消回滚/消息原子性" ``` --- ### Task 7: 随访逾期定时任务 **Files:** - Modify: `crates/erp-health/src/service/validation.rs` - Modify: `crates/erp-health/src/service/follow_up_service.rs` - Modify: `crates/erp-health/src/module.rs` - Modify: `crates/erp-server/src/main.rs` - [ ] **Step 1: validation.rs 添加 overdue 转换规则** 新增函数: ```rust /// follow_up_task.status transitions(含 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"), _ => false, }; if allowed { Ok(()) } else { Err(HealthError::InvalidStatusTransition(format!( "follow_up_task.status: 不允许从 '{}' 转换到 '{}'", current, new ))) } } ``` 注意:`follow_up_service.rs` 中已有私有 `validate_follow_up_status_transition` 函数,需要替换为调用 `validation.rs` 中的公共版本。 - [ ] **Step 2: follow_up_service.rs 新增 check_overdue_tasks** ```rust /// 检测 planned_date 已过且仍为 pending 的任务,标记为 overdue pub async fn check_overdue_tasks(db: &DatabaseConnection) -> HealthResult { let today = chrono::Utc::now().date_naive(); let result = follow_up_task::Entity::update_many() .col_expr(follow_up_task::Column::Status, Expr::value("overdue".to_string())) .col_expr(follow_up_task::Column::UpdatedAt, Expr::value(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) } ``` - [ ] **Step 3: module.rs 添加公开方法** ```rust impl HealthModule { /// 启动随访逾期检查后台任务 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)); interval.tick().await; // 跳过首次 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, "随访逾期检查失败"), } } }); } } ``` - [ ] **Step 4: erp-server/main.rs 后台任务区添加调用** 在 `erp_workflow::WorkflowModule::start_timeout_checker(db.clone());` 之后添加: ```rust erp_health::HealthModule::start_overdue_checker(db.clone()); tracing::info!("Follow-up overdue checker started"); ``` - [ ] **Step 5: 验证编译** Run: `cargo check` Expected: 无错误 - [ ] **Step 6: 提交** ```bash git add crates/erp-health/ crates/erp-server/ git commit -m "feat(health): 随访逾期定时任务 — 每 6 小时自动标记 overdue" ``` --- ### Task 8: article 管理 CRUD 补充 **Files:** - Modify: `crates/erp-health/src/dto/article_dto.rs` - Modify: `crates/erp-health/src/service/article_service.rs` - Modify: `crates/erp-health/src/handler/article_handler.rs` - Modify: `crates/erp-health/src/module.rs` (添加路由) - [ ] **Step 1: article_dto.rs 新增请求 DTO + sanitize** 新增 CreateArticleReq 和 UpdateArticleReq(含 sanitize): ```rust 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()); } } 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()); } } ``` ```rust #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct CreateArticleReq { pub title: String, pub summary: Option, pub content: Option, pub cover_image: Option, pub category: Option, pub author: Option, pub published_at: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UpdateArticleReq { pub title: Option, pub summary: Option, pub content: Option, pub cover_image: Option, pub category: Option, pub author: Option, pub published_at: Option>, pub version: i32, } ``` - [ ] **Step 2: article_service.rs 补充 create/update/delete** ```rust pub async fn create_article(state: &HealthState, tenant_id: Uuid, operator_id: Option, req: CreateArticleReq) -> HealthResult { ... } pub async fn update_article(state: &HealthState, tenant_id: Uuid, id: Uuid, operator_id: Option, req: UpdateArticleReq) -> HealthResult { ... } pub async fn delete_article(state: &HealthState, tenant_id: Uuid, id: Uuid, operator_id: Option) -> HealthResult<()> { ... } ``` - [ ] **Step 3: article_handler.rs 补充 handler 方法** - [ ] **Step 4: module.rs 修改现有路由(合并到现有 GET 路由上)** 修改 `module.rs` 第 208-214 行的现有文章路由,将 POST/PUT/DELETE 合并: ```rust // 修改前(第 208-214 行): .route("/health/articles", axum::routing::get(article_handler::list_articles)) .route("/health/articles/{id}", axum::routing::get(article_handler::get_article)) // 修改后: .route( "/health/articles", axum::routing::get(article_handler::list_articles) .post(article_handler::create_article), ) .route( "/health/articles/{id}", axum::routing::get(article_handler::get_article) .put(article_handler::update_article) .delete(article_handler::delete_article), ) ``` - [ ] **Step 5: 验证编译** Run: `cargo check` Expected: 无错误 - [ ] **Step 6: 提交** ```bash git add crates/erp-health/ git commit -m "feat(health): 文章管理 CRUD 补充 create/update/delete" ``` --- ## Chunk 2 里程碑验收 - [ ] `cargo check` 全 workspace 编译通过 - [ ] 事件处理器 `workflow.task.completed` 能更新随访任务状态 - [ ] 排班名额不能反向修改到小于已预约数 - [ ] 取消预约名额释放失败时整个事务回滚 - [ ] 咨询消息 INSERT + session CAS 在同一事务中 - [ ] 随访逾期任务每 6 小时自动检测 - [ ] 文章 create/update/delete API 可用 --- ## Chunk 3: Web 前端基础设施(阶段 3 Phase 1,1.5 天) ### Task 9: API 服务层 — 6 个 service 文件 **Files:** - Create: `apps/web/src/api/health/patients.ts` - Create: `apps/web/src/api/health/healthData.ts` - Create: `apps/web/src/api/health/appointments.ts` - Create: `apps/web/src/api/health/followUp.ts` - Create: `apps/web/src/api/health/consultations.ts` - Create: `apps/web/src/api/health/doctors.ts` **参考模式:** `apps/web/src/api/users.ts` — `import client from '../client'`,`const { data } = await client.get<...>(...)` 解构模式,返回 `data.data`。 - [ ] **Step 1: 创建 patients.ts(12 端点)** ```typescript import client from '../client'; import type { PaginatedResponse } from '../types'; // --- Types --- export interface PatientListItem { id: string; name: string; gender?: string; birth_date?: string; blood_type?: string; status: string; verification_status: string; source?: string; created_at: string; updated_at: string; version: number; } export interface PatientDetail { id: string; name: string; gender?: string; birth_date?: string; blood_type?: string; id_number?: string; allergy_history?: string; medical_history_summary?: string; emergency_contact_name?: string; emergency_contact_phone?: string; status: string; verification_status: string; source?: string; notes?: string; created_at: string; updated_at: string; version: number; } export interface CreatePatientReq { name: string; gender?: string; birth_date?: string; blood_type?: string; id_number?: string; allergy_history?: string; medical_history_summary?: string; emergency_contact_name?: string; emergency_contact_phone?: string; source?: string; notes?: string; } export interface UpdatePatientReq extends Partial { status?: string; verification_status?: string; version: number; } export const patientApi = { list: async (params: { page?: number; page_size?: number; search?: string; status?: string; tag_id?: string }) => { const { data } = await client.get<{ success: boolean; data: PaginatedResponse }>('/health/patients', { params }); return data.data; }, get: async (id: string) => { const { data } = await client.get<{ success: boolean; data: PatientDetail }>(`/health/patients/${id}`); return data.data; }, create: async (req: CreatePatientReq) => { const { data } = await client.post<{ success: boolean; data: PatientDetail }>('/health/patients', req); return data.data; }, update: async (id: string, req: UpdatePatientReq) => { const { data } = await client.put<{ success: boolean; data: PatientDetail }>(`/health/patients/${id}`, req); return data.data; }, delete: async (id: string, version: number) => { await client.delete(`/health/patients/${id}`, { data: { version } }); }, manageTags: async (id: string, tagIds: string[]) => { await client.post(`/health/patients/${id}/tags`, { tag_ids: tagIds }); }, getHealthSummary: async (id: string) => { const { data } = await client.get<{ success: boolean; data: Record }>(`/health/patients/${id}/health-summary`); return data.data; }, listFamilyMembers: async (id: string) => { const { data } = await client.get<{ success: boolean; data: unknown[] }>(`/health/patients/${id}/family-members`); return data.data; }, createFamilyMember: async (id: string, req: unknown) => { const { data } = await client.post<{ success: boolean; data: unknown }>(`/health/patients/${id}/family-members`, req); return data.data; }, assignDoctor: async (id: string, doctorId: string, relationshipType: string) => { await client.post(`/health/patients/${id}/doctors`, { doctor_id: doctorId, relationship_type: relationshipType }); }, removeDoctor: async (id: string, doctorId: string) => { await client.delete(`/health/patients/${id}/doctors/${doctorId}`); }, }; ``` - [ ] **Step 2: 创建 healthData.ts(13 端点)** 覆盖:vital_signs CRUD (list/create/update/delete)、lab_reports CRUD、health_records CRUD、trends(list/generate/indicator timeseries)、mini trend、mini today。 - [ ] **Step 3: 创建 appointments.ts(6 端点)** 覆盖:list/get/create/updateStatus、schedules (list/create/update)、calendar。 - [ ] **Step 4: 创建 followUp.ts(6 端点)** 覆盖:tasks (list/get/create/update/delete)、records (list/create)。 - [ ] **Step 5: 创建 consultations.ts(6 端点)** 覆盖:sessions (list/create/close)、messages (list/create)、export。 - [ ] **Step 6: 创建 doctors.ts(4 端点)** 覆盖:list/get/create/update/delete。 - [ ] **Step 7: 验证编译** Run: `cd apps/web && pnpm build` Expected: 无类型错误 - [ ] **Step 8: 提交** ```bash git add apps/web/src/api/health/ git commit -m "feat(web): 健康模块 API 服务层 6 文件 47 端点" ``` --- ### Task 10: 通用组件 — 8 个 **Files:** - Create: `apps/web/src/pages/health/components/StatusTag.tsx` - Create: `apps/web/src/pages/health/components/PatientSelect.tsx` - Create: `apps/web/src/pages/health/components/DoctorSelect.tsx` - Create: `apps/web/src/pages/health/components/VitalSignsChart.tsx` - Create: `apps/web/src/pages/health/components/CalendarView.tsx` - Create: `apps/web/src/pages/health/components/ChatBubble.tsx` - Create: `apps/web/src/pages/health/components/ImagePreview.tsx` - Create: `apps/web/src/pages/health/components/ExportButton.tsx` - [ ] **Step 1: StatusTag — 通用状态标签** ```tsx import { Tag } from 'antd'; const STATUS_CONFIG: Record = { // 预约状态 pending: { color: 'gold', label: '待确认' }, confirmed: { color: 'blue', label: '已确认' }, completed: { color: 'green', label: '已完成' }, cancelled: { color: 'default', label: '已取消' }, no_show: { color: 'red', label: '未到诊' }, // 随访状态 overdue: { color: 'red', label: '逾期' }, in_progress: { color: 'processing', label: '进行中' }, // 咨询状态 waiting: { color: 'gold', label: '等待中' }, active: { color: 'green', label: '进行中' }, closed: { color: 'default', label: '已关闭' }, // 患者状态 active: { color: 'green', label: '活跃' }, inactive: { color: 'default', label: '停用' }, deceased: { color: 'default', label: '已故' }, verified: { color: 'green', label: '已认证' }, }; interface Props { status: string; type?: 'appointment' | 'follow_up' | 'consultation' | 'patient'; } export function StatusTag({ status }: Props) { const cfg = STATUS_CONFIG[status] || { color: 'default', label: status }; return {cfg.label}; } ``` - [ ] **Step 2: PatientSelect — 患者远程搜索选择器** 基于 Ant Design `Select` + `Debounce`,调用 `patientApi.list({ search })`。 - [ ] **Step 3: DoctorSelect — 医护选择器** 同 PatientSelect 模式,调用 `doctorApi.list({ search })`。 - [ ] **Step 4: VitalSignsChart — ECharts 趋势图** 使用 `@ant-design/charts` 的 `Line` 组件,接收 `{ patientId: string; indicator: string; range?: string }`,调用 `healthDataApi.getIndicatorTimeseries`。 - [ ] **Step 5: CalendarView — 日历视图** Ant Design `Calendar` + 自定义 `cellRender`,接收 schedules 数据。 - [ ] **Step 6: ChatBubble — 聊天气泡** 基于 Ant Design `Typography.Paragraph` + `Avatar`,根据 `sender_role` 区分左右。**注意:使用 React 默认 JSX 转义,禁止 `dangerouslySetInnerHTML`**。 - [ ] **Step 7: ImagePreview — 图片预览** Ant Design `Image.PreviewGroup` 封装。 - [ ] **Step 8: ExportButton — 导出按钮** 封装 blob 下载逻辑,参照 `PluginCRUDPage` 中的 `exportPluginDataAsBlob` 模式。 - [ ] **Step 9: 验证编译** Run: `cd apps/web && pnpm build` Expected: 无类型错误 - [ ] **Step 10: 提交** ```bash git add apps/web/src/pages/health/components/ git commit -m "feat(web): 健康模块通用组件 8 个" ``` --- ### Task 11: 路由和菜单集成 **Files:** - Modify: `apps/web/src/App.tsx` - Modify: `apps/web/src/layouts/MainLayout.tsx` - [ ] **Step 1: App.tsx 添加 10 条 lazy 路由** ```typescript // 在现有 lazy imports 区添加 const PatientList = lazy(() => import('./pages/health/PatientList')); const PatientDetail = lazy(() => import('./pages/health/PatientDetail')); const PatientTagManage = lazy(() => import('./pages/health/PatientTagManage')); const DoctorList = lazy(() => import('./pages/health/DoctorList')); const AppointmentList = lazy(() => import('./pages/health/AppointmentList')); const DoctorSchedule = lazy(() => import('./pages/health/DoctorSchedule')); const FollowUpTaskList = lazy(() => import('./pages/health/FollowUpTaskList')); const FollowUpRecordList = lazy(() => import('./pages/health/FollowUpRecordList')); const ConsultationList = lazy(() => import('./pages/health/ConsultationList')); const ConsultationDetail = lazy(() => import('./pages/health/ConsultationDetail')); // 在嵌套 Routes 中添加 } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> ``` - [ ] **Step 2: MainLayout.tsx 添加健康模块菜单组** ```typescript import { MedicineBoxOutlined, TeamOutlined, HeartOutlined, CalendarOutlined, PhoneOutlined, CommentOutlined, TagsOutlined } from '@ant-design/icons'; const healthMenuItems: MenuItem[] = [ { key: '/health/patients', icon: , label: '患者管理' }, { key: '/health/doctors', icon: , label: '医护管理' }, { key: '/health/appointments', icon: , label: '预约排班' }, { key: '/health/follow-up-tasks', icon: , label: '随访管理' }, { key: '/health/consultations', icon: , label: '咨询管理' }, { key: '/health/tags', icon: , label: '标签管理' }, ]; // 侧边栏布局中,在 bizMenuItems 和 sysMenuItems 之间插入:
健康管理
{healthMenuItems.map(item => )} ``` 同时更新 `routeTitleMap`: ```typescript '/health/patients': '患者管理', '/health/patients/:id': '患者详情', '/health/tags': '标签管理', // ... 其余 7 条 ``` - [ ] **Step 3: 验证编译** Run: `cd apps/web && pnpm build` Expected: 无类型错误(页面组件尚未创建,此时使用占位文件) - [ ] **Step 4: 创建 10 个页面占位文件** 每个页面文件只包含最小占位组件,如: ```tsx export default function PatientList() { return
患者管理页面(开发中)
; } ``` 创建以下 10 个文件: - `apps/web/src/pages/health/PatientList.tsx` - `apps/web/src/pages/health/PatientDetail.tsx` - `apps/web/src/pages/health/PatientTagManage.tsx` - `apps/web/src/pages/health/DoctorList.tsx` - `apps/web/src/pages/health/AppointmentList.tsx` - `apps/web/src/pages/health/DoctorSchedule.tsx` - `apps/web/src/pages/health/FollowUpTaskList.tsx` - `apps/web/src/pages/health/FollowUpRecordList.tsx` - `apps/web/src/pages/health/ConsultationList.tsx` - `apps/web/src/pages/health/ConsultationDetail.tsx` - [ ] **Step 5: 验证前端构建 + 浏览器可访问** Run: `cd apps/web && pnpm dev` 验证:浏览器访问 `/#/health/patients` 显示占位页面。 - [ ] **Step 6: 提交** ```bash git add apps/web/src/ git commit -m "feat(web): 健康模块路由菜单 + 10 页面占位" ``` --- ## Chunk 4: Web 前端 10 页面实现(阶段 3 Phase 2-6,13.5 天) ### Task 12: PatientList + PatientTagManage + PatientDetail(Phase 2,4 天) **Files:** - Replace: `apps/web/src/pages/health/PatientList.tsx` - Replace: `apps/web/src/pages/health/PatientDetail.tsx` - Replace: `apps/web/src/pages/health/PatientTagManage.tsx` **参考模式:** `apps/web/src/pages/Users.tsx` — useState + useCallback fetch + Ant Design Table + Modal。 - [ ] **Step 1: PatientList.tsx(1.5 天)** 功能: - Ant Design `Table`(不使用 ProTable,项目未安装) - 搜索:姓名模糊 + 状态 `Select` 筛选 + 标签多选筛选 - 每行标签显示为 `Tag` 组件列表 - 行点击 → `useNavigate` 跳转 `/health/patients/:id` - 创建患者 `Modal`(Ant Design `Form`) - 编辑患者 `Modal`(复用创建表单) - 批量打标按钮 - [ ] **Step 2: PatientTagManage.tsx(0.5 天)** 功能: - 标准 CRUD 表格(标签名 + 颜色 + 描述) - Ant Design `ColorPicker` 选择标签颜色 - 批量打标功能(`Transfer` 或 `Select` 多选患者) - [ ] **Step 3: PatientDetail.tsx(2 天)** 功能: - 顶部:患者摘要卡片(`Descriptions` 展示姓名/性别/年龄/状态/标签) - Ant Design `Tabs` 5 个 Tab: 1. **基本信息** — `Descriptions` 展示 + 编辑 `Modal` 2. **健康趋势** — `VitalSignsChart` 组件 + 时间范围 `Radio.Group`(7d/30d/90d)+ 指标 `Select` 3. **化验报告** — 报告卡片列表 + `ImagePreview` 4. **就诊记录** — 嵌套列表(record_type 分组) 5. **随访记录** — 嵌套列表 + 关联的随访记录 - [ ] **Step 4: 验证编译 + 浏览器测试** Run: `cd apps/web && pnpm build` 浏览器:患者列表 → 点击进入详情 → 各 Tab 切换正常。 - [ ] **Step 5: 提交** ```bash git add apps/web/src/pages/health/ git commit -m "feat(web): PatientList + PatientTagManage + PatientDetail 页面" ``` --- ### Task 13: DoctorList + AppointmentList + DoctorSchedule(Phase 4,6 天) - [ ] **Step 1: DoctorList.tsx(0.5 天)** 功能: - 标准 CRUD 表格 - 科室筛选 + 在线状态 Badge - 详情 `Drawer` - [ ] **Step 2: AppointmentList.tsx(2 天)** 功能: - Ant Design `Segmented` 切换列表/日历视图 - 列表模式:Table + 状态筛选 + 日期筛选 - 日历模式:`Calendar` + `cellRender` 显示当日预约数 - 状态流转 `Dropdown`(pending→confirmed→completed/no_show/cancelled) - 创建预约 `Modal`(选择患者 `PatientSelect` + 医生 `DoctorSelect` + 日期时段) - [ ] **Step 3: DoctorSchedule.tsx(2.5 天)** 功能: - 选择医生后展示排班(`DoctorSelect` 切换) - 周视图(自定义 7 列网格,每列显示排班时段) - 月视图(Ant Design `Calendar`) - 批量创建排班(日期范围 + 时段模板) - 显示已预约/最大预约数 - [ ] **Step 4: 验证编译 + 浏览器测试** - [ ] **Step 5: 提交** ```bash git add apps/web/src/pages/health/ git commit -m "feat(web): DoctorList + AppointmentList + DoctorSchedule 页面" ``` --- ### Task 14: FollowUpTaskList + FollowUpRecordList + ConsultationList + ConsultationDetail(Phase 5,6.5 天) - [ ] **Step 1: FollowUpTaskList.tsx(1.5 天)** 功能: - Table + 状态筛选(pending/in_progress/completed/overdue/cancelled) - 分配医护(`DoctorSelect`) - 创建任务 `Modal` - 快捷"填写随访记录"按钮打开子 `Modal` - [ ] **Step 2: FollowUpRecordList.tsx(0.5 天)** 功能: - 纯只读台账 - 筛选:日期范围、患者、任务、结果 - 导出功能(`ExportButton`) - [ ] **Step 3: ConsultationList.tsx(1 天)** 功能: - Table + 状态筛选(waiting/active/closed) - 未读消息数 Badge - 最后消息时间 - 关闭会话操作 - 行点击跳转 `/health/consultations/:id` - [ ] **Step 4: ConsultationDetail.tsx(2 天)** 功能: - `ChatBubble` 组件渲染聊天记录 - `sender_role` 区分左右对齐 - 支持内容类型:text / image(`ImagePreview`)/ voice / file - 消息按时间排列,滚动加载更多(分页) - 导出按钮 - [ ] **Step 5: 验证编译 + 浏览器测试** - [ ] **Step 6: 提交** ```bash git add apps/web/src/pages/health/ git commit -m "feat(web): 随访管理 + 咨询管理页面完整实现" ``` --- ### Task 15: 前端打磨(Phase 6,1 天) - [ ] **Step 1: 暗色主题适配** - [ ] **Step 2: 响应式布局检查** - [ ] **Step 3: 前端联调 — 确保所有页面与后端 API 交互正常** - [ ] **Step 4: pnpm build 生产构建通过** - [ ] **Step 5: 提交** ```bash git add apps/web/ git commit -m "feat(web): 健康模块前端打磨 — 暗色主题 + 响应式 + 联调" ``` --- ## Chunk 5: 测试策略 + 端到端验证 + 路线图(交叉进行 + 1 周) ### Task 16: validation.rs 纯函数测试(P0,1 天) **Files:** - Create: `crates/erp-health/src/service/validation.rs`(在同文件 `#[cfg(test)] mod tests` 块内) - [ ] **Step 1: 编写 20-30 个枚举校验测试** ```rust #[cfg(test)] mod tests { use super::*; #[test] fn test_gender_valid() { assert!(validate_gender("male").is_ok()); } #[test] fn test_gender_invalid() { assert!(validate_gender("unknown").is_err()); } #[test] fn test_appointment_status_pending_to_confirmed() { assert!(validate_appointment_status_transition("pending", "confirmed").is_ok()); } #[test] fn test_appointment_status_completed_to_pending() { assert!(validate_appointment_status_transition("completed", "pending").is_err()); } // ... 覆盖所有枚举和状态转换 } ``` - [ ] **Step 2: 运行测试** Run: `cargo test -p erp-health -- validation::tests` Expected: 全部通过 - [ ] **Step 3: 提交** ```bash git add crates/erp-health/src/service/validation.rs git commit -m "test(health): validation.rs 纯函数测试 20+ 用例" ``` --- ### Task 17: 核心 service 集成测试(P0,4 天) **Files:** - Create: `crates/erp-health/tests/test_helpers.rs` - Create: `crates/erp-health/tests/appointment_test.rs` - Create: `crates/erp-health/tests/patient_test.rs` **测试基础设施:** 使用 testcontainers-postgreSQL 做真实数据库测试。 - [ ] **Step 1: 创建 test_helpers.rs** 提供 `create_integration_db()` — testcontainers PostgreSQL 实例 + 自动运行迁移。 - [ ] **Step 2: appointment_test.rs — CAS 并发 + 状态流转** 关键场景: - 排班已满 → 创建预约失败 - 排班有余 → CAS 成功 + 名额减 1 - 并发创建 → 只有 max_appointments 个成功 - 状态转换合法/非法 - 取消预约 → 名额释放 - [ ] **Step 3: patient_test.rs — CRUD + 状态机** 关键场景: - 创建/更新/删除患者 - 状态转换 active→inactive→deceased - 乐观锁版本冲突 - [ ] **Step 4: 运行测试** Run: `cargo test -p erp-health` Expected: 全部通过 - [ ] **Step 5: 提交** ```bash git add crates/erp-health/tests/ git commit -m "test(health): 预约 CAS 并发 + 患者 CRUD 集成测试" ``` --- ### Task 18: 端到端验证(阶段 4,1 周) - [ ] **Step 1: 小程序联调** — 确认小程序端 API 调用与新后端兼容 - [ ] **Step 2: 种子数据填充** — 在开发环境填充演示数据(患者/排班/预约/随访/咨询) - [ ] **Step 3: Docker 演示环境** — 确认 docker-compose 能启动完整演示 - [ ] **Step 4: 文档更新** — 更新 wiki/erp-health.md 和 CLAUDE.md 中的完成状态 - [ ] **Step 5: 最终提交 + 推送** ```bash git add . git commit -m "chore: 端到端验证完成 — 健康管理模块 V1 交付" git push ``` --- ## 总体里程碑 | 里程碑 | 交付物 | 验收标准 | |--------|--------|---------| | M1 | 安全省基完成(Task 1-4) | sanitize + 审计 + 加密 + 脱敏全部到位 | | M2 | 后端功能完整(Task 5-8) | 事件处理器 + 数据一致性 + 测试通过 | | M3 | Web 基础设施(Task 9-11) | API 层 + 组件 + 路由菜单可运行 | | M4 | Web 核心页面(Task 12-13) | PatientList + AppointmentList + DoctorSchedule 可操作 | | M5 | Web 全部页面(Task 14-15) | 10 页面功能可用,pnpm build 通过 | | M6 | 端到端验证(Task 16-18) | Web + 小程序 + 后端全链路可演示 | ## 总时间线 ``` Week 1-2 | 安全省基(Task 1-4) Week 2-4 | 后端补完 + 测试(Task 5-8 + Task 16-17) Week 4-7 | Web 前端(Task 9-15) Week 7-8 | 端到端验证(Task 18) 总计 7-8 周 ```