Добавить общий Storage-сервис данных
Новый сервис Deal.Storage (src/storage-service): gRPC Upload/ Download/Stat/Delete, определение типа контент-снифингом, MinIO-бэкенд, токен-валидация через общий интерцептор. Контракт storage.proto в Deal.Proto; sln сервиса; подключение в compose.dev и эндпоинт в core; тесты (снифер + хост/токен).
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
using Deal.Grpc.Storage;
|
||||
using Deal.Storage.Services;
|
||||
using Grpc.Core;
|
||||
|
||||
namespace Deal.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Реализация сервиса данных.
|
||||
/// </summary>
|
||||
public sealed class StorageServiceImpl : StorageService.StorageServiceBase
|
||||
{
|
||||
private const int HeadSize = 512;
|
||||
private const int DownloadChunkSize = 64 * 1024;
|
||||
|
||||
private readonly MinioObjectStore _store;
|
||||
private readonly ILogger<StorageServiceImpl> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт реализацию поверх хранилища.
|
||||
/// </summary>
|
||||
public StorageServiceImpl(MinioObjectStore store, ILogger<StorageServiceImpl> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_store = store;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<UploadReply> Upload(
|
||||
IAsyncStreamReader<UploadRequest> requestStream,
|
||||
ServerCallContext context)
|
||||
{
|
||||
CancellationToken ct = context.CancellationToken;
|
||||
await using MemoryStream buffer = new();
|
||||
byte[] head = new byte[HeadSize];
|
||||
int headLength = 0;
|
||||
UploadMeta? meta = null;
|
||||
|
||||
while (await requestStream.MoveNext(ct))
|
||||
{
|
||||
UploadRequest message = requestStream.Current;
|
||||
if (meta is null && message.Meta is not null)
|
||||
{
|
||||
meta = message.Meta;
|
||||
}
|
||||
|
||||
if (message.Data.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (headLength < HeadSize)
|
||||
{
|
||||
int take = Math.Min(HeadSize - headLength, message.Data.Length);
|
||||
message.Data.Span[..take].CopyTo(head.AsSpan(headLength));
|
||||
headLength += take;
|
||||
}
|
||||
|
||||
await buffer.WriteAsync(message.Data.Memory, ct);
|
||||
}
|
||||
|
||||
if (meta is null || string.IsNullOrWhiteSpace(meta.TenantId))
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, "tenant_id обязателен"));
|
||||
}
|
||||
|
||||
(string kind, string mimeType) = FileKindSniffer.Detect(head.AsSpan(0, headLength), meta.ContentType);
|
||||
string key = $"{meta.TenantId.Trim()}/{Guid.NewGuid():N}";
|
||||
buffer.Position = 0;
|
||||
long size = await _store.PutAsync(key, buffer, mimeType, ct);
|
||||
|
||||
_logger.LogInformation("Объект сохранён: {Key} ({Kind}, {Size} байт)", key, kind, size);
|
||||
|
||||
return new UploadReply
|
||||
{
|
||||
Id = key,
|
||||
Ref = key,
|
||||
Kind = kind,
|
||||
MimeType = mimeType,
|
||||
FileName = meta.FileName ?? string.Empty,
|
||||
Size = size,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task Download(
|
||||
DownloadRequest request,
|
||||
IServerStreamWriter<DownloadChunk> responseStream,
|
||||
ServerCallContext context)
|
||||
{
|
||||
CancellationToken ct = context.CancellationToken;
|
||||
await using Stream? content = await _store.GetAsync(request.Id, ct);
|
||||
if (content is null)
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.NotFound, "объект не найден"));
|
||||
}
|
||||
|
||||
byte[] chunk = new byte[DownloadChunkSize];
|
||||
int read;
|
||||
while ((read = await content.ReadAsync(chunk, ct)) > 0)
|
||||
{
|
||||
await responseStream.WriteAsync(new DownloadChunk { Data = Google.Protobuf.ByteString.CopyFrom(chunk, 0, read) }, ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<StatReply> Stat(StatRequest request, ServerCallContext context)
|
||||
{
|
||||
(long Size, string ContentType)? stat = await _store.StatAsync(request.Id, context.CancellationToken);
|
||||
if (stat is null)
|
||||
{
|
||||
return new StatReply { Found = false };
|
||||
}
|
||||
|
||||
return new StatReply
|
||||
{
|
||||
Found = true,
|
||||
Info = new UploadReply
|
||||
{
|
||||
Id = request.Id,
|
||||
Ref = request.Id,
|
||||
MimeType = stat.Value.ContentType,
|
||||
Size = stat.Value.Size,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<DeleteReply> Delete(DeleteRequest request, ServerCallContext context)
|
||||
{
|
||||
await _store.DeleteAsync(request.Id, context.CancellationToken);
|
||||
return new DeleteReply();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user