日期:2025-12-19
主题:围绕“缩略图”系统文件夹,统一后端能力和前端行为,做到:
- 系统文件夹本身受到严格保护(不能改名、不能删、不能当目标路径)
- 缩略图文件只通过专门 API 操作,不再误伤源文件
- 前端调用统一接口,避免自己拼业务逻辑
1. 系统文件夹的后端模型
文件:core/internal/api/server.go:88-128
- 常量:
const systemThumbsPath = "/缩略图/"
- 判断函数:
isSystemFolderPath(path string) bool- 统一判断某个
path是否是系统文件夹(目前就是缩略图目录)
- 统一判断某个
- 初始化元数据时:
ensureSystemFolders(meta *MetadataIndex)- 保证根目录
/下存在名为“缩略图”的系统文件夹:IsDir = truePath = "/",Name = "缩略图"IsSystemFolder = true
- 保证根目录
1.1 针对系统文件夹本身的写入限制
整体目标:系统文件夹本身是“只读结构”,不能被当成普通目录乱写。
-
上传文件:不能上传到系统文件夹
文件:
core/internal/api/server.go:290-299uploadFile中:- 从表单获取
path,默认/ - 如果
isSystemFolderPath(path):- 返回
400 {"error": "system folder cannot be modified"}
- 返回
- 从表单获取
-
创建文件夹:不能在系统文件夹下建子目录
文件:
core/internal/api/server.go:636-642createFolder中:- 请求里的
req.Path为空则归一化为/ isSystemFolderPath(req.Path)为真时:- 返回
400 {"error": "system folder cannot be modified"}
- 返回
- 请求里的
-
移动文件 / 文件夹:目标路径不能是系统文件夹,源不能是系统文件夹本身
文件:
core/internal/api/server.go:736-763(moveFiles)- 约束:
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"}
- 返回
- 其它目录正常处理(包括级联更新子路径)
- 如果
- 约束:
-
复制文件 / 文件夹:同样禁止目标是系统文件夹,禁止复制系统文件夹本身
文件:
core/internal/api/server.go:840-870(copyFiles)- 约束与
moveFiles对齐:- 目标路径是系统文件夹 → 拒绝
- 源是
IsDir && IsSystemFolder→ 拒绝
- 约束与
-
删除文件 / 文件夹:禁止删除系统文件夹
文件:
core/internal/api/server.go:526-590(deleteFile)- 读取元数据后,如果:
file.IsDir && file.IsSystemFolder:- 返回
400 {"error": "system folder cannot be modified"} - 不删元数据、不删 S3 对象
- 返回
- 读取元数据后,如果:
结论:
- 系统文件夹本身(例如“缩略图”):
- 不能作为上传 / 新建文件夹的目标路径
- 不能被
moveFiles、copyFiles、deleteFile当作普通目录和文件夹处理
- 这是“第一层防护”:保护系统目录结构不被破坏
2. 缩略图对象的后端模型
缩略图不是普通文件,而是存储在 S3 thumbs/ 前缀下的一套独立对象。
文件:core/internal/api/server.go:1176-1277
2.1 基础缩略图 API(已有)
路由注册:core/internal/api/server.go:195-200
POST /api/v1/thumbnails/:id→uploadThumbnail- 用于客户端自动生成缩略图后上传,不面向手动上传。
- 行为:
- 读取表单字段
file - 用
fileID+"#thumb"派生加密 key - 加密写入
thumbs/{fileID}.enc
- 读取表单字段
GET /api/v1/thumbnails/:id→downloadThumbnail- 读取
thumbs/{id}.enc,解密成 JPEG 返回。
- 读取
DELETE /api/v1/thumbnails/:id→deleteThumbnail- 删除
thumbs/{id}.enc
- 删除
GET /api/v1/thumbnails→listThumbnails- 列出
thumbs/下所有对象,返回:ID(去掉.enc后的 id)SizeTime(最近修改时间)
- 列出
这些接口只作用于缩略图对象,不修改任何源文件对象 files/*。
2.2 新增:导出缩略图为普通文件(复制 / 移动略缩图)
需求:
- 略缩图视图中的“文件”(虚拟条目)应当支持:
- 复制出来 → 变成一个真正的小图文件
- 移动出来 → 变成一个真正的小图文件,同时删除对应略缩图
- 严格保证:操作只基于略缩图对象本身,不对源文件做任何改动
2.2.1 路由与请求体
路由新增(在缩略图组中):
文件:core/internal/api/server.go:195-200
POST /api/v1/thumbnails/:id/export→exportThumbnail
请求体结构:
文件: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
核心步骤:
-
校验与归一化:
- 检查核心已解锁;
- 解析 JSON 请求;
TargetPath为空则归一化为/;- 如果
isSystemFolderPath(TargetPath):- 返回
400 {"error": "system folder cannot be modified"}
- 返回
-
从 S3 读取并解密缩略图:
- 构造
s3ThumbKey := "thumbs/{id}.enc"; DownloadBytes加载加密数据;- 用
fileID+"#thumb"派生fileKeyThumb; - 解密为
plainThumb(JPEG 字节数组)。
- 构造
-
作为普通文件重新加密并写入
files/:- 生成新的
newFileID := uuid.New().String(); - 用
newFileID派生普通文件 key; - 对
plainThumb加密,写入files/{newFileID}.enc。
- 生成新的
-
写入元数据索引:
- 加载现有元数据
loadMetadataIndex; - 决定文件名:
name := req.Name,若为空则默认"${fileID}-thumb.jpg";- 调用
resolveNameConflict做同目录重名处理;
- 新建
FileMetadata:ID = newFileIDPath = TargetPathName = nameMimeType = "image/jpeg"Size使用略缩图原始字节长度
- 保存元数据
saveMetadataIndex。
- 加载现有元数据
-
可选删除缩略图(实现“移动”语义):
- 若
DeleteOriginal == true:- 调用
s.s3.Delete(ctx, s3ThumbKey)删除thumbs/{id}.enc。
- 调用
- 若
-
响应:
- 返回新建的
FileMetadataJSON(普通文件的小图)。
- 返回新建的
关键点:
- 整个过程只依赖缩略图对象
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-2085Future<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
已有接口:
listThumbnailsgetThumbnailuploadThumbnail(客户端自动生成略缩图后上传用)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
几个核心约束:
-
多选时不包含系统文件夹
-
“全选”只选非系统文件夹:
files_page.dart:359-371final ids = state.currentFiles.where((f) => !f.isSystemFolder).map( (f) => f.id, ); -
“反选”同样基于非系统文件夹:
files_page.dart:373-384final allIds = state.currentFiles .where((f) => !f.isSystemFolder) .map((f) => f.id) .toSet();
-
-
系统文件夹不参与长按选中
files_page.dart:1060-1065- 列表视图长按:
- 若
file.isSystemFolder,直接返回,不进入_selectionMode
- 若
files_page.dart:2116-2121- 网格视图:
- 选择模式下点击系统文件夹无效
- 长按系统文件夹无效
- 列表视图长按:
-
系统文件夹不出现在“选择目标目录”列表中
files_page.dart:1211-1219final dirs = state.files.where((f) => f.isDir && !f.isSystemFolder).toList();- 复制 / 移动时,目标目录对话框不会列出“缩略图”等系统文件夹。
-
系统文件夹没有“重命名 / 删除”按钮
底部操作单
_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
流程:
- 从
state.currentFiles中收集当前选中的项目items。 - 弹出选择目标目录对话框
_pickTargetPath(只列普通目录,不含系统文件夹)。 - 若
state.currentPath == '/缩略图/':- 对
items中每个文件f调用:await state.api.exportThumbnail( fileId: f.id, targetPath: target, name: f.name, deleteOriginal: false, ); - 即:为每个缩略图在目标目录生成一个新的“小图普通文件”,保留原缩略图对象。
- 对
- 若当前路径非
/缩略图/:- 调用原来的
state.copyFiles(_selectedIds.toList(), target),复制真实文件。
- 调用原来的
- 操作结束后,退出多选模式并清空
_selectedIds。
结果:
- “复制”在缩略图视图中 = 导出一份新的小图文件
- 源文件本体和略缩图对象都不被删除。
3.4.3 移动缩略图文件“出来”
入口:多选模式下顶部 move 按钮
位置:files_page.dart:444-457
流程:
- 与复制类似,先收集选中的
items并选择目标目录。 - 若当前路径是
/缩略图/:- 对每个
f调用:await state.api.exportThumbnail( fileId: f.id, targetPath: target, name: f.name, deleteOriginal: true, ); - 然后调用
await state.refreshThumbnails();重新加载略缩图虚拟列表。
- 对每个
- 若当前路径非
/缩略图/:- 调用原来的
state.moveFiles(_selectedIds.toList(), target),移动真实文件。
- 调用原来的
- 操作结束后,退出多选模式并清空
_selectedIds。
结果:
- “移动”在缩略图视图中 = 导出小图文件 + 删除对应略缩图对象;
- 源文件本体仍然不受影响。
4. 新模型下的整体不变量
-
系统文件夹不变量
- 系统文件夹(例如“缩略图”):
- 后端:禁止作为上传 / 建目录 / 复制 / 移动 / 删除的目标或对象;
- 前端:不参与多选、不可作为目标目录、无“重命名 / 删除”按钮。
- 系统文件夹(例如“缩略图”):
-
缩略图对象不变量
- 所有涉及缩略图内容的写操作只通过以下接口:
uploadThumbnail(客户端自动生成略缩图上传)deleteThumbnail(删除略缩图对象)exportThumbnail(复制 / 移动略缩图为普通文件)
- 不会通过
deleteFile/copyFiles/moveFiles去操作源文件。
- 所有涉及缩略图内容的写操作只通过以下接口:
-
略缩图视图行为不变量
- 删除:只删
thumbs/*和本地略缩图缓存。 - 复制:通过
exportThumbnail(deleteOriginal=false)导出小图文件,略缩图对象和源文件都保留。 - 移动:通过
exportThumbnail(deleteOriginal=true)导出小图文件 + 删除略缩图对象,源文件不动。
- 删除:只删
-
前端职责不变量
- 前端只根据“当前视图类型 + 当前路径”决定调用哪类 API:
- 普通目录 → 文件 API(
copyFiles/moveFiles/deleteFile/...) - 缩略图视图
/缩略图/→ 缩略图 API(deleteThumbnail/exportThumbnail)
- 普通目录 → 文件 API(
- 不在前端拼“略缩图 id → 源文件 id 的操作逻辑”,所有跨对象的逻辑下沉到后端。
- 前端只根据“当前视图类型 + 当前路径”决定调用哪类 API:
5. 小结
这次调整以后:
- 系统文件夹的规则由后端统一维护,前端只做轻量 UI 过滤;
- 缩略图视图下的所有操作(删除 / 复制 / 移动)都只针对“略缩图对象”,通过专用 API 完成;
- 源文件的删除 / 复制 / 移动只在普通文件视图里触发,不再有“在略缩图里动源文件”的隐式行为。
整体达成了“后端统一、前端轻量、逻辑清晰且安全”的目标。