Compare commits

..

5 Commits

Author SHA1 Message Date
iven
e76f4feb4f feat(health): 告警微信模板消息通知 + alert.triggered 事件消费者
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-04-28 19:43:57 +08:00
iven
601b2d7f52 feat(mp): 首页设备状态卡片组件 — 血压计/血糖仪快捷入口 2026-04-28 19:42:24 +08:00
iven
00f615d8e5 feat(health): 新增血压/血糖临床阈值告警规则 + alert engine 直接查 device_readings 2026-04-28 19:40:25 +08:00
iven
8a61ae3f8e feat(health): device_readings 双写 vital_signs — 血压/血糖自动归档 2026-04-28 19:37:43 +08:00
iven
d715647a73 feat(mp): BloodPressureAdapter + GlucoseMeterAdapter — BLE 0x1810/0x1808 标准协议适配器 2026-04-28 19:30:03 +08:00
12 changed files with 612 additions and 12 deletions

View File

@@ -0,0 +1,51 @@
@import '../../styles/variables.scss';
.device-card {
display: flex;
align-items: center;
padding: 24rpx;
background: $card;
border-radius: $r;
margin-bottom: 16rpx;
box-shadow: $shadow-sm;
.device-icon {
font-size: 48rpx;
margin-right: 20rpx;
}
.device-info {
flex: 1;
.device-name {
font-size: 28rpx;
font-weight: 600;
color: $tx;
display: block;
}
.device-status {
font-size: 24rpx;
margin-top: 4rpx;
display: block;
&.connected { color: $pri; }
&.idle { color: $tx3; }
}
.last-sync {
font-size: 22rpx;
color: $tx3;
margin-top: 4rpx;
display: block;
}
}
.sync-btn {
padding: 12rpx 28rpx;
background: $pri;
color: #fff;
border-radius: $r-pill;
font-size: 24rpx;
}
}

View File

@@ -0,0 +1,39 @@
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import './index.scss';
interface DeviceCardProps {
deviceName: string;
deviceType: string;
lastSyncAt?: string;
status: 'connected' | 'disconnected' | 'never';
}
const DEVICE_ICONS: Record<string, string> = {
blood_pressure: '\u{1FA7A}',
blood_glucose: '\u{1FA78}',
heart_rate: '\u{2764}',
blood_oxygen: '\u{1FAB1}',
};
export default function DeviceCard({ deviceName, deviceType, lastSyncAt, status }: DeviceCardProps) {
const icon = DEVICE_ICONS[deviceType] || '\u{1F4F1}';
const statusLabel = status === 'connected' ? '已连接' : status === 'disconnected' ? '未连接' : '未配对';
const statusClass = status === 'connected' ? 'connected' : 'idle';
const handleSync = () => {
Taro.navigateTo({ url: '/pages/device-sync/index' });
};
return (
<View className='device-card' onClick={handleSync}>
<View className='device-icon'>{icon}</View>
<View className='device-info'>
<Text className='device-name'>{deviceName}</Text>
<Text className={`device-status ${statusClass}`}>{statusLabel}</Text>
{lastSyncAt && <Text className='last-sync'>: {lastSyncAt}</Text>}
</View>
<View className='sync-btn'></View>
</View>
);
}

View File

@@ -3,6 +3,8 @@ import { View, Text } from '@tarojs/components';
import { useDidShow } from '@tarojs/taro';
import { BLEManager } from '@/services/ble/BLEManager';
import { XiaomiBandAdapter } from '@/services/ble/adapters/XiaomiBandAdapter';
import { BloodPressureAdapter } from '@/services/ble/adapters/BloodPressureAdapter';
import { GlucoseMeterAdapter } from '@/services/ble/adapters/GlucoseMeterAdapter';
import { uploadReadings } from '@/services/device-sync';
import { useAuthStore } from '@/stores/auth';
import type { BLEDevice, NormalizedReading } from '@/services/ble/types';
@@ -10,6 +12,8 @@ import './index.scss';
const bleManager = new BLEManager({ scanTimeout: 10000, retryCount: 3 });
bleManager.registerAdapter(XiaomiBandAdapter);
bleManager.registerAdapter(BloodPressureAdapter);
bleManager.registerAdapter(GlucoseMeterAdapter);
type PageState = 'idle' | 'scanning' | 'connecting' | 'connected' | 'syncing' | 'done' | 'error';
@@ -41,7 +45,7 @@ export default function DeviceSync() {
const found = await bleManager.scanDevices();
setDevices(found);
if (found.length === 0) {
setErrorMsg('未发现支持的设备,请确认手环已开启蓝牙并靠近手机');
setErrorMsg('未发现支持的设备,请确认设备已开启蓝牙并靠近手机');
}
setPageState('idle');
} catch (e: any) {
@@ -106,7 +110,7 @@ export default function DeviceSync() {
<View className="sync-hero">
<Text className="sync-hero-icon">D</Text>
<Text className="sync-hero-title"></Text>
<Text className="sync-hero-desc"></Text>
<Text className="sync-hero-desc"></Text>
</View>
<View className="sync-action" onClick={handleScan}>
@@ -147,12 +151,17 @@ export default function DeviceSync() {
{liveReadings.slice(-5).reverse().map((r, i) => (
<View key={i} className="sync-reading-item">
<Text className="sync-reading-type">
{r.device_type === 'heart_rate' ? '心率' : r.device_type}
{r.device_type === 'heart_rate' ? '心率'
: r.device_type === 'blood_pressure' ? `血压(${r.metric === 'systolic' ? '收缩压' : r.metric === 'diastolic' ? '舒张压' : 'MAP'})`
: r.device_type === 'blood_glucose' ? '血糖'
: r.device_type}
</Text>
<Text className="sync-reading-value">
{r.device_type === 'heart_rate'
? `${r.values.heart_rate} bpm`
: JSON.stringify(r.values)}
: r.metric
? `${r.values.value} ${r.values.unit}`
: JSON.stringify(r.values)}
</Text>
</View>
))}

View File

@@ -3,7 +3,7 @@ import { useState } from 'react';
import Taro, { useDidShow } from '@tarojs/taro';
import { useAuthStore } from '../../stores/auth';
import { useHealthStore } from '../../stores/health';
import EmptyState from '../../components/EmptyState';
import DeviceCard from '../../components/DeviceCard';
import Loading from '../../components/Loading';
import { trackPageView } from '@/services/analytics';
import * as appointmentApi from '@/services/appointment';
@@ -13,8 +13,8 @@ import './index.scss';
const QUICK_SERVICES = [
{ label: '预约挂号', char: '约', path: '/pages/appointment/create/index' },
{ label: '健康录入', char: '录', path: '/pages/health/input/index' },
{ label: '健康趋势', char: '势', path: '/pages/health/trend/index' },
{ label: '健康录入', char: '录', path: '/pages/pkg-health/input/index' },
{ label: '健康趋势', char: '势', path: '/pages/pkg-health/trend/index' },
{ label: '资讯文章', char: '文', path: '/pages/article/index' },
{ label: 'AI 报告', char: 'AI', path: '/pages/ai-report/list/index' },
];
@@ -123,6 +123,20 @@ export default function Index() {
<Text className='greeting-date'>{new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'short' })}</Text>
</View>
{/* 设备快捷入口 */}
<View className='device-section'>
<DeviceCard
deviceName='血压计'
deviceType='blood_pressure'
status='never'
/>
<DeviceCard
deviceName='血糖仪'
deviceType='blood_glucose'
status='never'
/>
</View>
{/* 今日健康 */}
<View className='health-section'>
<Text className='section-title'></Text>
@@ -132,7 +146,7 @@ export default function Index() {
<View className='health-empty'>
<Text className='health-empty-text'></Text>
<View className='health-empty-action'>
<View className='health-empty-btn' onClick={() => Taro.navigateTo({ url: '/pages/health/input/index' })}>
<View className='health-empty-btn' onClick={() => Taro.navigateTo({ url: '/pages/pkg-health/input/index' })}>
<Text className='health-empty-btn-text'></Text>
</View>
</View>
@@ -142,7 +156,7 @@ export default function Index() {
{healthItems.map((item) => {
const tag = getStatusTag(item.status);
return (
<View className='health-cell' key={item.label} onClick={() => Taro.navigateTo({ url: `/pages/health/trend/index?indicator=${item.label === '血压' ? 'blood_pressure_systolic' : item.label === '心率' ? 'heart_rate' : item.label === '血糖' ? 'blood_sugar_fasting' : 'weight'}` })}>
<View className='health-cell' key={item.label} onClick={() => Taro.navigateTo({ url: `/pages/pkg-health/trend/index?indicator=${item.label === '血压' ? 'blood_pressure_systolic' : item.label === '心率' ? 'heart_rate' : item.label === '血糖' ? 'blood_sugar_fasting' : 'weight'}` })}>
<Text className='health-cell-label'>{item.label}</Text>
<Text className='health-cell-value'>{item.value}</Text>
<View className='health-cell-bottom'>

View File

@@ -0,0 +1,126 @@
import type { DeviceAdapter, NormalizedReading } from '../types';
// Bluetooth SIG Blood Pressure Service
const BPS_SERVICE = '00001810-0000-1000-8000-00805f9b34fb';
const BPM_CHARACTERISTIC = '00002A35-0000-1000-8000-00805f9b34fb';
interface BPMData {
systolic: number;
diastolic: number;
map: number;
unit: string;
pulseRate?: number;
}
// SFLOAT (IEEE 11073 16-bit float): mantissa bits 0-11, exponent bits 12-15
function readSfloat(view: DataView, byteOffset: number): number {
const raw = view.getUint16(byteOffset, true);
const mantissa = raw & 0x0FFF;
const exponent = (raw >> 12) & 0x0F;
const signedMantissa = mantissa > 0x07FF ? mantissa - 0x1000 : mantissa;
const signedExponent = exponent > 0x07 ? exponent - 0x10 : exponent;
return signedMantissa * Math.pow(10, signedExponent);
}
function parseBloodPressureMeasurement(data: ArrayBuffer): BPMData | null {
const view = new DataView(data);
if (view.byteLength < 7) return null;
let offset = 0;
const flags = view.getUint8(offset);
offset += 1;
const systolic = readSfloat(view, offset);
offset += 2;
const diastolic = readSfloat(view, offset);
offset += 2;
const map = readSfloat(view, offset);
offset += 2;
let pulseRate: number | undefined;
if (flags & 0x01) offset += 7; // timestamp
if (flags & 0x02) {
pulseRate = readSfloat(view, offset);
}
return {
systolic: Math.round(systolic * 10) / 10,
diastolic: Math.round(diastolic * 10) / 10,
map: Math.round(map * 10) / 10,
unit: 'mmHg',
pulseRate,
};
}
export const BloodPressureAdapter: DeviceAdapter = {
name: 'Blood Pressure Monitor',
supportedModels: [
'AND UA-651BLE',
'Omron HEM-7322',
'Omron BLE',
'BP Monitor',
'A&D BLE',
'iHealth BP',
'Beurer BM',
'Yuwell BLE',
],
serviceUUIDs: [BPS_SERVICE],
notifyCharacteristics: [
{ service: BPS_SERVICE, characteristic: BPM_CHARACTERISTIC },
],
readCharacteristics: [
{ service: BPS_SERVICE, characteristic: BPM_CHARACTERISTIC },
],
parseNotification(
_serviceUUID: string,
charUUID: string,
data: ArrayBuffer,
): NormalizedReading[] {
if (charUUID.toUpperCase() !== BPM_CHARACTERISTIC.toUpperCase()) return [];
const parsed = parseBloodPressureMeasurement(data);
if (!parsed) return [];
const measuredAt = new Date().toISOString();
const readings: NormalizedReading[] = [
{
device_type: 'blood_pressure',
metric: 'systolic',
values: { value: parsed.systolic, unit: parsed.unit },
measured_at: measuredAt,
},
{
device_type: 'blood_pressure',
metric: 'diastolic',
values: { value: parsed.diastolic, unit: parsed.unit },
measured_at: measuredAt,
},
{
device_type: 'blood_pressure',
metric: 'map',
values: { value: parsed.map, unit: parsed.unit },
measured_at: measuredAt,
},
];
if (parsed.pulseRate != null) {
readings.push({
device_type: 'heart_rate',
metric: 'pulse_rate',
values: { value: parsed.pulseRate, unit: 'bpm' },
measured_at: measuredAt,
});
}
return readings;
},
parseReadResponse(
serviceUUID: string,
charUUID: string,
data: ArrayBuffer,
): NormalizedReading[] {
return this.parseNotification(serviceUUID, charUUID, data);
},
};

View File

@@ -0,0 +1,140 @@
import type { DeviceAdapter, NormalizedReading } from '../types';
// Bluetooth SIG Glucose Service
const GLS_SERVICE = '00001808-0000-1000-8000-00805f9b34fb';
const GLM_CHARACTERISTIC = '00002A18-0000-1000-8000-00805f9b34fb';
type GlucoseType =
| 'capillary_whole'
| 'capillary_plasma'
| 'venous_whole'
| 'venous_plasma'
| 'arterial_whole'
| 'arterial_plasma'
| 'undetermined'
| 'control';
const GLUCOSE_TYPE_LABELS: Record<number, GlucoseType> = {
1: 'capillary_whole',
2: 'capillary_plasma',
3: 'venous_whole',
4: 'venous_plasma',
5: 'arterial_whole',
6: 'arterial_plasma',
7: 'undetermined',
8: 'control',
};
// SFLOAT (IEEE 11073 16-bit float)
function readSfloat(view: DataView, byteOffset: number): number {
const raw = view.getUint16(byteOffset, true);
const mantissa = raw & 0x0FFF;
const exponent = (raw >> 12) & 0x0F;
const signedMantissa = mantissa > 0x07FF ? mantissa - 0x1000 : mantissa;
const signedExponent = exponent > 0x07 ? exponent - 0x10 : exponent;
return signedMantissa * Math.pow(10, signedExponent);
}
interface GlucoseMeasurement {
concentration: number;
unit: string;
type: GlucoseType;
timestamp: Date;
sequenceNumber: number;
}
function parseGlucoseMeasurement(data: ArrayBuffer): GlucoseMeasurement | null {
const view = new DataView(data);
if (view.byteLength < 3) return null;
let offset = 0;
const flags = view.getUint8(offset);
offset += 1;
const sequenceNumber = view.getUint16(offset, true);
offset += 2;
const year = view.getUint16(offset, true);
offset += 2;
const month = view.getUint8(offset);
offset += 1;
const day = view.getUint8(offset);
offset += 1;
const hours = view.getUint8(offset);
offset += 1;
const minutes = view.getUint8(offset);
offset += 1;
const seconds = view.getUint8(offset);
offset += 1;
const timestamp = new Date(year, month - 1, day, hours, minutes, seconds);
if (flags & 0x01) offset += 2; // time offset
const concentration = readSfloat(view, offset);
offset += 2;
const typeAndLocation = view.getUint8(offset);
const glucoseType: GlucoseType =
GLUCOSE_TYPE_LABELS[(typeAndLocation >> 4) & 0x0F] || 'undetermined';
// kg/L → mmol/L (分子量 ~180.16 g/mol, 1 kg/L = 5546 mmol/L)
const concentrationMmol = Math.round(concentration * 5546 * 10) / 10;
return {
concentration: concentrationMmol,
unit: 'mmol/L',
type: glucoseType,
timestamp,
sequenceNumber,
};
}
export const GlucoseMeterAdapter: DeviceAdapter = {
name: 'Glucose Meter',
supportedModels: [
'Accu-Chek',
'Contour',
'OneTouch',
'FreeStyle Libre',
'Glucose Meter',
'iHealth BG',
'Yuwell GLU',
'Bionime',
'Sinocare',
],
serviceUUIDs: [GLS_SERVICE],
notifyCharacteristics: [
{ service: GLS_SERVICE, characteristic: GLM_CHARACTERISTIC },
],
readCharacteristics: [
{ service: GLS_SERVICE, characteristic: GLM_CHARACTERISTIC },
],
parseNotification(
_serviceUUID: string,
charUUID: string,
data: ArrayBuffer,
): NormalizedReading[] {
if (charUUID.toUpperCase() !== GLM_CHARACTERISTIC.toUpperCase()) return [];
const parsed = parseGlucoseMeasurement(data);
if (!parsed) return [];
return [
{
device_type: 'blood_glucose',
metric: parsed.type,
values: { value: parsed.concentration, unit: parsed.unit },
measured_at: parsed.timestamp.toISOString(),
},
];
},
parseReadResponse(
serviceUUID: string,
charUUID: string,
data: ArrayBuffer,
): NormalizedReading[] {
return this.parseNotification(serviceUUID, charUUID, data);
},
};

View File

@@ -1 +1,3 @@
export { XiaomiBandAdapter } from './XiaomiBandAdapter';
export { BloodPressureAdapter } from './BloodPressureAdapter';
export { GlucoseMeterAdapter } from './GlucoseMeterAdapter';

View File

@@ -5,6 +5,8 @@ export const TEMPLATE_IDS = {
APPOINTMENT_REMINDER: '',
FOLLOWUP_REMINDER: '',
REPORT_NOTIFICATION: '',
CRITICAL_HEALTH_ALERT: '',
HEALTH_DATA_ABNORMAL: '',
} as const;
/** 检查模板 ID 是否已配置,未配置时返回 false 并打印警告 */

View File

@@ -137,7 +137,7 @@ pub fn register_handlers_with_state(state: crate::state::HealthState) {
.and_then(|s| Uuid::parse_str(s).ok());
if let Some(pid) = patient_id {
// 对所有设备类型触发评估
for device_type in &["heart_rate", "blood_oxygen", "temperature"] {
for device_type in &["heart_rate", "blood_oxygen", "temperature", "blood_pressure", "blood_glucose"] {
if let Err(e) = crate::service::alert_engine::evaluate_rules(
&eval_state, event.tenant_id, pid, device_type,
).await {
@@ -159,6 +159,46 @@ pub fn register_handlers_with_state(state: crate::state::HealthState) {
// ── P1 事件消费者补全 ──
// alert.triggered → 告警消息通知
let (mut alert_rx, _alert_handle) = state.event_bus.subscribe_filtered("alert.".to_string());
let alert_db = state.db.clone();
let alert_bus = state.event_bus.clone();
tokio::spawn(async move {
loop {
match alert_rx.recv().await {
Some(event) if event.event_type == ALERT_TRIGGERED => {
if erp_core::events::is_event_processed(&alert_db, event.id, "alert_notifier").await.unwrap_or(false) {
continue;
}
let patient_id = event.payload.get("patient_id").and_then(|v| v.as_str());
let severity = event.payload.get("severity").and_then(|v| v.as_str()).unwrap_or("warning");
let rule_name = event.payload.get("rule_name").and_then(|v| v.as_str()).unwrap_or("健康告警");
if let Some(pid) = patient_id {
let notify_event = erp_core::events::DomainEvent::new(
"message.send",
event.tenant_id,
erp_core::events::build_event_payload(serde_json::json!({
"channel": "in_app",
"recipient_type": "patient",
"recipient_id": pid,
"template_key": if severity == "critical" { "CRITICAL_HEALTH_ALERT" } else { "HEALTH_DATA_ABNORMAL" },
"params": {
"rule_name": rule_name,
"severity": severity,
}
})),
);
alert_bus.publish(notify_event, &alert_db).await;
tracing::info!(patient_id = %pid, severity = %severity, "告警通知已发送");
}
let _ = erp_core::events::mark_event_processed(&alert_db, event.id, "alert_notifier").await;
}
Some(_) => {}
None => break,
}
}
});
// patient.created → 欢迎消息通知
let (mut patient_rx, _patient_handle) = state.event_bus.subscribe_filtered("patient.".to_string());
let patient_db = state.db.clone();

View File

@@ -5,7 +5,7 @@ use serde_json::json;
use std::collections::HashSet;
use uuid::Uuid;
use crate::entity::{alert_rules, alerts, vital_signs_hourly};
use crate::entity::{alert_rules, alerts, device_readings, vital_signs_hourly};
use crate::error::{HealthError, HealthResult};
use crate::state::HealthState;
@@ -62,6 +62,9 @@ pub async fn evaluate_rules(
let condition_type = rule.condition_type.as_str();
let is_triggered = match condition_type {
"single_threshold" if matches!(device_type, "blood_pressure" | "blood_glucose") => {
evaluate_bp_glucose_threshold(&state.db, tenant_id, patient_id, device_type, params).await
}
"single_threshold" => evaluate_single_threshold_in_memory(&hourly_records, params),
"consecutive" => evaluate_consecutive_in_memory(&hourly_records, params),
"trend" => evaluate_trend_in_memory(&hourly_records, params),
@@ -213,3 +216,38 @@ async fn create_alert_and_notify(
Ok(alert)
}
/// 血压/血糖告警:直接查 device_readings 最新值,支持 metric 过滤
async fn evaluate_bp_glucose_threshold(
db: &DatabaseConnection,
tenant_id: Uuid,
patient_id: Uuid,
device_type: &str,
params: &serde_json::Value,
) -> bool {
let direction = params["direction"].as_str().unwrap_or("above");
let threshold = params["value"].as_f64().unwrap_or(f64::MAX);
let metric = params["metric"].as_str();
let mut query = device_readings::Entity::find()
.filter(device_readings::Column::TenantId.eq(tenant_id))
.filter(device_readings::Column::PatientId.eq(patient_id))
.filter(device_readings::Column::DeviceType.eq(device_type))
.filter(device_readings::Column::DeletedAt.is_null())
.order_by_desc(device_readings::Column::MeasuredAt);
if let Some(m) = metric {
query = query.filter(device_readings::Column::Metric.eq(m));
}
let latest = query.one(db).await.ok().flatten();
let Some(record) = latest else { return false };
let val = record.raw_value.get("value").and_then(|v| v.as_f64()).unwrap_or(f64::MAX);
match direction {
"above" => val > threshold,
"below" => val < threshold,
_ => false,
}
}

View File

@@ -8,7 +8,7 @@ use uuid::Uuid;
use erp_core::events::DomainEvent;
use erp_core::types::PaginatedResponse;
use crate::entity::{device_readings, patient, patient_devices, vital_signs_hourly};
use crate::entity::{device_readings, patient, patient_devices, vital_signs, vital_signs_hourly};
use crate::error::{HealthError, HealthResult};
use crate::service::validation::validate_device_type;
use crate::state::HealthState;
@@ -118,6 +118,13 @@ pub async fn batch_create_readings(
&parsed_readings,
).await?;
// 4.5 双写 vital_signs血压/血糖自动归档)
if let Err(e) = sync_bp_glucose_to_vital_signs(
&state.db, tenant_id, patient_id, &parsed_readings,
).await {
tracing::warn!(error = %e, "双写 vital_signs 失败(不影响主流程)");
}
// 5. 降采样 upsert
upsert_hourly_aggregates(
&state.db, tenant_id, patient_id, &parsed_readings,
@@ -440,3 +447,88 @@ pub async fn query_hourly_readings(
total_pages: total.div_ceil(limit.max(1)),
})
}
/// 将血压/血糖设备数据同步到 vital_signs 表
async fn sync_bp_glucose_to_vital_signs(
db: &DatabaseConnection,
tenant_id: Uuid,
patient_id: Uuid,
readings: &[(&ReadingInput, DateTime<Utc>)],
) -> HealthResult<()> {
let bp_readings: Vec<_> = readings
.iter()
.filter(|(r, _)| matches!(r.device_type.as_str(), "blood_pressure" | "blood_glucose"))
.collect();
if bp_readings.is_empty() {
return Ok(());
}
let today = Utc::now().date_naive();
let existing = vital_signs::Entity::find()
.filter(vital_signs::Column::TenantId.eq(tenant_id))
.filter(vital_signs::Column::PatientId.eq(patient_id))
.filter(vital_signs::Column::RecordDate.eq(today))
.filter(vital_signs::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| HealthError::DbError(e.to_string()))?;
let mut model = if let Some(rec) = existing {
let mut m: vital_signs::ActiveModel = rec.into();
m.updated_at = Set(Utc::now());
m.version = Set(m.version.unwrap() + 1);
m
} else {
vital_signs::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
patient_id: Set(patient_id),
record_date: Set(today),
source: Set("device_auto".into()),
created_at: Set(Utc::now()),
updated_at: Set(Utc::now()),
created_by: Set(None),
updated_by: Set(None),
deleted_at: Set(None),
version: Set(1),
..Default::default()
}
};
let mut changed = false;
for (r, _) in &bp_readings {
let metric = r.values.get("metric").and_then(|v| v.as_str()).unwrap_or("");
let value = r.values.get("value").and_then(|v| v.as_f64()).unwrap_or(0.0);
match (r.device_type.as_str(), metric) {
("blood_pressure", "systolic") => {
model.systolic_bp_morning = Set(Some(value.round() as i32));
changed = true;
}
("blood_pressure", "diastolic") => {
model.diastolic_bp_morning = Set(Some(value.round() as i32));
changed = true;
}
("blood_glucose", _) => {
let dec_val = sea_orm::prelude::Decimal::from_f64_retain(value).unwrap_or_default();
model.blood_sugar = Set(Some(dec_val));
changed = true;
}
_ => {}
}
}
if changed {
let is_update = matches!(model.version, Set(v) if v > 1);
if is_update {
model.update(db).await.map_err(|e| HealthError::DbError(e.to_string()))?;
} else {
model.insert(db).await.map_err(|e| HealthError::DbError(e.to_string()))?;
}
}
Ok(())
}

View File

@@ -91,6 +91,53 @@ pub async fn seed_tenant_health(
"urgent",
120,
),
// 血压告警规则
(
"血压收缩压偏高",
Some("收缩压 ≥ 140 mmHg"),
"blood_pressure",
"single_threshold",
json!({"direction": "above", "value": 140.0, "metric": "systolic"}),
"warning",
60,
),
(
"血压收缩压危急",
Some("收缩压 ≥ 180 mmHg"),
"blood_pressure",
"single_threshold",
json!({"direction": "above", "value": 180.0, "metric": "systolic"}),
"critical",
30,
),
(
"血压舒张压偏低",
Some("舒张压 < 60 mmHg"),
"blood_pressure",
"single_threshold",
json!({"direction": "below", "value": 60.0, "metric": "diastolic"}),
"critical",
30,
),
// 血糖告警规则
(
"空腹血糖偏高",
Some("空腹血糖 ≥ 7.0 mmol/L"),
"blood_glucose",
"single_threshold",
json!({"direction": "above", "value": 7.0}),
"warning",
60,
),
(
"低血糖",
Some("血糖 < 3.9 mmol/L"),
"blood_glucose",
"single_threshold",
json!({"direction": "below", "value": 3.9}),
"critical",
30,
),
];
for (name, description, device_type, condition_type, condition_params, severity, cooldown) in