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:
soroush.asadi
2026-06-02 22:16:11 +03:30
parent eb165db182
commit 97a9481627
7 changed files with 3686 additions and 7 deletions
@@ -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);