跳至正文
Frontend — 现代编辑器与协作技术栈:从一次输入到可靠保存

现代编辑器与协作技术栈:从一次输入到可靠保存

AI 参与说明(Agent:Devin / Fusion):本文依据公开文档与源码核对能力;架构示意是综合解释,不构成某个组合的运行或性能认证。资料核查日为 2026-10-28,执行入口为 Linear。本轮 Context7 请求因配额耗尽未取得结果,改查文末所列官方来源。Fusion 为会话模式,具体模型标识与 reasoning effort 未取得任务级运行记录。

两个人同时改一段文字,互相看见光标,刷新后内容还在。这看起来是一个“编辑器功能”,实际上包含三个不同的承诺:输入能正确进入文档,并发修改能按规则合并,已经确认保存的内容能在故障后恢复。

把 Tiptap、Yjs 和一个 WebSocket 服务装在一起,还不能兑现这些承诺。把“对端收到了”当作“数据库存好了”,或把一份 JSON 当作完整的协作历史,都可能在断线、重启时丢失修改。我们从一次编辑开始看。

先认识几个贯穿全文的名称

英文术语中文名称简要解释
Schema模式约束定义哪些节点、属性和嵌套结构合法
Transaction事务一次编辑状态变更,可能包含多个文档修改及选区变化
CRDT无冲突复制数据类型按约定规则合并并发修改,使收到相同必要信息的副本收敛
Binding绑定层把编辑器文档与协作数据模型中的修改相互映射
Provider同步提供器交换同步消息、维护连接与重连的接线模块
Awareness感知状态在线用户、光标等短暂状态,不是持久正文
State Vector状态向量描述 Yjs 副本已知的客户端逻辑时钟前缀
Snapshot快照引擎定义的状态或恢复表示,不天然等于产品版本
Projection派生投影从权威数据生成的 HTML、搜索索引等表示

一次输入不是一条 WebSocket 消息

假设两人打开同一份会议记录。你在段落中输入“周五”,另一人把这个段落改为列表。浏览器先处理输入法组合输入(IME composition)、选区与 DOM;编辑器再把这些变化解释成自身模型中的修改。工具栏和快捷键也是修改入口,并不比键盘更接近数据库。

contentEditable 提供可编辑区域,却不知道产品中的“任务项”“提及”或“评论锚点”是什么。现代编辑器因此通常维护独立模型,而不是直接拿 DOM 当正文。DOM 还夹着展示样式、浏览器修复和临时交互元素;模型中的位置,也不能直接用 DOM 节点下标代替。

flowchart TB
  B["浏览器输入 / IME / Selection"] --> E["编辑器内核:文档与修改"]
  F["框架 / UI:命令、工具栏、块菜单"] --> E
  E --> V["渲染视图"]
  E <--> A["Binding:模型与位置映射"]
  A <--> C["CRDT 文档"]
  C <--> P["Provider:同步与连接"]
  P <--> R["房间服务:授权与生命周期"]
  C <--> L["本地持久化"]
  R <--> D["服务端持久化"]
  D --> Q["Projection:搜索 / HTML"]

图中是职责关系,不是必须依次经过的函数调用。Binding 位于编辑器与 CRDT 之间;远端修改沿反方向进入编辑器。Provider 不应把远端应用产生的事件再次当成新的本地编辑无限回传。实际库可能跨越几层,但每个边界仍要有人负责。

三种内核,三种修改语言

编辑器内核决定“正文是什么”和“怎样修改它”。框架可以包装这些规则,却不能让它们消失。

内核文档与状态修改机制扩展时绕不开的约束
ProseMirrorSchema 约束的节点树;EditorState 包含正文、选区及插件状态Transaction 可包含多个 Step,并携带映射与元数据节点结构、位置映射、插件状态和命令
Slate由 Element、Text 等组成的树;Editor 管理选区和操作高层 Transforms 产生底层 Operations;normalization 修复结构路径、操作语义、内建及自定义 normalization
LexicalEditorState 包含节点树和选区;提交后的状态不可变在 editor.update() 上下文中修改待提交状态,再协调渲染节点注册、读写上下文、命令与节点转换

ProseMirror:不要把 Transaction 等同于正文差异

ProseMirror applies transactions to produce a new editor state. 按 ProseMirror Guide 的定义,Transaction 可以插入文本、设置选区、携带插件元数据,也可能根本不改正文。Step 是文档变换单元,不是整个编辑器状态。

例如在段落前插入标题后,旧位置需要经映射才能继续使用。评论若只记住“第 18 个位置”,后续插入就可能把它指向别处。协作 Binding 还要处理不同副本暂时看到不同文档时的位置关系。

Schema 不是 TypeScript 类型检查的另一种写法。它描述运行时文档允许哪些节点、属性及子节点组合。双方安装不同节点扩展,即使更新字节成功送达,也可能对同一结构有不同解释。

Slate:灵活不等于没有约束

Slate 的 Operations 表达插入文本、移除节点、移动节点等基本修改;常用的 Transforms 是生成这些操作的高层接口。Normalization 则把内容修复到合法结构。

这让产品能定义自己的文档形态,但也带来协作要求:修复规则本身可能产生操作。不同客户端若使用互相矛盾的规则,一个拆节点、另一个又合并,CRDT 不会替应用决定哪套 Schema 才是正确的。复合变换还要考虑中间态是否需要暂缓 normalization,不能只看最终 JSON 漂不漂亮。

Lexical:EditorState 也不是 DOM 截图

Lexical 的 EditorState 是独立于 DOM 的状态模型。更新期间可以修改待提交状态,提交后作为不可变状态读取。$getRoot() 等 API 依赖当前读写上下文,并不是任何异步回调中都能调用的普通工具函数。

还要区分内存对象和序列化结果:当前文档说明,toJSON() 序列化文档树,不包含选区和运行时节点 key。因此“保存了 EditorState JSON”不代表恢复了用户全部会话状态,更不代表保存了协作协议的因果信息。

框架替你组织编辑行为,不替你完成产品

上层项目建立在哪些基础上主要减轻的工作宿主仍要负责
TiptapProseMirrorExtension、节点与标记、命令、框架接入产品 UI、权限、资源、版本与保存语义
PlateSlate插件组织、常见编辑能力与配套 UI文档规则、业务状态、协作服务与数据生命周期
BlockNoteTiptap / ProseMirror 等基础能力面向块的模型、插块和拖块等编辑体验自定义块语义、权限、持久化、导入导出边界

Tiptap 不是独立于 ProseMirror 的另一套底层内核;Plate 也不是与 Slate 无关的 UI 包。Tiptap Introduction、Plate 文档 和 BlockNote 内部节点说明 能帮助定位这些关系。

BlockNote 尤其容易让人混淆三种表示:面向应用的块 JSON、内部 ProseMirror 文档、协作使用的 Yjs 数据。它们描述有关联的内容,但结构和用途不同,不能直接互相当作恢复文件。BlockNote Document Structure 适合理解应用层看到的块;内部表示则要结合实现阅读。

选型时,与其问“谁支持表格”,不如追问一个具体动作:把带评论的表格行从一个块移动到另一个块,选区、撤销、远端光标、导出和旧客户端会怎样?成熟框架能减少接线工作,但这个产品行为仍需宿主定义并验收。

序列化:四种“保存”保存的不是同一个东西

表示适合什么通常不能单独保留什么
编辑器 JSON在匹配的 Schema 下重新编辑、服务端处理结构完整协作身份、因果历史、临时选区
HTML展示、跨系统交换与部分剪贴板场景所有自定义节点语义、稳定 ID、协作历史
Markdown文本发布、版本管理、受限语义交换任意块属性、复杂表格、评论及完整富文本语义
CRDT update / 引擎恢复编码同步副本、恢复协作状态产品 ACL、附件本体、业务审计和发布版本的全部规则

“都能转成 JSON”不是格式兼容保证。导出器可能丢掉自定义块的属性;重新导入可见正文,也可能为内容生成新的协作身份。反过来,二进制 CRDT 数据也不能脱离正确的 Binding、Schema 和版本约定,自动变成可显示的文章。

一个实用划分是:选定权威恢复数据,再生成 HTML、Markdown 和搜索索引作为 Projection。若业务同时允许直接写正文 JSON 和 CRDT,就必须定义二者如何协调;不能让最后写入的那份整篇 JSON 悄悄覆盖协作内容。

并发:中心排序与 CRDT 不是“有无服务器”之争

仍看会议记录的例子。两人从同一个版本出发,各自插入文字。若双方只发送“把第 10 个位置替换成某字符串”,收包时的第 10 个位置可能早已改变。

ProseMirror 内置的 collab 模型 采用中心权威确定 Step 顺序。客户端保留尚未确认的本地修改,接收权威顺序中的修改后,对自己的未确认修改做 rebase。这里的重点是共同顺序与位置映射,不是简单按服务器时间覆盖全文。OT 类方案也围绕并发操作变换展开,但不能把所有中心同步协议都当作同一套 OT 实现。

CRDT 为内容或操作建立稳定身份及合并规则,使副本能在满足协议前提、最终收到必要信息后收敛。它可以通过中心服务器转发、持久化和授权,并不要求浏览器直接 P2P 连接。反过来,WebRTC 只是传输方式,不会自动解决并发语义。

收敛还不等于符合业务意图。两人同时把一个标题改成两个不同标题,合并结果即使在所有副本上一致,也未必是任何一人想要的最终文案。产品仍可能需要冲突提示、审阅或人工选择。

Yjs:update 解决交换,Binding 解决语义

Yjs 的 Y.Doc 可以包含 Y.Text、Y.Map、Y.Array、XML 类型等共享数据。选哪个类型,取决于编辑器 Binding 和应用模型,不是所有编辑器都监听一个名叫 content 的字段。

update 与 State Vector

Yjs document updates are commutative, associative and idempotent. Document Updates 用这些性质说明:同一批必要更新可以乱序、重复应用,最终仍能收敛;这不允许永久丢弃更新。

State Vector 记录各客户端已知逻辑时钟前缀,对端据此计算差量。它不是在线人数、业务版本号、正文哈希或数据库收据。尤其不能只因为两个 State Vector 相同就宣布“正文一致且已保存”:删除信息还要通过更新传播,落盘确认又是另一层协议。

可以用一个不涉及网络的思想实验理解它:两个副本先共享 AB,断开后分别在中间插入 X 与 Y;随后交换各自缺失的 update,再重复投递其中一份。目标是两边内容一致,且重复投递不多插一个字符。并发字符的先后由 CRDT 规则决定,测试不能擅自断言一定是 AXYB。这个实验只说明合并性质,不证明 Provider、权限或保存链路已经可用。

三个模块,三种故障

模块负责的事情典型接线单独解决不了的问题
Binding模型变更、位置和协作编辑语义之间的映射y-prosemirror身份认证、服务端存储
Provider同步握手、传输、重连及可选 Awarenessy-websocket 等业务定义的持久化确认
持久化 adapter将更新或检查点写入介质并加载y-indexeddb、服务端数据库接线编辑器格式转换、业务 ACL

这里以已核对的 y-prosemirror v1.3.7 为具体例子:它把 Y.XmlFragment 映射到 ProseMirror state,并提供同步、光标和协作撤销插件。这是明确版本的说明,不把主分支未来的内部表示当作所有发行版的保证。更多 Binding 见 Yjs Editor Bindings。

将完整文档 JSON 反复放进 Y.Map 的某个键,并不会让这个 JSON 的每个内部字段自动变成独立共享类型。这样得到的往往是整值替换,而不是富文本 Binding 对字符、节点、选区和撤销的适配。两个用户改不同段落,也可能在同一个顶层值上竞争。

Awareness 和 UndoManager 分别守住什么边界

Awareness 传播短暂的在线状态和光标,不应作为正文存进持久 CRDT。连接消失后,presence 应随断开或超时失效。它携带的名字、头像或 user.id 是展示负载,不能充当服务端认证凭据。长期评论需要持久锚点、删除后的行为及迁移策略,不能拿临时光标或 DOM 下标凑用。

UndoManager 可以按共享类型范围、transaction origin 和捕获分组追踪修改。协作撤销需要撤掉所选范围内的本地操作,而不是用“昨天的整篇 JSON”覆盖现在的文档。origin 是本地接线中的来源标识,不是通过网络验证过的用户身份。

Tiptap Collaboration 因此要求避免默认历史机制与协作历史重复接管撤销。当前文档使用 StarterKit 的 undoRedo: false;旧版本配置名可能不同,接入时按实际依赖核对。协作撤销也不等于永久审计日志,页面关闭后要不要保留撤销历史,是另外的产品决定。

Loro:把已知历史和当前可见状态拆开

理解 Loro 最有用的入口不是“另一种 Yjs”,而是 OpLog 与 DocState 的分离:OpLog 记录操作与因果历史;DocState 是某个版本下物化出的可见内容。

同一个文档,可以知道新历史却显示旧状态

当文档 checkout 到旧版本,DocState 可以停留在旧状态,OpLog 仍保有后来收到的历史。因此 doc.version() 和 doc.oplogVersion() 不一定相同,对应的 frontiers() 与 oplogFrontiers() 也必须区分。默认的 detached 状态下不能随意编辑;不要把历史浏览直接等同于创建可写分支。

这也是同步代码不能只看屏幕上正文的原因:界面正在看旧版本,不代表副本没有收到新操作。中继若不需要显示正文,也有机会只处理 OpLog,避免不必要的状态物化;具体实现仍需按 API 与运行环境验证。

Version Vector 与 Frontiers

Version Vector 显式描述各 peer 的操作前缀;Frontiers 用因果图的边界操作集合表示一个版本,以及这些操作的因果祖先。这里的 peer 是副本身份,不是已认证的用户账户。

Frontiers 常比逐 peer 列举紧凑,但要理解它包含哪些历史,通常需要相应的因果信息。它不是“每个用户最后一次修改的时间戳”,也不保证只有一个元素。两个并发分支尚未被后续操作共同包含时,边界可以有多个操作。两种版本表示不是因为都叫“版本”就能互换参数。

Snapshot 的边界决定离线客户端能否回来

Export Mode 区分操作增量、完整 Snapshot 和 Shallow Snapshot。完整 Snapshot 保存已整合的历史与状态;Shallow Snapshot 截断保留起点之前的历史,同时保留后续恢复所需状态。减少存储体积的代价是改变了能够接纳的历史范围。

假设服务端清理历史时,有个笔记本仍离线在旧版本上修改。它带回的更新可能与 Shallow Snapshot 的起点并发,不能指望直接导入。设计清理策略时,必须一起设计旧副本重入:先保护未同步修改,再选择补历史、迁移、人工合并或重新建立副本,不能用“重置客户端”掩盖数据损失。

另一个更隐蔽的故障来自乱序:先收到依赖 A 的 B,A 尚未到达,B 进入 pending。当前官方 Persistence 明确说明,普通 Snapshot 不含这些等待因果前驱的导入操作。此时若保存 Snapshot 后就删除 B 的原始字节,服务重启会丢掉已经收到但未整合的 B。

因此,检查点应保留原始待整合字节,或采用所用版本支持的 pending-import 导出机制,并把状态与 pending 数据作为一致的一代持久化。本文不把当前文档中的新增 API 假定为所有历史版本可用;升级前应核对实际包与恢复测试。

有 loro-prosemirror,不代表可以只换一个包

loro-prosemirror 提供文档同步、协作撤销和光标相关接线,但不是 y-prosemirror 的二进制兼容实现。当前 README 优先介绍 EphemeralStore 光标方案,同时提到旧 Awareness / Cursor 路线;不宜跨版本复制初始化片段。

把 Yjs 改为 Loro,至少要同时核对 Binding 支持的节点、更新编码、Provider 协议、存储、presence、撤销和旧数据迁移。Tiptap 可以加载 ProseMirror 插件,并不表示所有依赖 Yjs 类型的扩展都能随之替换。导出可见正文再重建文档,也不等于保留了原协作身份与全部历史。

房间服务:PartyServer、y-partyserver 与 Hocuspocus

“一个文档一个房间”是常见组织方式。房间知道谁连接了哪个文档,负责加载、广播、断开及恢复,但房间本身不是 CRDT。Cloudflare Durable Object(下文简称 DO)提供执行、协调与存储能力,也不会自动把一份编辑器 JSON 变成协作文档。

项目所在职责层使用时要额外确认
PartyServer基于 DO 的连接、房间与生命周期文档授权、恢复和业务消息协议
y-partyserver在 PartyServer 上组织 Yjs server、Provider 与保存接线加载/保存钩子的实际语义、只读写入控制
HocuspocusYjs Provider / server,含认证和扩展接线存储实现、运行适配与确认时点

当前 Cloudflare PartyKit 仓库 以 PartyServer 为核心;y-partyserver README 展示 YServer 和加载、保存接口。旧 y-partykit 的配置属于另一代 API,不能因旧示例有 readOnly 就推断当前接线已经阻止所有写入。只读必须在真正接收文档更新的控制点验证。

Hocuspocus Overview 则把它定位为 Yjs 协作后端及 Provider;当前概览说明 v4 起有 Cloudflare Workers 支持。因此“只能跑 Node.js”不是跨版本成立的结论。具体 adapter、扩展和数据库驱动能否运行在目标环境中,仍要逐项核对,而不是由项目名称推断。

“已保存”应该是哪一级承诺

编辑器内容已经变化、IndexedDB 写入完成、WebSocket 已发送、服务端已接收、数据库已持久化、搜索索引已更新,这是六个不同事件。界面只画一个绿色勾号时,产品必须先选定它代表哪件事。

下面是一个应用自定义确认协议的示意,不是 Yjs 或 Loro 内置 API。假定服务器在持久存储提交成功后才确认对应批次,客户端只把已确认的本地修改范围标为保存;确认过程中继续输入的新内容仍是待保存。

sequenceDiagram
  participant E as 编辑器 / CRDT
  participant P as Provider
  participant R as 房间服务
  participant D as 持久存储
  E->>P: 本地 update 与待确认批次
  P->>R: 发送 update
  R->>R: 校验身份、文档权限与协议
  R->>D: 持久化可恢复的数据
  alt 提交成功
    D-->>R: 提交结果
    R-->>P: 应用层持久化确认
    P-->>E: 仅确认该批次,保留后续待保存项
  else 失败或确认丢失
    P->>R: 重连后补齐 / 重试
    Note over E,D: 保留未确认修改,恢复过程应支持重复投递
  end

这个设计有几个实际后果。首先,收到 update 的回调不能直接点亮“已保存”;异步保存可能稍后失败。其次,确认丢失时可以重复投递,但应用附带的副作用也要幂等,不能因 CRDT update 幂等,就重复创建附件记录或审计事件。最后,服务端恢复时必须能从持久数据重建已确认的修改,不能依赖某个房间恰好还没退出的内存。

检查点与产品版本分开管理

恢复检查点可以由完整状态加后续更新组成,也可以按引擎格式保存。生成新检查点后,应先保证它及所需 pending 数据已可靠提交,再裁剪被覆盖的日志,并确保切换期间的新增修改没有漏掉。

而用户点击“发布第 3 版”,通常还需要不可变版本 ID、Schema 版本、附件引用、作者、发布时间和保留策略。CRDT Snapshot 或一个版本边界,不会自动保存这些业务约束;只保存边界却删掉它依赖的历史,也可能无法重建。

“恢复到旧版”尤其需要明确语义:是把旧内容作为当前文档的一次新修改,还是创建独立文档?直接把旧文件覆盖进活跃房间,可能与在线用户的未确认修改冲突。产品版本、协作撤销、底层历史浏览,是三种不同能力。

上线前最容易遗漏的三类规则

身份与 ACL 不能藏在编辑器里

服务端应将已验证身份与文档、租户和角色绑定;房间名、Yjs clientID、Loro peerID、transaction origin、Awareness 展示字段都不能代替认证。读权限决定能否取得文档数据,写权限决定能否导入更新;禁用工具栏只是 UI 行为。

连接建立后权限还可能变化。需要定义撤权、token 过期、重连和离线修改回传时的处理方式,并限制消息大小、频率与资源消耗。CRDT 能合并数据,并不替服务器判断这个更新是否应该被接受。

空文档不等于新文档

两个客户端同时连接,都在加载完成前看见空内容,然后各自插入“会议模板”。CRDT 可能很正确地合并成两份模板。问题在初始化竞态,不在合并算法。

初始化应在具有唯一性保障的创建流程中完成,或由房间/存储的原子判定协调;客户端要区分“尚未加载”“真实为空”“已经初始化”。浏览器本地缓存、服务端历史和初始 JSON 同时存在时,也要确定谁是恢复依据。仅在 mount 时检查一次空树,不足以建立这个保证。

schemaVersion 和未知节点是升级协议

客户端 A 新增了一个“决策卡片”节点,旧客户端 B 只认识段落。如果 B 在导入时把未知节点删掉,再把修复结果同步回去,就可能造成持续的数据损坏。

schemaVersion 应参与文档元数据和接入检查。遇到未知节点,可以阻止写入并要求升级;也可以在确认能无损保留原始结构时用占位展示。降级成纯文本只有在语义损失被明确接受后才成立,不能悄悄做。迁移时还要覆盖 HTML / Markdown 导出、评论锚点、历史版本及旧离线客户端重入,不只是让新客户端成功打开一次。

性能与运行环境:不要把 Headless 理解成哪里都能跑

浏览器中的编辑视图仍要处理 DOM、Selection、布局与 IME。CRDT 编解码、搜索等工作有时适合放入 Web Worker,但把整个编辑器实例搬过去通常不成立:Worker 没有页面 DOM,消息传递还会增加复制和调度成本。

服务端也要区分“不显示 UI”和“不依赖 DOM”。某个转换函数能在 Node.js 中借助环境适配运行,不代表它在 workerd 中可直接使用。选择 DO 房间时,还要核对内存生命周期、WebSocket hibernation、恢复加载以及所用包的运行时依赖;不能把进程内对象当成持久数据库。

性能测试应按用户动作拆开:长文输入延迟、粘贴大段内容、远端更新应用、冷启动恢复、离线积压追赶、检查点体积与内存峰值。Tiptap Performance 也强调隔离编辑器与不必要的 React 重渲染。比较两个方案时,需要固定文档、历史规模、在线人数和网络条件;本文没有做这类跨库基准,不给性能排名。

用故障恢复检验选型,而不只看演示

选完内核与框架后,可以把下面这些场景变成集成测试。它们是验收建议,不是本文已经替某个生产组合跑过的结果。

场景需要检查的结果
两个副本离线修改后重连,更新乱序并重复投递最终正文收敛;重试不重复产生业务副作用
A 输入后 B 插入,再由 A 撤销撤销范围符合产品规则,不把 B 的内容整体覆盖
服务端确认前后分别崩溃并重启已确认修改可恢复;未确认修改可安全重试
两个客户端同时打开新文档模板只创建一次,不把未加载误判为空
旧客户端打开带新节点的文档按兼容策略拒绝写入或无损保留,不静默删除
历史裁剪后,长期离线副本携未同步修改回来明确识别边界,保护修改并走恢复/迁移流程
Loro 接收缺少前驱的更新后保存、重启pending 更新仍可在前驱到达后整合
在线连接被撤销写权限后续更新确实被服务端拒绝,而不只是 UI 变灰

正文模型决定可表达什么,Binding 决定如何把编辑映射为协作操作,Provider 决定如何交换信息,存储协议决定何时可以承诺已保存。选型时把这些问题分开,再用断线、重试、重启和升级测试把它们连起来,比只看一次双人编辑演示更能判断方案是否适合自己的产品。

公开资料

资料核查日期:2026-10-28。整理线索中的 Context7 检索标识为 /ueberdosis/tiptap-docs、/yjs/docs、/websites/loro_dev;它们不是 npm 版本。本轮尝试 Context7 时返回月度配额耗尽,未获得检索结果,以下陈述改由官方文档和公开源码核对,不把既有检索线索写成本轮成功调用。

资料本文核对主题
ProseMirror Guide / CollabSchema、Transaction、Step、中心顺序与 rebase
Slate Operations / Normalizing操作和结构修复的关系
Lexical Editor State状态读写、提交和序列化边界
Tiptap Introduction / CollaborationProseMirror 基础、扩展与协作撤销接线
Yjs Document Updates / Editor Bindingsupdate、State Vector、模型适配
Yjs Awareness / UndoManager临时状态、撤销范围与 origin
y-prosemirror v1.3.7该发行版的 Y.XmlFragment 映射与插件职责
Loro OpLog / DocState / Version Deep Dive历史、物化状态、Version Vector 与 Frontiers
Loro Encoding / PersistenceShallow Snapshot 边界与 pending-import 恢复
loro-prosemirror同步、光标、撤销,不等同于替换整个协议栈
PartyServer / y-partyserver / Hocuspocus房间、Provider、存储接线与运行环境

文中固定版本链接只为说明对应实现,不是最新安装建议;其余页面会继续演进。将机制用于具体产品前,仍应固定依赖并执行自己的兼容性与恢复测试。

博客助手

正在打开博客助手…