系统文件夹与缩略图:前后端统一模型与实现笔记

December 19, 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.

日期:2025-12-19
主题:围绕“缩略图”系统文件夹,统一后端能力和前端行为,做到:

  • 系统文件夹本身受到严格保护(不能改名、不能删、不能当目标路径)
  • 缩略图文件只通过专门 API 操作,不再误伤源文件
  • 前端调用统一接口,避免自己拼业务逻辑

1. 系统文件夹的后端模型

文件:core/internal/api/server.go:88-128

  • 常量:
    • const systemThumbsPath = "/缩略图/"
  • 判断函数:
    • isSystemFolderPath(path string) bool
      • 统一判断某个 path 是否是系统文件夹(目前就是缩略图目录)
  • 初始化元数据时:
    • ensureSystemFolders(meta *MetadataIndex)
      • 保证根目录 / 下存在名为“缩略图”的系统文件夹:
        • IsDir = true
        • Path = "/", Name = "缩略图"
        • IsSystemFolder = true

1.1 针对系统文件夹本身的写入限制

整体目标:系统文件夹本身是“只读结构”,不能被当成普通目录乱写。

  1. 上传文件:不能上传到系统文件夹

    文件:core/internal/api/server.go:290-299

    • uploadFile 中:
      • 从表单获取 path,默认 /
      • 如果 isSystemFolderPath(path)
        • 返回 400 {"error": "system folder cannot be modified"}
  2. 创建文件夹:不能在系统文件夹下建子目录

    文件:core/internal/api/server.go:636-642

    • createFolder 中:
      • 请求里的 req.Path 为空则归一化为 /
      • isSystemFolderPath(req.Path) 为真时:
        • 返回 400 {"error": "system folder cannot be modified"}
  3. 移动文件 / 文件夹:目标路径不能是系统文件夹,源不能是系统文件夹本身

    文件:core/internal/api/server.go:736-763moveFiles

    • 约束:
      • req.TargetPath 为空则归一化为 /
      • isSystemFolderPath(req.TargetPath)
        • 返回 400 {"error": "system folder cannot be modified"}
      • 遍历 req.FileIDs,对每个源文件:
        • 如果 file.IsDir && file.IsSystemFolder
          • 返回 400 {"error": "system folder cannot be modified"}
        • 其它目录正常处理(包括级联更新子路径)
  4. 复制文件 / 文件夹:同样禁止目标是系统文件夹,禁止复制系统文件夹本身

    文件:core/internal/api/server.go:840-870copyFiles

    • 约束与 moveFiles 对齐:
      • 目标路径是系统文件夹 → 拒绝
      • 源是 IsDir && IsSystemFolder → 拒绝
  5. 删除文件 / 文件夹:禁止删除系统文件夹

    文件:core/internal/api/server.go:526-590deleteFile

    • 读取元数据后,如果:
      • file.IsDir && file.IsSystemFolder
        • 返回 400 {"error": "system folder cannot be modified"}
        • 不删元数据、不删 S3 对象

结论:

  • 系统文件夹本身(例如“缩略图”):
    • 不能作为上传 / 新建文件夹的目标路径
    • 不能被 moveFilescopyFilesdeleteFile 当作普通目录和文件夹处理
  • 这是“第一层防护”:保护系统目录结构不被破坏

2. 缩略图对象的后端模型

缩略图不是普通文件,而是存储在 S3 thumbs/ 前缀下的一套独立对象。

文件:core/internal/api/server.go:1176-1277

2.1 基础缩略图 API(已有)

路由注册:core/internal/api/server.go:195-200

  • POST /api/v1/thumbnails/:iduploadThumbnail
    • 用于客户端自动生成缩略图后上传,不面向手动上传。
    • 行为:
      • 读取表单字段 file
      • fileID+"#thumb" 派生加密 key
      • 加密写入 thumbs/{fileID}.enc
  • GET /api/v1/thumbnails/:iddownloadThumbnail
    • 读取 thumbs/{id}.enc,解密成 JPEG 返回。
  • DELETE /api/v1/thumbnails/:iddeleteThumbnail
    • 删除 thumbs/{id}.enc
  • GET /api/v1/thumbnailslistThumbnails
    • 列出 thumbs/ 下所有对象,返回:
      • ID(去掉 .enc 后的 id)
      • Size
      • Time(最近修改时间)

这些接口只作用于缩略图对象,不修改任何源文件对象 files/*

2.2 新增:导出缩略图为普通文件(复制 / 移动略缩图)

需求:

  • 略缩图视图中的“文件”(虚拟条目)应当支持:
    • 复制出来 → 变成一个真正的小图文件
    • 移动出来 → 变成一个真正的小图文件,同时删除对应略缩图
  • 严格保证:操作只基于略缩图对象本身,不对源文件做任何改动

2.2.1 路由与请求体

路由新增(在缩略图组中):

文件:core/internal/api/server.go:195-200

  • POST /api/v1/thumbnails/:id/exportexportThumbnail

请求体结构:

文件:core/internal/api/server.go:1279-1284

type ExportThumbnailRequest struct {
	TargetPath     string `json:"targetPath"`
	Name           string `json:"name"`
	DeleteOriginal bool   `json:"deleteOriginal"`
}

语义:

  • targetPath:导出后的目标目录(普通文件目录,禁止系统文件夹)
  • name:导出文件名(可为空,后端会给默认)
  • deleteOriginal
    • false → 复制略缩图出来(Copy)
    • true → 移动略缩图出来(Move:导出 + 删除原缩略图)

2.2.2 导出逻辑 exportThumbnail

文件:core/internal/api/server.go:1286-1340

核心步骤:

  1. 校验与归一化:

    • 检查核心已解锁;
    • 解析 JSON 请求;
    • TargetPath 为空则归一化为 /
    • 如果 isSystemFolderPath(TargetPath)
      • 返回 400 {"error": "system folder cannot be modified"}
  2. 从 S3 读取并解密缩略图:

    • 构造 s3ThumbKey := "thumbs/{id}.enc"
    • DownloadBytes 加载加密数据;
    • fileID+"#thumb" 派生 fileKeyThumb
    • 解密为 plainThumb(JPEG 字节数组)。
  3. 作为普通文件重新加密并写入 files/

    • 生成新的 newFileID := uuid.New().String()
    • newFileID 派生普通文件 key;
    • plainThumb 加密,写入 files/{newFileID}.enc
  4. 写入元数据索引:

    • 加载现有元数据 loadMetadataIndex
    • 决定文件名:
      • name := req.Name,若为空则默认 "${fileID}-thumb.jpg"
      • 调用 resolveNameConflict 做同目录重名处理;
    • 新建 FileMetadata
      • ID = newFileID
      • Path = TargetPath
      • Name = name
      • MimeType = "image/jpeg"
      • Size 使用略缩图原始字节长度
    • 保存元数据 saveMetadataIndex
  5. 可选删除缩略图(实现“移动”语义):

    • DeleteOriginal == true
      • 调用 s.s3.Delete(ctx, s3ThumbKey) 删除 thumbs/{id}.enc
  6. 响应:

    • 返回新建的 FileMetadata JSON(普通文件的小图)。

关键点:

  • 整个过程只依赖缩略图对象 thumbs/{id}.enc,不会对源文件 files/{id}.enc 做任何读写。
  • “复制”与“移动”的区别完全通过 DeleteOriginal 控制,对前端是统一接口。

3. 前端:系统文件夹与缩略图视图的处理

前端目标:

  • 系统文件夹本身通过 UI 隐性保护(没有危险按钮,不参与多选)。
  • 略缩图视图中的条目,只调用缩略图专用接口,不再调用文件级操作接口去动源文件。

3.1 AppState 中的缩略图虚拟目录

文件:client/lib/core/state/app_state.dart:1000-1070, 2066-2085

  • 路径常量:

    • _thumbsPath = "/缩略图/"
  • 当前目录下文件:

    • currentFiles
      • _currentPath == _thumbsPath 时:
        • 返回 _thumbFiles 的排序版本(虚拟文件列表)
      • 否则:返回普通 _files 过滤结果
  • 刷新缩略图列表:

    文件:app_state.dart:2066-2085

    Future<void> refreshThumbnails() async {
      if (_isOffline) return;
      final res = await api.listThumbnails();
      ...
      _thumbFiles = res.data!.map((t) {
        final metaIndex = _files.indexWhere((f) => f.id == t.id);
        final name = metaIndex >= 0 ? _files[metaIndex].name : t.id;
        return FileMetadata(
          id: t.id,
          name: name,
          path: _thumbsPath,
          size: t.size,
          encryptedSize: 0,
          isDir: false,
          mimeType: 'image/jpeg',
          createdAt: now,
          updatedAt: t.time,
        );
      }).toList();
      notifyListeners();
    }
  • 这里的 _thumbFiles 就是“虚拟的略缩图文件”,对应后端 listThumbnails 的返回。

3.2 前端 API 封装:导出缩略图

文件:client/lib/core/api/api_client.dart:271-305

已有接口:

  • listThumbnails
  • getThumbnail
  • uploadThumbnail(客户端自动生成略缩图后上传用)
  • deleteThumbnail

新增接口:

Future<ApiResult<FileMetadata>> exportThumbnail({
  required String fileId,
  required String targetPath,
  required String name,
  bool deleteOriginal = false,
})
  • 对应后端 POST /api/v1/thumbnails/:id/export
  • 请求体包含 targetPath / name / deleteOriginal
  • 返回值是后端生成的新普通文件的 FileMetadata

3.3 系统文件夹本身的 UI 保护

文件:client/lib/ui/files_page.dart

几个核心约束:

  1. 多选时不包含系统文件夹

    • “全选”只选非系统文件夹:

      files_page.dart:359-371

      final ids =
          state.currentFiles.where((f) => !f.isSystemFolder).map(
                (f) => f.id,
              );
    • “反选”同样基于非系统文件夹:

      files_page.dart:373-384

      final allIds = state.currentFiles
          .where((f) => !f.isSystemFolder)
          .map((f) => f.id)
          .toSet();
  2. 系统文件夹不参与长按选中

    files_page.dart:1060-1065

    • 列表视图长按:
      • file.isSystemFolder,直接返回,不进入 _selectionMode

    files_page.dart:2116-2121

    • 网格视图:
      • 选择模式下点击系统文件夹无效
      • 长按系统文件夹无效
  3. 系统文件夹不出现在“选择目标目录”列表中

    files_page.dart:1211-1219

    final dirs =
        state.files.where((f) => f.isDir && !f.isSystemFolder).toList();
    • 复制 / 移动时,目标目录对话框不会列出“缩略图”等系统文件夹。
  4. 系统文件夹没有“重命名 / 删除”按钮

    底部操作单 _FileActionSheet

    files_page.dart:1870-1902

    • 只有在 !file.isSystemFolder 时才显示“删除”
    • 只有在 !file.isSystemFolder && !isThumbFolder 时才显示“重命名”

    这样根目录的“缩略图”文件夹视图中,只会展示安全操作(比如详情),没有破坏性入口。

3.4 略缩图视图中对“文件本身”的操作

重点是 /缩略图/ 路径下的行为。

3.4.1 删除缩略图文件(单个 / 批量)

多选删除:

  • 文件:files_page.dart:459-501
  • /缩略图/ 下:
    • 仅调用 state.api.deleteThumbnail(id)ThumbnailCacheManager().deleteThumbnail(id)
    • 完成后调用 state.refreshThumbnails() 刷新虚拟列表。
  • /缩略图/ 下:
    • 走原来的 state.deleteFile(id) 路径,删除真实文件。

单个删除:

  • 文件:files_page.dart:1186-1207
  • 逻辑与多选类似:
    • 缩略图目录 → 删除略缩图 + 刷新;
    • 其它目录 → 删除真实文件。

保证:略缩图视图中的删除操作只对缩略图对象生效,不碰源文件。

3.4.2 复制缩略图文件“出来”

入口:多选模式下顶部 copy 按钮
位置:files_page.dart:430-442

流程:

  1. state.currentFiles 中收集当前选中的项目 items
  2. 弹出选择目标目录对话框 _pickTargetPath(只列普通目录,不含系统文件夹)。
  3. state.currentPath == '/缩略图/'
    • items 中每个文件 f 调用:
      await state.api.exportThumbnail(
        fileId: f.id,
        targetPath: target,
        name: f.name,
        deleteOriginal: false,
      );
    • 即:为每个缩略图在目标目录生成一个新的“小图普通文件”,保留原缩略图对象。
  4. 若当前路径非 /缩略图/
    • 调用原来的 state.copyFiles(_selectedIds.toList(), target),复制真实文件。
  5. 操作结束后,退出多选模式并清空 _selectedIds

结果:

  • “复制”在缩略图视图中 = 导出一份新的小图文件
  • 源文件本体和略缩图对象都不被删除。

3.4.3 移动缩略图文件“出来”

入口:多选模式下顶部 move 按钮
位置:files_page.dart:444-457

流程:

  1. 与复制类似,先收集选中的 items 并选择目标目录。
  2. 若当前路径是 /缩略图/
    • 对每个 f 调用:
      await state.api.exportThumbnail(
        fileId: f.id,
        targetPath: target,
        name: f.name,
        deleteOriginal: true,
      );
    • 然后调用 await state.refreshThumbnails(); 重新加载略缩图虚拟列表。
  3. 若当前路径非 /缩略图/
    • 调用原来的 state.moveFiles(_selectedIds.toList(), target),移动真实文件。
  4. 操作结束后,退出多选模式并清空 _selectedIds

结果:

  • “移动”在缩略图视图中 = 导出小图文件 + 删除对应略缩图对象;
  • 源文件本体仍然不受影响。

4. 新模型下的整体不变量

  1. 系统文件夹不变量

    • 系统文件夹(例如“缩略图”):
      • 后端:禁止作为上传 / 建目录 / 复制 / 移动 / 删除的目标或对象;
      • 前端:不参与多选、不可作为目标目录、无“重命名 / 删除”按钮。
  2. 缩略图对象不变量

    • 所有涉及缩略图内容的写操作只通过以下接口:
      • uploadThumbnail(客户端自动生成略缩图上传)
      • deleteThumbnail(删除略缩图对象)
      • exportThumbnail(复制 / 移动略缩图为普通文件)
    • 不会通过 deleteFile / copyFiles / moveFiles 去操作源文件。
  3. 略缩图视图行为不变量

    • 删除:只删 thumbs/* 和本地略缩图缓存。
    • 复制:通过 exportThumbnail(deleteOriginal=false) 导出小图文件,略缩图对象和源文件都保留。
    • 移动:通过 exportThumbnail(deleteOriginal=true) 导出小图文件 + 删除略缩图对象,源文件不动。
  4. 前端职责不变量

    • 前端只根据“当前视图类型 + 当前路径”决定调用哪类 API:
      • 普通目录 → 文件 API(copyFiles/moveFiles/deleteFile/...
      • 缩略图视图 /缩略图/ → 缩略图 API(deleteThumbnail/exportThumbnail
    • 不在前端拼“略缩图 id → 源文件 id 的操作逻辑”,所有跨对象的逻辑下沉到后端。

5. 小结

这次调整以后:

  • 系统文件夹的规则由后端统一维护,前端只做轻量 UI 过滤;
  • 缩略图视图下的所有操作(删除 / 复制 / 移动)都只针对“略缩图对象”,通过专用 API 完成;
  • 源文件的删除 / 复制 / 移动只在普通文件视图里触发,不再有“在略缩图里动源文件”的隐式行为。

整体达成了“后端统一、前端轻量、逻辑清晰且安全”的目标。