feat: track verified file sizes

This commit is contained in:
xiangning 2026-07-24 10:09:12 +08:00
parent 156fb01fdb
commit 43a5911a5a
9 changed files with 571 additions and 5 deletions

View File

@ -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,

View File

@ -47,6 +47,7 @@ public class UploadToFolderCommandHandler(
S3Key = s3Key,
S3Bucket = bucket,
Size = req.Size,
SizeVerified = true,
ContentType = contentType,
ContentHash = req.ContentHash,
OwnerId = owner,

View File

@ -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,

View File

@ -20,6 +20,9 @@ public class FileMetaEntity : BaseEntity, IAuditable, IHasOperatorIP
public long Size { get; set; }
/// <summary>对象大小是否已由上传结果或后台 S3 查询确认。</summary>
public bool SizeVerified { get; set; }
/// <summary>文件内容 SHA-256 哈希(前端计算),秒传查重的依据。可空(老数据/老客户端兼容)。</summary>
public string? ContentHash { get; set; }

View File

@ -0,0 +1,365 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.HasColumnType("uuid")
.HasComment("文件元数据ID");
b.Property<string>("ContentHash")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasComment("文件内容SHA-256哈希,秒传查重依据");
b.Property<string>("ContentType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasDefaultValue("application/octet-stream")
.HasComment("MIME类型");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间(UTC)");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasComment("创建人");
b.Property<Guid?>("FolderId")
.HasColumnType("uuid")
.HasComment("所属文件夹ID,null表示根目录散落文件");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasComment("文件名");
b.Property<string>("OperatorIP")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasComment("操作者IP");
b.Property<string>("OwnerId")
.IsRequired()
.HasMaxLength(36)
.HasColumnType("character varying(36)")
.HasComment("所有者用户Id(JWT sub)");
b.Property<string>("S3Bucket")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasComment("S3存储桶名");
b.Property<string>("S3Key")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasComment("S3对象key(UploadToFolder恒用UUID)");
b.Property<long>("Size")
.HasColumnType("bigint")
.HasComment("文件大小(字节)");
b.Property<bool>("SizeVerified")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasColumnName("size_verified")
.HasDefaultValue(false)
.HasComment("文件大小是否已确认");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最后修改时间(UTC)");
b.Property<string>("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<int>("Id")
.HasColumnType("integer")
.HasComment("固定为1,保证单行");
b.Property<string>("DefaultBucket")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasComment("默认存储桶名");
b.Property<DateTime>("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<Guid>("Id")
.HasColumnType("uuid")
.HasComment("文件夹ID");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间(UTC)");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasComment("创建人");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasComment("文件夹名称(1-255,禁/\\控制字符)");
b.Property<string>("OperatorIP")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasComment("操作者IP");
b.Property<string>("OwnerId")
.IsRequired()
.HasMaxLength(36)
.HasColumnType("character varying(36)")
.HasComment("所有者用户Id(JWT sub)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid")
.HasComment("父文件夹ID,null表示根目录");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最后修改时间(UTC)");
b.Property<string>("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<Guid>("Id")
.HasColumnType("uuid")
.HasComment("会话ID");
b.Property<string>("BucketName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasComment("S3存储桶名");
b.Property<string>("ContentHash")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasComment("文件内容SHA-256哈希,仅记录用途");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间(UTC)");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasComment("创建人");
b.Property<string>("ObjectKey")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasComment("S3对象key(断点续传用内容哈希派生稳定值)");
b.Property<string>("OperatorIP")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasComment("操作者IP");
b.Property<string>("OwnerId")
.IsRequired()
.HasMaxLength(36)
.HasColumnType("character varying(36)")
.HasComment("所有者用户Id(JWT sub)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasComment("会话状态:active/completed/aborted");
b.Property<int?>("TotalParts")
.HasColumnType("integer")
.HasComment("预计分片总数,进度参考");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最后修改时间(UTC)");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasComment("最后修改人");
b.Property<string>("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<Guid>("Id")
.HasColumnType("uuid")
.HasComment("分享链接ID");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间(UTC)");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasComment("创建人(即分享创建者,用于所有权隔离)");
b.Property<int>("DownloadCount")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0)
.HasComment("已下载次数");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasComment("过期时间(UTC),null表示永不过期");
b.Property<int?>("MaxDownloads")
.HasColumnType("integer")
.HasComment("最大下载次数,null表示不限");
b.Property<string>("OperatorIP")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasComment("操作者IP");
b.Property<string>("Password")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasComment("bcrypt密码哈希,null表示无需密码");
b.Property<Guid>("ResourceId")
.HasColumnType("uuid")
.HasComment("被分享资源ID");
b.Property<string>("ResourceType")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasComment("资源类型:folder/file");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasComment("分享token(16字节随机hex),唯一索引");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最后修改时间(UTC)");
b.Property<string>("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
}
}
}

View File

@ -0,0 +1,41 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace File.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddFileSizeVerified : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
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" });
}
/// <inheritdoc />
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");
}
}
}

View File

@ -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<Guid>("Id")
.HasColumnType("uuid")
@ -89,6 +89,13 @@ namespace BuildFlow.File.Infrastructure.Migrations
.HasColumnType("bigint")
.HasComment("文件大小(字节)");
b.Property<bool>("SizeVerified")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasColumnName("size_verified")
.HasDefaultValue(false)
.HasComment("文件大小是否已确认");
b.Property<DateTime>("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<int>("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<Guid>("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<Guid>("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<Guid>("Id")
.HasColumnType("uuid")

View File

@ -20,6 +20,7 @@ public class FileMetaConfiguration : IEntityTypeConfiguration<FileMetaEntity>
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<FileMetaEntity>
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,

View File

@ -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;
/// <summary>文件大小确认状态在上传路径与 EF 模型中的持久化契约测试。</summary>
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<IMediator>().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<IS3StorageService> S3, Mock<ICurrentUserContext> User)
CreateDependencies()
{
var user = new Mock<ICurrentUserContext>();
user.SetupGet(context => context.UserId).Returns(OwnerId);
user.SetupGet(context => context.IPAddress).Returns("127.0.0.1");
var options = new DbContextOptionsBuilder<FileDbContext>()
.UseInMemoryDatabase($"file-size-verified-{Guid.NewGuid():N}")
.AddInterceptors(new AuditInterceptor(user.Object))
.Options;
return (new FileDbContext(options), new Mock<IS3StorageService>(), user);
}
private static IBucketResolver CreateBucketResolver()
{
var resolver = new Mock<IBucketResolver>();
resolver.Setup(x => x.GetDefaultBucketAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Bucket);
return resolver.Object;
}
}