2baf29b3ae
Deliver encrypted health zone, observation API, baseline maturity pipeline, page-data bindings, and H5/WeChat channel integration for health P0. Co-authored-by: Cursor <cursoragent@cursor.com>
142 lines
4.6 KiB
JavaScript
142 lines
4.6 KiB
JavaScript
import { validateBloodPressurePair, validateMetricValue } from './health-observation-validate.mjs';
|
|
import { assertSymptomRecord } from './health-symptoms.mjs';
|
|
import { observationDedupKey } from './health-observation-validate.mjs';
|
|
import { buildHealthTimeline } from './health-timeline.mjs';
|
|
|
|
const METRIC_SET_TO_TYPE = Object.freeze({
|
|
heart_rate: 'hr',
|
|
weight: 'weight',
|
|
spo2: 'spo2',
|
|
temperature: 'temperature',
|
|
sleep: 'sleep_minutes',
|
|
});
|
|
|
|
export function createHealthObservationService({ store } = {}) {
|
|
if (!store) throw new Error('createHealthObservationService requires store');
|
|
|
|
async function insertUnique(userId, observation) {
|
|
const dedup = observationDedupKey({
|
|
metricType: observation.metricType,
|
|
observedAt: observation.observedAt,
|
|
sourceRef: observation.sourceRef ?? null,
|
|
});
|
|
if (dedup && store.findByDedupKey) {
|
|
const existing = await store.findByDedupKey(userId, dedup);
|
|
if (existing) return existing;
|
|
}
|
|
return store.insert(userId, {
|
|
...observation,
|
|
confirmed: true,
|
|
dedupKey: dedup,
|
|
});
|
|
}
|
|
|
|
return {
|
|
store,
|
|
async list(userId, options) {
|
|
return store.list(userId, options);
|
|
},
|
|
async listTimeline(userId, options) {
|
|
const rows = await store.list(userId, { limit: options?.limit ?? 500 });
|
|
return buildHealthTimeline(rows, options);
|
|
},
|
|
async commit(userId, body = {}) {
|
|
if (body.confirmed !== true) {
|
|
const error = Object.assign(new Error('必须确认后才能保存'), { code: 'health_unconfirmed' });
|
|
throw error;
|
|
}
|
|
const observedAt = body.observedAt ?? Date.now();
|
|
const source = body.source ?? 'manual';
|
|
const context = body.context ?? 'other';
|
|
const sourceRef = body.sourceRef ?? null;
|
|
|
|
if (body.metricSet === 'blood_pressure') {
|
|
const pair = validateBloodPressurePair(body.systolic, body.diastolic);
|
|
if (!pair.ok) {
|
|
const error = Object.assign(new Error(pair.error ?? 'invalid_blood_pressure'), { code: pair.error });
|
|
throw error;
|
|
}
|
|
const systolic = await insertUnique(userId, {
|
|
confirmed: true,
|
|
observedAt,
|
|
metricType: 'bp_systolic',
|
|
valueNum: pair.systolic,
|
|
unit: 'mmHg',
|
|
context,
|
|
source,
|
|
sourceRef,
|
|
qualityFlag: body.qualityFlag ?? 'ok',
|
|
});
|
|
const diastolic = await insertUnique(userId, {
|
|
confirmed: true,
|
|
observedAt,
|
|
metricType: 'bp_diastolic',
|
|
valueNum: pair.diastolic,
|
|
unit: 'mmHg',
|
|
context,
|
|
source,
|
|
sourceRef,
|
|
qualityFlag: body.qualityFlag ?? 'ok',
|
|
});
|
|
return { observations: [systolic, diastolic] };
|
|
}
|
|
|
|
if (body.metricSet === 'symptom') {
|
|
const symptom = assertSymptomRecord({
|
|
code: body.symptomCode ?? body.code,
|
|
severity: body.severity,
|
|
note: body.note,
|
|
});
|
|
if (!symptom.ok) {
|
|
const error = Object.assign(new Error(symptom.error ?? 'invalid_symptom'), { code: symptom.error });
|
|
throw error;
|
|
}
|
|
const row = await insertUnique(userId, {
|
|
confirmed: true,
|
|
observedAt,
|
|
metricType: 'symptom',
|
|
valueText: symptom.code === 'none' ? 'none' : `${symptom.code}:${symptom.severity}`,
|
|
unit: null,
|
|
context,
|
|
source,
|
|
sourceRef,
|
|
qualityFlag: 'ok',
|
|
note: symptom.note,
|
|
});
|
|
return { observations: [row] };
|
|
}
|
|
|
|
const metricType = body.metricType ?? METRIC_SET_TO_TYPE[body.metricSet];
|
|
if (!metricType) {
|
|
const error = Object.assign(new Error('unknown_metric_set'), { code: 'unknown_metric_set' });
|
|
throw error;
|
|
}
|
|
const checked = validateMetricValue(metricType, body.valueNum ?? body.value);
|
|
if (!checked.ok) {
|
|
const error = Object.assign(new Error(checked.error ?? 'invalid_metric'), { code: checked.error });
|
|
throw error;
|
|
}
|
|
const row = await insertUnique(userId, {
|
|
confirmed: true,
|
|
observedAt,
|
|
metricType,
|
|
valueNum: checked.value,
|
|
unit: checked.unit,
|
|
context: body.context ?? 'any',
|
|
source,
|
|
sourceRef,
|
|
qualityFlag: body.qualityFlag ?? 'ok',
|
|
});
|
|
return { observations: [row] };
|
|
},
|
|
async commitFromPending(userId, pendingValues = {}, source = 'manual') {
|
|
if (!pendingValues?.metricSet) return null;
|
|
return this.commit(userId, {
|
|
confirmed: true,
|
|
...pendingValues,
|
|
source: pendingValues.source ?? source,
|
|
});
|
|
},
|
|
};
|
|
}
|