Files
hms/docs/superpowers/plans/2026-04-24-health-module-iteration.md
iven 07f4ba41ba
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / security-audit (push) Has been cancelled
fix(health): 穷尽审计修复 — 权限同步/编译错误/前端bug/审计日志
审计发现并修复的问题:

HIGH:
- H1: ConsultationDetail 使用 getSession(id) 替代错误的列表搜索
- H2: SessionResp 添加 version/updated_at 字段
- H3: 移除 FollowUpRecordList 调用不存在的导出端点
- H4: 新增 articles.ts 前端 API 模块

MEDIUM:
- M1: article delete 添加乐观锁 (expected_version)
- M2: 取消预约排班释放传播错误 (log::warn -> ?)
- M3: FollowUpTaskList 日期格式 Dayjs -> string
- M4: 补充 15 个缺失审计日志

LOW:
- L1: 替换 follow_up_service 中的 .unwrap()
- L2: PatientListItem 添加 version 字段

CRITICAL (新发现):
- 权限未同步: 健康模块 14 个权限从未写入数据库,添加启动时自动同步
- migration 表名错误: patients -> patient
- 编译错误: health_trend entity 未导入, ToPrimitive trait 未导入
- HealthError 缺少 From<AppError> 实现
2026-04-25 08:58:58 +08:00

53 KiB
Raw Blame History

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: 安全省基(阶段 11.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) 方法:

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
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
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
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
impl CreateMessageReq {
    pub fn sanitize(&mut self) {
        self.content = sanitize_string(&self.content);
    }
}
  • Step 6: 为 doctor_dto.rs 添加 sanitize
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()。示例:

// patient_handler.rs
async fn create_patient(/* ... */) -> AppResult<Json<ApiResponse<PatientResp>>> {
    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: 提交
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 方法中添加审计调用。每个方法在业务操作成功后、返回结果前插入:

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 中添加变更前后值摘要:

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: 提交
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 依赖

# 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
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<Sha256>;

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".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<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());
        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<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::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
// 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 和注册事件处理器:

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.rsFromRef<AppState> 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

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 中标记加密密钥
[health]
encryption_key = "__MUST_SET_VIA_ENV__"
hmac_key = "__MUST_SET_VIA_ENV__"
  • Step 8: 提交
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掩码

// patient_dto.rs 新增

/// 列表用 — 不含敏感字段
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct PatientListResp {
    pub id: Uuid,
    pub name: String,
    pub gender: Option<String>,
    pub birth_date: Option<NaiveDate>,
    pub blood_type: Option<String>,
    pub status: String,
    pub verification_status: String,
    pub source: Option<String>,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
    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<PatientResp> 改为 PaginatedResponse<PatientListResp>,转换时只包含安全字段。

  • Step 3: 修改 patient_service — get 返回脱敏后的 PatientDetailResp

get_patient 返回 PatientResp,但在 handler 层对 id_numberemergency_contact_phone 做掩码处理。

  • Step 4: 验证编译

Run: cargo check Expected: 无错误

  • Step 5: 提交
git add crates/erp-health/src/
git commit -m "feat(health): 患者列表脱敏 — 拆分 PatientListResp/PatientDetailResp"

Chunk 1 里程碑验收

  • cargo check 全 workspace 编译通过
  • 所有 DTO 的字符串输入字段都经过 sanitize
  • 所有写入操作15+ 方法)均有审计日志
  • 身份证号加密存储 + HMAC 索引可用
  • 列表接口不返回身份证号,详情接口掩码显示

Chunk 2: 后端补完(阶段 21.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
// 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 注册
// 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
/// 供事件处理器调用的系统级状态更新(跳过 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: 提交
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.rsupdate_schedule 方法中,let next_ver = ... 之后添加:

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 = ... ?;,让错误传播导致事务回滚:

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 分别独立执行。修改为事务包裹:

pub async fn create_message(/* ... */) -> HealthResult<MessageResp> {
    // ... 校验会话(同现有代码)...

    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: 提交
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 转换规则

新增函数:

/// 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
/// 检测 planned_date 已过且仍为 pending 的任务,标记为 overdue
pub async fn check_overdue_tasks(db: &DatabaseConnection) -> HealthResult<u64> {
    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 添加公开方法
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()); 之后添加:

erp_health::HealthModule::start_overdue_checker(db.clone());
tracing::info!("Follow-up overdue checker started");
  • Step 5: 验证编译

Run: cargo check Expected: 无错误

  • Step 6: 提交
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

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());
    }
}
#[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>>,
}

#[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,
}
  • Step 2: article_service.rs 补充 create/update/delete
pub async fn create_article(state: &HealthState, tenant_id: Uuid, operator_id: Option<Uuid>, req: CreateArticleReq) -> HealthResult<ArticleResp> { ... }
pub async fn update_article(state: &HealthState, tenant_id: Uuid, id: Uuid, operator_id: Option<Uuid>, req: UpdateArticleReq) -> HealthResult<ArticleResp> { ... }
pub async fn delete_article(state: &HealthState, tenant_id: Uuid, id: Uuid, operator_id: Option<Uuid>) -> HealthResult<()> { ... }
  • Step 3: article_handler.rs 补充 handler 方法

  • Step 4: module.rs 修改现有路由(合并到现有 GET 路由上)

修改 module.rs 第 208-214 行的现有文章路由,将 POST/PUT/DELETE 合并:

// 修改前(第 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: 提交
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 11.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.tsimport client from '../client'const { data } = await client.get<...>(...) 解构模式,返回 data.data

  • Step 1: 创建 patients.ts12 端点)
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<CreatePatientReq> {
  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<PatientListItem> }>('/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<string, unknown> }>(`/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.ts13 端点)

覆盖vital_signs CRUD (list/create/update/delete)、lab_reports CRUD、health_records CRUD、trendslist/generate/indicator timeseries、mini trend、mini today。

  • Step 3: 创建 appointments.ts6 端点)

覆盖list/get/create/updateStatus、schedules (list/create/update)、calendar。

  • Step 4: 创建 followUp.ts6 端点)

覆盖tasks (list/get/create/update/delete)、records (list/create)。

  • Step 5: 创建 consultations.ts6 端点)

覆盖sessions (list/create/close)、messages (list/create)、export。

  • Step 6: 创建 doctors.ts4 端点)

覆盖list/get/create/update/delete。

  • Step 7: 验证编译

Run: cd apps/web && pnpm build Expected: 无类型错误

  • Step 8: 提交
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 — 通用状态标签

import { Tag } from 'antd';

const STATUS_CONFIG: Record<string, { color: string; label: string }> = {
  // 预约状态
  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 <Tag color={cfg.color}>{cfg.label}</Tag>;
}
  • Step 2: PatientSelect — 患者远程搜索选择器

基于 Ant Design Select + Debounce,调用 patientApi.list({ search })

  • Step 3: DoctorSelect — 医护选择器

同 PatientSelect 模式,调用 doctorApi.list({ search })

  • Step 4: VitalSignsChart — ECharts 趋势图

使用 @ant-design/chartsLine 组件,接收 { 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: 提交
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 路由

// 在现有 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 中添加
<Route path="/health/patients" element={<PatientList />} />
<Route path="/health/patients/:id" element={<PatientDetail />} />
<Route path="/health/tags" element={<PatientTagManage />} />
<Route path="/health/doctors" element={<DoctorList />} />
<Route path="/health/appointments" element={<AppointmentList />} />
<Route path="/health/schedules" element={<DoctorSchedule />} />
<Route path="/health/follow-up-tasks" element={<FollowUpTaskList />} />
<Route path="/health/follow-up-records" element={<FollowUpRecordList />} />
<Route path="/health/consultations" element={<ConsultationList />} />
<Route path="/health/consultations/:id" element={<ConsultationDetail />} />
  • Step 2: MainLayout.tsx 添加健康模块菜单组
import { MedicineBoxOutlined, TeamOutlined, HeartOutlined, CalendarOutlined, PhoneOutlined, CommentOutlined, TagsOutlined } from '@ant-design/icons';

const healthMenuItems: MenuItem[] = [
  { key: '/health/patients', icon: <TeamOutlined />, label: '患者管理' },
  { key: '/health/doctors', icon: <HeartOutlined />, label: '医护管理' },
  { key: '/health/appointments', icon: <CalendarOutlined />, label: '预约排班' },
  { key: '/health/follow-up-tasks', icon: <PhoneOutlined />, label: '随访管理' },
  { key: '/health/consultations', icon: <CommentOutlined />, label: '咨询管理' },
  { key: '/health/tags', icon: <TagsOutlined />, label: '标签管理' },
];

// 侧边栏布局中,在 bizMenuItems 和 sysMenuItems 之间插入:
<div className="erp-sidebar-group">健康管理</div>
{healthMenuItems.map(item => <SidebarMenuItem key={item.key} item={item} />)}

同时更新 routeTitleMap

'/health/patients': '患者管理',
'/health/patients/:id': '患者详情',
'/health/tags': '标签管理',
// ... 其余 7 条
  • Step 3: 验证编译

Run: cd apps/web && pnpm build Expected: 无类型错误(页面组件尚未创建,此时使用占位文件)

  • Step 4: 创建 10 个页面占位文件

每个页面文件只包含最小占位组件,如:

export default function PatientList() {
  return <div>患者管理页面(开发中)</div>;
}

创建以下 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: 提交
git add apps/web/src/
git commit -m "feat(web): 健康模块路由菜单 + 10 页面占位"

Chunk 4: Web 前端 10 页面实现(阶段 3 Phase 2-613.5 天)

Task 12: PatientList + PatientTagManage + PatientDetailPhase 24 天)

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.tsx1.5 天)

功能:

  • Ant Design Table(不使用 ProTable项目未安装

  • 搜索:姓名模糊 + 状态 Select 筛选 + 标签多选筛选

  • 每行标签显示为 Tag 组件列表

  • 行点击 → useNavigate 跳转 /health/patients/:id

  • 创建患者 ModalAnt Design Form

  • 编辑患者 Modal(复用创建表单)

  • 批量打标按钮

  • Step 2: PatientTagManage.tsx0.5 天)

功能:

  • 标准 CRUD 表格(标签名 + 颜色 + 描述)

  • Ant Design ColorPicker 选择标签颜色

  • 批量打标功能(TransferSelect 多选患者)

  • Step 3: PatientDetail.tsx2 天)

功能:

  • 顶部:患者摘要卡片(Descriptions 展示姓名/性别/年龄/状态/标签)

  • Ant Design Tabs 5 个 Tab

    1. 基本信息Descriptions 展示 + 编辑 Modal
    2. 健康趋势VitalSignsChart 组件 + 时间范围 Radio.Group7d/30d/90d+ 指标 Select
    3. 化验报告 — 报告卡片列表 + ImagePreview
    4. 就诊记录 — 嵌套列表record_type 分组)
    5. 随访记录 — 嵌套列表 + 关联的随访记录
  • Step 4: 验证编译 + 浏览器测试

Run: cd apps/web && pnpm build 浏览器:患者列表 → 点击进入详情 → 各 Tab 切换正常。

  • Step 5: 提交
git add apps/web/src/pages/health/
git commit -m "feat(web): PatientList + PatientTagManage + PatientDetail 页面"

Task 13: DoctorList + AppointmentList + DoctorSchedulePhase 46 天)

  • Step 1: DoctorList.tsx0.5 天)

功能:

  • 标准 CRUD 表格

  • 科室筛选 + 在线状态 Badge

  • 详情 Drawer

  • Step 2: AppointmentList.tsx2 天)

功能:

  • Ant Design Segmented 切换列表/日历视图

  • 列表模式Table + 状态筛选 + 日期筛选

  • 日历模式:Calendar + cellRender 显示当日预约数

  • 状态流转 Dropdownpending→confirmed→completed/no_show/cancelled

  • 创建预约 Modal(选择患者 PatientSelect + 医生 DoctorSelect + 日期时段)

  • Step 3: DoctorSchedule.tsx2.5 天)

功能:

  • 选择医生后展示排班(DoctorSelect 切换)

  • 周视图(自定义 7 列网格,每列显示排班时段)

  • 月视图Ant Design Calendar

  • 批量创建排班(日期范围 + 时段模板)

  • 显示已预约/最大预约数

  • Step 4: 验证编译 + 浏览器测试

  • Step 5: 提交

git add apps/web/src/pages/health/
git commit -m "feat(web): DoctorList + AppointmentList + DoctorSchedule 页面"

Task 14: FollowUpTaskList + FollowUpRecordList + ConsultationList + ConsultationDetailPhase 56.5 天)

  • Step 1: FollowUpTaskList.tsx1.5 天)

功能:

  • Table + 状态筛选pending/in_progress/completed/overdue/cancelled

  • 分配医护(DoctorSelect

  • 创建任务 Modal

  • 快捷"填写随访记录"按钮打开子 Modal

  • Step 2: FollowUpRecordList.tsx0.5 天)

功能:

  • 纯只读台账

  • 筛选:日期范围、患者、任务、结果

  • 导出功能(ExportButton

  • Step 3: ConsultationList.tsx1 天)

功能:

  • Table + 状态筛选waiting/active/closed

  • 未读消息数 Badge

  • 最后消息时间

  • 关闭会话操作

  • 行点击跳转 /health/consultations/:id

  • Step 4: ConsultationDetail.tsx2 天)

功能:

  • ChatBubble 组件渲染聊天记录

  • sender_role 区分左右对齐

  • 支持内容类型text / imageImagePreview/ voice / file

  • 消息按时间排列,滚动加载更多(分页)

  • 导出按钮

  • Step 5: 验证编译 + 浏览器测试

  • Step 6: 提交

git add apps/web/src/pages/health/
git commit -m "feat(web): 随访管理 + 咨询管理页面完整实现"

Task 15: 前端打磨Phase 61 天)

  • Step 1: 暗色主题适配
  • Step 2: 响应式布局检查
  • Step 3: 前端联调 — 确保所有页面与后端 API 交互正常
  • Step 4: pnpm build 生产构建通过
  • Step 5: 提交
git add apps/web/
git commit -m "feat(web): 健康模块前端打磨 — 暗色主题 + 响应式 + 联调"

Chunk 5: 测试策略 + 端到端验证 + 路线图(交叉进行 + 1 周)

Task 16: validation.rs 纯函数测试P01 天)

Files:

  • Create: crates/erp-health/src/service/validation.rs(在同文件 #[cfg(test)] mod tests 块内)

  • Step 1: 编写 20-30 个枚举校验测试

#[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: 提交
git add crates/erp-health/src/service/validation.rs
git commit -m "test(health): validation.rs 纯函数测试 20+ 用例"

Task 17: 核心 service 集成测试P04 天)

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: 提交
git add crates/erp-health/tests/
git commit -m "test(health): 预约 CAS 并发 + 患者 CRUD 集成测试"

Task 18: 端到端验证(阶段 41 周)

  • Step 1: 小程序联调 — 确认小程序端 API 调用与新后端兼容
  • Step 2: 种子数据填充 — 在开发环境填充演示数据(患者/排班/预约/随访/咨询)
  • Step 3: Docker 演示环境 — 确认 docker-compose 能启动完整演示
  • Step 4: 文档更新 — 更新 wiki/erp-health.md 和 CLAUDE.md 中的完成状态
  • Step 5: 最终提交 + 推送
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 周