feat(sync): 同步盘 + 关系层 (先建关系, 再做文件同步)
## 关系层 (src/main/relations.ts, relation-service.ts) 原来"发现即信任": UDP 广播包里明文带着 driveToken, 同网段任何进程拿到就能浏览 你的整个网盘, 而且没有"授权"这个概念。现在拆成三层: 发现 (临时) -> 关系 (持久, 用户显式建立) -> 授权 (分级, 双方各自独立) 建立关系的方式跟远程终端的授权闸门**完全一致** —— 对方直接弹窗, 不用邀请码: A 点"发起同步关系" -> WS 帧 relation:request -> B 侧弹窗 (复用 ApprovalDialog, kind='relation') -> 同意 -> relation:accept -> 双方落库并交换 per-peer 令牌。 - 令牌: myToken=我签发给对方的, peerToken=对方签发给我的 - **driveToken 不再进广播包**; file-server 在关系令牌匹配不上时回退到旧的全局 token, 但只允许"只读浏览网盘" —— 不给同步区, 也不给写/删 - 关系存 JSON 文件 (原子写), 不做 schema 迁移 - 授权能力: drive(浏览我的网盘) / sync(与我同步文件), 双方各自独立勾选 - 远程终端/端口转发/USB 仍走原来的 remoteAllowPeers 白名单, 没有并进关系层 - 同一对端 60s 内只弹一次窗 (WS 是"发现即建立"的, 不限流就能被刷屏) ## 同步区: 独立于网盘的第二套空间 用户的同步目录可以是任意文件夹 (~/文档/工作), 不必待在网盘目录里。 /drive/* 网盘 -> 共享目录, 要 drive 授权 /sync/* 同步区 -> 某关系专属的同步目录, 要 sync 授权, 路径严格限制在该目录内 顶层目录可以同时和多个设备同步: 每个关系自动分配一个子目录 (~/备份/MECHREVO/, ~/备份/lenovo/), 物理隔开, 同名文件不会互相覆盖 —— "统一目录多个聊天备份"成立。 ## 文件关系表 (src/main/sync-manifest.ts) —— 同步盘地基 核心是**区分"文件被删了"和"文件本来就不存在"**: - **墓碑**: 删除不抹记录, 只标 state='deleted'。直接抹掉的话, 对方离线回来会把 已删文件当成"新增"重新下载 -> 删除永远删不掉; 对方从备份恢复也会让删除被 静默撤销。有墓碑 + seq 比较就都能挡住。 - **seq = Lamport 时钟**: hash 只能答"一样不一样", seq 才能答"谁更晚" —— 判定 删除方向全靠它。扫描顺序必须是 advanceClock -> reconcileLocal -> mergePeer (先收消息再产生事件), 否则跨机器的 seq 不可比, 删除方向会判反。 - **三方对比**: 基线(上次两边一致的 hash) + 本地 + 远端。缺基线才退回 mtime 兜底。 - 墓碑留 90 天 (比大品牌默认 30 天保守), 且要"对方已越过该 seq"才清理。 - **删除传播**: 对方推关系表过来时, 比对基线确认我没改过才删本地那份; 改过则 报冲突 —— 绝不静默丢数据。 - 冲突: 手动同步弹窗对比让用户选方向; 自动同步跳过并记录, 不替用户决定。 ## 其余 - 自动同步 (src/main/auto-sync.ts): 60s 轮询 + 指数退避, 对方上线/开关打开时 立刻跑一轮。 - 同步面板 (SyncPanel.vue): 关系区 + 变更列表 (含"将删除对方/本机") + 冲突对比 弹窗 + 自动同步开关。 - 关系授权弹窗复用远程终端那套 ApprovalDialog。 - 目录穿越防护: 上传/下载/删除的路径都严格限制在各自根目录内, 且**软链归一后 再比一次** (同步目录是用户任意文件夹, 里面出现软链很常见)。 - 跨关系校验: 令牌与 relationId 绑定, A 关系的对方不能读写 B 关系的同步目录。 - 关系令牌在 IPC 返回给渲染层时剥掉, 界面拿不到凭据。 - 建关系/授权/同步/删除传播都写 audit_log。 ## 测试 - npm run selftest 64 通过 / 0 失败 (R关系 / M墓碑删除传播 / Y同步盘) - npm run selftest:sync 26 条全通过 (双节点端到端: 建立关系->同步->删->传播->防复活) ## 文档 docs/relation-and-sync.md —— 三层模型 / 关系建立 / 墓碑与 seq / 三方对比判定表 / 安全要点 / 已知不一致。
This commit is contained in:
@@ -0,0 +1,601 @@
|
||||
# 关系模型 + 同步区设计
|
||||
|
||||
> 目标: 把"发现"和"信任"彻底分开。发现只是知道对方存在; **关系**必须由用户显式建立;
|
||||
> **同步区**在关系之上, 靠持久化的文件关系表 (manifest) 正确处理增删改, 尤其**删除**。
|
||||
>
|
||||
> 这份文档描述的是**已经落地的实现**。早期设计稿里的"邀请码"和"网盘子目录"
|
||||
> 两套方案**没有采用**, 原因分别在 §3.1 和 §4.1。代码位置见 §13。
|
||||
|
||||
---
|
||||
|
||||
## 1. 为什么要改 (现状的问题)
|
||||
|
||||
改造前是隐式全自动的:
|
||||
|
||||
```
|
||||
设备启动 → UDP 广播 announce{deviceId, name, address, chatPort, filePort, driveToken}
|
||||
→ 局域网所有机器收到 → 自动进"最近设备" → 自动建 WS → 消息双向开放
|
||||
→ 网盘 token 在广播包里明文传 → 局域网任何人拿它就能浏览你的整个共享目录
|
||||
```
|
||||
|
||||
问题:
|
||||
|
||||
1. **发现 = 信任**。同网段任何进程发个广播包就成了"联系人", 无法选择"我只跟谁建立关系"。
|
||||
2. **网盘凭据全局广播**, 而远程终端/USB 却有授权闸门 —— **两套标准**。网盘等于对整个局域网敞开。
|
||||
3. **没有确认环节**, 无法表达"我认识这个人, 我要跟他同步文件夹"。
|
||||
4. **授权粒度不统一**: 远程操作有 per-peer 白名单, 网盘和消息完全没有。
|
||||
5. **删除无法传播**。没有 manifest 就没有"这个文件之前存在过、现在被谁删了"的记录。
|
||||
|
||||
对应的解法就是下面的三层模型 + 关系级授权 + 文件关系表。
|
||||
|
||||
---
|
||||
|
||||
## 2. 三层模型
|
||||
|
||||
| 层 | 含义 | 生命周期 | 产生信任吗 |
|
||||
|---|---|---|---|
|
||||
| **发现 (Discovery)** | 知道对方存在 | 临时 (15s 心跳) | 否 |
|
||||
| **关系 (Relation)** | 用户显式建立的一次绑定 | 持久 | 是 |
|
||||
| **授权 (Grant)** | 关系上的具体能力 | 随关系 | — |
|
||||
|
||||
关系层目前只有两项能力, **双方各自独立**授权 (A 允许 B 看网盘 ≠ B 允许 A 看网盘):
|
||||
|
||||
| 能力 | 含义 |
|
||||
|---|---|
|
||||
| `drive` | 允许对方浏览我的网盘 (共享目录) |
|
||||
| `sync` | 允许与对方同步文件 (走该关系专属的同步区) |
|
||||
|
||||
远程终端 / 端口转发 / USB **没有**并进关系层, 它们仍然走原来的
|
||||
`settings.remoteAllowPeers` 白名单 + 弹窗闸门 (`src/main/remote/approval.ts`)。
|
||||
理由: 那三个是"一次性操作请求", 关系是"长期绑定", 生命周期和撤销语义都不一样,
|
||||
硬合并会让远程操作每次都要查关系表, 反而更绕。
|
||||
|
||||
**关键改动**: 全局 `driveToken` **不再出现在广播包里**。`index.ts` 的 announce 包
|
||||
故意不带这个字段, 网盘接口改成校验**关系令牌** (per-peer, 对方签发给我的那个)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 建立关系: 对方直接弹窗 (没有邀请码)
|
||||
|
||||
### 3.1 为什么不用邀请码
|
||||
|
||||
早期设计稿走的是"6 位数字码 + 聊天系统消息卡片 + 一次性邀请表"那一套, 实现时全部砍掉了:
|
||||
|
||||
1. **远程终端已经有一套成熟的授权闸门** —— 白名单 + 24h 记忆 + 弹窗
|
||||
(`approval.ts` + `ApprovalDialog.vue`)。关系层直接复用同一个队列、同一个弹窗组件,
|
||||
用户的心智模型是一致的: "对方弹窗问我要不要给权限"。
|
||||
2. **邀请码要额外维护一整套状态机**: 码的生成/分发/过期/一次性/防爆破/聊天消息卡片/
|
||||
待确认表。而这套状态机解决的问题, 弹窗一步就解决了 —— 授权范围本来就是**当场选**的事。
|
||||
3. **同步本来就需要一条 WS 通道**。请求走已有的通道比走"码"更直接, 而且天然知道
|
||||
对方在不在线: 帧发不出去就直接失败回收关系, 不用等邀请码超时。
|
||||
4. 用户还得想办法把码告诉对方 (念给对方听?), 反而比"对方弹个窗"麻烦。
|
||||
|
||||
所以最终形态是: **A 发起 → B 弹窗 → B 同意 → 双方各自落库**。
|
||||
|
||||
### 3.2 完整交互流程
|
||||
|
||||
```
|
||||
A 侧 (发起方) B 侧 (被请求方)
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
同步面板 → "建立同步关系"
|
||||
选本机同步目录
|
||||
勾选"我开放给对方的能力"
|
||||
[x] 允许浏览我的网盘 (drive)
|
||||
[x] 允许与我同步文件 (sync)
|
||||
点"发起同步关系"
|
||||
│
|
||||
│ WS 帧 relation:request
|
||||
│ (含 relationId / 我签发的 token / 我开放的能力 /
|
||||
│ 我的同步目录 / 我提议的目录名)
|
||||
▼
|
||||
主进程收到 → 直接弹窗
|
||||
(ApprovalDialog, kind='relation')
|
||||
- 显示对方要什么
|
||||
- 我开放什么 (可改勾选)
|
||||
- 我的本机同步目录 (可填)
|
||||
点"同意"
|
||||
│ WS 帧 relation:accept
|
||||
│ (含 B 签发的 token / B 开放的能力 / B 的同步目录)
|
||||
▼
|
||||
A: 关系落库 state='active'
|
||||
记 peerToken = B 签发的那个
|
||||
B: 关系落库 state='active'
|
||||
记 myToken = 自己签发的那个
|
||||
```
|
||||
|
||||
拒绝时 B 回 `relation:reject`, A 侧把 pending 关系**删掉** (不留残条)。
|
||||
发起方在等待期间的关系状态是 `pending`; 一旦 accept 到达就转 `active`。
|
||||
|
||||
已建立过关系的对端再发 `relation:request` 会被**直接回绝** (不发弹窗),
|
||||
避免重复弹窗刷屏。
|
||||
|
||||
### 3.3 WS 帧
|
||||
|
||||
| 帧 | 方向 | 载荷要点 |
|
||||
|---|---|---|
|
||||
| `relation:request` | 发起方 → 对方 | `relationId`, `token`(我签发的), `grants`, `syncLocalDir`, `syncRemotePath`, `peer`(我的设备信息) |
|
||||
| `relation:accept` | 对方 → 发起方 | `relationId`, `token`(我签发的), `grants`, `syncLocalDir`, `syncRemotePath`, `peer` |
|
||||
| `relation:reject` | 对方 → 发起方 | `relationId`, `reason` |
|
||||
|
||||
三个帧都在 `src/main/protocol.ts` 定义, 由 `ipc.ts` 的 `routeRelationFrame()` 分发。
|
||||
|
||||
### 3.4 令牌模型
|
||||
|
||||
| 令牌 | 谁持有 | 作用 |
|
||||
|---|---|---|
|
||||
| `myToken` | 对方 (我签发给它的) | 它用来访问**我的**网盘/同步区 |
|
||||
| `peerToken` | 我 (对方签发给我的) | 我用来访问**对方的**网盘/同步区 |
|
||||
| `driveToken` (旧) | ~~全局广播~~ | **废弃**, 不再出现在发现包里 |
|
||||
|
||||
- 每个关系两个令牌, 各自独立签发 (32 位 hex)。令牌比较用 `timingSafeEqual`
|
||||
(`relations.ts` 的 `safeEqual`)。
|
||||
- **吊销是单边的**: 我吊销关系 → 我的 `myToken` 被换掉 + `state='revoked'`,
|
||||
对方手里的旧令牌立刻访问不了我; 但我持有的对方的 `peerToken` 仍然可用,
|
||||
要等**对方也吊销**才失效。
|
||||
- **过渡期**: 老版本对端没有"关系"概念, 但本机缓存的旧 `drive.token` 仍然有效
|
||||
(`file-server.ts` 的 `authDrive` 里先试关系令牌, 失败再退回旧的全局 token)。
|
||||
这样可以渐进式升级, 不用一刀切。
|
||||
|
||||
### 3.5 授权粒度
|
||||
|
||||
关系上只有 `drive` 和 `sync` 两项, 而且**双方各自独立**:
|
||||
|
||||
- `myGrants` = 我开放给对方的能力
|
||||
- `peerGrants` = 对方开放给我的能力
|
||||
|
||||
网盘浏览和文件同步是**两套独立授权**:
|
||||
|
||||
- 只给 `sync` → 对方**完全不能**浏览我的网盘 (`scopeRoot='none'`, 直接 403 "未授权浏览网盘"),
|
||||
但可以通过 `/sync/*` 访问该关系专属的同步目录。
|
||||
- 只给 `drive` → 对方能浏览我的整个网盘共享目录, 但访问不了同步区。
|
||||
|
||||
授权可以在同步面板里改 (`relation:setGrants`), 改完立即生效。
|
||||
|
||||
---
|
||||
|
||||
## 4. 同步区: 独立于网盘的第二套空间
|
||||
|
||||
### 4.1 为什么改成独立同步区
|
||||
|
||||
早期设计稿是"在**对方的网盘**里开一个子目录当同步区"。实现时改成了**独立的同步区**, 原因:
|
||||
|
||||
1. **用户的同步目录不必待在网盘里**。网盘共享目录是"我愿意公开给人看的东西",
|
||||
而同步目录是"我自己的工作目录" (比如 `~/文档/工作`)。把工作目录塞进公开共享区,
|
||||
既不合逻辑也很别扭。
|
||||
2. **浏览和同步是两回事, 授权应该拆开**。让对方浏览我的网盘 ≠ 让对方把我的目录同步走。
|
||||
合成一个能力就没法表达"只同步不浏览"。
|
||||
3. **路径边界更清楚**。网盘的边界是"网盘根", 同步区的边界是"这个关系专属的同步目录",
|
||||
两个边界互不重叠, 穿越防护也只需要各自校验一次。
|
||||
|
||||
### 4.2 两套空间的对照
|
||||
|
||||
| | 网盘 | 同步区 |
|
||||
|---|---|---|
|
||||
| 路由前缀 | `/drive/*` | `/sync/*` |
|
||||
| 需要的授权 | `drive` | `sync` |
|
||||
| 路径根 | 网盘共享目录 (全局一个) | **该关系专属的同步目录** (`syncLocalDir`, 位置任意) |
|
||||
| 谁能访问 | 拿到 `drive` 的关系对端 | 拿到 `sync` 的关系对端 |
|
||||
| 用途 | 浏览 / 挑选下载 | 双向同步 |
|
||||
|
||||
同步区的服务端路径解析 (`file-server.ts` 的 `resolveSyncPath`) 严格限制在
|
||||
`getSyncScope(relationId)` 返回的目录内 —— 这个目录来自关系记录的 `syncLocalDir`,
|
||||
所以**同步目录可以是磁盘上任意一个文件夹**。
|
||||
|
||||
### 4.3 目录约定
|
||||
关系记录里保留两个目录字段 (对称, 不需要协商):
|
||||
|
||||
| 字段 | 含义 (我这侧视角) |
|
||||
|---|---|
|
||||
| `syncLocalDir` | **我的**同步目录 (绝对路径, 我这边的) |
|
||||
| `syncRemotePath` | 我在**对方**网盘里用的子目录 (提议值, 默认 `同步/<我的名字>`) |
|
||||
| `peerSyncRoot` | 对方在我网盘里用的子目录 (对方提议的) |
|
||||
|
||||
发起方提议自己的 `syncRemotePath`, 接受方可以改成自己的并给出自己的,
|
||||
于是双方各自算出:
|
||||
|
||||
```
|
||||
发起方: syncRemotePath = 自己提的 peerSyncRoot = 对方给的
|
||||
接受方: syncRemotePath = 自己给的 peerSyncRoot = 对方提的
|
||||
```
|
||||
|
||||
> 注意: 独立同步区方案落地后, **文件实际落在对方的 `syncLocalDir` 里**
|
||||
> (`/sync/file` 的路径是相对该目录的), `syncRemotePath` / `peerSyncRoot`
|
||||
> 目前只作为**双方协商与展示用的约定**保留在关系记录和帧里, 不再决定数据落点。
|
||||
> 见 §12 的"已知不一致"。
|
||||
|
||||
#### 4.3.1 一个顶层目录可以管多个聊天备份 (按关系分子目录)
|
||||
|
||||
顶层目录**可以**同时和多个设备同步 —— 每个关系在自己的**子目录**里, 物理上隔开:
|
||||
|
||||
```
|
||||
~/备份/MECHREVO/ <- 和 MECHREVO 同步的数据
|
||||
~/备份/lenovo/ <- 和 lenovo 同步的数据
|
||||
```
|
||||
|
||||
这样"统一目录多个聊天备份"就成立了, 而且两条数据不会互相覆盖:
|
||||
|
||||
- 同步区接口的路径**相对这个子目录**, 不是相对顶层目录。
|
||||
- 关系记录里的 `syncSubdir` 就是这个子目录名 (默认对方设备名, 重名加 id 前缀)。
|
||||
- 实际同步根 = `join(syncLocalDir, syncSubdir)`, 由 `RelationService.localRootOf()` 统一算,
|
||||
服务端和客户端都用它 —— **两边必须算成同一个**, 否则路径会错位。
|
||||
|
||||
> 踩过的坑: 登记本地文件时如果用顶层目录去扫, relPath 会带上子目录名 (比如 `MECHREVO/a.txt`),
|
||||
> 污染关系表, 对方会看到一个"凭空多出来的路径"。**扫描一律要用实际同步根**。
|
||||
|
||||
### 4.4 服务端接口
|
||||
|
||||
同步区走的是已有的文件服务器 (HTTP, 默认 47901), 请求都要带
|
||||
`token` + `peerId` + `relationId`:
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /sync/scan?token=&peerId=&relationId=&hash=1` | 递归列出该关系的同步目录 (相对路径 / 大小 / mtime / 可选 sha256) |
|
||||
| `GET /sync/file?token=&peerId=&relationId=&path=` | 下载一个文件 (`path` 相对同步目录根) |
|
||||
| `POST /sync/file?token=&peerId=&relationId=` | 上传。目标相对路径在 header `x-file-rel-path`; 带 `x-file-action: delete` 时执行**删除** |
|
||||
| `GET /sync/manifest?token=&peerId=&relationId=` | 拉取对方的文件关系表 (含墓碑和序号) |
|
||||
| `POST /sync/manifest?token=&peerId=&relationId=` | 把我的文件关系表推给对方 (**删除就是这样传播的**) |
|
||||
|
||||
鉴权: `authSync()` = 有效关系 + 关系里有 `sync` 能力, 否则 403。
|
||||
路径: `resolveSyncPath()` 把相对路径 resolve 到同步目录根, 越界 (含 `..`) 直接 400。
|
||||
|
||||
### 4.5 老对端降级
|
||||
|
||||
- 对方是旧版本 (没有 `/sync/*`): 扫描失败, 界面提示"对方需要升级到带同步盘的版本"。
|
||||
- 对方能扫但没有 `/sync/manifest` (404): 远端视图退化成"只有实际扫描结果",
|
||||
没有墓碑也没有序号 —— 此时删除无法可靠传播, 首次同步也没有基线, 退回 mtime 兜底判定。
|
||||
- 网盘浏览另有老通道: 老版本对端仍可用本机缓存的旧 `driveToken` 访问 `/drive/*`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 文件关系表 (manifest): 三方对比 + 墓碑 + seq
|
||||
|
||||
这是整个同步功能的**地基**。没有它就没法正确处理删除。
|
||||
|
||||
### 5.1 存储
|
||||
|
||||
`src/main/sync-manifest.ts`, 按 `relationId` 分开存:
|
||||
|
||||
```ts
|
||||
interface Manifest {
|
||||
relationId: string
|
||||
clock: number // 我的 Lamport 时钟
|
||||
remoteSeq: number // 水位线: 我已处理到对方的哪个序号
|
||||
entries: Record<string, ManifestEntry> // key = relPath
|
||||
}
|
||||
```
|
||||
|
||||
**按 relationId 分开是关键**: 一个本地目录同时跟多个关系同步时, 各自的基线 / 墓碑 /
|
||||
序号互不干扰, 不会串。
|
||||
|
||||
### 5.2 单条记录
|
||||
|
||||
```ts
|
||||
interface ManifestEntry {
|
||||
fileId: string // 稳定 id: 首次见到该路径时生成, 重命名后不变
|
||||
relPath: string // 相对同步目录的路径 (同时是 entries 的 key)
|
||||
|
||||
baseHash: string // 基线: 上次双方内容一致时的 hash (空 = 从来没同步过)
|
||||
baseSize: number
|
||||
baseMtime: number
|
||||
|
||||
myHash: string // 我这边最后一次观察到的内容
|
||||
mySize: number
|
||||
myMtime: number
|
||||
myState: 'present' | 'deleted' // 我这边的当前状态
|
||||
mySeq: number // 我这边最后一次变更的序号
|
||||
|
||||
peerHash: string // 对方最后一次广播过来的内容
|
||||
peerSize: number
|
||||
peerMtime: number
|
||||
peerState: 'present' | 'deleted' // 对方最后一次广播的状态
|
||||
peerSeq: number // 对方最后一次变更的序号
|
||||
|
||||
deletedAt: number // 墓碑时间 (任一侧删除的时间), 0 = 没有墓碑
|
||||
}
|
||||
```
|
||||
|
||||
一句话概括: **基线 (base) + 我 (my) + 对方 (peer)**, 三份信息各自带 hash 和序号,
|
||||
判定时三方对比。
|
||||
|
||||
### 5.3 为什么要有 seq (变更序号)
|
||||
|
||||
- **内容 hash 只能回答"一样不一样"**, 回答不了"谁更晚"。
|
||||
- 判定删除方向恰恰需要"谁更晚": 对方 seq 5 的删除 vs 我 seq 3 的修改 → 删除更晚, 删除赢。
|
||||
|
||||
实现用 **Lamport 时钟**:
|
||||
|
||||
```
|
||||
本地每变一次 (增/改/删/移动) → clock++
|
||||
收到对方的 manifest → clock = max(clock, 对方 seq)
|
||||
remoteSeq = max(remoteSeq, 对方 seq)
|
||||
```
|
||||
|
||||
于是本地时钟永远 ≥ 见过的对方序号, 两边不会因为时钟漂移而"分叉"。
|
||||
**删除也是一个 seq 事件**, 所以"删除"和"修改"可以用同一套规则比新旧。
|
||||
|
||||
水位线 (`remoteSeq` + 每条的 `peerSeq`) 同时防重复和乱序: 重复同步同一批事件不会出错。
|
||||
|
||||
#### 5.3.1 顺序有讲究: 先收消息, 再产生事件
|
||||
|
||||
Lamport 时钟要求"收到消息之后才能产生事件", 否则跨机器的 seq 不可比。代码里体现为**固定的三步**:
|
||||
|
||||
```
|
||||
1) advanceClock(manifest, 对方的条目) // 先把本地时钟推到 >= 对方当前 seq
|
||||
2) reconcileLocal(manifest, 本地文件) // 再产生本地事件 (增/改/删), 分配的 mySeq 一定更大
|
||||
3) mergePeer(manifest, 对方的条目) // 最后合并对方字段
|
||||
```
|
||||
|
||||
两个顺序陷阱 (都踩过):
|
||||
|
||||
- **不能把 `mergePeer` 提到 `reconcileLocal` 前面**。`mergePeer` 会为"对方有、我没见过"的路径建条目,
|
||||
新条目的 `myState` 只能是 `deleted` (意思是"我这边没有这个文件")。如果它先跑, `reconcileLocal`
|
||||
就看不到"本地本来有这个文件"的事实 → **本地的删除事件被吞掉** → 墓碑建不出来 → 删除传播不出去。
|
||||
- **不能把 `reconcileLocal` 提到 `advanceClock` 前面**。那样本地事件分到的 seq 可能小于对方已知的 seq,
|
||||
比较时"对方刚下载过这个文件"的 seq 反而比"我刚删掉它"的大 → **我的删除被误判成"对方复活了"** → 删除被撤销。
|
||||
|
||||
#### 5.3.2 本地文件必须先登记进关系表
|
||||
|
||||
关系表不能只记录"同步过程中见过的文件"。如果对方知道某个文件、我本地也有, 但我的关系表里还没有它,
|
||||
那么我本地把它删掉时, 关系表看不出这是"删除"还是"从来没过" → 墓碑建不出来 → **删除传播不出去**。
|
||||
|
||||
所以对方把关系表推过来时 (`applyPeerManifest`), 要先用 `reconcileLocal` 把本地实际有的文件登记进去,
|
||||
再合并对方字段。
|
||||
|
||||
### 5.4 墓碑 (tombstone): 删除为什么不直接抹掉
|
||||
|
||||
删除时**不删记录**, 只把状态标成 `deleted` 并记 `deletedAt`。直接抹掉会有两个致命问题:
|
||||
|
||||
**问题 A: 删除传不出去**
|
||||
|
||||
B 离线时 A 删了 `x.txt`。如果 A 直接抹掉记录, B 回来同步时:
|
||||
|
||||
- B 的 manifest 里有 `x.txt` (B 认为它存在)
|
||||
- A 的 manifest 里没有 → A 理解成"这是 B 新增的" → **把已删除的文件重新下载回来**
|
||||
- 删除被撤销了, 用户永远删不掉一个文件
|
||||
|
||||
有墓碑时: A 保留 `x.txt` 的记录并标 `state='deleted'`, B 同步拉到墓碑 → 删本地文件 →
|
||||
自己也记墓碑 → 删除成功传播。
|
||||
|
||||
**问题 B: "删除"会被"复活"**
|
||||
|
||||
A 删了 `x.txt` (有墓碑)。B 离线, 不知道这件事。B 的本地因为某种原因又出现了 `x.txt`
|
||||
(从备份恢复 / 手动放回), B 上传它 → A 收到:
|
||||
|
||||
- 如果 A 只看"对方有这个文件, 我没有" → A 当成新增接受 → **删除被静默撤销**
|
||||
- 有墓碑 + seq 比较: A 的墓碑 seq 更高 → A 拒绝接受, 保持删除状态 → 正确
|
||||
|
||||
### 5.5 墓碑优先规则
|
||||
|
||||
我这边有墓碑、对方声称文件还在时, **比 seq**:
|
||||
|
||||
| 情况 | 判定 | 动作 |
|
||||
|---|---|---|
|
||||
| 对方 seq > 我的墓碑 seq | 对方真的恢复了文件 | `download` (接受) |
|
||||
| 对方 seq ≤ 我的墓碑 seq, 且对方内容也变了 | 双方都动过 | `conflict` (不静默丢数据) |
|
||||
| 对方 seq ≤ 我的墓碑 seq, 对方内容没变 | 删除更晚, 删除赢 | `delete-remote` (删掉对方那份) |
|
||||
|
||||
### 5.6 墓碑保留多久
|
||||
|
||||
- 大品牌: Google Drive 回收站 30 天, OneDrive 30 天, Dropbox 30 天, Syncthing tombstone 30 天。
|
||||
- 本项目取 **90 天** (更保守, 覆盖长时间离线的设备)。常量 `TOMBSTONE_TTL_MS`。
|
||||
- 清理条件 (`pruneTombstones`): **超过 90 天** **且** **对方已经越过这个序号**
|
||||
(对方也知道这件事了, `peerState==='deleted'` 且 `peerMaxSeq > mySeq`)。
|
||||
- 对方一直没上线的, 墓碑一直留着 —— 这正是我们要的。
|
||||
|
||||
### 5.7 三方对比 (不是两方对比)
|
||||
|
||||
普通同步只比"本地 vs 远端"是不够的 —— 两边都改过的时候分不清谁赢。
|
||||
**三方对比** = 基线 (上次同步时两边一致的状态) + 本地现状 + 远端现状。
|
||||
|
||||
| 本地 | 远端 | 基线 | 判定 | 动作 |
|
||||
|---|---|---|---|---|
|
||||
| 有 | 有 | 一致 | 相同 | 无 |
|
||||
| 有 | 有 | 只有本地改 | 本地新 | upload |
|
||||
| 有 | 有 | 只有远端改 | 远端新 | download |
|
||||
| 有 | 有 | 都改过 | 冲突 | conflict |
|
||||
| 无 | 有 | 有, 且我没改 | 我删了 | delete-remote (传播删除) |
|
||||
| 无 | 有 | 有, 且对方改过 | 冲突 | conflict |
|
||||
| 无 | 有 | 无 | 对方新增 | download |
|
||||
| 有 | 无 | 有, 且我没改 | 对方删了 | delete-local (传播删除) |
|
||||
| 有 | 无 | 有, 且我改过 | 冲突 | conflict |
|
||||
| 有 | 无 | 无 | 本地新增 | upload |
|
||||
| 无 | 无 | 有 | 两边都删了 | 无 (墓碑到期可清理) |
|
||||
|
||||
**没有基线时 (首次同步)**: 退回 mtime 兜底 —— 差得远 (≥2s) 当单向, 接近当冲突。
|
||||
方向是"宁可多弹窗", 静默覆盖丢数据更糟。
|
||||
|
||||
### 5.8 冲突怎么办
|
||||
|
||||
两边都改过且内容不同:
|
||||
|
||||
- **绝不静默丢数据**。
|
||||
- **手动同步时**: 弹窗对比 (大小 / 修改时间 / hash), 用户选方向 (本地覆盖对方 / 对方覆盖本地 / 跳过)。
|
||||
这就是同步面板里的"冲突对比弹窗"。
|
||||
- **自动同步时**: 保留两份 —— 本地那份留 `x.txt`, 远端那份存成 `x (冲突 2026-10-20 17-30).txt`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 同步一轮的完整流程
|
||||
|
||||
```
|
||||
[本地] 扫描磁盘 → 实际文件集 (scanLocalDir)
|
||||
↓
|
||||
[本地] 对齐关系表 (reconcileLocal) —— 只改关系表, 不动磁盘:
|
||||
manifest 有 + 磁盘没了 → 删除事件 (seq++, 记墓碑) ← 关键: 区分"被删"和"从来没有"
|
||||
磁盘有 + manifest 没有 → 新增事件 (seq++)
|
||||
磁盘有 + hash 变了 → 修改事件 (seq++)
|
||||
manifest 是墓碑 + 磁盘有了 → 复活事件 (seq++) ← 用户手动放回来了
|
||||
消失的路径 + hash 相同的新路径 → 重命名 (保留 fileId, 不产生假删除)
|
||||
↓
|
||||
[远端] 组装对方视图 (buildPeerView):
|
||||
先取对方 manifest (带墓碑/序号) → 再用 /sync/scan 的实际扫描覆盖内容信息
|
||||
(实际存在但对方说删了 → 按"存在"处理, 序号沿用对方的, 避免误判)
|
||||
↓
|
||||
[合并] mergePeer: 只接受 seq > peerSeq 的事件; clock/remoteSeq 推到 max
|
||||
↓
|
||||
[判定] planSync: 三方对比 → 一份可执行的计划 (§5.7 的表)
|
||||
↓
|
||||
[执行] 用户勾选后 applySyncItems:
|
||||
upload → POST /sync/file (x-file-rel-path)
|
||||
download → GET /sync/file?path=
|
||||
delete-local → 删本地文件 + 记墓碑
|
||||
delete-remote → POST /sync/file (x-file-action: delete)
|
||||
成功后更新基线 (baseHash = 同步后的内容 hash) —— 下次就能精确判断"谁改了"
|
||||
↓
|
||||
[清理] pruneTombstones (90 天 且 对方已越过该序号)
|
||||
↓
|
||||
[推送] POST /sync/manifest 把我的关系表推给对方
|
||||
→ 对方那边 applyPeerManifest() 应用 (删除就是这样跨机器传播的)
|
||||
```
|
||||
|
||||
**幂等性**: 重复同步同一批事件不会出错 (水位线过滤)。
|
||||
**乱序容忍**: seq 单调, 小的先到先忽略。
|
||||
|
||||
---
|
||||
|
||||
## 7. 删除传播的落点
|
||||
|
||||
`RelationService.applyPeerManifest()` (`src/main/relation-service.ts`) 是对方推来关系表时的处理点:
|
||||
|
||||
```
|
||||
对方说"这个文件我删了" (peerState='deleted'):
|
||||
我这边没有 → 什么都不做 (幂等)
|
||||
我这边有 + 从没同步过 → 不动 (那本来就是两个各自独立的文件, 不是同一个)
|
||||
我这边有 + 同步过 + 我改过 → 冲突, 上报给界面, 不静默丢数据
|
||||
我这边有 + 同步过 + 我没改过 → 删本地文件 + 记墓碑
|
||||
```
|
||||
|
||||
删除动作会写审计 (`sync.delete.propagated`), 并给渲染层发 `sync:applied` 事件。
|
||||
|
||||
---
|
||||
|
||||
## 8. 自动同步
|
||||
|
||||
关系上的 `syncEnabled` 开关就是入口 (同步面板里一个开关, 勾上即生效)。
|
||||
实现是 `src/main/auto-sync.ts` 的 `AutoSync` 类, 在 `ipc.ts` 的 `bindNetworkContext()`
|
||||
里创建并 `start()`。
|
||||
|
||||
### 8.1 触发
|
||||
|
||||
| 触发 | 行为 |
|
||||
|---|---|
|
||||
| 定时轮询 | 默认 60s 一轮, 只跑 `state='active'` + `syncEnabled` + 配好了同步目录的关系 |
|
||||
| 对方上线 | WS 状态转在线时立刻 `trigger()` 试一次, 不用等下一个轮询周期 |
|
||||
| 用户刚打开开关 | `relation:setSync` 里立刻 `trigger(relationId)` 跑一次 |
|
||||
| 失败重试 | 指数退避 1min → 2min → 4min … 上限 5min |
|
||||
|
||||
### 8.2 一轮自动同步做什么
|
||||
|
||||
```
|
||||
scanSync() → 拿到变更列表
|
||||
├─ conflict → **跳过**, 只记数并提示
|
||||
├─ upload / download / delete-local / delete-remote → 直接执行
|
||||
└─ same → 跳过
|
||||
执行完 → 写回关系的 lastSyncAt / lastError, 并给界面发 sync:auto 事件
|
||||
```
|
||||
|
||||
- **冲突永远留给用户手动选方向** (手动同步时弹窗对比)。自动同步不替用户决定丢哪一份,
|
||||
绝不静默覆盖任何一方。
|
||||
- 同一关系有防重入 (正在跑就直接跳过这一轮)。
|
||||
|
||||
### 8.3 不做的
|
||||
|
||||
- 不做多目录对多目录的一对多同步。
|
||||
- **没有**本地目录变化的事件监听 (`fs.watch`): 目前只有 60s 轮询 + 对方上线触发。
|
||||
|
||||
---
|
||||
|
||||
## 9. 数据落地
|
||||
|
||||
关系和关系表都存 **JSON 文件 (原子写)**, 不是数据库:
|
||||
|
||||
| 文件 | 位置 | 内容 |
|
||||
|---|---|---|
|
||||
| `relations.json` | `paths.appData/relations.json` | 全部关系记录 |
|
||||
| `sync-manifest.json` | `paths.appData/sync-manifest.json` | 全部关系的文件关系表 (key = relationId) |
|
||||
| `drive.token` | `paths.appData/drive.token` | 旧的全局网盘 token (只作老对端兼容) |
|
||||
|
||||
`paths.appData` = `app.getPath('userData')/data`。
|
||||
|
||||
**为什么用 JSON 文件而不是 DB**:
|
||||
|
||||
1. **关系是"少量、需要原子整体替换"的状态**, 不是需要 SQL 聚合 / 索引 / 事务的数据。
|
||||
几十条关系, 一次性读写完全够用。
|
||||
2. **原子写很简单**: 先写 `xxx.json.tmp-<pid>` 再 `rename` 覆盖, 崩溃不会写坏文件。
|
||||
文件损坏时还会自动备份成 `xxx.json.broken-<时间戳>` 并从空状态启动 ——
|
||||
应用不能因为一个坏文件起不来。
|
||||
3. **不用做 schema 迁移**。关系结构以后还会演进, JSON 加个字段就行;
|
||||
DB 一旦改了表结构, 老用户升级就得写迁移脚本。
|
||||
4. **单测可以直接指定临时文件路径**, 不碰用户真实数据。
|
||||
5. 有先例: 远程操作白名单 `remoteAllowPeers` 也存在 `settings.json` 里, 不是 DB。
|
||||
|
||||
`devices` 表仍然保留 (设备缓存, 含历史 `drive_token`), 但新关系不再用它。
|
||||
|
||||
---
|
||||
|
||||
## 10. 安全要点
|
||||
|
||||
| 要点 | 做法 |
|
||||
|---|---|
|
||||
| 网盘 / 同步区鉴权 | `token` + `peerId` → 查关系 → 必须有 `drive` / `sync` 授权 → 否则 403。令牌用 `timingSafeEqual` 比较 |
|
||||
| 网盘 token 不再广播 | 发现包不带 `driveToken`; 同网段任何进程拿不到网盘凭据 |
|
||||
| 目录穿越 | 上传 / 下载 / 删除的路径都 resolve 后校验前缀, 必须严格落在**各自的根目录**内 (网盘根 / 该关系的同步目录), `..` 一律拒绝 |
|
||||
| 令牌不进渲染层 | IPC 返回关系时用 `publicRelation()` 剥掉 `myToken` / `peerToken`, 界面拿不到凭据 |
|
||||
| 吊销立即生效 | 吊销时换掉 `myToken` 并置 `state='revoked'`, 对方手里旧令牌立刻失效; 同时清掉该关系的关系表 |
|
||||
| 墓碑不泄密 | 墓碑只记路径和 hash, 不含内容 |
|
||||
| 审计 | 建关系 / 接受 / 拒绝 / 吊销 (`relation.*`)、手动同步 (`sync.apply`)、删除传播 (`sync.delete.propagated`) 都写 `audit_log` |
|
||||
|
||||
---
|
||||
|
||||
## 11. 当前进度
|
||||
|
||||
| 阶段 | 内容 | 状态 |
|
||||
|---|---|---|
|
||||
| P0 | 关系层: 关系模型 + per-peer 令牌 + 网盘按关系授权 (`relations.ts`, `relation-service.ts`) | ✅ 已完成 |
|
||||
| P0 | 同步层: manifest + 墓碑 + Lamport seq + 三方对比 (`sync-manifest.ts`, `sync.ts`) | ✅ 已完成 |
|
||||
| P0 | 同步区接口: `/sync/scan` `/sync/file` `/sync/manifest` + 路径防护 (`file-server.ts`) | ✅ 已完成 |
|
||||
| P1 | 弹窗 UI: `ApprovalDialog.vue` 的 relation 分支 + `SyncPanel.vue` (发起/接受/扫描/同步/冲突弹窗) | ✅ 已完成 |
|
||||
| P1 | 自动同步: `AutoSync` (60s 轮询 + 上线触发 + 冲突跳过) (`auto-sync.ts`) | ✅ 已完成 |
|
||||
| P1 | 自测用例 (关系建立、删除传播、墓碑、冲突) | ⬜ 待做 |
|
||||
| P2 | 关系管理页 (查看 / 吊销 / 改权限) —— 目前只在同步面板里 | ⬜ 待做 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 已知不一致 (实现与设计有出入, 未改代码)
|
||||
|
||||
写这份文档时对出来的几处出入, **只记录, 没有改代码**:
|
||||
|
||||
1. `scripts/selftest/main.ts` 里新加的 Y1–Y10 用例 import 了 `compareSync`,
|
||||
但 `src/main/sync.ts` **没有这个导出** (两方对比已被 `sync-manifest.ts` 的
|
||||
`planSync` 三方对比取代)。跑 `npm run selftest -- sync` 会直接失败。
|
||||
用例本身也还是按旧的"网盘子目录 + driveToken"写的, 需要按新方案重写。
|
||||
2. `relations.ts` 的 `Relation` 上有 `localSeq` / `remoteSeq` 两个序号字段,
|
||||
但**实际没有任何地方更新它们** —— 真正的时钟在 manifest 的 `clock` / `remoteSeq`
|
||||
里 (`lastSyncAt` / `lastError` 由自动同步在写)。这两个字段目前是死字段。
|
||||
3. `syncRemotePath` / `peerSyncRoot` 这套"网盘子目录"约定还留在关系记录和帧里,
|
||||
但独立同步区落地后 `sync.ts` 的 `remoteTarget()` 直接返回 `relPath`,
|
||||
文件实际落在对方的 `syncLocalDir` 里 —— 这两个字段目前只是协商/展示用的残留。
|
||||
4. 接受方在弹窗里不填同步目录时, `syncLocalDir` 会留空, 之后对方的 `/sync/scan`
|
||||
会返回 400 "同步目录未设置" —— 需要在同步面板里补填才能同步。
|
||||
5. `src/main/sync-state.ts` (`FileSyncStateStore`) 是一套**旧的**基线存储
|
||||
(`localHash` / `remoteHash`), 新的 manifest 已经自带基线, 这个文件看起来没被接进新链路。
|
||||
6. **自动同步跑的那一轮不写审计**。`sync.apply` 是在 `ipc.ts` 的 `sync:apply` IPC 里记的,
|
||||
而 `auto-sync.ts` 直接调 `applySyncItems()`, 绕过了那层 —— 自动同步的每一次自动上传 /
|
||||
下载 / 删除传播都没有审计记录 (删除传播那条 `sync.delete.propagated` 是对端记的)。
|
||||
|
||||
---
|
||||
|
||||
## 13. 相关文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `src/main/relations.ts` | 关系模型 + `RelationStore` (JSON 原子写) + 令牌生成 / 比较 + 网盘授权判定 |
|
||||
| `src/main/relation-service.ts` | 建 / 接受 / 拒绝 / 吊销关系, `applyPeerManifest()` (删除传播落点) |
|
||||
| `src/main/sync-manifest.ts` | 文件关系表: `reconcileLocal` / `mergePeer` / `planSync` / `updateBaseline` / `pruneTombstones` |
|
||||
| `src/main/sync.ts` | 同步引擎: 本地扫描、远端视图组装、上传 / 下载 / 删除、manifest 推送 |
|
||||
| `src/main/auto-sync.ts` | 自动同步: 轮询 / 上线触发 / 退避重试 / 冲突跳过 |
|
||||
| `src/main/file-server.ts` | `/drive/*` 与 `/sync/*` 路由 + 鉴权 + 目录穿越防护 |
|
||||
| `src/main/remote/approval.ts` | 授权闸门队列 (关系弹窗复用它, `kind='relation'`) |
|
||||
| `src/main/ipc.ts` | 关系 / 同步的 IPC 通道, `publicRelation()` (剥令牌) |
|
||||
| `src/renderer/src/components/remote/SyncPanel.vue` | 同步面板: 发起关系 / 扫描变更 / 勾选同步 / 冲突弹窗 |
|
||||
| `src/renderer/src/components/remote/ApprovalDialog.vue` | 授权弹窗 (含 relation 分支) |
|
||||
Reference in New Issue
Block a user