背景
发送功能允许向指定会话发送文本消息和文件附件。在实现过程中,发现以下问题需要解决:
- 网盘引用无乐观更新 - 选择网盘文件发送时,需要等待 API 返回才显示消息
- 删除消息不清理文件 - 删除本地上传的文件消息后,S3 上的文件数据仍然残留
- 发送附件污染元数据索引 - 本地上传的发送附件被写入元数据索引,导致在文件管理器中可见
- 路径显示问题 - 网盘引用的文件详情页显示为
/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 的经验教训:
复用现有能力,不重复实现
发送附件的上传/下载/预览完全复用现有文件逻辑,只需在关键点添加控制:
- skipMetadata 参数 - 控制是否写入元数据索引
- filePath 字段 - 保存文件的原路径(网盘引用)或 null(本地上传)
- isLocalFile 字段 - 区分文件来源,决定删除时是否清理文件数据
字段语义
| 字段 | 本地上传 | 网盘引用 |
|---|---|---|
| isLocalFile | true | false |
| filePath | null | 原路径 |
| skipMetadata | true | 不适用 |
| 删除时清理 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?
- 复用逻辑 - 95% 的上传逻辑相同,只有元数据写入不同
- 避免重复 - 新建 API 会导致两份相似的上传代码
- 简单直接 - 一个参数解决问题
六、测试验证
测试场景
| 场景 | 操作 | 预期结果 |
|---|---|---|
| 本地上传 | 发送本地文件 | 消息正常发送,文件不出现在文件管理器 |
| 网盘引用 | 发送网盘文件 | 消息立即显示(乐观更新),详情页显示原路径 |
| 删除本地消息 | 删除本地上传的文件消息 | 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 行 |
总结
本次重构解决了发送功能的四个核心问题:
- 乐观更新 - 发送网盘文件时立即显示消息
- 文件清理 - 删除消息时同步清理 S3 数据
- 元数据隔离 - 发送附件不污染元数据索引
- 路径显示 - 网盘引用显示原路径,本地上传显示虚拟路径
设计遵循了以下原则:
- 复用现有能力 - skipMetadata 参数复用上传逻辑
- 最小改动 - 约 100 行代码解决所有问题
- 数据一致 - 运行时构造显示路径,避免冗余存储