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 options; private readonly IGroupAccessService groupAccessService; private readonly IM.InitCommon.Management.RuntimePolicy runtime; public UploadFileService(IUploadFileReposity reposity, IMapper mapper, IObjectStorageRouter router, IOptionsSnapshot 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> GetFileInfoAsync(Guid id, Guid requesterId) { var file = await reposity.FindByIdAsync(id); if (file == null) { return Result.Fail(ResultCode.FILE_NOT_FOUND); } if (!await CanAccessAsync(file, requesterId)) { return Result.Fail(ResultCode.PERMISSION_DENIED); } var response = mapper.Map(file); response.Url = router.Route(file.StorageLocation.StorageProvider) .GetPublicUrl(file.StorageLocation); response.IsPublic = response.IsPublic || response.Url != null; return Result.Success(response); } /// /// 单次直传:一次性写入存储并同步落库(不分片、不走 MQ)。 /// 上传前按 checksum 检查是否已存在(秒传)。 /// public async Task> 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(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(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(file); response.Url = storage.GetPublicUrl(location); return Result.Success(response); } /// /// 打开文件下载流(鉴权下载)。返回流、内容类型与原始文件名。 /// public async Task> OpenDownloadAsync(Guid id, Guid requesterId, CancellationToken token = default) { var file = await reposity.FindByIdAsync(id); if (file == null) { return Result.Fail(ResultCode.FILE_NOT_FOUND); } if (!await CanAccessAsync(file, requesterId)) { return Result.Fail(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 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); } } }