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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Meezi.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A stored upload, recorded so identical files can be de-duplicated and reused
|
||||
/// instead of written to disk again. Files with the same content hash within a
|
||||
/// scope (café, or platform when <see cref="CafeId"/> is null) share one stored file.
|
||||
/// </summary>
|
||||
public class MediaAsset : BaseEntity
|
||||
{
|
||||
/// <summary>Owning café, or null for platform-level (admin) uploads.</summary>
|
||||
public string? CafeId { get; set; }
|
||||
|
||||
/// <summary>SHA-256 of the file content, lowercase hex.</summary>
|
||||
public string ContentHash { get; set; } = string.Empty;
|
||||
|
||||
public long SizeBytes { get; set; }
|
||||
|
||||
public string ContentType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Public URL/path the file is served from (e.g. /uploads/{cafeId}/{name}).</summary>
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Logical kind/prefix: menu_img, logo, cover, gallery, review, menu_3d, blog, ...</summary>
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
|
||||
public string? OriginalFileName { get; set; }
|
||||
}
|
||||
@@ -85,6 +85,9 @@ public class AppDbContext : DbContext
|
||||
// Idempotency keys for safe retry of offline-replayed writes.
|
||||
public DbSet<IdempotencyRecord> IdempotencyRecords => Set<IdempotencyRecord>();
|
||||
|
||||
// Uploaded files, recorded for content-hash de-duplication and a media library.
|
||||
public DbSet<MediaAsset> MediaAssets => Set<MediaAsset>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
@@ -113,6 +116,20 @@ public class AppDbContext : DbContext
|
||||
e.HasQueryFilter(x => x.DeletedAt == null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<MediaAsset>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
// Dedup lookups: same content within a scope (café or platform).
|
||||
e.HasIndex(x => new { x.CafeId, x.ContentHash });
|
||||
e.Property(x => x.CafeId).HasMaxLength(64);
|
||||
e.Property(x => x.ContentHash).HasMaxLength(64).IsRequired();
|
||||
e.Property(x => x.ContentType).HasMaxLength(100).IsRequired();
|
||||
e.Property(x => x.Url).HasMaxLength(500).IsRequired();
|
||||
e.Property(x => x.Kind).HasMaxLength(40).IsRequired();
|
||||
e.Property(x => x.OriginalFileName).HasMaxLength(260);
|
||||
e.HasQueryFilter(x => x.DeletedAt == null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Cafe>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
|
||||
+3413
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Meezi.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMediaAssets : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MediaAssets",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
CafeId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
ContentHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
|
||||
ContentType = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
Url = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
Kind = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
|
||||
OriginalFileName = table.Column<string>(type: "character varying(260)", maxLength: 260, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MediaAssets", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MediaAssets_CafeId_ContentHash",
|
||||
table: "MediaAssets",
|
||||
columns: new[] { "CafeId", "ContentHash" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "MediaAssets");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1308,6 +1308,55 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
b.ToTable("LeaveRequests");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.MediaAsset", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("CafeId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("ContentHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("character varying(40)");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(260)
|
||||
.HasColumnType("character varying(260)");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CafeId", "ContentHash");
|
||||
|
||||
b.ToTable("MediaAssets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.MenuCategory", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
|
||||
Reference in New Issue
Block a user