1. 为什么要在 PHP 项目里接入 OnlyOffice
做过文档管理系统的朋友大概都有过类似的纠结:用户上传了一份 Word 或 Excel,接下来怎么办?最省事的做法是让用户下载到本地,用 Office 打开改完再传回来,但这样体验割裂、版本混乱,协作更是无从谈起。稍微讲究一点的做法是找个在线预览方案,可预览和编辑往往是两套东西,预览用一套库、编辑再换一套,维护成本直接翻倍。
我这次要聊的,就是用一个相对统一的方案把"在线查看"和"在线编辑"这两件事一次性解决——在 PHP 项目里接入 OnlyOffice。OnlyOffice 这套东西本质上是一个文档服务端,它自己带了一套完整的编辑器前端(基于 Canvas 渲染),同时提供文档转换、协同编辑、权限控制等能力。PHP 在这里扮演的角色是"业务后端":负责用户鉴权、文件存取、生成编辑器需要的配置签名,然后把编辑器的入口交给前端去加载。
这个组合解决的核心问题很明确:让用户在浏览器里直接打开 docx、xlsx、pptx 这些格式,看得见、改得了、存得回,而且不用在每台机器上装 Office。适合谁来参考?我觉得三类人最对口:一是手里有 PHP 老项目、想低成本加文档能力的后端;二是做 OA、合同管理、教务系统这类"文档密集型"产品的团队;三是想自己搭一套私有化文档协作环境、不希望文档流出内网的技术负责人。
需要先泼一盆冷水:OnlyOffice 不是那种"composer require 一下就完事"的库。它是一个独立部署的服务(Document Server),PHP 只是它的调用方。所以整件事的难点不在 PHP 代码本身,而在于服务怎么部署、配置怎么签名、文件怎么流转、回调怎么处理这四件事的配合。下面我就按我自己实际落地的顺序,把每一块拆开讲清楚。
2. 整体架构与方案选型拆解
2.1 先搞清楚三个角色各自干什么
很多人一上来就写代码,结果卡在"编辑器打开了但提示下载失败"。根子在于没理清角色分工。这套方案里其实有三个独立的部分:
- 业务后端(PHP):管用户、管文件、管权限。它负责把磁盘或对象存储里的文件"暴露"成一个 OnlyOffice 能访问到的 URL,同时生成一份带签名的编辑器配置。
- 文档服务(Document Server):一个独立部署的服务,负责把 docx 解析渲染成可编辑的画布、处理协同、在保存时把文件回传。它不关心你的业务逻辑,只认配置和文件 URL。
- 前端页面:加载 Document Server 提供的 JS 脚本,把配置塞进去,编辑器就渲染出来了。
关键点在于:Document Server 是去"拉"文件的,不是 PHP 把文件"推"给它的。也就是说,Document Server 必须能通过网络访问到 PHP 这边提供的文件下载地址。这一点决定了后面所有的部署细节——如果 Document Server 部署在容器里、PHP 在宿主机,那 PHP 给的 URL 里写localhost是绝对拉不到的,这是新手第一大坑。
2.2 为什么选 OnlyOffice 而不是别的路子
在线文档这块能选的东西不少,我当初对比过几类方案,最后选 OnlyOffice 主要基于几个现实考量。
第一是格式保真度。用户上传的 docx 里经常有复杂表格、页眉页脚、批注,有些轻量方案渲染出来就变形了。OnlyOffice 的渲染引擎对 Office 格式的兼容性在开源方案里属于第一梯队,实测复杂排版基本能还原。
第二是编辑能力是原生的。有些方案只能预览不能编辑,或者编辑是"伪编辑"(改完导出成新文件)。OnlyOffice 是真的在画布上编辑,保存时回写原格式,用户感知上跟本地 Office 差别不大。
第三是私有化部署友好。整套东西可以完全跑在内网,文档不出门,这对合同、财务这类场景是硬需求。
第四是PHP 侧接入成本可控。它不需要你装什么 PHP 扩展,核心就是"生成配置 + 校验签名 + 处理回调"三件事,用原生 PHP 就能写,不依赖框架。
当然它也有代价:Document Server 本身比较吃资源,官方建议至少 2 核 2G 起步,实际多人协同场景下内存要给足;另外它的配置签名机制如果搞错,报错信息往往很含糊,排查起来需要点耐心。这些后面都会讲到。
2.3 一次完整的编辑流程长什么样
在动手之前,我习惯先把时序在脑子里过一遍,不然写到一半容易乱。整个流程大致是这样:
- 用户在 PHP 页面点击某份文档,前端请求 PHP 的"获取编辑器配置"接口。
- PHP 校验用户有没有权限看/改这份文件,然后生成一份配置 JSON,里面包含文件 URL、回调 URL、权限、用户信息等,并对配置做签名。
- 前端拿到配置,加载 Document Server 的
api.js,用配置初始化编辑器。 - Document Server 根据配置里的文件 URL,主动去 PHP 那边下载文件,渲染出来。
- 用户编辑,点保存(或自动保存),Document Server 向 PHP 的回调 URL 发请求,告诉 PHP"文件改好了,来取"。
- PHP 收到回调,去 Document Server 给的地址把新文件下载回来,覆盖原文件,返回成功。
- Document Server 收到成功响应,编辑器提示保存完成。
这里面第 4 步和第 6 步是文件的双向流动,方向相反,很多人只想到第 4 步忘了第 6 步,结果"能编辑但存不回去"。把这张图刻在脑子里,后面每一步都知道自己在干嘛。
3. 环境准备与服务部署要点
3.1 Document Server 的部署方式选择
Document Server 官方提供了几种跑法:Docker 镜像、各平台的安装包、以及一些云市场的一键部署。我个人强烈建议用 Docker,原因很实在——它的依赖(PostgreSQL、RabbitMQ、Nginx 等一堆组件)都被打包好了,升级和迁移都干净,出问题也好回滚。
一条典型的启动命令大概是这样(具体镜像版本按你实际拉取的为准):
docker run -d --name onlyoffice-ds \ -p 8080:80 \ -e JWT_ENABLED=true \ -e JWT_SECRET=your_secret_here \ -e JWT_HEADER=AuthorizationJwt \ --restart always \ onlyoffice/documentserver这里有几个参数值得单独说。JWT_ENABLED=true是开启配置签名校验,生产环境务必开启,否则任何人都能伪造配置去读写你的文件,这是安全底线。JWT_SECRET就是你和 PHP 共享的那把密钥,两边必须完全一致,后面 PHP 生成签名用的就是它。JWT_HEADER指定签名放在哪个请求头里,默认值一般不用改,但如果你前面有反向代理改过头,就要留意。
注意:如果你把 Document Server 部署在带 HTTPS 的域名后面,记得容器本身还是 HTTP,需要在反向代理层做 TLS 终止,并且保证 PHP 生成的配置里所有 URL 都用对外的 HTTPS 地址,否则会出现混合内容被浏览器拦截。
3.2 网络连通性是第一道坎
前面强调过,Document Server 要主动去拉 PHP 的文件。所以部署完第一件事不是写代码,而是验证双向连通。
我一般的验证方法是:先在 PHP 侧随便放一个静态文件,比如http://php-host:8000/test.docx,然后进 Document Server 容器里curl一下这个地址,看能不能拿到。反过来,也要确认 PHP 能访问到 Document Server 的地址(回调时 PHP 要去下载文件)。
如果 Document Server 在 Docker 里、PHP 在宿主机,容器内访问宿主机通常要用宿主机的内网 IP 或者host.docker.internal(部分环境支持),绝对不能用127.0.0.1,因为容器里的127.0.0.1指的是容器自己。这个坑我见过太多次,报错表现就是编辑器一直转圈然后提示"下载文件失败"。
3.3 PHP 侧需要准备什么
PHP 这边其实要求很低:能发 HTTP 请求(curl 扩展)、能读写文件、能处理 JSON 就够了。不需要装什么特殊扩展。如果你用 Composer,可以引一个 JWT 库来生成签名,但说实话,HS256 的签名逻辑就几行,自己写反而更可控,也少一个依赖。
文件存储方面,你要想清楚文件放哪。放本地磁盘最简单,但要注意 Document Server 拉文件时用的是它自己的网络身份,你得保证那个 URL 是公开可访问的(或者至少对 Document Server 可达)。如果文件在对象存储(比如各类兼容 S3 的服务),可以生成带时效的预签名 URL 给 Document Server,这样既安全又不用暴露存储凭证。
4. 核心配置与签名机制详解
4.1 编辑器配置到底包含哪些字段
配置是整个接入的心脏。它是一份 JSON,Document Server 拿到它才知道"打开哪个文件、用什么权限、存到哪、谁在编辑"。我把关键字段按用途分一下:
| 字段 | 作用 | 常见取值/说明 |
|---|---|---|
document.fileType | 文件扩展名 | docx、xlsx、pptx 等 |
document.key | 文档唯一标识 | 同一文件同一版本要用同一个 key,改了内容要换 key |
document.title | 显示标题 | 用户看到的文件名 |
document.url | 文件下载地址 | Document Server 从这里拉文件 |
document.permissions | 权限 | 控制能否编辑、下载、打印等 |
editorConfig.mode | 模式 | view只读、edit可编辑 |
editorConfig.callbackUrl | 保存回调地址 | Document Server 保存时请求这里 |
editorConfig.user | 当前用户 | id 和 name,协同编辑时用于区分 |
editorConfig.customization | 界面定制 | 语言、是否显示某些按钮等 |
document.key这个字段特别值得多说两句。它是 Document Server 用来做缓存和协同的标识。规则是:同一份文件、同一版本,key 必须稳定;文件内容一旦变化,key 必须变。如果你每次请求都随机生成 key,会导致每次打开都重新下载、协同也乱套;如果你永远用固定 key,用户改了内容再打开可能看到旧缓存。我一般的做法是用"文件 ID + 文件修改时间戳(或内容哈希)"拼成 key,这样内容一变 key 自然就变了。
4.2 签名是怎么算的,为什么必须算对
开启 JWT 后,Document Server 会校验请求里带的签名,确认这份配置确实是你发的、没被篡改。签名算法是 HS256,也就是用共享密钥对配置做 HMAC-SHA256。
具体做法是:把整个配置数组(注意是整个,包括document、editorConfig等所有字段)转成 JSON,然后用密钥算 HMAC,把结果作为token字段塞回配置里(或者放在请求头里)。Document Server 收到后,用同样的密钥重算一遍,对得上才放行。
这里最容易出错的地方是JSON 序列化的细节。PHP 的json_encode默认会把/转义成\/,还会对中文做 Unicode 转义,如果签名时用的序列化方式和 Document Server 解析时的不一致,算出来的签名就对不上。我的经验是签名时统一用json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),保证中文和斜杠不被转义,这样最稳。
一段可用的签名逻辑大概长这样:
function signConfig(array $config, string $secret): array { $payload = json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $header = json_encode(['alg' => 'HS256', 'typ' => 'JWT']); $base64Header = rtrim(strtr(base64_encode($header), '+/', '-_'), '='); $base64Payload = rtrim(strtr(base64_encode($payload), '+/', '-_'), '='); $signature = hash_hmac('sha256', $base64Header . '.' . $base64Payload, $secret, true); $base64Signature = rtrim(strtr(base64_encode($signature), '+/', '-_'), '='); $config['token'] = $base64Header . '.' . $base64Payload . '.' . $base64Signature; return $config; }注意:不同版本的 Document Server 对 token 放置位置的支持略有差异,有的版本支持放在配置体的
token字段,有的要求放在请求头。稳妥起见,先确认你部署的版本,再对照官方文档确认放置方式,别想当然。
4.3 权限配置的取舍
permissions这块看似简单,其实很影响体验。比如你只想让用户看不想让他改,就把edit设为 false、editorConfig.mode设为view。但要注意,即使设了只读,用户可能还是能通过"另存为"把文件下载走,如果你连下载都不允许,要把download也关掉。
还有一个细节:editorConfig.user里的id在协同编辑时是区分不同人的关键。如果多个用户用了同一个 id,Document Server 会认为他们是同一个人,光标会互相打架。所以这个 id 一定要用业务系统里真实的、唯一的用户标识。
5. 实操过程:从零跑通一次编辑保存
5.1 第一步:把文件暴露成可访问的 URL
假设文件存在本地storage/docs/目录下,文件名是contract_001.docx。你需要提供一个 PHP 接口,让 Document Server 能下载到它。最简单的做法是写一个下载接口,带上文件标识和签名参数,校验通过后readfile输出。
这里有个安全考量:不要直接把文件路径暴露在 URL 里,否则别人猜到路径就能下载任意文件。我一般用文件 ID 加一个短时效的签名参数,PHP 侧校验签名和时效后才输出文件。这样即使 URL 泄露,过期后也失效了。
输出文件时记得设置正确的Content-Type,docx 是application/vnd.openxmlformats-officedocument.wordprocessingml.document,xlsx 和 pptx 各有各的,设错了某些情况下会影响 Document Server 的识别。
5.2 第二步:生成配置并返回给前端
前端请求"获取配置"接口,PHP 做这几件事:校验登录和权限、拼出文件下载 URL、拼出回调 URL、组装配置数组、签名、返回 JSON。
配置里的document.url和editorConfig.callbackUrl都必须是Document Server 能访问到的绝对地址。我踩过的坑是:本地开发时用了http://localhost,结果 Document Server 在容器里根本访问不到,编辑器一直报下载失败。后来统一改成内网 IP 或者对外域名才通。
回调 URL 还要注意一点:它必须是公网或内网可达的,而且不能带需要登录的会话校验,因为 Document Server 回调时是不带用户 Cookie 的。所以回调接口要用签名或密钥来验证来源,而不是靠用户登录态。
5.3 第三步:前端加载编辑器
前端这块其实很简单,引入 Document Server 的脚本,然后初始化:
const config = { /* 从 PHP 接口拿到的配置 */ }; const docEditor = new DocsAPI.DocEditor("placeholder", config);页面上放一个id="placeholder"的 div 作为容器,编辑器会渲染进去。容器要有明确的宽高,否则可能显示不出来。
这里有个体验优化点:editorConfig.customization里可以配置界面语言、是否显示"关于"按钮、是否自动保存等。自动保存(autosave)开启后,用户不用手动点保存,Document Server 会定期回调。但要注意,自动保存频繁回调会给 PHP 带来压力,文件大的时候尤其明显,要根据实际情况权衡。
5.4 第四步:处理保存回调(最关键的一步)
用户在编辑器里点保存,Document Server 会向你的callbackUrl发一个 POST 请求,body 是 JSON,里面有个status字段标识当前状态,还有一个url字段指向改好的文件。
status的取值要搞清楚,常见的有:
1:文档正在被编辑(有人打开了)2:文档已就绪,可以保存(这是你要处理的核心状态)3:保存时出错4:文档已关闭且没有修改6:正在强制保存7:强制保存时出错
你真正要处理的是status == 2(以及某些场景下的6)。收到后,从url把文件下载回来,覆盖原文件,然后返回{"error": 0}表示成功。如果返回非 0,Document Server 会认为保存失败,可能重试或提示用户。
一段回调处理的核心逻辑:
$input = json_decode(file_get_contents('php://input'), true); $status = $input['status'] ?? 0; if ($status == 2 || $status == 6) { $fileUrl = $input['url']; // 下载 $fileUrl 的内容 $content = file_get_contents($fileUrl); // 覆盖原文件(路径根据你的业务映射) file_put_contents($targetPath, $content); } echo json_encode(['error' => 0]);注意:回调接口一定要做来源校验。虽然 Document Server 回调时可能带签名头,但不同版本行为不一,稳妥做法是在回调 URL 里带一个只有你和 Document Server 知道的密钥参数,PHP 侧校验后再处理,防止被人恶意调用覆盖文件。
5.5 第五步:验证整条链路
跑通的标准是:打开文档能看到内容、改一个字、点保存、刷新页面能看到改动。如果卡在某一步,按这个顺序排查:文件 URL 能不能被 Document Server 拉到 → 配置签名对不对 → 回调有没有被触发 → 回调里下载和覆盖有没有成功。每一步都可以单独打日志验证,别指望一次全通。
6. 常见问题与排查技巧实录
6.1 编辑器一直转圈或提示下载失败
这是最高频的问题,九成出在文件 URL 的可达性上。排查顺序:先在 Document Server 所在环境里curl你的文件 URL,看返回是不是 200 和正确的文件内容。如果返回 404,检查路由;如果返回 403,检查签名和权限;如果连接超时,就是网络不通,重点看是不是用了localhost或者防火墙拦了。
还有一种隐蔽情况:文件 URL 返回的是登录页 HTML 而不是文件本身(因为接口要求登录),Document Server 拿到一坨 HTML 自然解析失败。这种要看返回内容的Content-Type和实际字节。
6.2 保存后文件没变化
先确认回调有没有被触发。可以在回调接口里写日志,看有没有收到status == 2的请求。如果压根没收到,检查callbackUrl是否可达、是否被防火墙拦。如果收到了但文件没变,检查下载url那一步有没有成功、覆盖的路径对不对(有时候路径映射错了,写到了别的地方)。
另一个常见原因是document.key没变。如果文件内容变了但 key 没变,Document Server 可能用缓存,导致你看到的是旧内容。记住前面说的:内容变,key 必须变。
6.3 签名校验失败
报错往往很含糊,就一句"invalid token"。排查要点:密钥两边是否完全一致(注意有没有多余空格)、JSON 序列化方式是否一致(中文和斜杠转义)、token 放置位置是否符合当前版本要求。我一般会写个小脚本,用同样的配置和密钥在本地算一遍签名,和 Document Server 期望的对比,逐步缩小范围。
6.4 多人协同时光标错乱
基本是editorConfig.user.id重复导致的。检查是不是所有用户都用了同一个 id,或者匿名用户都用了默认 id。确保每个真实用户有唯一 id,匿名场景也要生成临时唯一标识。
6.5 大文件保存超时
文件大时,回调里下载和覆盖可能超过 PHP 的max_execution_time。可以在回调接口里临时set_time_limit(0),或者把保存逻辑丢到队列里异步处理(回调先返回成功,后台再慢慢下载覆盖)。后者更稳,但要处理好"回调返回成功但实际还没存完"的一致性问题。
| 问题现象 | 最可能原因 | 快速排查 |
|---|---|---|
| 编辑器转圈/下载失败 | 文件 URL 不可达 | 在 DS 环境 curl 文件 URL |
| 保存后无变化 | 回调未触发或 key 未变 | 查回调日志、核对 key 生成规则 |
| invalid token | 密钥或序列化不一致 | 本地复算签名对比 |
| 协同光标错乱 | 用户 id 重复 | 检查 user.id 唯一性 |
| 大文件保存超时 | 执行时间不足 | 异步化或放宽超时 |
6.6 几个我踩过的独家坑
第一个是反向代理改了 Host 头导致签名里的 URL 和实际访问的不一致。如果你前面有 Nginx 做代理,注意proxy_set_header Host的配置,保证 PHP 拿到的 Host 和对外一致。
第二个是文件覆盖时的并发问题。两个人几乎同时保存,可能后写的覆盖先写的。如果业务对这点敏感,要在覆盖前做版本校验或者加锁。
第三个是Document Server 升级后配置字段变化。它不同大版本之间配置结构偶有调整,升级前一定先看变更说明,别直接升。
7. 一些延伸思考与个人体会
这套方案跑稳之后,其实还能往外延展不少东西。比如把回调里的文件版本存起来做历史版本,用户就能回滚;比如结合业务做模板填充,用 OnlyOffice 的转换能力把模板生成正式文档;再比如把编辑权限和业务审批流绑定,未审批通过的文档只给只读。
我个人在实际操作中的体会是:OnlyOffice 接入的难点从来不在 PHP 代码写得多漂亮,而在于把网络、签名、文件流转这三件事的边界理清楚。代码量其实不大,但每一个配置字段背后都有它的道理,糊里糊涂抄一份配置能跑起来,但一旦出问题就抓瞎。所以我建议第一次接入时,别急着复制网上的完整示例,而是按"文件能拉到 → 配置能签对 → 编辑器能打开 → 回调能存回"这四步,一步一验证地走一遍,走通了你就真的懂了。
最后分享一个小技巧:本地开发阶段,可以先把JWT_ENABLED关掉快速验证链路通不通,等链路通了再打开签名,这样能把"网络问题"和"签名问题"分开排查,省很多时间。当然,这只限于本地调试,上线前务必确认签名是开着的。