Files
IM_NEW/FileService.Application/UploadFile/UploadFileService.cs

177 lines
7.6 KiB
C#

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;
using System.Security.Cryptography;
namespace FileService.Application.UploadFile
{
public class UploadFileService
{
private readonly IUploadFileReposity reposity;
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, 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, 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);
}
/// <summary>
/// 单次直传:一次性写入存储并同步落库(不分片、不走 MQ)。
/// 上传前按 checksum 检查是否已存在(秒传)。
/// </summary>
public async Task<Result<FileResponse>> SimpleUploadAsync(SimpleUploadCommand command, CancellationToken token = default)
{
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 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);
hit.Url = existingPublicUrl;
hit.IsPublic = hit.IsPublic || hit.Url != null;
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", checksum),
isPublic: command.IsPublic);
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);
}
if (!await CanAccessAsync(file, requesterId))
{
return Result.Fail<FileDownload>(ResultCode.PERMISSION_DENIED);
}
var stream = await router.Route(file.StorageLocation.StorageProvider)
.OpenReadAsync(file.StorageLocation, token);
return Result.Success(new FileDownload(
stream,
file.ContentType.Value,
file.FileName.Value));
}
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)
{
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, maxFileNameLength - ext.Length);
return new FileName(stem[..Math.Min(stem.Length, keep)] + ext);
}
}
}