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:
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 : '保存失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user