Добавить общий 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:
Rustam Khalimov
2026-09-11 14:33:43 +03:00
parent e1aebd1d78
commit 770dba7257
18 changed files with 1120 additions and 1 deletions
@@ -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();
}
}