From 898cf5dba0988a7b37381608ea4dd0a35935d29e Mon Sep 17 00:00:00 2001 From: nanxun Date: Fri, 11 Sep 2026 18:18:59 +0800 Subject: [PATCH] fix: align backend APIs and upload flow --- .../UploadTaskCompleteEventHandler.cs | 44 ++++-- .../StorageContracts/StorageDto.cs | 1 + .../UploadFile/FileResponse.cs | 10 +- .../UploadFile/IGroupAccessService.cs | 19 +++ .../UploadFile/UploadFileMapperConfig.cs | 5 +- .../UploadFile/UploadFileService.cs | 68 +++++++-- .../UploadFileTask/TaskInitResponse.cs | 5 + .../UploadFileTask/UploadFileTaskService.cs | 139 ++++++++++++++++-- .../UploadFileTask/UploadTaskInitCommand.cs | 4 +- .../UploadFileTask/UploadTaskResponse.cs | 3 + FileService.Domain/Entities/UploadFile.cs | 10 +- FileService.Domain/Entities/UploadTask.cs | 26 +++- .../IReposities/IUploadFileReposity.cs | 1 + FileService.Domain/ValueObjects/FileName.cs | 4 +- .../Configs/UploadFileConfig.cs | 10 ++ .../Configs/UploadTaskConfig.cs | 10 ++ .../20260909000300_AsyncUploadResult.cs | 131 +++++++++++++++++ .../Migrations/FileDbContextModelSnapshot.cs | 78 ++++++++-- .../Reposites/UploadFileReposity.cs | 5 + .../Storage/LocalStorageAdapter.cs | 1 + .../Controllers/File/FileController.cs | 10 +- .../FileTask/CompleteTaskRequest.cs | 4 +- .../FileTask/FileTaskController.cs | 16 +- .../FileTask/FileTaskInitRequest.cs | 24 ++- FileService.WebApi/ModueInit.cs | 11 ++ .../appsettings.Development.json | 4 + FileService.WebApi/appsettings.json | 6 +- GroupService.Domain/Entities/GroupMember.cs | 7 + .../Events/GroupMemberLeftDomainEvent.cs | 7 + .../IReposities/IGroupReposity.cs | 1 + .../IReposities/IGroupRequestReposity.cs | 1 + .../Configs/GroupJoinRequestConfig.cs | 3 + .../Configs/GroupMemberConfig.cs | 2 + .../20260909000200_ApiAlignmentFixes.cs | 48 ++++++ .../Migrations/GroupDbContextModelSnapshot.cs | 11 ++ .../Reposities/GroupJoinRequestReposity.cs | 14 ++ .../Reposities/GroupReposity.cs | 12 ++ .../EventHandler/GroupMemberLeftHandler.cs | 18 +++ .../Application/Group/GroupService.cs | 31 +++- .../GroupMember/GroupMemberService.cs | 28 +++- .../GroupRequest/GroupRequestService.cs | 2 +- .../Controllers/Group/GroupController.cs | 11 +- .../GroupMember/GroupMemberController.cs | 26 +++- .../appsettings.Development.json | 1 + GroupService.WebApi/appsettings.json | 3 +- IM.ASPNETCore/ExceptionMiddleware.cs | 31 +++- .../IntegrationEvents/GroupMemberLeftEvent.cs | 4 + .../UploadTaskCompleteEvent.cs | 2 + IM.InitCommon/RabbitMqExtension.cs | 4 + IM.Jwt/WebApplicationJwtExtension.cs | 4 +- MIGRATION_RUNBOOK.md | 74 ++++++++++ .../Entities/Conversation.cs | 31 ++-- MessageService.Domain/Entities/Message.cs | 22 +-- .../KeyObjects/MsgTypeObj.cs | 8 +- .../Configs/ConversationConfig.cs | 1 + .../20260909000100_ApiAlignmentFixes.cs | 25 ++++ .../MessageDbContextModelSnapshot.cs | 2 + .../Reposities/MessageReposity.cs | 16 +- .../Conversation/ConversationMapperConfig.cs | 2 +- .../Application/Dtos/ConversationResponse.cs | 2 +- .../EventHandlers/ConversationAddHandler.cs | 27 +++- .../EventHandlers/MessageHandler.cs | 26 ++-- .../Application/Message/MessageService.cs | 18 ++- .../Application/Message/SendMsgCommand.cs | 10 +- .../Controllers/Message/MessageSendRequest.cs | 24 ++- MessageService.WebApi/ModuleInit.cs | 11 +- .../appsettings.Development.json | 4 + MessageService.WebApi/appsettings.json | 6 +- docker-compose.yml | 5 + 69 files changed, 1081 insertions(+), 153 deletions(-) create mode 100644 FileService.Application/UploadFile/IGroupAccessService.cs create mode 100644 FileService.Infrastructure/Migrations/20260909000300_AsyncUploadResult.cs create mode 100644 GroupService.Domain/Events/GroupMemberLeftDomainEvent.cs create mode 100644 GroupService.Infrastructure/Migrations/20260909000200_ApiAlignmentFixes.cs create mode 100644 GroupService.WebApi/Application/EventHandler/GroupMemberLeftHandler.cs create mode 100644 IM.Commons/IntegrationEvents/GroupMemberLeftEvent.cs create mode 100644 MIGRATION_RUNBOOK.md create mode 100644 MessageService.Infrastructure/Migrations/20260909000100_ApiAlignmentFixes.cs diff --git a/FileService.Application/EventHandler/UploadTaskCompleteEventHandler.cs b/FileService.Application/EventHandler/UploadTaskCompleteEventHandler.cs index f2766ff..ae38539 100644 --- a/FileService.Application/EventHandler/UploadTaskCompleteEventHandler.cs +++ b/FileService.Application/EventHandler/UploadTaskCompleteEventHandler.cs @@ -16,22 +16,33 @@ namespace FileService.Application.EventHandler private readonly IObjectStorageRouter router; private readonly IUploadFileReposity uploadFile; private readonly IUnitOfWork uwork; - private readonly IStorageRedisCache storageCache; + private readonly IUploadTaskReposity uploadTask; - public UploadTaskCompleteEventHandler(IObjectStorageRouter router, IUploadFileReposity uploadFile, IUnitOfWork uwork, IStorageRedisCache storageCache) + public UploadTaskCompleteEventHandler(IObjectStorageRouter router, IUploadFileReposity uploadFile, IUploadTaskReposity uploadTask, IUnitOfWork uwork) { this.router = router; this.uploadFile = uploadFile; this.uwork = uwork; - this.storageCache = storageCache; + this.uploadTask = uploadTask; } public async Task Consume(ConsumeContext context) { var @event = context.Message; + var existingFile = await uploadFile.FindBySourceTaskIdAsync(@event.TaskId); + if (existingFile != null) + { + return; + } + + var task = await uploadTask.FindByIdAsync(@event.TaskId); + if (task is null) + { + throw new InvalidOperationException($"Upload task {@event.TaskId} has not been committed yet."); + } + var storage = router.Route(@event.ProviderCode); - var taskCache = await storageCache.GetAsync(@event.SessionId); - if(@event.ProviderCode == "Local") + try { await storage.CompleteUploadAsync(new StorageContracts.CompleteUploadCommand( ProviderCode: @event.ProviderCode, @@ -46,14 +57,27 @@ namespace FileService.Application.EventHandler Checksum: s.Checksum )).ToList() ), context.CancellationToken); - uploadFile.Create(new Domain.Entities.UploadFile( + var file = new Domain.Entities.UploadFile( ownerId: @event.OperatorId, fileName: @event.FileName, - fileSize: taskCache.FileSize, + fileSize: @event.FileSize, contentType: @event.ContentType, - new Domain.ValueObjects.StorageLocation(taskCache.ProviderCode, taskCache.Bucket, taskCache.ObjectKey, taskCache.Region), - checkSum: new Domain.ValueObjects.CheckSum("md5", @event.CheckSun) - )); + new Domain.ValueObjects.StorageLocation(@event.ProviderCode, @event.Bucket, @event.ObjectKey, @event.Region), + checkSum: new Domain.ValueObjects.CheckSum("md5", @event.CheckSun), + sourceTaskId: @event.TaskId, + chatType: @event.ChatType, + targetId: @event.TargetId, + isPublic: false + ); + uploadFile.Create(file); + task.CompleteUpload(file.Id); + await uwork.SaveChangesAsync(context.CancellationToken); + } + catch (Exception ex) + { + task.Fail(ex.Message); + await uwork.SaveChangesAsync(context.CancellationToken); + throw; } } } diff --git a/FileService.Application/StorageContracts/StorageDto.cs b/FileService.Application/StorageContracts/StorageDto.cs index 66c112c..db72d94 100644 --- a/FileService.Application/StorageContracts/StorageDto.cs +++ b/FileService.Application/StorageContracts/StorageDto.cs @@ -60,6 +60,7 @@ namespace FileService.Application.StorageContracts public sealed record PresignedUrl( string Url, + string Method, IReadOnlyDictionary Headers, DateTimeOffset ExpiresAt); diff --git a/FileService.Application/UploadFile/FileResponse.cs b/FileService.Application/UploadFile/FileResponse.cs index 98b2ec7..65f531f 100644 --- a/FileService.Application/UploadFile/FileResponse.cs +++ b/FileService.Application/UploadFile/FileResponse.cs @@ -12,12 +12,14 @@ namespace FileService.Application.UploadFile { public Guid Id { get; set; } public Guid OwnerId { get; set; } - public FileName FileName { get; set; } + public string FileName { get; set; } public long FileSize { get; set; } - public ContentType ContentType { get; set; } + public string ContentType { get; set; } public FileState State { get; set; } - public StorageLocation StorageLocation { get; set; } - public CheckSum CheckSum { get; set; } + public string CheckSum { get; set; } + public string? ChatType { get; set; } + public Guid? TargetId { get; set; } + public bool IsPublic { get; set; } public DateTimeOffset Created { get; set; } public DateTimeOffset Updated { get; set; } diff --git a/FileService.Application/UploadFile/IGroupAccessService.cs b/FileService.Application/UploadFile/IGroupAccessService.cs new file mode 100644 index 0000000..ba881a6 --- /dev/null +++ b/FileService.Application/UploadFile/IGroupAccessService.cs @@ -0,0 +1,19 @@ +using System.Net.Http.Json; + +namespace FileService.Application.UploadFile +{ + public interface IGroupAccessService + { + Task CheckMemberAsync(Guid userId, Guid groupId); + } + + public class GroupAccessService(HttpClient httpClient) : IGroupAccessService + { + public async Task CheckMemberAsync(Guid userId, Guid groupId) + { + var result = await httpClient.GetFromJsonAsync>( + $"api/groupmember/checkmember?userId={userId}&groupId={groupId}"); + return result?.Succeeded == true && result.Data; + } + } +} diff --git a/FileService.Application/UploadFile/UploadFileMapperConfig.cs b/FileService.Application/UploadFile/UploadFileMapperConfig.cs index 5a26bdb..cd6e07e 100644 --- a/FileService.Application/UploadFile/UploadFileMapperConfig.cs +++ b/FileService.Application/UploadFile/UploadFileMapperConfig.cs @@ -12,8 +12,11 @@ namespace FileService.Application.UploadFile public UploadFileMapperConfig() { CreateMap() + .ForMember(dest => dest.FileName, opt => opt.MapFrom(src => src.FileName.Value)) + .ForMember(dest => dest.ContentType, opt => opt.MapFrom(src => src.ContentType.Value)) + .ForMember(dest => dest.CheckSum, opt => opt.MapFrom(src => src.CheckSum.Value)) .ForMember(dest => dest.Created, opt => opt.MapFrom(src => src.CreationTime)) - .ForMember(dest => dest.Updated, opt => opt.MapFrom(src => src.ModificationTime)) + .ForMember(dest => dest.Updated, opt => opt.MapFrom(src => src.ModificationTime ?? src.CreationTime)) ; } } diff --git a/FileService.Application/UploadFile/UploadFileService.cs b/FileService.Application/UploadFile/UploadFileService.cs index 9670aa1..f6c1326 100644 --- a/FileService.Application/UploadFile/UploadFileService.cs +++ b/FileService.Application/UploadFile/UploadFileService.cs @@ -6,6 +6,7 @@ using FileService.Domain.ValueObjects; using IM.Commons; using IM.InitCommon; using Microsoft.Extensions.Options; +using System.Security.Cryptography; namespace FileService.Application.UploadFile { @@ -15,27 +16,35 @@ namespace FileService.Application.UploadFile private readonly IMapper mapper; private readonly IObjectStorageRouter router; private readonly IOptions options; + private readonly IGroupAccessService groupAccessService; public UploadFileService(IUploadFileReposity reposity, IMapper mapper, - IObjectStorageRouter router, IOptions options) + IObjectStorageRouter router, IOptions options, + IGroupAccessService groupAccessService) { this.reposity = reposity; this.mapper = mapper; this.router = router; this.options = options; + this.groupAccessService = groupAccessService; } - public async Task> GetFileInfoAsync(Guid id) + public async Task> GetFileInfoAsync(Guid id, Guid requesterId) { var file = await reposity.FindByIdAsync(id); if (file == null) { return Result.Fail(ResultCode.FILE_NOT_FOUND); } + if (!await CanAccessAsync(file, requesterId)) + { + return Result.Fail(ResultCode.PERMISSION_DENIED); + } var response = mapper.Map(file); response.Url = router.Route(file.StorageLocation.StorageProvider) .GetPublicUrl(file.StorageLocation); + response.IsPublic = response.IsPublic || response.Url != null; return Result.Success(response); } @@ -45,15 +54,29 @@ namespace FileService.Application.UploadFile /// public async Task> SimpleUploadAsync(SimpleUploadCommand command, CancellationToken token = default) { - // 秒传:相同 checksum 的文件已存在则直接返回已有记录 - if (!string.IsNullOrEmpty(command.CheckSum)) + var checksum = command.CheckSum; + if (string.IsNullOrWhiteSpace(checksum)) { - var existing = await reposity.FindByCheckSumGlobalAsync("md5", command.CheckSum); - if (existing != null) + var hash = await MD5.HashDataAsync(command.Content, token); + checksum = Convert.ToHexString(hash).ToLowerInvariant(); + if (command.Content.CanSeek) + { + command.Content.Position = 0; + } + } + // 秒传:相同 checksum 的文件已存在则直接返回已有记录 + if (!string.IsNullOrEmpty(checksum)) + { + var existing = await reposity.FindByCheckSumGlobalAsync("md5", checksum); + var existingPublicUrl = existing == null + ? null + : router.Route(existing.StorageLocation.StorageProvider).GetPublicUrl(existing.StorageLocation); + if (existing != null && (existingPublicUrl != null || + (!command.IsPublic && existing.OwnerId == command.OwnerId))) { var hit = mapper.Map(existing); - var hitStorage = router.Route(existing.StorageLocation.StorageProvider); - hit.Url = hitStorage.GetPublicUrl(existing.StorageLocation); + hit.Url = existingPublicUrl; + hit.IsPublic = hit.IsPublic || hit.Url != null; return Result.Success(hit); } } @@ -84,7 +107,8 @@ namespace FileService.Application.UploadFile fileSize: command.FileSize, contentType: command.ContentType, storageLocation: location, - checkSum: new CheckSum("md5", command.CheckSum ?? string.Empty)); + checkSum: new CheckSum("md5", checksum), + isPublic: command.IsPublic); reposity.Create(file); @@ -103,6 +127,10 @@ namespace FileService.Application.UploadFile { return Result.Fail(ResultCode.FILE_NOT_FOUND); } + if (!await CanAccessAsync(file, requesterId)) + { + return Result.Fail(ResultCode.PERMISSION_DENIED); + } var stream = await router.Route(file.StorageLocation.StorageProvider) .OpenReadAsync(file.StorageLocation, token); @@ -113,17 +141,33 @@ namespace FileService.Application.UploadFile file.FileName.Value)); } - // FileName 值对象限制 20 字符;原始名超长时安全截断(保留扩展名),真实文件名由 objectKey 保证唯一。 + private async Task CanAccessAsync(Domain.Entities.UploadFile file, Guid requesterId) + { + var publicUrl = router.Route(file.StorageLocation.StorageProvider).GetPublicUrl(file.StorageLocation); + if (file.IsPublic || publicUrl != null || file.OwnerId == requesterId) return true; + if (string.Equals(file.ChatType, "PRIVATE", StringComparison.OrdinalIgnoreCase)) + { + return file.TargetId == requesterId; + } + if (string.Equals(file.ChatType, "GROUP", StringComparison.OrdinalIgnoreCase) && file.TargetId.HasValue) + { + return await groupAccessService.CheckMemberAsync(requesterId, file.TargetId.Value); + } + return false; + } + + // 文件名超长时安全截断(保留扩展名),真实存储键由 objectKey 保证唯一。 private static FileName SafeFileName(string fileName) { - if (fileName.Length <= 20) + const int maxFileNameLength = 255; + if (fileName.Length <= maxFileNameLength) { return new FileName(fileName); } var ext = Path.GetExtension(fileName); var stem = Path.GetFileNameWithoutExtension(fileName); - var keep = Math.Max(0, 20 - ext.Length); + var keep = Math.Max(0, maxFileNameLength - ext.Length); return new FileName(stem[..Math.Min(stem.Length, keep)] + ext); } } diff --git a/FileService.Application/UploadFileTask/TaskInitResponse.cs b/FileService.Application/UploadFileTask/TaskInitResponse.cs index 0edd39b..0e033a1 100644 --- a/FileService.Application/UploadFileTask/TaskInitResponse.cs +++ b/FileService.Application/UploadFileTask/TaskInitResponse.cs @@ -13,5 +13,10 @@ namespace FileService.Application.UploadFileTask public string UploadSessionId { get; init; } public StorageLocation StorageLocation { get; init; } + public bool Instant { get; init; } + public string UploadMode { get; init; } = "LocalMultipart"; + public int TotalPartCount { get; init; } + public long PartSizeBytes { get; init; } + public global::FileService.Application.UploadFile.FileResponse? File { get; init; } } } diff --git a/FileService.Application/UploadFileTask/UploadFileTaskService.cs b/FileService.Application/UploadFileTask/UploadFileTaskService.cs index d8b679c..3e731c0 100644 --- a/FileService.Application/UploadFileTask/UploadFileTaskService.cs +++ b/FileService.Application/UploadFileTask/UploadFileTaskService.cs @@ -33,14 +33,22 @@ namespace FileService.Application.UploadFileTask // 秒传:相同 checksum 的文件若已存在于已完成文件表,直接返回已有记录 var existingFile = await uploadFileReposity.FindByCheckSumGlobalAsync("md5", command.checkSum); - if (existingFile != null) + if (existingFile != null && CanReuse(existingFile, command)) { var storageForResponse = router.Route(existingFile.StorageLocation.StorageProvider); + var fileResponse = mapper.Map(existingFile); + fileResponse.Url = storageForResponse.GetPublicUrl(existingFile.StorageLocation); + fileResponse.IsPublic = fileResponse.IsPublic || fileResponse.Url != null; return Result.Success(new TaskInitResponse { TaskId = existingFile.Id, UploadSessionId = existingFile.Id.ToString(), - StorageLocation = existingFile.StorageLocation + StorageLocation = existingFile.StorageLocation, + Instant = true, + UploadMode = "Instant", + TotalPartCount = 0, + PartSizeBytes = 0, + File = fileResponse }); } @@ -70,6 +78,18 @@ namespace FileService.Application.UploadFileTask var res = mapper.Map(initRes); res.TaskId = task.Id; + res = new TaskInitResponse + { + TaskId = task.Id, + UploadSessionId = initRes.UploadSessionId, + StorageLocation = initRes.Location, + Instant = false, + UploadMode = string.Equals(storage.ProviderCode, "Local", StringComparison.OrdinalIgnoreCase) + ? "LocalMultipart" + : "Presigned", + TotalPartCount = totalPartCount, + PartSizeBytes = storageOption.DefaultPartSizeBytes + }; task.StartUpload(); reposity.Create(task); @@ -97,6 +117,12 @@ namespace FileService.Application.UploadFileTask return Result.Fail(ResultCode.CHUNK_NOT_FOUND); } + var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId)); + if (task is null || task.UploaderId != userId) + { + return Result.Fail(ResultCode.PERMISSION_DENIED); + } + if (taskCache.TotalPartCount < partNum || partNum < 1) { return Result.Fail(ResultCode.INVALID_PART_NUMBER); @@ -123,24 +149,58 @@ namespace FileService.Application.UploadFileTask return Result.Fail(ResultCode.CHUNK_NOT_FOUND); } + var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId)); + if (task is null) + { + return Result.Fail(ResultCode.CHUNK_NOT_FOUND); + } + if (task.UploaderId != command.userId) + { + return Result.Fail(ResultCode.PERMISSION_DENIED); + } + // 校验分片数量必须匹配 if (command.Parts.Count != taskCache.TotalPartCount) { return Result.Fail(ResultCode.PART_COUNT_MISMATCH); } - // 校验所有分片都已在上传缓存中注册 - foreach (var part in command.Parts) + var expectedPartNumbers = Enumerable.Range(1, taskCache.TotalPartCount).ToHashSet(); + if (!expectedPartNumbers.SetEquals(command.Parts.Select(x => x.PartNumber))) { - if (!taskCache.Parts.TryGetValue(part.PartNumber, out _)) + return Result.Fail(ResultCode.INVALID_PART_NUMBER); + } + + // 本地分片必须由本服务接收;预签名模式由对象存储在完成合并时校验 ETag。 + if (string.Equals(taskCache.ProviderCode, "Local", StringComparison.OrdinalIgnoreCase)) + { + foreach (var part in command.Parts) { - return Result.Fail(ResultCode.CHUNK_NOT_FOUND); + if (!taskCache.Parts.TryGetValue(part.PartNumber, out _)) + { + return Result.Fail(ResultCode.CHUNK_NOT_FOUND); + } } } - var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId)); + if (task.State == Domain.UploadTaskState.Completed) + { + var completedResponse = mapper.Map(task); + if (task.ResultFileId.HasValue) + { + var file = await uploadFileReposity.FindByIdAsync(task.ResultFileId.Value); + if (file != null) + { + completedResponse.File = mapper.Map(file); + completedResponse.File.Url = router.Route(file.StorageLocation.StorageProvider) + .GetPublicUrl(file.StorageLocation); + completedResponse.File.IsPublic = completedResponse.File.IsPublic || completedResponse.File.Url != null; + } + } + return Result.Success(completedResponse); + } - task.CompleteUpload(new Domain.ValueObjects.StorageLocation( + task.StartMerging(new Domain.ValueObjects.StorageLocation( taskCache.ProviderCode, taskCache.Bucket, taskCache.ObjectKey, taskCache.Region )); @@ -162,19 +222,26 @@ namespace FileService.Application.UploadFileTask FileSize = task.FileSize, ContentType = task.ContentType.ToString(), CheckSun = task.CheckSum.Value + ,ChatType = task.ChatType + ,TargetId = task.TargetId }, cancellationToken); return Result.Success(mapper.Map(task)); } - public async Task> UploadPartAsync(UploadPartCommand command) + public async Task> UploadPartAsync(UploadPartCommand command, Guid userId) { var taskCache = await redis.GetAsync(command.SessionId); if (taskCache is null) { return Result.Fail(ResultCode.CHUNK_NOT_FOUND); } + var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId)); + if (task is null || task.UploaderId != userId) + { + return Result.Fail(ResultCode.PERMISSION_DENIED); + } var minPartSize = options.Value.Providers[options.Value.DefaultProviderCode].MinPartSizeBytes; @@ -213,6 +280,11 @@ namespace FileService.Application.UploadFileTask { return Result.Fail(ResultCode.CHUNK_NOT_FOUND); } + var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId)); + if (task is null || task.UploaderId != userId) + { + return Result.Fail(ResultCode.PERMISSION_DENIED); + } var response = new UploadProgressResponse { @@ -229,5 +301,54 @@ namespace FileService.Application.UploadFileTask return Result.Success(response); } + + private bool CanReuse(Domain.Entities.UploadFile file, UploadTaskInitCommand command) + { + var publicUrl = router.Route(file.StorageLocation.StorageProvider).GetPublicUrl(file.StorageLocation); + if (file.IsPublic || publicUrl != null) return true; + if (file.OwnerId == command.UploaderId) + { + return string.Equals(file.ChatType, command.ChatType, StringComparison.OrdinalIgnoreCase) && + file.TargetId == command.TargetId; + } + if (string.Equals(file.ChatType, "GROUP", StringComparison.OrdinalIgnoreCase)) + { + return string.Equals(command.ChatType, "GROUP", StringComparison.OrdinalIgnoreCase) && + file.TargetId == command.TargetId; + } + if (string.Equals(file.ChatType, "PRIVATE", StringComparison.OrdinalIgnoreCase)) + { + return string.Equals(command.ChatType, "PRIVATE", StringComparison.OrdinalIgnoreCase) && + file.TargetId == command.UploaderId && command.TargetId == file.OwnerId; + } + return false; + } + + public async Task> GetStatusAsync(Guid taskId, Guid userId) + { + var task = await reposity.FindByIdAsync(taskId); + if (task is null) + { + return Result.Fail(ResultCode.CHUNK_NOT_FOUND); + } + if (task.UploaderId != userId) + { + return Result.Fail(ResultCode.PERMISSION_DENIED); + } + + var response = mapper.Map(task); + if (task.ResultFileId.HasValue) + { + var file = await uploadFileReposity.FindByIdAsync(task.ResultFileId.Value); + if (file != null) + { + response.File = mapper.Map(file); + response.File.Url = router.Route(file.StorageLocation.StorageProvider) + .GetPublicUrl(file.StorageLocation); + response.File.IsPublic = response.File.IsPublic || response.File.Url != null; + } + } + return Result.Success(response); + } } } diff --git a/FileService.Application/UploadFileTask/UploadTaskInitCommand.cs b/FileService.Application/UploadFileTask/UploadTaskInitCommand.cs index 083f7b1..c710025 100644 --- a/FileService.Application/UploadFileTask/UploadTaskInitCommand.cs +++ b/FileService.Application/UploadFileTask/UploadTaskInitCommand.cs @@ -8,7 +8,7 @@ namespace FileService.Application.UploadFileTask { public record UploadTaskInitCommand( Guid UploaderId, - Guid ConversationId, string FileName, + Guid ConversationId, string? ChatType, Guid? TargetId, string FileName, long FileSize,string contentType, string checkSum ) @@ -17,7 +17,7 @@ namespace FileService.Application.UploadFileTask { return new Domain.Entities.UploadTask( UploaderId, - ConversationId, FileName, FileSize, contentType, + ConversationId, ChatType, TargetId, FileName, FileSize, contentType, null,new Domain.ValueObjects.CheckSum("md5", checkSum) ); } diff --git a/FileService.Application/UploadFileTask/UploadTaskResponse.cs b/FileService.Application/UploadFileTask/UploadTaskResponse.cs index d9ed42f..5a5e784 100644 --- a/FileService.Application/UploadFileTask/UploadTaskResponse.cs +++ b/FileService.Application/UploadFileTask/UploadTaskResponse.cs @@ -19,5 +19,8 @@ namespace FileService.Application.UploadFileTask public StorageLocation StorageLocation { get; set; } public string State { get; set; } public string CheckSum { get; set; } + public Guid? ResultFileId { get; set; } + public string? FailureReason { get; set; } + public global::FileService.Application.UploadFile.FileResponse? File { get; set; } } } diff --git a/FileService.Domain/Entities/UploadFile.cs b/FileService.Domain/Entities/UploadFile.cs index cf8d06a..b8de288 100644 --- a/FileService.Domain/Entities/UploadFile.cs +++ b/FileService.Domain/Entities/UploadFile.cs @@ -12,10 +12,14 @@ namespace FileService.Domain.Entities public FileState State { get; private set; } = FileState.Uploaded; public StorageLocation StorageLocation { get; private set; } = new StorageLocation(); public CheckSum CheckSum { get; private set; } + public Guid? SourceTaskId { get; private set; } + public string? ChatType { get; private set; } + public Guid? TargetId { get; private set; } + public bool IsPublic { get; private set; } private UploadFile() { } - public UploadFile(Guid ownerId, FileName fileName, long fileSize, ContentType contentType, StorageLocation? storageLocation, CheckSum checkSum) + public UploadFile(Guid ownerId, FileName fileName, long fileSize, ContentType contentType, StorageLocation? storageLocation, CheckSum checkSum, Guid? sourceTaskId = null, string? chatType = null, Guid? targetId = null, bool isPublic = false) { OwnerId = ownerId; FileName = fileName; @@ -23,6 +27,10 @@ namespace FileService.Domain.Entities ContentType = contentType; StorageLocation = storageLocation ?? new StorageLocation(); CheckSum = checkSum; + SourceTaskId = sourceTaskId; + ChatType = chatType?.ToUpperInvariant(); + TargetId = targetId; + IsPublic = isPublic; State = FileState.Uploaded; } diff --git a/FileService.Domain/Entities/UploadTask.cs b/FileService.Domain/Entities/UploadTask.cs index c368987..d19d7c1 100644 --- a/FileService.Domain/Entities/UploadTask.cs +++ b/FileService.Domain/Entities/UploadTask.cs @@ -8,19 +8,25 @@ namespace FileService.Domain.Entities { public Guid UploaderId { get; private set; } public Guid ConversationId { get; private set; } + public string? ChatType { get; private set; } + public Guid? TargetId { get; private set; } public FileName FileName { get; private set; } public long FileSize { get; private set; } public ContentType ContentType { get; private set; } public StorageLocation StorageLocation { get; private set; } public UploadTaskState State { get; private set; } public CheckSum CheckSum { get; private set; } + public Guid? ResultFileId { get; private set; } + public string? FailureReason { get; private set; } private UploadTask() { } - public UploadTask(Guid uploaderId, Guid conversationId, FileName fileName, long fileSize, ContentType contentType, StorageLocation? storageLocation, CheckSum checkSum) + public UploadTask(Guid uploaderId, Guid conversationId, string? chatType, Guid? targetId, FileName fileName, long fileSize, ContentType contentType, StorageLocation? storageLocation, CheckSum checkSum) { UploaderId = uploaderId; ConversationId = conversationId; + ChatType = chatType?.ToUpperInvariant(); + TargetId = targetId; FileName = fileName; FileSize = fileSize; ContentType = contentType; @@ -33,16 +39,28 @@ namespace FileService.Domain.Entities State = UploadTaskState.Uploading; } - public void CompleteUpload(StorageLocation location) + public void StartMerging(StorageLocation location) { + StorageLocation = location; + State = UploadTaskState.Merging; + FailureReason = null; + NotifyModified(); + } + + public void CompleteUpload(Guid fileId) + { + ResultFileId = fileId; State = UploadTaskState.Completed; + FailureReason = null; + NotifyModified(); AddDomainEvent(new UploadTaskCompletedDomainEvent(this)); } - public void Fail() + public void Fail(string reason) { State = UploadTaskState.Failed; - + FailureReason = reason.Length > 500 ? reason[..500] : reason; + NotifyModified(); } } } diff --git a/FileService.Domain/IReposities/IUploadFileReposity.cs b/FileService.Domain/IReposities/IUploadFileReposity.cs index cea61d4..244ec1b 100644 --- a/FileService.Domain/IReposities/IUploadFileReposity.cs +++ b/FileService.Domain/IReposities/IUploadFileReposity.cs @@ -19,5 +19,6 @@ namespace FileService.Domain.IReposities /// 全局按 checksum 查询(用于跨用户秒传去重) /// Task FindByCheckSumGlobalAsync(string algorithm, string value); + Task FindBySourceTaskIdAsync(Guid taskId); } } diff --git a/FileService.Domain/ValueObjects/FileName.cs b/FileService.Domain/ValueObjects/FileName.cs index cd086de..2a87eba 100644 --- a/FileService.Domain/ValueObjects/FileName.cs +++ b/FileService.Domain/ValueObjects/FileName.cs @@ -12,9 +12,9 @@ namespace FileService.Domain.ValueObjects public FileName(string value) { - if(value.Length > 20) + if (string.IsNullOrWhiteSpace(value) || value.Length > 255) { - throw new ArgumentException("文件名超出长度"); + throw new ArgumentException("文件名不能为空且不能超过 255 个字符"); } Value = value; diff --git a/FileService.Infrastructure/Configs/UploadFileConfig.cs b/FileService.Infrastructure/Configs/UploadFileConfig.cs index dcc0fd3..34ae306 100644 --- a/FileService.Infrastructure/Configs/UploadFileConfig.cs +++ b/FileService.Infrastructure/Configs/UploadFileConfig.cs @@ -15,12 +15,14 @@ namespace FileService.Infrastructure.Configs { builder.ToTable("upload_files"); builder.Property(x => x.FileName) + .HasMaxLength(255) .HasConversion( a => a.Value, b => new Domain.ValueObjects.FileName(b) ); builder.Property(x => x.ContentType) + .HasMaxLength(255) .HasConversion( a => a.Value, b => new Domain.ValueObjects.ContentType(b) @@ -29,24 +31,32 @@ namespace FileService.Infrastructure.Configs builder.ComplexProperty(x => x.CheckSum, c => { c.Property(p => p.Value) + .HasMaxLength(128) .HasColumnName("checksum_value"); c.Property(p => p.Algorithm) + .HasMaxLength(16) .HasColumnName("checksum_algorithm"); }); + builder.HasIndex(x => x.SourceTaskId).IsUnique(); + builder.Property(x => x.ChatType).HasMaxLength(16); builder.ComplexProperty(x => x.StorageLocation, c => { c.Property(p => p.StorageProvider) + .HasMaxLength(64) .HasColumnName("storage_provider"); c.Property(p => p.ObjectKey) + .HasMaxLength(1024) .HasColumnName("storage_key"); c.Property(p => p.Region) + .HasMaxLength(128) .HasColumnName("storage_region"); c.Property(p => p.Bucket) + .HasMaxLength(255) .HasColumnName("storage_bucket"); }); } diff --git a/FileService.Infrastructure/Configs/UploadTaskConfig.cs b/FileService.Infrastructure/Configs/UploadTaskConfig.cs index 737aa4c..3e62b06 100644 --- a/FileService.Infrastructure/Configs/UploadTaskConfig.cs +++ b/FileService.Infrastructure/Configs/UploadTaskConfig.cs @@ -15,12 +15,14 @@ namespace FileService.Infrastructure.Configs { builder.ToTable("upload_tasks"); builder.Property(x => x.FileName) + .HasMaxLength(255) .HasConversion( a => a.Value, b => new Domain.ValueObjects.FileName(b) ); builder.Property(x => x.ContentType) + .HasMaxLength(255) .HasConversion( a => a.Value, b => new Domain.ValueObjects.ContentType(b) @@ -29,24 +31,32 @@ namespace FileService.Infrastructure.Configs builder.ComplexProperty(x => x.CheckSum, c => { c.Property(p => p.Value) + .HasMaxLength(128) .HasColumnName("checksum_value"); c.Property(p => p.Algorithm) + .HasMaxLength(16) .HasColumnName("checksum_algorithm"); }); + builder.Property(x => x.FailureReason).HasMaxLength(500); + builder.Property(x => x.ChatType).HasMaxLength(16); builder.ComplexProperty(x => x.StorageLocation, c => { c.Property(p => p.StorageProvider) + .HasMaxLength(64) .HasColumnName("storage_provider"); c.Property(p => p.ObjectKey) + .HasMaxLength(1024) .HasColumnName("storage_key"); c.Property(p => p.Region) + .HasMaxLength(128) .HasColumnName("storage_region"); c.Property(p => p.Bucket) + .HasMaxLength(255) .HasColumnName("storage_bucket"); }); } diff --git a/FileService.Infrastructure/Migrations/20260909000300_AsyncUploadResult.cs b/FileService.Infrastructure/Migrations/20260909000300_AsyncUploadResult.cs new file mode 100644 index 0000000..f822acf --- /dev/null +++ b/FileService.Infrastructure/Migrations/20260909000300_AsyncUploadResult.cs @@ -0,0 +1,131 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace FileService.Infrastructure.Migrations +{ + [DbContext(typeof(FileDbContext))] + [Migration("20260909000300_AsyncUploadResult")] + public partial class AsyncUploadResult : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + AlterStringColumns(migrationBuilder, narrowing: true); + + migrationBuilder.AddColumn( + name: "ChatType", + table: "upload_files", + type: "varchar(16)", + maxLength: 16, + nullable: true); + + migrationBuilder.AddColumn( + name: "IsPublic", + table: "upload_files", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "SourceTaskId", + table: "upload_files", + type: "char(36)", + nullable: true); + + migrationBuilder.AddColumn( + name: "TargetId", + table: "upload_files", + type: "char(36)", + nullable: true); + + migrationBuilder.AddColumn( + name: "ChatType", + table: "upload_tasks", + type: "varchar(16)", + maxLength: 16, + nullable: true); + + migrationBuilder.AddColumn( + name: "FailureReason", + table: "upload_tasks", + type: "varchar(500)", + maxLength: 500, + nullable: true); + + migrationBuilder.AddColumn( + name: "ResultFileId", + table: "upload_tasks", + type: "char(36)", + nullable: true); + + migrationBuilder.AddColumn( + name: "TargetId", + table: "upload_tasks", + type: "char(36)", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_upload_files_SourceTaskId", + table: "upload_files", + column: "SourceTaskId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_upload_files_checksum", + table: "upload_files", + columns: new[] { "checksum_algorithm", "checksum_value", "IsDeleted" }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex("IX_upload_files_checksum", "upload_files"); + migrationBuilder.DropIndex("IX_upload_files_SourceTaskId", "upload_files"); + migrationBuilder.DropColumn("ChatType", "upload_files"); + migrationBuilder.DropColumn("IsPublic", "upload_files"); + migrationBuilder.DropColumn("SourceTaskId", "upload_files"); + migrationBuilder.DropColumn("TargetId", "upload_files"); + migrationBuilder.DropColumn("ChatType", "upload_tasks"); + migrationBuilder.DropColumn("FailureReason", "upload_tasks"); + migrationBuilder.DropColumn("ResultFileId", "upload_tasks"); + migrationBuilder.DropColumn("TargetId", "upload_tasks"); + AlterStringColumns(migrationBuilder, narrowing: false); + } + + + private static void AlterStringColumns(MigrationBuilder migrationBuilder, bool narrowing) + { + var columns = new (string Table, string Column, int Length, bool Nullable)[] + { + ("upload_files", "FileName", 255, false), + ("upload_files", "ContentType", 255, false), + ("upload_files", "checksum_algorithm", 16, false), + ("upload_files", "checksum_value", 128, false), + ("upload_files", "storage_provider", 64, false), + ("upload_files", "storage_bucket", 255, false), + ("upload_files", "storage_key", 1024, false), + ("upload_files", "storage_region", 128, true), + ("upload_tasks", "FileName", 255, false), + ("upload_tasks", "ContentType", 255, false), + ("upload_tasks", "checksum_algorithm", 16, false), + ("upload_tasks", "checksum_value", 128, false), + ("upload_tasks", "storage_provider", 64, false), + ("upload_tasks", "storage_bucket", 255, false), + ("upload_tasks", "storage_key", 1024, false), + ("upload_tasks", "storage_region", 128, true) + }; + + foreach (var (table, column, length, nullable) in columns) + { + migrationBuilder.AlterColumn( + name: column, + table: table, + type: narrowing ? $"varchar({length})" : "longtext", + maxLength: narrowing ? length : null, + nullable: nullable, + oldClrType: typeof(string), + oldType: narrowing ? "longtext" : $"varchar({length})", + oldMaxLength: narrowing ? null : length, + oldNullable: nullable); + } + } + } +} diff --git a/FileService.Infrastructure/Migrations/FileDbContextModelSnapshot.cs b/FileService.Infrastructure/Migrations/FileDbContextModelSnapshot.cs index 72719ce..5b815b9 100644 --- a/FileService.Infrastructure/Migrations/FileDbContextModelSnapshot.cs +++ b/FileService.Infrastructure/Migrations/FileDbContextModelSnapshot.cs @@ -25,13 +25,18 @@ namespace FileService.Infrastructure.Migrations modelBuilder.Entity("FileService.Domain.Entities.UploadFile", b => { + b.Property("ChatType") + .HasMaxLength(16) + .HasColumnType("varchar(16)"); + b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("char(36)"); b.Property("ContentType") .IsRequired() - .HasColumnType("longtext"); + .HasMaxLength(255) + .HasColumnType("varchar(255)"); b.Property("CreationTime") .HasColumnType("datetime(6)"); @@ -41,7 +46,8 @@ namespace FileService.Infrastructure.Migrations b.Property("FileName") .IsRequired() - .HasColumnType("longtext"); + .HasMaxLength(255) + .HasColumnType("varchar(255)"); b.Property("FileSize") .HasColumnType("bigint"); @@ -49,27 +55,38 @@ namespace FileService.Infrastructure.Migrations b.Property("IsDeleted") .HasColumnType("tinyint(1)"); + b.Property("IsPublic") + .HasColumnType("tinyint(1)"); + b.Property("ModificationTime") .HasColumnType("datetime(6)"); b.Property("OwnerId") .HasColumnType("char(36)"); + b.Property("SourceTaskId") + .HasColumnType("char(36)"); + b.Property("State") .HasColumnType("int"); + b.Property("TargetId") + .HasColumnType("char(36)"); + b.ComplexProperty>("CheckSum", "FileService.Domain.Entities.UploadFile.CheckSum#CheckSum", b1 => { b1.IsRequired(); b1.Property("Algorithm") .IsRequired() - .HasColumnType("longtext") + .HasMaxLength(16) + .HasColumnType("varchar(16)") .HasColumnName("checksum_algorithm"); b1.Property("Value") .IsRequired() - .HasColumnType("longtext") + .HasMaxLength(128) + .HasColumnType("varchar(128)") .HasColumnName("checksum_value"); }); @@ -79,38 +96,50 @@ namespace FileService.Infrastructure.Migrations b1.Property("Bucket") .IsRequired() - .HasColumnType("longtext") + .HasMaxLength(255) + .HasColumnType("varchar(255)") .HasColumnName("storage_bucket"); b1.Property("ObjectKey") .IsRequired() - .HasColumnType("longtext") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)") .HasColumnName("storage_key"); b1.Property("Region") - .HasColumnType("longtext") + .HasMaxLength(128) + .HasColumnType("varchar(128)") .HasColumnName("storage_region"); b1.Property("StorageProvider") .IsRequired() - .HasColumnType("longtext") + .HasMaxLength(64) + .HasColumnType("varchar(64)") .HasColumnName("storage_provider"); }); b.HasKey("Id"); + b.HasIndex("SourceTaskId") + .IsUnique(); + b.ToTable("upload_files", (string)null); }); modelBuilder.Entity("FileService.Domain.Entities.UploadTask", b => { + b.Property("ChatType") + .HasMaxLength(16) + .HasColumnType("varchar(16)"); + b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("char(36)"); b.Property("ContentType") .IsRequired() - .HasColumnType("longtext"); + .HasMaxLength(255) + .HasColumnType("varchar(255)"); b.Property("ConversationId") .HasColumnType("char(36)"); @@ -123,20 +152,31 @@ namespace FileService.Infrastructure.Migrations b.Property("FileName") .IsRequired() - .HasColumnType("longtext"); + .HasMaxLength(255) + .HasColumnType("varchar(255)"); b.Property("FileSize") .HasColumnType("bigint"); + b.Property("FailureReason") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + b.Property("IsDeleted") .HasColumnType("tinyint(1)"); b.Property("ModificationTime") .HasColumnType("datetime(6)"); + b.Property("ResultFileId") + .HasColumnType("char(36)"); + b.Property("State") .HasColumnType("int"); + b.Property("TargetId") + .HasColumnType("char(36)"); + b.Property("UploaderId") .HasColumnType("char(36)"); @@ -146,12 +186,14 @@ namespace FileService.Infrastructure.Migrations b1.Property("Algorithm") .IsRequired() - .HasColumnType("longtext") + .HasMaxLength(16) + .HasColumnType("varchar(16)") .HasColumnName("checksum_algorithm"); b1.Property("Value") .IsRequired() - .HasColumnType("longtext") + .HasMaxLength(128) + .HasColumnType("varchar(128)") .HasColumnName("checksum_value"); }); @@ -161,21 +203,25 @@ namespace FileService.Infrastructure.Migrations b1.Property("Bucket") .IsRequired() - .HasColumnType("longtext") + .HasMaxLength(255) + .HasColumnType("varchar(255)") .HasColumnName("storage_bucket"); b1.Property("ObjectKey") .IsRequired() - .HasColumnType("longtext") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)") .HasColumnName("storage_key"); b1.Property("Region") - .HasColumnType("longtext") + .HasMaxLength(128) + .HasColumnType("varchar(128)") .HasColumnName("storage_region"); b1.Property("StorageProvider") .IsRequired() - .HasColumnType("longtext") + .HasMaxLength(64) + .HasColumnType("varchar(64)") .HasColumnName("storage_provider"); }); diff --git a/FileService.Infrastructure/Reposites/UploadFileReposity.cs b/FileService.Infrastructure/Reposites/UploadFileReposity.cs index c55c0d2..3d9a957 100644 --- a/FileService.Infrastructure/Reposites/UploadFileReposity.cs +++ b/FileService.Infrastructure/Reposites/UploadFileReposity.cs @@ -42,5 +42,10 @@ namespace FileService.Infrastructure.Reposites x.CheckSum.Value == value ); } + + public Task FindBySourceTaskIdAsync(Guid taskId) + { + return db.Files.FirstOrDefaultAsync(x => x.SourceTaskId == taskId); + } } } diff --git a/FileService.Infrastructure/Storage/LocalStorageAdapter.cs b/FileService.Infrastructure/Storage/LocalStorageAdapter.cs index 117ed54..62efb44 100644 --- a/FileService.Infrastructure/Storage/LocalStorageAdapter.cs +++ b/FileService.Infrastructure/Storage/LocalStorageAdapter.cs @@ -132,6 +132,7 @@ namespace FileService.Infrastructure.Storage var baseUrl = options.Value.Providers[options.Value.DefaultProviderCode].LocalUploadApiBaseUrl; return new PresignedUrl( baseUrl + $"local/parts/upload?sessionId={command.UploadSessionId}&partNumber={command.PartNumber}", + "POST", new Dictionary(), ExpiresAt: DateTimeOffset.Now.Add(options.Value.Providers[options.Value.DefaultProviderCode].UploadUrlExpiresIn) ); diff --git a/FileService.WebApi/Controllers/File/FileController.cs b/FileService.WebApi/Controllers/File/FileController.cs index ae4bdb4..3fbb52f 100644 --- a/FileService.WebApi/Controllers/File/FileController.cs +++ b/FileService.WebApi/Controllers/File/FileController.cs @@ -1,6 +1,7 @@ using FileService.Application.UploadFile; using FileService.Infrastructure; using IM.ASPNETCore; +using IM.Commons; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -51,7 +52,8 @@ namespace FileService.WebApi.Controllers.File [HttpGet("{id}")] public async Task Get(Guid id) { - var res = await service.GetFileInfoAsync(id); + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var res = await service.GetFileInfoAsync(id, Guid.Parse(userId)); return Ok(res); } @@ -65,11 +67,15 @@ namespace FileService.WebApi.Controllers.File var res = await service.OpenDownloadAsync(id, Guid.Parse(userId)); if (res.Data == null) { + if (res.Code == (int)ResultCode.PERMISSION_DENIED) + { + return StatusCode(StatusCodes.Status403Forbidden, res); + } return NotFound(res); } Response.Headers["Cache-Control"] = "private,max-age=86400"; - return File(res.Data.Content, res.Data.ContentType); + return File(res.Data.Content, res.Data.ContentType, enableRangeProcessing: true); } } } diff --git a/FileService.WebApi/Controllers/FileTask/CompleteTaskRequest.cs b/FileService.WebApi/Controllers/FileTask/CompleteTaskRequest.cs index c79b5ce..5866a84 100644 --- a/FileService.WebApi/Controllers/FileTask/CompleteTaskRequest.cs +++ b/FileService.WebApi/Controllers/FileTask/CompleteTaskRequest.cs @@ -5,8 +5,8 @@ namespace FileService.WebApi.Controllers.FileTask { public class CompleteTaskRequest { - public string SessionId { get; set; } - public List Parts { get; set; } + public string SessionId { get; set; } = string.Empty; + public List Parts { get; set; } = []; } public class CompleteTaskRequestValidator : AbstractValidator diff --git a/FileService.WebApi/Controllers/FileTask/FileTaskController.cs b/FileService.WebApi/Controllers/FileTask/FileTaskController.cs index 28f1342..18392a0 100644 --- a/FileService.WebApi/Controllers/FileTask/FileTaskController.cs +++ b/FileService.WebApi/Controllers/FileTask/FileTaskController.cs @@ -28,6 +28,8 @@ namespace FileService.WebApi.Controllers.FileTask var res = await service.InitTaskAsync(new UploadTaskInitCommand( UploaderId: Guid.Parse(userId), ConversationId: request.ConversationId, + ChatType: request.ChatType, + TargetId: request.TargetId, FileName: request.FileName, FileSize: request.FileSize, contentType: request.ContentType, @@ -44,6 +46,13 @@ namespace FileService.WebApi.Controllers.FileTask return Ok(res); } + [HttpGet("status")] + public async Task Status(Guid taskId) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + return Ok(await service.GetStatusAsync(taskId, Guid.Parse(userId))); + } + [HttpGet("Getuploadurl")] public async Task GetUploadUrl(string sessionId, int partNum) { @@ -53,19 +62,20 @@ namespace FileService.WebApi.Controllers.FileTask } [HttpPost("complete")] + [UnitOfWork(typeof(FileDbContext))] public async Task Complete([FromBody] CompleteTaskRequest request) { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); var res = await service.CompleteTaskAsync(new UploadTaskCompleteCommand(request.SessionId, Guid.Parse(userId), request.Parts)); - return Ok(res); + return res.Succeeded ? Accepted(res) : Ok(res); } [HttpPost("local/parts/upload")] public async Task LocalUpload(string sessionId, int partNumber, IFormFile file) { - //var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); var stream = file.OpenReadStream(); - var res = await service.UploadPartAsync(new UploadPartCommand(stream, sessionId, partNumber, file.Length)); + var res = await service.UploadPartAsync(new UploadPartCommand(stream, sessionId, partNumber, file.Length), Guid.Parse(userId)); return Ok(res); } } diff --git a/FileService.WebApi/Controllers/FileTask/FileTaskInitRequest.cs b/FileService.WebApi/Controllers/FileTask/FileTaskInitRequest.cs index 2918aff..77f0b40 100644 --- a/FileService.WebApi/Controllers/FileTask/FileTaskInitRequest.cs +++ b/FileService.WebApi/Controllers/FileTask/FileTaskInitRequest.cs @@ -5,14 +5,30 @@ namespace FileService.WebApi.Controllers.FileTask public class FileTaskInitRequest { public Guid ConversationId { get; set; } - public string FileName { get; set; } + public string? ChatType { get; set; } + public Guid? TargetId { get; set; } + public string FileName { get; set; } = string.Empty; public long FileSize { get; set; } - public string ContentType { get; set; } - public string CheckSum { get; set; } + public string ContentType { get; set; } = string.Empty; + public string CheckSum { get; set; } = string.Empty; } public class FileTaskInitRequestValidator: AbstractValidator { - + public FileTaskInitRequestValidator() + { + RuleFor(x => x.FileName).NotEmpty().MaximumLength(255); + RuleFor(x => x.FileSize).GreaterThan(0); + RuleFor(x => x.ContentType).NotEmpty().MaximumLength(255); + RuleFor(x => x.CheckSum).NotEmpty().MaximumLength(128); + RuleFor(x => x.ChatType) + .Must(value => string.IsNullOrWhiteSpace(value) || + value.Equals("PRIVATE", StringComparison.OrdinalIgnoreCase) || + value.Equals("GROUP", StringComparison.OrdinalIgnoreCase)) + .WithMessage("chatType 必须为 PRIVATE 或 GROUP"); + RuleFor(x => x.TargetId) + .NotEmpty() + .When(x => !string.IsNullOrWhiteSpace(x.ChatType)); + } } } diff --git a/FileService.WebApi/ModueInit.cs b/FileService.WebApi/ModueInit.cs index 14f8d9f..cadc035 100644 --- a/FileService.WebApi/ModueInit.cs +++ b/FileService.WebApi/ModueInit.cs @@ -9,6 +9,17 @@ namespace FileService.WebApi public void Initialize(IServiceCollection services) { services.AddScoped(); + services.AddHttpClient((sp, client) => + { + var configuration = sp.GetRequiredService(); + client.BaseAddress = new Uri(configuration["InternalServices:GroupServiceBaseUrl"] + ?? "http://im-group-service:8080/"); + var internalApiKey = configuration["InternalApiKey"]; + if (!string.IsNullOrWhiteSpace(internalApiKey)) + { + client.DefaultRequestHeaders.Add("X-Internal-Api-Key", internalApiKey); + } + }); } } } diff --git a/FileService.WebApi/appsettings.Development.json b/FileService.WebApi/appsettings.Development.json index 0c208ae..69768ca 100644 --- a/FileService.WebApi/appsettings.Development.json +++ b/FileService.WebApi/appsettings.Development.json @@ -1,4 +1,8 @@ { + "InternalApiKey": "development-only-change-me", + "InternalServices": { + "GroupServiceBaseUrl": "http://localhost:5070/" + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/FileService.WebApi/appsettings.json b/FileService.WebApi/appsettings.json index 10f68b8..5add975 100644 --- a/FileService.WebApi/appsettings.json +++ b/FileService.WebApi/appsettings.json @@ -5,5 +5,9 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "InternalApiKey": "", + "InternalServices": { + "GroupServiceBaseUrl": "http://im-group-service:8080/" + } } diff --git a/GroupService.Domain/Entities/GroupMember.cs b/GroupService.Domain/Entities/GroupMember.cs index 158c730..428b79e 100644 --- a/GroupService.Domain/Entities/GroupMember.cs +++ b/GroupService.Domain/Entities/GroupMember.cs @@ -49,5 +49,12 @@ namespace GroupService.Domain.Entities { GroupNickName = nickname; } + + public void Leave() + { + if (IsDeleted) return; + SoftDelete(); + AddDomainEvent(new GroupMemberLeftDomainEvent(this)); + } } } diff --git a/GroupService.Domain/Events/GroupMemberLeftDomainEvent.cs b/GroupService.Domain/Events/GroupMemberLeftDomainEvent.cs new file mode 100644 index 0000000..9b53687 --- /dev/null +++ b/GroupService.Domain/Events/GroupMemberLeftDomainEvent.cs @@ -0,0 +1,7 @@ +using GroupService.Domain.Entities; +using MediatR; + +namespace GroupService.Domain.Events +{ + public record GroupMemberLeftDomainEvent(GroupMember Member) : INotification; +} diff --git a/GroupService.Domain/IReposities/IGroupReposity.cs b/GroupService.Domain/IReposities/IGroupReposity.cs index 7bce2f6..8967661 100644 --- a/GroupService.Domain/IReposities/IGroupReposity.cs +++ b/GroupService.Domain/IReposities/IGroupReposity.cs @@ -22,6 +22,7 @@ namespace GroupService.Domain.IReposities /// /// Task> FindByMasterIdAsync(Guid userId); + Task> FindByMemberIdAsync(Guid userId); /// /// 创建群聊 /// diff --git a/GroupService.Domain/IReposities/IGroupRequestReposity.cs b/GroupService.Domain/IReposities/IGroupRequestReposity.cs index 758553d..d2cadca 100644 --- a/GroupService.Domain/IReposities/IGroupRequestReposity.cs +++ b/GroupService.Domain/IReposities/IGroupRequestReposity.cs @@ -8,5 +8,6 @@ namespace GroupService.Domain.IReposities Task FindByIdAsync(Guid id); Task> FindByGroupIdAsync(Guid groupId); Task> FindByUserIdAsync(Guid userId); + Task> FindVisibleToUserAsync(Guid userId); } } diff --git a/GroupService.Infrastructure/Configs/GroupJoinRequestConfig.cs b/GroupService.Infrastructure/Configs/GroupJoinRequestConfig.cs index 2e6fc4e..ad23dfa 100644 --- a/GroupService.Infrastructure/Configs/GroupJoinRequestConfig.cs +++ b/GroupService.Infrastructure/Configs/GroupJoinRequestConfig.cs @@ -11,6 +11,9 @@ namespace GroupService.Infrastructure.Configs builder.ToTable("group_join_requests"); builder.HasKey(x => x.Id); builder.HasKey(x => new { x.GroupId, x.UserId }); + builder.HasIndex(x => x.Id).IsUnique(); + builder.HasIndex(x => new { x.GroupId, x.State, x.CreationTime }); + builder.HasIndex(x => new { x.UserId, x.CreationTime }); builder.ComplexProperty(x => x.UserProfile, u => { diff --git a/GroupService.Infrastructure/Configs/GroupMemberConfig.cs b/GroupService.Infrastructure/Configs/GroupMemberConfig.cs index 0c21f6b..539fdf2 100644 --- a/GroupService.Infrastructure/Configs/GroupMemberConfig.cs +++ b/GroupService.Infrastructure/Configs/GroupMemberConfig.cs @@ -9,6 +9,8 @@ namespace GroupService.Infrastructure.Configs public void Configure(EntityTypeBuilder builder) { builder.ToTable("group_members"); + builder.HasIndex(x => new { x.UserId, x.IsDeleted, x.GroupId }); + builder.HasIndex(x => new { x.GroupId, x.IsDeleted, x.Role }); } } } diff --git a/GroupService.Infrastructure/Migrations/20260909000200_ApiAlignmentFixes.cs b/GroupService.Infrastructure/Migrations/20260909000200_ApiAlignmentFixes.cs new file mode 100644 index 0000000..4bf7cb3 --- /dev/null +++ b/GroupService.Infrastructure/Migrations/20260909000200_ApiAlignmentFixes.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace GroupService.Infrastructure.Migrations +{ + [DbContext(typeof(GroupDbContext))] + [Migration("20260909000200_ApiAlignmentFixes")] + public partial class ApiAlignmentFixes : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_group_members_UserId_IsDeleted_GroupId", + table: "group_members", + columns: new[] { "UserId", "IsDeleted", "GroupId" }); + + migrationBuilder.CreateIndex( + name: "IX_group_members_GroupId_IsDeleted_Role", + table: "group_members", + columns: new[] { "GroupId", "IsDeleted", "Role" }); + + migrationBuilder.CreateIndex( + name: "IX_group_join_requests_Id", + table: "group_join_requests", + column: "Id", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_group_join_requests_GroupId_State_CreationTime", + table: "group_join_requests", + columns: new[] { "GroupId", "State", "CreationTime" }); + + migrationBuilder.CreateIndex( + name: "IX_group_join_requests_UserId_CreationTime", + table: "group_join_requests", + columns: new[] { "UserId", "CreationTime" }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex("IX_group_members_UserId_IsDeleted_GroupId", "group_members"); + migrationBuilder.DropIndex("IX_group_members_GroupId_IsDeleted_Role", "group_members"); + migrationBuilder.DropIndex("IX_group_join_requests_Id", "group_join_requests"); + migrationBuilder.DropIndex("IX_group_join_requests_GroupId_State_CreationTime", "group_join_requests"); + migrationBuilder.DropIndex("IX_group_join_requests_UserId_CreationTime", "group_join_requests"); + } + } +} diff --git a/GroupService.Infrastructure/Migrations/GroupDbContextModelSnapshot.cs b/GroupService.Infrastructure/Migrations/GroupDbContextModelSnapshot.cs index 592ecec..399455a 100644 --- a/GroupService.Infrastructure/Migrations/GroupDbContextModelSnapshot.cs +++ b/GroupService.Infrastructure/Migrations/GroupDbContextModelSnapshot.cs @@ -227,6 +227,13 @@ namespace GroupService.Infrastructure.Migrations b.HasKey("GroupId", "UserId"); + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("GroupId", "State", "CreationTime"); + + b.HasIndex("UserId", "CreationTime"); + b.ToTable("group_join_requests", (string)null); }); @@ -266,6 +273,10 @@ namespace GroupService.Infrastructure.Migrations b.HasKey("Id"); + b.HasIndex("GroupId", "IsDeleted", "Role"); + + b.HasIndex("UserId", "IsDeleted", "GroupId"); + b.ToTable("group_members", (string)null); }); #pragma warning restore 612, 618 diff --git a/GroupService.Infrastructure/Reposities/GroupJoinRequestReposity.cs b/GroupService.Infrastructure/Reposities/GroupJoinRequestReposity.cs index d3dc850..4e918a7 100644 --- a/GroupService.Infrastructure/Reposities/GroupJoinRequestReposity.cs +++ b/GroupService.Infrastructure/Reposities/GroupJoinRequestReposity.cs @@ -36,5 +36,19 @@ namespace GroupService.Infrastructure.Reposities x.UserId == userId || x.OperatorId == userId ).ToListAsync(); } + + public async Task> FindVisibleToUserAsync(Guid userId) + { + var managedGroupIds = db.GroupMembers + .Where(member => member.UserId == userId && + (member.Role == Domain.Enums.GroupMemberRole.Administrator || + member.Role == Domain.Enums.GroupMemberRole.Master)) + .Select(member => member.GroupId); + + return await db.GroupJoinRequests + .Where(request => request.UserId == userId || managedGroupIds.Contains(request.GroupId)) + .OrderByDescending(request => request.CreationTime) + .ToListAsync(); + } } } diff --git a/GroupService.Infrastructure/Reposities/GroupReposity.cs b/GroupService.Infrastructure/Reposities/GroupReposity.cs index 981b962..d55c695 100644 --- a/GroupService.Infrastructure/Reposities/GroupReposity.cs +++ b/GroupService.Infrastructure/Reposities/GroupReposity.cs @@ -29,6 +29,18 @@ namespace GroupService.Infrastructure.Reposities return await db.Groups.Where(x => x.GroupMaster == userId).ToListAsync(); } + public async Task> FindByMemberIdAsync(Guid userId) + { + var groupIds = db.GroupMembers + .Where(member => member.UserId == userId) + .Select(member => member.GroupId); + + return await db.Groups + .Where(group => groupIds.Contains(group.Id)) + .OrderByDescending(group => group.ModificationTime ?? group.CreationTime) + .ToListAsync(); + } + public async Task> FindByNameAsync(string name) { return await db.Groups.Where(x => x.Name == name).ToListAsync(); diff --git a/GroupService.WebApi/Application/EventHandler/GroupMemberLeftHandler.cs b/GroupService.WebApi/Application/EventHandler/GroupMemberLeftHandler.cs new file mode 100644 index 0000000..61b7b3e --- /dev/null +++ b/GroupService.WebApi/Application/EventHandler/GroupMemberLeftHandler.cs @@ -0,0 +1,18 @@ +using GroupService.Domain.Events; +using IM.Commons.IntegrationEvents; +using MassTransit; +using MediatR; + +namespace GroupService.WebApi.Application.EventHandler +{ + public class GroupMemberLeftHandler(IPublishEndpoint endpoint) + : INotificationHandler + { + public Task Handle(GroupMemberLeftDomainEvent notification, CancellationToken cancellationToken) + { + return endpoint.Publish( + new GroupMemberLeftEvent(notification.Member.UserId, notification.Member.GroupId), + cancellationToken); + } + } +} diff --git a/GroupService.WebApi/Application/Group/GroupService.cs b/GroupService.WebApi/Application/Group/GroupService.cs index aadea97..6cd69ba 100644 --- a/GroupService.WebApi/Application/Group/GroupService.cs +++ b/GroupService.WebApi/Application/Group/GroupService.cs @@ -27,11 +27,11 @@ namespace GroupService.WebApi.Application.Group public async Task>> GetAllAsync(Guid userId) { - var groups = await reposity.FindByMasterIdAsync(userId); + var groups = await reposity.FindByMemberIdAsync(userId); return Result>.Success(mapper.Map>(groups)); } - public async Task> GetByIdAsync(Guid groupId) + public async Task> GetByIdAsync(Guid groupId, Guid userId) { var group = await reposity.FindByIdAsync(groupId); if (group is null) @@ -39,9 +39,36 @@ namespace GroupService.WebApi.Application.Group return Result.Fail(ResultCode.GROUP_NOT_FOUND); } + if (!await memberReposity.CheckMemberExistAsync(groupId, userId)) + { + return Result.Fail(ResultCode.PERMISSION_DENIED); + } + return Result.Success(mapper.Map(group)); } + public async Task> DissolveAsync(Guid groupId, Guid userId) + { + var group = await reposity.FindByIdAsync(groupId); + if (group is null) + { + return Result.Fail(ResultCode.GROUP_NOT_FOUND); + } + + if (group.GroupMaster != userId) + { + return Result.Fail(ResultCode.PERMISSION_DENIED); + } + + var members = await memberReposity.FindByGroupIdAsync(groupId); + foreach (var member in members) + { + member.Leave(); + } + group.SoftDelete(); + return Result.Success(); + } + public async Task> UpdateAsync(GroupUpdateCommand command) { var group = await reposity.FindByIdAsync(command.GroupId); diff --git a/GroupService.WebApi/Application/GroupMember/GroupMemberService.cs b/GroupService.WebApi/Application/GroupMember/GroupMemberService.cs index 1a3b02e..0662e2c 100644 --- a/GroupService.WebApi/Application/GroupMember/GroupMemberService.cs +++ b/GroupService.WebApi/Application/GroupMember/GroupMemberService.cs @@ -24,13 +24,17 @@ namespace GroupService.WebApi.Application.GroupMember this.mapper = mapper; } - public async Task>> GetByGroupIdAsync(Guid groupId) + public async Task>> GetByGroupIdAsync(Guid groupId, Guid userId) { var group = await groupReposity.FindByIdAsync(groupId); if (group is null) { return Result>.Fail(ResultCode.GROUP_NOT_FOUND); } + if (!await reposity.CheckMemberExistAsync(groupId, userId)) + { + return Result>.Fail(ResultCode.PERMISSION_DENIED); + } var members = await reposity.FindByGroupIdAsync(groupId); return Result>.Success(mapper.Map>(members.ToList())); @@ -78,14 +82,32 @@ namespace GroupService.WebApi.Application.GroupMember } var operatorMember = await reposity.FindOneByGroupIdAndUserIdAsync(member.GroupId, operatorId); - if (operatorMember is null || operatorMember.Role == Domain.Enums.GroupMemberRole.Normal) + if (operatorMember is null || operatorMember.Id == member.Id || + member.Role == Domain.Enums.GroupMemberRole.Master || + operatorMember.Role <= member.Role) { return Result.Fail(ResultCode.PERMISSION_DENIED); } - member.SoftDelete(); + member.Leave(); return Result.Success(); } + + public async Task> LeaveAsync(Guid groupId, Guid userId) + { + var member = await reposity.FindOneByGroupIdAndUserIdAsync(groupId, userId); + if (member is null) + { + return Result.Fail(ResultCode.GROUP_MEMBER_NOT_FOUNT); + } + if (member.Role == Domain.Enums.GroupMemberRole.Master) + { + return Result.Fail(ResultCode.PERMISSION_DENIED, "群主不能直接退群,请使用解散群接口"); + } + + member.Leave(); + return Result.Success(); + } } } diff --git a/GroupService.WebApi/Application/GroupRequest/GroupRequestService.cs b/GroupService.WebApi/Application/GroupRequest/GroupRequestService.cs index faedf95..ed17071 100644 --- a/GroupService.WebApi/Application/GroupRequest/GroupRequestService.cs +++ b/GroupService.WebApi/Application/GroupRequest/GroupRequestService.cs @@ -98,7 +98,7 @@ namespace GroupService.WebApi.Application.GroupRequest } public async Task>> GetListAsync(Guid userId) { - var list = await reposity.FindByUserIdAsync(userId); + var list = await reposity.FindVisibleToUserAsync(userId); return Result.Success(mapper.Map>(list.ToList())); } diff --git a/GroupService.WebApi/Controllers/Group/GroupController.cs b/GroupService.WebApi/Controllers/Group/GroupController.cs index bd89aa1..ae7645e 100644 --- a/GroupService.WebApi/Controllers/Group/GroupController.cs +++ b/GroupService.WebApi/Controllers/Group/GroupController.cs @@ -32,7 +32,8 @@ namespace GroupService.WebApi.Controllers.Group [ProducesDefaultResponseType(typeof(Result))] public async Task GetOne(Guid groupId) { - return Ok(await service.GetByIdAsync(groupId)); + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + return Ok(await service.GetByIdAsync(groupId, Guid.Parse(userId))); } [HttpPost] @@ -49,5 +50,13 @@ namespace GroupService.WebApi.Controllers.Group var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); return Ok(await service.UpdateAsync(new GroupUpdateCommand(Guid.Parse(userId), request.GroupId, request.Avatar, request.GroupName, request.Description))); } + + [HttpPost] + [UnitOfWork(typeof(GroupDbContext))] + public async Task Dissolve([FromQuery] Guid groupId) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + return Ok(await service.DissolveAsync(groupId, Guid.Parse(userId))); + } } } diff --git a/GroupService.WebApi/Controllers/GroupMember/GroupMemberController.cs b/GroupService.WebApi/Controllers/GroupMember/GroupMemberController.cs index 859d286..93f01f8 100644 --- a/GroupService.WebApi/Controllers/GroupMember/GroupMemberController.cs +++ b/GroupService.WebApi/Controllers/GroupMember/GroupMemberController.cs @@ -1,6 +1,8 @@ using GroupService.Infrastructure; using GroupService.WebApi.Application.GroupMember; using IM.ASPNETCore; +using IM.Commons; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System.Security.Claims; @@ -11,25 +13,36 @@ namespace GroupService.WebApi.Controllers.GroupMember public class GroupMemberController : ControllerBase { private readonly GroupMemberService service; + private readonly IConfiguration configuration; - public GroupMemberController(GroupMemberService service) + public GroupMemberController(GroupMemberService service, IConfiguration configuration) { this.service = service; + this.configuration = configuration; } [HttpGet] public async Task CheckMember(Guid userId, Guid groupId) { + var expectedKey = configuration["InternalApiKey"]; + var suppliedKey = Request.Headers["X-Internal-Api-Key"].ToString(); + if (string.IsNullOrWhiteSpace(expectedKey) || suppliedKey != expectedKey) + { + return Unauthorized(Result.Fail(ResultCode.AUTH_FAILED)); + } return Ok(await service.CheckMemberAsync(groupId, userId)); } [HttpGet] + [Authorize] public async Task List(Guid groupId) { - return Ok(await service.GetByGroupIdAsync(groupId)); + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + return Ok(await service.GetByGroupIdAsync(groupId, Guid.Parse(userId))); } [HttpPost] + [Authorize] [UnitOfWork(typeof(GroupDbContext))] public async Task Delete([FromQuery] Guid memberId) { @@ -37,6 +50,15 @@ namespace GroupService.WebApi.Controllers.GroupMember return Ok(await service.DeleteAsync(memberId, Guid.Parse(userId))); } + [HttpPost] + [Authorize] + [UnitOfWork(typeof(GroupDbContext))] + public async Task Leave([FromQuery] Guid groupId) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + return Ok(await service.LeaveAsync(groupId, Guid.Parse(userId))); + } + } } diff --git a/GroupService.WebApi/appsettings.Development.json b/GroupService.WebApi/appsettings.Development.json index 0c208ae..712f178 100644 --- a/GroupService.WebApi/appsettings.Development.json +++ b/GroupService.WebApi/appsettings.Development.json @@ -1,4 +1,5 @@ { + "InternalApiKey": "development-only-change-me", "Logging": { "LogLevel": { "Default": "Information", diff --git a/GroupService.WebApi/appsettings.json b/GroupService.WebApi/appsettings.json index 10f68b8..5ad3a91 100644 --- a/GroupService.WebApi/appsettings.json +++ b/GroupService.WebApi/appsettings.json @@ -5,5 +5,6 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "InternalApiKey": "" } diff --git a/IM.ASPNETCore/ExceptionMiddleware.cs b/IM.ASPNETCore/ExceptionMiddleware.cs index 3151725..d8ef797 100644 --- a/IM.ASPNETCore/ExceptionMiddleware.cs +++ b/IM.ASPNETCore/ExceptionMiddleware.cs @@ -1,15 +1,19 @@ using IM.Commons; using IM.DomainCommons; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; namespace IM.ASPNETCore { public class ExceptionMiddleware { private readonly RequestDelegate _next; - public ExceptionMiddleware(RequestDelegate next) + private readonly ILogger logger; + + public ExceptionMiddleware(RequestDelegate next, ILogger logger) { _next = next; + this.logger = logger; } public async Task InvokeAsync(HttpContext context) @@ -22,12 +26,35 @@ namespace IM.ASPNETCore { await DomainExceptionHandlerAsync(context, ex); } + catch (Exception ex) + { + await UnhandledExceptionHandlerAsync(context, ex); + } } public Task DomainExceptionHandlerAsync(HttpContext context, DomainException ex) { context.Response.ContentType = "application/json"; - var result = Result.Fail(ResultCode.PARAMETER_ERROR, ex.Message); // 包装成你的 Result + context.Response.StatusCode = StatusCodes.Status400BadRequest; + var result = Result.Fail(ResultCode.PARAMETER_ERROR, ex.Message); + return context.Response.WriteAsJsonAsync(result); + } + + private Task UnhandledExceptionHandlerAsync(HttpContext context, Exception ex) + { + var correlationId = context.TraceIdentifier; + logger.LogError(ex, + "Unhandled exception. CorrelationId: {CorrelationId}, Path: {Path}", + correlationId, + context.Request.Path); + + context.Response.ContentType = "application/json"; + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + context.Response.Headers["X-Correlation-ID"] = correlationId; + + var result = Result.Fail( + ResultCode.SYSTEM_ERROR, + $"系统错误,关联编号:{correlationId}"); return context.Response.WriteAsJsonAsync(result); } } diff --git a/IM.Commons/IntegrationEvents/GroupMemberLeftEvent.cs b/IM.Commons/IntegrationEvents/GroupMemberLeftEvent.cs new file mode 100644 index 0000000..fa344b0 --- /dev/null +++ b/IM.Commons/IntegrationEvents/GroupMemberLeftEvent.cs @@ -0,0 +1,4 @@ +namespace IM.Commons.IntegrationEvents +{ + public record GroupMemberLeftEvent(Guid UserId, Guid GroupId); +} diff --git a/IM.Commons/IntegrationEvents/UploadTaskCompleteEvent.cs b/IM.Commons/IntegrationEvents/UploadTaskCompleteEvent.cs index e77e84c..de39b1d 100644 --- a/IM.Commons/IntegrationEvents/UploadTaskCompleteEvent.cs +++ b/IM.Commons/IntegrationEvents/UploadTaskCompleteEvent.cs @@ -20,6 +20,8 @@ namespace IM.Commons.IntegrationEvents public string ContentType { get; set; } public string CheckSun { get; set; } public IReadOnlyList Parts { get; set; } + public string? ChatType { get; set; } + public Guid? TargetId { get; set; } } public sealed record UploadPart( int PartNumber, diff --git a/IM.InitCommon/RabbitMqExtension.cs b/IM.InitCommon/RabbitMqExtension.cs index 8f628b8..f7ba8f5 100644 --- a/IM.InitCommon/RabbitMqExtension.cs +++ b/IM.InitCommon/RabbitMqExtension.cs @@ -28,6 +28,10 @@ namespace IM.InitCommon c.Password(options.Password); }); + cfg.UseMessageRetry(retry => retry.Intervals( + TimeSpan.FromMilliseconds(200), + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(5))); cfg.ConfigureEndpoints(context); }); }); diff --git a/IM.Jwt/WebApplicationJwtExtension.cs b/IM.Jwt/WebApplicationJwtExtension.cs index 91c6af4..b19b3af 100644 --- a/IM.Jwt/WebApplicationJwtExtension.cs +++ b/IM.Jwt/WebApplicationJwtExtension.cs @@ -61,7 +61,7 @@ namespace IM.Jwt context.HandleResponse(); context.Response.ContentType = "application/json"; - context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.StatusCode = StatusCodes.Status401Unauthorized; var result = Result.Fail(ResultCode.AUTH_FAILED); await context.Response.WriteAsJsonAsync(result); @@ -71,7 +71,7 @@ namespace IM.Jwt { context.Response.ContentType = "application/json"; - context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.StatusCode = StatusCodes.Status403Forbidden; var result = Result.Fail(ResultCode.PERMISSION_DENIED); await context.Response.WriteAsJsonAsync(result); } diff --git a/MIGRATION_RUNBOOK.md b/MIGRATION_RUNBOOK.md new file mode 100644 index 0000000..d051ec5 --- /dev/null +++ b/MIGRATION_RUNBOOK.md @@ -0,0 +1,74 @@ +# API 对齐数据库迁移运行手册 + +## 适用迁移 + +- MessageService:`20260909000100_ApiAlignmentFixes` +- GroupService:`20260909000200_ApiAlignmentFixes` +- FileService:`20260909000300_AsyncUploadResult` + +## 发布前检查 + +先完成三个库的可恢复备份,并在对应数据库执行: + +```sql +-- GroupService:新增 Id 唯一索引前必须无重复。 +SELECT Id, COUNT(*) AS duplicate_count +FROM group_join_requests +GROUP BY Id +HAVING COUNT(*) > 1; + +-- FileService:收紧 longtext 前检查历史最大长度。 +SELECT + MAX(CHAR_LENGTH(FileName)) AS max_file_name, + MAX(CHAR_LENGTH(ContentType)) AS max_content_type, + MAX(CHAR_LENGTH(checksum_algorithm)) AS max_checksum_algorithm, + MAX(CHAR_LENGTH(checksum_value)) AS max_checksum_value, + MAX(CHAR_LENGTH(storage_provider)) AS max_storage_provider, + MAX(CHAR_LENGTH(storage_bucket)) AS max_storage_bucket, + MAX(CHAR_LENGTH(storage_key)) AS max_storage_key, + MAX(CHAR_LENGTH(storage_region)) AS max_storage_region +FROM upload_files; +``` + +上面的长度必须分别不超过 `255/255/16/128/64/255/1024/128`。对 `upload_tasks` 执行同样检查。超长值应先人工确认和修正,不要依赖数据库静默截断。 + +## 执行迁移 + +在仓库根目录设置目标数据库连接字符串后执行。不要把真实密码写入仓库或命令记录。 + +```powershell +$env:DefaultDB_ConnStr = '' +dotnet ef database update --project MessageService.Infrastructure --startup-project MessageService.WebApi --context MessageDbContext + +$env:DefaultDB_ConnStr = '' +dotnet ef database update --project GroupService.Infrastructure --startup-project GroupService.WebApi --context GroupDbContext + +$env:DefaultDB_ConnStr = '' +dotnet ef database update --project FileService.Infrastructure --startup-project FileService.WebApi --context FileDbContext +``` + +建议顺序为 Message → Group → File,随后发布后端,再发布最终前端包。 + +## 数据兼容说明 + +- 所有新业务列均可空或有安全默认值,不删除历史记录。 +- 历史文件的 `IsPublic` 默认 `false`,无法确认作用域的旧文件因此只允许所有者读取。 +- 新上传文件会写入 `SourceTaskId/ChatType/TargetId/ResultFileId`;不要批量猜测旧文件作用域。 +- 群退出、群解散和会话隐藏使用软删除。 + +## 回滚 + +只有在已经回滚依赖新字段/接口的前后端版本后,才允许回滚数据库迁移: + +```powershell +$env:DefaultDB_ConnStr = '' +dotnet ef database update 20260423115234_InitMessageDb --project MessageService.Infrastructure --startup-project MessageService.WebApi --context MessageDbContext + +$env:DefaultDB_ConnStr = '' +dotnet ef database update 20260429103435_removeGroupRequestOperatorProfile --project GroupService.Infrastructure --startup-project GroupService.WebApi --context GroupDbContext + +$env:DefaultDB_ConnStr = '' +dotnet ef database update 20260509073447_InitFileDb --project FileService.Infrastructure --startup-project FileService.WebApi --context FileDbContext +``` + +FileService 回滚会删除新作用域和任务结果列,并把收紧的字符串列恢复为 `longtext`;回滚前应另行导出这些新列的数据。 diff --git a/MessageService.Domain/Entities/Conversation.cs b/MessageService.Domain/Entities/Conversation.cs index 19dcc92..245f0cb 100644 --- a/MessageService.Domain/Entities/Conversation.cs +++ b/MessageService.Domain/Entities/Conversation.cs @@ -57,24 +57,22 @@ namespace MessageService.Domain.Entities AddDomainEvent(new ConversationCreatedDomainEvent(this)); } - public void Update(long? LastReadSequenceId = default, int? unreadCount = default, string? lastMsg = default) + public void SetLastReadSequence(long sequenceId) { - if (LastReadSequenceId != null) - { - LastReadSequenceId = LastReadSequenceId.Value; - this.NotifyModified(); - } - if (unreadCount != null) - { - UnreadCount += unreadCount.Value; - NotifyModified(); - } + LastReadSequenceId = sequenceId; + NotifyModified(); + } - if (lastMsg != null) - { - LastMessage = lastMsg; - NotifyModified(); - } + public void IncrementUnread() + { + UnreadCount += 1; + NotifyModified(); + } + + public void UpdateLastMessage(string lastMessage) + { + LastMessage = lastMessage; + NotifyModified(); } @@ -86,6 +84,7 @@ namespace MessageService.Domain.Entities UnreadCount = 0; if (lastReadSequenceId.HasValue) LastReadSequenceId = lastReadSequenceId.Value; + NotifyModified(); } public void UpdateProfile(string name, string avatar) diff --git a/MessageService.Domain/Entities/Message.cs b/MessageService.Domain/Entities/Message.cs index e0fade0..5680718 100644 --- a/MessageService.Domain/Entities/Message.cs +++ b/MessageService.Domain/Entities/Message.cs @@ -91,10 +91,10 @@ namespace MessageService.Domain.Entities /// 预览图 /// /// - public static Message BuildImg(MessageCreateContext ctx, string url, - int width, int height, string thumb, long sequenceId) + public static Message BuildImg(MessageCreateContext ctx, string? url, + int width, int height, string thumb, long sequenceId, Guid? fileId = null) { - var content = new MessageContent("[图片]", new ImageBody(url, width, height, thumb)); + var content = new MessageContent("[图片]", new ImageBody(url, width, height, thumb, fileId)); return new Message(ctx, sequenceId, MessageType.Image, content); } /// @@ -107,10 +107,10 @@ namespace MessageService.Domain.Entities /// /// /// - public static Message BuildVideo(MessageCreateContext ctx, string url, - int width, int height, string thumb, long sequenceId) + public static Message BuildVideo(MessageCreateContext ctx, string? url, + int width, int height, string thumb, long sequenceId, Guid? fileId = null) { - var content = new MessageContent("[视频]", new VideoBody(url, width, height, thumb)); + var content = new MessageContent("[视频]", new VideoBody(url, width, height, thumb, fileId)); return new Message(ctx, sequenceId, MessageType.Video, content); } /// @@ -121,10 +121,10 @@ namespace MessageService.Domain.Entities /// 持续时间 /// /// - public static Message BuildVoice(MessageCreateContext ctx, string url, - int duration, long sequenceId) + public static Message BuildVoice(MessageCreateContext ctx, string? url, + int duration, long sequenceId, Guid? fileId = null) { - var content = new MessageContent("[音频]", new VoiceBody(url, duration)); + var content = new MessageContent("[音频]", new VoiceBody(url, duration, fileId)); return new Message(ctx, sequenceId, MessageType.Voice, content); } /// @@ -137,11 +137,11 @@ namespace MessageService.Domain.Entities /// 文件格式 /// /// - public static Message BuildFile(MessageCreateContext ctx, string url, + public static Message BuildFile(MessageCreateContext ctx, Guid fileId, string? url, string name, long size, string format, long sequenceId) { - var content = new MessageContent("[文件]", new FileBody(url, name, size, format)); + var content = new MessageContent("[文件]", new FileBody(fileId, url, name, size, format)); return new Message(ctx, sequenceId, MessageType.File, content); } /// diff --git a/MessageService.Domain/KeyObjects/MsgTypeObj.cs b/MessageService.Domain/KeyObjects/MsgTypeObj.cs index 647a895..86bb66f 100644 --- a/MessageService.Domain/KeyObjects/MsgTypeObj.cs +++ b/MessageService.Domain/KeyObjects/MsgTypeObj.cs @@ -1,8 +1,8 @@ namespace MessageService.Domain.KeyObjects { public record TextBody(string Text); - public record ImageBody(string Url, int Width, int Height, string Thumb); - public record VideoBody(string Url, int Width, int Height, string Thumb); - public record VoiceBody(string Url, int Duration); - public record FileBody(string Url, string FileName, long Size, string Format); + public record ImageBody(string? Url, int Width, int Height, string Thumb, Guid? FileId = null); + public record VideoBody(string? Url, int Width, int Height, string Thumb, Guid? FileId = null); + public record VoiceBody(string? Url, int Duration, Guid? FileId = null); + public record FileBody(Guid FileId, string? Url, string FileName, long Size, string Format); } diff --git a/MessageService.Infrastructure/Configs/ConversationConfig.cs b/MessageService.Infrastructure/Configs/ConversationConfig.cs index 7057e2e..80aa927 100644 --- a/MessageService.Infrastructure/Configs/ConversationConfig.cs +++ b/MessageService.Infrastructure/Configs/ConversationConfig.cs @@ -11,6 +11,7 @@ namespace MessageService.Infrastructure.Configs builder.ToTable("conversations"); builder.HasKey(x => x.Id); builder.HasIndex(x => x.UserId); + builder.HasIndex(x => new { x.UserId, x.ChatType, x.TargetId, x.IsDeleted }); } } diff --git a/MessageService.Infrastructure/Migrations/20260909000100_ApiAlignmentFixes.cs b/MessageService.Infrastructure/Migrations/20260909000100_ApiAlignmentFixes.cs new file mode 100644 index 0000000..70071b5 --- /dev/null +++ b/MessageService.Infrastructure/Migrations/20260909000100_ApiAlignmentFixes.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace MessageService.Infrastructure.Migrations +{ + [DbContext(typeof(MessageDbContext))] + [Migration("20260909000100_ApiAlignmentFixes")] + public partial class ApiAlignmentFixes : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_conversations_UserId_ChatType_TargetId_IsDeleted", + table: "conversations", + columns: new[] { "UserId", "ChatType", "TargetId", "IsDeleted" }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_conversations_UserId_ChatType_TargetId_IsDeleted", + table: "conversations"); + } + } +} diff --git a/MessageService.Infrastructure/Migrations/MessageDbContextModelSnapshot.cs b/MessageService.Infrastructure/Migrations/MessageDbContextModelSnapshot.cs index 3c3daf6..254f20b 100644 --- a/MessageService.Infrastructure/Migrations/MessageDbContextModelSnapshot.cs +++ b/MessageService.Infrastructure/Migrations/MessageDbContextModelSnapshot.cs @@ -76,6 +76,8 @@ namespace MessageService.Infrastructure.Migrations b.HasIndex("UserId"); + b.HasIndex("UserId", "ChatType", "TargetId", "IsDeleted"); + b.ToTable("conversations", (string)null); }); diff --git a/MessageService.Infrastructure/Reposities/MessageReposity.cs b/MessageService.Infrastructure/Reposities/MessageReposity.cs index fb15cd4..d7e1fa5 100644 --- a/MessageService.Infrastructure/Reposities/MessageReposity.cs +++ b/MessageService.Infrastructure/Reposities/MessageReposity.cs @@ -26,30 +26,32 @@ namespace MessageService.Infrastructure.Reposities public async Task<(IEnumerable messages, bool hasMore)> GetAsync(string streamKey, long? cusor, int direction, int limit) { var query = db.Messages.Where(x => x.StreamKey == streamKey); - List messages = []; + List fetched; if (direction == 0) // Before: 找比锚点小的,按倒序排 { if (cusor.HasValue) query = query.Where(m => m.SequenceId < cusor.Value); - var list = await query + fetched = await query .OrderByDescending(m => m.SequenceId) // 最新消息在最前 .Take(limit + 1) .ToListAsync(); - - messages = [.. list.OrderBy(s => s.SequenceId)]; } else { if (cusor is null) - return (messages, false); + return (Array.Empty(), false); - messages = await query.OrderBy(o => o.SequenceId) + fetched = await query + .Where(m => m.SequenceId > cusor.Value) + .OrderBy(o => o.SequenceId) .Take(limit + 1) .ToListAsync(); } - return (messages, messages.Count > limit); + var hasMore = fetched.Count > limit; + var messages = fetched.Take(limit).OrderBy(s => s.SequenceId).ToList(); + return (messages, hasMore); } } } diff --git a/MessageService.WebApi/Application/Conversation/ConversationMapperConfig.cs b/MessageService.WebApi/Application/Conversation/ConversationMapperConfig.cs index 4d9dd5e..863becb 100644 --- a/MessageService.WebApi/Application/Conversation/ConversationMapperConfig.cs +++ b/MessageService.WebApi/Application/Conversation/ConversationMapperConfig.cs @@ -8,7 +8,7 @@ namespace MessageService.WebApi.Application.Conversation public ConversationMapperConfig() { CreateMap() - .ForMember(dest => dest.DateTime, opt => opt.MapFrom(src => src.ModificationTime)) + .ForMember(dest => dest.DateTime, opt => opt.MapFrom(src => src.ModificationTime ?? src.CreationTime)) ; } } diff --git a/MessageService.WebApi/Application/Dtos/ConversationResponse.cs b/MessageService.WebApi/Application/Dtos/ConversationResponse.cs index 1efcf34..2f2e4e0 100644 --- a/MessageService.WebApi/Application/Dtos/ConversationResponse.cs +++ b/MessageService.WebApi/Application/Dtos/ConversationResponse.cs @@ -31,6 +31,6 @@ namespace MessageService.WebApi.Application.Dtos /// 最后一条最新消息 /// public string LastMessage { get; set; } - public DateTime DateTime { get; set; } + public DateTimeOffset DateTime { get; set; } } } diff --git a/MessageService.WebApi/Application/EventHandlers/ConversationAddHandler.cs b/MessageService.WebApi/Application/EventHandlers/ConversationAddHandler.cs index a2a669f..4236295 100644 --- a/MessageService.WebApi/Application/EventHandlers/ConversationAddHandler.cs +++ b/MessageService.WebApi/Application/EventHandlers/ConversationAddHandler.cs @@ -6,7 +6,7 @@ using MessageService.Infrastructure; namespace MessageService.WebApi.Application.EventHandlers { public class ConversationAddHandler : IConsumer, - IConsumer + IConsumer, IConsumer { private readonly IConversationReposity reposity; @@ -21,6 +21,11 @@ namespace MessageService.WebApi.Application.EventHandlers public async Task Consume(ConsumeContext context) { var @event = context.Message; + var existing = await reposity.FindByTargetIdAsync(@event.GroupId); + if (existing.Any(x => x.UserId == @event.UserId && x.ChatType == Domain.Enums.ChatType.GROUP)) + { + return; + } reposity.Create(new Domain.Entities.Conversation( userId: @event.UserId, targetId: @event.GroupId, @@ -32,12 +37,17 @@ namespace MessageService.WebApi.Application.EventHandlers lastMessage: string.Empty )); - await messageDb.SaveChangesAsync(); + await messageDb.SaveChangesAsync(context.CancellationToken); } public async Task Consume(ConsumeContext context) { var @event = context.Message; + var existing = await reposity.FindByUserIdAsync(@event.OwnerId); + if (existing.Any(x => x.TargetId == @event.TargetId && x.ChatType == Domain.Enums.ChatType.PRIVATE)) + { + return; + } reposity.Create(new Domain.Entities.Conversation( userId: @event.OwnerId, targetId: @event.TargetId, @@ -49,7 +59,18 @@ namespace MessageService.WebApi.Application.EventHandlers lastMessage: string.Empty )); - await messageDb.SaveChangesAsync(); + await messageDb.SaveChangesAsync(context.CancellationToken); + } + + public async Task Consume(ConsumeContext context) + { + var conversations = await reposity.FindByTargetIdAsync(context.Message.GroupId); + foreach (var conversation in conversations.Where(x => + x.UserId == context.Message.UserId && x.ChatType == Domain.Enums.ChatType.GROUP)) + { + conversation.SoftDelete(); + } + await messageDb.SaveChangesAsync(context.CancellationToken); } } } diff --git a/MessageService.WebApi/Application/EventHandlers/MessageHandler.cs b/MessageService.WebApi/Application/EventHandlers/MessageHandler.cs index f162d1a..a538418 100644 --- a/MessageService.WebApi/Application/EventHandlers/MessageHandler.cs +++ b/MessageService.WebApi/Application/EventHandlers/MessageHandler.cs @@ -24,20 +24,24 @@ namespace MessageService.WebApi.Application.EventHandlers { var message = notification.Message; - if(message.ChatType == Domain.Enums.ChatType.PRIVATE) + var conversations = await reposity.FindByStreamKeyAsync(message.StreamKey); + foreach (var conversation in conversations) { - var list = await reposity.FindByStreamKeyAsync(message.StreamKey); - var owner = list.First(x => x.UserId == message.SenderId); - var target = list.First(x => x.UserId == message.TargetId); - - owner.Update(message.SequenceId, 0, message.Content.Fallback); - target.Update(target.LastReadSequenceId, target.UnreadCount + 1, message.Content.Fallback); - - messageDb.Conversations.UpdateRange(owner,target); - await messageDb.SaveChangesAsync(cancellationToken); + conversation.UpdateLastMessage(message.Content.Fallback); + if (conversation.UserId == message.SenderId) + { + conversation.SetLastReadSequence(message.SequenceId); + } + else + { + conversation.IncrementUnread(); + } } - await endpoint.Publish(message.ToIntegrationEvent()); + messageDb.Conversations.UpdateRange(conversations); + await messageDb.SaveChangesAsync(cancellationToken); + + await endpoint.Publish(message.ToIntegrationEvent(), cancellationToken); } public async Task Handle(MessageWithdrawDomainEvent notification, CancellationToken cancellationToken) diff --git a/MessageService.WebApi/Application/Message/MessageService.cs b/MessageService.WebApi/Application/Message/MessageService.cs index 3f42c72..4e8c7af 100644 --- a/MessageService.WebApi/Application/Message/MessageService.cs +++ b/MessageService.WebApi/Application/Message/MessageService.cs @@ -52,13 +52,16 @@ namespace MessageService.WebApi.Application.Message { MessageType.Text => Domain.Entities.Message.BuildTxt(ctx, command.Text!, sequenceId), - MessageType.Image => Domain.Entities.Message.BuildImg(ctx, command.Url!, - command.Width ?? 0, command.Height ?? 0, command.Thumb!, sequenceId), + MessageType.Image => Domain.Entities.Message.BuildImg(ctx, command.Url, + command.Width ?? 0, command.Height ?? 0, command.Thumb!, sequenceId, command.FileId), - MessageType.Video => Domain.Entities.Message.BuildVideo(ctx, command.Url!, - command.Width ?? 0, command.Height ?? 0, command.Thumb!, sequenceId), + MessageType.Video => Domain.Entities.Message.BuildVideo(ctx, command.Url, + command.Width ?? 0, command.Height ?? 0, command.Thumb!, sequenceId, command.FileId), - MessageType.Voice => Domain.Entities.Message.BuildVoice(ctx, command.Url!, command.Duration ?? 0, sequenceId), + MessageType.Voice => Domain.Entities.Message.BuildVoice(ctx, command.Url, command.Duration ?? 0, sequenceId, command.FileId), + + MessageType.File => Domain.Entities.Message.BuildFile(ctx, command.FileId!.Value, command.Url, + command.FileName!, command.FileSize!.Value, command.FileFormat!, sequenceId), _ => null }; @@ -109,6 +112,11 @@ namespace MessageService.WebApi.Application.Message public async Task> GetMessagesAsync(GetMessageCommand command) { + if (command.direction is not 0 and not 1 || command.limit is < 1 or > 100) + { + return Result.Fail(ResultCode.PARAMETER_ERROR); + } + var conversation = await conversationReposity.FindByIdAsync(command.conversationId); if(conversation is null || conversation.UserId != command.userId) diff --git a/MessageService.WebApi/Application/Message/SendMsgCommand.cs b/MessageService.WebApi/Application/Message/SendMsgCommand.cs index c37bce4..c4b37af 100644 --- a/MessageService.WebApi/Application/Message/SendMsgCommand.cs +++ b/MessageService.WebApi/Application/Message/SendMsgCommand.cs @@ -22,8 +22,12 @@ namespace MessageService.WebApi.Application.Message public int? Height { get; init; } public string? Thumb { get; init; } public int? Duration { get; init; } + public Guid? FileId { get; init; } + public string? FileName { get; init; } + public long? FileSize { get; init; } + public string? FileFormat { get; init; } - public SendMsgCommand(Guid senderId, Guid targetId, ChatType chatType, MessageType msgType, Guid clientMsgId, Guid? quoteMessageId, Dictionary? ext, string? text, string? url, int? width, int? height, string? thumb, int? duration) + public SendMsgCommand(Guid senderId, Guid targetId, ChatType chatType, MessageType msgType, Guid clientMsgId, Guid? quoteMessageId, Dictionary? ext, string? text, string? url, int? width, int? height, string? thumb, int? duration, Guid? fileId, string? fileName, long? fileSize, string? fileFormat) { SenderId = senderId; TargetId = targetId; @@ -38,6 +42,10 @@ namespace MessageService.WebApi.Application.Message Height = height; Thumb = thumb; Duration = duration; + FileId = fileId; + FileName = fileName; + FileSize = fileSize; + FileFormat = fileFormat; } } } diff --git a/MessageService.WebApi/Controllers/Message/MessageSendRequest.cs b/MessageService.WebApi/Controllers/Message/MessageSendRequest.cs index 039a3ec..bc0546a 100644 --- a/MessageService.WebApi/Controllers/Message/MessageSendRequest.cs +++ b/MessageService.WebApi/Controllers/Message/MessageSendRequest.cs @@ -23,6 +23,10 @@ namespace MessageService.WebApi.Controllers.Message public int? Height { get; init; } public string? Thumb { get; init; } public int? Duration { get; init; } + public Guid? FileId { get; init; } + public string? FileName { get; init; } + public long? FileSize { get; init; } + public string? FileFormat { get; init; } public SendMsgCommand ToCommand(Guid senderId) { @@ -39,7 +43,11 @@ namespace MessageService.WebApi.Controllers.Message Width, Height, Thumb, - Duration + Duration, + FileId, + FileName, + FileSize, + FileFormat ); } } @@ -63,9 +71,19 @@ namespace MessageService.WebApi.Controllers.Message // 图片/视频/语音消息:url 必填 When(r => r.MsgType == MessageType.Image || r.MsgType == MessageType.Video - || r.MsgType == MessageType.Voice || r.MsgType == MessageType.File, () => + || r.MsgType == MessageType.Voice, () => { - RuleFor(r => r.Url).NotEmpty().WithMessage("媒体消息的 url 字段不能为空"); + RuleFor(r => r) + .Must(request => !string.IsNullOrWhiteSpace(request.Url) || request.FileId.HasValue) + .WithMessage("媒体消息必须提供 url 或 fileId"); + }); + + When(r => r.MsgType == MessageType.File, () => + { + RuleFor(r => r.FileId).NotNull().NotEmpty().WithMessage("文件消息的 fileId 字段不能为空"); + RuleFor(r => r.FileName).NotEmpty().WithMessage("文件消息的 fileName 字段不能为空"); + RuleFor(r => r.FileSize).NotNull().GreaterThanOrEqualTo(0).WithMessage("文件消息的 fileSize 字段不合法"); + RuleFor(r => r.FileFormat).NotEmpty().WithMessage("文件消息的 fileFormat 字段不能为空"); }); } } diff --git a/MessageService.WebApi/ModuleInit.cs b/MessageService.WebApi/ModuleInit.cs index e8bb4a0..7f11cb5 100644 --- a/MessageService.WebApi/ModuleInit.cs +++ b/MessageService.WebApi/ModuleInit.cs @@ -20,9 +20,16 @@ namespace MessageService.WebApi services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddHttpClient(c => + services.AddHttpClient((sp, c) => { - c.BaseAddress = new Uri("http://im-group-service:8080/"); + var configuration = sp.GetRequiredService(); + c.BaseAddress = new Uri(configuration["InternalServices:GroupServiceBaseUrl"] + ?? "http://im-group-service:8080/"); + var internalApiKey = configuration["InternalApiKey"]; + if (!string.IsNullOrWhiteSpace(internalApiKey)) + { + c.DefaultRequestHeaders.Add("X-Internal-Api-Key", internalApiKey); + } }); services.AddGrpcClient((sp, o) => { diff --git a/MessageService.WebApi/appsettings.Development.json b/MessageService.WebApi/appsettings.Development.json index 0c208ae..69768ca 100644 --- a/MessageService.WebApi/appsettings.Development.json +++ b/MessageService.WebApi/appsettings.Development.json @@ -1,4 +1,8 @@ { + "InternalApiKey": "development-only-change-me", + "InternalServices": { + "GroupServiceBaseUrl": "http://localhost:5070/" + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/MessageService.WebApi/appsettings.json b/MessageService.WebApi/appsettings.json index 10f68b8..5add975 100644 --- a/MessageService.WebApi/appsettings.json +++ b/MessageService.WebApi/appsettings.json @@ -5,5 +5,9 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "InternalApiKey": "", + "InternalServices": { + "GroupServiceBaseUrl": "http://im-group-service:8080/" + } } diff --git a/docker-compose.yml b/docker-compose.yml index 958017e..d70a681 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -145,6 +145,7 @@ services: ASPNETCORE_ENVIRONMENT: Production ASPNETCORE_URLS: http://+:8080 CONSUL_URL: http://consul:8500 + InternalApiKey: ${IM_INTERNAL_API_KEY:?IM_INTERNAL_API_KEY is required} networks: - im-net @@ -173,6 +174,7 @@ services: ASPNETCORE_ENVIRONMENT: Production ASPNETCORE_URLS: http://+:8080 CONSUL_URL: http://consul:8500 + InternalApiKey: ${IM_INTERNAL_API_KEY:?IM_INTERNAL_API_KEY is required} networks: - im-net @@ -189,12 +191,15 @@ services: condition: service_started rabbitmq: condition: service_started + group-service: + condition: service_started ports: - "5005:8080" environment: ASPNETCORE_ENVIRONMENT: Production ASPNETCORE_URLS: http://+:8080 CONSUL_URL: http://consul:8500 + InternalApiKey: ${IM_INTERNAL_API_KEY:?IM_INTERNAL_API_KEY is required} networks: - im-net