"能不能不下载就打开这个 docx"——一句话需求,底下是三条完全不同的路:接云端 Office、上 OnlyOffice、或自建"转 PDF + 前端预览"。工作量差一个量级,坑也各不相同。
以 OnlyOffice 为主线:架构 → 接入流程 → 签名 → 地址可达性 → 回调语义 → 并发锁,最后给"什么时候别用它"的判据。
1. 四条路线与代价
| 路线 | 能看 | 能改 | 数据是否出网 | 主要代价 |
|---|---|---|---|---|
| Office Online / 云文档 | 是 | 是 | 出网(文件要给对方) | 依赖外网、账号体系、合规沟通 |
| OnlyOffice Document Server | 是 | 是(协同编辑) | 可完全内网 | 自建资源、回调对接、网络打通 |
| WPS 在线预览/编辑 | 是 | 部分 | 多为云服务 | 授权与账号绑定 |
| 自建"转 PDF/图片" | 是 | 否 | 不出网 | 只读,依赖转换工具链 |
关键判断点有三个:要不要编辑、文件能不能出内网、愿不愿意维护一个文档服务。"要编辑 + 不能出网",基本只剩 OnlyOffice。
2. 架构:Document Server + 你的后端 + 回调
# docker-compose.yml(自建 Document Server 的最小形态)
services:
documentserver:
image: onlyoffice/documentserver:latest # 版本按当前官方标签核对
container_name: onlyoffice-ds
ports: ["8081:80"]
environment:
- JWT_ENABLED=true
- JWT_SECRET=${ONLYOFFICE_JWT_SECRET}
- JWT_HEADER=Authorization
volumes:
- ds_data:/var/www/onlyoffice/Data
restart: unless-stopped
volumes:
ds_data:
ds_logs:
| 组件 | 职责 | 常见误解 |
|---|---|---|
| Document Server | 渲染文档、提供编辑器前端、保存时回调后端 | 不存你的文件,文件始终在你的存储里 |
| 你的业务后端 | 生成编辑器配置、签发 JWT、实现下载与保存回调 | 只做"给地址 + 收结果",不参与渲染 |
| 浏览器 | 加载编辑器 iframe,与 DS 直连 | 浏览器也要能访问 DS,不只是后端 |
| 文件存储 | 原始文件与保存后的新版本 | 支持版本覆盖或另存 |
两个方向的网络都必须通:浏览器 → Document Server(加载编辑器与静态资源),Document Server → 你的文件 URL(拉取与回写)。只打通一个方向是最常见的失败原因。
3. 接入流程:配置 → 编辑器 → 回调
3.1 后端生成带签名的配置
// 生成编辑器配置(字段名按当前版本的 API 文档核对)
Map<String, Object> config = new LinkedHashMap<>();
config.put("documentType", "word");
config.put("document", Map.of(
"fileType", "docx",
"key", docKey, // 同一文件的同一版本必须同一个 key
"title", "季度报告.docx",
"url", "https://files.example.com/api/docs/123/download?token=" + fileToken,
"permissions", Map.of("edit", true, "print", true)
));
config.put("editorConfig", Map.of(
"mode", "edit", // view / edit
"callbackUrl", "https://api.example.com/api/docs/123/callback",
"user", Map.of("id", "u-1001", "name", "张三"),
"customization", Map.of("autosave", true, "forcesave", false)
));
String payload = objectMapper.writeValueAsString(config);
String token = jwtSigner.sign(payload); // HS256,密钥与 DS 的 JWT_SECRET 一致
Map<String, Object> body = Map.of("config", config, "token", token);
3.2 前端加载编辑器
<div id="placeholder" style="height:100vh"></div>
<script src="https://ds.example.com/web-apps/apps/api/documents/api.js"></script>
<script>
new DocsAPI.DocEditor("placeholder", {
document: { fileType: "docx", key: KEY, title: TITLE, url: DOC_URL },
editorConfig: { mode: "edit", callbackUrl: CALLBACK_URL,
user: { id: USER_ID, name: USER_NAME } },
token: TOKEN // 与后端签名一致,否则编辑器直接报错
});
</script>
3.3 保存回调:status 是全部语义所在
status |
含义 | 该做什么 |
|---|---|---|
1 |
文档被打开/有人在编辑 | 记审计即可,不动文件 |
2 |
编辑结束,有变更待保存 | 用回调里的 url 下载新文件并覆盖/另存 |
3 |
保存出错 | 告警,别静默吞掉 |
4 |
无变更关闭 | 无需处理 |
6 |
强制保存(forcesave) | 同 2,用来做定时快照 |
7 |
强制保存出错 | 同上 |
@PostMapping("/api/docs/{id}/callback")
public Map<String, Object> callback(@PathVariable Long id, @RequestBody JsonNode body,
@RequestHeader(value = "Authorization", required = false) String auth) {
verifyJwt(auth); // 校验 DS 发来的签名
int status = body.path("status").asInt();
if (status == 2 || status == 6) {
storage.saveVersion(id, body.path("key").asText(), body.path("url").asText());
}
return Map.of("error", 0); // 必须回 {"error":0}
}
回调响应体必须是 {"error":0};返回非 0 或非 JSON,DS 会判定保存失败并重试。
4. JWT 签名:为什么必须有
DS 默认对配置与回调做 JWT 校验,作用有二:防伪造 document.url 让你的服务器拉任意文件,以及确认回调真的来自你的 DS。
| 配置项 | 作用 | 不一致的后果 |
|---|---|---|
JWT_ENABLED |
是否开启校验 | 一端开一端关,编辑器打不开或回调被拒 |
JWT_SECRET |
HS256 密钥 | 必须两边完全相同,含首尾空格 |
# 验证签名链路:解出 token 的 payload,看是否与发给前端的 config 一致
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .
签名覆盖整个 config 的 JSON 序列化结果:字段顺序、空值、数字与字符串都会影响签名,所以要复用同一个 config 对象序列化。
5. 文档存储与地址可达性:第一大坑
Document Server 是从它自己的容器内部访问 document.url 的:对浏览器可达,对 DS 容器不一定可达。
| 你写的 URL | 浏览器 | DS 容器 | 结果 |
|---|---|---|---|
http://localhost:8080/file/1 |
可达 | 不可达(localhost 是容器自己) | 编辑器打开后一直转圈 |
http://host.docker.internal:8080/file/1 |
视系统 | 通常可达(Docker Desktop) | Linux 上需额外配置 |
https://files.example.com/file/1 |
可达 | 可达(走域名解析) | 推荐 |
结论:给 DS 用的 URL 要走"两边都能解析"的域名,而不是 localhost。容器网络里常见做法是给 DS 配 extra_hosts,把业务域名指到宿主机 IP:
extra_hosts:
- "files.example.com:172.17.0.1"
另一个条件是免登录可下载:DS 不带你的会话 Cookie,document.url 必须自带短期签名参数,且有效期覆盖整个编辑会话。
6. 格式支持与"只读预览"的轻量方案
| 需求 | 方案 | 说明 |
|---|---|---|
| docx/xlsx/pptx 在线编辑 | OnlyOffice | 完整度最高,需 DS |
| 只要看不要改 | 转 PDF + 浏览器 PDF 阅读器 | 工具链自选,成本最低 |
| 要保留排版地看 | 转 PDF(headless 转换) | 字体缺失会导致排版偏移 |
# 轻量预览:headless 转换,注意中文字体
soffice --headless --convert-to pdf --outdir /data/preview /data/upload/report.docx
````soffice` 转换最常见的问题是**服务器没装中文字体**,转出来全是方块;装字体后要清缓存或重启转换进程。预览占九成、编辑极少的场景,这条路线能省掉整个 Document Server。
---
## 7. 并发编辑与锁
| 概念 | 机制 | 工程含义 |
|---|---|---|
| `document.key` | 标识"某个文件的某个版本" | 同 key 才能协同;内容变了必须换 key |
| 协同会话 | DS 内部维护,同 key 即同会话 | 业务层不必再加编辑锁 |
| 覆盖写 | 回调给你新文件 | 建议"另存版本 + 切指针",别原地覆盖 |
`key` 最容易出错:用内容 hash 或"文件 id + 版本号",**不要用时间戳**——那会让两个人各拿一个 key,协同直接失效,每次刷新还多一个编辑会话。
---
## 8. 自建部署的资源要求
| 项目 | 说明 |
|---|---|
| 内存 | DS 是 Node 服务 + 转换进程,转大文件时内存峰值明显;按当前版本官方文档核对 |
| CPU | 转换吃单核性能,并发编辑吃多核 |
| 磁盘 | 日志与缓存持续增长,需配轮转 |
| 反代 | 必须支持 WebSocket 升级,否则协同失效 |
| 字体 | 装业务用到的中文字体,否则渲染与导出差错 |
```nginx
# 反代要点:WebSocket 必须放行
location / {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
9. 排错:七个高频现场
| 现象 | 原因 | 处理 |
|---|---|---|
| 编辑器一直转圈 | DS 容器访问不到 document.url |
换两边都能解析的域名,查 extra_hosts |
| 报"文档安全令牌无效" | JWT 密钥不一致,或前端 token 与后端 config 不同源 | 统一 JWT_SECRET,token 由后端一次生成 |
| 编辑后内容没保存 | callbackUrl 不可达,或回调没回 {"error":0} |
从 DS 容器内 curl 验证 |
| 保存提示文件被占用 | 原文件被锁定,或覆盖时未释放流 | 落新版本再切指针 |
| 协同编辑只有一个人能改 | 反代没放行 WebSocket | 补 Upgrade/Connection 头 |
| 浏览器报混合内容 | 页面 https,DS 或文件 URL 是 http | 全链路 https,或同域反代 |
| 中文显示为方块 | DS 容器缺中文字体 | 挂载字体目录并刷新字体缓存 |
# 从 DS 容器内部验证文件 URL 是否可达——这一步能排掉大半问题
docker exec onlyoffice-ds curl -sS -o /dev/null -w '%{http_code}\n' "https://files.example.com/api/docs/123/download?token=XXX"
10. 边界:什么时候别上 OnlyOffice
| 场景 | 更合适 | 理由 |
|---|---|---|
| 只需预览,不需要编辑 | 转 PDF + 内置阅读器 | 省掉一整套服务与回调 |
一句话:要编辑 + 要内网 + 愿意养一个常驻服务才上 OnlyOffice;只要看,转 PDF 更省事。
11. 自测
- 浏览器与 Document Server 之间、DS 与文件 URL 之间,哪两个方向的网络都必须通?
document.key用什么值生成,为什么不能用时间戳?- 回调里
status=2与status=4的处理差别是什么? - 回调接口必须返回什么响应体,返回别的会怎样?
- 为什么给 DS 用的文件 URL 不能写
localhost? - 反代少配哪个头会导致协同编辑失效?