diff --git a/api/src/report-materials/workbook-compatibility.spec.ts b/api/src/report-materials/workbook-compatibility.spec.ts index dddc47c..42a4fa0 100644 --- a/api/src/report-materials/workbook-compatibility.spec.ts +++ b/api/src/report-materials/workbook-compatibility.spec.ts @@ -22,7 +22,91 @@ async function standardWorkbook() { return Buffer.from(await workbook.xlsx.writeBuffer()); } +const BROKEN_ID = 'ID_8E811861F79046DDAB7EDBD3AEE9DE89'; +const BROKEN_NODE = ``; + +async function wpsFixture() { + return JSZip.loadAsync(await convertWorkbookOutput(await standardWorkbook(), 'wps_cell_image')); +} + +async function addBrokenNode(zip: JSZip, referenced: boolean) { + const xml = await zip.file('xl/cellimages.xml')!.async('string'); + zip.file('xl/cellimages.xml', xml.replace('', `${BROKEN_NODE}`)); + if (referenced) { + const sheet = await zip.file('xl/worksheets/sheet1.xml')!.async('string'); + zip.file( + 'xl/worksheets/sheet1.xml', + sheet.replace( + '', + `_xlfn.DISPIMG("${BROKEN_ID}",1)`, + ), + ); + } +} + describe('WPS workbook compatibility', () => { + describe.each([false, true])('includeImageData=%s', (includeImageData) => { + it('ignores an unreferenced damaged node without stealing the next image or shifting a blank cell', async () => { + const zip = await wpsFixture(); + await addBrokenNode(zip, false); + const sheet = await zip.file('xl/worksheets/sheet1.xml')!.async('string'); + zip.file('xl/worksheets/sheet1.xml', sheet.replace(' { + const zip = await wpsFixture(); + await addBrokenNode(zip, true); + await expect( + loadCompatibleWorkbook(await zip.generateAsync({ type: 'nodebuffer' }), { includeImageData }), + ).rejects.toThrow('工作表【签名报备】B3的WPS图片引用缺失或不唯一'); + }); + + it('rejects duplicate image IDs even when the later node is damaged', async () => { + const zip = await wpsFixture(); + const xml = await zip.file('xl/cellimages.xml')!.async('string'); + const id = xml.match(/name="(ID_[A-F0-9]{32})"/)![1]; + const duplicate = BROKEN_NODE.replace(BROKEN_ID, id); + zip.file('xl/cellimages.xml', xml.replace('', `${duplicate}`)); + await expect( + loadCompatibleWorkbook(await zip.generateAsync({ type: 'nodebuffer' }), { includeImageData }), + ).rejects.toThrow('工作表【签名报备】A2的WPS图片ID重复'); + }); + + it('reports a missing media file with the exact worksheet and cell', async () => { + const zip = await wpsFixture(); + for (const name of Object.keys(zip.files).filter((name) => name.startsWith('xl/media/'))) zip.remove(name); + await expect( + loadCompatibleWorkbook(await zip.generateAsync({ type: 'nodebuffer' }), { includeImageData }), + ).rejects.toThrow('工作表【签名报备】A2的WPS图片文件缺失'); + }); + + it.each(['missing', 'duplicate', 'external'])('rejects %s image relationships', async (kind) => { + const zip = await wpsFixture(); + const path = 'xl/_rels/cellimages.xml.rels'; + const xml = await zip.file(path)!.async('string'); + const relationship = xml.match(/]*\/>/)![0]; + zip.file( + path, + kind === 'missing' + ? xml.replace(relationship, '') + : kind === 'duplicate' + ? xml.replace(relationship, relationship + relationship) + : xml.replace(relationship, relationship.replace('/>', ' TargetMode="External"/>')), + ); + await expect( + loadCompatibleWorkbook(await zip.generateAsync({ type: 'nodebuffer' }), { includeImageData }), + ).rejects.toThrow('工作表【签名报备】A2的WPS图片引用缺失或不唯一'); + }); + }); + it('converts a standard Drawing image to DISPIMG and reads it back from cellimages.xml', async () => { const converted = await convertWorkbookOutput(await standardWorkbook(), 'wps_cell_image'); const zip = await JSZip.loadAsync(converted); diff --git a/api/src/report-materials/workbook-compatibility.ts b/api/src/report-materials/workbook-compatibility.ts index 8bf7467..6c5b52c 100644 --- a/api/src/report-materials/workbook-compatibility.ts +++ b/api/src/report-materials/workbook-compatibility.ts @@ -82,17 +82,32 @@ async function inspectWpsImages(zip: JSZip, includeImageData: boolean) { }; const relXml = await text(zip, 'xl/_rels/cellimages.xml.rels'); const relTargets = new Map(); + const seenRelations = new Set(); for (const match of relXml.matchAll(/]*)\/?>(?:<\/Relationship>)?/g)) { const attrs = attributes(match[1]); - if (attrs.Id && attrs.Target && /\/image$/.test(attrs.Type ?? '')) + if (seenRelations.has(attrs.Id)) { + relTargets.delete(attrs.Id); + continue; + } + seenRelations.add(attrs.Id); + if (attrs.Id && attrs.Target && attrs.TargetMode !== 'External' && /\/image$/.test(attrs.Type ?? '')) relTargets.set(attrs.Id, packagePath(attrs.Target)); } const imageTargets = new Map(); - for (const match of cellImagesXml.matchAll( - /]*>[\s\S]*?]*)\/?>(?:[\s\S]*?)]*)\/?>(?:[\s\S]*?)<\/etc:cellImage>/g, - )) { - const id = attributes(match[1]).name; - const target = relTargets.get(attributes(match[2])['r:embed']); + const seenImageIds = new Set(); + const duplicateImageIds = new Set(); + // Bound every lookup to one node: a damaged node must never consume its neighbour's blip. + for (const match of cellImagesXml.matchAll(/]*?(?:\/>|>([\s\S]*?)<\/etc:cellImage>)/g)) { + const node = match[1] ?? ''; + const id = attributes(node.match(/]*)>/)?.[1] ?? '').name; + if (!id) continue; + if (seenImageIds.has(id)) { + duplicateImageIds.add(id); + imageTargets.delete(id); + continue; + } + seenImageIds.add(id); + const target = relTargets.get(attributes(node.match(/]*)>/)?.[1] ?? '')['r:embed']); if (id && target) imageTargets.set(id, target); } const workbookXml = await text(zip, 'xl/workbook.xml'); @@ -112,16 +127,21 @@ async function inspectWpsImages(zip: JSZip, includeImageData: boolean) { if (!attrs.name || !path) continue; const sheetXml = await text(zip, path); const images: Array = []; - for (const cell of sheetXml.matchAll(/]*)>([\s\S]*?)<\/c>/g)) { - const formulaText = decodeXml(cell[2].match(/]*)?>([\s\S]*?)<\/f>/)?.[1]?.trim() ?? ''); + for (const cell of sheetXml.matchAll(/]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) { + const formulaText = decodeXml((cell[2] ?? '').match(/]*)?>([\s\S]*?)<\/f>/)?.[1]?.trim() ?? ''); if (!formulaText) continue; const formula = DISPIMG_FORMULA.exec(formulaText); if (!formula) throw new BadRequestException(`工作簿包含不允许的公式:${formulaText.slice(0, 80)}`); const target = imageTargets.get(formula[1]); - const cellPosition = coordinates(attributes(cell[1]).r); - if (!target || !cellPosition) throw new BadRequestException('WPS单元格图片关系不完整'); + const address = attributes(cell[1]).r ?? ''; + const cellPosition = coordinates(address); + const location = `工作表【${attrs.name}】${cellPosition ? address : '未知单元格'}`; + if (duplicateImageIds.has(formula[1])) + throw new BadRequestException(`${location}的WPS图片ID重复,无法确定对应图片,请重新插入图片`); + if (!cellPosition) throw new BadRequestException(`${location}的WPS图片单元格地址无效`); + if (!target) throw new BadRequestException(`${location}的WPS图片引用缺失或不唯一,请重新插入图片或清空该单元格`); const imageEntry = zip.file(target); - if (!imageEntry) throw new BadRequestException('WPS单元格图片文件缺失'); + if (!imageEntry) throw new BadRequestException(`${location}的WPS图片文件缺失,请重新插入图片或清空该单元格`); const extension = normalizeImageExtension(target.split('.').pop() ?? 'png'); if (!['png', 'jpeg', 'gif'].includes(extension)) throw new BadRequestException('WPS单元格图片格式不受支持'); const size = Number( diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index ef38009..68bc818 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5193,3 +5193,12 @@ npm run verify:phase8 - SMR-030/031/033/035:测试环境三尺寸真实API/PG页面及预生产三尺寸只读通过;通用空阈值、名称/类型列表筛选、远程对象搜索、签名/应用联动、草稿保护、刷新路由和错误重试有执行记录。 - SMR-032/034:测试环境真实停用覆盖v1→并发v2→旧表单409→重新加载保存v3→恢复继承v4通过,当前与待生效提示、过滤条件和已持久化恢复配置均核验;预生产不进行规则写入。 - 软删除历史、20+3分页、继承优先级、事务审计失败回滚等15项以真实Prisma隔离schema验证;测试替身只用于105项前端与639项API自动回归,不代替线上结论。匿名401/非法查询400、单独网络失败展示测试与正常真实API验收分开记录。 + +### TC-REPORT-WPS-006~009 图片关系节点边界(2026-09-07) + +| 编号 | 场景 | 预期 | +| --- | --- | --- | +| TC-REPORT-WPS-006 | 无blip的残留cellImage紧邻完整图片节点;正常图片单元格前有自闭合空单元格;残留节点未被引用 | 分析及提交解析均成功,正常图片单元格位置与图片字节保持,不能串位或误配到残留ID | +| TC-REPORT-WPS-007 | 上述残留节点仍被另一单元格DISPIMG引用 | 两种模式均拒绝,提示实际损坏单元格及工作表、重新插入/清空建议;不得误报相邻正常图片,不进行部分业务落库 | +| TC-REPORT-WPS-008 | 已引用图片ID重复,或图片relationship缺失/重复/External,或包内媒体缺失 | 明确拒绝并提示工作表及单元格;不后写覆盖重复ID、不读取外部图片、不静默跳过真实缺失图片 | +| TC-REPORT-WPS-009 | 真实故障文件及仅清空损坏单元格的内存副本,保留残留cellImage节点 | 原文件准确定位N7;内存副本33张有效图片位置与独立XML解析结果一致,提交模式33张SHA256全部匹配;原始文件保持不变,不据此冒充线上导入成功 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 47eeb05..213989b 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4679,3 +4679,12 @@ git diff --check - 预生产通过用户提供的现有管理员正常登录,未创建/重置账号;三尺寸通用规则、个性化空列表、远程应用搜索、编辑上下文、取消返回和刷新验收通过。原有2条规则(验证码通用启用、整体通用已恢复继承)及3条版本保持,未带入测试规则/演示告警。首次公网浏览器出现ERR_CONNECTION_CLOSED;匿名连通性复核后以无代理Edge完成验收,未改服务器网络、TLS或安全设置,不能把该现象称认证失败或根因已定位。 - 最终基础设施:两环境主JS index-a_5GyNeX.js、公共runtime及主CSS HTTP200且摘要等于运行dist;13项检查服务active(测试postgresql为aggregate单元,实际数据库查询正常)。测试短信119509/Submit130769/签名55/引流1;预生产87287/87428/179/11,企业/余额/通道/应用配置摘要与切换前一致。三个业务Stream pending/lag0、水位与此前只读基线一致:测试commands1787806802603-0/results1787806917609-0/protocol1787806802967-0;预生产commands1788690572448-2/results1788696610600-0/protocol1788696610646-0。初版临时巡检误用cmpp:*未匹配Stream,已按实际gateway.*重读;不以空对象证明队列正常。健康检查complete=true/pendingReceipts0/poolMax2,Inbox无待处理、Outbox均published;API本次启动后journal无error。 - 本机证据%TEMP%/cmpp-rule-manager-20260906:test-browser.json/test-writes.json/test-extra.json/preprod-browser.json、三尺寸截图、测试/构建日志;服务器恢复点保存before/immediate-before/after及release-*日志、最终infra与精确文件清单。浏览器账号凭据未入库/文档,验收会话退出;预生产验收只读,测试保存链路已恢复继承。未执行真实发送、高峰压测、实际数据恢复或预生产规则写入。 + +## 2026-09-07 WPS损坏图片与相邻节点误配修复(本地提交) + +- 本轮授权修改并本地提交,不推送、不部署、不重新导入/审核资料。起始main/HEAD/实时origin/main为f885f0b,暂存空;9份已有文档修改、3份未跟踪文件继续保护,仅精确追加本节及对应测试用例。 +- 前一轮只读诊断已定位预生产最近5次失败均为05_生活缴费_原行169-192.xlsx不同保存版本,停在35%。MinIO读取字节数与FileObject大小一致。最新文件生活缴费N7(身份证列)对应cellImage无blip;之前版本为N174。原跨节点正则将后一有效图片rId11配给损坏ID,实际M2/M169正常图片被误报。邻近04文件78张图片可解析。原文件SHA256为9d58d535f858960df5c03673b9c4bba8c292761c4b84c1bc8ea3cfe68d3962cf。不是仅修代码就能补回身份证图片。 +- 修复仅涉及workbook-compatibility.ts:单节点边界读取、未引用残留可忽略、被引用损坏仍拒绝;重复图片ID/关系不再静默覆盖,External图片关系不接受;错误给出工作表及具体单元格;自闭合空单元格不能吞掉后一单元格公式。公式白名单、图片字节校验、分析/提交资源限制、数据库/队列/审核和权限流程保持,无前端/CSS/依赖/schema修改。 +- 新增14项回归,含metadata/字节两模式×未引用残留及自闭合单元格、实际损坏位置、重复图片ID、媒体缺失、关系缺失/重复/外部。定向17项通过;API全量60套653项通过(测试中的Redis/Prometheus不可用warning来自既有隔离场景)。API生产配置TypeScript/构建、增量Prettier/ESLint与diff检查通过。曾误用基础tsconfig执行含测试文件的全量tsc,因该配置未加载Jest全局类型失败;改按仓库tsconfig.build.json核验生产代码,测试文件由完整ts-jest回归校验,未为此改动既有类型配置。 +- 当前修复代码在本机读取此前只读取得的真实原始文件:两种模式均准确拒绝N7;只在内存清空N7公式、保留损坏cellImage节点后,两模式读取33张图片且不串位。独立Python ElementTree解析原始OOXML建立期望位置/图片SHA256,字节模式33张全部逐一匹配;原始文件摘要不变,未生成或上传替换业务表格。 +- 证据在本机%TEMP%/cmpp-wps-diagnosis-20260907:api-full.log、independent-image-hashes.json、real-file-verification.json及受控原始文件。真实客户文件不进入Git。此前浏览器只读状态核验尝试登录返回401,未取得该项浏览器证据;本轮未复试、重置账号或修改服务器。已依据PG持久失败记录、真实MinIO文件、运行解析器与本地修复解析结果完成复现/回归;新版本的线上API/Worker/浏览器验收待另行授权部署后执行,不将本地文件回归称为线上导入成功。 diff --git a/docs/wps-cell-image-import-export-plan-20260904.md b/docs/wps-cell-image-import-export-plan-20260904.md index ee7b16c..f44dbe2 100644 --- a/docs/wps-cell-image-import-export-plan-20260904.md +++ b/docs/wps-cell-image-import-export-plan-20260904.md @@ -461,3 +461,13 @@ GET /api/admin/report-materials/batches/:id/download - 最大风险不是 UI,而是错误放宽公式安全检查、ZIP 资源消耗、WPS 与 Excel 客户端兼容差异,以及把占位 Drawing 误认成业务图片。 最小充分范围是“自动读取 WPS 单元格图片 + 保留现有 Excel 导出 + 按需生成 WPS 变体”。不在本次加入图片压缩、通道默认格式、派生文件缓存、历史数据回写或更多办公格式。 + +## 13. 2026-09-07 图片关系解析边界修复 + +本节细化第5.3、9和10节,不放宽公式白名单、不改变审核/导入流程。真实故障文件中,一个没有blip的图片节点仍被身份证单元格引用;跨节点正则错误地将其后的正常图片关系配给该损坏ID,反而首先报告正常营业执照图片缺失。 + +- 按单个cellImage节点提取图片ID与blip,不得越过该节点的结束边界。自闭合空图片节点、空单元格也有独立边界,不能吞入下一节点或单元格。 +- 未被任何单元格引用的残留节点不阻断正常图片解析;仍被DISPIMG引用而ID、内部图片关系或媒体文件不可解析时必须拒绝,不能按空值导入。 +- 同一图片ID重复时不得后写覆盖;被引用的重复ID明确报错。重复relationship ID不得任取目标,外部图片关系不作为包内图片接受。 +- 错误明确给出工作表名、单元格地址及重新插图/清空建议;缺失引用、重复图片ID、缺失媒体分别说明。此层尚无字段映射上下文,不猜测字段名;例如“工作表【生活缴费】N7的WPS图片引用缺失或不唯一,请重新插入图片或清空该单元格”。 +- 分析的metadata模式与提交的图片字节模式必须一致;清空损坏单元格后,其余图片定位及字节不变。补回缺失图片仍需用户提供原图,平台不能推断或借用相邻图片。