174 lines
7.3 KiB
C#
174 lines
7.3 KiB
C#
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<StorageOptions> options) : IObjectStoragePort, ILocalChunkStorage
|
|
{
|
|
private readonly IStorageRedisCache redis = redis;
|
|
private readonly IOptions<StorageOptions> options = options;
|
|
private readonly StorageProviderOptions providerOptions = options.Value.Providers[options.Value.DefaultProviderCode];
|
|
|
|
public string ProviderCode => "Local";
|
|
|
|
/// <summary>
|
|
/// 单次直传:直接写入 LocalRootPath/{bucket}/{objectKey}。
|
|
/// bucket 传公开桶名即落到公开目录,可被静态托管直链访问。
|
|
/// </summary>
|
|
public async Task<StorageLocation> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 公开桶文件返回静态托管直链;私有文件返回 null。
|
|
/// </summary>
|
|
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}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// 打开本地文件读取流:LocalRootPath/{bucket}/{objectKey}。
|
|
/// </summary>
|
|
public Task<Stream> 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<CompleteUploadResult> 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<Result<object>> MergeAsync(string sessionId, string objectKey, IReadOnlyList<UploadPart> 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<PresignedUrl> 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<string, string>(),
|
|
ExpiresAt: DateTimeOffset.Now.Add(options.Value.Providers[options.Value.DefaultProviderCode].UploadUrlExpiresIn)
|
|
);
|
|
}
|
|
|
|
public async Task<InitiateUploadResult> 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");
|
|
}
|
|
}
|
|
}
|