Files
cms-website/apps/api/src/modules/import/report-html.ts
T
浅念 a7177afde7 feat(import): HTML 报告与错误封顶;orchestrator 抽取
- report-html.ts:renderImportReportHtml(Plan 2 Task 6 模板纪律——全动态文本
  转义/静态骨架无 script/分组固定序渲染/封顶注记),import.html 与 import.json
  同 try 原子落盘(同 REPORT_WRITE_FAILED 失败口径)
- 报告 issues 封顶(Task 6 推迟项):REPORT_ISSUES_CAP=10k——flat 清单与
  issueGroups 各组同限截断 + issuesTruncated 计数;终态判定(succeeded_with_issues)
  在全量长度上做,截断不吞终态事实;counts/sanitize 始终完整
- orchestrator.ts 抽取(审查触发器 import.service 849 行):orchestratedPipeline/
  mergeOutcomes/sumCounts/trySyncFinish 迁出为纯函数 over ctx;契约类型单源
  import.service.ts 不动;既有测试全绿即证零行为变更
- 测试 +8:单元 7(恶意 payload 转义/结构/分组序/封顶注记/渲染不改输入)+
  集成 1(10_003 条注入:截断计数/JSON 原文保留/HTML 转义/终态判定);333 全绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-21 21:20:12 +08:00

156 lines
6.2 KiB
TypeScript
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.
// 导入报告 HTML 渲染(Plan 3 Task 8——Task 6 推迟项兑现):沿用 Plan 2 Task 6 模板纪律
// (@sf/adapter-wp 的 renderReportHtml 同款):
// - 对象层(import.json)保留**原文**(issue 字符串未转义)——JSON 归档/机器消费的语义层;
// - 渲染层对**所有**动态文本经 escapeHtml(<>&"' 全转义)——报告的动态文本来自不可信
// WXR 内容(行级问题携带清洗前 excerpt 形态,可含 `<script src="x">`),未转义渲染会使
// 报告本身成为存储型 XSS 载体(Plan 2 Task 5 审查移交纪律的导入侧续录);
// - 静态骨架无 script/事件属性;计数表键按字典序渲染(对象键序不保证,渲染须确定)。
//
// escapeHtml 与 adapter-wp/report.ts 的私有实现同形独立维护(17 行纯函数)——不为此扩
// @sf/adapter-wp 公共导出面;两处若任一改判集,由对抗测试各自护栏(本文件测试钉恶意
// payload 转义)。
import type { ImportReport, SnapshotRefSlim } from './import.service.js'
/** HTML 转义(渲染层唯一动态文本通道):<>&"' 全转义,& 最先避免二次转义陷阱 */
function escapeHtml(text: string): string {
return text.replace(/[&<>"']/g, (ch) => {
switch (ch) {
case '&':
return '&amp;'
case '<':
return '&lt;'
case '>':
return '&gt;'
case '"':
return '&quot;'
default:
return '&#39;'
}
})
}
const esc = escapeHtml
/** 分组渲染顺序(固定——对象键序不保证,人类视图须确定) */
const GROUP_ORDER = ['content', 'term', 'media', 'route', 'adapter', 'orchestrator'] as const
const GROUP_LABELS: Record<(typeof GROUP_ORDER)[number], string> = {
content: '内容(content-writer)',
term: '分类(term-writer)',
media: '媒体(media-writer)',
route: '路由(route-writer)',
adapter: '适配器(行级/unsupported/error)',
orchestrator: '编排器(orchestrator)',
}
function snapshotLine(label: string, ref: SnapshotRefSlim | null): string {
if (ref === null) return `<li>${label}:(无)</li>`
return `<li>${label}:${esc(ref.id)}(${esc(ref.kind)},sha256=${esc(ref.sha256.slice(0, 12))}…,${ref.byteSize} bytes)</li>`
}
/**
* 导入报告 → HTML 片段(结构:概览/快照/版本/路径/计数表/清洗统计/问题清单/错误/耗时)。
* 问题清单优先渲染分组视图(编排管道恒产 issueGroups——六组按固定序);分组缺席
* (注入管道的 Task 1 形态)回退 flat 清单。封顶溢出以 issuesTruncated 注记呈现。
*/
export function renderImportReportHtml(report: ImportReport): string {
const lines: string[] = []
lines.push('<h2>导入报告</h2>')
lines.push('<h3>概览</h3>')
lines.push(
`<ul><li>job: ${esc(report.jobId)}</li><li>状态: ${esc(report.status)}</li>` +
`<li>站点: ${esc(report.siteKey)}(${esc(report.siteId)})</li><li>connection: ${esc(report.connectionId)}</li>` +
`<li>幂等键: ${esc(report.idempotencyKey)}</li><li>attempt: ${report.attempt}</li>` +
`<li>生成时间: ${esc(report.generatedAt)}</li></ul>`,
)
lines.push('<h3>快照</h3>')
lines.push(
`<ul>${snapshotLine('wxr(主输入)', report.snapshots.wxr)}` +
`${snapshotLine('media_bundle', report.snapshots.media_bundle)}` +
`${snapshotLine('manual_url_list', report.snapshots.manual_url_list)}</ul>`,
)
lines.push('<h3>版本</h3>')
lines.push(
`<ul><li>adapter: ${esc(report.versions.adapter)}</li>` +
`<li>mapping: ${esc(report.versions.mapping)}</li>` +
`<li>sanitizer: ${esc(report.versions.sanitizer)}</li>` +
`<li>parser: ${esc(report.versions.parser)}</li></ul>`,
)
lines.push('<h3>路径</h3>')
lines.push(
`<ul><li>productsPath: ${esc(report.paths.productsPath)}</li>` +
`<li>newsPath: ${esc(report.paths.newsPath)}</li></ul>`,
)
lines.push('<h3>计数</h3>')
lines.push('<table><thead><tr><th>键</th><th>值</th></tr></thead><tbody>')
const keys = Object.keys(report.counts).sort()
if (keys.length === 0) {
lines.push('<tr><td colspan="2">(无)</td></tr>')
}
for (const key of keys) {
lines.push(`<tr><td>${esc(key)}</td><td>${report.counts[key]}</td></tr>`)
}
lines.push('</tbody></table>')
if (report.sanitize !== undefined) {
lines.push('<h3>清洗统计</h3>')
const diffTypes = Object.keys(report.sanitize.byDiffType).sort()
const diffs =
diffTypes.length === 0
? ''
: `<li>byDiffType: ${diffTypes.map((t) => `${esc(t)}=${report.sanitize!.byDiffType[t]}`).join('、')}</li>`
lines.push(`<ul><li>changed rows: ${report.sanitize.changedRows}</li>${diffs}</ul>`)
}
lines.push('<h3>问题清单</h3>')
const groups = report.issueGroups
if (groups !== undefined) {
for (const key of GROUP_ORDER) {
const groupIssues = groups[key]
if (groupIssues === undefined) continue
lines.push(`<h4>${esc(GROUP_LABELS[key])}(${groupIssues.length} 条)</h4>`)
if (groupIssues.length === 0) {
lines.push('<p>(无)</p>')
} else {
lines.push('<ul>')
for (const issue of groupIssues) {
lines.push(`<li>${esc(issue)}</li>`)
}
lines.push('</ul>')
}
}
} else if (report.issues.length === 0 && report.issuesTruncated === 0) {
lines.push('<p>(无)</p>')
} else {
lines.push('<ul>')
for (const issue of report.issues) {
lines.push(`<li>${esc(issue)}</li>`)
}
lines.push('</ul>')
}
if (report.issuesTruncated > 0) {
lines.push(`<p>(另有 ${report.issuesTruncated} 条超出固化上限,仅计数)</p>`)
}
if (report.error !== null) {
lines.push(
`<h3>错误</h3><ul><li>code: ${esc(report.error.code)}</li><li>summary: ${esc(report.error.summary)}</li></ul>`,
)
}
lines.push('<h3>耗时</h3>')
const adapterTiming =
report.adapterTiming === undefined
? ''
: `<li>adapter parse: ${report.adapterTiming.parseMs.toFixed(3)}ms</li>` +
`<li>adapter sanitize: ${report.adapterTiming.sanitizeMs.toFixed(3)}ms</li>`
lines.push(`<ul>${adapterTiming}<li>total: ${report.durationMs.toFixed(1)}ms</li></ul>`)
return lines.join('\n')
}