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:
@@ -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