diff --git a/src/BuildFlow.Host/BuildFlowHostModule.cs b/src/BuildFlow.Host/BuildFlowHostModule.cs index d8f1153..085c4cc 100644 --- a/src/BuildFlow.Host/BuildFlowHostModule.cs +++ b/src/BuildFlow.Host/BuildFlowHostModule.cs @@ -83,6 +83,9 @@ public class BuildFlowHostModule : AbpModule // 串联:IM @AI → RAG 问答(跨模块集成,RAG 实现 IAiBotHandler 供 IM 调用) services.AddScoped(); + // 串联:IM 离线消息 → 平台通知中心站内信(跨模块集成,Host 实现 IImNotificationPublisher 供 IM 调用, + // 内部转发为 PublishNotificationCommand 复用 platform 的 fanout 落库 + SignalR 推送) + services.AddScoped(); // 串联:AI 对话 → 触发工作流(跨模块集成,Workflow 实现 IWorkflowTriggerService 供 RAG 调用) services.AddScoped(); diff --git a/src/BuildFlow.Host/Endpoints/file/Files/DeleteFileEndpoint.cs b/src/BuildFlow.Host/Endpoints/file/Files/DeleteFileEndpoint.cs index 37c8c7b..8f3b42f 100644 --- a/src/BuildFlow.Host/Endpoints/file/Files/DeleteFileEndpoint.cs +++ b/src/BuildFlow.Host/Endpoints/file/Files/DeleteFileEndpoint.cs @@ -19,7 +19,7 @@ public class DeleteFileEndpoint(IMediator mediator) : Endpoint删除文件请求。以查询参数指定存储桶与对象键。 +/// 删除文件请求。以查询参数指定对象键。 public class DeleteFileRequest { - [QueryParam] public string BucketName { get; init; } = default!; [QueryParam] public string ObjectKey { get; init; } = default!; } diff --git a/src/BuildFlow.Host/Endpoints/file/Files/DownloadFileEndpoint.cs b/src/BuildFlow.Host/Endpoints/file/Files/DownloadFileEndpoint.cs index 7d77b02..57ddf50 100644 --- a/src/BuildFlow.Host/Endpoints/file/Files/DownloadFileEndpoint.cs +++ b/src/BuildFlow.Host/Endpoints/file/Files/DownloadFileEndpoint.cs @@ -19,7 +19,7 @@ public class DownloadFileEndpoint(IMediator mediator) : Endpoint下载文件请求。以查询参数指定存储桶与对象键。 +/// 下载文件请求。以查询参数指定对象键。 public class DownloadFileRequest { - [QueryParam] public string BucketName { get; init; } = default!; [QueryParam] public string ObjectKey { get; init; } = default!; } diff --git a/src/BuildFlow.Host/Endpoints/file/Files/GetFileContentEndpoint.cs b/src/BuildFlow.Host/Endpoints/file/Files/GetFileContentEndpoint.cs index 6399159..a4dd1eb 100644 --- a/src/BuildFlow.Host/Endpoints/file/Files/GetFileContentEndpoint.cs +++ b/src/BuildFlow.Host/Endpoints/file/Files/GetFileContentEndpoint.cs @@ -20,7 +20,7 @@ public class GetFileContentEndpoint(IMediator mediator) : Endpoint文件内容请求。以查询参数指定存储桶与对象键。 +/// 文件内容请求。以查询参数指定对象键(bucket 由系统设置统一管理)。 public class GetFileContentRequest { - [QueryParam] public string BucketName { get; init; } = default!; [QueryParam] public string ObjectKey { get; init; } = default!; } diff --git a/src/BuildFlow.Host/Endpoints/file/Files/GetFilePreviewEndpoint.cs b/src/BuildFlow.Host/Endpoints/file/Files/GetFilePreviewEndpoint.cs index 8a915c1..1d5f1d3 100644 --- a/src/BuildFlow.Host/Endpoints/file/Files/GetFilePreviewEndpoint.cs +++ b/src/BuildFlow.Host/Endpoints/file/Files/GetFilePreviewEndpoint.cs @@ -20,7 +20,7 @@ public class GetFilePreviewEndpoint(IMediator mediator) : Endpoint文件预览请求。以查询参数指定存储桶与对象键。 +/// 文件预览请求。以查询参数指定对象键。 public class GetFilePreviewRequest { - [QueryParam] public string BucketName { get; init; } = default!; [QueryParam] public string ObjectKey { get; init; } = default!; } diff --git a/src/BuildFlow.Host/Endpoints/file/Files/GetOfficePreviewEndpoint.cs b/src/BuildFlow.Host/Endpoints/file/Files/GetOfficePreviewEndpoint.cs index 02f16f0..d1edae6 100644 --- a/src/BuildFlow.Host/Endpoints/file/Files/GetOfficePreviewEndpoint.cs +++ b/src/BuildFlow.Host/Endpoints/file/Files/GetOfficePreviewEndpoint.cs @@ -22,7 +22,7 @@ public class GetOfficePreviewEndpoint(IMediator mediator) : EndpointOffice/PDF 文档预览请求。以查询参数指定存储桶与对象键。 +/// Office/PDF 文档预览请求。以查询参数指定对象键。 public class GetOfficePreviewRequest { - [QueryParam] public string BucketName { get; init; } = default!; [QueryParam] public string ObjectKey { get; init; } = default!; } diff --git a/src/BuildFlow.Host/Endpoints/file/Files/ListFilesEndpoint.cs b/src/BuildFlow.Host/Endpoints/file/Files/ListFilesEndpoint.cs index cc165e3..32b2cab 100644 --- a/src/BuildFlow.Host/Endpoints/file/Files/ListFilesEndpoint.cs +++ b/src/BuildFlow.Host/Endpoints/file/Files/ListFilesEndpoint.cs @@ -20,7 +20,7 @@ public class ListFilesEndpoint(IMediator mediator) : Endpoint列举文件请求。包含存储桶、前缀、最大键数与续传令牌。 +/// 列举文件请求。包含前缀、最大键数与续传令牌。 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; } diff --git a/src/BuildFlow.Host/Endpoints/file/Files/UploadFileEndpoint.cs b/src/BuildFlow.Host/Endpoints/file/Files/UploadFileEndpoint.cs index 66bb06e..6aca954 100644 --- a/src/BuildFlow.Host/Endpoints/file/Files/UploadFileEndpoint.cs +++ b/src/BuildFlow.Host/Endpoints/file/Files/UploadFileEndpoint.cs @@ -22,7 +22,7 @@ public class UploadFileEndpoint(IMediator mediator) : Endpoint上传文件请求。包含 bucket_name、object_key 与文件数据。 +/// 上传文件请求。包含 object_key 与文件数据。 public class UploadFileRequest { - public string BucketName { get; init; } = default!; public string ObjectKey { get; init; } = default!; public IFormFile File { get; init; } = default!; /// 文件内容哈希(可选),秒传查重用。 diff --git a/src/BuildFlow.Host/Endpoints/file/Multipart/AbortMultipartEndpoint.cs b/src/BuildFlow.Host/Endpoints/file/Multipart/AbortMultipartEndpoint.cs index af0a391..1b2dbe4 100644 --- a/src/BuildFlow.Host/Endpoints/file/Multipart/AbortMultipartEndpoint.cs +++ b/src/BuildFlow.Host/Endpoints/file/Multipart/AbortMultipartEndpoint.cs @@ -19,7 +19,7 @@ public class AbortMultipartEndpoint(IMediator mediator) : Endpoint中止分片上传请求(桶、对象 key、UploadId)。 +/// 中止分片上传请求(对象 key、UploadId)。 public class AbortMultipartRequest { - public string BucketName { get; init; } = default!; public string ObjectKey { get; init; } = default!; public string UploadId { get; init; } = default!; } diff --git a/src/BuildFlow.Host/Endpoints/file/Multipart/CompleteMultipartEndpoint.cs b/src/BuildFlow.Host/Endpoints/file/Multipart/CompleteMultipartEndpoint.cs index 751e605..031e9fe 100644 --- a/src/BuildFlow.Host/Endpoints/file/Multipart/CompleteMultipartEndpoint.cs +++ b/src/BuildFlow.Host/Endpoints/file/Multipart/CompleteMultipartEndpoint.cs @@ -21,7 +21,7 @@ public class CompleteMultipartEndpoint(IMediator mediator) : Endpoint 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); } } diff --git a/src/BuildFlow.Host/Endpoints/file/Multipart/CompleteMultipartRequest.cs b/src/BuildFlow.Host/Endpoints/file/Multipart/CompleteMultipartRequest.cs index 596b89a..3f42e90 100644 --- a/src/BuildFlow.Host/Endpoints/file/Multipart/CompleteMultipartRequest.cs +++ b/src/BuildFlow.Host/Endpoints/file/Multipart/CompleteMultipartRequest.cs @@ -3,7 +3,6 @@ namespace BuildFlow.Host.Endpoints.file.Multipart; /// 完成分片上传请求(含各分片信息列表)。 public class CompleteMultipartRequest { - public string BucketName { get; init; } = default!; public string ObjectKey { get; init; } = default!; public string UploadId { get; init; } = default!; public List Parts { get; init; } = new(); diff --git a/src/BuildFlow.Host/Endpoints/file/Multipart/InitMultipartEndpoint.cs b/src/BuildFlow.Host/Endpoints/file/Multipart/InitMultipartEndpoint.cs index 6e8a9b1..4b97e90 100644 --- a/src/BuildFlow.Host/Endpoints/file/Multipart/InitMultipartEndpoint.cs +++ b/src/BuildFlow.Host/Endpoints/file/Multipart/InitMultipartEndpoint.cs @@ -20,7 +20,7 @@ public class InitMultipartEndpoint(IMediator mediator) : Endpoint初始化分片上传请求。ContentHash/TotalParts 可选,断点续传时由前端附带。 public class InitMultipartRequest { - /// S3 存储桶名。 - public string BucketName { get; init; } = default!; /// S3 对象 key(断点续传建议用内容哈希派生稳定值)。 public string ObjectKey { get; init; } = default!; /// 文件内容哈希(可选),仅落库记录用。 diff --git a/src/BuildFlow.Host/Endpoints/file/Multipart/UploadPartEndpoint.cs b/src/BuildFlow.Host/Endpoints/file/Multipart/UploadPartEndpoint.cs index cfd8a28..80513d3 100644 --- a/src/BuildFlow.Host/Endpoints/file/Multipart/UploadPartEndpoint.cs +++ b/src/BuildFlow.Host/Endpoints/file/Multipart/UploadPartEndpoint.cs @@ -22,7 +22,7 @@ public class UploadPartEndpoint(IMediator mediator) : Endpoint上传单个分片请求(含 multipart 文件流)。 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; } diff --git a/src/BuildFlow.Host/Services/Integration/ImNotificationPublisher.cs b/src/BuildFlow.Host/Services/Integration/ImNotificationPublisher.cs new file mode 100644 index 0000000..6f764c1 --- /dev/null +++ b/src/BuildFlow.Host/Services/Integration/ImNotificationPublisher.cs @@ -0,0 +1,56 @@ +using IM.Domain.Common; +using MediatR; +using BuildFlow.Platform.Application.Notifications.Commands; + +namespace BuildFlow.Host.Services.Integration; + +/// +/// IM 站内信通知发布器。把 IM 离线消息作为站内信投递到平台通知中心。 +/// +/// +/// 跨模块胶水代码:IM 模块的 依赖 +/// (定义在 IM.Domain),但 IM 模块无法直接访问 +/// platform 模块的 Notification 聚合。本实现位于 Host 层,通过 MediatR 发送 +/// ,复用 platform 已有的 fanout 落库 + SignalR 推送链路, +/// 避免在 IM 侧重复实现通知持久化与分发。 +/// 容错:通知发布失败不阻断 IM 主链路—— 的调用方 +/// 已在注释中声明"通知失败不阻断主链路",但本实现仍捕获异常并吞掉,确保即使 platform +/// 通知链路异常也不会导致发消息接口报错。 +/// +public class ImNotificationPublisher(IMediator mediator) : IImNotificationPublisher +{ + /// + /// 批量发布站内信通知。内部转发为 , + /// 由 platform 的 PublishNotificationCommandHandler 完成 fanout 落库与 SignalR 推送。 + /// + /// 实际发布的通知条数(platform 侧 fanout 落库数)。 + public async Task PublishAsync( + string type, + string title, + string content, + Guid conversationId, + IEnumerable recipientUserIds, + CancellationToken ct = default) + { + var recipients = recipientUserIds as IReadOnlyCollection ?? 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; + } + } +} diff --git a/src/BuildFlow.Host/Services/Integration/InProcessAvatarUrlResolver.cs b/src/BuildFlow.Host/Services/Integration/InProcessAvatarUrlResolver.cs index 0056cb8..01a6245 100644 --- a/src/BuildFlow.Host/Services/Integration/InProcessAvatarUrlResolver.cs +++ b/src/BuildFlow.Host/Services/Integration/InProcessAvatarUrlResolver.cs @@ -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 diff --git a/src/BuildFlow.Host/Services/Integration/InProcessFileStorageClient.cs b/src/BuildFlow.Host/Services/Integration/InProcessFileStorageClient.cs index d277cdc..74a9636 100644 --- a/src/BuildFlow.Host/Services/Integration/InProcessFileStorageClient.cs +++ b/src/BuildFlow.Host/Services/Integration/InProcessFileStorageClient.cs @@ -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 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; } } diff --git a/src/modules/file/Application/Features/Files/Commands/DeleteFileCommand.cs b/src/modules/file/Application/Features/Files/Commands/DeleteFileCommand.cs index c8c3716..e892789 100644 --- a/src/modules/file/Application/Features/Files/Commands/DeleteFileCommand.cs +++ b/src/modules/file/Application/Features/Files/Commands/DeleteFileCommand.cs @@ -4,4 +4,4 @@ namespace File.Application.Features.Files.Commands; /// 删除文件命令。删 S3 对象并发布 FileDeletedEvent。 -public record DeleteFileCommand(string BucketName, string ObjectKey) : IRequest; +public record DeleteFileCommand(string ObjectKey) : IRequest; diff --git a/src/modules/file/Application/Features/Files/Commands/UploadFileCommand.cs b/src/modules/file/Application/Features/Files/Commands/UploadFileCommand.cs index 1229bf5..b10ae31 100644 --- a/src/modules/file/Application/Features/Files/Commands/UploadFileCommand.cs +++ b/src/modules/file/Application/Features/Files/Commands/UploadFileCommand.cs @@ -5,5 +5,5 @@ namespace File.Application.Features.Files.Commands; /// 上传文件命令。直传 S3 对象并发布 FileUploadedEvent。ContentHash 可选,由前端预检后附带,用于秒传查重。 -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; diff --git a/src/modules/file/Application/Features/Files/Handlers/DeleteFileCommandHandler.cs b/src/modules/file/Application/Features/Files/Handlers/DeleteFileCommandHandler.cs index b6001f1..277e119 100644 --- a/src/modules/file/Application/Features/Files/Handlers/DeleteFileCommandHandler.cs +++ b/src/modules/file/Application/Features/Files/Handlers/DeleteFileCommandHandler.cs @@ -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 删除失败时阻断(避免库删了对象还在)。 /// -public class DeleteFileCommandHandler(IS3StorageService s3, FileDbContext db, IMediator mediator) +public class DeleteFileCommandHandler(IS3StorageService s3, FileDbContext db, IBucketResolver bucketResolver, IMediator mediator) : IRequestHandler { public async Task 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; } } diff --git a/src/modules/file/Application/Features/Files/Handlers/DownloadFileQueryHandler.cs b/src/modules/file/Application/Features/Files/Handlers/DownloadFileQueryHandler.cs index e6ab63b..dd8273d 100644 --- a/src/modules/file/Application/Features/Files/Handlers/DownloadFileQueryHandler.cs +++ b/src/modules/file/Application/Features/Files/Handlers/DownloadFileQueryHandler.cs @@ -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 返回。 /// -public class DownloadFileQueryHandler(IS3StorageService s3) : IRequestHandler +public class DownloadFileQueryHandler(IS3StorageService s3, IBucketResolver bucketResolver) : IRequestHandler { public async Task 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)..] diff --git a/src/modules/file/Application/Features/Files/Handlers/GetFileContentQueryHandler.cs b/src/modules/file/Application/Features/Files/Handlers/GetFileContentQueryHandler.cs index c4a1265..94fada4 100644 --- a/src/modules/file/Application/Features/Files/Handlers/GetFileContentQueryHandler.cs +++ b/src/modules/file/Application/Features/Files/Handlers/GetFileContentQueryHandler.cs @@ -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; -/// 获取文件内容处理器。限定 10MB 上限,超限由 S3 侧截断。 -public class GetFileContentQueryHandler(IS3StorageService s3) : IRequestHandler +/// 获取文件内容处理器。限定 10MB 上限,超限由 S3 侧截断。bucket 从 IBucketResolver 统一获取。 +public class GetFileContentQueryHandler(IS3StorageService s3, IBucketResolver bucketResolver) + : IRequestHandler { private const long MaxContentBytes = 10 * 1024 * 1024; // 10 MB public async Task 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); } } diff --git a/src/modules/file/Application/Features/Files/Handlers/GetFilePreviewQueryHandler.cs b/src/modules/file/Application/Features/Files/Handlers/GetFilePreviewQueryHandler.cs index 5caaaf1..4488f08 100644 --- a/src/modules/file/Application/Features/Files/Handlers/GetFilePreviewQueryHandler.cs +++ b/src/modules/file/Application/Features/Files/Handlers/GetFilePreviewQueryHandler.cs @@ -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; /// 文件预览查询处理器。生成 24 小时有效的预签名 URL。 -public class GetFilePreviewQueryHandler(IS3StorageService s3) : IRequestHandler +public class GetFilePreviewQueryHandler(IS3StorageService s3, IBucketResolver bucketResolver) : IRequestHandler { private static readonly TimeSpan PreviewExpiry = TimeSpan.FromHours(24); public async Task 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); } } diff --git a/src/modules/file/Application/Features/Files/Handlers/GetOfficePreviewQueryHandler.cs b/src/modules/file/Application/Features/Files/Handlers/GetOfficePreviewQueryHandler.cs index 353f804..c3f2c8b 100644 --- a/src/modules/file/Application/Features/Files/Handlers/GetOfficePreviewQueryHandler.cs +++ b/src/modules/file/Application/Features/Files/Handlers/GetOfficePreviewQueryHandler.cs @@ -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 地址返回。 /// -public class GetOfficePreviewQueryHandler(IS3StorageService s3, IOptions kkOptions) +public class GetOfficePreviewQueryHandler(IS3StorageService s3, IBucketResolver bucketResolver, IOptions kkOptions) : IRequestHandler { /// Office 文档预签名有效期较短(1 小时),降低链接泄露风险。 @@ -18,7 +19,8 @@ public class GetOfficePreviewQueryHandler(IS3StorageService s3, IOptions 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)); diff --git a/src/modules/file/Application/Features/Files/Handlers/ListFilesQueryHandler.cs b/src/modules/file/Application/Features/Files/Handlers/ListFilesQueryHandler.cs index b8ff3df..02e3721 100644 --- a/src/modules/file/Application/Features/Files/Handlers/ListFilesQueryHandler.cs +++ b/src/modules/file/Application/Features/Files/Handlers/ListFilesQueryHandler.cs @@ -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; /// 列文件查询处理器。默认 100 条,上限 1000 条分页代理 S3。 -public class ListFilesQueryHandler(IS3StorageService s3) : IRequestHandler +public class ListFilesQueryHandler(IS3StorageService s3, IBucketResolver bucketResolver) : IRequestHandler { private const int DefaultMaxKeys = 100; private const int MaxListKeys = 1000; @@ -14,7 +15,8 @@ public class ListFilesQueryHandler(IS3StorageService s3) : IRequestHandler 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(); diff --git a/src/modules/file/Application/Features/Files/Handlers/UploadFileCommandHandler.cs b/src/modules/file/Application/Features/Files/Handlers/UploadFileCommandHandler.cs index 37e16d5..2ba86a8 100644 --- a/src/modules/file/Application/Features/Files/Handlers/UploadFileCommandHandler.cs +++ b/src/modules/file/Application/Features/Files/Handlers/UploadFileCommandHandler.cs @@ -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 { 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); } diff --git a/src/modules/file/Application/Features/Files/Queries/DownloadFileQuery.cs b/src/modules/file/Application/Features/Files/Queries/DownloadFileQuery.cs index 4a75a00..d96cd03 100644 --- a/src/modules/file/Application/Features/Files/Queries/DownloadFileQuery.cs +++ b/src/modules/file/Application/Features/Files/Queries/DownloadFileQuery.cs @@ -5,4 +5,4 @@ namespace File.Application.Features.Files.Queries; /// 下载文件查询。返回字节流。 -public record DownloadFileQuery(string BucketName, string ObjectKey) : IRequest; +public record DownloadFileQuery(string ObjectKey) : IRequest; diff --git a/src/modules/file/Application/Features/Files/Queries/GetFileContentQuery.cs b/src/modules/file/Application/Features/Files/Queries/GetFileContentQuery.cs index c249a82..1d1ae8b 100644 --- a/src/modules/file/Application/Features/Files/Queries/GetFileContentQuery.cs +++ b/src/modules/file/Application/Features/Files/Queries/GetFileContentQuery.cs @@ -4,5 +4,5 @@ using MediatR; namespace File.Application.Features.Files.Queries; -/// 获取文件文本内容查询。 -public record GetFileContentQuery(string BucketName, string ObjectKey) : IRequest; +/// 获取文件文本内容查询。bucket 由 IBucketResolver 统一解析。 +public record GetFileContentQuery(string ObjectKey) : IRequest; diff --git a/src/modules/file/Application/Features/Files/Queries/GetFilePreviewQuery.cs b/src/modules/file/Application/Features/Files/Queries/GetFilePreviewQuery.cs index 60fee59..fd819de 100644 --- a/src/modules/file/Application/Features/Files/Queries/GetFilePreviewQuery.cs +++ b/src/modules/file/Application/Features/Files/Queries/GetFilePreviewQuery.cs @@ -5,4 +5,4 @@ namespace File.Application.Features.Files.Queries; /// 获取文件预签名预览 URL 查询。 -public record GetFilePreviewQuery(string BucketName, string ObjectKey) : IRequest; +public record GetFilePreviewQuery(string ObjectKey) : IRequest; diff --git a/src/modules/file/Application/Features/Files/Queries/GetOfficePreviewQuery.cs b/src/modules/file/Application/Features/Files/Queries/GetOfficePreviewQuery.cs index 3bfb325..5bfe0a7 100644 --- a/src/modules/file/Application/Features/Files/Queries/GetOfficePreviewQuery.cs +++ b/src/modules/file/Application/Features/Files/Queries/GetOfficePreviewQuery.cs @@ -7,4 +7,4 @@ namespace File.Application.Features.Files.Queries; /// 获取 Office/PDF 文档在线预览地址查询。 /// 生成 S3 预签名 URL 后拼装为 KKFileView 预览链接,供前端 window.open 打开。 /// -public record GetOfficePreviewQuery(string BucketName, string ObjectKey) : IRequest; +public record GetOfficePreviewQuery(string ObjectKey) : IRequest; diff --git a/src/modules/file/Application/Features/Files/Queries/ListFilesQuery.cs b/src/modules/file/Application/Features/Files/Queries/ListFilesQuery.cs index 9009078..1dfc6c4 100644 --- a/src/modules/file/Application/Features/Files/Queries/ListFilesQuery.cs +++ b/src/modules/file/Application/Features/Files/Queries/ListFilesQuery.cs @@ -5,5 +5,5 @@ namespace File.Application.Features.Files.Queries; /// 列文件查询。支持前缀、分页大小、续传 token。 -public record ListFilesQuery(string BucketName, string? Prefix, int? MaxKeys, string? ContinuationToken) +public record ListFilesQuery(string? Prefix, int? MaxKeys, string? ContinuationToken) : IRequest; diff --git a/src/modules/file/Application/Features/Files/Validators/DeleteFileCommandValidator.cs b/src/modules/file/Application/Features/Files/Validators/DeleteFileCommandValidator.cs index ac54489..58253be 100644 --- a/src/modules/file/Application/Features/Files/Validators/DeleteFileCommandValidator.cs +++ b/src/modules/file/Application/Features/Files/Validators/DeleteFileCommandValidator.cs @@ -9,7 +9,6 @@ public class DeleteFileCommandValidator : AbstractValidator { public DeleteFileCommandValidator() { - RuleFor(x => x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称"); RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key"); } } diff --git a/src/modules/file/Application/Features/Files/Validators/DownloadFileQueryValidator.cs b/src/modules/file/Application/Features/Files/Validators/DownloadFileQueryValidator.cs index 7ad8be2..5cd699d 100644 --- a/src/modules/file/Application/Features/Files/Validators/DownloadFileQueryValidator.cs +++ b/src/modules/file/Application/Features/Files/Validators/DownloadFileQueryValidator.cs @@ -9,7 +9,6 @@ public class DownloadFileQueryValidator : AbstractValidator { public DownloadFileQueryValidator() { - RuleFor(x => x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称"); RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key"); } } diff --git a/src/modules/file/Application/Features/Files/Validators/GetFileContentQueryValidator.cs b/src/modules/file/Application/Features/Files/Validators/GetFileContentQueryValidator.cs index a52fa12..adc930e 100644 --- a/src/modules/file/Application/Features/Files/Validators/GetFileContentQueryValidator.cs +++ b/src/modules/file/Application/Features/Files/Validators/GetFileContentQueryValidator.cs @@ -9,7 +9,6 @@ public class GetFileContentQueryValidator : AbstractValidator x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称"); RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key"); } } diff --git a/src/modules/file/Application/Features/Files/Validators/GetFilePreviewQueryValidator.cs b/src/modules/file/Application/Features/Files/Validators/GetFilePreviewQueryValidator.cs index 90969ef..f7e9854 100644 --- a/src/modules/file/Application/Features/Files/Validators/GetFilePreviewQueryValidator.cs +++ b/src/modules/file/Application/Features/Files/Validators/GetFilePreviewQueryValidator.cs @@ -9,7 +9,6 @@ public class GetFilePreviewQueryValidator : AbstractValidator x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称"); RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key"); } } diff --git a/src/modules/file/Application/Features/Files/Validators/ListFilesQueryValidator.cs b/src/modules/file/Application/Features/Files/Validators/ListFilesQueryValidator.cs index 16c9b28..a477c4d 100644 --- a/src/modules/file/Application/Features/Files/Validators/ListFilesQueryValidator.cs +++ b/src/modules/file/Application/Features/Files/Validators/ListFilesQueryValidator.cs @@ -9,6 +9,5 @@ public class ListFilesQueryValidator : AbstractValidator { public ListFilesQueryValidator() { - RuleFor(x => x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称"); } } diff --git a/src/modules/file/Application/Features/Files/Validators/UploadFileCommandValidator.cs b/src/modules/file/Application/Features/Files/Validators/UploadFileCommandValidator.cs index 7d962e9..229c4e5 100644 --- a/src/modules/file/Application/Features/Files/Validators/UploadFileCommandValidator.cs +++ b/src/modules/file/Application/Features/Files/Validators/UploadFileCommandValidator.cs @@ -9,7 +9,6 @@ public class UploadFileCommandValidator : AbstractValidator { public UploadFileCommandValidator() { - RuleFor(x => x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称"); RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key"); } } diff --git a/src/modules/file/Application/Features/Folders/Handlers/UploadToFolderCommandHandler.cs b/src/modules/file/Application/Features/Folders/Handlers/UploadToFolderCommandHandler.cs index b2d4dff..bda3d3b 100644 --- a/src/modules/file/Application/Features/Folders/Handlers/UploadToFolderCommandHandler.cs +++ b/src/modules/file/Application/Features/Folders/Handlers/UploadToFolderCommandHandler.cs @@ -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; /// /// 上传文件到文件夹处理器。校验 500MiB 上限,UUID 作 S3 key,落库失败补偿删 S3。 -/// bucket 从配置 FileService:Bucket 读取(与直传/预览/下载/删除统一,不再硬编码 "files")。 +/// bucket 统一从 IBucketResolver 获取(与直传/预览/下载/删除一致,不再从配置或前端参数读取)。 /// public class UploadToFolderCommandHandler( FileDbContext db, IS3StorageService s3, ICurrentUserContext currentUser, - IConfiguration config) : IRequestHandler + IBucketResolver bucketResolver) : IRequestHandler { 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,不用原文件名 diff --git a/src/modules/file/Application/Features/Multipart/Commands/AbortMultipartCommand.cs b/src/modules/file/Application/Features/Multipart/Commands/AbortMultipartCommand.cs index f326ece..a1e2ce4 100644 --- a/src/modules/file/Application/Features/Multipart/Commands/AbortMultipartCommand.cs +++ b/src/modules/file/Application/Features/Multipart/Commands/AbortMultipartCommand.cs @@ -4,4 +4,4 @@ namespace File.Application.Features.Multipart.Commands; /// 中止分片上传命令。清理已传分片。 -public record AbortMultipartCommand(string BucketName, string ObjectKey, string UploadId) : IRequest; +public record AbortMultipartCommand(string ObjectKey, string UploadId) : IRequest; diff --git a/src/modules/file/Application/Features/Multipart/Commands/CompleteMultipartCommand.cs b/src/modules/file/Application/Features/Multipart/Commands/CompleteMultipartCommand.cs index bfa4d80..e193c9a 100644 --- a/src/modules/file/Application/Features/Multipart/Commands/CompleteMultipartCommand.cs +++ b/src/modules/file/Application/Features/Multipart/Commands/CompleteMultipartCommand.cs @@ -8,7 +8,6 @@ namespace File.Application.Features.Multipart.Commands; /// FileName/Size 可选:完成后用于创建 FileMeta(分片秒传查重记录),不带则不落 FileMeta(老客户端兼容)。 /// public record CompleteMultipartCommand( - string BucketName, string ObjectKey, string UploadId, List Parts, diff --git a/src/modules/file/Application/Features/Multipart/Commands/InitMultipartCommand.cs b/src/modules/file/Application/Features/Multipart/Commands/InitMultipartCommand.cs index d458c24..b3c17d8 100644 --- a/src/modules/file/Application/Features/Multipart/Commands/InitMultipartCommand.cs +++ b/src/modules/file/Application/Features/Multipart/Commands/InitMultipartCommand.cs @@ -9,7 +9,6 @@ namespace File.Application.Features.Multipart.Commands; /// 秒传判定用 (OwnerId, ContentHash, Size) 三元组(与 CheckFileQuery 对齐)。 /// public record InitMultipartCommand( - string BucketName, string ObjectKey, string? ContentHash = null, int? TotalParts = null, diff --git a/src/modules/file/Application/Features/Multipart/Commands/UploadPartCommand.cs b/src/modules/file/Application/Features/Multipart/Commands/UploadPartCommand.cs index 6f9d045..2c9bea3 100644 --- a/src/modules/file/Application/Features/Multipart/Commands/UploadPartCommand.cs +++ b/src/modules/file/Application/Features/Multipart/Commands/UploadPartCommand.cs @@ -5,5 +5,5 @@ namespace File.Application.Features.Multipart.Commands; /// 上传单分片命令。携带 uploadId 与 partNumber 续传。 -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; diff --git a/src/modules/file/Application/Features/Multipart/Handlers/AbortMultipartCommandHandler.cs b/src/modules/file/Application/Features/Multipart/Handlers/AbortMultipartCommandHandler.cs index 422d61a..be6b8bc 100644 --- a/src/modules/file/Application/Features/Multipart/Handlers/AbortMultipartCommandHandler.cs +++ b/src/modules/file/Application/Features/Multipart/Handlers/AbortMultipartCommandHandler.cs @@ -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); diff --git a/src/modules/file/Application/Features/Multipart/Handlers/CompleteMultipartCommandHandler.cs b/src/modules/file/Application/Features/Multipart/Handlers/CompleteMultipartCommandHandler.cs index 20069b3..304bf8a 100644 --- a/src/modules/file/Application/Features/Multipart/Handlers/CompleteMultipartCommandHandler.cs +++ b/src/modules/file/Application/Features/Multipart/Handlers/CompleteMultipartCommandHandler.cs @@ -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 + ICurrentUserContext currentUser, + IBucketResolver bucketResolver) : IRequestHandler { public async Task 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}"); } } diff --git a/src/modules/file/Application/Features/Multipart/Handlers/InitMultipartCommandHandler.cs b/src/modules/file/Application/Features/Multipart/Handlers/InitMultipartCommandHandler.cs index 1410fb2..05b950f 100644 --- a/src/modules/file/Application/Features/Multipart/Handlers/InitMultipartCommandHandler.cs +++ b/src/modules/file/Application/Features/Multipart/Handlers/InitMultipartCommandHandler.cs @@ -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 + ICurrentUserContext currentUser, + IBucketResolver bucketResolver) : IRequestHandler { public async Task 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, diff --git a/src/modules/file/Application/Features/Multipart/Handlers/UploadPartCommandHandler.cs b/src/modules/file/Application/Features/Multipart/Handlers/UploadPartCommandHandler.cs index bce29e7..940c445 100644 --- a/src/modules/file/Application/Features/Multipart/Handlers/UploadPartCommandHandler.cs +++ b/src/modules/file/Application/Features/Multipart/Handlers/UploadPartCommandHandler.cs @@ -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); } } diff --git a/src/modules/file/Application/Features/Multipart/Validators/AbortMultipartCommandValidator.cs b/src/modules/file/Application/Features/Multipart/Validators/AbortMultipartCommandValidator.cs index 22f5c89..c827056 100644 --- a/src/modules/file/Application/Features/Multipart/Validators/AbortMultipartCommandValidator.cs +++ b/src/modules/file/Application/Features/Multipart/Validators/AbortMultipartCommandValidator.cs @@ -9,7 +9,6 @@ public class AbortMultipartCommandValidator : AbstractValidator x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称"); RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key"); RuleFor(x => x.UploadId).NotEmpty().WithMessage("upload_id 不能为空"); } diff --git a/src/modules/file/Application/Features/Multipart/Validators/CompleteMultipartCommandValidator.cs b/src/modules/file/Application/Features/Multipart/Validators/CompleteMultipartCommandValidator.cs index 6ec06f4..c187b41 100644 --- a/src/modules/file/Application/Features/Multipart/Validators/CompleteMultipartCommandValidator.cs +++ b/src/modules/file/Application/Features/Multipart/Validators/CompleteMultipartCommandValidator.cs @@ -9,7 +9,6 @@ public class CompleteMultipartCommandValidator : AbstractValidator 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 不能为空"); diff --git a/src/modules/file/Application/Features/Multipart/Validators/InitMultipartCommandValidator.cs b/src/modules/file/Application/Features/Multipart/Validators/InitMultipartCommandValidator.cs index 09f1034..ca2c20b 100644 --- a/src/modules/file/Application/Features/Multipart/Validators/InitMultipartCommandValidator.cs +++ b/src/modules/file/Application/Features/Multipart/Validators/InitMultipartCommandValidator.cs @@ -9,7 +9,6 @@ public class InitMultipartCommandValidator : AbstractValidator x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称"); RuleFor(x => x.ObjectKey).Must(SanitizeRules.IsValidObjectKey).WithMessage("无效的对象 key"); } } diff --git a/src/modules/file/Application/Features/Multipart/Validators/UploadPartCommandValidator.cs b/src/modules/file/Application/Features/Multipart/Validators/UploadPartCommandValidator.cs index 338c007..997fa06 100644 --- a/src/modules/file/Application/Features/Multipart/Validators/UploadPartCommandValidator.cs +++ b/src/modules/file/Application/Features/Multipart/Validators/UploadPartCommandValidator.cs @@ -9,7 +9,6 @@ public class UploadPartCommandValidator : AbstractValidator { 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"); diff --git a/src/modules/platform/Infrastructure/Persistence/SeedData.cs b/src/modules/platform/Infrastructure/Persistence/SeedData.cs index de9e256..5b49917 100644 --- a/src/modules/platform/Infrastructure/Persistence/SeedData.cs +++ b/src/modules/platform/Infrastructure/Persistence/SeedData.cs @@ -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); } diff --git a/src/modules/workflow/Domain/Engine/WorkflowGraph.cs b/src/modules/workflow/Domain/Engine/WorkflowGraph.cs new file mode 100644 index 0000000..2993c65 --- /dev/null +++ b/src/modules/workflow/Domain/Engine/WorkflowGraph.cs @@ -0,0 +1,149 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Workflow.Domain.Enums; + +namespace Workflow.Domain.Engine; + +/// +/// X6 graph 的强类型模型,与 X6 toJSON/fromJSON 原生格式兼容。 +/// 替代裸 JsonDocument,供 ProcessEngine / 校验器 / 迁移脚本统一读写。 +/// 顶层结构为 { nodes: [], edges: [] }(X6 fromJSON 支持的两种等价格式之一)。 +/// +public sealed class WorkflowGraph +{ + [JsonPropertyName("nodes")] + public List Nodes { get; set; } = new(); + + [JsonPropertyName("edges")] + public List Edges { get; set; } = new(); + + /// 空图工厂(容错用)。 + public static WorkflowGraph Empty() => new(); +} + +/// +/// X6 节点。shape 决定渲染器并反推 NodeType(见 ); +/// 业务配置统一进 data;x/y/width/height/ports 为几何与渲染信息。 +/// +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; } + + /// 业务数据(节点配置)。替代旧 WorkflowNode.Config 字符串字段。 + [JsonPropertyName("data")] + public JsonElement? Data { get; set; } + + /// 显示名(对应旧 WorkflowNode.Name)。 + public string? GetTitle() => Data.TryGetString("title"); + + [JsonPropertyName("ports")] + public JsonElement? Ports { get; set; } +} + +/// +/// X6 边。source/target 用 { cell, port } 结构;几何信息(vertices/router/connector)放顶层; +/// 业务字段(edgeType/condition/order)放 data。 +/// +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(); + + /// 业务数据:edgeType / condition / order。 + [JsonPropertyName("data")] + public JsonElement? Data { get; set; } + + [JsonPropertyName("label")] + public JsonElement? Label { get; set; } + + [JsonPropertyName("vertices")] + public List? Vertices { get; set; } + + [JsonPropertyName("router")] + public JsonElement? Router { get; set; } + + [JsonPropertyName("connector")] + public JsonElement? Connector { get; set; } + + /// 边类型(Normal=0/Approved=1/Rejected=2)。缺省为 Normal。 + public EdgeType GetEdgeType() => Data.TryGetInt32("edgeType") is { } v ? (EdgeType)v : EdgeType.Normal; + + /// 条件表达式(Condition/ExclusiveGateway 出边)。 + public string? GetCondition() => Data.TryGetString("condition"); + + /// 分支评估顺序(值小优先)。 + public int GetOrder() => Data.TryGetInt32("order") ?? 0; +} + +public sealed class GraphEndpoint +{ + /// 节点 id(对应 GraphNode.Id)。 + [JsonPropertyName("cell")] + public string? Cell { get; set; } + + /// 端口 id(top/right/bottom/left)。 + [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; } +} + +/// +/// JsonElement? 安全取值扩展:统一处理 Nullable 解构与 ValueKind 校验, +/// 避免 WorkflowGraph 各 getter 重复 null 检查。 +/// +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; +} diff --git a/src/modules/workflow/Domain/Engine/WorkflowShapeMap.cs b/src/modules/workflow/Domain/Engine/WorkflowShapeMap.cs new file mode 100644 index 0000000..f4ac9d8 --- /dev/null +++ b/src/modules/workflow/Domain/Engine/WorkflowShapeMap.cs @@ -0,0 +1,52 @@ +using Workflow.Domain.Enums; + +namespace Workflow.Domain.Engine; + +/// +/// X6 节点 shape 名 与 的双向映射。 +/// 前端注册节点与后端反推 NodeType 都以此为准。新增 NodeType 必须同步登记 shape。 +/// +public static class WorkflowShapeMap +{ + /// shape 名常量,与前端 register 的 shape 一一对应。 + 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 ShapeToType = new Dictionary(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 TypeToShape = + ShapeToType.ToDictionary(kv => kv.Value, kv => kv.Key); + + /// shape 名反推 NodeType;未知 shape 返回 null(发布校验会拦截)。 + public static NodeType? ToNodeType(string? shape) => + shape is not null && ShapeToType.TryGetValue(shape, out var t) ? t : null; + + /// NodeType 转 shape 名。 + public static string ToShape(NodeType type) => TypeToShape[type]; + + /// 判断 shape 是否为已登记的合法节点 shape。 + public static bool IsValidNodeShape(string? shape) => + shape is not null && ShapeToType.ContainsKey(shape); +} diff --git a/src/modules/workflow/Domain/Entities/WorkflowDefinitionSnapshot.cs b/src/modules/workflow/Domain/Entities/WorkflowDefinitionSnapshot.cs new file mode 100644 index 0000000..c57a5bc --- /dev/null +++ b/src/modules/workflow/Domain/Entities/WorkflowDefinitionSnapshot.cs @@ -0,0 +1,34 @@ +using BuildFlow.Shared.Domain.Common; + +namespace Workflow.Domain.Entities; + +/// +/// 工作流定义的发布版本快照。每次发布(Draft → Published)写入一条不可变快照, +/// ProcessEngine 运行时按 DefinitionId + Version 读快照而非主表当前 DefinitionJson, +/// 实现「已发布定义不可变 + 运行实例版本隔离」。 +/// 参照仓库已有的 范式。 +/// +public class WorkflowDefinitionSnapshot : BaseEntity, IAuditable +{ + /// 所属流程定义 ID。 + public Guid DefinitionId { get; set; } + + /// 版本号(与发布时的 WorkflowDefinition.Version 对应)。 + public int Version { get; set; } + + /// 该版本的完整 X6 graph JSON 快照(不可变)。引擎运行时唯一事实源。 + public string? DefinitionJson { get; set; } + + /// 触发来源:Publish(仅发布写入)。 + public string Source { get; set; } = "Publish"; + + /// 变更摘要(可选)。 + 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"; +} diff --git a/src/modules/workflow/Domain/Entities/WorkflowInstance.cs b/src/modules/workflow/Domain/Entities/WorkflowInstance.cs index 8194b4b..06982e7 100644 --- a/src/modules/workflow/Domain/Entities/WorkflowInstance.cs +++ b/src/modules/workflow/Domain/Entities/WorkflowInstance.cs @@ -11,6 +11,11 @@ namespace Workflow.Domain.Entities; public class WorkflowInstance : BaseEntity, IFullAudit { public Guid DefinitionId { get; set; } + /// 实例启动时绑定的流程定义版本号。ProcessEngine 按 DefinitionId + DefinitionVersion 读不可变快照, + /// 避免已发布定义被新版覆盖后污染运行中实例(版本隔离)。 + public int DefinitionVersion { get; set; } = 1; + /// 实例启动时快照的流程定义 Code,便于追溯(运行时不读,仅审计用)。 + public string? DefinitionCode { get; set; } public string Title { get; set; } = string.Empty; public InstanceStatus Status { get; set; } = InstanceStatus.Running; /// 流程变量(JSON 字符串)。条件节点求值、节点动作钩子均从此读取运行时数据。 diff --git a/src/modules/workflow/Infrastructure/Persistence/Configurations/WorkflowDefinitionSnapshotConfiguration.cs b/src/modules/workflow/Infrastructure/Persistence/Configurations/WorkflowDefinitionSnapshotConfiguration.cs new file mode 100644 index 0000000..b5f4e17 --- /dev/null +++ b/src/modules/workflow/Infrastructure/Persistence/Configurations/WorkflowDefinitionSnapshotConfiguration.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Workflow.Domain.Entities; + +namespace Workflow.Infrastructure.Persistence.Configurations; + +/// +/// 工作流定义发布快照表映射配置。每次发布生成一条不可变快照,支撑引擎版本隔离。 +/// 参照仓库已有的 范式。 +/// +public class WorkflowDefinitionSnapshotConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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); + } +} diff --git a/tests/BuildFlow.File.Tests/DeleteFileCommandHandlerTests.cs b/tests/BuildFlow.File.Tests/DeleteFileCommandHandlerTests.cs index efbab7e..a656bfa 100644 --- a/tests/BuildFlow.File.Tests/DeleteFileCommandHandlerTests.cs +++ b/tests/BuildFlow.File.Tests/DeleteFileCommandHandlerTests.cs @@ -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"; + /// 测试用 bucket 解析器,固定返回与 一致的桶名。 + private static IBucketResolver BucketResolver() + { + var mock = new Mock(); + mock.Setup(r => r.GetDefaultBucketAsync(It.IsAny())).ReturnsAsync(Bucket); + return mock.Object; + } + /// 构造测试依赖:InMemory DB(挂审计拦截器自动填充非空字段)+ Mock S3 + Mock Mediator。 private static (FileDbContext db, Mock s3, Mock 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()), 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()), 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())) .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().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( diff --git a/tests/BuildFlow.File.Tests/DownloadFileQueryHandlerTests.cs b/tests/BuildFlow.File.Tests/DownloadFileQueryHandlerTests.cs index c504fa1..2ff96b1 100644 --- a/tests/BuildFlow.File.Tests/DownloadFileQueryHandlerTests.cs +++ b/tests/BuildFlow.File.Tests/DownloadFileQueryHandlerTests.cs @@ -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; /// public class DownloadFileQueryHandlerTests { + /// 测试用 bucket 解析器,固定返回 "test-bucket"。 + private static IBucketResolver BucketResolver() + { + var mock = new Mock(); + mock.Setup(r => r.GetDefaultBucketAsync(It.IsAny())).ReturnsAsync("test-bucket"); + return mock.Object; + } + /// 创建 Mock S3 + Handler。 private static (Mock 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())) .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(), It.IsAny(), It.IsAny())) .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 diff --git a/tests/BuildFlow.File.Tests/InstantUploadTests.cs b/tests/BuildFlow.File.Tests/InstantUploadTests.cs index 4be51fa..5401527 100644 --- a/tests/BuildFlow.File.Tests/InstantUploadTests.cs +++ b/tests/BuildFlow.File.Tests/InstantUploadTests.cs @@ -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"; + + /// 测试用 bucket 解析器,固定返回与 一致的桶名。 + private static IBucketResolver BucketResolver() + { + var mock = new Mock(); + mock.Setup(r => r.GetDefaultBucketAsync(It.IsAny())).ReturnsAsync(Bucket); + return mock.Object; + } private static (FileDbContext db, Mock s3, Mock 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(), It.IsAny(), It.IsAny())).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 已落库,再次分片上传同文件应秒传"); } } diff --git a/tests/BuildFlow.File.Tests/MultipartUploadSessionTests.cs b/tests/BuildFlow.File.Tests/MultipartUploadSessionTests.cs index 901effa..526192e 100644 --- a/tests/BuildFlow.File.Tests/MultipartUploadSessionTests.cs +++ b/tests/BuildFlow.File.Tests/MultipartUploadSessionTests.cs @@ -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"; + + /// 测试用 bucket 解析器,固定返回与预置会话一致的桶名 "bk"。 + private static IBucketResolver BucketResolver() + { + var mock = new Mock(); + mock.Setup(r => r.GetDefaultBucketAsync(It.IsAny())).ReturnsAsync(Bucket); + return mock.Object; + } /// /// 构造一组注入了 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(), It.IsAny(), It.IsAny())) .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(); s3.Verify(x => x.UploadPartAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), 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(); @@ -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(); } @@ -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 { 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>(), It.IsAny()), 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()), Times.Once); var session = await db.UploadSessions.SingleAsync(); diff --git a/tests/BuildFlow.File.Tests/UploadFileCommandHandlerTests.cs b/tests/BuildFlow.File.Tests/UploadFileCommandHandlerTests.cs index 5d9a357..13329a8 100644 --- a/tests/BuildFlow.File.Tests/UploadFileCommandHandlerTests.cs +++ b/tests/BuildFlow.File.Tests/UploadFileCommandHandlerTests.cs @@ -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"; + /// 测试用 bucket 解析器,固定返回 "files"。 + private static IBucketResolver BucketResolver(string bucket = "files") + { + var mock = new Mock(); + mock.Setup(r => r.GetDefaultBucketAsync(It.IsAny())).ReturnsAsync(bucket); + return mock.Object; + } + private static (FileDbContext db, Mock s3, Mock user) CreateDeps(string dbName) { var user = new Mock(); @@ -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().Object); + var handler = new UploadFileCommandHandler(s3.Object, db, user.Object, BucketResolver(), new Mock().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().Object); + var handler = new UploadFileCommandHandler(s3.Object, db, user.Object, BucketResolver(), new Mock().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(); @@ -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().Object); + var handler = new UploadFileCommandHandler(s3.Object, db, user.Object, BucketResolver(), new Mock().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); // 仍只有一条记录