Files
memind/health-report-page.mjs
T
john 2baf29b3ae 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>
2026-09-09 18:04:57 +08:00

187 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'node:fs';
import path from 'node:path';
import { buildHealthAssessSummary } from './health-assess-summary.mjs';
import { buildHealthTimeline } from './health-timeline.mjs';
import { buildMindSpacePublicUrlForUser } from './mindspace-runtime-config.mjs';
import { resolvePublishDir } from './user-publish.mjs';
import { looksLikeHealthReportPageRequest } from './health-channel-aliases.mjs';
export { looksLikeHealthReportPageRequest } from './health-channel-aliases.mjs';
/** 健康页落盘目录:remote MindSpace 交付时须与 MINDSPACE_SERVICE_H5_ROOT 一致。 */
export function resolveHealthMaterializeH5Root(portalH5Root = process.cwd(), env = process.env) {
const portalRoot = path.resolve(portalH5Root);
const adapter = String(env.MINDSPACE_SERVER_ADAPTER ?? 'local').trim().toLowerCase();
if (adapter === 'local') return portalRoot;
const sharedRoot = String(
env.MINDSPACE_SERVICE_H5_ROOT
?? env.MEMIND_SHARED_PUBLISH_ROOT
?? env.GOOSED_SANDBOX_PUBLISH_ROOT
?? '',
).trim();
if (sharedRoot) {
const resolved = path.resolve(sharedRoot);
if (fs.existsSync(resolved)) return resolved;
}
return portalRoot;
}
export const HEALTH_REPORT_PAGE_PREFIX = 'health-report';
export const HEALTH_REPORT_FILENAME_PATTERN = /^health-report-\d{4}-\d{2}-\d{2}\.html$/i;
export const HEALTH_PUBLIC_HTML_PATTERN = /^public\/health-[a-z0-9-]+\.html$/i;
export function writeHealthPublicHtmlPage({
h5Root = process.cwd(),
userId,
relativePath,
html,
minSize = 64,
} = {}) {
if (!userId || !relativePath || !html) {
const error = Object.assign(new Error('缺少写入参数'), { code: 'invalid_input' });
throw error;
}
const clean = String(relativePath).replace(/^\/+/, '');
if (!HEALTH_PUBLIC_HTML_PATTERN.test(clean)) {
const error = Object.assign(new Error('健康页路径不合法'), { code: 'invalid_health_page_path' });
throw error;
}
const publishDir = resolvePublishDir(h5Root, { id: String(userId) });
const absolutePath = path.join(publishDir, clean);
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
fs.writeFileSync(absolutePath, html, 'utf8');
if (!fs.existsSync(absolutePath) || fs.statSync(absolutePath).size < minSize) {
const error = Object.assign(new Error('健康页写入失败'), { code: 'health_page_write_failed' });
throw error;
}
return {
relativePath: clean,
absolutePath,
size: fs.statSync(absolutePath).size,
};
}
export function defaultHealthReportFilename(now = Date.now()) {
const date = new Date(now);
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${HEALTH_REPORT_PAGE_PREFIX}-${y}-${m}-${d}.html`;
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function formatMetricLines(timeline) {
return timeline.slice(0, 14).flatMap((day) => {
const metrics = Object.values(day.metrics ?? {})
.map((item) => `${item.label} ${item.value}`)
.join('');
if (!metrics) return [];
return [`${day.date}${metrics}`];
});
}
export function buildHealthReportHtml(observations = [], { now = Date.now(), title = '本人健康报告' } = {}) {
const timeline = buildHealthTimeline(observations, { limitDays: 30 });
const summary = buildHealthAssessSummary(observations, { now });
const metricLines = formatMetricLines(timeline);
const generatedAt = new Date(now).toISOString().slice(0, 10);
const sections = [
`<section><h2>概况</h2><pre class="block">${escapeHtml(summary)}</pre></section>`,
];
if (metricLines.length > 0) {
sections.push(
`<section><h2>近 14 日记录</h2><ul>${metricLines
.map((line) => `<li>${escapeHtml(line)}</li>`)
.join('')}</ul></section>`,
);
}
sections.push(
'<section><h2>说明</h2><p>本页基于你<strong>确认过</strong>的健康记录生成,仅供本人回顾,'
+ '不构成医疗诊断。如有不适请及时就医。</p></section>',
);
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex,nofollow" />
<title>${escapeHtml(title)}</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 46rem; margin: 2rem auto; padding: 0 1rem; color: #1f2937; line-height: 1.65; }
h1 { font-size: 1.45rem; margin-bottom: 0.25rem; }
h2 { font-size: 1.05rem; margin: 1.5rem 0 0.5rem; }
.meta { color: #6b7280; font-size: 0.92rem; margin-bottom: 1.25rem; }
pre.block { white-space: pre-wrap; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 0.85rem 1rem; font-family: inherit; font-size: 0.95rem; }
ul { padding-left: 1.2rem; }
.footer { margin-top: 2rem; font-size: 0.85rem; color: #9ca3af; }
</style>
</head>
<body>
<h1>${escapeHtml(title)}</h1>
<p class="meta">生成日期:${escapeHtml(generatedAt)} · MeMind Health · 加密健康档案区</p>
${sections.join('\n ')}
<p class="footer">禁止完全公开;外部访问须口令或登录。</p>
</body>
</html>`;
}
export function writeHealthReportPage({
h5Root = process.cwd(),
userId,
observations = [],
now = Date.now(),
filename = null,
} = {}) {
if (!userId) {
const error = Object.assign(new Error('缺少 userId'), { code: 'invalid_input' });
throw error;
}
const relativePath = `public/${filename ?? defaultHealthReportFilename(now)}`;
if (!HEALTH_REPORT_FILENAME_PATTERN.test(path.posix.basename(relativePath))) {
const error = Object.assign(new Error('健康报告文件名不合法'), { code: 'invalid_health_report_filename' });
throw error;
}
const html = buildHealthReportHtml(observations, { now });
const written = writeHealthPublicHtmlPage({
h5Root,
userId,
relativePath,
html,
});
return written;
}
export function healthReportPageExists(h5Root, userId, relativePath) {
const clean = String(relativePath ?? '').replace(/^\/+/, '');
if (!clean.startsWith('public/') || !HEALTH_REPORT_FILENAME_PATTERN.test(path.posix.basename(clean))) {
return false;
}
const publishDir = resolvePublishDir(h5Root, { id: String(userId) });
const target = path.join(publishDir, clean);
return fs.existsSync(target) && fs.statSync(target).isFile() && fs.statSync(target).size >= 64;
}
export function buildHealthReportPublicUrl({
h5Root = process.cwd(),
env = process.env,
userId,
username = null,
relativePath,
} = {}) {
return buildMindSpacePublicUrlForUser({
h5Root,
env,
user: { id: userId, username },
relativePath,
});
}