fix: align backend APIs and upload flow
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
using AutoMapper;
|
||||
using AutoMapper;
|
||||
using FileService.Application.Ports;
|
||||
using FileService.Application.StorageContracts;
|
||||
using FileService.Domain.IReposities;
|
||||
@@ -12,9 +12,9 @@ namespace FileService.Application.UploadFileTask
|
||||
{
|
||||
public class UploadFileTaskService(IUploadTaskReposity reposity,
|
||||
IMapper mapper, IObjectStorageRouter router,
|
||||
IOptions<StorageOptions> options, IStorageRedisCache redis,
|
||||
IOptionsSnapshot<StorageOptions> options, IStorageRedisCache redis,
|
||||
IPublishEndpoint endpoint, ILocalChunkStorage localChunkStorage,
|
||||
IUploadFileReposity uploadFileReposity
|
||||
IUploadFileReposity uploadFileReposity, IM.InitCommon.Management.RuntimePolicy runtime, UploadFile.IGroupAccessService groupAccess
|
||||
)
|
||||
{
|
||||
private readonly IUploadTaskReposity reposity = reposity;
|
||||
@@ -29,18 +29,29 @@ namespace FileService.Application.UploadFileTask
|
||||
|
||||
public async Task<Result<TaskInitResponse>> InitTaskAsync(UploadTaskInitCommand command)
|
||||
{
|
||||
runtime.CheckFile(command.FileName, command.FileSize);
|
||||
if (command.FileSize <= 0) return Result.Fail<TaskInitResponse>(ResultCode.PARAMETER_ERROR);
|
||||
await CheckGroup(command.ChatType, command.TargetId, command.UploaderId);
|
||||
CancellationToken cancellationToken = CancellationToken.None;
|
||||
|
||||
// 秒传:相同 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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -58,7 +69,7 @@ namespace FileService.Application.UploadFileTask
|
||||
var initUpdateCommand = new StorageContracts.InitiateUploadCommand(
|
||||
ProviderCode: storageOption.ProviderCode,
|
||||
Bucket: storageOption.Bucket,
|
||||
ObjectKey: $"{storageOption.LocalRootPath}\\{date.Year}\\{date.Month}\\{date.Day}\\{command.FileName}",
|
||||
ObjectKey: $"{date:yyyy/MM/dd}/{Guid.NewGuid():N}{Path.GetExtension(command.FileName)}",
|
||||
ContentType: task.ContentType.Value,
|
||||
ContentLength: command.FileSize,
|
||||
null);
|
||||
@@ -70,8 +81,20 @@ 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 = runtime.Enabled || string.Equals(storage.ProviderCode, "Local", StringComparison.OrdinalIgnoreCase)
|
||||
? "ServerMultipart"
|
||||
: "Presigned",
|
||||
TotalPartCount = totalPartCount,
|
||||
PartSizeBytes = storageOption.DefaultPartSizeBytes
|
||||
};
|
||||
|
||||
task.StartUpload();
|
||||
task.StartUpload(new Domain.ValueObjects.StorageLocation(storageOption.ProviderCode, storageOption.Bucket, initUpdateCommand.ObjectKey, storageOption.Region));
|
||||
reposity.Create(task);
|
||||
|
||||
await redis.SetAsync(new StorageContracts.UploadRuntimeCache(
|
||||
@@ -97,18 +120,27 @@ 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);
|
||||
}
|
||||
|
||||
runtime.CheckFile(task.FileName.Value, task.FileSize);
|
||||
await CheckGroup(task.ChatType, task.TargetId, userId);
|
||||
if (taskCache.TotalPartCount < partNum || partNum < 1)
|
||||
{
|
||||
return Result.Fail<PresignedUrl>(ResultCode.INVALID_PART_NUMBER);
|
||||
}
|
||||
|
||||
var presignUrl = await storage.GenerateUploadUrlAsync(new GenerateUploadUrlCommand(
|
||||
if (runtime.Enabled) return Result.Fail<PresignedUrl>(ResultCode.PERMISSION_DENIED, "请通过鉴权分片接口上传,以保证封禁立即生效");
|
||||
var presignUrl = await router.Route(taskCache.ProviderCode).GenerateUploadUrlAsync(new GenerateUploadUrlCommand(
|
||||
ProviderCode: taskCache.ProviderCode,
|
||||
Bucket: taskCache.Bucket,
|
||||
ObjectKey: taskCache.ObjectKey,
|
||||
UploadSessionId: taskCache.UploadSessionId,
|
||||
PartNumber: partNum,
|
||||
ExpiresIn: options.Value.Providers[options.Value.DefaultProviderCode].UploadUrlExpiresIn
|
||||
ExpiresIn: options.Value.Providers[taskCache.ProviderCode].UploadUrlExpiresIn
|
||||
), token);
|
||||
|
||||
return Result.Success(presignUrl);
|
||||
@@ -123,24 +155,61 @@ 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);
|
||||
}
|
||||
|
||||
runtime.CheckFile(task.FileName.Value, task.FileSize);
|
||||
await CheckGroup(task.ChatType, task.TargetId, command.userId);
|
||||
if (task.State == Domain.UploadTaskState.Merging) return Result.Success(mapper.Map<UploadTaskResponse>(task));
|
||||
// 校验分片数量必须匹配
|
||||
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 (runtime.Enabled || 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,21 +231,32 @@ 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;
|
||||
runtime.CheckFile(task.FileName.Value, task.FileSize);
|
||||
await CheckGroup(task.ChatType, task.TargetId, userId);
|
||||
if (command.PartNum < 1 || command.PartNum > taskCache.TotalPartCount || command.ContentLength <= 0 || command.ContentLength > task.FileSize)
|
||||
return Result.Fail<CompleteUploadResult>(ResultCode.PARAMETER_ERROR);
|
||||
var minPartSize = options.Value.Providers[taskCache.ProviderCode].MinPartSizeBytes;
|
||||
|
||||
// 最后一个分片豁免最小值校验(仅校验非最后一片)
|
||||
var isLastPart = command.PartNum == taskCache.TotalPartCount;
|
||||
@@ -186,13 +266,17 @@ namespace FileService.Application.UploadFileTask
|
||||
$"分片 {command.PartNum} 大小为 {command.ContentLength} 字节,小于最小值 {minPartSize} 字节");
|
||||
}
|
||||
|
||||
StorageContracts.UploadPart uploaded;
|
||||
if (taskCache.ProviderCode == "Local") {
|
||||
await localChunkStorage.SavePartAsync(new SaveLocalPartCommand(
|
||||
UploadSessionId: command.SessionId,
|
||||
PartNumber: command.PartNum,
|
||||
Stream: command.Stream,
|
||||
ContentLength: command.ContentLength
|
||||
));
|
||||
taskCache.AddOrUpdatePart(new StorageContracts.UploadPart(command.PartNum, command.PartNum.ToString(), command.ContentLength));
|
||||
uploaded = new StorageContracts.UploadPart(command.PartNum, command.PartNum.ToString(), command.ContentLength);
|
||||
} else uploaded = await router.Route(taskCache.ProviderCode).WritePartAsync(taskCache, command.PartNum, command.Stream, command.ContentLength, CancellationToken.None);
|
||||
taskCache.AddOrUpdatePart(uploaded);
|
||||
await redis.SetAsync(taskCache);
|
||||
var location = new Domain.ValueObjects.StorageLocation(
|
||||
storageProvider: taskCache.ProviderCode,
|
||||
@@ -200,7 +284,7 @@ namespace FileService.Application.UploadFileTask
|
||||
objectKey: taskCache.ObjectKey,
|
||||
region: taskCache.Region
|
||||
);
|
||||
return Result.Success(new CompleteUploadResult(location, command.PartNum.ToString(), command.ContentLength));
|
||||
return Result.Success(new CompleteUploadResult(location, uploaded.ETag, command.ContentLength));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -213,6 +297,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 +318,59 @@ namespace FileService.Application.UploadFileTask
|
||||
|
||||
return Result.Success(response);
|
||||
}
|
||||
|
||||
private async Task CheckGroup(string? chatType, Guid? targetId, Guid userId)
|
||||
{
|
||||
if (string.Equals(chatType, "GROUP", StringComparison.OrdinalIgnoreCase) && (!targetId.HasValue || !await groupAccess.CheckMemberAsync(userId, targetId.Value)))
|
||||
throw new IM.DomainCommons.DomainException("群组已被封禁或没有群文件写入权限");
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user