一、起点:文本文件图标与元数据“不同步”的问题
这次重构的起点是一个看起来很小、但暴露出架构问题的 bug:
- 文本文件在编辑保存后:
- 文件内容确实更新成功;
- 但列表里的文件图标会变成“通用文件”图标;
- 某些情况下,大小、更新时间等元信息也不准确。
更早一次排查(见同目录的另一篇笔记)已经发现:
- 元数据(
FileMetadata)既由前端维护,又由后端部分返回; - 前端通过
_syncMetadataToS3把自己的内存状态整包写回 S3 上的.e2eepan/meta.enc; - 后端在上传、更新文件时,对这份元数据几乎不关心,只当“附带信息”,甚至可以缺失。
这导致了一系列典型的“分布式状态”问题:
- 前端不知道后端真正保存的是什么;
- 后端也不知道前端什么时候会把一份“过期”的 metadata 覆盖回去;
- 任意一端的小 bug 都可能让两个世界永久不一致。
这次重构的目标由此明确下来:
元数据必须是“单一事实来源”(Single Source of Truth),而且应该在后端。前端只读、只通过 API 触发变更。
二、第一步:在后端引入系统化的 Metadata 读写
在真正“拆掉”前端的 _syncMetadataToS3 之前,先要让后端具备完整读写元数据索引的能力,而不是零散地操作。
2.1 元数据模型
后端在 core/internal/api/server.go:31-48 定义了两层结构:
FileMetadata:单个文件/文件夹的元信息:ID、Name、Path、Size、EncryptedSizeIsDir、MimeTypeCreatedAt、UpdatedAt
MetadataIndex:整体索引:Version、UpdatedAtFiles: map[string]*FileMetadata(以 id 作为 key)
索引文件存放在 S3 的 .e2eepan/meta.enc,本身也是加密的。
2.2 标准化的加载与保存
之前 getMetadata/syncMetadata 是临时写的逻辑,这次重构抽象成了通用方法(core/internal/api/server.go:472-538):
loadMetadataIndex(ctx):- 如果
.e2eepan/meta.enc不存在 → 返回一个空的MetadataIndex; - 如果存在:
- 使用当前
masterKey派生出 “metadata 专用密钥”; - 解密字节流,反序列化成
MetadataIndex。
- 使用当前
- 如果
saveMetadataIndex(ctx, meta):- 更新
meta.UpdatedAt为当前时间; - 序列化 JSON;
- 使用同样的 “metadata 专用密钥” 加密;
- 上传到
.e2eepan/meta.enc。
- 更新
updateMetadataIndex(ctx, file *FileMetadata):- 加载索引;
- 按 id 插入/更新指定的
FileMetadata; - 再保存索引。
有了这三个函数,后端就能用一致的方式维护 metadata,而不用每个接口各写一套加解密逻辑。
三、第二步:让上传 / 更新内容正确维护元数据
3.1 上传文件:写入 metadata
接口:POST /api/v1/files/upload,实现位于 core/internal/api/server.go:150-210。
这一步的改动核心是:
- 在成功加密并上传文件到 S3 后,构造一条完整的
FileMetadata:ID使用uuid.New();Name、Path来自上传表单(file+path);Size使用原始文件大小;EncryptedSize使用加密后缓冲区长度;IsDir = false;MimeType从上传头获取;CreatedAt、UpdatedAt都设为当前时间。
- 调用
updateMetadataIndex(ctx, &metadata):- 把这条 metadata 写入索引文件。
- 返回这条
metadata给前端。
这样保证:
- 每次上传文件,不仅 S3 上有
files/<id>.enc,而且.e2eepan/meta.enc里也有对应的结构化记录; - 前端 UI 在拿到
FileMetadata后,只需要缓存与展示,不必再自己构造元信息。
3.2 更新文件内容:只改“内容相关”的字段
接口:PUT /api/v1/files/:id,实现位于 core/internal/api/server.go:212-293。
完整逻辑:
- 使用
fileID := c.Param("id")和masterKey派生文件密钥; - 重新加密上传的新内容,覆盖
files/<id>.enc; - 加载元数据索引
meta := s.loadMetadataIndex(ctx); - 分两种情况:
- 如果索引中已有该 id:
- 保留
Name、Path、CreatedAt、IsDir; - 仅更新:
Size(原始文件大小);EncryptedSize(加密后长度);MimeType(如果 header 中有新的类型);UpdatedAt(当前时间)。
- 保留
- 如果索引中没有:
- 退化为“补建一条基础 metadata”,避免 metadata 与 S3 完全脱节。
- 如果索引中已有该 id:
- 保存索引,并把更新后的
FileMetadata返回给前端。
这一步直接解决了最早的图标问题:
- 之前前端可能把
mimeType搞丢(或被错误覆盖),导致图标逻辑拿到的是一个空或不匹配的 MIME 类型; - 现在更新是由后端统一完成,并且是“在原 metadata 上修改必要字段”,不会把
mimeType无意义地重置。
四、第三步:把“目录结构操作”全部后端化
前端原本做了很多和目录结构有关的操作,典型例子在 client/lib/core/state/app_state.dart:
createFolder/createFolderAt:- 前端自己生成 id 和
FileMetadata; - 写入
_files列表; - 最后整包
_syncMetadataToS3()到后端。
- 前端自己生成 id 和
moveFiles:- 前端自己负责递归更新路径:
- 目录的
path; - 子文件/子目录的
path前缀替换;
- 目录的
- 然后再次整包
_syncMetadataToS3()。
- 前端自己负责递归更新路径:
renameFile:- 前端只改
_files[index].name和updatedAt; - 再
_syncMetadataToS3()。
- 前端只改
copyFiles:- 前端下载 → 上传新文件;
- 同样再
_syncMetadataToS3()把新状态写回。
这种做法的问题在于:
- 前端可以绕开任何后端逻辑直接写 metadata;
- 后端对目录结构“一无所知”,无法做更高级的控制(配额、权限、审计等)。
为了解决这个问题,这次重构增加了三个专门的后端接口,并改造了前端调用。
4.1 创建文件夹:POST /api/v1/folders
后端实现:createFolder(core/internal/api/server.go 中新增)。
- 请求体:
{ "id": "可选", "name": "文件夹名", "path": "父路径" } - 行为:
- 如果未传 id,则在后端生成一个;
- 构造一个
IsDir = true、MimeType = "folder"的FileMetadata; - 写入 metadata 索引;
- 返回该
FileMetadata。
前端对应改动(client/lib/core/state/app_state.dart:973-997):
createFolder现在改为:- 调用
api.createFolder(id: uuid, name: name, path: _currentPath); - 成功后把返回的
FileMetadata加入_files; - 不再调用
_syncMetadataToS3()。
- 调用
createFolderAt用于拷贝目录时的子目录创建,同样改成调用api.createFolder。
4.2 重命名:PUT /api/v1/files/:id/rename
后端实现:renameFile(core/internal/api/server.go 中新增)。
- 请求体:
{ "name": "新名称" } - 行为:
- 在 metadata 索引中找到对应
FileMetadata; - 更新
Name和UpdatedAt; - 保存索引并返回更新后的
FileMetadata。
- 在 metadata 索引中找到对应
前端改动(client/lib/core/state/app_state.dart:1164-1187):
renameFile(fileId, newName)逻辑变为:- 先调用
api.renameFile(fileId, newName); - 成功后,用返回的
FileMetadata替换本地_files中对应条目; - 不再本地自改 name,也不再
_syncMetadataToS3()。
- 先调用
4.3 移动:POST /api/v1/files/move
后端实现:moveFiles(core/internal/api/server.go 中新增)。
- 请求体:
{ "fileIds": ["id1", "id2", ...], "targetPath": "/目标路径/" } - 行为:
- 加载 metadata 索引;
- 对每个 id:
- 如果是目录:
- 根据原始
file.Path与file.Name计算旧前缀oldDirPath; - 根据
targetPath计算新的newDirPath; - 更新目录自身的
Path; - 遍历所有其他
FileMetadata,凡是Path以旧前缀开头的,替换为新前缀; - 更新所有受影响条目的
UpdatedAt。
- 根据原始
- 如果是文件:
- 直接更新文件的
Path = targetPath和UpdatedAt。
- 直接更新文件的
- 如果是目录:
- 保存 metadata 索引;
- 返回
{"status":"ok"}。
前端改动(client/lib/core/state/app_state.dart:1060-1095):
moveFiles(fileIds, targetPath)的新行为:- 先调用
api.moveFiles(fileIds, targetPath),如果失败直接报错,不再本地改路径; - 调用成功后,为了 UI 立即更新:
- 按原来 Dart 版的算法在
_files上做同样的路径更新; - 但不再
_syncMetadataToS3()。
- 按原来 Dart 版的算法在
- 先调用
这保证了:
- 目录结构的“权威状态”在后端;
- 前端做的只是“本地的镜像更新”,并且与后端算法保持一致。
五、第四步:删除操作与目录级删除的语义统一
5.1 原有删除逻辑的问题
原来的 DELETE /api/v1/files/:id(core/internal/api/server.go:394-405)只做了:
- 直接删除 S3 上的
files/<id>.enc; - 完全不管 metadata 索引;
- 也不知道被删的 id 是文件还是“逻辑目录”。
前端在 AppState.deleteFile 中:
- 删除成功后,从
_files中移除该条目; - 再
_syncMetadataToS3()把“少了一条记录”的索引写回; - 目录删除时,子项是否被删除,完全取决于前端怎么改
_files。
这显然不符合“后端统一管理”的目标。
5.2 新的删除语义:后端负责整个子树
重构后的 deleteFile(core/internal/api/server.go:394-430)逻辑:
- 要求已解锁(有
masterKey); - 加载 metadata 索引;
- 如果索引中不存在该 id:
- 为兼容旧数据,仍然尝试删除
files/<id>.enc; - 返回
{"status":"deleted"}。
- 为兼容旧数据,仍然尝试删除
- 如果存在:
- 如果是目录:
- 计算
dirPrefix = file.Path + file.Name + "/"; - 遍历所有
meta.Files:- 如果某个条目的
Path以dirPrefix开头:- 如果它是文件 → 删除对应 S3 对象
files/<childId>.enc; - 不论文件/目录 → 一并从
meta.Files中删除;
- 如果它是文件 → 删除对应 S3 对象
- 如果某个条目的
- 最后删除目录自身的
meta.Files[fileID]。
- 计算
- 如果是文件:
- 删除 S3 上的
files/<id>.enc; - 从
meta.Files中delete(fileID)。
- 删除 S3 上的
- 如果是目录:
- 保存 metadata 索引;
- 返回
{"status":"deleted"}。
前端对应 AppState.deleteFile(client/lib/core/state/app_state.dart:925-943)简化为:
- 只调用
api.deleteFile(fileId),成功后:- 从
_files中移除该条目; - 清理缩略图缓存;
- 不再触碰 metadata 的持久化。
- 从
目录级删除的“子树处理”完全移到了后端,保证刷新/重启后目录结构仍然一致。
六、第五步:彻底移除前端的 _syncMetadataToS3
在前面的步骤中,所有会修改 metadata 的操作已经有了对应的后端接口:
- 上传文件、更新内容 →
uploadFile/updateFile; - 创建目录 →
createFolder; - 重命名 →
renameFile; - 移动 →
moveFiles; - 删除 →
deleteFile。
因此,前端的 _syncMetadataToS3 就变成了一个危险的“全量重写”出口。
最终改动:
- 删除
AppState中_syncMetadataToS3定义; - 清空所有调用点,包括:
uploadFile;- 上传队列
_startUploadJob; createTextFile;createFolder/createFolderAt;moveFiles;copyFiles;deleteFile。
现在:
- 前端只在初始化和刷新时调用
getMetadata; - 不再写入/覆盖
.e2eepan/meta.enc; - 元数据的“写入路径”只有后端的若干 HTTP API。
七、当前架构下的元数据流向(总结)
7.1 写路径(Write Path)
- 创建/上传文件:
client AppState→ApiClient.uploadFile→POST /api/v1/files/upload;- 后端:
- 加密写
files/<id>.enc; - 通过
updateMetadataIndex写入/更新meta.Files[id]。
- 加密写
- 更新文件内容:
client→updateFileContent→PUT /api/v1/files/:id;- 后端:
- 覆盖
files/<id>.enc; - 只更新尺寸、加密尺寸、mimeType、updatedAt。
- 覆盖
- 创建目录:
client→createFolder→POST /api/v1/folders;- 后端:
- 仅更新 metadata 索引(目录本身没有对应 S3 对象)。
- 重命名:
client→renameFile→PUT /api/v1/files/:id/rename;- 后端更新
Name和UpdatedAt。
- 移动:
client→moveFiles→POST /api/v1/files/move;- 后端负责整个子树的
Path更新。
- 删除:
client→deleteFile→DELETE /api/v1/files/:id;- 后端负责:
- 删除 S3 中对应文件;
- 删除 metadata 中该节点及目录子树。
7.2 读路径(Read Path)
- 前端启动 / 刷新:
- 调用
GET /api/v1/metadata; - 后端通过
loadMetadataIndex解密返回完整索引; - 前端将
meta.files.values变成_files列表; - 并同步一份到 sqlite,用于离线模式。
- 调用
至此,元数据真正达到了“后端单源”的状态:
- 前端不再需要知道
.e2eepan/meta.enc存在; - 前端只通过模型
FileMetadata+ API 响应来感知当前世界; - 刷新 / 重启 / 跨设备访问,也都是从同一份加密索引文件中读取。
八、收获与后续可能的演进方向
8.1 收获
- 小 bug 暴露大问题:
- 一个“文本图标不对”的细节,追下去其实是“元数据多源写入”的架构隐患。
- 明确职责边界:
- “真正可以持久化的东西”应该尽量放在后端;
- 前端做缓存、展示和乐观更新,但不直接写核心状态。
- 抽象的价值:
loadMetadataIndex/saveMetadataIndex/updateMetadataIndex这三个函数,把“操作一小段 JSON”升级为“维护一个结构化索引”;- 后续再加比如“回收站”、“分享”、“权限”等,都可以复用同一条数据通路。
8.2 下一步可以做的事情
- 把目前还在 Dart 端的“复制目录树”逻辑,也迁移到 Go 后端:
- 现在复制目录是“前端下载再上传”,后端只看到一堆新的 upload;
- 将来可以考虑在后端直接做“文件级 copy”,甚至不同存储后端之间迁移。
- 在 metadata 中加入更丰富的字段:
- 标签、收藏、访问次数等;
- 有了统一的后端维护,这类信息就不再难以演进。
这次重构的关键经验可以一句话概括:
凡是会影响系统整体一致性的核心数据结构,尽量收拢到一侧(这里是后端)集中维护,其他层只做视图和缓存。