feat: track verified file sizes
This commit is contained in:
parent
156fb01fdb
commit
43a5911a5a
@ -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,
|
||||
|
||||
@ -47,6 +47,7 @@ public class UploadToFolderCommandHandler(
|
||||
S3Key = s3Key,
|
||||
S3Bucket = bucket,
|
||||
Size = req.Size,
|
||||
SizeVerified = true,
|
||||
ContentType = contentType,
|
||||
ContentHash = req.ContentHash,
|
||||
OwnerId = owner,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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; }
|
||||
|
||||
|
||||
365
src/modules/file/Infrastructure/Migrations/20260724020220_AddFileSizeVerified.Designer.cs
generated
Normal file
365
src/modules/file/Infrastructure/Migrations/20260724020220_AddFileSizeVerified.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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,
|
||||
|
||||
141
tests/BuildFlow.File.Tests/FileSizeVerifiedTests.cs
Normal file
141
tests/BuildFlow.File.Tests/FileSizeVerifiedTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user