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.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Infrastructure.Data;
|
||||||
using Meezi.Infrastructure.Services.Platform;
|
using Meezi.Infrastructure.Services.Platform;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
@@ -81,6 +83,33 @@ public class MediaController : CafeApiControllerBase
|
|||||||
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
||||||
=> Upload(cafeId, file, tenant, _media.SaveCafeCoverAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", 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(
|
private async Task<IActionResult> Upload(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
IFormFile file,
|
IFormFile file,
|
||||||
@@ -103,3 +132,12 @@ public class MediaController : CafeApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
public record UploadResultDto(string Url);
|
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;
|
namespace Meezi.API.Services;
|
||||||
|
|
||||||
public interface IMediaStorageService
|
public interface IMediaStorageService
|
||||||
@@ -37,11 +42,16 @@ public class MediaStorageService : IMediaStorageService
|
|||||||
|
|
||||||
private readonly IWebHostEnvironment _env;
|
private readonly IWebHostEnvironment _env;
|
||||||
private readonly ILogger<MediaStorageService> _logger;
|
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;
|
_env = env;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<string?> SaveMenuImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
public Task<string?> SaveMenuImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
||||||
@@ -100,16 +110,29 @@ public class MediaStorageService : IMediaStorageService
|
|||||||
|| Model3dMime.Contains(file.ContentType);
|
|| Model3dMime.Contains(file.ContentType);
|
||||||
if (!isGlb) return null;
|
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);
|
var dir = Path.Combine(_env.ContentRootPath, "uploads", cafeId);
|
||||||
Directory.CreateDirectory(dir);
|
Directory.CreateDirectory(dir);
|
||||||
var savedName = $"menu_3d_{Guid.NewGuid():N}.glb";
|
var savedName = $"menu_3d_{Guid.NewGuid():N}.glb";
|
||||||
var path = Path.Combine(dir, savedName);
|
var path = Path.Combine(dir, savedName);
|
||||||
|
|
||||||
await using var stream = File.Create(path);
|
await File.WriteAllBytesAsync(path, bytes, cancellationToken);
|
||||||
await file.CopyToAsync(stream, 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);
|
_logger.LogInformation("Saved 3D model media for cafe {CafeId}", cafeId);
|
||||||
return $"/uploads/{cafeId}/{savedName}";
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string?> SaveAsync(
|
private async Task<string?> SaveAsync(
|
||||||
@@ -123,6 +146,20 @@ public class MediaStorageService : IMediaStorageService
|
|||||||
if (file.Length == 0 || file.Length > maxBytes) return null;
|
if (file.Length == 0 || file.Length > maxBytes) return null;
|
||||||
if (!allowedMime.Contains(file.ContentType)) 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
|
var ext = file.ContentType.ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
"image/png" => ".png",
|
"image/png" => ".png",
|
||||||
@@ -138,10 +175,61 @@ public class MediaStorageService : IMediaStorageService
|
|||||||
var fileName = $"{prefix}_{Guid.NewGuid():N}{ext}";
|
var fileName = $"{prefix}_{Guid.NewGuid():N}{ext}";
|
||||||
var path = Path.Combine(dir, fileName);
|
var path = Path.Combine(dir, fileName);
|
||||||
|
|
||||||
await using var stream = File.Create(path);
|
await File.WriteAllBytesAsync(path, bytes, cancellationToken);
|
||||||
await file.CopyToAsync(stream, 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);
|
_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.
|
// Idempotency keys for safe retry of offline-replayed writes.
|
||||||
public DbSet<IdempotencyRecord> IdempotencyRecords => Set<IdempotencyRecord>();
|
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)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
@@ -113,6 +116,20 @@ public class AppDbContext : DbContext
|
|||||||
e.HasQueryFilter(x => x.DeletedAt == null);
|
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 =>
|
modelBuilder.Entity<Cafe>(e =>
|
||||||
{
|
{
|
||||||
e.HasKey(x => x.Id);
|
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");
|
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 =>
|
modelBuilder.Entity("Meezi.Core.Entities.MenuCategory", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
|
|||||||
Reference in New Issue
Block a user