Some checks are pending
Backend:
- src/food-market.api/Storage/IObjectStorage.cs — абстракция
(SaveAsync, OpenAsync, DeleteAsync, PublicUrl). Имя реализации (Kind)
для логов.
- LocalObjectStorage — ContentRoot/uploads/{key}. По умолчанию.
- MinioObjectStorage — S3-совместимый bucket, ключ-объекта совпадает с
тем что хранится в БД (products/{id:N}/{guid}.png). PutObject с явным
size (для NetworkStream копируем в MemoryStream).
- StorageOptions: Type=Local|Minio, Endpoint, AccessKey, SecretKey,
UseSsl, Bucket=food-market-uploads.
- StorageBootstrap.AddObjectStorage — DI-регистрация с runtime fallback
на Local если MinIO-config пустой; MinioBootstrap (IHostedService)
создаёт bucket на старте, ловит ошибку и не валит API.
- UploadsController: GET /uploads/{**path} → стримит из IObjectStorage
(cache-control 7 дней). Нужен когда MinIO активен — для Local nginx
раздаёт быстрее, но фолбэк работает.
- ProductImagesController отрефакторен на IObjectStorage; URL'ы в БД
остаются /uploads/products/{id}/{guid}.ext.
Тесты:
- StorageAbstractionTests (3/3 ✓): Local default, round-trip bytes,
PublicUrl pattern.
Stage-готовность:
- deploy/docker-compose.yml на стейдже обновлён (через scp): добавлен
minio container, depends_on в api, env переменные Storage__*.
Bucket автосоздаётся.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
62 lines
2.3 KiB
C#
62 lines
2.3 KiB
C#
using Microsoft.Extensions.Options;
|
|
|
|
namespace foodmarket.Api.Storage;
|
|
|
|
/// <summary>Файловое хранилище в <c>{ContentRoot}/uploads/</c>. По умолчанию.
|
|
/// Содержимое раздаётся nginx'ом как статика (см. deploy/nginx.conf, location
|
|
/// /uploads/). Используется когда MinIO не настроен или fallback после
|
|
/// ошибки MinIO connect на старте.</summary>
|
|
public sealed class LocalObjectStorage : IObjectStorage
|
|
{
|
|
private readonly IWebHostEnvironment _env;
|
|
private readonly StorageOptions _opts;
|
|
|
|
public LocalObjectStorage(IWebHostEnvironment env, IOptions<StorageOptions> opts)
|
|
{
|
|
_env = env;
|
|
_opts = opts.Value;
|
|
}
|
|
|
|
public string Kind => "local";
|
|
|
|
private string Root => Path.Combine(_env.ContentRootPath, _opts.LocalRoot.TrimStart('/'));
|
|
|
|
private string PathOf(string key) => Path.Combine(Root, key.Replace('/', Path.DirectorySeparatorChar));
|
|
|
|
public async Task<string> SaveAsync(string key, Stream content, string contentType, CancellationToken ct = default)
|
|
{
|
|
var path = PathOf(key);
|
|
var dir = Path.GetDirectoryName(path);
|
|
if (dir is not null) Directory.CreateDirectory(dir);
|
|
await using var fs = File.Create(path);
|
|
await content.CopyToAsync(fs, ct);
|
|
return key;
|
|
}
|
|
|
|
public Task<(Stream Stream, string ContentType)?> OpenAsync(string key, CancellationToken ct = default)
|
|
{
|
|
var path = PathOf(key);
|
|
if (!File.Exists(path)) return Task.FromResult<(Stream, string)?>(null);
|
|
Stream s = File.OpenRead(path);
|
|
return Task.FromResult<(Stream, string)?>((s, GuessContentType(path)));
|
|
}
|
|
|
|
public Task DeleteAsync(string key, CancellationToken ct = default)
|
|
{
|
|
var path = PathOf(key);
|
|
try { if (File.Exists(path)) File.Delete(path); }
|
|
catch { /* idempotent */ }
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private static string GuessContentType(string path) => Path.GetExtension(path).ToLowerInvariant() switch
|
|
{
|
|
".jpg" or ".jpeg" => "image/jpeg",
|
|
".png" => "image/png",
|
|
".gif" => "image/gif",
|
|
".webp" => "image/webp",
|
|
".csv" => "text/csv",
|
|
_ => "application/octet-stream",
|
|
};
|
|
}
|