2baf29b3ae
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>
187 lines
6.7 KiB
JavaScript
187 lines
6.7 KiB
JavaScript
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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
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,
|
||
});
|
||
}
|