feat(health): complete P0 health channel — baseline engine, page-data, MindSpace UI

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>
This commit is contained in:
john
2026-09-09 18:04:22 +08:00
parent 7bf16500a1
commit 2baf29b3ae
94 changed files with 7283 additions and 194 deletions
+448 -6
View File
@@ -1,12 +1,67 @@
import { isMemindHealthEnabled } from '../health-feature.mjs';
import { extractHealthImageFromUrl, extractHealthImageFromVision, fetchHealthImageBuffer } from '../health-image-extract.mjs';
import { extractHealthDocumentFromUrl } from '../health-document-ocr.mjs';
import { hashHealthImageBuffer } from '../health-image-hash.mjs';
import { parseHealthAssetIdFromUrl } from '../health-document-store.mjs';
import { buildVisionThumbnailBuffer } from '../vision-image-thumb.mjs';
import { computeHealthBaselines } from '../health-baseline-engine.mjs';
import { evaluateHealthEvents } from '../health-event-engine.mjs';
import { buildHealthAssessSummary } from '../health-assess-summary.mjs';
import { buildHealthAgentContext } from '../health-agent-context.mjs';
import { baselineToApiShape } from '../health-baseline-serialize.mjs';
import { recomputeUserHealthBaselines } from '../health-baseline-job.mjs';
import { bootstrapHealthWorkspace } from '../health-workspace-bootstrap.mjs';
import { listHealthConnectors } from '../health-connector-registry.mjs';
import { buildMedicationTimelineHint } from '../health-share-service.mjs';
import {
buildHealthReportPublicUrl,
resolveHealthMaterializeH5Root,
writeHealthReportPage,
} from '../health-report-page.mjs';
import { markPageDeliveryContractReady } from '../mindspace-delivery-contract.mjs';
export function attachPortalHealthRoutes({
api,
observationStore,
healthDataRuntime = null,
observationStore = null,
observationService = null,
documentStore = null,
shareService = null,
getLlmProviderService = () => null,
getMindSpaceAssets = () => null,
getMindSpacePages = () => null,
getUserAuth = () => null,
getAuthPool = () => null,
h5Root = process.cwd(),
env = process.env,
logger = console,
} = {}) {
if (!api || !observationStore) {
const runtime = healthDataRuntime ?? {
get observationStore() {
return observationStore;
},
get observationService() {
return observationService;
},
get documentStore() {
return documentStore;
},
get shareService() {
return shareService;
},
get draftService() {
return null;
},
};
const store = runtime.observationStore;
const service = runtime.observationService;
const docs = runtime.documentStore;
const shares = runtime.shareService;
const drafts = runtime.draftService ?? null;
const baselineStore = runtime.baselineStore ?? null;
const eventStore = runtime.eventStore ?? null;
if (!api || !store) {
throw new Error('attachPortalHealthRoutes requires route dependencies');
}
@@ -23,11 +78,256 @@ export function attachPortalHealthRoutes({
return userId;
};
api.get('/health/timeline', async (req, res) => {
const userId = requireHealthUser(req, res);
if (!userId || !service) return;
try {
const timeline = await service.listTimeline(userId, {
limitDays: Math.min(Math.max(Number(req.query?.days) || 30, 1), 90),
});
res.json({ timeline });
} catch (error) {
logger.warn?.('List health timeline failed:', error);
res.status(500).json({ message: '读取健康 Timeline 失败' });
}
});
api.get('/health/baselines', async (req, res) => {
const userId = requireHealthUser(req, res);
if (!userId) return;
try {
const persisted = baselineStore ? await baselineStore.list(userId) : [];
if (persisted.length > 0) {
return res.json({ baselines: persisted.map(baselineToApiShape), source: 'persisted' });
}
const rows = await store.list(userId, { limit: 500 });
res.json({ baselines: computeHealthBaselines(rows), source: 'computed' });
} catch (error) {
logger.warn?.('List health baselines failed:', error);
res.status(500).json({ message: '读取基线失败' });
}
});
api.get('/health/events', async (req, res) => {
const userId = requireHealthUser(req, res);
if (!userId) return;
try {
const mode = String(req.query?.mode ?? 'active');
const persisted = eventStore
? await eventStore.list(userId, { status: 'open', limit: 100 })
: [];
if (persisted.length > 0) {
return res.json({ events: persisted, source: 'persisted' });
}
const rows = await store.list(userId, { limit: 500 });
const baselines = computeHealthBaselines(rows);
res.json({ events: evaluateHealthEvents(rows, baselines, { mode }), source: 'computed' });
} catch (error) {
logger.warn?.('List health events failed:', error);
res.status(500).json({ message: '读取健康事件失败' });
}
});
api.get('/health/alerts/unread', async (req, res) => {
const userId = requireHealthUser(req, res);
if (!userId || !eventStore) return res.json({ alerts: [] });
try {
const alerts = await eventStore.listUnreadAlerts(userId, {
limit: Math.min(Math.max(Number(req.query?.limit) || 20, 1), 50),
});
res.json({ alerts });
} catch (error) {
logger.warn?.('List unread health alerts failed:', error);
res.status(500).json({ message: '读取健康提醒失败' });
}
});
api.post('/health/events/:eventId/acknowledge', async (req, res) => {
const userId = requireHealthUser(req, res);
if (!userId || !eventStore) {
return res.status(503).json({ message: '健康事件服务未启用' });
}
try {
const event = await eventStore.acknowledge(userId, req.params?.eventId);
if (!event) return res.status(404).json({ message: '提醒不存在或已确认' });
res.json({ ok: true, event });
} catch (error) {
logger.warn?.('Acknowledge health event failed:', error);
res.status(400).json({
message: error instanceof Error ? error.message : '确认提醒失败',
});
}
});
api.post('/health/alerts/acknowledge-all', async (req, res) => {
const userId = requireHealthUser(req, res);
if (!userId || !eventStore) {
return res.status(503).json({ message: '健康事件服务未启用' });
}
try {
const count = await eventStore.acknowledgeAll(userId);
res.json({ ok: true, count });
} catch (error) {
logger.warn?.('Acknowledge all health alerts failed:', error);
res.status(400).json({
message: error instanceof Error ? error.message : '确认全部提醒失败',
});
}
});
api.get('/health/assess-summary', async (req, res) => {
const userId = requireHealthUser(req, res);
if (!userId) return;
try {
const rows = await store.list(userId, { limit: 500 });
res.json({ summary: buildHealthAssessSummary(rows) });
} catch (error) {
logger.warn?.('Build health assess summary failed:', error);
res.status(500).json({ message: '生成健康评估摘要失败' });
}
});
api.get('/health/agent-context', async (req, res) => {
const userId = requireHealthUser(req, res);
if (!userId) return;
try {
const rows = await store.list(userId, { limit: 500 });
const documents = docs
? await docs.list(userId, { limit: 20 })
: [];
res.json({ context: buildHealthAgentContext(rows, { documents }) });
} catch (error) {
logger.warn?.('Build health agent context failed:', error);
res.status(500).json({ message: '读取健康 Agent 上下文失败' });
}
});
api.post('/health/report-page', async (req, res) => {
const userId = requireHealthUser(req, res);
if (!userId) return;
try {
const rows = await store.list(userId, { limit: 500 });
const materializeRoot = resolveHealthMaterializeH5Root(h5Root, env);
const written = writeHealthReportPage({
h5Root: materializeRoot,
userId,
observations: rows,
});
const assets = getMindSpaceAssets?.();
const pool = getAuthPool?.();
if (assets?.syncWorkspaceAssets) {
await assets.syncWorkspaceAssets(userId, {
categoryCode: 'public',
onlyRelativePaths: [written.relativePath],
}).catch((error) => {
logger.warn?.('Sync health report workspace assets failed:', error);
});
}
if (pool) {
await markPageDeliveryContractReady({
pool,
userId,
relativePath: written.relativePath,
}).catch(() => {});
}
const url = buildHealthReportPublicUrl({
h5Root: materializeRoot,
env,
userId,
username: req.currentUser?.username ?? null,
relativePath: written.relativePath,
});
res.json({
relativePath: written.relativePath,
url,
summary: buildHealthAssessSummary(rows),
size: written.size,
});
} catch (error) {
logger.warn?.('Create health report page failed:', error);
res.status(error?.code === 'invalid_input' ? 400 : 500).json({
message: error instanceof Error ? error.message : '生成健康报告页失败',
});
}
});
api.post('/health/workspace/bootstrap', async (req, res) => {
const userId = requireHealthUser(req, res);
if (!userId) return;
try {
const auth = getUserAuth?.();
const pages = getMindSpacePages?.();
const result = await bootstrapHealthWorkspace({
userId,
env,
resolveWorkspaceRoot: async (id) => {
if (req.currentUser?.workspaceRoot) return req.currentUser.workspaceRoot;
return auth?.resolveWorkingDir?.(id) ?? null;
},
createHealthSystemPage: pages?.createHealthSystemPage?.bind(pages) ?? null,
listPages: pages?.listPages?.bind(pages) ?? null,
});
res.json({ bootstrap: result });
} catch (error) {
logger.warn?.('Bootstrap health workspace failed:', error);
res.status(500).json({ message: '初始化健康工作区失败' });
}
});
api.get('/health/connectors', async (req, res) => {
if (!requireHealthUser(req, res)) return;
res.json({ connectors: listHealthConnectors() });
});
api.get('/health/medication-hint', async (req, res) => {
if (!requireHealthUser(req, res)) return;
res.json({ hint: buildMedicationTimelineHint() });
});
api.get('/health/documents', async (req, res) => {
const userId = requireHealthUser(req, res);
if (!userId) return;
if (!docs) return res.json({ documents: [] });
try {
const documents = await docs.list(userId, {
limit: Math.min(Math.max(Number(req.query?.limit) || 50, 1), 200),
});
res.json({ documents });
} catch (error) {
logger.warn?.('List health documents failed:', error);
res.status(500).json({ message: '读取报告归档失败' });
}
});
api.post('/health/share-snapshot', async (req, res) => {
const userId = requireHealthUser(req, res);
if (!userId || !shares) {
return res.status(503).json({ message: '分享服务未启用' });
}
try {
const snapshot = await shares.createReadonlySnapshot(userId);
res.json(snapshot);
} catch (error) {
logger.warn?.('Create health share snapshot failed:', error);
res.status(500).json({ message: '创建分享摘要失败' });
}
});
api.get('/health/share/:token', async (req, res) => {
if (!isMemindHealthEnabled(env)) {
return res.status(404).json({ message: '健康助手未启用' });
}
if (!shares) return res.status(503).json({ message: '分享服务未启用' });
const snapshot = shares.getSnapshot(req.params?.token);
if (!snapshot) return res.status(404).json({ message: '分享链接无效或已过期' });
res.json({ snapshot });
});
api.get('/health/observations', async (req, res) => {
const userId = requireHealthUser(req, res);
if (!userId) return;
try {
const rows = await observationStore.list(userId, {
const rows = await store.list(userId, {
limit: Math.min(Math.max(Number(req.query?.limit) || 50, 1), 200),
});
res.json({ observations: rows });
@@ -42,12 +342,16 @@ export function attachPortalHealthRoutes({
if (!userId) return;
try {
const body = req.body ?? {};
if (service) {
const result = await service.commit(userId, body);
return res.json(result);
}
if (body.confirmed !== true) {
return res.status(400).json({ message: '必须确认后才能保存' });
}
if (body.metricSet === 'blood_pressure') {
const observedAt = body.observedAt ?? Date.now();
const systolic = await observationStore.insert(userId, {
const systolic = await store.insert(userId, {
confirmed: true,
observedAt,
metricType: 'bp_systolic',
@@ -57,7 +361,7 @@ export function attachPortalHealthRoutes({
source: body.source ?? 'manual',
qualityFlag: 'ok',
});
const diastolic = await observationStore.insert(userId, {
const diastolic = await store.insert(userId, {
confirmed: true,
observedAt,
metricType: 'bp_diastolic',
@@ -69,7 +373,7 @@ export function attachPortalHealthRoutes({
});
return res.json({ observations: [systolic, diastolic] });
}
const row = await observationStore.insert(userId, {
const row = await store.insert(userId, {
confirmed: true,
observedAt: body.observedAt ?? Date.now(),
metricType: body.metricType,
@@ -88,4 +392,142 @@ export function attachPortalHealthRoutes({
});
}
});
api.post('/health/documents', async (req, res) => {
const userId = requireHealthUser(req, res);
if (!userId) return;
if (!docs) {
return res.status(503).json({ message: '报告归档服务未启用' });
}
try {
const body = req.body ?? {};
if (body.confirmed !== true) {
return res.status(400).json({ message: '必须确认后才能归档报告' });
}
const imageUrl = String(body.imageUrl ?? '').trim();
if (!imageUrl) {
return res.status(400).json({ message: '缺少报告图片地址' });
}
const llmProviderService = getLlmProviderService();
const analyzeImagesWithVision = llmProviderService?.analyzeImagesWithVision?.bind(llmProviderService);
let ocr = null;
if (body.runOcr !== false && analyzeImagesWithVision) {
ocr = await extractHealthDocumentFromUrl({
userId,
imageUrl,
analyzeImagesWithVision,
mindSpaceAssets: getMindSpaceAssets(),
buildVisionThumbnailBuffer,
});
}
const row = await docs.insert(userId, {
confirmed: true,
imageUrl,
assetId: parseHealthAssetIdFromUrl(imageUrl),
source: body.source ?? 'manual',
notes: body.notes ?? ocr?.title ?? null,
docType: ocr?.docType ?? 'other',
reportDate: ocr?.reportDate ?? null,
institution: ocr?.institution ?? null,
ocrText: ocr?.ocrText ?? null,
extractedMetrics: ocr?.extractedMetrics ?? [],
extractionStatus: ocr?.ok ? ocr.extractionStatus : 'pending',
});
res.json({ document: row, ocr: ocr?.ok ? ocr : null });
} catch (error) {
logger.warn?.('Insert health document failed:', error);
res.status(400).json({
message: error instanceof Error ? error.message : '报告归档失败',
});
}
});
api.post('/health/extract-image', async (req, res) => {
const userId = requireHealthUser(req, res);
if (!userId) return;
const body = req.body ?? {};
const imageUrl = String(body.imageUrl ?? '').trim();
if (!imageUrl) {
return res.status(400).json({ message: '缺少图片地址' });
}
const llmProviderService = getLlmProviderService();
const analyzeImagesWithVision = llmProviderService?.analyzeImagesWithVision?.bind(llmProviderService);
if (!analyzeImagesWithVision) {
return res.status(503).json({ message: '图片识别服务暂不可用' });
}
try {
const { buffer, mimeType } = await fetchHealthImageBuffer({
userId,
imageUrl,
mindSpaceAssets: getMindSpaceAssets(),
});
const sourceRef = hashHealthImageBuffer(buffer);
const result = await extractHealthImageFromVision({
buffer,
mimeType,
metricSetHint: body.metricSet ?? null,
analyzeImagesWithVision,
buildVisionThumbnailBuffer,
});
if (!result.ok) {
return res.status(422).json({
message: result.message ?? '未能识别图片读数',
error: result.error ?? 'extract_failed',
});
}
let draft = null;
if (drafts) {
draft = await drafts.saveExtractionDraft(userId, {
extraction: result,
channel: body.channel ?? 'h5',
sourceRef,
});
}
return res.json({
extraction: result,
draftKey: draft?.draftKey ?? null,
sourceRef,
});
} catch (error) {
logger.warn?.('Health image extract failed:', error);
return res.status(500).json({
message: error instanceof Error ? error.message : '图片识别失败',
});
}
});
api.post('/health/drafts/:draftKey/commit', async (req, res) => {
const userId = requireHealthUser(req, res);
if (!userId || !drafts) {
return res.status(503).json({ message: '草稿服务未启用' });
}
try {
const result = await drafts.commitDraft(userId, req.params?.draftKey, {
source: req.body?.source ?? 'photo',
});
res.json(result);
} catch (error) {
logger.warn?.('Commit health draft failed:', error);
res.status(400).json({
message: error instanceof Error ? error.message : '草稿提交失败',
});
}
});
api.post('/health/drafts/:draftKey/discard', async (req, res) => {
const userId = requireHealthUser(req, res);
if (!userId || !drafts) {
return res.status(503).json({ message: '草稿服务未启用' });
}
try {
const draft = await drafts.discardDraft(userId, req.params?.draftKey);
if (!draft) return res.status(404).json({ message: '草稿不存在' });
res.json({ ok: true, draft });
} catch (error) {
logger.warn?.('Discard health draft failed:', error);
res.status(400).json({
message: error instanceof Error ? error.message : '草稿丢弃失败',
});
}
});
}
@@ -9,6 +9,8 @@ import { createNotificationDispatcher } from '../notification-dispatcher.mjs';
import { startScheduleReminderWorker } from '../schedule-reminder-worker.mjs';
import { startScheduledTaskWorker } from '../scheduled-task-worker.mjs';
import { isScheduledTaskWorkerEnabled } from '../scheduled-task-worker-config.mjs';
import { startHealthBaselineWorker } from '../health-baseline-worker.mjs';
import { createHealthEventNotificationService } from '../health-event-notification-service.mjs';
import { isPassiveCanaryRuntime } from './portal-runtime-role.mjs';
import { loadWechatMpModule } from '../wechat-mp-loader.mjs';
import { createToolGateway } from '../tool-gateway.mjs';
@@ -49,6 +51,10 @@ export async function bootstrapPortalIntegrationServices({
logger = console,
healthChannelStore = null,
healthObservationStore = null,
healthObservationService = null,
healthDocumentStore = null,
healthDataRuntime = null,
healthEventStore = null,
loadWechatMpModuleFn = loadWechatMpModule,
resolveAnalyticsOwnerSegmentFn =
resolveAnalyticsOwnerSegment,
@@ -63,6 +69,8 @@ export async function bootstrapPortalIntegrationServices({
startScheduleReminderWorker,
startScheduledTaskWorkerFn =
startScheduledTaskWorker,
startHealthBaselineWorkerFn =
startHealthBaselineWorker,
createPageEditSessionServiceFn =
createPageEditSessionService,
createToolGatewayFn = createToolGateway,
@@ -251,6 +259,9 @@ export async function bootstrapPortalIntegrationServices({
: null,
healthChannelStore: healthChannelStore ?? undefined,
healthObservationStore,
healthObservationService,
healthDocumentStore,
healthEventStore: healthEventStore ?? healthDataRuntime?.eventStore ?? null,
env,
});
@@ -324,6 +335,25 @@ export async function bootstrapPortalIntegrationServices({
);
}
let healthBaselineWorker = null;
if (!isPassiveCanaryRuntime(env) && healthDataRuntime) {
const healthEventNotificationService = createHealthEventNotificationService({
createUserNotification: scheduleService?.createUserNotification?.bind(scheduleService) ?? null,
notificationDispatcher,
logger,
});
healthBaselineWorker = startHealthBaselineWorkerFn({
healthDataRuntime,
eventNotificationService: healthEventNotificationService,
pool,
env,
logger,
});
if (healthBaselineWorker?.runOnce) {
logger.log?.('Health baseline job worker enabled');
}
}
let subscriptionExpiryTimer = null;
if (subscriptionService && !isPassiveCanaryRuntime(env)) {
subscriptionExpiryTimer = setIntervalFn(
@@ -382,6 +412,7 @@ export async function bootstrapPortalIntegrationServices({
notificationDispatcher,
scheduleReminderWorker,
scheduledTaskWorker,
healthBaselineWorker,
subscriptionExpiryTimer,
mindSpacePageEditSession,
};
+15
View File
@@ -23,6 +23,7 @@ import {
} from '../conversation-repair.mjs';
import { filterNonemptyUserVisibleMessages } from '../conversation-transcript-persist.mjs';
import { sanitizeSessionConversationPublicHtmlLinks } from '../tkmind-proxy.mjs';
import { sanitizeHealthAssistantReportDelivery } from '../health-report-finish-guard.mjs';
function assertRouter(api) {
if (
@@ -568,6 +569,20 @@ export function attachPortalSessionRoutes(
req.currentUser.username,
},
});
const healthReportSanitized = sanitizeHealthAssistantReportDelivery(
messages,
{
userId: uid,
username: req.currentUser.username,
h5Root: process.cwd(),
},
);
if (healthReportSanitized.changed) {
messages = healthReportSanitized.messages;
logger.warn?.(
`[Health] stripped unverified health report links for session ${sid}`,
);
}
if (
Array.isArray(syncResult?.docxSync?.missing) &&
syncResult.docxSync.missing.length > 0