97a9481627
Uploads previously wrote every file to disk with a fresh GUID name, so the
same image uploaded twice produced two identical files. Now:
- New MediaAsset table records each stored upload (SHA-256 hash, size, type,
url, kind, scope) + migration. Indexed on (CafeId, ContentHash).
- MediaStorageService computes the content hash on upload; if an identical file
already exists for that café it returns the existing URL instead of writing a
duplicate (covers images, videos, 3D models). Dedup lookup/record run via a
scoped DbContext (the service is a singleton) and never block an upload on
failure.
- GET /api/cafes/{cafeId}/media lists the café's library (newest first, optional
?kind=) so the UI can let users pick an existing file instead of re-uploading.
86 API tests pass.
236 lines
10 KiB
C#
236 lines
10 KiB
C#
using System.Security.Cryptography;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Meezi.Core.Entities;
|
|
using Meezi.Infrastructure.Data;
|
|
|
|
namespace Meezi.API.Services;
|
|
|
|
public interface IMediaStorageService
|
|
{
|
|
Task<string?> SaveMenuImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
|
Task<string?> SaveMenuVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
|
Task<string?> SaveTableImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
|
Task<string?> SaveTableVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
|
Task<string?> SaveCafeLogoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
|
Task<string?> SaveCafeCoverAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
|
Task<string?> SaveMenuModel3dAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
|
Task<string?> SaveMenuModel3dFromBytesAsync(string cafeId, byte[] glbBytes, CancellationToken cancellationToken = default);
|
|
Task<string?> SaveReviewPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
|
Task<string?> SaveCafeGalleryPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
public class MediaStorageService : IMediaStorageService
|
|
{
|
|
private static readonly HashSet<string> ImageMime = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"image/jpeg", "image/png", "image/webp"
|
|
};
|
|
|
|
private static readonly HashSet<string> VideoMime = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"video/mp4", "video/webm", "video/quicktime"
|
|
};
|
|
|
|
private const long MaxImageBytes = 5 * 1024 * 1024;
|
|
private const long MaxVideoBytes = 25 * 1024 * 1024;
|
|
private const long MaxModel3dBytes = 8 * 1024 * 1024;
|
|
|
|
private static readonly HashSet<string> Model3dMime = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"model/gltf-binary", "application/octet-stream"
|
|
};
|
|
|
|
private readonly IWebHostEnvironment _env;
|
|
private readonly ILogger<MediaStorageService> _logger;
|
|
private readonly IServiceScopeFactory _scopeFactory;
|
|
|
|
public MediaStorageService(
|
|
IWebHostEnvironment env,
|
|
ILogger<MediaStorageService> logger,
|
|
IServiceScopeFactory scopeFactory)
|
|
{
|
|
_env = env;
|
|
_logger = logger;
|
|
_scopeFactory = scopeFactory;
|
|
}
|
|
|
|
public Task<string?> SaveMenuImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
|
=> SaveAsync(cafeId, file, "menu_img", ImageMime, MaxImageBytes, cancellationToken);
|
|
|
|
public Task<string?> SaveMenuVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
|
=> SaveAsync(cafeId, file, "menu_vid", VideoMime, MaxVideoBytes, cancellationToken);
|
|
|
|
public Task<string?> SaveTableImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
|
=> SaveAsync(cafeId, file, "table_img", ImageMime, MaxImageBytes, cancellationToken);
|
|
|
|
public Task<string?> SaveTableVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
|
=> SaveAsync(cafeId, file, "table_vid", VideoMime, MaxVideoBytes, cancellationToken);
|
|
|
|
public Task<string?> SaveCafeLogoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
|
=> SaveAsync(cafeId, file, "logo", ImageMime, MaxImageBytes, cancellationToken);
|
|
|
|
public Task<string?> SaveCafeCoverAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
|
=> SaveAsync(cafeId, file, "cover", ImageMime, MaxImageBytes, cancellationToken);
|
|
|
|
public Task<string?> SaveReviewPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
|
=> SaveAsync(cafeId, file, "review", ImageMime, MaxImageBytes, cancellationToken);
|
|
|
|
public Task<string?> SaveCafeGalleryPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
|
=> SaveAsync(cafeId, file, "gallery", ImageMime, MaxImageBytes, cancellationToken);
|
|
|
|
public Task<string?> SaveMenuModel3dAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
|
=> SaveModel3dAsync(cafeId, file, cancellationToken);
|
|
|
|
public async Task<string?> SaveMenuModel3dFromBytesAsync(
|
|
string cafeId,
|
|
byte[] glbBytes,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (glbBytes.Length == 0 || glbBytes.Length > MaxModel3dBytes) return null;
|
|
|
|
var dir = Path.Combine(_env.ContentRootPath, "uploads", cafeId);
|
|
Directory.CreateDirectory(dir);
|
|
var savedName = $"menu_3d_ai_{Guid.NewGuid():N}.glb";
|
|
var path = Path.Combine(dir, savedName);
|
|
|
|
await File.WriteAllBytesAsync(path, glbBytes, cancellationToken);
|
|
_logger.LogInformation("Saved AI 3D model for cafe {CafeId}", cafeId);
|
|
return $"/uploads/{cafeId}/{savedName}";
|
|
}
|
|
|
|
private async Task<string?> SaveModel3dAsync(
|
|
string cafeId,
|
|
IFormFile file,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (file.Length == 0 || file.Length > MaxModel3dBytes) return null;
|
|
|
|
var fileName = file.FileName.ToLowerInvariant();
|
|
var isGlb = fileName.EndsWith(".glb", StringComparison.OrdinalIgnoreCase)
|
|
|| Model3dMime.Contains(file.ContentType);
|
|
if (!isGlb) return null;
|
|
|
|
await using var buffer = new MemoryStream();
|
|
await file.CopyToAsync(buffer, cancellationToken);
|
|
var bytes = buffer.ToArray();
|
|
var hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
|
|
|
var existing = await FindExistingByHashAsync(cafeId, hash, cancellationToken);
|
|
if (existing is not null)
|
|
{
|
|
_logger.LogInformation("Dedup hit for 3D model (cafe {CafeId}); reusing existing file", cafeId);
|
|
return existing;
|
|
}
|
|
|
|
var dir = Path.Combine(_env.ContentRootPath, "uploads", cafeId);
|
|
Directory.CreateDirectory(dir);
|
|
var savedName = $"menu_3d_{Guid.NewGuid():N}.glb";
|
|
var path = Path.Combine(dir, savedName);
|
|
|
|
await File.WriteAllBytesAsync(path, bytes, cancellationToken);
|
|
|
|
var url = $"/uploads/{cafeId}/{savedName}";
|
|
await RecordAsync(cafeId, hash, bytes.LongLength, file.ContentType, url, "menu_3d", file.FileName, cancellationToken);
|
|
_logger.LogInformation("Saved 3D model media for cafe {CafeId}", cafeId);
|
|
return url;
|
|
}
|
|
|
|
private async Task<string?> SaveAsync(
|
|
string cafeId,
|
|
IFormFile file,
|
|
string prefix,
|
|
HashSet<string> allowedMime,
|
|
long maxBytes,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (file.Length == 0 || file.Length > maxBytes) return null;
|
|
if (!allowedMime.Contains(file.ContentType)) return null;
|
|
|
|
// Buffer once so we can hash the content and (if new) write it.
|
|
await using var buffer = new MemoryStream();
|
|
await file.CopyToAsync(buffer, cancellationToken);
|
|
var bytes = buffer.ToArray();
|
|
var hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
|
|
|
// Dedup: an identical file already stored for this scope is reused as-is.
|
|
var existing = await FindExistingByHashAsync(cafeId, hash, cancellationToken);
|
|
if (existing is not null)
|
|
{
|
|
_logger.LogInformation("Dedup hit for {Prefix} (cafe {CafeId}); reusing existing file", prefix, cafeId);
|
|
return existing;
|
|
}
|
|
|
|
var ext = file.ContentType.ToLowerInvariant() switch
|
|
{
|
|
"image/png" => ".png",
|
|
"image/webp" => ".webp",
|
|
"video/webm" => ".webm",
|
|
"video/quicktime" => ".mov",
|
|
"video/mp4" => ".mp4",
|
|
_ => ".jpg"
|
|
};
|
|
|
|
var dir = Path.Combine(_env.ContentRootPath, "uploads", cafeId);
|
|
Directory.CreateDirectory(dir);
|
|
var fileName = $"{prefix}_{Guid.NewGuid():N}{ext}";
|
|
var path = Path.Combine(dir, fileName);
|
|
|
|
await File.WriteAllBytesAsync(path, bytes, cancellationToken);
|
|
|
|
var url = $"/uploads/{cafeId}/{fileName}";
|
|
await RecordAsync(cafeId, hash, bytes.LongLength, file.ContentType, url, prefix, file.FileName, cancellationToken);
|
|
_logger.LogInformation("Saved {Prefix} media for cafe {CafeId}", prefix, cafeId);
|
|
return url;
|
|
}
|
|
|
|
// ─── Deduplication helpers ────────────────────────────────────────────────
|
|
// MediaStorageService is a singleton; resolve a scoped DbContext per call.
|
|
|
|
private async Task<string?> FindExistingByHashAsync(string? cafeId, string hash, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
return await db.MediaAssets.AsNoTracking()
|
|
.Where(m => m.CafeId == cafeId && m.ContentHash == hash)
|
|
.Select(m => m.Url)
|
|
.FirstOrDefaultAsync(ct);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Never let a dedup-lookup failure block an upload.
|
|
_logger.LogWarning(ex, "Media dedup lookup failed; proceeding with a fresh upload");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async Task RecordAsync(
|
|
string? cafeId, string hash, long size, string contentType,
|
|
string url, string kind, string? originalName, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
db.MediaAssets.Add(new MediaAsset
|
|
{
|
|
CafeId = cafeId,
|
|
ContentHash = hash,
|
|
SizeBytes = size,
|
|
ContentType = contentType,
|
|
Url = url,
|
|
Kind = kind,
|
|
OriginalFileName = originalName,
|
|
});
|
|
await db.SaveChangesAsync(ct);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// The file is already written; a missing dedup record only means a
|
|
// future identical upload won't be de-duplicated. Don't fail the upload.
|
|
_logger.LogWarning(ex, "Failed to record media asset for cafe {CafeId}", cafeId);
|
|
}
|
|
}
|
|
}
|