Files
competition-website/app/lib/docx.js
qubiaobiao 5392345078 feat(score): R173/R174 大题成绩 Excel 导入(三小问四列制)+ 三节点备份调度器与四项修复
成绩导入(R173 落地 → R174 改为三小问四列制)
- app/lib/bigScoreImport.js(新增 · 判据唯一真相源 · 零依赖:不 require Egg / 不连库 / 不读文件)
  · R174 删除 splitScoreAcrossSubs(均分),改为 scoreSubs 逐项取值
  · 留空 SUB_MISSING(整行拒绝,⛔ 不按 0 分)· 非数值 SUB_INVALID · 超单问上限 SUB_OUT_OF_RANGE
  · 文件级 LEGACY_TEMPLATE(旧两列模板)与 SUB_COLUMNS_MISSING(缺一小问整列)⇒ 整份拒绝
  · 定位键必须带 group:(group, team_uid),因 team_uid 跨组重号
- app/service/bigQuestion.js
  · buildScoreTemplate 改四列 + 说明书重写(删均分段,补"三列必须全填"与"旧模板不再支持")
  · previewScoreImport 回带 subRaws/subs/total;importScores 逐项写 setSubScores
  · ⛔ 不新增第二条写成绩通路:仍只走 setSubScores → admin.updateScores
- app/lib/docx.js:run 支持 color(6 位 hex);w:color 必须排在 w:sz 之前(CT_RPr sequence)
- app/controller/admin.js + app/router.js:import-template / import-preview(零写入)/ import-scores
  · 上传内存解析不落盘(app/public 对静态服务开放 ⇒ 成绩表可被猜名下载)

节点备份调度器(R168/R170)
- app/lib/nodeBackupScheduler.js(新增):考试开始前 30min / 结束后 10min / 成绩发布瞬间
  · 幂等键 group:node:fireAtISO(时间点即身份);overdue 判定用 clamp:false 的数学时刻
  · 非生产 enabled 强制 false 且 fail-safe(与 DEPLOY_ENV_LOCAL_SKIP_PREFIXES 同源)
- config/config.default.js:config.nodeBackup 四项路径全走环境变量,默认值只覆盖本地
  (生产目录布局与仓库不一致,⛔ 不许用 __dirname 推导)
- app.js:启动构造单例 + hydrate 未到期计划

缺陷修复
- app/controller/admin.js:R161 临时文件路径移到 try 外声明,否则导入抛错即跳过清理
- app/service/admin.js:R166「已答 N / N 题」恒真显示(原 totalQuestions = answeredCount)
- app/service/upload.js + app/controller/upload.js:R157/R162 删除结果计数 + @no-temp-cleanup 标注

测试
- .gitignore + test/lib.test.js:R134 候选 ㊴ —— test/* + !test/*.test.js,
  使第 26 步裸 mocha 在干净检出上可复现(改前 rc=1 ↔ 改后 23 passing)
2026-10-19 13:03:48 +08:00

491 lines
18 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.
'use strict';
/**
* 极简 WordprocessingML(.docx) 生成器(2026-09-23)
*
* 用途:把综合大题(`big_questions`)导出为**真正的 .docx**,供管理员留存 / 打印 / 分发。
*
* 为什么不用第三方 docx 库:后端已有 `archiver`(service/admin.js 的批量下载在用),
* 而 .docx 本质就是一个 ZIP + 几个 XML part ⇒ 直接手写 OOXML,**新增零依赖**、
* 也避免引入未经验证的库影响既有交卷链路。
*
* 生成的包结构(最小合法 OOXML):
* [Content_Types].xml
* _rels/.rels
* word/document.xml
* word/styles.xml
* word/_rels/document.xml.rels
*
* 支持的 HTML 子集(实测题干只用到这些:p / table / tbody / tr / td / strong / span / a / br):
* 块级:p、div、h1–h6、li、ul/ol、blockquote、pre、hr
* 表格:table(含 tbody/thead/tfoot 包裹)、tr、td/th(th 加粗)
* 行内:strong/b(加粗)、em/i(斜体)、u、span、a(只取文字)、br
* 样式:仅解析 `text-align`(对齐)与 `text-indent`(首行缩进,pt→twips)
* 实体:  < > & " ' '
* 忽略:script / style / img(图片不外链,避免生成"打不开的图片占位")
*
* ⛔ 已知取舍:嵌套表格会被扁平化;图片不导出。
*/
// ==================== XML / 实体 ====================
function decodeEntities(str) {
return String(str == null ? '' : str)
.replace(/ /gi, ' ')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#0*39;/g, "'")
.replace(/&apos;/gi, "'")
.replace(/&#(\d+);/g, (m, d) => {
const code = Number(d);
return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : m;
})
// ⚠️ &amp; 必须放最后:否则 `&amp;lt;` 会被二次解码成 `<`
.replace(/&amp;/gi, '&');
}
/** XML 文本转义(在 decodeEntities 之后调用,顺序不可颠倒) */
function esc(str) {
return String(str == null ? '' : str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ==================== 极简 HTML 解析(容错栈式) ====================
const VOID_TAGS = new Set(['br', 'hr', 'img', 'meta', 'link', 'input', 'col', 'area', 'base', 'source', 'wbr']);
/**
* 词法正则。
* ⚠️ 必须用**具名捕获组**:早期版本用「按出现顺序数下标」(m[1]..m[8]),
* 但多个 `|` 分支各自贡献捕获组,实际只有 5 组 ⇒ 下标全部错位 ⇒
* 所有标签被当成文本、块序列恒为空(静默产出空文档,无任何报错)。
* 具名组不受分支数量影响,从根上消除这类错位。
*/
const TOKEN_RE = /<!--[\s\S]*?-->|<![^>]*>|<\?[\s\S]*?\?>|<\/\s*(?<close>[a-zA-Z][a-zA-Z0-9:-]*)\s*>|<(?<open>[a-zA-Z][a-zA-Z0-9:-]*)(?<attrs>(?:"[^"]*"|'[^']*'|[^>"'])*?)(?<self>\/?)>|(?<text>[^<]+)/g;
const ATTR_RE = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
function parseAttrs(raw) {
const attrs = {};
if (!raw) return attrs;
ATTR_RE.lastIndex = 0;
let m;
while ((m = ATTR_RE.exec(raw))) {
attrs[m[1].toLowerCase()] = m[2] !== undefined ? m[2] : (m[3] !== undefined ? m[3] : m[4]);
}
return attrs;
}
/**
* 把 HTML 片段解析为节点树。
* ⛔ 容错优先:标签不闭合 / 交叉嵌套时按栈就近回退,绝不抛异常
* (题干是管理员手工粘贴的富文本,脏 HTML 不能导致整次导出失败)。
*/
function parseHtml(html) {
const root = { type: 'node', tag: '#root', attrs: {}, children: [] };
const stack = [root];
const src = String(html == null ? '' : html);
TOKEN_RE.lastIndex = 0;
let m;
while ((m = TOKEN_RE.exec(src))) {
const g = m.groups || {};
if (g.text !== undefined) {
stack[stack.length - 1].children.push({ type: 'text', value: g.text });
continue;
}
if (g.close !== undefined) {
// 闭标签:从栈顶向下找同名节点;找不到就忽略(脏 HTML 不抛异常)
const name = g.close.toLowerCase();
for (let i = stack.length - 1; i > 0; i--) {
if (stack[i].tag === name) {
stack.length = i;
break;
}
}
continue;
}
if (g.open !== undefined) {
const tag = g.open.toLowerCase();
const node = { type: 'node', tag, attrs: parseAttrs(g.attrs), children: [] };
stack[stack.length - 1].children.push(node);
if (!VOID_TAGS.has(tag) && g.self !== '/') stack.push(node);
continue;
}
// 注释 / doctype / 处理指令:忽略
}
return root;
}
// ==================== 节点树 → 中间块序列 ====================
const BLOCK_TAGS = new Set([
'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'ul', 'ol',
'blockquote', 'pre', 'section', 'article', 'hr', 'dl', 'dt', 'dd',
]);
const SKIP_TAGS = new Set(['script', 'style', 'head', 'meta', 'link', 'title', 'img']);
/** 标题级别 → 半磅字号(21 半磅 = 10.5pt = 五号,正文基准) */
const HEADING_SIZE = { h1: 32, h2: 28, h3: 26, h4: 24, h5: 22, h6: 21 };
const HEADING_BOLD = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
const INLINE_BOLD = new Set(['strong', 'b', 'th']);
const INLINE_ITALIC = new Set(['em', 'i']);
function styleOf(node) {
return (node && node.attrs && node.attrs.style) || '';
}
/** 从 style 里取段落对齐(仅映射 Word 支持的四种) */
function alignOf(node) {
const s = styleOf(node);
const m = /text-align\s*:\s*(left|right|center|justify)/i.exec(s);
return m ? m[1].toLowerCase() : '';
}
/** 从 style 里取首行缩进(pt → twips,1pt = 20twips) */
function firstLineTwips(node) {
const s = styleOf(node);
const m = /text-indent\s*:\s*(-?\d+(?:\.\d+)?)\s*pt/i.exec(s);
if (!m) return 0;
const pt = Number(m[1]);
return Number.isFinite(pt) && pt > 0 ? Math.round(pt * 20) : 0;
}
/**
* 深度优先遍历,产出「块」序列:
* { type:'p', runs:[...], align, firstLine }
* { type:'table', rows:[{ cells:[{ blocks:[...] }] }] }
* run 形态:{ text, bold, italic, size, color } 或 { br: true }
* color 为 6 位十六进制(可带 `#` 前缀),如 'FF0000' / '#FF0000';非法值忽略。
*/
function walk(nodes, fmt, out) {
const flush = () => {
if (out.cur.length) {
out.blocks.push(Object.assign({ type: 'p', runs: out.cur }, out.curMeta));
out.cur = [];
out.curMeta = {};
}
};
for (const n of nodes) {
if (n.type === 'text') {
// HTML 源码里的换行/缩进属排版噪声,须丢弃;
// 但 `&nbsp;` 是作者有意的占位空格(题干排版大量使用),必须保留 ⇒ 用原始值判别
if (!/\S/.test(n.value) && !/&nbsp;/i.test(n.value)) continue;
// 原始换行/制表压成单空格:真实换行只由 <br> 与段落边界产生
const text = decodeEntities(n.value).replace(/[\r\n\t]+/g, ' ');
if (text === '') continue;
out.cur.push({ text, bold: !!fmt.bold, italic: !!fmt.italic });
continue;
}
const tag = n.tag;
if (SKIP_TAGS.has(tag)) continue;
if (tag === 'br') {
out.cur.push({ br: true });
continue;
}
if (tag === 'table') {
flush();
out.blocks.push({ type: 'table', rows: extractRows(n) });
continue;
}
if (tag === 'hr') {
flush();
out.blocks.push({ type: 'p', runs: [] });
continue;
}
if (tag === 'li') {
flush();
out.curMeta = Object.assign({}, out.curMeta, { align: alignOf(n) || out.curMeta.align, firstLine: 0 });
out.cur.push({ text: '• ', bold: false, italic: false });
walk(n.children, fmt, out);
// 若不 flush,下一条 li 会并入同一段 ⇒ 显式收尾
const last = out.blocks[out.blocks.length - 1];
if (last && last.type === 'p' && last.runs.length && last.runs[0].text === '• ') flush();
continue;
}
if (BLOCK_TAGS.has(tag)) {
flush();
const nextFmt = HEADING_BOLD.has(tag)
? Object.assign({}, fmt, { bold: true, size: HEADING_SIZE[tag] })
: fmt;
out.curMeta = { align: alignOf(n), firstLine: firstLineTwips(n) };
walk(n.children, nextFmt, out);
flush();
continue;
}
// 行内标签
const nextFmt = Object.assign({}, fmt);
if (INLINE_BOLD.has(tag)) nextFmt.bold = true;
if (INLINE_ITALIC.has(tag)) nextFmt.italic = true;
walk(n.children, nextFmt, out);
}
flush();
}
/** 提取表格行/单元格(容忍 tbody / thead / tfoot 包裹;嵌套表格扁平化到单元格文本) */
function extractRows(tableNode) {
const rows = [];
const collect = (nodes) => {
for (const n of nodes) {
if (n.type !== 'node') continue;
if (n.tag === 'tr') {
const cells = [];
for (const c of n.children) {
if (c.type !== 'node') continue;
if (c.tag !== 'td' && c.tag !== 'th') continue;
const cellOut = { blocks: [], cur: [], curMeta: {} };
walk(c.children, { bold: c.tag === 'th', italic: false }, cellOut);
cells.push({ blocks: cellOut.blocks, header: c.tag === 'th' });
}
rows.push({ cells });
} else if (n.tag === 'tbody' || n.tag === 'thead' || n.tag === 'tfoot' || n.tag === 'table') {
collect(n.children);
}
}
};
collect(tableNode.children);
return rows;
}
/** HTML → 块序列(对外唯一入口) */
function htmlToBlocks(html) {
const out = { blocks: [], cur: [], curMeta: {} };
walk(parseHtml(html).children, { bold: false, italic: false }, out);
return out.blocks;
}
/** 纯文本 → 单块(自动按空行/换行切段) */
function textToBlocks(text) {
const s = String(text == null ? '' : text);
if (!s.trim()) return [];
return s
.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line !== '')
.map(line => ({ type: 'p', runs: [{ text: line, bold: false, italic: false }] }));
}
// ==================== 块 → WordprocessingML ====================
const W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
/**
* 规范化 run 的颜色值。
*
* ⛔ 只接受 6 位十六进制(可带 `#`),其余一律返回 '':
* OOXML 的 `w:val` 若被填入 `red` / `#F00` / 任意字符串,Word 会拒绝打开整个文档,
* 而调用方(导出服务)无法感知 —— 表现为「导出的 docx 打不开」,排查成本极高。
*
* @param {*} v
* @returns {string} 6 位大写十六进制或 ''
*/
function normalizeColor(v) {
if (v === null || v === undefined) return '';
const s = String(v).trim().replace(/^#/, '');
return /^[0-9a-fA-F]{6}$/.test(s) ? s.toUpperCase() : '';
}
function rPrOf(run) {
const parts = [];
if (run.bold) parts.push('<w:b/><w:bCs/>');
if (run.italic) parts.push('<w:i/><w:iCs/>');
parts.push('<w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:eastAsia="宋体" w:cs="Times New Roman"/>');
// ⚠️ 顺序硬约束:CT_RPr 是 sequence,`w:color` 必须排在 rFonts 之后、`w:sz` 之前。
// 顺序错乱(如放到 sz 之后)Word 同样会判文档损坏 —— 与「pPr 必须先于 runs」同理。
const color = normalizeColor(run.color);
if (color) parts.push(`<w:color w:val="${color}"/>`);
const sz = Number(run.size) > 0 ? Number(run.size) : 21;
parts.push(`<w:sz w:val="${sz}"/><w:szCs w:val="${sz}"/>`);
return `<w:rPr>${parts.join('')}</w:rPr>`;
}
function renderRun(run) {
if (run.br) return '<w:r><w:br/></w:r>';
const text = String(run.text == null ? '' : run.text);
if (text === '') return '';
return `<w:r>${rPrOf(run)}<w:t xml:space="preserve">${esc(text)}</w:t></w:r>`;
}
function pPrOf(block, extra) {
const parts = [];
const align = block.align || '';
if (align) parts.push(`<w:jc w:val="${align === 'justify' ? 'both' : align}"/>`);
const fl = Number(block.firstLine) > 0 ? Number(block.firstLine) : 0;
if (fl) parts.push(`<w:ind w:firstLine="${fl}"/>`);
if (extra) parts.push(extra);
return parts.length ? `<w:pPr>${parts.join('')}</w:pPr>` : '';
}
/** 段落。⛔ Word 要求 pPr 必须排在 runs 之前(顺序颠倒会导致文档被判损坏) */
function renderParagraph(block) {
const runs = (block.runs || []).map(renderRun).join('');
return `<w:p>${pPrOf(block)}${runs}</w:p>`;
}
const TBL_BORDERS = '<w:tblBorders>'
+ ['top', 'left', 'bottom', 'right', 'insideH', 'insideV']
.map(side => `<w:${side} w:val="single" w:sz="4" w:space="0" w:color="auto"/>`)
.join('')
+ '</w:tblBorders>';
function renderTable(block) {
const rows = block.rows || [];
if (!rows.length) return '';
const cellCount = Math.max(...rows.map(r => r.cells.length), 1);
const trs = rows.map(r => {
const tcs = [];
for (let i = 0; i < cellCount; i++) {
const cell = r.cells[i];
let inner;
if (!cell || !cell.blocks.length) {
inner = '<w:p/>';
} else {
inner = cell.blocks.map(b => (b.type === 'table'
// 嵌套表扁平化为文本,避免非法嵌套
? renderParagraph({ runs: flattenTableText(b) })
: renderParagraph(b))).join('');
}
tcs.push(`<w:tc><w:tcPr><w:tcW w:w="0" w:type="auto"/></w:tcPr>${inner}</w:tc>`);
}
return `<w:tr>${tcs.join('')}</w:tr>`;
}).join('');
return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/>${TBL_BORDERS}</w:tblPr>${trs}</w:tbl>`;
}
function flattenTableText(tableBlock) {
const buf = [];
for (const r of tableBlock.rows || []) {
const cells = [];
for (const c of r.cells || []) {
const texts = [];
for (const b of c.blocks || []) {
if (b.type === 'p') texts.push((b.runs || []).map(x => (x.br ? ' ' : x.text)).join(''));
}
cells.push(texts.join(' ').trim());
}
buf.push({ br: false, text: cells.join(' | ') });
}
const runs = [];
buf.forEach((x, i) => {
if (i > 0) runs.push({ br: true });
runs.push({ text: x.text, bold: false, italic: false });
});
return runs;
}
function buildDocumentXml(blocks) {
const parts = [];
for (const b of blocks) {
if (b.type === 'table') {
const t = renderTable(b);
if (t) {
parts.push(t);
// 表后补空段:Word 中「表格紧邻表格/节尾」易被判异常
parts.push('<w:p/>');
}
} else {
parts.push(renderParagraph(b));
}
}
if (!parts.length) parts.push('<w:p/>');
parts.push('<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="851" w:footer="992" w:gutter="0"/></w:sectPr>');
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
+ `<w:document xmlns:w="${W_NS}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">`
+ `<w:body>${parts.join('')}</w:body></w:document>`;
}
// ==================== 打包为 .docx ====================
const CONTENT_TYPES = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
+ '<Default Extension="xml" ContentType="application/xml"/>'
+ '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
+ '<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>'
+ '</Types>';
const ROOT_RELS = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
+ '</Relationships>';
const DOC_RELS = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'
+ '</Relationships>';
const STYLES_XML = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
+ `<w:styles xmlns:w="${W_NS}">`
+ '<w:docDefaults><w:rPrDefault><w:rPr>'
+ '<w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:eastAsia="宋体" w:cs="Times New Roman"/>'
+ '<w:sz w:val="21"/><w:szCs w:val="21"/>'
+ '</w:rPr></w:rPrDefault></w:docDefaults>'
+ '<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>'
+ '</w:styles>';
/**
* 把若干 part 打成一个 ZIP Buffer。
* ⚠️ archiver v8 为 ESM:沿用 service/admin.js:754 已验证的 `const { ZipArchive } = require('archiver')` 写法。
*/
function zipParts(parts) {
return new Promise((resolve, reject) => {
let ZipArchive;
try {
({ ZipArchive } = require('archiver'));
} catch (e) {
reject(new Error('缺少 archiver 依赖,无法生成 .docx'));
return;
}
const chunks = [];
const archive = new ZipArchive({ zlib: { level: 9 } });
archive.on('error', reject);
archive.on('data', c => chunks.push(c));
archive.on('end', () => resolve(Buffer.concat(chunks)));
for (const p of parts) archive.append(Buffer.from(p.data, 'utf8'), { name: p.name });
archive.finalize();
});
}
/**
* 生成单个 .docx
* @param {Array} blocks - htmlToBlocks / textToBlocks 的产物(可多个数组按顺序拼接)
* @returns {Promise<Buffer>}
*/
async function buildDocx(blocks) {
const list = ([]).concat(blocks || []).filter(Boolean);
const flat = [];
for (const b of list) {
if (Array.isArray(b)) flat.push(...b);
else flat.push(b);
}
return zipParts([
{ name: '[Content_Types].xml', data: CONTENT_TYPES },
{ name: '_rels/.rels', data: ROOT_RELS },
{ name: 'word/document.xml', data: buildDocumentXml(flat) },
{ name: 'word/styles.xml', data: STYLES_XML },
{ name: 'word/_rels/document.xml.rels', data: DOC_RELS },
]);
}
/** 把多个 .docx 打成一个 ZIP(批量/全部导出用),返回 Buffer */
async function zipDocxFiles(entries) {
return zipParts(entries.map(e => ({ name: e.name, data: e.buffer })));
}
module.exports = {
buildDocx,
zipDocxFiles,
htmlToBlocks,
textToBlocks,
decodeEntities,
esc,
// 导出内部件便于单测(`scripts/verify_big_score_import.py` 会断言 color 渲染与序列位置)
_internal: { parseHtml, buildDocumentXml, rPrOf, normalizeColor, CONTENT_TYPES, ROOT_RELS, STYLES_XML, DOC_RELS },
};