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

背景

发送功能允许向指定会话发送文本消息和文件附件。在实现过程中,发现以下问题需要解决:

  1. 网盘引用无乐观更新 - 选择网盘文件发送时,需要等待 API 返回才显示消息
  2. 删除消息不清理文件 - 删除本地上传的文件消息后,S3 上的文件数据仍然残留
  3. 发送附件污染元数据索引 - 本地上传的发送附件被写入元数据索引,导致在文件管理器中可见
  4. 路径显示问题 - 网盘引用的文件详情页显示为 /sender/ 而非原路径

一、问题分析

问题 1:缺少乐观更新

现象

选择网盘文件发送后,需要等待 API 响应才能看到消息,体验不流畅。

原因

// 旧代码:直接调用 API,成功后才添加消息
final result = await appState.api.addSendMessage(...);
if (result.isSuccess) {
  setState(() {
    _messages = [..._messages, result.data!];
  });
}

问题 2:文件数据泄漏

现象

删除发送的本地文件消息后,S3 上的 files/{fileId}/... 数据仍然存在。

原因

后端 deleteSendMessage 只删除消息记录,未清理关联的文件数据。

问题 3:元数据索引污染

现象

发送的本地文件出现在文件管理器的 /sender/ 路径下,与自己上传的文件混在一起。

原因

上传时调用 uploadByPath,该方法会将文件写入元数据索引:

// server.go - uploadByPath
metadata := FileMetadata{
    ID:   fileID,
    Name: fileName,
    Path: remotePath, // /sender/
    // ...
}
s.updateMetadataIndex(ctx, &metadata) // 写入元数据索引

潜在问题

如果手动创建名为 sender 的文件夹,会与发送附件的虚拟路径冲突。

问题 4:路径显示错误

现象

引用网盘文件 /documents/report.pdf 发送后,详情页显示路径为 /sender/

原因

消息中未保存文件的原路径。

二、解决方案设计

核心设计原则

参考 e2e-import-export-refactor 的经验教训:

复用现有能力,不重复实现

发送附件的上传/下载/预览完全复用现有文件逻辑,只需在关键点添加控制:

  1. skipMetadata 参数 - 控制是否写入元数据索引
  2. filePath 字段 - 保存文件的原路径(网盘引用)或 null(本地上传)
  3. isLocalFile 字段 - 区分文件来源,决定删除时是否清理文件数据

字段语义

字段本地上传网盘引用
isLocalFiletruefalse
filePathnull原路径
skipMetadatatrue不适用
删除时清理 S3

三、后端修改

1. uploadByPath 添加 skipMetadata 参数

// server.go
 
// UploadByPathRequest 路径模式上传请求
type UploadByPathRequest struct {
    FilePath     string `json:"filePath"`
    FileName     string `json:"fileName"`
    RemotePath   string `json:"remotePath"`
    SkipMetadata bool   `json:"skipMetadata"` // 新增:跳过写入元数据索引
}
 
// doUploadByPath 实际执行上传
func (s *Server) doUploadByPath(taskID, filePath, fileName, remotePath string, 
                                fileSize int64, skipMetadata bool) {
    // ... 上传逻辑 ...
    
    // 更新元数据索引(发送附件不写入索引)
    if !skipMetadata {
        if err := s.updateMetadataIndex(ctx, &metadata); err != nil {
            setError(fmt.Sprintf("update metadata failed: %v", err))
            return
        }
    }
    
    // ... 完成 ...
}

2. SendMessage 添加 filePath 字段

type SendMessage struct {
    ID          string    `json:"id"`
    SessionID   string    `json:"sessionId"`
    Type        string    `json:"type"`
    Content     string    `json:"content,omitempty"`
    FileID      string    `json:"fileId,omitempty"`
    FileName    string    `json:"fileName,omitempty"`
    FileSize    int64     `json:"fileSize,omitempty"`
    FilePath    string    `json:"filePath,omitempty"` // 新增:文件在网盘中的路径
    IsLocalFile bool      `json:"isLocalFile"`
    CreatedAt   time.Time `json:"createdAt"`
}

3. deleteSendMessage 清理文件数据

func (s *Server) deleteSendMessage(c *gin.Context) {
    // ... 查找要删除的消息 ...
    
    var deletedMsg *SendMessage
    for _, msg := range msgs.Messages {
        if msg.ID == msgID {
            deletedMsg = msg
        } else {
            newMessages = append(newMessages, msg)
        }
    }
    
    // 如果是本地上传的文件消息,删除关联的文件数据
    if deletedMsg != nil && deletedMsg.Type == "file" && 
       deletedMsg.FileID != "" && deletedMsg.IsLocalFile {
        // 删除文件数据(S3 上的 files/{fileId}/...)
        filePrefix := fmt.Sprintf("files/%s/", deletedMsg.FileID)
        _ = s.s3.DeletePrefix(ctx, filePrefix)
        
        // 删除缩略图
        thumbKey := fmt.Sprintf("thumbs/%s.enc", deletedMsg.FileID)
        _ = s.s3.Delete(ctx, thumbKey)
        
        // 从元数据索引中删除(如果存在)
        if meta, err := s.loadMetadataIndex(ctx); err == nil {
            if _, ok := meta.Files[deletedMsg.FileID]; ok {
                delete(meta.Files, deletedMsg.FileID)
                _ = s.saveMetadataIndex(ctx, meta)
            }
        }
        
        log.Printf("[SEND] Deleted local file data for message %s, fileId=%s", 
                   msgID, deletedMsg.FileID)
    }
    
    // ... 保存消息列表 ...
}

四、客户端修改

1. API 添加 skipMetadata 参数

// api_client.dart
 
/// 路径模式上传(Go 核心直接读取文件)
/// [skipMetadata] 为 true 时不写入元数据索引(发送附件用)
Future<ApiResult<UploadTask>> uploadByPath({
  required String filePath,
  required String fileName,
  required String remotePath,
  bool skipMetadata = false,  // 新增参数
}) async {
  try {
    final response = await _dio.post(
      '/api/v1/files/upload-by-path',
      data: {
        'filePath': filePath,
        'fileName': fileName,
        'remotePath': remotePath,
        'skipMetadata': skipMetadata,  // 传递参数
      },
    );
    return ApiResult.success(UploadTask.fromJson(response.data));
  } catch (e) {
    return ApiResult.error(_parseError(e));
  }
}

2. SendMessage 添加 filePath 字段

// send_message.dart
 
class SendMessage {
  final String id;
  final String sessionId;
  final SendMessageType type;
  final String? content;
  final String? fileId;
  final String? fileName;
  final int? fileSize;
  final String? filePath;  // 新增:文件在网盘中的路径
  final bool isLocalFile;
  final SendMessageStatus status;
  final DateTime createdAt;
  
  // ...
}

3. 数据库添加 filePath 列

// database.dart
 
class SendMessages extends Table {
  TextColumn get id => text()();
  TextColumn get sessionId => text()();
  TextColumn get type => text()();
  TextColumn get content => text().nullable()();
  TextColumn get fileId => text().nullable()();
  TextColumn get fileName => text().nullable()();
  IntColumn get fileSize => integer().nullable()();
  TextColumn get filePath => text().nullable()();  // 新增
  BoolColumn get isLocalFile => boolean().withDefault(const Constant(false))();
  TextColumn get status => text().withDefault(const Constant('sent'))();
  DateTimeColumn get createdAt => dateTime()();
 
  @override
  Set<Column> get primaryKey => {id};
}
 
// Schema migration
@override
int get schemaVersion => 9;
 
@override
MigrationStrategy get migration => MigrationStrategy(
  onUpgrade: (migrator, from, to) async {
    if (from < 9) {
      await migrator.addColumn(sendMessages, sendMessages.filePath);
    }
  },
);

4. 发送本地文件使用 skipMetadata

// chat_page.dart - _sendLocalFileMessage
 
// 使用 uploadByPath 流式上传
// skipMetadata: true 不写入元数据索引,发送附件不应出现在文件管理器中
final taskResult = await appState.api.uploadByPath(
  filePath: filePath,
  fileName: safeFileName,
  remotePath: '/sender/',
  skipMetadata: true,  // 关键:跳过元数据索引
);
 
// 发送文件消息
final result = await appState.api.addSendMessage(
  sessionId: widget.session.id,
  type: 'file',
  fileId: uploadedFileId,
  fileName: safeFileName,
  fileSize: fileSize,
  filePath: null,       // 本地上传的发送附件无真实路径
  isLocalFile: true,    // 本地上传的文件
);

5. 发送网盘文件保存原路径

// chat_page.dart - _sendCloudFileMessage
 
final result = await appState.api.addSendMessage(
  sessionId: widget.session.id,
  type: 'file',
  fileId: file.id,
  fileName: file.name,
  fileSize: file.size,
  filePath: file.path,   // 保存原路径
  isLocalFile: false,    // 网盘引用
);

6. 预览时构造虚拟显示路径

// chat_page.dart - _openFile
 
void _openFile(SendMessage message) async {
  if (message.type != SendMessageType.file || message.fileId == null) return;
 
  final fileName = message.fileName ?? '未知文件';
  
  // 构造显示路径:
  // - 本地上传的发送附件(isLocalFile = true):显示为 "发送/会话名称/"
  // - 网盘引用(isLocalFile = false):显示实际路径
  final displayPath = message.isLocalFile
      ? '发送/${widget.session.name}/'
      : (message.filePath ?? '/');
 
  // 从 SendMessage 构造 FileMetadata
  final fileMeta = FileMetadata(
    id: message.fileId!,
    name: fileName,
    path: displayPath,  // 使用构造的显示路径
    size: message.fileSize ?? 0,
    // ...
  );
 
  // 打开预览页面...
}

7. 乐观更新实现

// chat_page.dart - _sendCloudFileMessage
 
Future<void> _sendCloudFileMessage(FileMetadata file) async {
  final appState = context.read<AppState>();
  final tempId = const Uuid().v4();
 
  // 立即添加临时消息(乐观更新)
  final tempMessage = SendMessage(
    id: tempId,
    sessionId: widget.session.id,
    type: SendMessageType.file,
    fileId: file.id,
    fileName: file.name,
    fileSize: file.size,
    filePath: file.path,
    isLocalFile: false,
    status: SendMessageStatus.pending,
    createdAt: DateTime.now(),
  );
 
  // 立即显示
  setState(() {
    _messages = [..._messages, tempMessage];
    _pendingMessageIds.add(tempId);
  });
  _scrollToBottom();
  
  // 保存到本地数据库(乐观)
  await appState.db.upsertSendMessage(tempMessage);
 
  try {
    // 调用 API
    final result = await appState.api.addSendMessage(...);
 
    if (result.isSuccess && result.data != null) {
      // 成功:替换临时消息为真实消息
      setState(() {
        final index = _messages.indexWhere((m) => m.id == tempId);
        if (index != -1) {
          _messages[index] = result.data!;
        }
        _pendingMessageIds.remove(tempId);
      });
      
      // 更新本地数据库
      await appState.db.deleteSendMessage(tempId);
      await appState.db.upsertSendMessage(result.data!);
    } else {
      _markMessageFailed(tempId, tempMessage);
    }
  } catch (e) {
    _markMessageFailed(tempId, tempMessage);
  }
}

五、设计决策

为什么不在消息中存储 sessionName?

我当时建议在 SendMessage 中添加 sessionName 字段,以便直接构造显示路径。

分析:

方案优点缺点
存储 sessionName任何地方都能直接用会话重命名后不同步
运行时获取数据一致,重命名自动更新需要上下文

结论: 采用运行时获取方案

在 chat_page 中已有 widget.session.name,不需要额外存储。如果将来需要全局”发送文件夹”功能,可以通过 sessionId 查询 sessionName。

为什么使用 skipMetadata 而非新建 API?

  1. 复用逻辑 - 95% 的上传逻辑相同,只有元数据写入不同
  2. 避免重复 - 新建 API 会导致两份相似的上传代码
  3. 简单直接 - 一个参数解决问题

六、测试验证

测试场景

场景操作预期结果
本地上传发送本地文件消息正常发送,文件不出现在文件管理器
网盘引用发送网盘文件消息立即显示(乐观更新),详情页显示原路径
删除本地消息删除本地上传的文件消息S3 上的文件数据被清理
删除引用消息删除网盘引用的文件消息原文件不受影响

代码验证

flutter analyze
# No issues found!
 
go build ./...
# 编译成功

七、代码变化统计

文件修改类型行数变化
server.go添加 skipMetadata、filePath、删除清理逻辑+45
api_client.dart添加 skipMetadata 参数+3
send_message.dart添加 filePath 字段+6
database.dart添加 filePath 列,schema 升级+10
chat_page.dart乐观更新、虚拟路径+30
总计+94 行

总结

本次重构解决了发送功能的四个核心问题:

  1. 乐观更新 - 发送网盘文件时立即显示消息
  2. 文件清理 - 删除消息时同步清理 S3 数据
  3. 元数据隔离 - 发送附件不污染元数据索引
  4. 路径显示 - 网盘引用显示原路径,本地上传显示虚拟路径

设计遵循了以下原则:

  • 复用现有能力 - skipMetadata 参数复用上传逻辑
  • 最小改动 - 约 100 行代码解决所有问题
  • 数据一致 - 运行时构造显示路径,避免冗余存储