文件夹上传结构丢失问题 - 架构级修复

January 5, 2026
5 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.

涉及功能: 文件夹上传、文件元数据管理、前后端数据一致性


一、问题描述

1.1 现象

上传文件夹时出现以下异常行为:

  1. 上传过程中,文件会临时出现在当前目录的文件列表中
  2. 文件名显示为带路径的形式,如 ddd/ddd/fdfs.mp3
  3. 刷新后文件消失,变成正常的文件夹结构
  4. 后续上传的文件仍然会出现在根目录,上传完成后刷新才正常

1.2 我更希望

  • 上传文件夹时,文件应该直接显示在正确的子目录位置
  • 或者不显示临时状态,等待上传完成后统一展示
  • 存储本身是正确的(刷新后显示正常)

二、问题分析

2.1 数据流追踪

┌─────────────────────────────────────────────────────────────────┐
│ 前端 (Flutter) │
├─────────────────────────────────────────────────────────────────┤
│ 1. _uploadFolder() 选择文件夹 │
│ 2. path.relative() 计算相对路径 → Windows: "folder\file.txt" │
│ 3. replaceAll('\\', '/') → "folder/file.txt" │
│ 4. enqueueUpload(fileName: "myFolder/folder/file.txt") │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 后端 (Go) │
├─────────────────────────────────────────────────────────────────┤
│ 5. sanitizeFilePath() 清理路径 │
│ 6. 解析 fileName → actualFileName + actualRemotePath │
│ "myFolder/folder/file.txt" → name:"file.txt", path:"/myFolder/folder/"
│ 7. ensureParentFolders() 自动创建中间目录 │
│ 8. 保存 FileMetadata { name: "file.txt", path: "/myFolder/folder/" }
│ 9. UploadProgress { fileId: "xxx", phase: "done" } │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 前端 (Flutter) │
├─────────────────────────────────────────────────────────────────┤
│ 10. _handleUploadSuccess() 收到 fileId │
│ 11. 【问题所在】自己构建 FileMetadata: │
│ { name: "myFolder/folder/file.txt", path: "/" } ← 错误! │
│ 12. _files.add(metadata) → 在根目录显示带路径的文件名 │
└─────────────────────────────────────────────────────────────────┘

2.2 根因定位

问题1:后端路径解析(已修复)

  • Windows 上 path.relative() 返回使用 \ 的路径
  • 后端 sanitizeFilePath() 只处理 /,无法识别 \
  • sanitizePathSegment()\ 替换为 _,破坏路径结构

问题2:前端临时 Metadata 构建(核心问题)

  • 前端在 _handleUploadSuccess() 中自己构建 FileMetadata
  • 使用原始 fileName(可能包含路径)作为 name
  • 使用 job.remotePath(当前目录)作为 path
  • 结果:文件显示在错误的位置,文件名带路径

三、方案演进

3.1 初始方案:补丁式修复

思路:在前端增加条件判断,如果 fileName 包含 /,则不添加临时 metadata。

// 补丁方案(已废弃)
void _handleUploadSuccess(...) {
  // 如果 fileName 包含路径分隔符,不添加临时 metadata
  if (!fileName.contains('/')) {
    final metadata = FileMetadata(
      id: fileId,
      name: fileName,
      path: job.remotePath,
      // ...
    );
    _files.add(metadata);
  }
  // ...
}

问题

  • 代码变得越来越复杂,充满条件判断
  • 前端需要”猜测”后端的行为
  • 违反了”好品味”原则 —— 通过增加条件来处理特殊情况

3.2 我这边用的时候发现

“难道没有什么更加统一的更加好的方法吗,这种缝缝补补会非常大程度降低代码维护和可读性,对于bug,我们在现在开发早期阶段应该首先从自身架构修复出发,而不是缝缝补补”

这个反馈完全正确。根据 Linus 的”好品味”原则:

  • 好代码没有特殊情况
  • 与其增加条件判断,不如重新设计数据流

3.3 最终方案:架构级修复

核心思想:后端是数据的权威来源,前端不应该”猜测”构建 metadata。

解决方案:后端上传完成时返回完整的 FileMetadata,前端直接使用。

修复前的数据流:
后端: UploadProgress { fileId: "xxx" }
前端: 自己构建 FileMetadata { name: ?, path: ? } ← 猜测,容易出错
修复后的数据流:
后端: UploadProgress { fileId: "xxx", metadata: FileMetadata }
前端: 直接使用 progress.metadata ← 权威数据,零猜测

四、实现细节

4.1 后端修改

types.go - 添加 Metadata 字段

// 修改前
type UploadProgress struct {
    TaskID       string `json:"taskId"`
    Phase        string `json:"phase"`
    // ...
    FileID       string `json:"fileId,omitempty"`
    Cancelled    bool   `json:"-"`
}
 
// 修改后
type UploadProgress struct {
    TaskID       string        `json:"taskId"`
    Phase        string        `json:"phase"`
    // ...
    FileID       string        `json:"fileId,omitempty"`
    Metadata     *FileMetadata `json:"metadata,omitempty"`  // 新增:完整元数据
    Cancelled    bool          `json:"-"`
}

files.go - 上传完成时返回 Metadata

// 修改前
if progress != nil {
    progress.Phase = "done"
    progress.Progress = 100
    progress.FileID = fileID
}
 
// 修改后
if progress != nil {
    progress.Phase = "done"
    progress.Progress = 100
    progress.FileID = fileID
    progress.Metadata = &metadata  // 新增:返回完整元数据
}

关键点:metadata 变量在后端已经正确构建,包含:

  • Name: “file.txt”(不含路径)
  • Path: “/myFolder/folder/“(正确的远程路径)

4.2 前端修改

upload_progress.dart - 解析 Metadata

// 修改前
class UploadProgress {
  final String? fileId;
  // ...
}
 
// 修改后
import 'file_metadata.dart';
 
class UploadProgress {
  final String? fileId;
  final FileMetadata? metadata;  // 新增
  // ...
  
  factory UploadProgress.fromJson(Map<String, dynamic> json) {
    return UploadProgress(
      // ...
      fileId: json['fileId'],
      metadata: json['metadata'] != null
          ? FileMetadata.fromJson(Map<String, dynamic>.from(json['metadata']))
          : null,
    );
  }
}

app_state.dart - 使用后端返回的 Metadata

// 修改前:前端自己构建 metadata
void _handleUploadSuccess(_UploadJob job, String fileId, String fileName, int fileSize) {
  if (!fileName.contains('/')) {  // 补丁式判断
    final metadata = FileMetadata(
      id: fileId,
      name: fileName,        // 可能错误
      path: job.remotePath,  // 可能错误
      // ...
    );
    _files.add(metadata);
  }
  // ...
}
 
// 修改后:直接使用后端返回的 metadata
void _handleUploadSuccess(_UploadJob job, UploadProgress progress) {
  // 后端返回了完整的 metadata,直接使用
  if (progress.metadata != null) {
    _files.add(progress.metadata!);  // 权威数据,零条件判断
  }
  // ...
}

4.3 后端路径处理修复(附带)

utils.go - 支持 Windows 反斜杠

// 修改前
func sanitizeFilePath(filePath string) string {
    parts := strings.Split(filePath, "/")  // 只处理 /
    // ...
}
 
// 修改后
func sanitizeFilePath(filePath string) string {
    // 先将 Windows 的反斜杠统一转换为正斜杠
    filePath = strings.ReplaceAll(filePath, "\\", "/")
    
    parts := strings.Split(filePath, "/")
    // ...
}

五、方案对比

维度补丁方案架构方案
复杂度高(增加条件判断)低(删除条件判断)
数据一致性弱(前端猜测)强(后端权威)
可维护性差(分散逻辑)好(单一数据源)
扩展性差(新增特殊情况需新增判断)好(后端统一处理)
代码行数+15 行条件判断-18 行(删除猜测逻辑)

六、设计原则回顾

6.1 “好品味”原则应用

“有时你可以从不同角度看问题,重写它让特殊情况消失,变成正常情况。”

补丁方案的问题:

// 充满特殊情况的代码
if (!fileName.contains('/')) {
  // 正常情况
  _files.add(metadata);
}
// 包含路径的文件(特殊情况)不添加

架构方案的优雅:

// 没有特殊情况,统一处理
if (progress.metadata != null) {
  _files.add(progress.metadata!);
}

6.2 数据流设计原则

Bad: 前端猜测后端行为

后端: { fileId: "xxx" }
前端: 我来猜猜这个文件的 name 和 path 是什么...

Good: 后端是权威数据源

后端: { fileId: "xxx", metadata: { name: "file.txt", path: "/folder/" } }
前端: 直接使用,不需要猜测

6.3 实用主义原则

“Theory and practice sometimes clash. Theory loses. Every single time.”

不是为了”架构正确”而重构,而是因为:

  1. 补丁方案引入了更多复杂性
  2. 前端猜测逻辑容易出错
  3. 后端已经有正确的数据,只是没有返回

架构方案实际上是更简单的方案。


七、文件修改清单

文件修改类型修改内容
core/internal/api/types.go结构体UploadProgress 添加 Metadata *FileMetadata 字段
core/internal/api/files.go逻辑上传完成时 progress.Metadata = &metadata
core/internal/api/utils.go逻辑sanitizeFilePath 支持 Windows 反斜杠
client/lib/core/models/upload_progress.dart模型添加 metadata 字段和解析逻辑
client/lib/core/state/app_state.dart逻辑_handleUploadSuccess 使用后端返回的 metadata

八、验证

  • go vet ./... 通过
  • flutter analyze 通过(仅有预存在的 info 级别警告)
  • 功能测试:上传文件夹,观察文件列表实时更新

九、经验总结

  1. 早期阶段优先架构修复:补丁式修复只会让代码越来越复杂
  2. 数据流分析是关键:画出完整的数据流向,问题自然暴露
  3. 后端是权威来源:前端不应该猜测后端行为,应该使用后端返回的权威数据
  4. 好代码没有特殊情况:如果需要增加条件判断来处理特殊情况,说明数据结构设计有问题
  5. 简单方案往往是最好的方案:架构方案看似”大动作”,实际上删除了更多代码

十、后续可能的优化

  1. 上传文件夹时的进度显示:考虑添加文件夹级别的进度(已上传 3/10 个文件)
  2. 批量 metadata 更新:上传文件夹时,考虑一次性获取所有新增文件的 metadata,减少 UI 刷新次数
  3. 乐观更新策略:对于简单文件(非文件夹),可以保留前端乐观更新;对于复杂路径,依赖后端返回