审计发现并修复的问题: 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> 实现
53 KiB
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) 方法:
use erp_core::sanitize::{strip_html_tags, sanitize_option, sanitize_string};
impl CreatePatientReq {
pub fn sanitize(&mut self) {
self.name = sanitize_string(&self.name);
self.notes = sanitize_option(self.notes.take());
self.allergy_history = sanitize_option(self.allergy_history.take());
self.medical_history_summary = sanitize_option(self.medical_history_summary.take());
self.emergency_contact_name = sanitize_option(self.emergency_contact_name.take());
self.source = sanitize_option(self.source.take());
}
}
impl UpdatePatientReq {
pub fn sanitize(&mut self) {
if let Some(ref mut v) = self.name { *v = strip_html_tags(v); }
self.id_number = sanitize_option(self.id_number.take());
self.notes = sanitize_option(self.notes.take());
self.allergy_history = sanitize_option(self.allergy_history.take());
self.medical_history_summary = sanitize_option(self.medical_history_summary.take());
self.emergency_contact_name = sanitize_option(self.emergency_contact_name.take());
self.emergency_contact_phone = sanitize_option(self.emergency_contact_phone.take());
self.source = sanitize_option(self.source.take());
}
}
impl FamilyMemberReq {
pub fn sanitize(&mut self) {
self.name = sanitize_string(&self.name);
self.notes = sanitize_option(self.notes.take());
}
}
- Step 2: 为 health_data_dto.rs 添加 sanitize
impl CreateVitalSignsReq {
pub fn sanitize(&mut self) {
self.notes = sanitize_option(self.notes.take());
}
}
impl CreateLabReportReq {
pub fn sanitize(&mut self) {
self.doctor_interpretation = sanitize_option(self.doctor_interpretation.take());
}
}
impl CreateHealthRecordReq {
pub fn sanitize(&mut self) {
self.source = sanitize_option(self.source.take());
self.overall_assessment = sanitize_option(self.overall_assessment.take());
self.notes = sanitize_option(self.notes.take());
}
}
- Step 3: 为 appointment_dto.rs 添加 sanitize
impl CreateAppointmentReq {
pub fn sanitize(&mut self) {
self.notes = sanitize_option(self.notes.take());
}
}
impl UpdateAppointmentStatusReq {
pub fn sanitize(&mut self) {
self.cancel_reason = sanitize_option(self.cancel_reason.take());
}
}
impl UpdateVitalSignsReq {
pub fn sanitize(&mut self) {
self.notes = sanitize_option(self.notes.take());
}
}
impl UpdateLabReportReq {
pub fn sanitize(&mut self) {
self.doctor_interpretation = sanitize_option(self.doctor_interpretation.take());
}
}
impl UpdateHealthRecordReq {
pub fn sanitize(&mut self) {
self.source = sanitize_option(self.source.take());
self.overall_assessment = sanitize_option(self.overall_assessment.take());
self.notes = sanitize_option(self.notes.take());
}
}
- Step 4: 为 follow_up_dto.rs 添加 sanitize
impl CreateFollowUpTaskReq {
pub fn sanitize(&mut self) {
self.content_template = sanitize_option(self.content_template.take());
}
}
impl UpdateFollowUpTaskReq {
pub fn sanitize(&mut self) {
self.content_template = sanitize_option(self.content_template.take());
}
}
impl CreateFollowUpRecordReq {
pub fn sanitize(&mut self) {
self.patient_condition = sanitize_option(self.patient_condition.take());
self.medical_advice = sanitize_option(self.medical_advice.take());
}
}
- Step 5: 为 consultation_dto.rs 添加 sanitize
impl CreateMessageReq {
pub fn sanitize(&mut self) {
self.content = sanitize_string(&self.content);
}
}
- Step 6: 为 doctor_dto.rs 添加 sanitize
impl CreateDoctorReq {
pub fn sanitize(&mut self) {
self.name = sanitize_string(&self.name);
self.department = sanitize_option(self.department.take());
self.title = sanitize_option(self.title.take());
self.specialty = sanitize_option(self.specialty.take());
self.bio = sanitize_option(self.bio.take());
}
}
impl UpdateDoctorReq {
pub fn sanitize(&mut self) {
if let Some(ref mut v) = self.name { *v = strip_html_tags(v); }
self.department = sanitize_option(self.department.take());
self.title = sanitize_option(self.title.take());
self.specialty = sanitize_option(self.specialty.take());
self.bio = sanitize_option(self.bio.take());
}
}
- Step 7: 在所有 handler 中调用 sanitize
在每个 handler 的 create/update 方法中,在调用 service 之前添加 req.sanitize()。示例:
// patient_handler.rs
async fn create_patient(/* ... */) -> AppResult<Json<ApiResponse<PatientResp>>> {
let mut req = req.0;
req.sanitize(); // ← 新增
let result = patient_service::create_patient(&state.health, tenant_id, user_id, req).await?;
Ok(Json(ApiResponse::success(result)))
}
所有需要修改的 handler 方法:
-
patient_handler: create_patient, update_patient, create_family_member, update_family_member -
health_data_handler: create_vital_signs, update_vital_signs, create_lab_report, update_lab_report, create_health_record, update_health_record -
appointment_handler: create_appointment, update_appointment_status -
follow_up_handler: create_task, update_task, create_record -
consultation_handler: create_message -
doctor_handler: create_doctor, update_doctor -
Step 8: 验证编译
Run: cargo check
Expected: 无错误
- Step 9: 提交
git add crates/erp-health/src/dto/ crates/erp-health/src/handler/
git commit -m "feat(health): 为所有 DTO 添加 sanitize 防止存储型 XSS"
Task 2: 审计日志注入
Files:
- Modify:
crates/erp-health/src/service/patient_service.rs - Modify:
crates/erp-health/src/service/appointment_service.rs - Modify:
crates/erp-health/src/service/consultation_service.rs - Modify:
crates/erp-health/src/service/follow_up_service.rs - Modify:
crates/erp-health/src/service/doctor_service.rs - Modify:
crates/erp-health/src/service/health_data_service.rs
参考模式: crates/erp-auth/src/service/auth_service.rs 第 168-177 行。
注意: 使用现有 erp_core::audit_service::record(log, db) 的 fire-and-forget 模式。未来如需事务保证再升级为 record_in_txn。
- Step 1: 在 patient_service.rs 注入审计日志
在 create_patient、update_patient、delete_patient、manage_patient_tags 方法中添加审计调用。每个方法在业务操作成功后、返回结果前插入:
use erp_core::audit::AuditLog;
use erp_core::audit_service;
// create_patient 末尾(return 之前)
audit_service::record(
AuditLog::new(tenant_id, operator_id, "patient.created", "patient")
.with_resource_id(m.id),
&state.db,
).await;
覆盖清单(共 4 个操作):
| 方法 | action |
|---|---|
| create_patient | patient.created |
| update_patient | patient.updated |
| delete_patient | patient.deleted |
| manage_patient_tags | patient.tags_updated |
- Step 2: 在 appointment_service.rs 注入审计日志
覆盖清单(共 2 个操作):
| 方法 | action |
|---|---|
| create_appointment | appointment.created |
| update_appointment_status | appointment.status_changed |
update_appointment_status 中添加变更前后值摘要:
audit_service::record(
AuditLog::new(tenant_id, operator_id, "appointment.status_changed", "appointment")
.with_resource_id(m.id)
.with_changes(
Some(serde_json::json!({ "status": old_status })),
Some(serde_json::json!({ "status": m.status })),
),
&state.db,
).await;
- Step 3: 在 consultation_service.rs 注入审计日志
覆盖清单(共 3 个操作):
| 方法 | action |
|---|---|
| create_session | consultation.opened |
| close_session | consultation.closed |
| create_message | consultation.message_sent |
- Step 4: 在 follow_up_service.rs 注入审计日志
覆盖清单(共 2 个操作):
| 方法 | action |
|---|---|
| create_task | follow_up_task.created |
| create_record | follow_up_record.created |
- Step 5: 在 doctor_service.rs 和 health_data_service.rs 注入审计日志
doctor_service 覆盖清单(共 3 个操作):
| 方法 | action |
|---|---|
| create_doctor | doctor.created |
| update_doctor | doctor.updated |
| delete_doctor | doctor.deleted |
health_data_service 覆盖清单(共 3 个操作):
| 方法 | action |
|---|---|
| create_vital_signs | vital_signs.created |
| create_lab_report | lab_report.created |
| create_health_record | health_record.created |
- Step 6: 验证编译
Run: cargo check
Expected: 无错误
- Step 7: 提交
git add crates/erp-health/src/service/
git commit -m "feat(health): 注入审计日志覆盖所有写入操作"
Task 3: 身份证号加密存储
Files:
-
Create:
crates/erp-health/src/crypto.rs -
Modify:
crates/erp-health/src/lib.rs(添加 mod crypto) -
Modify:
crates/erp-health/src/service/patient_service.rs -
Modify:
crates/erp-health/src/state.rs(添加 crypto 字段) -
Modify:
crates/erp-health/src/module.rs(初始化 crypto + 注册事件处理器) -
Modify:
crates/erp-health/Cargo.toml(添加 aes-gcm 依赖) -
Modify:
crates/erp-server/src/state.rs(更新 FromRef 添加 crypto 字段) -
Modify:
crates/erp-server/src/main.rs(注入 crypto 到 HealthState) -
Step 1: 添加 Cargo.toml 依赖
# crates/erp-health/Cargo.toml [dependencies] 新增
aes-gcm = "0.10"
hmac = "0.12"
sha2 = "0.10"
base64 = "0.22"
hex = "0.4"
- Step 2: 创建 crypto.rs
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
use aes_gcm::aead::Aead;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use erp_core::error::AppResult;
use erp_core::error::AppError;
type HmacSha256 = Hmac<Sha256>;
pub struct HealthCrypto {
aes_key: [u8; 32],
hmac_key: [u8; 32],
}
impl HealthCrypto {
pub fn from_keys(aes_key_hex: &str, hmac_key_hex: &str) -> AppResult<Self> {
let aes_key = hex::decode(aes_key_hex)
.map_err(|e| AppError::Internal(format!("AES key hex decode failed: {}", e)))?;
let hmac_key = hex::decode(hmac_key_hex)
.map_err(|e| AppError::Internal(format!("HMAC key hex decode failed: {}", e)))?;
if aes_key.len() != 32 || hmac_key.len() != 32 {
return Err(AppError::Internal("Encryption keys must be 32 bytes".into()));
}
let mut aes = [0u8; 32];
let mut hmac = [0u8; 32];
aes.copy_from_slice(&aes_key);
hmac.copy_from_slice(&hmac_key);
Ok(Self { aes_key: aes, hmac_key: hmac })
}
pub fn encrypt(&self, plaintext: &str) -> AppResult<String> {
let cipher = Aes256Gcm::new_from_slice(&self.aes_key)
.map_err(|e| AppError::Internal(format!("AES init failed: {}", e)))?;
let nonce_bytes = uuid::Uuid::now_v7();
let nonce = Nonce::from_slice(nonce_bytes.as_bytes());
let ciphertext = cipher.encrypt(nonce, plaintext.as_bytes())
.map_err(|e| AppError::Internal(format!("Encryption failed: {}", e)))?;
// 格式: base64(nonce_12byte + ciphertext)
let mut combined = nonce_bytes.as_bytes()[..12].to_vec();
combined.extend_from_slice(&ciphertext);
Ok(BASE64.encode(&combined))
}
pub fn decrypt(&self, encoded: &str) -> AppResult<String> {
let combined = BASE64.decode(encoded)
.map_err(|e| AppError::Internal(format!("Base64 decode failed: {}", e)))?;
if combined.len() < 12 {
return Err(AppError::Internal("Ciphertext too short".into()));
}
let (nonce_bytes, ciphertext) = combined.split_at(12);
let cipher = Aes256Gcm::new_from_slice(&self.aes_key)
.map_err(|e| AppError::Internal(format!("AES init failed: {}", e)))?;
let plaintext = cipher.decrypt(Nonce::from_slice(nonce_bytes), ciphertext)
.map_err(|e| AppError::Internal(format!("Decryption failed: {}", e)))?;
String::from_utf8(plaintext)
.map_err(|e| AppError::Internal(format!("UTF-8 decode failed: {}", e)))
}
pub fn hmac_hash(&self, value: &str) -> String {
let mut mac = HmacSha256::new_from_slice(&self.hmac_key)
.expect("HMAC key length is valid");
mac.update(value.as_bytes());
hex::encode(mac.finalize().into_bytes())
}
}
- Step 3: 更新 state.rs
// state.rs
use crate::crypto::HealthCrypto;
#[derive(Clone)]
pub struct HealthState {
pub db: sea_orm::DatabaseConnection,
pub event_bus: erp_core::events::EventBus,
pub crypto: HealthCrypto,
}
- Step 4: 更新 module.rs 初始化 crypto(同时注册事件处理器)
在 module.rs 中实现 on_startup,一次性初始化 crypto 和注册事件处理器:
async fn on_startup(&self, ctx: &erp_core::module::ModuleContext) -> erp_core::error::AppResult<()> {
let state = HealthState {
db: ctx.db.clone(),
event_bus: ctx.event_bus.clone(),
crypto: crate::crypto::HealthCrypto::from_keys(
&std::env::var("ERP__HEALTH__ENCRYPTION_KEY").unwrap_or_default(),
&std::env::var("ERP__HEALTH__HMAC_KEY").unwrap_or_default(),
)?,
};
crate::event::register_handlers_with_state(state);
Ok(())
}
fn register_event_handlers(&self, _bus: &EventBus) {
// 已迁移到 on_startup,此处为空
}
同时必须更新 crates/erp-server/src/state.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:
use sea_orm_migration::prelude::*;
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str { "m20260424_000001_add_id_number_hash" }
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.alter_table(
Table::alter()
.table(Alias::new("patients"))
.add_column(ColumnDef::String(Alias::new("id_number_hash")).null())
.to_owned()
).await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.alter_table(
Table::alter()
.table(Alias::new("patients"))
.drop_column(Alias::new("id_number_hash"))
.to_owned()
).await
}
}
并在 migration 的 mod.rs 中注册。
- Step 6: 验证编译
Run: cargo check
Expected: 无错误
- Step 7: 在 config/default.toml 中标记加密密钥
[health]
encryption_key = "__MUST_SET_VIA_ENV__"
hmac_key = "__MUST_SET_VIA_ENV__"
- Step 8: 提交
git add crates/erp-health/ crates/erp-server/ docker/
git commit -m "feat(health): 身份证号 AES-256-GCM 加密 + HMAC 索引"
Task 4: 字段级脱敏 — 拆分 PatientResp
Files:
-
Modify:
crates/erp-health/src/dto/patient_dto.rs -
Modify:
crates/erp-health/src/service/patient_service.rs -
Modify:
crates/erp-health/src/handler/patient_handler.rs -
Step 1: 新增 PatientListResp(不含敏感字段)和 PatientDetailResp(掩码)
// patient_dto.rs 新增
/// 列表用 — 不含敏感字段
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct PatientListResp {
pub id: Uuid,
pub name: String,
pub gender: Option<String>,
pub birth_date: Option<NaiveDate>,
pub blood_type: Option<String>,
pub status: String,
pub verification_status: String,
pub source: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
}
/// 详情用 — 敏感字段掩码
pub type PatientDetailResp = PatientResp; // 保留全字段,handler 中掩码处理
/// 掩码辅助函数
fn mask_id_number(s: &str) -> String {
if s.len() >= 7 {
format!("{}****{}", &s[..3], &s[s.len()-4..])
} else {
"****".to_string()
}
}
fn mask_phone(s: &str) -> String {
if s.len() >= 7 {
format!("{}****{}", &s[..3], &s[s.len()-4..])
} else {
"****".to_string()
}
}
- Step 2: 修改 patient_service — list 返回 PatientListResp
将 list_patients 返回类型从 PaginatedResponse<PatientResp> 改为 PaginatedResponse<PatientListResp>,转换时只包含安全字段。
- Step 3: 修改 patient_service — get 返回脱敏后的 PatientDetailResp
get_patient 返回 PatientResp,但在 handler 层对 id_number 和 emergency_contact_phone 做掩码处理。
- Step 4: 验证编译
Run: cargo check
Expected: 无错误
- Step 5: 提交
git add crates/erp-health/src/
git commit -m "feat(health): 患者列表脱敏 — 拆分 PatientListResp/PatientDetailResp"
Chunk 1 里程碑验收
cargo check全 workspace 编译通过- 所有 DTO 的字符串输入字段都经过 sanitize
- 所有写入操作(15+ 方法)均有审计日志
- 身份证号加密存储 + HMAC 索引可用
- 列表接口不返回身份证号,详情接口掩码显示
Chunk 2: 后端补完(阶段 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
// event.rs — 完整替换
use erp_core::events::EventBus;
use sea_orm::DatabaseConnection;
use crate::state::HealthState;
/// 旧版注册(无 db),保留空实现以兼容 trait
pub fn register_handlers(_bus: &EventBus) {
// 已迁移到 register_handlers_with_state
}
/// 新版注册 — 带 db 连接,在 on_startup 中调用
pub fn register_handlers_with_state(state: HealthState) {
let db = state.db.clone();
// workflow.task.completed → 更新随访任务状态
let mut workflow_rx = state.event_bus.subscribe_filtered("workflow.task.".to_string()).0;
tokio::spawn(async move {
loop {
match workflow_rx.recv().await {
Some(event) if event.event_type == "workflow.task.completed" => {
if let Some(payload) = event.payload.get("task_id") {
if let Some(task_id_str) = payload.as_str() {
if let Ok(task_id) = uuid::Uuid::parse_str(task_id_str) {
match crate::service::follow_up_service::complete_task_by_system(&db, task_id, event.tenant_id).await {
Ok(_) => tracing::info!(task_id = %task_id, "随访任务通过工作流事件完成"),
Err(e) => tracing::warn!(error = %e, "工作流事件触发随访任务完成失败"),
}
}
}
}
}
Some(_) => {}
None => break,
}
}
});
let db2 = state.db.clone();
let mut msg_rx = state.event_bus.subscribe_filtered("message.".to_string()).0;
tokio::spawn(async move {
loop {
match msg_rx.recv().await {
Some(event) if event.event_type == "message.sent" => {
// 查找与消息发送者/接收者关联的咨询会话,更新 last_message_at
tracing::info!(event_id = %event.id, "健康模块收到消息事件(待实现关联逻辑)");
}
Some(_) => {}
None => break,
}
}
});
}
- Step 2: 修改 module.rs — register_event_handlers 改为空,on_startup 注册
// module.rs
fn register_event_handlers(&self, _bus: &EventBus) {
// 迁移到 on_startup
}
async fn on_startup(&self, ctx: &erp_core::module::ModuleContext) -> erp_core::error::AppResult<()> {
let state = HealthState {
db: ctx.db.clone(),
event_bus: ctx.event_bus.clone(),
crypto: crate::crypto::HealthCrypto::from_keys(
&std::env::var("ERP__HEALTH__ENCRYPTION_KEY").unwrap_or_default(),
&std::env::var("ERP__HEALTH__HMAC_KEY").unwrap_or_default(),
)?,
};
crate::event::register_handlers_with_state(state);
Ok(())
}
- Step 3: 在 follow_up_service.rs 新增 complete_task_by_system
/// 供事件处理器调用的系统级状态更新(跳过 version 检查,由系统触发)
pub async fn complete_task_by_system(
db: &DatabaseConnection,
task_id: Uuid,
tenant_id: Uuid,
) -> HealthResult<()> {
use crate::entity::follow_up_task;
let model = follow_up_task::Entity::find()
.filter(follow_up_task::Column::Id.eq(task_id))
.filter(follow_up_task::Column::TenantId.eq(tenant_id))
.filter(follow_up_task::Column::DeletedAt.is_null())
.one(db).await?.ok_or(HealthError::FollowUpTaskNotFound)?;
if model.status != "pending" && model.status != "in_progress" {
return Ok(()); // 已完成/已取消,忽略
}
let mut active: follow_up_task::ActiveModel = model.into();
active.status = Set("completed".to_string());
active.updated_at = Set(Utc::now());
active.update(db).await?;
Ok(())
}
- Step 4: 验证编译
Run: cargo check
Expected: 无错误
- Step 5: 提交
git add crates/erp-health/src/
git commit -m "feat(health): 事件处理器实现 — workflow→随访、message→咨询联动"
Task 6: 数据一致性修复
Files:
-
Modify:
crates/erp-health/src/service/appointment_service.rs -
Modify:
crates/erp-health/src/service/consultation_service.rs -
Step 1: 排班名额保护 — update_schedule 增加校验
在 appointment_service.rs 的 update_schedule 方法中,let next_ver = ... 之后添加:
if let Some(new_max) = req.max_appointments {
if new_max < model.current_appointments {
return Err(HealthError::Validation(
format!("max_appointments ({}) 不能小于当前已预约数 ({})",
new_max, model.current_appointments)
));
}
}
- Step 2: 取消预约名额释放 — 失败时回滚整个事务
当前 update_appointment_status 中取消时名额释放失败只 log error。修改为:名额释放失败时回滚整个事务。
将 if let Err(e) = release_result 改为 let release_result = ... ?;,让错误传播导致事务回滚:
if req.status == "cancelled" {
if let Some(did) = model.doctor_id {
doctor_schedule::Entity::update_many()
// ... 同现有代码 ...
.exec(&txn)
.await?; // ← 改为 ? 传播错误,不再 if let Err
}
}
- Step 3: 咨询消息原子性 — 消息 INSERT + session CAS 放同一事务
当前 create_message 中消息 INSERT 和 session CAS 分别独立执行。修改为事务包裹:
pub async fn create_message(/* ... */) -> HealthResult<MessageResp> {
// ... 校验会话(同现有代码)...
let txn = state.db.begin().await?;
// 在事务内 INSERT 消息
let m = active.insert(&txn).await?;
// 在事务内 CAS 更新 session
let cas_result = cas.exec(&txn).await?;
if cas_result.rows_affected == 0 {
txn.rollback().await?;
return Err(HealthError::VersionMismatch);
}
txn.commit().await?;
// ...
}
- Step 4: 验证编译
Run: cargo check
Expected: 无错误
- Step 5: 提交
git add crates/erp-health/src/service/
git commit -m "fix(health): 数据一致性修复 — 排班名额保护/取消回滚/消息原子性"
Task 7: 随访逾期定时任务
Files:
-
Modify:
crates/erp-health/src/service/validation.rs -
Modify:
crates/erp-health/src/service/follow_up_service.rs -
Modify:
crates/erp-health/src/module.rs -
Modify:
crates/erp-server/src/main.rs -
Step 1: validation.rs 添加 overdue 转换规则
新增函数:
/// follow_up_task.status transitions(含 overdue)
pub fn validate_follow_up_status_transition(current: &str, new: &str) -> HealthResult<()> {
if current == new { return Ok(()); }
let allowed = match current {
"pending" => matches!(new, "in_progress" | "cancelled" | "overdue"),
"in_progress" => matches!(new, "completed" | "cancelled"),
_ => false,
};
if allowed {
Ok(())
} else {
Err(HealthError::InvalidStatusTransition(format!(
"follow_up_task.status: 不允许从 '{}' 转换到 '{}'", current, new
)))
}
}
注意:follow_up_service.rs 中已有私有 validate_follow_up_status_transition 函数,需要替换为调用 validation.rs 中的公共版本。
- Step 2: follow_up_service.rs 新增 check_overdue_tasks
/// 检测 planned_date 已过且仍为 pending 的任务,标记为 overdue
pub async fn check_overdue_tasks(db: &DatabaseConnection) -> HealthResult<u64> {
let today = chrono::Utc::now().date_naive();
let result = follow_up_task::Entity::update_many()
.col_expr(follow_up_task::Column::Status, Expr::value("overdue".to_string()))
.col_expr(follow_up_task::Column::UpdatedAt, Expr::value(Utc::now()))
.filter(follow_up_task::Column::Status.eq("pending"))
.filter(follow_up_task::Column::PlannedDate.lt(today))
.filter(follow_up_task::Column::DeletedAt.is_null())
.exec(db)
.await?;
Ok(result.rows_affected)
}
- Step 3: module.rs 添加公开方法
impl HealthModule {
/// 启动随访逾期检查后台任务
pub fn start_overdue_checker(db: sea_orm::DatabaseConnection) {
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(6 * 3600));
interval.tick().await; // 跳过首次
loop {
interval.tick().await;
match crate::service::follow_up_service::check_overdue_tasks(&db).await {
Ok(count) if count > 0 => tracing::info!(count = count, "随访逾期检查完成"),
Ok(_) => {}
Err(e) => tracing::warn!(error = %e, "随访逾期检查失败"),
}
}
});
}
}
- Step 4: erp-server/main.rs 后台任务区添加调用
在 erp_workflow::WorkflowModule::start_timeout_checker(db.clone()); 之后添加:
erp_health::HealthModule::start_overdue_checker(db.clone());
tracing::info!("Follow-up overdue checker started");
- Step 5: 验证编译
Run: cargo check
Expected: 无错误
- Step 6: 提交
git add crates/erp-health/ crates/erp-server/
git commit -m "feat(health): 随访逾期定时任务 — 每 6 小时自动标记 overdue"
Task 8: article 管理 CRUD 补充
Files:
-
Modify:
crates/erp-health/src/dto/article_dto.rs -
Modify:
crates/erp-health/src/service/article_service.rs -
Modify:
crates/erp-health/src/handler/article_handler.rs -
Modify:
crates/erp-health/src/module.rs(添加路由) -
Step 1: article_dto.rs 新增请求 DTO + sanitize
新增 CreateArticleReq 和 UpdateArticleReq(含 sanitize):
impl CreateArticleReq {
pub fn sanitize(&mut self) {
self.title = sanitize_string(&self.title);
self.summary = sanitize_option(self.summary.take());
self.content = sanitize_option(self.content.take());
self.category = sanitize_option(self.category.take());
self.author = sanitize_option(self.author.take());
}
}
impl UpdateArticleReq {
pub fn sanitize(&mut self) {
if let Some(ref mut v) = self.title { *v = strip_html_tags(v); }
self.summary = sanitize_option(self.summary.take());
self.content = sanitize_option(self.content.take());
self.category = sanitize_option(self.category.take());
self.author = sanitize_option(self.author.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateArticleReq {
pub title: String,
pub summary: Option<String>,
pub content: Option<String>,
pub cover_image: Option<String>,
pub category: Option<String>,
pub author: Option<String>,
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateArticleReq {
pub title: Option<String>,
pub summary: Option<String>,
pub content: Option<String>,
pub cover_image: Option<String>,
pub category: Option<String>,
pub author: Option<String>,
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
pub version: i32,
}
- Step 2: article_service.rs 补充 create/update/delete
pub async fn create_article(state: &HealthState, tenant_id: Uuid, operator_id: Option<Uuid>, req: CreateArticleReq) -> HealthResult<ArticleResp> { ... }
pub async fn update_article(state: &HealthState, tenant_id: Uuid, id: Uuid, operator_id: Option<Uuid>, req: UpdateArticleReq) -> HealthResult<ArticleResp> { ... }
pub async fn delete_article(state: &HealthState, tenant_id: Uuid, id: Uuid, operator_id: Option<Uuid>) -> HealthResult<()> { ... }
-
Step 3: article_handler.rs 补充 handler 方法
-
Step 4: module.rs 修改现有路由(合并到现有 GET 路由上)
修改 module.rs 第 208-214 行的现有文章路由,将 POST/PUT/DELETE 合并:
// 修改前(第 208-214 行):
.route("/health/articles", axum::routing::get(article_handler::list_articles))
.route("/health/articles/{id}", axum::routing::get(article_handler::get_article))
// 修改后:
.route(
"/health/articles",
axum::routing::get(article_handler::list_articles)
.post(article_handler::create_article),
)
.route(
"/health/articles/{id}",
axum::routing::get(article_handler::get_article)
.put(article_handler::update_article)
.delete(article_handler::delete_article),
)
- Step 5: 验证编译
Run: cargo check
Expected: 无错误
- Step 6: 提交
git add crates/erp-health/
git commit -m "feat(health): 文章管理 CRUD 补充 create/update/delete"
Chunk 2 里程碑验收
cargo check全 workspace 编译通过- 事件处理器
workflow.task.completed能更新随访任务状态 - 排班名额不能反向修改到小于已预约数
- 取消预约名额释放失败时整个事务回滚
- 咨询消息 INSERT + session CAS 在同一事务中
- 随访逾期任务每 6 小时自动检测
- 文章 create/update/delete API 可用
Chunk 3: Web 前端基础设施(阶段 3 Phase 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 端点)
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: 提交
git add apps/web/src/api/health/
git commit -m "feat(web): 健康模块 API 服务层 6 文件 47 端点"
Task 10: 通用组件 — 8 个
Files:
-
Create:
apps/web/src/pages/health/components/StatusTag.tsx -
Create:
apps/web/src/pages/health/components/PatientSelect.tsx -
Create:
apps/web/src/pages/health/components/DoctorSelect.tsx -
Create:
apps/web/src/pages/health/components/VitalSignsChart.tsx -
Create:
apps/web/src/pages/health/components/CalendarView.tsx -
Create:
apps/web/src/pages/health/components/ChatBubble.tsx -
Create:
apps/web/src/pages/health/components/ImagePreview.tsx -
Create:
apps/web/src/pages/health/components/ExportButton.tsx -
Step 1: StatusTag — 通用状态标签
import { Tag } from 'antd';
const STATUS_CONFIG: Record<string, { color: string; label: string }> = {
// 预约状态
pending: { color: 'gold', label: '待确认' },
confirmed: { color: 'blue', label: '已确认' },
completed: { color: 'green', label: '已完成' },
cancelled: { color: 'default', label: '已取消' },
no_show: { color: 'red', label: '未到诊' },
// 随访状态
overdue: { color: 'red', label: '逾期' },
in_progress: { color: 'processing', label: '进行中' },
// 咨询状态
waiting: { color: 'gold', label: '等待中' },
active: { color: 'green', label: '进行中' },
closed: { color: 'default', label: '已关闭' },
// 患者状态
active: { color: 'green', label: '活跃' },
inactive: { color: 'default', label: '停用' },
deceased: { color: 'default', label: '已故' },
verified: { color: 'green', label: '已认证' },
};
interface Props { status: string; type?: 'appointment' | 'follow_up' | 'consultation' | 'patient'; }
export function StatusTag({ status }: Props) {
const cfg = STATUS_CONFIG[status] || { color: 'default', label: status };
return <Tag color={cfg.color}>{cfg.label}</Tag>;
}
- Step 2: PatientSelect — 患者远程搜索选择器
基于 Ant Design Select + Debounce,调用 patientApi.list({ search })。
- Step 3: DoctorSelect — 医护选择器
同 PatientSelect 模式,调用 doctorApi.list({ search })。
- Step 4: VitalSignsChart — ECharts 趋势图
使用 @ant-design/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: 提交
git add apps/web/src/pages/health/components/
git commit -m "feat(web): 健康模块通用组件 8 个"
Task 11: 路由和菜单集成
Files:
-
Modify:
apps/web/src/App.tsx -
Modify:
apps/web/src/layouts/MainLayout.tsx -
Step 1: App.tsx 添加 10 条 lazy 路由
// 在现有 lazy imports 区添加
const PatientList = lazy(() => import('./pages/health/PatientList'));
const PatientDetail = lazy(() => import('./pages/health/PatientDetail'));
const PatientTagManage = lazy(() => import('./pages/health/PatientTagManage'));
const DoctorList = lazy(() => import('./pages/health/DoctorList'));
const AppointmentList = lazy(() => import('./pages/health/AppointmentList'));
const DoctorSchedule = lazy(() => import('./pages/health/DoctorSchedule'));
const FollowUpTaskList = lazy(() => import('./pages/health/FollowUpTaskList'));
const FollowUpRecordList = lazy(() => import('./pages/health/FollowUpRecordList'));
const ConsultationList = lazy(() => import('./pages/health/ConsultationList'));
const ConsultationDetail = lazy(() => import('./pages/health/ConsultationDetail'));
// 在嵌套 Routes 中添加
<Route path="/health/patients" element={<PatientList />} />
<Route path="/health/patients/:id" element={<PatientDetail />} />
<Route path="/health/tags" element={<PatientTagManage />} />
<Route path="/health/doctors" element={<DoctorList />} />
<Route path="/health/appointments" element={<AppointmentList />} />
<Route path="/health/schedules" element={<DoctorSchedule />} />
<Route path="/health/follow-up-tasks" element={<FollowUpTaskList />} />
<Route path="/health/follow-up-records" element={<FollowUpRecordList />} />
<Route path="/health/consultations" element={<ConsultationList />} />
<Route path="/health/consultations/:id" element={<ConsultationDetail />} />
- Step 2: MainLayout.tsx 添加健康模块菜单组
import { MedicineBoxOutlined, TeamOutlined, HeartOutlined, CalendarOutlined, PhoneOutlined, CommentOutlined, TagsOutlined } from '@ant-design/icons';
const healthMenuItems: MenuItem[] = [
{ key: '/health/patients', icon: <TeamOutlined />, label: '患者管理' },
{ key: '/health/doctors', icon: <HeartOutlined />, label: '医护管理' },
{ key: '/health/appointments', icon: <CalendarOutlined />, label: '预约排班' },
{ key: '/health/follow-up-tasks', icon: <PhoneOutlined />, label: '随访管理' },
{ key: '/health/consultations', icon: <CommentOutlined />, label: '咨询管理' },
{ key: '/health/tags', icon: <TagsOutlined />, label: '标签管理' },
];
// 侧边栏布局中,在 bizMenuItems 和 sysMenuItems 之间插入:
<div className="erp-sidebar-group">健康管理</div>
{healthMenuItems.map(item => <SidebarMenuItem key={item.key} item={item} />)}
同时更新 routeTitleMap:
'/health/patients': '患者管理',
'/health/patients/:id': '患者详情',
'/health/tags': '标签管理',
// ... 其余 7 条
- Step 3: 验证编译
Run: cd apps/web && pnpm build
Expected: 无类型错误(页面组件尚未创建,此时使用占位文件)
- Step 4: 创建 10 个页面占位文件
每个页面文件只包含最小占位组件,如:
export default function PatientList() {
return <div>患者管理页面(开发中)</div>;
}
创建以下 10 个文件:
-
apps/web/src/pages/health/PatientList.tsx -
apps/web/src/pages/health/PatientDetail.tsx -
apps/web/src/pages/health/PatientTagManage.tsx -
apps/web/src/pages/health/DoctorList.tsx -
apps/web/src/pages/health/AppointmentList.tsx -
apps/web/src/pages/health/DoctorSchedule.tsx -
apps/web/src/pages/health/FollowUpTaskList.tsx -
apps/web/src/pages/health/FollowUpRecordList.tsx -
apps/web/src/pages/health/ConsultationList.tsx -
apps/web/src/pages/health/ConsultationDetail.tsx -
Step 5: 验证前端构建 + 浏览器可访问
Run: cd apps/web && pnpm dev
验证:浏览器访问 /#/health/patients 显示占位页面。
- Step 6: 提交
git add apps/web/src/
git commit -m "feat(web): 健康模块路由菜单 + 10 页面占位"
Chunk 4: Web 前端 10 页面实现(阶段 3 Phase 2-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 DesignForm) -
编辑患者
Modal(复用创建表单) -
批量打标按钮
-
Step 2: PatientTagManage.tsx(0.5 天)
功能:
-
标准 CRUD 表格(标签名 + 颜色 + 描述)
-
Ant Design
ColorPicker选择标签颜色 -
批量打标功能(
Transfer或Select多选患者) -
Step 3: PatientDetail.tsx(2 天)
功能:
-
顶部:患者摘要卡片(
Descriptions展示姓名/性别/年龄/状态/标签) -
Ant Design
Tabs5 个 Tab:- 基本信息 —
Descriptions展示 + 编辑Modal - 健康趋势 —
VitalSignsChart组件 + 时间范围Radio.Group(7d/30d/90d)+ 指标Select - 化验报告 — 报告卡片列表 +
ImagePreview - 就诊记录 — 嵌套列表(record_type 分组)
- 随访记录 — 嵌套列表 + 关联的随访记录
- 基本信息 —
-
Step 4: 验证编译 + 浏览器测试
Run: cd apps/web && pnpm build
浏览器:患者列表 → 点击进入详情 → 各 Tab 切换正常。
- Step 5: 提交
git add apps/web/src/pages/health/
git commit -m "feat(web): PatientList + PatientTagManage + PatientDetail 页面"
Task 13: DoctorList + AppointmentList + 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: 提交
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: 提交
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: 提交
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 个枚举校验测试
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_gender_valid() { assert!(validate_gender("male").is_ok()); }
#[test]
fn test_gender_invalid() { assert!(validate_gender("unknown").is_err()); }
#[test]
fn test_appointment_status_pending_to_confirmed() {
assert!(validate_appointment_status_transition("pending", "confirmed").is_ok());
}
#[test]
fn test_appointment_status_completed_to_pending() {
assert!(validate_appointment_status_transition("completed", "pending").is_err());
}
// ... 覆盖所有枚举和状态转换
}
- Step 2: 运行测试
Run: cargo test -p erp-health -- validation::tests
Expected: 全部通过
- Step 3: 提交
git add crates/erp-health/src/service/validation.rs
git commit -m "test(health): validation.rs 纯函数测试 20+ 用例"
Task 17: 核心 service 集成测试(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: 提交
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: 最终提交 + 推送
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 周