阶段 5 账号与托管同步 Beta
1. 交付范围
阶段 5 将阶段 4 冻结的同步协议 v1 接入可独立部署的单体服务,并在桌面客户端提供 Beta 登录和运维入口。已实现:
- 邮箱密码账号、30 天会话、设备列表、其他设备远程退出和当前设备远程登出。
- 每账号配额、SQLite 关系元数据、AES-256-GCM 加密对象目录、完整版本历史。
- v1 保留 revision、cursor、operationId 幂等和墓碑字段,但
/changes已原地升级为不兼容的压缩快照:每个fileId只返回 cursor 之后的最终状态,客户端只对最终正文执行一次保守三方合并。 - 托管服务 HTTPS 边界、操作设备绑定、路径校验、审计、指标和脱敏客户端错误报告。
- 系统安全凭据存储;客户端不把 bearer token 或密码写入明文配置。
- Markdown 与常见图片附件同步、原始库导出、在线备份校验和空目录恢复演练。
- 桌面端仅保留托管同步 Beta;协议回归通过测试目录中的内存远端替身完成,不提供第二种运行模式。
Beta 不包含团队共享、端到端加密、非图片通用附件同步、自动更新服务或多节点服务端。
- SQLite 使用 Node.js 22 的内置
node:sqlite,当前仍会显示实验性 API 警告。 - 正式大规模发布前应固定运行时并完成升级回归,或迁移到受支持的关系数据库驱动。
2. 服务端运行
生成 32 字节主密钥:
npm run sync-admin -- generate-key
生产启动必须提供 TLS 和独立保存的密钥:
export MYNOTE_SYNC_ENCRYPTION_KEY='<32 字节 base64>'
export MYNOTE_SYNC_DATA='/srv/mynote-sync'
export MYNOTE_SYNC_HOST='0.0.0.0'
export MYNOTE_SYNC_PORT='8787'
export MYNOTE_SYNC_TLS_KEY='/run/secrets/tls.key'
export MYNOTE_SYNC_TLS_CERT='/run/secrets/tls.crt'
export MYNOTE_SYNC_ADMIN_TOKEN='<独立指标令牌>'
npm run sync-server
MYNOTE_SYNC_FORCE_HTTPS 默认值为 true。未配置 TLS 时,服务端会拒绝监听非回环地址,客户端也会拒绝非本机 HTTP 地址。仅本机开发可使用:
MYNOTE_SYNC_DEV=1 npm run sync-server
开发模式会在数据目录创建权限为 0600 的 .dev-encryption-key,不能用于生产。默认情况下客户端只接受 HTTPS,http://localhost 和 http://127.0.0.1 仅用于本机测试。
需要从另一台电脑通过局域网进行无 TLS 联调时,服务端和客户端进程必须都显式设置:
# 服务端电脑:监听局域网地址并允许 HTTP
MYNOTE_SYNC_DEV=1 MYNOTE_SYNC_FORCE_HTTPS=false MYNOTE_SYNC_HOST=0.0.0.0 npm run sync-server
# 客户端电脑:允许填写 http://<服务端局域网 IP>:8787
MYNOTE_SYNC_FORCE_HTTPS=false npm run dev
- 变量只接受小写字符串
true或false。 - 未设置、空字符串或
true都会保持强制策略,其他值会让进程以配置错误停止。 false会允许 token、邮箱和正文经明文 HTTP 传输,只能用于受控开发网络,生产环境不得关闭。
3. 数据模型与 API
SQLite 保存账号、设备、会话哈希、笔记库、当前文件元数据、版本、幂等结果、对象计量、审计和客户端报告。正文不进入 SQLite:
数据目录/
├── metadata.sqlite
└── objects/
└── ab/<HMAC 对象键>.bin # MYN1 + nonce + GCM tag + ciphertext
对象键是账号 ID 与正文 SHA-256 的 HMAC,避免直接暴露正文哈希并在账号内去重。对象使用 AES-256-GCM,加密对象键作为 AAD;篡改密文、nonce、tag 或对象键都会导致解密失败。主密钥不写入数据库或对象目录。
主要接口:
| 方法 | 路径 | 用途 |
|---|---|---|
POST |
/v1/accounts/register |
注册并创建当前设备会话 |
POST |
/v1/accounts/login |
登录;可复用同一安装的现有设备,否则登记新设备 |
GET |
/v1/account |
验证会话和当前账号 |
GET |
/v1/devices |
设备、最近访问和退出状态 |
DELETE |
/v1/devices/:id |
撤销目标设备全部会话 |
GET |
/v1/quota |
已计量历史对象字节数和配额 |
GET |
/v1/libraries |
当前账号的远端笔记库名称、revision 与当前 Markdown 文件数 |
PATCH |
/v1/libraries/:id |
修改当前账号拥有的仓库名称 |
DELETE |
/v1/libraries/:id |
永久删除当前账号拥有的仓库及其远端历史 |
POST |
/v1/libraries/:id/operations |
v1 幂等上传 |
GET |
/v1/libraries/:id/changes |
cursor 之后按 fileId 压缩的单次最终快照 |
GET |
/v1/libraries/:id/versions |
含动作、时间和设备名的版本历史元数据 |
GET |
/v1/libraries/:id/versions/:revision |
单个版本元数据、前后正文及 diff 所需信息 |
GET |
/v1/libraries/:id/versions/:revision/snapshot |
指定全局 revision 时仍存在的完整文件快照;只读,不修改远端 |
POST |
/v1/libraries/:id/versions/:revision/restore |
将历史正文恢复为新 revision |
GET |
/v1/libraries/:id/export |
当前未删除 Markdown 与图片全量导出 |
POST |
/v1/client-reports |
有界、无 token 的客户端错误报告 |
GET |
/health |
健康状态和支持的协议版本 [1] |
GET |
/metrics |
管理令牌保护的 Prometheus 文本指标 |
服务端只接受操作中的 deviceId 与 bearer token 对应设备一致的请求。会话 token 使用 SHA-256 后落库,密码使用随机盐和 scrypt;原始密码和 token 不进入日志、审计或报告。
4. 客户端流程
客户端与托管服务端之间的上传、拉取、冲突处理流程图,以及每一步对应的源码位置,见同步流程与代码位置。
- 同步中心仅提供托管同步 Beta。
- 用户配置服务地址、邮箱和密码,注册或登录成功后,客户端按以下规则运行。
4.0 客户端功能概览
运行时与凭据
- 桌面客户端固定使用 Electron 43.4.0,并通过主进程
net.fetch发起托管同步请求,不依赖运行环境是否暴露全局fetch;依赖变更后应执行npm ci,避免本地运行时版本落后于锁文件。 - token、账号和当前设备 ID 经 Electron
safeStorage加密后写入应用数据目录。 - 服务端签发的设备 ID 还会按规范化服务地址与账号邮箱保存到独立的加密身份表。
- token 丢失、到期、手工重新登录,或执行“远程退出当前设备”后再次登录时,客户端把该 ID 作为复用提示发送。
- 服务端在密码验证成功且设备仍属于该账号时轮换会话并重新激活该身份,不创建新设备,因此已有同步基线和 cursor 可继续使用。
- 只有该设备记录已不存在或不属于该账号时才签发新 ID。
- 旧服务端忽略可选提示时仍可登录,客户端会保存其实际返回的设备 ID。
- 系统凭据加密不可用时失败关闭;Linux
basic_text后端被拒绝。 - 服务地址和邮箱保存在非敏感配置中;使用新服务地址登录时立即清除旧凭据和客户端实例。
自动同步调度
- 自动同步间隔与服务地址、邮箱一同保存在系统应用数据目录的
sync-config-v1.json中。未设置或在同步中心关闭时保存为null,不创建定时任务;启用时界面默认填写 60 秒,可配置 1–86400 的整数秒。 - 自动同步定时器在 Electron 主进程运行,不依赖同步中心窗口保持打开。
- 全局自动同步开启时,当前仓库始终是候选目标,无需选择仓库级后台同步。
- 侧边栏仓库操作区中默认关闭的“切换仓库后仍自动同步”偏好保存在
workspace-state.json,可独立于全局开关配置;全局关闭期间仅停止调度。 - “安全的自动同步”是另一个彼此独立的仓库级偏好,同样可随时配置。
- 安全偏好不决定仓库是否成为目标,只决定该仓库被全局定时器选中后采用完整同步还是安全同步;因此当前仓库即使未开启“切换仓库后仍自动同步”也会遵循安全模式。
- 安全模式先以同一 cursor 读取远端最终快照并只读扫描本地,仅有待推送或仅有待拉取时才执行对应同步。
- 双向变化会直接跳过;预检后的上传冲突保留队列,拉取期间的新本地变化在冲突副本生成前停止。
- 没有当前或后台目标、没有有效登录或上一轮仍在运行时,也会跳过本次触发。
- 每轮对候选仓库去重后顺序执行,单个仓库失败不阻断其余仓库。
- 退出应用时停止定时器。
- 从最近仓库中移除仓库时会同时删除两个仓库级偏好。
- 手动“立即同步”不检查仓库级开关,并继续使用完整冲突恢复流程。
仓库身份、发现与克隆
- 每个账号/服务地址使用独立同步基线目录,防止不同托管环境的 revision 和 cursor 相互污染。
- 同一账号与远端库在不同仓库目录中各自使用独立 cursor;新的空目录从 cursor 0 拉取当前最终快照,不下载中间历史正文。
- cursor 位于
<笔记库>/.mynote/sync-hosted-v1/<账号与服务地址命名空间>/state.json;文件同时保存文件基线、待上传队列、最近错误和诊断日志,不包含密码或会话 token。路径、字段和恢复语义见下文本地身份与 cursor。 - 登录后首次打开同步中心会等待托管客户端初始化再读取状态,并有独立回归测试覆盖。
- 新设备打开空文件夹时,如果账号中只有一个已有数据的远端库,客户端会自动采用其
libraryId并拉取完整内容;非空文件夹不会被自动改绑。若账号已有多个远端库,客户端不会自动猜测,可从远端仓库列表选择克隆,或打开保留.mynote/library.json的已有目录。 - 同步中心无需已有本地工作区即可打开,并通过
/v1/libraries展示当前账号全部远端仓库的 revision、当前未删除 Markdown 文件数和创建时间;图片附件不计入该数字。 - 首次上传会把本地根目录的文件夹名称作为远端默认名称。
- 用户仍可把名称修改为 1–80 个有效字符,列表按
名称(完整 ID)显示。 - 本地独立显示名称不会自动覆盖远端名称。
- 已有服务端数据库启动时会自动增加可空的
libraries.name列。 - 点击“克隆到本地”并选择空文件夹时直接使用该目录。
- 选择非空文件夹时,在其中新建以仓库名称或完整 ID 命名的子文件夹,替换 Windows/macOS 不安全字符,并在同名路径已存在时追加编号,绝不复用已有内容。
- 客户端验证仓库归属后写入
.mynote/library.json、从 cursor 0 拉取当前最终快照,并把实际克隆目录切换为当前工作区。 - 首次拉取失败时保留已创建的仓库身份和错误状态,可直接点击重试。
- “删除远端”需要二次确认,并永久删除仓库文件头、版本和幂等记录。
- 仅由该仓库引用的加密对象同时释放,共享对象会保留到最后一个仓库引用被删除。
- 服务端保留已删除仓库 ID 墓碑,其他已关联设备不能用旧身份重建仓库;本地文件不会随远端删除。
- 文件数由服务端按受支持的 Markdown 扩展名统计
files表中当前未删除的记录;客户端兼容安全的数字或数字字符串。 - 旧服务端未返回该字段时显示“Markdown 文件数未知”,不再错误显示为 0。
仓库详情与部署边界
- 当前工作区侧边栏在“刷新”旁提供“仓库详情”入口。
- 详情集中显示本地显示名称和真实路径、完整远端仓库名称/ID、同步账号、本地与远端 Markdown 数、远端 revision、本地 cursor、同步状态、上次同步和远端创建时间。
- 详情界面支持修改独立的本地显示名称,侧边栏与最近仓库列表同步更新。
- 修改本地显示名称不会重命名根目录,也不会变更
.mynote同步身份、游标、后台同步配置和文档路径。 - 未登录、未建立同步身份或离线时仍保留可读取的本地详情,并明确标记缺失的远端信息。
- 若
.mynote/library.json中的仓库 ID 不在当前账号与服务地址对应的远端列表中,详情显示“当前账号中不存在”,且不再初始化同步客户端,避免自动采用其他仓库身份。 - 若本地根目录不存在,则清除失效工作区选择并从最近列表移除。
- 详情入口后的“移除仓库”操作需用户确认;成功后删除本地
.mynote元数据目录并清除最近列表记录和本地显示名称,但保留 Markdown 等笔记文件和远端仓库。 - 仓库名称、删除接口、文件数修正、版本详情和整体恢复快照依赖新版服务端。
- 部署代码后需要重启同步服务;启动过程会自动完成名称列、版本动作/恢复来源列与删除墓碑表迁移。
- 迁移前产生的版本会根据前一条文件版本推断新增、修改、重命名或删除动作。
.mynoteignore直接作为新的 v1 UTF-8 文件类型同步,不提供旧版兼容。- 启用
.mynoteignore同步前必须升级所有客户端并重启同步服务,否则旧端拉取该记录时会按不支持的文件类型失败。 - 旧版服务端没有
/v1/libraries时,客户端回退到原有独立库行为,不会让同步概览失败。 - 跨设备自动发现需要同步更新并重启服务端。
错误与恢复
- 同步错误保留队列,并向
/v1/client-reports发送最多 2000 字符的错误摘要;渲染进程退出原因也会报告。 - 应用在同步完成前退出时,下一进程会把磁盘中遗留的
syncing恢复为“需要重试”,不回滚已保存的文件基线、队列或 cursor。
同步进度、版本与导出
-
手动同步既可从同步中心的“立即同步”触发,也可从侧边栏“仓库详情”后的“同步”快捷按钮触发。
-
快捷按钮在执行期间显示“同步中…”并禁止重复操作,结束后通过非阻塞通知提示成功或失败。
-
主进程事件显示阶段进度:扫描显示已发现文件数,上传和下载显示当前项、总数及相对路径。
-
下载总数来自单次压缩快照中的最终文件数。
-
进度输出位于“立即同步”按钮区域下方。
-
界面把动态路径放在固定高度的省略区域,并按 120 ms 合并高频更新,避免大量文件同步时文字重排和画面晃动。
-
进度输出不发起额外网络轮询。
-
诊断日志旁的“详细输出”默认关闭。
-
启用“详细输出”后,仅在界面展开完整日期时间和日志已有的结构化详情,不改变同步过程或线协议。
-
用户可查看配额、设备和最近版本。
-
版本列表显示 revision、动作、时间与修改设备。
-
点击版本后按需读取详细元数据和修改前后正文,在客户端生成逐行 diff。
-
“精简差异”默认启用,只保留每处变化前后 3 行且不会隐藏变化行;取消勾选后显示完整 diff。
-
“自动换行”默认关闭,关闭时长行横向滚动,启用后在差异框内换行。
-
选项切换只更新已加载内容,不重新请求服务端。
-
新增、修改、重命名、删除和恢复动作会明确区分。
-
图片等二进制附件只显示修改前后字节数,不作为文本展开。
-
用户可将未删除的历史正文恢复为新 revision,或对任意历史条目执行“整体恢复”。
-
整体恢复会先校验目标快照和当前远端头,再覆盖本地同步文件、打开的编辑器内容与待推送队列。
-
全局自动同步关闭时,整体恢复仅保留为新的本地差异;开启时立即遵循仓库的普通或安全自动同步策略。
-
用户也可撤销其他设备,或导出到新的
mynote-export-<时间>/原始目录。 -
导出目录包含当前所有未删除 Markdown、受支持图片和
.mynote/library.json,不会覆盖已有目录。 -
图片使用 base64 通过 JSON 传输。
-
服务端按原始字节计算配额并使用 AES-256-GCM 加密对象存储。
-
单个同步文件上限为 20 MB。
4.1 账号与服务地址命名空间
托管同步状态不能只按 libraryId 存放:同一个客户端可能登录不同账号,或者连接不同的独立服务,而这些环境中可能出现相同的库 ID。客户端因此先计算一个本地 namespaceKey:
输入 = <规范化服务地址> + ":" + <服务端账号 ID>
namespaceKey = SHA-256(输入) 的十六进制结果前 16 个字符
对应实现等价于:
crypto.createHash('sha256')
.update(`${config.endpoint}:${credentials.accountId}`)
.digest('hex')
.slice(0, 16);
例如服务地址为 https://sync.example.com,账号 ID 为 11111111-2222-4333-8444-555555555555 时,命名空间为 7189751063e3b260,仓库状态目录为:
<笔记库>/.mynote/sync-hosted-v1/7189751063e3b260/
输入字段的来源与约束:
endpoint由客户端校验并规范化,末尾/会被移除;同一服务的 IP、域名或不同端口仍被视为不同服务地址。accountId是注册或登录响应中的服务端账号 ID,经系统安全存储加密后保存在本机凭据文件中;不会使用可能变化的邮箱作为命名空间输入。- 密码、会话 token、设备 ID、邮箱和笔记正文都不参与计算,也不会出现在目录名中。
- 16 位十六进制摘要用于避免本地目录冲突和直接暴露账号 ID,不是鉴权或加密边界;真正的访问控制仍由 bearer token 和服务端账号归属检查完成。
完整状态路径使用固定文件名:
<笔记库>/.mynote/sync-hosted-v1/<namespaceKey>/state.json
- 状态文件不再使用仓库绝对路径或
replicaKey。 repo1与repo2的状态分别位于各自的.mynote,天然拥有独立 cursor;仓库移动或目录改名不会改变状态文件位置规则。- 进程内的
SyncClient缓存键还包含当前libraryId;即使同一根目录的library.json被替换,也不会继续复用保留旧 cursor 和基线的客户端。 - 仓库详情在组合远程元数据和本地运行状态前会再校验两者的仓库 ID;身份在读取期间变更时直接报错,不混合显示 revision 和 cursor。
相关代码位置:
| 职责 | 代码位置 |
|---|---|
| 服务地址校验与规范化 | validateHostedEndpoint |
保存登录响应中的 accountId |
SyncManager.authenticate |
计算 namespaceKey 并传入 stateNamespace |
SyncManager.hostedClient |
在仓库 .mynote 下确定固定状态文件路径 |
SyncClient.initialize |
| 读取、推进并原子保存 cursor | SyncStateStore |
4.2 本地身份与 cursor
首次对笔记库运行托管同步时,客户端会在仓库内维护两类元数据:
<笔记库>/.mynote/library.json保存可移植的libraryId和格式版本。复制笔记库时应保留此文件,多个副本才会识别为同一个逻辑仓库。<笔记库>/.mynote/sync-hosted-v1/<namespaceKey>/state.json保存当前账号、服务地址和本地副本对应的同步检查点,包括设备身份、文件基线、待上传队列、拉取 cursor、状态和诊断日志。
cursor 不写入 library.json,也不是由服务端替客户端保存。它表示当前本地副本已经成功应用的最大远端 revision,因此同一 libraryId 位于两个本地目录时,两份状态自然位于各自的 .mynote 中并维护独立 cursor。仓库移动或目录改名不会改变状态文件位置规则。
状态文件的关键字段示例:
{
"protocolVersion": 1,
"libraryId": "dcd91689-6d5e-4626-8174-f050b09227db",
"deviceId": "当前设备 ID",
"cursor": 5,
"files": {},
"queue": [],
"status": "idle",
"lastError": null,
"lastSyncedAt": "2026-08-25T02:09:31.121Z",
"logs": []
}
-
files是最近已接受的文件身份、路径、哈希、revision 和删除状态基线。 -
queue是尚未获得持久化处理结果的本地操作。 -
upsert队列项包含扫描时的正文与哈希,以便文件随后再次变化时仍能重试同一操作。 -
诊断日志最多保留 200 条,不包含密码或会话 token。
-
library.json已存在但损坏时,客户端会停止并报告错误,不会生成新libraryId覆盖它。 -
state.json不是普通缓存:删除它会丢失当前副本的本地基线和待确认队列,下一次同步将从 cursor 0 重建状态,因此不应手工清理。
4.3 v1 当前契约字段
所有同步操作携带 protocolVersion: 1。客户端变更结构如下:
上传操作
{
"protocolVersion": 1,
"operationId": "稳定且唯一的客户端操作 ID",
"libraryId": "稳定笔记库 ID",
"deviceId": "稳定设备 ID",
"fileId": "稳定文件 ID",
"type": "upsert | rename | delete",
"baseRevision": 12,
"relativePath": "notes/example.md",
"content": "# 完整 Markdown 正文\n",
"encoding": "utf8",
"hash": "按原始字节计算的 SHA-256"
}
fileId是稳定身份,relativePath是可变属性。- 路径必须是根目录
.mynoteignore、笔记库内受支持的 Markdown 或图片相对路径。 .mynote/和名称以.app结尾的 macOS 应用包内部始终不作为正文或附件同步。.mynoteignore自身固定使用 UTF-8 同步且不能排除自身。- 用户规则只阻止新的本地路径加入同步。
- 本地基线、持久化管理清单或远端最终快照中的文件都已属于管理范围,继续扫描、上传、拉取和显示。
upsert对 Markdown 使用utf8,对 PNG、JPEG、GIF、WebP、BMP 使用base64;SHA-256 始终按解码后的原始文件字节计算。- 单文件解码后最多 20 MB;HTTP JSON 请求上限为 28 MB,以容纳 base64 约三分之一的体积开销。v1 上传完整文件,不做块级差分。
rename只改变路径;delete产生保留文件身份、路径和 revision 的墓碑,不立即物理删除服务端记录。operationId的处理结果会持久化。即使服务端提交后连接中断,相同操作重试也只产生一个 revision。upsert的目标路径和正文哈希已与当前有效文件头完全相同时,即使另一端先提交相同修改而使baseRevision过期,服务端也把它作为成功的无变化操作持久化,不递增仓库 revision,也不增加版本历史;相同正文但路径变化仍视为可见变更。- 每个库的 revision 由服务端单调递增。除目标状态已由另一端实现的无变化
upsert外,只有baseRevision等于该fileId当前 revision 时才接受覆盖,否则返回 conflict 和当前版本。
拉取快照
- 拉取接口接收最后成功应用的 cursor。
- 服务端从当前
files头表选择最后 revision 大于 cursor 的记录,因此同一文件的多次修改、重命名、删除或恢复只返回最终一条。 - 完整版本历史仍保留在
versions中供查看和恢复。 - 响应固定包含
compacted: true、hasMore: false和快照目标 cursor。 - 客户端防御性地再次按
fileId取最高 revision,先保存每个受影响身份的本地正文,再处理路径循环,并以本地持久化基线、本地当前正文、远端最终正文只执行一次三方合并。 - 每个文件基线可以单独保存,但只有整份快照成功后才一次性推进 cursor。
- 中断重试会跳过已保存的文件 revision 并继续余项。
部署兼容边界
- 这次压缩拉取按产品决定直接替换 v1,不提供旧分页 v1 的兼容层,也不升级协议号。
- 新客户端会拒绝缺少
compacted: true或仍返回分页的 v1 响应;部署时必须同时升级并重启所有客户端和同步服务。
4.4 客户端状态机与崩溃恢复
完整的 UI → IPC → 托管远端 → 双轮收敛 → 上传/冲突 → 拉取/落盘流程及源码位置,见同步流程与代码位置。核心状态机为:
idle/error
│ 用户点击同步、定时触发或重试
▼
扫描本地 → 校验未跟踪路径的远端最终状态 → 原子持久化队列 → 幂等上传 → 拉取压缩快照 → 应用最终文件 → 提交 cursor → idle
│ │
└──── 任何异常 ──────────┘
▼
error(队列与 cursor 保留)
同步扫描根据已知 fileId、路径和哈希生成变更:
- 已知文件消失且恰好出现一个相同哈希的新路径时识别为重命名。
- 候选不唯一时保守地记录删除和新建,不猜测文件身份。
- 扫描发现本地存在但基线未跟踪的路径时,会从 cursor 0 读取远端压缩头状态确认它是否真的为新文件。
- 远端同路径同哈希时直接采用远端
fileId、revision 和合并基线,不发送上传。 - 同路径不同内容时按远端胜出规则保存本地冲突版本。
- 即使远端在预检后才出现同内容路径,上传冲突分支也会采用其身份而不生成冲突副本或新 revision。
没有本地基线的新副本会先从 cursor 0 拉取远端最终快照,再判断本地新增或删除。这样空目录不会把远端文件误判为本地删除,已有的离线正文也会按照冲突规则保留双方版本。
崩溃恢复依赖以下持久化顺序:
- 扫描得到的本地操作先整体写入
queue并原子保存,之后才允许发出 HTTP 请求。 - 上传只处理持久化队首;远端结果更新文件基线后,才移除队首并保存。
- 拉取先捕获受影响
fileId的本地正文,再逐项应用最终远端状态并保存文件基线;整份快照完成前保持旧 cursor,全部成功后才提交目标 cursor。 - 同路径 Markdown 冲突先写出相邻的 Base/Local/Remote;非重叠修改自动合并,若合并正文已经等于远端正文则直接采用已落盘的远端头,不重复写盘或上传,并且只在存在对应路径时清理临时冲突文件;重叠修改保留三份版本等待用户处理。其他冲突仍先保留本地正文,再应用远端当前文件、路径拥有者或墓碑。
- 任何异常只记录
error与诊断信息,不清空未确认队列,也不回退已保存 cursor。
如果进程在上传响应后、保存队列前退出,重启后会重发相同 operationId,服务端返回第一次提交的结果;如果进程在压缩快照部分写盘后、提交目标 cursor 前退出,重启后会重新请求同一快照,跳过基线中已达到最终 revision 的文件并继续余项。两种窗口都允许重复工作,但不允许跳过未确认工作。
同步文件写入沿用“同目录临时文件 → 刷盘 → 原子替换”。远端相对路径在读写前逐级拒绝符号链接,并再次检查解析结果位于笔记库内,不能借本地链接或目录穿越逃出仓库。
4.5 冲突与恢复规则
- 版本冲突:服务端返回当前版本,不接受过期覆盖。
- 同路径 Markdown 双方编辑:客户端读取共同祖先,在原文件旁写入
a.md.base、a.md.local、a.md.remote后执行行级三方合并。合并成功时保存新的幂等上传操作并删除临时文件;修改重叠或超过合并上限时保留三份文件。 - 手动处理:工作区树在任一临时版本存在时给原 Markdown 显示警告标记;用户可以直接修改原文件,或通过右键菜单把 Local/Remote/Base/Merged 的绝对路径传给设置中配置的外部工具。完成后选择“标记同步冲突已解决”删除三份临时文件。
- 重命名/删除冲突:本地重命名内容先成为冲突副本,删除墓碑仍被应用。
- 远端删除:原文件移入
<笔记库>/.mynote/sync-trash/,不是不可恢复地删除。 - 路径冲突:同路径同内容直接采用远端文件身份,不上传也不生成副本;内容不同时先保存本地冲突副本,再应用已经拥有该路径的远端文件。
- 网络失败:当前操作继续留在持久化队列;界面显示“需要重试”、错误原因和有界诊断日志。
.base/.local/.remote 不是受支持的同步扩展名,不会获得 fileId 或上传;它们只保留在发生冲突的本地副本中。图片、删除/修改、路径占用和重命名意图冲突仍使用原有冲突副本或同步回收目录。客户端不使用时间戳决定胜者,也不会把冲突标记写入正常笔记。
4.6 协议回归矩阵与当前限制
test/sync-protocol.test.js、test/sync-client.test.js 和 test/hosted-sync-http.test.js 共同冻结客户端、内存远端替身和真实 HTTP 服务的 v1 行为:
| 场景 | 必须保持的断言 |
|---|---|
| 重复请求 | 同一 operationId 返回相同结果,只产生一个 revision |
| 两端修改为相同正文 | 后提交端即使基线过期,只要目标路径与正文哈希已和当前文件头相同就成功收敛,不产生 revision |
| 过期基线 | 返回 conflict,远端当前版本不被覆盖 |
| 压缩快照 | 同一 fileId 多个 revision 只返回最终状态,整批完成后推进 cursor |
| 墓碑 | 删除保留文件身份、路径和 revision |
| 双设备离线编辑不同段落 | 自动生成并上传合并正文,临时三方版本被删除 |
| 双设备离线编辑同一区域 | 原路径旁保留 Base/Local/Remote,文件树显示待处理标记 |
| 重命名与删除并发 | 墓碑保留,本地重命名正文仍可恢复并成为新文件 |
| 提交后断网 | 队列保留;重试幂等且没有重复版本 |
| 乱序和重复拉取 | 客户端排序、去重并收敛到相同 cursor |
| 随机双设备操作 | 固定种子多轮离线双写后,每个并发正文都存在于远端版本或冲突副本 |
| 恶意路径或符号链接 | 拒绝目录穿越、.mynote 写入和符号链接逃逸 |
当前协议限制:
- 只同步 Markdown 与常见图片;PDF、音视频和其他通用附件尚未实现。
- 可手动或按秒级间隔定时同步;尚无文件变化触发、网络状态监听或 WebSocket 提示。
- Markdown 以 UTF-8 文本传输,图片以 base64 传输原始字节;Markdown 的 BOM 和 CRLF 元数据不能跨设备保真,需要后续扩展协议并增加兼容迁移测试。
- 本机诊断日志最多保存 200 条,不包含账号令牌;开始同步和本地队列记录会附带 cursor、待处理数、已跟踪文件数或操作类型计数,其他冲突及失败记录保留其原有结构化上下文。上述详情仅在用户启用“详细输出”后展开;尚无面向用户的脱敏导出流程。
5. 安全评审结论
| 威胁 | 控制 | 自动化证据 |
|---|---|---|
| 网络窃听 | 默认强制 HTTPS;仅显式设置 MYNOTE_SYNC_FORCE_HTTPS=false 才允许非回环 HTTP |
默认拒绝非回环 HTTP;自动化测试覆盖开关解析和双端放行 |
| 凭据泄露/暴力登录 | scrypt 密码、哈希 token、系统凭据加密、每 IP 登录限速、无 token 日志 | 数据库和凭据文件不含原始秘密 |
| 越权访问 | 每次库访问校验账号所有权,操作绑定登录设备 | 未登录 401、错误设备和跨账号访问拒绝 |
| 正文静态泄露/篡改 | AES-256-GCM、随机 nonce、AAD、外置主密钥 | 对象中无明文,密文认证后才返回 |
| 重放/重复提交 | 持久化 operationId 结果 |
重复请求只产生一个 revision |
| 目录穿越/链接逃逸 | 协议路径规范化与客户端逐级链接检查 | v1 安全测试保持通过 |
| 静默覆盖 | baseRevision 冲突和普通 Markdown 冲突副本 |
双设备随机操作测试保持通过 |
| 不可恢复删除 | 服务端墓碑、本机同步回收目录、版本历史 | 删除/重命名并发测试 |
| 备份损坏 | SHA-256 清单、SQLite 在线备份、恢复到空目录 | 自动备份恢复演练 |
剩余风险:SQLite 元数据依赖部署磁盘加密保护邮箱和路径;主密钥轮换、WAF/反向代理限流、邮件验证、密码重置、管理员审计导出和对象垃圾回收尚未产品化,因此只标记为 Beta。
6. 备份与恢复演练
服务运行期间使用 SQLite 在线备份,并复制加密对象、生成逐文件 SHA-256 清单:
export MYNOTE_SYNC_ENCRYPTION_KEY='<与服务相同的密钥>'
npm run sync-admin -- backup /srv/mynote-sync /srv/backups
npm run sync-admin -- verify /srv/backups/backup-<时间>
npm run sync-admin -- restore /srv/backups/backup-<时间> /srv/restore-drill
恢复只允许空目录,避免覆盖在线数据。演练必须使用相同主密钥启动恢复实例,执行登录、全库导出和版本抽查后才可切换流量。主密钥必须独立备份;只有数据目录而没有主密钥无法恢复正文。
6.1 服务端按用户导出仓库
服务器管理员可直接从同步数据目录导出指定用户的当前仓库快照,不需要该用户的密码或会话 token:
export MYNOTE_SYNC_DATA='/srv/mynote-sync'
export MYNOTE_SYNC_ENCRYPTION_KEY='<与同步服务相同的密钥>'
npm run sync-export -- '<用户 ID>' '<仓库名称或 ID>' '/srv/exports/target-repository'
参数与目标目录
- 三个位置参数依次为用户 ID、仓库名称或 ID、最终输出目录。
- 工具只在该用户拥有的仓库中查找。
- ID 优先于名称,重名仓库必须改用 ID。
- 输出目录不存在时创建完整克隆。
- 输出目录已存在时,必须包含该工具生成的同仓库
.mynote/library.json。 - 工具会根据元数据中的上次导出 revision,同步服务端新增、修改、重命名和删除,并更新 revision。
- 普通同名目录、其他仓库目录和损坏的元数据都会被拒绝。
增量更新与安全边界
- 同步更新以服务端快照为准,但不会静默覆盖本地修改。
- 上次克隆的文件若在本地被改过,或新的远端路径与本地文件冲突,命令会在写入前失败。
- 本地自行增加且不与远端路径冲突的文件会保留。
- 成功目录包含当前所有未删除的 Markdown、图片、
.mynoteignore和.mynote/library.json,不导出历史版本正文。 - 该命令需要直接读取
metadata.sqlite、版本元数据和加密对象,因此必须在能够访问MYNOTE_SYNC_DATA且持有服务端主密钥的受信任环境执行。
7. 指标、容量与灰度
7.1 指标
/metrics暴露账号、设备、库、版本、客户端报告和计量对象数量,不包含邮箱、路径或正文。- 部署层还应采集 HTTP 延迟、状态码、进程 RSS、磁盘延迟/容量和备份时长。
7.2 容量基线
2026-08-27 在当前 macOS 开发机执行 npm run benchmark:hosted-sync -- 250:
-
250 次含正文、加密对象和 SQLite 提交的串行写入为 13,836 ms。
-
随后一次拉取 250 个最终文件的压缩快照为 63 ms。
-
该结果只是 fsync 密集型单进程基线,不代表线上用户容量。
-
压缩快照的响应体仍与变化文件总正文大小成正比。
-
Beta 上线前需在目标磁盘按实际正文尺寸和并发度复测,并根据 p95 写入延迟、响应体大小和磁盘余量限制仓库规模。
7.3 灰度顺序
灰度顺序:内部账号 → 显式 beta 渠道小批用户 → 观察错误报告、401/409/413 和同步延迟 → 扩大批次。
/health当前声明只支持协议 v1。- 客户端继续发送
protocolVersion: 1,并额外要求拉取响应包含compacted: true。 - 这次发布不提供旧分页 v1 的兼容窗口,必须先停止同步流量,再同步升级服务端与全部客户端并重启。
