1. 为什么ERP里的Office附件预览这么难搞
做企业信息化这些年,不管是自研系统还是基于开源框架二次开发,最后都会撞上一个很不起眼、但特别折磨人的需求:在网页里直接预览Word、Excel、PPT附件。放在GoodERP和Odoo这套体系里,业务人员上传了合同、报价单、质检报告,审核人员不想下载,就想在详情页里直接瞄一眼内容。听起来是个加分功能,做起来却是一串坑。
业务侧的诉求其实特别朴素。采购同事要看订单页面挂的Word版合同,条款有没有改、金额对不对;财务审核报销单,要打开Excel报价单看清折扣列;车间主管用平板接收质检附件,里面可能是一个PPT格式的设备操作说明,他不想下载到本地再找软件打开。所有这些场景,本质都是同一个词:预览。
可一旦动手实现,问题马上暴露。Office文件不像PDF那样天生为浏览器而生,它是一个封装格式:Word有分页符、字体依赖、段落流;Excel有行列坐标系、打印区域、条件格式;PPT更有母版、动画、备注页。浏览器本身不会渲染这些格式,所以“预览”这两个字背后,隐藏着一整套格式解析、转换、存储、权限控制、前端展示的逻辑。
1.1 业务场景里的真实痛点
先讲三个我实际接触过的场景,看完你就明白预览功能到底卡在哪儿。
第一个是合同审批场景。在GoodERP里,销售订单、采购订单、费用报销单都会挂附件,这些附件里出现频率最高的就是Word合同和Excel报价单。审核人关心两个信息:金额对不对,条款有没有被改过。没有预览功能的时候,审核流程是这样的:下载附件、找到目录、双击打开Office、来回滚动查找、关闭文件、切换回ERP页面。一天审二三十单,光这个动作就能耗掉一下午。
某公司财务部门的A同学,手上有400多张报销单,每张单子至少挂两个Excel附件。我以前去他们办公室做需求访谈,下午三点半推开财务室的门,她桌上开了七八个Excel窗口,整个人在窗口之间来回切换。后来我们做了预览功能,她的操作变成了:点一下附件,弹层里直接看到报价单内容,核对完关掉,再点下一张。她跟我说,下午的审核效率差不多提升了一半,眼睛也不那么酸了。
第二个是生产质检场景。工厂环境里的设备更新、工艺变更、巡检记录,经常以PPT或者Word形式作为附件上传。质检主管希望在一个页面里同时查看表单内容和附件内容,而不是把文件拷来拷去。这个场景对预览还有额外的要求:必须支持图片,也必须支持Office文档,而且设备可能是平板,屏幕不大,ui要够简洁。
第三个是跨公司对账场景。很多企业用ERP跟供应商对接订单,双方通过邮件或系统互换Excel对账单。如果系统里能直接预览对方文件的打开状态,就不会出现“下载了但打不开”“打开了一堆乱码”这种基础问题。更重要的是,预览可以避免文件在下载环节被二次修改,对账数据更可信。
这三个场景各有侧重,但落到技术上都有一个共同的依赖:系统得先把Office文件转换成浏览器能直接打开的内容。
1.2 预览不等于下载:三个层面的矛盾
预览功能看起来只是“多一个按钮”,实际上要实现它,必须同时处理三个层面的矛盾。
第一个矛盾是格式转换与保真度之间的矛盾。要让浏览器显示Office文件,你就得把它转换成浏览器能渲染的格式,常见的有PDF、图片、HTML或者一种自定义富文本协议。转换做得越彻底,显示效果越接近Office原生打开状态,但转换耗时长、服务器压力大。反过来,用简化方式解析,速度快了,字体却丢了,图表乱了,业务人员看一眼就会直接吐槽“这预览的是个什么东西”。
第二个矛盾是即时性与安全性之间的矛盾。理想状态是一上传就立刻转换,用户点击预览时毫秒级打开。但这个思路意味着每条附件都要提前转换、占用额外的磁盘和数据库空间,还要维护一个后台队列任务。很多团队为了省事,改成“用户点击时临时转换”,结果首次打开要等好几秒甚至十几秒,体验非常差。而如果为了让预览更快,直接把转换好的PDF文件丢到静态目录,又容易被扫描、被绕过权限下载原始文件,安全隐患很大。
第三个矛盾是浏览器兼容性和部署复杂度之间的矛盾。不同浏览器对PDF、图片、iframe的支持程度差异很大,旧版本浏览器连基本的inline展示都可能出问题。更麻烦的是,Office在线预览能力不是浏览器自带的,团队得自建服务或接入外部能力,选型不同,后续维护成本完全不一样。尤其在内外网隔离的企业环境里,外部服务不可用,服务器带宽受限,软件采购流程长,好多项目就卡在选型这一步迈不出去。
把这三个矛盾想清楚,后面的方案对比和代码落地就有了方向。
2. 方案选型:三条主路线的取舍
2.1 浏览器套壳方案:依赖第三方预览通道
第一种方案看着最省事:把附件的下载URL交给一个第三方在线预览服务,服务解析文件后返回HTML,前端用一个iframe嵌进去就能显示。
优点很直接:开发量极小,不用在服务器端做任何转换,前端几行代码搞定;预览还原度由第三方服务保证,Word、Excel、PPT基本都能正常打开,大文件也不会拖垮本地服务器。
缺点也摆在明面上。首先,依赖外网。公司内网部署的ERP,网管通常对出网地址有严格白名单,第三方预览服务一旦连不上,功能直接瘫痪。其次,存在数据泄露风险。预览服务会拿到完整的文件内容。合同、报价单这些数据,很多企业连内部员工都不让随便看,更不可能同意传给外部服务。第三,稳定性不可控。第三方通道偶尔会返回错误页或者响应超时,这种问题你没法在本地解决,只能干等恢复。
这个方案我只建议用在对外公开的非敏感文件上,比如产品说明书、公开的刊例价表。但凡涉及合同、内部报表、客户数据,我都不推荐碰。
2.2 服务端转换方案:转PDF最稳
第二种方案是目前中小团队落地最多的:在服务器上用开源Office套件以无界面方式打开待预览文件,另存为PDF,用户点击预览时直接展示PDF。原理不复杂,但每一步都有细节要处理。
好处是显著的。第一,PDF天生适合浏览器,页面内展示、缩放、翻页、搜索都原生支持,不需要额外前端库。第二,安全可控,整个转换过程在服务器本地完成,文件不出内网;权限控制也好做,前端拿到的只是一个临时预览地址,并不直接暴露原始文件路径。第三,生态成熟,主流编程语言都有封装好的转换库和命令行工具,集成门槛低。
代价主要落在服务器上。每次预览都要启动一个转换进程,吃CPU、吃内存;如果文件很大,转换时间还可能拖到分钟级。另外,转换之后的PDF和Office原生打开效果存在偏差,尤其是Excel的列宽自适应、PPT的字体嵌入、Word的复杂表格,都需要人工调参数才能把误差降到可接受范围。我实际用下来的体会是:大文件先转换成PDF缓存一份,小文件临时转换,再用队列和缓存削峰,才能平衡体验和成本。
2.3 私有编辑器集成方案:以部署复杂度换取编辑能力
第三种方案是在ERP系统旁边独立部署一套完整的在线Office应用,通过接口跟ERP附件存储对接。这套方案不仅支持预览,还支持在线编辑、多人协同、版本历史。听起来功能最全,但代价也是三套方案里最高的。
首先是部署成本。这套应用通常需要独立的数据库、文件存储、缓存服务和固定域名,有的还要HTTPS证书,光是把环境搭起来就得一两周。其次是授权成本,商业版按用户数收费,开源版虽然可以自己改造,但后续升级兼容、中文字体、性能调优都是持续维护负担。第三是性能隔离问题,一旦在线编辑能力跟ERP主进程耦合,文件同步时的网络I/O和资源消耗会直接影响业务模块的响应速度。
我的判断是:只有业务确实需要“在线编辑”这个能力时,才值得上这套方案。如果场景只是打开看一眼,完全没必要给自己背一个重型组件。
三条路线放在一起,对比很清楚:
| 对比维度 | 第三方预览通道 | 服务端转PDF | 私有在线编辑套件 |
|---|---|---|---|
| 开发量 | 小 | 中 | 大 |
| 部署范围 | 依赖外网 | 内网独立 | 内网独立 |
| 预览还原度 | 跟随第三方 | 可调优 | 最佳 |
| 是否支持在线编辑 | 不支持 | 不支持 | 支持 |
| 数据隐私风险 | 高 | 最可控 | 最可控 |
| 服务器维护成本 | 低 | 中高 | 高 |
补充一点个人体会:选型时不要只看功能,还要看团队有没有能力长期维护某条路线。我见过不止一个项目,前期上了编辑器方案,演示效果确实好,但半年后没人维护,组件升级一次痛一次,最后又退回转PDF方案。选型不是选最好的,是选你最养得起的。
3. 在GoodERP/Odoo里落地预览功能的实操记录
3.1 数据模型与附件挂接
先说数据模型。Odoo里所有附件都挂在ir.attachment模型上,GoodERP继承了这个模型,所以预览功能可以做成通用能力,任何业务单据都能复用。
我习惯新做一个erp_preview模块,在/models/attachment.py里给附件模型加几个字段:
class IrAttachment(models.Model): _inherit = 'ir.attachment' preview_file_id = fields.Many2one( 'ir.attachment', string='Preview File', help='预览文件,通常是一个PDF', ) preview_state = fields.Selection([ ('none', '未准备'), ('pending', '等待转换'), ('done', '已就绪'), ('failed', '转换失败'), ], default='none', string='预览状态', index=True)这里有个设计细节容易被新手忽略:预览文件本身也是ir.attachment记录,而不是单独建一张文件表。这样做的好处是,Odoo自带的权限框架、存储路径、下载接口全部复用,不用额外开发一套文件管理逻辑。preview_file_id记录了原附件和预览文件的对应关系,做定时清理、做关联审计都非常方便。
前端判断一句attachment.preview_state == 'done',就知道该显示预览内容还是提示“预览不可用”。如果状态是failed,还应该给一个“重新转换”按钮,让用户可以手动触发,而不是干看着报错。
3.2 文件类型识别与预览入口
附件能不能预览,第一步看文件类型。ir.attachment自带mimetype字段,但实测数据里这个字段经常不准,尤其是用户通过网页上传文件时,浏览器给的MIME有时会是application/octet-stream这种通用值。所以我实现里做了双保险:先读MIME,再根据文件后缀校正。
PREVIEWABLE_TYPES = { 'application/msword': 'doc', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', 'application/vnd.ms-excel': 'xls', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx', 'application/vnd.ms-powerpoint': 'ppt', 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx', } def is_previewable(self): mime = self.mimetype or '' ext = self.name.split('.')[-1].lower() if '.' in self.name else '' if mime in PREVIEWABLE_TYPES: return True if ext in {'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'}: return True return False别小看这段“检查”逻辑,真实数据里什么怪情况都有:有后缀明明是.docx、MIME却是application/octet-stream的文件;有用户把.pdf内容改名成.docx上传的;还有高版本的Office文件MIME在旧数据里根本查不到。所以一开始就要把“按后缀兜底”做进去,能少踩很多坑。
接着在控制器层写一个预览路由。路由返回PDF文件流,而不是返回一个包含PDF地址的JSON,这样前端iframe直接指向该地址就能显示,代码最简单。
@http.route(['/erp_preview/preview/<int:attachment_id>'], type='http', auth='user', csrf=False) def preview_attachment(self, attachment_id, **kwargs): attachment = request.env['ir.attachment'].browse(attachment_id) if not attachment.exists() or not attachment.is_previewable(): raise werkzeug.exceptions.NotFound() # 业务级权限校验,不能只依赖登录态 if not request.env.user.has_group('base.group_user'): raise werkzeug.exceptions.Forbidden() preview_id = attachment.generate_preview() if not preview_id: raise werkzeug.exceptions.InternalServerError() preview = request.env['ir.attachment'].browse(preview_id) headers = {'Content-Disposition': 'inline; filename=preview.pdf'} return werkzeug.Response(preview.raw, content_type='application/pdf', headers=headers)这里有几个关键点。第一,auth='user'确保只有登录用户可以访问路由,这是底线。第二,控制器里只检查登录还不够,还要再做一层业务权限判断,比如“订单附件允许哪些角色预览”,这一步一定要放在后端。第三,Content-Disposition设置成inline,浏览器才会在当前页面内嵌显示PDF,否则会直接触发下载,预览功能就失去了意义。
3.3 转换任务与缓存设计
generate_preview()方法是整个功能的核心。它的内部逻辑大致分四步:检查是否有现成的预览文件;没有则调用转换命令;生成的文件写入新的ir.attachment记录;最后更新预览状态。
def generate_preview(self): self.ensure_one() if self.preview_file_id and self.preview_state == 'done': return self.preview_file_id.id if self.preview_state == 'pending': # 已经有任务在跑,直接返回等待提示 return False self.write({'preview_state': 'pending'}) try: pdf_path = self._convert_to_pdf() preview_attachment = self.env['ir.attachment'].create({ 'name': self.name + '.preview.pdf', 'datas': self._binary_from_path(pdf_path), 'mimetype': 'application/pdf', 'res_model': self._name, 'res_id': self.id, }) self.write({ 'preview_file_id': preview_attachment.id, 'preview_state': 'done', }) return preview_attachment.id except Exception: self.write({'preview_state': 'failed'}) return False设计上有个细节:第一次点击预览时,如果是同步转换,用户要等好几秒。所以我通常会配合一个异步任务,上传后立即触发转换,转换完成再把状态改成done。前端用轮询或者刷新拉取最新状态。别小看这一步,“上传完立刻点预览是白屏”的体验,多半就是没有异步转换造成的。
关于缓存,我强烈建议给预览文件设一个生命周期。比如30天没被访问就清理,下次预览重新生成。不要觉得磁盘便宜就一直堆着,预览文件会越积越多,最后跟原始文件纠缠在一起,存储目录会变得非常难维护。
3.4 前端浮层与权限控制
前端我不用重页面跳转,而是在详情页加一个“预览”按钮,点击后弹出全屏浮层,浮层里放一个iframe,iframe的src指向预览路由。
Odoo前端是模块化的,我用一个简单的前端组件承载这个能力:在视图里挂按钮,点击时读取当前记录的附件列表,对可预览的附件生成按钮,再把iframe动态插入浮层。视图定义的XML片段大致是这样:
<record id="view_order_form_preview_button" model="ir.ui.view"> <field name="name">sale.order.form.preview.button</field> <field name="model">sale.order</field> <field name="inherit_id" ref="sale.view_order_form"/> <field name="arch" type="xml"> <xpath expr="//div[@name='button_box']" position="inside"> <button name="do_preview_attachment" type="object" class="oe_stat_button" icon="fa-file-pdf-o" string="预览附件"/> </xpath> </field> </record>前端浮层实现有个经验:关闭浮层时一定要把iframe的src置空。如果不置空,这个iframe会一直占用预览文件句柄,后台定时清理任务删不掉临时文件,甚至会导致转换进程不释放内存。这个坑我踩过几次,后来习惯在弹层关闭事件里统一清理。
权限控制方面,除了路由层的权限校验,前端也要做一层控制:只对“当前用户至少可读附件”的情况显示预览按钮。Odoo权限系统会自动处理ir.attachment的读写,前端按钮通过can_write、can_read这类辅助方法提前过滤,用户就不会点进去才看到报错页。
4. 常见问题与排查技巧实录
4.1 中文文件名与URL编码问题
预览功能里翻车概率最高的就是中文文件名。预览URL一旦带中文,浏览器和服务器之间的编码稍微不一致,就会404或者解码乱码,排查起来还很隐蔽。
我的经验是:预览URL永远不加文件名参数。预览文件由服务器自动命名,比如直接用附件ID命名;前端需要显示文件名时,单独从模型的name字段取值,不放进URL。绕开文件名,所有编码问题都会消失。
如果确实需要在URL里带文件名,至少要保证两端编码一致。我用urllib.parse.quote(name)编码,前端再用decodeURIComponent解码,两端统一UTF-8,这样中文名、空格、括号这些特殊字符都能稳定通过。
4.2 大文件转换超时
Excel和PPT文件一旦上几十MB,转换时间就非常不稳定。我调试时遇到过一张十几MB的Excel表,转换跑了15分钟,前端早就超时,服务器还在后台拼命算。这个问题最直接的解法是设定文件大小上限,超过30MB的Office附件不做转换,退回“仅下载”模式。
这个阈值不是拍脑袋定的。我实测过,30MB以内的Word、Excel文件在普通云主机上转换耗时一般不超过10秒;超过这个值,耗时和失败率都会陡增,而预览收益并不会随着文件变大而增加。所以给业务定一个“可预览文件大小上限”,反而是一种负责任的取舍。
4.3 并发转换造成的服务器卡顿
多个用户同时预览不同的Office文件,服务器CPU会瞬间被打满。尤其是无界面转换方式,每个进程都要读文件、解析、渲染,负载非常高。我经历过一次生产事故,四五个用户同时打开大Excel文件,服务器内存直接吃满,ERP其他模块跟着卡死。
后来我做了三层控制。第一层是一个互斥信号量,同一时间最多跑N个转换进程,N根据服务器核数配置;第二层是转换队列,超出并发数的任务先进先出排队;第三层是文件缓存,同一个文件只转换一次,后续直接读缓存。这段逻辑没什么高级技术,但控制住并发上限之后,再高的请求量也只会让任务排队,不会把系统打挂。
4.4 预览还原度与分页错位
转换后的PDF跟Office原生打开效果总有些偏差,最常见的三个问题值得单独说一下。
第一个是Excel列宽错位。默认转换参数会把超出打印区域的列截断,预览时看不到右侧的数据。解决办法是转换前调整打印区域,或者在转换参数里设置“适应页面宽度”。第二个是PPT字体缺失。服务器没装中文字体,转出来的PPT文字全是方框。这个最致命,而且问题出在操作系统层面,必须在服务器安装完整的中文字体包,装完还要清字体缓存再重新转换。第三个是Word复杂表格换行丢失。这个跟转换库版本和支持度有关,目前没有一劳永逸的方案,只能升级转换库版本,或者针对高频模板人工调参。
4.5 预览背后的安全边界
预览功能最容易出安全问题的位置,是预览URL被当作“免权限访问”的静态文件导出。有人图省事,把转换后的PDF丢进静态目录,前端直接引用,等于把合同内容暴露在意想不到的路径上,扫描工具很容易发现。
我的做法是:预览文件始终作为ir.attachment记录存在数据库里,不落静态目录;预览路由每次都做权限校验;预览URL里加一个短时效签名参数,比如过期时间戳加哈希,过期后必须重新生成。这个方法不复杂,但能挡住绝大多数拿着旧链接来访问的情况。
另外一个安全细节是元数据清理。转换后的PDF里可能携带原始文档的作者名、修订记录、隐藏备注,金融、政务类企业对这个很敏感。处理办法是在转换后主动清理元数据,或者配置转换参数时关闭元数据写入。
5. 按需精简:给预算有限的团队一个轻量方案
5.1 轻量方案的组成
如果预算有限、服务器一般,不想上一套完整的异步队列系统,我给一个能跑起来的最小方案。
结构是:上传Office文件后不立即转换,用户首次点击预览时才触发转换,并把转换结果缓存。前端体验做成“首次点击等几秒,第二次秒开”。需要的组件只有四个:
- 一个控制器路由,接收预览请求;
- 一个模型方法,检查缓存、触发转换、写入结果;
- 一个前端按钮和iframe浮层,展示PDF;
- 一个定时任务,清理超过生命周期未访问的预览缓存文件。
这套方案没有队列、没有信号量、没有分布式存储,部署成本非常低。缺点是首次预览耗时偏长,并发稍高时响应会慢。但内部使用、并发不超过二十人的团队,完全够用。
5.2 性能指标与可接受范围
我在一个内部使用的模拟项目X里用这套轻量方案做过压测。测试环境是4核8GB的普通云主机,文件样本是10个Word文档和10个Excel表格,平均大小约8MB,结果如下:
- 首次点击预览,平均等待时间约6秒,最慢的Excel接近11秒;
- 第二次点击相同文件,命中缓存,响应时间在0.5秒以内;
- 同一时间5个用户分别预览不同文件,平均等待时间升到约14秒;
- 同一时间10个用户并发预览,服务明显变慢,但没有崩溃。
这个结果说明:轻量方案的核心不是快,而是稳。只要不让服务器崩溃,业务用户是能接受几秒等待的。如果业务方对响应时间很敏感,再考虑升级异步队列方案不迟。
6. 个人经验总结
最后分享几个实际排错和维护过程中的小心得。
预览功能最容易被低估的是缓存清理。开发时只关心怎么生成预览文件,上线之后才会发现预览文件长期占据磁盘和数据库空间。我建议预览缓存设一个生命周期,比如7到30天,过期后下次预览重新生成,空间和数据新鲜度都能兼顾。
转换服务最好是做成进程外的。不要在ERP进程里直接启动转换命令,否则一旦转换卡死,会拖垮整个服务。更稳妥的做法是单独写一个转换脚本,独立控制超时和资源占用,ERP只管发起请求和接收结果。
在GoodERP或Odoo上做扩展,尽量不要动核心模块的代码。我落地所有这些功能时,只新增了一个独立模块:新增模型、新增路由、新增前端组件,通过继承机制挂接。这样框架升级时不冲突,功能模块也容易拆出来复用。
最后一个小技巧:给附件模型加了preview_state字段之后,别忘了在附件列表视图里增加一个过滤器,把preview_state = 'failed'的附件筛出来。这个过滤器在做问题排查时比翻日志高效得多,哪个附件转换失败一目了然,还能反推出是文件本身的问题,还是转换环境的问题。
预览功能看起来不起眼,但它直接决定了业务人员愿不愿意在ERP里直接处理事情。把它做稳定了,整个系统的使用体验会上一个台阶。