把 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 只出现在这里,绝不能下发到浏览器。
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 自己处理。
<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集成链路
一共三跳。理解这张图,后面所有的权限和报错都好解释。
为什么要绕这一圈
直觉上更省事的做法是前端直接调 /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 个事件
内容读写
getContentsetContentinsertContentclearContentdeleteContentundoredosavegetSelectionsetSelectionscrollTo
文档信息
getInfogetPermissiongetStatusgetOnlineUsersgetMetadatasetMetadata
版本与模板
listVersionsrestoreVersiondiffVersionslistTemplatesinstantiateTemplate
评论
listCommentscreateCommentupdateCommentdeleteComment
修订(track changes)
listRevisionssetRevisionModeacceptRevisionrejectRevisionsetRevisionPreview
文档导入
importDocx
导出与界面
exportDocxsetThemesetReadonlysetWatermarksetToolbarfocusdestroy
事件
readypermissionChangedonlineUsersChangedcontentChangedconnectionChangedsaveStateChangeddiagnosticdestroyedauth.refreshRequiredauth.userDisabledauth.sessionExpirederrorlifecycle.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 里的能力清单判断。
// 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。用于区分同一应用的不同环境策略。 |
响应
{
"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 -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
}'
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());
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 三元组管理,由我们代为配置。它决定了你这个应用的能力上限:
{
"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,需要放行:
Content-Security-Policy: frame-src https://inner.jitword.com; script-src 'self' https://inner.jitword.com;
只需要这两条。SDK 与协同服务之间的请求全部发生在 iframe 内部,属于另一个来源,不受你页面 CSP 的管辖;宿主页面唯一发起的网络请求是调用你自己的换票接口,走 'self' 即可。
09自检清单
七条命令,从静态资源一路验到换票。按顺序跑,第一条失败的就是问题所在——不用再往下查。
1编辑器静态站可达
最基础的一条。这条不过,说明站点或网络有问题,后面都不用看。
curl -s -o /dev/null -w "%{http_code} %{content_type}\n" \
https://inner.jitword.com/px-editor/
2loader.js 返回的是 JavaScript
这条最容易悄悄出错,而且错了以后前端报的是莫名其妙的语法错误。
curl -s -o /dev/null -w "%{http_code} %{content_type}\n" \
https://inner.jitword.com/px-editor/iframe-sdk/v1/loader.js
text/html 说明 nginx 的 try_files 把 JS 也回落到 index.html 了。浏览器会把一整页 HTML 当脚本执行,控制台报 Unexpected token '<'。这是服务端配置问题,请告诉我们。3SPA 路由回落正常
iframe 打开的 /px-editor/embed/document/<docId> 是前端路由,磁盘上没有对应文件,必须能回落到 index.html。
curl -s -o /dev/null -w "%{http_code} %{content_type}\n" \
https://inner.jitword.com/px-editor/embed/document/probe
4嵌入服务在线
这个接口不需要鉴权,适合做你监控系统里的健康探针。
curl -s https://inner.jitword.com/api/v1/embed/manifest
5隔离边界生效
确认 JitWord 自有入口确实关着,你的用户体系不会被绕过。
for p in /api/v1/user/login /api/v1/admin/users /api/v1/documents; do curl -s https://inner.jitword.com$p; echo done
Authentication Error,说明这些路径只是碰巧不存在,拦截中间件并没有在工作。请告诉我们。6你的密钥可用
换票成功,说明 tenantKey、providerKey、clientSecret、origin 白名单、docId 这五样全对了。
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
}'
7没有密钥就换不到票
反向验证。这条必须失败——如果它成功了,说明密钥校验没生效,请立刻告诉我们。
curl -s -X POST https://inner.jitword.com/api/v1/embed/tickets \
-H 'Content-Type: application/json' -d '{"tenantKey":"x"}'
七条全过,剩下的问题就只可能在你自己的页面里了:容器高度、CSP、或者 getEmbedTicket 返回的不是票据字符串。
10错误码与排障
换票接口
| 状态 | 消息 | 原因 |
|---|---|---|
401 | embed client authentication failed | X-Embed-Client-Secret 缺失或不匹配。注意首尾空格和换行。 |
400 | externalSubject, docId and origin are required | 三个必填字段缺了至少一个。docId 支持 document.docId 或顶层 docId 两种写法。 |
403 | embed origin is not allowed by profile | origin 不在白名单。检查协议、端口、有无尾斜杠。 |
403 | embed scopes are not allowed by profile | 求交后的权限不含 document:read。检查 role 在策略里映射到了什么。 |
403 | embed loader channel is not allowed by profile | 请求的 loaderChannel 不在允许列表内。 |
404 | document not found | docId 不存在。用 POST /embed/documents 建一个。 |
403 | external user is disabled | 该 externalSubject 已被停用。 |
403 | document permission denied | 映射出的内部用户对这份文档没有访问权。 |
400 | sessionExpiresAt must be a valid future timestamp… | 不是未来时刻,或超过最大会话时长(默认 24 小时)。 |
400 | watermark.text is required / must be within 100 characters | 启用水印时 text 必填且不超过 100 字符。 |
会话兑换(SDK 内部,你会在控制台看到)
| 状态 | 消息 | 原因 |
|---|---|---|
409 | embed ticket has already been used or revoked | 最常见。票据被复用了——检查 getEmbedTicket 是不是缓存了返回值。 |
401 | embed ticket is invalid or expired | 票据过期(默认 300 秒)或被篡改。 |
403 | embed ticket origin mismatch | 换票时声明的 origin 与页面实际来源不一致。 |
401 | embed session deadline exceeded | sessionExpiresAt 已过,会话被强制收回。 |
SDK 错误码
| code | 含义与处理 |
|---|---|
INVALID_CONFIG | container、editorUrl、docId 缺了必填项,或 documentType / mode 取值非法。 |
HANDSHAKE_TIMEOUT | iframe 没在超时内完成握手。多半是 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 白屏,按这个顺序查
- 容器有高度吗。iframe 默认
height:100%,父容器高度为 0 时表现就是一片空白。这是白屏最常见的原因,而且和后端完全无关。 - 自检第 2、3 条过了吗。不过就是服务端配置问题,改代码没用。
- 控制台有 CSP 报错吗。缺
frame-src会被浏览器直接拦掉。 - 换票接口返回什么。Network 面板看你自己那个
/api/jitword-ticket:401 说明密钥不对,403 看具体消息对照上面的表。 - 只有画布、没有任何工具条/顶栏。这是
ui.chrome默认值'host'的正常表现(编辑器外壳默认不渲染),不是权限或票据问题。改成chrome:'toolbar'(只保留编辑工具条)或'full'(完整外壳)刷新即可,细节见第 5 节。 - 还是没头绪,打开
debug: { enabled: true },把diagnostic事件的输出和traceId一起发给我们。
11环境限制
这些是这个演示环境当前的真实状态,不是产品能力上限。写在这里是为了让你不在这上面浪费时间。
| 限制 | 影响 | 说明 |
|---|---|---|
| 服务端 PDF 导出未部署 | 导出 PDF 不可用 | 该功能依赖独立的无头浏览器服务,本演示环境未启用。DOCX 导出正常。私有化部署时可按需开启。 |
仅支持 document |
sheet 与 mindmap 不可用 |
能力清单里会明确返回不支持,不是静默失败。 |
| 跨域策略较宽松 | 接口层 CORS 目前按请求来源反射 | 真正的来源约束在换票与会话兑换两道校验上,安全性不依赖 CORS。私有化部署可收紧为白名单。 |
| 无可用性承诺 | 可能随时重启或清理数据 | 演示与联调用途。正式业务请走私有化部署。 |
| 审计接口有频率约束 | limit 最大 100 |
用于对账,不适合当作实时事件流。需要实时推送请用 webhook。 |
12申请接入
加微信说明来意,我们确认后下发密钥和演示文档。
徐先森
JitWord 集成对接负责人
扫码加微信,请在验证信息里写清楚:
- 公司名称与联系人
- 要嵌入的系统是什么、大概什么场景
- 你的页面来源,含生产地址和本地联调地址
- 预计的用户规模
确认后你会收到 tenantKey、providerKey、clientSecret;演示文档可以找我们要一个,也可以直接用示例包(首次启动自动创建一份本租户专属文档)。密钥请通过安全渠道接收并存进环境变量,不要贴进聊天记录或代码仓库。
联调过程中遇到问题,先跑一遍自检清单。把失败那一条的完整输出发给我们,比描述现象快得多。