Новый сервис Deal.Storage (src/storage-service): gRPC Upload/ Download/Stat/Delete, определение типа контент-снифингом, MinIO-бэкенд, токен-валидация через общий интерцептор. Контракт storage.proto в Deal.Proto; sln сервиса; подключение в compose.dev и эндпоинт в core; тесты (снифер + хост/токен).
136 lines
4.4 KiB
C#
136 lines
4.4 KiB
C#
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();
|
|
}
|
|
}
|