元数据从前端同步到后端统一管理的完整重构记录

December 14, 2025
6 min read
By devshan

Table of Contents

This is a list of all the sections in this post. Click on any of them to jump to that section.

一、起点:文本文件图标与元数据“不同步”的问题

这次重构的起点是一个看起来很小、但暴露出架构问题的 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、EncryptedSize
    • IsDir、MimeType
    • CreatedAt、UpdatedAt
  • MetadataIndex:整体索引:
    • Version、UpdatedAt
    • Files: 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。

完整逻辑:

  1. 使用 fileID := c.Param("id") 和 masterKey 派生文件密钥;
  2. 重新加密上传的新内容,覆盖 files/<id>.enc;
  3. 加载元数据索引 meta := s.loadMetadataIndex(ctx);
  4. 分两种情况:
    • 如果索引中已有该 id:
      • 保留 Name、Path、CreatedAt、IsDir;
      • 仅更新:
        • Size(原始文件大小);
        • EncryptedSize(加密后长度);
        • MimeType(如果 header 中有新的类型);
        • UpdatedAt(当前时间)。
    • 如果索引中没有:
      • 退化为“补建一条基础 metadata”,避免 metadata 与 S3 完全脱节。
  5. 保存索引,并把更新后的 FileMetadata 返回给前端。

这一步直接解决了最早的图标问题:

  • 之前前端可能把 mimeType 搞丢(或被错误覆盖),导致图标逻辑拿到的是一个空或不匹配的 MIME 类型;
  • 现在更新是由后端统一完成,并且是“在原 metadata 上修改必要字段”,不会把 mimeType 无意义地重置。

四、第三步:把“目录结构操作”全部后端化

前端原本做了很多和目录结构有关的操作,典型例子在 client/lib/core/state/app_state.dart:

  • createFolder / createFolderAt:
    • 前端自己生成 id 和 FileMetadata;
    • 写入 _files 列表;
    • 最后整包 _syncMetadataToS3() 到后端。
  • 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。

前端改动(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) 的新行为:
    1. 先调用 api.moveFiles(fileIds, targetPath),如果失败直接报错,不再本地改路径;
    2. 调用成功后,为了 UI 立即更新:
      • 按原来 Dart 版的算法在 _files 上做同样的路径更新;
      • 但不再 _syncMetadataToS3()。

这保证了:

  • 目录结构的“权威状态”在后端;
  • 前端做的只是“本地的镜像更新”,并且与后端算法保持一致。

五、第四步:删除操作与目录级删除的语义统一

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)逻辑:

  1. 要求已解锁(有 masterKey);
  2. 加载 metadata 索引;
  3. 如果索引中不存在该 id:
    • 为兼容旧数据,仍然尝试删除 files/<id>.enc;
    • 返回 {"status":"deleted"}。
  4. 如果存在:
    • 如果是目录:
      • 计算 dirPrefix = file.Path + file.Name + "/";
      • 遍历所有 meta.Files:
        • 如果某个条目的 Path 以 dirPrefix 开头:
          • 如果它是文件 → 删除对应 S3 对象 files/<childId>.enc;
          • 不论文件/目录 → 一并从 meta.Files 中删除;
      • 最后删除目录自身的 meta.Files[fileID]。
    • 如果是文件:
      • 删除 S3 上的 files/<id>.enc;
      • 从 meta.Files 中 delete(fileID)。
  5. 保存 metadata 索引;
  6. 返回 {"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 中加入更丰富的字段:
    • 标签、收藏、访问次数等;
    • 有了统一的后端维护,这类信息就不再难以演进。

这次重构的关键经验可以一句话概括:

凡是会影响系统整体一致性的核心数据结构,尽量收拢到一侧(这里是后端)集中维护,其他层只做视图和缓存。