diff --git a/src/modules/file/Application/Features/Files/Handlers/UploadFileCommandHandler.cs b/src/modules/file/Application/Features/Files/Handlers/UploadFileCommandHandler.cs
index 63181cc..6e67995 100644
--- a/src/modules/file/Application/Features/Files/Handlers/UploadFileCommandHandler.cs
+++ b/src/modules/file/Application/Features/Files/Handlers/UploadFileCommandHandler.cs
@@ -49,6 +49,7 @@ public class UploadFileCommandHandler(
S3Key = req.ObjectKey,
S3Bucket = bucket,
Size = req.Size,
+ SizeVerified = true,
ContentType = string.IsNullOrEmpty(req.ContentType) ? "application/octet-stream" : req.ContentType,
ContentHash = req.ContentHash,
OwnerId = owner,
diff --git a/src/modules/file/Application/Features/Folders/Handlers/UploadToFolderCommandHandler.cs b/src/modules/file/Application/Features/Folders/Handlers/UploadToFolderCommandHandler.cs
index 459b79f..6ed0fab 100644
--- a/src/modules/file/Application/Features/Folders/Handlers/UploadToFolderCommandHandler.cs
+++ b/src/modules/file/Application/Features/Folders/Handlers/UploadToFolderCommandHandler.cs
@@ -47,6 +47,7 @@ public class UploadToFolderCommandHandler(
S3Key = s3Key,
S3Bucket = bucket,
Size = req.Size,
+ SizeVerified = true,
ContentType = contentType,
ContentHash = req.ContentHash,
OwnerId = owner,
diff --git a/src/modules/file/Application/Features/Multipart/Handlers/CompleteMultipartCommandHandler.cs b/src/modules/file/Application/Features/Multipart/Handlers/CompleteMultipartCommandHandler.cs
index 0b2e17e..252acae 100644
--- a/src/modules/file/Application/Features/Multipart/Handlers/CompleteMultipartCommandHandler.cs
+++ b/src/modules/file/Application/Features/Multipart/Handlers/CompleteMultipartCommandHandler.cs
@@ -56,6 +56,7 @@ public class CompleteMultipartCommandHandler(
S3Key = session.ObjectKey,
S3Bucket = session.BucketName,
Size = req.Size,
+ SizeVerified = true,
ContentType = "application/octet-stream",
ContentHash = session.ContentHash,
OwnerId = session.OwnerId,
diff --git a/src/modules/file/Domain/Entities/FileMeta/FileMetaEntity.cs b/src/modules/file/Domain/Entities/FileMeta/FileMetaEntity.cs
index d84e20c..56b96e1 100644
--- a/src/modules/file/Domain/Entities/FileMeta/FileMetaEntity.cs
+++ b/src/modules/file/Domain/Entities/FileMeta/FileMetaEntity.cs
@@ -20,6 +20,9 @@ public class FileMetaEntity : BaseEntity, IAuditable, IHasOperatorIP
public long Size { get; set; }
+ /// 对象大小是否已由上传结果或后台 S3 查询确认。
+ public bool SizeVerified { get; set; }
+
/// 文件内容 SHA-256 哈希(前端计算),秒传查重的依据。可空(老数据/老客户端兼容)。
public string? ContentHash { get; set; }
diff --git a/src/modules/file/Infrastructure/Migrations/20260724020220_AddFileSizeVerified.Designer.cs b/src/modules/file/Infrastructure/Migrations/20260724020220_AddFileSizeVerified.Designer.cs
new file mode 100644
index 0000000..cb0e58e
--- /dev/null
+++ b/src/modules/file/Infrastructure/Migrations/20260724020220_AddFileSizeVerified.Designer.cs
@@ -0,0 +1,365 @@
+//
+using System;
+using File.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace File.Infrastructure.Migrations
+{
+ [DbContext(typeof(FileDbContext))]
+ [Migration("20260724020220_AddFileSizeVerified")]
+ partial class AddFileSizeVerified
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.7")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("File.Domain.Entities.FileMeta.FileMetaEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasComment("文件元数据ID");
+
+ b.Property("ContentHash")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasComment("文件内容SHA-256哈希,秒传查重依据");
+
+ b.Property("ContentType")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasDefaultValue("application/octet-stream")
+ .HasComment("MIME类型");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasComment("创建人");
+
+ b.Property("FolderId")
+ .HasColumnType("uuid")
+ .HasComment("所属文件夹ID,null表示根目录散落文件");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasComment("文件名");
+
+ b.Property("OperatorIP")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasComment("操作者IP");
+
+ b.Property("OwnerId")
+ .IsRequired()
+ .HasMaxLength(36)
+ .HasColumnType("character varying(36)")
+ .HasComment("所有者用户Id(JWT sub)");
+
+ b.Property("S3Bucket")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasComment("S3存储桶名");
+
+ b.Property("S3Key")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasComment("S3对象key(UploadToFolder恒用UUID)");
+
+ b.Property("Size")
+ .HasColumnType("bigint")
+ .HasComment("文件大小(字节)");
+
+ b.Property("SizeVerified")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasColumnName("size_verified")
+ .HasDefaultValue(false)
+ .HasComment("文件大小是否已确认");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最后修改时间(UTC)");
+
+ b.Property("UpdatedBy")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasComment("最后修改人");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FolderId");
+
+ b.HasIndex("OwnerId");
+
+ b.HasIndex("S3Key");
+
+ b.HasIndex("OwnerId", "ContentHash")
+ .IsUnique();
+
+ b.HasIndex("SizeVerified", "CreatedAt")
+ .HasDatabaseName("ix_b_files_size_verified_created_at");
+
+ b.ToTable("b_files", (string)null);
+ });
+
+ modelBuilder.Entity("File.Domain.Entities.FileSetting.FileSettingEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("integer")
+ .HasComment("固定为1,保证单行");
+
+ b.Property("DefaultBucket")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasComment("默认存储桶名");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最后修改时间(UTC)");
+
+ b.HasKey("Id");
+
+ b.ToTable("b_file_settings", (string)null);
+ });
+
+ modelBuilder.Entity("File.Domain.Entities.Folder.FolderEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasComment("文件夹ID");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasComment("创建人");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasComment("文件夹名称(1-255,禁/\\控制字符)");
+
+ b.Property("OperatorIP")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasComment("操作者IP");
+
+ b.Property("OwnerId")
+ .IsRequired()
+ .HasMaxLength(36)
+ .HasColumnType("character varying(36)")
+ .HasComment("所有者用户Id(JWT sub)");
+
+ b.Property("ParentId")
+ .HasColumnType("uuid")
+ .HasComment("父文件夹ID,null表示根目录");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最后修改时间(UTC)");
+
+ b.Property("UpdatedBy")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasComment("最后修改人");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OwnerId");
+
+ b.HasIndex("ParentId");
+
+ b.ToTable("b_folders", (string)null);
+ });
+
+ modelBuilder.Entity("File.Domain.Entities.MultipartUploadSession.MultipartUploadSessionEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasComment("会话ID");
+
+ b.Property("BucketName")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasComment("S3存储桶名");
+
+ b.Property("ContentHash")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasComment("文件内容SHA-256哈希,仅记录用途");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasComment("创建人");
+
+ b.Property("ObjectKey")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("character varying(1024)")
+ .HasComment("S3对象key(断点续传用内容哈希派生稳定值)");
+
+ b.Property("OperatorIP")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasComment("操作者IP");
+
+ b.Property("OwnerId")
+ .IsRequired()
+ .HasMaxLength(36)
+ .HasColumnType("character varying(36)")
+ .HasComment("所有者用户Id(JWT sub)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)")
+ .HasComment("会话状态:active/completed/aborted");
+
+ b.Property("TotalParts")
+ .HasColumnType("integer")
+ .HasComment("预计分片总数,进度参考");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最后修改时间(UTC)");
+
+ b.Property("UpdatedBy")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasComment("最后修改人");
+
+ b.Property("UploadId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasComment("S3 multipart upload的UploadId,续传依据");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UploadId")
+ .IsUnique();
+
+ b.HasIndex("OwnerId", "BucketName", "ObjectKey");
+
+ b.ToTable("b_multipart_upload_sessions", (string)null);
+ });
+
+ modelBuilder.Entity("File.Domain.Entities.ShareLink.ShareLinkEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasComment("分享链接ID");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasComment("创建人(即分享创建者,用于所有权隔离)");
+
+ b.Property("DownloadCount")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0)
+ .HasComment("已下载次数");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("过期时间(UTC),null表示永不过期");
+
+ b.Property("MaxDownloads")
+ .HasColumnType("integer")
+ .HasComment("最大下载次数,null表示不限");
+
+ b.Property("OperatorIP")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasComment("操作者IP");
+
+ b.Property("Password")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasComment("bcrypt密码哈希,null表示无需密码");
+
+ b.Property("ResourceId")
+ .HasColumnType("uuid")
+ .HasComment("被分享资源ID");
+
+ b.Property("ResourceType")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)")
+ .HasComment("资源类型:folder/file");
+
+ b.Property("Token")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasComment("分享token(16字节随机hex),唯一索引");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最后修改时间(UTC)");
+
+ b.Property("UpdatedBy")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasComment("最后修改人");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Token")
+ .IsUnique();
+
+ b.ToTable("b_share_links", (string)null);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/modules/file/Infrastructure/Migrations/20260724020220_AddFileSizeVerified.cs b/src/modules/file/Infrastructure/Migrations/20260724020220_AddFileSizeVerified.cs
new file mode 100644
index 0000000..1ee3974
--- /dev/null
+++ b/src/modules/file/Infrastructure/Migrations/20260724020220_AddFileSizeVerified.cs
@@ -0,0 +1,41 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace File.Infrastructure.Migrations
+{
+ ///
+ public partial class AddFileSizeVerified : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "size_verified",
+ table: "b_files",
+ type: "boolean",
+ nullable: false,
+ defaultValue: false,
+ comment: "文件大小是否已确认");
+
+ migrationBuilder.Sql("UPDATE b_files SET size_verified = TRUE WHERE \"Size\" > 0");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_b_files_size_verified_created_at",
+ table: "b_files",
+ columns: new[] { "size_verified", "CreatedAt" });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "ix_b_files_size_verified_created_at",
+ table: "b_files");
+
+ migrationBuilder.DropColumn(
+ name: "size_verified",
+ table: "b_files");
+ }
+ }
+}
diff --git a/src/modules/file/Infrastructure/Migrations/FileDbContextModelSnapshot.cs b/src/modules/file/Infrastructure/Migrations/FileDbContextModelSnapshot.cs
index a66ff6a..898ecb9 100644
--- a/src/modules/file/Infrastructure/Migrations/FileDbContextModelSnapshot.cs
+++ b/src/modules/file/Infrastructure/Migrations/FileDbContextModelSnapshot.cs
@@ -22,7 +22,7 @@ namespace BuildFlow.File.Infrastructure.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
- modelBuilder.Entity("File.Domain.Entities.FileMeta", b =>
+ modelBuilder.Entity("File.Domain.Entities.FileMeta.FileMetaEntity", b =>
{
b.Property("Id")
.HasColumnType("uuid")
@@ -89,6 +89,13 @@ namespace BuildFlow.File.Infrastructure.Migrations
.HasColumnType("bigint")
.HasComment("文件大小(字节)");
+ b.Property("SizeVerified")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasColumnName("size_verified")
+ .HasDefaultValue(false)
+ .HasComment("文件大小是否已确认");
+
b.Property("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最后修改时间(UTC)");
@@ -110,10 +117,13 @@ namespace BuildFlow.File.Infrastructure.Migrations
b.HasIndex("OwnerId", "ContentHash")
.IsUnique();
+ b.HasIndex("SizeVerified", "CreatedAt")
+ .HasDatabaseName("ix_b_files_size_verified_created_at");
+
b.ToTable("b_files", (string)null);
});
- modelBuilder.Entity("File.Domain.Entities.FileSetting", b =>
+ modelBuilder.Entity("File.Domain.Entities.FileSetting.FileSettingEntity", b =>
{
b.Property("Id")
.HasColumnType("integer")
@@ -134,7 +144,7 @@ namespace BuildFlow.File.Infrastructure.Migrations
b.ToTable("b_file_settings", (string)null);
});
- modelBuilder.Entity("File.Domain.Entities.Folder", b =>
+ modelBuilder.Entity("File.Domain.Entities.Folder.FolderEntity", b =>
{
b.Property("Id")
.HasColumnType("uuid")
@@ -191,7 +201,7 @@ namespace BuildFlow.File.Infrastructure.Migrations
b.ToTable("b_folders", (string)null);
});
- modelBuilder.Entity("File.Domain.Entities.MultipartUploadSession", b =>
+ modelBuilder.Entity("File.Domain.Entities.MultipartUploadSession.MultipartUploadSessionEntity", b =>
{
b.Property("Id")
.HasColumnType("uuid")
@@ -272,7 +282,7 @@ namespace BuildFlow.File.Infrastructure.Migrations
b.ToTable("b_multipart_upload_sessions", (string)null);
});
- modelBuilder.Entity("File.Domain.Entities.ShareLink", b =>
+ modelBuilder.Entity("File.Domain.Entities.ShareLink.ShareLinkEntity", b =>
{
b.Property("Id")
.HasColumnType("uuid")
diff --git a/src/modules/file/Infrastructure/Persistence/Configurations/FileMetaConfiguration.cs b/src/modules/file/Infrastructure/Persistence/Configurations/FileMetaConfiguration.cs
index 7f0fc2d..6597d76 100644
--- a/src/modules/file/Infrastructure/Persistence/Configurations/FileMetaConfiguration.cs
+++ b/src/modules/file/Infrastructure/Persistence/Configurations/FileMetaConfiguration.cs
@@ -20,6 +20,7 @@ public class FileMetaConfiguration : IEntityTypeConfiguration
builder.Property(e => e.S3Key).HasMaxLength(512).IsRequired().HasComment("S3对象key(UploadToFolder恒用UUID)");
builder.Property(e => e.S3Bucket).HasMaxLength(255).IsRequired().HasComment("S3存储桶名");
builder.Property(e => e.Size).HasComment("文件大小(字节)");
+ builder.Property(e => e.SizeVerified).HasColumnName("size_verified").HasDefaultValue(false).HasComment("文件大小是否已确认");
builder.Property(e => e.ContentType).HasMaxLength(255).HasDefaultValue("application/octet-stream").HasComment("MIME类型");
builder.Property(e => e.OwnerId).HasMaxLength(36).IsRequired().HasComment("所有者用户Id(JWT sub)");
builder.Property(e => e.ContentHash).HasMaxLength(128).HasComment("文件内容SHA-256哈希,秒传查重依据");
@@ -33,6 +34,8 @@ public class FileMetaConfiguration : IEntityTypeConfiguration
builder.HasIndex(e => e.FolderId);
builder.HasIndex(e => e.S3Key);
builder.HasIndex(e => e.OwnerId);
+ builder.HasIndex(e => new { e.SizeVerified, e.CreatedAt })
+ .HasDatabaseName("ix_b_files_size_verified_created_at");
// 秒传查重主路径:(OwnerId, ContentHash) 复合唯一索引。
// 唯一约束是并发安全的根本保证:应用层"查+插"存在 TOCTOU 窗口,唯有 DB 唯一约束能在
// 并发上传同一文件时真正阻止重复 FileMetaEntity + S3 孤儿对象。PostgreSQL 唯一索引允许多个 NULL,
diff --git a/tests/BuildFlow.File.Tests/FileSizeVerifiedTests.cs b/tests/BuildFlow.File.Tests/FileSizeVerifiedTests.cs
new file mode 100644
index 0000000..ee26f17
--- /dev/null
+++ b/tests/BuildFlow.File.Tests/FileSizeVerifiedTests.cs
@@ -0,0 +1,141 @@
+using BuildFlow.Shared.Domain.Common;
+using BuildFlow.Shared.Infrastructure;
+using File.Application.Common;
+using File.Application.Features.Files.Commands;
+using File.Application.Features.Files.Handlers;
+using File.Application.Features.Folders.Commands;
+using File.Application.Features.Folders.Handlers;
+using File.Application.Features.Multipart.Commands;
+using File.Application.Features.Multipart.DTOs;
+using File.Application.Features.Multipart.Handlers;
+using File.Domain.Entities.FileMeta;
+using File.Domain.Entities.MultipartUploadSession;
+using File.Domain.Values;
+using File.Infrastructure.Persistence;
+using File.Infrastructure.Storage;
+using FluentAssertions;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Moq;
+using Xunit;
+
+namespace BuildFlow.File.Tests;
+
+/// 文件大小确认状态在上传路径与 EF 模型中的持久化契约测试。
+public class FileSizeVerifiedTests
+{
+ private const string OwnerId = "user-001";
+ private const string Bucket = "files";
+
+ [Fact]
+ public async Task DirectZeroByteUploadMarksSizeVerified()
+ {
+ var (db, s3, user) = CreateDependencies();
+ var handler = new UploadFileCommandHandler(
+ s3.Object,
+ db,
+ user.Object,
+ CreateBucketResolver(),
+ new Mock().Object);
+
+ await handler.Handle(
+ new UploadFileCommand("empty.txt", Stream.Null, "text/plain", 0),
+ CancellationToken.None);
+
+ var file = await db.Files.SingleAsync();
+ file.Size.Should().Be(0);
+ file.SizeVerified.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task FolderUploadMarksSizeVerified()
+ {
+ var (db, s3, user) = CreateDependencies();
+ var handler = new UploadToFolderCommandHandler(
+ db,
+ s3.Object,
+ user.Object,
+ CreateBucketResolver());
+
+ await handler.Handle(
+ new UploadToFolderCommand(
+ Guid.NewGuid(),
+ "report.pdf",
+ new MemoryStream([1, 2, 3]),
+ "application/pdf",
+ 3),
+ CancellationToken.None);
+
+ (await db.Files.SingleAsync()).SizeVerified.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task CompletedMultipartUploadMarksSizeVerified()
+ {
+ var (db, s3, user) = CreateDependencies();
+ db.UploadSessions.Add(new MultipartUploadSessionEntity
+ {
+ BucketName = Bucket,
+ ObjectKey = "multipart-key",
+ UploadId = "upload-1",
+ OwnerId = OwnerId,
+ Status = UploadSessionStatus.Active,
+ });
+ await db.SaveChangesAsync();
+ var handler = new CompleteMultipartCommandHandler(
+ s3.Object,
+ db,
+ user.Object,
+ CreateBucketResolver());
+
+ await handler.Handle(
+ new CompleteMultipartCommand(
+ "multipart-key",
+ "upload-1",
+ [new CompletedPartDto(1, "etag-1")],
+ "archive.zip",
+ 1024),
+ CancellationToken.None);
+
+ (await db.Files.SingleAsync()).SizeVerified.Should().BeTrue();
+ }
+
+ [Fact]
+ public void ModelHasPendingSizeVerificationIndex()
+ {
+ var (db, _, _) = CreateDependencies();
+ var entity = db.Model.FindEntityType(typeof(FileMetaEntity));
+
+ entity.Should().NotBeNull();
+ var sizeVerified = entity!.FindProperty(nameof(FileMetaEntity.SizeVerified));
+ sizeVerified.Should().NotBeNull();
+ sizeVerified!.GetColumnName(StoreObjectIdentifier.Table("b_files", null))
+ .Should().Be("size_verified");
+ entity!.GetIndexes().Should().Contain(index =>
+ index.Properties.Select(property => property.Name)
+ .SequenceEqual(new[] { nameof(FileMetaEntity.SizeVerified), nameof(FileMetaEntity.CreatedAt) }));
+ }
+
+ private static (FileDbContext Db, Mock S3, Mock User)
+ CreateDependencies()
+ {
+ var user = new Mock();
+ user.SetupGet(context => context.UserId).Returns(OwnerId);
+ user.SetupGet(context => context.IPAddress).Returns("127.0.0.1");
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase($"file-size-verified-{Guid.NewGuid():N}")
+ .AddInterceptors(new AuditInterceptor(user.Object))
+ .Options;
+
+ return (new FileDbContext(options), new Mock(), user);
+ }
+
+ private static IBucketResolver CreateBucketResolver()
+ {
+ var resolver = new Mock();
+ resolver.Setup(x => x.GetDefaultBucketAsync(It.IsAny()))
+ .ReturnsAsync(Bucket);
+ return resolver.Object;
+ }
+}