using FileService.Application.Ports; using FileService.Application.StorageContracts; using FileService.Domain.ValueObjects; using IM.Commons; using IM.InitCommon; using Microsoft.Extensions.Options; namespace FileService.Infrastructure.Storage; public class LocalStorageAdapter(IStorageRedisCache redis, IOptionsSnapshot options) : IObjectStoragePort, ILocalChunkStorage { private StorageProviderOptions Provider => options.Value.Providers["Local"]; public string ProviderCode => "Local"; public static string SafePath(string root, params string[] segments) { var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; var path = Path.GetFullPath(Path.Combine(new[] { fullRoot }.Concat(segments).ToArray())); if (!path.StartsWith(fullRoot, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) throw new InvalidOperationException("存储路径超出允许根目录"); for (var dir = Path.GetDirectoryName(path); dir != null && dir.Length >= fullRoot.Length; dir = Path.GetDirectoryName(dir)) if (Directory.Exists(dir) && File.GetAttributes(dir).HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("不允许使用存储目录链接"); if (File.Exists(path) && File.GetAttributes(path).HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("不允许使用文件链接"); return path; } public async Task PutObjectAsync(PutObjectCommand command, CancellationToken token) { var path = SafePath(Provider.LocalRootPath!, command.Bucket, command.ObjectKey); Directory.CreateDirectory(Path.GetDirectoryName(path)!); await using var stream = new FileStream(path, FileMode.CreateNew); await command.Content.CopyToAsync(stream, token); if (stream.Length != command.ContentLength) throw new InvalidOperationException("文件实际大小与申报大小不符"); return new(ProviderCode, command.Bucket, command.ObjectKey, Provider.Region); } public string? GetPublicUrl(StorageLocation location) => !string.IsNullOrEmpty(Provider.PublicBucket) && location.Bucket == Provider.PublicBucket ? $"{(Provider.PublicBaseUrl ?? Provider.LocalUploadApiBaseUrl ?? "").TrimEnd('/')}/static/{string.Join('/', location.ObjectKey.Replace('\\', '/').Split('/').Select(Uri.EscapeDataString))}" : null; public Task OpenReadAsync(StorageLocation location, CancellationToken token) => Task.FromResult(File.OpenRead(SafePath(Provider.LocalRootPath!, location.Bucket, location.ObjectKey))); public Task InitUploadAsync(InitiateUploadCommand command, CancellationToken token) => Task.FromResult(new InitiateUploadResult(Guid.NewGuid().ToString(), new StorageLocation(ProviderCode, command.Bucket, command.ObjectKey, Provider.Region))); public Task GenerateUploadUrlAsync(GenerateUploadUrlCommand command, CancellationToken token) => Task.FromResult(new PresignedUrl( Provider.LocalUploadApiBaseUrl!.TrimEnd('/') + $"/local/parts/upload?sessionId={Uri.EscapeDataString(command.UploadSessionId)}&partNumber={command.PartNumber}", "POST", new Dictionary(), DateTimeOffset.UtcNow.Add(command.ExpiresIn))); public async Task SavePartAsync(SaveLocalPartCommand command) { if (!Guid.TryParse(command.UploadSessionId, out _) || command.PartNumber < 1) throw new InvalidOperationException("分片参数无效"); var path = SafePath(Provider.LocalRootPath!, "staging", command.UploadSessionId, $"{command.PartNumber}.part"); Directory.CreateDirectory(Path.GetDirectoryName(path)!); await using var stream = File.Create(path); await command.Stream.CopyToAsync(stream); if (stream.Length != command.ContentLength) throw new InvalidOperationException("分片实际大小不符"); } public async Task CompleteUploadAsync(CompleteUploadCommand command, CancellationToken token) { var cache = await redis.GetAsync(command.UploadSessionId) ?? throw new InvalidOperationException("上传任务已过期"); var final = SafePath(Provider.LocalRootPath!, command.Bucket, command.ObjectKey); Directory.CreateDirectory(Path.GetDirectoryName(final)!); var temporary = final + ".merging"; await using (var output = File.Create(temporary)) { foreach (var part in command.Parts.OrderBy(x => x.PartNumber)) { await using var input = File.OpenRead(SafePath(Provider.LocalRootPath!, "staging", command.UploadSessionId, $"{part.PartNumber}.part")); await input.CopyToAsync(output, token); } if (output.Length != cache.FileSize) throw new InvalidOperationException("合并文件大小不符"); } File.Move(temporary, final, true); // Keep parts available for idempotent retry after response loss. return new(new StorageLocation(ProviderCode, command.Bucket, command.ObjectKey, command.Region), null, cache.FileSize); } public async Task> MergeAsync(string sessionId, string objectKey, IReadOnlyList parts) { var cache = await redis.GetAsync(sessionId) ?? throw new InvalidOperationException("上传任务已过期"); await CompleteUploadAsync(new CompleteUploadCommand(ProviderCode: cache.ProviderCode, Bucket: cache.Bucket, Region: cache.Region, ObjectKey: objectKey, UploadSessionId: sessionId, Parts: parts), CancellationToken.None); return Result.Success(); } }