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:
xiangning 2026-07-03 13:32:08 +08:00
parent 67dce6b1f4
commit 8f85a2d3c8
70 changed files with 538 additions and 133 deletions

View File

@ -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>();

View File

@ -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);
}
}

View File

@ -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!;
}

View File

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

View File

@ -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!;
}

View File

@ -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);
}
}

View File

@ -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!;
}

View File

@ -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);
}
}

View File

@ -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!;
}

View File

@ -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);
}
}

View File

@ -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!;
}

View File

@ -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);
}
}

View File

@ -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; }

View File

@ -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);
}

View File

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

View File

@ -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);
}
}

View File

@ -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!;
}

View File

@ -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);
}
}

View File

@ -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();

View File

@ -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);
}
}

View File

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

View File

@ -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);
}

View File

@ -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; }

View File

@ -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;
}
}
}

View File

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

View File

@ -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;
}
}

View File

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

View File

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

View File

@ -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;
}
}

View File

@ -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)..]

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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));

View File

@ -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();

View File

@ -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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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");
}
}

View File

@ -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");
}
}

View File

@ -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");
}
}

View File

@ -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");
}
}

View File

@ -9,6 +9,5 @@ public class ListFilesQueryValidator : AbstractValidator<ListFilesQuery>
{
public ListFilesQueryValidator()
{
RuleFor(x => x.BucketName).Must(SanitizeRules.IsValidBucketName).WithMessage("无效的存储桶名称");
}
}

View File

@ -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");
}
}

View File

@ -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,不用原文件名

View File

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

View File

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

View File

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

View File

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

View File

@ -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);

View File

@ -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}");
}
}

View File

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

View File

@ -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);
}
}

View File

@ -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 不能为空");
}

View File

@ -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 不能为空");

View File

@ -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");
}
}

View File

@ -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");

View File

@ -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);
}

View 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;
}

View 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);
}

View File

@ -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";
}

View File

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

View File

@ -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);
}
}

View File

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

View File

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

View File

@ -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 已落库,再次分片上传同文件应秒传");
}
}

View File

@ -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();

View File

@ -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);
// 仍只有一条记录