feat(health): wire H5 health page, WeChat channel, and observation API.

Expose /health and /api/health/observations behind MEMIND_HEALTH_ENABLED, redirect ordinary chat health intents without persisting, and run a dedicated WeChat menu flow with confirm-before-write semantics.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-02 10:05:56 +08:00
parent f96fdcb3b9
commit 7bf16500a1
22 changed files with 1132 additions and 10 deletions
+6
View File
@@ -1,6 +1,7 @@
import { sessionCookie } from '../auth.mjs';
import { isDatabaseConfigured } from '../db.mjs';
import { isGoalRunEnabledForUser } from '../goal-run-intent.mjs';
import { isMemindHealthEnabled } from '../health-feature.mjs';
import {
exchangeMiniProgramCode,
loadWechatMiniappConfig,
@@ -21,6 +22,9 @@ export function attachPortalCoreAuthRoutes({
resolveGoalRunForClient = async (userId) => ({
enabled: isGoalRunEnabledForUser(userId, process.env),
}),
resolveHealthForClient = async () => ({
enabled: isMemindHealthEnabled(process.env),
}),
getSubscriptionService = () => null,
getPlazaSeo = () => null,
plazaClientIp = (req) => req.ip,
@@ -65,6 +69,7 @@ export function attachPortalCoreAuthRoutes({
const agentCodeRun =
await resolveAgentCodeRunForClient(me.id);
const goalRun = await resolveGoalRunForClient(me.id);
const health = await resolveHealthForClient(me.id);
const subscriptionService = getSubscriptionService();
const subscription = subscriptionService
? await subscriptionService.getActiveSubscription(me.id)
@@ -83,6 +88,7 @@ export function attachPortalCoreAuthRoutes({
skillRuntime,
agentCodeRun,
goalRun,
health,
});
} catch (error) {
logger.error(
+1
View File
@@ -217,6 +217,7 @@ test('returns multi-user status and preserves capability projection', async () =
skillRuntime: { enabled: true },
agentCodeRun: { enabled: true, userId: 'user-1' },
goalRun: { enabled: true },
health: { enabled: false },
});
});
+91
View File
@@ -0,0 +1,91 @@
import { isMemindHealthEnabled } from '../health-feature.mjs';
export function attachPortalHealthRoutes({
api,
observationStore,
env = process.env,
logger = console,
} = {}) {
if (!api || !observationStore) {
throw new Error('attachPortalHealthRoutes requires route dependencies');
}
const requireHealthUser = (req, res) => {
if (!isMemindHealthEnabled(env)) {
res.status(404).json({ message: '健康助手未启用' });
return null;
}
const userId = req.currentUser?.id;
if (!userId) {
res.status(401).json({ message: '未登录' });
return null;
}
return userId;
};
api.get('/health/observations', async (req, res) => {
const userId = requireHealthUser(req, res);
if (!userId) return;
try {
const rows = await observationStore.list(userId, {
limit: Math.min(Math.max(Number(req.query?.limit) || 50, 1), 200),
});
res.json({ observations: rows });
} catch (error) {
logger.warn?.('List health observations failed:', error);
res.status(500).json({ message: '读取健康记录失败' });
}
});
api.post('/health/observations', async (req, res) => {
const userId = requireHealthUser(req, res);
if (!userId) return;
try {
const body = req.body ?? {};
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, {
confirmed: true,
observedAt,
metricType: 'bp_systolic',
valueNum: Number(body.systolic),
unit: 'mmHg',
context: body.context ?? 'other',
source: body.source ?? 'manual',
qualityFlag: 'ok',
});
const diastolic = await observationStore.insert(userId, {
confirmed: true,
observedAt,
metricType: 'bp_diastolic',
valueNum: Number(body.diastolic),
unit: 'mmHg',
context: body.context ?? 'other',
source: body.source ?? 'manual',
qualityFlag: 'ok',
});
return res.json({ observations: [systolic, diastolic] });
}
const row = await observationStore.insert(userId, {
confirmed: true,
observedAt: body.observedAt ?? Date.now(),
metricType: body.metricType,
valueNum: body.valueNum ?? null,
valueText: body.valueText ?? null,
unit: body.unit ?? null,
context: body.context ?? null,
source: body.source ?? 'manual',
qualityFlag: 'ok',
});
res.json({ observations: [row] });
} catch (error) {
logger.warn?.('Insert health observation failed:', error);
res.status(400).json({
message: error instanceof Error ? error.message : '保存失败',
});
}
});
}
+78
View File
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createInMemoryHealthObservationStore } from '../health-observation-store.mjs';
import { attachPortalHealthRoutes } from './portal-health-routes.mjs';
function createResponse() {
return {
statusCode: 200,
body: undefined,
status(code) {
this.statusCode = code;
return this;
},
json(body) {
this.body = body;
return this;
},
};
}
function createSetup(env = { MEMIND_HEALTH_ENABLED: '1' }) {
const routes = new Map();
const api = {
get(path, handler) {
routes.set(`GET ${path}`, handler);
},
post(path, handler) {
routes.set(`POST ${path}`, handler);
},
};
const store = createInMemoryHealthObservationStore();
attachPortalHealthRoutes({ api, observationStore: store, env });
return { routes, store };
}
test('health API is hidden when the feature flag is off', async () => {
const setup = createSetup({});
const res = createResponse();
await setup.routes.get('GET /health/observations')({ currentUser: { id: 'user-1' }, query: {} }, res);
assert.equal(res.statusCode, 404);
});
test('health API rejects unconfirmed writes and accepts confirmed blood pressure', async () => {
const setup = createSetup();
const denied = createResponse();
await setup.routes.get('POST /health/observations')(
{
currentUser: { id: 'user-1' },
body: { metricSet: 'blood_pressure', systolic: 128, diastolic: 76 },
},
denied,
);
assert.equal(denied.statusCode, 400);
const saved = createResponse();
await setup.routes.get('POST /health/observations')(
{
currentUser: { id: 'user-1' },
body: {
confirmed: true,
metricSet: 'blood_pressure',
systolic: 128,
diastolic: 76,
context: 'morning',
},
},
saved,
);
assert.equal(saved.statusCode, 200);
assert.equal(saved.body.observations.length, 2);
const listed = createResponse();
await setup.routes.get('GET /health/observations')(
{ currentUser: { id: 'user-1' }, query: {} },
listed,
);
assert.equal(listed.body.observations.length, 2);
});
@@ -47,6 +47,8 @@ export async function bootstrapPortalIntegrationServices({
mindSpacePages,
mindSpacePageLiveEdit,
logger = console,
healthChannelStore = null,
healthObservationStore = null,
loadWechatMpModuleFn = loadWechatMpModule,
resolveAnalyticsOwnerSegmentFn =
resolveAnalyticsOwnerSegment,
@@ -247,6 +249,9 @@ export async function bootstrapPortalIntegrationServices({
},
)
: null,
healthChannelStore: healthChannelStore ?? undefined,
healthObservationStore,
env,
});
const notificationDispatcher =