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

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:单个文件/文件夹的元信息:
    • IDNamePathSizeEncryptedSize
    • IsDirMimeType
    • CreatedAtUpdatedAt
  • MetadataIndex:整体索引:
    • VersionUpdatedAt
    • 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()
    • NamePath 来自上传表单(file + path);
    • Size 使用原始文件大小;EncryptedSize 使用加密后缓冲区长度;
    • IsDir = false
    • MimeType 从上传头获取;
    • CreatedAtUpdatedAt 都设为当前时间。
  • 调用 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:
      • 保留 NamePathCreatedAtIsDir
      • 仅更新:
        • 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].nameupdatedAt
    • _syncMetadataToS3()
  • copyFiles
    • 前端下载 → 上传新文件;
    • 同样再 _syncMetadataToS3() 把新状态写回。

这种做法的问题在于:

  • 前端可以绕开任何后端逻辑直接写 metadata;
  • 后端对目录结构“一无所知”,无法做更高级的控制(配额、权限、审计等)。

为了解决这个问题,这次重构增加了三个专门的后端接口,并改造了前端调用。

4.1 创建文件夹:POST /api/v1/folders

后端实现:createFoldercore/internal/api/server.go 中新增)。

  • 请求体:
    { "id": "可选", "name": "文件夹名", "path": "父路径" }
  • 行为:
    • 如果未传 id,则在后端生成一个;
    • 构造一个 IsDir = trueMimeType = "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

后端实现:renameFilecore/internal/api/server.go 中新增)。

  • 请求体:
    { "name": "新名称" }
  • 行为:
    • 在 metadata 索引中找到对应 FileMetadata
    • 更新 NameUpdatedAt
    • 保存索引并返回更新后的 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

后端实现:moveFilescore/internal/api/server.go 中新增)。

  • 请求体:
    {
      "fileIds": ["id1", "id2", ...],
      "targetPath": "/目标路径/"
    }
  • 行为:
    • 加载 metadata 索引;
    • 对每个 id:
      • 如果是目录:
        • 根据原始 file.Pathfile.Name 计算旧前缀 oldDirPath
        • 根据 targetPath 计算新的 newDirPath
        • 更新目录自身的 Path
        • 遍历所有其他 FileMetadata,凡是 Path 以旧前缀开头的,替换为新前缀;
        • 更新所有受影响条目的 UpdatedAt
      • 如果是文件:
        • 直接更新文件的 Path = targetPathUpdatedAt
    • 保存 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/:idcore/internal/api/server.go:394-405)只做了:

  • 直接删除 S3 上的 files/<id>.enc
  • 完全不管 metadata 索引;
  • 也不知道被删的 id 是文件还是“逻辑目录”。

前端在 AppState.deleteFile 中:

  • 删除成功后,从 _files 中移除该条目;
  • _syncMetadataToS3() 把“少了一条记录”的索引写回;
  • 目录删除时,子项是否被删除,完全取决于前端怎么改 _files

这显然不符合“后端统一管理”的目标。

5.2 新的删除语义:后端负责整个子树

重构后的 deleteFilecore/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
        • 如果某个条目的 PathdirPrefix 开头:
          • 如果它是文件 → 删除对应 S3 对象 files/<childId>.enc
          • 不论文件/目录 → 一并从 meta.Files 中删除;
      • 最后删除目录自身的 meta.Files[fileID]
    • 如果是文件
      • 删除 S3 上的 files/<id>.enc
      • meta.Filesdelete(fileID)
  5. 保存 metadata 索引;
  6. 返回 {"status":"deleted"}

前端对应 AppState.deleteFileclient/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 AppStateApiClient.uploadFilePOST /api/v1/files/upload
    • 后端:
      • 加密写 files/<id>.enc
      • 通过 updateMetadataIndex 写入/更新 meta.Files[id]
  • 更新文件内容:
    • clientupdateFileContentPUT /api/v1/files/:id
    • 后端:
      • 覆盖 files/<id>.enc
      • 只更新尺寸、加密尺寸、mimeType、updatedAt。
  • 创建目录:
    • clientcreateFolderPOST /api/v1/folders
    • 后端:
      • 仅更新 metadata 索引(目录本身没有对应 S3 对象)。
  • 重命名:
    • clientrenameFilePUT /api/v1/files/:id/rename
    • 后端更新 NameUpdatedAt
  • 移动:
    • clientmoveFilesPOST /api/v1/files/move
    • 后端负责整个子树的 Path 更新。
  • 删除:
    • clientdeleteFileDELETE /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 中加入更丰富的字段:
    • 标签、收藏、访问次数等;
    • 有了统一的后端维护,这类信息就不再难以演进。

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

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