fix: align backend APIs and upload flow

This commit is contained in:
2026-09-11 18:18:59 +08:00
parent 32177a7293
commit 898cf5dba0
69 changed files with 1081 additions and 153 deletions
@@ -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<UploadTaskCompleteEvent> 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;
}
}
}
@@ -60,6 +60,7 @@ namespace FileService.Application.StorageContracts
public sealed record PresignedUrl(
string Url,
string Method,
IReadOnlyDictionary<string, string> Headers,
DateTimeOffset ExpiresAt);
@@ -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; }
@@ -0,0 +1,19 @@
using System.Net.Http.Json;
namespace FileService.Application.UploadFile
{
public interface IGroupAccessService
{
Task<bool> CheckMemberAsync(Guid userId, Guid groupId);
}
public class GroupAccessService(HttpClient httpClient) : IGroupAccessService
{
public async Task<bool> CheckMemberAsync(Guid userId, Guid groupId)
{
var result = await httpClient.GetFromJsonAsync<IM.Commons.Result<bool>>(
$"api/groupmember/checkmember?userId={userId}&groupId={groupId}");
return result?.Succeeded == true && result.Data;
}
}
}
@@ -12,8 +12,11 @@ namespace FileService.Application.UploadFile
public UploadFileMapperConfig()
{
CreateMap<Domain.Entities.UploadFile, FileResponse>()
.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))
;
}
}
@@ -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<StorageOptions> options;
private readonly IGroupAccessService groupAccessService;
public UploadFileService(IUploadFileReposity reposity, IMapper mapper,
IObjectStorageRouter router, IOptions<StorageOptions> options)
IObjectStorageRouter router, IOptions<StorageOptions> options,
IGroupAccessService groupAccessService)
{
this.reposity = reposity;
this.mapper = mapper;
this.router = router;
this.options = options;
this.groupAccessService = groupAccessService;
}
public async Task<Result<FileResponse>> GetFileInfoAsync(Guid id)
public async Task<Result<FileResponse>> GetFileInfoAsync(Guid id, Guid requesterId)
{
var file = await reposity.FindByIdAsync(id);
if (file == null)
{
return Result.Fail<FileResponse>(ResultCode.FILE_NOT_FOUND);
}
if (!await CanAccessAsync(file, requesterId))
{
return Result.Fail<FileResponse>(ResultCode.PERMISSION_DENIED);
}
var response = mapper.Map<FileResponse>(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
/// </summary>
public async Task<Result<FileResponse>> 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<FileResponse>(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<FileDownload>(ResultCode.FILE_NOT_FOUND);
}
if (!await CanAccessAsync(file, requesterId))
{
return Result.Fail<FileDownload>(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<bool> 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);
}
}
@@ -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; }
}
}
@@ -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<UploadFile.FileResponse>(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<TaskInitResponse>(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<PresignedUrl>(ResultCode.CHUNK_NOT_FOUND);
}
var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId));
if (task is null || task.UploaderId != userId)
{
return Result.Fail<PresignedUrl>(ResultCode.PERMISSION_DENIED);
}
if (taskCache.TotalPartCount < partNum || partNum < 1)
{
return Result.Fail<PresignedUrl>(ResultCode.INVALID_PART_NUMBER);
@@ -123,24 +149,58 @@ namespace FileService.Application.UploadFileTask
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_NOT_FOUND);
}
var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId));
if (task is null)
{
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_NOT_FOUND);
}
if (task.UploaderId != command.userId)
{
return Result.Fail<UploadTaskResponse>(ResultCode.PERMISSION_DENIED);
}
// 校验分片数量必须匹配
if (command.Parts.Count != taskCache.TotalPartCount)
{
return Result.Fail<UploadTaskResponse>(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<UploadTaskResponse>(ResultCode.INVALID_PART_NUMBER);
}
// 本地分片必须由本服务接收;预签名模式由对象存储在完成合并时校验 ETag。
if (string.Equals(taskCache.ProviderCode, "Local", StringComparison.OrdinalIgnoreCase))
{
foreach (var part in command.Parts)
{
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_NOT_FOUND);
if (!taskCache.Parts.TryGetValue(part.PartNumber, out _))
{
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_NOT_FOUND);
}
}
}
var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId));
if (task.State == Domain.UploadTaskState.Completed)
{
var completedResponse = mapper.Map<UploadTaskResponse>(task);
if (task.ResultFileId.HasValue)
{
var file = await uploadFileReposity.FindByIdAsync(task.ResultFileId.Value);
if (file != null)
{
completedResponse.File = mapper.Map<UploadFile.FileResponse>(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<UploadTaskResponse>(task));
}
public async Task<Result<CompleteUploadResult>> UploadPartAsync(UploadPartCommand command)
public async Task<Result<CompleteUploadResult>> UploadPartAsync(UploadPartCommand command, Guid userId)
{
var taskCache = await redis.GetAsync(command.SessionId);
if (taskCache is null)
{
return Result.Fail<CompleteUploadResult>(ResultCode.CHUNK_NOT_FOUND);
}
var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId));
if (task is null || task.UploaderId != userId)
{
return Result.Fail<CompleteUploadResult>(ResultCode.PERMISSION_DENIED);
}
var minPartSize = options.Value.Providers[options.Value.DefaultProviderCode].MinPartSizeBytes;
@@ -213,6 +280,11 @@ namespace FileService.Application.UploadFileTask
{
return Result.Fail<UploadProgressResponse>(ResultCode.CHUNK_NOT_FOUND);
}
var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId));
if (task is null || task.UploaderId != userId)
{
return Result.Fail<UploadProgressResponse>(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<Result<UploadTaskResponse>> GetStatusAsync(Guid taskId, Guid userId)
{
var task = await reposity.FindByIdAsync(taskId);
if (task is null)
{
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_NOT_FOUND);
}
if (task.UploaderId != userId)
{
return Result.Fail<UploadTaskResponse>(ResultCode.PERMISSION_DENIED);
}
var response = mapper.Map<UploadTaskResponse>(task);
if (task.ResultFileId.HasValue)
{
var file = await uploadFileReposity.FindByIdAsync(task.ResultFileId.Value);
if (file != null)
{
response.File = mapper.Map<UploadFile.FileResponse>(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);
}
}
}
@@ -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)
);
}
@@ -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; }
}
}