Files
LocalNetMsg/docs/relation-and-sync.md
T
xiaoshuai 4fc86edd4e 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 / 三方对比判定表 /
安全要点 / 已知不一致。
2026-10-20 19:30:25 +08:00

602 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 关系模型 + 同步区设计
> 目标: 把"发现"和"信任"彻底分开。发现只是知道对方存在; **关系**必须由用户显式建立;
> **同步区**在关系之上, 靠持久化的文件关系表 (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 分支) |