Compare commits
8 Commits
5140552ff6
...
70aacf47a0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70aacf47a0 | ||
|
|
24562dd54b | ||
|
|
c5b686499c | ||
|
|
8656896847 | ||
|
|
43894446d9 | ||
|
|
fa0a788cf9 | ||
|
|
feab61b132 | ||
|
|
2afe3a8848 |
@@ -48,6 +48,8 @@ const AlertList = lazy(() => import('./pages/health/AlertList'));
|
||||
const AlertDashboard = lazy(() => import('./pages/health/AlertDashboard'));
|
||||
const AlertRuleList = lazy(() => import('./pages/health/AlertRuleList'));
|
||||
const DeviceManage = lazy(() => import('./pages/health/DeviceManage'));
|
||||
const RealtimeMonitor = lazy(() => import('./pages/health/RealtimeMonitor'));
|
||||
const OAuthClientList = lazy(() => import('./pages/health/OAuthClientList'));
|
||||
const DialysisManageList = lazy(() => import('./pages/health/DialysisManageList'));
|
||||
const ActionInbox = lazy(() => import('./pages/health/ActionInbox'));
|
||||
const FollowUpTemplateList = lazy(() => import('./pages/health/FollowUpTemplateList'));
|
||||
@@ -257,6 +259,8 @@ export default function App() {
|
||||
<Route path="/health/alert-dashboard" element={<AlertDashboard />} />
|
||||
<Route path="/health/alert-rules" element={<AlertRuleList />} />
|
||||
<Route path="/health/devices" element={<DeviceManage />} />
|
||||
<Route path="/health/realtime-monitor" element={<RealtimeMonitor />} />
|
||||
<Route path="/health/oauth-clients" element={<OAuthClientList />} />
|
||||
<Route path="/health/dialysis" element={<DialysisManageList />} />
|
||||
<Route path="/health/action-inbox" element={<ActionInbox />} />
|
||||
<Route path="/health/follow-up-templates" element={<FollowUpTemplateList />} />
|
||||
|
||||
@@ -22,6 +22,17 @@ export interface HourlyReading {
|
||||
sample_count: number;
|
||||
}
|
||||
|
||||
export interface DailyReading {
|
||||
id: string;
|
||||
device_type: string;
|
||||
date_bucket: string;
|
||||
min_val?: number;
|
||||
max_val?: number;
|
||||
avg_val: number;
|
||||
sample_count: number;
|
||||
percentile_95?: number;
|
||||
}
|
||||
|
||||
export interface BatchReadingRequest {
|
||||
device_id: string;
|
||||
device_model?: string;
|
||||
@@ -53,4 +64,9 @@ export const deviceReadingApi = {
|
||||
const { patient_id, ...query } = params;
|
||||
return client.get(`/health/patients/${patient_id}/device-readings/hourly`, { params: query }).then((r) => r.data.data as PaginatedResponse<HourlyReading>);
|
||||
},
|
||||
|
||||
queryDaily: (params: { patient_id: string; device_type?: string; from_date?: string; to_date?: string; page?: number; page_size?: number }) => {
|
||||
const { patient_id, ...query } = params;
|
||||
return client.get(`/health/patients/${patient_id}/device-readings/daily`, { params: query }).then((r) => r.data.data as PaginatedResponse<DailyReading>);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,6 +8,11 @@ export interface DeviceItem {
|
||||
device_id: string;
|
||||
device_model: string;
|
||||
device_type: string;
|
||||
status?: string;
|
||||
firmware_version?: string;
|
||||
manufacturer?: string;
|
||||
connection_type?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
bound_at: string;
|
||||
last_sync_at: string;
|
||||
version: number;
|
||||
|
||||
73
apps/web/src/api/health/oauthClients.ts
Normal file
73
apps/web/src/api/health/oauthClients.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import client from '../client';
|
||||
|
||||
// --- Types ---
|
||||
export interface OAuthClient {
|
||||
id: string;
|
||||
client_id: string;
|
||||
client_name: string;
|
||||
scopes: string[];
|
||||
rate_limit_per_minute: number;
|
||||
is_active: boolean;
|
||||
token_lifetime_seconds: number;
|
||||
created_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface OAuthClientDetail extends OAuthClient {
|
||||
tenant_id: string;
|
||||
client_secret: string;
|
||||
allowed_patient_ids?: string[];
|
||||
}
|
||||
|
||||
export interface CreateOAuthClientReq {
|
||||
client_name: string;
|
||||
scopes: string[];
|
||||
allowed_patient_ids?: string[];
|
||||
rate_limit_per_minute?: number;
|
||||
token_lifetime_seconds?: number;
|
||||
}
|
||||
|
||||
export interface UpdateOAuthClientReq {
|
||||
client_name?: string;
|
||||
scopes?: string[];
|
||||
allowed_patient_ids?: string[] | null;
|
||||
rate_limit_per_minute?: number;
|
||||
is_active?: boolean;
|
||||
token_lifetime_seconds?: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface RegenerateSecretResp {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
}
|
||||
|
||||
// --- FHIR Scope ---
|
||||
export const FHIR_SCOPE_OPTIONS = [
|
||||
{ value: 'Patient.read', label: 'Patient.read — 读取患者' },
|
||||
{ value: 'Observation.read', label: 'Observation.read — 读取体征' },
|
||||
{ value: 'Device.read', label: 'Device.read — 读取设备' },
|
||||
{ value: 'DiagnosticReport.read', label: 'DiagnosticReport.read — 读取诊断报告' },
|
||||
{ value: 'Encounter.read', label: 'Encounter.read — 读取就诊记录' },
|
||||
{ value: 'Practitioner.read', label: 'Practitioner.read — 读取医护' },
|
||||
{ value: 'Appointment.read', label: 'Appointment.read — 读取预约' },
|
||||
{ value: 'Task.read', label: 'Task.read — 读取随访任务' },
|
||||
];
|
||||
|
||||
// --- API ---
|
||||
export const oauthClientApi = {
|
||||
list: () =>
|
||||
client.get('/health/oauth/clients').then((r) => r.data.data as OAuthClient[]),
|
||||
|
||||
create: (data: CreateOAuthClientReq) =>
|
||||
client.post('/health/oauth/clients', data).then((r) => r.data.data as OAuthClientDetail),
|
||||
|
||||
update: (id: string, data: UpdateOAuthClientReq) =>
|
||||
client.put(`/health/oauth/clients/${id}`, data).then((r) => r.data.data as OAuthClient),
|
||||
|
||||
delete: (id: string) =>
|
||||
client.delete(`/health/oauth/clients/${id}`).then((r) => r.data),
|
||||
|
||||
regenerateSecret: (id: string) =>
|
||||
client.post(`/health/oauth/clients/${id}/regenerate-secret`).then((r) => r.data.data as RegenerateSecretResp),
|
||||
};
|
||||
@@ -118,6 +118,39 @@ export const CONDITION_TYPE_OPTIONS = [
|
||||
{ value: 'trend', label: '趋势变化' },
|
||||
];
|
||||
|
||||
// --- 设备连接状态 ---
|
||||
export const DEVICE_STATUS_OPTIONS = [
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'online', label: '在线' },
|
||||
{ value: 'offline', label: '离线' },
|
||||
{ value: 'paired', label: '已配对' },
|
||||
{ value: 'error', label: '异常' },
|
||||
];
|
||||
|
||||
export const DEVICE_STATUS_COLOR: Record<string, string> = {
|
||||
online: 'green',
|
||||
offline: 'default',
|
||||
paired: 'blue',
|
||||
error: 'red',
|
||||
};
|
||||
|
||||
// --- 设备连接类型 ---
|
||||
export const CONNECTION_TYPE_OPTIONS = [
|
||||
{ value: 'ble', label: '蓝牙' },
|
||||
{ value: 'gateway', label: '网关' },
|
||||
{ value: 'manual', label: '手动录入' },
|
||||
];
|
||||
|
||||
// --- 实时监控卡片指标 ---
|
||||
export const VITAL_CARD_METRICS = [
|
||||
{ key: 'heart_rate', label: '心率', unit: 'bpm', color: '#ff4d4f' },
|
||||
{ key: 'blood_oxygen', label: '血氧', unit: '%', color: '#1890ff' },
|
||||
{ key: 'blood_pressure', label: '血压', unit: 'mmHg', color: '#f5222d' },
|
||||
{ key: 'blood_glucose', label: '血糖', unit: 'mg/dL', color: '#722ed1' },
|
||||
{ key: 'temperature', label: '体温', unit: '°C', color: '#fa8c16' },
|
||||
{ key: 'steps', label: '步数', unit: '步', color: '#52c41a' },
|
||||
] as const;
|
||||
|
||||
// --- 通用状态标签(StatusTag 组件统一引用) ---
|
||||
export const STATUS_TAG_CONFIG: Record<string, { color: string; label: string }> = {
|
||||
// 预约状态
|
||||
|
||||
67
apps/web/src/hooks/useVitalSSE.ts
Normal file
67
apps/web/src/hooks/useVitalSSE.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useAlertSSE, type VitalUpdateSSEEvent } from './useAlertSSE';
|
||||
|
||||
export type VitalUpdateEvent = VitalUpdateSSEEvent;
|
||||
|
||||
interface PatientVital {
|
||||
patient_id: string;
|
||||
device_type: string;
|
||||
latest_value?: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface UseVitalSSEOptions {
|
||||
enabled?: boolean;
|
||||
patientIds?: string[];
|
||||
onUpdate?: (data: VitalUpdateEvent) => void;
|
||||
}
|
||||
|
||||
interface UseVitalSSEReturn {
|
||||
connected: boolean;
|
||||
patientVitals: Map<string, PatientVital>;
|
||||
lastUpdate: VitalUpdateEvent | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时体征 hook — 复用全局 SSE 连接的 vital_update 事件。
|
||||
*
|
||||
* 内部调用 useAlertSSE(共享 /messages/stream 连接),
|
||||
* 聚合患者最新体征数据到 Map。
|
||||
*/
|
||||
export function useVitalSSE(options: UseVitalSSEOptions = {}): UseVitalSSEReturn {
|
||||
const { enabled = true, patientIds, onUpdate } = options;
|
||||
const [patientVitals, setPatientVitals] = useState<Map<string, PatientVital>>(new Map());
|
||||
const [lastUpdate, setLastUpdate] = useState<VitalUpdateEvent | null>(null);
|
||||
|
||||
const handleVitalUpdate = useCallback(
|
||||
(data: VitalUpdateEvent) => {
|
||||
if (patientIds && patientIds.length > 0 && !patientIds.includes(data.patient_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPatientVitals((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (data.device_model) {
|
||||
const key = `${data.patient_id}_${data.device_model}`;
|
||||
next.set(key, {
|
||||
patient_id: data.patient_id,
|
||||
device_type: data.device_model,
|
||||
latest_value: data.count > 0 ? undefined : undefined,
|
||||
updated_at: data.occurred_at ?? new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setLastUpdate(data);
|
||||
onUpdate?.(data);
|
||||
},
|
||||
[patientIds, onUpdate],
|
||||
);
|
||||
|
||||
const { connected } = useAlertSSE({
|
||||
enabled,
|
||||
onVitalUpdate: handleVitalUpdate,
|
||||
});
|
||||
|
||||
return { connected, patientVitals, lastUpdate };
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Input, message, Popconfirm, Select, Space, Table, Tag } from 'antd';
|
||||
import { Button, Input, message, Popconfirm, Select, Space, Table, Tag, Badge } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { deviceApi, type DeviceItem } from '../../api/health/devices';
|
||||
import { DEVICE_TYPE_OPTIONS, DEVICE_TYPE_COLOR } from '../../constants/health';
|
||||
import { DEVICE_TYPE_OPTIONS, DEVICE_TYPE_COLOR, DEVICE_STATUS_OPTIONS } from '../../constants/health';
|
||||
import { PatientSelect } from './components/PatientSelect';
|
||||
|
||||
function formatTime(val?: string | null): string {
|
||||
@@ -20,6 +20,7 @@ export default function DeviceManage() {
|
||||
|
||||
const [filterPatientId, setFilterPatientId] = useState('');
|
||||
const [filterDeviceType, setFilterDeviceType] = useState<string | undefined>(undefined);
|
||||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||||
|
||||
const fetchDevices = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -29,6 +30,7 @@ export default function DeviceManage() {
|
||||
page_size: 20,
|
||||
...(filterPatientId ? { patient_id: filterPatientId } : {}),
|
||||
...(filterDeviceType ? { device_type: filterDeviceType } : {}),
|
||||
...(filterStatus ? { status: filterStatus } : {}),
|
||||
});
|
||||
setData(res.data);
|
||||
setTotal(res.total);
|
||||
@@ -37,7 +39,7 @@ export default function DeviceManage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, filterPatientId, filterDeviceType]);
|
||||
}, [page, filterPatientId, filterDeviceType, filterStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDevices();
|
||||
@@ -74,6 +76,36 @@ export default function DeviceManage() {
|
||||
return <Tag color={DEVICE_TYPE_COLOR[v] || 'default'}>{label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (v: string) => {
|
||||
const config: Record<string, { color: 'success' | 'error' | 'default' | 'processing'; label: string }> = {
|
||||
online: { color: 'success', label: '在线' },
|
||||
offline: { color: 'default', label: '离线' },
|
||||
paired: { color: 'processing', label: '已配对' },
|
||||
error: { color: 'error', label: '异常' },
|
||||
};
|
||||
const c = config[v];
|
||||
return c ? <Badge status={c.color} text={c.label} /> : <span>{v || '-'}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '连接方式',
|
||||
dataIndex: 'connection_type',
|
||||
width: 90,
|
||||
render: (v: string) => {
|
||||
const map: Record<string, string> = { ble: '蓝牙', gateway: '网关', manual: '手动' };
|
||||
return <Tag>{map[v] ?? v ?? '-'}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '固件版本',
|
||||
dataIndex: 'firmware_version',
|
||||
width: 110,
|
||||
render: (v: string) => v ?? '-',
|
||||
},
|
||||
{
|
||||
title: '绑定时间',
|
||||
dataIndex: 'bound_at',
|
||||
@@ -125,6 +157,14 @@ export default function DeviceManage() {
|
||||
style={{ width: 140 }}
|
||||
allowClear
|
||||
/>
|
||||
<Select
|
||||
placeholder="设备状态"
|
||||
value={filterStatus}
|
||||
onChange={setFilterStatus}
|
||||
options={DEVICE_STATUS_OPTIONS}
|
||||
style={{ width: 120 }}
|
||||
allowClear
|
||||
/>
|
||||
<Button type="primary" onClick={() => setPage(1)}>
|
||||
查询
|
||||
</Button>
|
||||
|
||||
36
apps/web/src/pages/health/OAuthClientList.test.tsx
Normal file
36
apps/web/src/pages/health/OAuthClientList.test.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import OAuthClientList from './OAuthClientList';
|
||||
|
||||
vi.mock('../../api/health/oauthClients', () => ({
|
||||
oauthClientApi: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
regenerateSecret: vi.fn(),
|
||||
},
|
||||
FHIR_SCOPE_OPTIONS: [
|
||||
{ value: 'Patient.read', label: 'Patient.read' },
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock('../../components/PageContainer', () => ({
|
||||
PageContainer: ({ children, title, actions }: { children: React.ReactNode; title: string; actions?: React.ReactNode }) => (
|
||||
<div><h1>{title}</h1>{actions}{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('OAuthClientList', () => {
|
||||
it('renders page title', async () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<OAuthClientList />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('FHIR API 合作方管理')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
207
apps/web/src/pages/health/OAuthClientList.tsx
Normal file
207
apps/web/src/pages/health/OAuthClientList.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Button, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import {
|
||||
oauthClientApi,
|
||||
type OAuthClient,
|
||||
type CreateOAuthClientReq,
|
||||
type UpdateOAuthClientReq,
|
||||
FHIR_SCOPE_OPTIONS,
|
||||
} from '../../api/health/oauthClients';
|
||||
import { PageContainer } from '../../components/PageContainer';
|
||||
|
||||
export default function OAuthClientList() {
|
||||
const [data, setData] = useState<OAuthClient[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editRecord, setEditRecord] = useState<OAuthClient | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await oauthClientApi.list();
|
||||
setData(list);
|
||||
} catch {
|
||||
message.error('加载合作方列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditRecord(null);
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: OAuthClient) => {
|
||||
setEditRecord(record);
|
||||
form.setFieldsValue({
|
||||
client_name: record.client_name,
|
||||
scopes: record.scopes,
|
||||
rate_limit_per_minute: record.rate_limit_per_minute,
|
||||
token_lifetime_seconds: record.token_lifetime_seconds,
|
||||
is_active: record.is_active,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (editRecord) {
|
||||
const req: UpdateOAuthClientReq = {
|
||||
...values,
|
||||
version: editRecord.version,
|
||||
};
|
||||
await oauthClientApi.update(editRecord.id, req);
|
||||
message.success('合作方已更新');
|
||||
} else {
|
||||
const req: CreateOAuthClientReq = values;
|
||||
const result = await oauthClientApi.create(req);
|
||||
Modal.success({
|
||||
title: '合作方已创建',
|
||||
content: (
|
||||
<div>
|
||||
<p><strong>Client ID:</strong> {result.client_id}</p>
|
||||
<p><strong>Client Secret:</strong> {result.client_secret}</p>
|
||||
<Typography.Text type="warning">请妥善保存 Secret,关闭后不可再次查看。</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
setModalOpen(false);
|
||||
fetchData();
|
||||
} catch {
|
||||
// 表单校验失败
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await oauthClientApi.delete(id);
|
||||
message.success('合作方已删除');
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerate = async (record: OAuthClient) => {
|
||||
try {
|
||||
const result = await oauthClientApi.regenerateSecret(record.id);
|
||||
Modal.info({
|
||||
title: 'Secret 已重新生成',
|
||||
content: (
|
||||
<div>
|
||||
<p><strong>新 Client Secret:</strong></p>
|
||||
<Typography.Text copyable code>{result.client_secret}</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
} catch {
|
||||
message.error('重新生成 Secret 失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<OAuthClient> = [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'client_name',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: 'Client ID',
|
||||
dataIndex: 'client_id',
|
||||
width: 200,
|
||||
render: (v: string) => <Typography.Text copyable code>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: 'Scope',
|
||||
dataIndex: 'scopes',
|
||||
width: 250,
|
||||
render: (scopes: string[]) =>
|
||||
scopes.map((s) => <Tag key={s} color="blue">{s}</Tag>),
|
||||
},
|
||||
{
|
||||
title: '限流 (次/分)',
|
||||
dataIndex: 'rate_limit_per_minute',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'is_active',
|
||||
width: 80,
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 170,
|
||||
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => handleEdit(record)}>编辑</Button>
|
||||
<Button size="small" onClick={() => handleRegenerate(record)}>重置 Secret</Button>
|
||||
<Popconfirm title="确定删除此合作方?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="FHIR API 合作方管理"
|
||||
actions={<Button type="primary" onClick={handleCreate}>创建合作方</Button>}
|
||||
>
|
||||
<Table<OAuthClient>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editRecord ? '编辑合作方' : '创建合作方'}
|
||||
open={modalOpen}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="client_name" label="合作方名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="如:XX医院HIS系统" />
|
||||
</Form.Item>
|
||||
<Form.Item name="scopes" label="FHIR Scope" rules={[{ required: true, message: '请选择至少一个 scope' }]}>
|
||||
<Select mode="multiple" options={FHIR_SCOPE_OPTIONS} placeholder="选择允许的 FHIR 资源" />
|
||||
</Form.Item>
|
||||
<Form.Item name="rate_limit_per_minute" label="限流 (次/分钟)" initialValue={60}>
|
||||
<InputNumber min={1} max={1000} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="token_lifetime_seconds" label="Token 有效期 (秒)" initialValue={3600}>
|
||||
<InputNumber min={60} max={86400} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
{editRecord && (
|
||||
<Form.Item name="is_active" label="启用状态" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
37
apps/web/src/pages/health/RealtimeMonitor.test.tsx
Normal file
37
apps/web/src/pages/health/RealtimeMonitor.test.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import RealtimeMonitor from './RealtimeMonitor';
|
||||
|
||||
vi.mock('../../api/health/alerts', () => ({
|
||||
alertApi: {
|
||||
list: vi.fn().mockResolvedValue({ data: [], total: 0 }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useVitalSSE', () => ({
|
||||
useVitalSSE: vi.fn().mockReturnValue({
|
||||
connected: true,
|
||||
patientVitals: new Map(),
|
||||
lastUpdate: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/PageContainer', () => ({
|
||||
PageContainer: ({ children, title, actions }: { children: React.ReactNode; title: string; actions?: React.ReactNode }) => (
|
||||
<div><h1>{title}</h1>{actions}{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('RealtimeMonitor', () => {
|
||||
it('renders page title', async () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<RealtimeMonitor />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('实时体征监控')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
147
apps/web/src/pages/health/RealtimeMonitor.tsx
Normal file
147
apps/web/src/pages/health/RealtimeMonitor.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Card, Row, Col, Statistic, Tag, List, Select, Badge, Typography, Space, Empty } from 'antd';
|
||||
import { AlertOutlined } from '@ant-design/icons';
|
||||
import { useVitalSSE } from '../../hooks/useVitalSSE';
|
||||
import { alertApi, type Alert } from '../../api/health/alerts';
|
||||
import { PageContainer } from '../../components/PageContainer';
|
||||
import { SEVERITY_COLOR, SEVERITY_LABEL, VITAL_CARD_METRICS } from '../../constants/health';
|
||||
|
||||
interface PatientAlertSummary {
|
||||
patient_id: string;
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时体征监控台 — 医生 Web 端。
|
||||
*
|
||||
* SSE 实时接收体征更新,按告警严重度排序患者列表。
|
||||
*/
|
||||
export default function RealtimeMonitor() {
|
||||
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedPatientId, setSelectedPatientId] = useState<string | null>(null);
|
||||
const [alertSummary, setAlertSummary] = useState<PatientAlertSummary[]>([]);
|
||||
|
||||
const { connected, patientVitals } = useVitalSSE({ enabled: true });
|
||||
|
||||
const fetchAlerts = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await alertApi.list({ page: 1, page_size: 100, status: 'pending' });
|
||||
setAlerts(result.data);
|
||||
|
||||
const summaryMap = new Map<string, PatientAlertSummary>();
|
||||
for (const alert of result.data) {
|
||||
const existing = summaryMap.get(alert.patient_id) ?? {
|
||||
patient_id: alert.patient_id,
|
||||
critical: 0, high: 0, medium: 0, low: 0,
|
||||
};
|
||||
const sev = alert.severity;
|
||||
if (sev === 'critical') existing.critical++;
|
||||
else if (sev === 'high') existing.high++;
|
||||
else if (sev === 'medium') existing.medium++;
|
||||
else existing.low++;
|
||||
summaryMap.set(alert.patient_id, existing);
|
||||
}
|
||||
setAlertSummary(Array.from(summaryMap.values()));
|
||||
} catch {
|
||||
// 静默降级
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAlerts();
|
||||
}, [fetchAlerts]);
|
||||
|
||||
const sortedPatients = [...alertSummary].sort((a, b) => {
|
||||
const score = (s: PatientAlertSummary) => s.critical * 4 + s.high * 3 + s.medium * 2 + s.low;
|
||||
return score(b) - score(a);
|
||||
});
|
||||
|
||||
const totalCritical = alertSummary.reduce((s, a) => s + a.critical, 0);
|
||||
const totalHigh = alertSummary.reduce((s, a) => s + a.high, 0);
|
||||
const totalMedium = alertSummary.reduce((s, a) => s + a.medium, 0);
|
||||
const totalLow = alertSummary.reduce((s, a) => s + a.low, 0);
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="实时体征监控"
|
||||
actions={
|
||||
<Space>
|
||||
<Badge status={connected ? 'success' : 'error'} text={connected ? '已连接' : '断开'} />
|
||||
<Select
|
||||
placeholder="筛选患者"
|
||||
allowClear
|
||||
style={{ width: 200 }}
|
||||
onChange={(v) => setSelectedPatientId(v ?? null)}
|
||||
options={sortedPatients.map((p) => ({
|
||||
value: p.patient_id,
|
||||
label: p.patient_id,
|
||||
}))}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={6}><Card><Statistic title="危急" value={totalCritical} valueStyle={{ color: '#cf1322' }} prefix={<AlertOutlined />} /></Card></Col>
|
||||
<Col span={6}><Card><Statistic title="高危" value={totalHigh} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} /></Card></Col>
|
||||
<Col span={6}><Card><Statistic title="中等" value={totalMedium} valueStyle={{ color: '#fa8c16' }} prefix={<AlertOutlined />} /></Card></Col>
|
||||
<Col span={6}><Card><Statistic title="低危" value={totalLow} valueStyle={{ color: '#8c8c8c' }} prefix={<AlertOutlined />} /></Card></Col>
|
||||
</Row>
|
||||
|
||||
<Card title={`患者列表(${sortedPatients.length} 人有活跃告警)`} loading={loading}>
|
||||
{sortedPatients.length === 0 ? (
|
||||
<Empty description="暂无活跃告警" />
|
||||
) : (
|
||||
<List
|
||||
dataSource={selectedPatientId ? sortedPatients.filter((p) => p.patient_id === selectedPatientId) : sortedPatients}
|
||||
renderItem={(item) => {
|
||||
const vitalKeys = Array.from(patientVitals.entries())
|
||||
.filter(([key]) => key.startsWith(item.patient_id));
|
||||
const latestVitals = vitalKeys.map(([, v]) => v);
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
style={{ cursor: 'pointer', padding: '12px 16px' }}
|
||||
onClick={() => setSelectedPatientId(
|
||||
selectedPatientId === item.patient_id ? null : item.patient_id,
|
||||
)}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Space>
|
||||
<Typography.Text>{item.patient_id}</Typography.Text>
|
||||
{item.critical > 0 && <Tag color="red">{item.critical} 危急</Tag>}
|
||||
{item.high > 0 && <Tag color="volcano">{item.high} 高危</Tag>}
|
||||
{item.medium > 0 && <Tag color="orange">{item.medium} 中等</Tag>}
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<Space wrap>
|
||||
{latestVitals.map((v) => {
|
||||
const metric = VITAL_CARD_METRICS.find((m) => m.key === v.device_type);
|
||||
if (!metric) return null;
|
||||
return (
|
||||
<Tag key={v.device_type} color="blue">
|
||||
{metric.label}: {v.latest_value ?? '-'} {metric.unit}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
{latestVitals.length === 0 && <Typography.Text type="secondary">暂无实时数据</Typography.Text>}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -42,4 +42,5 @@ pub mod offline_event_registration;
|
||||
pub mod medication_record;
|
||||
pub mod medication_reminder;
|
||||
pub mod vital_signs;
|
||||
pub mod vital_signs_daily;
|
||||
pub mod vital_signs_hourly;
|
||||
|
||||
@@ -26,6 +26,14 @@ pub struct Model {
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
pub status: String,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub firmware_version: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub manufacturer: Option<String>,
|
||||
pub connection_type: String,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
||||
45
crates/erp-health/src/entity/vital_signs_daily.rs
Normal file
45
crates/erp-health/src/entity/vital_signs_daily.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "vital_signs_daily")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub device_type: String,
|
||||
pub date_bucket: chrono::NaiveDate,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub min_val: Option<f64>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub max_val: Option<f64>,
|
||||
pub avg_val: f64,
|
||||
pub sample_count: i32,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub percentile_95: Option<f64>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::patient::Entity",
|
||||
from = "Column::PatientId",
|
||||
to = "super::patient::Column::Id"
|
||||
)]
|
||||
Patient,
|
||||
}
|
||||
|
||||
impl Related<super::patient::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Patient.def()
|
||||
}
|
||||
fn via() -> Option<RelationDef> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
228
crates/erp-health/src/fhir/handler.rs
Normal file
228
crates/erp-health/src/fhir/handler.rs
Normal file
@@ -0,0 +1,228 @@
|
||||
use axum::extract::{FromRef, Path, Query, State};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::state::HealthState;
|
||||
|
||||
/// GET /fhir/R4/metadata — FHIR CapabilityStatement
|
||||
pub async fn capability_statement<S>() -> Result<impl IntoResponse, erp_core::error::AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let stmt = serde_json::json!({
|
||||
"resourceType": "CapabilityStatement",
|
||||
"status": "active",
|
||||
"date": chrono::Utc::now().format("%Y-%m-%d").to_string(),
|
||||
"kind": "instance",
|
||||
"fhirVersion": "4.0.1",
|
||||
"format": ["application/fhir+json"],
|
||||
"rest": [{
|
||||
"mode": "server",
|
||||
"resource": [
|
||||
{ "type": "Patient", "interaction": [{"code": "read"}, {"code": "search-type"}], "operation": [{"name": "everything"}] },
|
||||
{ "type": "Observation", "interaction": [{"code": "read"}, {"code": "search-type"}], "operation": [{"name": "lastn"}] },
|
||||
{ "type": "Device", "interaction": [{"code": "read"}, {"code": "search-type"}] },
|
||||
{ "type": "DiagnosticReport", "interaction": [{"code": "read"}, {"code": "search-type"}] },
|
||||
{ "type": "Encounter", "interaction": [{"code": "read"}, {"code": "search-type"}] },
|
||||
{ "type": "Practitioner", "interaction": [{"code": "read"}, {"code": "search-type"}] },
|
||||
{ "type": "Appointment", "interaction": [{"code": "read"}, {"code": "search-type"}] },
|
||||
{ "type": "Task", "interaction": [{"code": "read"}, {"code": "search-type"}] },
|
||||
],
|
||||
"operation": [
|
||||
{ "name": "everything", "definition": "/fhir/R4/Patient/{id}/$everything" },
|
||||
{ "name": "lastn", "definition": "/fhir/R4/Observation/$lastn" },
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
Ok(Json(stmt))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SearchParams {
|
||||
pub patient: Option<String>,
|
||||
pub category: Option<String>,
|
||||
#[serde(rename = "_count")]
|
||||
pub count: Option<u32>,
|
||||
#[serde(rename = "_offset")]
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
// ── Patient ────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn search_patients(
|
||||
State(_state): State<HealthState>,
|
||||
Query(_params): Query<SearchParams>,
|
||||
) -> Result<impl IntoResponse, erp_core::error::AppError> {
|
||||
Ok(Json(serde_json::json!({
|
||||
"resourceType": "Bundle", "type": "searchset", "total": 0, "entry": []
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn get_patient(
|
||||
State(_state): State<HealthState>,
|
||||
Path(_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, erp_core::error::AppError> {
|
||||
Ok(Json(serde_json::json!({
|
||||
"resourceType": "OperationOutcome",
|
||||
"issue": [{"severity": "information", "code": "not-found", "diagnostics": "Patient not implemented yet"}]
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn patient_everything(
|
||||
State(_state): State<HealthState>,
|
||||
Path(_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, erp_core::error::AppError> {
|
||||
Ok(Json(serde_json::json!({
|
||||
"resourceType": "Bundle", "type": "searchset", "total": 0, "entry": []
|
||||
})))
|
||||
}
|
||||
|
||||
// ── Observation ────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn search_observations(
|
||||
State(_state): State<HealthState>,
|
||||
Query(_params): Query<SearchParams>,
|
||||
) -> Result<impl IntoResponse, erp_core::error::AppError> {
|
||||
Ok(Json(serde_json::json!({
|
||||
"resourceType": "Bundle", "type": "searchset", "total": 0, "entry": []
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn observation_lastn(
|
||||
State(_state): State<HealthState>,
|
||||
Query(_params): Query<SearchParams>,
|
||||
) -> Result<impl IntoResponse, erp_core::error::AppError> {
|
||||
Ok(Json(serde_json::json!({
|
||||
"resourceType": "Bundle", "type": "searchset", "total": 0, "entry": []
|
||||
})))
|
||||
}
|
||||
|
||||
// ── Device ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn search_devices(
|
||||
State(_state): State<HealthState>,
|
||||
Query(_params): Query<SearchParams>,
|
||||
) -> Result<impl IntoResponse, erp_core::error::AppError> {
|
||||
Ok(Json(serde_json::json!({
|
||||
"resourceType": "Bundle", "type": "searchset", "total": 0, "entry": []
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn get_device(
|
||||
State(_state): State<HealthState>,
|
||||
Path(_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, erp_core::error::AppError> {
|
||||
Ok(Json(serde_json::json!({
|
||||
"resourceType": "OperationOutcome",
|
||||
"issue": [{"severity": "information", "code": "not-found", "diagnostics": "Device not implemented yet"}]
|
||||
})))
|
||||
}
|
||||
|
||||
// ── Practitioner ───────────────────────────────────────────────────────
|
||||
|
||||
pub async fn search_practitioners(
|
||||
State(_state): State<HealthState>,
|
||||
Query(_params): Query<SearchParams>,
|
||||
) -> Result<impl IntoResponse, erp_core::error::AppError> {
|
||||
Ok(Json(serde_json::json!({
|
||||
"resourceType": "Bundle", "type": "searchset", "total": 0, "entry": []
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn get_practitioner(
|
||||
State(_state): State<HealthState>,
|
||||
Path(_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, erp_core::error::AppError> {
|
||||
Ok(Json(serde_json::json!({
|
||||
"resourceType": "OperationOutcome",
|
||||
"issue": [{"severity": "information", "code": "not-found", "diagnostics": "Practitioner not implemented yet"}]
|
||||
})))
|
||||
}
|
||||
|
||||
// ── Appointment ────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn search_appointments(
|
||||
State(_state): State<HealthState>,
|
||||
Query(_params): Query<SearchParams>,
|
||||
) -> Result<impl IntoResponse, erp_core::error::AppError> {
|
||||
Ok(Json(serde_json::json!({
|
||||
"resourceType": "Bundle", "type": "searchset", "total": 0, "entry": []
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn get_appointment(
|
||||
State(_state): State<HealthState>,
|
||||
Path(_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, erp_core::error::AppError> {
|
||||
Ok(Json(serde_json::json!({
|
||||
"resourceType": "OperationOutcome",
|
||||
"issue": [{"severity": "information", "code": "not-found", "diagnostics": "Appointment not implemented yet"}]
|
||||
})))
|
||||
}
|
||||
|
||||
// ── DiagnosticReport ───────────────────────────────────────────────────
|
||||
|
||||
pub async fn search_diagnostic_reports(
|
||||
State(_state): State<HealthState>,
|
||||
Query(_params): Query<SearchParams>,
|
||||
) -> Result<impl IntoResponse, erp_core::error::AppError> {
|
||||
Ok(Json(serde_json::json!({
|
||||
"resourceType": "Bundle", "type": "searchset", "total": 0, "entry": []
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn get_diagnostic_report(
|
||||
State(_state): State<HealthState>,
|
||||
Path(_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, erp_core::error::AppError> {
|
||||
Ok(Json(serde_json::json!({
|
||||
"resourceType": "OperationOutcome",
|
||||
"issue": [{"severity": "information", "code": "not-found", "diagnostics": "DiagnosticReport not implemented yet"}]
|
||||
})))
|
||||
}
|
||||
|
||||
// ── Encounter ──────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn search_encounters(
|
||||
State(_state): State<HealthState>,
|
||||
Query(_params): Query<SearchParams>,
|
||||
) -> Result<impl IntoResponse, erp_core::error::AppError> {
|
||||
Ok(Json(serde_json::json!({
|
||||
"resourceType": "Bundle", "type": "searchset", "total": 0, "entry": []
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn get_encounter(
|
||||
State(_state): State<HealthState>,
|
||||
Path(_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, erp_core::error::AppError> {
|
||||
Ok(Json(serde_json::json!({
|
||||
"resourceType": "OperationOutcome",
|
||||
"issue": [{"severity": "information", "code": "not-found", "diagnostics": "Encounter not implemented yet"}]
|
||||
})))
|
||||
}
|
||||
|
||||
// ── Task ───────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn search_tasks(
|
||||
State(_state): State<HealthState>,
|
||||
Query(_params): Query<SearchParams>,
|
||||
) -> Result<impl IntoResponse, erp_core::error::AppError> {
|
||||
Ok(Json(serde_json::json!({
|
||||
"resourceType": "Bundle", "type": "searchset", "total": 0, "entry": []
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn get_task(
|
||||
State(_state): State<HealthState>,
|
||||
Path(_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, erp_core::error::AppError> {
|
||||
Ok(Json(serde_json::json!({
|
||||
"resourceType": "OperationOutcome",
|
||||
"issue": [{"severity": "information", "code": "not-found", "diagnostics": "Task not implemented yet"}]
|
||||
})))
|
||||
}
|
||||
@@ -107,6 +107,30 @@ impl HealthModule {
|
||||
})
|
||||
}
|
||||
|
||||
/// 启动日聚合任务(每 24 小时运行一次),从前一天的 hourly 数据聚合到 daily
|
||||
pub fn start_daily_aggregation(db: sea_orm::DatabaseConnection) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(24 * 3600));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
let yesterday = chrono::Local::now().date_naive() - chrono::Duration::days(1);
|
||||
tracing::info!(date = %yesterday, "Running daily aggregation");
|
||||
match crate::service::vital_signs_daily_service::aggregate_daily_for_all_tenants(&db, yesterday).await {
|
||||
Ok(count) if count > 0 => tracing::info!(count = count, date = %yesterday, "日聚合完成"),
|
||||
Ok(_) => tracing::info!(date = %yesterday, "日聚合完成(无数据)"),
|
||||
Err(e) => tracing::warn!(error = %e, "日聚合任务失败"),
|
||||
}
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
tracing::info!("日聚合任务收到关闭信号,正在停止");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn public_routes<S>() -> Router<S>
|
||||
where
|
||||
crate::state::HealthState: axum::extract::FromRef<S>,
|
||||
@@ -115,6 +139,43 @@ impl HealthModule {
|
||||
Router::new()
|
||||
}
|
||||
|
||||
/// FHIR R4 只读路由(复用 JWT 认证中间件)
|
||||
pub fn fhir_routes<S>() -> Router<S>
|
||||
where
|
||||
crate::state::HealthState: axum::extract::FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
use crate::fhir::handler as fhir;
|
||||
|
||||
Router::new()
|
||||
.route("/fhir/R4/metadata", axum::routing::get(fhir::capability_statement))
|
||||
// Patient
|
||||
.route("/fhir/R4/Patient", axum::routing::get(fhir::search_patients))
|
||||
.route("/fhir/R4/Patient/{id}", axum::routing::get(fhir::get_patient))
|
||||
// Observation
|
||||
.route("/fhir/R4/Observation", axum::routing::get(fhir::search_observations))
|
||||
// Device
|
||||
.route("/fhir/R4/Device", axum::routing::get(fhir::search_devices))
|
||||
.route("/fhir/R4/Device/{id}", axum::routing::get(fhir::get_device))
|
||||
// Practitioner
|
||||
.route("/fhir/R4/Practitioner", axum::routing::get(fhir::search_practitioners))
|
||||
.route("/fhir/R4/Practitioner/{id}", axum::routing::get(fhir::get_practitioner))
|
||||
// Appointment
|
||||
.route("/fhir/R4/Appointment", axum::routing::get(fhir::search_appointments))
|
||||
.route("/fhir/R4/Appointment/{id}", axum::routing::get(fhir::get_appointment))
|
||||
// DiagnosticReport
|
||||
.route("/fhir/R4/DiagnosticReport", axum::routing::get(fhir::search_diagnostic_reports))
|
||||
.route("/fhir/R4/DiagnosticReport/{id}", axum::routing::get(fhir::get_diagnostic_report))
|
||||
// Encounter
|
||||
.route("/fhir/R4/Encounter", axum::routing::get(fhir::search_encounters))
|
||||
.route("/fhir/R4/Encounter/{id}", axum::routing::get(fhir::get_encounter))
|
||||
// Task
|
||||
.route("/fhir/R4/Task", axum::routing::get(fhir::search_tasks))
|
||||
.route("/fhir/R4/Task/{id}", axum::routing::get(fhir::get_task))
|
||||
// $everything
|
||||
.route("/fhir/R4/Patient/{id}/$everything", axum::routing::get(fhir::patient_everything))
|
||||
}
|
||||
|
||||
pub fn protected_routes<S>() -> Router<S>
|
||||
where
|
||||
crate::state::HealthState: axum::extract::FromRef<S>,
|
||||
@@ -831,6 +892,21 @@ impl ErpModule for HealthModule {
|
||||
let _cleanup_handle = Self::start_device_readings_cleanup(ctx.db.clone());
|
||||
tracing::info!(module = "health", "Device readings cleanup task started");
|
||||
|
||||
// 启动日聚合任务(每 24 小时从前一天的 hourly 数据聚合到 daily)
|
||||
{
|
||||
let db = ctx.db.clone();
|
||||
tokio::spawn(async move {
|
||||
let yesterday = chrono::Local::now().date_naive() - chrono::Duration::days(1);
|
||||
match crate::service::vital_signs_daily_service::aggregate_daily_for_all_tenants(&db, yesterday).await {
|
||||
Ok(count) if count > 0 => tracing::info!(count = count, "启动时日聚合完成"),
|
||||
Ok(_) => tracing::info!("启动时日聚合完成(无数据)"),
|
||||
Err(e) => tracing::warn!(error = %e, "启动时日聚合失败"),
|
||||
}
|
||||
});
|
||||
}
|
||||
let _daily_agg_handle = Self::start_daily_aggregation(ctx.db.clone());
|
||||
tracing::info!(module = "health", "Daily aggregation task started");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
170
crates/erp-health/src/service/alert_noise_reducer.rs
Normal file
170
crates/erp-health/src/service/alert_noise_reducer.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entity::alerts;
|
||||
use crate::state::HealthState;
|
||||
|
||||
/// 严重度等级(数值越大越严重)
|
||||
fn severity_rank(s: &str) -> u8 {
|
||||
match s {
|
||||
"critical" => 4,
|
||||
"high" => 3,
|
||||
"medium" => 2,
|
||||
"low" | "warning" | "info" => 1,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 患者级告警升级阈值:同一患者在最近 N 分钟内连续产生 M 条低级别告警 → 升级为高级别
|
||||
const ESCALATION_WINDOW_MINUTES: i64 = 30;
|
||||
const ESCALATION_THRESHOLD_COUNT: usize = 3;
|
||||
|
||||
/// 系统级聚合窗口:N 分钟内同一设备的多个告警合并为一条通知
|
||||
const AGGREGATION_WINDOW_MINUTES: i64 = 5;
|
||||
|
||||
/// 检查是否需要患者级告警升级。
|
||||
///
|
||||
/// 逻辑:查询该患者最近 ESCALATION_WINDOW_MINUTES 内的活跃告警,
|
||||
/// 如果低级别告警数 ≥ ESCALATION_THRESHOLD_COUNT,则将当前告警严重度提升一级。
|
||||
pub async fn check_patient_escalation(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
original_severity: &str,
|
||||
) -> String {
|
||||
let rank = severity_rank(original_severity);
|
||||
if rank >= 3 {
|
||||
return original_severity.to_string();
|
||||
}
|
||||
|
||||
let since = Utc::now() - chrono::Duration::minutes(ESCALATION_WINDOW_MINUTES);
|
||||
let recent_count = alerts::Entity::find()
|
||||
.filter(alerts::Column::TenantId.eq(tenant_id))
|
||||
.filter(alerts::Column::PatientId.eq(patient_id))
|
||||
.filter(alerts::Column::CreatedAt.gt(since))
|
||||
.filter(alerts::Column::DeletedAt.is_null())
|
||||
.filter(alerts::Column::Severity.is_in(["low", "info", "medium", "warning"]))
|
||||
.count(&state.db)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
if recent_count as usize >= ESCALATION_THRESHOLD_COUNT {
|
||||
let escalated = match original_severity {
|
||||
"low" | "info" => "medium",
|
||||
"medium" | "warning" => "high",
|
||||
_ => original_severity,
|
||||
};
|
||||
tracing::info!(
|
||||
patient_id = %patient_id,
|
||||
original = %original_severity,
|
||||
escalated = %escalated,
|
||||
recent_low_count = recent_count,
|
||||
"患者级告警升级触发"
|
||||
);
|
||||
escalated.to_string()
|
||||
} else {
|
||||
original_severity.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否应该系统级聚合(抑制重复通知)。
|
||||
///
|
||||
/// 返回 (should_suppress, aggregated_alert_count)
|
||||
pub async fn check_system_aggregation(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
_device_type: &str,
|
||||
) -> (bool, u64) {
|
||||
let since = Utc::now() - chrono::Duration::minutes(AGGREGATION_WINDOW_MINUTES);
|
||||
|
||||
let count = alerts::Entity::find()
|
||||
.filter(alerts::Column::TenantId.eq(tenant_id))
|
||||
.filter(alerts::Column::PatientId.eq(patient_id))
|
||||
.filter(alerts::Column::CreatedAt.gt(since))
|
||||
.filter(alerts::Column::DeletedAt.is_null())
|
||||
.count(&state.db)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let should_suppress = count > 0;
|
||||
|
||||
if should_suppress {
|
||||
tracing::debug!(
|
||||
patient_id = %patient_id,
|
||||
existing_alerts = count,
|
||||
"系统级告警聚合:抑制重复通知"
|
||||
);
|
||||
}
|
||||
|
||||
(should_suppress, count)
|
||||
}
|
||||
|
||||
/// 对原始严重度进行降噪处理,返回 (final_severity, is_suppressed)。
|
||||
///
|
||||
/// 调用顺序:
|
||||
/// 1. 患者级升级 — 可能提升严重度
|
||||
/// 2. 系统级聚合 — 可能抑制通知
|
||||
pub async fn apply_noise_reduction(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
device_type: &str,
|
||||
original_severity: &str,
|
||||
) -> (String, bool) {
|
||||
let escalated_severity =
|
||||
check_patient_escalation(state, tenant_id, patient_id, original_severity).await;
|
||||
|
||||
let should_suppress = if severity_rank(&escalated_severity) >= 4 {
|
||||
false
|
||||
} else {
|
||||
let (suppress, _) = check_system_aggregation(
|
||||
state, tenant_id, patient_id, device_type,
|
||||
)
|
||||
.await;
|
||||
suppress
|
||||
};
|
||||
|
||||
(escalated_severity, should_suppress)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn severity_rank_ordering() {
|
||||
assert!(severity_rank("critical") > severity_rank("high"));
|
||||
assert!(severity_rank("high") > severity_rank("medium"));
|
||||
assert!(severity_rank("medium") > severity_rank("low"));
|
||||
assert!(severity_rank("medium") > severity_rank("info"));
|
||||
assert!(severity_rank("low") > severity_rank("unknown"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn severity_rank_critical_is_highest() {
|
||||
assert_eq!(severity_rank("critical"), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn severity_rank_unknown_is_zero() {
|
||||
assert_eq!(severity_rank("nonexistent"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escalation_threshold_constants() {
|
||||
assert_eq!(ESCALATION_WINDOW_MINUTES, 30);
|
||||
assert_eq!(ESCALATION_THRESHOLD_COUNT, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregation_window_constant() {
|
||||
assert_eq!(AGGREGATION_WINDOW_MINUTES, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn severity_rank_warning_equals_low() {
|
||||
assert_eq!(severity_rank("warning"), severity_rank("low"));
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,11 @@ async fn ensure_device_binding(
|
||||
updated_by: Set(None),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
status: Set("active".to_string()),
|
||||
firmware_version: Set(None),
|
||||
manufacturer: Set(None),
|
||||
connection_type: Set("ble".to_string()),
|
||||
metadata: Set(None),
|
||||
};
|
||||
binding.insert(db).await?;
|
||||
} else if let Some(existing) = existing {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod action_inbox_service;
|
||||
pub mod alert_noise_reducer;
|
||||
pub mod ai_action_dispatcher;
|
||||
pub mod ai_suggestion_loader;
|
||||
pub mod alert_engine;
|
||||
@@ -29,4 +30,5 @@ pub mod seed;
|
||||
pub mod stats_service;
|
||||
pub mod trend_service;
|
||||
pub mod trend_stats;
|
||||
pub mod vital_signs_daily_service;
|
||||
pub mod validation;
|
||||
|
||||
167
crates/erp-health/src/service/vital_signs_daily_service.rs
Normal file
167
crates/erp-health/src/service/vital_signs_daily_service.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
use chrono::NaiveDate;
|
||||
use sea_orm::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entity::vital_signs_daily;
|
||||
use crate::error::HealthResult;
|
||||
|
||||
/// 从 vital_signs_hourly 聚合指定日期的数据到 vital_signs_daily
|
||||
pub async fn aggregate_daily(
|
||||
db: &DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
date: NaiveDate,
|
||||
) -> HealthResult<u64> {
|
||||
let start_of_day = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
|
||||
let end_of_day = date.and_hms_opt(23, 59, 59).unwrap().and_utc();
|
||||
|
||||
let hourly_rows = crate::entity::vital_signs_hourly::Entity::find()
|
||||
.filter(crate::entity::vital_signs_hourly::Column::TenantId.eq(tenant_id))
|
||||
.filter(crate::entity::vital_signs_hourly::Column::HourStart.gte(start_of_day))
|
||||
.filter(crate::entity::vital_signs_hourly::Column::HourStart.lte(end_of_day))
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
let mut grouped: std::collections::HashMap<(Uuid, String), Vec<_>> =
|
||||
std::collections::HashMap::new();
|
||||
for row in &hourly_rows {
|
||||
let key = (row.patient_id, row.device_type.clone());
|
||||
grouped.entry(key).or_default().push(row.clone());
|
||||
}
|
||||
|
||||
let mut upserted = 0u64;
|
||||
for ((patient_id, device_type), rows) in grouped {
|
||||
let avg_val = rows.iter().map(|r| r.avg_val).sum::<f64>() / rows.len() as f64;
|
||||
let min_val = rows.iter().filter_map(|r| r.min_val).reduce(f64::min);
|
||||
let max_val = rows.iter().filter_map(|r| r.max_val).reduce(f64::max);
|
||||
let sample_count: i32 = rows.iter().map(|r| r.sample_count).sum();
|
||||
let all_avgs: Vec<f64> = rows.iter().map(|r| r.avg_val).collect();
|
||||
let percentile_95 = if all_avgs.len() >= 2 {
|
||||
Some(calculate_percentile(&all_avgs, 95.0))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let result = vital_signs_daily::Entity::insert(vital_signs_daily::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
tenant_id: Set(tenant_id),
|
||||
patient_id: Set(patient_id),
|
||||
device_type: Set(device_type),
|
||||
date_bucket: Set(date),
|
||||
min_val: Set(min_val),
|
||||
max_val: Set(max_val),
|
||||
avg_val: Set(avg_val),
|
||||
sample_count: Set(sample_count),
|
||||
percentile_95: Set(percentile_95),
|
||||
created_at: Set(chrono::Utc::now()),
|
||||
updated_at: Set(chrono::Utc::now()),
|
||||
version: Set(1),
|
||||
})
|
||||
.on_conflict(
|
||||
sea_orm::sea_query::OnConflict::columns([
|
||||
vital_signs_daily::Column::TenantId,
|
||||
vital_signs_daily::Column::PatientId,
|
||||
vital_signs_daily::Column::DeviceType,
|
||||
vital_signs_daily::Column::DateBucket,
|
||||
])
|
||||
.update_columns([
|
||||
vital_signs_daily::Column::MinVal,
|
||||
vital_signs_daily::Column::MaxVal,
|
||||
vital_signs_daily::Column::AvgVal,
|
||||
vital_signs_daily::Column::SampleCount,
|
||||
vital_signs_daily::Column::Percentile95,
|
||||
vital_signs_daily::Column::UpdatedAt,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(db)
|
||||
.await?;
|
||||
|
||||
upserted += 1;
|
||||
let _ = result;
|
||||
}
|
||||
|
||||
Ok(upserted)
|
||||
}
|
||||
|
||||
/// 遍历所有租户执行日聚合
|
||||
pub async fn aggregate_daily_for_all_tenants(
|
||||
db: &DatabaseConnection,
|
||||
date: NaiveDate,
|
||||
) -> HealthResult<u64> {
|
||||
let start_of_day = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
|
||||
let end_of_day = date.and_hms_opt(23, 59, 59).unwrap().and_utc();
|
||||
|
||||
let hourly_rows = crate::entity::vital_signs_hourly::Entity::find()
|
||||
.filter(crate::entity::vital_signs_hourly::Column::HourStart.gte(start_of_day))
|
||||
.filter(crate::entity::vital_signs_hourly::Column::HourStart.lte(end_of_day))
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
let tenant_ids: std::collections::HashSet<Uuid> =
|
||||
hourly_rows.iter().map(|r| r.tenant_id).collect();
|
||||
|
||||
let mut total = 0u64;
|
||||
for tenant_id in tenant_ids {
|
||||
total += aggregate_daily(db, tenant_id, date).await?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// 计算百分位数
|
||||
fn calculate_percentile(values: &[f64], percentile: f64) -> f64 {
|
||||
let mut sorted = values.to_vec();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let idx = (percentile / 100.0 * (sorted.len() - 1) as f64).ceil() as usize;
|
||||
sorted[idx.min(sorted.len() - 1)]
|
||||
}
|
||||
|
||||
/// 查询日聚合数据
|
||||
pub async fn query_daily(
|
||||
db: &DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Option<Uuid>,
|
||||
device_type: Option<String>,
|
||||
start_date: NaiveDate,
|
||||
end_date: NaiveDate,
|
||||
) -> HealthResult<Vec<vital_signs_daily::Model>> {
|
||||
let mut query = vital_signs_daily::Entity::find()
|
||||
.filter(vital_signs_daily::Column::TenantId.eq(tenant_id))
|
||||
.filter(vital_signs_daily::Column::DateBucket.gte(start_date))
|
||||
.filter(vital_signs_daily::Column::DateBucket.lte(end_date));
|
||||
|
||||
if let Some(pid) = patient_id {
|
||||
query = query.filter(vital_signs_daily::Column::PatientId.eq(pid));
|
||||
}
|
||||
if let Some(dt) = device_type {
|
||||
query = query.filter(vital_signs_daily::Column::DeviceType.eq(dt));
|
||||
}
|
||||
|
||||
let results = query.all(db).await?;
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_calculate_percentile_95() {
|
||||
let values = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
|
||||
let p95 = calculate_percentile(&values, 95.0);
|
||||
assert!(p95 > 9.0 && p95 <= 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_percentile_edge_single() {
|
||||
let values = vec![5.0];
|
||||
let p95 = calculate_percentile(&values, 95.0);
|
||||
assert_eq!(p95, 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_percentile_two_values() {
|
||||
let values = vec![10.0, 20.0];
|
||||
let p95 = calculate_percentile(&values, 95.0);
|
||||
assert_eq!(p95, 20.0);
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,9 @@ mod m20260501_000100_seed_action_inbox_menu;
|
||||
mod m20260502_000101_seed_health_dictionaries;
|
||||
mod m20260502_000102_seed_warning_thresholds;
|
||||
mod m20260502_000103_seed_follow_up_template_menu;
|
||||
mod m20260504_000104_create_vital_signs_daily;
|
||||
mod m20260504_000105_alter_patient_devices_add_status;
|
||||
mod m20260504_000106_create_api_clients;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -213,6 +216,9 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260502_000101_seed_health_dictionaries::Migration),
|
||||
Box::new(m20260502_000102_seed_warning_thresholds::Migration),
|
||||
Box::new(m20260502_000103_seed_follow_up_template_menu::Migration),
|
||||
Box::new(m20260504_000104_create_vital_signs_daily::Migration),
|
||||
Box::new(m20260504_000105_alter_patient_devices_add_status::Migration),
|
||||
Box::new(m20260504_000106_create_api_clients::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("vital_signs_daily"))
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("id"))
|
||||
.uuid()
|
||||
.not_null()
|
||||
.default(Expr::val("gen_random_uuid()")),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("patient_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("device_type")).string().not_null())
|
||||
.col(ColumnDef::new(Alias::new("date_bucket")).date().not_null())
|
||||
.col(ColumnDef::new(Alias::new("min_val")).double())
|
||||
.col(ColumnDef::new(Alias::new("max_val")).double())
|
||||
.col(ColumnDef::new(Alias::new("avg_val")).double().not_null())
|
||||
.col(ColumnDef::new(Alias::new("sample_count")).integer().not_null())
|
||||
.col(ColumnDef::new(Alias::new("percentile_95")).double())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("created_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::val("NOW()")),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("updated_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::val("NOW()")),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("version"))
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.primary_key(Index::create().col(Alias::new("id")))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_vital_signs_daily_unique")
|
||||
.table(Alias::new("vital_signs_daily"))
|
||||
.col(Alias::new("tenant_id"))
|
||||
.col(Alias::new("patient_id"))
|
||||
.col(Alias::new("device_type"))
|
||||
.col(Alias::new("date_bucket"))
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Alias::new("vital_signs_daily")).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Alias::new("patient_devices"))
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new("status"))
|
||||
.string()
|
||||
.not_null()
|
||||
.default("active"),
|
||||
)
|
||||
.add_column(ColumnDef::new(Alias::new("firmware_version")).string())
|
||||
.add_column(ColumnDef::new(Alias::new("manufacturer")).string())
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new("connection_type"))
|
||||
.string()
|
||||
.not_null()
|
||||
.default("ble"),
|
||||
)
|
||||
.add_column(ColumnDef::new(Alias::new("metadata")).json_binary())
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Alias::new("patient_devices"))
|
||||
.drop_column(Alias::new("status"))
|
||||
.drop_column(Alias::new("firmware_version"))
|
||||
.drop_column(Alias::new("manufacturer"))
|
||||
.drop_column(Alias::new("connection_type"))
|
||||
.drop_column(Alias::new("metadata"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# IoT 设备采集 + FHIR 开放平台生态 — 发散式探讨
|
||||
|
||||
> 日期: 2026-05-04 | 参与者: 用户 + Claude
|
||||
|
||||
## 背景
|
||||
|
||||
HMS 项目已进入功能基本完整阶段(575 次提交,B+ 架构评分)。Q2 路线图聚焦技术债清理和 AI 前端补全。用户希望探讨更长远的新功能方向:实时体征 + IoT 设备采集 与 平台生态扩展的融合。
|
||||
|
||||
## 讨论要点
|
||||
|
||||
### 1. 平台定位
|
||||
|
||||
- HMS 作为"健康数据枢纽" — 设备数据流入,通过标准接口流出
|
||||
- 选择**渐进演进**策略:先修炼 IoT 采集内功,再开放 API 给外部系统
|
||||
- 设备范围:**全场景覆盖**(穿戴 + 居家医疗 + 专业设备),但分阶段实施
|
||||
|
||||
### 2. 架构方案论证
|
||||
|
||||
比较了三种设备集成架构:
|
||||
|
||||
| 方案 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| A: 统一适配器 | 上层逻辑一致 | 穿戴和医疗设备差异太大,抽象变"最低公分母" |
|
||||
| B: 分通道 | 各通道独立优化 | 校验/存储逻辑重复,代码分散 |
|
||||
| **C: 混合(采纳)** | BLE 统一适配器 + 网关扩展,平衡 MVP 速度和扩展性 | 两套模式需维护 |
|
||||
|
||||
### 3. 数据输出标准
|
||||
|
||||
- 选择 **HL7 FHIR R4** — 国际医疗互操作标准,合作方无需学习私有接口
|
||||
- 输出方式:**拉取 + 推送分层** — V1 API 拉取,V2 Webhook 推送
|
||||
- 接入策略:**合作伙伴专属** — 审核制,多层隔离
|
||||
|
||||
### 4. Spec Review 发现
|
||||
|
||||
代码库中已存在大量设计为"新增"的功能:
|
||||
|
||||
| 已存在 | 位置 |
|
||||
|--------|------|
|
||||
| device_readings 表 + 批量摄入 | erp-health entity + service |
|
||||
| vital_signs_hourly 小时聚合 | erp-health entity |
|
||||
| alert_rules + alert_engine(3 种规则) | erp-health entity + service |
|
||||
| BLE 适配器框架 + 3 个适配器(TypeScript) | miniprogram services/ble/ |
|
||||
| SSE 推送(vital_update + alert) | erp-message handler |
|
||||
| EventBus 事件 + 消费者 | erp-health event.rs |
|
||||
|
||||
真正新增的只有:FHIR API 层、OAuth2 合作伙伴认证、日聚合表、设备网关 trait(V2)
|
||||
|
||||
### 5. 设计决策
|
||||
|
||||
- 保持现有 `device_type`(8种)+ JSONB `raw_value` 模式,不引入 ReadingType 拆分
|
||||
- BLE 适配器继续用 TypeScript(小程序端),不在 Rust 后端定义设备交互 trait
|
||||
- FHIR 路由使用 `/fhir/R4/` 前缀(合规性要求),豁免于 `/api/v1/` 约定
|
||||
- client_secret 使用 Argon2 哈希(与现有密码策略一致)
|
||||
|
||||
## 结论
|
||||
|
||||
1. **架构方案 C(混合)确认** — BLE 统一适配器 + 设备网关扩展点
|
||||
2. **渐进演进:V1(12周)→ V2a → V2b → V3**
|
||||
3. **大量基础设施已存在** — V1 的核心工作量在 FHIR API 层和合作伙伴认证,而非设备采集
|
||||
4. **Q2 路线图协同** — 建议顺延至 Q3 启动 IoT V1,Q2 专注现有技术债
|
||||
|
||||
## 关联文档
|
||||
|
||||
- 设计规格:`docs/superpowers/specs/2026-05-04-iot-fhir-platform-ecosystem-design.md`
|
||||
- 已有实时体征设计:`docs/superpowers/specs/2026-04-26-realtime-vital-signs-pipeline-design.md`
|
||||
- Q2 路线图:`docs/superpowers/specs/2026-05-03-q2-roadmap-design.md`
|
||||
@@ -0,0 +1,652 @@
|
||||
# IoT + FHIR V1 Plan 1: 数据层增强
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 新增 vital_signs_daily 日聚合表、增强 patient_devices 表字段、实现日聚合 background task,为 FHIR API 和监控看板提供数据基础。
|
||||
|
||||
**Architecture:** 在现有 erp-health 模块内扩展。新增 SeaORM Entity + Migration 对应日聚合表;为 patient_devices 添加迁移扩展列;日聚合任务复用现有 `tokio::spawn` + interval 模式,从 vital_signs_hourly 聚合为日级数据。
|
||||
|
||||
**Tech Stack:** Rust / SeaORM / PostgreSQL / tokio / Axum
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-04-iot-fhir-platform-ecosystem-design.md` §3
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: vital_signs_daily 表 + Entity
|
||||
|
||||
### Task 1: 日聚合表迁移
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-server/migration/src/m20260504_000105_create_vital_signs_daily.rs`
|
||||
- Modify: `crates/erp-server/migration/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: 创建迁移文件**
|
||||
|
||||
```rust
|
||||
// crates/erp-server/migration/src/m20260504_000105_create_vital_signs_daily.rs
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("vital_signs_daily"))
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("id"))
|
||||
.uuid()
|
||||
.not_null()
|
||||
.default(Expr::val("gen_random_uuid()")),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("patient_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("device_type")).string().not_null())
|
||||
.col(ColumnDef::new(Alias::new("date_bucket")).date().not_null())
|
||||
.col(ColumnDef::new(Alias::new("min_val")).double())
|
||||
.col(ColumnDef::new(Alias::new("max_val")).double())
|
||||
.col(ColumnDef::new(Alias::new("avg_val")).double().not_null())
|
||||
.col(ColumnDef::new(Alias::new("sample_count")).integer().not_null())
|
||||
.col(ColumnDef::new(Alias::new("percentile_95")).double())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("created_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::val("NOW()")),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("updated_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::val("NOW()")),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("version"))
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.primary_key(Index::create().col(Alias::new("id")))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_vital_signs_daily_unique")
|
||||
.table(Alias::new("vital_signs_daily"))
|
||||
.col(Alias::new("tenant_id"))
|
||||
.col(Alias::new("patient_id"))
|
||||
.col(Alias::new("device_type"))
|
||||
.col(Alias::new("date_bucket"))
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Alias::new("vital_signs_daily")).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 注册迁移**
|
||||
|
||||
在 `crates/erp-server/migration/src/lib.rs` 顶部添加:
|
||||
```rust
|
||||
mod m20260504_000105_create_vital_signs_daily;
|
||||
```
|
||||
|
||||
在 `Migrator::migrations()` 的 `vec![]` 末尾追加:
|
||||
```rust
|
||||
Box::new(m20260504_000105_create_vital_signs_daily::Migration),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证迁移编译**
|
||||
|
||||
Run: `cargo check -p erp-server`
|
||||
Expected: 编译通过,无错误
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-server/migration/src/m20260504_000105_create_vital_signs_daily.rs crates/erp-server/migration/src/lib.rs
|
||||
git commit -m "feat(db): 新增 vital_signs_daily 日聚合表迁移"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 日聚合 Entity
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-health/src/entity/vital_signs_daily.rs`
|
||||
- Modify: `crates/erp-health/src/entity/mod.rs`
|
||||
|
||||
- [ ] **Step 1: 创建 Entity 文件**
|
||||
|
||||
参考 `vital_signs_hourly.rs` 的结构,创建 `vital_signs_daily.rs`:
|
||||
|
||||
```rust
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "vital_signs_daily")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub device_type: String,
|
||||
pub date_bucket: chrono::NaiveDate,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub min_val: Option<f64>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub max_val: Option<f64>,
|
||||
pub avg_val: f64,
|
||||
pub sample_count: i32,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub percentile_95: Option<f64>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::patient::Entity",
|
||||
from = "Column::PatientId",
|
||||
to = "super::patient::Column::Id"
|
||||
)]
|
||||
Patient,
|
||||
}
|
||||
|
||||
impl Related<super::patient::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Patient.def()
|
||||
}
|
||||
fn via() -> Option<RelationDef> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 注册 Entity 模块**
|
||||
|
||||
在 `crates/erp-health/src/entity/mod.rs` 添加:
|
||||
```rust
|
||||
pub mod vital_signs_daily;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证编译**
|
||||
|
||||
Run: `cargo check -p erp-health`
|
||||
Expected: 编译通过
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-health/src/entity/vital_signs_daily.rs crates/erp-health/src/entity/mod.rs
|
||||
git commit -m "feat(health): 新增 VitalSignsDaily SeaORM Entity"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 日聚合 Service 函数
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-health/src/service/vital_signs_daily_service.rs`
|
||||
- Modify: `crates/erp-health/src/service/mod.rs`
|
||||
|
||||
- [ ] **Step 1: 编写日聚合 service 单元测试**
|
||||
|
||||
```rust
|
||||
// 文件末尾的 tests 模块
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_daily_agg_query_basic() {
|
||||
// 验证聚合查询构建器产生正确的 SQL 片段
|
||||
let query = build_daily_agg_query(Uuid::new_v4(), chrono::NaiveDate::from_ymd_opt(2026, 5, 4).unwrap());
|
||||
assert!(query.contains("vital_signs_hourly"));
|
||||
assert!(query.contains("date_bucket"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_percentile_95() {
|
||||
let values = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
|
||||
let p95 = calculate_percentile(&values, 95.0);
|
||||
assert!(p95 > 9.0 && p95 <= 10.0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试验证失败**
|
||||
|
||||
Run: `cargo test -p erp-health vital_signs_daily`
|
||||
Expected: 编译失败(函数未定义)
|
||||
|
||||
- [ ] **Step 3: 实现 service**
|
||||
|
||||
```rust
|
||||
use chrono::NaiveDate;
|
||||
use sea_orm::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entity::vital_signs_daily;
|
||||
use crate::error::HealthResult;
|
||||
|
||||
/// 从 vital_signs_hourly 聚合指定日期的数据到 vital_signs_daily
|
||||
pub async fn aggregate_daily(
|
||||
db: &DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
date: NaiveDate,
|
||||
) -> HealthResult<u64> {
|
||||
let start_of_day = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
|
||||
let end_of_day = date.and_hms_opt(23, 59, 59).unwrap().and_utc();
|
||||
|
||||
// 查询当天所有小时聚合数据
|
||||
let hourly_rows = entity::vital_signs_hourly::Entity::find()
|
||||
.filter(entity::vital_signs_hourly::Column::TenantId.eq(tenant_id))
|
||||
.filter(entity::vital_signs_hourly::Column::HourStart.gte(start_of_day))
|
||||
.filter(entity::vital_signs_hourly::Column::HourStart.lte(end_of_day))
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
// 按 (patient_id, device_type) 分组聚合
|
||||
let mut grouped: std::collections::HashMap<(Uuid, String), Vec<&_>> = std::collections::HashMap::new();
|
||||
for row in &hourly_rows {
|
||||
let key = (row.patient_id, row.device_type.clone());
|
||||
grouped.entry(key).or_default().push(row);
|
||||
}
|
||||
|
||||
let mut upserted = 0u64;
|
||||
for ((patient_id, device_type), rows) in grouped {
|
||||
let avg_val = rows.iter().map(|r| r.avg_val).sum::<f64>() / rows.len() as f64;
|
||||
let min_val = rows.iter().filter_map(|r| r.min_val).reduce(f64::min);
|
||||
let max_val = rows.iter().filter_map(|r| r.max_val).reduce(f64::max);
|
||||
let sample_count: i32 = rows.iter().map(|r| r.sample_count).sum();
|
||||
let all_avgs: Vec<f64> = rows.iter().map(|r| r.avg_val).collect();
|
||||
let percentile_95 = if all_avgs.len() >= 2 {
|
||||
Some(calculate_percentile(&all_avgs, 95.0))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Upsert: ON CONFLICT UPDATE
|
||||
let result = vital_signs_daily::Entity::insert(
|
||||
vital_signs_daily::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
tenant_id: Set(tenant_id),
|
||||
patient_id: Set(patient_id),
|
||||
device_type: Set(device_type),
|
||||
date_bucket: Set(date),
|
||||
min_val: Set(min_val),
|
||||
max_val: Set(max_val),
|
||||
avg_val: Set(avg_val),
|
||||
sample_count: Set(sample_count),
|
||||
percentile_95: Set(percentile_95),
|
||||
created_at: Set(chrono::Utc::now()),
|
||||
updated_at: Set(chrono::Utc::now()),
|
||||
version: Set(1),
|
||||
},
|
||||
)
|
||||
.on_conflict(
|
||||
sea_orm::sea_query::OnConflict::columns([
|
||||
vital_signs_daily::Column::TenantId,
|
||||
vital_signs_daily::Column::PatientId,
|
||||
vital_signs_daily::Column::DeviceType,
|
||||
vital_signs_daily::Column::DateBucket,
|
||||
])
|
||||
.update_columns([
|
||||
vital_signs_daily::Column::MinVal,
|
||||
vital_signs_daily::Column::MaxVal,
|
||||
vital_signs_daily::Column::AvgVal,
|
||||
vital_signs_daily::Column::SampleCount,
|
||||
vital_signs_daily::Column::Percentile95,
|
||||
vital_signs_daily::Column::UpdatedAt,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(db)
|
||||
.await?;
|
||||
|
||||
upserted += result.last_insert_id as u64;
|
||||
}
|
||||
|
||||
Ok(upserted)
|
||||
}
|
||||
|
||||
/// 计算百分位数
|
||||
fn calculate_percentile(values: &[f64], percentile: f64) -> f64 {
|
||||
let mut sorted = values.to_vec();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let idx = (percentile / 100.0 * (sorted.len() - 1) as f64).ceil() as usize;
|
||||
sorted[idx.min(sorted.len() - 1)]
|
||||
}
|
||||
|
||||
/// 查询日聚合数据
|
||||
pub async fn query_daily(
|
||||
db: &DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Option<Uuid>,
|
||||
device_type: Option<String>,
|
||||
start_date: NaiveDate,
|
||||
end_date: NaiveDate,
|
||||
) -> HealthResult<Vec<vital_signs_daily::Model>> {
|
||||
let mut query = vital_signs_daily::Entity::find()
|
||||
.filter(vital_signs_daily::Column::TenantId.eq(tenant_id))
|
||||
.filter(vital_signs_daily::Column::DateBucket.gte(start_date))
|
||||
.filter(vital_signs_daily::Column::DateBucket.lte(end_date));
|
||||
|
||||
if let Some(pid) = patient_id {
|
||||
query = query.filter(vital_signs_daily::Column::PatientId.eq(pid));
|
||||
}
|
||||
if let Some(dt) = device_type {
|
||||
query = query.filter(vital_signs_daily::Column::DeviceType.eq(dt));
|
||||
}
|
||||
|
||||
let results = query.all(db).await?;
|
||||
Ok(results)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 注册 service 模块**
|
||||
|
||||
在 `crates/erp-health/src/service/mod.rs` 添加:
|
||||
```rust
|
||||
pub mod vital_signs_daily_service;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 验证编译 + 测试通过**
|
||||
|
||||
Run: `cargo test -p erp-health vital_signs_daily`
|
||||
Expected: 测试通过
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-health/src/service/vital_signs_daily_service.rs crates/erp-health/src/service/mod.rs
|
||||
git commit -m "feat(health): 日聚合 service — 从 hourly 聚合到 daily"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: patient_devices 表增强
|
||||
|
||||
### Task 4: patient_devices 迁移(新增列)
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-server/migration/src/m20260504_000106_alter_patient_devices_add_status.rs`
|
||||
- Modify: `crates/erp-server/migration/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: 创建迁移文件**
|
||||
|
||||
```rust
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Alias::new("patient_devices"))
|
||||
.add_column(ColumnDef::new(Alias::new("status")).string().not_null().default("active"))
|
||||
.add_column(ColumnDef::new(Alias::new("firmware_version")).string())
|
||||
.add_column(ColumnDef::new(Alias::new("manufacturer")).string())
|
||||
.add_column(ColumnDef::new(Alias::new("connection_type")).string().not_null().default("ble"))
|
||||
.add_column(ColumnDef::new(Alias::new("metadata")).json_binary())
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Alias::new("patient_devices"))
|
||||
.drop_column(Alias::new("status"))
|
||||
.drop_column(Alias::new("firmware_version"))
|
||||
.drop_column(Alias::new("manufacturer"))
|
||||
.drop_column(Alias::new("connection_type"))
|
||||
.drop_column(Alias::new("metadata"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 注册迁移**
|
||||
|
||||
在 `lib.rs` 添加 `mod m20260504_000106_alter_patient_devices_add_status;` 并在 migrations vec 末尾追加对应 `Box::new(...)`。
|
||||
|
||||
- [ ] **Step 3: 更新 Entity**
|
||||
|
||||
在 `crates/erp-health/src/entity/patient_devices.rs` 的 `Model` struct 中添加字段:
|
||||
|
||||
```rust
|
||||
pub status: String,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub firmware_version: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub manufacturer: Option<String>,
|
||||
pub connection_type: String,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 验证编译**
|
||||
|
||||
Run: `cargo check -p erp-health`
|
||||
Expected: 编译通过
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-server/migration/src/m20260504_000106_alter_patient_devices_add_status.rs crates/erp-server/migration/src/lib.rs crates/erp-health/src/entity/patient_devices.rs
|
||||
git commit -m "feat(health): patient_devices 增强 — status/firmware/manufacturer/connection_type/metadata"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: 日聚合 Background Task
|
||||
|
||||
### Task 5: 日聚合定时任务
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-health/src/module.rs`
|
||||
|
||||
- [ ] **Step 1: 添加 start_daily_aggregation 任务**
|
||||
|
||||
参考现有 `start_overdue_checker` 模式,在 `module.rs` 中添加:
|
||||
|
||||
```rust
|
||||
pub fn start_daily_aggregation(db: sea_orm::DatabaseConnection) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
// 每天凌晨 2:00 执行(用 interval 模拟:每 24 小时)
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(24 * 3600));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
let yesterday = chrono::Local::now().date_naive() - chrono::Duration::days(1);
|
||||
// TODO: 遍历所有租户,对每个租户调用 aggregate_daily
|
||||
// 当前单租户阶段,使用配置的默认租户 ID
|
||||
tracing::info!(date = %yesterday, "Running daily aggregation");
|
||||
if let Err(e) = vital_signs_daily_service::aggregate_daily_for_all_tenants(&db, yesterday).await {
|
||||
tracing::warn!(error = %e, "Daily aggregation task failed");
|
||||
}
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加 aggregate_daily_for_all_tenants 辅助函数**
|
||||
|
||||
在 `vital_signs_daily_service.rs` 中添加:
|
||||
|
||||
```rust
|
||||
/// 遍历所有租户执行日聚合
|
||||
pub async fn aggregate_daily_for_all_tenants(
|
||||
db: &DatabaseConnection,
|
||||
date: NaiveDate,
|
||||
) -> HealthResult<u64> {
|
||||
// 查询有 hourly 数据的活跃租户
|
||||
let tenant_ids: Vec<Uuid> = entity::vital_signs_hourly::Entity::find()
|
||||
.filter(entity::vital_signs_hourly::Column::HourStart.gte(
|
||||
date.and_hms_opt(0, 0, 0).unwrap().and_utc(),
|
||||
))
|
||||
.filter(entity::vital_signs_hourly::Column::HourStart.lte(
|
||||
date.and_hms_opt(23, 59, 59).unwrap().and_utc(),
|
||||
))
|
||||
.all(db)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| r.tenant_id)
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let mut total = 0u64;
|
||||
for tenant_id in tenant_ids {
|
||||
total += aggregate_daily(db, tenant_id, date).await?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 在 on_startup 中启动任务**
|
||||
|
||||
在 `HealthModule` 的 `on_startup()` 方法中追加:
|
||||
|
||||
```rust
|
||||
Self::start_daily_aggregation(state.db.clone());
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 验证编译**
|
||||
|
||||
Run: `cargo check -p erp-health`
|
||||
Expected: 编译通过
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-health/src/service/vital_signs_daily_service.rs crates/erp-health/src/module.rs
|
||||
git commit -m "feat(health): 日聚合 background task — 每天自动从 hourly 聚合到 daily"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: 日聚合查询 API
|
||||
|
||||
### Task 6: 日聚合查询 Handler + 路由
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-health/src/handler/vital_signs_daily_handler.rs`
|
||||
- Modify: `crates/erp-health/src/handler/mod.rs`
|
||||
- Modify: `crates/erp-health/src/module.rs`(路由注册)
|
||||
|
||||
- [ ] **Step 1: 创建查询 DTO**
|
||||
|
||||
```rust
|
||||
use serde::Deserialize;
|
||||
use utoipa::IntoParams;
|
||||
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct DailyAggQuery {
|
||||
pub patient_id: Option<uuid::Uuid>,
|
||||
pub device_type: Option<String>,
|
||||
pub start_date: String, // YYYY-MM-DD
|
||||
pub end_date: String, // YYYY-MM-DD
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 Handler**
|
||||
|
||||
```rust
|
||||
pub async fn get_daily_aggregations<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(query): Query<DailyAggQuery>,
|
||||
) -> Result<impl IntoResponse, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.device-readings.list")?;
|
||||
|
||||
let start = query.start_date.parse::<chrono::NaiveDate>()
|
||||
.map_err(|_| HealthError::Validation("Invalid start_date format, expected YYYY-MM-DD".into()))?;
|
||||
let end = query.end_date.parse::<chrono::NaiveDate>()
|
||||
.map_err(|_| HealthError::Validation("Invalid end_date format, expected YYYY-MM-DD".into()))?;
|
||||
|
||||
let results = vital_signs_daily_service::query_daily(
|
||||
&state.db, ctx.tenant_id, query.patient_id, query.device_type, start, end,
|
||||
).await?;
|
||||
|
||||
Ok(axum::Json(ApiResponse::ok(results)))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 注册 handler 模块和路由**
|
||||
|
||||
在 `handler/mod.rs` 添加 `pub mod vital_signs_daily_handler;`
|
||||
|
||||
在 `module.rs` 的 `protected_routes()` 中追加路由:
|
||||
|
||||
```rust
|
||||
.route("/health/vital-signs/daily", axum::routing::get(vital_signs_daily_handler::get_daily_aggregations))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 验证编译**
|
||||
|
||||
Run: `cargo check -p erp-health`
|
||||
Expected: 编译通过
|
||||
|
||||
- [ ] **Step 5: 运行全量测试**
|
||||
|
||||
Run: `cargo test --workspace`
|
||||
Expected: 全部通过
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-health/src/handler/vital_signs_daily_handler.rs crates/erp-health/src/handler/mod.rs crates/erp-health/src/module.rs
|
||||
git commit -m "feat(health): 日聚合查询 API — GET /health/vital-signs/daily"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证清单
|
||||
|
||||
- [ ] `cargo check --workspace` — 编译通过
|
||||
- [ ] `cargo test --workspace` — 全部测试通过
|
||||
- [ ] 数据库迁移可正/反向执行
|
||||
- [ ] 日聚合 background task 可手动触发验证
|
||||
- [ ] GET `/api/v1/health/vital-signs/daily` 返回正确的聚合数据
|
||||
- [ ] `git push` 推送到远程仓库
|
||||
1414
docs/superpowers/plans/2026-05-04-iot-fhir-v1-plan2-fhir-api.md
Normal file
1414
docs/superpowers/plans/2026-05-04-iot-fhir-v1-plan2-fhir-api.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,955 @@
|
||||
# HMS IoT 设备采集 + FHIR 开放平台设计规格
|
||||
|
||||
> 日期: 2026-05-04 | 类型: 设计规格 | 状态: Draft
|
||||
>
|
||||
> **如何使用本文档:** §1-2 是全景架构,所有人应读。§3-6 是各层详细设计,按需阅读。§7-8 是实施路线和变更清单,执行时参考。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 业务背景
|
||||
|
||||
HMS 平台当前已实现体征数据的手动录入和危急值阈值告警。但慢病患者(高血压、糖尿病、肾病)的健康数据采集严重依赖患者主动录入,依从性差、数据稀疏、无法捕捉短时波动。
|
||||
|
||||
健康手环(小米手环、华为手环)的普及率为 **24 小时被动采集**提供了硬件基础。心率、血氧、步数、睡眠等数据可连续产生,通过小程序 BLE 同步到 HMS。居家医疗设备(蓝牙血压计、血糖仪)进一步覆盖慢病管理的核心指标。体检中心专业设备(生化分析仪、心电图机)则提供最权威的医疗级数据。
|
||||
|
||||
HMS 从"数据采集平台"升级为"健康数据枢纽":设备数据流入,通过 FHIR 标准接口流出,第三方系统(HIS/LIS/体检系统)可消费标准化数据。
|
||||
|
||||
### 1.2 目标用户与场景
|
||||
|
||||
| 用户 | 场景 | 价值 |
|
||||
|------|------|------|
|
||||
| **慢病患者** | 佩戴手环/使用蓝牙设备,小程序自动同步数据 | 免去手动录入,持续监测 |
|
||||
| **主管医生** | Web 端实时看板查看患者体征趋势和告警 | 及时发现异常,干预更早 |
|
||||
| **护士站** | 接收高危告警推送 | 快速响应危急情况 |
|
||||
| **体检中心** | 出具报告时参考连续监测数据 + 专业设备直连 | 报告更全面,数据自动入库 |
|
||||
| **合作方系统** | 通过 FHIR API 拉取/推送标准化健康数据 | 无需适配 HMS 私有接口 |
|
||||
|
||||
### 1.3 成功指标
|
||||
|
||||
| 指标 | 基线 | V1 目标 | V2 目标 |
|
||||
|------|------|---------|---------|
|
||||
| 体征数据日均采集量 | ~10 条/患者(手动) | > 500 条/患者(设备) | > 1000 条/患者 |
|
||||
| 设备接入数 | 0 | 2 款(手环 + 血压计) | 6+ 款(含网关) |
|
||||
| 告警延迟(采集→通知) | 分钟级(轮询) | < 30 秒(SSE) | < 10 秒 |
|
||||
| FHIR API 响应时间 | N/A | < 200ms (P95) | < 150ms (P95) |
|
||||
| 合作方接入 | 0 | 1 家(验证) | 3-5 家 |
|
||||
|
||||
### 1.4 范围边界
|
||||
|
||||
**做:**
|
||||
|
||||
- BLE 设备采集(穿戴 + 居家医疗)— DeviceAdapter 统一抽象
|
||||
- 专业设备接入扩展点 — DeviceGateway trait(V2 实现)
|
||||
- 后端设备数据摄入 API(批量提交 + 降采样)
|
||||
- 告警规则引擎(单次阈值 + 连续超标 + 趋势恶化)
|
||||
- SSE 推送扩展(体征聚合更新 + 告警通知)
|
||||
- FHIR R4 只读 API — 10 个资源端点
|
||||
- OAuth2 Client Credentials 合作伙伴认证
|
||||
- 数据分区与降采样策略
|
||||
|
||||
**不做(本设计不覆盖):**
|
||||
|
||||
- ICU 级实时监控(亚秒级,需要专用系统)
|
||||
- AI 驱动的异常检测(erp-ai 模块职责,本设计只提供数据输入)
|
||||
- FHIR 写入 API(V2a 范围)
|
||||
- Webhook 事件推送(V2b 范围)
|
||||
- 设备厂商自助接入 SDK(V2c 范围)
|
||||
- 多实例部署的事件总线演进(单实例足够)
|
||||
|
||||
---
|
||||
|
||||
## 2. 整体架构与演进路线
|
||||
|
||||
### 2.1 全景数据流
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 设备层 (Device Layer) │
|
||||
│ │
|
||||
│ 消费穿戴 居家医疗 专业设备 │
|
||||
│ ┌───────┐ ┌──────────┐ ┌──────────────┐ │
|
||||
│ │小米手环│ │蓝牙血压计 │ │生化分析仪 │ │
|
||||
│ │华为手环│ │蓝牙血糖仪 │ │心电图机 │ │
|
||||
│ │Apple │ │蓝牙体温计 │ │超声设备 │ │
|
||||
│ │Watch │ │智能体脂秤 │ │DICOM 设备 │ │
|
||||
│ └───┬────┘ └────┬─────┘ └──────┬───────┘ │
|
||||
│ │ │ │ │
|
||||
│ ────┴──── BLE GATT ───┴──── │ HL7/DICOM/TCP │
|
||||
│ │ │ │ │
|
||||
│ ┌───┴──────────────────┴──┐ ┌─────────┴──────────┐ │
|
||||
│ │ DeviceAdapter trait │ │ DeviceGateway trait │ │
|
||||
│ │ (V1 实现, 小程序端) │ │ (V2, 插件/网关) │ │
|
||||
│ └───────────┬─────────────┘ └──────────┬───────────┘ │
|
||||
│ │ │ │
|
||||
├──────────────┼───────────────────────────────┼────────────────────┤
|
||||
│ │ 摄入层 (Ingestion) │ │
|
||||
│ │
|
||||
│ ┌───────────┴───────────────────────────────┴───────────┐ │
|
||||
│ │ 统一摄入 API │ │
|
||||
│ │ POST /api/v1/health/device/readings (批量) │ │
|
||||
│ │ POST /api/v1/health/device/gateway/{type} (网关) │ │
|
||||
│ │ │ │
|
||||
│ │ 校验 → 降采样 → 存储 → 事件发布 │ │
|
||||
│ └───────────────────────────┬────────────────────────────┘ │
|
||||
│ │ │
|
||||
├──────────────────────────────┼────────────────────────────────────┤
|
||||
│ │ 数据层 (Storage) │
|
||||
│ │
|
||||
│ ┌───────────┐ ┌──────────┐ ┌───────────┐ ┌───────────────┐ │
|
||||
│ │device_ │ │device_ │ │vital_ │ │health_ │ │
|
||||
│ │readings │ │gateways │ │signs │ │alerts │ │
|
||||
│ │(高频原始) │ │(网关注册)│ │(手动+降采样)│ │(规则触发) │ │
|
||||
│ └───────────┘ └──────────┘ └───────────┘ └───────────────┘ │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 输出层 (Output) │
|
||||
│ │
|
||||
│ V1: API 拉取 V2: 事件推送 │
|
||||
│ ┌─────────────────┐ ┌──────────────────┐ │
|
||||
│ │ FHIR API │ │ Webhook 推送 │ │
|
||||
│ │ /fhir/Patient │ │ 事件订阅管理 │ │
|
||||
│ │ /fhir/Observation│ │ 幂等投递+重试 │ │
|
||||
│ │ /fhir/Device │ │ Dead letter 处理 │ │
|
||||
│ │ /fhir/Diagnostic │ │ │ │
|
||||
│ └────────┬────────┘ └────────┬──────────┘ │
|
||||
│ │ │ │
|
||||
│ ─────────┴───── 合作伙伴 API ─────┴───────── │
|
||||
│ │ │ │
|
||||
│ ┌────────┴──────┐ ┌────────┴──────────┐ │
|
||||
│ │HIS/LIS 系统 │ │体检中心系统 │ │
|
||||
│ │电子病历 │ │健康管理机构 │ │
|
||||
│ └───────────────┘ └───────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 现有功能基线
|
||||
|
||||
以下功能**已存在于代码库中**,本 spec 不重复设计,只标注需要的增强:
|
||||
|
||||
| 已有功能 | 位置 | 状态 |
|
||||
|----------|------|------|
|
||||
| device_readings 表 + Entity | `erp-health/entity/device_readings.rs` | 生产就绪,含分区 |
|
||||
| 批量摄入 API + 服务 | `erp-health/service/device_reading_service.rs` | 生产就绪,含去重/校验/小时聚合 |
|
||||
| patient_devices 设备绑定 | `erp-health/entity/patient_devices.rs` | 基础功能完成,缺少状态追踪 |
|
||||
| vital_signs_hourly 小时聚合 | `erp-health/entity/vital_signs_hourly.rs` | 生产就绪 |
|
||||
| alert_rules 告警规则 | `erp-health/entity/alert_rules.rs` | 生产就绪 |
|
||||
| alert_engine 告警引擎 | `erp-health/service/alert_engine.rs` | 3 种规则类型已实现 |
|
||||
| EventBus 事件 | `erp-health/event.rs` | `device.readings.synced` + `alert.triggered` 已发布 |
|
||||
| SSE 推送 | `erp-message/handler/sse_handler.rs` | 已推送 vital_update + alert 事件 |
|
||||
| BLE 适配器框架 | 小程序 `services/ble/` | TypeScript DeviceAdapter 接口 + 3 个适配器 |
|
||||
| 设备类型校验 | `erp-health/service/validation.rs` | 8 种 device_type |
|
||||
|
||||
### 2.3 模块划分与职责
|
||||
|
||||
| 模块 | 位置 | 职责 | 状态 |
|
||||
|------|------|------|------|
|
||||
| **BLE 采集层** | 小程序 `services/ble/` | 设备发现、连接、数据读取 | 已存在 |
|
||||
| **DeviceAdapter** | 小程序 `services/ble/adapters/` | 统一接口,屏蔽 BLE 差异 | 已存在(TypeScript) |
|
||||
| **摄入 API** | `erp-health` handler | 批量接收、校验、存储 | 已存在 |
|
||||
| **降采样 Pipeline** | `erp-health` service | 小时聚合已实现 | 已存在,**需新增日聚合** |
|
||||
| **告警引擎** | `erp-health` service | 3 种规则评估 | 已存在 |
|
||||
| **SSE 推送** | `erp-message` handler | vital_update + alert 推送 | 已存在,**需增强重连** |
|
||||
| **FHIR API** | `erp-health` handler (新路由组) | FHIR R4 资源查询 | **新增** |
|
||||
| **合作伙伴认证** | `erp-auth` | OAuth2 Client Credentials | **新增** |
|
||||
| **DeviceGateway** | `erp-health` trait | 非 BLE 设备接入扩展点 | **新增(V2)** |
|
||||
|
||||
### 2.3 演进阶段
|
||||
|
||||
| 阶段 | 时间 | 内容 | 里程碑 |
|
||||
|------|------|------|--------|
|
||||
| **V1** | 8-12 周 | BLE 适配器 + 摄入 API + 降采样 + 告警引擎 + FHIR 只读 API + OAuth2 | 手环数据全链路跑通,1 家合作方验证 |
|
||||
| **V2a** | 4-6 周 | 居家医疗设备适配 ×3 + FHIR 写入 API | 血压/血糖/体温数据自动采集 |
|
||||
| **V2b** | 6-8 周 | DeviceGateway + HL7/DICOM 适配 + Webhook 推送 | 体检中心设备接入 |
|
||||
| **V3** | 持续 | 设备厂商自助接入(插件 SDK)+ 数据市场 + AI 分析增强 | 平台生态自运转 |
|
||||
|
||||
### 2.5 与现有 HMS 架构的集成点
|
||||
|
||||
| 集成点 | 现有 | 本设计扩展 |
|
||||
|--------|------|-----------|
|
||||
| EventBus | `device.readings.synced` + `alert.triggered` 已实现 | 不需要新增事件类型 |
|
||||
| SSE Handler | 已推送 `vital_update` + `alert` + `message` | 增强:Last-Event-ID 重连 + 心跳 |
|
||||
| device_readings 表 | 已有分区表 + 批量摄入 + 去重 | 不变更,复用现有 |
|
||||
| vital_signs_hourly | 已有小时聚合 | 不变更,复用现有 |
|
||||
| vital_signs_daily | 不存在 | **新增**:日聚合表 + background task |
|
||||
| patient_devices | 基础设备绑定 | **增强**:status/firmware/metadata 列 |
|
||||
| alert_engine | 3 种规则类型 + cooldown | **增强**:患者级升级 + 系统级聚合降噪 |
|
||||
| 认证系统 | JWT + RBAC | **新增**:OAuth2 Client Credentials(合作伙伴专用) |
|
||||
| WASM 插件 | erp-plugin 运行时 | V2 复用为设备网关扩展点 |
|
||||
|
||||
### 2.5 架构决策原则
|
||||
|
||||
**为什么 BLE 适配器 + 设备网关混合而非统一抽象?**
|
||||
|
||||
BLE 穿戴和居家医疗设备本质上是同一协议族(蓝牙 GATT),统一 DeviceAdapter 自然合理。专业设备的数据模型(HL7 OBX/PID/OBR)与体征读数(时间序列)根本不同,强行统一会变成"最低公分母"。分开处理是正确的关注点分离。HMS 已有 WASM 插件系统,设备网关可复用插件架构作为扩展点。
|
||||
|
||||
**为什么 FHIR 而非自定义 JSON API?**
|
||||
|
||||
FHIR 是国际医疗互操作标准,HIS/LIS/EMR 系统广泛支持。选择 FHIR 意味着 HMS 从一开始就为与现有医疗系统对接做好了准备,合作方不需要学习 HMS 私有接口。V1 只做 6 个核心资源的只读 API,复杂度可控。
|
||||
|
||||
**为什么合作伙伴专属而非完全开放?**
|
||||
|
||||
医疗数据的敏感性要求严格的访问控制。合作伙伴模式确保只有经过审核的机构才能接入,配合多层隔离(租户/资源/患者范围/频率限制)降低数据泄露风险。
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据模型与 FHIR 映射
|
||||
|
||||
### 3.1 现有表(不修改)
|
||||
|
||||
以下表已存在于代码库中,本设计不做结构性变更:
|
||||
|
||||
| 表名 | 关键列 | 说明 |
|
||||
|------|--------|------|
|
||||
| `device_readings` | id, tenant_id, patient_id, device_id(VARCHAR), device_type, metric, raw_value(JSON), measured_at | 高频原始数据,含分区 |
|
||||
| `patient_devices` | id, tenant_id, patient_id, device_id(VARCHAR), device_model, device_type, bound_at, last_sync_at | 设备绑定 |
|
||||
| `vital_signs_hourly` | id, tenant_id, patient_id, device_type, hour_start, min_val, max_val, avg_val, sample_count | 小时聚合 |
|
||||
| `alert_rules` | id, tenant_id, name, device_type, condition_type, condition_params(JSON), severity, is_active, cooldown_minutes | 告警规则 |
|
||||
| `alerts` | id, tenant_id, patient_id, rule_id, device_type, severity, status, ... | 告警实例 |
|
||||
|
||||
### 3.2 需增强的表
|
||||
|
||||
**patient_devices — 增加设备状态追踪**
|
||||
|
||||
新增列(迁移):
|
||||
|
||||
| 列名 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| status | VARCHAR(20) DEFAULT 'active' | active / inactive / disconnected |
|
||||
| firmware_version | VARCHAR(100) | 固件版本 |
|
||||
| manufacturer | VARCHAR(200) | 厂商 |
|
||||
| connection_type | VARCHAR(50) DEFAULT 'ble' | ble / ble_gateway / serial / tcp / hl7 |
|
||||
| metadata | JSONB | 设备特有配置 |
|
||||
|
||||
### 3.3 新增表
|
||||
|
||||
#### vital_signs_daily — 日聚合(新增)
|
||||
|
||||
| 列名 | 类型 | 约束 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | UUID v7 | PK | |
|
||||
| tenant_id | UUID | NOT NULL | |
|
||||
| patient_id | UUID | FK → patients, NOT NULL | |
|
||||
| device_type | VARCHAR(50) | NOT NULL | 与现有 device_type 一致 |
|
||||
| date_bucket | DATE | NOT NULL | 日期 |
|
||||
| min_val | NUMERIC(12,4) | | 最小值 |
|
||||
| max_val | NUMERIC(12,4) | | 最大值 |
|
||||
| avg_val | NUMERIC(12,4) | NOT NULL | 平均值 |
|
||||
| sample_count | INTEGER | NOT NULL | 样本数 |
|
||||
| percentile_95 | NUMERIC(12,4) | | 95 百分位 |
|
||||
| created_at | TIMESTAMPTZ | NOT NULL | |
|
||||
| updated_at | TIMESTAMPTZ | NOT NULL | |
|
||||
| version | INTEGER | NOT NULL DEFAULT 1 | |
|
||||
|
||||
UNIQUE: `(tenant_id, patient_id, device_type, date_bucket)`
|
||||
|
||||
#### api_clients — 合作方 API 客户端(新增)
|
||||
|
||||
| 列名 | 类型 | 约束 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | UUID v7 | PK | |
|
||||
| tenant_id | UUID | NOT NULL | 所属租户 |
|
||||
| client_id | VARCHAR(128) | UNIQUE NOT NULL | OAuth2 client_id |
|
||||
| client_secret_hash | VARCHAR(256) | NOT NULL | Argon2 哈希 |
|
||||
| client_name | VARCHAR(200) | NOT NULL | 合作方名称 |
|
||||
| scopes | JSONB | NOT NULL | 允许的 FHIR 资源范围 |
|
||||
| allowed_patient_ids | JSONB | | 可访问的患者 ID 白名单(null = 全部) |
|
||||
| rate_limit_per_minute | INTEGER | NOT NULL DEFAULT 60 | 限流 |
|
||||
| is_active | BOOLEAN | NOT NULL DEFAULT true | |
|
||||
| token_lifetime_seconds | INTEGER | NOT NULL DEFAULT 3600 | token 有效期 |
|
||||
| created_at | TIMESTAMPTZ | NOT NULL | |
|
||||
| updated_at | TIMESTAMPTZ | NOT NULL | |
|
||||
| created_by | UUID | | |
|
||||
| updated_by | UUID | | |
|
||||
| deleted_at | TIMESTAMPTZ | | |
|
||||
| version | INTEGER | NOT NULL DEFAULT 1 | |
|
||||
|
||||
### 3.2 FHIR Resource 映射
|
||||
|
||||
| HMS 实体 | FHIR R4 Resource | 说明 |
|
||||
|----------|-----------------|------|
|
||||
| Patient | Patient | 患者基本信息 |
|
||||
| Doctor | Practitioner | 医护人员 |
|
||||
| Department | Organization | 科室/组织 |
|
||||
| Appointment | Appointment | 预约 |
|
||||
| VitalSign | Observation | 体征数据(手动录入) |
|
||||
| DeviceReading(新) | Observation | 设备采集数据 |
|
||||
| Device(新) | Device | 设备注册信息 |
|
||||
| HealthAlert | Flag | 告警 |
|
||||
| LabReport | DiagnosticReport | 化验报告 |
|
||||
| Consultation | Encounter | 咨询记录 |
|
||||
| FollowUpTask | Task | 随访任务 |
|
||||
|
||||
### 3.3 LOINC 编码映射
|
||||
|
||||
| ReadingType | LOINC Code | Display |
|
||||
|-------------|-----------|---------|
|
||||
| HeartRate | 8867-4 | Heart rate |
|
||||
| SpO2 | 2708-6 | Oxygen saturation in Arterial blood |
|
||||
| BloodPressureSystolic | 8480-6 | Systolic blood pressure |
|
||||
| BloodPressureDiastolic | 8462-4 | Diastolic blood pressure |
|
||||
| BloodGlucose | 2339-0 | Glucose in Blood |
|
||||
| Temperature | 8310-5 | Body temperature |
|
||||
| Weight | 29463-7 | Body weight |
|
||||
| BodyFat | 41982-0 | Body fat percentage |
|
||||
| Steps | 55423-8 | Number of steps in 24 hours |
|
||||
| RespiratoryRate | 9279-1 | Respiratory rate |
|
||||
|
||||
### 3.4 降采样策略
|
||||
|
||||
| 数据年龄 | 存储策略 | 查询来源 |
|
||||
|----------|---------|---------|
|
||||
| 0-24h | 原始数据 `device_readings` | 实时看板、详情页 |
|
||||
| 1-30 天 | 小时聚合 `device_reading_hourly` | 趋势图、日报 |
|
||||
| 30+ 天 | 日聚合 `device_reading_daily` | 月报、长期趋势 |
|
||||
| 保留原始数据 | 可配置(默认 90 天后归档到冷存储) | 审计、科研需要时恢复 |
|
||||
|
||||
**数据量估算**(1000 活跃患者,手环每 5 分钟采集 4 种指标):
|
||||
|
||||
| 指标 | 日数据量 | 月数据量 |
|
||||
|------|---------|---------|
|
||||
| 原始读数 | ~115 万条 | ~3,450 万条 |
|
||||
| 小时聚合 | ~2,880 条 | ~8.6 万条 |
|
||||
| 日聚合 | ~4,000 条 | ~12 万条 |
|
||||
|
||||
降采样后存储压力降低 **400 倍**,查询长期趋势从扫描千万行降到扫描几百行。
|
||||
|
||||
---
|
||||
|
||||
## 4. DeviceAdapter 与 DeviceGateway 接口设计
|
||||
|
||||
### 4.1 现有 BLE 适配器架构(已实现)
|
||||
|
||||
BLE 设备的适配器模式运行在**小程序端(TypeScript)**,已有完整实现:
|
||||
|
||||
```typescript
|
||||
// 已有接口 — services/ble/types.ts
|
||||
interface DeviceAdapter {
|
||||
name: string;
|
||||
supportedModels: string[];
|
||||
serviceUUIDs: string[];
|
||||
notifyCharacteristics: BLECharacteristic[];
|
||||
readCharacteristics: BLECharacteristic[];
|
||||
parseNotification(data: ArrayBuffer): NormalizedReading[];
|
||||
parseReadResponse(data: ArrayBuffer): NormalizedReading[];
|
||||
}
|
||||
|
||||
type DeviceType = 'heart_rate' | 'blood_oxygen' | 'steps' | 'sleep'
|
||||
| 'temperature' | 'stress' | 'blood_pressure' | 'blood_glucose';
|
||||
|
||||
interface NormalizedReading {
|
||||
device_type: DeviceType;
|
||||
values: Record<string, number | string>;
|
||||
metric?: string;
|
||||
measured_at: string;
|
||||
}
|
||||
```
|
||||
|
||||
**已实现的适配器:**
|
||||
- `XiaomiBandAdapter.ts` — 小米手环(心率/血氧/步数/睡眠)
|
||||
- `BloodPressureAdapter.ts` — 蓝牙血压计(8 个型号,含欧姆龙/AND/iHealth)
|
||||
- `GlucoseMeterAdapter.ts` — 蓝牙血糖仪
|
||||
|
||||
**已有的 BLEManager 功能:**
|
||||
- 适配器注册 + 按设备名自动匹配
|
||||
- BLE 扫描 + 连接管理
|
||||
- 数据同步 + 本地缓冲(最大 2000 条)
|
||||
- 可配置:扫描超时、每次同步最大条数、重试次数
|
||||
|
||||
### 4.2 BLE 适配器扩展(V1 增强)
|
||||
|
||||
V1 需要新增的适配器和功能:
|
||||
|
||||
| 新增内容 | 说明 |
|
||||
|----------|------|
|
||||
| `HuaweiBandAdapter.ts` | 华为手环(Band 7/8/9) |
|
||||
| `GenericBleAdapter.ts` | 通用 BLE 适配器(标准 Health Thermometer Profile) |
|
||||
| `DataSyncScheduler.ts` | 定时同步调度(每天至少 1 次自动同步) |
|
||||
| BLEManager 离线增强 | DataBuffer 持久化到 Storage,网络恢复后自动提交 |
|
||||
|
||||
### 4.3 现有 device_type 与 FHIR 映射
|
||||
|
||||
现有系统使用 `device_type` 字段(8 种),与 FHIR Observation 的 LOINC 编码映射:
|
||||
|
||||
| device_type(现有) | FHIR Observation code | LOINC |
|
||||
|---------------------|----------------------|-------|
|
||||
| heart_rate | Heart rate | 8867-4 |
|
||||
| blood_oxygen | Oxygen saturation | 2708-6 |
|
||||
| blood_pressure | Blood pressure panel | 85354-9(含收缩压 8480-6 + 舒张压 8462-4) |
|
||||
| blood_glucose | Glucose in Blood | 2339-0 |
|
||||
| temperature | Body temperature | 8310-5 |
|
||||
| steps | Steps | 55423-8 |
|
||||
| sleep | Sleep duration | 93832-4 |
|
||||
| stress | Stress level | 自定义(无标准 LOINC) |
|
||||
|
||||
**设计决策:保持现有 `device_type` 而非引入 `ReadingType`**
|
||||
|
||||
现有系统用一个 `device_type`(如 `blood_pressure`)+ JSONB `raw_value`(含收缩压/舒张压)的模式存储多值数据。这比拆分为多行(每行一个 reading_type + 标量 value)更高效。FHIR 转换层负责将 `blood_pressure` 的 JSONB 拆分为独立的 Systolic/Diastolic Observation。不在数据层做拆分。
|
||||
|
||||
### 4.4 同步流程(已实现,不需变更)
|
||||
|
||||
```
|
||||
1. 用户打开小程序 → BLEManager 检查已配对设备
|
||||
2. 扫描 → AdapterRegistry.matchAdapter() 匹配适配器
|
||||
3. 适配器连接设备 → parseNotification/parseReadResponse
|
||||
4. NormalizedReading[] → 本地缓冲(ble_pending_readings)
|
||||
5. 批量提交 → POST /api/v1/health/device/readings
|
||||
6. 后端 batch_create_readings() → 去重 → 存储 → 小时聚合 → 事件发布
|
||||
7. EventBus → alert_engine.evaluate_rules() → 告警触发 → SSE 推送
|
||||
8. 同步完成 → 更新 patient_devices.last_sync_at
|
||||
```
|
||||
|
||||
### 4.3 同步流程
|
||||
|
||||
```
|
||||
1. 用户打开小程序 → DataSyncScheduler 检查同步计划
|
||||
2. BleManager 扫描已配对设备 → AdapterRegistry 匹配适配器
|
||||
3. 适配器连接设备 → read_history(since_last_sync)
|
||||
4. 批量读取的 RawDeviceReading[] → DataBuffer 暂存
|
||||
5. 网络可用时批量提交 → POST /api/v1/health/device/readings
|
||||
6. 后端校验 → 存储 → 降采样触发 → 告警评估 → 事件发布
|
||||
7. 同步完成 → 更新 last_synced_at → 显示同步结果给用户
|
||||
```
|
||||
|
||||
### 4.5 DeviceGateway Trait(V2 扩展点,新增)
|
||||
|
||||
专业医疗设备的接入抽象,运行在**后端**(Rust):
|
||||
|
||||
```rust
|
||||
/// 网关接收的标准化医疗数据
|
||||
struct GatewayObservation {
|
||||
device_id: String,
|
||||
patient_id: Option<String>,
|
||||
observation_type: String, // HL7 OBX-3 或 DICOM tag
|
||||
value: serde_json::Value,
|
||||
unit: Option<String>,
|
||||
observed_at: DateTime<Utc>,
|
||||
performer: Option<String>,
|
||||
metadata: serde_json::Value,
|
||||
}
|
||||
|
||||
/// 设备网关 trait — 处理非 BLE 协议
|
||||
#[async_trait]
|
||||
trait DeviceGateway: Send + Sync {
|
||||
fn gateway_id(&self) -> &str;
|
||||
fn protocol(&self) -> GatewayProtocol;
|
||||
async fn start_listening(&self, config: GatewayConfig) -> Result<()>;
|
||||
async fn stop_listening(&self) -> Result<()>;
|
||||
async fn parse_message(&self, raw: &[u8]) -> Result<Vec<GatewayObservation>>;
|
||||
}
|
||||
|
||||
enum GatewayProtocol {
|
||||
Hl7V2,
|
||||
Hl7Fhir,
|
||||
Dicom,
|
||||
SerialRs232,
|
||||
TcpRaw,
|
||||
}
|
||||
```
|
||||
|
||||
### 4.6 网关插件化接入流程
|
||||
|
||||
```
|
||||
第三方设备厂商接入流程:
|
||||
|
||||
1. 实现 DeviceGateway trait(或使用通用 HL7/DICOM 网关)
|
||||
2. 打包为 WASM 插件(复用 erp-plugin 基础设施)
|
||||
3. 租户管理员在设备管理页面启用该网关插件
|
||||
4. 配置连接参数(TCP 端口 / serial 设备路径 / FHIR endpoint)
|
||||
5. 网关开始接收数据 → 自动解析 → 写入 device_readings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. FHIR API 输出层
|
||||
|
||||
### 5.1 API 路由设计
|
||||
|
||||
FHIR API 独立于 HMS 内部 API,使用独立的认证中间件和路由前缀。
|
||||
|
||||
> **注意:** FHIR 使用 `/fhir/R4/` 路由前缀而非 HMS 标准的 `/api/v1/`。这是 FHIR 合规性要求 — FHIR 标准规定服务器使用标准 URL 路径,合作方系统期望标准的 FHIR 端点。此豁免仅适用于 FHIR 路由组,不影响内部 API。
|
||||
|
||||
```
|
||||
/fhir/R4/ — FHIR R4 标准 endpoint
|
||||
├── /metadata — CapabilityStatement(服务器能力声明)
|
||||
├── /Patient — 患者资源 CRUD
|
||||
│ ├── GET / — 搜索(_id, name, identifier, birthdate)
|
||||
│ ├── GET /{id} — 读取单个
|
||||
│ └── GET /{id}/$everything — 患者全景数据
|
||||
├── /Observation — 观测数据(体征 + 设备读数)
|
||||
│ ├── GET / — 搜索(patient, category, code, date, device)
|
||||
│ ├── GET /{id} — 读取单个
|
||||
│ └── GET /$lastn — 患者最近 N 次观测
|
||||
├── /Device — 设备资源
|
||||
│ ├── GET / — 搜索
|
||||
│ ├── GET /{id} — 读取
|
||||
│ └── POST / — 注册新设备(V2a)
|
||||
├── /DiagnosticReport — 化验/诊断报告
|
||||
│ ├── GET / — 搜索
|
||||
│ └── GET /{id} — 读取
|
||||
├── /Encounter — 咨询/就诊记录
|
||||
│ ├── GET / — 搜索
|
||||
│ └── GET /{id} — 读取
|
||||
├── /Appointment — 预约
|
||||
│ ├── GET / — 搜索
|
||||
│ ├── POST / — 创建(V2a)
|
||||
│ └── PATCH /{id} — 更新(V2a)
|
||||
├── /Practitioner — 医护人员
|
||||
│ ├── GET / — 搜索
|
||||
│ └── GET /{id} — 读取
|
||||
└── /Task — 随访任务
|
||||
├── GET / — 搜索
|
||||
└── GET /{id} — 读取
|
||||
```
|
||||
|
||||
### 5.2 HMS → FHIR Observation 转换示例
|
||||
|
||||
以设备采集的心率数据为例:
|
||||
|
||||
```json
|
||||
// HMS device_readings 记录
|
||||
{
|
||||
"id": "01901abc-def0-7000-8000-000000000001",
|
||||
"tenant_id": "t001",
|
||||
"device_id": "dev-xiaomi-8",
|
||||
"patient_id": "p-12345",
|
||||
"reading_type": "heart_rate",
|
||||
"value": 78.0,
|
||||
"unit": "bpm",
|
||||
"measured_at": "2026-05-04T14:30:00Z",
|
||||
"quality_indicator": "raw",
|
||||
"source": "ble_sync"
|
||||
}
|
||||
|
||||
// ↓ 转换为 FHIR Observation ↓
|
||||
|
||||
{
|
||||
"resourceType": "Observation",
|
||||
"id": "01901abc-def0-7000-8000-000000000001",
|
||||
"status": "final",
|
||||
"category": [{
|
||||
"coding": [{
|
||||
"system": "http://terminology.hl7.org/CodeSystem/observation-category",
|
||||
"code": "vital-signs",
|
||||
"display": "Vital Signs"
|
||||
}]
|
||||
}],
|
||||
"code": {
|
||||
"coding": [{
|
||||
"system": "http://loinc.org",
|
||||
"code": "8867-4",
|
||||
"display": "Heart rate"
|
||||
}]
|
||||
},
|
||||
"subject": {
|
||||
"reference": "Patient/p-12345"
|
||||
},
|
||||
"device": {
|
||||
"reference": "Device/dev-xiaomi-8"
|
||||
},
|
||||
"effectiveDateTime": "2026-05-04T14:30:00Z",
|
||||
"valueQuantity": {
|
||||
"value": 78.0,
|
||||
"unit": "beats/minute",
|
||||
"system": "http://unitsofmeasure.org",
|
||||
"code": "/min"
|
||||
},
|
||||
"meta": {
|
||||
"source": "hms-device-ble",
|
||||
"lastUpdated": "2026-05-04T14:30:01Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 合作伙伴认证(OAuth2 Client Credentials)
|
||||
|
||||
```
|
||||
认证流程:
|
||||
|
||||
1. 合作方在 HMS 管理后台注册
|
||||
→ 获得 client_id + client_secret
|
||||
→ 配置允许的 FHIR 资源范围和患者数据范围
|
||||
|
||||
2. 合作方请求令牌
|
||||
POST /oauth/token
|
||||
Body: grant_type=client_credentials&client_id=xxx&client_secret=xxx
|
||||
&scope=fhir/Patient.read+Observation.read
|
||||
→ 返回 access_token (JWT, 1小时有效)
|
||||
|
||||
3. 合作方调用 FHIR API
|
||||
GET /fhir/R4/Observation?patient=p-12345&category=vital-signs&date=gt2026-05-01
|
||||
Header: Authorization: Bearer <access_token>
|
||||
|
||||
4. HMS 验证
|
||||
→ JWT 签名验证
|
||||
→ tenant_id 匹配(合作方只能访问所属租户数据)
|
||||
→ scope 验证(只允许已授权的资源操作)
|
||||
→ 患者 ID 范围验证(合作方只能访问授权的患者列表)
|
||||
```
|
||||
|
||||
### 5.4 数据隔离矩阵
|
||||
|
||||
| 维度 | 隔离方式 | 说明 |
|
||||
|------|---------|------|
|
||||
| 租户 | JWT `tenant_id` | 合作方只能访问所属租户数据 |
|
||||
| 资源类型 | OAuth scope | `Patient.read` / `Observation.read` 等 |
|
||||
| 患者范围 | 白名单/黑名单 | 合作方可访问的患者 ID 列表 |
|
||||
| 时间窗口 | 可选限制 | 数据只开放最近 N 天 |
|
||||
| 频率 | 限流 | 每合作方每分钟最大请求数 |
|
||||
|
||||
### 5.5 $everything 操作
|
||||
|
||||
患者全景数据一次请求获取,用于 HIS 系统拉取完整健康档案、体检中心出报告参考、转诊数据导出:
|
||||
|
||||
```
|
||||
GET /fhir/R4/Patient/{id}/$everything
|
||||
|
||||
返回 Bundle(包含):
|
||||
├── Patient 本身
|
||||
├── 所有 Observation(体征 + 设备读数,支持 _count 分页)
|
||||
├── 所有 DiagnosticReport(化验报告)
|
||||
├── 所有 Encounter(就诊记录)
|
||||
├── 所有 Appointment(预约)
|
||||
├── 所有 Task(随访任务)
|
||||
└── 关联的 Device 列表
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 告警引擎与实时监控
|
||||
|
||||
### 6.1 现有告警引擎(已实现)
|
||||
|
||||
`alert_engine.rs` 已实现完整的规则评估流程:
|
||||
|
||||
- 加载租户+设备类型的活跃规则
|
||||
- 批量查询近期告警实现 cooldown
|
||||
- 批量查询 `vital_signs_hourly` 近 168 小时数据
|
||||
- 3 种评估器:`single_threshold`(阈值)、`consecutive`(连续超标)、`trend`(趋势恶化)
|
||||
- 血压/血糖特殊处理(从 JSONB raw_value 提取收缩压/舒张压)
|
||||
- 告警创建 + `alert.triggered` 事件发布
|
||||
- 事件消费者发送站内通知
|
||||
|
||||
**本设计不做重复实现。** 以下 §6.2-6.5 仅描述**增强内容**。
|
||||
|
||||
### 6.2 告警降噪增强
|
||||
|
||||
现有引擎已有 cooldown 机制。V1 增加两级降噪:
|
||||
|
||||
| 层级 | 现有 | 增强 |
|
||||
|------|------|------|
|
||||
| 规则级 | cooldown_minutes 已实现 | 无需变更 |
|
||||
| 患者级 | 无 | 新增:同一患者连续低级别告警 → 自动升级为高级别 |
|
||||
| 系统级 | 无 | 新增:5 分钟内同一设备的多个告警合并为一条通知 |
|
||||
|
||||
```
|
||||
device_readings 写入
|
||||
│
|
||||
▼
|
||||
规则加载器(从 device_alert_rules 缓存)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 规则评估器 │
|
||||
│ │
|
||||
│ ┌─────────┐ ┌──────────┐ ┌─────────┐ │
|
||||
│ │单次阈值 │ │连续超标 │ │趋势恶化 │ │
|
||||
│ │评估器 │ │评估器 │ │评估器 │ │
|
||||
│ └────┬────┘ └─────┬────┘ └────┬────┘ │
|
||||
│ │ │ │ │
|
||||
│ └────────────┴───────────┘ │
|
||||
│ │ │
|
||||
│ 告警触发? │
|
||||
│ Yes ↓ │
|
||||
└──────────────────┼─────────────────────┘
|
||||
│
|
||||
▼
|
||||
生成 HealthAlert
|
||||
│
|
||||
EventBus 发布
|
||||
device.alert.triggered
|
||||
│
|
||||
┌──────────┼──────────┐
|
||||
▼ ▼ ▼
|
||||
SSE 推送 消息通知 行动收件箱
|
||||
(医生Web) (护士站) (待办聚合)
|
||||
```
|
||||
|
||||
### 6.2 规则类型
|
||||
|
||||
**类型 1:单次阈值(Threshold)**
|
||||
|
||||
```json
|
||||
{
|
||||
"rule_name": "心动过速预警",
|
||||
"reading_type": "heart_rate",
|
||||
"rule_type": "threshold",
|
||||
"condition": {
|
||||
"operator": "gt",
|
||||
"value": 120,
|
||||
"unit": "bpm"
|
||||
},
|
||||
"severity": "high",
|
||||
"cooldown_minutes": 30
|
||||
}
|
||||
```
|
||||
|
||||
**类型 2:连续超标(Consecutive)**
|
||||
|
||||
```json
|
||||
{
|
||||
"rule_name": "高血压持续预警",
|
||||
"reading_type": "blood_pressure_systolic",
|
||||
"rule_type": "consecutive",
|
||||
"condition": {
|
||||
"operator": "gt",
|
||||
"value": 140,
|
||||
"consecutive_count": 3,
|
||||
"within_minutes": 60
|
||||
},
|
||||
"severity": "critical",
|
||||
"cooldown_minutes": 120
|
||||
}
|
||||
```
|
||||
|
||||
**类型 3:趋势恶化(Trend)**
|
||||
|
||||
```json
|
||||
{
|
||||
"rule_name": "血糖恶化趋势",
|
||||
"reading_type": "blood_glucose",
|
||||
"rule_type": "trend",
|
||||
"condition": {
|
||||
"baseline_window_days": 7,
|
||||
"change_threshold_percent": 20,
|
||||
"direction": "up",
|
||||
"min_samples": 5
|
||||
},
|
||||
"severity": "medium",
|
||||
"cooldown_minutes": 1440
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 实时看板(医生 Web 端)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 实时体征监控台 [自动刷新] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 告警摘要栏 │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ 危急 2 │ │ 高危 5 │ │ 中等 12 │ │ 低危 3 │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ │
|
||||
│ 患者列表(按告警优先级排序) │
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ 🔴 张三 | 心率 135bpm ↑ | 血压 158/95 | 最近同步 2分钟前 │ │
|
||||
│ │ 🟠 李四 | 血糖 285mg/dL | 趋势: 7天+35% | 15分钟前 │ │
|
||||
│ │ 🟡 王五 | SpO2 93% ↓ | 心率 98bpm | 30分钟前 │ │
|
||||
│ │ 赵六 | 心率 72bpm 正常 | 步数 3,200 | 1小时前 │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 患者详情(点击展开) │
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ 张三 | 男 58岁 | 高血压+糖尿病 | 设备: 小米手环8 │ │
|
||||
│ │ │ │
|
||||
│ │ 心率趋势 (24h): 血压趋势 (7天): │ │
|
||||
│ │ ┌─────────────────────┐ ┌─────────────────────┐ │ │
|
||||
│ │ │ ╱╲ ╱╲ ╱╲╱╲ │ │ ╱╲ │ │ │
|
||||
│ │ │ ╱ ╲╱╱ ╲ ╱╱ ╲ │ │╱ ╲╱╱ ╱╲╱╱ ╱╲ │ │ │
|
||||
│ │ │╱ ╲╱ ╲│ │ ╲╱ ╲ │ │ │
|
||||
│ │ └─────────────────────┘ └─────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ 最近告警: │ │
|
||||
│ │ 14:32 心率 135bpm → 触发心动过速预警 (高危) │ │
|
||||
│ │ 14:15 收缩压 158mmHg → 连续第3次超标 (危急) │ │
|
||||
│ │ │ │
|
||||
│ │ [查看完整数据] [AI 分析] [发起咨询] [调整告警阈值] │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.4 SSE 推送(已实现,需增强)
|
||||
|
||||
现有 `sse_handler.rs` 已实现三种事件推送:
|
||||
|
||||
| SSE 事件 | 触发源 | 过滤方式 |
|
||||
|----------|--------|---------|
|
||||
| `message` | `message.sent` | 按收件人 user_id |
|
||||
| `alert` | `alert.triggered` | 按医患关系 |
|
||||
| `vital_update` | `device.readings.synced` | 按医患关系 |
|
||||
|
||||
V1 需增强的内容:
|
||||
|
||||
| 增强 | 说明 |
|
||||
|------|------|
|
||||
| `Last-Event-ID` 支持 | SSE 重连时从断点续传 |
|
||||
| 心跳机制 | 定期 ping 保持连接活跃 |
|
||||
| 订阅范围 | 医生可选择性订阅特定患者的更新 |
|
||||
|
||||
### 6.5 告警降噪机制
|
||||
|
||||
高频设备数据容易产生告警疲劳,三级降噪:
|
||||
|
||||
| 层级 | 机制 | 说明 |
|
||||
|------|------|------|
|
||||
| **规则级** | cooldown 窗口 | 同一患者同一规则在冷却期内不重复触发 |
|
||||
| **患者级** | 告警升级 | 同一患者连续低级别告警 → 自动升级为高级别 |
|
||||
| **系统级** | 告警聚合 | 5 分钟内同一设备的多个告警合并为一条通知 |
|
||||
|
||||
```
|
||||
示例:
|
||||
10:00 心率 > 100 → 中等告警(单独通知)
|
||||
10:05 心率 > 105 → 中等告警(冷却期内,不通知)
|
||||
10:10 心率 > 110 → 连续3次超标 → 升级为高危告警(通知)
|
||||
10:15 SpO2 < 95% → 中等告警 → 与心率告警聚合为 1 条通知
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 演进路线与风险分析
|
||||
|
||||
### 7.1 V1 详细周计划(12 周)
|
||||
|
||||
**W1-2:基础设施 + 数据层**
|
||||
- devices / device_readings / device_reading_hourly / device_reading_daily 表迁移
|
||||
- ReadingType 枚举 + LOINC 映射常量
|
||||
- 摄入 API handler(POST /api/v1/health/device/readings)
|
||||
- 降采样 background task(小时/日聚合)
|
||||
- EventBus 新增事件类型
|
||||
|
||||
**W3-4:小程序 BLE 采集层**
|
||||
- BleManager + AdapterRegistry
|
||||
- XiaomiBandAdapter(第一个适配器)
|
||||
- DataSyncScheduler + DataBuffer
|
||||
- 设备管理页面(绑定/解绑/同步状态)
|
||||
- E2E 测试:手环 → 小程序 → 后端 → 数据库
|
||||
|
||||
**W5-6:告警引擎**
|
||||
- device_alert_rules 表 + CRUD API
|
||||
- 三种规则评估器(threshold / consecutive / trend)
|
||||
- 告警降噪(cooldown + 升级 + 聚合)
|
||||
- SSE 推送扩展
|
||||
- 规则引擎单元测试 + 集成测试
|
||||
|
||||
**W7-8:告警看板 + 第二个适配器**
|
||||
- 实时体征监控台页面(Web)
|
||||
- OmronBpAdapter(血压计,验证适配器扩展性)
|
||||
- 患者体征趋势图(复用 ECharts)
|
||||
- 看板页面测试
|
||||
|
||||
**W9-10:FHIR 只读 API**
|
||||
- /fhir/R4/ 路由组 + 独立认证中间件
|
||||
- HMS → FHIR 转换层(Patient / Observation / Device)
|
||||
- 搜索参数支持(patient, category, code, date)
|
||||
- $everything 操作
|
||||
- FHIR 一致性测试
|
||||
|
||||
**W11-12:合作伙伴认证 + 集成测试**
|
||||
- OAuth2 Client Credentials 实现
|
||||
- 数据隔离矩阵(租户/资源/患者范围/频率)
|
||||
- API 限流 + 审计日志
|
||||
- 合作方管理页面(注册/密钥/权限配置)
|
||||
- 全链路集成测试 + 性能测试
|
||||
|
||||
### 7.2 V2 扩展路线
|
||||
|
||||
| 阶段 | 时间 | 内容 | 前置条件 |
|
||||
|------|------|------|---------|
|
||||
| **V2a** | 4-6 周 | 居家设备适配器 ×3 + FHIR 写入 API | V1 完成 |
|
||||
| **V2b** | 6-8 周 | DeviceGateway + HL7 适配 + Webhook 推送 | V2a 完成 |
|
||||
| **V2c** | 4-6 周 | 合作方自助接入 + 设备厂商插件 SDK | V2b 完成 |
|
||||
|
||||
### 7.3 与 Q2 路线图的协同
|
||||
|
||||
当前 Q2 路线图(W1-8)已排满技术债清理和 AI 前端补全。建议策略:
|
||||
|
||||
| 策略 | 说明 | 影响 |
|
||||
|------|------|------|
|
||||
| **顺延(推荐)** | Q2 专注技术债 + AI 前端,Q3(7-9 月)启动 IoT V1 | 不影响 Q2 计划,IoT 交付推迟到 Q3 末 |
|
||||
| **并行** | Q2 W5-8 开始 IoT 基础设施(表迁移 + 摄入 API),Q3 做前端和 FHIR | 前后端分离交付,风险可控 |
|
||||
| **替换** | 用 IoT V1 替换 Q2 的实时体征管线计划(两者本质相同) | 最紧凑,但需重新评估测试覆盖目标 |
|
||||
|
||||
### 7.4 风险矩阵
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解措施 |
|
||||
|------|:----:|:----:|---------|
|
||||
| **BLE 协议碎片化** — 不同厂商 BLE Service UUID 不同 | 高 | 中 | V1 只支持 2 款设备,AdapterRegistry 抽象层隔离差异 |
|
||||
| **FHIR 合规性** — 自实现 FHIR server 可能不完全符合规范 | 中 | 高 | V1 只做 read-only 6 个资源,后续评估引入 fhir-sdk crate |
|
||||
| **高频数据写入性能** — 日增 115 万条原始读数 | 中 | 高 | 降采样 + 分区索引 + 批量写入;PostgreSQL 单表可承受千万级 |
|
||||
| **小程序 BLE 兼容性** — 微信 BLE API 在不同手机行为不一致 | 高 | 中 | 真机测试矩阵;DataBuffer 离线兜底 |
|
||||
| **合作伙伴数据安全** — 医疗数据通过 API 暴露给第三方 | 低 | 极高 | 多层隔离(租户+scope+患者白名单+限流+审计日志) |
|
||||
| **告警疲劳** — 高频数据导致过多告警 | 高 | 中 | 三级降噪 + cooldown + 告警升级 + 可配置阈值 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 与现有模块的变更清单
|
||||
|
||||
### 8.1 erp-health 变更
|
||||
|
||||
| 变更 | 类型 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| device_readings Entity + 摄入服务 | 已有 | — | 完整的批量摄入+去重+校验 |
|
||||
| vital_signs_hourly 聚合 | 已有 | — | 小时聚合完整 |
|
||||
| alert_rules + alert_engine | 已有 | — | 3 种规则类型完整 |
|
||||
| EventBus 事件发布+消费 | 已有 | — | device.readings.synced + alert.triggered |
|
||||
| patient_devices 增强迁移 | 迁移 | 增强 | 增加 status/firmware_version/metadata 列 |
|
||||
| vital_signs_daily Entity + 迁移 | 迁移 | **新增** | 日聚合表 |
|
||||
| `fhir` 子模块 | 模块 | **新增** | FHIR R4 路由组 + HMS→FHIR 转换层 |
|
||||
| 日聚合 background task | 代码 | **新增** | 从小时数据聚合为日数据 |
|
||||
| 告警降噪增强 | 代码 | 增强 | 患者级升级 + 系统级聚合 |
|
||||
|
||||
### 8.2 erp-core 变更
|
||||
|
||||
| 变更 | 类型 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 事件类型 | 已有 | — | device.readings.synced + alert.triggered 已定义 |
|
||||
| 无新增事件 | — | — | 现有事件足够 |
|
||||
|
||||
### 8.3 erp-auth 变更
|
||||
|
||||
| 变更 | 类型 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| api_clients 表 + 迁移 | 迁移 | **新增** | 合作方 client_id/secret_hash/scope |
|
||||
| OAuth2 Client Credentials 流程 | 功能 | **新增** | 合作伙伴专用认证,与现有 JWT 中间件并行 |
|
||||
| FHIR 路由认证中间件 | 代码 | **新增** | 从 Bearer token 提取 api_client 上下文 |
|
||||
| client_secret 使用 Argon2 哈希 | 安全 | **新增** | 与现有用户密码哈希策略一致 |
|
||||
|
||||
### 8.4 erp-server 变更
|
||||
|
||||
| 变更 | 类型 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `/fhir/R4/*` 路由组 | 代码 | **新增** | 挂载 FHIR API,使用独立认证中间件 |
|
||||
| `/oauth/token` endpoint | 代码 | **新增** | OAuth2 token 签发 |
|
||||
| 日聚合 background task 注册 | 代码 | **新增** | 复用现有 tasks 框架 |
|
||||
|
||||
### 8.5 erp-message 变更
|
||||
|
||||
| 变更 | 类型 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| SSE handler | 已有 | — | 已推送 vital_update + alert 事件 |
|
||||
| Last-Event-ID 重连支持 | 增强 | 增强 | SSE 断线续传 |
|
||||
| 心跳机制 | 增强 | 增强 | 保持连接活跃 |
|
||||
|
||||
### 8.6 小程序变更
|
||||
|
||||
| 变更 | 类型 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| BLE 采集框架 + 3 个适配器 | 已有 | — | XiaomiBand/BloodPressure/GlucoseMeter |
|
||||
| HuaweiBandAdapter.ts | 适配器 | **新增** | 华为手环支持 |
|
||||
| GenericBleAdapter.ts | 适配器 | **新增** | 通用 BLE 设备 |
|
||||
| DataSyncScheduler.ts | 服务 | **新增** | 定时自动同步调度 |
|
||||
| DataBuffer 持久化增强 | 增强 | 增强 | 离线缓冲持久化到 Storage |
|
||||
| 设备管理页面 | 页面 | 增强 | 增加设备状态/同步历史展示 |
|
||||
|
||||
### 8.7 Web 前端变更
|
||||
|
||||
| 变更 | 类型 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 实时体征监控台页面 | 页面 | **新增** | 告警摘要 + 患者列表 + 趋势图 |
|
||||
| 设备管理页面 | 页面 | **新增** | 设备列表/状态/同步历史 |
|
||||
| 告警规则配置页面 | 页面 | **新增** | 规则 CRUD + 启用/禁用 |
|
||||
| 合作方管理页面(管理后台) | 页面 | **新增** | 注册/密钥/权限/审计 |
|
||||
| SSE 事件监听增强 | 代码 | 增强 | vital_update 事件已在监听,增加 Last-Event-ID |
|
||||
Reference in New Issue
Block a user