diff --git a/warpgate-prd-v3.md b/warpgate-prd-v3.md
index 65381be..80943c3 100644
--- a/warpgate-prd-v3.md
+++ b/warpgate-prd-v3.md
@@ -42,28 +42,36 @@
### 3.1 整体架构
-```
- 局域网 (高速) 公网 / Tailscale (低速)
- ┌──────────────────────────────┐ ┌──────────────────────┐
- │ │ │ │
- ┌──────────┐ SMB │ ┌─────────────┐ │ SFTP │ ┌────────────────┐ │
- │ Lightroom│──────┼─▶│ Samba Server│ │ │ │ │ │
- │ macOS │ │ └──────┬──────┘ │ │ │ 任意品牌 NAS │ │
- └──────────┘ │ │ │ │ │ (SFTP) │ │
- │ ▼ │ │ │ │ │
- ┌──────────┐ NFS │ ┌─────────────────────────┐ │ ┌────────┐│ └────────────────┘ │
- │ Linux │──────┼─▶│ │─┼▶│Tailscal││ │
- │ 客户端 │ │ │ rclone VFS mount │ │ │/WireGrd││ │
- └──────────┘ │ │ + Write-back Controller│ │ └────────┘│ │
- │ │ │ │ │ │
- ┌──────────┐WebDAV│ └────────────┬────────────┘ │ │ │
- │ iPad │──────┼─▶ │ │ │ │
- │ 移动端 │ │ ┌────────────▼────────────┐ │ │ │
- └──────────┘ │ │ SSD 缓存 + 元数据 DB │ │ │ │
- │ │ (btrfs/ZFS) │ │ │ │
- │ └─────────────────────────┘ │ │ │
- │ Linux Proxy │ │ │
- └──────────────────────────────┘ └──────────────────────┘
+```mermaid
+graph LR
+ subgraph clients ["客户端设备"]
+ LR["Lightroom
macOS"]
+ Linux["Linux
客户端"]
+ iPad["iPad
移动端"]
+ end
+
+ subgraph proxy ["Linux Proxy(局域网·高速)"]
+ Samba["Samba Server"]
+ NFS_S["NFS Server"]
+ WebDAV_S["WebDAV Server"]
+ VFS["rclone VFS mount
+ Write-back Controller"]
+ SSD["SSD 缓存 + 元数据 DB
(btrfs/ZFS)"]
+ Samba --> VFS
+ NFS_S --> VFS
+ WebDAV_S --> VFS
+ VFS --> SSD
+ end
+
+ subgraph remote ["远程(公网 / Tailscale·低速)"]
+ TS["Tailscale /
WireGuard"]
+ NAS["任意品牌 NAS
(SFTP)"]
+ TS --- NAS
+ end
+
+ LR -- "SMB" --> Samba
+ Linux -- "NFS" --> NFS_S
+ iPad -- "WebDAV" --> WebDAV_S
+ VFS -- "SFTP" --> TS
```
### 3.2 协议选择说明
@@ -83,10 +91,12 @@
**设计决策**:所有对外协议服务共享同一个 rclone FUSE 挂载点。缓存层只有一份,不会因为多协议而重复缓存。
-```
- SMB Server ───┐
- NFS Server ───┼──▶ /mnt/nas-photos (rclone FUSE mount) ──▶ SSD 缓存
- WebDAV ───┘ 唯一缓存层
+```mermaid
+graph LR
+ SMB["SMB Server"] --> FUSE["/mnt/nas-photos
(rclone FUSE mount)"]
+ NFS["NFS Server"] --> FUSE
+ WebDAV --> FUSE
+ FUSE --> SSD["SSD 缓存
唯一缓存层"]
```
**注意事项**:SMB 和 NFS 的文件锁机制不同,同一文件不建议多协议同时写入。产品层面通过文档告知用户"多协议是为不同设备类型服务,非同时并发写同一文件"。
@@ -190,13 +200,16 @@ Time Machine 使用 sparsebundle 格式(一个目录包含数千个 8MB band
- TM 备份持续 10-30 分钟,持续写入不同 band 文件,通用计时器会不断重置
- 每次 TM 备份只修改部分 band 文件,不需要传输整个 sparsebundle
+```mermaid
+flowchart LR
+ TM["macOS Time Machine
写入 sparsebundle"] --> Monitor["① 会话结束检测
连续 5min 无新写入"]
+ Monitor --> Sync["② band 文件级增量同步
rsync/rclone 只传 mtime 变化的 band"]
+ Sync --> NAS["③ SFTP 直传到
TIMEMACHINE_PATH
(NAS 独立目录)"]
+
+ style NAS fill:#e8f5e9
```
-TM 回写策略:
- ① TM 备份会话结束检测:监控 sparsebundle 目录,连续 5 分钟无新写入 → 视为会话结束
- ② band 文件级增量同步:rsync/rclone sync 只传输 mtime 变化的 band 文件到 NAS
- ③ 目标路径:TIMEMACHINE_PATH(NAS 上独立目录,如 /volume1/timemachine/)
- ④ 不走通用 Write-back Controller 的三时间戳冲突检测(TM 数据只有一个写入源)
-```
+
+> **注**:④ TM 回写不走通用 Write-back Controller 的三时间戳冲突检测(TM 数据只有一个写入源)。
**TIMEMACHINE_PATH 与 NAS_REMOTE_PATH 的关系**:两者是 NAS 上的不同目录,互不关联。NAS_REMOTE_PATH 是用户的照片/文件目录(如 `/volume1/photos`),TIMEMACHINE_PATH 是 TM 备份专用目录(如 `/volume1/timemachine`)。TM 回写通过独立的 SFTP 连接直接传输到 TIMEMACHINE_PATH,不经过 rclone VFS。
@@ -396,61 +409,43 @@ remote_mtime ── 回写/读取时实时查询远程的当前 mtime
当 Write-back Controller 准备回写一个脏文件时,先通过 SFTP stat 查询远程 mtime,然后按以下矩阵决策:
-```
- 远程没变 远程也改了 远程被删了
- (remote == origin) (remote != origin) (文件不存在)
- ┌────────────────────┬───────────────────────┬──────────────────┐
- │ │ │ │
- │ ✅ 正常回写 │ ⚠️ 比较 mtime │ ⚠️ 不回写 │
- │ │ │ │
- 本地改过 │ 远程自上次同步后 │ cache > remote: │ 尊重远程删除 │
- (dirty) │ 没人动过 │ → 本地更新,回写 │ 本地脏文件移到 │
- │ 安全回写 │ remote > cache: │ conflict 目录 │
- │ 更新 origin_mtime │ → 远程更新,远程胜 │ 通知用户 │
- │ │ → 拉远程新版本 │ │
- │ │ → 本地存冲突副本 │ │
- │ │ 相等: │ │
- │ │ → 内容可能不同 │ │
- │ │ → 本地胜(写入者优先)│ │
- └────────────────────┴───────────────────────┴──────────────────┘
+```mermaid
+flowchart TD
+ Start["Write-back Controller
准备回写脏文件"] --> Check["SFTP stat 查远程 mtime"]
+ Check --> NoChange{"remote == origin?
远程没变"}
+ Check --> Changed{"remote != origin?
远程也改了"}
+ Check --> Deleted{"远程文件不存在?
远程被删了"}
+
+ NoChange -->|"✅ 安全回写"| WB["回写到远程
更新 origin_mtime"]
+
+ Changed --> CmpMtime{"比较 mtime"}
+ CmpMtime -->|"cache > remote
本地更新"| WB2["本地胜,回写"]
+ CmpMtime -->|"remote > cache
远程更新"| Conflict1["远程胜
拉远程新版本
本地存冲突副本"]
+ CmpMtime -->|"mtime 相等
内容可能不同"| LocalWins["本地胜
(写入者优先)"]
+
+ Deleted -->|"⚠️ 不回写"| Respect["尊重远程删除
脏文件移到 conflict/
通知用户"]
```
### 5.4b SD 卡导入文件的回写决策(补充)
从 SD 卡导入的文件,本质上等同于「本地新创建的脏文件」。在 metadata.db 的 cache_files 表中,`origin_mtime = NULL` 表示此文件从未存在于远程 NAS。
-```
-origin_mtime = NULL(SD 卡导入的新文件)
- │
- ├── SFTP stat 查远程:文件不存在
- │ → 直接上传(新建),更新 origin_mtime,state → clean
- │
- ├── SFTP stat 查远程:文件已存在(说明之前某次导入已成功上传,或远程碰巧有同名文件)
- │ → 比较 cache_mtime vs remote_mtime
- │ → 按 5.4 正常冲突逻辑处理
- │
- └── 上传失败
- → 重试(指数退避),保持 dirty 状态
+```mermaid
+flowchart TD
+ Start["origin_mtime = NULL
SD 卡导入的新文件"] --> Stat["SFTP stat 查远程"]
+ Stat -->|"文件不存在"| Upload["直接上传(新建)
更新 origin_mtime
state → clean"]
+ Stat -->|"文件已存在
(之前导入已上传 / 远程碰巧同名)"| Compare["比较 cache_mtime vs remote_mtime
按 5.4 正常冲突逻辑处理"]
+ Stat -->|"上传失败"| Retry["重试(指数退避)
保持 dirty 状态"]
```
### 5.5 读取时的缓存验证
当应用读取一个已缓存的文件时:
-```
- 远程没变 远程也改了 远程被删了
- (remote == origin) (remote != origin) (文件不存在)
- ┌────────────────────┬───────────────────────┬──────────────────┐
- 干净缓存 │ │ │ │
- (clean) │ ✅ 直接用缓存 │ 🔄 拉新版本 │ 🗑️ 删除本地缓存 │
- │ │ 更新 origin_mtime │ 返回文件不存在 │
- ├────────────────────┼───────────────────────┼──────────────────┤
- 本地改过 │ │ │ │
- (dirty) │ ✅ 用本地版本 │ ⚠️ 标记冲突 │ ⚠️ 用本地版本 │
- │ 等待回写 │ 两边版本都保留 │ 但标记为冲突 │
- │ │ 通知用户 │ 通知用户 │
- └────────────────────┴───────────────────────┴──────────────────┘
-```
+| | 远程没变 (remote == origin) | 远程也改了 (remote != origin) | 远程被删了 (文件不存在) |
+|---|---|---|---|
+| **干净缓存 (clean)** | ✅ 直接用缓存 | 🔄 拉新版本,更新 origin_mtime | 🗑️ 删除本地缓存,返回文件不存在 |
+| **本地改过 (dirty)** | ✅ 用本地版本,等待回写 | ⚠️ 标记冲突,两边版本都保留,通知用户 | ⚠️ 用本地版本,但标记为冲突,通知用户 |
**注意**:读取时不是每次都查远程 mtime(那样太慢)。远程 mtime 信息由后台轮询线程定期更新(见第六章)。热路径上读取命中缓存直接返回,只有当轮询发现 mtime 变化时才触发重新验证。
@@ -458,133 +453,69 @@ origin_mtime = NULL(SD 卡导入的新文件)
#### 场景 1:Cache 关机几天,NAS 上文件被修改
-```
-Day 1: Cache 缓存了 photo.cr3 (origin_mtime = Day1, 状态 clean)
-Day 1: Cache 关机
-
-Day 3: 有人在 NAS 上修改了 photo.cr3 (NAS mtime → Day3)
-
-Day 5: Cache 开机
- │
- ▼
- rclone 启动,后台轮询线程开始工作
- │
- ▼
- 轮询发现 photo.cr3 的 remote_mtime(Day3) != origin_mtime(Day1)
- │
- ▼
- 文件是 clean 的(本地没改过)
- │
- ▼
- 标记本地缓存失效
- 下次应用访问时拉新版本,更新 origin_mtime = Day3
- │
- ▼
- ✅ 结果:用户读到 NAS 上的最新版本,符合预期
+```mermaid
+flowchart TD
+ D1["Day 1: Cache 缓存 photo.cr3
(origin_mtime=Day1, clean)"] --> Off["Day 1: Cache 关机"]
+ Off --> D3["Day 3: NAS 上 photo.cr3 被修改
(NAS mtime → Day3)"]
+ D3 --> D5["Day 5: Cache 开机
rclone 启动,轮询线程开始"]
+ D5 --> Detect["轮询发现 remote_mtime(Day3)
!= origin_mtime(Day1)"]
+ Detect --> Clean["文件是 clean(本地没改过)"]
+ Clean --> Invalidate["标记缓存失效
下次访问时拉新版本"]
+ Invalidate --> Result["✅ 用户读到 NAS 最新版本"]
```
#### 场景 2:Cache 关机几天,本地有脏数据,NAS 也被更新
-```
-Day 1: 用户在 Cache 上修改了 photo.cr3
- (origin_mtime = Day0, cache_mtime = Day1, 状态 dirty)
-Day 1: 回写还没执行,Cache 关机(电池保护数据落盘)
-
-Day 3: 家人在 NAS 上也修改了 photo.cr3 (NAS mtime → Day3)
-
-Day 5: Cache 开机,rclone 恢复,发现本地有脏文件
- │
- ▼
- Write-back Controller 准备回写 photo.cr3
- │
- ▼
- 回写前先 SFTP stat 查远程:remote_mtime = Day3
- │
- ▼
- remote_mtime(Day3) != origin_mtime(Day0) → 远程被改过!
- │
- ▼
- 比较 cache_mtime(Day1) vs remote_mtime(Day3)
- │
- ▼
- Day3 > Day1 → 远程版本更新 → 远程胜
- │
- ├── 本地脏版本 → 移到 /conflict/photo.cr3.local-20260216-Day1
- ├── 拉取远程新版本覆盖本地缓存
- ├── 更新 origin_mtime = Day3, 状态 → clean
- └── 通知用户:"photo.cr3 存在冲突,远程版本更新已采用,本地修改已保存到冲突目录"
- │
- ▼
- ✅ 结果:不会丢数据,两个版本都保留,用户自行决定
+```mermaid
+flowchart TD
+ D1["Day 1: 用户修改 photo.cr3
(origin=Day0, cache=Day1, dirty)"]
+ D1 --> Off["Day 1: 回写未执行,Cache 关机
(电池保护数据落盘)"]
+ Off --> D3["Day 3: 家人在 NAS 修改 photo.cr3
(NAS mtime → Day3)"]
+ D3 --> D5["Day 5: Cache 开机
发现本地有脏文件"]
+ D5 --> WB["Write-back Controller 准备回写"]
+ WB --> Stat["SFTP stat 查远程:
remote_mtime = Day3"]
+ Stat --> Conflict["remote(Day3) != origin(Day0)
→ 远程被改过!"]
+ Conflict --> Compare["cache(Day1) vs remote(Day3)
Day3 > Day1 → 远程胜"]
+ Compare --> Actions["本地脏版本 → conflict/
拉远程新版本覆盖缓存
更新 origin=Day3, clean
通知用户"]
+ Actions --> Result["✅ 不丢数据,两版本都保留"]
```
#### 场景 3:NAS 删了文件,Cache 上有脏数据
-```
-Day 1: 用户在 Cache 上修改了 photo.cr3 (dirty)
-Day 1: Cache 关机
-
-Day 3: 有人在 NAS 上删了 photo.cr3
-
-Day 5: Cache 开机,准备回写脏文件
- │
- ▼
- Write-back Controller 查远程:photo.cr3 不存在
- │
- ▼
- 远程文件被删 + 本地有未回写修改 → 冲突
- │
- ├── ❌ 不回写(尊重远程删除决定)
- ├── 本地脏版本 → 移到 /conflict/photo.cr3.local-20260216
- └── 通知用户:"photo.cr3 已在远程被删除,本地修改已保存到冲突目录"
- │
- ▼
- ✅ 结果:不会把已删文件复活回 NAS,用户可从冲突目录恢复
+```mermaid
+flowchart TD
+ D1["Day 1: 用户修改 photo.cr3 (dirty)"] --> Off["Day 1: Cache 关机"]
+ Off --> D3["Day 3: NAS 上 photo.cr3 被删除"]
+ D3 --> D5["Day 5: Cache 开机,准备回写"]
+ D5 --> Stat["Write-back Controller 查远程:
photo.cr3 不存在"]
+ Stat --> Conflict["远程已删 + 本地有脏数据 → 冲突"]
+ Conflict --> Actions["❌ 不回写(尊重远程删除)
脏版本 → conflict/
通知用户"]
+ Actions --> Result["✅ 不会复活已删文件
用户可从 conflict/ 恢复"]
```
#### 场景 4:NAS 删了文件,Cache 上是 clean 的
-```
-Day 1: Cache 缓存了 photo.cr3 (clean)
-
-Day 3: NAS 上删了 photo.cr3
-
-Day 3+: 后台轮询检测到远程目录变化
- │
- ▼
- photo.cr3 远程不存在 + 本地是 clean → 无争议
- │
- ▼
- 直接删除本地缓存
- │
- ▼
- ✅ 结果:本地缓存与远程保持一致
+```mermaid
+flowchart TD
+ D1["Day 1: Cache 缓存 photo.cr3 (clean)"]
+ D1 --> D3["Day 3: NAS 上删除 photo.cr3"]
+ D3 --> Poll["轮询检测到远程目录变化"]
+ Poll --> Check["photo.cr3 远程不存在
+ 本地是 clean → 无争议"]
+ Check --> Del["直接删除本地缓存"]
+ Del --> Result["✅ 本地缓存与远程保持一致"]
```
#### 场景 5:Cache 在外编辑文件,网络正常,NAS 无变化(最常见 happy path)
-```
-用户在酒店打开 Lightroom 修图
- │
- ▼
- 首次打开 photo.cr3 → 缓存未命中 → 从远程下载 → 缓存到 SSD
- (origin_mtime = T0, cache_mtime = T0, 状态 clean)
- │
- ▼
- 用户编辑并保存
- (cache_mtime = T1, 状态 dirty)
- │
- ▼
- 60 秒后 Write-back Controller 触发
- │
- ▼
- 查远程 mtime:remote_mtime == origin_mtime(T0) → 远程没变
- │
- ▼
- 正常回写,更新 origin_mtime = T1,状态 → clean
- │
- ▼
- ✅ 结果:编辑秒存本地,后台静默同步,用户无感
+```mermaid
+flowchart TD
+ Open["用户在酒店打开 Lightroom 修图"]
+ Open --> Miss["首次打开 photo.cr3
缓存未命中 → 远程下载 → 缓存到 SSD
(origin=T0, cache=T0, clean)"]
+ Miss --> Edit["用户编辑并保存
(cache=T1, dirty)"]
+ Edit --> Timer["60s 后 Write-back Controller 触发"]
+ Timer --> Check["查远程: remote == origin(T0)
→ 远程没变"]
+ Check --> WB["正常回写
更新 origin=T1, clean"]
+ WB --> Result["✅ 编辑秒存本地
后台静默同步,用户无感"]
```
### 5.7 元数据持久化(metadata DB)
@@ -773,34 +704,47 @@ CREATE INDEX idx_original_path ON import_history(original_path, file_size);
#### 5.7.4 四张表的关系与数据规模
-```
-┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
-│ dir_snapshots │ │ dir_file_list │ │ cache_files │
-│ (目录级) │ │ (v1.5+, 可选) │ │ (文件级) │
-│ │ │ │ │ │
-│ /2026/02/ │────▶│ /2026/02/ │ │ │
-│ remote_mtime │ │ IMG_0001.cr3 │────▶│ /2026/02/ │
-│ last_polled │ │ IMG_0002.cr3 │ │ IMG_0001.cr3 │
-│ last_accessed │ │ IMG_0003.cr3 │ │ origin_mtime │
-│ │ │ ... │ │ cache_mtime │
-│ │ │ (全部远程文件) │ │ state=dirty │
-└──────────────────┘ └──────────────────┘ └──────────────────┘
- 每个关心的目录 该目录下远程全部文件 只有被缓存过的文件
- 1 行记录 可能 200 行 可能 3 行
+```mermaid
+erDiagram
+ dir_snapshots ||--o{ dir_file_list : "1:N (v1.5+)"
+ dir_file_list ||--o| cache_files : "仅缓存过的文件"
+ dir_snapshots ||--o{ cache_files : "1:N"
-┌──────────────────┐
-│ import_history │ ← 独立于缓存生命周期,永久保留
-│ (导入记录) │
-│ │
-│ IMG_0001.cr3 │
-│ checksum=abc.. │
-│ imported_at │
-│ writeback_at │
-└──────────────────┘
- 每次 SD 卡导入的文件
- 持续累积
+ dir_snapshots {
+ TEXT dir_path PK "如 /2026/02/"
+ INTEGER remote_mtime
+ INTEGER last_polled
+ INTEGER last_accessed
+ INTEGER cached_count
+ }
+
+ dir_file_list {
+ TEXT dir_path FK "v1.5+ 可选"
+ TEXT file_name "该目录下全部远程文件"
+ INTEGER remote_mtime
+ INTEGER remote_size
+ }
+
+ cache_files {
+ TEXT path PK "只有缓存过的文件"
+ TEXT dir_path FK
+ INTEGER origin_mtime "NULL=SD导入"
+ INTEGER cache_mtime
+ TEXT state "clean/dirty/conflict"
+ TEXT source "remote/ingest"
+ }
+
+ import_history {
+ INTEGER id PK "独立于缓存生命周期"
+ TEXT original_path
+ TEXT target_path
+ TEXT checksum "持续累积,永久保留"
+ TEXT state "imported/writeback_done/failed"
+ }
```
+数据规模参考:dir_snapshots 每个关心的目录 1 行,dir_file_list 可能 200 行/目录,cache_files 仅缓存过的文件(可能 3 行/目录),import_history 持续累积。
+
数据规模估算(以 NAS 上 10 万个文件、用户缓存了 1000 个为例):
| 表 | 记录范围 | 预估行数 | 存储开销 |
@@ -815,24 +759,18 @@ CREATE INDEX idx_original_path ON import_history(original_path, file_size);
MVP 阶段不使用 dir_file_list,通过反查 cache_files 实现删除检测:
-```
-轮询发现 /2026/02/ 目录 mtime 变了
- │
- ▼
-sftp ls /2026/02/ → 拿到当前远程文件列表 remote_set
- │
- ▼
-SELECT path FROM cache_files WHERE dir_path = '/2026/02/'
-→ 拿到该目录下所有缓存文件 cached_set
- │
- ▼
-对比:
- 在 cached_set 但不在 remote_set → 远程被删了 → 按决策矩阵处理
- 在 cached_set 且在 remote_set → 比较 mtime,变了则标记失效
- 在 remote_set 但不在 cached_set → 远程新增/未缓存文件 → 不处理(等访问时再拉)
- │
- ▼
-更新 dir_snapshots 的 remote_mtime 和 last_polled
+```mermaid
+flowchart TD
+ Trigger["轮询发现 /2026/02/ 目录 mtime 变了"]
+ Trigger --> LS["sftp ls /2026/02/
→ 拿到 remote_set"]
+ LS --> Query["SELECT path FROM cache_files
WHERE dir_path = '/2026/02/'
→ 拿到 cached_set"]
+ Query --> Compare{"对比两个集合"}
+ Compare -->|"在 cached 不在 remote"| Deleted["远程被删了
→ 按决策矩阵处理"]
+ Compare -->|"在 cached 且在 remote"| MtimeCheck["比较 mtime
变了则标记失效"]
+ Compare -->|"在 remote 不在 cached"| Ignore["远程新增/未缓存
→ 不处理(等访问时拉)"]
+ Deleted --> Update["更新 dir_snapshots
remote_mtime + last_polled"]
+ MtimeCheck --> Update
+ Ignore --> Update
```
这样只用两张表就完成了所有检测,逻辑清晰,开销极小。
@@ -859,28 +797,29 @@ WAL 模式的优势:
rclone 原生的 `--vfs-cache-mode full` 不做回写前的 mtime 比较(盲写),因此需要在 rclone 之上包一层 Write-back Controller:
-```
- 原来(rclone 默认):
- rclone VFS → 脏文件 → 直接 SFTP 上传 → 可能覆盖新数据 ❌
+**原来(rclone 默认)**:rclone VFS → 脏文件 → 直接 SFTP 上传 → 可能覆盖新数据 ❌
- 改为(双管道架构):
+**改为(双管道架构)**:
- 管道 A — 远程拉取后的本地编辑:
- rclone VFS → 脏文件 → Write-back Controller
- │
- ├── SFTP stat 查远程 mtime
- ├── 三时间戳比较
- ├── 决策:回写 / 冲突保留 / 跳过
- └── 回写到原始远程路径 + 更新 metadata.db
+```mermaid
+flowchart TD
+ subgraph pipeA ["管道 A — 远程拉取后的本地编辑"]
+ A1["rclone VFS 脏文件
(source=remote)"] --> WBC_A["Write-back Controller"]
+ WBC_A --> A2["SFTP stat 查远程 mtime"]
+ A2 --> A3["三时间戳比较"]
+ A3 --> A4["决策: 回写 / 冲突保留 / 跳过"]
+ A4 --> A5["回写到原始远程路径
+ 更新 metadata.db"]
+ end
- 管道 B — SD 卡导入的新文件:
- SD 卡导入(通过 FUSE 挂载点写入)→ rclone 自动标记为脏文件 → Write-back Controller
- │
- ├── 检测 source=ingest, origin_mtime=NULL
- ├── 按 INGEST_TARGET_PATH 模板计算 NAS 目标路径
- ├── 在 NAS 上创建目标目录(如不存在)
- ├── SFTP stat 查远程:按 5.4b 决策矩阵处理
- └── 上传到目标路径 + 更新 metadata.db + 记录 import_history
+ subgraph pipeB ["管道 B — SD 卡导入的新文件"]
+ B1["SD 卡导入
(通过 FUSE 挂载点写入)"] --> B2["rclone 自动标记为脏文件
(source=ingest)"]
+ B2 --> WBC_B["Write-back Controller"]
+ WBC_B --> B3["检测 origin_mtime=NULL"]
+ B3 --> B4["按 INGEST_TARGET_PATH
计算 NAS 目标路径"]
+ B4 --> B5["创建目标目录(如不存在)"]
+ B5 --> B6["SFTP stat → 按 5.4b 决策"]
+ B6 --> B7["上传到目标路径
+ 更新 metadata.db
+ 记录 import_history"]
+ end
```
Write-back Controller 作为独立进程运行,监控 rclone 缓存目录中的脏文件,替代 rclone 的原生回写逻辑。Controller 通过 metadata.db 中的 `source` 字段区分两条管道,对 `source=ingest` 的文件执行路径重映射和目录创建逻辑。
@@ -916,31 +855,23 @@ Write-back Controller 作为独立进程运行,监控 rclone 缓存目录中
**解决方案**:导入过程维护严格的状态机,只有完整校验通过的文件才进入缓存。
-```
-导入状态机:
+```mermaid
+stateDiagram-v2
+ [*] --> detecting: SD 卡插入
+ detecting --> copying: 列出文件清单
+ copying --> checksumming: 复制到 ingest_staging/
+ checksumming --> registered: SHA-256 校验通过
+ registered --> complete: 移到 FUSE 挂载点 +
写入 metadata.db + import_history
+ complete --> [*]: Write-back Controller 接管
- detecting SD 卡插入检测,列出文件清单
- │
- ▼
- copying 文件从 SD 卡复制到 ingest_staging/ 暂存目录
- │ (注意:此时文件还没有进入 rclone VFS 或 cache_files)
- ▼
- checksumming 对暂存文件计算 SHA-256,可选与 SD 卡原文件二次比对
- │
- ▼
- registered 校验通过,将文件从暂存目录移到 FUSE 挂载点
- │ 同步在 metadata.db 创建记录(state=dirty, source=ingest)
- │ 同步写入 import_history
- ▼
- complete 该文件导入完成,Write-back Controller 可接管
-
-中断处理:
- - detecting/copying 阶段中断 → ingest_staging/ 中的临时文件在下次启动时清理
- - checksumming 阶段中断 → 同上,checksum 不完整的文件清理
- - registered 阶段中断 → 文件已在 FUSE 挂载点和 metadata.db 中,重启后 Write-back Controller 正常接管
- - 每个导入会话有唯一 session_id,中断后可报告哪些文件已完成、哪些需要重试
+ note right of detecting: 中断 → 清理 staging 临时文件
+ note right of copying: 中断 → 清理 staging 临时文件
+ note right of checksumming: 中断 → 清理不完整文件
+ note right of registered: 中断 → 文件已在 FUSE + DB
重启后 Controller 正常接管
```
+**中断保护**:每个导入会话有唯一 `session_id`。`detecting`/`copying`/`checksumming` 阶段中断时,`ingest_staging/` 中的临时文件在下次启动时自动清理。`registered` 阶段中断时文件已安全进入缓存系统,Controller 重启后正常接管。
+
**实现要点**:
- 暂存目录 `ingest_staging//` 按导入会话隔离
- 进程启动时扫描 `ingest_staging/`,清理所有未完成的会话(非 registered 状态的文件)
@@ -978,32 +909,23 @@ Write-back Controller 作为独立进程运行,监控 rclone 缓存目录中
核心优化思路:**SFTP 目录本身也有 mtime**。当目录下有文件新增/修改/删除时,目录的 mtime 会更新。因此可以先查目录 mtime(一个 stat 请求),没变就跳过整个目录下所有文件的检查,大幅减少远程请求量。
-```
-┌─────────────────────────────────────────────────────────────────┐
-│ 分层轮询策略 │
-│ │
-│ 第一层:目录 mtime 快检 │
-│ 对每个已缓存的目录,SFTP stat 查其 mtime │
-│ 目录 mtime 没变 → 该目录下所有文件一定没变 → 跳过 ✅ │
-│ 目录 mtime 变了 → 进入第二层 │
-│ 开销:每个目录 1 个 stat 请求,非常轻量 │
-│ │
-│ 第二层:文件级 mtime 对比 │
-│ 仅对 mtime 有变化的目录执行 SFTP ls -l │
-│ 逐个对比文件 mtime 与 metadata.db 中的 origin_mtime │
-│ 发现变化 → 标记缓存失效(不主动下载,等访问时再拉) │
-│ 发现删除 → 按决策矩阵处理 │
-│ │
-│ 第三层:按热度分级轮询间隔 │
-│ 热目录(最近 7 天有访问) → 每 30 秒轮询 │
-│ 温目录(7-30 天内有访问) → 每 5 分钟轮询 │
-│ 冷目录(30 天+ 未访问) → 每 1 小时轮询 │
-│ │
-│ 第四层:每日全量校对(兜底) │
-│ 每天凌晨一次全量递归对比 │
-│ 捕捉所有分层轮询可能遗漏的变更 │
-│ 同时清理已不存在的缓存条目 │
-└─────────────────────────────────────────────────────────────────┘
+```mermaid
+flowchart TD
+ Start["轮询触发"] --> L3{"第三层:热度分级
决定轮询间隔"}
+ L3 -->|"热目录(7天内访问)
每 30s"| L1
+ L3 -->|"温目录(7-30天)
每 5m"| L1
+ L3 -->|"冷目录(30天+)
每 1h"| L1
+
+ L1["第一层:目录 mtime 快检
SFTP stat 查目录 mtime
(每目录 1 个请求)"]
+ L1 -->|"mtime 没变"| Skip["跳过该目录 ✅
所有文件一定没变"]
+ L1 -->|"mtime 变了"| L2
+
+ L2["第二层:文件级 mtime 对比
SFTP ls -l 该目录"]
+ L2 --> Changed["发现变化 → 标记缓存失效
(等访问时再拉)"]
+ L2 --> Del["发现删除 → 按决策矩阵处理"]
+ L2 --> New["远程新增 → 不处理"]
+
+ L4["第四层:每日全量校对(兜底)
凌晨全量递归对比
捕捉遗漏 + 清理过期条目"]
```
轮询伪代码(与 5.7.5 删除检测对齐):
@@ -1052,13 +974,17 @@ for dir in watched_directories:
对于需要秒级同步的用户,可选在 NAS 上部署轻量 Agent(Docker 容器),通过 inotify 监听变化并推送:
-```
-群晖 NAS Linux Proxy
-┌────────────────────┐ ┌────────────────┐
-│ inotifywait 监听 │ Tailscale │ HTTP 接收端 │
-│ 文件变化 │───── 通知 ────────▶│ 触发缓存刷新 │
-│ (Docker 容器) │ (轻量 HTTP POST) │ │
-└────────────────────┘ └────────────────┘
+```mermaid
+flowchart LR
+ subgraph NAS ["群晖 NAS"]
+ Agent["inotifywait 监听文件变化
(Docker 容器)"]
+ end
+
+ subgraph Proxy ["Linux Proxy"]
+ HTTP["HTTP 接收端
触发缓存刷新"]
+ end
+
+ Agent -- "轻量 HTTP POST
(via Tailscale)" --> HTTP
```
此方案作为分层轮询的增强,不是替代。即使 Agent 不可用,轮询机制仍然工作。
@@ -1069,26 +995,21 @@ for dir in watched_directories:
### 7.1 读取流程
-```
-应用请求读取文件
- │
- ▼
- ① 查本地缓存 ──── 命中 ──▶ ② 后台轮询是否已标记失效?
- │ │ │
- 未命中 未失效 已失效
- │ │ │
- ▼ ▼ ▼
- ③ 向远程请求 直接返回缓存 ④ 检查文件状态
- │ (SSD 速度) │
- ▼ ┌────┴────┐
- ④ 按 chunk 分块下载 clean dirty
- │ │ │
- ▼ ▼ ▼
- ⑤ 写入本地 SSD 缓存 拉远程新版本 标记冲突
- ⑥ 记录 origin_mtime 更新缓存 两边保留
- │
- ▼
- ⑦ 返回数据给应用
+```mermaid
+flowchart TD
+ App["应用请求读取文件"] --> Check{"① 查本地缓存"}
+ Check -->|"未命中"| Remote["③ 向远程请求"]
+ Check -->|"命中"| Valid{"② 轮询是否标记失效?"}
+
+ Valid -->|"未失效"| Return["直接返回缓存
(SSD 速度)"]
+ Valid -->|"已失效"| State{"④ 检查文件状态"}
+
+ State -->|"clean"| Pull["拉远程新版本
更新缓存"]
+ State -->|"dirty"| Conflict["标记冲突
两边保留"]
+
+ Remote --> Chunk["④ 按 chunk 分块下载"]
+ Chunk --> Write["⑤ 写入本地 SSD 缓存
⑥ 记录 origin_mtime"]
+ Write --> ReturnData["⑦ 返回数据给应用"]
```
**设计要点**:
@@ -1100,52 +1021,35 @@ for dir in watched_directories:
### 7.2 写入流程
-```
-应用写入文件
- │
- ▼
- ① 写入本地 SSD 缓存(立即返回成功给应用)
- │
- ▼
- ② 更新 metadata.db:cache_mtime = now, state = dirty
- │
- ▼
- ③ 启动写回计时器(默认 60 秒)
- │
- ├── 计时期间又有写入 → 重置计时器(合并频繁写入)
- │
- ▼
- ④ 计时器到期,Write-back Controller 接管
- │
- ▼
- ⑤ SFTP stat 查远程 mtime
- │
- ▼
- ⑥ 三时间戳比较,按决策矩阵执行
- │
- ├── 正常回写 → 上传 + 更新 origin_mtime + state → clean
- ├── 冲突 → 保留双版本 + 通知用户
- └── 远程已删 → 不回写 + 移到冲突目录
- │
- ├── 上传失败 → 重试(指数退避: 10s, 20s, 40s... 最多 10 次)
- │
- └── 最终失败 → 保留在本地缓存,state 保持 dirty,记录日志
+```mermaid
+flowchart TD
+ App["应用写入文件"] --> SSD["① 写入本地 SSD 缓存
(立即返回成功给应用)"]
+ SSD --> Meta["② 更新 metadata.db
cache_mtime=now, state=dirty"]
+ Meta --> Timer["③ 启动写回计时器(60s)"]
+ Timer -->|"计时期间又有写入"| Timer
+ Timer -->|"计时器到期"| WBC["④ Write-back Controller 接管"]
+ WBC --> Stat["⑤ SFTP stat 查远程 mtime"]
+ Stat --> Decision{"⑥ 三时间戳比较"}
+
+ Decision -->|"正常回写"| Upload["上传 + 更新 origin_mtime
state → clean"]
+ Decision -->|"冲突"| ConflictKeep["保留双版本 + 通知用户"]
+ Decision -->|"远程已删"| NoWrite["不回写 + 移到 conflict/"]
+
+ Upload -->|"上传失败"| Retry["重试(指数退避)
10s, 20s, 40s... 最多 10 次"]
+ Retry -->|"最终失败"| Keep["保留本地,state=dirty
记录日志"]
```
### 7.3 缓存淘汰策略
-```
-淘汰触发条件(任一):
- - 缓存总大小超过 CACHE_MAX_SIZE
- - 缓存盘可用空间低于 CACHE_MIN_FREE
-
-淘汰规则(优先级从高到低):
- ① dirty 文件永不淘汰(必须等回写完成)
- ② conflict 文件永不淘汰(必须等用户处理)
- ③ 超过 CACHE_MAX_AGE 且状态为 clean 的文件优先淘汰
- ④ 剩余 clean 文件按 LRU(last_accessed 最早的先淘汰)
- ⑤ 淘汰至空间满足条件为止
- ⑥ 如果淘汰全部 clean 文件后空间仍不足 → 进入「缓存空间告警」状态(详见下文)
+```mermaid
+flowchart TD
+ Trigger{"淘汰触发
(总大小 > MAX_SIZE
或可用 < MIN_FREE)"} --> D1{"① dirty 文件?"}
+ D1 -->|"永不淘汰"| D2{"② conflict 文件?"}
+ D2 -->|"永不淘汰"| D3["③ 淘汰超过 MAX_AGE
的 clean 文件"]
+ D3 --> D4["④ 剩余 clean 按 LRU 淘汰
(last_accessed 最早优先)"]
+ D4 --> D5{"⑤ 空间满足?"}
+ D5 -->|"是"| Done["淘汰完成"]
+ D5 -->|"否(clean 已耗尽)"| Alert["⑥ 进入「缓存空间告警」
详见 7.3.1"]
```
#### 7.3.1 缓存空间保护机制
@@ -1154,26 +1058,10 @@ for dir in watched_directories:
**保护措施**:
-```
-1. 导入前空间预检(SD 卡导入):
- 可用空间 = CACHE_MAX_SIZE - 当前dirty文件总量 - 当前conflict文件总量 - CACHE_MIN_FREE
- if SD卡总数据量 > 可用空间:
- → 拒绝导入,通知用户(LED 红灯 + CLI 提示剩余空间和 SD 卡大小)
- → 建议用户先连接网络完成回写,或清理部分缓存
-
-2. Time Machine 硬配额:
- TIMEMACHINE_MAX_SIZE 作为硬配额强制执行(由 smb.conf 的 fruit:time machine max size 实现)
- Time Machine 写入超过配额时 macOS 会自动清理旧备份
-
-3. 配置验证(部署时检查):
- TIMEMACHINE_MAX_SIZE + 预期最大单次导入量(INGEST_MAX_IMPORT_SIZE) + CACHE_MIN_FREE < CACHE_MAX_SIZE
- 不满足时部署脚本报警告
-
-4. 缓存空间告警状态:
- 当 dirty 文件 + conflict 文件总量 > CACHE_MAX_SIZE × 80%:
- → 触发告警(CLI 提示 / 日志 / 可选 Webhook)
- → 提示用户尽快连接网络完成回写
-```
+1. **导入前空间预检**(SD 卡导入):`可用空间 = CACHE_MAX_SIZE - dirty总量 - conflict总量 - CACHE_MIN_FREE`。SD 卡总数据量 > 可用空间时拒绝导入,通知用户(LED 红灯 + CLI 提示),建议先连网回写或清理缓存
+2. **Time Machine 硬配额**:`TIMEMACHINE_MAX_SIZE` 由 smb.conf 的 `fruit:time machine max size` 强制执行,超过配额时 macOS 自动清理旧备份
+3. **配置验证**(部署时):`TIMEMACHINE_MAX_SIZE + INGEST_MAX_IMPORT_SIZE + CACHE_MIN_FREE < CACHE_MAX_SIZE`,不满足时部署脚本报警告
+4. **缓存空间告警**:dirty + conflict 总量 > `CACHE_MAX_SIZE × 80%` 时触发告警(CLI / 日志 / Webhook),提示用户尽快连网回写
### 7.4 离线行为
@@ -1455,21 +1343,19 @@ mount -o compress=zstd /dev/ssd_partition /mnt/ssd/warpgate
**方案**:
-```
-┌─────────────────────────────────────────────────────────────┐
-│ 运营基础设施 │
-│ │
-│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
-│ │ Headscale │ │ DERP 节点 │ │ DERP 节点 │ │
-│ │ 控制面板 │ │ 国内 BGP │ │ 香港/日本 │ │
-│ │ (用户管理) │ │ (低延迟) │ │ (跨境加速) │ │
-│ └──────────────┘ └──────────────┘ └──────────────┘ │
-│ │
-└─────────────────────────────────────────────────────────────┘
- ▲ ▲ ▲
- │ │ │
- 盒子开箱即连 NAS 端自动连接 出差设备自动连接
- (内置配置) (安装脚本) (通过盒子中继)
+```mermaid
+flowchart BT
+ subgraph infra ["运营基础设施"]
+ HS["Headscale
控制面板
(用户管理)"]
+ DERP1["DERP 节点
国内 BGP
(低延迟)"]
+ DERP2["DERP 节点
香港/日本
(跨境加速)"]
+ end
+
+ Box["盒子
开箱即连
(内置配置)"] --> HS
+ Box --> DERP1
+ NAS_C["NAS 端
自动连接
(安装脚本)"] --> DERP1
+ NAS_C --> DERP2
+ Travel["出差设备
自动连接
(通过盒子中继)"] --> DERP2
```
**用户体验**:
@@ -1504,24 +1390,19 @@ mount -o compress=zstd /dev/ssd_partition /mnt/ssd/warpgate
**方案**:
-```
-盒子 SSD 缓存(热数据子集)
- │
- ▼ 空闲时段,加密增量备份
- │
-┌───▼──────────────────────────────────────────────┐
-│ 云存储服务 │
-│ │
-│ 用户视角:盒子里一键开通,按月付费 │
-│ 实际后端:Backblaze B2 / Cloudflare R2 / MinIO │
-│ (对象存储,成本约 $5/TB/月) │
-│ │
-│ 数据加密:用户本地生成密钥,运营方看不到明文 │
-│ 密钥备份:首次设置时强制引导用户备份恢复密钥 │
-│ (下载密钥文件 / 抄写助记词 / 导出到密码管理器)│
-│ 增量同步:只传变化的部分,节省带宽 │
-│ 恢复流程:新盒子 → 输入恢复密钥 → 自动拉取 │
-└──────────────────────────────────────────────────┘
+```mermaid
+flowchart TD
+ SSD["盒子 SSD 缓存
(热数据子集)"]
+ SSD -->|"空闲时段
加密增量备份"| Cloud
+
+ subgraph Cloud ["云存储服务"]
+ UX["用户视角: 一键开通,按月付费"]
+ Backend["后端: B2 / R2 / MinIO
(~$5/TB/月)"]
+ Encrypt["数据加密: 用户本地生成密钥
运营方看不到明文"]
+ KeyBackup["密钥备份: 首次设置强制引导
(密钥文件 / 助记词 / 密码管理器)"]
+ Sync["增量同步: 只传变化部分"]
+ Restore["恢复: 新盒子 → 输入密钥 → 自动拉取"]
+ end
```
**与现有架构的关系**: