feat(storage): IObjectStorage abstraction (Local + MinIO) — P2-15
Some checks are pending
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>
This commit is contained in:
parent
d451e77642
commit
7de159d5f2
|
|
@ -29,6 +29,7 @@
|
||||||
|
|
||||||
<!-- App services -->
|
<!-- App services -->
|
||||||
<PackageVersion Include="CsvHelper" Version="33.0.1" />
|
<PackageVersion Include="CsvHelper" Version="33.0.1" />
|
||||||
|
<PackageVersion Include="Minio" Version="6.0.5" />
|
||||||
<PackageVersion Include="ClosedXML" Version="0.104.2" />
|
<PackageVersion Include="ClosedXML" Version="0.104.2" />
|
||||||
<PackageVersion Include="MailKit" Version="4.10.0" />
|
<PackageVersion Include="MailKit" Version="4.10.0" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="8.0.11" />
|
<PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="8.0.11" />
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@
|
||||||
|
|
||||||
namespace foodmarket.Api.Controllers.Catalog;
|
namespace foodmarket.Api.Controllers.Catalog;
|
||||||
|
|
||||||
/// <summary>Локальное хранилище изображений товаров: multipart upload →
|
/// <summary>Картинки товаров. Файлы лежат в <see cref="foodmarket.Api.Storage.IObjectStorage"/>
|
||||||
/// /app/uploads/products/{productId}/{guid}.{ext}; в БД лежит относительный путь
|
/// (Local или MinIO — настраивается через Storage:Type). URL в БД хранится
|
||||||
/// типа "/uploads/products/{id}/{file}", раздаётся nginx'ом как статика.</summary>
|
/// как <c>/uploads/products/{id}/{file}</c>; для Local раздаётся nginx'ом
|
||||||
|
/// напрямую, для MinIO — через <c>UploadsController</c>.</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[Route("api/catalog/products/{productId:guid}/images")]
|
[Route("api/catalog/products/{productId:guid}/images")]
|
||||||
|
|
@ -18,13 +19,14 @@ public class ProductImagesController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly ITenantContext _tenant;
|
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;
|
_db = db;
|
||||||
_tenant = tenant;
|
_tenant = tenant;
|
||||||
_env = env;
|
_storage = storage;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly HashSet<string> AllowedExt = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly HashSet<string> AllowedExt = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
|
@ -32,8 +34,6 @@ public ProductImagesController(AppDbContext db, ITenantContext tenant, IWebHostE
|
||||||
|
|
||||||
private const long MaxBytes = 10 * 1024 * 1024;
|
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);
|
public record ImageDto(Guid Id, string Url, bool IsMain, int SortOrder);
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
|
@ -63,17 +63,13 @@ public async Task<ActionResult<ImageDto>> Upload(Guid productId, IFormFile file,
|
||||||
if (product is null) return NotFound();
|
if (product is null) return NotFound();
|
||||||
|
|
||||||
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
|
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 fileName = $"{Guid.NewGuid():N}{ext}";
|
||||||
var fullPath = Path.Combine(dir, fileName);
|
var key = $"products/{productId:N}/{fileName}";
|
||||||
using (var stream = System.IO.File.Create(fullPath))
|
using (var stream = file.OpenReadStream())
|
||||||
{
|
{
|
||||||
await file.CopyToAsync(stream, ct);
|
await _storage.SaveAsync(key, stream, file.ContentType ?? "application/octet-stream", ct);
|
||||||
}
|
}
|
||||||
|
var relativeUrl = _storage.PublicUrl(key);
|
||||||
var relativeUrl = $"/uploads/products/{productId}/{fileName}";
|
|
||||||
var sortOrder = await _db.ProductImages.Where(i => i.ProductId == productId).CountAsync(ct);
|
var sortOrder = await _db.ProductImages.Where(i => i.ProductId == productId).CountAsync(ct);
|
||||||
var isMain = sortOrder == 0; // первое загруженное — основное
|
var isMain = sortOrder == 0; // первое загруженное — основное
|
||||||
var entity = new ProductImage
|
var entity = new ProductImage
|
||||||
|
|
@ -97,11 +93,10 @@ public async Task<IActionResult> Delete(Guid productId, Guid imageId, Cancellati
|
||||||
var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct);
|
var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct);
|
||||||
if (image is null) return NotFound();
|
if (image is null) return NotFound();
|
||||||
|
|
||||||
// Удаляем файл с диска (не фейлим если отсутствует).
|
// Удаляем файл из storage (idempotent).
|
||||||
var fileName = Path.GetFileName(image.Url);
|
var fileName = Path.GetFileName(image.Url);
|
||||||
var fullPath = Path.Combine(UploadRoot, productId.ToString(), fileName);
|
var key = $"products/{productId:N}/{fileName}";
|
||||||
try { if (System.IO.File.Exists(fullPath)) System.IO.File.Delete(fullPath); }
|
await _storage.DeleteAsync(key, ct);
|
||||||
catch { /* ignore */ }
|
|
||||||
|
|
||||||
_db.ProductImages.Remove(image);
|
_db.ProductImages.Remove(image);
|
||||||
|
|
||||||
|
|
|
||||||
32
src/food-market.api/Controllers/Uploads/UploadsController.cs
Normal file
32
src/food-market.api/Controllers/Uploads/UploadsController.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
using foodmarket.Api.Storage;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers.Uploads;
|
||||||
|
|
||||||
|
/// <summary>Универсальный stream-endpoint для отдачи объектов из
|
||||||
|
/// IObjectStorage. Маршрут <c>/uploads/{**path}</c>. Для Local — читает с
|
||||||
|
/// диска, для MinIO — proxy'ит. Авторизация не требуется (картинки товаров
|
||||||
|
/// публичны как и было раньше); если в будущем понадобится защита — добавить
|
||||||
|
/// [Authorize] и подписать URL.
|
||||||
|
///
|
||||||
|
/// nginx должен по-прежнему перехватывать <c>/uploads/</c> и проксировать на
|
||||||
|
/// API (см. deploy/nginx.conf). Сейчас локальный root монтируется в volume и
|
||||||
|
/// nginx раздаёт напрямую — этот контроллер становится альтернативой когда
|
||||||
|
/// MinIO активен.</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("uploads")]
|
||||||
|
public class UploadsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IObjectStorage _storage;
|
||||||
|
public UploadsController(IObjectStorage storage) => _storage = storage;
|
||||||
|
|
||||||
|
[HttpGet("{*path}")]
|
||||||
|
public async Task<IActionResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using foodmarket.Api.Storage;
|
||||||
using Hangfire;
|
using Hangfire;
|
||||||
using Hangfire.PostgreSql;
|
using Hangfire.PostgreSql;
|
||||||
using Prometheus;
|
using Prometheus;
|
||||||
|
|
@ -312,6 +313,10 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||||
});
|
});
|
||||||
builder.Services.AddScoped<foodmarket.Api.Background.OwnerDailySummaryJob>();
|
builder.Services.AddScoped<foodmarket.Api.Background.OwnerDailySummaryJob>();
|
||||||
|
|
||||||
|
// Object storage (картинки товаров, CSV-импорт). Type=Local|Minio,
|
||||||
|
// fallback на Local если MinIO недоступен. См. Storage/StorageBootstrap.cs.
|
||||||
|
builder.Services.AddObjectStorage(builder.Configuration);
|
||||||
|
|
||||||
// SignalR + per-org notification publisher. Hub смонтирован ниже на
|
// SignalR + per-org notification publisher. Hub смонтирован ниже на
|
||||||
// /hubs/notifications. JsonPascalCase оставляем — фронт получает PascalCase
|
// /hubs/notifications. JsonPascalCase оставляем — фронт получает PascalCase
|
||||||
// имена полей в DTO (см. NotificationsPublisher payload-records).
|
// имена полей в DTO (см. NotificationsPublisher payload-records).
|
||||||
|
|
|
||||||
32
src/food-market.api/Storage/IObjectStorage.cs
Normal file
32
src/food-market.api/Storage/IObjectStorage.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
namespace foodmarket.Api.Storage;
|
||||||
|
|
||||||
|
/// <summary>Абстракция для хранилища бинарных файлов (картинки товаров,
|
||||||
|
/// CSV-импорт и т.д.). Реализации: <see cref="LocalObjectStorage"/>
|
||||||
|
/// (файлы в <c>{ContentRoot}/uploads/</c>, доступны nginx'ом) и
|
||||||
|
/// <see cref="MinioObjectStorage"/> (S3-совместимый bucket).
|
||||||
|
///
|
||||||
|
/// Принцип идентификации: <c>key</c> — относительный путь типа
|
||||||
|
/// <c>products/{productId:N}/{guid}.png</c>. URL для публичной отдачи
|
||||||
|
/// возвращает <see cref="PublicUrl"/> — для Local это
|
||||||
|
/// <c>/uploads/{key}</c>, для Minio — тот же путь, но nginx уже его
|
||||||
|
/// проксирует на API (см. GET /uploads/{**path}).</summary>
|
||||||
|
public interface IObjectStorage
|
||||||
|
{
|
||||||
|
/// <summary>Сохранить поток. Возвращает <c>key</c> (то что
|
||||||
|
/// каллер передал).</summary>
|
||||||
|
Task<string> SaveAsync(string key, Stream content, string contentType, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Открыть на чтение. null если объекта нет.</summary>
|
||||||
|
Task<(Stream Stream, string ContentType)?> OpenAsync(string key, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Удалить. Не бросает если объекта уже нет.</summary>
|
||||||
|
Task DeleteAsync(string key, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Публичный URL для отдачи через API/nginx. Не presigned —
|
||||||
|
/// в обоих реализациях возвращает <c>/uploads/{key}</c>. Nginx/api
|
||||||
|
/// прокси решает, откуда взять (диск или MinIO).</summary>
|
||||||
|
string PublicUrl(string key) => $"/uploads/{key.TrimStart('/')}";
|
||||||
|
|
||||||
|
/// <summary>Имя реализации — для логов и диагностики.</summary>
|
||||||
|
string Kind { get; }
|
||||||
|
}
|
||||||
61
src/food-market.api/Storage/LocalObjectStorage.cs
Normal file
61
src/food-market.api/Storage/LocalObjectStorage.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
}
|
||||||
81
src/food-market.api/Storage/MinioObjectStorage.cs
Normal file
81
src/food-market.api/Storage/MinioObjectStorage.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Minio;
|
||||||
|
using Minio.DataModel.Args;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Storage;
|
||||||
|
|
||||||
|
/// <summary>S3-совместимое хранилище. Bucket один (<c>StorageOptions.Bucket</c>),
|
||||||
|
/// создаётся автоматически на старте если не существует (см. EnsureBucketAsync).
|
||||||
|
/// Ключ-объекта совпадает с тем что хранит БД (например
|
||||||
|
/// <c>products/{productId:N}/{guid}.png</c>).
|
||||||
|
///
|
||||||
|
/// Если на старте подключение к MinIO упало — DI заменит сервис на
|
||||||
|
/// <see cref="LocalObjectStorage"/> (см. Program.cs MinIO bootstrap).</summary>
|
||||||
|
public sealed class MinioObjectStorage : IObjectStorage
|
||||||
|
{
|
||||||
|
private readonly IMinioClient _client;
|
||||||
|
private readonly StorageOptions _opts;
|
||||||
|
|
||||||
|
public MinioObjectStorage(IMinioClient client, IOptions<StorageOptions> opts)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
_opts = opts.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Kind => "minio";
|
||||||
|
|
||||||
|
public async Task<string> 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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/food-market.api/Storage/StorageBootstrap.cs
Normal file
122
src/food-market.api/Storage/StorageBootstrap.cs
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Minio;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Storage;
|
||||||
|
|
||||||
|
/// <summary>Регистрирует <see cref="IObjectStorage"/> в DI с поведением:
|
||||||
|
/// - Storage:Type=Local → <see cref="LocalObjectStorage"/>.
|
||||||
|
/// - Storage:Type=Minio → проверяем доступность MinIO, создаём bucket если
|
||||||
|
/// нет; если MinIO недоступен (нет endpoint, нет ключей, сетевая ошибка) —
|
||||||
|
/// fallback на Local + warning в логе. Это позволяет деплоить с
|
||||||
|
/// Storage:Type=Minio даже если контейнер MinIO ещё не поднят, без
|
||||||
|
/// падения API.</summary>
|
||||||
|
public static class StorageBootstrap
|
||||||
|
{
|
||||||
|
public static void AddObjectStorage(this IServiceCollection services, IConfiguration cfg)
|
||||||
|
{
|
||||||
|
services.Configure<StorageOptions>(cfg.GetSection("Storage"));
|
||||||
|
services.AddSingleton<LocalObjectStorage>();
|
||||||
|
|
||||||
|
// MinioClient — singleton, чтобы переиспользовать HttpClient пул.
|
||||||
|
// Лямбда может вернуть null когда конфиг не задан — DI это допускает
|
||||||
|
// через .NET 8 generic constraint nullability. GetService<IMinioClient>()
|
||||||
|
// вернёт null если фабрика отдала null.
|
||||||
|
services.AddSingleton(sp =>
|
||||||
|
{
|
||||||
|
var opts = sp.GetRequiredService<IOptions<StorageOptions>>().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<IMinioClient>();
|
||||||
|
if (client is null) return (MinioObjectStorage?)null;
|
||||||
|
return new MinioObjectStorage(client, sp.GetRequiredService<IOptions<StorageOptions>>());
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddSingleton<IObjectStorage>(sp =>
|
||||||
|
{
|
||||||
|
var opts = sp.GetRequiredService<IOptions<StorageOptions>>().Value;
|
||||||
|
var log = sp.GetRequiredService<ILogger<LocalObjectStorage>>();
|
||||||
|
if (opts.Type == StorageType.Local)
|
||||||
|
{
|
||||||
|
log.LogInformation("Storage: using Local ({Root})", opts.LocalRoot);
|
||||||
|
return sp.GetRequiredService<LocalObjectStorage>();
|
||||||
|
}
|
||||||
|
var minio = sp.GetService<MinioObjectStorage>();
|
||||||
|
if (minio is null)
|
||||||
|
{
|
||||||
|
log.LogWarning("Storage: MinIO configured but endpoint/keys missing — falling back to Local");
|
||||||
|
return sp.GetRequiredService<LocalObjectStorage>();
|
||||||
|
}
|
||||||
|
log.LogInformation("Storage: using MinIO endpoint={Endpoint} bucket={Bucket}",
|
||||||
|
opts.Endpoint, opts.Bucket);
|
||||||
|
return minio;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hosted service: на старте проверяем MinIO и создаём bucket если нужно.
|
||||||
|
services.AddHostedService<MinioBootstrap>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hosted service — проверяет MinIO живой, создаёт bucket. Если
|
||||||
|
/// что-то не так — логируем и продолжаем; запрос uploadа фолбэкнется на
|
||||||
|
/// LocalObjectStorage благодаря StorageBootstrap.AddObjectStorage.</summary>
|
||||||
|
public sealed class MinioBootstrap : IHostedService
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _sp;
|
||||||
|
private readonly StorageOptions _opts;
|
||||||
|
private readonly ILogger<MinioBootstrap> _log;
|
||||||
|
|
||||||
|
public MinioBootstrap(IServiceProvider sp, IOptions<StorageOptions> opts, ILogger<MinioBootstrap> log)
|
||||||
|
{
|
||||||
|
_sp = sp;
|
||||||
|
_opts = opts.Value;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_opts.Type != StorageType.Minio) return;
|
||||||
|
var client = _sp.GetService<IMinioClient>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
31
src/food-market.api/Storage/StorageOptions.cs
Normal file
31
src/food-market.api/Storage/StorageOptions.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
namespace foodmarket.Api.Storage;
|
||||||
|
|
||||||
|
/// <summary>Конфиг секции <c>Storage</c>. Биндится в Program.cs.</summary>
|
||||||
|
public sealed class StorageOptions
|
||||||
|
{
|
||||||
|
/// <summary>Какой backend использовать. <c>Local</c> (default) — диск
|
||||||
|
/// в ContentRoot/uploads. <c>Minio</c> — S3-совместимый bucket.</summary>
|
||||||
|
public StorageType Type { get; set; } = StorageType.Local;
|
||||||
|
|
||||||
|
/// <summary>Относительный путь под ContentRoot для Local-стораджа.</summary>
|
||||||
|
public string LocalRoot { get; set; } = "uploads";
|
||||||
|
|
||||||
|
/// <summary>S3/MinIO endpoint (без https). Например
|
||||||
|
/// <c>192.168.1.190:9000</c> или <c>minio:9000</c>. Прокидывается через
|
||||||
|
/// env <c>Storage__Endpoint</c>.</summary>
|
||||||
|
public string? Endpoint { get; set; }
|
||||||
|
|
||||||
|
/// <summary>S3 access key. env <c>Storage__AccessKey</c>.</summary>
|
||||||
|
public string? AccessKey { get; set; }
|
||||||
|
|
||||||
|
/// <summary>S3 secret key. env <c>Storage__SecretKey</c>.</summary>
|
||||||
|
public string? SecretKey { get; set; }
|
||||||
|
|
||||||
|
/// <summary>HTTPS или HTTP. На staging-vm у нас HTTP.</summary>
|
||||||
|
public bool UseSsl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Bucket. Создаётся автоматически на старте если не существует.</summary>
|
||||||
|
public string Bucket { get; set; } = "food-market-uploads";
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum StorageType { Local, Minio }
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
<PackageReference Include="Hangfire.AspNetCore" />
|
<PackageReference Include="Hangfire.AspNetCore" />
|
||||||
<PackageReference Include="Hangfire.PostgreSql" />
|
<PackageReference Include="Hangfire.PostgreSql" />
|
||||||
<PackageReference Include="CsvHelper" />
|
<PackageReference Include="CsvHelper" />
|
||||||
|
<PackageReference Include="Minio" />
|
||||||
<PackageReference Include="ClosedXML" />
|
<PackageReference Include="ClosedXML" />
|
||||||
<PackageReference Include="prometheus-net.AspNetCore" />
|
<PackageReference Include="prometheus-net.AspNetCore" />
|
||||||
<PackageReference Include="MediatR" />
|
<PackageReference Include="MediatR" />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
using FluentAssertions;
|
||||||
|
using foodmarket.IntegrationTests.Support;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace foodmarket.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>Тест абстракции <see cref="foodmarket.Api.Storage.IObjectStorage"/>:
|
||||||
|
/// в default-конфиге (Storage:Type=Local) загружаем картинку товара через
|
||||||
|
/// существующий endpoint, потом читаем по public-URL через UploadsController.
|
||||||
|
/// Проверяем что file round-trip'aет.</summary>
|
||||||
|
[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<foodmarket.Api.Storage.IObjectStorage>();
|
||||||
|
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<foodmarket.Api.Storage.IObjectStorage>();
|
||||||
|
|
||||||
|
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<foodmarket.Api.Storage.IObjectStorage>();
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue