refactor(file): 移除bucket参数并使用默认桶解析器
- 移除AbortMultipartCommand中的BucketName参数 - 移除CompleteMultipartCommand中的BucketName参数 - 移除DeleteFileCommand中的BucketName参数 - 移除DownloadFileQuery中的BucketName参数 - 移除GetFileContentQuery中的BucketName参数 - 移除GetFilePreviewQuery中的BucketName参数 - 移除GetOfficePreviewQuery中的BucketName参数 - 移除InitMultipartCommand中的BucketName参数 - 在各处理器中注入IBucketResolver并获取默认桶 - 更新所有相关端点和请求模型移除bucket参数 - 修改测试代码适配参数变更 - 添加IM离线消息到平台通知中心的跨模块集成实现
This commit is contained in:
parent
67dce6b1f4
commit
8f85a2d3c8
@ -83,6 +83,9 @@ public class BuildFlowHostModule : AbpModule
|
|||||||
|
|
||||||
// 串联:IM @AI → RAG 问答(跨模块集成,RAG 实现 IAiBotHandler 供 IM 调用)
|
// 串联:IM @AI → RAG 问答(跨模块集成,RAG 实现 IAiBotHandler 供 IM 调用)
|
||||||
services.AddScoped<BuildFlow.Shared.Integration.IAiBotHandler, BuildFlow.Application.Integration.AiBotHandler>();
|
services.AddScoped<BuildFlow.Shared.Integration.IAiBotHandler, BuildFlow.Application.Integration.AiBotHandler>();
|
||||||
|
// 串联:IM 离线消息 → 平台通知中心站内信(跨模块集成,Host 实现 IImNotificationPublisher 供 IM 调用,
|
||||||
|
// 内部转发为 PublishNotificationCommand 复用 platform 的 fanout 落库 + SignalR 推送)
|
||||||
|
services.AddScoped<IImNotificationPublisher, ImNotificationPublisher>();
|
||||||
// 串联:AI 对话 → 触发工作流(跨模块集成,Workflow 实现 IWorkflowTriggerService 供 RAG 调用)
|
// 串联:AI 对话 → 触发工作流(跨模块集成,Workflow 实现 IWorkflowTriggerService 供 RAG 调用)
|
||||||
services.AddScoped<BuildFlow.Shared.Integration.IWorkflowTriggerService, WorkflowTriggerService>();
|
services.AddScoped<BuildFlow.Shared.Integration.IWorkflowTriggerService, WorkflowTriggerService>();
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,7 @@ public class DeleteFileEndpoint(IMediator mediator) : Endpoint<DeleteFileRequest
|
|||||||
|
|
||||||
public override async Task HandleAsync(DeleteFileRequest req, CancellationToken ct)
|
public override async Task HandleAsync(DeleteFileRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await mediator.Send(new DeleteFileCommand(req.BucketName, req.ObjectKey), ct);
|
await mediator.Send(new DeleteFileCommand(req.ObjectKey), ct);
|
||||||
await Send.OkAsync(new { message = "deleted" }, ct);
|
await Send.OkAsync(new { message = "deleted" }, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
namespace BuildFlow.Host.Endpoints.file.Files;
|
namespace BuildFlow.Host.Endpoints.file.Files;
|
||||||
|
|
||||||
/// <summary>删除文件请求。以查询参数指定存储桶与对象键。</summary>
|
/// <summary>删除文件请求。以查询参数指定对象键。</summary>
|
||||||
public class DeleteFileRequest
|
public class DeleteFileRequest
|
||||||
{
|
{
|
||||||
[QueryParam] public string BucketName { get; init; } = default!;
|
|
||||||
[QueryParam] public string ObjectKey { get; init; } = default!;
|
[QueryParam] public string ObjectKey { get; init; } = default!;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,7 @@ public class DownloadFileEndpoint(IMediator mediator) : Endpoint<DownloadFileReq
|
|||||||
|
|
||||||
public override async Task HandleAsync(DownloadFileRequest req, CancellationToken ct)
|
public override async Task HandleAsync(DownloadFileRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new DownloadFileQuery(req.BucketName, req.ObjectKey), ct);
|
var result = await mediator.Send(new DownloadFileQuery(req.ObjectKey), ct);
|
||||||
|
|
||||||
// 设置响应头:真实 ContentType + 触发下载的文件名
|
// 设置响应头:真实 ContentType + 触发下载的文件名
|
||||||
HttpContext.Response.StatusCode = 200;
|
HttpContext.Response.StatusCode = 200;
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
namespace BuildFlow.Host.Endpoints.file.Files;
|
namespace BuildFlow.Host.Endpoints.file.Files;
|
||||||
|
|
||||||
/// <summary>下载文件请求。以查询参数指定存储桶与对象键。</summary>
|
/// <summary>下载文件请求。以查询参数指定对象键。</summary>
|
||||||
public class DownloadFileRequest
|
public class DownloadFileRequest
|
||||||
{
|
{
|
||||||
[QueryParam] public string BucketName { get; init; } = default!;
|
|
||||||
[QueryParam] public string ObjectKey { get; init; } = default!;
|
[QueryParam] public string ObjectKey { get; init; } = default!;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,7 @@ public class GetFileContentEndpoint(IMediator mediator) : Endpoint<GetFileConten
|
|||||||
|
|
||||||
public override async Task HandleAsync(GetFileContentRequest req, CancellationToken ct)
|
public override async Task HandleAsync(GetFileContentRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetFileContentQuery(req.BucketName, req.ObjectKey), ct);
|
var result = await mediator.Send(new GetFileContentQuery(req.ObjectKey), ct);
|
||||||
await Send.OkAsync(result, ct);
|
await Send.OkAsync(result, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
namespace BuildFlow.Host.Endpoints.file.Files;
|
namespace BuildFlow.Host.Endpoints.file.Files;
|
||||||
|
|
||||||
/// <summary>文件内容请求。以查询参数指定存储桶与对象键。</summary>
|
/// <summary>文件内容请求。以查询参数指定对象键(bucket 由系统设置统一管理)。</summary>
|
||||||
public class GetFileContentRequest
|
public class GetFileContentRequest
|
||||||
{
|
{
|
||||||
[QueryParam] public string BucketName { get; init; } = default!;
|
|
||||||
[QueryParam] public string ObjectKey { get; init; } = default!;
|
[QueryParam] public string ObjectKey { get; init; } = default!;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,7 @@ public class GetFilePreviewEndpoint(IMediator mediator) : Endpoint<GetFilePrevie
|
|||||||
|
|
||||||
public override async Task HandleAsync(GetFilePreviewRequest req, CancellationToken ct)
|
public override async Task HandleAsync(GetFilePreviewRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetFilePreviewQuery(req.BucketName, req.ObjectKey), ct);
|
var result = await mediator.Send(new GetFilePreviewQuery(req.ObjectKey), ct);
|
||||||
await Send.OkAsync(result, ct);
|
await Send.OkAsync(result, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
namespace BuildFlow.Host.Endpoints.file.Files;
|
namespace BuildFlow.Host.Endpoints.file.Files;
|
||||||
|
|
||||||
/// <summary>文件预览请求。以查询参数指定存储桶与对象键。</summary>
|
/// <summary>文件预览请求。以查询参数指定对象键。</summary>
|
||||||
public class GetFilePreviewRequest
|
public class GetFilePreviewRequest
|
||||||
{
|
{
|
||||||
[QueryParam] public string BucketName { get; init; } = default!;
|
|
||||||
[QueryParam] public string ObjectKey { get; init; } = default!;
|
[QueryParam] public string ObjectKey { get; init; } = default!;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,7 @@ public class GetOfficePreviewEndpoint(IMediator mediator) : Endpoint<GetOfficePr
|
|||||||
|
|
||||||
public override async Task HandleAsync(GetOfficePreviewRequest req, CancellationToken ct)
|
public override async Task HandleAsync(GetOfficePreviewRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetOfficePreviewQuery(req.BucketName, req.ObjectKey), ct);
|
var result = await mediator.Send(new GetOfficePreviewQuery(req.ObjectKey), ct);
|
||||||
await Send.OkAsync(result, ct);
|
await Send.OkAsync(result, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
namespace BuildFlow.Host.Endpoints.file.Files;
|
namespace BuildFlow.Host.Endpoints.file.Files;
|
||||||
|
|
||||||
/// <summary>Office/PDF 文档预览请求。以查询参数指定存储桶与对象键。</summary>
|
/// <summary>Office/PDF 文档预览请求。以查询参数指定对象键。</summary>
|
||||||
public class GetOfficePreviewRequest
|
public class GetOfficePreviewRequest
|
||||||
{
|
{
|
||||||
[QueryParam] public string BucketName { get; init; } = default!;
|
|
||||||
[QueryParam] public string ObjectKey { get; init; } = default!;
|
[QueryParam] public string ObjectKey { get; init; } = default!;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,7 @@ public class ListFilesEndpoint(IMediator mediator) : Endpoint<ListFilesRequest,
|
|||||||
|
|
||||||
public override async Task HandleAsync(ListFilesRequest req, CancellationToken ct)
|
public override async Task HandleAsync(ListFilesRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new ListFilesQuery(req.BucketName, req.Prefix, req.MaxKeys, req.ContinuationToken), ct);
|
var result = await mediator.Send(new ListFilesQuery(req.Prefix, req.MaxKeys, req.ContinuationToken), ct);
|
||||||
await Send.OkAsync(result, ct);
|
await Send.OkAsync(result, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
namespace BuildFlow.Host.Endpoints.file.Files;
|
namespace BuildFlow.Host.Endpoints.file.Files;
|
||||||
|
|
||||||
/// <summary>列举文件请求。包含存储桶、前缀、最大键数与续传令牌。</summary>
|
/// <summary>列举文件请求。包含前缀、最大键数与续传令牌。</summary>
|
||||||
public class ListFilesRequest
|
public class ListFilesRequest
|
||||||
{
|
{
|
||||||
[QueryParam] public string BucketName { get; init; } = default!;
|
|
||||||
[QueryParam] public string? Prefix { get; init; }
|
[QueryParam] public string? Prefix { get; init; }
|
||||||
[QueryParam] public int? MaxKeys { get; init; }
|
[QueryParam] public int? MaxKeys { get; init; }
|
||||||
[QueryParam] public string? ContinuationToken { get; init; }
|
[QueryParam] public string? ContinuationToken { get; init; }
|
||||||
|
|||||||
@ -22,7 +22,7 @@ public class UploadFileEndpoint(IMediator mediator) : Endpoint<UploadFileRequest
|
|||||||
public override async Task HandleAsync(UploadFileRequest req, CancellationToken ct)
|
public override async Task HandleAsync(UploadFileRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await using var stream = req.File.OpenReadStream();
|
await using var stream = req.File.OpenReadStream();
|
||||||
var result = await mediator.Send(new UploadFileCommand(req.BucketName, req.ObjectKey,
|
var result = await mediator.Send(new UploadFileCommand(req.ObjectKey,
|
||||||
stream, req.File.ContentType, req.File.Length, req.ContentHash), ct);
|
stream, req.File.ContentType, req.File.Length, req.ContentHash), ct);
|
||||||
await Send.OkAsync(result, ct);
|
await Send.OkAsync(result, ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
namespace BuildFlow.Host.Endpoints.file.Files;
|
namespace BuildFlow.Host.Endpoints.file.Files;
|
||||||
|
|
||||||
/// <summary>上传文件请求。包含 bucket_name、object_key 与文件数据。</summary>
|
/// <summary>上传文件请求。包含 object_key 与文件数据。</summary>
|
||||||
public class UploadFileRequest
|
public class UploadFileRequest
|
||||||
{
|
{
|
||||||
public string BucketName { get; init; } = default!;
|
|
||||||
public string ObjectKey { get; init; } = default!;
|
public string ObjectKey { get; init; } = default!;
|
||||||
public IFormFile File { get; init; } = default!;
|
public IFormFile File { get; init; } = default!;
|
||||||
/// <summary>文件内容哈希(可选),秒传查重用。</summary>
|
/// <summary>文件内容哈希(可选),秒传查重用。</summary>
|
||||||
|
|||||||
@ -19,7 +19,7 @@ public class AbortMultipartEndpoint(IMediator mediator) : Endpoint<AbortMultipar
|
|||||||
|
|
||||||
public override async Task HandleAsync(AbortMultipartRequest req, CancellationToken ct)
|
public override async Task HandleAsync(AbortMultipartRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await mediator.Send(new AbortMultipartCommand(req.BucketName, req.ObjectKey, req.UploadId), ct);
|
await mediator.Send(new AbortMultipartCommand(req.ObjectKey, req.UploadId), ct);
|
||||||
await Send.OkAsync(new { message = "aborted" }, ct);
|
await Send.OkAsync(new { message = "aborted" }, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
namespace BuildFlow.Host.Endpoints.file.Multipart;
|
namespace BuildFlow.Host.Endpoints.file.Multipart;
|
||||||
|
|
||||||
/// <summary>中止分片上传请求(桶、对象 key、UploadId)。</summary>
|
/// <summary>中止分片上传请求(对象 key、UploadId)。</summary>
|
||||||
public class AbortMultipartRequest
|
public class AbortMultipartRequest
|
||||||
{
|
{
|
||||||
public string BucketName { get; init; } = default!;
|
|
||||||
public string ObjectKey { get; init; } = default!;
|
public string ObjectKey { get; init; } = default!;
|
||||||
public string UploadId { get; init; } = default!;
|
public string UploadId { get; init; } = default!;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,7 +21,7 @@ public class CompleteMultipartEndpoint(IMediator mediator) : Endpoint<CompleteMu
|
|||||||
public override async Task HandleAsync(CompleteMultipartRequest req, CancellationToken ct)
|
public override async Task HandleAsync(CompleteMultipartRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var parts = req.Parts.Select(p => new CompletedPartDto(p.PartNumber, p.ETag)).ToList();
|
var parts = req.Parts.Select(p => new CompletedPartDto(p.PartNumber, p.ETag)).ToList();
|
||||||
var result = await mediator.Send(new CompleteMultipartCommand(req.BucketName, req.ObjectKey, req.UploadId, parts, req.FileName, req.Size), ct);
|
var result = await mediator.Send(new CompleteMultipartCommand(req.ObjectKey, req.UploadId, parts, req.FileName, req.Size), ct);
|
||||||
await Send.OkAsync(result, ct);
|
await Send.OkAsync(result, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ namespace BuildFlow.Host.Endpoints.file.Multipart;
|
|||||||
/// <summary>完成分片上传请求(含各分片信息列表)。</summary>
|
/// <summary>完成分片上传请求(含各分片信息列表)。</summary>
|
||||||
public class CompleteMultipartRequest
|
public class CompleteMultipartRequest
|
||||||
{
|
{
|
||||||
public string BucketName { get; init; } = default!;
|
|
||||||
public string ObjectKey { get; init; } = default!;
|
public string ObjectKey { get; init; } = default!;
|
||||||
public string UploadId { get; init; } = default!;
|
public string UploadId { get; init; } = default!;
|
||||||
public List<PartRequest> Parts { get; init; } = new();
|
public List<PartRequest> Parts { get; init; } = new();
|
||||||
|
|||||||
@ -20,7 +20,7 @@ public class InitMultipartEndpoint(IMediator mediator) : Endpoint<InitMultipartR
|
|||||||
|
|
||||||
public override async Task HandleAsync(InitMultipartRequest req, CancellationToken ct)
|
public override async Task HandleAsync(InitMultipartRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new InitMultipartCommand(req.BucketName, req.ObjectKey, req.ContentHash, req.TotalParts, req.Size), ct);
|
var result = await mediator.Send(new InitMultipartCommand(req.ObjectKey, req.ContentHash, req.TotalParts, req.Size), ct);
|
||||||
await Send.OkAsync(result, ct);
|
await Send.OkAsync(result, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,6 @@ namespace BuildFlow.Host.Endpoints.file.Multipart;
|
|||||||
/// <summary>初始化分片上传请求。ContentHash/TotalParts 可选,断点续传时由前端附带。</summary>
|
/// <summary>初始化分片上传请求。ContentHash/TotalParts 可选,断点续传时由前端附带。</summary>
|
||||||
public class InitMultipartRequest
|
public class InitMultipartRequest
|
||||||
{
|
{
|
||||||
/// <summary>S3 存储桶名。</summary>
|
|
||||||
public string BucketName { get; init; } = default!;
|
|
||||||
/// <summary>S3 对象 key(断点续传建议用内容哈希派生稳定值)。</summary>
|
/// <summary>S3 对象 key(断点续传建议用内容哈希派生稳定值)。</summary>
|
||||||
public string ObjectKey { get; init; } = default!;
|
public string ObjectKey { get; init; } = default!;
|
||||||
/// <summary>文件内容哈希(可选),仅落库记录用。</summary>
|
/// <summary>文件内容哈希(可选),仅落库记录用。</summary>
|
||||||
|
|||||||
@ -22,7 +22,7 @@ public class UploadPartEndpoint(IMediator mediator) : Endpoint<UploadPartRequest
|
|||||||
public override async Task HandleAsync(UploadPartRequest req, CancellationToken ct)
|
public override async Task HandleAsync(UploadPartRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await using var stream = req.File.OpenReadStream();
|
await using var stream = req.File.OpenReadStream();
|
||||||
var result = await mediator.Send(new UploadPartCommand(req.BucketName, req.ObjectKey,
|
var result = await mediator.Send(new UploadPartCommand(req.ObjectKey,
|
||||||
req.UploadId, req.PartNumber, stream), ct);
|
req.UploadId, req.PartNumber, stream), ct);
|
||||||
await Send.OkAsync(result, ct);
|
await Send.OkAsync(result, ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ namespace BuildFlow.Host.Endpoints.file.Multipart;
|
|||||||
/// <summary>上传单个分片请求(含 multipart 文件流)。</summary>
|
/// <summary>上传单个分片请求(含 multipart 文件流)。</summary>
|
||||||
public class UploadPartRequest
|
public class UploadPartRequest
|
||||||
{
|
{
|
||||||
public string BucketName { get; init; } = default!;
|
|
||||||
public string ObjectKey { get; init; } = default!;
|
public string ObjectKey { get; init; } = default!;
|
||||||
public string UploadId { get; init; } = default!;
|
public string UploadId { get; init; } = default!;
|
||||||
public int PartNumber { get; init; }
|
public int PartNumber { get; init; }
|
||||||
|
|||||||
@ -0,0 +1,56 @@
|
|||||||
|
using IM.Domain.Common;
|
||||||
|
using MediatR;
|
||||||
|
using BuildFlow.Platform.Application.Notifications.Commands;
|
||||||
|
|
||||||
|
namespace BuildFlow.Host.Services.Integration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IM 站内信通知发布器。把 IM 离线消息作为站内信投递到平台通知中心。
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 跨模块胶水代码:IM 模块的 <see cref="SendMessageCommandHandler"/> 依赖
|
||||||
|
/// <see cref="IImNotificationPublisher"/>(定义在 IM.Domain),但 IM 模块无法直接访问
|
||||||
|
/// platform 模块的 Notification 聚合。本实现位于 Host 层,通过 MediatR 发送
|
||||||
|
/// <see cref="PublishNotificationCommand"/>,复用 platform 已有的 fanout 落库 + SignalR 推送链路,
|
||||||
|
/// 避免在 IM 侧重复实现通知持久化与分发。
|
||||||
|
/// 容错:通知发布失败不阻断 IM 主链路——<see cref="SendMessageCommandHandler"/> 的调用方
|
||||||
|
/// 已在注释中声明"通知失败不阻断主链路",但本实现仍捕获异常并吞掉,确保即使 platform
|
||||||
|
/// 通知链路异常也不会导致发消息接口报错。
|
||||||
|
/// </remarks>
|
||||||
|
public class ImNotificationPublisher(IMediator mediator) : IImNotificationPublisher
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 批量发布站内信通知。内部转发为 <see cref="PublishNotificationCommand"/>,
|
||||||
|
/// 由 platform 的 <c>PublishNotificationCommandHandler</c> 完成 fanout 落库与 SignalR 推送。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>实际发布的通知条数(platform 侧 fanout 落库数)。</returns>
|
||||||
|
public async Task<int> PublishAsync(
|
||||||
|
string type,
|
||||||
|
string title,
|
||||||
|
string content,
|
||||||
|
Guid conversationId,
|
||||||
|
IEnumerable<Guid> recipientUserIds,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var recipients = recipientUserIds as IReadOnlyCollection<Guid> ?? recipientUserIds.ToList();
|
||||||
|
if (recipients.Count == 0) return 0;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await mediator.Send(new PublishNotificationCommand(
|
||||||
|
Type: type,
|
||||||
|
Title: title,
|
||||||
|
Content: content,
|
||||||
|
Source: "im",
|
||||||
|
RelatedId: conversationId.ToString(),
|
||||||
|
RelatedType: "im-conversation",
|
||||||
|
RecipientUserIds: recipients,
|
||||||
|
RecipientRoles: null), ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 通知是旁路触达,失败不应影响 IM 发消息主链路,吞异常降级。
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -37,7 +37,7 @@ public class InProcessAvatarUrlResolver(IMediator mediator) : IAvatarUrlResolver
|
|||||||
// 其余视为 objectKey:通过 file 模块生成 24 小时有效的预签名 URL
|
// 其余视为 objectKey:通过 file 模块生成 24 小时有效的预签名 URL
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetFilePreviewQuery(AvatarBucket, avatarRef), ct);
|
var result = await mediator.Send(new GetFilePreviewQuery(avatarRef), ct);
|
||||||
return result.PresignedUrl;
|
return result.PresignedUrl;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@ -25,8 +25,9 @@ public class InProcessFileStorageClient(IMediator mediator) : IFileStorageClient
|
|||||||
string? authToken, // 进程内调用无需鉴权,token 忽略
|
string? authToken, // 进程内调用无需鉴权,token 忽略
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
// 通过 MediatR 命令调用 file 模块的 UploadFileCommandHandler(它内部走 IS3StorageService)
|
// 通过 MediatR 命令调用 file 模块的 UploadFileCommandHandler(它内部走 IS3StorageService)。
|
||||||
await mediator.Send(new UploadFileCommand(bucket, objectKey, content, contentType, content.Length), ct);
|
// bucket 由 handler 内的 IBucketResolver 统一解析,此处不再透传。
|
||||||
|
await mediator.Send(new UploadFileCommand(objectKey, content, contentType, content.Length), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Stream> DownloadAsync(
|
public async Task<Stream> DownloadAsync(
|
||||||
@ -36,8 +37,9 @@ public class InProcessFileStorageClient(IMediator mediator) : IFileStorageClient
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
// 通过 MediatR 查询调用 file 模块的 DownloadFileQueryHandler。
|
// 通过 MediatR 查询调用 file 模块的 DownloadFileQueryHandler。
|
||||||
|
// bucket 由 handler 内的 IBucketResolver 统一解析,此处不再透传。
|
||||||
// DownloadFileQuery 现在直接返回 S3 原始流(不再 buffer 成 byte[]),此处原样透传。
|
// DownloadFileQuery 现在直接返回 S3 原始流(不再 buffer 成 byte[]),此处原样透传。
|
||||||
var result = await mediator.Send(new DownloadFileQuery(bucket, objectKey), ct);
|
var result = await mediator.Send(new DownloadFileQuery(objectKey), ct);
|
||||||
return result.Stream;
|
return result.Stream;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,4 +4,4 @@ namespace File.Application.Features.Files.Commands;
|
|||||||
|
|
||||||
|
|
||||||
/// <summary>删除文件命令。删 S3 对象并发布 FileDeletedEvent。</summary>
|
/// <summary>删除文件命令。删 S3 对象并发布 FileDeletedEvent。</summary>
|
||||||
public record DeleteFileCommand(string BucketName, string ObjectKey) : IRequest<Unit>;
|
public record DeleteFileCommand(string ObjectKey) : IRequest<Unit>;
|
||||||
|
|||||||
@ -5,5 +5,5 @@ namespace File.Application.Features.Files.Commands;
|
|||||||
|
|
||||||
|
|
||||||
/// <summary>上传文件命令。直传 S3 对象并发布 FileUploadedEvent。ContentHash 可选,由前端预检后附带,用于秒传查重。</summary>
|
/// <summary>上传文件命令。直传 S3 对象并发布 FileUploadedEvent。ContentHash 可选,由前端预检后附带,用于秒传查重。</summary>
|
||||||
public record UploadFileCommand(string BucketName, string ObjectKey, Stream Data, string ContentType, long Size, string? ContentHash = null)
|
public record UploadFileCommand(string ObjectKey, Stream Data, string ContentType, long Size, string? ContentHash = null)
|
||||||
: IRequest<UploadFileResponse>;
|
: IRequest<UploadFileResponse>;
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
using File.Application.Common;
|
||||||
using File.Application.Events;
|
using File.Application.Events;
|
||||||
using File.Application.Features.Files.Commands;
|
using File.Application.Features.Files.Commands;
|
||||||
using File.Infrastructure.Persistence;
|
using File.Infrastructure.Persistence;
|
||||||
@ -11,17 +12,19 @@ namespace File.Application.Features.Files.Handlers;
|
|||||||
/// 两者必须同步删除:若只删 S3 不删库,文件夹视图(读 DB)仍会显示已失效的文件;
|
/// 两者必须同步删除:若只删 S3 不删库,文件夹视图(读 DB)仍会显示已失效的文件;
|
||||||
/// 若只删库不删 S3,对象存储会残留孤儿对象。S3 删除失败时阻断(避免库删了对象还在)。
|
/// 若只删库不删 S3,对象存储会残留孤儿对象。S3 删除失败时阻断(避免库删了对象还在)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class DeleteFileCommandHandler(IS3StorageService s3, FileDbContext db, IMediator mediator)
|
public class DeleteFileCommandHandler(IS3StorageService s3, FileDbContext db, IBucketResolver bucketResolver, IMediator mediator)
|
||||||
: IRequestHandler<DeleteFileCommand, Unit>
|
: IRequestHandler<DeleteFileCommand, Unit>
|
||||||
{
|
{
|
||||||
public async Task<Unit> Handle(DeleteFileCommand req, CancellationToken ct)
|
public async Task<Unit> Handle(DeleteFileCommand req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var bucket = await bucketResolver.GetDefaultBucketAsync(ct);
|
||||||
|
|
||||||
// 1. 删 S3 对象(先删存储,成功后再清库记录,保证不会出现"库没了但对象还在"的孤儿)
|
// 1. 删 S3 对象(先删存储,成功后再清库记录,保证不会出现"库没了但对象还在"的孤儿)
|
||||||
await s3.DeleteFileAsync(req.BucketName, req.ObjectKey, ct);
|
await s3.DeleteFileAsync(bucket, req.ObjectKey, ct);
|
||||||
|
|
||||||
// 2. 删 FileMeta 记录(按 bucket + key 精确匹配,可能有 0~N 条)
|
// 2. 删 FileMeta 记录(按 bucket + key 精确匹配,可能有 0~N 条)
|
||||||
var metas = await db.Files
|
var metas = await db.Files
|
||||||
.Where(f => f.S3Bucket == req.BucketName && f.S3Key == req.ObjectKey)
|
.Where(f => f.S3Bucket == bucket && f.S3Key == req.ObjectKey)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
if (metas.Count > 0)
|
if (metas.Count > 0)
|
||||||
{
|
{
|
||||||
@ -29,7 +32,7 @@ public class DeleteFileCommandHandler(IS3StorageService s3, FileDbContext db, IM
|
|||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
await mediator.Publish(new FileDeletedEvent(req.BucketName, req.ObjectKey), ct);
|
await mediator.Publish(new FileDeletedEvent(bucket, req.ObjectKey), ct);
|
||||||
return Unit.Value;
|
return Unit.Value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
using File.Application.Common;
|
||||||
using File.Application.Features.Files.DTOs;
|
using File.Application.Features.Files.DTOs;
|
||||||
using File.Application.Features.Files.Queries;
|
using File.Application.Features.Files.Queries;
|
||||||
using File.Infrastructure.Storage;
|
using File.Infrastructure.Storage;
|
||||||
@ -9,11 +10,12 @@ namespace File.Application.Features.Files.Handlers;
|
|||||||
/// 下载文件查询处理器。从 S3 拉取响应流,原样透传给 Endpoint 写入 HTTP 响应体,
|
/// 下载文件查询处理器。从 S3 拉取响应流,原样透传给 Endpoint 写入 HTTP 响应体,
|
||||||
/// 不再拷贝到内存,大文件零内存压力。真实 ContentType 由 S3 HeadObject/GetObject 返回。
|
/// 不再拷贝到内存,大文件零内存压力。真实 ContentType 由 S3 HeadObject/GetObject 返回。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class DownloadFileQueryHandler(IS3StorageService s3) : IRequestHandler<DownloadFileQuery, DownloadFileResult>
|
public class DownloadFileQueryHandler(IS3StorageService s3, IBucketResolver bucketResolver) : IRequestHandler<DownloadFileQuery, DownloadFileResult>
|
||||||
{
|
{
|
||||||
public async Task<DownloadFileResult> Handle(DownloadFileQuery req, CancellationToken ct)
|
public async Task<DownloadFileResult> Handle(DownloadFileQuery req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var (stream, contentType) = await s3.DownloadFileAsync(req.BucketName, req.ObjectKey, ct);
|
var bucket = await bucketResolver.GetDefaultBucketAsync(ct);
|
||||||
|
var (stream, contentType) = await s3.DownloadFileAsync(bucket, req.ObjectKey, ct);
|
||||||
// 文件名取 ObjectKey 最后一段(S3 key 可能含目录前缀)
|
// 文件名取 ObjectKey 最后一段(S3 key 可能含目录前缀)
|
||||||
var fileName = req.ObjectKey.Contains('/')
|
var fileName = req.ObjectKey.Contains('/')
|
||||||
? req.ObjectKey[(req.ObjectKey.LastIndexOf('/') + 1)..]
|
? req.ObjectKey[(req.ObjectKey.LastIndexOf('/') + 1)..]
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
using File.Application.Common;
|
||||||
using File.Application.Features.Files.DTOs;
|
using File.Application.Features.Files.DTOs;
|
||||||
using File.Application.Features.Files.Queries;
|
using File.Application.Features.Files.Queries;
|
||||||
using File.Infrastructure.Storage;
|
using File.Infrastructure.Storage;
|
||||||
@ -5,14 +6,16 @@ using MediatR;
|
|||||||
|
|
||||||
namespace File.Application.Features.Files.Handlers;
|
namespace File.Application.Features.Files.Handlers;
|
||||||
|
|
||||||
/// <summary>获取文件内容处理器。限定 10MB 上限,超限由 S3 侧截断。</summary>
|
/// <summary>获取文件内容处理器。限定 10MB 上限,超限由 S3 侧截断。bucket 从 IBucketResolver 统一获取。</summary>
|
||||||
public class GetFileContentQueryHandler(IS3StorageService s3) : IRequestHandler<GetFileContentQuery, FileContentResult>
|
public class GetFileContentQueryHandler(IS3StorageService s3, IBucketResolver bucketResolver)
|
||||||
|
: IRequestHandler<GetFileContentQuery, FileContentResult>
|
||||||
{
|
{
|
||||||
private const long MaxContentBytes = 10 * 1024 * 1024; // 10 MB
|
private const long MaxContentBytes = 10 * 1024 * 1024; // 10 MB
|
||||||
|
|
||||||
public async Task<FileContentResult> Handle(GetFileContentQuery req, CancellationToken ct)
|
public async Task<FileContentResult> Handle(GetFileContentQuery req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var content = await s3.GetFileContentAsync(req.BucketName, req.ObjectKey, MaxContentBytes, ct);
|
var bucket = await bucketResolver.GetDefaultBucketAsync(ct);
|
||||||
|
var content = await s3.GetFileContentAsync(bucket, req.ObjectKey, MaxContentBytes, ct);
|
||||||
return new FileContentResult(content);
|
return new FileContentResult(content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
using File.Application.Common;
|
||||||
using File.Application.Features.Files.DTOs;
|
using File.Application.Features.Files.DTOs;
|
||||||
using File.Application.Features.Files.Queries;
|
using File.Application.Features.Files.Queries;
|
||||||
using File.Infrastructure.Storage;
|
using File.Infrastructure.Storage;
|
||||||
@ -6,13 +7,14 @@ using MediatR;
|
|||||||
namespace File.Application.Features.Files.Handlers;
|
namespace File.Application.Features.Files.Handlers;
|
||||||
|
|
||||||
/// <summary>文件预览查询处理器。生成 24 小时有效的预签名 URL。</summary>
|
/// <summary>文件预览查询处理器。生成 24 小时有效的预签名 URL。</summary>
|
||||||
public class GetFilePreviewQueryHandler(IS3StorageService s3) : IRequestHandler<GetFilePreviewQuery, FilePreviewResult>
|
public class GetFilePreviewQueryHandler(IS3StorageService s3, IBucketResolver bucketResolver) : IRequestHandler<GetFilePreviewQuery, FilePreviewResult>
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan PreviewExpiry = TimeSpan.FromHours(24);
|
private static readonly TimeSpan PreviewExpiry = TimeSpan.FromHours(24);
|
||||||
|
|
||||||
public async Task<FilePreviewResult> Handle(GetFilePreviewQuery req, CancellationToken ct)
|
public async Task<FilePreviewResult> Handle(GetFilePreviewQuery req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var url = await s3.GeneratePresignedUrlAsync(req.BucketName, req.ObjectKey, PreviewExpiry, ct);
|
var bucket = await bucketResolver.GetDefaultBucketAsync(ct);
|
||||||
|
var url = await s3.GeneratePresignedUrlAsync(bucket, req.ObjectKey, PreviewExpiry, ct);
|
||||||
return new FilePreviewResult(url);
|
return new FilePreviewResult(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
using File.Application.Common;
|
||||||
using File.Application.Features.Files.DTOs;
|
using File.Application.Features.Files.DTOs;
|
||||||
using File.Application.Features.Files.Queries;
|
using File.Application.Features.Files.Queries;
|
||||||
using File.Infrastructure.Storage;
|
using File.Infrastructure.Storage;
|
||||||
@ -10,7 +11,7 @@ namespace File.Application.Features.Files.Handlers;
|
|||||||
/// Office/PDF 文档预览查询处理器。生成 S3 预签名 URL(1 小时有效),
|
/// Office/PDF 文档预览查询处理器。生成 S3 预签名 URL(1 小时有效),
|
||||||
/// 按 KKFileView 约定 Base64 编码后拼成 onlinePreview 地址返回。
|
/// 按 KKFileView 约定 Base64 编码后拼成 onlinePreview 地址返回。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class GetOfficePreviewQueryHandler(IS3StorageService s3, IOptions<KkFileViewOptions> kkOptions)
|
public class GetOfficePreviewQueryHandler(IS3StorageService s3, IBucketResolver bucketResolver, IOptions<KkFileViewOptions> kkOptions)
|
||||||
: IRequestHandler<GetOfficePreviewQuery, OfficePreviewResult>
|
: IRequestHandler<GetOfficePreviewQuery, OfficePreviewResult>
|
||||||
{
|
{
|
||||||
/// <summary>Office 文档预签名有效期较短(1 小时),降低链接泄露风险。</summary>
|
/// <summary>Office 文档预签名有效期较短(1 小时),降低链接泄露风险。</summary>
|
||||||
@ -18,7 +19,8 @@ public class GetOfficePreviewQueryHandler(IS3StorageService s3, IOptions<KkFileV
|
|||||||
|
|
||||||
public async Task<OfficePreviewResult> Handle(GetOfficePreviewQuery req, CancellationToken ct)
|
public async Task<OfficePreviewResult> Handle(GetOfficePreviewQuery req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var presignedUrl = await s3.GeneratePresignedUrlAsync(req.BucketName, req.ObjectKey, PreviewExpiry, ct);
|
var bucket = await bucketResolver.GetDefaultBucketAsync(ct);
|
||||||
|
var presignedUrl = await s3.GeneratePresignedUrlAsync(bucket, req.ObjectKey, PreviewExpiry, ct);
|
||||||
|
|
||||||
// KKFileView onlinePreview 约定:url 参数为 Base64 编码的文件直链
|
// KKFileView onlinePreview 约定:url 参数为 Base64 编码的文件直链
|
||||||
var encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(presignedUrl));
|
var encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(presignedUrl));
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
using File.Application.Common;
|
||||||
using File.Application.Features.Files.DTOs;
|
using File.Application.Features.Files.DTOs;
|
||||||
using File.Application.Features.Files.Queries;
|
using File.Application.Features.Files.Queries;
|
||||||
using File.Infrastructure.Storage;
|
using File.Infrastructure.Storage;
|
||||||
@ -6,7 +7,7 @@ using MediatR;
|
|||||||
namespace File.Application.Features.Files.Handlers;
|
namespace File.Application.Features.Files.Handlers;
|
||||||
|
|
||||||
/// <summary>列文件查询处理器。默认 100 条,上限 1000 条分页代理 S3。</summary>
|
/// <summary>列文件查询处理器。默认 100 条,上限 1000 条分页代理 S3。</summary>
|
||||||
public class ListFilesQueryHandler(IS3StorageService s3) : IRequestHandler<ListFilesQuery, ListFilesResponse>
|
public class ListFilesQueryHandler(IS3StorageService s3, IBucketResolver bucketResolver) : IRequestHandler<ListFilesQuery, ListFilesResponse>
|
||||||
{
|
{
|
||||||
private const int DefaultMaxKeys = 100;
|
private const int DefaultMaxKeys = 100;
|
||||||
private const int MaxListKeys = 1000;
|
private const int MaxListKeys = 1000;
|
||||||
@ -14,7 +15,8 @@ public class ListFilesQueryHandler(IS3StorageService s3) : IRequestHandler<ListF
|
|||||||
public async Task<ListFilesResponse> Handle(ListFilesQuery req, CancellationToken ct)
|
public async Task<ListFilesResponse> Handle(ListFilesQuery req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var maxKeys = (req.MaxKeys is null or <= 0) ? DefaultMaxKeys : Math.Min(req.MaxKeys.Value, MaxListKeys);
|
var maxKeys = (req.MaxKeys is null or <= 0) ? DefaultMaxKeys : Math.Min(req.MaxKeys.Value, MaxListKeys);
|
||||||
var s3Result = await s3.ListObjectsAsync(req.BucketName, req.Prefix, maxKeys, req.ContinuationToken, ct);
|
var bucket = await bucketResolver.GetDefaultBucketAsync(ct);
|
||||||
|
var s3Result = await s3.ListObjectsAsync(bucket, req.Prefix, maxKeys, req.ContinuationToken, ct);
|
||||||
var files = s3Result.Files
|
var files = s3Result.Files
|
||||||
.Select(f => new FileListItemDto(f.Key, f.Size, f.LastModified.ToString("O"), f.ETag))
|
.Select(f => new FileListItemDto(f.Key, f.Size, f.LastModified.ToString("O"), f.ETag))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using BuildFlow.Shared.Domain.Common;
|
using BuildFlow.Shared.Domain.Common;
|
||||||
|
using File.Application.Common;
|
||||||
using File.Application.Events;
|
using File.Application.Events;
|
||||||
using File.Application.Features.Files.Commands;
|
using File.Application.Features.Files.Commands;
|
||||||
using File.Application.Features.Files.DTOs;
|
using File.Application.Features.Files.DTOs;
|
||||||
@ -19,6 +20,7 @@ public class UploadFileCommandHandler(
|
|||||||
IS3StorageService s3,
|
IS3StorageService s3,
|
||||||
FileDbContext db,
|
FileDbContext db,
|
||||||
ICurrentUserContext currentUser,
|
ICurrentUserContext currentUser,
|
||||||
|
IBucketResolver bucketResolver,
|
||||||
IMediator mediator) : IRequestHandler<UploadFileCommand, UploadFileResponse>
|
IMediator mediator) : IRequestHandler<UploadFileCommand, UploadFileResponse>
|
||||||
{
|
{
|
||||||
private const long MaxFileSize = 500 * 1024 * 1024; // 500 MiB
|
private const long MaxFileSize = 500 * 1024 * 1024; // 500 MiB
|
||||||
@ -32,10 +34,12 @@ public class UploadFileCommandHandler(
|
|||||||
throw new TooLargeException("文件大小不能超过 500MB");
|
throw new TooLargeException("文件大小不能超过 500MB");
|
||||||
}
|
}
|
||||||
|
|
||||||
await s3.UploadFileAsync(req.BucketName, req.ObjectKey, req.Data, req.ContentType, ct);
|
var bucket = await bucketResolver.GetDefaultBucketAsync(ct);
|
||||||
|
|
||||||
|
await s3.UploadFileAsync(bucket, req.ObjectKey, req.Data, req.ContentType, ct);
|
||||||
|
|
||||||
// 落 FileMeta:直传为根目录散落文件(FolderId=null)。唯一约束 (OwnerId,ContentHash) 冲突时复用。
|
// 落 FileMeta:直传为根目录散落文件(FolderId=null)。唯一约束 (OwnerId,ContentHash) 冲突时复用。
|
||||||
if (!await db.Files.AnyAsync(f => f.S3Bucket == req.BucketName && f.S3Key == req.ObjectKey, ct))
|
if (!await db.Files.AnyAsync(f => f.S3Bucket == bucket && f.S3Key == req.ObjectKey, ct))
|
||||||
{
|
{
|
||||||
db.Files.Add(new FileMeta
|
db.Files.Add(new FileMeta
|
||||||
{
|
{
|
||||||
@ -43,7 +47,7 @@ public class UploadFileCommandHandler(
|
|||||||
FolderId = null,
|
FolderId = null,
|
||||||
Name = req.ObjectKey,
|
Name = req.ObjectKey,
|
||||||
S3Key = req.ObjectKey,
|
S3Key = req.ObjectKey,
|
||||||
S3Bucket = req.BucketName,
|
S3Bucket = bucket,
|
||||||
Size = req.Size,
|
Size = req.Size,
|
||||||
ContentType = string.IsNullOrEmpty(req.ContentType) ? "application/octet-stream" : req.ContentType,
|
ContentType = string.IsNullOrEmpty(req.ContentType) ? "application/octet-stream" : req.ContentType,
|
||||||
ContentHash = req.ContentHash,
|
ContentHash = req.ContentHash,
|
||||||
@ -62,7 +66,7 @@ public class UploadFileCommandHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await mediator.Publish(new FileUploadedEvent(req.BucketName, req.ObjectKey), ct);
|
await mediator.Publish(new FileUploadedEvent(bucket, req.ObjectKey), ct);
|
||||||
|
|
||||||
return new UploadFileResponse("uploaded", req.ObjectKey);
|
return new UploadFileResponse("uploaded", req.ObjectKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,4 +5,4 @@ namespace File.Application.Features.Files.Queries;
|
|||||||
|
|
||||||
|
|
||||||
/// <summary>下载文件查询。返回字节流。</summary>
|
/// <summary>下载文件查询。返回字节流。</summary>
|
||||||
public record DownloadFileQuery(string BucketName, string ObjectKey) : IRequest<DownloadFileResult>;
|
public record DownloadFileQuery(string ObjectKey) : IRequest<DownloadFileResult>;
|
||||||
|
|||||||
@ -4,5 +4,5 @@ using MediatR;
|
|||||||
namespace File.Application.Features.Files.Queries;
|
namespace File.Application.Features.Files.Queries;
|
||||||
|
|
||||||
|
|
||||||
/// <summary>获取文件文本内容查询。</summary>
|
/// <summary>获取文件文本内容查询。bucket 由 IBucketResolver 统一解析。</summary>
|
||||||
public record GetFileContentQuery(string BucketName, string ObjectKey) : IRequest<FileContentResult>;
|
public record GetFileContentQuery(string ObjectKey) : IRequest<FileContentResult>;
|
||||||
|
|||||||
@ -5,4 +5,4 @@ namespace File.Application.Features.Files.Queries;
|
|||||||
|
|
||||||
|
|
||||||
/// <summary>获取文件预签名预览 URL 查询。</summary>
|
/// <summary>获取文件预签名预览 URL 查询。</summary>
|
||||||
public record GetFilePreviewQuery(string BucketName, string ObjectKey) : IRequest<FilePreviewResult>;
|
public record GetFilePreviewQuery(string ObjectKey) : IRequest<FilePreviewResult>;
|
||||||
|
|||||||
@ -7,4 +7,4 @@ namespace File.Application.Features.Files.Queries;
|
|||||||
/// 获取 Office/PDF 文档在线预览地址查询。
|
/// 获取 Office/PDF 文档在线预览地址查询。
|
||||||
/// 生成 S3 预签名 URL 后拼装为 KKFileView 预览链接,供前端 window.open 打开。
|
/// 生成 S3 预签名 URL 后拼装为 KKFileView 预览链接,供前端 window.open 打开。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record GetOfficePreviewQuery(string BucketName, string ObjectKey) : IRequest<OfficePreviewResult>;
|
public record GetOfficePreviewQuery(string ObjectKey) : IRequest<OfficePreviewResult>;
|
||||||
|
|||||||
@ -5,5 +5,5 @@ namespace File.Application.Features.Files.Queries;
|
|||||||
|
|
||||||
|
|
||||||
/// <summary>列文件查询。支持前缀、分页大小、续传 token。</summary>
|
/// <summary>列文件查询。支持前缀、分页大小、续传 token。</summary>
|
||||||
public record ListFilesQuery(string BucketName, string? Prefix, int? MaxKeys, string? ContinuationToken)
|
public record ListFilesQuery(string? Prefix, int? MaxKeys, string? ContinuationToken)
|
||||||
: IRequest<ListFilesResponse>;
|
: IRequest<ListFilesResponse>;
|
||||||
|
|||||||
@ -9,7 +9,6 @@ public class DeleteFileCommandValidator : AbstractValidator<DeleteFileCommand>
|
|||||||
{
|
{
|
||||||
public DeleteFileCommandValidator()
|
public DeleteFileCommandValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称");
|
|
||||||
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,6 @@ public class DownloadFileQueryValidator : AbstractValidator<DownloadFileQuery>
|
|||||||
{
|
{
|
||||||
public DownloadFileQueryValidator()
|
public DownloadFileQueryValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称");
|
|
||||||
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,6 @@ public class GetFileContentQueryValidator : AbstractValidator<GetFileContentQuer
|
|||||||
{
|
{
|
||||||
public GetFileContentQueryValidator()
|
public GetFileContentQueryValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称");
|
|
||||||
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,6 @@ public class GetFilePreviewQueryValidator : AbstractValidator<GetFilePreviewQuer
|
|||||||
{
|
{
|
||||||
public GetFilePreviewQueryValidator()
|
public GetFilePreviewQueryValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称");
|
|
||||||
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,5 @@ public class ListFilesQueryValidator : AbstractValidator<ListFilesQuery>
|
|||||||
{
|
{
|
||||||
public ListFilesQueryValidator()
|
public ListFilesQueryValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,6 @@ public class UploadFileCommandValidator : AbstractValidator<UploadFileCommand>
|
|||||||
{
|
{
|
||||||
public UploadFileCommandValidator()
|
public UploadFileCommandValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称");
|
|
||||||
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,19 +7,18 @@ using File.Domain.Exceptions;
|
|||||||
using File.Infrastructure.Persistence;
|
using File.Infrastructure.Persistence;
|
||||||
using File.Infrastructure.Storage;
|
using File.Infrastructure.Storage;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
|
|
||||||
namespace File.Application.Features.Folders.Handlers;
|
namespace File.Application.Features.Folders.Handlers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 上传文件到文件夹处理器。校验 500MiB 上限,UUID 作 S3 key,落库失败补偿删 S3。
|
/// 上传文件到文件夹处理器。校验 500MiB 上限,UUID 作 S3 key,落库失败补偿删 S3。
|
||||||
/// bucket 从配置 FileService:Bucket 读取(与直传/预览/下载/删除统一,不再硬编码 "files")。
|
/// bucket 统一从 IBucketResolver 获取(与直传/预览/下载/删除一致,不再从配置或前端参数读取)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class UploadToFolderCommandHandler(
|
public class UploadToFolderCommandHandler(
|
||||||
FileDbContext db,
|
FileDbContext db,
|
||||||
IS3StorageService s3,
|
IS3StorageService s3,
|
||||||
ICurrentUserContext currentUser,
|
ICurrentUserContext currentUser,
|
||||||
IConfiguration config) : IRequestHandler<UploadToFolderCommand, FileMetaDto>
|
IBucketResolver bucketResolver) : IRequestHandler<UploadToFolderCommand, FileMetaDto>
|
||||||
{
|
{
|
||||||
private const long MaxUploadBytes = 500 * 1024 * 1024; // 500 MiB
|
private const long MaxUploadBytes = 500 * 1024 * 1024; // 500 MiB
|
||||||
|
|
||||||
@ -29,8 +28,8 @@ public class UploadToFolderCommandHandler(
|
|||||||
if (req.Size > MaxUploadBytes)
|
if (req.Size > MaxUploadBytes)
|
||||||
throw new TooLargeException("文件大小超过上限 500MB");
|
throw new TooLargeException("文件大小超过上限 500MB");
|
||||||
|
|
||||||
// bucket 与直传/预览/下载统一(前端默认 build-flow),不再硬编码 "files"
|
// bucket 统一从 IBucketResolver 获取(默认 build-flow),与直传/预览/下载/删除一致
|
||||||
var bucket = config["FileService:Bucket"] ?? "build-flow";
|
var bucket = await bucketResolver.GetDefaultBucketAsync(ct);
|
||||||
|
|
||||||
var safeName = SanitizeRules.SanitizeFilename(req.FileName.Trim());
|
var safeName = SanitizeRules.SanitizeFilename(req.FileName.Trim());
|
||||||
var s3Key = Guid.NewGuid().ToString(); // 永远用 UUID,不用原文件名
|
var s3Key = Guid.NewGuid().ToString(); // 永远用 UUID,不用原文件名
|
||||||
|
|||||||
@ -4,4 +4,4 @@ namespace File.Application.Features.Multipart.Commands;
|
|||||||
|
|
||||||
|
|
||||||
/// <summary>中止分片上传命令。清理已传分片。</summary>
|
/// <summary>中止分片上传命令。清理已传分片。</summary>
|
||||||
public record AbortMultipartCommand(string BucketName, string ObjectKey, string UploadId) : IRequest<Unit>;
|
public record AbortMultipartCommand(string ObjectKey, string UploadId) : IRequest<Unit>;
|
||||||
|
|||||||
@ -8,7 +8,6 @@ namespace File.Application.Features.Multipart.Commands;
|
|||||||
/// FileName/Size 可选:完成后用于创建 FileMeta(分片秒传查重记录),不带则不落 FileMeta(老客户端兼容)。
|
/// FileName/Size 可选:完成后用于创建 FileMeta(分片秒传查重记录),不带则不落 FileMeta(老客户端兼容)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record CompleteMultipartCommand(
|
public record CompleteMultipartCommand(
|
||||||
string BucketName,
|
|
||||||
string ObjectKey,
|
string ObjectKey,
|
||||||
string UploadId,
|
string UploadId,
|
||||||
List<CompletedPartDto> Parts,
|
List<CompletedPartDto> Parts,
|
||||||
|
|||||||
@ -9,7 +9,6 @@ namespace File.Application.Features.Multipart.Commands;
|
|||||||
/// 秒传判定用 (OwnerId, ContentHash, Size) 三元组(与 CheckFileQuery 对齐)。
|
/// 秒传判定用 (OwnerId, ContentHash, Size) 三元组(与 CheckFileQuery 对齐)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record InitMultipartCommand(
|
public record InitMultipartCommand(
|
||||||
string BucketName,
|
|
||||||
string ObjectKey,
|
string ObjectKey,
|
||||||
string? ContentHash = null,
|
string? ContentHash = null,
|
||||||
int? TotalParts = null,
|
int? TotalParts = null,
|
||||||
|
|||||||
@ -5,5 +5,5 @@ namespace File.Application.Features.Multipart.Commands;
|
|||||||
|
|
||||||
|
|
||||||
/// <summary>上传单分片命令。携带 uploadId 与 partNumber 续传。</summary>
|
/// <summary>上传单分片命令。携带 uploadId 与 partNumber 续传。</summary>
|
||||||
public record UploadPartCommand(string BucketName, string ObjectKey, string UploadId, int PartNumber, Stream Data)
|
public record UploadPartCommand(string ObjectKey, string UploadId, int PartNumber, Stream Data)
|
||||||
: IRequest<UploadPartResult>;
|
: IRequest<UploadPartResult>;
|
||||||
|
|||||||
@ -31,7 +31,7 @@ public class AbortMultipartCommandHandler(
|
|||||||
throw new ConflictException("上传会话已结束,不可重复中止");
|
throw new ConflictException("上传会话已结束,不可重复中止");
|
||||||
}
|
}
|
||||||
|
|
||||||
await s3.AbortMultipartUploadAsync(req.BucketName, req.ObjectKey, req.UploadId, ct);
|
await s3.AbortMultipartUploadAsync(session.BucketName, req.ObjectKey, req.UploadId, ct);
|
||||||
|
|
||||||
session.Status = UploadSessionStatus.Aborted;
|
session.Status = UploadSessionStatus.Aborted;
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using BuildFlow.Shared.Domain.Common;
|
using BuildFlow.Shared.Domain.Common;
|
||||||
|
using File.Application.Common;
|
||||||
using File.Application.Features.Multipart.Commands;
|
using File.Application.Features.Multipart.Commands;
|
||||||
using File.Application.Features.Multipart.DTOs;
|
using File.Application.Features.Multipart.DTOs;
|
||||||
using File.Domain.Entities;
|
using File.Domain.Entities;
|
||||||
@ -19,11 +20,13 @@ namespace File.Application.Features.Multipart.Handlers;
|
|||||||
public class CompleteMultipartCommandHandler(
|
public class CompleteMultipartCommandHandler(
|
||||||
IS3StorageService s3,
|
IS3StorageService s3,
|
||||||
FileDbContext db,
|
FileDbContext db,
|
||||||
ICurrentUserContext currentUser) : IRequestHandler<CompleteMultipartCommand, CompleteMultipartResult>
|
ICurrentUserContext currentUser,
|
||||||
|
IBucketResolver bucketResolver) : IRequestHandler<CompleteMultipartCommand, CompleteMultipartResult>
|
||||||
{
|
{
|
||||||
public async Task<CompleteMultipartResult> Handle(CompleteMultipartCommand req, CancellationToken ct)
|
public async Task<CompleteMultipartResult> Handle(CompleteMultipartCommand req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var owner = currentUser.GetRequiredUserId();
|
var owner = currentUser.GetRequiredUserId();
|
||||||
|
var bucket = await bucketResolver.GetDefaultBucketAsync(ct);
|
||||||
|
|
||||||
var session = await db.UploadSessions.FirstOrDefaultAsync(s => s.UploadId == req.UploadId, ct);
|
var session = await db.UploadSessions.FirstOrDefaultAsync(s => s.UploadId == req.UploadId, ct);
|
||||||
if (session is null || session.OwnerId != owner)
|
if (session is null || session.OwnerId != owner)
|
||||||
@ -36,7 +39,7 @@ public class CompleteMultipartCommandHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var parts = req.Parts.Select(p => new CompletedPart(p.PartNumber, p.ETag)).ToList();
|
var parts = req.Parts.Select(p => new CompletedPart(p.PartNumber, p.ETag)).ToList();
|
||||||
await s3.CompleteMultipartUploadAsync(req.BucketName, req.ObjectKey, req.UploadId, parts, ct);
|
await s3.CompleteMultipartUploadAsync(bucket, req.ObjectKey, req.UploadId, parts, ct);
|
||||||
|
|
||||||
// S3 成功后落库:session 终态 + FileMeta(供秒传查重),单次 SaveChanges 保证原子
|
// S3 成功后落库:session 终态 + FileMeta(供秒传查重),单次 SaveChanges 保证原子
|
||||||
session.Status = UploadSessionStatus.Completed;
|
session.Status = UploadSessionStatus.Completed;
|
||||||
@ -71,6 +74,6 @@ public class CompleteMultipartCommandHandler(
|
|||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new CompleteMultipartResult($"{req.BucketName}/{req.ObjectKey}");
|
return new CompleteMultipartResult($"{bucket}/{req.ObjectKey}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using BuildFlow.Shared.Domain.Common;
|
using BuildFlow.Shared.Domain.Common;
|
||||||
|
using File.Application.Common;
|
||||||
using File.Application.Features.Multipart.Commands;
|
using File.Application.Features.Multipart.Commands;
|
||||||
using File.Application.Features.Multipart.DTOs;
|
using File.Application.Features.Multipart.DTOs;
|
||||||
using File.Domain.Entities;
|
using File.Domain.Entities;
|
||||||
@ -18,11 +19,13 @@ namespace File.Application.Features.Multipart.Handlers;
|
|||||||
public class InitMultipartCommandHandler(
|
public class InitMultipartCommandHandler(
|
||||||
IS3StorageService s3,
|
IS3StorageService s3,
|
||||||
FileDbContext db,
|
FileDbContext db,
|
||||||
ICurrentUserContext currentUser) : IRequestHandler<InitMultipartCommand, InitMultipartResult>
|
ICurrentUserContext currentUser,
|
||||||
|
IBucketResolver bucketResolver) : IRequestHandler<InitMultipartCommand, InitMultipartResult>
|
||||||
{
|
{
|
||||||
public async Task<InitMultipartResult> Handle(InitMultipartCommand req, CancellationToken ct)
|
public async Task<InitMultipartResult> Handle(InitMultipartCommand req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var owner = currentUser.GetRequiredUserId();
|
var owner = currentUser.GetRequiredUserId();
|
||||||
|
var bucket = await bucketResolver.GetDefaultBucketAsync(ct);
|
||||||
|
|
||||||
// 1. 秒传命中:同用户已有相同内容哈希文件,零字节复用。
|
// 1. 秒传命中:同用户已有相同内容哈希文件,零字节复用。
|
||||||
// 判定三元组 (OwnerId, ContentHash, Size) 与 CheckFileQuery 对齐——Size 防哈希碰撞误判。
|
// 判定三元组 (OwnerId, ContentHash, Size) 与 CheckFileQuery 对齐——Size 防哈希碰撞误判。
|
||||||
@ -40,7 +43,7 @@ public class InitMultipartCommandHandler(
|
|||||||
// 2. 幂等复用:同一用户同一对象若已有 Active 会话,直接返回其 uploadId
|
// 2. 幂等复用:同一用户同一对象若已有 Active 会话,直接返回其 uploadId
|
||||||
var existing = await db.UploadSessions
|
var existing = await db.UploadSessions
|
||||||
.FirstOrDefaultAsync(s => s.OwnerId == owner
|
.FirstOrDefaultAsync(s => s.OwnerId == owner
|
||||||
&& s.BucketName == req.BucketName
|
&& s.BucketName == bucket
|
||||||
&& s.ObjectKey == req.ObjectKey
|
&& s.ObjectKey == req.ObjectKey
|
||||||
&& s.Status == UploadSessionStatus.Active, ct);
|
&& s.Status == UploadSessionStatus.Active, ct);
|
||||||
if (existing is not null)
|
if (existing is not null)
|
||||||
@ -49,11 +52,11 @@ public class InitMultipartCommandHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 真正发起 S3 multipart upload
|
// 3. 真正发起 S3 multipart upload
|
||||||
var uploadId = await s3.CreateMultipartUploadAsync(req.BucketName, req.ObjectKey, ct);
|
var uploadId = await s3.CreateMultipartUploadAsync(bucket, req.ObjectKey, ct);
|
||||||
|
|
||||||
var session = new MultipartUploadSession
|
var session = new MultipartUploadSession
|
||||||
{
|
{
|
||||||
BucketName = req.BucketName,
|
BucketName = bucket,
|
||||||
ObjectKey = req.ObjectKey,
|
ObjectKey = req.ObjectKey,
|
||||||
UploadId = uploadId,
|
UploadId = uploadId,
|
||||||
ContentHash = req.ContentHash,
|
ContentHash = req.ContentHash,
|
||||||
|
|||||||
@ -38,7 +38,7 @@ public class UploadPartCommandHandler(
|
|||||||
throw new ConflictException("上传会话已结束,不可继续上传");
|
throw new ConflictException("上传会话已结束,不可继续上传");
|
||||||
}
|
}
|
||||||
|
|
||||||
var etag = await s3.UploadPartAsync(req.BucketName, req.ObjectKey, req.UploadId, req.PartNumber, req.Data, ct);
|
var etag = await s3.UploadPartAsync(session.BucketName, req.ObjectKey, req.UploadId, req.PartNumber, req.Data, ct);
|
||||||
return new UploadPartResult(etag);
|
return new UploadPartResult(etag);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,6 @@ public class AbortMultipartCommandValidator : AbstractValidator<AbortMultipartCo
|
|||||||
{
|
{
|
||||||
public AbortMultipartCommandValidator()
|
public AbortMultipartCommandValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称");
|
|
||||||
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
||||||
RuleFor(x => x.UploadId).NotEmpty().WithMessage("upload_id 不能为空");
|
RuleFor(x => x.UploadId).NotEmpty().WithMessage("upload_id 不能为空");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,6 @@ public class CompleteMultipartCommandValidator : AbstractValidator<CompleteMulti
|
|||||||
{
|
{
|
||||||
public CompleteMultipartCommandValidator()
|
public CompleteMultipartCommandValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称");
|
|
||||||
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
||||||
RuleFor(x => x.UploadId).NotEmpty().WithMessage("upload_id 不能为空");
|
RuleFor(x => x.UploadId).NotEmpty().WithMessage("upload_id 不能为空");
|
||||||
RuleFor(x => x.Parts).NotEmpty().WithMessage("parts 不能为空");
|
RuleFor(x => x.Parts).NotEmpty().WithMessage("parts 不能为空");
|
||||||
|
|||||||
@ -9,7 +9,6 @@ public class InitMultipartCommandValidator : AbstractValidator<InitMultipartComm
|
|||||||
{
|
{
|
||||||
public InitMultipartCommandValidator()
|
public InitMultipartCommandValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称");
|
|
||||||
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,6 @@ public class UploadPartCommandValidator : AbstractValidator<UploadPartCommand>
|
|||||||
{
|
{
|
||||||
public UploadPartCommandValidator()
|
public UploadPartCommandValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称");
|
|
||||||
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
||||||
RuleFor(x => x.UploadId).NotEmpty().WithMessage("upload_id 不能为空");
|
RuleFor(x => x.UploadId).NotEmpty().WithMessage("upload_id 不能为空");
|
||||||
RuleFor(x => x.PartNumber).GreaterThan(0).WithMessage("part_number 必须大于 0");
|
RuleFor(x => x.PartNumber).GreaterThan(0).WithMessage("part_number 必须大于 0");
|
||||||
|
|||||||
@ -142,6 +142,29 @@ public static class SeedData
|
|||||||
|
|
||||||
if (permissions.Count == 0) return;
|
if (permissions.Count == 0) return;
|
||||||
|
|
||||||
|
// 释放主键冲突:历史库可能存在与新种子同 Id 但不同 Code 的(已软删)残留记录,
|
||||||
|
// 导致 AddRangeAsync 主键冲突(23505)。这里物理删除这些占位记录,释放 Guid。
|
||||||
|
var seedIds = permissions.Select(p => p.Id).ToList();
|
||||||
|
var conflicting = await db.Permissions
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(p => seedIds.Contains(p.Id))
|
||||||
|
.Select(p => new { p.Id, p.Code })
|
||||||
|
.ToListAsync();
|
||||||
|
if (conflicting.Count > 0)
|
||||||
|
{
|
||||||
|
// 全程用原生 SQL 物理清理,避免 ChangeTracker 与外键级联互相冲突导致并发异常。
|
||||||
|
// conflictIds 来自上面数据库查询的 Guid 主键,非用户输入,无注入风险;EF1003 在此为误报。
|
||||||
|
var conflictIds = conflicting.Select(c => c.Id).ToList();
|
||||||
|
#pragma warning disable EF1003
|
||||||
|
var idList = string.Join(", ", conflictIds.Select(id => "'" + id.ToString() + "'"));
|
||||||
|
// 先删角色-权限关联(该表无软删除),再物理删残留权限本身释放主键。
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
"DELETE FROM role_permissions WHERE permission_id IN (" + idList + ");");
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
"DELETE FROM permissions WHERE id IN (" + idList + ");");
|
||||||
|
#pragma warning restore EF1003
|
||||||
|
}
|
||||||
|
|
||||||
await db.Permissions.AddRangeAsync(permissions);
|
await db.Permissions.AddRangeAsync(permissions);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
@ -182,7 +205,7 @@ public static class SeedData
|
|||||||
// id 列表来自上面的数据库查询(Guid 主键),非用户输入,用 string.Concat / string.Join 拼成 SQL 字面量无注入风险;
|
// id 列表来自上面的数据库查询(Guid 主键),非用户输入,用 string.Concat / string.Join 拼成 SQL 字面量无注入风险;
|
||||||
// permissions 表主键列名为带引号的 "Id"(EF 配置的 PascalCase 列名)。
|
// permissions 表主键列名为带引号的 "Id"(EF 配置的 PascalCase 列名)。
|
||||||
var idList = string.Join(", ", deprecatedPermissionIds.Select(id => "'" + id.ToString() + "'"));
|
var idList = string.Join(", ", deprecatedPermissionIds.Select(id => "'" + id.ToString() + "'"));
|
||||||
var sql = "DELETE FROM permissions WHERE \"Id\" IN (" + idList + ");";
|
var sql = "DELETE FROM permissions WHERE id IN (" + idList + ");";
|
||||||
await db.Database.ExecuteSqlRawAsync(sql);
|
await db.Database.ExecuteSqlRawAsync(sql);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
149
src/modules/workflow/Domain/Engine/WorkflowGraph.cs
Normal file
149
src/modules/workflow/Domain/Engine/WorkflowGraph.cs
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Workflow.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Workflow.Domain.Engine;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// X6 graph 的强类型模型,与 X6 toJSON/fromJSON 原生格式兼容。
|
||||||
|
/// 替代裸 JsonDocument,供 ProcessEngine / 校验器 / 迁移脚本统一读写。
|
||||||
|
/// 顶层结构为 { nodes: [], edges: [] }(X6 fromJSON 支持的两种等价格式之一)。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WorkflowGraph
|
||||||
|
{
|
||||||
|
[JsonPropertyName("nodes")]
|
||||||
|
public List<GraphNode> Nodes { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("edges")]
|
||||||
|
public List<GraphEdge> Edges { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>空图工厂(容错用)。</summary>
|
||||||
|
public static WorkflowGraph Empty() => new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// X6 节点。shape 决定渲染器并反推 NodeType(见 <see cref="WorkflowShapeMap"/>);
|
||||||
|
/// 业务配置统一进 data;x/y/width/height/ports 为几何与渲染信息。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GraphNode
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("shape")]
|
||||||
|
public string Shape { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("x")]
|
||||||
|
public double X { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("y")]
|
||||||
|
public double Y { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("width")]
|
||||||
|
public double? Width { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("height")]
|
||||||
|
public double? Height { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("angle")]
|
||||||
|
public double? Angle { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("visible")]
|
||||||
|
public bool? Visible { get; set; }
|
||||||
|
|
||||||
|
/// <summary>业务数据(节点配置)。替代旧 WorkflowNode.Config 字符串字段。</summary>
|
||||||
|
[JsonPropertyName("data")]
|
||||||
|
public JsonElement? Data { get; set; }
|
||||||
|
|
||||||
|
/// <summary>显示名(对应旧 WorkflowNode.Name)。</summary>
|
||||||
|
public string? GetTitle() => Data.TryGetString("title");
|
||||||
|
|
||||||
|
[JsonPropertyName("ports")]
|
||||||
|
public JsonElement? Ports { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// X6 边。source/target 用 { cell, port } 结构;几何信息(vertices/router/connector)放顶层;
|
||||||
|
/// 业务字段(edgeType/condition/order)放 data。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GraphEdge
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("shape")]
|
||||||
|
public string? Shape { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("source")]
|
||||||
|
public GraphEndpoint Source { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("target")]
|
||||||
|
public GraphEndpoint Target { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>业务数据:edgeType / condition / order。</summary>
|
||||||
|
[JsonPropertyName("data")]
|
||||||
|
public JsonElement? Data { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("label")]
|
||||||
|
public JsonElement? Label { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("vertices")]
|
||||||
|
public List<GraphPoint>? Vertices { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("router")]
|
||||||
|
public JsonElement? Router { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("connector")]
|
||||||
|
public JsonElement? Connector { get; set; }
|
||||||
|
|
||||||
|
/// <summary>边类型(Normal=0/Approved=1/Rejected=2)。缺省为 Normal。</summary>
|
||||||
|
public EdgeType GetEdgeType() => Data.TryGetInt32("edgeType") is { } v ? (EdgeType)v : EdgeType.Normal;
|
||||||
|
|
||||||
|
/// <summary>条件表达式(Condition/ExclusiveGateway 出边)。</summary>
|
||||||
|
public string? GetCondition() => Data.TryGetString("condition");
|
||||||
|
|
||||||
|
/// <summary>分支评估顺序(值小优先)。</summary>
|
||||||
|
public int GetOrder() => Data.TryGetInt32("order") ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class GraphEndpoint
|
||||||
|
{
|
||||||
|
/// <summary>节点 id(对应 GraphNode.Id)。</summary>
|
||||||
|
[JsonPropertyName("cell")]
|
||||||
|
public string? Cell { get; set; }
|
||||||
|
|
||||||
|
/// <summary>端口 id(top/right/bottom/left)。</summary>
|
||||||
|
[JsonPropertyName("port")]
|
||||||
|
public string? Port { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class GraphPoint
|
||||||
|
{
|
||||||
|
[JsonPropertyName("x")]
|
||||||
|
public double X { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("y")]
|
||||||
|
public double Y { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JsonElement? 安全取值扩展:统一处理 Nullable 解构与 ValueKind 校验,
|
||||||
|
/// 避免 WorkflowGraph 各 getter 重复 null 检查。
|
||||||
|
/// </summary>
|
||||||
|
internal static class GraphJsonExtensions
|
||||||
|
{
|
||||||
|
public static string? TryGetString(this JsonElement? element, string name) =>
|
||||||
|
element is { ValueKind: JsonValueKind.Object } el
|
||||||
|
&& el.TryGetProperty(name, out var p)
|
||||||
|
&& p.ValueKind == JsonValueKind.String
|
||||||
|
? p.GetString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
public static int? TryGetInt32(this JsonElement? element, string name) =>
|
||||||
|
element is { ValueKind: JsonValueKind.Object } el
|
||||||
|
&& el.TryGetProperty(name, out var p)
|
||||||
|
&& (p.ValueKind == JsonValueKind.Number || p.ValueKind == JsonValueKind.String)
|
||||||
|
&& p.TryGetInt32(out var n)
|
||||||
|
? n
|
||||||
|
: null;
|
||||||
|
}
|
||||||
52
src/modules/workflow/Domain/Engine/WorkflowShapeMap.cs
Normal file
52
src/modules/workflow/Domain/Engine/WorkflowShapeMap.cs
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
using Workflow.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Workflow.Domain.Engine;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// X6 节点 shape 名 与 <see cref="NodeType"/> 的双向映射。
|
||||||
|
/// 前端注册节点与后端反推 NodeType 都以此为准。新增 NodeType 必须同步登记 shape。
|
||||||
|
/// </summary>
|
||||||
|
public static class WorkflowShapeMap
|
||||||
|
{
|
||||||
|
/// <summary>shape 名常量,与前端 register 的 shape 一一对应。</summary>
|
||||||
|
public static class Shapes
|
||||||
|
{
|
||||||
|
public const string Start = "wf-start";
|
||||||
|
public const string End = "wf-end";
|
||||||
|
public const string Approval = "wf-approval";
|
||||||
|
public const string Cc = "wf-cc";
|
||||||
|
public const string Condition = "wf-condition";
|
||||||
|
public const string Parallel = "wf-parallel";
|
||||||
|
public const string SubProcess = "wf-subprocess";
|
||||||
|
public const string Timer = "wf-timer";
|
||||||
|
public const string Exclusive = "wf-exclusive";
|
||||||
|
public const string Edge = "wf-edge";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly IReadOnlyDictionary<string, NodeType> ShapeToType = new Dictionary<string, NodeType>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
[Shapes.Start] = NodeType.Start,
|
||||||
|
[Shapes.End] = NodeType.End,
|
||||||
|
[Shapes.Approval] = NodeType.Approval,
|
||||||
|
[Shapes.Cc] = NodeType.Cc,
|
||||||
|
[Shapes.Condition] = NodeType.Condition,
|
||||||
|
[Shapes.Parallel] = NodeType.Parallel,
|
||||||
|
[Shapes.SubProcess] = NodeType.SubProcess,
|
||||||
|
[Shapes.Timer] = NodeType.Timer,
|
||||||
|
[Shapes.Exclusive] = NodeType.ExclusiveGateway,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly IReadOnlyDictionary<NodeType, string> TypeToShape =
|
||||||
|
ShapeToType.ToDictionary(kv => kv.Value, kv => kv.Key);
|
||||||
|
|
||||||
|
/// <summary>shape 名反推 NodeType;未知 shape 返回 null(发布校验会拦截)。</summary>
|
||||||
|
public static NodeType? ToNodeType(string? shape) =>
|
||||||
|
shape is not null && ShapeToType.TryGetValue(shape, out var t) ? t : null;
|
||||||
|
|
||||||
|
/// <summary>NodeType 转 shape 名。</summary>
|
||||||
|
public static string ToShape(NodeType type) => TypeToShape[type];
|
||||||
|
|
||||||
|
/// <summary>判断 shape 是否为已登记的合法节点 shape。</summary>
|
||||||
|
public static bool IsValidNodeShape(string? shape) =>
|
||||||
|
shape is not null && ShapeToType.ContainsKey(shape);
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
using BuildFlow.Shared.Domain.Common;
|
||||||
|
|
||||||
|
namespace Workflow.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 工作流定义的发布版本快照。每次发布(Draft → Published)写入一条不可变快照,
|
||||||
|
/// ProcessEngine 运行时按 DefinitionId + Version 读快照而非主表当前 DefinitionJson,
|
||||||
|
/// 实现「已发布定义不可变 + 运行实例版本隔离」。
|
||||||
|
/// 参照仓库已有的 <see cref="FormDefinitionVersion"/> 范式。
|
||||||
|
/// </summary>
|
||||||
|
public class WorkflowDefinitionSnapshot : BaseEntity, IAuditable
|
||||||
|
{
|
||||||
|
/// <summary>所属流程定义 ID。</summary>
|
||||||
|
public Guid DefinitionId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>版本号(与发布时的 WorkflowDefinition.Version 对应)。</summary>
|
||||||
|
public int Version { get; set; }
|
||||||
|
|
||||||
|
/// <summary>该版本的完整 X6 graph JSON 快照(不可变)。引擎运行时唯一事实源。</summary>
|
||||||
|
public string? DefinitionJson { get; set; }
|
||||||
|
|
||||||
|
/// <summary>触发来源:Publish(仅发布写入)。</summary>
|
||||||
|
public string Source { get; set; } = "Publish";
|
||||||
|
|
||||||
|
/// <summary>变更摘要(可选)。</summary>
|
||||||
|
public string? ChangeSummary { get; set; }
|
||||||
|
|
||||||
|
public string CreatedBy { get; set; } = default!;
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public string UpdatedBy { get; set; } = default!;
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public bool IsDeleted { get; set; }
|
||||||
|
public string OperatorIP { get; set; } = "unknown";
|
||||||
|
}
|
||||||
@ -11,6 +11,11 @@ namespace Workflow.Domain.Entities;
|
|||||||
public class WorkflowInstance : BaseEntity, IFullAudit
|
public class WorkflowInstance : BaseEntity, IFullAudit
|
||||||
{
|
{
|
||||||
public Guid DefinitionId { get; set; }
|
public Guid DefinitionId { get; set; }
|
||||||
|
/// <summary>实例启动时绑定的流程定义版本号。ProcessEngine 按 DefinitionId + DefinitionVersion 读不可变快照,
|
||||||
|
/// 避免已发布定义被新版覆盖后污染运行中实例(版本隔离)。</summary>
|
||||||
|
public int DefinitionVersion { get; set; } = 1;
|
||||||
|
/// <summary>实例启动时快照的流程定义 Code,便于追溯(运行时不读,仅审计用)。</summary>
|
||||||
|
public string? DefinitionCode { get; set; }
|
||||||
public string Title { get; set; } = string.Empty;
|
public string Title { get; set; } = string.Empty;
|
||||||
public InstanceStatus Status { get; set; } = InstanceStatus.Running;
|
public InstanceStatus Status { get; set; } = InstanceStatus.Running;
|
||||||
/// <summary>流程变量(JSON 字符串)。条件节点求值、节点动作钩子均从此读取运行时数据。</summary>
|
/// <summary>流程变量(JSON 字符串)。条件节点求值、节点动作钩子均从此读取运行时数据。</summary>
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using Workflow.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Workflow.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 工作流定义发布快照表映射配置。每次发布生成一条不可变快照,支撑引擎版本隔离。
|
||||||
|
/// 参照仓库已有的 <see cref="FormDefinitionVersionConfiguration"/> 范式。
|
||||||
|
/// </summary>
|
||||||
|
public class WorkflowDefinitionSnapshotConfiguration : IEntityTypeConfiguration<WorkflowDefinitionSnapshot>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<WorkflowDefinitionSnapshot> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("wf_workflow_definition_snapshots");
|
||||||
|
|
||||||
|
builder.HasKey(e => e.Id);
|
||||||
|
builder.Property(e => e.Id).ValueGeneratedNever().HasComment("主键ID");
|
||||||
|
builder.Property(e => e.DefinitionId).HasComment("所属工作流定义ID");
|
||||||
|
builder.Property(e => e.Version).HasComment("版本号(发布时的 WorkflowDefinition.Version)");
|
||||||
|
builder.Property(e => e.DefinitionJson).HasColumnType("jsonb").HasComment("该版本的完整X6 graph JSON快照(不可变)");
|
||||||
|
builder.Property(e => e.Source).HasMaxLength(50).HasComment("触发来源 Publish");
|
||||||
|
builder.Property(e => e.ChangeSummary).HasMaxLength(1000).HasComment("变更摘要");
|
||||||
|
builder.Property(e => e.CreatedBy).HasMaxLength(100).HasComment("创建人ID");
|
||||||
|
builder.Property(e => e.CreatedAt).HasComment("创建时间");
|
||||||
|
builder.Property(e => e.UpdatedBy).HasMaxLength(100).HasComment("更新人ID");
|
||||||
|
builder.Property(e => e.UpdatedAt).HasComment("更新时间");
|
||||||
|
builder.Property(e => e.IsDeleted).HasDefaultValue(false).HasComment("是否软删除");
|
||||||
|
builder.Property(e => e.OperatorIP).HasMaxLength(500).HasComment("操作人IP地址");
|
||||||
|
|
||||||
|
// 复合唯一索引:同一定义同版本仅一条快照
|
||||||
|
builder.HasIndex(e => new { e.DefinitionId, e.Version }).IsUnique();
|
||||||
|
builder.HasIndex(e => e.DefinitionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
using BuildFlow.Shared.Domain.Common;
|
using BuildFlow.Shared.Domain.Common;
|
||||||
using BuildFlow.Shared.Infrastructure;
|
using BuildFlow.Shared.Infrastructure;
|
||||||
|
using File.Application.Common;
|
||||||
using File.Application.Features.Files.Commands;
|
using File.Application.Features.Files.Commands;
|
||||||
using File.Application.Features.Files.Handlers;
|
using File.Application.Features.Files.Handlers;
|
||||||
using File.Domain.Entities;
|
using File.Domain.Entities;
|
||||||
@ -21,6 +22,14 @@ public class DeleteFileCommandHandlerTests
|
|||||||
private const string Bucket = "build-flow";
|
private const string Bucket = "build-flow";
|
||||||
private const string Key = "multipart/abc123";
|
private const string Key = "multipart/abc123";
|
||||||
|
|
||||||
|
/// <summary>测试用 bucket 解析器,固定返回与 <see cref="Bucket"/> 一致的桶名。</summary>
|
||||||
|
private static IBucketResolver BucketResolver()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IBucketResolver>();
|
||||||
|
mock.Setup(r => r.GetDefaultBucketAsync(It.IsAny<CancellationToken>())).ReturnsAsync(Bucket);
|
||||||
|
return mock.Object;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>构造测试依赖:InMemory DB(挂审计拦截器自动填充非空字段)+ Mock S3 + Mock Mediator。</summary>
|
/// <summary>构造测试依赖:InMemory DB(挂审计拦截器自动填充非空字段)+ Mock S3 + Mock Mediator。</summary>
|
||||||
private static (FileDbContext db, Mock<IS3StorageService> s3, Mock<MediatR.IMediator> mediator) CreateDeps(string dbName)
|
private static (FileDbContext db, Mock<IS3StorageService> s3, Mock<MediatR.IMediator> mediator) CreateDeps(string dbName)
|
||||||
{
|
{
|
||||||
@ -57,10 +66,10 @@ public class DeleteFileCommandHandlerTests
|
|||||||
db.Files.Add(NewMeta(Bucket, Key));
|
db.Files.Add(NewMeta(Bucket, Key));
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var handler = new DeleteFileCommandHandler(s3.Object, db, mediator.Object);
|
var handler = new DeleteFileCommandHandler(s3.Object, db, BucketResolver(), mediator.Object);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await handler.Handle(new DeleteFileCommand(Bucket, Key), CancellationToken.None);
|
await handler.Handle(new DeleteFileCommand(Key), CancellationToken.None);
|
||||||
|
|
||||||
// Assert:S3 对象已删
|
// Assert:S3 对象已删
|
||||||
s3.Verify(x => x.DeleteFileAsync(Bucket, Key, It.IsAny<CancellationToken>()), Times.Once);
|
s3.Verify(x => x.DeleteFileAsync(Bucket, Key, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
@ -74,10 +83,10 @@ public class DeleteFileCommandHandlerTests
|
|||||||
{
|
{
|
||||||
// Arrange:DB 无匹配记录(如根目录直传未落库、或已被其他途径清理)
|
// Arrange:DB 无匹配记录(如根目录直传未落库、或已被其他途径清理)
|
||||||
var (db, s3, mediator) = CreateDeps(nameof(Delete_无FileMeta记录_仍删S3对象_不报错));
|
var (db, s3, mediator) = CreateDeps(nameof(Delete_无FileMeta记录_仍删S3对象_不报错));
|
||||||
var handler = new DeleteFileCommandHandler(s3.Object, db, mediator.Object);
|
var handler = new DeleteFileCommandHandler(s3.Object, db, BucketResolver(), mediator.Object);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await handler.Handle(new DeleteFileCommand(Bucket, Key), CancellationToken.None);
|
await handler.Handle(new DeleteFileCommand(Key), CancellationToken.None);
|
||||||
|
|
||||||
// Assert:S3 仍尝试删除(幂等,删不存在的对象也返回成功)
|
// Assert:S3 仍尝试删除(幂等,删不存在的对象也返回成功)
|
||||||
s3.Verify(x => x.DeleteFileAsync(Bucket, Key, It.IsAny<CancellationToken>()), Times.Once);
|
s3.Verify(x => x.DeleteFileAsync(Bucket, Key, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
@ -92,10 +101,10 @@ public class DeleteFileCommandHandlerTests
|
|||||||
db.Files.Add(NewMeta(Bucket, Key, "user-002"));
|
db.Files.Add(NewMeta(Bucket, Key, "user-002"));
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var handler = new DeleteFileCommandHandler(s3.Object, db, mediator.Object);
|
var handler = new DeleteFileCommandHandler(s3.Object, db, BucketResolver(), mediator.Object);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await handler.Handle(new DeleteFileCommand(Bucket, Key), CancellationToken.None);
|
await handler.Handle(new DeleteFileCommand(Key), CancellationToken.None);
|
||||||
|
|
||||||
// Assert:两条记录都被清理
|
// Assert:两条记录都被清理
|
||||||
var remaining = await db.Files.Where(f => f.S3Bucket == Bucket && f.S3Key == Key).ToListAsync();
|
var remaining = await db.Files.Where(f => f.S3Bucket == Bucket && f.S3Key == Key).ToListAsync();
|
||||||
@ -111,10 +120,10 @@ public class DeleteFileCommandHandlerTests
|
|||||||
db.Files.Add(NewMeta(Bucket, "multipart/bbb"));
|
db.Files.Add(NewMeta(Bucket, "multipart/bbb"));
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var handler = new DeleteFileCommandHandler(s3.Object, db, mediator.Object);
|
var handler = new DeleteFileCommandHandler(s3.Object, db, BucketResolver(), mediator.Object);
|
||||||
|
|
||||||
// Act:只删 aaa
|
// Act:只删 aaa
|
||||||
await handler.Handle(new DeleteFileCommand(Bucket, "multipart/aaa"), CancellationToken.None);
|
await handler.Handle(new DeleteFileCommand("multipart/aaa"), CancellationToken.None);
|
||||||
|
|
||||||
// Assert:aaa 没了,bbb 还在
|
// Assert:aaa 没了,bbb 还在
|
||||||
var remaining = await db.Files.ToListAsync();
|
var remaining = await db.Files.ToListAsync();
|
||||||
@ -132,10 +141,10 @@ public class DeleteFileCommandHandlerTests
|
|||||||
s3.Setup(x => x.DeleteFileAsync(Bucket, Key, It.IsAny<CancellationToken>()))
|
s3.Setup(x => x.DeleteFileAsync(Bucket, Key, It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new Exception("S3 连接失败"));
|
.ThrowsAsync(new Exception("S3 连接失败"));
|
||||||
|
|
||||||
var handler = new DeleteFileCommandHandler(s3.Object, db, mediator.Object);
|
var handler = new DeleteFileCommandHandler(s3.Object, db, BucketResolver(), mediator.Object);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var act = () => handler.Handle(new DeleteFileCommand(Bucket, Key), CancellationToken.None);
|
var act = () => handler.Handle(new DeleteFileCommand(Key), CancellationToken.None);
|
||||||
|
|
||||||
// Assert:异常向上抛
|
// Assert:异常向上抛
|
||||||
await act.Should().ThrowAsync<Exception>().WithMessage("S3 连接失败");
|
await act.Should().ThrowAsync<Exception>().WithMessage("S3 连接失败");
|
||||||
@ -149,10 +158,10 @@ public class DeleteFileCommandHandlerTests
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (db, s3, mediator) = CreateDeps(nameof(Delete_发布FileDeletedEvent));
|
var (db, s3, mediator) = CreateDeps(nameof(Delete_发布FileDeletedEvent));
|
||||||
var handler = new DeleteFileCommandHandler(s3.Object, db, mediator.Object);
|
var handler = new DeleteFileCommandHandler(s3.Object, db, BucketResolver(), mediator.Object);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await handler.Handle(new DeleteFileCommand(Bucket, Key), CancellationToken.None);
|
await handler.Handle(new DeleteFileCommand(Key), CancellationToken.None);
|
||||||
|
|
||||||
// Assert:事件已发布(供其他模块响应,如清理缓存)
|
// Assert:事件已发布(供其他模块响应,如清理缓存)
|
||||||
mediator.Verify(
|
mediator.Verify(
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
|
using File.Application.Common;
|
||||||
using File.Application.Features.Files.Handlers;
|
using File.Application.Features.Files.Handlers;
|
||||||
using File.Application.Features.Files.Queries;
|
using File.Application.Features.Files.Queries;
|
||||||
using File.Infrastructure.Storage;
|
using File.Infrastructure.Storage;
|
||||||
@ -14,6 +15,14 @@ namespace BuildFlow.File.Tests;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class DownloadFileQueryHandlerTests
|
public class DownloadFileQueryHandlerTests
|
||||||
{
|
{
|
||||||
|
/// <summary>测试用 bucket 解析器,固定返回 "test-bucket"。</summary>
|
||||||
|
private static IBucketResolver BucketResolver()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IBucketResolver>();
|
||||||
|
mock.Setup(r => r.GetDefaultBucketAsync(It.IsAny<CancellationToken>())).ReturnsAsync("test-bucket");
|
||||||
|
return mock.Object;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>创建 Mock S3 + Handler。</summary>
|
/// <summary>创建 Mock S3 + Handler。</summary>
|
||||||
private static (Mock<IS3StorageService> s3Mock, DownloadFileQueryHandler handler) Setup(string content = "hello world")
|
private static (Mock<IS3StorageService> s3Mock, DownloadFileQueryHandler handler) Setup(string content = "hello world")
|
||||||
{
|
{
|
||||||
@ -23,7 +32,7 @@ public class DownloadFileQueryHandlerTests
|
|||||||
.Setup(x => x.DownloadFileAsync("test-bucket", "test-key", It.IsAny<CancellationToken>()))
|
.Setup(x => x.DownloadFileAsync("test-bucket", "test-key", It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync((stream, "text/plain"));
|
.ReturnsAsync((stream, "text/plain"));
|
||||||
|
|
||||||
var handler = new DownloadFileQueryHandler(s3Mock.Object);
|
var handler = new DownloadFileQueryHandler(s3Mock.Object, BucketResolver());
|
||||||
return (s3Mock, handler);
|
return (s3Mock, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,7 +44,7 @@ public class DownloadFileQueryHandlerTests
|
|||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await handler.Handle(
|
var result = await handler.Handle(
|
||||||
new DownloadFileQuery("test-bucket", "test-key"),
|
new DownloadFileQuery("test-key"),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert — 返回的是 S3 原始流,透传给调用方;ContentType 取 S3 返回的真实类型
|
// Assert — 返回的是 S3 原始流,透传给调用方;ContentType 取 S3 返回的真实类型
|
||||||
@ -56,11 +65,11 @@ public class DownloadFileQueryHandlerTests
|
|||||||
.Setup(x => x.DownloadFileAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
.Setup(x => x.DownloadFileAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync((emptyStream, "application/octet-stream"));
|
.ReturnsAsync((emptyStream, "application/octet-stream"));
|
||||||
|
|
||||||
var handler = new DownloadFileQueryHandler(s3Mock.Object);
|
var handler = new DownloadFileQueryHandler(s3Mock.Object, BucketResolver());
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await handler.Handle(
|
var result = await handler.Handle(
|
||||||
new DownloadFileQuery("test-bucket", "test-key"),
|
new DownloadFileQuery("test-key"),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert — Handler 透传空流,长度为 0
|
// Assert — Handler 透传空流,长度为 0
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
using BuildFlow.Shared.Domain.Common;
|
using BuildFlow.Shared.Domain.Common;
|
||||||
using BuildFlow.Shared.Infrastructure;
|
using BuildFlow.Shared.Infrastructure;
|
||||||
|
using File.Application.Common;
|
||||||
using File.Application.Features.Multipart.Commands;
|
using File.Application.Features.Multipart.Commands;
|
||||||
using File.Application.Features.Multipart.DTOs;
|
using File.Application.Features.Multipart.DTOs;
|
||||||
using File.Application.Features.Multipart.Handlers;
|
using File.Application.Features.Multipart.Handlers;
|
||||||
@ -23,6 +24,15 @@ public class InstantUploadTests
|
|||||||
{
|
{
|
||||||
private const string OwnerId = "user-001";
|
private const string OwnerId = "user-001";
|
||||||
private const string Sha = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
|
private const string Sha = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
|
||||||
|
private const string Bucket = "files";
|
||||||
|
|
||||||
|
/// <summary>测试用 bucket 解析器,固定返回与 <see cref="Bucket"/> 一致的桶名。</summary>
|
||||||
|
private static IBucketResolver BucketResolver()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IBucketResolver>();
|
||||||
|
mock.Setup(r => r.GetDefaultBucketAsync(It.IsAny<CancellationToken>())).ReturnsAsync(Bucket);
|
||||||
|
return mock.Object;
|
||||||
|
}
|
||||||
|
|
||||||
private static (FileDbContext db, Mock<IS3StorageService> s3, Mock<ICurrentUserContext> user) CreateDeps(string dbName)
|
private static (FileDbContext db, Mock<IS3StorageService> s3, Mock<ICurrentUserContext> user) CreateDeps(string dbName)
|
||||||
{
|
{
|
||||||
@ -49,9 +59,9 @@ public class InstantUploadTests
|
|||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var handler = new InitMultipartCommandHandler(s3.Object, db, user.Object);
|
var handler = new InitMultipartCommandHandler(s3.Object, db, user.Object, BucketResolver());
|
||||||
|
|
||||||
var result = await handler.Handle(new InitMultipartCommand("files", "obj", Sha, TotalParts: 1, Size: 1024), CancellationToken.None);
|
var result = await handler.Handle(new InitMultipartCommand("obj", Sha, TotalParts: 1, Size: 1024), CancellationToken.None);
|
||||||
|
|
||||||
result.Instant.Should().BeTrue("同用户同哈希同大小应命中秒传");
|
result.Instant.Should().BeTrue("同用户同哈希同大小应命中秒传");
|
||||||
result.UploadId.Should().BeEmpty("秒传不需要 uploadId");
|
result.UploadId.Should().BeEmpty("秒传不需要 uploadId");
|
||||||
@ -73,9 +83,9 @@ public class InstantUploadTests
|
|||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
s3.Setup(x => x.CreateMultipartUploadAsync("files", "obj", default)).ReturnsAsync("uid-1");
|
s3.Setup(x => x.CreateMultipartUploadAsync("files", "obj", default)).ReturnsAsync("uid-1");
|
||||||
var handler = new InitMultipartCommandHandler(s3.Object, db, user.Object);
|
var handler = new InitMultipartCommandHandler(s3.Object, db, user.Object, BucketResolver());
|
||||||
|
|
||||||
var result = await handler.Handle(new InitMultipartCommand("files", "obj", Sha, TotalParts: 1, Size: 2048), CancellationToken.None);
|
var result = await handler.Handle(new InitMultipartCommand("obj", Sha, TotalParts: 1, Size: 2048), CancellationToken.None);
|
||||||
|
|
||||||
result.Instant.Should().BeFalse("大小不同不应命中秒传");
|
result.Instant.Should().BeFalse("大小不同不应命中秒传");
|
||||||
result.UploadId.Should().Be("uid-1");
|
result.UploadId.Should().Be("uid-1");
|
||||||
@ -93,9 +103,9 @@ public class InstantUploadTests
|
|||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
s3.Setup(x => x.CreateMultipartUploadAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync("uid-1");
|
s3.Setup(x => x.CreateMultipartUploadAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync("uid-1");
|
||||||
var handler = new InitMultipartCommandHandler(s3.Object, db, user.Object);
|
var handler = new InitMultipartCommandHandler(s3.Object, db, user.Object, BucketResolver());
|
||||||
|
|
||||||
var result = await handler.Handle(new InitMultipartCommand("files", "obj", Sha, TotalParts: 1, Size: 1024), CancellationToken.None);
|
var result = await handler.Handle(new InitMultipartCommand("obj", Sha, TotalParts: 1, Size: 1024), CancellationToken.None);
|
||||||
|
|
||||||
result.Instant.Should().BeFalse("他人文件不参与当前用户秒传");
|
result.Instant.Should().BeFalse("他人文件不参与当前用户秒传");
|
||||||
}
|
}
|
||||||
@ -117,9 +127,9 @@ public class InstantUploadTests
|
|||||||
{
|
{
|
||||||
new(1, "etag-1"), new(2, "etag-2"),
|
new(1, "etag-1"), new(2, "etag-2"),
|
||||||
};
|
};
|
||||||
var handler = new CompleteMultipartCommandHandler(s3.Object, db, user.Object);
|
var handler = new CompleteMultipartCommandHandler(s3.Object, db, user.Object, BucketResolver());
|
||||||
|
|
||||||
var result = await handler.Handle(new CompleteMultipartCommand("files", "obj-key", "uid-1", parts, FileName: "a.bin", Size: 1024), CancellationToken.None);
|
var result = await handler.Handle(new CompleteMultipartCommand("obj-key", "uid-1", parts, FileName: "a.bin", Size: 1024), CancellationToken.None);
|
||||||
|
|
||||||
result.Location.Should().Be("files/obj-key");
|
result.Location.Should().Be("files/obj-key");
|
||||||
// FileMeta 应被创建
|
// FileMeta 应被创建
|
||||||
@ -135,8 +145,8 @@ public class InstantUploadTests
|
|||||||
session.Status.Should().Be(UploadSessionStatus.Completed);
|
session.Status.Should().Be(UploadSessionStatus.Completed);
|
||||||
|
|
||||||
// 串联验证:再次 init 同文件现在能命中秒传
|
// 串联验证:再次 init 同文件现在能命中秒传
|
||||||
var initHandler = new InitMultipartCommandHandler(s3.Object, db, user.Object);
|
var initHandler = new InitMultipartCommandHandler(s3.Object, db, user.Object, BucketResolver());
|
||||||
var initResult = await initHandler.Handle(new InitMultipartCommand("files", "obj2", Sha, TotalParts: 2, Size: 1024), CancellationToken.None);
|
var initResult = await initHandler.Handle(new InitMultipartCommand("obj2", Sha, TotalParts: 2, Size: 1024), CancellationToken.None);
|
||||||
initResult.Instant.Should().BeTrue("FileMeta 已落库,再次分片上传同文件应秒传");
|
initResult.Instant.Should().BeTrue("FileMeta 已落库,再次分片上传同文件应秒传");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
using BuildFlow.Shared.Domain.Common;
|
using BuildFlow.Shared.Domain.Common;
|
||||||
using BuildFlow.Shared.Domain.Exceptions;
|
using BuildFlow.Shared.Domain.Exceptions;
|
||||||
using BuildFlow.Shared.Infrastructure;
|
using BuildFlow.Shared.Infrastructure;
|
||||||
|
using File.Application.Common;
|
||||||
using File.Application.Features.Multipart.Commands;
|
using File.Application.Features.Multipart.Commands;
|
||||||
using File.Application.Features.Multipart.DTOs;
|
using File.Application.Features.Multipart.DTOs;
|
||||||
using File.Application.Features.Multipart.Handlers;
|
using File.Application.Features.Multipart.Handlers;
|
||||||
@ -23,6 +24,15 @@ namespace BuildFlow.File.Tests;
|
|||||||
public class MultipartUploadSessionTests
|
public class MultipartUploadSessionTests
|
||||||
{
|
{
|
||||||
private const string OwnerId = "user-001";
|
private const string OwnerId = "user-001";
|
||||||
|
private const string Bucket = "bk";
|
||||||
|
|
||||||
|
/// <summary>测试用 bucket 解析器,固定返回与预置会话一致的桶名 "bk"。</summary>
|
||||||
|
private static IBucketResolver BucketResolver()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IBucketResolver>();
|
||||||
|
mock.Setup(r => r.GetDefaultBucketAsync(It.IsAny<CancellationToken>())).ReturnsAsync(Bucket);
|
||||||
|
return mock.Object;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 构造一组注入了 InMemory DbContext 的 handler 依赖。
|
/// 构造一组注入了 InMemory DbContext 的 handler 依赖。
|
||||||
@ -63,9 +73,9 @@ public class MultipartUploadSessionTests
|
|||||||
{
|
{
|
||||||
var (db, s3, user) = CreateDeps(nameof(Init_首次_发起S3Init并落库会话));
|
var (db, s3, user) = CreateDeps(nameof(Init_首次_发起S3Init并落库会话));
|
||||||
s3.Setup(x => x.CreateMultipartUploadAsync("bk", "key", default)).ReturnsAsync("upload-1");
|
s3.Setup(x => x.CreateMultipartUploadAsync("bk", "key", default)).ReturnsAsync("upload-1");
|
||||||
var handler = new InitMultipartCommandHandler(s3.Object, db, user.Object);
|
var handler = new InitMultipartCommandHandler(s3.Object, db, user.Object, BucketResolver());
|
||||||
|
|
||||||
var result = await handler.Handle(new InitMultipartCommand("bk", "key", "hash-abc", TotalParts: 3), CancellationToken.None);
|
var result = await handler.Handle(new InitMultipartCommand("key", "hash-abc", TotalParts: 3), CancellationToken.None);
|
||||||
|
|
||||||
result.UploadId.Should().Be("upload-1");
|
result.UploadId.Should().Be("upload-1");
|
||||||
result.Resume.Should().BeFalse();
|
result.Resume.Should().BeFalse();
|
||||||
@ -85,9 +95,9 @@ public class MultipartUploadSessionTests
|
|||||||
|
|
||||||
s3.Setup(x => x.CreateMultipartUploadAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
s3.Setup(x => x.CreateMultipartUploadAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync("upload-new"); // 不应被调用
|
.ReturnsAsync("upload-new"); // 不应被调用
|
||||||
var handler = new InitMultipartCommandHandler(s3.Object, db, user.Object);
|
var handler = new InitMultipartCommandHandler(s3.Object, db, user.Object, BucketResolver());
|
||||||
|
|
||||||
var result = await handler.Handle(new InitMultipartCommand("bk", "key"), CancellationToken.None);
|
var result = await handler.Handle(new InitMultipartCommand("key"), CancellationToken.None);
|
||||||
|
|
||||||
result.UploadId.Should().Be("upload-existing");
|
result.UploadId.Should().Be("upload-existing");
|
||||||
result.Resume.Should().BeTrue();
|
result.Resume.Should().BeTrue();
|
||||||
@ -102,7 +112,7 @@ public class MultipartUploadSessionTests
|
|||||||
var (db, s3, user) = CreateDeps(nameof(UploadPart_会话不存在_抛NotFound));
|
var (db, s3, user) = CreateDeps(nameof(UploadPart_会话不存在_抛NotFound));
|
||||||
var handler = new UploadPartCommandHandler(s3.Object, db, user.Object);
|
var handler = new UploadPartCommandHandler(s3.Object, db, user.Object);
|
||||||
|
|
||||||
var act = () => handler.Handle(new UploadPartCommand("bk", "key", "no-such", 1, new MemoryStream([1])), CancellationToken.None);
|
var act = () => handler.Handle(new UploadPartCommand("key", "no-such", 1, new MemoryStream([1])), CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<NotFoundException>();
|
await act.Should().ThrowAsync<NotFoundException>();
|
||||||
s3.Verify(x => x.UploadPartAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<Stream>(), It.IsAny<CancellationToken>()), Times.Never);
|
s3.Verify(x => x.UploadPartAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<Stream>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
@ -116,7 +126,7 @@ public class MultipartUploadSessionTests
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
var handler = new UploadPartCommandHandler(s3.Object, db, user.Object);
|
var handler = new UploadPartCommandHandler(s3.Object, db, user.Object);
|
||||||
|
|
||||||
var act = () => handler.Handle(new UploadPartCommand("bk", "key", "upload-other", 1, new MemoryStream([1])), CancellationToken.None);
|
var act = () => handler.Handle(new UploadPartCommand("key", "upload-other", 1, new MemoryStream([1])), CancellationToken.None);
|
||||||
|
|
||||||
// 他人会话也报 NotFound,不泄露存在性
|
// 他人会话也报 NotFound,不泄露存在性
|
||||||
await act.Should().ThrowAsync<NotFoundException>();
|
await act.Should().ThrowAsync<NotFoundException>();
|
||||||
@ -130,7 +140,7 @@ public class MultipartUploadSessionTests
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
var handler = new UploadPartCommandHandler(s3.Object, db, user.Object);
|
var handler = new UploadPartCommandHandler(s3.Object, db, user.Object);
|
||||||
|
|
||||||
var act = () => handler.Handle(new UploadPartCommand("bk", "key", "upload-done", 1, new MemoryStream([1])), CancellationToken.None);
|
var act = () => handler.Handle(new UploadPartCommand("key", "upload-done", 1, new MemoryStream([1])), CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<ConflictException>();
|
await act.Should().ThrowAsync<ConflictException>();
|
||||||
}
|
}
|
||||||
@ -141,10 +151,10 @@ public class MultipartUploadSessionTests
|
|||||||
var (db, s3, user) = CreateDeps(nameof(Complete_成功后会话标记Completed));
|
var (db, s3, user) = CreateDeps(nameof(Complete_成功后会话标记Completed));
|
||||||
db.UploadSessions.Add(NewSession("bk", "key", "upload-1", OwnerId));
|
db.UploadSessions.Add(NewSession("bk", "key", "upload-1", OwnerId));
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
var handler = new CompleteMultipartCommandHandler(s3.Object, db, user.Object);
|
var handler = new CompleteMultipartCommandHandler(s3.Object, db, user.Object, BucketResolver());
|
||||||
var parts = new List<CompletedPartDto> { new(1, "etag-1") };
|
var parts = new List<CompletedPartDto> { new(1, "etag-1") };
|
||||||
|
|
||||||
var result = await handler.Handle(new CompleteMultipartCommand("bk", "key", "upload-1", parts), CancellationToken.None);
|
var result = await handler.Handle(new CompleteMultipartCommand("key", "upload-1", parts), CancellationToken.None);
|
||||||
|
|
||||||
result.Location.Should().Be("bk/key");
|
result.Location.Should().Be("bk/key");
|
||||||
s3.Verify(x => x.CompleteMultipartUploadAsync("bk", "key", "upload-1", It.IsAny<List<CompletedPart>>(), It.IsAny<CancellationToken>()), Times.Once);
|
s3.Verify(x => x.CompleteMultipartUploadAsync("bk", "key", "upload-1", It.IsAny<List<CompletedPart>>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||||
@ -160,7 +170,7 @@ public class MultipartUploadSessionTests
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
var handler = new AbortMultipartCommandHandler(s3.Object, db, user.Object);
|
var handler = new AbortMultipartCommandHandler(s3.Object, db, user.Object);
|
||||||
|
|
||||||
await handler.Handle(new AbortMultipartCommand("bk", "key", "upload-1"), CancellationToken.None);
|
await handler.Handle(new AbortMultipartCommand("key", "upload-1"), CancellationToken.None);
|
||||||
|
|
||||||
s3.Verify(x => x.AbortMultipartUploadAsync("bk", "key", "upload-1", It.IsAny<CancellationToken>()), Times.Once);
|
s3.Verify(x => x.AbortMultipartUploadAsync("bk", "key", "upload-1", It.IsAny<CancellationToken>()), Times.Once);
|
||||||
var session = await db.UploadSessions.SingleAsync();
|
var session = await db.UploadSessions.SingleAsync();
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
using BuildFlow.Shared.Domain.Common;
|
using BuildFlow.Shared.Domain.Common;
|
||||||
using BuildFlow.Shared.Infrastructure;
|
using BuildFlow.Shared.Infrastructure;
|
||||||
|
using File.Application.Common;
|
||||||
using File.Application.Features.Files.Commands;
|
using File.Application.Features.Files.Commands;
|
||||||
using File.Application.Features.Files.Handlers;
|
using File.Application.Features.Files.Handlers;
|
||||||
using File.Domain.Entities;
|
using File.Domain.Entities;
|
||||||
@ -21,6 +22,14 @@ public class UploadFileCommandHandlerTests
|
|||||||
private const string OwnerId = "user-001";
|
private const string OwnerId = "user-001";
|
||||||
private const string Sha = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
|
private const string Sha = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
|
||||||
|
|
||||||
|
/// <summary>测试用 bucket 解析器,固定返回 "files"。</summary>
|
||||||
|
private static IBucketResolver BucketResolver(string bucket = "files")
|
||||||
|
{
|
||||||
|
var mock = new Mock<IBucketResolver>();
|
||||||
|
mock.Setup(r => r.GetDefaultBucketAsync(It.IsAny<CancellationToken>())).ReturnsAsync(bucket);
|
||||||
|
return mock.Object;
|
||||||
|
}
|
||||||
|
|
||||||
private static (FileDbContext db, Mock<IS3StorageService> s3, Mock<ICurrentUserContext> user) CreateDeps(string dbName)
|
private static (FileDbContext db, Mock<IS3StorageService> s3, Mock<ICurrentUserContext> user) CreateDeps(string dbName)
|
||||||
{
|
{
|
||||||
var user = new Mock<ICurrentUserContext>();
|
var user = new Mock<ICurrentUserContext>();
|
||||||
@ -40,10 +49,10 @@ public class UploadFileCommandHandlerTests
|
|||||||
// 缺陷C核心:直传必须落 FileMeta,否则直传的文件永远无法被秒传
|
// 缺陷C核心:直传必须落 FileMeta,否则直传的文件永远无法被秒传
|
||||||
var (db, s3, user) = CreateDeps(nameof(Upload_成功后落FileMeta_含ContentHash_使直传参与秒传));
|
var (db, s3, user) = CreateDeps(nameof(Upload_成功后落FileMeta_含ContentHash_使直传参与秒传));
|
||||||
var data = new MemoryStream([1, 2, 3]);
|
var data = new MemoryStream([1, 2, 3]);
|
||||||
var handler = new UploadFileCommandHandler(s3.Object, db, user.Object, new Mock<MediatR.IMediator>().Object);
|
var handler = new UploadFileCommandHandler(s3.Object, db, user.Object, BucketResolver(), new Mock<MediatR.IMediator>().Object);
|
||||||
|
|
||||||
var result = await handler.Handle(
|
var result = await handler.Handle(
|
||||||
new UploadFileCommand("files", "obj-key", data, "application/octet-stream", 3, Sha),
|
new UploadFileCommand("obj-key", data, "application/octet-stream", 3, Sha),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
result.ObjectKey.Should().Be("obj-key");
|
result.ObjectKey.Should().Be("obj-key");
|
||||||
@ -63,10 +72,10 @@ public class UploadFileCommandHandlerTests
|
|||||||
public async Task Upload_超过500MiB_抛TooLarge()
|
public async Task Upload_超过500MiB_抛TooLarge()
|
||||||
{
|
{
|
||||||
var (db, s3, user) = CreateDeps(nameof(Upload_超过500MiB_抛TooLarge));
|
var (db, s3, user) = CreateDeps(nameof(Upload_超过500MiB_抛TooLarge));
|
||||||
var handler = new UploadFileCommandHandler(s3.Object, db, user.Object, new Mock<MediatR.IMediator>().Object);
|
var handler = new UploadFileCommandHandler(s3.Object, db, user.Object, BucketResolver(), new Mock<MediatR.IMediator>().Object);
|
||||||
|
|
||||||
var act = () => handler.Handle(
|
var act = () => handler.Handle(
|
||||||
new UploadFileCommand("files", "big", new MemoryStream(), "application/octet-stream", 501 * 1024 * 1024),
|
new UploadFileCommand("big", new MemoryStream(), "application/octet-stream", 501 * 1024 * 1024),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<TooLargeException>();
|
await act.Should().ThrowAsync<TooLargeException>();
|
||||||
@ -84,10 +93,10 @@ public class UploadFileCommandHandlerTests
|
|||||||
Size = 3, ContentType = "application/octet-stream", ContentHash = Sha, OwnerId = OwnerId,
|
Size = 3, ContentType = "application/octet-stream", ContentHash = Sha, OwnerId = OwnerId,
|
||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
var handler = new UploadFileCommandHandler(s3.Object, db, user.Object, new Mock<MediatR.IMediator>().Object);
|
var handler = new UploadFileCommandHandler(s3.Object, db, user.Object, BucketResolver(), new Mock<MediatR.IMediator>().Object);
|
||||||
|
|
||||||
await handler.Handle(
|
await handler.Handle(
|
||||||
new UploadFileCommand("files", "obj-key", new MemoryStream([1, 2, 3]), "application/octet-stream", 3, Sha),
|
new UploadFileCommand("obj-key", new MemoryStream([1, 2, 3]), "application/octet-stream", 3, Sha),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// 仍只有一条记录
|
// 仍只有一条记录
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user