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);
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")