Merge branch 'master' of https://gitea.nxsir.cn/nanxun/IM_NEW
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using FileService.Application.StorageContracts;
|
||||
using FileService.Domain.ValueObjects;
|
||||
|
||||
namespace FileService.Application.Ports
|
||||
{
|
||||
@@ -8,5 +9,21 @@ namespace FileService.Application.Ports
|
||||
public Task<InitiateUploadResult> InitUploadAsync(InitiateUploadCommand command,CancellationToken token);
|
||||
public Task<PresignedUrl> GenerateUploadUrlAsync(GenerateUploadUrlCommand command, CancellationToken token);
|
||||
public Task<CompleteUploadResult> CompleteUploadAsync(CompleteUploadCommand command, CancellationToken token);
|
||||
|
||||
/// <summary>
|
||||
/// 单次直传:把整个文件一次性写入存储(小文件,不分片)。
|
||||
/// </summary>
|
||||
public Task<StorageLocation> PutObjectAsync(PutObjectCommand command, CancellationToken token);
|
||||
|
||||
/// <summary>
|
||||
/// 生成可直接访问的公开直链。
|
||||
/// 文件位于公开桶/目录返回直链;私有文件返回 null(需走鉴权下载接口)。
|
||||
/// </summary>
|
||||
public string? GetPublicUrl(StorageLocation location);
|
||||
|
||||
/// <summary>
|
||||
/// 打开文件读取流(用于私有文件鉴权下载)。
|
||||
/// </summary>
|
||||
public Task<Stream> OpenReadAsync(StorageLocation location, CancellationToken token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,24 @@ using FileService.Domain.ValueObjects;
|
||||
namespace FileService.Application.StorageContracts
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// 单次直传命令(小文件,一次性写入,不分片)。
|
||||
/// </summary>
|
||||
/// <param name="ProviderCode">储存提供商编号</param>
|
||||
/// <param name="Bucket">目标桶;传入公开桶名即落到公开位置</param>
|
||||
/// <param name="ObjectKey">对象 key(含目录前缀)</param>
|
||||
/// <param name="ContentType">内容类型</param>
|
||||
/// <param name="Content">文件流</param>
|
||||
/// <param name="ContentLength">文件大小</param>
|
||||
public sealed record PutObjectCommand(
|
||||
string ProviderCode,
|
||||
string Bucket,
|
||||
string ObjectKey,
|
||||
string ContentType,
|
||||
Stream Content,
|
||||
long ContentLength);
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="ProviderCode">储存提供商编号</param>
|
||||
/// <param name="Bucket">储存桶名称</param>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace FileService.Application.UploadFile
|
||||
{
|
||||
/// <summary>
|
||||
/// 文件下载内容:流 + 内容类型 + 文件名。
|
||||
/// </summary>
|
||||
public sealed record FileDownload(
|
||||
Stream Content,
|
||||
string ContentType,
|
||||
string FileName);
|
||||
}
|
||||
@@ -20,5 +20,10 @@ namespace FileService.Application.UploadFile
|
||||
public CheckSum CheckSum { get; set; }
|
||||
public DateTimeOffset Created { get; set; }
|
||||
public DateTimeOffset Updated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 公开直链;私有文件为 null,需走鉴权下载接口。
|
||||
/// </summary>
|
||||
public string? Url { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace FileService.Application.UploadFile
|
||||
{
|
||||
/// <summary>
|
||||
/// 单次直传命令(小文件,如头像/封面,一次性上传并同步落库)。
|
||||
/// </summary>
|
||||
/// <param name="OwnerId">上传者</param>
|
||||
/// <param name="FileName">原始文件名</param>
|
||||
/// <param name="ContentType">内容类型</param>
|
||||
/// <param name="FileSize">文件大小</param>
|
||||
/// <param name="Content">文件流</param>
|
||||
/// <param name="IsPublic">是否落到公开桶(可生成直链)</param>
|
||||
/// <param name="CheckSum">可选校验值(md5)</param>
|
||||
public sealed record SimpleUploadCommand(
|
||||
Guid OwnerId,
|
||||
string FileName,
|
||||
string ContentType,
|
||||
long FileSize,
|
||||
Stream Content,
|
||||
bool IsPublic,
|
||||
string? CheckSum = null);
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
using AutoMapper;
|
||||
using AutoMapper;
|
||||
using FileService.Application.Ports;
|
||||
using FileService.Application.StorageContracts;
|
||||
using FileService.Domain.IReposities;
|
||||
using FileService.Domain.ValueObjects;
|
||||
using IM.Commons;
|
||||
using IM.InitCommon;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace FileService.Application.UploadFile
|
||||
{
|
||||
@@ -8,11 +13,16 @@ namespace FileService.Application.UploadFile
|
||||
{
|
||||
private readonly IUploadFileReposity reposity;
|
||||
private readonly IMapper mapper;
|
||||
private readonly IObjectStorageRouter router;
|
||||
private readonly IOptions<StorageOptions> options;
|
||||
|
||||
public UploadFileService(IUploadFileReposity reposity, IMapper mapper)
|
||||
public UploadFileService(IUploadFileReposity reposity, IMapper mapper,
|
||||
IObjectStorageRouter router, IOptions<StorageOptions> options)
|
||||
{
|
||||
this.reposity = reposity;
|
||||
this.mapper = mapper;
|
||||
this.router = router;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
public async Task<Result<FileResponse>> GetFileInfoAsync(Guid id)
|
||||
@@ -23,7 +33,98 @@ namespace FileService.Application.UploadFile
|
||||
return Result.Fail<FileResponse>(ResultCode.FILE_NOT_FOUND);
|
||||
}
|
||||
|
||||
return Result.Success(mapper.Map<FileResponse>(file));
|
||||
var response = mapper.Map<FileResponse>(file);
|
||||
response.Url = router.Route(file.StorageLocation.StorageProvider)
|
||||
.GetPublicUrl(file.StorageLocation);
|
||||
return Result.Success(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单次直传:一次性写入存储并同步落库(不分片、不走 MQ)。
|
||||
/// 上传前按 checksum 检查是否已存在(秒传)。
|
||||
/// </summary>
|
||||
public async Task<Result<FileResponse>> SimpleUploadAsync(SimpleUploadCommand command, CancellationToken token = default)
|
||||
{
|
||||
// 秒传:相同 checksum 的文件已存在则直接返回已有记录
|
||||
if (!string.IsNullOrEmpty(command.CheckSum))
|
||||
{
|
||||
var existing = await reposity.FindByCheckSumGlobalAsync("md5", command.CheckSum);
|
||||
if (existing != null)
|
||||
{
|
||||
var hit = mapper.Map<FileResponse>(existing);
|
||||
var hitStorage = router.Route(existing.StorageLocation.StorageProvider);
|
||||
hit.Url = hitStorage.GetPublicUrl(existing.StorageLocation);
|
||||
return Result.Success(hit);
|
||||
}
|
||||
}
|
||||
|
||||
var providerOption = options.Value.Providers[options.Value.DefaultProviderCode];
|
||||
var storage = router.Route(providerOption.ProviderCode);
|
||||
|
||||
// 公开文件落公开桶/目录,否则落私有桶
|
||||
var bucket = command.IsPublic
|
||||
? (providerOption.PublicBucket ?? providerOption.Bucket)
|
||||
: providerOption.Bucket;
|
||||
|
||||
var ext = Path.GetExtension(command.FileName);
|
||||
var date = DateTime.Now;
|
||||
var objectKey = $"{date:yyyy/MM/dd}/{Guid.NewGuid():N}{ext}";
|
||||
|
||||
var location = await storage.PutObjectAsync(new PutObjectCommand(
|
||||
ProviderCode: providerOption.ProviderCode,
|
||||
Bucket: bucket,
|
||||
ObjectKey: objectKey,
|
||||
ContentType: command.ContentType,
|
||||
Content: command.Content,
|
||||
ContentLength: command.FileSize), token);
|
||||
|
||||
var file = new Domain.Entities.UploadFile(
|
||||
ownerId: command.OwnerId,
|
||||
fileName: SafeFileName(command.FileName),
|
||||
fileSize: command.FileSize,
|
||||
contentType: command.ContentType,
|
||||
storageLocation: location,
|
||||
checkSum: new CheckSum("md5", command.CheckSum ?? string.Empty));
|
||||
|
||||
reposity.Create(file);
|
||||
|
||||
var response = mapper.Map<FileResponse>(file);
|
||||
response.Url = storage.GetPublicUrl(location);
|
||||
return Result.Success(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开文件下载流(鉴权下载)。返回流、内容类型与原始文件名。
|
||||
/// </summary>
|
||||
public async Task<Result<FileDownload>> OpenDownloadAsync(Guid id, Guid requesterId, CancellationToken token = default)
|
||||
{
|
||||
var file = await reposity.FindByIdAsync(id);
|
||||
if (file == null)
|
||||
{
|
||||
return Result.Fail<FileDownload>(ResultCode.FILE_NOT_FOUND);
|
||||
}
|
||||
|
||||
var stream = await router.Route(file.StorageLocation.StorageProvider)
|
||||
.OpenReadAsync(file.StorageLocation, token);
|
||||
|
||||
return Result.Success(new FileDownload(
|
||||
stream,
|
||||
file.ContentType.Value,
|
||||
file.FileName.Value));
|
||||
}
|
||||
|
||||
// FileName 值对象限制 20 字符;原始名超长时安全截断(保留扩展名),真实文件名由 objectKey 保证唯一。
|
||||
private static FileName SafeFileName(string fileName)
|
||||
{
|
||||
if (fileName.Length <= 20)
|
||||
{
|
||||
return new FileName(fileName);
|
||||
}
|
||||
|
||||
var ext = Path.GetExtension(fileName);
|
||||
var stem = Path.GetFileNameWithoutExtension(fileName);
|
||||
var keep = Math.Max(0, 20 - ext.Length);
|
||||
return new FileName(stem[..Math.Min(stem.Length, keep)] + ext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,11 @@ using Microsoft.Extensions.Options;
|
||||
|
||||
namespace FileService.Application.UploadFileTask
|
||||
{
|
||||
public class UploadFileTaskService(IUploadTaskReposity reposity,
|
||||
IMapper mapper, IObjectStorageRouter router,
|
||||
public class UploadFileTaskService(IUploadTaskReposity reposity,
|
||||
IMapper mapper, IObjectStorageRouter router,
|
||||
IOptions<StorageOptions> options, IStorageRedisCache redis,
|
||||
IPublishEndpoint endpoint, ILocalChunkStorage localChunkStorage
|
||||
IPublishEndpoint endpoint, ILocalChunkStorage localChunkStorage,
|
||||
IUploadFileReposity uploadFileReposity
|
||||
)
|
||||
{
|
||||
private readonly IUploadTaskReposity reposity = reposity;
|
||||
@@ -23,11 +24,33 @@ namespace FileService.Application.UploadFileTask
|
||||
private readonly IStorageRedisCache redis = redis;
|
||||
private readonly IPublishEndpoint endpoint = endpoint;
|
||||
private readonly ILocalChunkStorage localChunkStorage = localChunkStorage;
|
||||
private readonly IUploadFileReposity uploadFileReposity = uploadFileReposity;
|
||||
private readonly IObjectStoragePort storage = router.Route(options.Value.DefaultProviderCode);
|
||||
|
||||
public async Task<Result<TaskInitResponse>> InitTaskAsync(UploadTaskInitCommand command)
|
||||
{
|
||||
CancellationToken cancellationToken = CancellationToken.None;
|
||||
|
||||
// 秒传:相同 checksum 的文件若已存在于已完成文件表,直接返回已有记录
|
||||
var existingFile = await uploadFileReposity.FindByCheckSumGlobalAsync("md5", command.checkSum);
|
||||
if (existingFile != null)
|
||||
{
|
||||
var storageForResponse = router.Route(existingFile.StorageLocation.StorageProvider);
|
||||
return Result.Success(new TaskInitResponse
|
||||
{
|
||||
TaskId = existingFile.Id,
|
||||
UploadSessionId = existingFile.Id.ToString(),
|
||||
StorageLocation = existingFile.StorageLocation
|
||||
});
|
||||
}
|
||||
|
||||
// 文件大小上限校验
|
||||
var maxSize = options.Value.Providers[options.Value.DefaultProviderCode].MaxObjectSizeBytes;
|
||||
if (command.FileSize > maxSize)
|
||||
{
|
||||
return Result.Fail<TaskInitResponse>(ResultCode.FILE_TOO_LARGE);
|
||||
}
|
||||
|
||||
var task = command.ToUploadTask();
|
||||
var date = DateTime.Now;
|
||||
var storageOption = options.Value.Providers[options.Value.DefaultProviderCode];
|
||||
@@ -41,6 +64,10 @@ namespace FileService.Application.UploadFileTask
|
||||
null);
|
||||
var initRes = await storage.InitUploadAsync(initUpdateCommand, cancellationToken);
|
||||
|
||||
var totalPartCount = (int)(task.FileSize % storageOption.DefaultPartSizeBytes > 0 ?
|
||||
(task.FileSize / storageOption.DefaultPartSizeBytes) + 1 :
|
||||
task.FileSize / storageOption.DefaultPartSizeBytes);
|
||||
|
||||
var res = mapper.Map<TaskInitResponse>(initRes);
|
||||
res.TaskId = task.Id;
|
||||
|
||||
@@ -55,9 +82,7 @@ namespace FileService.Application.UploadFileTask
|
||||
region: storageOption.Region,
|
||||
objectKey: initUpdateCommand.ObjectKey,
|
||||
fileSize: task.FileSize,
|
||||
totalPartCount: (int)(task.FileSize % storageOption.DefaultPartSizeBytes > 0 ?
|
||||
(task.FileSize / storageOption.DefaultPartSizeBytes) + 1 :
|
||||
task.FileSize / storageOption.DefaultPartSizeBytes)
|
||||
totalPartCount: totalPartCount
|
||||
));
|
||||
|
||||
return Result.Success(res);
|
||||
@@ -67,14 +92,14 @@ namespace FileService.Application.UploadFileTask
|
||||
public async Task<Result<PresignedUrl>> GenerateUrlAsync(string sessionId, int partNum, Guid userId, CancellationToken token = default)
|
||||
{
|
||||
var taskCache = await redis.GetAsync(sessionId);
|
||||
if(taskCache is null)
|
||||
if (taskCache is null)
|
||||
{
|
||||
return Result.Fail<PresignedUrl>(ResultCode.CHUNK_NOT_FOUND);
|
||||
}
|
||||
|
||||
if(taskCache.TotalPartCount < partNum)
|
||||
if (taskCache.TotalPartCount < partNum || partNum < 1)
|
||||
{
|
||||
return Result.Fail<PresignedUrl>(ResultCode.CHUNK_NOT_FOUND);
|
||||
return Result.Fail<PresignedUrl>(ResultCode.INVALID_PART_NUMBER);
|
||||
}
|
||||
|
||||
var presignUrl = await storage.GenerateUploadUrlAsync(new GenerateUploadUrlCommand(
|
||||
@@ -93,26 +118,27 @@ namespace FileService.Application.UploadFileTask
|
||||
{
|
||||
var taskCache = await redis.GetAsync(command.UploadSessionId);
|
||||
|
||||
if(taskCache is null)
|
||||
if (taskCache is null)
|
||||
{
|
||||
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_NOT_FOUND);
|
||||
}
|
||||
|
||||
|
||||
if(taskCache.Parts.Count < taskCache.TotalPartCount)
|
||||
// 校验分片数量必须匹配
|
||||
if (command.Parts.Count != taskCache.TotalPartCount)
|
||||
{
|
||||
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_COMBINE_FAIL);
|
||||
return Result.Fail<UploadTaskResponse>(ResultCode.PART_COUNT_MISMATCH);
|
||||
}
|
||||
|
||||
// 校验所有分片都已在上传缓存中注册
|
||||
foreach (var part in command.Parts)
|
||||
{
|
||||
if (!taskCache.Parts.TryGetValue(part.PartNumber, out _))
|
||||
{
|
||||
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId));
|
||||
//var res = await storage.CompleteUploadAsync(new CompleteUploadCommand(
|
||||
// ProviderCode: taskCache.ProviderCode,
|
||||
// Bucket: taskCache.Bucket,
|
||||
// Region: taskCache.Region,
|
||||
// ObjectKey: taskCache.ObjectKey,
|
||||
// UploadSessionId: taskCache.UploadSessionId,
|
||||
// Parts: command.Parts
|
||||
// ), cancellationToken);
|
||||
|
||||
task.CompleteUpload(new Domain.ValueObjects.StorageLocation(
|
||||
taskCache.ProviderCode, taskCache.Bucket,
|
||||
@@ -125,7 +151,7 @@ namespace FileService.Application.UploadFileTask
|
||||
Bucket = taskCache.Bucket,
|
||||
FileName = task.FileName.ToString(),
|
||||
ObjectKey = taskCache.ObjectKey,
|
||||
Parts = command.Parts.Select(s =>
|
||||
Parts = command.Parts.Select(s =>
|
||||
new IM.Commons.IntegrationEvents.UploadPart(
|
||||
s.PartNumber, s.ETag, s.Size, s.Checksum)
|
||||
).ToList(),
|
||||
@@ -144,12 +170,22 @@ namespace FileService.Application.UploadFileTask
|
||||
|
||||
public async Task<Result<CompleteUploadResult>> UploadPartAsync(UploadPartCommand command)
|
||||
{
|
||||
|
||||
var taskCache = await redis.GetAsync(command.SessionId);
|
||||
if(taskCache is null)
|
||||
if (taskCache is null)
|
||||
{
|
||||
return Result.Fail<CompleteUploadResult>(ResultCode.CHUNK_NOT_FOUND);
|
||||
}
|
||||
|
||||
var minPartSize = options.Value.Providers[options.Value.DefaultProviderCode].MinPartSizeBytes;
|
||||
|
||||
// 最后一个分片豁免最小值校验(仅校验非最后一片)
|
||||
var isLastPart = command.PartNum == taskCache.TotalPartCount;
|
||||
if (!isLastPart && command.ContentLength < minPartSize)
|
||||
{
|
||||
return Result.Fail<CompleteUploadResult>(ResultCode.PART_TOO_SMALL,
|
||||
$"分片 {command.PartNum} 大小为 {command.ContentLength} 字节,小于最小值 {minPartSize} 字节");
|
||||
}
|
||||
|
||||
await localChunkStorage.SavePartAsync(new SaveLocalPartCommand(
|
||||
UploadSessionId: command.SessionId,
|
||||
PartNumber: command.PartNum,
|
||||
@@ -166,5 +202,32 @@ namespace FileService.Application.UploadFileTask
|
||||
);
|
||||
return Result.Success(new CompleteUploadResult(location, command.PartNum.ToString(), command.ContentLength));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询分片上传进度。
|
||||
/// </summary>
|
||||
public async Task<Result<UploadProgressResponse>> GetProgressAsync(string sessionId, Guid userId)
|
||||
{
|
||||
var taskCache = await redis.GetAsync(sessionId);
|
||||
if (taskCache is null)
|
||||
{
|
||||
return Result.Fail<UploadProgressResponse>(ResultCode.CHUNK_NOT_FOUND);
|
||||
}
|
||||
|
||||
var response = new UploadProgressResponse
|
||||
{
|
||||
SessionId = sessionId,
|
||||
TaskId = taskCache.TaskId,
|
||||
FileSize = taskCache.FileSize,
|
||||
TotalPartCount = taskCache.TotalPartCount,
|
||||
CompletedPartCount = taskCache.Parts.Count,
|
||||
UploadedBytes = taskCache.UploadedBytes,
|
||||
ProgressPercent = taskCache.FileSize > 0
|
||||
? (int)(taskCache.UploadedBytes * 100 / taskCache.FileSize)
|
||||
: 0
|
||||
};
|
||||
|
||||
return Result.Success(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace FileService.Application.UploadFileTask
|
||||
{
|
||||
/// <summary>
|
||||
/// 分片上传进度响应。
|
||||
/// </summary>
|
||||
public class UploadProgressResponse
|
||||
{
|
||||
public string SessionId { get; set; }
|
||||
public string TaskId { get; set; }
|
||||
public long FileSize { get; set; }
|
||||
public int TotalPartCount { get; set; }
|
||||
public int CompletedPartCount { get; set; }
|
||||
public long UploadedBytes { get; set; }
|
||||
public int ProgressPercent { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user