using FileService.Application.Ports; using FileService.Application.StorageContracts; using FileService.Domain.ValueObjects; using IM.Commons; using IM.InitCommon; using Microsoft.Extensions.Options; using StackExchange.Redis; namespace FileService.Infrastructure.Storage { public class LocalStorageAdapter(IStorageRedisCache redis, IOptions options) : IObjectStoragePort, ILocalChunkStorage { private readonly IStorageRedisCache redis = redis; private readonly IOptions options = options; private readonly StorageProviderOptions providerOptions = options.Value.Providers[options.Value.DefaultProviderCode]; public string ProviderCode => "Local"; /// /// 单次直传:直接写入 LocalRootPath/{bucket}/{objectKey}。 /// bucket 传公开桶名即落到公开目录,可被静态托管直链访问。 /// public async Task PutObjectAsync(PutObjectCommand command, CancellationToken token) { var fullPath = Path.Combine(providerOptions.LocalRootPath!, command.Bucket, command.ObjectKey); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); await using (var fs = new FileStream(fullPath, FileMode.Create)) { await command.Content.CopyToAsync(fs, token); await fs.FlushAsync(token); } return new StorageLocation( storageProvider: ProviderCode, bucket: command.Bucket, objectKey: command.ObjectKey, region: providerOptions.Region); } /// /// 公开桶文件返回静态托管直链;私有文件返回 null。 /// public string? GetPublicUrl(StorageLocation location) { if (string.IsNullOrEmpty(providerOptions.PublicBucket) || !string.Equals(location.Bucket, providerOptions.PublicBucket, StringComparison.OrdinalIgnoreCase)) { return null; } var baseUrl = (providerOptions.PublicBaseUrl ?? providerOptions.LocalUploadApiBaseUrl ?? string.Empty) .TrimEnd('/'); var key = location.ObjectKey.Replace('\\', '/').TrimStart('/'); return $"{baseUrl}/static/{key}"; } /// /// 打开本地文件读取流:LocalRootPath/{bucket}/{objectKey}。 /// public Task OpenReadAsync(StorageLocation location, CancellationToken token) { var fullPath = Path.Combine(providerOptions.LocalRootPath!, location.Bucket, location.ObjectKey); if (!File.Exists(fullPath)) { throw new FileNotFoundException(fullPath); } Stream stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read); return Task.FromResult(stream); } public async Task CompleteUploadAsync(CompleteUploadCommand command, CancellationToken token) { var res = await MergeAsync(command.UploadSessionId, command.ObjectKey, command.Parts); return new CompleteUploadResult(new StorageLocation( storageProvider: command.ProviderCode, bucket: command.Bucket, objectKey: command.ObjectKey, region: command.Region ), null, command.Parts.Sum(x => x.Size).Value); } public async Task> MergeAsync(string sessionId, string objectKey, IReadOnlyList parts) { var rootPath = options.Value.Providers[options.Value.DefaultProviderCode].LocalRootPath; var tempPath = Path.Combine(rootPath, sessionId, "parts"); // 项目根目录下 uploads // 最终文件存储路径(这里可以用你之前 ObjectNameGenerator 生成的名字) var finalPath = Path.Combine(rootPath, objectKey); var finalDir = Path.GetDirectoryName(finalPath); Directory.CreateDirectory(finalDir); var storageCache = await redis.GetAsync(sessionId); var totalChunks = storageCache.TotalPartCount; try { using (var finalStream = new FileStream(finalPath, FileMode.Create)) { for (var i = 1; i <= totalChunks; i++) { var progress = (i * 100.0 / totalChunks); if (i % 5 == 0 || i == totalChunks) { //await _redis.HashSetAsync(RedisKeys.MergeStatus(taskId), new HashEntry[] //{ // new("status", "processing"), // new("progress", progress.ToString("F2")) //}); } var chunkPath = Path.Combine(tempPath, $"{i}.part"); if (!File.Exists(chunkPath)) return Result.Fail(ResultCode.CHUNK_NOT_FOUND); using (var chunkStream = new FileStream(chunkPath, FileMode.Open)) { await chunkStream.CopyToAsync(finalStream); } } Directory.Delete(tempPath, true); await redis.DeleteAsync(sessionId); } return Result.Success(); } catch (Exception e) { //_logger.LogError(e, e.Message); throw; } } public async Task GenerateUploadUrlAsync(GenerateUploadUrlCommand command, CancellationToken token) { var baseUrl = options.Value.Providers[options.Value.DefaultProviderCode].LocalUploadApiBaseUrl; return new PresignedUrl( baseUrl + $"local/parts/upload?sessionId={command.UploadSessionId}&partNumber={command.PartNumber}", new Dictionary(), ExpiresAt: DateTimeOffset.Now.Add(options.Value.Providers[options.Value.DefaultProviderCode].UploadUrlExpiresIn) ); } public async Task InitUploadAsync(InitiateUploadCommand command, CancellationToken token) { var sessionId = Guid.NewGuid(); var location = new StorageLocation(); return new InitiateUploadResult(sessionId.ToString(),location); } public async Task SavePartAsync(SaveLocalPartCommand command) { var path = BuildPartPath( command.UploadSessionId, command.PartNumber); Directory.CreateDirectory( Path.GetDirectoryName(path)!); await using var fs = File.Create(path); await command.Stream.CopyToAsync(fs); await fs.FlushAsync(); } private string BuildPartPath( string uploadSessionId, int partNumber) { return Path.Combine( providerOptions.LocalRootPath, uploadSessionId, "parts", $"{partNumber}.part"); } } }