fix(health): 穷尽审计修复 — 权限同步/编译错误/前端bug/审计日志
审计发现并修复的问题: HIGH: - H1: ConsultationDetail 使用 getSession(id) 替代错误的列表搜索 - H2: SessionResp 添加 version/updated_at 字段 - H3: 移除 FollowUpRecordList 调用不存在的导出端点 - H4: 新增 articles.ts 前端 API 模块 MEDIUM: - M1: article delete 添加乐观锁 (expected_version) - M2: 取消预约排班释放传播错误 (log::warn -> ?) - M3: FollowUpTaskList 日期格式 Dayjs -> string - M4: 补充 15 个缺失审计日志 LOW: - L1: 替换 follow_up_service 中的 .unwrap() - L2: PatientListItem 添加 version 字段 CRITICAL (新发现): - 权限未同步: 健康模块 14 个权限从未写入数据库,添加启动时自动同步 - migration 表名错误: patients -> patient - 编译错误: health_trend entity 未导入, ToPrimitive trait 未导入 - HealthError 缺少 From<AppError> 实现
This commit is contained in:
130
Cargo.lock
generated
130
Cargo.lock
generated
@@ -17,6 +17,41 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
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]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.7.8"
|
version = "0.7.8"
|
||||||
@@ -382,6 +417,15 @@ dependencies = [
|
|||||||
"generic-array",
|
"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]]
|
[[package]]
|
||||||
name = "borsh"
|
name = "borsh"
|
||||||
version = "1.6.1"
|
version = "1.6.1"
|
||||||
@@ -527,6 +571,15 @@ dependencies = [
|
|||||||
"winx",
|
"winx",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cbc"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.60"
|
version = "1.2.60"
|
||||||
@@ -565,6 +618,16 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.0"
|
version = "4.6.0"
|
||||||
@@ -980,6 +1043,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
|
"rand_core",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1004,6 +1068,15 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ctr"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.20.11"
|
version = "0.20.11"
|
||||||
@@ -1215,12 +1288,16 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
|||||||
name = "erp-auth"
|
name = "erp-auth"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"cbc",
|
||||||
"chrono",
|
"chrono",
|
||||||
"erp-core",
|
"erp-core",
|
||||||
|
"hex",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
@@ -1277,14 +1354,19 @@ dependencies = [
|
|||||||
name = "erp-health"
|
name = "erp-health"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes-gcm",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
"erp-core",
|
"erp-core",
|
||||||
|
"hex",
|
||||||
|
"hmac",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -1755,6 +1837,16 @@ dependencies = [
|
|||||||
"wasip3",
|
"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]]
|
[[package]]
|
||||||
name = "gimli"
|
name = "gimli"
|
||||||
version = "0.33.0"
|
version = "0.33.0"
|
||||||
@@ -2201,6 +2293,16 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "io-extras"
|
name = "io-extras"
|
||||||
version = "0.18.4"
|
version = "0.18.4"
|
||||||
@@ -2707,6 +2809,12 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opaque-debug"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.78"
|
version = "0.10.78"
|
||||||
@@ -2966,6 +3074,18 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
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]]
|
[[package]]
|
||||||
name = "portable-atomic"
|
name = "portable-atomic"
|
||||||
version = "1.13.1"
|
version = "1.13.1"
|
||||||
@@ -4873,6 +4993,16 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
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]]
|
[[package]]
|
||||||
name = "unsafe-libyaml"
|
name = "unsafe-libyaml"
|
||||||
version = "0.2.11"
|
version = "0.2.11"
|
||||||
|
|||||||
88
apps/web/src/api/health/articles.ts
Normal file
88
apps/web/src/api/health/articles.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -65,6 +65,14 @@ export const consultationApi = {
|
|||||||
return data.data;
|
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 (
|
closeSession: async (
|
||||||
id: string,
|
id: string,
|
||||||
req: { version: number },
|
req: { version: number },
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface PatientListItem {
|
|||||||
source?: string;
|
source?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
version: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PatientDetail {
|
export interface PatientDetail {
|
||||||
|
|||||||
@@ -63,12 +63,10 @@ export default function ConsultationDetail() {
|
|||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
setSessionLoading(true);
|
setSessionLoading(true);
|
||||||
try {
|
try {
|
||||||
// Use the list endpoint to find our session
|
const result = await consultationApi.getSession(sessionId);
|
||||||
const result = await consultationApi.listSessions({ page: 1, page_size: 1 });
|
setSession(result);
|
||||||
const found = result.data.find((s) => s.id === sessionId);
|
|
||||||
if (found) setSession(found);
|
|
||||||
} catch {
|
} catch {
|
||||||
// Session info is supplementary; don't block chat
|
message.error('加载会话信息失败');
|
||||||
}
|
}
|
||||||
setSessionLoading(false);
|
setSessionLoading(false);
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { followUpApi, type FollowUpRecord } from '../../api/health/followUp';
|
import { followUpApi, type FollowUpRecord } from '../../api/health/followUp';
|
||||||
import { PatientSelect } from './components/PatientSelect';
|
import { PatientSelect } from './components/PatientSelect';
|
||||||
import { ExportButton } from './components/ExportButton';
|
|
||||||
|
|
||||||
const RESULT_MAP: Record<string, string> = {
|
const RESULT_MAP: Record<string, string> = {
|
||||||
normal: '正常',
|
normal: '正常',
|
||||||
@@ -43,8 +42,9 @@ export default function FollowUpRecordList() {
|
|||||||
setTotal(result.total);
|
setTotal(result.total);
|
||||||
} catch {
|
} catch {
|
||||||
message.error('加载随访记录失败');
|
message.error('加载随访记录失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
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 ---
|
// --- Columns ---
|
||||||
const columns: ColumnsType<FollowUpRecord> = [
|
const columns: ColumnsType<FollowUpRecord> = [
|
||||||
{
|
{
|
||||||
@@ -178,11 +172,6 @@ export default function FollowUpRecordList() {
|
|||||||
onChange={(val) => handlePatientChange(val)}
|
onChange={(val) => handlePatientChange(val)}
|
||||||
placeholder="筛选患者"
|
placeholder="筛选患者"
|
||||||
/>
|
/>
|
||||||
<ExportButton
|
|
||||||
fetchUrl="/health/follow-up-records/export"
|
|
||||||
params={exportParams}
|
|
||||||
filename={`随访记录_${dayjs().format('YYYYMMDD')}.csv`}
|
|
||||||
/>
|
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
|||||||
@@ -108,8 +108,9 @@ export default function FollowUpTaskList() {
|
|||||||
setTotal(result.total);
|
setTotal(result.total);
|
||||||
} catch {
|
} catch {
|
||||||
message.error('加载随访任务失败');
|
message.error('加载随访任务失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -134,9 +135,13 @@ export default function FollowUpTaskList() {
|
|||||||
try {
|
try {
|
||||||
const values = await createForm.validateFields();
|
const values = await createForm.validateFields();
|
||||||
setCreateLoading(true);
|
setCreateLoading(true);
|
||||||
|
const plannedDate = values.planned_date;
|
||||||
await followUpApi.createTask({
|
await followUpApi.createTask({
|
||||||
...values,
|
patient_id: values.patient_id,
|
||||||
planned_date: values.planned_date,
|
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('随访任务创建成功');
|
message.success('随访任务创建成功');
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
|
|||||||
@@ -88,3 +88,82 @@ impl HealthCrypto {
|
|||||||
hex::encode(mac.finalize().into_bytes())
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ pub struct SessionResp {
|
|||||||
pub unread_count_patient: i32,
|
pub unread_count_patient: i32,
|
||||||
pub unread_count_doctor: i32,
|
pub unread_count_doctor: i32,
|
||||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ use serde::{Deserialize, Serialize};
|
|||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
use uuid::Uuid;
|
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;
|
type Decimal = f64;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
|||||||
@@ -5,3 +5,8 @@ pub mod doctor_dto;
|
|||||||
pub mod follow_up_dto;
|
pub mod follow_up_dto;
|
||||||
pub mod health_data_dto;
|
pub mod health_data_dto;
|
||||||
pub mod patient_dto;
|
pub mod patient_dto;
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct DeleteWithVersion {
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ impl From<sea_orm::DbErr> for HealthError {
|
|||||||
|
|
||||||
impl From<AppError> for HealthError {
|
impl From<AppError> for HealthError {
|
||||||
fn from(err: AppError) -> Self {
|
fn from(err: AppError) -> Self {
|
||||||
HealthError::DbError(err.to_string())
|
HealthError::Validation(err.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,16 +77,22 @@ where
|
|||||||
Ok(Json(ApiResponse::ok(result)))
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct DeleteArticleReq {
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn delete_article<S>(
|
pub async fn delete_article<S>(
|
||||||
State(state): State<HealthState>,
|
State(state): State<HealthState>,
|
||||||
Extension(ctx): Extension<TenantContext>,
|
Extension(ctx): Extension<TenantContext>,
|
||||||
Path(id): Path<uuid::Uuid>,
|
Path(id): Path<uuid::Uuid>,
|
||||||
|
Json(req): Json<DeleteArticleReq>,
|
||||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||||
where
|
where
|
||||||
HealthState: FromRef<S>,
|
HealthState: FromRef<S>,
|
||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
require_permission(&ctx, "health.articles.manage")?;
|
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(())))
|
Ok(Json(ApiResponse::ok(())))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use axum::Extension;
|
use axum::Extension;
|
||||||
use axum::extract::{FromRef, Json, Path, Query, State};
|
use axum::extract::{FromRef, Json, Path, Query, State};
|
||||||
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use utoipa::IntoParams;
|
use utoipa::IntoParams;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -44,6 +45,8 @@ pub struct ExportSessionsParams {
|
|||||||
pub status: Option<String>,
|
pub status: Option<String>,
|
||||||
pub patient_id: Option<Uuid>,
|
pub patient_id: Option<Uuid>,
|
||||||
pub doctor_id: Option<Uuid>,
|
pub doctor_id: Option<Uuid>,
|
||||||
|
pub page: Option<u64>,
|
||||||
|
pub page_size: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_session<S>(
|
pub async fn create_session<S>(
|
||||||
@@ -83,6 +86,20 @@ where
|
|||||||
Ok(Json(ApiResponse::ok(result)))
|
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>(
|
pub async fn list_messages<S>(
|
||||||
State(state): State<HealthState>,
|
State(state): State<HealthState>,
|
||||||
Extension(ctx): Extension<TenantContext>,
|
Extension(ctx): Extension<TenantContext>,
|
||||||
@@ -131,10 +148,18 @@ where
|
|||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
require_permission(&ctx, "health.consultation.manage")?;
|
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 {
|
let mut msg_req = CreateMessageReq {
|
||||||
session_id: req.session_id,
|
session_id: req.session_id,
|
||||||
sender_id: ctx.user_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_type: req.content_type,
|
||||||
content: req.content,
|
content: req.content,
|
||||||
};
|
};
|
||||||
@@ -150,7 +175,7 @@ pub async fn export_sessions<S>(
|
|||||||
State(state): State<HealthState>,
|
State(state): State<HealthState>,
|
||||||
Extension(ctx): Extension<TenantContext>,
|
Extension(ctx): Extension<TenantContext>,
|
||||||
Query(params): Query<ExportSessionsParams>,
|
Query(params): Query<ExportSessionsParams>,
|
||||||
) -> Result<Json<ApiResponse<Vec<SessionResp>>>, AppError>
|
) -> Result<Json<ApiResponse<PaginatedResponse<SessionResp>>>, AppError>
|
||||||
where
|
where
|
||||||
HealthState: FromRef<S>,
|
HealthState: FromRef<S>,
|
||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
@@ -158,6 +183,7 @@ where
|
|||||||
require_permission(&ctx, "health.consultation.list")?;
|
require_permission(&ctx, "health.consultation.list")?;
|
||||||
let result = consultation_service::export_sessions(
|
let result = consultation_service::export_sessions(
|
||||||
&state, ctx.tenant_id, params.status, params.patient_id, params.doctor_id,
|
&state, ctx.tenant_id, params.status, params.patient_id, params.doctor_id,
|
||||||
|
params.page, params.page_size,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(ApiResponse::ok(result)))
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use erp_core::rbac::require_permission;
|
|||||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||||
|
|
||||||
use crate::dto::doctor_dto::*;
|
use crate::dto::doctor_dto::*;
|
||||||
|
use crate::dto::DeleteWithVersion;
|
||||||
use crate::service::doctor_service;
|
use crate::service::doctor_service;
|
||||||
use crate::state::HealthState;
|
use crate::state::HealthState;
|
||||||
|
|
||||||
@@ -28,11 +29,6 @@ pub struct UpdateDoctorWithVersion {
|
|||||||
pub version: i32,
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
|
||||||
pub struct DeleteWithVersion {
|
|
||||||
pub version: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_doctors<S>(
|
pub async fn list_doctors<S>(
|
||||||
State(state): State<HealthState>,
|
State(state): State<HealthState>,
|
||||||
Extension(ctx): Extension<TenantContext>,
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use erp_core::rbac::require_permission;
|
|||||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||||
|
|
||||||
use crate::dto::follow_up_dto::*;
|
use crate::dto::follow_up_dto::*;
|
||||||
|
use crate::dto::DeleteWithVersion;
|
||||||
use crate::service::follow_up_service;
|
use crate::service::follow_up_service;
|
||||||
use crate::state::HealthState;
|
use crate::state::HealthState;
|
||||||
|
|
||||||
@@ -36,11 +37,6 @@ pub struct UpdateFollowUpTaskWithVersion {
|
|||||||
pub version: i32,
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
|
||||||
pub struct DeleteWithVersion {
|
|
||||||
pub version: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_tasks<S>(
|
pub async fn list_tasks<S>(
|
||||||
State(state): State<HealthState>,
|
State(state): State<HealthState>,
|
||||||
Extension(ctx): Extension<TenantContext>,
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ use erp_core::rbac::require_permission;
|
|||||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||||
|
|
||||||
use crate::dto::health_data_dto::*;
|
use crate::dto::health_data_dto::*;
|
||||||
|
use crate::dto::DeleteWithVersion;
|
||||||
use crate::service::health_data_service;
|
use crate::service::health_data_service;
|
||||||
|
use crate::service::trend_service;
|
||||||
use crate::state::HealthState;
|
use crate::state::HealthState;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -34,11 +36,6 @@ pub struct GenerateTrendReq {
|
|||||||
pub period_end: chrono::NaiveDate,
|
pub period_end: chrono::NaiveDate,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
|
||||||
pub struct DeleteWithVersion {
|
|
||||||
pub version: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||||
pub struct UpdateWithVersion<T> {
|
pub struct UpdateWithVersion<T> {
|
||||||
pub data: T,
|
pub data: T,
|
||||||
@@ -299,7 +296,7 @@ where
|
|||||||
require_permission(&ctx, "health.health-data.list")?;
|
require_permission(&ctx, "health.health-data.list")?;
|
||||||
let page = params.page.unwrap_or(1);
|
let page = params.page.unwrap_or(1);
|
||||||
let page_size = params.page_size.unwrap_or(20);
|
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,
|
&state, ctx.tenant_id, patient_id, page, page_size,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -317,7 +314,7 @@ where
|
|||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
require_permission(&ctx, "health.health-data.manage")?;
|
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,
|
&state, ctx.tenant_id, patient_id, Some(ctx.user_id), req.period_start, req.period_end,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -335,7 +332,7 @@ where
|
|||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
require_permission(&ctx, "health.health-data.list")?;
|
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,
|
&state, ctx.tenant_id, patient_id, indicator, params.start_date, params.end_date,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -356,7 +353,7 @@ where
|
|||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
require_permission(&ctx, "health.health-data.list")?;
|
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,
|
&state, ctx.tenant_id, ctx.user_id, params.indicator, params.range,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -376,7 +373,7 @@ where
|
|||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
require_permission(&ctx, "health.health-data.list")?;
|
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,
|
&state, ctx.tenant_id, ctx.user_id,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use crate::dto::patient_dto::{
|
|||||||
CreatePatientReq, FamilyMemberReq, FamilyMemberResp, ManageTagsReq, PatientResp,
|
CreatePatientReq, FamilyMemberReq, FamilyMemberResp, ManageTagsReq, PatientResp,
|
||||||
UpdatePatientReq,
|
UpdatePatientReq,
|
||||||
};
|
};
|
||||||
|
use crate::dto::DeleteWithVersion;
|
||||||
use crate::service::patient_service;
|
use crate::service::patient_service;
|
||||||
use crate::state::HealthState;
|
use crate::state::HealthState;
|
||||||
|
|
||||||
@@ -30,11 +31,6 @@ pub struct AssignDoctorReq {
|
|||||||
pub relationship_type: Option<String>,
|
pub relationship_type: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
|
||||||
pub struct DeleteWithVersion {
|
|
||||||
pub version: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_patients<S>(
|
pub async fn list_patients<S>(
|
||||||
State(state): State<HealthState>,
|
State(state): State<HealthState>,
|
||||||
Extension(ctx): Extension<TenantContext>,
|
Extension(ctx): Extension<TenantContext>,
|
||||||
@@ -278,7 +274,7 @@ where
|
|||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
require_permission(&ctx, "health.patient.manage")?;
|
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(())))
|
Ok(Json(ApiResponse::ok(())))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,19 +17,26 @@ impl HealthModule {
|
|||||||
Self
|
Self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 启动定时逾期随访检查(每 6 小时运行一次)
|
/// 启动定时逾期随访检查(每 6 小时运行一次),返回 JoinHandle 用于优雅关闭
|
||||||
pub fn start_overdue_checker(db: sea_orm::DatabaseConnection) {
|
pub fn start_overdue_checker(db: sea_orm::DatabaseConnection) -> tokio::task::JoinHandle<()> {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(6 * 3600));
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(6 * 3600));
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
tokio::select! {
|
||||||
match crate::service::follow_up_service::check_overdue_tasks(&db).await {
|
_ = interval.tick() => {
|
||||||
Ok(count) if count > 0 => tracing::info!(count = count, "随访逾期检查完成"),
|
match crate::service::follow_up_service::check_overdue_tasks(&db).await {
|
||||||
Ok(_) => {}
|
Ok(count) if count > 0 => tracing::info!(count = count, "随访逾期检查完成"),
|
||||||
Err(e) => tracing::warn!(error = %e, "随访逾期检查失败"),
|
Ok(_) => {}
|
||||||
|
Err(e) => tracing::warn!(error = %e, "随访逾期检查失败"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = tokio::signal::ctrl_c() => {
|
||||||
|
tracing::info!("随访逾期检查任务收到关闭信号,正在停止");
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn public_routes<S>() -> Router<S>
|
pub fn public_routes<S>() -> Router<S>
|
||||||
@@ -190,6 +197,14 @@ impl HealthModule {
|
|||||||
axum::routing::get(consultation_handler::list_sessions)
|
axum::routing::get(consultation_handler::list_sessions)
|
||||||
.post(consultation_handler::create_session),
|
.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(
|
.route(
|
||||||
"/health/consultation-sessions/{id}/messages",
|
"/health/consultation-sessions/{id}/messages",
|
||||||
axum::routing::get(consultation_handler::list_messages),
|
axum::routing::get(consultation_handler::list_messages),
|
||||||
@@ -202,10 +217,6 @@ impl HealthModule {
|
|||||||
"/health/consultation-messages",
|
"/health/consultation-messages",
|
||||||
axum::routing::post(consultation_handler::create_message),
|
axum::routing::post(consultation_handler::create_message),
|
||||||
)
|
)
|
||||||
.route(
|
|
||||||
"/health/consultation-sessions/export",
|
|
||||||
axum::routing::get(consultation_handler::export_sessions),
|
|
||||||
)
|
|
||||||
// 医护管理
|
// 医护管理
|
||||||
.route(
|
.route(
|
||||||
"/health/doctors",
|
"/health/doctors",
|
||||||
@@ -258,14 +269,23 @@ impl ErpModule for HealthModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn on_startup(&self, ctx: &erp_core::module::ModuleContext) -> erp_core::error::AppResult<()> {
|
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_AES_KEY").unwrap_or_default(),
|
||||||
&std::env::var("HEALTH_HMAC_KEY").unwrap_or_default(),
|
&std::env::var("HEALTH_HMAC_KEY").unwrap_or_default(),
|
||||||
)
|
) {
|
||||||
.unwrap_or_else(|_| {
|
Ok(c) => c,
|
||||||
tracing::warn!("HEALTH_AES_KEY / HEALTH_HMAC_KEY 未设置或无效,使用开发默认密钥");
|
Err(_) => {
|
||||||
crate::crypto::HealthCrypto::dev_default()
|
#[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 {
|
let state = crate::state::HealthState {
|
||||||
db: ctx.db.clone(),
|
db: ctx.db.clone(),
|
||||||
|
|||||||
@@ -362,6 +362,13 @@ pub async fn create_schedule(
|
|||||||
version: Set(1),
|
version: Set(1),
|
||||||
};
|
};
|
||||||
let m = active.insert(&state.db).await?;
|
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 {
|
Ok(ScheduleResp {
|
||||||
id: m.id, doctor_id: m.doctor_id, schedule_date: m.schedule_date,
|
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,
|
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);
|
active.version = Set(next_ver);
|
||||||
|
|
||||||
let m = active.update(&state.db).await?;
|
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 {
|
Ok(ScheduleResp {
|
||||||
id: m.id, doctor_id: m.doctor_id, schedule_date: m.schedule_date,
|
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,
|
period_type: m.period_type, start_time: m.start_time, end_time: m.end_time,
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ pub async fn delete_article(
|
|||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
operator_id: Option<Uuid>,
|
operator_id: Option<Uuid>,
|
||||||
|
expected_version: i32,
|
||||||
) -> HealthResult<()> {
|
) -> HealthResult<()> {
|
||||||
let model = article::Entity::find()
|
let model = article::Entity::find()
|
||||||
.filter(article::Column::Id.eq(id))
|
.filter(article::Column::Id.eq(id))
|
||||||
@@ -201,10 +202,14 @@ pub async fn delete_article(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or(HealthError::ArticleNotFound)?;
|
.ok_or(HealthError::ArticleNotFound)?;
|
||||||
|
|
||||||
|
let next_ver = check_version(expected_version, model.version)
|
||||||
|
.map_err(|_| HealthError::VersionMismatch)?;
|
||||||
|
|
||||||
let mut active: article::ActiveModel = model.into();
|
let mut active: article::ActiveModel = model.into();
|
||||||
active.deleted_at = Set(Some(Utc::now()));
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_ver);
|
||||||
active.update(&state.db).await?;
|
active.update(&state.db).await?;
|
||||||
|
|
||||||
audit_service::record(
|
audit_service::record(
|
||||||
|
|||||||
@@ -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(
|
pub async fn create_session(
|
||||||
state: &HealthState,
|
state: &HealthState,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
@@ -73,14 +85,24 @@ pub async fn create_session(
|
|||||||
&state.db,
|
&state.db,
|
||||||
).await;
|
).await;
|
||||||
|
|
||||||
Ok(SessionResp {
|
Ok(model_to_session_resp(m))
|
||||||
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,
|
pub async fn get_session(
|
||||||
unread_count_doctor: m.unread_count_doctor,
|
state: &HealthState,
|
||||||
created_at: m.created_at,
|
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(
|
pub async fn list_sessions(
|
||||||
@@ -112,14 +134,7 @@ pub async fn list_sessions(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let total_pages = total.div_ceil(limit.max(1));
|
let total_pages = total.div_ceil(limit.max(1));
|
||||||
let data = models.into_iter().map(|m| SessionResp {
|
let data = models.into_iter().map(model_to_session_resp).collect();
|
||||||
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();
|
|
||||||
|
|
||||||
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
|
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
|
||||||
}
|
}
|
||||||
@@ -168,14 +183,7 @@ pub async fn close_session(
|
|||||||
&state.db,
|
&state.db,
|
||||||
).await;
|
).await;
|
||||||
|
|
||||||
Ok(SessionResp {
|
Ok(model_to_session_resp(m))
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn export_sessions(
|
pub async fn export_sessions(
|
||||||
@@ -184,7 +192,13 @@ pub async fn export_sessions(
|
|||||||
status: Option<String>,
|
status: Option<String>,
|
||||||
patient_id: Option<Uuid>,
|
patient_id: Option<Uuid>,
|
||||||
doctor_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()
|
let mut query = consultation_session::Entity::find()
|
||||||
.filter(consultation_session::Column::TenantId.eq(tenant_id))
|
.filter(consultation_session::Column::TenantId.eq(tenant_id))
|
||||||
.filter(consultation_session::Column::DeletedAt.is_null());
|
.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(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)); }
|
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
|
let models = query
|
||||||
.order_by_desc(consultation_session::Column::CreatedAt)
|
.order_by_desc(consultation_session::Column::CreatedAt)
|
||||||
.limit(10000)
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
.all(&state.db)
|
.all(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(models.into_iter().map(|m| SessionResp {
|
let total_pages = total.div_ceil(limit.max(1));
|
||||||
id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id,
|
let data = models.into_iter().map(model_to_session_resp).collect();
|
||||||
consultation_type: m.consultation_type, status: m.status,
|
|
||||||
last_message_at: m.last_message_at,
|
Ok(PaginatedResponse { data, total, page: page_num, page_size: limit, total_pages })
|
||||||
unread_count_patient: m.unread_count_patient,
|
|
||||||
unread_count_doctor: m.unread_count_doctor,
|
|
||||||
created_at: m.created_at,
|
|
||||||
}).collect())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -180,6 +180,13 @@ pub async fn update_task(
|
|||||||
active.version = Set(next_ver);
|
active.version = Set(next_ver);
|
||||||
|
|
||||||
let m = active.update(&state.db).await?;
|
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 {
|
Ok(FollowUpTaskResp {
|
||||||
id: m.id, patient_id: m.patient_id, assigned_to: m.assigned_to,
|
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,
|
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.updated_by = Set(operator_id);
|
||||||
active.version = Set(next_ver);
|
active.version = Set(next_ver);
|
||||||
active.update(&state.db).await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,11 +279,12 @@ pub async fn create_record(
|
|||||||
let task_patient_id = task.patient_id;
|
let task_patient_id = task.patient_id;
|
||||||
let task_assigned_to = task.assigned_to;
|
let task_assigned_to = task.assigned_to;
|
||||||
let task_follow_up_type = task.follow_up_type.clone();
|
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();
|
let mut task_active: follow_up_task::ActiveModel = task.into();
|
||||||
task_active.status = Set("completed".to_string());
|
task_active.status = Set("completed".to_string());
|
||||||
task_active.updated_at = Set(now);
|
task_active.updated_at = Set(now);
|
||||||
task_active.updated_by = Set(operator_id);
|
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?;
|
task_active.update(&txn).await?;
|
||||||
|
|
||||||
// 当 next_follow_up_date 不为空时,自动创建后续随访任务
|
// 当 next_follow_up_date 不为空时,自动创建后续随访任务
|
||||||
@@ -392,10 +407,11 @@ pub async fn complete_task_by_system(
|
|||||||
|
|
||||||
match model {
|
match model {
|
||||||
Some(m) if m.status == "pending" || m.status == "in_progress" => {
|
Some(m) if m.status == "pending" || m.status == "in_progress" => {
|
||||||
|
let current_version = m.version;
|
||||||
let mut active: follow_up_task::ActiveModel = m.into();
|
let mut active: follow_up_task::ActiveModel = m.into();
|
||||||
active.status = Set("completed".to_string());
|
active.status = Set("completed".to_string());
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.version = Set(active.version.unwrap() + 1);
|
active.version = Set(current_version + 1);
|
||||||
active.update(db).await?;
|
active.update(db).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
//! 健康数据 Service — 体征记录、化验报告、体检记录、趋势分析
|
//! 健康数据 Service — 体征记录、化验报告、体检记录
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use erp_core::audit::AuditLog;
|
use erp_core::audit::AuditLog;
|
||||||
use erp_core::audit_service;
|
use erp_core::audit_service;
|
||||||
use erp_core::events::DomainEvent;
|
use erp_core::events::DomainEvent;
|
||||||
use num_traits::cast::ToPrimitive;
|
use num_traits::ToPrimitive;
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect};
|
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -13,7 +13,7 @@ use erp_core::error::check_version;
|
|||||||
use erp_core::types::PaginatedResponse;
|
use erp_core::types::PaginatedResponse;
|
||||||
|
|
||||||
use crate::dto::health_data_dto::*;
|
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::error::{HealthError, HealthResult};
|
||||||
use crate::service::validation::validate_record_type;
|
use crate::service::validation::validate_record_type;
|
||||||
use crate::state::HealthState;
|
use crate::state::HealthState;
|
||||||
@@ -164,6 +164,13 @@ pub async fn update_vital_signs(
|
|||||||
active.version = Set(next_ver);
|
active.version = Set(next_ver);
|
||||||
|
|
||||||
let m = active.update(&state.db).await?;
|
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 {
|
Ok(VitalSignsResp {
|
||||||
id: m.id, patient_id: m.patient_id, record_date: m.record_date,
|
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,
|
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.updated_by = Set(operator_id);
|
||||||
active.version = Set(next_ver);
|
active.version = Set(next_ver);
|
||||||
active.update(&state.db).await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,6 +342,13 @@ pub async fn update_lab_report(
|
|||||||
active.version = Set(next_ver);
|
active.version = Set(next_ver);
|
||||||
|
|
||||||
let m = active.update(&state.db).await?;
|
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 {
|
Ok(LabReportResp {
|
||||||
id: m.id, patient_id: m.patient_id, report_date: m.report_date,
|
id: m.id, patient_id: m.patient_id, report_date: m.report_date,
|
||||||
report_type: m.report_type, indicators: m.indicators,
|
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.updated_by = Set(operator_id);
|
||||||
active.version = Set(next_ver);
|
active.version = Set(next_ver);
|
||||||
active.update(&state.db).await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,6 +514,13 @@ pub async fn update_health_record(
|
|||||||
active.version = Set(next_ver);
|
active.version = Set(next_ver);
|
||||||
|
|
||||||
let m = active.update(&state.db).await?;
|
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 {
|
Ok(HealthRecordResp {
|
||||||
id: m.id, patient_id: m.patient_id, record_type: m.record_type,
|
id: m.id, patient_id: m.patient_id, record_type: m.record_type,
|
||||||
record_date: m.record_date, source: m.source,
|
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.updated_by = Set(operator_id);
|
||||||
active.version = Set(next_ver);
|
active.version = Set(next_ver);
|
||||||
active.update(&state.db).await?;
|
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(())
|
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 查找关联的 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<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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ pub mod follow_up_service;
|
|||||||
pub mod health_data_service;
|
pub mod health_data_service;
|
||||||
pub mod patient_service;
|
pub mod patient_service;
|
||||||
pub mod seed;
|
pub mod seed;
|
||||||
|
pub mod trend_service;
|
||||||
pub mod validation;
|
pub mod validation;
|
||||||
|
|||||||
@@ -476,6 +476,13 @@ pub async fn create_family_member(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let model = active.insert(&state.db).await?;
|
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 {
|
Ok(FamilyMemberResp {
|
||||||
id: model.id,
|
id: model.id,
|
||||||
patient_id: model.patient_id,
|
patient_id: model.patient_id,
|
||||||
@@ -523,6 +530,13 @@ pub async fn update_family_member(
|
|||||||
active.version = Set(next_ver);
|
active.version = Set(next_ver);
|
||||||
|
|
||||||
let updated = active.update(&state.db).await?;
|
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 {
|
Ok(FamilyMemberResp {
|
||||||
id: updated.id,
|
id: updated.id,
|
||||||
patient_id: updated.patient_id,
|
patient_id: updated.patient_id,
|
||||||
@@ -565,6 +579,12 @@ pub async fn delete_family_member(
|
|||||||
active.version = Set(next_ver);
|
active.version = Set(next_ver);
|
||||||
active.update(&state.db).await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,7 +629,14 @@ pub async fn assign_doctor(
|
|||||||
deleted_at: Set(None),
|
deleted_at: Set(None),
|
||||||
version: Set(1),
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,6 +646,7 @@ pub async fn remove_doctor(
|
|||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
patient_id: Uuid,
|
patient_id: Uuid,
|
||||||
doctor_id: Uuid,
|
doctor_id: Uuid,
|
||||||
|
operator_id: Option<Uuid>,
|
||||||
) -> HealthResult<()> {
|
) -> HealthResult<()> {
|
||||||
let model = patient_doctor_relation::Entity::find()
|
let model = patient_doctor_relation::Entity::find()
|
||||||
.filter(patient_doctor_relation::Column::TenantId.eq(tenant_id))
|
.filter(patient_doctor_relation::Column::TenantId.eq(tenant_id))
|
||||||
@@ -629,10 +657,19 @@ pub async fn remove_doctor(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or(HealthError::DoctorNotFound)?;
|
.ok_or(HealthError::DoctorNotFound)?;
|
||||||
|
|
||||||
|
let relation_id = model.id;
|
||||||
let mut active: patient_doctor_relation::ActiveModel = model.into();
|
let mut active: patient_doctor_relation::ActiveModel = model.into();
|
||||||
active.deleted_at = Set(Some(Utc::now()));
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
active.update(&state.db).await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
425
crates/erp-health/src/service/trend_service.rs
Normal file
425
crates/erp-health/src/service/trend_service.rs
Normal 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 查找关联的 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<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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ impl MigrationTrait for Migration {
|
|||||||
manager
|
manager
|
||||||
.alter_table(
|
.alter_table(
|
||||||
Table::alter()
|
Table::alter()
|
||||||
.table(Alias::new("patients"))
|
.table(Alias::new("patient"))
|
||||||
.add_column(
|
.add_column(
|
||||||
ColumnDef::new(Alias::new("id_number_hash"))
|
ColumnDef::new(Alias::new("id_number_hash"))
|
||||||
.string()
|
.string()
|
||||||
@@ -29,7 +29,7 @@ impl MigrationTrait for Migration {
|
|||||||
manager
|
manager
|
||||||
.alter_table(
|
.alter_table(
|
||||||
Table::alter()
|
Table::alter()
|
||||||
.table(Alias::new("patients"))
|
.table(Alias::new("patient"))
|
||||||
.drop_column(Alias::new("id_number_hash"))
|
.drop_column(Alias::new("id_number_hash"))
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -363,6 +363,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
registry.startup_all(&module_ctx).await?;
|
registry.startup_all(&module_ctx).await?;
|
||||||
tracing::info!("All modules started");
|
tracing::info!("All modules started");
|
||||||
|
|
||||||
|
// 同步所有模块声明的权限到数据库(upsert)
|
||||||
|
sync_module_permissions(&db, ®istry, default_tenant_id).await?;
|
||||||
|
|
||||||
// 恢复运行中的插件(服务器重启后自动重新加载)
|
// 恢复运行中的插件(服务器重启后自动重新加载)
|
||||||
match plugin_engine.recover_plugins(&db).await {
|
match plugin_engine.recover_plugins(&db).await {
|
||||||
Ok(recovered) => {
|
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(())
|
||||||
|
}
|
||||||
|
|||||||
1626
docs/superpowers/plans/2026-04-24-health-module-iteration.md
Normal file
1626
docs/superpowers/plans/2026-04-24-health-module-iteration.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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<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. 后端补完(阶段 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<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 表格
|
||||||
|
- 科室筛选 + 在线状态 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 缓存
|
||||||
|
- 国际化(英文等多语言)
|
||||||
|
- 小程序医护端
|
||||||
Reference in New Issue
Block a user