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

1627 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)` 方法:
```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<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: 提交**
```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<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**
```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<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`
```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<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_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: 后端补完(阶段 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**
```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<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: 提交**
```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<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 添加公开方法**
```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<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**
```rust
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 合并:
```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 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.ts``import client from '../client'``const { data } = await client.get<...>(...)` 解构模式,返回 `data.data`
- [ ] **Step 1: 创建 patients.ts12 端点)**
```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<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: 提交**
```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<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/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 中添加
<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 添加健康模块菜单组**
```typescript
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`
```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 <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: 提交**
```bash
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`
- 创建患者 `Modal`Ant Design `Form`
- 编辑患者 `Modal`(复用创建表单)
- 批量打标按钮
- [ ] **Step 2: PatientTagManage.tsx0.5 天)**
功能:
- 标准 CRUD 表格(标签名 + 颜色 + 描述)
- Ant Design `ColorPicker` 选择标签颜色
- 批量打标功能(`Transfer``Select` 多选患者)
- [ ] **Step 3: PatientDetail.tsx2 天)**
功能:
- 顶部:患者摘要卡片(`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 + DoctorSchedulePhase 46 天)
- [ ] **Step 1: DoctorList.tsx0.5 天)**
功能:
- 标准 CRUD 表格
- 科室筛选 + 在线状态 Badge
- 详情 `Drawer`
- [ ] **Step 2: AppointmentList.tsx2 天)**
功能:
- Ant Design `Segmented` 切换列表/日历视图
- 列表模式Table + 状态筛选 + 日期筛选
- 日历模式:`Calendar` + `cellRender` 显示当日预约数
- 状态流转 `Dropdown`pending→confirmed→completed/no_show/cancelled
- 创建预约 `Modal`(选择患者 `PatientSelect` + 医生 `DoctorSelect` + 日期时段)
- [ ] **Step 3: DoctorSchedule.tsx2.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 + 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 / image`ImagePreview`/ voice / file
- 消息按时间排列,滚动加载更多(分页)
- 导出按钮
- [ ] **Step 5: 验证编译 + 浏览器测试**
- [ ] **Step 6: 提交**
```bash
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: 提交**
```bash
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 个枚举校验测试**
```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 集成测试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: 提交**
```bash
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: 最终提交 + 推送**
```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 周
```