跳转至

系统架构

本文描述随手记 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 用 ASCII Sui:.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_core 294 项(含 5 个 e2e:同步、受保护通道、 加密笔记本跨端、令牌刷新、既有同步链);flutter_app 171 项。加密封装另有 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_core 329 项全量通过(M12 新增 sync_status_m12_test.dart 15 项
  • schema_migration_v8_test.dart 1 项);服务端 go test ./... 全绿(M12 新增 internal/api/m12_test.go 4 项);flutter_app 171 项全绿(含新增同步状态图标与单项 / 全局入口的 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 有界。状态只驻内存(重启即解锁,单用户自托管逃生口), 失败 / 锁定 / 解锁写审计日志且不含口令;阈值环境变量化、时钟可注入(门禁不因真实等待变慢)。 部署侧配套 nginx limit_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 界面与站点权限弹窗仍为真机手测。