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 调用)
|
||||
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 调用)
|
||||
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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
namespace BuildFlow.Host.Endpoints.file.Files;
|
||||
|
||||
/// <summary>删除文件请求。以查询参数指定存储桶与对象键。</summary>
|
||||
/// <summary>删除文件请求。以查询参数指定对象键。</summary>
|
||||
public class DeleteFileRequest
|
||||
{
|
||||
[QueryParam] public string BucketName { 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)
|
||||
{
|
||||
var result = await mediator.Send(new DownloadFileQuery(req.BucketName, req.ObjectKey), ct);
|
||||
var result = await mediator.Send(new DownloadFileQuery(req.ObjectKey), ct);
|
||||
|
||||
// 设置响应头:真实 ContentType + 触发下载的文件名
|
||||
HttpContext.Response.StatusCode = 200;
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
namespace BuildFlow.Host.Endpoints.file.Files;
|
||||
|
||||
/// <summary>下载文件请求。以查询参数指定存储桶与对象键。</summary>
|
||||
/// <summary>下载文件请求。以查询参数指定对象键。</summary>
|
||||
public class DownloadFileRequest
|
||||
{
|
||||
[QueryParam] public string BucketName { 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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
namespace BuildFlow.Host.Endpoints.file.Files;
|
||||
|
||||
/// <summary>文件内容请求。以查询参数指定存储桶与对象键。</summary>
|
||||
/// <summary>文件内容请求。以查询参数指定对象键(bucket 由系统设置统一管理)。</summary>
|
||||
public class GetFileContentRequest
|
||||
{
|
||||
[QueryParam] public string BucketName { 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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
namespace BuildFlow.Host.Endpoints.file.Files;
|
||||
|
||||
/// <summary>文件预览请求。以查询参数指定存储桶与对象键。</summary>
|
||||
/// <summary>文件预览请求。以查询参数指定对象键。</summary>
|
||||
public class GetFilePreviewRequest
|
||||
{
|
||||
[QueryParam] public string BucketName { 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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
namespace BuildFlow.Host.Endpoints.file.Files;
|
||||
|
||||
/// <summary>Office/PDF 文档预览请求。以查询参数指定存储桶与对象键。</summary>
|
||||
/// <summary>Office/PDF 文档预览请求。以查询参数指定对象键。</summary>
|
||||
public class GetOfficePreviewRequest
|
||||
{
|
||||
[QueryParam] public string BucketName { 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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
namespace BuildFlow.Host.Endpoints.file.Files;
|
||||
|
||||
/// <summary>列举文件请求。包含存储桶、前缀、最大键数与续传令牌。</summary>
|
||||
/// <summary>列举文件请求。包含前缀、最大键数与续传令牌。</summary>
|
||||
public class ListFilesRequest
|
||||
{
|
||||
[QueryParam] public string BucketName { get; init; } = default!;
|
||||
[QueryParam] public string? Prefix { get; init; }
|
||||
[QueryParam] public int? MaxKeys { 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)
|
||||
{
|
||||
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);
|
||||
await Send.OkAsync(result, ct);
|
||||
}
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
namespace BuildFlow.Host.Endpoints.file.Files;
|
||||
|
||||
/// <summary>上传文件请求。包含 bucket_name、object_key 与文件数据。</summary>
|
||||
/// <summary>上传文件请求。包含 object_key 与文件数据。</summary>
|
||||
public class UploadFileRequest
|
||||
{
|
||||
public string BucketName { get; init; } = default!;
|
||||
public string ObjectKey { get; init; } = default!;
|
||||
public IFormFile File { get; init; } = default!;
|
||||
/// <summary>文件内容哈希(可选),秒传查重用。</summary>
|
||||
|
||||
@ -19,7 +19,7 @@ public class AbortMultipartEndpoint(IMediator mediator) : Endpoint<AbortMultipar
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
namespace BuildFlow.Host.Endpoints.file.Multipart;
|
||||
|
||||
/// <summary>中止分片上传请求(桶、对象 key、UploadId)。</summary>
|
||||
/// <summary>中止分片上传请求(对象 key、UploadId)。</summary>
|
||||
public class AbortMultipartRequest
|
||||
{
|
||||
public string BucketName { get; init; } = default!;
|
||||
public string ObjectKey { 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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@ namespace BuildFlow.Host.Endpoints.file.Multipart;
|
||||
/// <summary>完成分片上传请求(含各分片信息列表)。</summary>
|
||||
public class CompleteMultipartRequest
|
||||
{
|
||||
public string BucketName { get; init; } = default!;
|
||||
public string ObjectKey { get; init; } = default!;
|
||||
public string UploadId { get; init; } = default!;
|
||||
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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,8 +3,6 @@ namespace BuildFlow.Host.Endpoints.file.Multipart;
|
||||
/// <summary>初始化分片上传请求。ContentHash/TotalParts 可选,断点续传时由前端附带。</summary>
|
||||
public class InitMultipartRequest
|
||||
{
|
||||
/// <summary>S3 存储桶名。</summary>
|
||||
public string BucketName { get; init; } = default!;
|
||||
/// <summary>S3 对象 key(断点续传建议用内容哈希派生稳定值)。</summary>
|
||||
public string ObjectKey { get; init; } = default!;
|
||||
/// <summary>文件内容哈希(可选),仅落库记录用。</summary>
|
||||
|
||||
@ -22,7 +22,7 @@ public class UploadPartEndpoint(IMediator mediator) : Endpoint<UploadPartRequest
|
||||
public override async Task HandleAsync(UploadPartRequest req, CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
await Send.OkAsync(result, ct);
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@ namespace BuildFlow.Host.Endpoints.file.Multipart;
|
||||
/// <summary>上传单个分片请求(含 multipart 文件流)。</summary>
|
||||
public class UploadPartRequest
|
||||
{
|
||||
public string BucketName { get; init; } = default!;
|
||||
public string ObjectKey { get; init; } = default!;
|
||||
public string UploadId { get; init; } = default!;
|
||||
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
|
||||
try
|
||||
{
|
||||
var result = await mediator.Send(new GetFilePreviewQuery(AvatarBucket, avatarRef), ct);
|
||||
var result = await mediator.Send(new GetFilePreviewQuery(avatarRef), ct);
|
||||
return result.PresignedUrl;
|
||||
}
|
||||
catch
|
||||
|
||||
@ -25,8 +25,9 @@ public class InProcessFileStorageClient(IMediator mediator) : IFileStorageClient
|
||||
string? authToken, // 进程内调用无需鉴权,token 忽略
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// 通过 MediatR 命令调用 file 模块的 UploadFileCommandHandler(它内部走 IS3StorageService)
|
||||
await mediator.Send(new UploadFileCommand(bucket, objectKey, content, contentType, content.Length), ct);
|
||||
// 通过 MediatR 命令调用 file 模块的 UploadFileCommandHandler(它内部走 IS3StorageService)。
|
||||
// bucket 由 handler 内的 IBucketResolver 统一解析,此处不再透传。
|
||||
await mediator.Send(new UploadFileCommand(objectKey, content, contentType, content.Length), ct);
|
||||
}
|
||||
|
||||
public async Task<Stream> DownloadAsync(
|
||||
@ -36,8 +37,9 @@ public class InProcessFileStorageClient(IMediator mediator) : IFileStorageClient
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// 通过 MediatR 查询调用 file 模块的 DownloadFileQueryHandler。
|
||||
// bucket 由 handler 内的 IBucketResolver 统一解析,此处不再透传。
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,4 +4,4 @@ namespace File.Application.Features.Files.Commands;
|
||||
|
||||
|
||||
/// <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>
|
||||
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>;
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
using File.Application.Common;
|
||||
using File.Application.Events;
|
||||
using File.Application.Features.Files.Commands;
|
||||
using File.Infrastructure.Persistence;
|
||||
@ -11,17 +12,19 @@ namespace File.Application.Features.Files.Handlers;
|
||||
/// 两者必须同步删除:若只删 S3 不删库,文件夹视图(读 DB)仍会显示已失效的文件;
|
||||
/// 若只删库不删 S3,对象存储会残留孤儿对象。S3 删除失败时阻断(避免库删了对象还在)。
|
||||
/// </summary>
|
||||
public class DeleteFileCommandHandler(IS3StorageService s3, FileDbContext db, IMediator mediator)
|
||||
public class DeleteFileCommandHandler(IS3StorageService s3, FileDbContext db, IBucketResolver bucketResolver, IMediator mediator)
|
||||
: IRequestHandler<DeleteFileCommand, Unit>
|
||||
{
|
||||
public async Task<Unit> Handle(DeleteFileCommand req, CancellationToken ct)
|
||||
{
|
||||
var bucket = await bucketResolver.GetDefaultBucketAsync(ct);
|
||||
|
||||
// 1. 删 S3 对象(先删存储,成功后再清库记录,保证不会出现"库没了但对象还在"的孤儿)
|
||||
await s3.DeleteFileAsync(req.BucketName, req.ObjectKey, ct);
|
||||
await s3.DeleteFileAsync(bucket, req.ObjectKey, ct);
|
||||
|
||||
// 2. 删 FileMeta 记录(按 bucket + key 精确匹配,可能有 0~N 条)
|
||||
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);
|
||||
if (metas.Count > 0)
|
||||
{
|
||||
@ -29,7 +32,7 @@ public class DeleteFileCommandHandler(IS3StorageService s3, FileDbContext db, IM
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
using File.Application.Common;
|
||||
using File.Application.Features.Files.DTOs;
|
||||
using File.Application.Features.Files.Queries;
|
||||
using File.Infrastructure.Storage;
|
||||
@ -9,11 +10,12 @@ namespace File.Application.Features.Files.Handlers;
|
||||
/// 下载文件查询处理器。从 S3 拉取响应流,原样透传给 Endpoint 写入 HTTP 响应体,
|
||||
/// 不再拷贝到内存,大文件零内存压力。真实 ContentType 由 S3 HeadObject/GetObject 返回。
|
||||
/// </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)
|
||||
{
|
||||
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 可能含目录前缀)
|
||||
var fileName = req.ObjectKey.Contains('/')
|
||||
? 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.Queries;
|
||||
using File.Infrastructure.Storage;
|
||||
@ -5,14 +6,16 @@ using MediatR;
|
||||
|
||||
namespace File.Application.Features.Files.Handlers;
|
||||
|
||||
/// <summary>获取文件内容处理器。限定 10MB 上限,超限由 S3 侧截断。</summary>
|
||||
public class GetFileContentQueryHandler(IS3StorageService s3) : IRequestHandler<GetFileContentQuery, FileContentResult>
|
||||
/// <summary>获取文件内容处理器。限定 10MB 上限,超限由 S3 侧截断。bucket 从 IBucketResolver 统一获取。</summary>
|
||||
public class GetFileContentQueryHandler(IS3StorageService s3, IBucketResolver bucketResolver)
|
||||
: IRequestHandler<GetFileContentQuery, FileContentResult>
|
||||
{
|
||||
private const long MaxContentBytes = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
using File.Application.Common;
|
||||
using File.Application.Features.Files.DTOs;
|
||||
using File.Application.Features.Files.Queries;
|
||||
using File.Infrastructure.Storage;
|
||||
@ -6,13 +7,14 @@ using MediatR;
|
||||
namespace File.Application.Features.Files.Handlers;
|
||||
|
||||
/// <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);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
using File.Application.Common;
|
||||
using File.Application.Features.Files.DTOs;
|
||||
using File.Application.Features.Files.Queries;
|
||||
using File.Infrastructure.Storage;
|
||||
@ -10,7 +11,7 @@ namespace File.Application.Features.Files.Handlers;
|
||||
/// Office/PDF 文档预览查询处理器。生成 S3 预签名 URL(1 小时有效),
|
||||
/// 按 KKFileView 约定 Base64 编码后拼成 onlinePreview 地址返回。
|
||||
/// </summary>
|
||||
public class GetOfficePreviewQueryHandler(IS3StorageService s3, IOptions<KkFileViewOptions> kkOptions)
|
||||
public class GetOfficePreviewQueryHandler(IS3StorageService s3, IBucketResolver bucketResolver, IOptions<KkFileViewOptions> kkOptions)
|
||||
: IRequestHandler<GetOfficePreviewQuery, OfficePreviewResult>
|
||||
{
|
||||
/// <summary>Office 文档预签名有效期较短(1 小时),降低链接泄露风险。</summary>
|
||||
@ -18,7 +19,8 @@ public class GetOfficePreviewQueryHandler(IS3StorageService s3, IOptions<KkFileV
|
||||
|
||||
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 编码的文件直链
|
||||
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.Queries;
|
||||
using File.Infrastructure.Storage;
|
||||
@ -6,7 +7,7 @@ using MediatR;
|
||||
namespace File.Application.Features.Files.Handlers;
|
||||
|
||||
/// <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 MaxListKeys = 1000;
|
||||
@ -14,7 +15,8 @@ public class ListFilesQueryHandler(IS3StorageService s3) : IRequestHandler<ListF
|
||||
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 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
|
||||
.Select(f => new FileListItemDto(f.Key, f.Size, f.LastModified.ToString("O"), f.ETag))
|
||||
.ToList();
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
using BuildFlow.Shared.Domain.Common;
|
||||
using File.Application.Common;
|
||||
using File.Application.Events;
|
||||
using File.Application.Features.Files.Commands;
|
||||
using File.Application.Features.Files.DTOs;
|
||||
@ -19,6 +20,7 @@ public class UploadFileCommandHandler(
|
||||
IS3StorageService s3,
|
||||
FileDbContext db,
|
||||
ICurrentUserContext currentUser,
|
||||
IBucketResolver bucketResolver,
|
||||
IMediator mediator) : IRequestHandler<UploadFileCommand, UploadFileResponse>
|
||||
{
|
||||
private const long MaxFileSize = 500 * 1024 * 1024; // 500 MiB
|
||||
@ -32,10 +34,12 @@ public class UploadFileCommandHandler(
|
||||
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) 冲突时复用。
|
||||
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
|
||||
{
|
||||
@ -43,7 +47,7 @@ public class UploadFileCommandHandler(
|
||||
FolderId = null,
|
||||
Name = req.ObjectKey,
|
||||
S3Key = req.ObjectKey,
|
||||
S3Bucket = req.BucketName,
|
||||
S3Bucket = bucket,
|
||||
Size = req.Size,
|
||||
ContentType = string.IsNullOrEmpty(req.ContentType) ? "application/octet-stream" : req.ContentType,
|
||||
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);
|
||||
}
|
||||
|
||||
@ -5,4 +5,4 @@ namespace File.Application.Features.Files.Queries;
|
||||
|
||||
|
||||
/// <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;
|
||||
|
||||
|
||||
/// <summary>获取文件文本内容查询。</summary>
|
||||
public record GetFileContentQuery(string BucketName, string ObjectKey) : IRequest<FileContentResult>;
|
||||
/// <summary>获取文件文本内容查询。bucket 由 IBucketResolver 统一解析。</summary>
|
||||
public record GetFileContentQuery(string ObjectKey) : IRequest<FileContentResult>;
|
||||
|
||||
@ -5,4 +5,4 @@ namespace File.Application.Features.Files.Queries;
|
||||
|
||||
|
||||
/// <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 文档在线预览地址查询。
|
||||
/// 生成 S3 预签名 URL 后拼装为 KKFileView 预览链接,供前端 window.open 打开。
|
||||
/// </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>
|
||||
public record ListFilesQuery(string BucketName, string? Prefix, int? MaxKeys, string? ContinuationToken)
|
||||
public record ListFilesQuery(string? Prefix, int? MaxKeys, string? ContinuationToken)
|
||||
: IRequest<ListFilesResponse>;
|
||||
|
||||
@ -9,7 +9,6 @@ public class DeleteFileCommandValidator : AbstractValidator<DeleteFileCommand>
|
||||
{
|
||||
public DeleteFileCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称");
|
||||
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,6 @@ public class DownloadFileQueryValidator : AbstractValidator<DownloadFileQuery>
|
||||
{
|
||||
public DownloadFileQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称");
|
||||
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,6 @@ public class GetFileContentQueryValidator : AbstractValidator<GetFileContentQuer
|
||||
{
|
||||
public GetFileContentQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称");
|
||||
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,6 @@ public class GetFilePreviewQueryValidator : AbstractValidator<GetFilePreviewQuer
|
||||
{
|
||||
public GetFilePreviewQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称");
|
||||
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,5 @@ public class ListFilesQueryValidator : AbstractValidator<ListFilesQuery>
|
||||
{
|
||||
public ListFilesQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称");
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,6 @@ public class UploadFileCommandValidator : AbstractValidator<UploadFileCommand>
|
||||
{
|
||||
public UploadFileCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称");
|
||||
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,19 +7,18 @@ using File.Domain.Exceptions;
|
||||
using File.Infrastructure.Persistence;
|
||||
using File.Infrastructure.Storage;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace File.Application.Features.Folders.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 上传文件到文件夹处理器。校验 500MiB 上限,UUID 作 S3 key,落库失败补偿删 S3。
|
||||
/// bucket 从配置 FileService:Bucket 读取(与直传/预览/下载/删除统一,不再硬编码 "files")。
|
||||
/// bucket 统一从 IBucketResolver 获取(与直传/预览/下载/删除一致,不再从配置或前端参数读取)。
|
||||
/// </summary>
|
||||
public class UploadToFolderCommandHandler(
|
||||
FileDbContext db,
|
||||
IS3StorageService s3,
|
||||
ICurrentUserContext currentUser,
|
||||
IConfiguration config) : IRequestHandler<UploadToFolderCommand, FileMetaDto>
|
||||
IBucketResolver bucketResolver) : IRequestHandler<UploadToFolderCommand, FileMetaDto>
|
||||
{
|
||||
private const long MaxUploadBytes = 500 * 1024 * 1024; // 500 MiB
|
||||
|
||||
@ -29,8 +28,8 @@ public class UploadToFolderCommandHandler(
|
||||
if (req.Size > MaxUploadBytes)
|
||||
throw new TooLargeException("文件大小超过上限 500MB");
|
||||
|
||||
// bucket 与直传/预览/下载统一(前端默认 build-flow),不再硬编码 "files"
|
||||
var bucket = config["FileService:Bucket"] ?? "build-flow";
|
||||
// bucket 统一从 IBucketResolver 获取(默认 build-flow),与直传/预览/下载/删除一致
|
||||
var bucket = await bucketResolver.GetDefaultBucketAsync(ct);
|
||||
|
||||
var safeName = SanitizeRules.SanitizeFilename(req.FileName.Trim());
|
||||
var s3Key = Guid.NewGuid().ToString(); // 永远用 UUID,不用原文件名
|
||||
|
||||
@ -4,4 +4,4 @@ namespace File.Application.Features.Multipart.Commands;
|
||||
|
||||
|
||||
/// <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(老客户端兼容)。
|
||||
/// </summary>
|
||||
public record CompleteMultipartCommand(
|
||||
string BucketName,
|
||||
string ObjectKey,
|
||||
string UploadId,
|
||||
List<CompletedPartDto> Parts,
|
||||
|
||||
@ -9,7 +9,6 @@ namespace File.Application.Features.Multipart.Commands;
|
||||
/// 秒传判定用 (OwnerId, ContentHash, Size) 三元组(与 CheckFileQuery 对齐)。
|
||||
/// </summary>
|
||||
public record InitMultipartCommand(
|
||||
string BucketName,
|
||||
string ObjectKey,
|
||||
string? ContentHash = null,
|
||||
int? TotalParts = null,
|
||||
|
||||
@ -5,5 +5,5 @@ namespace File.Application.Features.Multipart.Commands;
|
||||
|
||||
|
||||
/// <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>;
|
||||
|
||||
@ -31,7 +31,7 @@ public class AbortMultipartCommandHandler(
|
||||
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;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
using BuildFlow.Shared.Domain.Common;
|
||||
using File.Application.Common;
|
||||
using File.Application.Features.Multipart.Commands;
|
||||
using File.Application.Features.Multipart.DTOs;
|
||||
using File.Domain.Entities;
|
||||
@ -19,11 +20,13 @@ namespace File.Application.Features.Multipart.Handlers;
|
||||
public class CompleteMultipartCommandHandler(
|
||||
IS3StorageService s3,
|
||||
FileDbContext db,
|
||||
ICurrentUserContext currentUser) : IRequestHandler<CompleteMultipartCommand, CompleteMultipartResult>
|
||||
ICurrentUserContext currentUser,
|
||||
IBucketResolver bucketResolver) : IRequestHandler<CompleteMultipartCommand, CompleteMultipartResult>
|
||||
{
|
||||
public async Task<CompleteMultipartResult> Handle(CompleteMultipartCommand req, CancellationToken ct)
|
||||
{
|
||||
var owner = currentUser.GetRequiredUserId();
|
||||
var bucket = await bucketResolver.GetDefaultBucketAsync(ct);
|
||||
|
||||
var session = await db.UploadSessions.FirstOrDefaultAsync(s => s.UploadId == req.UploadId, ct);
|
||||
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();
|
||||
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 保证原子
|
||||
session.Status = UploadSessionStatus.Completed;
|
||||
@ -71,6 +74,6 @@ public class CompleteMultipartCommandHandler(
|
||||
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 File.Application.Common;
|
||||
using File.Application.Features.Multipart.Commands;
|
||||
using File.Application.Features.Multipart.DTOs;
|
||||
using File.Domain.Entities;
|
||||
@ -18,11 +19,13 @@ namespace File.Application.Features.Multipart.Handlers;
|
||||
public class InitMultipartCommandHandler(
|
||||
IS3StorageService s3,
|
||||
FileDbContext db,
|
||||
ICurrentUserContext currentUser) : IRequestHandler<InitMultipartCommand, InitMultipartResult>
|
||||
ICurrentUserContext currentUser,
|
||||
IBucketResolver bucketResolver) : IRequestHandler<InitMultipartCommand, InitMultipartResult>
|
||||
{
|
||||
public async Task<InitMultipartResult> Handle(InitMultipartCommand req, CancellationToken ct)
|
||||
{
|
||||
var owner = currentUser.GetRequiredUserId();
|
||||
var bucket = await bucketResolver.GetDefaultBucketAsync(ct);
|
||||
|
||||
// 1. 秒传命中:同用户已有相同内容哈希文件,零字节复用。
|
||||
// 判定三元组 (OwnerId, ContentHash, Size) 与 CheckFileQuery 对齐——Size 防哈希碰撞误判。
|
||||
@ -40,7 +43,7 @@ public class InitMultipartCommandHandler(
|
||||
// 2. 幂等复用:同一用户同一对象若已有 Active 会话,直接返回其 uploadId
|
||||
var existing = await db.UploadSessions
|
||||
.FirstOrDefaultAsync(s => s.OwnerId == owner
|
||||
&& s.BucketName == req.BucketName
|
||||
&& s.BucketName == bucket
|
||||
&& s.ObjectKey == req.ObjectKey
|
||||
&& s.Status == UploadSessionStatus.Active, ct);
|
||||
if (existing is not null)
|
||||
@ -49,11 +52,11 @@ public class InitMultipartCommandHandler(
|
||||
}
|
||||
|
||||
// 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
|
||||
{
|
||||
BucketName = req.BucketName,
|
||||
BucketName = bucket,
|
||||
ObjectKey = req.ObjectKey,
|
||||
UploadId = uploadId,
|
||||
ContentHash = req.ContentHash,
|
||||
|
||||
@ -38,7 +38,7 @@ public class UploadPartCommandHandler(
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,6 @@ public class AbortMultipartCommandValidator : AbstractValidator<AbortMultipartCo
|
||||
{
|
||||
public AbortMultipartCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称");
|
||||
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
||||
RuleFor(x => x.UploadId).NotEmpty().WithMessage("upload_id 不能为空");
|
||||
}
|
||||
|
||||
@ -9,7 +9,6 @@ public class CompleteMultipartCommandValidator : AbstractValidator<CompleteMulti
|
||||
{
|
||||
public CompleteMultipartCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称");
|
||||
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
||||
RuleFor(x => x.UploadId).NotEmpty().WithMessage("upload_id 不能为空");
|
||||
RuleFor(x => x.Parts).NotEmpty().WithMessage("parts 不能为空");
|
||||
|
||||
@ -9,7 +9,6 @@ public class InitMultipartCommandValidator : AbstractValidator<InitMultipartComm
|
||||
{
|
||||
public InitMultipartCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称");
|
||||
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,6 @@ public class UploadPartCommandValidator : AbstractValidator<UploadPartCommand>
|
||||
{
|
||||
public UploadPartCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称");
|
||||
RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key");
|
||||
RuleFor(x => x.UploadId).NotEmpty().WithMessage("upload_id 不能为空");
|
||||
RuleFor(x => x.PartNumber).GreaterThan(0).WithMessage("part_number 必须大于 0");
|
||||
|
||||
@ -142,6 +142,29 @@ public static class SeedData
|
||||
|
||||
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.SaveChangesAsync();
|
||||
}
|
||||
@ -182,7 +205,7 @@ public static class SeedData
|
||||
// id 列表来自上面的数据库查询(Guid 主键),非用户输入,用 string.Concat / string.Join 拼成 SQL 字面量无注入风险;
|
||||
// permissions 表主键列名为带引号的 "Id"(EF 配置的 PascalCase 列名)。
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
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 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 InstanceStatus Status { get; set; } = InstanceStatus.Running;
|
||||
/// <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.Infrastructure;
|
||||
using File.Application.Common;
|
||||
using File.Application.Features.Files.Commands;
|
||||
using File.Application.Features.Files.Handlers;
|
||||
using File.Domain.Entities;
|
||||
@ -21,6 +22,14 @@ public class DeleteFileCommandHandlerTests
|
||||
private const string Bucket = "build-flow";
|
||||
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>
|
||||
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));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var handler = new DeleteFileCommandHandler(s3.Object, db, mediator.Object);
|
||||
var handler = new DeleteFileCommandHandler(s3.Object, db, BucketResolver(), mediator.Object);
|
||||
|
||||
// Act
|
||||
await handler.Handle(new DeleteFileCommand(Bucket, Key), CancellationToken.None);
|
||||
await handler.Handle(new DeleteFileCommand(Key), CancellationToken.None);
|
||||
|
||||
// Assert:S3 对象已删
|
||||
s3.Verify(x => x.DeleteFileAsync(Bucket, Key, It.IsAny<CancellationToken>()), Times.Once);
|
||||
@ -74,10 +83,10 @@ public class DeleteFileCommandHandlerTests
|
||||
{
|
||||
// Arrange:DB 无匹配记录(如根目录直传未落库、或已被其他途径清理)
|
||||
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
|
||||
await handler.Handle(new DeleteFileCommand(Bucket, Key), CancellationToken.None);
|
||||
await handler.Handle(new DeleteFileCommand(Key), CancellationToken.None);
|
||||
|
||||
// Assert:S3 仍尝试删除(幂等,删不存在的对象也返回成功)
|
||||
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"));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var handler = new DeleteFileCommandHandler(s3.Object, db, mediator.Object);
|
||||
var handler = new DeleteFileCommandHandler(s3.Object, db, BucketResolver(), mediator.Object);
|
||||
|
||||
// Act
|
||||
await handler.Handle(new DeleteFileCommand(Bucket, Key), CancellationToken.None);
|
||||
await handler.Handle(new DeleteFileCommand(Key), CancellationToken.None);
|
||||
|
||||
// Assert:两条记录都被清理
|
||||
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"));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var handler = new DeleteFileCommandHandler(s3.Object, db, mediator.Object);
|
||||
var handler = new DeleteFileCommandHandler(s3.Object, db, BucketResolver(), mediator.Object);
|
||||
|
||||
// Act:只删 aaa
|
||||
await handler.Handle(new DeleteFileCommand(Bucket, "multipart/aaa"), CancellationToken.None);
|
||||
await handler.Handle(new DeleteFileCommand("multipart/aaa"), CancellationToken.None);
|
||||
|
||||
// Assert:aaa 没了,bbb 还在
|
||||
var remaining = await db.Files.ToListAsync();
|
||||
@ -132,10 +141,10 @@ public class DeleteFileCommandHandlerTests
|
||||
s3.Setup(x => x.DeleteFileAsync(Bucket, Key, It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Exception("S3 连接失败"));
|
||||
|
||||
var handler = new DeleteFileCommandHandler(s3.Object, db, mediator.Object);
|
||||
var handler = new DeleteFileCommandHandler(s3.Object, db, BucketResolver(), mediator.Object);
|
||||
|
||||
// Act
|
||||
var act = () => handler.Handle(new DeleteFileCommand(Bucket, Key), CancellationToken.None);
|
||||
var act = () => handler.Handle(new DeleteFileCommand(Key), CancellationToken.None);
|
||||
|
||||
// Assert:异常向上抛
|
||||
await act.Should().ThrowAsync<Exception>().WithMessage("S3 连接失败");
|
||||
@ -149,10 +158,10 @@ public class DeleteFileCommandHandlerTests
|
||||
{
|
||||
// Arrange
|
||||
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
|
||||
await handler.Handle(new DeleteFileCommand(Bucket, Key), CancellationToken.None);
|
||||
await handler.Handle(new DeleteFileCommand(Key), CancellationToken.None);
|
||||
|
||||
// Assert:事件已发布(供其他模块响应,如清理缓存)
|
||||
mediator.Verify(
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
using System.Text;
|
||||
using File.Application.Common;
|
||||
using File.Application.Features.Files.Handlers;
|
||||
using File.Application.Features.Files.Queries;
|
||||
using File.Infrastructure.Storage;
|
||||
@ -14,6 +15,14 @@ namespace BuildFlow.File.Tests;
|
||||
/// </summary>
|
||||
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>
|
||||
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>()))
|
||||
.ReturnsAsync((stream, "text/plain"));
|
||||
|
||||
var handler = new DownloadFileQueryHandler(s3Mock.Object);
|
||||
var handler = new DownloadFileQueryHandler(s3Mock.Object, BucketResolver());
|
||||
return (s3Mock, handler);
|
||||
}
|
||||
|
||||
@ -35,7 +44,7 @@ public class DownloadFileQueryHandlerTests
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(
|
||||
new DownloadFileQuery("test-bucket", "test-key"),
|
||||
new DownloadFileQuery("test-key"),
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert — 返回的是 S3 原始流,透传给调用方;ContentType 取 S3 返回的真实类型
|
||||
@ -56,11 +65,11 @@ public class DownloadFileQueryHandlerTests
|
||||
.Setup(x => x.DownloadFileAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((emptyStream, "application/octet-stream"));
|
||||
|
||||
var handler = new DownloadFileQueryHandler(s3Mock.Object);
|
||||
var handler = new DownloadFileQueryHandler(s3Mock.Object, BucketResolver());
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(
|
||||
new DownloadFileQuery("test-bucket", "test-key"),
|
||||
new DownloadFileQuery("test-key"),
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert — Handler 透传空流,长度为 0
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
using BuildFlow.Shared.Domain.Common;
|
||||
using BuildFlow.Shared.Infrastructure;
|
||||
using File.Application.Common;
|
||||
using File.Application.Features.Multipart.Commands;
|
||||
using File.Application.Features.Multipart.DTOs;
|
||||
using File.Application.Features.Multipart.Handlers;
|
||||
@ -23,6 +24,15 @@ public class InstantUploadTests
|
||||
{
|
||||
private const string OwnerId = "user-001";
|
||||
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)
|
||||
{
|
||||
@ -49,9 +59,9 @@ public class InstantUploadTests
|
||||
});
|
||||
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.UploadId.Should().BeEmpty("秒传不需要 uploadId");
|
||||
@ -73,9 +83,9 @@ public class InstantUploadTests
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
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.UploadId.Should().Be("uid-1");
|
||||
@ -93,9 +103,9 @@ public class InstantUploadTests
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
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("他人文件不参与当前用户秒传");
|
||||
}
|
||||
@ -117,9 +127,9 @@ public class InstantUploadTests
|
||||
{
|
||||
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");
|
||||
// FileMeta 应被创建
|
||||
@ -135,8 +145,8 @@ public class InstantUploadTests
|
||||
session.Status.Should().Be(UploadSessionStatus.Completed);
|
||||
|
||||
// 串联验证:再次 init 同文件现在能命中秒传
|
||||
var initHandler = new InitMultipartCommandHandler(s3.Object, db, user.Object);
|
||||
var initResult = await initHandler.Handle(new InitMultipartCommand("files", "obj2", Sha, TotalParts: 2, Size: 1024), CancellationToken.None);
|
||||
var initHandler = new InitMultipartCommandHandler(s3.Object, db, user.Object, BucketResolver());
|
||||
var initResult = await initHandler.Handle(new InitMultipartCommand("obj2", Sha, TotalParts: 2, Size: 1024), CancellationToken.None);
|
||||
initResult.Instant.Should().BeTrue("FileMeta 已落库,再次分片上传同文件应秒传");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
using BuildFlow.Shared.Domain.Common;
|
||||
using BuildFlow.Shared.Domain.Exceptions;
|
||||
using BuildFlow.Shared.Infrastructure;
|
||||
using File.Application.Common;
|
||||
using File.Application.Features.Multipart.Commands;
|
||||
using File.Application.Features.Multipart.DTOs;
|
||||
using File.Application.Features.Multipart.Handlers;
|
||||
@ -23,6 +24,15 @@ namespace BuildFlow.File.Tests;
|
||||
public class MultipartUploadSessionTests
|
||||
{
|
||||
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>
|
||||
/// 构造一组注入了 InMemory DbContext 的 handler 依赖。
|
||||
@ -63,9 +73,9 @@ public class MultipartUploadSessionTests
|
||||
{
|
||||
var (db, s3, user) = CreateDeps(nameof(Init_首次_发起S3Init并落库会话));
|
||||
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.Resume.Should().BeFalse();
|
||||
@ -85,9 +95,9 @@ public class MultipartUploadSessionTests
|
||||
|
||||
s3.Setup(x => x.CreateMultipartUploadAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.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.Resume.Should().BeTrue();
|
||||
@ -102,7 +112,7 @@ public class MultipartUploadSessionTests
|
||||
var (db, s3, user) = CreateDeps(nameof(UploadPart_会话不存在_抛NotFound));
|
||||
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>();
|
||||
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();
|
||||
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,不泄露存在性
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
@ -130,7 +140,7 @@ public class MultipartUploadSessionTests
|
||||
await db.SaveChangesAsync();
|
||||
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>();
|
||||
}
|
||||
@ -141,10 +151,10 @@ public class MultipartUploadSessionTests
|
||||
var (db, s3, user) = CreateDeps(nameof(Complete_成功后会话标记Completed));
|
||||
db.UploadSessions.Add(NewSession("bk", "key", "upload-1", OwnerId));
|
||||
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 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");
|
||||
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();
|
||||
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);
|
||||
var session = await db.UploadSessions.SingleAsync();
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
using BuildFlow.Shared.Domain.Common;
|
||||
using BuildFlow.Shared.Infrastructure;
|
||||
using File.Application.Common;
|
||||
using File.Application.Features.Files.Commands;
|
||||
using File.Application.Features.Files.Handlers;
|
||||
using File.Domain.Entities;
|
||||
@ -21,6 +22,14 @@ public class UploadFileCommandHandlerTests
|
||||
private const string OwnerId = "user-001";
|
||||
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)
|
||||
{
|
||||
var user = new Mock<ICurrentUserContext>();
|
||||
@ -40,10 +49,10 @@ public class UploadFileCommandHandlerTests
|
||||
// 缺陷C核心:直传必须落 FileMeta,否则直传的文件永远无法被秒传
|
||||
var (db, s3, user) = CreateDeps(nameof(Upload_成功后落FileMeta_含ContentHash_使直传参与秒传));
|
||||
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(
|
||||
new UploadFileCommand("files", "obj-key", data, "application/octet-stream", 3, Sha),
|
||||
new UploadFileCommand("obj-key", data, "application/octet-stream", 3, Sha),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ObjectKey.Should().Be("obj-key");
|
||||
@ -63,10 +72,10 @@ public class UploadFileCommandHandlerTests
|
||||
public async Task 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(
|
||||
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);
|
||||
|
||||
await act.Should().ThrowAsync<TooLargeException>();
|
||||
@ -84,10 +93,10 @@ public class UploadFileCommandHandlerTests
|
||||
Size = 3, ContentType = "application/octet-stream", ContentHash = Sha, OwnerId = OwnerId,
|
||||
});
|
||||
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(
|
||||
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);
|
||||
|
||||
// 仍只有一条记录
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user