ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

帝国CMS如何完整保留Word修订痕迹:docx解析与双轨发布方案

帝国CMS如何完整保留Word修订痕迹:docx解析与双轨发布方案

去年给某金融单位的内网内容平台做改造,风控部门同事问了我一个特别具体的问题:他们每次把Word格式的风控报告改完,用帝国CMS后台编辑器直接粘贴发布之后,之前留下的修订痕迹全没了。如果只是看不见还好,关键是审计那边追问起来,谁在哪个版本改了什么、为什么这么改,全都没法说清楚。

这个问题看着像是“换个编辑器就能解决”的小事,实际拆开之后涉及文档格式、XML结构、发布流程和权限控制四件事。这篇文章把我当时踩过的坑、对比过的方案、最终落地的一套做法完整记录下来,适合所有用帝国CMS做内部文档管理、又需要保留Word修订痕迹的场景。

1. 先把问题说准:修订痕迹是在哪一步丢的

1.1 四类常见导入路径,每一步都在丢东西

我一开始也以为问题出在帝国CMS的编辑器上,后来把常见的导入方式全测了一遍,才发现“丢修订”不是某个环节的偶然问题,而是几乎所有路径都会丢,只是丢的程度不一样。

导入方式修订标记是否保留主要问题
复制全文,直接粘贴到后台编辑器不保留编辑器只接收可见内容和少量样式,修订结构没有载体
在Word里先另存为HTML,再把HTML粘贴进编辑器部分保留另存HTML默认输出“合并后的结果版本”,修订信息约等于被拍平
直接上传docx附件文件本身完整保留线上无法预览修订,浏览者必须下载后打开Word才能看到
转成PDF再上传只保留某个视图状态修订被固化成图形,不可检索、不可编辑,author和date信息丢失

最典型的坑就是第一种:风控专员在Word里把报告改得满篇红色,顺手全选、复制,切到帝国CMS后台编辑器,Ctrl+V,发布。最后页面上只剩下“改后的文字”,连删除线都看不到,更不用说“谁改的、什么时候改的”。

第二种方式迷惑性很大。很多人以为在Word里“另存为网页”总能保住标记吧?实际上Word默认导出的HTML是把修订合并之后的最终文本,修订的元数据基本不会跟着出去。就算设置了导出标记选项,最多是视觉上看到红色和下划线,author、date信息还是不完整。

第三种上传原文件最保险,但问题在于“能下载”不等于“能看”。金融行业的风控报告往往要经过多人审阅,你不能要求每一个查看的人都下载docx再用Word打开,更别说有些审批场景只允许在线预览。

1.2 “看到红字”不等于“保留修订痕迹”

这里必须先纠正一个误判:很多同事看到页面上有红色文字和删除线,就觉得修订保留成功了。放到普通内容场景也许够用,放在风控报告里远远不够。

真正的修订痕迹至少包含四个要素:

  • 改前的内容是什么,也就是被删除的部分
  • 改后的内容是什么,也就是新插入的部分
  • 谁做的修改,author属性
  • 什么时间做的修改,date属性

如果你的页面只能显示“有一段红字”,但问不出“这是谁在哪个版本里红的、原来是什么样”,那在审计和风控内审环节依然交不了差。所以我后来定义“保留修订痕迹”的标准是:既能视觉上区分插入和删除,又能追溯到修改人和修改时间。这个标准贯穿了后面所有的方案选择,也建议你一开始就按这个标准来要求自己。

2. 修订痕迹不是样式,是docx里带“户口”的XML

2.1 docx不过是个ZIP包,拆开看就明白了

如果没深入研究过Word文件格式,很容易把修订痕迹理解成“红色字体、删除线”这一类样式。实际上,Word的修订在docx文件里是一组结构化的XML标记,和“字体颜色”完全不在一个层次。

docx本身就是一个ZIP压缩包。你把任意一个.docx文件后缀改成.zip,解压之后就能看到word/document.xml、word/comments.xml、word/media这些内容。修订痕迹主要存在word/document.xml里面。下面是一段典型的修订结构示意:

<w:p> <w:r> <w:del w:id="13" w:author="某审阅人" w:date="2024-03-15T10:24:00Z"> <w:delText>原风险阈值</w:delText> </w:del> </w:r> <w:r> <w:ins w:id="14" w:author="某审阅人" w:date="2024-03-15T10:26:00Z"> <w:r> <w:t>调整后风险阈值</w:t> </w:r> </w:ins> </w:r> </w:p>

w:del表示一次删除操作,里面用w:delText保存被删掉的原文;w:ins表示一次插入操作,里面用w:t保存新增内容;w:author和w:date分别记录修改人和修改时间。批注不在document.xml里,而是单独存在word/comments.xml,通过commentRangeStart和commentRangeEnd两个标记和正文关联。

这个结构说明一件事:修订痕迹在Word内部是“有户口”的结构化数据,有修改人、修改时间、修改前后内容,而普通粘贴行为传递的只是一段拍平后的HTML文本,户口信息在剪贴板层面就没有对应的标准字段。

2.2 为什么粘贴到富文本编辑器会丢得干干净净

帝国CMS后台的编辑器,和市面上大多数富文本编辑器一样,只理解HTML,不理解OOXML。你从Word里复制一段带修订的文字时,Word放在剪贴板里的 HTML 片段是一个“修订接受后”的版本:插入的内容变成正常文字,删除的内容直接不出现,或者以格式标记的形式出现。

也就是说,当你按下Ctrl+V的那一刻,删除内容这个“行为记录”就已经不存在了,author和date更是无从谈起。编辑器能做的顶多是把你复制过来的静态样式(颜色、下划线)保留一下,而它并不知道这些样式曾经对应过什么修改行为。

这也就解释了为什么“换个编辑器”解决不了问题:编辑器层面根本没有修订的语义模型。想保留修订痕迹,必须绕开“人工粘贴”这条链路,直接在docx文件层面做处理。

3. 先别急着写代码:两条能落地的保留路线

搞清楚原理之后,我当时形成了两条技术路线。不要一上来就写PHP解析,先看你的场景适合哪一边,能省很多无用功。

3.1 方案A:原始文件归档 + 静态HTML预览(双轨制)

这个方案是我到现在仍然最推荐的路线,尤其适合金融行业。

它的思路很简单:帝国CMS里建一个“风控报告”内容模型,原始docx文件作为附件上传,完整保留Word里的修订记录;同时再用转换工具生成一份带修订标记的HTML,作为在线预览视图。页面上下两部分:上面是“在线预览”,下面是“下载原始文件”。

这样做的好处是核心数据永远保留在原始docx里,CMS里的HTML只是一个展示层。就算哪天帝国CMS升级了、模板重做了、HTML字段被误删了,只要原始文件还在,修订痕迹就还在。审计的时候需要证据,直接下载原文件,打开Word的“审阅”面板,所有记录都在。

3.2 方案B:PHP解析docx,把修订痕迹实时渲染成HTML

如果你的报告量大,每周几十篇,每篇都手工转一次HTML不现实,那就在帝国CMS后台做一个自动化解析流程。

具体做法是用PHP读取上传上来的docx,通过ZipArchive把word/document.xml解出来,再用DOMXPath把里面所有w:ins和w:del节点找出来,映射成带class和data属性的HTML片段,写入内容模型的“HTML修订版”字段。这样用户在前台看到的正文就是有标注的HTML,背后还带着修改人信息和修改时间,检索、导出都更方便。

方案B的优点是一旦跑通就一劳永逸,缺点是开发量比方案A大,而且docx格式的兼容性测试是绕不开的活。

3.3 怎么选:报告量、检索需求、技术预算三个维度

我建议按下面这个判断框架来做选择,避免过度设计:

判断维度方案A方案B
报告数量少,每周几篇多,每天几十篇
在线检索修订内容弱需求强需求
技术预算无或低有开发资源
审计证据粒度原始文件完整保留HTML中保留关键元数据
上线周期当天内可跑通需要测试和迭代

如果问我金融行业到底用哪个,我的回答是先做A,A跑通并稳定运行一段时间之后再评估B。方案A能覆盖90%的审计场景,方案B只是在“效率”和“检索”上做加法。两者不冲突,很多项目最后是A为主、B做辅助。

4. 帝国CMS侧:模型字段与发布入口这样设计

4.1 建立“风控报告”内容模型,给修订痕迹留够字段

进入帝国CMS后台,在“系统设置-数据模型-管理模型”里新增一个“风控报告”模型(不同版本菜单名称可能略有差异,思路一致)。建模型的时候最忌讳把所有内容塞进一个默认字段里,建议按下面的字段表来设计:

字段名字段类型用途
报告标题文本标题,列表页展示用
报告批次文本关联发布批次/版本号
正文HTML文本域或编辑区带修订标记的HTML预览内容
原始报告文件附件上传原始docx,保留完整修订
审阅人文本汇总修订人,方便列表页检索
修订时间文本汇总最近修订时间
报告状态选择列表草稿、待审、已发布

特别注意:原始docx和正文HTML一定要分开字段。有人图省事,把docx附件塞到正文编辑器的上传按钮里,结果前台正文区域既要展示HTML又要展示下载链接,模板逻辑一团糟。分开之后,发布页模板只要分别输出两个字段就行,清晰很多。

4.2 录入端改造:传原文件、生成HTML、填元数据三步走

录入流程建议固定成三步,减少业务人员误操作的空间:

  1. 上传原始docx到“原始报告文件”字段;
  2. 用脚本或工具生成带修订标注的HTML,粘贴到“正文HTML”字段;
  3. 填写审阅人、修订时间、报告状态等元数据。

如果用的是方案B,第二步可以做成自动化的:上传docx后由自定义处理函数自动调用解析逻辑,生成的HTML直接写入字段,业务人员只需要检查结果。帝国CMS支持自定义函数调用,你可以把解析函数挂在提交动作里,也可以做一个独立的管理页面,选择docx文件后一键处理,再把结果回填到当前文章。

我还遇到过一种情况:业务人员希望在Word里改完就在帝国CMS里看到效果,但又不想保存第二份文件。这种诉求本质上需要做到“单文件双视图”,原文件是唯一数据源,HTML只是它的投影。发布规范里务必强调一点:不要在后台编辑器里二次修改从Word生成过来的HTML,否则原文件和HTML就脱钩了,后期审计会说不清楚。

4.3 前台模板输出与访问权限控制

模板输出比较简单,直接用标签调用对应字段即可:

[!--report_title--] [!--report_body--] [!--report_original--]

“原始报告文件”字段在帝国CMS里一般会返回文件地址,模板里包装成一个下载链接就行。我建议在这个下载链接旁边加一句提示:“如需查看完整修订记录,请下载原始Word文档”。

权限控制是金融场景里容易漏掉的部分。修订痕迹不等于最终对外版本,风控报告如果挂在公网栏目上,请务必做访问控制。帝国CMS可以用会员组权限控制栏目浏览,也可以给独立频道设置浏览量权限。更稳妥的做法是:内部系统完全不对未登录访客开放;需要对外披露的版本,单独生成干净的最终PDF,修订版HTML和原始docx只对内部账号开放。

5. 核心实现:用PHP把Word修订痕迹翻译成HTML

5.1 解包docx并读取document.xml

不管走方案A还是方案B,都建议掌握这一节里的PHP解析思路。它不复杂,核心依赖就三个:ZipArchive、DOMDocument、DOMXPath,都是PHP内置扩展或常用扩展。

第一步,确认服务器环境。查看PHPinfo,确认开了zip扩展和dom扩展。帝国CMS最常见的运行环境是Linux + Apache/Nginx,这两组扩展一般都有,但某些精简配置的虚拟主机可能没开zip,那就只能装LibreOffice走命令行转换路线。

第二步,拿到docx临时文件路径后,用ZipArchive打开,提取word/document.xml。

$zip = new ZipArchive(); if ($zip->open($docxPath) !== true) { return ['error' => '无法打开docx文件']; } $xml = $zip->getFromName('word/document.xml'); $zip->close(); if (!$xml) { return ['error' => '未找到 document.xml']; }

这里有个易错点:docx是ZIP压缩包,不能直接用file_get_contents去读,必须用ZipArchive才能正确解压出内部文件。我第一次就是直接读了整个文件,结果拿到一堆乱码。

第三步,加载XML并注册命名空间。

$dom = new DOMDocument(); $dom->loadXML($xml); $xpath = new DOMXPath($dom); $xpath->registerNamespace('w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main');

OOXML的命名空间是固定字符串,必须注册好,否则后面查询w:ins、w:del全部查不到。

5.2 关键转换逻辑:w:ins和w:del处理

这一步的思路是把docx的修订节点改成HTML节点,同时把author和date写到data属性里。

  • w:ins:替换成<span class="w-ins">.w-ins { color: #cc0000; text-decoration: underline; } .w-del { color: #999999; text-decoration: line-through; }

    5.3 一段可用的基础版PHP代码

    下面这段是我简化后的核心函数,能处理最常见的中文修订场景。它并不是一个包打天下的生产级方案,但足够让你理解整体思路:

    function wordRevisionToHtml($docxPath) { $zip = new ZipArchive(); if ($zip->open($docxPath) !== true) { return ['error' => '无法打开docx文件']; } $xml = $zip->getFromName('word/document.xml'); $zip->close(); if (!$xml) { return ['error' => '未找到 document.xml']; } $dom = new DOMDocument(); $dom->loadXML($xml); $xpath = new DOMXPath($dom); $xpath->registerNamespace('w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'); // 处理插入节点 foreach ($xpath->query('//w:ins') as $node) { $span = $dom->createElement('span'); $span->setAttribute('class', 'w-ins'); $author = $node->getAttribute('w:author'); $date = $node->getAttribute('w:date'); $span->setAttribute('data-author', $author); $span->setAttribute('data-date', $date); while ($node->firstChild) { $span->appendChild($node->firstChild); } $node->parentNode->replaceChild($span, $node); } // 处理删除节点 foreach ($xpath->query('//w:del') as $node) { $del = $dom->createElement('del'); $del->setAttribute('class', 'w-del'); $author = $node->getAttribute('w:author'); $date = $node->getAttribute('w:date'); $del->setAttribute('data-author', $author); $del->setAttribute('data-date', $date); // 删除节点的文本内容在 w:delText 中,需要单独提取 $textParts = []; foreach ($xpath->query('.//w:delText', $node) as $dt) { $textParts[] = $dt->nodeValue; } if ($textParts) { $del->appendChild($dom->createTextNode(implode('', $textParts))); } $node->parentNode->replaceChild($del, $node); } // 序列化 body 内容 $body = $xpath->query('//w:body')->item(0); $html = ''; if ($body) { foreach ($body->childNodes as $child) { $html .= $dom->saveXML($child); } } return ['html' => $html]; }

    代码里有个细节值得说:删除节点的内容不在普通文本节点里,而是在w:delText中,不能简单复用插入节点的“直接搬内部节点”逻辑,否则会出现空段落。上面的实现会单独提取delText里的文字,再塞进del标签。

    5.4 进阶兜底:LibreOffice命令行转换

    如果不想写解析逻辑,或者遇到了PHP解析搞不定的复杂文档,可以在服务器上装LibreOffice,用命令行转换兜底:

    soffice --headless --convert-to html:HTML --outdir /tmp 报告.docx

    LibreOffice导出的HTML会把修订痕迹以视觉样式保留一部分,能看到红字、下划线、删除线,但author和date这种元数据是否能完整带出来,取决于版本和设置。它更适合做方案A里的“静态预览HTML”,不太适合做需要结构化追踪的方案B。

    6. 上线前自检,以及我踩过的那些坑

    6.1 发布前至少做三件事

    方案落地之后,不要急着把所有旧报告一股脑导入。建议每一篇报告发布前都过一遍自检清单:

    第一,肉眼检查。打开前台页面,确认插入内容是红字下划线,删除内容是灰色删除线,段落结构和原始docx基本一致。这一步能拦住绝大多数解析问题。

    第二,元数据抽查。随机挑两三处修订,鼠标悬停或者查看页面源码里的data-author和data-date,确认修改人和修改时间与原始docx一致。

    第三,附件完整性。下载原始docx,用Word打开,切到“审阅-所有标记”,确认修订记录还在。注意:这一步必须做,因为帝国CMS的上传组件有时候会对附件做重命名,但通常不会改文件内容;就怕遇到某些“上传时自动转码”的特殊配置。

    6.2 实际最容易翻车的五个坑

    坑一:命名空间不统一。不同来源的docx可能存在不同版本的OOXML命名空间,比如极少数旧格式文档用的是其他命名空间。解决办法是解析前先检查document.xml开头,如果出现多条命名空间声明,注册对应的前缀。我的习惯是注册多个常用命名空间,查询时不只查w:ins,也查兜底路径,遇到解析结果为空就先看原始XML。

    坑二:PHP内存限制。风控报告动辄几十页,document.xml解压后有几MB甚至更大,DOMDocument加载大XML时容易触发memory_limit。处理批量导入前先算一下这批文档的大小,临时调高memory_limit和max_execution_time,批量任务建议分批跑。

    坑三:帝国CMS编辑器过滤自定义class。如果你把生成的HTML粘贴到后台的“编辑区”字段里,编辑器可能会把class属性过滤掉。我的做法是:带修订标记的HTML放进“文本域”类型字段,文本域不会做可视化二次处理,原样保存,前台模板直接输出。代价是不能在后台可视化编辑这段内容,但这在审计场景里反而更好——避免二次编辑污染数据。

    坑四:修订人信息里的乱码和时区。Word的date字段通常是ISO 8601格式,像2024-03-15T10:24:00Z,直接丢到前端很丑,同时也容易让人误以为是乱码。我一般在PHP里做一个标准格式化转换,显示成本地时间。author字段如果是中文,基本没有乱码问题;如果出现乱码,多半是PHP读取XML时的编码声明问题,检查DOMDocument加载时是否自动识别了UTF-8。

    坑五:图片和复杂表格的位置漂移。修订标记里如果包含图片、嵌入对象,纯文本级解析没办法处理得很完美。我的经验是:对于图文混排严重的报告,不要强求PHP解析出来的HTML和原稿一模一样,把HTML预览定位成“文字修订预览”,图片相关内容引导用户下载原文件查看,这样最省心,也最不容易出错。

    6.3 安全与权限方面的额外提醒

    金融行业的风控报告内容天然敏感,技术做通了,安全不能漏。有几条经验供你参考:

    原始docx下载链接要加权限控制,不要裸露出一个可公开访问的文件地址,否则等于把内部风控报告直接暴露在公网上。建议在帝国CMS里用会员组权限或者自定义登录校验来限制下载接口。

    生成HTML的时候要把author、批注文本做HTML实体转义。docx里的批注内容可能包含特殊字符、引号,如果不做转义就直接拼进HTML,存在注入风险。即使文档内容来自内部人员,也不要赌这一点。

    最后,附件目录和数据库字段要纳入日常备份策略。修订痕迹属于审计证据性质的数据,备份时最好连同原始docx一起走对象存储或异地备份,不要只备份数据库而把附件目录漏掉。

    这套双轨方案在客户那边跑了大半年,我最大的体会是:想保住修订痕迹,最先要保的不是编辑器,也不是复杂的解析算法,而是“原始文件加一套能让人看明白谁改过什么的预览逻辑”。工具层面,帝国CMS只是一个载体,真正决定成败的是字段怎么设计、docx结构理解得够不够透。

    再分享一个实用小技巧:批量上线前,先用两三份有代表性的旧报告做全流程测试,一份带大量文字修订,一份带批注和图片,一份只有少量简单修订。把这三类跑通,后面基本不会再翻车。文档处理这块没什么黑魔法,能做到不丢信息、来源可溯,就已经赢了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进