JitWord 集成文档 正在检测服务 下载示例包 产品官网

把 JitWord 编辑器嵌进你的业务系统

这里是 JitWord 的集成示范环境。拿到授权后,你可以直接连这个站点做联调,不必先搭一套私有化部署——一个下午就能在自己的系统里跑通一个能多人协同编辑的 Word 文档。

编辑器地址
https://inner.jitword.com/px-editor
接口地址
https://inner.jitword.com/api/v1
SDK 入口
https://inner.jitword.com/px-editor/iframe-sdk/v1/loader.js

01这个站点是什么

先说清楚它能做什么、不能做什么,省得你按错方向查问题。

inner.jitword.com 只承担一个角色:被别的系统嵌入。它对外只开放三类资源——编辑器静态站、iframe SDK、以及嵌入专用接口 /api/v1/embed/*。

JitWord 自己那套产品入口(注册、登录、管理后台、文档列表)在这个站点上全部关闭。这不是前端藏起来了,是服务端拦截:打这些路径会拿到 {"code":404,"message":"Not Found"}。所以你的用户体系不会被绕过——谁能打开哪份文档,完全由你的后端签发票据时决定。

这是演示环境,不是生产环境。

不承诺可用性,不做数据备份,文档内容可能在任何时候被清理。请只放测试数据,不要放任何真实业务内容或个人信息。正式上线请走私有化部署,届时所有地址换成你自己的域名,代码一行不用改。

02五分钟跑通

三步。第一步在你的后端,第二步在你的前端,第三步打开浏览器。

想跳过手写代码:下载 示例包(前端 + 后端都有,零依赖、无数据库、无需用户体系)。解压后把 server.mjs 顶部的 JITWORD 换成我们发你的值,跑 npm start,打开终端打印的地址(默认 http://localhost:3000)即通;首次启动会自动为本租户创建一份独立的演示文档,不需要提前准备 docId。起不来时先跑 node server.mjs --check,会逐步报出卡在哪一步。下面的代码与示例包一致,方便对照理解。

第一步:后端加一个换票接口

这个接口做两件事:确认当前登录用户有权限看这份文档(用你自己的逻辑),然后向 JitWord 换一张一次性票据。clientSecret 只出现在这里,绝不能下发到浏览器。

server.js — Node / Express
const JITWORD = {
  api: 'https://inner.jitword.com/api/v1',
  tenantKey: 'your-tenant',        // 我们发给你的
  providerKey: 'your-app',         // 我们发给你的
  clientSecret: process.env.JITWORD_CLIENT_SECRET,
  origin: 'https://your-portal.example.com'   // 你页面的来源,必须与浏览器地址栏一致
}

app.post('/api/jitword-ticket', async (req, res) => {
  // 1) 用你自己的登录态和权限逻辑判断,这一步我们不干预
  const me = req.user
  if (!me) return res.status(401).json({ error: 'not logged in' })

  const { docId } = req.body
  const canEdit = await yourAcl.canEdit(me.id, docId)

  // 2) 换票
  const r = await fetch(`${JITWORD.api}/embed/tickets`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Embed-Client-Secret': JITWORD.clientSecret
    },
    body: JSON.stringify({
      tenantKey: JITWORD.tenantKey,
      providerKey: JITWORD.providerKey,
      externalSubject: String(me.id),        // 你系统里的用户 ID
      user: { displayName: me.name, email: me.email },
      document: { docId },
      permission: {
        role: canEdit ? 'editor' : 'viewer',
        scopes: canEdit
          ? ['document:read', 'document:edit', 'comment:write']
          : ['document:read']
      },
      origin: JITWORD.origin,
      expiresIn: 300
    })
  })

  const { data } = await r.json()
  res.json({ ticket: data.ticket })          // 只把 ticket 交出去
})

第二步:前端粘一段 HTML

加载 loader,给它一个容器和一个“去哪拿票”的函数。剩下的握手、鉴权、协同连接,SDK 自己处理。

editor.html
<div id="jitwordEditor" style="height:720px"></div>

<script src="https://inner.jitword.com/px-editor/iframe-sdk/v1/loader.js"></script>
<script>
  const docId = 'your-demo-doc-id'        // 我们发给你的演示文档 ID

  const editor = JitWord.createEditor({
    container: '#jitwordEditor',
    editorUrl: 'https://inner.jitword.com/px-editor',
    apiBase:   'https://inner.jitword.com/api/v1',
    docId,
    documentType: 'document',
    tenantKey: 'your-tenant',
    providerKey: 'your-app',
    auth: {
      mode: 'embed-ticket',
      getEmbedTicket: () =>
        fetch('/api/jitword-ticket', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ docId })
        })
          .then(r => r.json())
          .then(p => p.ticket)         // 必须返回 ticket 字符串
    },
    ui: { theme: 'auto', chrome: 'toolbar' },   // 显示编辑器自带工具条;默认 'host' 只渲染内容
    debug: { enabled: true }             // 联调期打开,上线前关掉
  })

  editor.on('ready', s => console.log('编辑器就绪', s))
  editor.on('error', e => console.error('出错', e.code, e.message))
</script>

第三步:打开页面

看到编辑器加载出来、能输入文字,这条链路就通了。开两个浏览器窗口打开同一个 docId,能看到实时协同光标。

页面打不开或者白屏?先别改代码,去第 9 节把七条 curl 跑一遍,能直接定位卡在哪一环。

03接入前准备

授权是双向的:密钥三件套由我们下发,演示文档可自助创建;你需要准备三样。

我们发给你

走申请流程后由我们配置下发。

tenantKey
租户标识。同一家公司共用一个。
providerKey
应用标识。同一租户下不同系统各用一个,便于分别配置策略和查审计。
clientSecret
换票密钥。只放服务端环境变量,不要进代码仓库、不要进前端构建产物。
演示 docId(可选)
一份可读写的测试文档。需要更多可以找我们建,或用 POST /embed/documents 自助创建;也可以直接用示例包,它首次启动会自动创建一份本租户专属文档。

你需要准备

这三样缺一个,链路就跑不通。

一个后端换票接口
你系统里的一个普通接口,用你自己的登录态鉴权。第 2 节有完整示例。
一个固定的页面来源
形如 https://your-portal.example.com,协议加域名加端口。我们会把它加进白名单,不在名单里的来源换票直接 403。本地联调请一并提供(如 http://localhost:3000)。
CSP 放行
如果你的页面有内容安全策略,需要加 frame-src https://inner.jitword.com,否则浏览器会拦掉 iframe。

关于 origin 白名单的常见误解:白名单校验的是你换票时声明的 origin,不是 HTTP 请求头里的 Referer。换票请求由你的服务端发出,所以 origin 字段必须由你显式填写,填成你页面在浏览器地址栏里的那个来源。填错了会在 iframe 兑换会话时报 origin mismatch。

不需要用户体系,也不需要数据库。联调期后端把 externalSubject 写成一个静态用户(如 demo-user-001)即可,首次出现时我们会自动映射出内部用户,不碰你现有任何系统;正式接入时换成你登录态里稳定不变的用户 ID。文档由 JitWord 侧负责存储与协同,你只需要保存 docId 与业务对象的映射(一个字段、一张表甚至静态配置都行),既不必维护文档系统,也不必为此建库。

04集成链路

一共三跳。理解这张图,后面所有的权限和报错都好解释。

JitWord 嵌入集成时序 你的前端向你的服务端请求票据,你的服务端携带 clientSecret 向 JitWord 换取一次性票据并回传,前端把票据交给 iframe 兑换会话,随后建立协同连接。clientSecret 只在服务端与 JitWord 之间传递,从不进入浏览器。 你的前端页面 你的服务端 inner.jitword.com 浏览器环境 持有 clientSecret 编辑服务 ① 要一张票(带你自己的登录态) ② POST /embed/tickets 携带 clientSecret ③ 一次性票据(60–300 秒) ④ 只回传 ticket ⑤ 用 ticket 兑换会话(SDK 自动完成) wss 协同通道,全程保持 clientSecret 不越过这条线
第 ⑤ 步由 SDK 在 iframe 内部自动完成,你不需要写这段代码。票据一次性:同一张票第二次兑换返回 409。

为什么要绕这一圈

直觉上更省事的做法是前端直接调 /embed/tickets。不行——那意味着 clientSecret 得下发到浏览器,任何人打开开发者工具就能拿到它,然后给自己签一张 editor 权限的票,打开任意文档。

所以票据必须由你的服务端签发:签发那一刻,“这个人是谁、能看哪份文档、能不能编辑”已经被你的系统判定完毕并冻结进票据。浏览器拿到的只是一张有效期几分钟、用一次就废的凭证。

05前端接入

loader 的加载通道、createEditor 的全部参数、事件、以及 52 项可调用能力。

返回值约定:所有方法都返回 { result: ... },取数据要读一层——const r = await editor.getInfo(),数据在 r.result;失败时 Promise 抛出或 error 事件给出 { code, message },建议按 code 分支处理。

选择加载通道

三个入口对应三种稳定性,生产环境固定用 v1。

通道路径用途
v1/px-editor/iframe-sdk/v1/loader.js生产接入。主版本内保持兼容,不会有破坏性变更。
latest/px-editor/iframe-sdk/latest/loader.js最新稳定版预览。适合提前验证新能力。
canary/px-editor/iframe-sdk/canary/loader.js灰度验证。不缓存,随时可能变动。

latest 或 canary 出问题,把地址改回 v1 即可回滚,不需要我们介入。当前各通道的实际版本可以随时查 /px-editor/iframe-sdk/manifest.json。

createEditor 参数

参数说明
container必填CSS 选择器或 DOM 元素。容器必须有确定高度,否则 iframe 高度塌成 0,表现为一片空白。
editorUrl必填https://inner.jitword.com/px-editor
docId必填文档 ID。必须与换票时 document.docId 一致。
apiBase可选https://inner.jitword.com/api/v1
auth可选{ mode:'embed-ticket', getEmbedTicket }。getEmbedTicket 返回 Promise<string>,每次握手和续期都会调用,所以它必须能被重复调用。
documentType可选默认 document。当前仅支持 document。
tenantKey / providerKey可选用于诊断归因。建议与换票时保持一致。
ui可选{ theme:'auto'|'light'|'dark', readonly:boolean, chrome:'host'|'toolbar'|'full' }。readonly 只能收紧权限,不能把只读升级为可编辑;chrome 决定显示多少编辑器自带外壳(默认不显示),见下节。工具条档位用 editor.setToolbar(...) 运行时切换。
mode可选full(默认,完整协同)或 preview(零协同静态只读渲染)。见下方多实例优化。
lazy可选true 时进入视口才真正创建 iframe。配合 destroyOnLeave 和 lazyRootMargin(默认 '200px')使用。
timeout可选单次请求超时毫秒数,默认 10000,取值钳制在 1000–60000。
width / height / border可选iframe 内联样式,默认 100% / 100% / 0。
debug可选{ enabled:true, traceId:'...' }。打开后 diagnostic 事件才会派发,traceId 会带进服务端日志便于排查。

界面外壳与工具栏(chrome)

默认状态下 SDK 只渲染文档内容:顶栏、编辑工具条、右侧工具条都收起来,由宿主页面自己画 UI(字段级嵌入最常用)。要给用户“和独立编辑器一样”的操作体验,用 ui.chrome 把编辑器自带外壳放出来:

chrome 取值显示内容适用场景
'host'(默认)只有文档内容,无顶栏、无工具条、无右侧工具条宿主自绘工具栏;表单里的富文本字段
'toolbar'编辑工具条(字体、段落、表格、图片等),隐藏顶栏、工具条里的“文件”入口和 AI 入口嵌入业务页面但不想自己拼工具栏(推荐,见下例)
'full'完整外壳:顶栏 + 工具条 + 右侧工具条(大纲、评论等),最接近独立编辑器页面本身就是编辑器形态的场景
三种外壳 + 运行时切换工具条
// 1) 只有内容(默认,宿主自绘 UI 时用)
const editor = JitWord.createEditor({ ...baseConfig, ui: { chrome: 'host' } })

// 2) 显示编辑器自带编辑工具条(嵌入业务页面最常用)
const editor = JitWord.createEditor({ ...baseConfig, ui: { chrome: 'toolbar' } })

// 3) 完整编辑器外壳(顶栏 + 工具条 + 右侧工具条)
const editor = JitWord.createEditor({ ...baseConfig, ui: { chrome: 'full' } })

// 运行中切换工具条档位:full(分类面板)/ simple(单行极简)/ none(整体隐藏)
editor.setToolbar('full')
  • 工具条按钮随实时权限自动启用/禁用:只读票据下写操作按钮不可用,宿主不需要自己判断哪些按钮能点。
  • 嵌入模式不提供文档列表,因此 'toolbar' 会隐藏工具条里的“文件”入口;宿主自备标题栏或文件树时正好。
  • 改 chrome 只需刷新页面,不影响文档内容和协同会话;editor.setToolbar() 更是即时生效。

只看到白色画布、什么工具条都没有?这是 ui.chrome 默认 'host' 的正常表现(内容区之外的外壳默认不渲染),不是权限或票据问题:把 chrome 改成 'toolbar' 即可看到完整编辑工具条。

事件

用 editor.on(name, handler) 订阅。这几个是接入时一定要处理的:

事件何时触发 / 你该做什么
ready握手完成,编辑器可用。payload 含实际生效的权限与能力清单——以它为准渲染你的工具栏,而不是以你请求的权限为准。
error任何失败。payload 含 code,对照第 10 节。
auth.sessionExpired会话到达硬截止时刻。编辑器已自动切只读,你应提示用户并销毁 iframe。
auth.refreshRequired需要新票据。SDK 会自己调 getEmbedTicket,你只需保证那个函数始终可用。
auth.userDisabled该外部用户已被停用,应立即收回入口。
saveStateChanged保存状态变化,含 saveStatus、savedSeq。适合驱动“正在保存/已保存”提示。
contentChanged协同内容已持久化。
connectionChanged协同连接状态变化,可用于显示离线提示。
onlineUsersChanged在线协作者变化。
permissionChanged权限被收紧,应同步更新你的 UI。
diagnostic仅在 debug.enabled 时派发,内容已脱敏。
destroyed实例已销毁。
全部 52 项能力39 个方法 · 13 个事件
内容读写
  • getContent
  • setContent
  • insertContent
  • clearContent
  • deleteContent
  • undo
  • redo
  • save
  • getSelection
  • setSelection
  • scrollTo
文档信息
  • getInfo
  • getPermission
  • getStatus
  • getOnlineUsers
  • getMetadata
  • setMetadata
版本与模板
  • listVersions
  • restoreVersion
  • diffVersions
  • listTemplates
  • instantiateTemplate
评论
  • listComments
  • createComment
  • updateComment
  • deleteComment
修订(track changes)
  • listRevisions
  • setRevisionMode
  • acceptRevision
  • rejectRevision
  • setRevisionPreview
文档导入
  • importDocx
导出与界面
  • exportDocx
  • setTheme
  • setReadonly
  • setWatermark
  • setToolbar
  • focus
  • destroy
事件
  • ready
  • permissionChanged
  • onlineUsersChanged
  • contentChanged
  • connectionChanged
  • saveStateChanged
  • diagnostic
  • destroyed
  • auth.refreshRequired
  • auth.userDisabled
  • auth.sessionExpired
  • error
  • lifecycle.upgradeRequested

权威清单随版本变化,以 /px-editor/iframe-sdk/manifest.json 为准;结构定义见同目录 capability.schema.json,可直接用于自动化能力发现。

前端导入 DOCX

宿主把 .docx 的字节流传进来,解析在浏览器内完成(高保真管线,不经过服务端解析接口、不落临时文件),解析结果直接写入当前文档。需要 document:edit 票据 scope;preview 实例为只读白名单,调用会失败。

该能力随 SDK 1.1.0 提供(manifest 里 since: "1.1")。若你的页面缓存过旧版 loader,调用前先做能力探测:typeof editor.importDocx === 'function',或用 ready 事件 payload 里的能力清单判断。

把本地 docx 导入当前文档
// content 支持 ArrayBuffer / Uint8Array / Blob / base64 字符串
const file = input.files[0]                      // <input type="file" accept=".docx">
const result = await editor.importDocx({
  content: file,                // 也可传 await file.arrayBuffer()
  fileName: file.name,
  mode: 'replace',            // 默认整体替换;'insert' 追加到文末或 nodeId 锚点之后
  timeout: 60000                // 可选;默认 60s(大文件解析可能超过常规 10s)
})
// result: { mode, applied, importedComments, headerFooter, warnings }
if (result.importedComments || result.headerFooter) {
  console.warn('批注/页眉页脚未写入文档,需要走服务端通道')
}
约束说明
文件格式仅 .docx(OOXML)。真实格式按文件头魔数判定(扩展名写成 .docx 的老文档同样会被识别);.doc(Word 97-2003)与加密/受保护的 OOXML 在嵌入链路下不做自动转换,请宿主先另存为无密码的 .docx——嵌入实例不开放服务端解析/转换通道。
体积上限30MB(与应用侧导入同一上限),超出返回 INVALID_CONFIG。超大文档建议拆分后分批导入。
解析与提示复用应用侧导入管线:内置 docx 签名闸门、体积/空/名校验与错误脱敏;导入期间编辑器内会显示应用自带的进度浮层(与导出同一套提示)。
图片文档内图片自动上传到本实例公开上传目录(与粘贴图片同一链路);如需落你自己的对象存储,请与平台方对接。
不写入的内容批注、页眉页脚只解析不落库/不写部件,返回值里给出数量,避免误以为"全量还原"。
权限与治理需编辑权限;可用 Profile features 里的 document.importDocx: false 对单个客户关闭。

一页多个编辑器

表单或列表里有十几个富文本字段时,全部建成完整协同实例会很重。两个开关可以叠加使用:

多实例优化
// 不可见时零开销,可见时轻量只读渲染,用户点击后自动升级为完整协同
const editor = JitWord.createEditor({
  ...baseConfig,
  mode: 'preview',        // 不建 WebSocket,REST 拉快照后只读渲染
  lazy: true,             // 进入视口才实例化
  destroyOnLeave: true,   // 离开视口释放,再进入自动重建(内容无损)
  placeholderText: '滚动到此处自动加载'
})

editor.on('upgraded', () => console.log('已升级为完整协同'))

preview 实例只开放只读白名单能力,调用 setContent 这类写方法会返回 ACTION_UNSUPPORTED。用户点击正文时 SDK 默认自动重建为完整模式;传 autoUpgrade: false 可改为监听 lifecycle.upgradeRequested 后由你自己调 editor.upgrade()。

06服务端换票

整套集成里唯一需要你写服务端代码的地方。

POST /api/v1/embed/tickets

鉴权方式是请求头 X-Embed-Client-Secret,不是 Bearer token。

请求字段

字段说明
tenantKey必填我们发给你的租户标识。
providerKey必填我们发给你的应用标识。
externalSubject必填你系统里的用户唯一 ID。首次出现时会自动映射出一个内部用户,之后保持稳定——不要用会变的值(比如手机号、邮箱),否则同一个人会变成多个协作者。
document.docId必填文档 ID,必须已存在,否则 404。
origin必填你页面的来源,必须在白名单内且与浏览器实际来源一致。
user可选{ displayName, email, role }。displayName 会显示为协同光标上的名字,建议填。
permission.role可选角色名,会按配置策略映射成一组 scopes。
permission.scopes可选document:read / document:edit / comment:write。缺少 document:read 直接 403。
permissionVersion可选你自己的权限版本号,会透传进审计,便于事后对账。
ui可选{ readonly, theme, toolbar }。这里的 readonly:true 会在服务端生效,比前端传的更可靠。
expiresIn可选票据寿命秒数,默认 300,取值钳制在 60–300。
sessionExpiresAt可选会话绝对截止时刻。到点后续期、兑换、正文读写、协同连接、导出全部被服务端拒绝。适合限时审阅、考试交卷这类场景。
behavior可选{ copy, print, export, anonymousView }。见第 7 节。
watermark可选{ text, color, opacity }。text 必填且不超过 100 字符。
environment可选默认 production。用于区分同一应用的不同环境策略。

响应

200 OK
{
  "code": 200,
  "message": "ok",
  "data": {
    "ticket": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",   // 交给前端的就是这个
    "expiresAt": "2026-09-21T15:32:10.000Z",
    "expiresIn": 300,
    "sessionExpiresAt": null,
    "docId": "demo-001",
    "origin": "https://your-portal.example.com",
    "scopes": ["document:read", "document:edit", "comment:write"],
    "behavior": { "copy": true, "print": true, "export": true },
    "ui": { "readonly": false, "theme": "auto", "toolbar": "compact" },
    "mapping": {
      "internalUserId": "u_9f2c...",      // 映射出的内部用户
      "externalSubject": "user-123",
      "status": "active"
    }
  }
}

data.scopes 是实际生效的权限,可能比你请求的窄——服务端会把你的请求与策略配置求交,只能收紧不能放大。如果你的 UI 依赖权限渲染,以这个返回值或前端 ready 事件为准。

其它语言

curl
curl -X POST https://inner.jitword.com/api/v1/embed/tickets \
  -H 'Content-Type: application/json' \
  -H "X-Embed-Client-Secret: $JITWORD_CLIENT_SECRET" \
  -d '{
    "tenantKey": "your-tenant",
    "providerKey": "your-app",
    "externalSubject": "user-123",
    "user": { "displayName": "张三", "email": "zhangsan@example.com" },
    "document": { "docId": "demo-001" },
    "permission": { "role": "editor",
                    "scopes": ["document:read","document:edit","comment:write"] },
    "origin": "https://your-portal.example.com",
    "expiresIn": 300
  }'
Java 11+ — java.net.http
var payload = """
  {
    "tenantKey": "your-tenant",
    "providerKey": "your-app",
    "externalSubject": "%s",
    "user": { "displayName": "%s" },
    "document": { "docId": "%s" },
    "permission": { "scopes": ["document:read","document:edit"] },
    "origin": "https://your-portal.example.com",
    "expiresIn": 300
  }""".formatted(userId, displayName, docId);

var request = HttpRequest.newBuilder()
    .uri(URI.create("https://inner.jitword.com/api/v1/embed/tickets"))
    .header("Content-Type", "application/json")
    .header("X-Embed-Client-Secret", System.getenv("JITWORD_CLIENT_SECRET"))
    .POST(HttpRequest.BodyPublishers.ofString(payload))
    .build();

var response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
Python — requests
import os, requests

resp = requests.post(
    "https://inner.jitword.com/api/v1/embed/tickets",
    headers={"X-Embed-Client-Secret": os.environ["JITWORD_CLIENT_SECRET"]},
    json={
        "tenantKey": "your-tenant",
        "providerKey": "your-app",
        "externalSubject": str(user.id),
        "user": {"displayName": user.name, "email": user.email},
        "document": {"docId": doc_id},
        "permission": {"scopes": ["document:read", "document:edit"]},
        "origin": "https://your-portal.example.com",
        "expiresIn": 300,
    },
    timeout=10,
)
resp.raise_for_status()
ticket = resp.json()["data"]["ticket"]

其它服务端接口

都用同一个 X-Embed-Client-Secret 鉴权。

接口用途
POST /embed/documents创建文档。传 type:"document"、name、externalSubject,返回新的 docId。联调时自助建测试文档用。
POST /embed/tickets/:jti/revoke吊销尚未使用的票据。
GET /embed/audit拉取本租户的脱敏审计流。tenantKey 与 providerKey 必填,limit 钳制在 1–100(默认 50)。用于合规对账,不建议高频轮询。
GET /embed/diagnostics返回能力清单、命中的策略摘要、存储健康与审计尾部。排障时贴给我们最有用。
GET /embed/profile/:tenantKey/:providerKey查看当前生效的策略配置。
POST /embed/webhooks订阅出站事件推送,详见第 7 节。
GET /embed/manifest能力清单。无需鉴权,可用作健康探针。

07权限与治理

一条贯穿始终的规则:所有策略求交,只能收紧,不能放大。

每次换票,服务端会把四层权限求交:文档本身的权限、你请求的 scopes、策略配置里该角色允许的 scopes、以及 readonly 标记。交集就是最终权限。这意味着你在请求里写 document:edit,如果策略配置没给这个角色开编辑权,最终仍然是只读。

策略配置(Config Profile)

按 tenantKey / providerKey / environment 三元组管理,由我们代为配置。它决定了你这个应用的能力上限:

Config Profile 结构
{
  "allowedOrigins": ["https://your-portal.example.com", "https://*.example.com"],
  "defaultUi": { "theme": "auto", "toolbar": "compact", "readonly": false },
  "roleScopesMap": {
    "viewer": ["document:read"],
    "editor": ["document:read", "document:edit", "comment:write"]
  },
  "features": { "document.exportDocx": true, "document.setMetadata": false },
  "behavior": { "copy": true, "print": false, "export": true },
  "loaderChannels": { "default": "v1", "allowed": ["v1", "latest"] },
  "watermarkDefault": "示例公司 · 内部文档",
  "watermarkLock": true,
  "exportReview": { "enabled": false },
  "diagnostics": { "enabled": true, "level": "info" }
}

行为管控

换票时传 behavior,控制复制、打印、导出。缺省全部允许,只有显式传 false 才生效。三者的可靠性不同,接入前必须知道差别:

开关拦在哪里可靠性
export服务端。所有导出接口对该会话返回 403 并记审计。无法绕过。合规场景请以这一项为准。
copy前端拦截 copy / cut 事件。挡不住开发者工具、截屏、浏览器扩展。
print前端注入 @media print 隐藏样式。同上。
anonymousView视图层脱敏:隐藏协作者头像与在线面板,模糊评论和修订的作者名。语义与前三者相反——缺省 false,你或策略任一方要求即生效,且策略强制时你无法关闭。

copy 和 print 是用户体验层面的约束,不是安全边界。真正需要防外泄时,请叠加水印、关闭导出并依赖审计留痕,不要把合规承诺建立在这两个开关上。

水印

换票时传 watermark: { text, color, opacity }。text 必填且不超过 100 字符,opacity 取 0–1 小数。不传则落回策略里的 watermarkDefault。策略开了 watermarkLock 时,你的关闭请求会被忽略,编辑器里的水印开关也会被锁定。

水印是会话级视图策略,不写进文档内容,也不参与协同同步。运行时可以调 editor.setWatermark({ text }) 改,传 null 请求移除。

导出前置审查

对导出有逐次审批要求时,可以在策略里配 exportReview。每次导出裁决前,服务端会同步回调你的审批端点:

你需要实现的审批端点
// 收到的请求体(不含正文)
{
  "eventType": "exportReviewRequest",
  "tenantKey": "your-tenant",  "providerKey": "your-app",
  "externalSubject": "user-123", "internalUserId": "u_9f2c...",
  "docId": "demo-001", "format": "docx", "jti": "jti_..."
}

// 请求头带 HMAC-SHA256 签名,密钥就是你的 clientSecret,请务必验签
// X-Webhook-Signature: t=<timestamp>,v1=<hex>

// 你必须返回 200 且 decision 显式为 approve,否则一律拒绝
{ "decision": "approve" }
{ "decision": "deny", "reason": "密级文档不允许外发" }

超时、网络错误、非 2xx、返回格式不对——全部按拒绝处理。超时时长默认 5 秒(钳制 1–30 秒),超时行为可配 onTimeout: "allow" 改为放行。

Webhook 事件推送

订阅后由服务端主动推送,统一 HMAC-SHA256 签名,超时 5 秒,失败退避重试 3 次(1s / 5s / 30s),重试耗尽写入死信表可手动重放。

事件触发时机
contentChanged协同内容持久化成功。修订的接受/拒绝也会触发。
saveStateChanged保存状态变更。
auditEvent任一审计条目写入成功。只带事件类型与结果,不透传原始明细。
commentCreated评论创建。不含评论正文。
commentResolved评论被解决或重开。

注意兼容口径:events 为空的存量订阅只匹配 contentChanged 和 saveStateChanged,新增事件必须显式列进 events 才会推送。

数据驻留

本产品为私有化部署形态。编辑器、协同服务、文档存储、审计与 webhook 数据全部驻留在部署方自有基础设施内,不存在向第三方公有服务外传文档内容、用户数据或遥测的链路。Webhook 的目标地址由你自行配置并签名验证,属你方自主的数据流转。这一节可以直接作为合规评审的依据。

08安全边界

四条规则,接入时逐条对照。

一、clientSecret 只能在服务端

不要放进前端代码、不要放进构建产物、不要通过接口下发给浏览器。任何“前端直接调换票接口”的写法都是错的。需要轮换时联系我们,我们会给一个过渡窗口让新旧密钥并存。

二、票据一次性且短寿命

寿命 60–300 秒,兑换一次后立即作废。同一张票第二次兑换返回 409 embed ticket has already been used or revoked。

这意味着 getEmbedTicket 不能缓存返回值。SDK 在握手和续期时都会调它,每次都必须给一张新票。把票据存进变量复用,是接入阶段最常见的错误之一。

三、来源双重校验

换票时校验你声明的 origin 是否在白名单内;iframe 兑换会话时再校验实际来源是否与票据里的 origin 一致。两道都过才放行。所以本地联调的地址也要提前报给我们加进白名单。

四、JitWord 自有入口全部关闭

这个站点上,以下路径由服务端中间件拦截,一律返回 404:

被拦截的路径原因
/api/v1/user/register、/api/v1/user/login放开等于允许任何人在这里开户
/api/v1/admin/*、/admin管理后台
/api/v1/users/list会暴露全量用户
/api/v1/documents(不带 ID)会暴露全量文档
/api/v1/templates 的写操作模板管理
/entry、/login、/docs/原生页面与接口文档页
/api/v1/parse/*文档解析接口匿名可达且接受大文件上传,嵌入链路用不到

返回 404 而不是 403 是刻意的:403 等于向探测者确认“这个端点存在,只是你没权限”。

怎么判断拦截真的生效、而不是路径碰巧不存在?被拦截的返回 {"code":404,"message":"Not Found"},真正不存在的路径返回 Authentication Error。两者不同,才说明中间件在工作。

iframe 沙箱与 CSP

编辑器 iframe 默认带 sandbox="allow-scripts allow-same-origin allow-forms allow-downloads allow-popups",分别用于脚本运行、同源资源、表单、DOCX 下载和必要弹窗。你的页面如果配了 CSP,需要放行:

CSP 最小放行
Content-Security-Policy: frame-src https://inner.jitword.com; script-src 'self' https://inner.jitword.com;

只需要这两条。SDK 与协同服务之间的请求全部发生在 iframe 内部,属于另一个来源,不受你页面 CSP 的管辖;宿主页面唯一发起的网络请求是调用你自己的换票接口,走 'self' 即可。

09自检清单

七条命令,从静态资源一路验到换票。按顺序跑,第一条失败的就是问题所在——不用再往下查。

1编辑器静态站可达

最基础的一条。这条不过,说明站点或网络有问题,后面都不用看。

curl
curl -s -o /dev/null -w "%{http_code} %{content_type}\n" \
  https://inner.jitword.com/px-editor/
200 text/html

2loader.js 返回的是 JavaScript

这条最容易悄悄出错,而且错了以后前端报的是莫名其妙的语法错误。

curl
curl -s -o /dev/null -w "%{http_code} %{content_type}\n" \
  https://inner.jitword.com/px-editor/iframe-sdk/v1/loader.js
200 application/javascript
返回 text/html 说明 nginx 的 try_files 把 JS 也回落到 index.html 了。浏览器会把一整页 HTML 当脚本执行,控制台报 Unexpected token '<'。这是服务端配置问题,请告诉我们。

3SPA 路由回落正常

iframe 打开的 /px-editor/embed/document/<docId> 是前端路由,磁盘上没有对应文件,必须能回落到 index.html。

curl
curl -s -o /dev/null -w "%{http_code} %{content_type}\n" \
  https://inner.jitword.com/px-editor/embed/document/probe
200 text/html
返回 404 就是 iframe 白屏的直接原因。

4嵌入服务在线

这个接口不需要鉴权,适合做你监控系统里的健康探针。

curl
curl -s https://inner.jitword.com/api/v1/embed/manifest
{"code":200,"data":{"protocolVersion":"1.0","loaderVersion":"1.1.0",...}}

5隔离边界生效

确认 JitWord 自有入口确实关着,你的用户体系不会被绕过。

curl
for p in /api/v1/user/login /api/v1/admin/users /api/v1/documents; do
  curl -s https://inner.jitword.com$p; echo
done
{"code":404,"message":"Not Found"} {"code":404,"message":"Not Found"} {"code":404,"message":"Not Found"}
如果返回的是 Authentication Error,说明这些路径只是碰巧不存在,拦截中间件并没有在工作。请告诉我们。

6你的密钥可用

换票成功,说明 tenantKey、providerKey、clientSecret、origin 白名单、docId 这五样全对了。

curl 把占位符换成你自己的值
curl -s -X POST https://inner.jitword.com/api/v1/embed/tickets \
  -H 'Content-Type: application/json' \
  -H "X-Embed-Client-Secret: $JITWORD_CLIENT_SECRET" \
  -d '{
    "tenantKey": "your-tenant",
    "providerKey": "your-app",
    "externalSubject": "selfcheck-user",
    "document": { "docId": "your-demo-doc-id" },
    "permission": { "scopes": ["document:read","document:edit"] },
    "origin": "https://your-portal.example.com",
    "expiresIn": 60
  }'
{"code":200,"message":"ok","data":{"ticket":"eyJhbGci...","expiresIn":60,...}}
拿到 401 / 403 / 404?对照第 10 节的错误表,每种情况对应的原因是明确的。

7没有密钥就换不到票

反向验证。这条必须失败——如果它成功了,说明密钥校验没生效,请立刻告诉我们。

curl
curl -s -X POST https://inner.jitword.com/api/v1/embed/tickets \
  -H 'Content-Type: application/json' -d '{"tenantKey":"x"}'
{"code":401,"message":"embed client authentication failed"}

七条全过,剩下的问题就只可能在你自己的页面里了:容器高度、CSP、或者 getEmbedTicket 返回的不是票据字符串。

10错误码与排障

换票接口

状态消息原因
401embed client authentication failedX-Embed-Client-Secret 缺失或不匹配。注意首尾空格和换行。
400externalSubject, docId and origin are required三个必填字段缺了至少一个。docId 支持 document.docId 或顶层 docId 两种写法。
403embed origin is not allowed by profileorigin 不在白名单。检查协议、端口、有无尾斜杠。
403embed scopes are not allowed by profile求交后的权限不含 document:read。检查 role 在策略里映射到了什么。
403embed loader channel is not allowed by profile请求的 loaderChannel 不在允许列表内。
404document not founddocId 不存在。用 POST /embed/documents 建一个。
403external user is disabled该 externalSubject 已被停用。
403document permission denied映射出的内部用户对这份文档没有访问权。
400sessionExpiresAt must be a valid future timestamp…不是未来时刻,或超过最大会话时长(默认 24 小时)。
400watermark.text is required / must be within 100 characters启用水印时 text 必填且不超过 100 字符。

会话兑换(SDK 内部,你会在控制台看到)

状态消息原因
409embed ticket has already been used or revoked最常见。票据被复用了——检查 getEmbedTicket 是不是缓存了返回值。
401embed ticket is invalid or expired票据过期(默认 300 秒)或被篡改。
403embed ticket origin mismatch换票时声明的 origin 与页面实际来源不一致。
401embed session deadline exceededsessionExpiresAt 已过,会话被强制收回。

SDK 错误码

code含义与处理
INVALID_CONFIGcontainer、editorUrl、docId 缺了必填项,或 documentType / mode 取值非法。
HANDSHAKE_TIMEOUTiframe 没在超时内完成握手。多半是 iframe 自身加载失败——先跑自检第 3 条。
REQUEST_TIMEOUT单次调用超时。默认 10 秒,大文档的导出操作可以单独传更长的 timeout。
ACTION_UNSUPPORTED调了不支持的能力。可能是 preview 模式下调了写方法,或该能力被策略禁用。
PERMISSION_DENIED权限不足。以 ready 事件里的实际能力清单渲染 UI,可以避免大部分这类错误。
SDK_NOT_READY在 ready 之前就调了方法。
INTERNAL_ERROR服务端异常。带上 traceId 找我们。

上表是接入阶段最常见的几个;完整错误码还包括 ORIGIN_DENIED、INVALID_MESSAGE、TICKET_EXPIRED、USER_DISABLED、PERMISSION_STALE、EDITOR_BUSY、EXTERNAL_PERMISSION_UNAVAILABLE,以运行时协议为准。判断分支请用 code,不要匹配 message 文本。

iframe 白屏,按这个顺序查

  1. 容器有高度吗。iframe 默认 height:100%,父容器高度为 0 时表现就是一片空白。这是白屏最常见的原因,而且和后端完全无关。
  2. 自检第 2、3 条过了吗。不过就是服务端配置问题,改代码没用。
  3. 控制台有 CSP 报错吗。缺 frame-src 会被浏览器直接拦掉。
  4. 换票接口返回什么。Network 面板看你自己那个 /api/jitword-ticket:401 说明密钥不对,403 看具体消息对照上面的表。
  5. 只有画布、没有任何工具条/顶栏。这是 ui.chrome 默认值 'host' 的正常表现(编辑器外壳默认不渲染),不是权限或票据问题。改成 chrome:'toolbar'(只保留编辑工具条)或 'full'(完整外壳)刷新即可,细节见第 5 节。
  6. 还是没头绪,打开 debug: { enabled: true },把 diagnostic 事件的输出和 traceId 一起发给我们。

11环境限制

这些是这个演示环境当前的真实状态,不是产品能力上限。写在这里是为了让你不在这上面浪费时间。

限制影响说明
服务端 PDF 导出未部署 导出 PDF 不可用 该功能依赖独立的无头浏览器服务,本演示环境未启用。DOCX 导出正常。私有化部署时可按需开启。
仅支持 document sheet 与 mindmap 不可用 能力清单里会明确返回不支持,不是静默失败。
跨域策略较宽松 接口层 CORS 目前按请求来源反射 真正的来源约束在换票与会话兑换两道校验上,安全性不依赖 CORS。私有化部署可收紧为白名单。
无可用性承诺 可能随时重启或清理数据 演示与联调用途。正式业务请走私有化部署。
审计接口有频率约束 limit 最大 100 用于对账,不适合当作实时事件流。需要实时推送请用 webhook。

12申请接入

加微信说明来意,我们确认后下发密钥和演示文档。

负责人微信二维码

徐先森

JitWord 集成对接负责人

扫码加微信,请在验证信息里写清楚:

  1. 公司名称与联系人
  2. 要嵌入的系统是什么、大概什么场景
  3. 你的页面来源,含生产地址和本地联调地址
  4. 预计的用户规模

确认后你会收到 tenantKey、providerKey、clientSecret;演示文档可以找我们要一个,也可以直接用示例包(首次启动自动创建一份本租户专属文档)。密钥请通过安全渠道接收并存进环境变量,不要贴进聊天记录或代码仓库。

联调过程中遇到问题,先跑一遍自检清单。把失败那一条的完整输出发给我们,比描述现象快得多。