fix(health): 审计问题修复 — 权限守卫 + OAuth中间件 + FHIR声明 + SSE聚合
- OAuthClientList/RealtimeMonitor/OfflineEventList/StatisticsDashboard 补权限守卫 - OAuth 中间件注入 TenantContext + FHIR scope→permission 映射 - FHIR CapabilityStatement 移除未实现的 $lastn 操作 - useVitalSSE 修复批量同步事件数据聚合逻辑
This commit is contained in:
@@ -41,15 +41,17 @@ export function useVitalSSE(options: UseVitalSSEOptions = {}): UseVitalSSEReturn
|
||||
|
||||
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(),
|
||||
});
|
||||
}
|
||||
const key = `${data.patient_id}_${data.device_model ?? 'unknown'}`;
|
||||
const existing = next.get(key);
|
||||
next.set(key, {
|
||||
patient_id: data.patient_id,
|
||||
device_type: data.device_model ?? 'unknown',
|
||||
// 批量同步事件不含单值,用累计 count 表示活跃度
|
||||
latest_value: data.count > 0
|
||||
? (existing?.latest_value ?? 0) + data.count
|
||||
: existing?.latest_value,
|
||||
updated_at: data.occurred_at ?? new Date().toISOString(),
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setLastUpdate(data);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Button, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography } from 'antd';
|
||||
import { Button, Form, Input, InputNumber, message, Modal, Popconfirm, Result, Select, Space, Switch, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
FHIR_SCOPE_OPTIONS,
|
||||
} from '../../api/health/oauthClients';
|
||||
import { PageContainer } from '../../components/PageContainer';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
|
||||
export default function OAuthClientList() {
|
||||
const { hasPermission } = usePermission('health.oauth.manage');
|
||||
const [data, setData] = useState<OAuthClient[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -162,6 +164,10 @@ export default function OAuthClientList() {
|
||||
},
|
||||
];
|
||||
|
||||
if (!hasPermission) {
|
||||
return <Result status="403" title="权限不足" subTitle="您没有管理 FHIR API 合作方的权限" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="FHIR API 合作方管理"
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
type CreateOfflineEventReq,
|
||||
} from '../../api/health/points';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { Result } from 'antd';
|
||||
|
||||
/** 活动状态映射 */
|
||||
const STATUS_MAP: Record<string, { text: string; color: string }> = {
|
||||
@@ -48,6 +50,7 @@ const STATUS_OPTIONS = Object.entries(STATUS_MAP).map(([value, { text }]) => ({
|
||||
}));
|
||||
|
||||
export default function OfflineEventList() {
|
||||
const { hasPermission } = usePermission('health.points.list');
|
||||
const [data, setData] = useState<OfflineEvent[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -58,6 +61,10 @@ export default function OfflineEventList() {
|
||||
const [editing, setEditing] = useState<OfflineEvent | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
if (!hasPermission) {
|
||||
return <Result status="403" title="权限不足" subTitle="您没有访问线下活动管理的权限" />;
|
||||
}
|
||||
|
||||
// ---- 数据获取 ----
|
||||
const fetchData = useCallback(async (p = page, ps = pageSize) => {
|
||||
setLoading(true);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Card, Row, Col, Statistic, Tag, List, Select, Badge, Typography, Space, Empty } from 'antd';
|
||||
import { Card, Row, Col, Statistic, Tag, List, Select, Badge, Typography, Space, Empty, Result } from 'antd';
|
||||
import { AlertOutlined } from '@ant-design/icons';
|
||||
import { useVitalSSE } from '../../hooks/useVitalSSE';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { alertApi, type Alert } from '../../api/health/alerts';
|
||||
import { PageContainer } from '../../components/PageContainer';
|
||||
import { SEVERITY_COLOR, SEVERITY_LABEL, VITAL_CARD_METRICS } from '../../constants/health';
|
||||
@@ -20,6 +21,7 @@ interface PatientAlertSummary {
|
||||
* SSE 实时接收体征更新,按告警严重度排序患者列表。
|
||||
*/
|
||||
export default function RealtimeMonitor() {
|
||||
const { hasPermission } = usePermission('health.alerts.list');
|
||||
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedPatientId, setSelectedPatientId] = useState<string | null>(null);
|
||||
@@ -68,6 +70,10 @@ export default function RealtimeMonitor() {
|
||||
const totalMedium = alertSummary.reduce((s, a) => s + a.medium, 0);
|
||||
const totalLow = alertSummary.reduce((s, a) => s + a.low, 0);
|
||||
|
||||
if (!hasPermission) {
|
||||
return <Result status="403" title="权限不足" subTitle="您没有访问实时体征监控的权限" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="实时体征监控"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useDashboardRole } from '../../hooks/useDashboardRole';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { Result } from 'antd';
|
||||
import { DoctorDashboard } from './StatisticsDashboard/DoctorDashboard';
|
||||
import { NurseDashboard } from './StatisticsDashboard/NurseDashboard';
|
||||
import { AdminDashboard } from './StatisticsDashboard/AdminDashboard';
|
||||
@@ -13,7 +15,13 @@ const DASHBOARD_MAP: Record<string, React.FC> = {
|
||||
};
|
||||
|
||||
export default function StatisticsDashboard() {
|
||||
const { hasPermission } = usePermission('health.dashboard.manage');
|
||||
const role = useDashboardRole();
|
||||
|
||||
if (!hasPermission) {
|
||||
return <Result status="403" title="权限不足" subTitle="您没有访问统计面板的权限" />;
|
||||
}
|
||||
|
||||
const DashboardComponent = DASHBOARD_MAP[role];
|
||||
return <DashboardComponent />;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ where
|
||||
"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": "Observation", "interaction": [{"code": "read"}, {"code": "search-type"}] },
|
||||
{ "type": "Device", "interaction": [{"code": "read"}, {"code": "search-type"}] },
|
||||
{ "type": "DiagnosticReport", "interaction": [{"code": "read"}, {"code": "search-type"}] },
|
||||
{ "type": "Encounter", "interaction": [{"code": "read"}, {"code": "search-type"}] },
|
||||
@@ -40,8 +40,7 @@ where
|
||||
{ "type": "Task", "interaction": [{"code": "read"}, {"code": "search-type"}] },
|
||||
],
|
||||
"operation": [
|
||||
{ "name": "everything", "definition": "/fhir/R4/Patient/{id}/$everything" },
|
||||
{ "name": "lastn", "definition": "/fhir/R4/Observation/$lastn" },
|
||||
{ "name": "everything", "definition": "/fhir/R4/Patient/{id}/$everything" }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ use axum::{
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Client Credentials JWT Claims
|
||||
@@ -107,12 +108,48 @@ pub async fn oauth_auth_middleware(request: Request, next: Next) -> Response {
|
||||
let fhir_ctx = FhirAuthContext {
|
||||
client_id: claims.sub,
|
||||
tenant_id: claims.tid,
|
||||
scopes: claims.scopes,
|
||||
scopes: claims.scopes.clone(),
|
||||
allowed_patient_ids: claims.allowed_patient_ids,
|
||||
};
|
||||
|
||||
// 同时注入 TenantContext,兼容 require_permission() 和 ctx.tenant_id
|
||||
// 将 FHIR scope 映射为内部 permission code
|
||||
let permissions: Vec<String> = claims
|
||||
.scopes
|
||||
.iter()
|
||||
.flat_map(|s| scope_to_permissions(s))
|
||||
.collect();
|
||||
|
||||
let tenant_ctx = erp_core::types::TenantContext {
|
||||
tenant_id: claims.tid,
|
||||
user_id: claims.sub,
|
||||
roles: vec!["api_client".to_string()],
|
||||
permissions,
|
||||
department_ids: vec![],
|
||||
permission_data_scopes: HashMap::new(),
|
||||
};
|
||||
|
||||
let mut request = request;
|
||||
request.extensions_mut().insert(tenant_ctx);
|
||||
request.extensions_mut().insert(fhir_ctx);
|
||||
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
/// FHIR scope → 内部 permission code 映射
|
||||
fn scope_to_permissions(scope: &str) -> Vec<String> {
|
||||
match scope {
|
||||
"Patient.read" => vec!["health.patient.list".to_string()],
|
||||
"Observation.read" => vec![
|
||||
"health.device-readings.list".to_string(),
|
||||
"health.health-data.list".to_string(),
|
||||
],
|
||||
"Device.read" => vec!["health.devices.list".to_string()],
|
||||
"Practitioner.read" => vec!["health.doctor.list".to_string()],
|
||||
"Appointment.read" => vec!["health.appointment.list".to_string()],
|
||||
"DiagnosticReport.read" => vec!["health.health-data.list".to_string()],
|
||||
"Encounter.read" => vec!["health.consultation.list".to_string()],
|
||||
"Task.read" => vec!["health.follow-up.list".to_string()],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user