feat(media): content-hash dedup for uploads + media-library endpoint
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.
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.Shared;
|
||||
|
||||
@@ -81,6 +83,33 @@ public class MediaController : CafeApiControllerBase
|
||||
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
=> Upload(cafeId, file, tenant, _media.SaveCafeCoverAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
|
||||
|
||||
/// <summary>Media library for this café — previously uploaded files so the UI can
|
||||
/// reuse one instead of re-uploading. Deduplication means each distinct file appears once.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ListMedia(
|
||||
string cafeId,
|
||||
ITenantContext tenant,
|
||||
[FromServices] AppDbContext db,
|
||||
CancellationToken cancellationToken,
|
||||
[FromQuery] string? kind = null,
|
||||
[FromQuery] int limit = 60)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var query = db.MediaAssets.AsNoTracking().Where(m => m.CafeId == cafeId);
|
||||
if (!string.IsNullOrWhiteSpace(kind))
|
||||
query = query.Where(m => m.Kind == kind);
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(m => m.CreatedAt)
|
||||
.Take(Math.Clamp(limit, 1, 200))
|
||||
.Select(m => new MediaAssetDto(
|
||||
m.Id, m.Url, m.Kind, m.ContentType, m.SizeBytes, m.OriginalFileName, m.CreatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Ok(new ApiResponse<List<MediaAssetDto>>(true, items));
|
||||
}
|
||||
|
||||
private async Task<IActionResult> Upload(
|
||||
string cafeId,
|
||||
IFormFile file,
|
||||
@@ -103,3 +132,12 @@ public class MediaController : CafeApiControllerBase
|
||||
}
|
||||
|
||||
public record UploadResultDto(string Url);
|
||||
|
||||
public record MediaAssetDto(
|
||||
string Id,
|
||||
string Url,
|
||||
string Kind,
|
||||
string ContentType,
|
||||
long SizeBytes,
|
||||
string? OriginalFileName,
|
||||
DateTime CreatedAt);
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Infrastructure.Data;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IMediaStorageService
|
||||
@@ -37,11 +42,16 @@ public class MediaStorageService : IMediaStorageService
|
||||
|
||||
private readonly IWebHostEnvironment _env;
|
||||
private readonly ILogger<MediaStorageService> _logger;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
|
||||
public MediaStorageService(IWebHostEnvironment env, ILogger<MediaStorageService> logger)
|
||||
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)
|
||||
@@ -100,16 +110,29 @@ public class MediaStorageService : IMediaStorageService
|
||||
|| 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 using var stream = File.Create(path);
|
||||
await file.CopyToAsync(stream, cancellationToken);
|
||||
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 $"/uploads/{cafeId}/{savedName}";
|
||||
return url;
|
||||
}
|
||||
|
||||
private async Task<string?> SaveAsync(
|
||||
@@ -123,6 +146,20 @@ public class MediaStorageService : IMediaStorageService
|
||||
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",
|
||||
@@ -138,10 +175,61 @@ public class MediaStorageService : IMediaStorageService
|
||||
var fileName = $"{prefix}_{Guid.NewGuid():N}{ext}";
|
||||
var path = Path.Combine(dir, fileName);
|
||||
|
||||
await using var stream = File.Create(path);
|
||||
await file.CopyToAsync(stream, cancellationToken);
|
||||
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 $"/uploads/{cafeId}/{fileName}";
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user