diff --git a/src/modules/file/Application/Common/FileSizeBackfillHelper.cs b/src/modules/file/Application/Common/FileSizeBackfillHelper.cs
new file mode 100644
index 0000000..043b64e
--- /dev/null
+++ b/src/modules/file/Application/Common/FileSizeBackfillHelper.cs
@@ -0,0 +1,52 @@
+using File.Domain.Entities;
+using File.Infrastructure.Persistence;
+using File.Infrastructure.Storage;
+
+namespace File.Application.Common;
+
+///
+/// 文件大小回填助手:对 Size==0 的 FileMeta,从 S3 HeadObject 取真实 ContentLength 写回。
+/// 用于读路径(列表加载)惰性修复早期未记录大小的旧数据——首次加载即回填并持久化,
+/// 后续加载直接命中 DB 不再访问 S3。对象缺失或空文件保留 Size=0;单个失败不中断整体。
+///
+public static class FileSizeBackfillHelper
+{
+ public static async Task BackfillAsync(
+ FileDbContext db,
+ IS3StorageService s3,
+ IReadOnlyList files,
+ CancellationToken ct)
+ {
+ var stale = files.Where(f => f.Size == 0).ToList();
+ if (stale.Count == 0)
+ {
+ return;
+ }
+
+ var updated = 0;
+ foreach (var file in stale)
+ {
+ try
+ {
+ var size = await s3.GetObjectSizeAsync(file.S3Bucket, file.S3Key, ct);
+ if (size is null || size <= 0)
+ {
+ // 对象缺失(已被外部清理)或空文件:保留 Size=0
+ continue;
+ }
+
+ file.Size = size.Value;
+ updated++;
+ }
+ catch
+ {
+ // 单个对象查询失败不中断整体回填,保留 Size=0 下次可重试
+ }
+ }
+
+ if (updated > 0)
+ {
+ await db.SaveChangesAsync(ct);
+ }
+ }
+}
diff --git a/src/modules/file/Application/Common/FolderSizeCalculator.cs b/src/modules/file/Application/Common/FolderSizeCalculator.cs
new file mode 100644
index 0000000..10925f8
--- /dev/null
+++ b/src/modules/file/Application/Common/FolderSizeCalculator.cs
@@ -0,0 +1,57 @@
+using File.Domain.Entities;
+
+namespace File.Application.Common;
+
+///
+/// 文件夹递归大小计算器:给定全部文件夹 + 每个文件夹的直接文件总大小,
+/// 用后序 DFS 算出每个文件夹的递归总大小(自身文件 + 所有子孙文件夹文件)。
+/// 用于 getFolderTree 在返回前一次性算出所有文件夹的 Size。
+///
+public static class FolderSizeCalculator
+{
+ public static Dictionary Compute(
+ IReadOnlyList folders,
+ IReadOnlyDictionary filesSizeByFolder)
+ {
+ // parentId → 子文件夹 id 列表(根文件夹 parentId=null 归到 Guid.Empty 桶,不会被当文件夹遍历)
+ var childrenByParent = folders
+ .GroupBy(f => f.ParentId ?? Guid.Empty)
+ .ToDictionary(g => g.Key, g => g.Select(f => f.Id).ToList());
+
+ var result = new Dictionary(folders.Count);
+ var inStack = new HashSet(); // 防环(理论上 ParentId 不该成环,保险起见)
+
+ long Dfs(Guid id)
+ {
+ if (result.TryGetValue(id, out var cached))
+ {
+ return cached;
+ }
+
+ if (!inStack.Add(id))
+ {
+ return 0; // 检测到环,断开避免死循环
+ }
+
+ var sum = filesSizeByFolder.GetValueOrDefault(id, 0L);
+ if (childrenByParent.TryGetValue(id, out var children))
+ {
+ foreach (var childId in children)
+ {
+ sum += Dfs(childId);
+ }
+ }
+
+ inStack.Remove(id);
+ result[id] = sum;
+ return sum;
+ }
+
+ foreach (var folder in folders)
+ {
+ Dfs(folder.Id);
+ }
+
+ return result;
+ }
+}
diff --git a/src/modules/file/Application/Features/Folders/Commands/FolderMapper.cs b/src/modules/file/Application/Features/Folders/Commands/FolderMapper.cs
index b78d288..0642097 100644
--- a/src/modules/file/Application/Features/Folders/Commands/FolderMapper.cs
+++ b/src/modules/file/Application/Features/Folders/Commands/FolderMapper.cs
@@ -6,5 +6,5 @@ namespace File.Application.Features.Folders.Commands;
/// Folder ↔ FolderDto 映射工具。
internal static class FolderMapper
{
- public static FolderDto ToDto(Folder f) => new(f.Id, f.ParentId, f.Name, f.OwnerId, f.CreatedAt, f.UpdatedAt);
+ public static FolderDto ToDto(Folder f, long size = 0) => new(f.Id, f.ParentId, f.Name, f.OwnerId, f.CreatedAt, f.UpdatedAt, size);
}
diff --git a/src/modules/file/Application/Features/Folders/DTOs/FolderDtos.cs b/src/modules/file/Application/Features/Folders/DTOs/FolderDtos.cs
index adf3bed..3b3c741 100644
--- a/src/modules/file/Application/Features/Folders/DTOs/FolderDtos.cs
+++ b/src/modules/file/Application/Features/Folders/DTOs/FolderDtos.cs
@@ -3,5 +3,5 @@
namespace File.Application.Features.Folders.DTOs;
-/// 文件夹 DTO。
-public record FolderDto(Guid Id, Guid? ParentId, string Name, string OwnerId, DateTime CreatedAt, DateTime UpdatedAt);
+/// 文件夹 DTO。Size = 该文件夹下所有文件递归汇总的字节大小(由 getFolderTree 计算)。
+public record FolderDto(Guid Id, Guid? ParentId, string Name, string OwnerId, DateTime CreatedAt, DateTime UpdatedAt, long Size = 0);
diff --git a/src/modules/file/Application/Features/Folders/Handlers/GetFolderQueryHandler.cs b/src/modules/file/Application/Features/Folders/Handlers/GetFolderQueryHandler.cs
index 233aaa5..5329937 100644
--- a/src/modules/file/Application/Features/Folders/Handlers/GetFolderQueryHandler.cs
+++ b/src/modules/file/Application/Features/Folders/Handlers/GetFolderQueryHandler.cs
@@ -1,15 +1,23 @@
using BuildFlow.Shared.Domain.Common;
+using File.Application.Common;
using File.Application.Features.Folders.Commands;
using File.Application.Features.Folders.DTOs;
using File.Application.Features.Folders.Queries;
using File.Infrastructure.Persistence;
+using File.Infrastructure.Storage;
using MediatR;
namespace File.Application.Features.Folders.Handlers;
-/// 查询单文件夹处理器。校验归属后聚合子文件夹与文件清单。
-public class GetFolderQueryHandler(FileDbContext db, ICurrentUserContext currentUser)
- : IRequestHandler
+///
+/// 查询单文件夹处理器。校验归属后聚合子文件夹与文件清单。
+/// 加载时对 Size==0 的旧文件记录惰性回填(从 S3 HeadObject 取真实大小写回),
+/// 这样移动文件进文件夹后再加载也会自动补全大小。
+///
+public class GetFolderQueryHandler(
+ FileDbContext db,
+ ICurrentUserContext currentUser,
+ IS3StorageService s3) : IRequestHandler
{
public async Task Handle(GetFolderQuery req, CancellationToken ct)
{
@@ -23,9 +31,11 @@ public class GetFolderQueryHandler(FileDbContext db, ICurrentUserContext current
.OrderBy(f => f.Name)
.ToListAsync(ct);
+ await FileSizeBackfillHelper.BackfillAsync(db, s3, files, ct);
+
return new FolderWithChildrenDto(
FolderMapper.ToDto(folder),
- subFolders.Select(FolderMapper.ToDto).ToList(),
+ subFolders.Select(f => FolderMapper.ToDto(f)).ToList(),
files.Select(f => new FileMetaDto(f.Id, f.FolderId, f.Name, f.S3Key, f.S3Bucket,
f.Size, f.ContentType, f.OwnerId, f.CreatedAt, f.UpdatedAt)).ToList());
}
diff --git a/src/modules/file/Application/Features/Folders/Handlers/GetFolderTreeQueryHandler.cs b/src/modules/file/Application/Features/Folders/Handlers/GetFolderTreeQueryHandler.cs
index 8e0a47a..fc30c6d 100644
--- a/src/modules/file/Application/Features/Folders/Handlers/GetFolderTreeQueryHandler.cs
+++ b/src/modules/file/Application/Features/Folders/Handlers/GetFolderTreeQueryHandler.cs
@@ -1,20 +1,43 @@
using BuildFlow.Shared.Domain.Common;
+using File.Application.Common;
using File.Application.Features.Folders.Commands;
using File.Application.Features.Folders.DTOs;
using File.Application.Features.Folders.Queries;
using File.Infrastructure.Persistence;
+using File.Infrastructure.Storage;
using MediatR;
namespace File.Application.Features.Folders.Handlers;
-/// 文件夹树查询处理器。返回当前用户范围内的全部文件夹。
-public class GetFolderTreeQueryHandler(FileDbContext db, ICurrentUserContext currentUser)
- : IRequestHandler>
+///
+/// 文件夹树查询处理器。返回当前用户范围内的全部文件夹。
+/// 同时计算每个文件夹的递归总大小(自身文件 + 所有子孙文件夹文件),写入 FolderDto.Size。
+/// 计算前先回填历史 Size=0 的文件,保证汇总准确。
+///
+public class GetFolderTreeQueryHandler(
+ FileDbContext db,
+ ICurrentUserContext currentUser,
+ IS3StorageService s3) : IRequestHandler>
{
public async Task> Handle(GetFolderTreeQuery req, CancellationToken ct)
{
var owner = currentUser.GetRequiredUserId();
+
var folders = await db.Folders.Where(f => f.OwnerId == owner).ToListAsync(ct);
- return folders.Select(FolderMapper.ToDto).ToList();
+
+ // 加载该用户全部文件:先回填历史 Size=0 记录(保证文件夹大小准确),再按文件夹汇总
+ var files = await db.Files.Where(f => f.OwnerId == owner).ToListAsync(ct);
+ await FileSizeBackfillHelper.BackfillAsync(db, s3, files, ct);
+
+ var filesSizeByFolder = files
+ .Where(f => f.FolderId.HasValue)
+ .GroupBy(f => f.FolderId!.Value)
+ .ToDictionary(g => g.Key, g => g.Sum(f => f.Size));
+
+ var sizes = FolderSizeCalculator.Compute(folders, filesSizeByFolder);
+
+ return folders
+ .Select(f => FolderMapper.ToDto(f, sizes.GetValueOrDefault(f.Id, 0L)))
+ .ToList();
}
}
diff --git a/src/modules/file/Application/Features/Folders/Handlers/GetRootFilesQueryHandler.cs b/src/modules/file/Application/Features/Folders/Handlers/GetRootFilesQueryHandler.cs
index 140c025..dfbb6dc 100644
--- a/src/modules/file/Application/Features/Folders/Handlers/GetRootFilesQueryHandler.cs
+++ b/src/modules/file/Application/Features/Folders/Handlers/GetRootFilesQueryHandler.cs
@@ -1,7 +1,9 @@
using BuildFlow.Shared.Domain.Common;
+using File.Application.Common;
using File.Application.Features.Folders.DTOs;
using File.Application.Features.Folders.Queries;
using File.Infrastructure.Persistence;
+using File.Infrastructure.Storage;
using MediatR;
using Microsoft.EntityFrameworkCore;
@@ -10,9 +12,12 @@ namespace File.Application.Features.Folders.Handlers;
///
/// 查询根级文件处理器。返回当前用户名下 FolderId=null 的所有 FileMeta(按名称排序)。
/// 与 GetFolderQueryHandler 配合,使根目录与子文件夹共享同一数据源(DB),而非 S3 列表。
+/// 加载时对 Size==0 的旧记录惰性回填(从 S3 HeadObject 取真实大小写回)。
///
-public class GetRootFilesQueryHandler(FileDbContext db, ICurrentUserContext currentUser)
- : IRequestHandler>
+public class GetRootFilesQueryHandler(
+ FileDbContext db,
+ ICurrentUserContext currentUser,
+ IS3StorageService s3) : IRequestHandler>
{
public async Task> Handle(GetRootFilesQuery req, CancellationToken ct)
{
@@ -22,6 +27,8 @@ public class GetRootFilesQueryHandler(FileDbContext db, ICurrentUserContext curr
.OrderBy(f => f.Name)
.ToListAsync(ct);
+ await FileSizeBackfillHelper.BackfillAsync(db, s3, files, ct);
+
return files.Select(f => new FileMetaDto(f.Id, f.FolderId, f.Name, f.S3Key, f.S3Bucket,
f.Size, f.ContentType, f.OwnerId, f.CreatedAt, f.UpdatedAt)).ToList();
}
diff --git a/src/modules/file/Infrastructure/Storage/IS3StorageService.cs b/src/modules/file/Infrastructure/Storage/IS3StorageService.cs
index 1b49f62..1af2376 100644
--- a/src/modules/file/Infrastructure/Storage/IS3StorageService.cs
+++ b/src/modules/file/Infrastructure/Storage/IS3StorageService.cs
@@ -26,6 +26,12 @@ public interface IS3StorageService
///
Task ObjectExistsAsync(string bucket, string key, CancellationToken ct);
+ ///
+ /// 取对象字节大小(HeadObject → ContentLength)。对象不存在返回 null。
+ /// 用于回填历史 FileMeta.Size=0 的记录(早期未记录大小的旧数据)。
+ ///
+ Task GetObjectSizeAsync(string bucket, string key, CancellationToken ct);
+
// 分片上传
Task CreateMultipartUploadAsync(string bucket, string key, CancellationToken ct);
Task UploadPartAsync(string bucket, string key, string uploadId, int partNumber, Stream data, CancellationToken ct);
diff --git a/src/modules/file/Infrastructure/Storage/S3StorageService.cs b/src/modules/file/Infrastructure/Storage/S3StorageService.cs
index 2190153..39cd276 100644
--- a/src/modules/file/Infrastructure/Storage/S3StorageService.cs
+++ b/src/modules/file/Infrastructure/Storage/S3StorageService.cs
@@ -166,6 +166,21 @@ public class S3StorageService : IS3StorageService
catch (Exception ex) { throw Wrap(ex); }
}
+ public async Task GetObjectSizeAsync(string bucket, string key, CancellationToken ct)
+ {
+ try
+ {
+ // HeadObject 取 ContentLength;不存在抛 NotFound → 返回 null
+ var resp = await _client.GetObjectMetadataAsync(bucket, key, ct);
+ return resp?.ContentLength;
+ }
+ catch (Amazon.S3.AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ return null;
+ }
+ catch (Exception ex) { throw Wrap(ex); }
+ }
+
public async Task CreateMultipartUploadAsync(string bucket, string key, CancellationToken ct)
{
try