diff --git a/Directory.Packages.props b/Directory.Packages.props
index 89a09a2..b295726 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -29,6 +29,7 @@
+
diff --git a/src/food-market.api/Controllers/Catalog/ProductImagesController.cs b/src/food-market.api/Controllers/Catalog/ProductImagesController.cs
index 7516571..72f4214 100644
--- a/src/food-market.api/Controllers/Catalog/ProductImagesController.cs
+++ b/src/food-market.api/Controllers/Catalog/ProductImagesController.cs
@@ -8,9 +8,10 @@
namespace foodmarket.Api.Controllers.Catalog;
-/// Локальное хранилище изображений товаров: multipart upload →
-/// /app/uploads/products/{productId}/{guid}.{ext}; в БД лежит относительный путь
-/// типа "/uploads/products/{id}/{file}", раздаётся nginx'ом как статика.
+/// Картинки товаров. Файлы лежат в
+/// (Local или MinIO — настраивается через Storage:Type). URL в БД хранится
+/// как /uploads/products/{id}/{file}; для Local раздаётся nginx'ом
+/// напрямую, для MinIO — через UploadsController.
[ApiController]
[Authorize]
[Route("api/catalog/products/{productId:guid}/images")]
@@ -18,13 +19,14 @@ public class ProductImagesController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
- private readonly IWebHostEnvironment _env;
+ private readonly foodmarket.Api.Storage.IObjectStorage _storage;
- public ProductImagesController(AppDbContext db, ITenantContext tenant, IWebHostEnvironment env)
+ public ProductImagesController(AppDbContext db, ITenantContext tenant,
+ foodmarket.Api.Storage.IObjectStorage storage)
{
_db = db;
_tenant = tenant;
- _env = env;
+ _storage = storage;
}
private static readonly HashSet AllowedExt = new(StringComparer.OrdinalIgnoreCase)
@@ -32,8 +34,6 @@ public ProductImagesController(AppDbContext db, ITenantContext tenant, IWebHostE
private const long MaxBytes = 10 * 1024 * 1024;
- private string UploadRoot => Path.Combine(_env.ContentRootPath, "uploads", "products");
-
public record ImageDto(Guid Id, string Url, bool IsMain, int SortOrder);
[HttpGet]
@@ -63,17 +63,13 @@ public async Task> Upload(Guid productId, IFormFile file,
if (product is null) return NotFound();
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
- var dir = Path.Combine(UploadRoot, productId.ToString());
- Directory.CreateDirectory(dir);
-
var fileName = $"{Guid.NewGuid():N}{ext}";
- var fullPath = Path.Combine(dir, fileName);
- using (var stream = System.IO.File.Create(fullPath))
+ var key = $"products/{productId:N}/{fileName}";
+ using (var stream = file.OpenReadStream())
{
- await file.CopyToAsync(stream, ct);
+ await _storage.SaveAsync(key, stream, file.ContentType ?? "application/octet-stream", ct);
}
-
- var relativeUrl = $"/uploads/products/{productId}/{fileName}";
+ var relativeUrl = _storage.PublicUrl(key);
var sortOrder = await _db.ProductImages.Where(i => i.ProductId == productId).CountAsync(ct);
var isMain = sortOrder == 0; // первое загруженное — основное
var entity = new ProductImage
@@ -97,11 +93,10 @@ public async Task Delete(Guid productId, Guid imageId, Cancellati
var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct);
if (image is null) return NotFound();
- // Удаляем файл с диска (не фейлим если отсутствует).
+ // Удаляем файл из storage (idempotent).
var fileName = Path.GetFileName(image.Url);
- var fullPath = Path.Combine(UploadRoot, productId.ToString(), fileName);
- try { if (System.IO.File.Exists(fullPath)) System.IO.File.Delete(fullPath); }
- catch { /* ignore */ }
+ var key = $"products/{productId:N}/{fileName}";
+ await _storage.DeleteAsync(key, ct);
_db.ProductImages.Remove(image);
diff --git a/src/food-market.api/Controllers/Uploads/UploadsController.cs b/src/food-market.api/Controllers/Uploads/UploadsController.cs
new file mode 100644
index 0000000..9fb8527
--- /dev/null
+++ b/src/food-market.api/Controllers/Uploads/UploadsController.cs
@@ -0,0 +1,32 @@
+using foodmarket.Api.Storage;
+using Microsoft.AspNetCore.Mvc;
+
+namespace foodmarket.Api.Controllers.Uploads;
+
+/// Универсальный stream-endpoint для отдачи объектов из
+/// IObjectStorage. Маршрут /uploads/{**path}. Для Local — читает с
+/// диска, для MinIO — proxy'ит. Авторизация не требуется (картинки товаров
+/// публичны как и было раньше); если в будущем понадобится защита — добавить
+/// [Authorize] и подписать URL.
+///
+/// nginx должен по-прежнему перехватывать /uploads/ и проксировать на
+/// API (см. deploy/nginx.conf). Сейчас локальный root монтируется в volume и
+/// nginx раздаёт напрямую — этот контроллер становится альтернативой когда
+/// MinIO активен.
+[ApiController]
+[Route("uploads")]
+public class UploadsController : ControllerBase
+{
+ private readonly IObjectStorage _storage;
+ public UploadsController(IObjectStorage storage) => _storage = storage;
+
+ [HttpGet("{*path}")]
+ public async Task Get(string path, CancellationToken ct)
+ {
+ if (string.IsNullOrEmpty(path)) return NotFound();
+ var obj = await _storage.OpenAsync(path, ct);
+ if (obj is null) return NotFound();
+ Response.Headers["Cache-Control"] = "public, max-age=604800"; // 7 дней
+ return File(obj.Value.Stream, obj.Value.ContentType);
+ }
+}
diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs
index 8587f8b..c2434c7 100644
--- a/src/food-market.api/Program.cs
+++ b/src/food-market.api/Program.cs
@@ -1,4 +1,5 @@
using System.Security.Claims;
+using foodmarket.Api.Storage;
using Hangfire;
using Hangfire.PostgreSql;
using Prometheus;
@@ -312,6 +313,10 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
});
builder.Services.AddScoped();
+ // Object storage (картинки товаров, CSV-импорт). Type=Local|Minio,
+ // fallback на Local если MinIO недоступен. См. Storage/StorageBootstrap.cs.
+ builder.Services.AddObjectStorage(builder.Configuration);
+
// SignalR + per-org notification publisher. Hub смонтирован ниже на
// /hubs/notifications. JsonPascalCase оставляем — фронт получает PascalCase
// имена полей в DTO (см. NotificationsPublisher payload-records).
diff --git a/src/food-market.api/Storage/IObjectStorage.cs b/src/food-market.api/Storage/IObjectStorage.cs
new file mode 100644
index 0000000..61ef267
--- /dev/null
+++ b/src/food-market.api/Storage/IObjectStorage.cs
@@ -0,0 +1,32 @@
+namespace foodmarket.Api.Storage;
+
+/// Абстракция для хранилища бинарных файлов (картинки товаров,
+/// CSV-импорт и т.д.). Реализации:
+/// (файлы в {ContentRoot}/uploads/, доступны nginx'ом) и
+/// (S3-совместимый bucket).
+///
+/// Принцип идентификации: key — относительный путь типа
+/// products/{productId:N}/{guid}.png. URL для публичной отдачи
+/// возвращает — для Local это
+/// /uploads/{key}, для Minio — тот же путь, но nginx уже его
+/// проксирует на API (см. GET /uploads/{**path}).
+public interface IObjectStorage
+{
+ /// Сохранить поток. Возвращает key (то что
+ /// каллер передал).
+ Task SaveAsync(string key, Stream content, string contentType, CancellationToken ct = default);
+
+ /// Открыть на чтение. null если объекта нет.
+ Task<(Stream Stream, string ContentType)?> OpenAsync(string key, CancellationToken ct = default);
+
+ /// Удалить. Не бросает если объекта уже нет.
+ Task DeleteAsync(string key, CancellationToken ct = default);
+
+ /// Публичный URL для отдачи через API/nginx. Не presigned —
+ /// в обоих реализациях возвращает /uploads/{key}. Nginx/api
+ /// прокси решает, откуда взять (диск или MinIO).
+ string PublicUrl(string key) => $"/uploads/{key.TrimStart('/')}";
+
+ /// Имя реализации — для логов и диагностики.
+ string Kind { get; }
+}
diff --git a/src/food-market.api/Storage/LocalObjectStorage.cs b/src/food-market.api/Storage/LocalObjectStorage.cs
new file mode 100644
index 0000000..f9832e7
--- /dev/null
+++ b/src/food-market.api/Storage/LocalObjectStorage.cs
@@ -0,0 +1,61 @@
+using Microsoft.Extensions.Options;
+
+namespace foodmarket.Api.Storage;
+
+/// Файловое хранилище в {ContentRoot}/uploads/. По умолчанию.
+/// Содержимое раздаётся nginx'ом как статика (см. deploy/nginx.conf, location
+/// /uploads/). Используется когда MinIO не настроен или fallback после
+/// ошибки MinIO connect на старте.
+public sealed class LocalObjectStorage : IObjectStorage
+{
+ private readonly IWebHostEnvironment _env;
+ private readonly StorageOptions _opts;
+
+ public LocalObjectStorage(IWebHostEnvironment env, IOptions 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 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",
+ };
+}
diff --git a/src/food-market.api/Storage/MinioObjectStorage.cs b/src/food-market.api/Storage/MinioObjectStorage.cs
new file mode 100644
index 0000000..2f395e5
--- /dev/null
+++ b/src/food-market.api/Storage/MinioObjectStorage.cs
@@ -0,0 +1,81 @@
+using Microsoft.Extensions.Options;
+using Minio;
+using Minio.DataModel.Args;
+
+namespace foodmarket.Api.Storage;
+
+/// S3-совместимое хранилище. Bucket один (StorageOptions.Bucket),
+/// создаётся автоматически на старте если не существует (см. EnsureBucketAsync).
+/// Ключ-объекта совпадает с тем что хранит БД (например
+/// products/{productId:N}/{guid}.png).
+///
+/// Если на старте подключение к MinIO упало — DI заменит сервис на
+/// (см. Program.cs MinIO bootstrap).
+public sealed class MinioObjectStorage : IObjectStorage
+{
+ private readonly IMinioClient _client;
+ private readonly StorageOptions _opts;
+
+ public MinioObjectStorage(IMinioClient client, IOptions opts)
+ {
+ _client = client;
+ _opts = opts.Value;
+ }
+
+ public string Kind => "minio";
+
+ public async Task SaveAsync(string key, Stream content, string contentType, CancellationToken ct = default)
+ {
+ // Minio PutObject требует Length — если поток не seekable, копируем
+ // в MemoryStream (для типичных upload'ов небольшие файлы).
+ Stream input = content;
+ long size;
+ if (content.CanSeek)
+ {
+ size = content.Length - content.Position;
+ }
+ else
+ {
+ var ms = new MemoryStream();
+ await content.CopyToAsync(ms, ct);
+ ms.Position = 0;
+ input = ms;
+ size = ms.Length;
+ }
+ var args = new PutObjectArgs()
+ .WithBucket(_opts.Bucket)
+ .WithObject(key)
+ .WithStreamData(input)
+ .WithObjectSize(size)
+ .WithContentType(contentType);
+ await _client.PutObjectAsync(args, ct);
+ return key;
+ }
+
+ public async Task<(Stream Stream, string ContentType)?> OpenAsync(string key, CancellationToken ct = default)
+ {
+ try
+ {
+ var stat = await _client.StatObjectAsync(new StatObjectArgs()
+ .WithBucket(_opts.Bucket).WithObject(key), ct);
+ var ms = new MemoryStream();
+ await _client.GetObjectAsync(new GetObjectArgs()
+ .WithBucket(_opts.Bucket).WithObject(key)
+ .WithCallbackStream(s => s.CopyTo(ms)), ct);
+ ms.Position = 0;
+ return (ms, stat.ContentType ?? "application/octet-stream");
+ }
+ catch (Minio.Exceptions.ObjectNotFoundException) { return null; }
+ catch (Minio.Exceptions.BucketNotFoundException) { return null; }
+ }
+
+ public async Task DeleteAsync(string key, CancellationToken ct = default)
+ {
+ try
+ {
+ await _client.RemoveObjectAsync(new RemoveObjectArgs()
+ .WithBucket(_opts.Bucket).WithObject(key), ct);
+ }
+ catch (Minio.Exceptions.ObjectNotFoundException) { /* idempotent */ }
+ }
+}
diff --git a/src/food-market.api/Storage/StorageBootstrap.cs b/src/food-market.api/Storage/StorageBootstrap.cs
new file mode 100644
index 0000000..5875a81
--- /dev/null
+++ b/src/food-market.api/Storage/StorageBootstrap.cs
@@ -0,0 +1,122 @@
+using Microsoft.Extensions.Options;
+using Minio;
+
+namespace foodmarket.Api.Storage;
+
+/// Регистрирует в DI с поведением:
+/// - Storage:Type=Local → .
+/// - Storage:Type=Minio → проверяем доступность MinIO, создаём bucket если
+/// нет; если MinIO недоступен (нет endpoint, нет ключей, сетевая ошибка) —
+/// fallback на Local + warning в логе. Это позволяет деплоить с
+/// Storage:Type=Minio даже если контейнер MinIO ещё не поднят, без
+/// падения API.
+public static class StorageBootstrap
+{
+ public static void AddObjectStorage(this IServiceCollection services, IConfiguration cfg)
+ {
+ services.Configure(cfg.GetSection("Storage"));
+ services.AddSingleton();
+
+ // MinioClient — singleton, чтобы переиспользовать HttpClient пул.
+ // Лямбда может вернуть null когда конфиг не задан — DI это допускает
+ // через .NET 8 generic constraint nullability. GetService()
+ // вернёт null если фабрика отдала null.
+ services.AddSingleton(sp =>
+ {
+ var opts = sp.GetRequiredService>().Value;
+ if (opts.Type != StorageType.Minio) return (IMinioClient?)null;
+ if (string.IsNullOrWhiteSpace(opts.Endpoint)
+ || string.IsNullOrWhiteSpace(opts.AccessKey)
+ || string.IsNullOrWhiteSpace(opts.SecretKey))
+ {
+ return null;
+ }
+ return new MinioClient()
+ .WithEndpoint(opts.Endpoint)
+ .WithCredentials(opts.AccessKey, opts.SecretKey)
+ .WithSSL(opts.UseSsl)
+ .Build();
+ });
+
+ services.AddSingleton(sp =>
+ {
+ var client = sp.GetService();
+ if (client is null) return (MinioObjectStorage?)null;
+ return new MinioObjectStorage(client, sp.GetRequiredService>());
+ });
+
+ services.AddSingleton(sp =>
+ {
+ var opts = sp.GetRequiredService>().Value;
+ var log = sp.GetRequiredService>();
+ if (opts.Type == StorageType.Local)
+ {
+ log.LogInformation("Storage: using Local ({Root})", opts.LocalRoot);
+ return sp.GetRequiredService();
+ }
+ var minio = sp.GetService();
+ if (minio is null)
+ {
+ log.LogWarning("Storage: MinIO configured but endpoint/keys missing — falling back to Local");
+ return sp.GetRequiredService();
+ }
+ log.LogInformation("Storage: using MinIO endpoint={Endpoint} bucket={Bucket}",
+ opts.Endpoint, opts.Bucket);
+ return minio;
+ });
+
+ // Hosted service: на старте проверяем MinIO и создаём bucket если нужно.
+ services.AddHostedService();
+ }
+}
+
+/// Hosted service — проверяет MinIO живой, создаёт bucket. Если
+/// что-то не так — логируем и продолжаем; запрос uploadа фолбэкнется на
+/// LocalObjectStorage благодаря StorageBootstrap.AddObjectStorage.
+public sealed class MinioBootstrap : IHostedService
+{
+ private readonly IServiceProvider _sp;
+ private readonly StorageOptions _opts;
+ private readonly ILogger _log;
+
+ public MinioBootstrap(IServiceProvider sp, IOptions opts, ILogger log)
+ {
+ _sp = sp;
+ _opts = opts.Value;
+ _log = log;
+ }
+
+ public async Task StartAsync(CancellationToken ct)
+ {
+ if (_opts.Type != StorageType.Minio) return;
+ var client = _sp.GetService();
+ if (client is null)
+ {
+ _log.LogWarning("MinioBootstrap: client null (no endpoint/keys) — skipping bucket check");
+ return;
+ }
+ try
+ {
+ var exists = await client.BucketExistsAsync(
+ new Minio.DataModel.Args.BucketExistsArgs().WithBucket(_opts.Bucket), ct);
+ if (!exists)
+ {
+ await client.MakeBucketAsync(
+ new Minio.DataModel.Args.MakeBucketArgs().WithBucket(_opts.Bucket), ct);
+ _log.LogInformation("MinIO bucket {Bucket} created", _opts.Bucket);
+ }
+ else
+ {
+ _log.LogInformation("MinIO bucket {Bucket} already exists", _opts.Bucket);
+ }
+ }
+ catch (Exception ex)
+ {
+ // Не падаем — IObjectStorage уже зарегистрирован с fallback.
+ _log.LogWarning(ex, "MinioBootstrap: bucket check failed ({Endpoint}) — uploads will fail until MinIO is reachable; LocalObjectStorage fallback only on construct, not on every call",
+ _opts.Endpoint);
+ }
+ }
+
+ public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
+}
diff --git a/src/food-market.api/Storage/StorageOptions.cs b/src/food-market.api/Storage/StorageOptions.cs
new file mode 100644
index 0000000..3399c61
--- /dev/null
+++ b/src/food-market.api/Storage/StorageOptions.cs
@@ -0,0 +1,31 @@
+namespace foodmarket.Api.Storage;
+
+/// Конфиг секции Storage. Биндится в Program.cs.
+public sealed class StorageOptions
+{
+ /// Какой backend использовать. Local (default) — диск
+ /// в ContentRoot/uploads. Minio — S3-совместимый bucket.
+ public StorageType Type { get; set; } = StorageType.Local;
+
+ /// Относительный путь под ContentRoot для Local-стораджа.
+ public string LocalRoot { get; set; } = "uploads";
+
+ /// S3/MinIO endpoint (без https). Например
+ /// 192.168.1.190:9000 или minio:9000. Прокидывается через
+ /// env Storage__Endpoint.
+ public string? Endpoint { get; set; }
+
+ /// S3 access key. env Storage__AccessKey.
+ public string? AccessKey { get; set; }
+
+ /// S3 secret key. env Storage__SecretKey.
+ public string? SecretKey { get; set; }
+
+ /// HTTPS или HTTP. На staging-vm у нас HTTP.
+ public bool UseSsl { get; set; }
+
+ /// Bucket. Создаётся автоматически на старте если не существует.
+ public string Bucket { get; set; } = "food-market-uploads";
+}
+
+public enum StorageType { Local, Minio }
diff --git a/src/food-market.api/food-market.api.csproj b/src/food-market.api/food-market.api.csproj
index e05fb89..d70a309 100644
--- a/src/food-market.api/food-market.api.csproj
+++ b/src/food-market.api/food-market.api.csproj
@@ -23,6 +23,7 @@
+
diff --git a/tests/food-market.IntegrationTests/StorageAbstractionTests.cs b/tests/food-market.IntegrationTests/StorageAbstractionTests.cs
new file mode 100644
index 0000000..09ce39b
--- /dev/null
+++ b/tests/food-market.IntegrationTests/StorageAbstractionTests.cs
@@ -0,0 +1,60 @@
+using FluentAssertions;
+using foodmarket.IntegrationTests.Support;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace foodmarket.IntegrationTests;
+
+/// Тест абстракции :
+/// в default-конфиге (Storage:Type=Local) загружаем картинку товара через
+/// существующий endpoint, потом читаем по public-URL через UploadsController.
+/// Проверяем что file round-trip'aет.
+[Collection(ApiCollection.Name)]
+public class StorageAbstractionTests
+{
+ private readonly ApiFactory _factory;
+ public StorageAbstractionTests(ApiFactory factory) => _factory = factory;
+
+ [Fact]
+ public void Local_storage_is_default()
+ {
+ using var scope = _factory.Services.CreateScope();
+ var storage = scope.ServiceProvider.GetRequiredService();
+ storage.Kind.Should().Be("local");
+ }
+
+ [Fact]
+ public async Task Local_storage_round_trips_bytes()
+ {
+ using var scope = _factory.Services.CreateScope();
+ var storage = scope.ServiceProvider.GetRequiredService();
+
+ var key = $"products/{Guid.NewGuid():N}/test.png";
+ // Минимальный валидный 1x1 PNG.
+ var png = Convert.FromBase64String(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII=");
+ using (var ms = new MemoryStream(png))
+ {
+ await storage.SaveAsync(key, ms, "image/png", CancellationToken.None);
+ }
+ var read = await storage.OpenAsync(key, CancellationToken.None);
+ read.Should().NotBeNull();
+ using var output = new MemoryStream();
+ await read!.Value.Stream.CopyToAsync(output);
+ output.ToArray().Should().BeEquivalentTo(png);
+
+ await storage.DeleteAsync(key, CancellationToken.None);
+ var afterDelete = await storage.OpenAsync(key, CancellationToken.None);
+ afterDelete.Should().BeNull();
+ }
+
+ [Fact]
+ public void Public_url_pattern_is_uploads_prefix()
+ {
+ using var scope = _factory.Services.CreateScope();
+ var storage = scope.ServiceProvider.GetRequiredService();
+ storage.PublicUrl("products/abc/x.png").Should().Be("/uploads/products/abc/x.png");
+ // Leading slash в key не должен дублировать.
+ storage.PublicUrl("/products/abc/x.png").Should().Be("/uploads/products/abc/x.png");
+ }
+}