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模式与提交的图片字节模式必须一致;清空损坏单元格后,其余图片定位及字节不变。补回缺失图片仍需用户提供原图,平台不能推断或借用相邻图片。