fix: align backend APIs and upload flow
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace FileService.Application.Ports
|
||||
public interface IObjectStoragePort
|
||||
{
|
||||
string ProviderCode { get; }
|
||||
Task<UploadPart> WritePartAsync(UploadRuntimeCache task, int partNumber, Stream content, long size, CancellationToken ct) => throw new NotSupportedException();
|
||||
public Task<InitiateUploadResult> InitUploadAsync(InitiateUploadCommand command,CancellationToken token);
|
||||
public Task<PresignedUrl> GenerateUploadUrlAsync(GenerateUploadUrlCommand command, CancellationToken token);
|
||||
public Task<CompleteUploadResult> CompleteUploadAsync(CompleteUploadCommand command, CancellationToken token);
|
||||
|
||||
@@ -60,6 +60,7 @@ namespace FileService.Application.StorageContracts
|
||||
|
||||
public sealed record PresignedUrl(
|
||||
string Url,
|
||||
string Method,
|
||||
IReadOnlyDictionary<string, string> Headers,
|
||||
DateTimeOffset ExpiresAt);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
@@ -46,7 +46,7 @@ namespace FileService.Application.StorageContracts
|
||||
ObjectKey = objectKey;
|
||||
FileSize = fileSize;
|
||||
TotalPartCount = totalPartCount;
|
||||
ExpireAt = expireAt ?? DateTime.MaxValue;
|
||||
ExpireAt = expireAt ?? DateTimeOffset.UtcNow.AddHours(24);
|
||||
CreatedAt = DateTime.Now;
|
||||
}
|
||||
|
||||
|
||||
@@ -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; private readonly IM.InitCommon.Management.RuntimePolicy runtime;
|
||||
|
||||
public UploadFileService(IUploadFileReposity reposity, IMapper mapper,
|
||||
IObjectStorageRouter router, IOptions<StorageOptions> options)
|
||||
IObjectStorageRouter router, IOptionsSnapshot<StorageOptions> options,
|
||||
IGroupAccessService groupAccessService, IM.InitCommon.Management.RuntimePolicy runtime)
|
||||
{
|
||||
this.reposity = reposity;
|
||||
this.mapper = mapper;
|
||||
this.router = router;
|
||||
this.options = options;
|
||||
this.groupAccessService = groupAccessService; this.runtime = runtime;
|
||||
}
|
||||
|
||||
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,31 @@ namespace FileService.Application.UploadFile
|
||||
/// </summary>
|
||||
public async Task<Result<FileResponse>> SimpleUploadAsync(SimpleUploadCommand command, CancellationToken token = default)
|
||||
{
|
||||
// 秒传:相同 checksum 的文件已存在则直接返回已有记录
|
||||
if (!string.IsNullOrEmpty(command.CheckSum))
|
||||
runtime.CheckFile(command.FileName, command.FileSize);
|
||||
if (command.FileSize <= 0 || command.FileSize > options.Value.Providers[options.Value.DefaultProviderCode].MaxObjectSizeBytes) return Result.Fail<FileResponse>(ResultCode.FILE_TOO_LARGE);
|
||||
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 +109,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 +129,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 +143,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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user