fix(health): 设备数据管线 Phase 1 缺陷修复 + AI 产品策略讨论
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

- device_readings 批量插入添加 ON CONFLICT 去重唯一索引
- 小程序 BLEManager 增加离线缓存(Storage 持久化 + 启动重传)
- 新增 device_readings 90 天数据保留清理定时任务
- 小米手环适配器增加 RACP 历史心率读取支持
- SSE 告警按医生过滤已确认实现(patient_doctor_relation)
- 新增 AI 产品策略与设备数据医院场景讨论记录
This commit is contained in:
iven
2026-04-29 06:17:23 +08:00
parent a491eb19a6
commit f6ccb8a35c
8 changed files with 389 additions and 9 deletions

View File

@@ -9,6 +9,9 @@ import type {
BLEManagerConfig,
} from './types';
const CACHE_KEY = 'ble_pending_readings';
const CACHE_MAX = 2000;
const DEFAULT_CONFIG: BLEManagerConfig = {
scanTimeout: 10000,
maxReadingsPerSync: 500,
@@ -179,6 +182,7 @@ export class BLEManager {
);
if (newReadings.length > 0) {
this.readings = [...this.readings, ...newReadings];
this.persistPendingReadings();
this.onReadings?.(newReadings);
}
});
@@ -248,6 +252,7 @@ export class BLEManager {
try {
const uploaded = await uploadFn(batch);
this.readings = this.readings.slice(batch.length);
this.clearPendingReadings();
this.updateState('connected');
return {
success: true,
@@ -303,6 +308,50 @@ export class BLEManager {
clearReadings(): void {
this.readings = [];
}
// ── 离线缓存 ──
/** 将当前未上传读数同步写入 Storage */
private persistPendingReadings(): void {
try {
const batch = this.readings.slice(-CACHE_MAX);
Taro.setStorageSync(CACHE_KEY, JSON.stringify(batch));
} catch {
// Storage 写入失败不影响主流程
}
}
/** 上传成功后清除缓存 */
private clearPendingReadings(): void {
try {
Taro.removeStorageSync(CACHE_KEY);
} catch {
// 忽略
}
}
/** 启动时检查缓存,有未上传数据则自动重传。
* 返回重传的记录数 */
async flushPendingReadings(
uploadFn: (readings: NormalizedReading[]) => Promise<number>,
): Promise<number> {
try {
const raw = Taro.getStorageSync(CACHE_KEY) as string;
if (!raw) return 0;
const cached: NormalizedReading[] = JSON.parse(raw);
if (!Array.isArray(cached) || cached.length === 0) {
Taro.removeStorageSync(CACHE_KEY);
return 0;
}
const batch = cached.slice(0, this.config.maxReadingsPerSync);
const uploaded = await uploadFn(batch);
Taro.removeStorageSync(CACHE_KEY);
return uploaded;
} catch {
return 0;
}
}
}
export default new BLEManager();

View File

@@ -4,15 +4,34 @@ import type { DeviceAdapter, NormalizedReading } from '../types';
* 小米手环 BLE 适配器
*
* 支持 Mi Band 7/8 等型号,使用标准 Heart Rate Service 读取心率数据。
* 未来可扩展步数0xFEE1、睡眠等 Mi Band 专有 Service
* 支持 RACP (Record Access Control Point) 读取历史心率记录
*/
// 标准 BLE Heart Rate Service
const HRS_SERVICE = '0000180D-0000-1000-8000-00805F9B34FB';
const HRM_CHARACTERISTIC = '00002A37-0000-1000-8000-00805F9B34FB';
// 小米 Mi Band 专有 Service步数/活动量)
// const MI_BAND_SERVICE = '0000FEE1-0000-1000-8000-8000-00805F9B34FB';
// Heart Rate Service — Body Sensor Location
const BODY_SENSOR_LOCATION = '00002A38-0000-1000-8000-00805F9B34FB';
// Record Access Control Point — 用于读取历史心率记录
const RACP_CHARACTERISTIC = '00002A52-0000-1000-8000-00805F9B34FB';
// RACP 操作码
const RACP_OPCODE_REPORT_STORED_RECORDS = 0x01;
const RACP_OPCODE_DELETE_STORED_RECORDS = 0x02;
const RACP_OPCODE_ABORT_OPERATION = 0x03;
const RACP_OPCODE_REPORT_NUMBER_OF_RECORDS = 0x04;
const RACP_OPCODE_NUMBER_OF_STORED_RECORDS_RESPONSE = 0x05;
const RACP_OPCODE_RESPONSE_CODE = 0x06;
// RACP 操作符
const RACP_OPERATOR_ALL = 0x01;
const RACP_OPERATOR_LESS_THAN = 0x04;
const RACP_OPERATOR_GREATER_THAN = 0x05;
// RACP 过滤器类型
const RACP_FILTER_TYPE_TIME = 0x01;
/** 解析心率测量值Heart Rate Measurement 格式) */
function parseHeartRate(data: ArrayBuffer): number | null {
@@ -50,6 +69,8 @@ export const XiaomiBandAdapter: DeviceAdapter = {
readCharacteristics: [
{ service: HRS_SERVICE, characteristic: HRM_CHARACTERISTIC },
{ service: HRS_SERVICE, characteristic: BODY_SENSOR_LOCATION },
{ service: HRS_SERVICE, characteristic: RACP_CHARACTERISTIC },
],
parseNotification(
@@ -72,12 +93,78 @@ export const XiaomiBandAdapter: DeviceAdapter = {
parseReadResponse(
_serviceUUID: string,
_charUUID: string,
_data: ArrayBuffer,
charUUID: string,
data: ArrayBuffer,
): NormalizedReading[] {
// 读取模式暂不支持,使用通知模式获取数据
const upper = charUUID.toUpperCase();
if (upper.includes('2A37')) {
// Heart Rate Measurement — 通过 read 触发的回调
const hr = parseHeartRate(data);
if (hr !== null && hr > 0 && hr < 300) {
return [{
device_type: 'heart_rate',
values: { heart_rate: hr },
measured_at: new Date().toISOString(),
}];
}
}
if (upper.includes('2A52')) {
// RACP 响应
const view = new DataView(data);
if (view.byteLength < 3) return [];
const opcode = view.getUint8(0);
if (opcode === RACP_OPCODE_NUMBER_OF_STORED_RECORDS_RESPONSE) {
const count = view.getUint16(2, true);
// 不返回 reading仅用于日志/调试
return [];
}
if (opcode === RACP_OPCODE_RESPONSE_CODE) {
const reqOpcode = view.getUint8(1);
const status = view.getUint8(2);
// status: 0x01=成功, 0x02=不支持的操作码, 等
// 不返回 reading仅用于日志
return [];
}
}
return [];
},
};
/**
* 构建 RACP "Report All Stored Records" 请求命令。
* 写入 RACP characteristic 触发设备通过 HRM characteristic 回传历史心率。
*
* 用法:
* Taro.writeBLECharacteristicValue({
* deviceId, serviceId: HRS_SERVICE,
* characteristicId: RACP_CHARACTERISTIC,
* value: buildRACPReportAll(),
* })
*/
export function buildRACPReportAll(): ArrayBuffer {
const buffer = new ArrayBuffer(2);
const view = new DataView(buffer);
view.setUint8(0, RACP_OPCODE_REPORT_STORED_RECORDS);
view.setUint8(1, RACP_OPERATOR_ALL);
return buffer;
}
/**
* 构建 RACP "Report Number of Stored Records" 请求命令。
* 返回设备中存储的记录数。
*/
export function buildRACPReportCount(): ArrayBuffer {
const buffer = new ArrayBuffer(2);
const view = new DataView(buffer);
view.setUint8(0, RACP_OPCODE_REPORT_NUMBER_OF_STORED_RECORDS);
view.setUint8(1, RACP_OPERATOR_ALL);
return buffer;
}
export default XiaomiBandAdapter;