1. 集成架构全景回顾
在写任何代码之前,先把三方交互模型再清晰地过一遍——这是理解后续所有 API 行为的基础。
┌─────────────────────────────────────────────────────────────┐│ 浏览器 / 客户端 ││ ││ 1. 请求编辑页面 4. 初始化编辑器 ││ ─────────────> ─────────────> ││ ││ 2. 返回含 config 的页面 5. WebSocket 实时通信 ││ <───────────── <────────────> │└──────────┬──────────────────────────────────────────────────┘ │ ▲ │ 3. 后端生成 │ │ JWT 签名的 config │ ▼ │┌──────────────────────┐ ┌──────────────────────────────┐│ │ │ ││ 你的应用后端 │ │ OnlyOffice Document Server ││ │ │ ││ - 用户认证 │ │ 6. 主动拉取文件 ││ - 权限控制 │ ───> │ GET document.url ││ - 生成 config + JWT │ │ ││ - 接收保存回调 │ <─── │ 7. 保存时回调 ││ - 存储文件 │ │ POST callbackUrl ││ │ │ │└──────────────────────┘ └──────────────────────────────┘ │ │ │ │ ▼ ▼┌──────────────────────────────────────────────────────────────┐│ 文件存储 ││ (本地磁盘 / S3 / OSS / 你的存储系统) │└──────────────────────────────────────────────────────────────┘三个容易误解的关键点:
第一,文件由 Document Server 主动拉取,不是由你的后端推送。你只需要提供一个 Document Server 可以访问的文件 URL。这意味着你的文件存储服务必须对 Document Server 网络可达。
第二,文档保存通过回调触发,不是实时同步。用户关闭编辑器(或触发强制保存)后,Document Server 向你的 callbackUrl 发送 POST 请求,你从请求体中拿到新文件的下载链接,然后自行下载并保存。
第三,document.key 是 Document Server 判断是否使用缓存的唯一依据。同一个 key 对应的文档,Document Server 只拉取一次,后续打开直接用缓存。 如果文件内容已更新,必须更换 key,否则用户看到的是旧版本。
2. 最小化集成示例
在深入完整 API 之前,先用最少的代码跑通一个可用的集成,建立直觉。
2.1 后端:生成编辑器配置页面
// app.js(Node.js + Express)const express = require('express');const jwt = require('jsonwebtoken');const path = require('path');const app = express();const DOCUMENT_SERVER_URL = 'https://docs.example.com'; // 你的 Document Server 地址const JWT_SECRET = process.env.JWT_SECRET; // 与 Document Server 共享的密钥app.get('/edit/:fileId', (req, res) => { const { fileId } = req.params; // 构造编辑器配置 const config = { document: { fileType: 'docx', key: fileId + '_v1', // 文档唯一标识 title: '我的文档.docx', url: `https://your-app.com/files/${fileId}`, // 文件下载地址 }, editorConfig: { callbackUrl: `https://your-app.com/callback/${fileId}`, user: { id: 'user-001', name: '张三', }, lang: 'zh', mode: 'edit', }, documentType: 'word', }; // 用 JWT 签名配置 config.token = jwt.sign(config, JWT_SECRET, { algorithm: 'HS256' }); // 渲染编辑器页面,将 config 传入前端 res.send(`</code></pre> <pre><code> html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; }</code></pre> <pre><code> #editor { width: 100%; height: 100vh; }</code></pre> <pre><code> </code></pre> <pre><code> const config = ${JSON.stringify(config)};</code></pre> <pre><code> const docEditor = new DocsAPI.DocEditor('editor', config);</code></pre> <pre><code> `);});app.listen(3000, () => console.log('Server running on port 3000'));2.2 后端:接收保存回调
app.use(express.json());app.post('/callback/:fileId', async (req, res) => { const { fileId } = req.params; const body = req.body; // status 2 表示文档准备好保存(用户关闭编辑器) // status 6 表示强制保存(Force Save)触发 if (body.status === 2 || body.status === 6) { // 从 Document Server 下载最新版本 const response = await fetch(body.url); const buffer = await response.arrayBuffer(); // 保存到你的存储系统(示例为写入本地磁盘) const fs = require('fs'); fs.writeFileSync(`./storage/${fileId}.docx`, Buffer.from(buffer)); console.log(`文件 ${fileId} 保存成功`); } // 必须返回 {"error": 0},否则 Document Server 会认为回调失败并重试 res.json({ error: 0 });});这个最简示例可以跑通完整的编辑 → 保存流程。接下来逐步展开每个环节的完整细节。
3. Document Editor API 完整参数详解
编辑器配置对象(config)是集成的核心,它决定了编辑器的几乎所有行为。以下按结构逐层展开。
3.1 顶层结构
const config = { documentType: 'word', // 编辑器类型 document: { ... }, // 文档信息 editorConfig: { ... }, // 编辑器行为配置 events: { ... }, // 前端事件回调 token: 'jwt_string', // JWT 签名(生产环境必须)};documentType 的可选值:
| 值 | 对应编辑器 | 支持格式 |
|---|---|---|
| word | Document Editor | .docx .doc .odt .txt .rtf .html |
| cell | Spreadsheet Editor | .xlsx .xls .ods .csv |
| slide | Presentation Editor | .pptx .ppt .odp |
3.2 document 对象
document: { // ── 必填项 ────────────────────────────────────────────── fileType: 'docx', // 文件扩展名(不含点),影响解析方式 key: 'doc_abc_v3', // 文档唯一标识,缓存控制的关键 title: '合同草稿.docx', // 显示在编辑器标题栏的文件名 url: 'https://your-storage.com/files/abc.docx', // 文件下载地址 // ── 权限控制 ───────────────────────────────────────────── permissions: { comment: true, // 允许添加批注 copy: true, // 允许复制内容 download: true, // 允许下载文件 edit: true, // 允许编辑(false 则为只读模式) fillForms: true, // 允许填写表单 modifyContentControl: true, // 允许修改内容控件 modifyFilter: true, // 允许修改过滤器(表格) print: true, // 允许打印 protect: true, // 允许修改文档保护设置 rename: false, // 禁止在编辑器内重命名 review: true, // 允许修订模式 reviewGroups: null, // 修订组控制(高级用法) chat: true, // 允许聊天面板(需配置聊天后端) changeHistory: true, // 允许查看版本历史 userInfoGroups: null, // 用户信息组(高级用法) }, // ── 文档信息(可选)────────────────────────────────────── info: { author: '张三', created: '2024-01-15', // 文档创建日期(显示用) owner: '研发部', uploaded: '2024-03-20', favorite: null, // 是否标记为收藏(true/false/null) }, // ── 参考文档(只读,与主文档并列显示)──────────────────── // referenceData: { // fileKey: 'ref_doc_key', // instanceId: 'your-app-id', // },}`document.key` 的管理策略
key 是集成中最容易出错的地方,规则很简单但必须严格执行:
- 文件内容没有变化时,保持 key 不变(利用缓存,加快打开速度)
- 文件内容已更新时(例如其他系统修改了文件),必须生成新 key
- 推荐策略:使用 文件ID + 文件最后修改时间戳 的哈希值作为 key
const crypto = require('crypto');function generateDocumentKey(fileId, lastModifiedAt) { return crypto .createHash('md5') .update(`${fileId}_${lastModifiedAt}`) .digest('hex') .substring(0, 20);}3.3 editorConfig 对象
editorConfig: { // ── 必填项 ────────────────────────────────────────────── callbackUrl: 'https://your-app.com/callback/file-id', // ── 用户信息 ───────────────────────────────────────────── user: { id: 'user-001', // 用户唯一 ID,协同编辑时用于区分用户 name: '张三', // 显示在编辑器中的用户名 group: 'editors', // 所属组(用于修订组权限控制) image: 'https://your-app.com/avatars/user-001.png', // 头像 URL }, // ── 语言与地区 ─────────────────────────────────────────── lang: 'zh', // 界面语言(zh / en / de / fr 等) region: 'zh-CN', // 地区格式(影响日期、数字格式) // ── 编辑模式 ───────────────────────────────────────────── // edit 编辑模式(默认) // view 只读预览模式(不占用连接数许可) // fillForms 表单填写模式 mode: 'edit', // ── 协作配置 ───────────────────────────────────────────── coEditing: { // fast 快速模式(操作实时同步,默认) // strict 严格模式(段落锁定后提交) mode: 'fast', change: true, // 允许用户切换协作模式 }, // ── 修订配置 ───────────────────────────────────────────── // auto 自动跟踪所有修改 // disable 不跟踪修改 // enable 启用修订跟踪 // show 显示已有修订 review: { hideReviewDisplay: false, showReviewChanges: false, reviewDisplay: 'original', // original / markup / final trackChanges: null, // null 表示使用文档自身设置 }, // ── 自定义菜单(在编辑器内嵌入你自己系统的入口)─────────── customization: { // 控制内置 UI 元素显示 autosave: true, // 是否显示自动保存提示 comments: true, // 是否显示批注面板 compactHeader: false, // 使用紧凑型标题栏 compactToolbar: false, // 使用紧凑型工具栏 compatibleFeatures: false, // 兼容模式(禁用部分新特性) forcesave: false, // 是否显示"强制保存"按钮 help: true, // 是否显示帮助菜单 hideRightMenu: false, // 是否隐藏右侧菜单 hideRulers: false, // 是否隐藏标尺 integrationMode: 'embed', // 集成模式:embed(内嵌)/ full(独立) logo: { // 替换编辑器左上角 Logo image: 'https://your-app.com/logo.png', imageDark: 'https://your-app.com/logo-dark.png', url: 'https://your-app.com', visible: true, }, macros: true, // 是否允许运行宏 macrosMode: 'warn', // 宏运行策略:warn / enable / disable mentionShare: true, // @提及时是否提示分享 mobileForceView: true, // 移动端强制使用查看模式 plugins: true, // 是否启用插件 spellcheck: true, // 是否开启拼写检查 submitForm: false, // 是否显示"提交表单"按钮 toolbarHideFileName: false, // 是否在工具栏隐藏文件名 toolbarNoTabs: false, // 是否使用无标签页工具栏样式 uiTheme: 'theme-light', // 主题:theme-light / theme-dark unit: 'cm', // 标尺单位:cm / pt / inch zoom: 100, // 初始缩放比例(百分比) }, // ── 菜单按钮自定义 ────────────────────────────────────── // 在 File 菜单中添加自定义按钮,点击时触发 onRequestSaveAs 等事件 createUrl: 'https://your-app.com/new-document', // "创建新文档"按钮链接 // ── 插入外部图片 / 文档 ───────────────────────────────── // 当用户点击"插入图片"时,是否允许从外部 URL 插入 // 通过 events.onRequestInsertImage 配合使用 // ── 最近文档列表 ───────────────────────────────────────── recent: [ { title: '上周的报告.docx', url: 'https://your-app.com/edit/doc-456', folder: '我的文档', }, ], // ── 模板列表 ───────────────────────────────────────────── templates: [ { title: '合同模板', image: 'https://your-app.com/templates/contract-thumb.png', url: 'https://your-app.com/edit/template-contract', }, ],}3.4 events 对象:前端事件回调
events 是集成深度的重要体现。通过监听编辑器事件,可以在用户执行特定操作时注入你自己的系统逻辑。
events: { // ── 生命周期事件 ───────────────────────────────────────── onAppReady: function() { // 编辑器初始化完成,此后可安全调用 docEditor 的方法 console.log('编辑器已就绪'); }, onDocumentReady: function() { // 文档内容加载完成(文件已渲染到编辑器) console.log('文档已加载'); }, onDocumentStateChange: function(event) { // 文档修改状态变化 // event.data === true → 有未保存的修改 // event.data === false → 已保存 if (event.data) { document.title = '* 合同草稿.docx(已修改)'; } else { document.title = '合同草稿.docx'; } }, onError: function(event) { // 编辑器发生错误 console.error('编辑器错误:', event.data); // event.data 包含错误代码和描述 }, onWarning: function(event) { console.warn('编辑器警告:', event.data); }, onInfo: function(event) { // 编辑器信息通知(如文档被其他用户修改) }, // ── 保存相关事件 ───────────────────────────────────────── onRequestSave: function() { // 用户点击"保存"按钮(需在 customization 中启用保存按钮) // 调用强制保存接口 docEditor.forceSave(); }, onRequestSaveAs: function(event) { // 用户点击"另存为" // event.data.fileType → 目标文件类型 // event.data.title → 用户输入的文件名 // event.data.url → 当前文档的下载链接 console.log('另存为:', event.data.title, event.data.fileType); // 在你的系统中创建新文件 }, // ── 文件操作事件 ───────────────────────────────────────── onRequestOpen: function(event) { // 用户点击"打开文档"或最近文件列表中的文档 // event.data.url → 要打开的文档 URL window.open(event.data.url, '_blank'); }, onRequestClose: function() { // 用户点击"关闭"按钮 // 此处可跳转回文件列表页面 window.location.href = '/files'; }, onRequestCreateNew: function() { // 用户点击"新建文档" window.open('/new-document', '_blank'); }, // ── 用户与协作事件 ─────────────────────────────────────── onCollaborativeChanges: function() { // 其他协作者对文档进行了修改(快速模式下不触发,严格模式下触发) }, onRequestUsers: function(event) { // 编辑器请求用户列表(用于 @提及功能) // event.data.c === 'mention' → 获取可提及的用户列表 // event.data.c === 'protect' → 获取可设置文档保护的用户 if (event.data.c === 'mention') { // 从你的系统获取用户列表后,通过 setUsers 方法返回 docEditor.setUsers({ c: 'mention', users: [ { id: 'user-002', name: '李四', email: 'lisi@company.com' }, { id: 'user-003', name: '王五', email: 'wangwu@company.com' }, ], }); } }, onRequestSendNotify: function(event) { // 用户在批注中 @提及了某人 // event.data.emails → 被提及者的邮箱列表 // event.data.message → 提及的消息内容 // 在此处触发你的通知系统(邮件/消息推送) sendMentionNotification(event.data.emails, event.data.message); }, // ── 文档内容事件 ───────────────────────────────────────── onRequestInsertImage: function(event) { // 用户点击"从存储插入图片"(需配置 editorConfig.customization.features.spellcheck) // 展示你的图片选择器,选择后调用 insertImage showImagePicker(function(imageUrl) { docEditor.insertImage({ c: event.data.c, images: [{ fileType: 'png', url: imageUrl }], }); }); }, onRequestCompareFile: function() { // 用户请求"比较文档"功能 // 展示文件选择器,选择后调用 setRevisedFile showFilePicker(function(fileInfo) { docEditor.setRevisedFile({ fileType: fileInfo.fileType, url: fileInfo.url, token: fileInfo.jwt, }); }); }, onRequestHistory: function() { // 用户请求版本历史列表 // 从你的系统获取历史版本后,调用 refreshHistory fetchVersionHistory(currentDocId).then(versions => { docEditor.refreshHistory({ currentVersion: versions.currentVersion, history: versions.list, }); }); }, onRequestHistoryData: function(event) { // 用户点击了某个历史版本,请求该版本的文件内容 // event.data.version → 请求的版本号 const version = event.data.version; fetchVersionData(currentDocId, version).then(data => { docEditor.setHistoryData({ fileType: data.fileType, key: data.key, url: data.url, version: version, // 如果需要显示与上一版本的差异: // changesUrl: data.changesUrl, // previous: { fileType, key, url } }); }); }, onRequestHistoryClose: function() { // 用户关闭版本历史面板 docEditor.refreshHistory({ currentVersion: 1, history: [] }); }, onRequestRename: function(event) { // 用户在编辑器内重命名了文件(需 permissions.rename: true) // event.data.title → 新文件名 renameFile(currentDocId, event.data.title); }, onRequestRestore: function(event) { // 用户请求恢复到某个历史版本 // event.data.version → 要恢复的版本号 // event.data.url → 该版本文件的下载链接 restoreFileVersion(currentDocId, event.data.version, event.data.url); },}4. 回调机制:处理文档保存
回调(Callback)是集成中逻辑最复杂、最容易出错的环节,需要完整理解每种状态码的含义及其处理方式。
4.1 回调请求体结构
Document Server 向 callbackUrl 发送 POST 请求,请求体为 JSON:
{ "actions": [{ "type": 0, "userid": "user-001" }], "changesurl": "https://docs.example.com/cache/files/doc_key/changes.zip", "history": { "changes": [...], "serverVersion": "7.5.0" }, "key": "doc_abc_v3", "status": 2, "url": "https://docs.example.com/cache/files/doc_key/output.docx", "users": ["user-001", "user-002"]}4.2 status 状态码完整说明
这是回调处理的核心,必须正确处理每种状态:
| status | 含义 | 是否包含 url | 推荐处理方式 |
|---|---|---|---|
| 0 | 文档未找到或错误 | 否 | 记录错误,返回 {"error": 1} |
| 1 | 文档正在被编辑中 | 否 | 仅记录在线用户,返回 {"error": 0} |
| 2 | 文档准备好保存(所有用户已关闭) | 是 | 下载并保存文件 |
| 3 | 文档保存时发生错误 | 否 | 记录错误,通知管理员 |
| 4 | 文档已关闭,无修改 | 否 | 无需操作,返回 {"error": 0} |
| 6 | 强制保存(Force Save)触发 | 是 | 下载并保存文件 |
| 7 | 强制保存时发生错误 | 否 | 记录错误 |
最重要的规则: 无论处理成功还是失败,回调接口必须在 15 秒内返回 HTTP 200 + {"error": 0}(成功)或 {"error": 1}(失败)。如果超时或返回非 200,Document Server 会认为回调失败并重试,最多重试 48 次,期间文档处于锁定状态无法重新编辑。
4.3 完整的回调处理实现
// Node.js + Expressconst https = require('https');const fs = require('fs');const path = require('path');const jwt = require('jsonwebtoken');const stream = require('stream');const { promisify } = require('util');const pipeline = promisify(stream.pipeline);const JWT_SECRET = process.env.JWT_SECRET;app.post('/callback/:fileId', async (req, res) => { const { fileId } = req.params; // ── 第一步:验证 JWT 签名 ──────────────────────────────── const authHeader = req.headers['authorization']; if (!authHeader || !authHeader.startsWith('Bearer ')) { console.error(`[Callback] 缺少 JWT Token,fileId=${fileId}`); return res.json({ error: 1 }); } try { jwt.verify(authHeader.slice(7), JWT_SECRET); } catch (err) { console.error(`[Callback] JWT 验签失败,fileId=${fileId}:`, err.message); return res.json({ error: 1 }); } const body = req.body; const status = body.status; console.log(`[Callback] fileId=${fileId}, status=${status}, users=${JSON.stringify(body.users)}`); try { switch (status) { case 1: // 文档编辑中,记录在线用户,无需其他操作 await updateOnlineUsers(fileId, body.users || []); break; case 2: // 文档准备保存 case 6: { // 强制保存 if (!body.url) { console.error(`[Callback] status=${status} 但缺少 url,fileId=${fileId}`); return res.json({ error: 1 }); } // 下载并保存文件 await downloadAndSave(fileId, body.url); // 更新数据库中的文件版本信息 await updateFileVersion(fileId, { savedAt: new Date(), editedBy: body.users || [], }); // 清除在线用户缓存 if (status === 2) { await clearOnlineUsers(fileId); } console.log(`[Callback] 文件 ${fileId} 保存成功`); break; } case 3: console.error(`[Callback] 文档保存错误,fileId=${fileId}`, body); await notifyAdminSaveError(fileId, body); break; case 4: // 文档已关闭,无修改,清除在线用户 await clearOnlineUsers(fileId); break; case 7: console.error(`[Callback] 强制保存错误,fileId=${fileId}`, body); break; default: console.warn(`[Callback] 未知状态码 status=${status},fileId=${fileId}`); } // 必须返回 {"error": 0} res.json({ error: 0 }); } catch (err) { console.error(`[Callback] 处理异常,fileId=${fileId}:`, err); // 即使处理失败,也返回 error: 0 阻止重试(已记录日志,可人工处理) // 如果希望 Document Server 重试,返回 error: 1 res.json({ error: 0 }); }});// 下载文件并保存到本地存储async function downloadAndSave(fileId, downloadUrl) { const tempPath = path.join('./storage/tmp', `${fileId}_${Date.now()}.docx`); const finalPath = path.join('./storage', `${fileId}.docx`); // 先下载到临时文件,下载完成后原子替换(防止下载中断导致文件损坏) await downloadFile(downloadUrl, tempPath); fs.renameSync(tempPath, finalPath);}function downloadFile(url, destPath) { return new Promise((resolve, reject) => { const file = fs.createWriteStream(destPath); https.get(url, (response) => { if (response.statusCode !== 200) { file.close(); fs.unlinkSync(destPath); return reject(new Error(`下载失败,HTTP ${response.statusCode}`)); } response.pipe(file); file.on('finish', () => file.close(resolve)); file.on('error', (err) => { fs.unlinkSync(destPath); reject(err); }); }).on('error', reject); });}4.4 强制保存(Force Save)
强制保存允许在用户仍在编辑时触发中间保存,有两种触发方式:
方式一:通过 Document Server 命令接口触发
// 向 Document Server 发送 forcesave 命令async function triggerForceSave(documentKey) { const command = { c: 'forcesave', key: documentKey, }; // 对命令签名 const token = jwt.sign(command, JWT_SECRET); const response = await fetch( `${DOCUMENT_SERVER_URL}/coauthoring/CommandService.ashx`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ ...command, token }), } ); const result = await response.json(); // result.error === 0 表示成功触发 return result;}方式二:在编辑器前端触发
// 通过编辑器 API 触发(用户在当前标签页编辑时)docEditor.forceSave();5. 文件转换 API
文件格式转换是 Document Server 的独立能力,不需要打开编辑器即可调用。
5.1 同步转换
async function convertFile(fileUrl, fromType, toType, fileKey) { const requestBody = { async: false, // 同步模式:等待转换完成返回结果 filetype: fromType, // 源文件格式(不含点,如 'docx') key: fileKey, // 转换任务唯一标识 outputtype: toType, // 目标格式(如 'pdf') title: `output.${toType}`, url: fileUrl, // 源文件的可访问 URL }; const token = jwt.sign(requestBody, JWT_SECRET); const response = await fetch( `${DOCUMENT_SERVER_URL}/ConvertService.ashx`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ ...requestBody, token }), } ); const result = await response.json(); // result.endConvert === true → 转换完成 // result.fileUrl → 转换结果下载链接 // result.error → 错误码(0 表示无错误) if (result.endConvert && result.fileUrl) { return result.fileUrl; } else { throw new Error(`转换失败,错误码:${result.error}`); }}// 使用示例:将 docx 转换为 pdfconst pdfUrl = await convertFile( 'https://your-storage.com/files/report.docx', 'docx', 'pdf', 'convert_task_001');5.2 异步转换(适用于大文件)
async function convertFileAsync(fileUrl, fromType, toType, fileKey) { const requestBody = { async: true, // 异步模式:立即返回,通过 key 轮询结果 filetype: fromType, key: fileKey, outputtype: toType, url: fileUrl, }; const token = jwt.sign(requestBody, JWT_SECRET); // 发起转换请求 const response = await fetch( `${DOCUMENT_SERVER_URL}/ConvertService.ashx`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ ...requestBody, token }), } ); let result = await response.json(); // 如果尚未完成,轮询进度(最多等待 5 分钟) const maxAttempts = 60; let attempts = 0; while (!result.endConvert && attempts < maxAttempts) { await new Promise(r => setTimeout(r, 5000)); // 每 5 秒轮询一次 const pollBody = { async: true, filetype: fromType, key: fileKey, outputtype: toType, url: fileUrl }; const pollToken = jwt.sign(pollBody, JWT_SECRET); const pollResponse = await fetch( `${DOCUMENT_SERVER_URL}/ConvertService.ashx`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${pollToken}` }, body: JSON.stringify({ ...pollBody, token: pollToken }), } ); result = await pollResponse.json(); attempts++; console.log(`转换进度:${result.percent || 0}%`); } if (result.endConvert && result.fileUrl) { return result.fileUrl; } else { throw new Error(`转换超时或失败,最后状态:${JSON.stringify(result)}`); }}5.3 支持的转换格式矩阵
常用的格式转换方向:
| 源格式 | 可转换的目标格式 |
|---|---|
| docx | pdf, odt, rtf, txt, html, epub, fb2 |
| xlsx | pdf, ods, csv |
| pptx | pdf, odp |
| odt | docx, pdf, rtf, txt |
| ods | xlsx, pdf, csv |
| odp | pptx, pdf |
| txt | docx, pdf |
| csv | xlsx |
| html | docx, pdf |
6. 协同会话管理
6.1 查询当前编辑文档的用户
通过 CommandService 接口可以查询当前在编辑某个文档的用户列表:
async function getEditingUsers(documentKey) { const command = { c: 'info', key: documentKey }; const token = jwt.sign(command, JWT_SECRET); const response = await fetch( `${DOCUMENT_SERVER_URL}/coauthoring/CommandService.ashx`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ ...command, token }), } ); const result = await response.json(); // result.users → 当前在线编辑的用户 ID 列表 return result.users || [];}6.2 强制关闭文档编辑会话
某些场景下需要强制结束所有用户的编辑会话(如文件权限变更、文件被删除):
async function forceCloseDocument(documentKey) { const command = { c: 'drop', key: documentKey, users: [] }; // users 为空数组表示关闭所有用户的会话 // users 传入用户 ID 列表表示仅关闭特定用户的会话 const token = jwt.sign(command, JWT_SECRET); const response = await fetch( `${DOCUMENT_SERVER_URL}/coauthoring/CommandService.ashx`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ ...command, token }), } ); return await response.json();}注意: 调用 drop 命令后,Document Server 会触发文档的保存回调(status 2),确保保存回调逻辑正确处理这种情况。
6.3 文档 key 的更新(强制刷新缓存)
当外部系统修改了文件内容(如批量替换内容、合并分支),需要通知 Document Server 放弃缓存,下次打开时重新拉取文件:
正确做法是更新传给前端的 config 中的 `document.key`,而不是调用任何 API。Document Server 看到新 key 时,会自动识别这是一个新版本,重新拉取文件。
如果有用户正在编辑旧版本,处理方式取决于业务策略:
// 策略一:先强制关闭旧会话,再提供新 key(数据安全优先)await forceCloseDocument(oldKey);// 之后用新 key 重新打开文档// 策略二:用旧 key 触发强制保存,保存完成后更新 key(减少数据丢失)await triggerForceSave(oldKey);// 在回调处理中检测到 status === 6 后,更新数据库中的 key7. 完整集成示例:从上传到保存
将前面所有环节串联起来,给出一个接近生产可用的完整示例。
7.1 项目结构
onlyoffice-integration/├── app.js # 主应用├── routes/│ ├── editor.js # 编辑器页面路由│ └── callback.js # 回调处理路由├── services/│ ├── jwt.js # JWT 工具│ ├── storage.js # 文件存储服务│ └── onlyoffice.js # OnlyOffice API 封装├── views/│ └── editor.html # 编辑器页面模板└── .env7.2 JWT 工具模块
// services/jwt.jsconst jwt = require('jsonwebtoken');const SECRET = process.env.JWT_SECRET;if (!SECRET || SECRET.length < 32) { throw new Error('JWT_SECRET 未配置或长度不足 32 位');}module.exports = { sign(payload) { return jwt.sign(payload, SECRET, { algorithm: 'HS256' }); }, verify(token) { return jwt.verify(token, SECRET); }, verifyFromHeader(authHeader) { if (!authHeader || !authHeader.startsWith('Bearer ')) { throw new Error('缺少 Authorization Header 或格式不正确'); } return this.verify(authHeader.slice(7)); },};7.3 编辑器路由
// routes/editor.jsconst express = require('express');const router = express.Router();const jwtHelper = require('../services/jwt');const storage = require('../services/storage');const crypto = require('crypto');const DOCUMENT_SERVER_URL = process.env.DOCUMENT_SERVER_URL;const APP_BASE_URL = process.env.APP_BASE_URL;// 根据文件 ID 和最后修改时间生成 document.keyfunction makeDocKey(fileId, updatedAt) { return crypto .createHash('sha256') .update(`${fileId}_${updatedAt.getTime()}`) .digest('hex') .substring(0, 20);}router.get('/:fileId', async (req, res) => { try { const { fileId } = req.params; const userId = req.user.id; // 来自你的认证中间件 const userName = req.user.name; // 从数据库查询文件信息 const file = await storage.getFileById(fileId); if (!file) return res.status(404).send('文件不存在'); // 检查用户权限 const canEdit = await storage.checkPermission(userId, fileId, 'edit'); const config = { documentType: getDocumentType(file.extension), document: { fileType: file.extension, key: makeDocKey(fileId, file.updatedAt), title: file.name, url: `${APP_BASE_URL}/api/files/${fileId}/download`, permissions: { edit: canEdit, comment: true, download: true, print: true, review: canEdit, }, }, editorConfig: { callbackUrl: `${APP_BASE_URL}/api/callback/${fileId}`, user: { id: String(userId), name: userName }, lang: 'zh', mode: canEdit ? 'edit' : 'view', coEditing: { mode: 'fast', change: true }, customization: { forcesave: true, autosave: true, logo: { visible: false }, integrationMode: 'embed', }, }, }; // 签名 config.token = jwtHelper.sign(config); res.render('editor', { config: JSON.stringify(config), documentServerUrl: DOCUMENT_SERVER_URL, }); } catch (err) { console.error('[Editor] 路由错误:', err); res.status(500).send('服务器内部错误'); }});function getDocumentType(ext) { if (['doc', 'docx', 'odt', 'rtf', 'txt'].includes(ext)) return 'word'; if (['xls', 'xlsx', 'ods', 'csv'].includes(ext)) return 'cell'; if (['ppt', 'pptx', 'odp'].includes(ext)) return 'slide'; return 'word';}module.exports = router;7.4 回调路由
// routes/callback.jsconst express = require('express');const router = express.Router();const jwtHelper = require('../services/jwt');const storage = require('../services/storage');const https = require('https');const fs = require('fs');const path = require('path');router.post('/:fileId', async (req, res) => { const { fileId } = req.params; // 1. 验签 try { jwtHelper.verifyFromHeader(req.headers['authorization']); } catch (err) { console.error(`[Callback] 验签失败 fileId=${fileId}:`, err.message); return res.json({ error: 1 }); } const { status, url, users, actions, key } = req.body; console.log(`[Callback] fileId=${fileId} status=${status} key=${key}`); try { if (status === 1) { await storage.updateOnlineUsers(fileId, users || []); } else if (status === 2 || status === 6) { await saveFile(fileId, url); if (status === 2) { await storage.updateOnlineUsers(fileId, []); } } else if (status === 3 || status === 7) { console.error(`[Callback] 保存错误 fileId=${fileId} status=${status}`); } else if (status === 4) { await storage.updateOnlineUsers(fileId, []); } res.json({ error: 0 }); } catch (err) { console.error(`[Callback] 处理失败 fileId=${fileId}:`, err); res.json({ error: 0 }); }});async function saveFile(fileId, downloadUrl) { const tmpPath = path.join('./tmp', `${fileId}_${Date.now()}`); const finalPath = await storage.getFilePath(fileId); await new Promise((resolve, reject) => { const file = fs.createWriteStream(tmpPath); https.get(downloadUrl, res => { if (res.statusCode !== 200) { file.close(); return reject(new Error(`HTTP ${res.statusCode}`)); } res.pipe(file); file.on('finish', () => file.close(resolve)); file.on('error', reject); }).on('error', reject); }); fs.renameSync(tmpPath, finalPath); await storage.updateFileTimestamp(fileId);}module.exports = router;8. 多语言 SDK 参考
OnlyOffice 官方提供了多语言集成 SDK,以下是各语言 JWT 签名的关键实现。
PHP:
use Firebase\JWT\JWT;
$jwtSecret = getenv('JWT_SECRET');
function generateEditorConfig($fileId, $userId, $userName) {
global $jwtSecret;
$config = [
'documentType' => 'word',
'document' => [
'fileType' => 'docx',
'key' => $fileId . '_' . time(),
'title' => '文档.docx',
'url' => 'https://your-app.com/files/' . $fileId,
],
'editorConfig' => [
'callbackUrl' => 'https://your-app.com/callback/' . $fileId,
'user' => ['id' => $userId, 'name' => $userName],
'lang' => 'zh',
'mode' => 'edit',
],
];
$config['token'] = JWT::encode($config, $jwtSecret, 'HS256');
return $config;
}
Java(Spring Boot):
@Service
public class OnlyOfficeService {
@Value("${onlyoffice.jwt.secret}")
private String jwtSecret;
@Value("${onlyoffice.server.url}")
private String serverUrl;
public Map generateConfig(String fileId,
String userId,
String userName) {
Map document = new LinkedHashMap<>();
document.put("fileType", "docx");
document.put("key", fileId + "_" + System.currentTimeMillis());
document.put("title", "文档.docx");
document.put("url", "https://your-app.com/files/" + fileId);
Map user = new LinkedHashMap<>();
user.put("id", userId);
user.put("name", userName);
Map editorConfig = new LinkedHashMap<>();
editorConfig.put("callbackUrl", "https://your-app.com/callback/" + fileId);
editorConfig.put("user", user);
editorConfig.put("lang", "zh");
editorConfig.put("mode", "edit");
Map config = new LinkedHashMap<>();
config.put("documentType", "word");
config.put("document", document);
config.put("editorConfig", editorConfig);
String token = Jwts.builder()
.setClaims(config)
.signWith(SignatureAlgorithm.HS256,
jwtSecret.getBytes(StandardCharsets.UTF_8))
.compact();
config.put("token", token);
return config;
}
}
Python(Django / Flask):
import jwt
import hashlib
import time
import os
JWT_SECRET = os.environ.get('JWT_SECRET')
def generate_config(file_id: str, user_id: str, user_name: str) -> dict:
key = hashlib.md5(f"{file_id}_{int(time.time())}".encode()).hexdigest()[:20]
config = {
'documentType': 'word',
'document': {
'fileType': 'docx',
'key': key,
'title': '文档.docx',
'url': f'https://your-app.com/files/{file_id}',
'permissions': {
'edit': True,
'comment': True,
},
},
'editorConfig': {
'callbackUrl': f'https://your-app.com/callback/{file_id}',
'user': {'id': user_id, 'name': user_name},
'lang': 'zh',
'mode': 'edit',
},
}
config['token'] = jwt.encode(config, JWT_SECRET, algorithm='HS256')
return config
9. 常见陷阱与调试技巧
陷阱一:document.key 未更新导致用户看到旧文档
现象: 文件内容已更新,但用户打开编辑器看到的仍是旧版本。
原因:document.key 没有随文件内容更新而更换,Document Server 使用了缓存。
解决: 使用文件内容哈希或最后修改时间戳生成 key,确保文件变化时 key 一定变化。
陷阱二:回调地址使用了 localhost
现象: 用户关闭编辑器后,文件未保存,日志显示 connection refused 或 connection timeout。
原因: Document Server 是从容器内部访问 callbackUrl 的,容器内的 localhost 指向容器自身,不是宿主机。
解决: 回调地址使用宿主机的内网 IP 或域名,不要使用 localhost 或 127.0.0.1。
陷阱三:回调响应超时导致文档锁定
现象: 文档保存成功,但此后同一文档无法再次编辑,始终显示"正在保存"。
原因: 回调处理耗时超过 15 秒(如文件下载慢、数据库操作慢),Document Server 判定回调失败,文档进入重试锁定状态。
解决: 回调接口应立即返回 {"error": 0},将实际的文件下载和保存操作异步处理。
app.post('/callback/:fileId', async (req, res) => {
// 立即响应,不等待文件保存完成
res.json({ error: 0 });
// 异步处理文件保存(不阻塞响应)
if (req.body.status === 2 || req.body.status === 6) {
saveFileAsync(req.params.fileId, req.body.url)
.catch(err => console.error('异步保存失败:', err));
}
});
陷阱四:多实例部署时协同编辑异常
现象: 多用户协同时,有用户看不到他人的修改,或出现内容回滚。
原因: 负载均衡将同一文档的不同用户路由到了不同的 Document Server 实例,各实例独立维护会话状态。
解决: 在负载均衡器上配置基于 document.key 的会话亲和(Sticky Session),确保同一文档的所有请求路由到同一实例。
陷阱五:iframe 中 WebSocket 连接被拦截
现象: 编辑器在 iframe 中加载正常,但协同编辑不工作,浏览器控制台显示 WebSocket 连接失败。
原因: 父页面的 CSP(Content Security Policy)策略没有允许 Document Server 域名的 WebSocket 连接。
解决: 在父页面的 HTTP 响应头中添加:
Content-Security-Policy: connect-src 'self' wss://docs.example.com;
frame-src 'self' https://docs.example.com;
调试技巧:使用浏览器开发者工具
集成问题的 80% 可以通过浏览器开发者工具定位:
- Network 标签:查找对 api.js 的请求是否 200,查找 WebSocket 连接状态是否 101
- Console 标签:OnlyOffice 的错误会通过 onError 事件输出,确保你的 events.onError 做了日志记录
- Application 标签:检查是否有跨域 Cookie 问题影响认证
10. 集成安全核查
在集成上线前,逐项确认:
JWT 安全
- JWT Secret 长度不少于 32 位,使用随机生成的字符串
- Document Server 侧 JWT_ENABLED=true
- 集成方后端对回调请求进行 JWT 验签,拒绝无效 Token 的请求
- document.url 和 callbackUrl 均为 HTTPS
文件访问控制
- 提供给 Document Server 的 document.url 需要携带有效的访问凭证(Token 参数或 Header)
- 该访问凭证应为短期有效(建议不超过 1 小时),防止链接被滥用
- 回调处理中验证 fileId 是否属于当前用户,防止越权覆盖他人文件
网络隔离
- Document Server 的非 HTTP 端口(PostgreSQL、RabbitMQ、Redis)不对外暴露
- 如果是内网部署,通过白名单限制 Document Server 可访问的回调地址范围
权限控制
- 在生成 config 时从你的系统查询权限,不信任前端传来的权限参数
- permissions.edit 等字段在服务端生成,不允许客户端修改
小结
本文从最小化集成示例出发,逐步展开了 Document Editor API 的完整参数体系、回调处理的状态机逻辑、文件转换接口,以及协同会话管理的常用操作。
集成工作中最容易出问题的三个环节是:document.key 的缓存管理、回调响应的超时处理、以及 JWT 签名的双向验证。把这三个地方做扎实,集成稳定性就有了基本保证。
夜雨聆风