审计发现并修复的问题: 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> 实现
1627 lines
53 KiB
Markdown
1627 lines
53 KiB
Markdown
# HMS 健康管理模块全面迭代 — 实施计划
|
||
|
||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 修复 4 个 V1 阻塞项,完成 erp-health 后端补完和 Web 前端 10 页面开发。
|
||
|
||
**Architecture:** 安全地基先行(sanitize/审计/加密/脱敏)→ 后端补完(事件处理器/数据一致性/逾期检测/测试)→ Web 前端(API 层 + 10 页面)→ 端到端验证。严格按阶段顺序,每阶段独立可验证。
|
||
|
||
**Tech Stack:** Rust/Axum/SeaORM (后端) · React 19/Ant Design 6/Zustand 5 (前端) · PostgreSQL 18 · Taro 4 (小程序)
|
||
|
||
**设计文档:** `docs/superpowers/specs/2026-04-24-health-module-iteration-design.md`
|
||
|
||
---
|
||
|
||
## Chunk 1: 安全省基(阶段 1,1.5-2 周)
|
||
|
||
### Task 1: DTO sanitize 全覆盖
|
||
|
||
**Files:**
|
||
- Modify: `crates/erp-health/src/dto/patient_dto.rs`
|
||
- Modify: `crates/erp-health/src/dto/health_data_dto.rs`
|
||
- Modify: `crates/erp-health/src/dto/appointment_dto.rs`
|
||
- Modify: `crates/erp-health/src/dto/follow_up_dto.rs`
|
||
- Modify: `crates/erp-health/src/dto/consultation_dto.rs`
|
||
- Modify: `crates/erp-health/src/dto/doctor_dto.rs`
|
||
- Modify: `crates/erp-health/src/handler/patient_handler.rs` (调用 sanitize)
|
||
- Modify: `crates/erp-health/src/handler/health_data_handler.rs`
|
||
- Modify: `crates/erp-health/src/handler/appointment_handler.rs`
|
||
- Modify: `crates/erp-health/src/handler/follow_up_handler.rs`
|
||
- Modify: `crates/erp-health/src/handler/consultation_handler.rs`
|
||
- Modify: `crates/erp-health/src/handler/doctor_handler.rs`
|
||
|
||
**参考模式:** `crates/erp-auth/src/dto.rs` 第 94-119 行的 sanitize 实现。
|
||
**可用函数:** `erp_core::sanitize::{strip_html_tags, sanitize_option, sanitize_string}`
|
||
|
||
- [ ] **Step 1: 为 patient_dto.rs 添加 sanitize**
|
||
|
||
在每个请求 DTO 的 impl 块中添加 `sanitize(&mut self)` 方法:
|
||
|
||
```rust
|
||
use erp_core::sanitize::{strip_html_tags, sanitize_option, sanitize_string};
|
||
|
||
impl CreatePatientReq {
|
||
pub fn sanitize(&mut self) {
|
||
self.name = sanitize_string(&self.name);
|
||
self.notes = sanitize_option(self.notes.take());
|
||
self.allergy_history = sanitize_option(self.allergy_history.take());
|
||
self.medical_history_summary = sanitize_option(self.medical_history_summary.take());
|
||
self.emergency_contact_name = sanitize_option(self.emergency_contact_name.take());
|
||
self.source = sanitize_option(self.source.take());
|
||
}
|
||
}
|
||
|
||
impl UpdatePatientReq {
|
||
pub fn sanitize(&mut self) {
|
||
if let Some(ref mut v) = self.name { *v = strip_html_tags(v); }
|
||
self.id_number = sanitize_option(self.id_number.take());
|
||
self.notes = sanitize_option(self.notes.take());
|
||
self.allergy_history = sanitize_option(self.allergy_history.take());
|
||
self.medical_history_summary = sanitize_option(self.medical_history_summary.take());
|
||
self.emergency_contact_name = sanitize_option(self.emergency_contact_name.take());
|
||
self.emergency_contact_phone = sanitize_option(self.emergency_contact_phone.take());
|
||
self.source = sanitize_option(self.source.take());
|
||
}
|
||
}
|
||
|
||
impl FamilyMemberReq {
|
||
pub fn sanitize(&mut self) {
|
||
self.name = sanitize_string(&self.name);
|
||
self.notes = sanitize_option(self.notes.take());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 为 health_data_dto.rs 添加 sanitize**
|
||
|
||
```rust
|
||
impl CreateVitalSignsReq {
|
||
pub fn sanitize(&mut self) {
|
||
self.notes = sanitize_option(self.notes.take());
|
||
}
|
||
}
|
||
|
||
impl CreateLabReportReq {
|
||
pub fn sanitize(&mut self) {
|
||
self.doctor_interpretation = sanitize_option(self.doctor_interpretation.take());
|
||
}
|
||
}
|
||
|
||
impl CreateHealthRecordReq {
|
||
pub fn sanitize(&mut self) {
|
||
self.source = sanitize_option(self.source.take());
|
||
self.overall_assessment = sanitize_option(self.overall_assessment.take());
|
||
self.notes = sanitize_option(self.notes.take());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 为 appointment_dto.rs 添加 sanitize**
|
||
|
||
```rust
|
||
impl CreateAppointmentReq {
|
||
pub fn sanitize(&mut self) {
|
||
self.notes = sanitize_option(self.notes.take());
|
||
}
|
||
}
|
||
|
||
impl UpdateAppointmentStatusReq {
|
||
pub fn sanitize(&mut self) {
|
||
self.cancel_reason = sanitize_option(self.cancel_reason.take());
|
||
}
|
||
}
|
||
|
||
impl UpdateVitalSignsReq {
|
||
pub fn sanitize(&mut self) {
|
||
self.notes = sanitize_option(self.notes.take());
|
||
}
|
||
}
|
||
|
||
impl UpdateLabReportReq {
|
||
pub fn sanitize(&mut self) {
|
||
self.doctor_interpretation = sanitize_option(self.doctor_interpretation.take());
|
||
}
|
||
}
|
||
|
||
impl UpdateHealthRecordReq {
|
||
pub fn sanitize(&mut self) {
|
||
self.source = sanitize_option(self.source.take());
|
||
self.overall_assessment = sanitize_option(self.overall_assessment.take());
|
||
self.notes = sanitize_option(self.notes.take());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 为 follow_up_dto.rs 添加 sanitize**
|
||
|
||
```rust
|
||
impl CreateFollowUpTaskReq {
|
||
pub fn sanitize(&mut self) {
|
||
self.content_template = sanitize_option(self.content_template.take());
|
||
}
|
||
}
|
||
|
||
impl UpdateFollowUpTaskReq {
|
||
pub fn sanitize(&mut self) {
|
||
self.content_template = sanitize_option(self.content_template.take());
|
||
}
|
||
}
|
||
|
||
impl CreateFollowUpRecordReq {
|
||
pub fn sanitize(&mut self) {
|
||
self.patient_condition = sanitize_option(self.patient_condition.take());
|
||
self.medical_advice = sanitize_option(self.medical_advice.take());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 为 consultation_dto.rs 添加 sanitize**
|
||
|
||
```rust
|
||
impl CreateMessageReq {
|
||
pub fn sanitize(&mut self) {
|
||
self.content = sanitize_string(&self.content);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: 为 doctor_dto.rs 添加 sanitize**
|
||
|
||
```rust
|
||
impl CreateDoctorReq {
|
||
pub fn sanitize(&mut self) {
|
||
self.name = sanitize_string(&self.name);
|
||
self.department = sanitize_option(self.department.take());
|
||
self.title = sanitize_option(self.title.take());
|
||
self.specialty = sanitize_option(self.specialty.take());
|
||
self.bio = sanitize_option(self.bio.take());
|
||
}
|
||
}
|
||
|
||
impl UpdateDoctorReq {
|
||
pub fn sanitize(&mut self) {
|
||
if let Some(ref mut v) = self.name { *v = strip_html_tags(v); }
|
||
self.department = sanitize_option(self.department.take());
|
||
self.title = sanitize_option(self.title.take());
|
||
self.specialty = sanitize_option(self.specialty.take());
|
||
self.bio = sanitize_option(self.bio.take());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: 在所有 handler 中调用 sanitize**
|
||
|
||
在每个 handler 的 create/update 方法中,在调用 service 之前添加 `req.sanitize()`。示例:
|
||
|
||
```rust
|
||
// patient_handler.rs
|
||
async fn create_patient(/* ... */) -> AppResult<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: 后端补完(阶段 2,1.5 周)
|
||
|
||
### Task 5: 事件处理器实现
|
||
|
||
**Files:**
|
||
- Modify: `crates/erp-health/src/event.rs`
|
||
- Modify: `crates/erp-health/src/module.rs`
|
||
- Modify: `crates/erp-health/src/service/follow_up_service.rs` (新增 update_task_status_by_system)
|
||
|
||
**问题:** `event.rs` 中两个事件处理器只有 `tracing::info`,无 db 连接。需要迁移到 `on_startup`。
|
||
|
||
- [ ] **Step 1: 改写 event.rs — 新增 register_handlers_with_state**
|
||
|
||
```rust
|
||
// event.rs — 完整替换
|
||
use erp_core::events::EventBus;
|
||
use sea_orm::DatabaseConnection;
|
||
use crate::state::HealthState;
|
||
|
||
/// 旧版注册(无 db),保留空实现以兼容 trait
|
||
pub fn register_handlers(_bus: &EventBus) {
|
||
// 已迁移到 register_handlers_with_state
|
||
}
|
||
|
||
/// 新版注册 — 带 db 连接,在 on_startup 中调用
|
||
pub fn register_handlers_with_state(state: HealthState) {
|
||
let db = state.db.clone();
|
||
|
||
// workflow.task.completed → 更新随访任务状态
|
||
let mut workflow_rx = state.event_bus.subscribe_filtered("workflow.task.".to_string()).0;
|
||
tokio::spawn(async move {
|
||
loop {
|
||
match workflow_rx.recv().await {
|
||
Some(event) if event.event_type == "workflow.task.completed" => {
|
||
if let Some(payload) = event.payload.get("task_id") {
|
||
if let Some(task_id_str) = payload.as_str() {
|
||
if let Ok(task_id) = uuid::Uuid::parse_str(task_id_str) {
|
||
match crate::service::follow_up_service::complete_task_by_system(&db, task_id, event.tenant_id).await {
|
||
Ok(_) => tracing::info!(task_id = %task_id, "随访任务通过工作流事件完成"),
|
||
Err(e) => tracing::warn!(error = %e, "工作流事件触发随访任务完成失败"),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Some(_) => {}
|
||
None => break,
|
||
}
|
||
}
|
||
});
|
||
|
||
let db2 = state.db.clone();
|
||
let mut msg_rx = state.event_bus.subscribe_filtered("message.".to_string()).0;
|
||
tokio::spawn(async move {
|
||
loop {
|
||
match msg_rx.recv().await {
|
||
Some(event) if event.event_type == "message.sent" => {
|
||
// 查找与消息发送者/接收者关联的咨询会话,更新 last_message_at
|
||
tracing::info!(event_id = %event.id, "健康模块收到消息事件(待实现关联逻辑)");
|
||
}
|
||
Some(_) => {}
|
||
None => break,
|
||
}
|
||
}
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 修改 module.rs — register_event_handlers 改为空,on_startup 注册**
|
||
|
||
```rust
|
||
// module.rs
|
||
fn register_event_handlers(&self, _bus: &EventBus) {
|
||
// 迁移到 on_startup
|
||
}
|
||
|
||
async fn on_startup(&self, ctx: &erp_core::module::ModuleContext) -> erp_core::error::AppResult<()> {
|
||
let state = HealthState {
|
||
db: ctx.db.clone(),
|
||
event_bus: ctx.event_bus.clone(),
|
||
crypto: crate::crypto::HealthCrypto::from_keys(
|
||
&std::env::var("ERP__HEALTH__ENCRYPTION_KEY").unwrap_or_default(),
|
||
&std::env::var("ERP__HEALTH__HMAC_KEY").unwrap_or_default(),
|
||
)?,
|
||
};
|
||
crate::event::register_handlers_with_state(state);
|
||
Ok(())
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 在 follow_up_service.rs 新增 complete_task_by_system**
|
||
|
||
```rust
|
||
/// 供事件处理器调用的系统级状态更新(跳过 version 检查,由系统触发)
|
||
pub async fn complete_task_by_system(
|
||
db: &DatabaseConnection,
|
||
task_id: Uuid,
|
||
tenant_id: Uuid,
|
||
) -> HealthResult<()> {
|
||
use crate::entity::follow_up_task;
|
||
let model = follow_up_task::Entity::find()
|
||
.filter(follow_up_task::Column::Id.eq(task_id))
|
||
.filter(follow_up_task::Column::TenantId.eq(tenant_id))
|
||
.filter(follow_up_task::Column::DeletedAt.is_null())
|
||
.one(db).await?.ok_or(HealthError::FollowUpTaskNotFound)?;
|
||
|
||
if model.status != "pending" && model.status != "in_progress" {
|
||
return Ok(()); // 已完成/已取消,忽略
|
||
}
|
||
|
||
let mut active: follow_up_task::ActiveModel = model.into();
|
||
active.status = Set("completed".to_string());
|
||
active.updated_at = Set(Utc::now());
|
||
active.update(db).await?;
|
||
Ok(())
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 验证编译**
|
||
|
||
Run: `cargo check`
|
||
Expected: 无错误
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add crates/erp-health/src/
|
||
git commit -m "feat(health): 事件处理器实现 — workflow→随访、message→咨询联动"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: 数据一致性修复
|
||
|
||
**Files:**
|
||
- Modify: `crates/erp-health/src/service/appointment_service.rs`
|
||
- Modify: `crates/erp-health/src/service/consultation_service.rs`
|
||
|
||
- [ ] **Step 1: 排班名额保护 — update_schedule 增加校验**
|
||
|
||
在 `appointment_service.rs` 的 `update_schedule` 方法中,`let next_ver = ...` 之后添加:
|
||
|
||
```rust
|
||
if let Some(new_max) = req.max_appointments {
|
||
if new_max < model.current_appointments {
|
||
return Err(HealthError::Validation(
|
||
format!("max_appointments ({}) 不能小于当前已预约数 ({})",
|
||
new_max, model.current_appointments)
|
||
));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 取消预约名额释放 — 失败时回滚整个事务**
|
||
|
||
当前 `update_appointment_status` 中取消时名额释放失败只 log error。修改为:名额释放失败时回滚整个事务。
|
||
|
||
将 `if let Err(e) = release_result` 改为 `let release_result = ... ?;`,让错误传播导致事务回滚:
|
||
|
||
```rust
|
||
if req.status == "cancelled" {
|
||
if let Some(did) = model.doctor_id {
|
||
doctor_schedule::Entity::update_many()
|
||
// ... 同现有代码 ...
|
||
.exec(&txn)
|
||
.await?; // ← 改为 ? 传播错误,不再 if let Err
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 咨询消息原子性 — 消息 INSERT + session CAS 放同一事务**
|
||
|
||
当前 `create_message` 中消息 INSERT 和 session CAS 分别独立执行。修改为事务包裹:
|
||
|
||
```rust
|
||
pub async fn create_message(/* ... */) -> HealthResult<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 1,1.5 天)
|
||
|
||
### Task 9: API 服务层 — 6 个 service 文件
|
||
|
||
**Files:**
|
||
- Create: `apps/web/src/api/health/patients.ts`
|
||
- Create: `apps/web/src/api/health/healthData.ts`
|
||
- Create: `apps/web/src/api/health/appointments.ts`
|
||
- Create: `apps/web/src/api/health/followUp.ts`
|
||
- Create: `apps/web/src/api/health/consultations.ts`
|
||
- Create: `apps/web/src/api/health/doctors.ts`
|
||
|
||
**参考模式:** `apps/web/src/api/users.ts` — `import client from '../client'`,`const { data } = await client.get<...>(...)` 解构模式,返回 `data.data`。
|
||
|
||
- [ ] **Step 1: 创建 patients.ts(12 端点)**
|
||
|
||
```typescript
|
||
import client from '../client';
|
||
import type { PaginatedResponse } from '../types';
|
||
|
||
// --- Types ---
|
||
export interface PatientListItem {
|
||
id: string; name: string; gender?: string; birth_date?: string;
|
||
blood_type?: string; status: string; verification_status: string;
|
||
source?: string; created_at: string; updated_at: string; version: number;
|
||
}
|
||
export interface PatientDetail {
|
||
id: string; name: string; gender?: string; birth_date?: string;
|
||
blood_type?: string; id_number?: string; allergy_history?: string;
|
||
medical_history_summary?: string; emergency_contact_name?: string;
|
||
emergency_contact_phone?: string; status: string; verification_status: string;
|
||
source?: string; notes?: string; created_at: string; updated_at: string; version: number;
|
||
}
|
||
export interface CreatePatientReq {
|
||
name: string; gender?: string; birth_date?: string; blood_type?: string;
|
||
id_number?: string; allergy_history?: string; medical_history_summary?: string;
|
||
emergency_contact_name?: string; emergency_contact_phone?: string;
|
||
source?: string; notes?: string;
|
||
}
|
||
export interface UpdatePatientReq extends Partial<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.ts(13 端点)**
|
||
|
||
覆盖:vital_signs CRUD (list/create/update/delete)、lab_reports CRUD、health_records CRUD、trends(list/generate/indicator timeseries)、mini trend、mini today。
|
||
|
||
- [ ] **Step 3: 创建 appointments.ts(6 端点)**
|
||
|
||
覆盖:list/get/create/updateStatus、schedules (list/create/update)、calendar。
|
||
|
||
- [ ] **Step 4: 创建 followUp.ts(6 端点)**
|
||
|
||
覆盖:tasks (list/get/create/update/delete)、records (list/create)。
|
||
|
||
- [ ] **Step 5: 创建 consultations.ts(6 端点)**
|
||
|
||
覆盖:sessions (list/create/close)、messages (list/create)、export。
|
||
|
||
- [ ] **Step 6: 创建 doctors.ts(4 端点)**
|
||
|
||
覆盖:list/get/create/update/delete。
|
||
|
||
- [ ] **Step 7: 验证编译**
|
||
|
||
Run: `cd apps/web && pnpm build`
|
||
Expected: 无类型错误
|
||
|
||
- [ ] **Step 8: 提交**
|
||
|
||
```bash
|
||
git add apps/web/src/api/health/
|
||
git commit -m "feat(web): 健康模块 API 服务层 6 文件 47 端点"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: 通用组件 — 8 个
|
||
|
||
**Files:**
|
||
- Create: `apps/web/src/pages/health/components/StatusTag.tsx`
|
||
- Create: `apps/web/src/pages/health/components/PatientSelect.tsx`
|
||
- Create: `apps/web/src/pages/health/components/DoctorSelect.tsx`
|
||
- Create: `apps/web/src/pages/health/components/VitalSignsChart.tsx`
|
||
- Create: `apps/web/src/pages/health/components/CalendarView.tsx`
|
||
- Create: `apps/web/src/pages/health/components/ChatBubble.tsx`
|
||
- Create: `apps/web/src/pages/health/components/ImagePreview.tsx`
|
||
- Create: `apps/web/src/pages/health/components/ExportButton.tsx`
|
||
|
||
- [ ] **Step 1: StatusTag — 通用状态标签**
|
||
|
||
```tsx
|
||
import { Tag } from 'antd';
|
||
|
||
const STATUS_CONFIG: Record<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-6,13.5 天)
|
||
|
||
### Task 12: PatientList + PatientTagManage + PatientDetail(Phase 2,4 天)
|
||
|
||
**Files:**
|
||
- Replace: `apps/web/src/pages/health/PatientList.tsx`
|
||
- Replace: `apps/web/src/pages/health/PatientDetail.tsx`
|
||
- Replace: `apps/web/src/pages/health/PatientTagManage.tsx`
|
||
|
||
**参考模式:** `apps/web/src/pages/Users.tsx` — useState + useCallback fetch + Ant Design Table + Modal。
|
||
|
||
- [ ] **Step 1: PatientList.tsx(1.5 天)**
|
||
|
||
功能:
|
||
- Ant Design `Table`(不使用 ProTable,项目未安装)
|
||
- 搜索:姓名模糊 + 状态 `Select` 筛选 + 标签多选筛选
|
||
- 每行标签显示为 `Tag` 组件列表
|
||
- 行点击 → `useNavigate` 跳转 `/health/patients/:id`
|
||
- 创建患者 `Modal`(Ant Design `Form`)
|
||
- 编辑患者 `Modal`(复用创建表单)
|
||
- 批量打标按钮
|
||
|
||
- [ ] **Step 2: PatientTagManage.tsx(0.5 天)**
|
||
|
||
功能:
|
||
- 标准 CRUD 表格(标签名 + 颜色 + 描述)
|
||
- Ant Design `ColorPicker` 选择标签颜色
|
||
- 批量打标功能(`Transfer` 或 `Select` 多选患者)
|
||
|
||
- [ ] **Step 3: PatientDetail.tsx(2 天)**
|
||
|
||
功能:
|
||
- 顶部:患者摘要卡片(`Descriptions` 展示姓名/性别/年龄/状态/标签)
|
||
- Ant Design `Tabs` 5 个 Tab:
|
||
1. **基本信息** — `Descriptions` 展示 + 编辑 `Modal`
|
||
2. **健康趋势** — `VitalSignsChart` 组件 + 时间范围 `Radio.Group`(7d/30d/90d)+ 指标 `Select`
|
||
3. **化验报告** — 报告卡片列表 + `ImagePreview`
|
||
4. **就诊记录** — 嵌套列表(record_type 分组)
|
||
5. **随访记录** — 嵌套列表 + 关联的随访记录
|
||
|
||
- [ ] **Step 4: 验证编译 + 浏览器测试**
|
||
|
||
Run: `cd apps/web && pnpm build`
|
||
浏览器:患者列表 → 点击进入详情 → 各 Tab 切换正常。
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add apps/web/src/pages/health/
|
||
git commit -m "feat(web): PatientList + PatientTagManage + PatientDetail 页面"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: DoctorList + AppointmentList + DoctorSchedule(Phase 4,6 天)
|
||
|
||
- [ ] **Step 1: DoctorList.tsx(0.5 天)**
|
||
|
||
功能:
|
||
- 标准 CRUD 表格
|
||
- 科室筛选 + 在线状态 Badge
|
||
- 详情 `Drawer`
|
||
|
||
- [ ] **Step 2: AppointmentList.tsx(2 天)**
|
||
|
||
功能:
|
||
- Ant Design `Segmented` 切换列表/日历视图
|
||
- 列表模式:Table + 状态筛选 + 日期筛选
|
||
- 日历模式:`Calendar` + `cellRender` 显示当日预约数
|
||
- 状态流转 `Dropdown`(pending→confirmed→completed/no_show/cancelled)
|
||
- 创建预约 `Modal`(选择患者 `PatientSelect` + 医生 `DoctorSelect` + 日期时段)
|
||
|
||
- [ ] **Step 3: DoctorSchedule.tsx(2.5 天)**
|
||
|
||
功能:
|
||
- 选择医生后展示排班(`DoctorSelect` 切换)
|
||
- 周视图(自定义 7 列网格,每列显示排班时段)
|
||
- 月视图(Ant Design `Calendar`)
|
||
- 批量创建排班(日期范围 + 时段模板)
|
||
- 显示已预约/最大预约数
|
||
|
||
- [ ] **Step 4: 验证编译 + 浏览器测试**
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add apps/web/src/pages/health/
|
||
git commit -m "feat(web): DoctorList + AppointmentList + DoctorSchedule 页面"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: FollowUpTaskList + FollowUpRecordList + ConsultationList + ConsultationDetail(Phase 5,6.5 天)
|
||
|
||
- [ ] **Step 1: FollowUpTaskList.tsx(1.5 天)**
|
||
|
||
功能:
|
||
- Table + 状态筛选(pending/in_progress/completed/overdue/cancelled)
|
||
- 分配医护(`DoctorSelect`)
|
||
- 创建任务 `Modal`
|
||
- 快捷"填写随访记录"按钮打开子 `Modal`
|
||
|
||
- [ ] **Step 2: FollowUpRecordList.tsx(0.5 天)**
|
||
|
||
功能:
|
||
- 纯只读台账
|
||
- 筛选:日期范围、患者、任务、结果
|
||
- 导出功能(`ExportButton`)
|
||
|
||
- [ ] **Step 3: ConsultationList.tsx(1 天)**
|
||
|
||
功能:
|
||
- Table + 状态筛选(waiting/active/closed)
|
||
- 未读消息数 Badge
|
||
- 最后消息时间
|
||
- 关闭会话操作
|
||
- 行点击跳转 `/health/consultations/:id`
|
||
|
||
- [ ] **Step 4: ConsultationDetail.tsx(2 天)**
|
||
|
||
功能:
|
||
- `ChatBubble` 组件渲染聊天记录
|
||
- `sender_role` 区分左右对齐
|
||
- 支持内容类型:text / image(`ImagePreview`)/ voice / file
|
||
- 消息按时间排列,滚动加载更多(分页)
|
||
- 导出按钮
|
||
|
||
- [ ] **Step 5: 验证编译 + 浏览器测试**
|
||
|
||
- [ ] **Step 6: 提交**
|
||
|
||
```bash
|
||
git add apps/web/src/pages/health/
|
||
git commit -m "feat(web): 随访管理 + 咨询管理页面完整实现"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 15: 前端打磨(Phase 6,1 天)
|
||
|
||
- [ ] **Step 1: 暗色主题适配**
|
||
- [ ] **Step 2: 响应式布局检查**
|
||
- [ ] **Step 3: 前端联调 — 确保所有页面与后端 API 交互正常**
|
||
- [ ] **Step 4: pnpm build 生产构建通过**
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add apps/web/
|
||
git commit -m "feat(web): 健康模块前端打磨 — 暗色主题 + 响应式 + 联调"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 5: 测试策略 + 端到端验证 + 路线图(交叉进行 + 1 周)
|
||
|
||
### Task 16: validation.rs 纯函数测试(P0,1 天)
|
||
|
||
**Files:**
|
||
- Create: `crates/erp-health/src/service/validation.rs`(在同文件 `#[cfg(test)] mod tests` 块内)
|
||
|
||
- [ ] **Step 1: 编写 20-30 个枚举校验测试**
|
||
|
||
```rust
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_gender_valid() { assert!(validate_gender("male").is_ok()); }
|
||
#[test]
|
||
fn test_gender_invalid() { assert!(validate_gender("unknown").is_err()); }
|
||
|
||
#[test]
|
||
fn test_appointment_status_pending_to_confirmed() {
|
||
assert!(validate_appointment_status_transition("pending", "confirmed").is_ok());
|
||
}
|
||
#[test]
|
||
fn test_appointment_status_completed_to_pending() {
|
||
assert!(validate_appointment_status_transition("completed", "pending").is_err());
|
||
}
|
||
// ... 覆盖所有枚举和状态转换
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试**
|
||
|
||
Run: `cargo test -p erp-health -- validation::tests`
|
||
Expected: 全部通过
|
||
|
||
- [ ] **Step 3: 提交**
|
||
|
||
```bash
|
||
git add crates/erp-health/src/service/validation.rs
|
||
git commit -m "test(health): validation.rs 纯函数测试 20+ 用例"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 17: 核心 service 集成测试(P0,4 天)
|
||
|
||
**Files:**
|
||
- Create: `crates/erp-health/tests/test_helpers.rs`
|
||
- Create: `crates/erp-health/tests/appointment_test.rs`
|
||
- Create: `crates/erp-health/tests/patient_test.rs`
|
||
|
||
**测试基础设施:** 使用 testcontainers-postgreSQL 做真实数据库测试。
|
||
|
||
- [ ] **Step 1: 创建 test_helpers.rs**
|
||
|
||
提供 `create_integration_db()` — testcontainers PostgreSQL 实例 + 自动运行迁移。
|
||
|
||
- [ ] **Step 2: appointment_test.rs — CAS 并发 + 状态流转**
|
||
|
||
关键场景:
|
||
- 排班已满 → 创建预约失败
|
||
- 排班有余 → CAS 成功 + 名额减 1
|
||
- 并发创建 → 只有 max_appointments 个成功
|
||
- 状态转换合法/非法
|
||
- 取消预约 → 名额释放
|
||
|
||
- [ ] **Step 3: patient_test.rs — CRUD + 状态机**
|
||
|
||
关键场景:
|
||
- 创建/更新/删除患者
|
||
- 状态转换 active→inactive→deceased
|
||
- 乐观锁版本冲突
|
||
|
||
- [ ] **Step 4: 运行测试**
|
||
|
||
Run: `cargo test -p erp-health`
|
||
Expected: 全部通过
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add crates/erp-health/tests/
|
||
git commit -m "test(health): 预约 CAS 并发 + 患者 CRUD 集成测试"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 18: 端到端验证(阶段 4,1 周)
|
||
|
||
- [ ] **Step 1: 小程序联调** — 确认小程序端 API 调用与新后端兼容
|
||
- [ ] **Step 2: 种子数据填充** — 在开发环境填充演示数据(患者/排班/预约/随访/咨询)
|
||
- [ ] **Step 3: Docker 演示环境** — 确认 docker-compose 能启动完整演示
|
||
- [ ] **Step 4: 文档更新** — 更新 wiki/erp-health.md 和 CLAUDE.md 中的完成状态
|
||
- [ ] **Step 5: 最终提交 + 推送**
|
||
|
||
```bash
|
||
git add .
|
||
git commit -m "chore: 端到端验证完成 — 健康管理模块 V1 交付"
|
||
git push
|
||
```
|
||
|
||
---
|
||
|
||
## 总体里程碑
|
||
|
||
| 里程碑 | 交付物 | 验收标准 |
|
||
|--------|--------|---------|
|
||
| M1 | 安全省基完成(Task 1-4) | sanitize + 审计 + 加密 + 脱敏全部到位 |
|
||
| M2 | 后端功能完整(Task 5-8) | 事件处理器 + 数据一致性 + 测试通过 |
|
||
| M3 | Web 基础设施(Task 9-11) | API 层 + 组件 + 路由菜单可运行 |
|
||
| M4 | Web 核心页面(Task 12-13) | PatientList + AppointmentList + DoctorSchedule 可操作 |
|
||
| M5 | Web 全部页面(Task 14-15) | 10 页面功能可用,pnpm build 通过 |
|
||
| M6 | 端到端验证(Task 16-18) | Web + 小程序 + 后端全链路可演示 |
|
||
|
||
## 总时间线
|
||
|
||
```
|
||
Week 1-2 | 安全省基(Task 1-4)
|
||
Week 2-4 | 后端补完 + 测试(Task 5-8 + Task 16-17)
|
||
Week 4-7 | Web 前端(Task 9-15)
|
||
Week 7-8 | 端到端验证(Task 18)
|
||
总计 7-8 周
|
||
```
|