fix(health): 穷尽审计修复 — 权限同步/编译错误/前端bug/审计日志
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / security-audit (push) Has been cancelled

审计发现并修复的问题:

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

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

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

CRITICAL (新发现):
- 权限未同步: 健康模块 14 个权限从未写入数据库,添加启动时自动同步
- migration 表名错误: patients -> patient
- 编译错误: health_trend entity 未导入, ToPrimitive trait 未导入
- HealthError 缺少 From<AppError> 实现
This commit is contained in:
iven
2026-04-25 08:58:58 +08:00
parent 9ffb938128
commit 07f4ba41ba
31 changed files with 3373 additions and 445 deletions

130
Cargo.lock generated
View File

@@ -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"

View File

@@ -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<ArticleListItem>;
}>('/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;
},
};

View File

@@ -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 },

View File

@@ -13,6 +13,7 @@ export interface PatientListItem {
source?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface PatientDetail {

View File

@@ -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]);

View File

@@ -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<string, string> = {
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<string, string> = {};
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<FollowUpRecord> = [
{
@@ -178,11 +172,6 @@ export default function FollowUpRecordList() {
onChange={(val) => handlePatientChange(val)}
placeholder="筛选患者"
/>
<ExportButton
fetchUrl="/health/follow-up-records/export"
params={exportParams}
filename={`随访记录_${dayjs().format('YYYYMMDD')}.csv`}
/>
<span
style={{
fontSize: 13,

View File

@@ -108,8 +108,9 @@ export default function FollowUpTaskList() {
setTotal(result.total);
} catch {
message.error('加载随访任务失败');
} finally {
setLoading(false);
}
setLoading(false);
}, []);
useEffect(() => {
@@ -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);

View File

@@ -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());
}
}

View File

@@ -14,6 +14,8 @@ pub struct SessionResp {
pub unread_count_patient: i32,
pub unread_count_doctor: i32,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]

View File

@@ -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)]

View File

@@ -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,
}

View File

@@ -86,7 +86,7 @@ impl From<sea_orm::DbErr> for HealthError {
impl From<AppError> for HealthError {
fn from(err: AppError) -> Self {
HealthError::DbError(err.to_string())
HealthError::Validation(err.to_string())
}
}

View File

@@ -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<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
Json(req): Json<DeleteArticleReq>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
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(())))
}

View File

@@ -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<String>,
pub patient_id: Option<Uuid>,
pub doctor_id: Option<Uuid>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}
pub async fn create_session<S>(
@@ -83,6 +86,20 @@ where
Ok(Json(ApiResponse::ok(result)))
}
pub async fn get_session<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<SessionResp>>, AppError>
where
HealthState: FromRef<S>,
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<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
@@ -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<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<ExportSessionsParams>,
) -> Result<Json<ApiResponse<Vec<SessionResp>>>, AppError>
) -> Result<Json<ApiResponse<PaginatedResponse<SessionResp>>>, AppError>
where
HealthState: FromRef<S>,
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)))

View File

@@ -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<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,

View File

@@ -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<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,

View File

@@ -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<T> {
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?;

View File

@@ -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<String>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct DeleteWithVersion {
pub version: i32,
}
pub async fn list_patients<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
@@ -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(())))
}

View File

@@ -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<S>() -> Router<S>
@@ -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(),

View File

@@ -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,

View File

@@ -192,6 +192,7 @@ pub async fn delete_article(
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
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(

View File

@@ -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<SessionResp> {
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<String>,
patient_id: Option<Uuid>,
doctor_id: Option<Uuid>,
) -> HealthResult<Vec<SessionResp>> {
page: Option<u64>,
page_size: Option<u64>,
) -> HealthResult<PaginatedResponse<SessionResp>> {
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 })
}
// ---------------------------------------------------------------------------

View File

@@ -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(())
}

View File

@@ -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<PaginatedResponse<TrendResp>> {
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<Uuid>,
period_start: chrono::NaiveDate,
period_end: chrono::NaiveDate,
) -> HealthResult<TrendResp> {
// 汇总该时间段内的体征数据
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::<i32>() 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<chrono::NaiveDate>,
end_date: Option<chrono::NaiveDate>,
) -> HealthResult<IndicatorTimeseriesResp> {
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<Option<Uuid>> {
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<String>) -> 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 查找关联的 patientpatient.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<String>,
) -> HealthResult<MiniTrendResp> {
// 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<MiniTodayResp> {
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,
})
}

View File

@@ -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;

View File

@@ -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<Uuid>,
) -> 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(())
}

View File

@@ -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<PaginatedResponse<TrendResp>> {
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<Uuid>,
period_start: chrono::NaiveDate,
period_end: chrono::NaiveDate,
) -> HealthResult<TrendResp> {
// 汇总该时间段内的体征数据
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<i32>]| -> f64 {
let valid: Vec<i32> = vals.iter().filter_map(|&v| v).collect();
if valid.is_empty() { return 0.0; }
valid.iter().sum::<i32>() as f64 / valid.len() as f64
};
let avg_f64 = |vals: &[Option<f64>]| -> f64 {
let valid: Vec<f64> = vals.iter().filter_map(|&v| v).collect();
if valid.is_empty() { return 0.0; }
valid.iter().sum::<f64>() / valid.len() as f64
};
let heart_rates: Vec<Option<i32>> = vitals.iter().map(|v| v.heart_rate).collect();
let weights: Vec<Option<f64>> = vitals.iter().map(|v| v.weight.and_then(|d| d.to_f64())).collect();
let blood_sugars: Vec<Option<f64>> = vitals.iter().map(|v| v.blood_sugar.and_then(|d| d.to_f64())).collect();
let sys_morn: Vec<Option<i32>> = vitals.iter().map(|v| v.systolic_bp_morning).collect();
let dia_morn: Vec<Option<i32>> = vitals.iter().map(|v| v.diastolic_bp_morning).collect();
let sys_eve: Vec<Option<i32>> = vitals.iter().map(|v| v.systolic_bp_evening).collect();
let dia_eve: Vec<Option<i32>> = 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<i32>]| -> Option<f64> {
let valid: Vec<i32> = vals.iter().filter_map(|&v| v).collect();
if valid.is_empty() { return None; }
Some(valid.iter().sum::<i32>() as f64 / valid.len() as f64)
};
let avg_opt_f64 = |vals: &[Option<f64>]| -> Option<f64> {
let valid: Vec<f64> = vals.iter().filter_map(|&v| v).collect();
if valid.is_empty() { return None; }
Some(valid.iter().sum::<f64>() / valid.len() as f64)
};
let heart_rates: Vec<Option<i32>> = vitals.iter().map(|v| v.heart_rate).collect();
let blood_sugars: Vec<Option<f64>> = vitals.iter().map(|v| v.blood_sugar.and_then(|d| d.to_f64())).collect();
let sys_morn: Vec<Option<i32>> = vitals.iter().map(|v| v.systolic_bp_morning).collect();
let dia_morn: Vec<Option<i32>> = vitals.iter().map(|v| v.diastolic_bp_morning).collect();
let sys_eve: Vec<Option<i32>> = vitals.iter().map(|v| v.systolic_bp_evening).collect();
let dia_eve: Vec<Option<i32>> = 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<chrono::NaiveDate>,
end_date: Option<chrono::NaiveDate>,
) -> HealthResult<IndicatorTimeseriesResp> {
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<Option<Uuid>> {
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<String>) -> 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 查找关联的 patientpatient.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<String>,
) -> HealthResult<MiniTrendResp> {
// 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<MiniTodayResp> {
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,
})
}

View File

@@ -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(),
)

View File

@@ -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, &registry, 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(())
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,671 @@
# 健康管理模块全面迭代设计
> **文档版本**: 1.0
> **日期**: 2026-04-24
> **状态**: 待评审
> **基于**: 5 位专家(后端架构/前端架构/医疗业务/安全质量/产品策略)深度审查
---
## 0. 审查发现总览
### 0.1 V1 发布阻塞项
| # | 阻塞项 | 来源 | 影响 |
|---|--------|------|------|
| B1 | Web 健康模块 10 页面未实现 | 前端架构/产品策略 | 无法演示和交付 |
| B2 | 医疗数据安全不合规 | 安全质量 | 零 sanitize / 零审计 / 身证明文 / 零测试 |
| B3 | 数据一致性缺陷 | 医疗业务/后端架构 | 排班可超额 / 名额释放可能失败 / 随访逾期未实现 |
| B4 | 事件处理器空壳 | 后端架构 | 随访状态/咨询消息不联动 |
### 0.2 当前完成度
| 层级 | 模块 | 完成度 |
|------|------|--------|
| 后端 | erp-health16 实体/8 服务/7 handler/40+ API | 95% |
| 后端 | 事件处理器业务逻辑 | 0%(框架已搭建,需填充 db 操作) |
| 后端 | sanitize / 审计 / 加密 | 0% |
| 后端 | 测试覆盖 | 0% |
| Web 前端 | 健康模块页面 | 0% |
| Web 前端 | 健康模块 API 服务层 | 0% |
| 小程序 | 初版 21 页面 | 85% |
---
## 1. 安全省基(阶段 11.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` FamilyMemberReqcreate + 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<String>) -> Option<String> {
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<Json<ApiResponse<PatientResp>>> {
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<String> { /* AES-256-GCM + Base64 */ }
pub fn decrypt(&self, ciphertext: &str) -> AppResult<String> { /* 解密 */ }
}
```
**集成点**:
- `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<String>,
pub birth_date: Option<NaiveDate>,
pub status: String,
pub tags: Vec<TagResp>,
// 无 id_number, allergy_history, medical_history_summary, emergency_contact_phone 等
}
// 详情用 — 敏感字段掩码
pub struct PatientDetailResp {
// ... 全部字段
pub id_number: Option<String>, // "320***********1234"
pub emergency_contact_phone: Option<String>, // "138****1234"
}
```
---
## 2. 后端补完(阶段 21.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 页面(阶段 33.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<Patient> }>(
'/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 内
<Route path="/health/patients" element={<PatientList />} />
<Route path="/health/patients/:id" element={<PatientDetail />} />
<Route path="/health/tags" element={<PatientTagManage />} />
<Route path="/health/doctors" element={<DoctorList />} />
<Route path="/health/appointments" element={<AppointmentList />} />
<Route path="/health/schedules" element={<DoctorSchedule />} />
<Route path="/health/follow-up-tasks" element={<FollowUpTaskList />} />
<Route path="/health/follow-up-records" element={<FollowUpRecordList />} />
<Route path="/health/consultations" element={<ConsultationList />} />
<Route path="/health/consultations/:id" element={<ConsultationDetail />} />
```
### 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 表格
- 科室筛选 + 在线状态 Badgeonline=绿/busy=黄/offline=灰)
- 详情 Drawer
#### AppointmentList.tsx中复杂度2 天)
- `Segmented` 切换列表/日历视图
- 列表模式:表格 + 状态筛选 + 日期筛选
- 日历模式:`Calendar` + `cellRender` 显示当日预约数
- 状态流转 Dropdownpending → 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 CRUD0.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 缓存
- 国际化(英文等多语言)
- 小程序医护端