From 07f4ba41ba61b8323fef8f4f777150b170ff44dd Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 25 Apr 2026 08:58:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(health):=20=E7=A9=B7=E5=B0=BD=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E4=BF=AE=E5=A4=8D=20=E2=80=94=20=E6=9D=83=E9=99=90?= =?UTF-8?q?=E5=90=8C=E6=AD=A5/=E7=BC=96=E8=AF=91=E9=94=99=E8=AF=AF/?= =?UTF-8?q?=E5=89=8D=E7=AB=AFbug/=E5=AE=A1=E8=AE=A1=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审计发现并修复的问题: 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 实现 --- Cargo.lock | 130 ++ apps/web/src/api/health/articles.ts | 88 + apps/web/src/api/health/consultations.ts | 8 + apps/web/src/api/health/patients.ts | 1 + .../src/pages/health/ConsultationDetail.tsx | 8 +- .../src/pages/health/FollowUpRecordList.tsx | 15 +- .../web/src/pages/health/FollowUpTaskList.tsx | 11 +- crates/erp-health/src/crypto.rs | 79 + crates/erp-health/src/dto/consultation_dto.rs | 2 + crates/erp-health/src/dto/health_data_dto.rs | 5 +- crates/erp-health/src/dto/mod.rs | 5 + crates/erp-health/src/error.rs | 2 +- .../erp-health/src/handler/article_handler.rs | 8 +- .../src/handler/consultation_handler.rs | 30 +- .../erp-health/src/handler/doctor_handler.rs | 6 +- .../src/handler/follow_up_handler.rs | 6 +- .../src/handler/health_data_handler.rs | 17 +- .../erp-health/src/handler/patient_handler.rs | 8 +- crates/erp-health/src/module.rs | 56 +- .../src/service/appointment_service.rs | 14 + .../erp-health/src/service/article_service.rs | 5 + .../src/service/consultation_service.rs | 80 +- .../src/service/follow_up_service.rs | 20 +- .../src/service/health_data_service.rs | 381 +--- crates/erp-health/src/service/mod.rs | 1 + .../erp-health/src/service/patient_service.rs | 39 +- .../erp-health/src/service/trend_service.rs | 425 +++++ ...60425_000048_add_patient_id_number_hash.rs | 4 +- crates/erp-server/src/main.rs | 67 + .../2026-04-24-health-module-iteration.md | 1626 +++++++++++++++++ ...26-04-24-health-module-iteration-design.md | 671 +++++++ 31 files changed, 3373 insertions(+), 445 deletions(-) create mode 100644 apps/web/src/api/health/articles.ts create mode 100644 crates/erp-health/src/service/trend_service.rs create mode 100644 docs/superpowers/plans/2026-04-24-health-module-iteration.md create mode 100644 docs/superpowers/specs/2026-04-24-health-module-iteration-design.md diff --git a/Cargo.lock b/Cargo.lock index 711203c..1567164 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.7.8" @@ -382,6 +417,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "borsh" version = "1.6.1" @@ -527,6 +571,15 @@ dependencies = [ "winx", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.60" @@ -565,6 +618,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.6.0" @@ -980,6 +1043,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core", "typenum", ] @@ -1004,6 +1068,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "darling" version = "0.20.11" @@ -1215,12 +1288,16 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" name = "erp-auth" version = "0.1.0" dependencies = [ + "aes", "anyhow", "argon2", "async-trait", "axum", + "base64 0.22.1", + "cbc", "chrono", "erp-core", + "hex", "jsonwebtoken", "reqwest", "sea-orm", @@ -1277,14 +1354,19 @@ dependencies = [ name = "erp-health" version = "0.1.0" dependencies = [ + "aes-gcm", "async-trait", "axum", + "base64 0.22.1", "chrono", "erp-core", + "hex", + "hmac", "num-traits", "sea-orm", "serde", "serde_json", + "sha2", "thiserror 2.0.18", "tokio", "tracing", @@ -1755,6 +1837,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.33.0" @@ -2201,6 +2293,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "io-extras" version = "0.18.4" @@ -2707,6 +2809,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.78" @@ -2966,6 +3074,18 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -4873,6 +4993,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/apps/web/src/api/health/articles.ts b/apps/web/src/api/health/articles.ts new file mode 100644 index 0000000..6ab72ba --- /dev/null +++ b/apps/web/src/api/health/articles.ts @@ -0,0 +1,88 @@ +import client from '../client'; +import type { PaginatedResponse } from '../types'; + +// --- Types --- +export interface ArticleListItem { + id: string; + title: string; + summary?: string; + cover_image?: string; + category?: string; + author?: string; + published_at?: string; +} + +export interface Article extends ArticleListItem { + content?: string; + created_at: string; + updated_at: string; + version: number; +} + +export interface CreateArticleReq { + title: string; + summary?: string; + content?: string; + cover_image?: string; + category?: string; + author?: string; + published_at?: string; +} + +export interface UpdateArticleReq { + title?: string; + summary?: string; + content?: string; + cover_image?: string; + category?: string; + author?: string; + published_at?: string; + version: number; +} + +// --- API --- +export const articleApi = { + list: async (params: { + page?: number; + page_size?: number; + category?: string; + }) => { + const { data } = await client.get<{ + success: boolean; + data: PaginatedResponse; + }>('/health/articles', { params }); + return data.data; + }, + + get: async (id: string) => { + const { data } = await client.get<{ + success: boolean; + data: Article; + }>(`/health/articles/${id}`); + return data.data; + }, + + create: async (req: CreateArticleReq) => { + const { data } = await client.post<{ + success: boolean; + data: Article; + }>('/health/articles', req); + return data.data; + }, + + update: async (id: string, req: UpdateArticleReq) => { + const { data } = await client.put<{ + success: boolean; + data: Article; + }>(`/health/articles/${id}`, req); + return data.data; + }, + + delete: async (id: string) => { + const { data } = await client.delete<{ + success: boolean; + data: null; + }>(`/health/articles/${id}`); + return data.data; + }, +}; diff --git a/apps/web/src/api/health/consultations.ts b/apps/web/src/api/health/consultations.ts index f9f33a3..e31f9a2 100644 --- a/apps/web/src/api/health/consultations.ts +++ b/apps/web/src/api/health/consultations.ts @@ -65,6 +65,14 @@ export const consultationApi = { return data.data; }, + getSession: async (id: string) => { + const { data } = await client.get<{ + success: boolean; + data: Session; + }>(`/health/consultation-sessions/${id}`); + return data.data; + }, + closeSession: async ( id: string, req: { version: number }, diff --git a/apps/web/src/api/health/patients.ts b/apps/web/src/api/health/patients.ts index 857aca2..7eb20fc 100644 --- a/apps/web/src/api/health/patients.ts +++ b/apps/web/src/api/health/patients.ts @@ -13,6 +13,7 @@ export interface PatientListItem { source?: string; created_at: string; updated_at: string; + version: number; } export interface PatientDetail { diff --git a/apps/web/src/pages/health/ConsultationDetail.tsx b/apps/web/src/pages/health/ConsultationDetail.tsx index 556a9d1..82dab65 100644 --- a/apps/web/src/pages/health/ConsultationDetail.tsx +++ b/apps/web/src/pages/health/ConsultationDetail.tsx @@ -63,12 +63,10 @@ export default function ConsultationDetail() { if (!sessionId) return; setSessionLoading(true); try { - // Use the list endpoint to find our session - const result = await consultationApi.listSessions({ page: 1, page_size: 1 }); - const found = result.data.find((s) => s.id === sessionId); - if (found) setSession(found); + const result = await consultationApi.getSession(sessionId); + setSession(result); } catch { - // Session info is supplementary; don't block chat + message.error('加载会话信息失败'); } setSessionLoading(false); }, [sessionId]); diff --git a/apps/web/src/pages/health/FollowUpRecordList.tsx b/apps/web/src/pages/health/FollowUpRecordList.tsx index 4a32763..b07f699 100644 --- a/apps/web/src/pages/health/FollowUpRecordList.tsx +++ b/apps/web/src/pages/health/FollowUpRecordList.tsx @@ -4,7 +4,6 @@ import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'; import dayjs from 'dayjs'; import { followUpApi, type FollowUpRecord } from '../../api/health/followUp'; import { PatientSelect } from './components/PatientSelect'; -import { ExportButton } from './components/ExportButton'; const RESULT_MAP: Record = { normal: '正常', @@ -43,8 +42,9 @@ export default function FollowUpRecordList() { setTotal(result.total); } catch { message.error('加载随访记录失败'); + } finally { + setLoading(false); } - setLoading(false); }, []); useEffect(() => { @@ -81,12 +81,6 @@ export default function FollowUpRecordList() { })); }; - // Build export params - const exportParams: Record = {}; - if (query.patient_id) exportParams.patient_id = query.patient_id; - if (query.start_date) exportParams.start_date = query.start_date; - if (query.end_date) exportParams.end_date = query.end_date; - // --- Columns --- const columns: ColumnsType = [ { @@ -178,11 +172,6 @@ export default function FollowUpRecordList() { onChange={(val) => handlePatientChange(val)} placeholder="筛选患者" /> - { @@ -134,9 +135,13 @@ export default function FollowUpTaskList() { try { const values = await createForm.validateFields(); setCreateLoading(true); + const plannedDate = values.planned_date; await followUpApi.createTask({ - ...values, - planned_date: values.planned_date, + patient_id: values.patient_id, + follow_up_type: values.follow_up_type, + planned_date: dayjs.isDayjs(plannedDate) ? plannedDate.format('YYYY-MM-DD') : plannedDate, + assigned_to: values.assigned_to, + content_template: values.content_template, }); message.success('随访任务创建成功'); setCreateOpen(false); diff --git a/crates/erp-health/src/crypto.rs b/crates/erp-health/src/crypto.rs index 99d45a9..88217a0 100644 --- a/crates/erp-health/src/crypto.rs +++ b/crates/erp-health/src/crypto.rs @@ -88,3 +88,82 @@ impl HealthCrypto { hex::encode(mac.finalize().into_bytes()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn test_crypto() -> HealthCrypto { + HealthCrypto::dev_default() + } + + #[test] + fn encrypt_decrypt_roundtrip() { + let crypto = test_crypto(); + let plaintext = "110101199001011234"; + let encrypted = crypto.encrypt(plaintext).unwrap(); + let decrypted = crypto.decrypt(&encrypted).unwrap(); + assert_eq!(plaintext, decrypted); + } + + #[test] + fn encrypt_produces_different_ciphertexts() { + let crypto = test_crypto(); + let plaintext = "110101199001011234"; + let e1 = crypto.encrypt(plaintext).unwrap(); + let e2 = crypto.encrypt(plaintext).unwrap(); + assert_ne!(e1, e2); // 不同 nonce 导致不同密文 + } + + #[test] + fn decrypt_wrong_key_fails() { + let crypto1 = HealthCrypto::dev_default(); + let hex_key = "00".repeat(32); // 64 个 0 + let crypto2 = HealthCrypto::from_keys(&hex_key, &hex_key).unwrap(); + let encrypted = crypto1.encrypt("test").unwrap(); + assert!(crypto2.decrypt(&encrypted).is_err()); + } + + #[test] + fn hmac_hash_deterministic() { + let crypto = test_crypto(); + let hash1 = crypto.hmac_hash("110101199001011234"); + let hash2 = crypto.hmac_hash("110101199001011234"); + assert_eq!(hash1, hash2); + } + + #[test] + fn hmac_hash_different_inputs() { + let crypto = test_crypto(); + let h1 = crypto.hmac_hash("123456789012345678"); + let h2 = crypto.hmac_hash("987654321098765432"); + assert_ne!(h1, h2); + } + + #[test] + fn encrypt_empty_string() { + let crypto = test_crypto(); + let encrypted = crypto.encrypt("").unwrap(); + let decrypted = crypto.decrypt(&encrypted).unwrap(); + assert_eq!("", decrypted); + } + + #[test] + fn decrypt_too_short_fails() { + let crypto = test_crypto(); + let short = BASE64.encode(b"short"); + assert!(crypto.decrypt(&short).is_err()); + } + + #[test] + fn from_keys_invalid_hex() { + let result = HealthCrypto::from_keys("not-hex", "not-hex"); + assert!(result.is_err()); + } + + #[test] + fn from_keys_wrong_length() { + let result = HealthCrypto::from_keys("ab", "cd"); + assert!(result.is_err()); + } +} diff --git a/crates/erp-health/src/dto/consultation_dto.rs b/crates/erp-health/src/dto/consultation_dto.rs index a2ce9df..8b09a2a 100644 --- a/crates/erp-health/src/dto/consultation_dto.rs +++ b/crates/erp-health/src/dto/consultation_dto.rs @@ -14,6 +14,8 @@ pub struct SessionResp { pub unread_count_patient: i32, pub unread_count_doctor: i32, pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] diff --git a/crates/erp-health/src/dto/health_data_dto.rs b/crates/erp-health/src/dto/health_data_dto.rs index ee63851..270f284 100644 --- a/crates/erp-health/src/dto/health_data_dto.rs +++ b/crates/erp-health/src/dto/health_data_dto.rs @@ -4,7 +4,10 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; -/// 用 f64 替代 Decimal 以满足 utoipa ToSchema +/// 用 f64 表示 Decimal 值以满足 utoipa ToSchema 要求。 +/// 对于健康数值(血压 60-200mmHg、血糖 3.9-11.1mmol/L、体重 30-300kg), +/// f64 的 15 位有效数字精度完全足够,不存在实际精度丢失风险。 +/// 数据库层仍使用 SeaORM Decimal 类型,转换仅在 DTO 边界进行。 type Decimal = f64; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] diff --git a/crates/erp-health/src/dto/mod.rs b/crates/erp-health/src/dto/mod.rs index 5ea0aa1..d50513c 100644 --- a/crates/erp-health/src/dto/mod.rs +++ b/crates/erp-health/src/dto/mod.rs @@ -5,3 +5,8 @@ pub mod doctor_dto; pub mod follow_up_dto; pub mod health_data_dto; pub mod patient_dto; + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct DeleteWithVersion { + pub version: i32, +} diff --git a/crates/erp-health/src/error.rs b/crates/erp-health/src/error.rs index be7f41c..2074810 100644 --- a/crates/erp-health/src/error.rs +++ b/crates/erp-health/src/error.rs @@ -86,7 +86,7 @@ impl From for HealthError { impl From for HealthError { fn from(err: AppError) -> Self { - HealthError::DbError(err.to_string()) + HealthError::Validation(err.to_string()) } } diff --git a/crates/erp-health/src/handler/article_handler.rs b/crates/erp-health/src/handler/article_handler.rs index 9b80ac8..8c3a7b8 100644 --- a/crates/erp-health/src/handler/article_handler.rs +++ b/crates/erp-health/src/handler/article_handler.rs @@ -77,16 +77,22 @@ where Ok(Json(ApiResponse::ok(result))) } +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct DeleteArticleReq { + pub version: i32, +} + pub async fn delete_article( State(state): State, Extension(ctx): Extension, Path(id): Path, + Json(req): Json, ) -> Result>, AppError> where HealthState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.articles.manage")?; - article_service::delete_article(&state, ctx.tenant_id, id, Some(ctx.user_id)).await?; + article_service::delete_article(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version).await?; Ok(Json(ApiResponse::ok(()))) } diff --git a/crates/erp-health/src/handler/consultation_handler.rs b/crates/erp-health/src/handler/consultation_handler.rs index 0b010b8..c92f419 100644 --- a/crates/erp-health/src/handler/consultation_handler.rs +++ b/crates/erp-health/src/handler/consultation_handler.rs @@ -1,5 +1,6 @@ use axum::Extension; use axum::extract::{FromRef, Json, Path, Query, State}; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use serde::Deserialize; use utoipa::IntoParams; use uuid::Uuid; @@ -44,6 +45,8 @@ pub struct ExportSessionsParams { pub status: Option, pub patient_id: Option, pub doctor_id: Option, + pub page: Option, + pub page_size: Option, } pub async fn create_session( @@ -83,6 +86,20 @@ where Ok(Json(ApiResponse::ok(result))) } +pub async fn get_session( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.consultation.list")?; + let result = consultation_service::get_session(&state, ctx.tenant_id, id).await?; + Ok(Json(ApiResponse::ok(result))) +} + pub async fn list_messages( State(state): State, Extension(ctx): Extension, @@ -131,10 +148,18 @@ where S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.consultation.manage")?; + let is_doctor = crate::entity::doctor_profile::Entity::find() + .filter(crate::entity::doctor_profile::Column::UserId.eq(ctx.user_id)) + .filter(crate::entity::doctor_profile::Column::TenantId.eq(ctx.tenant_id)) + .filter(crate::entity::doctor_profile::Column::DeletedAt.is_null()) + .one(&state.db) + .await + .map_err(|e| AppError::Internal(e.to_string()))? + .is_some(); let mut msg_req = CreateMessageReq { session_id: req.session_id, sender_id: ctx.user_id, - sender_role: "doctor".to_string(), + sender_role: if is_doctor { "doctor" } else { "patient" }.to_string(), content_type: req.content_type, content: req.content, }; @@ -150,7 +175,7 @@ pub async fn export_sessions( State(state): State, Extension(ctx): Extension, Query(params): Query, -) -> Result>>, AppError> +) -> Result>>, AppError> where HealthState: FromRef, S: Clone + Send + Sync + 'static, @@ -158,6 +183,7 @@ where require_permission(&ctx, "health.consultation.list")?; let result = consultation_service::export_sessions( &state, ctx.tenant_id, params.status, params.patient_id, params.doctor_id, + params.page, params.page_size, ) .await?; Ok(Json(ApiResponse::ok(result))) diff --git a/crates/erp-health/src/handler/doctor_handler.rs b/crates/erp-health/src/handler/doctor_handler.rs index 1bda341..3235889 100644 --- a/crates/erp-health/src/handler/doctor_handler.rs +++ b/crates/erp-health/src/handler/doctor_handler.rs @@ -9,6 +9,7 @@ use erp_core::rbac::require_permission; use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use crate::dto::doctor_dto::*; +use crate::dto::DeleteWithVersion; use crate::service::doctor_service; use crate::state::HealthState; @@ -28,11 +29,6 @@ pub struct UpdateDoctorWithVersion { pub version: i32, } -#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] -pub struct DeleteWithVersion { - pub version: i32, -} - pub async fn list_doctors( State(state): State, Extension(ctx): Extension, diff --git a/crates/erp-health/src/handler/follow_up_handler.rs b/crates/erp-health/src/handler/follow_up_handler.rs index f697e84..b16dcd6 100644 --- a/crates/erp-health/src/handler/follow_up_handler.rs +++ b/crates/erp-health/src/handler/follow_up_handler.rs @@ -9,6 +9,7 @@ use erp_core::rbac::require_permission; use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use crate::dto::follow_up_dto::*; +use crate::dto::DeleteWithVersion; use crate::service::follow_up_service; use crate::state::HealthState; @@ -36,11 +37,6 @@ pub struct UpdateFollowUpTaskWithVersion { pub version: i32, } -#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] -pub struct DeleteWithVersion { - pub version: i32, -} - pub async fn list_tasks( State(state): State, Extension(ctx): Extension, diff --git a/crates/erp-health/src/handler/health_data_handler.rs b/crates/erp-health/src/handler/health_data_handler.rs index 08284e6..5ecb476 100644 --- a/crates/erp-health/src/handler/health_data_handler.rs +++ b/crates/erp-health/src/handler/health_data_handler.rs @@ -9,7 +9,9 @@ use erp_core::rbac::require_permission; use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use crate::dto::health_data_dto::*; +use crate::dto::DeleteWithVersion; use crate::service::health_data_service; +use crate::service::trend_service; use crate::state::HealthState; // --------------------------------------------------------------------------- @@ -34,11 +36,6 @@ pub struct GenerateTrendReq { pub period_end: chrono::NaiveDate, } -#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] -pub struct DeleteWithVersion { - pub version: i32, -} - #[derive(Debug, serde::Deserialize, utoipa::ToSchema)] pub struct UpdateWithVersion { pub data: T, @@ -299,7 +296,7 @@ where require_permission(&ctx, "health.health-data.list")?; let page = params.page.unwrap_or(1); let page_size = params.page_size.unwrap_or(20); - let result = health_data_service::list_trends( + let result = trend_service::list_trends( &state, ctx.tenant_id, patient_id, page, page_size, ) .await?; @@ -317,7 +314,7 @@ where S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.health-data.manage")?; - let result = health_data_service::generate_trend( + let result = trend_service::generate_trend( &state, ctx.tenant_id, patient_id, Some(ctx.user_id), req.period_start, req.period_end, ) .await?; @@ -335,7 +332,7 @@ where S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.health-data.list")?; - let result = health_data_service::get_indicator_timeseries( + let result = trend_service::get_indicator_timeseries( &state, ctx.tenant_id, patient_id, indicator, params.start_date, params.end_date, ) .await?; @@ -356,7 +353,7 @@ where S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.health-data.list")?; - let result = health_data_service::get_mini_trend( + let result = trend_service::get_mini_trend( &state, ctx.tenant_id, ctx.user_id, params.indicator, params.range, ) .await?; @@ -376,7 +373,7 @@ where S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.health-data.list")?; - let result = health_data_service::get_mini_today( + let result = trend_service::get_mini_today( &state, ctx.tenant_id, ctx.user_id, ) .await?; diff --git a/crates/erp-health/src/handler/patient_handler.rs b/crates/erp-health/src/handler/patient_handler.rs index 3117782..c3a21d9 100644 --- a/crates/erp-health/src/handler/patient_handler.rs +++ b/crates/erp-health/src/handler/patient_handler.rs @@ -12,6 +12,7 @@ use crate::dto::patient_dto::{ CreatePatientReq, FamilyMemberReq, FamilyMemberResp, ManageTagsReq, PatientResp, UpdatePatientReq, }; +use crate::dto::DeleteWithVersion; use crate::service::patient_service; use crate::state::HealthState; @@ -30,11 +31,6 @@ pub struct AssignDoctorReq { pub relationship_type: Option, } -#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] -pub struct DeleteWithVersion { - pub version: i32, -} - pub async fn list_patients( State(state): State, Extension(ctx): Extension, @@ -278,7 +274,7 @@ where S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.patient.manage")?; - patient_service::remove_doctor(&state, ctx.tenant_id, patient_id, doctor_id).await?; + patient_service::remove_doctor(&state, ctx.tenant_id, patient_id, doctor_id, Some(ctx.user_id)).await?; Ok(Json(ApiResponse::ok(()))) } diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index 8f3a72b..42fa305 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -17,19 +17,26 @@ impl HealthModule { Self } - /// 启动定时逾期随访检查(每 6 小时运行一次) - pub fn start_overdue_checker(db: sea_orm::DatabaseConnection) { + /// 启动定时逾期随访检查(每 6 小时运行一次),返回 JoinHandle 用于优雅关闭 + pub fn start_overdue_checker(db: sea_orm::DatabaseConnection) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_secs(6 * 3600)); 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, "随访逾期检查失败"), + tokio::select! { + _ = interval.tick() => { + 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, "随访逾期检查失败"), + } + } + _ = tokio::signal::ctrl_c() => { + tracing::info!("随访逾期检查任务收到关闭信号,正在停止"); + break; + } } } - }); + }) } pub fn public_routes() -> Router @@ -190,6 +197,14 @@ impl HealthModule { axum::routing::get(consultation_handler::list_sessions) .post(consultation_handler::create_session), ) + .route( + "/health/consultation-sessions/export", + axum::routing::get(consultation_handler::export_sessions), + ) + .route( + "/health/consultation-sessions/{id}", + axum::routing::get(consultation_handler::get_session), + ) .route( "/health/consultation-sessions/{id}/messages", axum::routing::get(consultation_handler::list_messages), @@ -202,10 +217,6 @@ impl HealthModule { "/health/consultation-messages", axum::routing::post(consultation_handler::create_message), ) - .route( - "/health/consultation-sessions/export", - axum::routing::get(consultation_handler::export_sessions), - ) // 医护管理 .route( "/health/doctors", @@ -258,14 +269,23 @@ impl ErpModule for HealthModule { } async fn on_startup(&self, ctx: &erp_core::module::ModuleContext) -> erp_core::error::AppResult<()> { - let crypto = crate::crypto::HealthCrypto::from_keys( + let crypto = match crate::crypto::HealthCrypto::from_keys( &std::env::var("HEALTH_AES_KEY").unwrap_or_default(), &std::env::var("HEALTH_HMAC_KEY").unwrap_or_default(), - ) - .unwrap_or_else(|_| { - tracing::warn!("HEALTH_AES_KEY / HEALTH_HMAC_KEY 未设置或无效,使用开发默认密钥"); - crate::crypto::HealthCrypto::dev_default() - }); + ) { + Ok(c) => c, + Err(_) => { + #[cfg(debug_assertions)] + { + tracing::warn!("HEALTH_AES_KEY / HEALTH_HMAC_KEY 未设置或无效,使用开发默认密钥"); + crate::crypto::HealthCrypto::dev_default() + } + #[cfg(not(debug_assertions))] + { + panic!("HEALTH_AES_KEY 和 HEALTH_HMAC_KEY 必须设置为有效的 64 字符 hex 字符串(生产环境不允许回退到开发密钥)"); + } + } + }; let state = crate::state::HealthState { db: ctx.db.clone(), diff --git a/crates/erp-health/src/service/appointment_service.rs b/crates/erp-health/src/service/appointment_service.rs index 4035dc5..9d1cf92 100644 --- a/crates/erp-health/src/service/appointment_service.rs +++ b/crates/erp-health/src/service/appointment_service.rs @@ -362,6 +362,13 @@ pub async fn create_schedule( version: Set(1), }; let m = active.insert(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "doctor_schedule.created", "doctor_schedule") + .with_resource_id(m.id), + &state.db, + ).await; + Ok(ScheduleResp { id: m.id, doctor_id: m.doctor_id, schedule_date: m.schedule_date, period_type: m.period_type, start_time: m.start_time, end_time: m.end_time, @@ -410,6 +417,13 @@ pub async fn update_schedule( active.version = Set(next_ver); let m = active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "doctor_schedule.updated", "doctor_schedule") + .with_resource_id(m.id), + &state.db, + ).await; + Ok(ScheduleResp { id: m.id, doctor_id: m.doctor_id, schedule_date: m.schedule_date, period_type: m.period_type, start_time: m.start_time, end_time: m.end_time, diff --git a/crates/erp-health/src/service/article_service.rs b/crates/erp-health/src/service/article_service.rs index 5390568..2cd9e9e 100644 --- a/crates/erp-health/src/service/article_service.rs +++ b/crates/erp-health/src/service/article_service.rs @@ -192,6 +192,7 @@ pub async fn delete_article( tenant_id: Uuid, id: Uuid, operator_id: Option, + expected_version: i32, ) -> HealthResult<()> { let model = article::Entity::find() .filter(article::Column::Id.eq(id)) @@ -201,10 +202,14 @@ pub async fn delete_article( .await? .ok_or(HealthError::ArticleNotFound)?; + let next_ver = check_version(expected_version, model.version) + .map_err(|_| HealthError::VersionMismatch)?; + let mut active: article::ActiveModel = model.into(); active.deleted_at = Set(Some(Utc::now())); active.updated_at = Set(Utc::now()); active.updated_by = Set(operator_id); + active.version = Set(next_ver); active.update(&state.db).await?; audit_service::record( diff --git a/crates/erp-health/src/service/consultation_service.rs b/crates/erp-health/src/service/consultation_service.rs index fc3a9ca..a6da53c 100644 --- a/crates/erp-health/src/service/consultation_service.rs +++ b/crates/erp-health/src/service/consultation_service.rs @@ -21,6 +21,18 @@ use crate::state::HealthState; // 咨询会话 // --------------------------------------------------------------------------- +fn model_to_session_resp(m: consultation_session::Model) -> SessionResp { + SessionResp { + id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id, + consultation_type: m.consultation_type, status: m.status, + last_message_at: m.last_message_at, + unread_count_patient: m.unread_count_patient, + unread_count_doctor: m.unread_count_doctor, + created_at: m.created_at, updated_at: m.updated_at, + version: m.version, + } +} + pub async fn create_session( state: &HealthState, tenant_id: Uuid, @@ -73,14 +85,24 @@ pub async fn create_session( &state.db, ).await; - Ok(SessionResp { - id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id, - consultation_type: m.consultation_type, status: m.status, - last_message_at: m.last_message_at, - unread_count_patient: m.unread_count_patient, - unread_count_doctor: m.unread_count_doctor, - created_at: m.created_at, - }) + Ok(model_to_session_resp(m)) +} + +/// 获取单个咨询会话 +pub async fn get_session( + state: &HealthState, + tenant_id: Uuid, + session_id: Uuid, +) -> HealthResult { + let model = consultation_session::Entity::find() + .filter(consultation_session::Column::Id.eq(session_id)) + .filter(consultation_session::Column::TenantId.eq(tenant_id)) + .filter(consultation_session::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::ConsultationNotFound)?; + + Ok(model_to_session_resp(model)) } pub async fn list_sessions( @@ -112,14 +134,7 @@ pub async fn list_sessions( .await?; let total_pages = total.div_ceil(limit.max(1)); - let data = models.into_iter().map(|m| SessionResp { - id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id, - consultation_type: m.consultation_type, status: m.status, - last_message_at: m.last_message_at, - unread_count_patient: m.unread_count_patient, - unread_count_doctor: m.unread_count_doctor, - created_at: m.created_at, - }).collect(); + let data = models.into_iter().map(model_to_session_resp).collect(); Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) } @@ -168,14 +183,7 @@ pub async fn close_session( &state.db, ).await; - Ok(SessionResp { - id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id, - consultation_type: m.consultation_type, status: m.status, - last_message_at: m.last_message_at, - unread_count_patient: m.unread_count_patient, - unread_count_doctor: m.unread_count_doctor, - created_at: m.created_at, - }) + Ok(model_to_session_resp(m)) } pub async fn export_sessions( @@ -184,7 +192,13 @@ pub async fn export_sessions( status: Option, patient_id: Option, doctor_id: Option, -) -> HealthResult> { + page: Option, + page_size: Option, +) -> HealthResult> { + let limit = page_size.unwrap_or(100).min(500); + let page_num = page.unwrap_or(1); + let offset = page_num.saturating_sub(1) * limit; + let mut query = consultation_session::Entity::find() .filter(consultation_session::Column::TenantId.eq(tenant_id)) .filter(consultation_session::Column::DeletedAt.is_null()); @@ -193,20 +207,18 @@ pub async fn export_sessions( if let Some(pid) = patient_id { query = query.filter(consultation_session::Column::PatientId.eq(pid)); } if let Some(did) = doctor_id { query = query.filter(consultation_session::Column::DoctorId.eq(did)); } + let total = query.clone().count(&state.db).await?; let models = query .order_by_desc(consultation_session::Column::CreatedAt) - .limit(10000) + .offset(offset) + .limit(limit) .all(&state.db) .await?; - Ok(models.into_iter().map(|m| SessionResp { - id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id, - consultation_type: m.consultation_type, status: m.status, - last_message_at: m.last_message_at, - unread_count_patient: m.unread_count_patient, - unread_count_doctor: m.unread_count_doctor, - created_at: m.created_at, - }).collect()) + let total_pages = total.div_ceil(limit.max(1)); + let data = models.into_iter().map(model_to_session_resp).collect(); + + Ok(PaginatedResponse { data, total, page: page_num, page_size: limit, total_pages }) } // --------------------------------------------------------------------------- diff --git a/crates/erp-health/src/service/follow_up_service.rs b/crates/erp-health/src/service/follow_up_service.rs index 30473d0..8d45230 100644 --- a/crates/erp-health/src/service/follow_up_service.rs +++ b/crates/erp-health/src/service/follow_up_service.rs @@ -180,6 +180,13 @@ pub async fn update_task( active.version = Set(next_ver); let m = active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "follow_up_task.updated", "follow_up_task") + .with_resource_id(m.id), + &state.db, + ).await; + Ok(FollowUpTaskResp { id: m.id, patient_id: m.patient_id, assigned_to: m.assigned_to, follow_up_type: m.follow_up_type, planned_date: m.planned_date, @@ -213,6 +220,13 @@ pub async fn delete_task( active.updated_by = Set(operator_id); active.version = Set(next_ver); active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "follow_up_task.deleted", "follow_up_task") + .with_resource_id(task_id), + &state.db, + ).await; + Ok(()) } @@ -265,11 +279,12 @@ pub async fn create_record( let task_patient_id = task.patient_id; let task_assigned_to = task.assigned_to; let task_follow_up_type = task.follow_up_type.clone(); + let current_version = task.version; let mut task_active: follow_up_task::ActiveModel = task.into(); task_active.status = Set("completed".to_string()); task_active.updated_at = Set(now); task_active.updated_by = Set(operator_id); - task_active.version = Set(task_active.version.unwrap() + 1); + task_active.version = Set(current_version + 1); task_active.update(&txn).await?; // 当 next_follow_up_date 不为空时,自动创建后续随访任务 @@ -392,10 +407,11 @@ pub async fn complete_task_by_system( match model { Some(m) if m.status == "pending" || m.status == "in_progress" => { + let current_version = m.version; let mut active: follow_up_task::ActiveModel = m.into(); active.status = Set("completed".to_string()); active.updated_at = Set(Utc::now()); - active.version = Set(active.version.unwrap() + 1); + active.version = Set(current_version + 1); active.update(db).await?; Ok(()) } diff --git a/crates/erp-health/src/service/health_data_service.rs b/crates/erp-health/src/service/health_data_service.rs index f52bfc3..5265271 100644 --- a/crates/erp-health/src/service/health_data_service.rs +++ b/crates/erp-health/src/service/health_data_service.rs @@ -1,10 +1,10 @@ -//! 健康数据 Service — 体征记录、化验报告、体检记录、趋势分析 +//! 健康数据 Service — 体征记录、化验报告、体检记录 use chrono::Utc; use erp_core::audit::AuditLog; use erp_core::audit_service; use erp_core::events::DomainEvent; -use num_traits::cast::ToPrimitive; +use num_traits::ToPrimitive; use sea_orm::entity::prelude::*; use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect}; use uuid::Uuid; @@ -13,7 +13,7 @@ use erp_core::error::check_version; use erp_core::types::PaginatedResponse; use crate::dto::health_data_dto::*; -use crate::entity::{health_record, health_trend, lab_report, patient, vital_signs}; +use crate::entity::{health_record, lab_report, patient, vital_signs}; use crate::error::{HealthError, HealthResult}; use crate::service::validation::validate_record_type; use crate::state::HealthState; @@ -164,6 +164,13 @@ pub async fn update_vital_signs( active.version = Set(next_ver); let m = active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "vital_signs.updated", "vital_signs") + .with_resource_id(m.id), + &state.db, + ).await; + Ok(VitalSignsResp { id: m.id, patient_id: m.patient_id, record_date: m.record_date, systolic_bp_morning: m.systolic_bp_morning, diastolic_bp_morning: m.diastolic_bp_morning, @@ -200,6 +207,13 @@ pub async fn delete_vital_signs( active.updated_by = Set(operator_id); active.version = Set(next_ver); active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "vital_signs.deleted", "vital_signs") + .with_resource_id(vital_signs_id), + &state.db, + ).await; + Ok(()) } @@ -328,6 +342,13 @@ pub async fn update_lab_report( active.version = Set(next_ver); let m = active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "lab_report.updated", "lab_report") + .with_resource_id(m.id), + &state.db, + ).await; + Ok(LabReportResp { id: m.id, patient_id: m.patient_id, report_date: m.report_date, report_type: m.report_type, indicators: m.indicators, @@ -360,6 +381,13 @@ pub async fn delete_lab_report( active.updated_by = Set(operator_id); active.version = Set(next_ver); active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "lab_report.deleted", "lab_report") + .with_resource_id(report_id), + &state.db, + ).await; + Ok(()) } @@ -486,6 +514,13 @@ pub async fn update_health_record( active.version = Set(next_ver); let m = active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "health_record.updated", "health_record") + .with_resource_id(m.id), + &state.db, + ).await; + Ok(HealthRecordResp { id: m.id, patient_id: m.patient_id, record_type: m.record_type, record_date: m.record_date, source: m.source, @@ -518,338 +553,12 @@ pub async fn delete_health_record( active.updated_by = Set(operator_id); active.version = Set(next_ver); active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "health_record.deleted", "health_record") + .with_resource_id(record_id), + &state.db, + ).await; + Ok(()) } - -// --------------------------------------------------------------------------- -// 趋势分析 (Trends) -// --------------------------------------------------------------------------- - -pub async fn list_trends( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, - page: u64, - page_size: u64, -) -> HealthResult> { - let limit = page_size.min(100); - let offset = page.saturating_sub(1) * limit; - - let query = health_trend::Entity::find() - .filter(health_trend::Column::TenantId.eq(tenant_id)) - .filter(health_trend::Column::PatientId.eq(patient_id)) - .filter(health_trend::Column::DeletedAt.is_null()); - - let total = query.clone().count(&state.db).await?; - let models = query - .order_by_desc(health_trend::Column::CreatedAt) - .offset(offset) - .limit(limit) - .all(&state.db) - .await?; - - let total_pages = total.div_ceil(limit.max(1)); - let data = models.into_iter().map(|m| TrendResp { - id: m.id, patient_id: m.patient_id, - period_start: m.period_start, period_end: m.period_end, - indicator_summary: m.indicator_summary, abnormal_items: m.abnormal_items, - generation_type: m.generation_type, report_file_url: m.report_file_url, - }).collect(); - - Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) -} - -pub async fn generate_trend( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, - operator_id: Option, - period_start: chrono::NaiveDate, - period_end: chrono::NaiveDate, -) -> HealthResult { - // 汇总该时间段内的体征数据 - let vitals = vital_signs::Entity::find() - .filter(vital_signs::Column::TenantId.eq(tenant_id)) - .filter(vital_signs::Column::PatientId.eq(patient_id)) - .filter(vital_signs::Column::DeletedAt.is_null()) - .filter(vital_signs::Column::RecordDate.gte(period_start)) - .filter(vital_signs::Column::RecordDate.lte(period_end)) - .all(&state.db) - .await?; - - let summary = serde_json::json!({ - "period": { "start": period_start, "end": period_end }, - "record_count": vitals.len(), - "avg_heart_rate": vitals.iter().filter_map(|v| v.heart_rate).sum::() as f64 - / vitals.iter().filter(|v| v.heart_rate.is_some()).count().max(1) as f64, - }); - - let now = Utc::now(); - let active = health_trend::ActiveModel { - id: Set(Uuid::now_v7()), - tenant_id: Set(tenant_id), - patient_id: Set(patient_id), - period_start: Set(period_start), - period_end: Set(period_end), - indicator_summary: Set(Some(summary)), - abnormal_items: Set(None), - generation_type: Set("auto".to_string()), - report_file_url: Set(None), - created_at: Set(now), - updated_at: Set(now), - created_by: Set(operator_id), - updated_by: Set(operator_id), - deleted_at: Set(None), - version: Set(1), - }; - - let m = active.insert(&state.db).await?; - Ok(TrendResp { - id: m.id, patient_id: m.patient_id, - period_start: m.period_start, period_end: m.period_end, - indicator_summary: m.indicator_summary, abnormal_items: m.abnormal_items, - generation_type: m.generation_type, report_file_url: m.report_file_url, - }) -} - -pub async fn get_indicator_timeseries( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, - indicator: String, - start_date: Option, - end_date: Option, -) -> HealthResult { - let mut query = vital_signs::Entity::find() - .filter(vital_signs::Column::TenantId.eq(tenant_id)) - .filter(vital_signs::Column::PatientId.eq(patient_id)) - .filter(vital_signs::Column::DeletedAt.is_null()); - - if let Some(sd) = start_date { - query = query.filter(vital_signs::Column::RecordDate.gte(sd)); - } - if let Some(ed) = end_date { - query = query.filter(vital_signs::Column::RecordDate.lte(ed)); - } - - let vitals = query - .order_by_asc(vital_signs::Column::RecordDate) - .all(&state.db) - .await?; - - let data: Vec<(chrono::NaiveDate, f64)> = vitals.into_iter().filter_map(|v| { - let val = match indicator.as_str() { - "heart_rate" => v.heart_rate.map(|x| x as f64), - "weight" => v.weight.map(|d| d.to_f64().unwrap_or(0.0)), - "blood_sugar" => v.blood_sugar.map(|d| d.to_f64().unwrap_or(0.0)), - "systolic_bp_morning" => v.systolic_bp_morning.map(|x| x as f64), - "diastolic_bp_morning" => v.diastolic_bp_morning.map(|x| x as f64), - "systolic_bp_evening" => v.systolic_bp_evening.map(|x| x as f64), - "diastolic_bp_evening" => v.diastolic_bp_evening.map(|x| x as f64), - _ => None, - }; - val.map(|fv| (v.record_date, fv)) - }).collect(); - - Ok(IndicatorTimeseriesResp { indicator, data }) -} - -// --------------------------------------------------------------------------- -// 小程序趋势查询(通过 user_id 关联 patient) -// --------------------------------------------------------------------------- - -/// 根据 user_id 查找关联的 patient_id。 -/// patient 表的 user_id 字段关联 erp-auth 的用户。 -/// 如果未关联则返回 Ok(None)。 -async fn find_patient_by_user_id( - state: &HealthState, - tenant_id: Uuid, - user_id: Uuid, -) -> HealthResult> { - let patient_model = patient::Entity::find() - .filter(patient::Column::TenantId.eq(tenant_id)) - .filter(patient::Column::UserId.eq(user_id)) - .filter(patient::Column::DeletedAt.is_null()) - .one(&state.db) - .await?; - - Ok(patient_model.map(|p| p.id)) -} - -/// 解析 range 参数为天数,默认 7 天。 -/// 支持 "7d", "30d", "90d" 格式。 -fn parse_range_days(range: &Option) -> i64 { - match range.as_deref() { - Some("30d") => 30, - Some("90d") => 90, - // 默认 7 天(包括 "7d" 和 None) - _ => 7, - } -} - -/// 小程序趋势查询:通过当前用户的 user_id 关联 patient,查询指定指标的时间序列。 -/// -/// 逻辑流程: -/// 1. 解析 range 参数计算 start_date/end_date -/// 2. 通过 user_id 查找关联的 patient(patient.user_id 字段) -/// 3. 复用 get_indicator_timeseries 的查询逻辑 -/// 4. 转换为 DataPoint 格式返回 -pub async fn get_mini_trend( - state: &HealthState, - tenant_id: Uuid, - user_id: Uuid, - indicator: String, - range: Option, -) -> HealthResult { - // 1. 通过 user_id 查找关联的 patient - let patient_id = find_patient_by_user_id(state, tenant_id, user_id).await?; - - // 如果用户未关联 patient,返回空数据 - let Some(patient_id) = patient_id else { - return Ok(MiniTrendResp { - indicator, - data_points: vec![], - }); - }; - - // 2. 根据 range 计算日期范围 - let days = parse_range_days(&range); - let today = chrono::Local::now().date_naive(); - let start_date = today - chrono::Duration::days(days); - let end_date = today; - - // 3. 复用已有逻辑查询时间序列数据 - let timeseries = get_indicator_timeseries( - state, - tenant_id, - patient_id, - indicator.clone(), - Some(start_date), - Some(end_date), - ) - .await?; - - // 4. 转换为 DataPoint 格式 - let data_points = timeseries - .data - .into_iter() - .map(|(date, value)| DataPoint { - date: date.to_string(), - value, - }) - .collect(); - - Ok(MiniTrendResp { - indicator, - data_points, - }) -} - -// --------------------------------------------------------------------------- -// 小程序今日体征摘要 -// --------------------------------------------------------------------------- - -/// 根据参考范围计算指标状态 -fn compute_status(value: f64, low: f64, high: f64) -> &'static str { - if value < low { - "low" - } else if value > high { - "high" - } else { - "normal" - } -} - -/// 查询今日最新体征记录并生成摘要 -pub async fn get_mini_today( - state: &HealthState, - tenant_id: Uuid, - user_id: Uuid, -) -> HealthResult { - let patient_id = find_patient_by_user_id(state, tenant_id, user_id).await?; - - let Some(patient_id) = patient_id else { - return Ok(MiniTodayResp { - blood_pressure: None, - heart_rate: None, - blood_sugar: None, - weight: None, - }); - }; - - let today = chrono::Local::now().date_naive(); - - // 查询今日最新体征记录 - let vital = vital_signs::Entity::find() - .filter(vital_signs::Column::TenantId.eq(tenant_id)) - .filter(vital_signs::Column::PatientId.eq(patient_id)) - .filter(vital_signs::Column::DeletedAt.is_null()) - .filter(vital_signs::Column::RecordDate.eq(today)) - .order_by_desc(vital_signs::Column::CreatedAt) - .one(&state.db) - .await?; - - let Some(v) = vital else { - return Ok(MiniTodayResp { - blood_pressure: None, - heart_rate: None, - blood_sugar: None, - weight: None, - }); - }; - - // 构建各指标摘要,优先使用晨间数据 - let blood_pressure = v.systolic_bp_morning.and_then(|sys| { - v.diastolic_bp_morning.map(|dia| { - let status = compute_status(sys as f64, 90.0, 140.0); - IndicatorSummary { - value: sys as f64, - status: status.to_string(), - reference_range: Some("90-140/60-90".to_string()), - systolic: Some(sys as f64), - diastolic: Some(dia as f64), - } - }) - }); - - let heart_rate = v.heart_rate.map(|hr| { - let status = compute_status(hr as f64, 60.0, 100.0); - IndicatorSummary { - value: hr as f64, - status: status.to_string(), - reference_range: Some("60-100".to_string()), - systolic: None, - diastolic: None, - } - }); - - let blood_sugar = v.blood_sugar.map(|bs| { - let val = bs.to_f64().unwrap_or(0.0); - let status = compute_status(val, 3.9, 6.1); - IndicatorSummary { - value: val, - status: status.to_string(), - reference_range: Some("3.9-6.1".to_string()), - systolic: None, - diastolic: None, - } - }); - - let weight = v.weight.map(|w| { - let val = w.to_f64().unwrap_or(0.0); - IndicatorSummary { - value: val, - status: "normal".to_string(), // 体重无通用参考范围 - reference_range: None, - systolic: None, - diastolic: None, - } - }); - - Ok(MiniTodayResp { - blood_pressure, - heart_rate, - blood_sugar, - weight, - }) -} diff --git a/crates/erp-health/src/service/mod.rs b/crates/erp-health/src/service/mod.rs index 04d5647..cc27787 100644 --- a/crates/erp-health/src/service/mod.rs +++ b/crates/erp-health/src/service/mod.rs @@ -6,4 +6,5 @@ pub mod follow_up_service; pub mod health_data_service; pub mod patient_service; pub mod seed; +pub mod trend_service; pub mod validation; diff --git a/crates/erp-health/src/service/patient_service.rs b/crates/erp-health/src/service/patient_service.rs index ec24709..92ab447 100644 --- a/crates/erp-health/src/service/patient_service.rs +++ b/crates/erp-health/src/service/patient_service.rs @@ -476,6 +476,13 @@ pub async fn create_family_member( }; let model = active.insert(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "patient.family_member_created", "patient_family_member") + .with_resource_id(model.id), + &state.db, + ).await; + Ok(FamilyMemberResp { id: model.id, patient_id: model.patient_id, @@ -523,6 +530,13 @@ pub async fn update_family_member( active.version = Set(next_ver); let updated = active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "patient.family_member_updated", "patient_family_member") + .with_resource_id(updated.id), + &state.db, + ).await; + Ok(FamilyMemberResp { id: updated.id, patient_id: updated.patient_id, @@ -565,6 +579,12 @@ pub async fn delete_family_member( active.version = Set(next_ver); active.update(&state.db).await?; + audit_service::record( + AuditLog::new(tenant_id, operator_id, "patient.family_member_deleted", "patient_family_member") + .with_resource_id(family_member_id), + &state.db, + ).await; + Ok(()) } @@ -609,7 +629,14 @@ pub async fn assign_doctor( deleted_at: Set(None), version: Set(1), }; - active.insert(&state.db).await?; + let relation = active.insert(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "patient.doctor_assigned", "patient_doctor_relation") + .with_resource_id(relation.id), + &state.db, + ).await; + Ok(()) } @@ -619,6 +646,7 @@ pub async fn remove_doctor( tenant_id: Uuid, patient_id: Uuid, doctor_id: Uuid, + operator_id: Option, ) -> HealthResult<()> { let model = patient_doctor_relation::Entity::find() .filter(patient_doctor_relation::Column::TenantId.eq(tenant_id)) @@ -629,10 +657,19 @@ pub async fn remove_doctor( .await? .ok_or(HealthError::DoctorNotFound)?; + let relation_id = model.id; let mut active: patient_doctor_relation::ActiveModel = model.into(); active.deleted_at = Set(Some(Utc::now())); active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "patient.doctor_removed", "patient_doctor_relation") + .with_resource_id(relation_id), + &state.db, + ).await; + Ok(()) } diff --git a/crates/erp-health/src/service/trend_service.rs b/crates/erp-health/src/service/trend_service.rs new file mode 100644 index 0000000..c69fb02 --- /dev/null +++ b/crates/erp-health/src/service/trend_service.rs @@ -0,0 +1,425 @@ +//! 趋势分析 Service — 趋势报表、指标时间序列、小程序趋势查询、今日体征摘要 + +use chrono::Utc; +use num_traits::cast::ToPrimitive; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect}; +use uuid::Uuid; + +use erp_core::types::PaginatedResponse; + +use crate::dto::health_data_dto::*; +use crate::entity::{health_trend, patient, vital_signs}; +use crate::error::HealthResult; +use crate::state::HealthState; + +// --------------------------------------------------------------------------- +// 趋势分析 (Trends) +// --------------------------------------------------------------------------- + +pub async fn list_trends( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + page: u64, + page_size: u64, +) -> HealthResult> { + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let query = health_trend::Entity::find() + .filter(health_trend::Column::TenantId.eq(tenant_id)) + .filter(health_trend::Column::PatientId.eq(patient_id)) + .filter(health_trend::Column::DeletedAt.is_null()); + + let total = query.clone().count(&state.db).await?; + let models = query + .order_by_desc(health_trend::Column::CreatedAt) + .offset(offset) + .limit(limit) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + let data = models.into_iter().map(|m| TrendResp { + id: m.id, patient_id: m.patient_id, + period_start: m.period_start, period_end: m.period_end, + indicator_summary: m.indicator_summary, abnormal_items: m.abnormal_items, + generation_type: m.generation_type, report_file_url: m.report_file_url, + }).collect(); + + Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) +} + +pub async fn generate_trend( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + operator_id: Option, + period_start: chrono::NaiveDate, + period_end: chrono::NaiveDate, +) -> HealthResult { + // 汇总该时间段内的体征数据 + let vitals = vital_signs::Entity::find() + .filter(vital_signs::Column::TenantId.eq(tenant_id)) + .filter(vital_signs::Column::PatientId.eq(patient_id)) + .filter(vital_signs::Column::DeletedAt.is_null()) + .filter(vital_signs::Column::RecordDate.gte(period_start)) + .filter(vital_signs::Column::RecordDate.lte(period_end)) + .all(&state.db) + .await?; + + let summary = { + let count = vitals.len(); + let avg = |vals: &[Option]| -> f64 { + let valid: Vec = vals.iter().filter_map(|&v| v).collect(); + if valid.is_empty() { return 0.0; } + valid.iter().sum::() as f64 / valid.len() as f64 + }; + let avg_f64 = |vals: &[Option]| -> f64 { + let valid: Vec = vals.iter().filter_map(|&v| v).collect(); + if valid.is_empty() { return 0.0; } + valid.iter().sum::() / valid.len() as f64 + }; + let heart_rates: Vec> = vitals.iter().map(|v| v.heart_rate).collect(); + let weights: Vec> = vitals.iter().map(|v| v.weight.and_then(|d| d.to_f64())).collect(); + let blood_sugars: Vec> = vitals.iter().map(|v| v.blood_sugar.and_then(|d| d.to_f64())).collect(); + let sys_morn: Vec> = vitals.iter().map(|v| v.systolic_bp_morning).collect(); + let dia_morn: Vec> = vitals.iter().map(|v| v.diastolic_bp_morning).collect(); + let sys_eve: Vec> = vitals.iter().map(|v| v.systolic_bp_evening).collect(); + let dia_eve: Vec> = vitals.iter().map(|v| v.diastolic_bp_evening).collect(); + + serde_json::json!({ + "period": { "start": period_start, "end": period_end }, + "record_count": count, + "avg_heart_rate": avg(&heart_rates), + "avg_weight": avg_f64(&weights), + "avg_blood_sugar": avg_f64(&blood_sugars), + "avg_systolic_bp_morning": avg(&sys_morn), + "avg_diastolic_bp_morning": avg(&dia_morn), + "avg_systolic_bp_evening": avg(&sys_eve), + "avg_diastolic_bp_evening": avg(&dia_eve), + }) + }; + + let abnormal_items = { + let mut items = Vec::new(); + let avg_i32 = |vals: &[Option]| -> Option { + let valid: Vec = vals.iter().filter_map(|&v| v).collect(); + if valid.is_empty() { return None; } + Some(valid.iter().sum::() as f64 / valid.len() as f64) + }; + let avg_opt_f64 = |vals: &[Option]| -> Option { + let valid: Vec = vals.iter().filter_map(|&v| v).collect(); + if valid.is_empty() { return None; } + Some(valid.iter().sum::() / valid.len() as f64) + }; + let heart_rates: Vec> = vitals.iter().map(|v| v.heart_rate).collect(); + let blood_sugars: Vec> = vitals.iter().map(|v| v.blood_sugar.and_then(|d| d.to_f64())).collect(); + let sys_morn: Vec> = vitals.iter().map(|v| v.systolic_bp_morning).collect(); + let dia_morn: Vec> = vitals.iter().map(|v| v.diastolic_bp_morning).collect(); + let sys_eve: Vec> = vitals.iter().map(|v| v.systolic_bp_evening).collect(); + let dia_eve: Vec> = vitals.iter().map(|v| v.diastolic_bp_evening).collect(); + + if let Some(hr) = avg_i32(&heart_rates) { + if hr < 60.0 || hr > 100.0 { + items.push(serde_json::json!({ "indicator": "heart_rate", "avg": hr, "normal_range": [60, 100] })); + } + } + if let Some(bs) = avg_opt_f64(&blood_sugars) { + if bs < 3.9 || bs > 11.1 { + items.push(serde_json::json!({ "indicator": "blood_sugar", "avg": bs, "normal_range": [3.9, 11.1] })); + } + } + for (label, vals, sys_lo, sys_hi) in [ + ("systolic_bp_morning", &sys_morn, 90, 140), + ("systolic_bp_evening", &sys_eve, 90, 140), + ] { + if let Some(v) = avg_i32(vals) { + if v < sys_lo as f64 || v > sys_hi as f64 { + items.push(serde_json::json!({ "indicator": label, "avg": v, "normal_range": [sys_lo, sys_hi] })); + } + } + } + for (label, vals, dia_lo, dia_hi) in [ + ("diastolic_bp_morning", &dia_morn, 60, 90), + ("diastolic_bp_evening", &dia_eve, 60, 90), + ] { + if let Some(v) = avg_i32(vals) { + if v < dia_lo as f64 || v > dia_hi as f64 { + items.push(serde_json::json!({ "indicator": label, "avg": v, "normal_range": [dia_lo, dia_hi] })); + } + } + } + if items.is_empty() { None } else { Some(serde_json::json!(items)) } + }; + + let now = Utc::now(); + let active = health_trend::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + patient_id: Set(patient_id), + period_start: Set(period_start), + period_end: Set(period_end), + indicator_summary: Set(Some(summary)), + abnormal_items: Set(abnormal_items), + generation_type: Set("auto".to_string()), + report_file_url: Set(None), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + + let m = active.insert(&state.db).await?; + Ok(TrendResp { + id: m.id, patient_id: m.patient_id, + period_start: m.period_start, period_end: m.period_end, + indicator_summary: m.indicator_summary, abnormal_items: m.abnormal_items, + generation_type: m.generation_type, report_file_url: m.report_file_url, + }) +} + +pub async fn get_indicator_timeseries( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + indicator: String, + start_date: Option, + end_date: Option, +) -> HealthResult { + let mut query = vital_signs::Entity::find() + .filter(vital_signs::Column::TenantId.eq(tenant_id)) + .filter(vital_signs::Column::PatientId.eq(patient_id)) + .filter(vital_signs::Column::DeletedAt.is_null()); + + if let Some(sd) = start_date { + query = query.filter(vital_signs::Column::RecordDate.gte(sd)); + } + if let Some(ed) = end_date { + query = query.filter(vital_signs::Column::RecordDate.lte(ed)); + } + + let vitals = query + .order_by_asc(vital_signs::Column::RecordDate) + .all(&state.db) + .await?; + + let data: Vec<(chrono::NaiveDate, f64)> = vitals.into_iter().filter_map(|v| { + let val = match indicator.as_str() { + "heart_rate" => v.heart_rate.map(|x| x as f64), + "weight" => v.weight.map(|d| d.to_f64().unwrap_or(0.0)), + "blood_sugar" => v.blood_sugar.map(|d| d.to_f64().unwrap_or(0.0)), + "systolic_bp_morning" => v.systolic_bp_morning.map(|x| x as f64), + "diastolic_bp_morning" => v.diastolic_bp_morning.map(|x| x as f64), + "systolic_bp_evening" => v.systolic_bp_evening.map(|x| x as f64), + "diastolic_bp_evening" => v.diastolic_bp_evening.map(|x| x as f64), + _ => None, + }; + val.map(|fv| (v.record_date, fv)) + }).collect(); + + Ok(IndicatorTimeseriesResp { indicator, data }) +} + +// --------------------------------------------------------------------------- +// 小程序趋势查询(通过 user_id 关联 patient) +// --------------------------------------------------------------------------- + +/// 根据 user_id 查找关联的 patient_id。 +/// patient 表的 user_id 字段关联 erp-auth 的用户。 +/// 如果未关联则返回 Ok(None)。 +async fn find_patient_by_user_id( + state: &HealthState, + tenant_id: Uuid, + user_id: Uuid, +) -> HealthResult> { + let patient_model = patient::Entity::find() + .filter(patient::Column::TenantId.eq(tenant_id)) + .filter(patient::Column::UserId.eq(user_id)) + .filter(patient::Column::DeletedAt.is_null()) + .one(&state.db) + .await?; + + Ok(patient_model.map(|p| p.id)) +} + +/// 解析 range 参数为天数,默认 7 天。 +/// 支持 "7d", "30d", "90d" 格式。 +fn parse_range_days(range: &Option) -> i64 { + match range.as_deref() { + Some("30d") => 30, + Some("90d") => 90, + // 默认 7 天(包括 "7d" 和 None) + _ => 7, + } +} + +/// 小程序趋势查询:通过当前用户的 user_id 关联 patient,查询指定指标的时间序列。 +/// +/// 逻辑流程: +/// 1. 解析 range 参数计算 start_date/end_date +/// 2. 通过 user_id 查找关联的 patient(patient.user_id 字段) +/// 3. 复用 get_indicator_timeseries 的查询逻辑 +/// 4. 转换为 DataPoint 格式返回 +pub async fn get_mini_trend( + state: &HealthState, + tenant_id: Uuid, + user_id: Uuid, + indicator: String, + range: Option, +) -> HealthResult { + // 1. 通过 user_id 查找关联的 patient + let patient_id = find_patient_by_user_id(state, tenant_id, user_id).await?; + + // 如果用户未关联 patient,返回空数据 + let Some(patient_id) = patient_id else { + return Ok(MiniTrendResp { + indicator, + data_points: vec![], + }); + }; + + // 2. 根据 range 计算日期范围 + let days = parse_range_days(&range); + let today = chrono::Local::now().date_naive(); + let start_date = today - chrono::Duration::days(days); + let end_date = today; + + // 3. 复用已有逻辑查询时间序列数据 + let timeseries = get_indicator_timeseries( + state, + tenant_id, + patient_id, + indicator.clone(), + Some(start_date), + Some(end_date), + ) + .await?; + + // 4. 转换为 DataPoint 格式 + let data_points = timeseries + .data + .into_iter() + .map(|(date, value)| DataPoint { + date: date.to_string(), + value, + }) + .collect(); + + Ok(MiniTrendResp { + indicator, + data_points, + }) +} + +// --------------------------------------------------------------------------- +// 小程序今日体征摘要 +// --------------------------------------------------------------------------- + +/// 根据参考范围计算指标状态 +fn compute_status(value: f64, low: f64, high: f64) -> &'static str { + if value < low { + "low" + } else if value > high { + "high" + } else { + "normal" + } +} + +/// 查询今日最新体征记录并生成摘要 +pub async fn get_mini_today( + state: &HealthState, + tenant_id: Uuid, + user_id: Uuid, +) -> HealthResult { + let patient_id = find_patient_by_user_id(state, tenant_id, user_id).await?; + + let Some(patient_id) = patient_id else { + return Ok(MiniTodayResp { + blood_pressure: None, + heart_rate: None, + blood_sugar: None, + weight: None, + }); + }; + + let today = chrono::Local::now().date_naive(); + + // 查询今日最新体征记录 + let vital = vital_signs::Entity::find() + .filter(vital_signs::Column::TenantId.eq(tenant_id)) + .filter(vital_signs::Column::PatientId.eq(patient_id)) + .filter(vital_signs::Column::DeletedAt.is_null()) + .filter(vital_signs::Column::RecordDate.eq(today)) + .order_by_desc(vital_signs::Column::CreatedAt) + .one(&state.db) + .await?; + + let Some(v) = vital else { + return Ok(MiniTodayResp { + blood_pressure: None, + heart_rate: None, + blood_sugar: None, + weight: None, + }); + }; + + // 构建各指标摘要,优先使用晨间数据 + let blood_pressure = v.systolic_bp_morning.and_then(|sys| { + v.diastolic_bp_morning.map(|dia| { + let status = compute_status(sys as f64, 90.0, 140.0); + IndicatorSummary { + value: sys as f64, + status: status.to_string(), + reference_range: Some("90-140/60-90".to_string()), + systolic: Some(sys as f64), + diastolic: Some(dia as f64), + } + }) + }); + + let heart_rate = v.heart_rate.map(|hr| { + let status = compute_status(hr as f64, 60.0, 100.0); + IndicatorSummary { + value: hr as f64, + status: status.to_string(), + reference_range: Some("60-100".to_string()), + systolic: None, + diastolic: None, + } + }); + + let blood_sugar = v.blood_sugar.map(|bs| { + let val = bs.to_f64().unwrap_or(0.0); + let status = compute_status(val, 3.9, 6.1); + IndicatorSummary { + value: val, + status: status.to_string(), + reference_range: Some("3.9-6.1".to_string()), + systolic: None, + diastolic: None, + } + }); + + let weight = v.weight.map(|w| { + let val = w.to_f64().unwrap_or(0.0); + IndicatorSummary { + value: val, + status: "normal".to_string(), // 体重无通用参考范围 + reference_range: None, + systolic: None, + diastolic: None, + } + }); + + Ok(MiniTodayResp { + blood_pressure, + heart_rate, + blood_sugar, + weight, + }) +} diff --git a/crates/erp-server/migration/src/m20260425_000048_add_patient_id_number_hash.rs b/crates/erp-server/migration/src/m20260425_000048_add_patient_id_number_hash.rs index 02d006a..0f73b28 100644 --- a/crates/erp-server/migration/src/m20260425_000048_add_patient_id_number_hash.rs +++ b/crates/erp-server/migration/src/m20260425_000048_add_patient_id_number_hash.rs @@ -14,7 +14,7 @@ impl MigrationTrait for Migration { manager .alter_table( Table::alter() - .table(Alias::new("patients")) + .table(Alias::new("patient")) .add_column( ColumnDef::new(Alias::new("id_number_hash")) .string() @@ -29,7 +29,7 @@ impl MigrationTrait for Migration { manager .alter_table( Table::alter() - .table(Alias::new("patients")) + .table(Alias::new("patient")) .drop_column(Alias::new("id_number_hash")) .to_owned(), ) diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index 868ad7e..a0ea542 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -363,6 +363,9 @@ async fn main() -> anyhow::Result<()> { registry.startup_all(&module_ctx).await?; tracing::info!("All modules started"); + // 同步所有模块声明的权限到数据库(upsert) + sync_module_permissions(&db, ®istry, default_tenant_id).await?; + // 恢复运行中的插件(服务器重启后自动重新加载) match plugin_engine.recover_plugins(&db).await { Ok(recovered) => { @@ -554,3 +557,67 @@ async fn shutdown_signal() { }, } } + +/// 同步所有模块声明的权限到数据库。 +/// +/// 对每个模块的 `permissions()` 返回的权限执行 upsert: +/// - 新权限:INSERT +/// - 已有权限(同 tenant_id + code):跳过 +/// 同时将新权限分配给 admin 角色。 +async fn sync_module_permissions( + db: &sea_orm::DatabaseConnection, + registry: &erp_core::module::ModuleRegistry, + tenant_id: uuid::Uuid, +) -> Result<(), anyhow::Error> { + let system_user_id = uuid::Uuid::nil(); + let mut total_new = 0u32; + + for module in registry.modules() { + let perms = module.permissions(); + if perms.is_empty() { + continue; + } + + for perm in perms { + let result = db.execute(sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + r#"INSERT INTO permissions (id, tenant_id, code, name, resource, action, description, created_at, updated_at, created_by, updated_by, deleted_at, version) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW(), $8, $8, NULL, 1) + ON CONFLICT (tenant_id, code) WHERE deleted_at IS NULL DO NOTHING"#, + [ + uuid::Uuid::now_v7().into(), + tenant_id.into(), + perm.code.clone().into(), + perm.name.clone().into(), + perm.module.clone().into(), + perm.code.split('.').last().unwrap_or("manage").into(), + perm.description.clone().into(), + system_user_id.into(), + ], + )).await?; + + let rows = result.rows_affected(); + if rows > 0 { + total_new += 1; + } + } + } + + if total_new > 0 { + // 将新权限分配给 admin 角色 + db.execute(sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + r#"INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) + SELECT r.id, p.id, p.tenant_id, 'all', NOW(), NOW(), $1, $1, NULL, 1 + FROM permissions p + JOIN roles r ON r.code = 'admin' AND r.tenant_id = p.tenant_id AND r.deleted_at IS NULL + WHERE p.tenant_id = $2 AND p.code LIKE 'health.%' + ON CONFLICT DO NOTHING"#, + [system_user_id.into(), tenant_id.into()], + )).await?; + + tracing::info!(total_new, "Module permissions synced to database"); + } + + Ok(()) +} diff --git a/docs/superpowers/plans/2026-04-24-health-module-iteration.md b/docs/superpowers/plans/2026-04-24-health-module-iteration.md new file mode 100644 index 0000000..e6f3e83 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-health-module-iteration.md @@ -0,0 +1,1626 @@ +# HMS 健康管理模块全面迭代 — 实施计划 + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 修复 4 个 V1 阻塞项,完成 erp-health 后端补完和 Web 前端 10 页面开发。 + +**Architecture:** 安全地基先行(sanitize/审计/加密/脱敏)→ 后端补完(事件处理器/数据一致性/逾期检测/测试)→ Web 前端(API 层 + 10 页面)→ 端到端验证。严格按阶段顺序,每阶段独立可验证。 + +**Tech Stack:** Rust/Axum/SeaORM (后端) · React 19/Ant Design 6/Zustand 5 (前端) · PostgreSQL 18 · Taro 4 (小程序) + +**设计文档:** `docs/superpowers/specs/2026-04-24-health-module-iteration-design.md` + +--- + +## Chunk 1: 安全省基(阶段 1,1.5-2 周) + +### Task 1: DTO sanitize 全覆盖 + +**Files:** +- Modify: `crates/erp-health/src/dto/patient_dto.rs` +- Modify: `crates/erp-health/src/dto/health_data_dto.rs` +- Modify: `crates/erp-health/src/dto/appointment_dto.rs` +- Modify: `crates/erp-health/src/dto/follow_up_dto.rs` +- Modify: `crates/erp-health/src/dto/consultation_dto.rs` +- Modify: `crates/erp-health/src/dto/doctor_dto.rs` +- Modify: `crates/erp-health/src/handler/patient_handler.rs` (调用 sanitize) +- Modify: `crates/erp-health/src/handler/health_data_handler.rs` +- Modify: `crates/erp-health/src/handler/appointment_handler.rs` +- Modify: `crates/erp-health/src/handler/follow_up_handler.rs` +- Modify: `crates/erp-health/src/handler/consultation_handler.rs` +- Modify: `crates/erp-health/src/handler/doctor_handler.rs` + +**参考模式:** `crates/erp-auth/src/dto.rs` 第 94-119 行的 sanitize 实现。 +**可用函数:** `erp_core::sanitize::{strip_html_tags, sanitize_option, sanitize_string}` + +- [ ] **Step 1: 为 patient_dto.rs 添加 sanitize** + +在每个请求 DTO 的 impl 块中添加 `sanitize(&mut self)` 方法: + +```rust +use erp_core::sanitize::{strip_html_tags, sanitize_option, sanitize_string}; + +impl CreatePatientReq { + pub fn sanitize(&mut self) { + self.name = sanitize_string(&self.name); + self.notes = sanitize_option(self.notes.take()); + self.allergy_history = sanitize_option(self.allergy_history.take()); + self.medical_history_summary = sanitize_option(self.medical_history_summary.take()); + self.emergency_contact_name = sanitize_option(self.emergency_contact_name.take()); + self.source = sanitize_option(self.source.take()); + } +} + +impl UpdatePatientReq { + pub fn sanitize(&mut self) { + if let Some(ref mut v) = self.name { *v = strip_html_tags(v); } + self.id_number = sanitize_option(self.id_number.take()); + self.notes = sanitize_option(self.notes.take()); + self.allergy_history = sanitize_option(self.allergy_history.take()); + self.medical_history_summary = sanitize_option(self.medical_history_summary.take()); + self.emergency_contact_name = sanitize_option(self.emergency_contact_name.take()); + self.emergency_contact_phone = sanitize_option(self.emergency_contact_phone.take()); + self.source = sanitize_option(self.source.take()); + } +} + +impl FamilyMemberReq { + pub fn sanitize(&mut self) { + self.name = sanitize_string(&self.name); + self.notes = sanitize_option(self.notes.take()); + } +} +``` + +- [ ] **Step 2: 为 health_data_dto.rs 添加 sanitize** + +```rust +impl CreateVitalSignsReq { + pub fn sanitize(&mut self) { + self.notes = sanitize_option(self.notes.take()); + } +} + +impl CreateLabReportReq { + pub fn sanitize(&mut self) { + self.doctor_interpretation = sanitize_option(self.doctor_interpretation.take()); + } +} + +impl CreateHealthRecordReq { + pub fn sanitize(&mut self) { + self.source = sanitize_option(self.source.take()); + self.overall_assessment = sanitize_option(self.overall_assessment.take()); + self.notes = sanitize_option(self.notes.take()); + } +} +``` + +- [ ] **Step 3: 为 appointment_dto.rs 添加 sanitize** + +```rust +impl CreateAppointmentReq { + pub fn sanitize(&mut self) { + self.notes = sanitize_option(self.notes.take()); + } +} + +impl UpdateAppointmentStatusReq { + pub fn sanitize(&mut self) { + self.cancel_reason = sanitize_option(self.cancel_reason.take()); + } +} + +impl UpdateVitalSignsReq { + pub fn sanitize(&mut self) { + self.notes = sanitize_option(self.notes.take()); + } +} + +impl UpdateLabReportReq { + pub fn sanitize(&mut self) { + self.doctor_interpretation = sanitize_option(self.doctor_interpretation.take()); + } +} + +impl UpdateHealthRecordReq { + pub fn sanitize(&mut self) { + self.source = sanitize_option(self.source.take()); + self.overall_assessment = sanitize_option(self.overall_assessment.take()); + self.notes = sanitize_option(self.notes.take()); + } +} +``` + +- [ ] **Step 4: 为 follow_up_dto.rs 添加 sanitize** + +```rust +impl CreateFollowUpTaskReq { + pub fn sanitize(&mut self) { + self.content_template = sanitize_option(self.content_template.take()); + } +} + +impl UpdateFollowUpTaskReq { + pub fn sanitize(&mut self) { + self.content_template = sanitize_option(self.content_template.take()); + } +} + +impl CreateFollowUpRecordReq { + pub fn sanitize(&mut self) { + self.patient_condition = sanitize_option(self.patient_condition.take()); + self.medical_advice = sanitize_option(self.medical_advice.take()); + } +} +``` + +- [ ] **Step 5: 为 consultation_dto.rs 添加 sanitize** + +```rust +impl CreateMessageReq { + pub fn sanitize(&mut self) { + self.content = sanitize_string(&self.content); + } +} +``` + +- [ ] **Step 6: 为 doctor_dto.rs 添加 sanitize** + +```rust +impl CreateDoctorReq { + pub fn sanitize(&mut self) { + self.name = sanitize_string(&self.name); + self.department = sanitize_option(self.department.take()); + self.title = sanitize_option(self.title.take()); + self.specialty = sanitize_option(self.specialty.take()); + self.bio = sanitize_option(self.bio.take()); + } +} + +impl UpdateDoctorReq { + pub fn sanitize(&mut self) { + if let Some(ref mut v) = self.name { *v = strip_html_tags(v); } + self.department = sanitize_option(self.department.take()); + self.title = sanitize_option(self.title.take()); + self.specialty = sanitize_option(self.specialty.take()); + self.bio = sanitize_option(self.bio.take()); + } +} +``` + +- [ ] **Step 7: 在所有 handler 中调用 sanitize** + +在每个 handler 的 create/update 方法中,在调用 service 之前添加 `req.sanitize()`。示例: + +```rust +// patient_handler.rs +async fn create_patient(/* ... */) -> AppResult>> { + let mut req = req.0; + req.sanitize(); // ← 新增 + let result = patient_service::create_patient(&state.health, tenant_id, user_id, req).await?; + Ok(Json(ApiResponse::success(result))) +} +``` + +所有需要修改的 handler 方法: +- `patient_handler`: create_patient, update_patient, create_family_member, update_family_member +- `health_data_handler`: create_vital_signs, update_vital_signs, create_lab_report, update_lab_report, create_health_record, update_health_record +- `appointment_handler`: create_appointment, update_appointment_status +- `follow_up_handler`: create_task, update_task, create_record +- `consultation_handler`: create_message +- `doctor_handler`: create_doctor, update_doctor + +- [ ] **Step 8: 验证编译** + +Run: `cargo check` +Expected: 无错误 + +- [ ] **Step 9: 提交** + +```bash +git add crates/erp-health/src/dto/ crates/erp-health/src/handler/ +git commit -m "feat(health): 为所有 DTO 添加 sanitize 防止存储型 XSS" +``` + +--- + +### Task 2: 审计日志注入 + +**Files:** +- Modify: `crates/erp-health/src/service/patient_service.rs` +- Modify: `crates/erp-health/src/service/appointment_service.rs` +- Modify: `crates/erp-health/src/service/consultation_service.rs` +- Modify: `crates/erp-health/src/service/follow_up_service.rs` +- Modify: `crates/erp-health/src/service/doctor_service.rs` +- Modify: `crates/erp-health/src/service/health_data_service.rs` + +**参考模式:** `crates/erp-auth/src/service/auth_service.rs` 第 168-177 行。 + +**注意:** 使用现有 `erp_core::audit_service::record(log, db)` 的 fire-and-forget 模式。未来如需事务保证再升级为 `record_in_txn`。 + +- [ ] **Step 1: 在 patient_service.rs 注入审计日志** + +在 create_patient、update_patient、delete_patient、manage_patient_tags 方法中添加审计调用。每个方法在业务操作成功后、返回结果前插入: + +```rust +use erp_core::audit::AuditLog; +use erp_core::audit_service; + +// create_patient 末尾(return 之前) +audit_service::record( + AuditLog::new(tenant_id, operator_id, "patient.created", "patient") + .with_resource_id(m.id), + &state.db, +).await; +``` + +覆盖清单(共 4 个操作): +| 方法 | action | +|------|--------| +| create_patient | `patient.created` | +| update_patient | `patient.updated` | +| delete_patient | `patient.deleted` | +| manage_patient_tags | `patient.tags_updated` | + +- [ ] **Step 2: 在 appointment_service.rs 注入审计日志** + +覆盖清单(共 2 个操作): +| 方法 | action | +|------|--------| +| create_appointment | `appointment.created` | +| update_appointment_status | `appointment.status_changed` | + +`update_appointment_status` 中添加变更前后值摘要: +```rust +audit_service::record( + AuditLog::new(tenant_id, operator_id, "appointment.status_changed", "appointment") + .with_resource_id(m.id) + .with_changes( + Some(serde_json::json!({ "status": old_status })), + Some(serde_json::json!({ "status": m.status })), + ), + &state.db, +).await; +``` + +- [ ] **Step 3: 在 consultation_service.rs 注入审计日志** + +覆盖清单(共 3 个操作): +| 方法 | action | +|------|--------| +| create_session | `consultation.opened` | +| close_session | `consultation.closed` | +| create_message | `consultation.message_sent` | + +- [ ] **Step 4: 在 follow_up_service.rs 注入审计日志** + +覆盖清单(共 2 个操作): +| 方法 | action | +|------|--------| +| create_task | `follow_up_task.created` | +| create_record | `follow_up_record.created` | + +- [ ] **Step 5: 在 doctor_service.rs 和 health_data_service.rs 注入审计日志** + +doctor_service 覆盖清单(共 3 个操作): +| 方法 | action | +|------|--------| +| create_doctor | `doctor.created` | +| update_doctor | `doctor.updated` | +| delete_doctor | `doctor.deleted` | + +health_data_service 覆盖清单(共 3 个操作): +| 方法 | action | +|------|--------| +| create_vital_signs | `vital_signs.created` | +| create_lab_report | `lab_report.created` | +| create_health_record | `health_record.created` | + +- [ ] **Step 6: 验证编译** + +Run: `cargo check` +Expected: 无错误 + +- [ ] **Step 7: 提交** + +```bash +git add crates/erp-health/src/service/ +git commit -m "feat(health): 注入审计日志覆盖所有写入操作" +``` + +--- + +### Task 3: 身份证号加密存储 + +**Files:** +- Create: `crates/erp-health/src/crypto.rs` +- Modify: `crates/erp-health/src/lib.rs` (添加 mod crypto) +- Modify: `crates/erp-health/src/service/patient_service.rs` +- Modify: `crates/erp-health/src/state.rs` (添加 crypto 字段) +- Modify: `crates/erp-health/src/module.rs` (初始化 crypto + 注册事件处理器) +- Modify: `crates/erp-health/Cargo.toml` (添加 aes-gcm 依赖) +- Modify: `crates/erp-server/src/state.rs` (更新 FromRef 添加 crypto 字段) +- Modify: `crates/erp-server/src/main.rs` (注入 crypto 到 HealthState) + +- [ ] **Step 1: 添加 Cargo.toml 依赖** + +```toml +# crates/erp-health/Cargo.toml [dependencies] 新增 +aes-gcm = "0.10" +hmac = "0.12" +sha2 = "0.10" +base64 = "0.22" +hex = "0.4" +``` + +- [ ] **Step 2: 创建 crypto.rs** + +```rust +use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; +use aes_gcm::aead::Aead; +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +use erp_core::error::AppResult; +use erp_core::error::AppError; + +type HmacSha256 = Hmac; + +pub struct HealthCrypto { + aes_key: [u8; 32], + hmac_key: [u8; 32], +} + +impl HealthCrypto { + pub fn from_keys(aes_key_hex: &str, hmac_key_hex: &str) -> AppResult { + let aes_key = hex::decode(aes_key_hex) + .map_err(|e| AppError::Internal(format!("AES key hex decode failed: {}", e)))?; + let hmac_key = hex::decode(hmac_key_hex) + .map_err(|e| AppError::Internal(format!("HMAC key hex decode failed: {}", e)))?; + if aes_key.len() != 32 || hmac_key.len() != 32 { + return Err(AppError::Internal("Encryption keys must be 32 bytes".into())); + } + let mut aes = [0u8; 32]; + let mut hmac = [0u8; 32]; + aes.copy_from_slice(&aes_key); + hmac.copy_from_slice(&hmac_key); + Ok(Self { aes_key: aes, hmac_key: hmac }) + } + + pub fn encrypt(&self, plaintext: &str) -> AppResult { + let cipher = Aes256Gcm::new_from_slice(&self.aes_key) + .map_err(|e| AppError::Internal(format!("AES init failed: {}", e)))?; + let nonce_bytes = uuid::Uuid::now_v7(); + let nonce = Nonce::from_slice(nonce_bytes.as_bytes()); + let ciphertext = cipher.encrypt(nonce, plaintext.as_bytes()) + .map_err(|e| AppError::Internal(format!("Encryption failed: {}", e)))?; + // 格式: base64(nonce_12byte + ciphertext) + let mut combined = nonce_bytes.as_bytes()[..12].to_vec(); + combined.extend_from_slice(&ciphertext); + Ok(BASE64.encode(&combined)) + } + + pub fn decrypt(&self, encoded: &str) -> AppResult { + let combined = BASE64.decode(encoded) + .map_err(|e| AppError::Internal(format!("Base64 decode failed: {}", e)))?; + if combined.len() < 12 { + return Err(AppError::Internal("Ciphertext too short".into())); + } + let (nonce_bytes, ciphertext) = combined.split_at(12); + let cipher = Aes256Gcm::new_from_slice(&self.aes_key) + .map_err(|e| AppError::Internal(format!("AES init failed: {}", e)))?; + let plaintext = cipher.decrypt(Nonce::from_slice(nonce_bytes), ciphertext) + .map_err(|e| AppError::Internal(format!("Decryption failed: {}", e)))?; + String::from_utf8(plaintext) + .map_err(|e| AppError::Internal(format!("UTF-8 decode failed: {}", e))) + } + + pub fn hmac_hash(&self, value: &str) -> String { + let mut mac = HmacSha256::new_from_slice(&self.hmac_key) + .expect("HMAC key length is valid"); + mac.update(value.as_bytes()); + hex::encode(mac.finalize().into_bytes()) + } +} +``` + +- [ ] **Step 3: 更新 state.rs** + +```rust +// state.rs +use crate::crypto::HealthCrypto; + +#[derive(Clone)] +pub struct HealthState { + pub db: sea_orm::DatabaseConnection, + pub event_bus: erp_core::events::EventBus, + pub crypto: HealthCrypto, +} +``` + +- [ ] **Step 4: 更新 module.rs 初始化 crypto(同时注册事件处理器)** + +在 `module.rs` 中实现 `on_startup`,一次性初始化 crypto 和注册事件处理器: + +```rust +async fn on_startup(&self, ctx: &erp_core::module::ModuleContext) -> erp_core::error::AppResult<()> { + let state = HealthState { + db: ctx.db.clone(), + event_bus: ctx.event_bus.clone(), + crypto: crate::crypto::HealthCrypto::from_keys( + &std::env::var("ERP__HEALTH__ENCRYPTION_KEY").unwrap_or_default(), + &std::env::var("ERP__HEALTH__HMAC_KEY").unwrap_or_default(), + )?, + }; + crate::event::register_handlers_with_state(state); + Ok(()) +} + +fn register_event_handlers(&self, _bus: &EventBus) { + // 已迁移到 on_startup,此处为空 +} +``` + +**同时必须更新** `crates/erp-server/src/state.rs` 中 `FromRef for erp_health::HealthState` 的实现,添加 `crypto` 字段。需要从环境变量或 AppState 配置中获取密钥。 + +- [ ] **Step 5: 修改 patient_service.rs — 加密存储 + HMAC 索引** + +- `create_patient`: 加密 `id_number`,同时写入 `id_number_hash = crypto.hmac_hash(明文)` +- `update_patient`: 同上 +- `get_patient`: 解密 `id_number` 后返回 +- `list_patients`: 不返回 `id_number`(使用新的 `PatientListResp`,见 Task 4) + +需要新增数据库迁移:添加 `id_number_hash` 列(后续迁移任务中执行)。 + +- [ ] **Step 5: 创建数据库迁移 — 添加 id_number_hash 列** + +在 `crates/erp-server/migration/src/` 创建新迁移文件 `m20260424_000001_add_id_number_hash.rs`: + +```rust +use sea_orm_migration::prelude::*; + +pub struct Migration; + +impl MigrationName for Migration { + fn name(&self) -> &str { "m20260424_000001_add_id_number_hash" } +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager.alter_table( + Table::alter() + .table(Alias::new("patients")) + .add_column(ColumnDef::String(Alias::new("id_number_hash")).null()) + .to_owned() + ).await + } + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager.alter_table( + Table::alter() + .table(Alias::new("patients")) + .drop_column(Alias::new("id_number_hash")) + .to_owned() + ).await + } +} +``` + +并在 migration 的 `mod.rs` 中注册。 + +- [ ] **Step 6: 验证编译** + +Run: `cargo check` +Expected: 无错误 + +- [ ] **Step 7: 在 config/default.toml 中标记加密密钥** + +```toml +[health] +encryption_key = "__MUST_SET_VIA_ENV__" +hmac_key = "__MUST_SET_VIA_ENV__" +``` + +- [ ] **Step 8: 提交** + +```bash +git add crates/erp-health/ crates/erp-server/ docker/ +git commit -m "feat(health): 身份证号 AES-256-GCM 加密 + HMAC 索引" +``` + +--- + +### Task 4: 字段级脱敏 — 拆分 PatientResp + +**Files:** +- Modify: `crates/erp-health/src/dto/patient_dto.rs` +- Modify: `crates/erp-health/src/service/patient_service.rs` +- Modify: `crates/erp-health/src/handler/patient_handler.rs` + +- [ ] **Step 1: 新增 PatientListResp(不含敏感字段)和 PatientDetailResp(掩码)** + +```rust +// patient_dto.rs 新增 + +/// 列表用 — 不含敏感字段 +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct PatientListResp { + pub id: Uuid, + pub name: String, + pub gender: Option, + pub birth_date: Option, + pub blood_type: Option, + pub status: String, + pub verification_status: String, + pub source: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, +} + +/// 详情用 — 敏感字段掩码 +pub type PatientDetailResp = PatientResp; // 保留全字段,handler 中掩码处理 + +/// 掩码辅助函数 +fn mask_id_number(s: &str) -> String { + if s.len() >= 7 { + format!("{}****{}", &s[..3], &s[s.len()-4..]) + } else { + "****".to_string() + } +} + +fn mask_phone(s: &str) -> String { + if s.len() >= 7 { + format!("{}****{}", &s[..3], &s[s.len()-4..]) + } else { + "****".to_string() + } +} +``` + +- [ ] **Step 2: 修改 patient_service — list 返回 PatientListResp** + +将 `list_patients` 返回类型从 `PaginatedResponse` 改为 `PaginatedResponse`,转换时只包含安全字段。 + +- [ ] **Step 3: 修改 patient_service — get 返回脱敏后的 PatientDetailResp** + +`get_patient` 返回 `PatientResp`,但在 handler 层对 `id_number` 和 `emergency_contact_phone` 做掩码处理。 + +- [ ] **Step 4: 验证编译** + +Run: `cargo check` +Expected: 无错误 + +- [ ] **Step 5: 提交** + +```bash +git add crates/erp-health/src/ +git commit -m "feat(health): 患者列表脱敏 — 拆分 PatientListResp/PatientDetailResp" +``` + +--- + +## Chunk 1 里程碑验收 + +- [ ] `cargo check` 全 workspace 编译通过 +- [ ] 所有 DTO 的字符串输入字段都经过 sanitize +- [ ] 所有写入操作(15+ 方法)均有审计日志 +- [ ] 身份证号加密存储 + HMAC 索引可用 +- [ ] 列表接口不返回身份证号,详情接口掩码显示 + +--- + +## Chunk 2: 后端补完(阶段 2,1.5 周) + +### Task 5: 事件处理器实现 + +**Files:** +- Modify: `crates/erp-health/src/event.rs` +- Modify: `crates/erp-health/src/module.rs` +- Modify: `crates/erp-health/src/service/follow_up_service.rs` (新增 update_task_status_by_system) + +**问题:** `event.rs` 中两个事件处理器只有 `tracing::info`,无 db 连接。需要迁移到 `on_startup`。 + +- [ ] **Step 1: 改写 event.rs — 新增 register_handlers_with_state** + +```rust +// event.rs — 完整替换 +use erp_core::events::EventBus; +use sea_orm::DatabaseConnection; +use crate::state::HealthState; + +/// 旧版注册(无 db),保留空实现以兼容 trait +pub fn register_handlers(_bus: &EventBus) { + // 已迁移到 register_handlers_with_state +} + +/// 新版注册 — 带 db 连接,在 on_startup 中调用 +pub fn register_handlers_with_state(state: HealthState) { + let db = state.db.clone(); + + // workflow.task.completed → 更新随访任务状态 + let mut workflow_rx = state.event_bus.subscribe_filtered("workflow.task.".to_string()).0; + tokio::spawn(async move { + loop { + match workflow_rx.recv().await { + Some(event) if event.event_type == "workflow.task.completed" => { + if let Some(payload) = event.payload.get("task_id") { + if let Some(task_id_str) = payload.as_str() { + if let Ok(task_id) = uuid::Uuid::parse_str(task_id_str) { + match crate::service::follow_up_service::complete_task_by_system(&db, task_id, event.tenant_id).await { + Ok(_) => tracing::info!(task_id = %task_id, "随访任务通过工作流事件完成"), + Err(e) => tracing::warn!(error = %e, "工作流事件触发随访任务完成失败"), + } + } + } + } + } + Some(_) => {} + None => break, + } + } + }); + + let db2 = state.db.clone(); + let mut msg_rx = state.event_bus.subscribe_filtered("message.".to_string()).0; + tokio::spawn(async move { + loop { + match msg_rx.recv().await { + Some(event) if event.event_type == "message.sent" => { + // 查找与消息发送者/接收者关联的咨询会话,更新 last_message_at + tracing::info!(event_id = %event.id, "健康模块收到消息事件(待实现关联逻辑)"); + } + Some(_) => {} + None => break, + } + } + }); +} +``` + +- [ ] **Step 2: 修改 module.rs — register_event_handlers 改为空,on_startup 注册** + +```rust +// module.rs +fn register_event_handlers(&self, _bus: &EventBus) { + // 迁移到 on_startup +} + +async fn on_startup(&self, ctx: &erp_core::module::ModuleContext) -> erp_core::error::AppResult<()> { + let state = HealthState { + db: ctx.db.clone(), + event_bus: ctx.event_bus.clone(), + crypto: crate::crypto::HealthCrypto::from_keys( + &std::env::var("ERP__HEALTH__ENCRYPTION_KEY").unwrap_or_default(), + &std::env::var("ERP__HEALTH__HMAC_KEY").unwrap_or_default(), + )?, + }; + crate::event::register_handlers_with_state(state); + Ok(()) +} +``` + +- [ ] **Step 3: 在 follow_up_service.rs 新增 complete_task_by_system** + +```rust +/// 供事件处理器调用的系统级状态更新(跳过 version 检查,由系统触发) +pub async fn complete_task_by_system( + db: &DatabaseConnection, + task_id: Uuid, + tenant_id: Uuid, +) -> HealthResult<()> { + use crate::entity::follow_up_task; + let model = follow_up_task::Entity::find() + .filter(follow_up_task::Column::Id.eq(task_id)) + .filter(follow_up_task::Column::TenantId.eq(tenant_id)) + .filter(follow_up_task::Column::DeletedAt.is_null()) + .one(db).await?.ok_or(HealthError::FollowUpTaskNotFound)?; + + if model.status != "pending" && model.status != "in_progress" { + return Ok(()); // 已完成/已取消,忽略 + } + + let mut active: follow_up_task::ActiveModel = model.into(); + active.status = Set("completed".to_string()); + active.updated_at = Set(Utc::now()); + active.update(db).await?; + Ok(()) +} +``` + +- [ ] **Step 4: 验证编译** + +Run: `cargo check` +Expected: 无错误 + +- [ ] **Step 5: 提交** + +```bash +git add crates/erp-health/src/ +git commit -m "feat(health): 事件处理器实现 — workflow→随访、message→咨询联动" +``` + +--- + +### Task 6: 数据一致性修复 + +**Files:** +- Modify: `crates/erp-health/src/service/appointment_service.rs` +- Modify: `crates/erp-health/src/service/consultation_service.rs` + +- [ ] **Step 1: 排班名额保护 — update_schedule 增加校验** + +在 `appointment_service.rs` 的 `update_schedule` 方法中,`let next_ver = ...` 之后添加: + +```rust +if let Some(new_max) = req.max_appointments { + if new_max < model.current_appointments { + return Err(HealthError::Validation( + format!("max_appointments ({}) 不能小于当前已预约数 ({})", + new_max, model.current_appointments) + )); + } +} +``` + +- [ ] **Step 2: 取消预约名额释放 — 失败时回滚整个事务** + +当前 `update_appointment_status` 中取消时名额释放失败只 log error。修改为:名额释放失败时回滚整个事务。 + +将 `if let Err(e) = release_result` 改为 `let release_result = ... ?;`,让错误传播导致事务回滚: + +```rust +if req.status == "cancelled" { + if let Some(did) = model.doctor_id { + doctor_schedule::Entity::update_many() + // ... 同现有代码 ... + .exec(&txn) + .await?; // ← 改为 ? 传播错误,不再 if let Err + } +} +``` + +- [ ] **Step 3: 咨询消息原子性 — 消息 INSERT + session CAS 放同一事务** + +当前 `create_message` 中消息 INSERT 和 session CAS 分别独立执行。修改为事务包裹: + +```rust +pub async fn create_message(/* ... */) -> HealthResult { + // ... 校验会话(同现有代码)... + + let txn = state.db.begin().await?; + + // 在事务内 INSERT 消息 + let m = active.insert(&txn).await?; + + // 在事务内 CAS 更新 session + let cas_result = cas.exec(&txn).await?; + if cas_result.rows_affected == 0 { + txn.rollback().await?; + return Err(HealthError::VersionMismatch); + } + + txn.commit().await?; + // ... +} +``` + +- [ ] **Step 4: 验证编译** + +Run: `cargo check` +Expected: 无错误 + +- [ ] **Step 5: 提交** + +```bash +git add crates/erp-health/src/service/ +git commit -m "fix(health): 数据一致性修复 — 排班名额保护/取消回滚/消息原子性" +``` + +--- + +### Task 7: 随访逾期定时任务 + +**Files:** +- Modify: `crates/erp-health/src/service/validation.rs` +- Modify: `crates/erp-health/src/service/follow_up_service.rs` +- Modify: `crates/erp-health/src/module.rs` +- Modify: `crates/erp-server/src/main.rs` + +- [ ] **Step 1: validation.rs 添加 overdue 转换规则** + +新增函数: + +```rust +/// follow_up_task.status transitions(含 overdue) +pub fn validate_follow_up_status_transition(current: &str, new: &str) -> HealthResult<()> { + if current == new { return Ok(()); } + let allowed = match current { + "pending" => matches!(new, "in_progress" | "cancelled" | "overdue"), + "in_progress" => matches!(new, "completed" | "cancelled"), + _ => false, + }; + if allowed { + Ok(()) + } else { + Err(HealthError::InvalidStatusTransition(format!( + "follow_up_task.status: 不允许从 '{}' 转换到 '{}'", current, new + ))) + } +} +``` + +注意:`follow_up_service.rs` 中已有私有 `validate_follow_up_status_transition` 函数,需要替换为调用 `validation.rs` 中的公共版本。 + +- [ ] **Step 2: follow_up_service.rs 新增 check_overdue_tasks** + +```rust +/// 检测 planned_date 已过且仍为 pending 的任务,标记为 overdue +pub async fn check_overdue_tasks(db: &DatabaseConnection) -> HealthResult { + let today = chrono::Utc::now().date_naive(); + let result = follow_up_task::Entity::update_many() + .col_expr(follow_up_task::Column::Status, Expr::value("overdue".to_string())) + .col_expr(follow_up_task::Column::UpdatedAt, Expr::value(Utc::now())) + .filter(follow_up_task::Column::Status.eq("pending")) + .filter(follow_up_task::Column::PlannedDate.lt(today)) + .filter(follow_up_task::Column::DeletedAt.is_null()) + .exec(db) + .await?; + Ok(result.rows_affected) +} +``` + +- [ ] **Step 3: module.rs 添加公开方法** + +```rust +impl HealthModule { + /// 启动随访逾期检查后台任务 + pub fn start_overdue_checker(db: sea_orm::DatabaseConnection) { + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(6 * 3600)); + interval.tick().await; // 跳过首次 + loop { + interval.tick().await; + match crate::service::follow_up_service::check_overdue_tasks(&db).await { + Ok(count) if count > 0 => tracing::info!(count = count, "随访逾期检查完成"), + Ok(_) => {} + Err(e) => tracing::warn!(error = %e, "随访逾期检查失败"), + } + } + }); + } +} +``` + +- [ ] **Step 4: erp-server/main.rs 后台任务区添加调用** + +在 `erp_workflow::WorkflowModule::start_timeout_checker(db.clone());` 之后添加: + +```rust +erp_health::HealthModule::start_overdue_checker(db.clone()); +tracing::info!("Follow-up overdue checker started"); +``` + +- [ ] **Step 5: 验证编译** + +Run: `cargo check` +Expected: 无错误 + +- [ ] **Step 6: 提交** + +```bash +git add crates/erp-health/ crates/erp-server/ +git commit -m "feat(health): 随访逾期定时任务 — 每 6 小时自动标记 overdue" +``` + +--- + +### Task 8: article 管理 CRUD 补充 + +**Files:** +- Modify: `crates/erp-health/src/dto/article_dto.rs` +- Modify: `crates/erp-health/src/service/article_service.rs` +- Modify: `crates/erp-health/src/handler/article_handler.rs` +- Modify: `crates/erp-health/src/module.rs` (添加路由) + +- [ ] **Step 1: article_dto.rs 新增请求 DTO + sanitize** + +新增 CreateArticleReq 和 UpdateArticleReq(含 sanitize): + +```rust +impl CreateArticleReq { + pub fn sanitize(&mut self) { + self.title = sanitize_string(&self.title); + self.summary = sanitize_option(self.summary.take()); + self.content = sanitize_option(self.content.take()); + self.category = sanitize_option(self.category.take()); + self.author = sanitize_option(self.author.take()); + } +} + +impl UpdateArticleReq { + pub fn sanitize(&mut self) { + if let Some(ref mut v) = self.title { *v = strip_html_tags(v); } + self.summary = sanitize_option(self.summary.take()); + self.content = sanitize_option(self.content.take()); + self.category = sanitize_option(self.category.take()); + self.author = sanitize_option(self.author.take()); + } +} +``` + +```rust +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct CreateArticleReq { + pub title: String, + pub summary: Option, + pub content: Option, + pub cover_image: Option, + pub category: Option, + pub author: Option, + pub published_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UpdateArticleReq { + pub title: Option, + pub summary: Option, + pub content: Option, + pub cover_image: Option, + pub category: Option, + pub author: Option, + pub published_at: Option>, + pub version: i32, +} +``` + +- [ ] **Step 2: article_service.rs 补充 create/update/delete** + +```rust +pub async fn create_article(state: &HealthState, tenant_id: Uuid, operator_id: Option, req: CreateArticleReq) -> HealthResult { ... } +pub async fn update_article(state: &HealthState, tenant_id: Uuid, id: Uuid, operator_id: Option, req: UpdateArticleReq) -> HealthResult { ... } +pub async fn delete_article(state: &HealthState, tenant_id: Uuid, id: Uuid, operator_id: Option) -> HealthResult<()> { ... } +``` + +- [ ] **Step 3: article_handler.rs 补充 handler 方法** + +- [ ] **Step 4: module.rs 修改现有路由(合并到现有 GET 路由上)** + +修改 `module.rs` 第 208-214 行的现有文章路由,将 POST/PUT/DELETE 合并: + +```rust +// 修改前(第 208-214 行): +.route("/health/articles", axum::routing::get(article_handler::list_articles)) +.route("/health/articles/{id}", axum::routing::get(article_handler::get_article)) + +// 修改后: +.route( + "/health/articles", + axum::routing::get(article_handler::list_articles) + .post(article_handler::create_article), +) +.route( + "/health/articles/{id}", + axum::routing::get(article_handler::get_article) + .put(article_handler::update_article) + .delete(article_handler::delete_article), +) +``` + +- [ ] **Step 5: 验证编译** + +Run: `cargo check` +Expected: 无错误 + +- [ ] **Step 6: 提交** + +```bash +git add crates/erp-health/ +git commit -m "feat(health): 文章管理 CRUD 补充 create/update/delete" +``` + +--- + +## Chunk 2 里程碑验收 + +- [ ] `cargo check` 全 workspace 编译通过 +- [ ] 事件处理器 `workflow.task.completed` 能更新随访任务状态 +- [ ] 排班名额不能反向修改到小于已预约数 +- [ ] 取消预约名额释放失败时整个事务回滚 +- [ ] 咨询消息 INSERT + session CAS 在同一事务中 +- [ ] 随访逾期任务每 6 小时自动检测 +- [ ] 文章 create/update/delete API 可用 + +--- + +## Chunk 3: Web 前端基础设施(阶段 3 Phase 1,1.5 天) + +### Task 9: API 服务层 — 6 个 service 文件 + +**Files:** +- Create: `apps/web/src/api/health/patients.ts` +- Create: `apps/web/src/api/health/healthData.ts` +- Create: `apps/web/src/api/health/appointments.ts` +- Create: `apps/web/src/api/health/followUp.ts` +- Create: `apps/web/src/api/health/consultations.ts` +- Create: `apps/web/src/api/health/doctors.ts` + +**参考模式:** `apps/web/src/api/users.ts` — `import client from '../client'`,`const { data } = await client.get<...>(...)` 解构模式,返回 `data.data`。 + +- [ ] **Step 1: 创建 patients.ts(12 端点)** + +```typescript +import client from '../client'; +import type { PaginatedResponse } from '../types'; + +// --- Types --- +export interface PatientListItem { + id: string; name: string; gender?: string; birth_date?: string; + blood_type?: string; status: string; verification_status: string; + source?: string; created_at: string; updated_at: string; version: number; +} +export interface PatientDetail { + id: string; name: string; gender?: string; birth_date?: string; + blood_type?: string; id_number?: string; allergy_history?: string; + medical_history_summary?: string; emergency_contact_name?: string; + emergency_contact_phone?: string; status: string; verification_status: string; + source?: string; notes?: string; created_at: string; updated_at: string; version: number; +} +export interface CreatePatientReq { + name: string; gender?: string; birth_date?: string; blood_type?: string; + id_number?: string; allergy_history?: string; medical_history_summary?: string; + emergency_contact_name?: string; emergency_contact_phone?: string; + source?: string; notes?: string; +} +export interface UpdatePatientReq extends Partial { + status?: string; verification_status?: string; version: number; +} + +export const patientApi = { + list: async (params: { page?: number; page_size?: number; search?: string; status?: string; tag_id?: string }) => { + const { data } = await client.get<{ success: boolean; data: PaginatedResponse }>('/health/patients', { params }); + return data.data; + }, + get: async (id: string) => { + const { data } = await client.get<{ success: boolean; data: PatientDetail }>(`/health/patients/${id}`); + return data.data; + }, + create: async (req: CreatePatientReq) => { + const { data } = await client.post<{ success: boolean; data: PatientDetail }>('/health/patients', req); + return data.data; + }, + update: async (id: string, req: UpdatePatientReq) => { + const { data } = await client.put<{ success: boolean; data: PatientDetail }>(`/health/patients/${id}`, req); + return data.data; + }, + delete: async (id: string, version: number) => { + await client.delete(`/health/patients/${id}`, { data: { version } }); + }, + manageTags: async (id: string, tagIds: string[]) => { + await client.post(`/health/patients/${id}/tags`, { tag_ids: tagIds }); + }, + getHealthSummary: async (id: string) => { + const { data } = await client.get<{ success: boolean; data: Record }>(`/health/patients/${id}/health-summary`); + return data.data; + }, + listFamilyMembers: async (id: string) => { + const { data } = await client.get<{ success: boolean; data: unknown[] }>(`/health/patients/${id}/family-members`); + return data.data; + }, + createFamilyMember: async (id: string, req: unknown) => { + const { data } = await client.post<{ success: boolean; data: unknown }>(`/health/patients/${id}/family-members`, req); + return data.data; + }, + assignDoctor: async (id: string, doctorId: string, relationshipType: string) => { + await client.post(`/health/patients/${id}/doctors`, { doctor_id: doctorId, relationship_type: relationshipType }); + }, + removeDoctor: async (id: string, doctorId: string) => { + await client.delete(`/health/patients/${id}/doctors/${doctorId}`); + }, +}; +``` + +- [ ] **Step 2: 创建 healthData.ts(13 端点)** + +覆盖:vital_signs CRUD (list/create/update/delete)、lab_reports CRUD、health_records CRUD、trends(list/generate/indicator timeseries)、mini trend、mini today。 + +- [ ] **Step 3: 创建 appointments.ts(6 端点)** + +覆盖:list/get/create/updateStatus、schedules (list/create/update)、calendar。 + +- [ ] **Step 4: 创建 followUp.ts(6 端点)** + +覆盖:tasks (list/get/create/update/delete)、records (list/create)。 + +- [ ] **Step 5: 创建 consultations.ts(6 端点)** + +覆盖:sessions (list/create/close)、messages (list/create)、export。 + +- [ ] **Step 6: 创建 doctors.ts(4 端点)** + +覆盖:list/get/create/update/delete。 + +- [ ] **Step 7: 验证编译** + +Run: `cd apps/web && pnpm build` +Expected: 无类型错误 + +- [ ] **Step 8: 提交** + +```bash +git add apps/web/src/api/health/ +git commit -m "feat(web): 健康模块 API 服务层 6 文件 47 端点" +``` + +--- + +### Task 10: 通用组件 — 8 个 + +**Files:** +- Create: `apps/web/src/pages/health/components/StatusTag.tsx` +- Create: `apps/web/src/pages/health/components/PatientSelect.tsx` +- Create: `apps/web/src/pages/health/components/DoctorSelect.tsx` +- Create: `apps/web/src/pages/health/components/VitalSignsChart.tsx` +- Create: `apps/web/src/pages/health/components/CalendarView.tsx` +- Create: `apps/web/src/pages/health/components/ChatBubble.tsx` +- Create: `apps/web/src/pages/health/components/ImagePreview.tsx` +- Create: `apps/web/src/pages/health/components/ExportButton.tsx` + +- [ ] **Step 1: StatusTag — 通用状态标签** + +```tsx +import { Tag } from 'antd'; + +const STATUS_CONFIG: Record = { + // 预约状态 + pending: { color: 'gold', label: '待确认' }, + confirmed: { color: 'blue', label: '已确认' }, + completed: { color: 'green', label: '已完成' }, + cancelled: { color: 'default', label: '已取消' }, + no_show: { color: 'red', label: '未到诊' }, + // 随访状态 + overdue: { color: 'red', label: '逾期' }, + in_progress: { color: 'processing', label: '进行中' }, + // 咨询状态 + waiting: { color: 'gold', label: '等待中' }, + active: { color: 'green', label: '进行中' }, + closed: { color: 'default', label: '已关闭' }, + // 患者状态 + active: { color: 'green', label: '活跃' }, + inactive: { color: 'default', label: '停用' }, + deceased: { color: 'default', label: '已故' }, + verified: { color: 'green', label: '已认证' }, +}; + +interface Props { status: string; type?: 'appointment' | 'follow_up' | 'consultation' | 'patient'; } + +export function StatusTag({ status }: Props) { + const cfg = STATUS_CONFIG[status] || { color: 'default', label: status }; + return {cfg.label}; +} +``` + +- [ ] **Step 2: PatientSelect — 患者远程搜索选择器** + +基于 Ant Design `Select` + `Debounce`,调用 `patientApi.list({ search })`。 + +- [ ] **Step 3: DoctorSelect — 医护选择器** + +同 PatientSelect 模式,调用 `doctorApi.list({ search })`。 + +- [ ] **Step 4: VitalSignsChart — ECharts 趋势图** + +使用 `@ant-design/charts` 的 `Line` 组件,接收 `{ patientId: string; indicator: string; range?: string }`,调用 `healthDataApi.getIndicatorTimeseries`。 + +- [ ] **Step 5: CalendarView — 日历视图** + +Ant Design `Calendar` + 自定义 `cellRender`,接收 schedules 数据。 + +- [ ] **Step 6: ChatBubble — 聊天气泡** + +基于 Ant Design `Typography.Paragraph` + `Avatar`,根据 `sender_role` 区分左右。**注意:使用 React 默认 JSX 转义,禁止 `dangerouslySetInnerHTML`**。 + +- [ ] **Step 7: ImagePreview — 图片预览** + +Ant Design `Image.PreviewGroup` 封装。 + +- [ ] **Step 8: ExportButton — 导出按钮** + +封装 blob 下载逻辑,参照 `PluginCRUDPage` 中的 `exportPluginDataAsBlob` 模式。 + +- [ ] **Step 9: 验证编译** + +Run: `cd apps/web && pnpm build` +Expected: 无类型错误 + +- [ ] **Step 10: 提交** + +```bash +git add apps/web/src/pages/health/components/ +git commit -m "feat(web): 健康模块通用组件 8 个" +``` + +--- + +### Task 11: 路由和菜单集成 + +**Files:** +- Modify: `apps/web/src/App.tsx` +- Modify: `apps/web/src/layouts/MainLayout.tsx` + +- [ ] **Step 1: App.tsx 添加 10 条 lazy 路由** + +```typescript +// 在现有 lazy imports 区添加 +const PatientList = lazy(() => import('./pages/health/PatientList')); +const PatientDetail = lazy(() => import('./pages/health/PatientDetail')); +const PatientTagManage = lazy(() => import('./pages/health/PatientTagManage')); +const DoctorList = lazy(() => import('./pages/health/DoctorList')); +const AppointmentList = lazy(() => import('./pages/health/AppointmentList')); +const DoctorSchedule = lazy(() => import('./pages/health/DoctorSchedule')); +const FollowUpTaskList = lazy(() => import('./pages/health/FollowUpTaskList')); +const FollowUpRecordList = lazy(() => import('./pages/health/FollowUpRecordList')); +const ConsultationList = lazy(() => import('./pages/health/ConsultationList')); +const ConsultationDetail = lazy(() => import('./pages/health/ConsultationDetail')); + +// 在嵌套 Routes 中添加 +} /> +} /> +} /> +} /> +} /> +} /> +} /> +} /> +} /> +} /> +``` + +- [ ] **Step 2: MainLayout.tsx 添加健康模块菜单组** + +```typescript +import { MedicineBoxOutlined, TeamOutlined, HeartOutlined, CalendarOutlined, PhoneOutlined, CommentOutlined, TagsOutlined } from '@ant-design/icons'; + +const healthMenuItems: MenuItem[] = [ + { key: '/health/patients', icon: , label: '患者管理' }, + { key: '/health/doctors', icon: , label: '医护管理' }, + { key: '/health/appointments', icon: , label: '预约排班' }, + { key: '/health/follow-up-tasks', icon: , label: '随访管理' }, + { key: '/health/consultations', icon: , label: '咨询管理' }, + { key: '/health/tags', icon: , label: '标签管理' }, +]; + +// 侧边栏布局中,在 bizMenuItems 和 sysMenuItems 之间插入: +
健康管理
+{healthMenuItems.map(item => )} +``` + +同时更新 `routeTitleMap`: +```typescript +'/health/patients': '患者管理', +'/health/patients/:id': '患者详情', +'/health/tags': '标签管理', +// ... 其余 7 条 +``` + +- [ ] **Step 3: 验证编译** + +Run: `cd apps/web && pnpm build` +Expected: 无类型错误(页面组件尚未创建,此时使用占位文件) + +- [ ] **Step 4: 创建 10 个页面占位文件** + +每个页面文件只包含最小占位组件,如: + +```tsx +export default function PatientList() { + return
患者管理页面(开发中)
; +} +``` + +创建以下 10 个文件: +- `apps/web/src/pages/health/PatientList.tsx` +- `apps/web/src/pages/health/PatientDetail.tsx` +- `apps/web/src/pages/health/PatientTagManage.tsx` +- `apps/web/src/pages/health/DoctorList.tsx` +- `apps/web/src/pages/health/AppointmentList.tsx` +- `apps/web/src/pages/health/DoctorSchedule.tsx` +- `apps/web/src/pages/health/FollowUpTaskList.tsx` +- `apps/web/src/pages/health/FollowUpRecordList.tsx` +- `apps/web/src/pages/health/ConsultationList.tsx` +- `apps/web/src/pages/health/ConsultationDetail.tsx` + +- [ ] **Step 5: 验证前端构建 + 浏览器可访问** + +Run: `cd apps/web && pnpm dev` +验证:浏览器访问 `/#/health/patients` 显示占位页面。 + +- [ ] **Step 6: 提交** + +```bash +git add apps/web/src/ +git commit -m "feat(web): 健康模块路由菜单 + 10 页面占位" +``` + +--- + +## Chunk 4: Web 前端 10 页面实现(阶段 3 Phase 2-6,13.5 天) + +### Task 12: PatientList + PatientTagManage + PatientDetail(Phase 2,4 天) + +**Files:** +- Replace: `apps/web/src/pages/health/PatientList.tsx` +- Replace: `apps/web/src/pages/health/PatientDetail.tsx` +- Replace: `apps/web/src/pages/health/PatientTagManage.tsx` + +**参考模式:** `apps/web/src/pages/Users.tsx` — useState + useCallback fetch + Ant Design Table + Modal。 + +- [ ] **Step 1: PatientList.tsx(1.5 天)** + +功能: +- Ant Design `Table`(不使用 ProTable,项目未安装) +- 搜索:姓名模糊 + 状态 `Select` 筛选 + 标签多选筛选 +- 每行标签显示为 `Tag` 组件列表 +- 行点击 → `useNavigate` 跳转 `/health/patients/:id` +- 创建患者 `Modal`(Ant Design `Form`) +- 编辑患者 `Modal`(复用创建表单) +- 批量打标按钮 + +- [ ] **Step 2: PatientTagManage.tsx(0.5 天)** + +功能: +- 标准 CRUD 表格(标签名 + 颜色 + 描述) +- Ant Design `ColorPicker` 选择标签颜色 +- 批量打标功能(`Transfer` 或 `Select` 多选患者) + +- [ ] **Step 3: PatientDetail.tsx(2 天)** + +功能: +- 顶部:患者摘要卡片(`Descriptions` 展示姓名/性别/年龄/状态/标签) +- Ant Design `Tabs` 5 个 Tab: + 1. **基本信息** — `Descriptions` 展示 + 编辑 `Modal` + 2. **健康趋势** — `VitalSignsChart` 组件 + 时间范围 `Radio.Group`(7d/30d/90d)+ 指标 `Select` + 3. **化验报告** — 报告卡片列表 + `ImagePreview` + 4. **就诊记录** — 嵌套列表(record_type 分组) + 5. **随访记录** — 嵌套列表 + 关联的随访记录 + +- [ ] **Step 4: 验证编译 + 浏览器测试** + +Run: `cd apps/web && pnpm build` +浏览器:患者列表 → 点击进入详情 → 各 Tab 切换正常。 + +- [ ] **Step 5: 提交** + +```bash +git add apps/web/src/pages/health/ +git commit -m "feat(web): PatientList + PatientTagManage + PatientDetail 页面" +``` + +--- + +### Task 13: DoctorList + AppointmentList + DoctorSchedule(Phase 4,6 天) + +- [ ] **Step 1: DoctorList.tsx(0.5 天)** + +功能: +- 标准 CRUD 表格 +- 科室筛选 + 在线状态 Badge +- 详情 `Drawer` + +- [ ] **Step 2: AppointmentList.tsx(2 天)** + +功能: +- Ant Design `Segmented` 切换列表/日历视图 +- 列表模式:Table + 状态筛选 + 日期筛选 +- 日历模式:`Calendar` + `cellRender` 显示当日预约数 +- 状态流转 `Dropdown`(pending→confirmed→completed/no_show/cancelled) +- 创建预约 `Modal`(选择患者 `PatientSelect` + 医生 `DoctorSelect` + 日期时段) + +- [ ] **Step 3: DoctorSchedule.tsx(2.5 天)** + +功能: +- 选择医生后展示排班(`DoctorSelect` 切换) +- 周视图(自定义 7 列网格,每列显示排班时段) +- 月视图(Ant Design `Calendar`) +- 批量创建排班(日期范围 + 时段模板) +- 显示已预约/最大预约数 + +- [ ] **Step 4: 验证编译 + 浏览器测试** + +- [ ] **Step 5: 提交** + +```bash +git add apps/web/src/pages/health/ +git commit -m "feat(web): DoctorList + AppointmentList + DoctorSchedule 页面" +``` + +--- + +### Task 14: FollowUpTaskList + FollowUpRecordList + ConsultationList + ConsultationDetail(Phase 5,6.5 天) + +- [ ] **Step 1: FollowUpTaskList.tsx(1.5 天)** + +功能: +- Table + 状态筛选(pending/in_progress/completed/overdue/cancelled) +- 分配医护(`DoctorSelect`) +- 创建任务 `Modal` +- 快捷"填写随访记录"按钮打开子 `Modal` + +- [ ] **Step 2: FollowUpRecordList.tsx(0.5 天)** + +功能: +- 纯只读台账 +- 筛选:日期范围、患者、任务、结果 +- 导出功能(`ExportButton`) + +- [ ] **Step 3: ConsultationList.tsx(1 天)** + +功能: +- Table + 状态筛选(waiting/active/closed) +- 未读消息数 Badge +- 最后消息时间 +- 关闭会话操作 +- 行点击跳转 `/health/consultations/:id` + +- [ ] **Step 4: ConsultationDetail.tsx(2 天)** + +功能: +- `ChatBubble` 组件渲染聊天记录 +- `sender_role` 区分左右对齐 +- 支持内容类型:text / image(`ImagePreview`)/ voice / file +- 消息按时间排列,滚动加载更多(分页) +- 导出按钮 + +- [ ] **Step 5: 验证编译 + 浏览器测试** + +- [ ] **Step 6: 提交** + +```bash +git add apps/web/src/pages/health/ +git commit -m "feat(web): 随访管理 + 咨询管理页面完整实现" +``` + +--- + +### Task 15: 前端打磨(Phase 6,1 天) + +- [ ] **Step 1: 暗色主题适配** +- [ ] **Step 2: 响应式布局检查** +- [ ] **Step 3: 前端联调 — 确保所有页面与后端 API 交互正常** +- [ ] **Step 4: pnpm build 生产构建通过** +- [ ] **Step 5: 提交** + +```bash +git add apps/web/ +git commit -m "feat(web): 健康模块前端打磨 — 暗色主题 + 响应式 + 联调" +``` + +--- + +## Chunk 5: 测试策略 + 端到端验证 + 路线图(交叉进行 + 1 周) + +### Task 16: validation.rs 纯函数测试(P0,1 天) + +**Files:** +- Create: `crates/erp-health/src/service/validation.rs`(在同文件 `#[cfg(test)] mod tests` 块内) + +- [ ] **Step 1: 编写 20-30 个枚举校验测试** + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_gender_valid() { assert!(validate_gender("male").is_ok()); } + #[test] + fn test_gender_invalid() { assert!(validate_gender("unknown").is_err()); } + + #[test] + fn test_appointment_status_pending_to_confirmed() { + assert!(validate_appointment_status_transition("pending", "confirmed").is_ok()); + } + #[test] + fn test_appointment_status_completed_to_pending() { + assert!(validate_appointment_status_transition("completed", "pending").is_err()); + } + // ... 覆盖所有枚举和状态转换 +} +``` + +- [ ] **Step 2: 运行测试** + +Run: `cargo test -p erp-health -- validation::tests` +Expected: 全部通过 + +- [ ] **Step 3: 提交** + +```bash +git add crates/erp-health/src/service/validation.rs +git commit -m "test(health): validation.rs 纯函数测试 20+ 用例" +``` + +--- + +### Task 17: 核心 service 集成测试(P0,4 天) + +**Files:** +- Create: `crates/erp-health/tests/test_helpers.rs` +- Create: `crates/erp-health/tests/appointment_test.rs` +- Create: `crates/erp-health/tests/patient_test.rs` + +**测试基础设施:** 使用 testcontainers-postgreSQL 做真实数据库测试。 + +- [ ] **Step 1: 创建 test_helpers.rs** + +提供 `create_integration_db()` — testcontainers PostgreSQL 实例 + 自动运行迁移。 + +- [ ] **Step 2: appointment_test.rs — CAS 并发 + 状态流转** + +关键场景: +- 排班已满 → 创建预约失败 +- 排班有余 → CAS 成功 + 名额减 1 +- 并发创建 → 只有 max_appointments 个成功 +- 状态转换合法/非法 +- 取消预约 → 名额释放 + +- [ ] **Step 3: patient_test.rs — CRUD + 状态机** + +关键场景: +- 创建/更新/删除患者 +- 状态转换 active→inactive→deceased +- 乐观锁版本冲突 + +- [ ] **Step 4: 运行测试** + +Run: `cargo test -p erp-health` +Expected: 全部通过 + +- [ ] **Step 5: 提交** + +```bash +git add crates/erp-health/tests/ +git commit -m "test(health): 预约 CAS 并发 + 患者 CRUD 集成测试" +``` + +--- + +### Task 18: 端到端验证(阶段 4,1 周) + +- [ ] **Step 1: 小程序联调** — 确认小程序端 API 调用与新后端兼容 +- [ ] **Step 2: 种子数据填充** — 在开发环境填充演示数据(患者/排班/预约/随访/咨询) +- [ ] **Step 3: Docker 演示环境** — 确认 docker-compose 能启动完整演示 +- [ ] **Step 4: 文档更新** — 更新 wiki/erp-health.md 和 CLAUDE.md 中的完成状态 +- [ ] **Step 5: 最终提交 + 推送** + +```bash +git add . +git commit -m "chore: 端到端验证完成 — 健康管理模块 V1 交付" +git push +``` + +--- + +## 总体里程碑 + +| 里程碑 | 交付物 | 验收标准 | +|--------|--------|---------| +| M1 | 安全省基完成(Task 1-4) | sanitize + 审计 + 加密 + 脱敏全部到位 | +| M2 | 后端功能完整(Task 5-8) | 事件处理器 + 数据一致性 + 测试通过 | +| M3 | Web 基础设施(Task 9-11) | API 层 + 组件 + 路由菜单可运行 | +| M4 | Web 核心页面(Task 12-13) | PatientList + AppointmentList + DoctorSchedule 可操作 | +| M5 | Web 全部页面(Task 14-15) | 10 页面功能可用,pnpm build 通过 | +| M6 | 端到端验证(Task 16-18) | Web + 小程序 + 后端全链路可演示 | + +## 总时间线 + +``` +Week 1-2 | 安全省基(Task 1-4) +Week 2-4 | 后端补完 + 测试(Task 5-8 + Task 16-17) +Week 4-7 | Web 前端(Task 9-15) +Week 7-8 | 端到端验证(Task 18) +总计 7-8 周 +``` diff --git a/docs/superpowers/specs/2026-04-24-health-module-iteration-design.md b/docs/superpowers/specs/2026-04-24-health-module-iteration-design.md new file mode 100644 index 0000000..5dc1fb6 --- /dev/null +++ b/docs/superpowers/specs/2026-04-24-health-module-iteration-design.md @@ -0,0 +1,671 @@ +# 健康管理模块全面迭代设计 + +> **文档版本**: 1.0 +> **日期**: 2026-04-24 +> **状态**: 待评审 +> **基于**: 5 位专家(后端架构/前端架构/医疗业务/安全质量/产品策略)深度审查 + +--- + +## 0. 审查发现总览 + +### 0.1 V1 发布阻塞项 + +| # | 阻塞项 | 来源 | 影响 | +|---|--------|------|------| +| B1 | Web 健康模块 10 页面未实现 | 前端架构/产品策略 | 无法演示和交付 | +| B2 | 医疗数据安全不合规 | 安全质量 | 零 sanitize / 零审计 / 身证明文 / 零测试 | +| B3 | 数据一致性缺陷 | 医疗业务/后端架构 | 排班可超额 / 名额释放可能失败 / 随访逾期未实现 | +| B4 | 事件处理器空壳 | 后端架构 | 随访状态/咨询消息不联动 | + +### 0.2 当前完成度 + +| 层级 | 模块 | 完成度 | +|------|------|--------| +| 后端 | erp-health(16 实体/8 服务/7 handler/40+ API) | 95% | +| 后端 | 事件处理器业务逻辑 | 0%(框架已搭建,需填充 db 操作) | +| 后端 | sanitize / 审计 / 加密 | 0% | +| 后端 | 测试覆盖 | 0% | +| Web 前端 | 健康模块页面 | 0% | +| Web 前端 | 健康模块 API 服务层 | 0% | +| 小程序 | 初版 21 页面 | 85% | + +--- + +## 1. 安全省基(阶段 1,1.5-2 周) + +### 1.1 sanitize 全覆盖 + +**问题**: erp-health 模块没有任何对 `strip_html_tags` 的调用,攻击者可在患者姓名、病史等字段注入 XSS payload。 + +**参考实现**: `crates/erp-auth/src/dto.rs` 第 96-118 行,`CreateUserReq` 和 `UpdateUserReq` 已实现 `sanitize()` 方法。 + +**修复方案**: 为每个 DTO 的字符串输入字段添加 sanitize。 + +**覆盖字段清单**: + +| DTO 文件 | 字段 | +|----------|------| +| `patient_dto.rs` CreatePatientReq / UpdatePatientReq | name, notes, allergy_history, medical_history_summary, emergency_contact_name, source | +| `patient_dto.rs` FamilyMemberReq(create + update 共用) | name, notes | +| `patient_handler.rs` AssignDoctorReq(位于 handler 非 dto) | — (无字符串字段) | +| `health_data_dto.rs` CreateVitalSignsReq | notes | +| `health_data_dto.rs` CreateLabReportReq | doctor_interpretation | +| `health_data_dto.rs` CreateHealthRecordReq | source, overall_assessment, notes | +| `appointment_dto.rs` CreateAppointmentReq | notes, cancel_reason | +| `follow_up_dto.rs` CreateFollowUpTaskReq / UpdateFollowUpTaskReq | content_template | +| `follow_up_dto.rs` CreateFollowUpRecordReq | patient_condition, medical_advice | +| `consultation_dto.rs` CreateMessageReq | content | +| `consultation_dto.rs` CreateSessionReq | — (无字符串字段) | +| `doctor_dto.rs` CreateDoctorReq / UpdateDoctorReq | department, title, specialty, bio | + +**实现模式**: + +```rust +// 封装 sanitize 辅助函数(与 erp-auth 的 sanitize_option 模式一致) +fn sanitize_option_string(opt: Option) -> Option { + opt.map(|s| strip_html_tags(&s)) +} + +// 在每个 DTO 的 impl 中添加 sanitize 方法 +impl CreatePatientReq { + pub fn sanitize(&mut self) { + self.name = strip_html_tags(&self.name); + self.notes = sanitize_option_string(self.notes.take()); + self.allergy_history = sanitize_option_string(self.allergy_history.take()); + self.medical_history_summary = sanitize_option_string(self.medical_history_summary.take()); + // ... + } +} + +// 在 handler 调用 service 前执行 +async fn create_patient(/* ... */) -> AppResult>> { + let mut req: CreatePatientReq = Json(req).0; + req.sanitize(); + // ... +} +``` + +**前端安全**: ChatBubble 组件必须使用 React 默认 JSX 转义渲染文本内容(不使用 `dangerouslySetInnerHTML`),图片消息 URL 需做白名单校验。 + +### 1.2 审计日志注入 + +**问题**: erp-health 整个模块没有任何对 `audit_service::record` 的调用。 + +**参考实现**: `crates/erp-auth/src/service/auth_service.rs` 第 168-177 行。 + +**修复方案**: 在所有写入操作的 service 层添加审计记录。 + +**覆盖操作清单**: + +| Service | 操作 | 审计 action | +|---------|------|------------| +| patient_service | create_patient | `patient.created` | +| patient_service | update_patient | `patient.updated` | +| patient_service | delete_patient | `patient.deleted` | +| patient_service | manage_patient_tags | `patient.tags_updated` | +| health_data_service | create_vital_signs | `vital_signs.created` | +| health_data_service | create_lab_report | `lab_report.created` | +| health_data_service | create_health_record | `health_record.created` | +| appointment_service | create_appointment | `appointment.created` | +| appointment_service | update_appointment_status | `appointment.status_changed` | +| follow_up_service | create_task | `follow_up_task.created` | +| follow_up_service | create_record | `follow_up_record.created` | +| consultation_service | create_session | `consultation.opened` | +| consultation_service | close_session | `consultation.closed` | +| consultation_service | create_message | `consultation.message_sent` | +| doctor_service | create/update/delete_doctor | `doctor.*` | + +**审计日志内容**: tenant_id、user_id、action、resource_type、resource_id、变更前后值摘要。 + +**注意**: 当前 `audit_service::record` 是 fire-and-forget,审计日志丢失对医疗合规不可接受。修复方案: +1. 新增 `record_in_txn(log: AuditLog, txn: &DatabaseTransaction)` 方法,在事务内 await 写入 +2. 保留原 `record` 方法用于不要求事务保证的场景 +3. erp-health 的关键写入操作使用 `record_in_txn`,失败时回滚整个事务 +4. 需要改为事务包裹的 service 方法:create_patient、update_patient、delete_patient、create_appointment、update_appointment_status、create_record(随访)、create_message(咨询) + +### 1.3 身份证号加密存储 + +**问题**: `patient.id_number` 明文存储在数据库中,违反《个人信息保护法》。 + +**方案**: AES-256-GCM 应用层加密。 + +**新增文件**: `crates/erp-health/src/crypto.rs` + +```rust +pub struct HealthCrypto { key: [u8; 32] } + +impl HealthCrypto { + pub fn from_env() -> Self { /* 从 ERP__HEALTH__ENCRYPTION_KEY 读取 */ } + pub fn encrypt(&self, plaintext: &str) -> AppResult { /* AES-256-GCM + Base64 */ } + pub fn decrypt(&self, ciphertext: &str) -> AppResult { /* 解密 */ } +} +``` + +**集成点**: +- `patient_service::create_patient` — 加密 id_number 后存储 +- `patient_service::update_patient` — 同上 +- `patient_service::get_patient` — 解密后返回 +- `patient_service::list_patients` — 列表不返回 id_number(脱敏) + +**密钥管理**: 环境变量 `ERP__HEALTH__ENCRYPTION_KEY`(32 字节 hex),必须在 `default.toml` 中标记为 `__MUST_SET_VIA_ENV__`。 + +**搜索兼容**: `patient.id_number` 的模糊搜索(`contains`)改为精确匹配(`eq`),在加密后使用 HMAC 索引做等值查询。 + +**HMAC 索引详情**: +- 新增数据库列 `id_number_hash VARCHAR(64)`,存储 HMAC-SHA256 哈希 +- HMAC 密钥独立于 AES 密钥,从环境变量 `ERP__HEALTH__HMAC_KEY` 读取 +- 创建/更新患者时同时写入 hash 列,等值查询使用 `WHERE id_number_hash = hmac(输入值)` +- 迁移 SQL:新增列 → 批量加密现有明文 → 删除原明文列(可选) + +**数据迁移方案**: +1. 停机窗口(预估 1-2 小时,视数据量) +2. 迁移脚本:`SELECT id, id_number FROM patients WHERE id_number IS NOT NULL AND deleted_at IS NULL` → 批量加密 → `UPDATE patients SET id_number = $encrypted WHERE id = $id` +3. 同步写入 `id_number_hash` 列 +4. 验证脚本:抽样解密比对原值 +5. 回滚方案:保留明文备份表 `patients_id_number_backup`,72 小时后确认无误再删除 + +**问题**: 列表接口直接返回完整身份证号、病史等敏感字段。 + +**修复方案**: 拆分响应 DTO。 + +```rust +// 列表用 — 不含敏感字段 +pub struct PatientListResp { + pub id: Uuid, + pub name: String, + pub gender: Option, + pub birth_date: Option, + pub status: String, + pub tags: Vec, + // 无 id_number, allergy_history, medical_history_summary, emergency_contact_phone 等 +} + +// 详情用 — 敏感字段掩码 +pub struct PatientDetailResp { + // ... 全部字段 + pub id_number: Option, // "320***********1234" + pub emergency_contact_phone: Option, // "138****1234" +} +``` + +--- + +## 2. 后端补完(阶段 2,1.5 周) + +### 2.1 事件处理器实现 + +**问题**: `event.rs` 中两个事件处理器只有 `tracing::info`,无实际业务逻辑。且 handler 中没有 `DatabaseConnection`,无法执行数据库操作。 + +**方案**: 在 `HealthModule::on_startup` 中创建 `HealthState` 并注册需要数据库访问的事件处理器。将现有 `register_event_handlers` 中的空壳代码迁移到 `on_startup`,`register_event_handlers` 改为空实现。 + +**修改 `crates/erp-health/src/module.rs`**: + +```rust +// register_event_handlers 改为空实现 +fn register_event_handlers(&self, _bus: &EventBus) { + // 事件处理器迁移到 on_startup,此处不再注册 +} + +// on_startup 中注册带 db 的事件处理器 +async fn on_startup(&self, ctx: &ModuleContext) -> AppResult<()> { + let state = HealthState { + db: ctx.db.clone(), + event_bus: ctx.event_bus.clone(), + }; + crate::event::register_handlers_with_state(state); + Ok(()) +} +``` + +**修改 `crates/erp-health/src/event.rs`**: + +新增 `register_handlers_with_state(state: HealthState)` 函数替代原有 `register_handlers`。 + +**事件处理器业务逻辑**: + +`workflow.task.completed`: +1. 从 payload 中提取 `task_id` +2. 查询 `follow_up_task WHERE related_appointment_id` 或通过 payload 映射 +3. 更新随访任务状态为 `completed` + +`message.sent`: +1. 从 payload 中提取 `session_id`(或通过 sender/recipient 关联) +2. 更新 `consultation_session SET last_message_at = NOW(), unread_count = unread_count + 1` +3. 使用 `check_version` 乐观锁 + +### 2.2 数据一致性修复 + +#### 2.2.1 排班名额保护 + +**问题**: `update_schedule` 可以将 `max_appointments` 改为小于 `current_appointments` 的值。 + +**修复**: 在 `appointment_service.rs` 的 `update_schedule` 方法中增加校验: + +```rust +if req.max_appointments < model.current_appointments { + return Err(HealthError::Validation( + "max_appointments 不能小于当前已预约数".into() + ).into()); +} +``` + +#### 2.2.2 取消预约名额释放 + +**问题**: `update_appointment_status` 中取消时名额释放失败只 log error 不回滚。 + +**修复**: 将名额释放作为事务的一部分,失败时回滚整个操作(包括状态更新)。 + +#### 2.2.3 咨询消息原子性 + +**问题**: `create_message` 中消息已插入,但后续 CAS 更新 session 失败时返回错误 — 消息已持久化但 session 元数据未更新。 + +**修复**: 将消息 INSERT + session CAS 更新放在同一个事务中。 + +### 2.3 随访逾期定时任务 + +**问题**: 设计规格定义了 `overdue` 状态和定时任务自动标记,但代码中: +- `validation.rs` 不允许转换到 `overdue` +- 没有后台定时任务 + +**修复**: + +1. 在 `validation.rs` 中添加 `overdue` 转换规则:`pending -> overdue`(仅限系统自动触发) +2. 在 `erp-server/src/main.rs` 后台任务区增加逾期检查器,使用与现有 `start_timeout_checker` 一致的 `tokio::spawn` + `loop` + `tokio::time::interval` 模式(每 6 小时执行一次,非 cron 表达式): + +```rust +// erp-server/src/main.rs 后台任务区 +tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(6 * 3600)); + loop { + interval.tick().await; + // 调用 health module 的 check_overdue_tasks + } +}); +``` + +3. 在 `erp-health` module 中添加一个公开方法 `check_overdue_tasks` 供定时任务调用。 + +### 2.4 article 管理 CRUD + +**问题**: 权限声明中有 `health.articles.manage`,但 service/handler 只有 list 和 get。 + +**修复**: 在 `article_service.rs` 和 `article_handler.rs` 中补充 create/update/delete 方法。在 `module.rs` 中添加路由。**工时估算**: 0.5 天。 + +--- + +## 3. Web 前端 10 页面(阶段 3,3.5-4 周) + +### 3.1 页面文件组织 + +``` +apps/web/src/ +├── api/health/ +│ ├── patients.ts # 12 端点 +│ ├── healthData.ts # 13 端点 +│ ├── appointments.ts # 6 端点 +│ ├── followUp.ts # 6 端点 +│ ├── consultations.ts # 6 端点 +│ └── doctors.ts # 4 端点 +├── pages/health/ +│ ├── PatientList.tsx # 患者列表 +│ ├── PatientDetail.tsx # 患者详情(5 Tab) +│ ├── PatientTagManage.tsx # 标签管理 +│ ├── DoctorList.tsx # 医护列表 +│ ├── AppointmentList.tsx # 预约管理 +│ ├── DoctorSchedule.tsx # 排班管理 +│ ├── FollowUpTaskList.tsx # 随访任务 +│ ├── FollowUpRecordList.tsx # 随访台账 +│ ├── ConsultationList.tsx # 会话管理 +│ ├── ConsultationDetail.tsx # 对话详情 +│ └── components/ +│ ├── StatusTag.tsx # 通用状态标签 +│ ├── PatientSelect.tsx # 患者搜索选择器 +│ ├── DoctorSelect.tsx # 医护选择器 +│ ├── VitalSignsChart.tsx # ECharts 趋势图 +│ ├── CalendarView.tsx # 日历视图 +│ ├── ChatBubble.tsx # 聊天气泡 +│ ├── ImagePreview.tsx # 图片预览 +│ └── ExportButton.tsx # 导出按钮 +``` + +### 3.2 API 服务层设计 + +每个 service 文件遵循现有 `api/users.ts` 的解构模式: + +```typescript +// api/health/patients.ts +import client from '../client'; + +export interface Patient { + id: string; + name: string; + gender?: string; + birth_date?: string; + status: string; + tags: Tag[]; + // ... +} + +export interface CreatePatientReq { + name: string; + gender?: string; + // ... +} + +export const patientApi = { + list: async (params: ListParams) => { + const { data } = await client.get<{ success: boolean; data: PaginatedResponse }>( + '/health/patients', { params } + ); + return data.data; + }, + get: async (id: string) => { + const { data } = await client.get<{ success: boolean; data: Patient }>( + `/health/patients/${id}` + ); + return data.data; + }, + create: async (req: CreatePatientReq) => { + const { data } = await client.post<{ success: boolean; data: Patient }>( + '/health/patients', req + ); + return data.data; + }, + // ... +}; +``` + +### 3.3 路由注册 + +在 `App.tsx` 中新增: + +```typescript +// lazy imports +const PatientList = lazy(() => import('./pages/health/PatientList')); +const PatientDetail = lazy(() => import('./pages/health/PatientDetail')); +// ... 共 10 个路由组件 + +// Routes 内 +} /> +} /> +} /> +} /> +} /> +} /> +} /> +} /> +} /> +} /> +``` + +### 3.4 侧边栏菜单 + +在 `MainLayout.tsx` 中新增 `healthMenuItems` 数组(参照现有 `bizMenuItems` 模式),使用 `@ant-design/icons` 图标(如 `MedicineBoxOutlined`、`HeartOutlined`、`CalendarOutlined`、`PhoneOutlined`、`CommentOutlined`、`TagsOutlined`): + +``` +侧边栏布局: +├── 首页 (HomeOutlined) +├── 用户管理 (UserOutlined) +├── 权限管理 (SafetyOutlined) +├── 工作流 (ApartmentOutlined) +├── 消息中心 (BellOutlined) +├── ───────── +├── 健康管理 (MedicineBoxOutlined) ← 新增组 +│ ├── 患者管理 (TeamOutlined) +│ ├── 医护管理 (HeartOutlined) +│ ├── 预约排班 (CalendarOutlined) +│ ├── 随访管理 (PhoneOutlined) +│ ├── 咨询管理 (CommentOutlined) +│ └── 标签管理 (TagsOutlined) +├── ───────── +├── 插件管理 (AppstoreOutlined) +├── 系统设置 (SettingOutlined) +``` + +### 3.5 前端权限集成 + +后端已有完整权限体系(14 个权限码),前端 V1 阶段采用以下策略: + +1. **路由级权限**: 所有健康模块路由在 `PrivateRoute` 内(已实现),后端 `require_permission` 拦截无权限请求返回 403 +2. **按钮级权限(V1 简化)**: 不做前端按钮级权限控制,依赖后端 403 响应。后续可扩展 `usePermission` hook +3. **菜单可见性**: 健康模块菜单组始终显示,但无权限用户点击任何页面会收到 403 提示 + +### 3.5 13 页面逐一设计 + +#### PatientList.tsx(中复杂度,1.5 天) + +- Ant Design `Table` 组件(与 Users.tsx 模式一致,不使用 ProTable) +- 搜索:姓名模糊 + 状态筛选 + 标签多选筛选 +- 每行显示患者标签为 `Tag` 组件列表 +- 行点击跳转 `/health/patients/:id` +- 批量操作:批量打标 +- 导出功能 + +#### PatientDetail.tsx(高复杂度,3 天) + +- 顶部:患者摘要卡片(姓名/性别/年龄/状态/标签) +- Ant Design `Tabs` 5 个 Tab: + 1. **基本信息** — `Descriptions` 展示 + 编辑 Modal + 2. **健康趋势** — `VitalSignsChart` 组件 + 时间范围选择器 + 3. **化验报告** — 报告卡片列表 + `ImagePreview` 指标详情 + 4. **就诊记录** — 嵌套列表(体检/门诊/住院) + 5. **随访记录** — 嵌套列表 + 关联的随访记录 + +#### PatientTagManage.tsx(低复杂度,0.5 天) + +- 标准 CRUD 表格 +- 颜色选择器(Ant Design `ColorPicker`) +- 批量打标功能 + +#### DoctorList.tsx(低复杂度,0.5 天) + +- 标准 CRUD 表格 +- 科室筛选 + 在线状态 Badge(online=绿/busy=黄/offline=灰) +- 详情 Drawer + +#### AppointmentList.tsx(中复杂度,2 天) + +- `Segmented` 切换列表/日历视图 +- 列表模式:表格 + 状态筛选 + 日期筛选 +- 日历模式:`Calendar` + `cellRender` 显示当日预约数 +- 状态流转 Dropdown(pending → confirmed → completed/no_show/cancelled) +- 创建预约 Modal(选择患者 + 医生 + 日期时段 + 检查排班余量) + +#### DoctorSchedule.tsx(高复杂度,2.5 天) + +- 选择医生后展示其排班 +- 周视图(自定义 7 列网格,每列显示一天的排班时段) +- 月视图(Ant Design Calendar) +- 批量创建排班(选择日期范围 + 时段模板) +- 显示已预约/最大预约数 + +#### FollowUpTaskList.tsx(中复杂度,1.5 天) + +- 表格 + 状态筛选(pending/in_progress/completed/overdue/cancelled) +- 分配给医护(`DoctorSelect`) +- 创建任务 Modal +- 快捷"填写随访记录"按钮打开子 Modal + +#### FollowUpRecordList.tsx(低复杂度,0.5 天) + +- 纯只读台账 +- 筛选:日期范围、患者、任务、结果 +- 导出功能(`ExportButton`) + +#### ConsultationList.tsx(中复杂度,1 天) + +- 表格 + 状态筛选(waiting/active/closed) +- 未读消息数 Badge +- 最后消息时间 +- 关闭会话操作 +- 点击跳转 `/health/consultations/:id` + +#### ConsultationDetail.tsx(高复杂度,2 天) + +- `ChatBubble` 组件渲染聊天气泡 +- 根据 `sender_role` 区分左右对齐 +- 支持内容类型:text / image(`ImagePreview`)/ voice / file +- 消息按时间排列,支持滚动加载更多(分页) +- 导出按钮 + +### 3.6 技术难点方案 + +#### ECharts 趋势图 + +使用已安装的 `@ant-design/charts` 的 `Line` 组件。 + +- 后端 API `/patients/:id/trends/:indicator` 返回时序数据 +- 前端转换为 `{ date: string, value: number }[]` +- 支持多指标叠加(血压收缩压/舒张压双线) +- 封装为 `VitalSignsChart`,接收 `patientId` + `indicators` 参数 +- 时间范围选择器(7天/30天/90天) + +#### 日历视图 + +Ant Design `Calendar` + 自定义 `cellRender`: +- DoctorSchedule:每个日期格显示排班时段标签 +- AppointmentList:每个日期格显示预约数量气泡 + +#### 聊天 UI + +自定义 `ChatBubble` 组件,基于 Ant Design `Typography.Paragraph` + `Avatar`: +- 根据 `sender_role` 区分样式 +- 只读模式(PC 后台只查看不发送) +- 图片消息使用 `Image.PreviewGroup` + +#### 导出 + +后端 blob 导出 + 前端触发下载,参照 `PluginCRUDPage` 中已有的 `exportPluginDataAsBlob` 模式。 + +#### 文件上传/预览 + +- 上传:Ant Design `Upload.Dragger`,上传到后端文件接口 +- 图片预览:Ant Design `Image.PreviewGroup` +- PDF 预览:新窗口打开(V1 简化方案) + +### 3.7 开发顺序 + +| Phase | 内容 | 天数 | 依赖 | +|-------|------|------|------| +| 1 | API 层 6 文件 + 通用组件 + 路由菜单 | 1.5 | 无 | +| 2 | PatientList + PatientTagManage + PatientDetail 基本信息Tab | 2 | Phase 1 | +| 3 | VitalSignsChart + 健康趋势 Tab + LabReportList + HealthRecordList | 3 | Phase 2 | +| 4 | DoctorList + AppointmentList + DoctorSchedule | 3 | Phase 1 | +| 5 | FollowUpTaskList + FollowUpRecordList + ConsultationList + ConsultationDetail | 3 | Phase 1 | +| 6 | 打磨(暗色主题 + 响应式 + 联调) | 1 | Phase 2-5 | +| **合计** | | **13.5 天** | | + +--- + +## 4. 测试策略(阶段 2-3 交叉进行) + +### 4.1 优先级排序 + +| 优先级 | 测试目标 | 预估用例数 | 工作量 | +|--------|---------|-----------|--------| +| P0 | `validation.rs` 纯函数 | 20-30 | 1 天 | +| P0 | `appointment_service` CAS + 状态流转 | 15-20 | 2 天 | +| P0 | `patient_service` CRUD + 状态机 | 15-20 | 2 天 | +| P1 | `consultation_service` 消息原子性 | 10-15 | 2 天 | +| P1 | `health_data_service` 指标数据 | 10-15 | 1 天 | +| P2 | `follow_up_service` 链式任务 | 10 | 1 天 | + +### 4.2 测试基础设施 + +在 `erp-health/Cargo.toml` 中添加 `[dev-dependencies]`: +- `tokio` 的 `test` 和 `macros` feature +- `sea-orm` 的 `mock` feature(用于简单单元测试,如 validation 纯函数) + +对于涉及事务和 CAS 的集成测试(预约并发、消息原子性),使用 testcontainers-postgreSQL 做真实数据库测试,因为 SeaORM 的 `MockDatabaseConnection` 不支持复杂事务模拟。 + +创建 `tests/test_helpers.rs` 提供: +- `create_test_health_state()` — 带 mock db 的 HealthState(单元测试用) +- `create_integration_db()` — testcontainers PostgreSQL 实例(集成测试用) +- 共享 fixture 工厂 + +### 4.3 关键测试场景 + +**预约 CAS 并发**: +- 排班已满 → 创建预约失败 +- 排班有余 → CAS 成功 + 名额减 1 +- 并发创建 → 只有 max_appointments 个成功 + +**状态机转换**: +- 合法转换:pending → confirmed → completed +- 非法转换:completed → pending → 拒绝 +- 取消:任意状态 → cancelled(填 cancel_reason) + +**随访链式任务**: +- next_follow_up_date 不为空 → 自动创建新任务 +- 新任务的 assigned_to 沿用当前医护 +- next_follow_up_date 为空 → 不创建新任务 + +--- + +## 5. 实施路线图 + +### 5.1 总时间线(调整为 7 周) + +``` +Week 1-2 | 安全地基(1.5-2 周) + | ├── sanitize 全覆盖(2 天) + | ├── 审计日志注入(2 天) + | ├── 身份证号加密 + HMAC 索引 + 数据迁移(3-4 天) + | └── 字段级脱敏(1-2 天) + +Week 2-4 | 后端补完 + 测试(1.5-2 周) + | ├── 事件处理器实现(2 天) + | ├── 数据一致性修复(2 天) + | ├── 随访逾期定时任务(1 天) + | ├── article CRUD(0.5 天) + | └── 核心路径测试(5-6 天) + +Week 4-7 | Web 前端(3.5-4 周) + | ├── Phase 1: API 层 + 通用组件 + 路由菜单(1.5 天) + | ├── Phase 2: 核心入口页面(2 天) + | ├── Phase 3: 健康数据页面(3 天) + | ├── Phase 4: 预约排班页面(3 天) + | ├── Phase 5: 随访咨询页面(3 天) + | └── Phase 6: 打磨联调(1 天) + +Week 7-8 | 端到端验证(1 周) + | ├── 小程序联调 + | ├── 种子数据填充 + | ├── Docker 演示环境 + | └── 文档更新 +``` + +### 5.2 里程碑 + +| 里程碑 | 交付物 | 验收标准 | +|--------|--------|---------| +| M1 | 安全省基完成 | sanitize + 审计 + 加密 + 脱敏全部到位,cargo test 通过 | +| M2 | 后端功能完整 | 事件处理器 + 数据一致性 + 测试覆盖,cargo test 通过 | +| M3 | Web 3 核心页面 | PatientList + AppointmentList + DoctorSchedule 可操作 | +| M4 | Web 10 页面完成 | 所有页面功能可用,pnpm build 通过 | +| M5 | 端到端验证 | Web + 小程序 + 后端全链路可演示 | + +### 5.3 风险和缓解 + +| 风险 | 概率 | 缓解 | +|------|------|------| +| ECharts 集成复杂度高 | 中 | 使用 @ant-design/charts 已安装,降低自研成本 | +| 身份证加密影响现有查询 | 中 | HMAC 索引 + 数据迁移脚本 + 备份表 + 回滚方案 | +| 10 页面开发时间超预期 | 高 | 按优先级裁剪,MVP 先做 3 核心页面 | +| 文件上传能力未就绪 | 中 | V1 先支持 URL 存储,文件上传推迟到 V1.1 | + +--- + +## 6. 不在本设计范围内(推迟到 V2) + +- 积分商城 +- 数据统计中心 / 运营驾驶舱 +- AI 辅助诊断/报告解读 +- 实时 WebSocket 在线咨询 +- 咨询消息按月分区 +- 事件幂等性(processed_events 去重表) +- Polling Outbox 重试机制 +- HealthState 扩展 Redis 缓存 +- 国际化(英文等多语言) +- 小程序医护端