系统架构¶
本文描述随手记 Sui 的整体架构、分层、数据模型、同步协议与关键设计决策。 决策的背景与权衡见架构决策记录(ADR);接口细节见 API 参考。
1. 总体架构¶
┌───────────────┐ push/pull (REST) ┌──────────────────┐
│ Flutter 客户端 │ ◄────────────────────► │ Go 服务端 │
│ (多端) │ WebSocket 变更通知 │ │
│ │ ◄──────────────────── │ sync 协议 │
│ ┌───────────┐ │ │ store (SQLite) │
│ │note_core │ │ │ blob (磁盘分片) │
│ │ SyncClient│ │ │ clip (净化引擎) │
│ │ Repository│ │ │ ws (Hub) │
│ │ drift/SQL │ │ └──────────────────┘
│ └───────────┘ │ HTTPS + HTML ┌──────────────────┐
│ │ ◄──────────────────── │ Chrome 剪藏扩展 │
└───────────────┘ └──────────────────┘
设计原则¶
- 离线优先:客户端所有数据本地持久化,服务端只负责汇聚与版本仲裁。
- Markdown 唯一正本:笔记内容一律为 Markdown 字符串,编辑器提供「格式 / 源码 / 预览」三态 (格式态为正本之上的可视化视图),杜绝富文本中间态转换风险。
- 内容寻址附件:附件以 sha256 为键存储,天然去重、可校验完整性。
- 服务端权威版本线:
version由服务端递增下发,客户端只携带base_version做冲突 判定,不可自行篡改。 - 绝不丢字:冲突合并采用启发式策略,双方内容都会保留。
技术栈¶
| 层级 | 技术 |
|---|---|
| 客户端 | Flutter(跨平台:Android / iOS / Windows / macOS / Linux / Web) |
| 状态管理 | Provider |
| 本地存储 | SQLite + Moor/Drift(FTS5 全文搜索预留) |
| 服务端 | Go(标准库 net/http) |
| 服务端存储 | SQLite + 本地文件系统(Blob) |
| 同步协议 | 自研 push/pull + base_version 冲突检测 + 启发式合并 |
| 剪藏净化 | 类 Readability 启发式 + 自定义 HTML→Markdown 转换器 |
| 浏览器扩展 | Chrome Extension MV3 |
| 实时通知 | WebSocket(nhooyr.io/websocket) |
2. 服务端¶
2.1 分层¶
| 包 | 职责 |
|---|---|
api |
HTTP 路由 + handler(JSON 入出参) |
sync |
同步协议:Push(冲突判定)/ Pull(增量拉取) |
store |
SQLite 数据访问层(Store 结构体) |
blob |
附件存储:sha256 分片路径、引用计数、GC |
auth |
Bearer Token 中间件 |
clip |
网页净化:类 Readability 选主内容(article)/ 整页保序快照(snapshot)+ HTML→Markdown + 图片本地化(下载→sha256→附件库,失败降级保留绝对 URL) |
ws |
WebSocket Hub:push/剪藏成功后广播 {"type":"changed"} |
cors |
跨域中间件(SUI_ALLOWED_ORIGINS 白名单;未配置时开发模式全允许) |
2.2 数据模型(SQLite,服务端)¶
服务端库只承载同步汇聚所需的最小元数据,实际建表语句见
server/internal/store/store.go 的 migrate()。
| 表 | 用途 |
|---|---|
users |
用户(id / username / password_hash / password_salt / created_at)。单用户模式至多一行 |
sessions |
会话令牌(token / username / created_at),多设备 / 多 profile 各持一行 |
notes |
笔记元数据(id / title / content_markdown / notebook_id / version / is_deleted / archived / source_device / source_url / updated_at)。source_url 为剪藏幂等键(普通笔记为 NULL) |
revisions |
修订历史(id / note_id / version / title / content_markdown / source_device / is_conflict / created_at) |
blobs |
附件字节登记(sha256 / size / refcount / created_at)。refcount 唯一来源是 attachments 映射:指向该 hash 的有效映射条数 |
attachments |
附件-笔记映射(id / note_id / filename / mime_kind / byte_size / sha256 / storage_ref / thumbnail_ref / embedded_pos / is_deleted / created_at / updated_at) |
notebooks |
笔记本分组(id / parent_id / name / sort_order / is_deleted / version / source_device / created_at / updated_at) |
tags |
标签(id / name / is_deleted / version / source_device / created_at / updated_at) |
note_tags |
笔记-标签多对多关联(note_id / tag_id) |
meta |
实例元数据键值(key / value / updated_at)。当前存 instance_id —— 云端实例身份(形如 inst-<32 位小写十六进制>),首启初始化生成、同一数据目录内恒定、重建 / 换库必变(M12 / FR-55) |
关键索引:idx_notes_updated、idx_revisions_note、idx_revisions_note_ver、idx_notes_isdel、
idx_attachments_note、idx_notebooks_updated、idx_tags_updated、idx_note_tags_note、idx_note_tags_tag、idx_sessions_user。
同步净荷(M1 起,M2 增补):push 请求体在
items(笔记)之外新增notebooks/tags两个数组, 笔记条目新增notebookId(指针语义:缺省=不变、""=移入收件箱、有值=归属该笔记本)与tagIds(该笔记标签的全量集合);pull 响应新增notebooks/tags两个数组,笔记条目回带notebookId/tagIds。push 响应新增notebookResults/tagResults,与results同构 (accepted/serverVersion/appliedVersion),复用同一套base_version冲突与墓碑机制。 M2 起笔记条目增带archived(归档状态,随 push/pull 往返、跨端一致)。 旧表清单中的outbox为设计预留描述,代码中并未建表,已从本表移除。M12 起(FR-55):服务端库由 9 张表增至 10 张(新增
meta);GET /api/v1/sync/pull响应新增instanceId(该端点恒鉴权),GET /api/v1/ping仅在鉴权通过时回带instanceId(匿名探测不返回,避免对公网暴露稳定指纹)。push 逐项结果新增notFound,且serverVersion去掉omitempty——「服务端没有该实体」时显式给出serverVersion: 0。push/pull 的其余净荷 字段、baseVersion冲突判定与启发式合并策略均未改动;契约细节见 API 参考。
2.3 测试清单(internal/api/handlers_test.go + internal/api/m4_test.go + internal/api/m6_test.go + internal/api/m10_test.go + internal/api/m12_test.go)¶
| 测试 | 覆盖 |
|---|---|
TestPing |
心跳 |
TestHealth |
健康检查 |
TestPushPull |
推送 + 增量拉取闭环 |
TestConflictDetection |
base_version 冲突判定 |
TestAuthReject |
无 token / 坏 token 拒绝 |
TestRevisionListAndGet |
修订列表 + 详情 |
TestAttachmentMappingSync |
附件映射往返 + refcount 幂等/改指/墓碑归零 + 孤儿 GC |
TestClipEndpoint |
剪藏净化 + URL 幂等 + 同步集成 |
TestSyncNotebookTagPayload |
笔记本 / 标签净荷往返 + notebookId / tagIds + 冲突回传 serverVersion |
TestSyncArchivedFlag |
归档状态 archived 净荷往返 |
TestSyncNotebookCreateRename |
笔记本新建 / 重命名变更上行与 pull 收敛 |
TestPingInitialized |
initialized 心跳(首启 false → 建号后 true) |
TestRegisterGatewayClosed |
首启后注册网关关闭(403 already-initialized) |
TestLoginRejectsWrongPassword |
密码哈希校验(错误密码 401,正确密码可登录) |
TestWsAuthRequired |
WS 端点鉴权(无 token 拒绝 / 有效 token 升级) |
TestClipIDUniqueness |
剪藏 id ≥128 bit + 同 URL 幂等复用 / 异 URL 区分 |
TestClipSnapshotMode |
snapshot 模式:整页保序净化(标题 / 表格 / 图注)产出语义等价 Markdown |
TestClipMediaLocalization |
图片本地化:下载 → sha256 → 附件入库 → 正文改 sui:// + 附件映射随笔记入库 |
TestClipMediaFailureDegrade |
单图失败降级:保留(已解析的)绝对 URL,不阻断整篇 |
TestClipOfflineReadable |
离线可读:本地化后按 sha256 可从附件库取回图片字节 |
TestM12InstanceIDStableWithinDataDir |
云端实例身份在同一数据目录内恒定(重开库不变) |
TestM12InstanceIDChangesAfterRebuild |
重建 / 换库后实例身份必变 |
TestM12PingExposesInstanceIDOnlyWhenAuthed |
匿名 ping 不返回 instanceId,鉴权通过后返回 |
TestM12PushNotFoundSignalAndSelfHeal |
「服务端没有该实体」→ notFound: true + 显式 serverVersion: 0;基线归零后立即被接受 |
3. 客户端¶
3.1 note_core(共享核心)¶
纯 Dart 包,不依赖 Flutter,可被任何 Dart 宿主复用。
| 类 | 职责 |
|---|---|
NoteRepository |
本地数据访问门面:笔记本 CRUD、标签、笔记 CRUD、修订追加、搜索 |
SyncClient |
同步引擎:按库中状态重建上行批次(状态即队列)、增量 pull、冲突本地合并与「服务端没有该实体」自愈、核对补齐 reconcile() |
EntitySyncState |
逐项同步状态五态(已同步 / 待上传 / 仅本地 / 冲突 / 同步失败)+ 承载实体类别映射(M12 / FR-53) |
AppDatabase |
drift 数据库(8 表 + 迁移) |
LocalBlobStore |
附件本地存储实现(原生分片落盘 / Web 内存缓存) |
CachedBlobStore |
附件缓存层:LRU 上限 + 按需下载 + uploaded_at 待上传记账(blob_refs) |
mimeKindFor() |
由扩展名推断附件大类(卡片图标用) |
DeviceId |
设备标识(冲突合并 / 来源标记用) |
本机数据表(drift,schemaVersion = 8,共 8 张):定义见
clients/note_core/lib/src/db/app_database.dart。
| 表 | 用途 | 随同步上行 |
|---|---|---|
notebooks |
笔记本分组(树形:id / parent_id / name / sort_order / is_deleted / version / 时间戳) | ✅ |
tags |
标签(扁平、跨笔记组合) | ✅ |
notes |
笔记正本(notebook_id / title / content_markdown / pinned / archived / is_deleted / revision_count / version / source_device / 时间戳) | ✅ |
note_tags |
笔记-标签多对多关联(note_id / tag_id) | ✅(随笔记 tagIds) |
revisions |
修订历史(快照 + diff 增量) | ✅ |
attachments |
附件元数据(字节存 BlobStore,此处只存引用 + sha256) | ✅(映射随笔记) |
blob_refs |
本机附件缓存记账(LRU 元数据 + uploaded_at 待上传标记) |
❌ 本机缓存状态 |
settings |
应用级键值配置(服务端地址 / 会话令牌 / deviceId;M12 增 sync.instanceId / sync.lastPull / sync.lastReconcileAt) |
❌ 设备级偏好 |
M1 起服务端补齐
notebooks/tags/note_tags三表:notebooks/tags随 sync/push、 sync/pull 净荷上下行(与笔记一样携带version/is_deleted/ 来源设备与墓碑机制);note_tags关联不单独传输,而是随所属笔记的tagIds全量携带、在笔记被接受时重建关联。M12 起(FR-53):新增逐项同步状态列。
notes/notebooks/tags三表各增sync_state(0 已同步 / 1 待上传 / 2 仅本地 / 3 冲突 / 4 同步失败)、sync_error、sync_error_at、sync_checked_at、sync_hold(用户选「以云端为准」后被按住的项)。 状态是纯本地记账 —— 它描述「本端这一条相对云端的关系」,故不写入 Markdown 正本、 不进 push/pull 净荷、服务端不存储(BR-53.3)。迁移(v7 → v8)取保守初值、 绝不预置「已同步」:有服务端基线(version > 0)→ 待上传;version == 0且无未确认 修订 → 仅本地;有未确认修订 → 待上传;首次连接并完成核对后由同步层整体重算。笔记本 / 标签的
version自 M12 起与笔记同口径 —— 是服务端基线镜像,本地编辑不再递增它。
3.2 flutter_app(Flutter 客户端)¶
状态管理:Provider + AppController(单一状态源,暴露 notifyListeners)。
| 文件 | 职责 |
|---|---|
note_shell.dart |
响应式三栏骨架(宽屏三栏 / 窄屏抽屉 + 导航堆栈);桌面端左栏 / 中栏按视图偏好条件渲染(折叠面板与其分隔线一并消失);M12 增顶栏同步状态入口(未连接 → 打开同步设置,已连接 → 立即同步)与「云端数据已更换 / 重建」三选一对话框(默认焦点在「取消」,非破坏性) |
notebook_tree.dart |
笔记本树 + 收件箱 + 全部笔记 + 标签入口 + 归档 / 回收站入口(底部区域) |
note_list.dart |
笔记列表(置顶 / 剪藏标签 / 搜索过滤) |
note_editor.dart |
编辑器(标题 / 格式工具栏 + 编辑快捷键 / 勾选框点选 / 标签 / 附件面板 / 附件预览 / 历史 / 导出 / 删除 / 表格工具 / 粘贴保留格式 / 简化格式) |
markdown_editing_controller.dart |
「格式 / 源码 / 预览」三态编辑控制器(Markdown 为正本;格式态渲染行内样式、图片单元、任务勾选框与高亮、表格呈现单元、附件胶囊单元、有序列表编号,支持选中调尺寸) |
markdown_editor.dart |
源码编辑 + 预览切换(sizedImageBuilder 渲染 sui:// 附件图;预览态渲染任务列表、==高亮==、表格、有序列表编号) |
revision_panel.dart |
版本历史面板 + 一键恢复(宽屏为右侧栏、窄屏整页推入,能力一致;BR-12.3) |
app_controller.dart |
全局状态与业务编排(附件增删 / 上传 / 缓存状态);桌面折叠状态 leftPanelCollapsed / noteListCollapsed 与编辑模式上提(本地视图偏好 ui.*,不进同步);退出前落库 flushPendingEdits;同步调度:编辑防抖 0.7s 推送、WS 通知拉取、30s 周期兜底;多窗口:编辑器命令目标按视图键索引(_editorTargets / setActiveViewKey)、窗口注册表与「一笔记一窗口」去重聚焦、quitApplication 关闭全部窗口后退出 |
note_window_manager.dart |
桌面多窗口平台无关接缝:NoteWindowManager 接口(开窗 / 聚焦 / 关闭 / 查询)+ WindowEventHub 事件枢纽(窗口打开 / 关闭 / 视图聚焦)—— 窗口注册表语义与「两面并行」流程可在纯 Dart 单测中驱动,无需真实窗口 |
note_window.dart |
独立笔记窗口内容(复用 NoteEditor,按笔记 id 装载);窗口内以视图键 setActiveViewKey 认领命令、关闭经 closeCurrentWindow 释放 |
desktop_commands.dart |
桌面命令单一来源(DesktopCommandId / DesktopCommand / desktopCommands 注册表)+ EditorCommandTarget 接口桥;菜单项 / 顶栏图标按钮 / 快捷键同源(多窗口下按当前聚焦视图路由命令) |
app_menu_bar.dart |
桌面端自绘菜单栏「文件 / 编辑 / 视图 / 同步 / 帮助」(M12 增「同步 → 全部重新同步」)+ 顶栏折叠切换控件 PanelToggles(窄屏 / 非桌面不渲染) |
sync_settings_dialog.dart |
同步设置对话框(服务端地址 + 账号密码登录 / 设备 ID / 附件缓存上限)+ M12 的「全部重新同步」面板 + 桌面端「本地存储」区(v0.12.2:路径 / 占用 / 迁移入口;窄屏与 Web 不渲染) |
sync_status_icon.dart |
逐项同步状态的就地呈现(五态图标 + tooltip 缘由 + 未同步计数角标;窄屏降级为状态点)——图标与文案的单一来源在 note_core(M12 / FR-53) |
platform/attachment_picker.dart |
跨端文件选择(file_picker,返回文件名 + 字节) |
platform/app_lifecycle.dart(+ _io / _web) |
桌面退出前落库接缝(条件导入):桌面实现「flushPendingEdits → 尽力推送 → 退出」,其他平台降级为空实现 |
platform/multi_window.dart(+ _io / _stub) |
多窗口平台接缝(条件导入):桌面经 multiview_desktop 开真 OS 窗口(单引擎多视图),其他平台降级为 no-op(Web / 移动端不构建该依赖) |
服务端地址:不写死在代码里,由用户在「同步设置」对话框录入,经
SyncConfig.normalizeBaseUrl 规整后存入本地 SQLite settings 表;未填写时输入框以
http://127.0.0.1:8080 作占位提示。运行时地址一律取自 AppController 持有的 SyncConfig。
3.3 测试¶
- note_core:329 个用例,覆盖仓储 CRUD / 标签 / 搜索 / 修订 / 同步 / 附件引用计数与上传 /
缓存 LRU / 配置存取 / 落盘持久化 / 笔记本排序(同级
sortOrder递增、子笔记本权重独立、 重排归一化)/ 编辑器格式化(快捷键映射同源、勾选框往返、==高亮==往返、块级行为)/ 逐项同步状态与核对补齐(M12:写路径状态、notFound自愈、真冲突合并且绝不丢字、 游标回退与重放幂等、换库拒落数据、v7 → v8 迁移保守初值),另含 e2e(首批注册后双端 push/pull→冲突合并→重发;附件映射同步 + 字节按需下载)。 - flutter_app:62 个用例,含 widget 测试、
sync_wiring_test.dart(起真服务端跑注册连接→同步→ 第二设备拉取)、editor_format_image_test.dart(格式模式图片渲染与尺寸手柄)、editor_enhancement_test.dart/editor_enhancement_e2e_test.dart(编辑器增强:编辑快捷键、 勾选框点选回写、==高亮==三态渲染,以及「打开不编辑」跨三态逐字节保真)、note_shell_narrow_test.dart(窄屏历史入口 3 项:点历史整页推入修订面板、顶栏返回 / 系统返回 先关面板回编辑页再退出编辑)、desktop_shell_test.dart(桌面外壳 12 项:折叠四态与上下文不丢、视图偏好持久化、恢复入口、 菜单命令与图标 / 快捷键等价、不可用命令置灰、快捷键提示、编辑模式跨重启、窄屏不渲染、 落盘失败不阻断刷新)与note_window_manager_test.dart/attachment_window_isolation_test.dart(桌面多窗口 12 项: 开窗 / 去重聚焦(含最小化先还原)、两面并行(主窗口照常保留编辑面、选中不变)、 关闭释放与「关闭全部窗口退出」、窗口局部ui.window.note.editorMode独立于主窗口、 附件列表按笔记取值不回读全局字段的跨窗口隔离)。 - 运行前确保
libsqlite3可用(见快速开始 §2.3)。
4. 同步协议与冲突解决¶
4.1 版本模型¶
客户端 服务端
┌────────────────────┐ ┌──────────────────────┐
│ base_version = 服务 │ │ version(权威,单调递增)│
│ 端最近一次下发的值 │ │ │
│ 本地修订 = 动态递增 │ │ 每次接受 push: │
│ (未同步的修改) │ │ base==server → 应用 │
└────────────────────┘ │ version+1 │
│ base!=server → 冲突 │
└──────────────────────┘
version只由服务端下发,客户端不可修改。- 客户端本地每次修改 = 本地版本 +1(未提交前一直 +1)。
- 客户端 pull 后 base 刷新、本地修订重置。
4.2 冲突判定与合并¶
- 判定:服务端
base_version撞车即冲突,返回当前serverVersion。 - 服务端没有该实体(客户端基线非 0、服务端无记录):不算冲突 —— M12 起服务端回
accepted: false+notFound: true+ 显式serverVersion: 0;客户端据此把该实体基线归零 并在同一轮内重发(自愈)。旧实现只回accepted: false,客户端无从判断 → 会永久冲突(B24-①)。 - 合并(客户端启发式):
- 标题取较新修订。
- 正文:合并双方内容,冲突段落用标记分隔,绝不丢字。
- 合并结果作为新 base 重发。
- 与 Git 类比:rebase(基于服务端最新)+ merge(双方内容并集)。
4.3 历史一致性¶
- 历史记录以服务端合并记录为准,所有端看到一致。
- 客户端未同步的本地修改在同步后并入服务端时间线。
4.4 WebSocket 通知¶
- 端点:
GET /api/v1/ws?token=<token>(亦接受Authorization: Bearer);鉴权未通过 → 401。 - push / 剪藏成功后向已鉴权连接广播
{"type":"changed"}。 - 客户端收到后触发一次增量 pull;WS 不可用仅静默降级,手动同步与防抖推送仍可用。
4.5 逐项同步状态(M12 / FR-53)¶
五态为纯本地记账(不写入 Markdown 正本、不进 push/pull 净荷、服务端不存储),落在
notes / notebooks / tags 三表:
| 状态 | 判定口径 |
|---|---|
已同步 |
本端无未确认改动,且已知服务端版本与本端基线一致 |
待上传 |
本端有未上行改动,且云端已知持有该实体 |
仅本地 |
云端从未持有该实体(核对确认不存在) |
冲突 |
云端持有、版本 ≠ 本端基线,且本端仍有未上行改动(含加密笔记本未解锁、无法就地合并) |
同步失败 |
最近一次针对该项的上行 / 下行失败(保留原因与时间) |
未知落库值一律按保守态(待上传)处理,不误报「已同步」(BR-53.6);未配置同步时界面
一律呈现「仅本地」的弱化文案,同样不伪造「已同步」。
4.6 状态即队列与自愈(M12)¶
- 上行队列 = 库中状态:
push()按sync_state重建批次(内存 Outbox 退役),本地写路径 (新建 / 编辑 / 改名 / 移动 / 排序 / 删除 / 归档 / 恢复 / 附件 / 标签 / 加密开关 / 还原修订) 即时置位 —— 待上传意图跨重启、跨换服务端保持(B24-②)。 - 自愈:收到
notFound(或旧服务端的serverVersion == 0)→ 该实体基线归零并在同一轮内 重发;真冲突 → 同一轮内完成「明文层启发式合并 → 以服务端版本为 base 重发」,重发轮不再 重复合并(避免冲突标记膨胀);加密笔记本未解锁 → 保持「冲突」并提示先解锁。 - 单项重试:
retryOne()只影响该项,并顺带解除「以云端为准」留下的按住标记。
4.7 拉取游标与下行幂等(M12)¶
游标持久化于 settings(sync.lastPull),取值 max(updated_at) − 1s(服务端 updated_at
仍是秒精度,未改);下行幂等,故重放最后一秒安全,而同一秒内新增的多条不再被永久漏拉(B24-③)。
4.8 核对补齐与换库决策(M12 / FR-54 / FR-55)¶
reconcile()(「全部重新同步」):ping(取实例身份)→pull?since=epoch(云端全量权威态, 含墓碑)→ 逐项判定与下行 → 云端缺失的本地实体标「仅本地」→ 分批 push(用户选「用本地补齐」时 含被按住项;选「以云端为准」则按住不上行)→ 逐项回写状态 + 失败原因。可取消、幂等, 给出进度与逐项结果。- 云端实例身份:服务端首启初始化生成
instance_id(meta表,形如inst-<32 位小写十六进制>);pull恒下发,ping仅鉴权时下发。 - 换库 / 重建:客户端记录的身份与云端不一致,或首次连接即发现云端为空而本机有数据 →
抛
CloudInstanceChangedException,在用户决断前不落任何数据、不上传、不更新身份; 界面弹三选一(用本地补齐 / 以云端为准 / 取消)。「以云端为准」不删除本地数据,只把它们 降级为「仅本地」并按住(sync_hold)。
5. 附件存储策略(客户端侧)¶
决策来源:M5 后讨论「客户端附件存储压力」。结论:采用 B 方案(元数据全量同步 + 附件按需拉取 + LRU 容量上限),放弃全量镜像。
5.1 问题¶
客户端存储压力 = 本地持久化的附件字节总量。全量镜像(A 方案)下每台设备存储 ≈ 附件总量: 剪藏图片 50KB~2MB/张、PDF 1~50MB、视频更大,移动端极易被不知不觉占满。
内容寻址(sha256 去重)只能解决「同图多篇引用只存一份」,解决不了总量大 —— 两个不同问题。
5.2 方案 B 设计¶
┌─────────────────────────────────────────────────────────┐
│ 同步层(SyncClient) │
│ - 笔记-附件映射关系:全量同步(映射数据极小) │
│ - 附件字节:不同步,打开时按需下载(GET /blobs/{hash}) │
├─────────────────────────────────────────────────────────┤
│ 缓存层(CachedBlobStore,实现 BlobStore 接口) │
│ - 容量记账:维护本地已缓存字节总量 │
│ - LRU 淘汰:超上限(默认 512MB,可配置)按最近最少使用淘汰 │
│ - 元数据(hash/大小/最后访问)落 SQLite 或 sidecar 文件 │
│ - 命中缓存直接读本地;未命中 → 服务端下载 → 写入缓存 │
├─────────────────────────────────────────────────────────┤
│ 物理层(LocalBlobStore) │
│ - <root>/<hash前2位>/<hash> 分片存储 │
└─────────────────────────────────────────────────────────┘
关键点
- 映射同步:笔记附件清单随笔记一起走 push/pull(
items[].attachments/notes[].attachments,含墓碑,否则删除无法传播),字节永远不随 pull 下发。 映射随笔记而非独立游标,是因为映射脱离笔记没有意义,且省掉一个同步游标。 - 惰性下载:附件打开 / 预览时才请求
GET /api/v1/blobs/{hash}。 - LRU 淘汰:容量超限时淘汰最久未访问的 hash(先删物理字节,再清元数据); 被淘汰后再次打开触发重新下载。
- 占位 UX:未缓存的附件显示「未下载」占位 + 手动下载按钮;离线时未缓存附件不可看 (可接受权衡)。
- 桌面可扩:桌面端可提供「全量镜像」开关(关闭 LRU),移动端默认按需。
上传方向
方案 B 只规定了「字节按需下行」,上行方向必须同时明确,否则新增的附件永远留在本机:
- 新增即上传,失败不阻断:挂载附件时先落本地字节、再写映射(引用计数 +1),然后尽力
PUT /blobs/{hash}。上传失败(断网 / 未连服务端)不算失败 —— 字节已在本地、映射已入库, 属于「待上传」状态。 - 同步周期补传:
blob_refs.uploaded_at为 null 即「服务端尚未确认持有」,SyncClient.backfillBlobs()在每轮同步开头扫一遍待上传清单补齐。 - 顺序:先补传字节,再 push 映射。反过来会出现对端已收到映射、却下载不到字节的空窗。
- 四态可用性:UI 用
AttachmentAvailability(cached/pendingUpload/localOnly/remoteOnly)而非布尔「已缓存」,因为「本地有字节」与「服务端有字节」是两件独立事实。
存储压力结论
- 移动端:本地占用 ≤ 512MB 上限(可配置),与附件总量解耦,压力可控。
- 桌面端:可选全量镜像,默认同上限策略。
- 离线:未缓存附件不可看,但有占位提示;已缓存附件完整可用。
6. 平台分层:条件导入与构建目标¶
6.1 方案:条件导入分层¶
平台相关能力下沉为「壳文件 + 各平台实现」,由 Dart 条件导入在编译期选择:
| 能力 | 壳文件 | 原生实现 | Web 实现 |
|---|---|---|---|
| 数据库连接 | db/connection/connection.dart |
connection_io.dart:drift/native + SQLite 文件 |
connection_web.dart:drift/wasm + 浏览器持久化 |
| Blob 存储 | blob/local_blob_store.dart |
local_blob_store_io.dart:文件系统分片 |
local_blob_store_web.dart:内存缓存 |
| 数据目录 | flutter_app/src/platform/data_dir.dart |
data_dir_io.dart:path_provider |
data_dir_web.dart:返回 null |
条件常量用 dart.library.io(原生)与 dart.library.js_interop(Web)。
判定顺序是关键:dart.library.io 必须排在前面 —— 已实测 dart.library.js_interop
在 Dart VM 上为 false,但仍以显式顺序兜底,避免未来 SDK 行为变化导致 VM 误选 Web 实现。
6.2 Web 端持久化¶
WasmDatabase.open 需要 flutter_app/web/ 下两个资源:
| 资源 | 来源 | 大小 |
|---|---|---|
sqlite3.wasm |
sqlite3.dart releases(版本需与 sqlite3 依赖一致,当前 2.9.4) |
~714KB |
drift_worker.dart.js |
由 web/drift_worker.dart 经 dart compile js -O4 生成 |
~355KB |
生成命令见快速开始 §4.4。 drift 会探测浏览器能力并按可靠性择优: OPFS(shared) → OPFS(locks) → IndexedDB(shared) → IndexedDB(unsafe) → 内存。
实测(Chrome、非跨域隔离、无
SharedArrayBuffer):落到 IndexedDB 持久化 ——indexedDB.databases()中存在名为sui的库,非内存回退。
6.3 数据落库位置¶
| 平台 | 位置 |
|---|---|
| 桌面 / 移动 | <应用支持目录>/sui/sui.sqlite(path_provider,目录不存在时自动创建) |
| Web | 浏览器 OPFS / IndexedDB(库名 sui) |
| 测试 | 内存库(AppDatabase.memory()) |
选「应用支持目录」而非「文档目录」:数据库属应用内部状态,不应出现在用户可见的文件列表中, 也避免被系统云盘同步误处理。
6.4 Web 端 Blob 的取舍¶
Web 无文件系统,LocalBlobStore 在 Web 退化为进程内内存缓存。方案 B 中 BlobStore 本就是
缓存语义(正本在服务端、按需拉取),刷新页面后重新下载即可,不构成数据丢失。若后续需要
Web 端跨会话缓存附件字节,可再补一个 IndexedDB 实现(壳文件的第三个分支)。
6.5 构建目标现状¶
各目标的可构建性取决于本机工具链(脚手架均已入库)。详见 快速开始 §4.3 的平台脚手架现状表。
平台相关配置
| 项 | 位置 | 说明 |
|---|---|---|
| 应用显示名 | linux/runner/my_application.cc、windows/runner/main.cpp、android/app/src/main/AndroidManifest.xml、ios/Runner/Info.plist、macos/Runner/Configs/AppInfo.xcconfig |
统一为「随手记 Sui」(Windows 资源元数据用 ASCII Sui) |
| MSVC 源码编码 | windows/runner/CMakeLists.txt |
加 /utf-8:窗口标题含中文,MSVC 默认按系统 ANSI 代码页解析无 BOM 源文件会乱码 |
| Android 网络权限 | android/app/src/main/AndroidManifest.xml |
模板只在 debug/profile 清单声明 INTERNET,release 包必须补在 main 清单 |
Windows 的
Runner.rc里FileDescription/ProductName用 ASCIISui:.rc由rc.exe编译,无 BOM 的 UTF-8 中文会被按代码页误读,不值得为此引入 BOM。InternalName/OriginalFilename必须保持sui_flutter_app(与可执行文件名绑定)。明文 HTTP 说明:iOS 的 ATS 与 Android 的 Network Security Config 都只约束平台原生 网络栈(
NSURLSession/ OkHttp 等)。本客户端走package:http→dart:io的 Dart 自有 socket,Flutter 不在 socket 层施加策略,因此自托管的http://服务端无需任何明文豁免配置。 生产环境仍建议 HTTPS。
7. 项目目录结构(目标)¶
sui/
├── Makefile # 服务端构建/测试入口
├── README.md # 项目总览(薄入口)
├── ARCHITECTURE.md # 架构入口(薄入口,指向本文件)
├── DEVELOPMENT.md # 开发入口(薄入口)
├── LICENSE / CONTRIBUTING.md / CHANGELOG.md
├── CODE_OF_CONDUCT.md / SECURITY.md
├── docs/ # 详细文档
│ ├── index.md / getting-started.md / architecture.md
│ ├── api-reference.md / deployment.md / troubleshooting.md
│ ├── guides/ # 用户指南、网页剪藏
│ └── examples/ # 代码示例
├── protos/ # 同步协议定义(预留)
├── server/ # Go 服务端
│ ├── cmd/sui-server/ # 入口
│ └── internal/
│ ├── api/ auth/ blob/ clip/ cors/ store/ sync/ version/ ws/
├── extension/ # Chrome MV3 剪藏扩展
└── clients/
├── note_core/ # 多端共享核心(纯 Dart,不依赖 Flutter)
│ ├── lib/src/
│ │ ├── db/ # app_database.dart + connection/(平台条件导入)
│ │ ├── blob/ # blob_store / local_blob_store* / cached_blob_store
│ │ ├── models/ # Note / Notebook / Tag / Revision / Attachment
│ │ ├── repository/ # note_repository.dart
│ │ ├── sync/ # sync_client.dart
│ │ └── util/ # ids.dart / mime_kind.dart
│ └── test/ # 329 用例
└── flutter_app/ # Flutter 客户端
├── lib/src/
│ ├── app.dart / main.dart / bootstrap.dart
│ ├── platform/ # 数据目录 / 附件选择 / 生命周期 / 多窗口接缝(均条件导入)
│ └── ui/ # 三栏外壳 / 编辑器 / 修订面板 / 附件卡片 / 独立笔记窗口
├── web/ # index.html / sqlite3.wasm / drift_worker.dart(.js)
├── android/ ios/ linux/ windows/ macos/
└── test/
8. 当前状态与已知缺口¶
本节用于区分「设计目标」与「已落地」。变更历史见根目录
CHANGELOG.md。
8.1 已落地并验证¶
- 服务端:Go 构建通过、
go test ./...全绿;ping/register/login/push/pull/blobs(HEAD/PUT/GET) /revisions/clips/ws全部实测正常,鉴权 401、密码错误 401、 已建号后重复注册 403、坏 body 400、不存在资源 404、base_version冲突accepted=false均正确。 - note_core:329/329 测试通过(仓储 CRUD / 标签 / 搜索 / 修订 / 同步 / 附件引用计数与上传 / 缓存 LRU / 配置存取 / 落盘持久化 / 笔记本排序 / 编辑器格式化(含图片块级插入)/ 逐项同步状态与核对补齐 + e2e 同步等)。
- flutter_app:189/189 测试通过(含真服务端端到端:注册连接 → 本地新建 → 同步 → 第二台设备拉取到;格式模式图片渲染与尺寸手柄、块级图片下方留出行高;编辑器增强的快捷键 / 勾选框 / 高亮与「打开不编辑」保真用例;窄屏紧凑模式行与附件弹窗;窄屏历史入口(整页推入 修订面板、返回两级);桌面外壳 12 项:折叠 / 展开与持久化、菜单命令等价与置灰、编辑模式跨重启、窄屏不渲染、落盘失败不阻断刷新; 桌面多窗口 12 项:开窗 / 去重聚焦 / 两面并行 / 关闭全部窗口退出与附件列表跨窗口隔离)。
- 同步链路:
SyncClient已实例化并注入CachedBlobStore,push/pull + WS 通知已接线。 同步触发点有三:编辑防抖 0.7s 推送、WS 通知拉取、30s 周期兜底(让「断网改动在恢复 网络后自动补上」成立,而不必等用户再编辑一次)。 - 笔记本 / 标签同步:服务端新增
notebooks/tags/note_tags三表,push/pull 净荷 扩展为items+notebooks+tags;笔记条目携带notebookId(指针语义)与tagIds全量集合;复用base_version冲突与墓碑机制,新设备首拉即可重建完整分组树与标签。 - 整理与归档(M2):笔记归属调整、排序、置顶 / 归档 / 删除;笔记本排序与软删除(删除 笔记本级联把其笔记置入回收站);左侧栏底部「归档」「回收站」入口(回收站支持查看 + 还原到 原笔记本或「全部笔记」);全部标签总览统计 + 多选筛选。
- 格式化编辑(M2):编辑器「格式 / 源码 / 预览」三态,Markdown 唯一正本;格式工具栏与
图片插入 / 尺寸调整(手柄 / 预设)即时回写
content;服务端notes表新增archived列。 - 界面配色(M2):左侧栏
RGB(34,34,38)、中间栏与编辑栏白底;「新建笔记本」按钮置于左栏顶部。 - 附件映射:随笔记 push/pull 全量交换(含墓碑),服务端 blob
refcount由映射驱动; 字节仍按需下载,映射同步不触发字节传输。 - 附件上传:新增附件先落本地 → 写映射 → 尽力上传,失败留待
backfillBlobs()在同步 周期补传;push 前先补字节,避免对端拿到映射却下不到字节。 - 附件 UI:编辑器格式工具栏「附件」入口(
file_picker跨端取字节)→ 弹出附件面板 (AlertDialog,列出全部附件,四态:已同步 / 待上传 / 仅本机 / 未下载)+ 预览 / 格式态内sui://<sha256>图片渲染。图片以独占块插入正文(块高向下撑开,后续文字下移、光标可越过)。 - Web 构建:
flutter build web --release成功;真实浏览器验证启动、IndexedDB 落库。 - 平台脚手架:
web/+ 桌面/移动五端目录均已就位,应用显示名统一为「随手记 Sui」; Linux 桌面实测只差宿主编译工具链。 - 代码质量:
flutter analyze两个包 0 问题。 - 测试稳定性:两个 e2e 起服务端由固定
sleep 1.5s改为轮询/healthz探活, 消除机器繁忙时的「连接被拒」假失败。 - 单用户服务化(M4):服务实例即单用户笔记库;首启建号后关闭自助注册(403
already-initialized),/api/v1/ping回带initialized供客户端判定首启状态。 - 安全加固(M4):密码以 PBKDF2-HMAC-SHA256(100000 次迭代)+ 每用户随机盐存储,
登录常量时间比对,老库明文密码首登自动升级为哈希;WS 端点须 Token 鉴权;CORS 支持
SUI_ALLOWED_ORIGINS白名单(未配置时开发模式全允许);会话以sessions表为唯一 真源,支持多设备登录。 - 剪藏 id 幂等(M4):剪藏 id 改为 ≥128 bit 摘要(
clip-<32hex>),新增notes.source_url作幂等键 —— 同 URL 复用既有笔记(版本递增),异 URL 各自成篇。 - 编辑器交互增强(M5):格式态新增与工具栏同源的编辑快捷键(加粗 / 斜体 / 删除线 /
高亮 / 任务项 / 列表 / 引用 / 代码块 / 分隔线 / 链接 / 标题 / 缩进 / 清除格式,撤销重做沿用
原生栈);GFM 任务列表
- [ ]/- [x]可点选勾选框(仅改写方括号内字符,逐字符回写);==高亮==在格式态与预览态均渲染(tertiaryContainer底色);块级呈现单元(标题 / 列表 / 引用 / 代码 / 分隔线)与空块回车退出的聚焦式呈现。以上均为呈现层能力,正本 Markdown 字节不变(「打开 →(不编辑)→ 关闭」逐字节保真,BR-32.1 / AC-91 / AC-93)。 - 剪藏全页快照与图片本地化(M6):
POST /api/v1/clips新增mode(article默认 /snapshot);snapshot保留整页结构与文档顺序(标题 / 表格 / 图注保序),产出语义等价 Markdown(非逐像素还原)。两种模式均尽量本地化正文图片(下载 →sha256→ 附件库 → 正文改sui://<sha256>,映射随笔记入库),原网页下线后图片仍可从附件库读取;单图 失败 / 超限降级为保留绝对外链(不阻断整篇),数量经响应unlocalizedImages回带。 Chrome 扩展新增「智能提取正文 / 全页快照」分段控件(默认article、记住上次),采集时 等待完整 DOM 并触发懒加载图片。幂等键仍为notes.source_url,与mode无关。 - 桌面端界面布局优化(M7):桌面端左栏(笔记本树)与中栏(笔记列表)可分别折叠 / 展开,
折叠后面板与其分隔线一并消失,得到纯编辑区沉浸写作;顶栏左上角新增自绘菜单栏
「文件 / 编辑 / 视图 / 帮助」,菜单项与顶栏图标按钮、编辑快捷键同源(
desktopCommands单一来源),不可用命令置灰、有状态命令显示勾选、快捷键以右侧提示呈现。折叠状态与 「格式 / 源码 / 预览」编辑模式为本机视图偏好(ui.leftPanelCollapsed/ui.noteListCollapsed/ui.editorMode),重启后保持且不进同步;经菜单「退出」时 先flushPendingEdits落库、再尽力推送后退出。窄屏 / 移动端 / Web 不渲染菜单栏与折叠 开关,既有响应式导航不回归(FR-40 / FR-41)。 - 移动端编辑布局与图片块级插入(M7 补丁 / v0.8.2,B17 / B18):窄屏(编辑列宽 < 520px)
模式行改紧凑单行——「格式 / 源码 / 预览」只留图标,与「历史 / 导出」压成一行并收紧上下
留白,保留格式工具栏;常驻底部的附件条移除,改由格式工具栏「附件」按钮弹出附件面板,
为正文腾出编辑高度。图片引用以独占块(前后
\n\n)插入,编辑器正文显式strutStyle: StrutStyle.fromTextStyle(style, forceStrutHeight: false)——解除EditableText默认按 strut 高度强制排行的约束(该默认会忽略较高的行内WidgetSpan,导致图片溢出本行 遮挡后续文字、光标无法越过),使块高向下撑开、后续文字整体下移(AC-135 / AC-136)。 - 桌面端独立笔记窗口(M8):桌面端可将笔记在独立 OS 窗口中打开 —— 所有窗口共享
同一 Flutter 引擎 / isolate(单引擎多视图),因而共用同一
AppController、drift SQLite 连接、SettingsStore与附件缓存,窗口间通信为纯 Dart、无 IPC / 序列化。同一笔记 一笔记一窗口:重复打开即聚焦(已最小化则先还原);主窗口照常保留该笔记的编辑面 (两面并行、可同时编辑、实时互相同步),两窗口指向同一条笔记记录。窗口局部与全局状态分治:selectedNotebookId/selectedNoteId/ 搜索 / 标签筛选 / 排序为全局(独立窗口不得改写 —— BR-42.2 / AC-125), 而ui.window.note.editorMode(编辑模式)与当前编辑笔记的附件列表为窗口 / 编辑器局部 (附件刷新以refreshAttachments返回值取自该笔记,不回读全局字段)。编辑器命令目标按 视图键索引(主窗口固定kMainViewKey = 'main'、独立窗口取窗口句柄),由焦点事件驱动setActiveViewKey路由命令。生命周期:窗口注册表预置主窗口,关闭全部窗口即退出应用;菜单 「退出」=flushPendingEdits→ 尽力推送 → 关闭全部窗口 → 退出(FR-42 / FR-43 / BR-42 / BR-43)。接缝NoteWindowManager/WindowEventHub平台无关,窗口语义可在纯 Dart 单测中驱动(12 项)。 - 编辑器编辑能力与附件体验增强(M9 / v0.10.0):在「Markdown 唯一正本」原则不变的前提下 (架构零改动,与 M5 同一范式),补齐编辑器五大编辑能力:
- 表格工具(FR-44):格式工具栏「表格」按钮 → 自定义行列数对话框 → 光标处插入 GFM 管道表;表格呈现单元内置行/列增删按钮,改动即时回写 Markdown 正本。
- 粘贴保留格式 + 简化格式(FR-45):Ctrl+V 富粘贴——剪贴板 HTML → Markdown 转换 (表格 / 列表 / 引用 / 粗斜体 / 链接 / 代码等),无法转换时降级纯文本并转义 Markdown 特殊字符; Ctrl+Shift+V 纯粘贴——纯文本转义后插入。工具栏「简化格式」一键剥离行内格式(粗/斜/删/ 高亮/行内代码),保留块级结构(列表/引用/代码块/表格)。
- 附件整体编辑单元(FR-46):
[name](sui://<sha256>)附件引用在格式态渲染为整块「附件 胶囊」,退格/删除键整块移除(而非逐个字符删除),残缺引用自动自愈;源码模式不受约束。 - 附件预览与外部编辑回写(FR-47):点附件卡片弹出预览弹窗——文本(只读截断)/ 图片
(缩放)/ PDF 与其他(元信息卡);「用系统默认应用打开」启动外部编辑器,文件变更后自动
回写(新 sha256 → 更新正文
sui://引用 → 上传同步),一处变更多端可见。 - 有序列表自动编号(FR-48):Markdown 正本统一
1.(写回时不递增),格式态与预览态 按 DOM 顺序渲染为 1. / 2. / 3.;手动输入非 1 数字或空行隔断后编号重启。 所有能力均为呈现层 / 编辑器层增强,Markdown 正本字节在「未编辑」时逐字节不变 (FormatNoTouchFidelity门禁 8 条全绿)。 - 格式模式「所见即预览」与表格边界保护(M9 补丁 / v0.10.13):在正本仍为 Markdown 的前提下 (架构零改动),把「格式」模式收敛为与「预览」同源的所见即所得呈现:
- 记号彻底不可见(BR-23.7 / AC-172):凡被解析为语法的字符(
**/__/*/_/~~/==/`/#前缀 /-/1./>/ 围栏行 /---/ 链接语法)一律以零宽 + 透明的等码元替身呈现,改由真实格式承载(粗体 / 底色 / 标题字号 / 项目符号 / 连续编号 / 勾选框 / 引用竖条 / 水平线 / 仅链接标签);未识别的记号仍按普通文本显示——两态可见文本 逐例比对(FormatPreviewParity)。扫描口径与预览同源:围栏内部、良构表格内部不扫描, 缩进越界的行不建立行级前缀记号。 - 格式单元原子化(BR-23.8 / AC-173 / AC-174):折叠光标不停在记号内部(自动跨到外侧 边界,内容仍可编辑);删除键命中记号时一次取消整处格式且内容逐字保留(成对记号去整对、 行级前缀去整行、围栏 / 分割线连行移除),选区删除不留残缺记号。
- 缩进只产出 GFM 合法结构(BR-23.9 / AC-176):无合法父列表项时最多缩进到 3 个空格
(再缩进原样不动),有父列表项时落在合法层级内;缩进越界的行在两态都按普通文本呈现,
杜绝「格式态是勾选框、预览态是
[ ]字面量 / 缩进代码块」。 - 表格永不「打回原形」(BR-44.11 / AC-175):表格首 / 末行两侧的换行受保护,任何编辑不得
把表格行与相邻行文字合并——命中边界时按「先取消相邻行的块级记号 → 空行允许默认合并 →
其余吞掉按键」处理;
Backspace落在表格下方默认空行行首仍整块删表(BR-44.6 不回归)。 - 表格单元格内亦复用同一套行内记号隐藏与原子删除(§12.1.2 延伸)。
-
超链接弹框录入与工具栏快捷键提示(M9 补丁 / v0.10.14):同为编辑器层增强,正本仍为 Markdown(守 BR-23.1): 「链接」由「插入
[文字](url)占位文本」改为弹框录入(显示文字 + 网址;网址为空不落笔;显示文字留空 以网址充当;未写协议自动补https://,mailto:/#锚点//相对路径原样保留),Ctrl+K与工具栏按钮 同源走同一路径;表格单元格内亦可插入链接(上下文栏「链接」按钮 + 单元格获焦时正文工具栏的「链接」 路由到该单元格,以<br>软换行追加、其余单元格逐字不动);工具栏按钮的悬浮提示带键位说明 (加粗 (Ctrl+B)),键位由编辑器Shortcuts表反查生成(单一来源,改键位提示自动跟随), 顺带补齐「插入表格」的Ctrl+Shift+T(桌面「编辑」菜单同步显示)。 -
认证与会话(M10 / FR-49):服务端签发短时访问令牌(30 分钟)+ 可撤销刷新令牌(30 天), 刷新为单次使用 + 轮换;轮换时把旧哈希挪到
prev_refresh_token_hash,故重放旧刷新令牌可被识别 并吊销整个会话(refresh-revoked)。401 错误码可区分(token-expired/invalid_token), 客户端据此单飞刷新 + 重放一次(并发去重;invalid_token亦允许一次性刷新,见auth.md§8.5 —— 该详细设计文档随项目技术文档单独维护,不随本仓库发布)。 会话与 WS 连接绑定:会话被吊销即断开连接。 - 受保护通道(M10 / FR-50):
http://地址下应用层加密,https://走 TLS 不叠加。 服务端持有长期 X25519 密钥(<数据目录>/securechan.key,0600),客户端经GET /api/v1/crypto/handshake取公钥 + 指纹并 TOFU 记录,指纹变化即阻断。 逐请求:临时 X25519 → ECDH → HKDF-SHA256 →K_chan,正文以 AES-256-GCM 封装为base64(ver|alg|nonce|ct|tag),AAD = method\npath\nreqId,请求头X-Sui-Enc/X-Sui-Eph/X-Sui-Req-Id;服务端维护有界重放缓存(TTL 5 分钟 / 4096 条)。 客户端接缝在http.BaseClient包装层,push/pull 与合并逻辑零改动。 - 加密笔记本(M10 / FR-51):笔记本级端到端加密,服务端只搬密文、无解密分支。
K_nb = HKDF-SHA256(Argon2id(锁定密码, salt, m=64 MiB, t=3, p=1), info="sui-notebook-v1"),verifier用于快速校验密码;密钥只驻内存、不落库、不上行(回锁即删)。 字段密文为同一ver|alg|nonce|ct|tag封装,AAD绑定「笔记本 + 笔记 + 字段」(防密文搬运)。 落库/传输层加密,编辑器与合并逻辑仍以 Markdown 明文为正本;未解锁端列表显示占位、 不进编辑、不纳入搜索;空闲 15 分钟自动回锁(可配置,活动续期)。 -
测试现状(M10 收口):服务端 Go 测试全部通过(M10 新增
m10_test.go/m10_auth_test.go/m10_clip_test.go/m10_encnotebook_test.go/m10_securechan_test.go/m10_transport_test.go与internal/securechan/channel_test.go);note_core294 项(含 5 个 e2e:同步、受保护通道、 加密笔记本跨端、令牌刷新、既有同步链);flutter_app171 项。加密封装另有 Spike 向量 (sui-crypto-v1)在 Go 与 Dart 两侧逐字节门禁。 -
逐项同步状态与云端核对补齐(M12 / v0.12.0,FR-53 ~ FR-55):
notes/notebooks/tags三表新增本地状态列(五态已同步/待上传/仅本地/冲突/同步失败),界面就地呈现 状态图标与缘由(悬停可见,窄屏降级为状态点);支持单项「立即上传 / 重试」与全局 「全部重新同步」(核对 → 补齐,可取消、幂等、给出逐项结果与失败原因)。上行队列改由库中状态 重建(内存 Outbox 退役);服务端 push 逐项裁决显式化(notFound+ 显式serverVersion: 0), 使「换服务端 / 服务端重建后推不上去」可自愈;拉取游标持久化并回退 1 秒;服务端新增meta表 与云端实例身份instanceId(pull恒下发 /ping仅鉴权时下发),换库 / 重建由用户在 三选一提示中决断(选择前不动任何一端数据)。状态为纯本地记账,Markdown 正本与 同步净荷均不变(架构零改动)。 - 测试现状(M12 收口):
note_core329 项全量通过(M12 新增sync_status_m12_test.dart15 项 schema_migration_v8_test.dart1 项);服务端go test ./...全绿(M12 新增internal/api/m12_test.go4 项);flutter_app171 项全绿(含新增同步状态图标与单项 / 全局入口的 widget 用例 12 项);Windows 桌面目标flutter build windows --debug通过。
8.2 历史缺口(均已修复)¶
| # | 缺口 | 状态 | 影响 |
|---|---|---|---|
| 1 | 同步链路未接线:SyncClient 从未实例化,blobStore 从未注入 |
✅ 已修复 | 客户端原为纯本地编辑器;附件按需下载/LRU 机制曾是死代码 |
| 2 | 无登录 / 服务端地址配置 UI | ✅ 已修复 | 客户端原无法连接服务端 |
| 3 | 服务端 attachments 表为半成品(建表但无读写方法与协议字段) |
✅ 已修复 | 附件-笔记映射无法跨端重建 |
| 4 | 无附件上传 / 选择器 | ✅ 已修复 | 用户无法添加附件 |
| 5 | 缺桌面/移动平台脚手架目录 | ✅ 已修复 | 这些端暂不可构建(代码路径已就绪) |
| 6 | lib/src/home_page.dart 为 M0 死代码 |
✅ 已修复 | 冗余,易误导 |
| 7 | README / 开发者文档的运行命令与实际不符 | ✅ 已修复 | 按文档操作会失败 |
| 8 | 文档「核心特性」全 ✅ 但部分未在客户端生效 | ✅ 已修复 | 认知偏差 |
| 9 | 笔记本分组 / 标签 / 笔记-标签关联无云端存储与同步 | ✅ 已修复 | 换设备后分组树与标签不跟随;M1 补齐服务端三表 + 协议净荷 |
| 10 | WebSocket 端点无需鉴权,任意客户端均可订阅变更广播 | ✅ 已修复 | 未授权方可感知笔记变更;M4 起 WS 须 Token 鉴权 |
| 11 | 剪藏笔记 id 与手写笔记共用 64 bit 摘要命名空间、可能碰撞 | ✅ 已修复 | 剪藏可能覆盖同名笔记;M4 起改为 ≥128 bit 摘要 + source_url 幂等 |
| 12 | 换服务端 / 服务端重建(回滚)后本地实体基线非 0 而云端无记录 → 永久冲突、再也推不上去(B24-①) | ✅ 已修复 | 改动永久卡在本机;M12 起服务端回 notFound + 显式 serverVersion: 0,客户端基线归零并同轮重发 |
| 13 | 上传意图只存在于内存 Outbox(B24-②) | ✅ 已修复 | 重启 / 换服务端后未上传改动静默滞留本机;M12 起上行队列 = 库中 sync_state(状态即队列) |
| 14 | 拉取游标为进程内且无回退(B24-③) | ✅ 已修复 | 同一秒内的多条变更被永久漏拉;M12 起游标持久化于 settings 并取 max(updated_at) − 1s(下行幂等,重放安全) |
8.3 未实现的设计项¶
- 缩略图生成(附件方案 C)。
- 桌面端「全量镜像」开关。
- Web 端 BlobStore 的 IndexedDB 实现(当前为内存)。
- FTS5 全文搜索(当前为
LIKE子串匹配;表结构已预留)。 - 富文本 WYSIWYG 编辑器(
flutter_quill升级,当前为「格式 / 源码 / 预览」三态)。 - WS 升级头不在受保护通道内(M10 已知边界):浏览器无法自定义 WebSocket 升级请求头,故
Sec-WebSocket-Protocol: bearer.<token>在http://下对被动观察者可见。加固方向:经加密通道 换取一次性短时 WS ticket。 - 加密笔记本的服务端检索:服务端只见密文,故服务端无法对加密笔记本做全文检索; 客户端在解锁后于本端对明文搜索(未解锁不纳入搜索范围)。
- 加密笔记本内附件字节的静态加密:映射随笔记同步(明文元数据),字节仍内容寻址、 仅传输时加密;静态加密列为将来可选项(未解锁时访问入口隐藏)。
- Argon2id 解锁耗时的真机数据:现仅测得 WSL2 VM 上 m=64 MiB / t=3 / p=1 ≈ 937 ms; Android / iOS / Web 未实测(Web 端为纯 Dart 或 WASM 兑算,风险最高)。
- 通道正文整包缓冲:请求与响应均先整体缓冲再加解密,单请求峰值内存约 2× 报文大小; 流式加密为后续优化项。
- 核对补齐首版做「带正文」的全量 pull(M12 已知取舍):
reconcile()以pull?since=epoch取云端全量权威态,因此首次核对会把所有笔记正文拉一遍;数据量大时首轮较慢,但幂等且只此一次。 pull?meta=1精简清单(演进项,当前未实现):另提供「只回 id / version / isDeleted 的轻量 清单」用于核对,可把首轮全量 pull 降为清单比对 + 按需拉正文。- 标签的同步状态目前仅落在数据层:
tags已有五态记账,但界面尚未为标签单列状态入口 (笔记列表与笔记本树上的状态可就地辨认)。
8.4 M10 安全加固的实现要点(FR-49 / FR-50 / FR-51)¶
| 关注点 | 结论 |
|---|---|
| 分层 | 受保护通道保护线路(服务端可解密);加密笔记本保护数据本身(服务端不可解密) |
| 密钥来源 | 通道密钥逐请求由临时 X25519 协商(天然前向保密);笔记本密钥由锁定密码派生,不落库、不上行 |
| 服务端口径 | 只搬运密文:notebooks.encrypted / crypto_meta、notes.encrypted 三个线上字段 透传不解析 |
| 明文兼容 | 通道是加成而非强制:未声明 X-Sui-Enc 的请求照常处理(同一二进制要服务未升级客户端) |
| 未解锁语义 | 列表占位、不进编辑、不搜索;未解锁拒绝写入(否则会把占位当内容覆盖密文) |
| 形态转换 | 移入/移出加密笔记本就地加解密(含既有修订);同本移动幂等;解锁态不可得时拒绝移动 |
| 冲突合并 | 加密笔记的合并在明文层完成;未解锁端不就地合并(保持排队),保证「绝不丢字」 |
| 重放防护 | 刷新令牌单次使用 + 轮换 + 旧哈希留存;通道请求 id有界缓存去重 |
8.9 M12 修订(v0.12.2):会话入口统一、本地存储位置与登录防护¶
三项用户诉求归口,均不改变 Markdown 唯一正本与同步协议净荷(FR-57 / FR-58 / FR-59):
- 会话入口统一(FR-57):客户端「同步设置」删除手工令牌输入(
connectWithToken保留为 内部 / 测试装配 API);剪藏扩展新增账号密码登录,令牌(访问 + 刷新)存chrome.storage.local, 请求 401 时单飞刷新 + 重放一次,刷新失败即清令牌并要求重新登录,不保存口令。 扩展不实现应用层受保护通道(内容本身公开、简化设计)→ 公网必须使用https://, 设置页对http://非本机地址给出警示。 - 桌面端本地存储位置(FR-58):数据目录由库外引导指针
<应用支持根>/storage.json(原子写)决定;「同步设置 → 本地存储」可把sui.sqlite与blobs/迁移到任意可写目录, 过程为「落盘(wal_checkpoint)→ 复制 → 校验(关键表行数 + 附件数与总字节)→ 原子切换指针 → 重启生效」事务,失败不切换指针、旧目录保留。指针目录不可用时启动整屏阻断并三选一 (重试 / 改回默认目录 / 重新指定),绝不静默回落默认目录。迁移期间控制器进入维护态, 暂停同步调度;与本机目录无关的服务端数据 / 会话 / 设备 ID 均保持不变。 - 登录失败防护(FR-59):
server/internal/api/login_guard.go在POST /api/v1/login前置判定, 键 =lower(username) + 来源;≥5 次起指数退避(上限 30s),≥10 次锁定 15 分钟, 锁定返回429+Retry-After且正确口令同样被拒,成功即清零;同键串行化防止并发 请求把延迟放大为 DoS;键表 LRU 有界。状态只驻内存(重启即解锁,单用户自托管逃生口), 失败 / 锁定 / 解锁写审计日志且不含口令;阈值环境变量化、时钟可注入(门禁不因真实等待变慢)。 部署侧配套 nginxlimit_req/limit_conn作为第二层(详见部署文档 §11)。
测试现状(本版新增):服务端新增 m12_login_guard_test.go 10 项(退避 / 锁定 /
锁定拒绝正确口令 / 成功清零 / 重启解锁 / 审计不含口令 / 账号存在性响应一致 / 默认不信任
X-Forwarded-For / 开启信任后按最右跳 / 关闭防护),go vet 与 go test ./... 全绿;
flutter_app 新增 storage_location_test.dart 10 项与 storage_settings_test.dart 6 项,
flutter analyze 无问题;扩展侧新增 Node 自动化门禁 extension/test/session_test.mjs 9 项
(登录 / 主动刷新 / 401 单飞刷新重放 / 并发单飞 / 刷新被拒清令牌 / 明文警示 / 剪藏带会话,
node --test 运行),popup 界面与站点权限弹窗仍为真机手测。