ef15fd6247
Full backend implementation: - Multi-tenant cafe/restaurant management (menus, orders, tables, staff) - POS order flow with ZarinPal and Snappfood payment integration - OTP authentication via Kavenegar SMS - QR digital menu with public discover/finder endpoints - Customer loyalty, coupons, CRM - PostgreSQL via EF Core, Redis for caching/sessions - Background jobs, webhook handlers - Full migration history Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
148 lines
6.7 KiB
C#
148 lines
6.7 KiB
C#
namespace Meezi.API.Services;
|
|
|
|
public interface IMediaStorageService
|
|
{
|
|
Task<string?> SaveMenuImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
|
Task<string?> SaveMenuVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
|
Task<string?> SaveTableImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
|
Task<string?> SaveTableVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
|
Task<string?> SaveCafeLogoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
|
Task<string?> SaveCafeCoverAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
|
Task<string?> SaveMenuModel3dAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
|
Task<string?> SaveMenuModel3dFromBytesAsync(string cafeId, byte[] glbBytes, CancellationToken cancellationToken = default);
|
|
Task<string?> SaveReviewPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
|
Task<string?> SaveCafeGalleryPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
public class MediaStorageService : IMediaStorageService
|
|
{
|
|
private static readonly HashSet<string> ImageMime = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"image/jpeg", "image/png", "image/webp"
|
|
};
|
|
|
|
private static readonly HashSet<string> VideoMime = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"video/mp4", "video/webm", "video/quicktime"
|
|
};
|
|
|
|
private const long MaxImageBytes = 5 * 1024 * 1024;
|
|
private const long MaxVideoBytes = 25 * 1024 * 1024;
|
|
private const long MaxModel3dBytes = 8 * 1024 * 1024;
|
|
|
|
private static readonly HashSet<string> Model3dMime = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"model/gltf-binary", "application/octet-stream"
|
|
};
|
|
|
|
private readonly IWebHostEnvironment _env;
|
|
private readonly ILogger<MediaStorageService> _logger;
|
|
|
|
public MediaStorageService(IWebHostEnvironment env, ILogger<MediaStorageService> logger)
|
|
{
|
|
_env = env;
|
|
_logger = logger;
|
|
}
|
|
|
|
public Task<string?> SaveMenuImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
|
=> SaveAsync(cafeId, file, "menu_img", ImageMime, MaxImageBytes, cancellationToken);
|
|
|
|
public Task<string?> SaveMenuVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
|
=> SaveAsync(cafeId, file, "menu_vid", VideoMime, MaxVideoBytes, cancellationToken);
|
|
|
|
public Task<string?> SaveTableImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
|
=> SaveAsync(cafeId, file, "table_img", ImageMime, MaxImageBytes, cancellationToken);
|
|
|
|
public Task<string?> SaveTableVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
|
=> SaveAsync(cafeId, file, "table_vid", VideoMime, MaxVideoBytes, cancellationToken);
|
|
|
|
public Task<string?> SaveCafeLogoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
|
=> SaveAsync(cafeId, file, "logo", ImageMime, MaxImageBytes, cancellationToken);
|
|
|
|
public Task<string?> SaveCafeCoverAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
|
=> SaveAsync(cafeId, file, "cover", ImageMime, MaxImageBytes, cancellationToken);
|
|
|
|
public Task<string?> SaveReviewPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
|
=> SaveAsync(cafeId, file, "review", ImageMime, MaxImageBytes, cancellationToken);
|
|
|
|
public Task<string?> SaveCafeGalleryPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
|
=> SaveAsync(cafeId, file, "gallery", ImageMime, MaxImageBytes, cancellationToken);
|
|
|
|
public Task<string?> SaveMenuModel3dAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
|
=> SaveModel3dAsync(cafeId, file, cancellationToken);
|
|
|
|
public async Task<string?> SaveMenuModel3dFromBytesAsync(
|
|
string cafeId,
|
|
byte[] glbBytes,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (glbBytes.Length == 0 || glbBytes.Length > MaxModel3dBytes) return null;
|
|
|
|
var dir = Path.Combine(_env.ContentRootPath, "uploads", cafeId);
|
|
Directory.CreateDirectory(dir);
|
|
var savedName = $"menu_3d_ai_{Guid.NewGuid():N}.glb";
|
|
var path = Path.Combine(dir, savedName);
|
|
|
|
await File.WriteAllBytesAsync(path, glbBytes, cancellationToken);
|
|
_logger.LogInformation("Saved AI 3D model for cafe {CafeId}", cafeId);
|
|
return $"/uploads/{cafeId}/{savedName}";
|
|
}
|
|
|
|
private async Task<string?> SaveModel3dAsync(
|
|
string cafeId,
|
|
IFormFile file,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (file.Length == 0 || file.Length > MaxModel3dBytes) return null;
|
|
|
|
var fileName = file.FileName.ToLowerInvariant();
|
|
var isGlb = fileName.EndsWith(".glb", StringComparison.OrdinalIgnoreCase)
|
|
|| Model3dMime.Contains(file.ContentType);
|
|
if (!isGlb) return null;
|
|
|
|
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);
|
|
|
|
_logger.LogInformation("Saved 3D model media for cafe {CafeId}", cafeId);
|
|
return $"/uploads/{cafeId}/{savedName}";
|
|
}
|
|
|
|
private async Task<string?> SaveAsync(
|
|
string cafeId,
|
|
IFormFile file,
|
|
string prefix,
|
|
HashSet<string> allowedMime,
|
|
long maxBytes,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (file.Length == 0 || file.Length > maxBytes) return null;
|
|
if (!allowedMime.Contains(file.ContentType)) return null;
|
|
|
|
var ext = file.ContentType.ToLowerInvariant() switch
|
|
{
|
|
"image/png" => ".png",
|
|
"image/webp" => ".webp",
|
|
"video/webm" => ".webm",
|
|
"video/quicktime" => ".mov",
|
|
"video/mp4" => ".mp4",
|
|
_ => ".jpg"
|
|
};
|
|
|
|
var dir = Path.Combine(_env.ContentRootPath, "uploads", cafeId);
|
|
Directory.CreateDirectory(dir);
|
|
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);
|
|
|
|
_logger.LogInformation("Saved {Prefix} media for cafe {CafeId}", prefix, cafeId);
|
|
return $"/uploads/{cafeId}/{fileName}";
|
|
}
|
|
}
|