Files
memind/health-report-page.mjs
john 357e26ab49
Memind CI / Test, build, and release guards (pull_request) Failing after 7m12s
Memind CI / Test, build, and release guards (push) Has been cancelled
feat(seo-geo): 落盘自动补齐与 143 全量回填支持
Finish/交付写盘前自动回填 mindspace-geo,扩展 storage publications 扫描与 geo meta 解析,并增加 143 生产回填脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 10:26:02 +08:00

200 lines
7.6 KiB
JavaScript
Raw Permalink 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 { prepareMindspacePublicHtmlForDisk } from './mindspace-seo-geo-disk.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 });
const preparedHtml = prepareMindspacePublicHtmlForDisk(String(html ?? ''), {
htmlFilePath: absolutePath,
});
fs.writeFileSync(absolutePath, preparedHtml, '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>
<meta name="description" content="${escapeHtml(`${title} · 基于确认健康记录生成的个人回顾页,仅供本人查看。`)}" />
<meta name="mindspace-geo" content='${JSON.stringify({
summary: `${title} · 基于确认健康记录生成的个人回顾摘要,仅供本人查看,不构成医疗诊断。`,
keywords: ['健康报告', 'MeMind Health', title],
faq: [
{ q: '这份健康报告包含什么?', a: '包含近阶段健康概况、指标记录与说明,基于用户确认过的健康数据生成。' },
{ q: '能否公开分享?', a: '不可以。健康报告属于加密健康档案区,外部访问须口令或登录。' },
],
}).replace(/'/g, '&#39;')}' />
<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,
});
}