WeddingThankYou 1.0.0.0
결혼식 이후 감사 인사와 사진, 계좌 안내, 연락처를 모바일 페이지로 전달하는 감사장 서비스입니다.
로딩중...
검색중...
일치하는것 없음
LocalPhotoService.cs
이 파일의 문서화 페이지로 가기
1using System.IO;
2using Microsoft.AspNetCore.Components.Forms;
4
6
15public sealed class LocalPhotoService : IPhotoService
16{
25 private readonly ITenantStore _tenants;
61 private const long MaxImageUploadBytes = 200 * 1024 * 1024;
70 private static readonly string[] AllowedExts = [".jpg", ".jpeg", ".png", ".webp", ".gif"];
71
113 ITenantStore tenants,
114 IMediaQuotaPolicyResolver policyResolver,
115 IImageOptimizationService imageOptimization,
116 IMediaUsageQueryService usageQuery)
117 {
118 _tenants = tenants;
119 _policyResolver = policyResolver;
120 _imageOptimization = imageOptimization;
121 _usageQuery = usageQuery;
122 }
123
156 public async Task<IReadOnlyList<PhotoInfo>> GetGalleryAsync(string slug, CancellationToken ct = default)
157 {
158 var config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
159 if (config is null) return [];
160
161 var list = new List<PhotoInfo>();
162 foreach (var fn in config.GalleryFileNames)
163 {
164 var full = GalleryPath(slug, fn);
165 if (!File.Exists(full)) continue;
166 var fi = new FileInfo(full);
167 list.Add(new PhotoInfo
168 {
169 FileName = fn,
170 Url = GetPhotoUrl(slug, fn),
171 ThumbUrl = GetThumbUrl(slug, fn),
172 SizeBytes = fi.Length,
173 LastModified = fi.LastWriteTime
174 });
175 }
176 return list;
177 }
178
219 public async Task<string> UploadAsync(string slug, IBrowserFile file, CancellationToken ct = default)
220 {
221 ValidateImageFile(file);
222 var config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false) ?? new TenantConfig { Slug = slug };
223 var policy = await _policyResolver.ResolveAsync(config, ct).ConfigureAwait(false);
224 await ValidateImageQuotaAsync(config, policy, file, countGalleryImage: true, ct).ConfigureAwait(false);
225
226 var baseName = Path.GetFileNameWithoutExtension(UniqueFileName(file.Name));
227 var galleryDir = Path.Combine(_tenants.GetTenantDataPath(slug), "gallery");
228 var thumbDir = Path.Combine(_tenants.GetTenantDataPath(slug), "thumb");
229 Directory.CreateDirectory(galleryDir);
230 Directory.CreateDirectory(thumbDir);
231
232 var fileName = await SaveImageFileAsync(slug, file, galleryDir, baseName, policy, ct).ConfigureAwait(false);
233 File.Copy(Path.Combine(galleryDir, fileName), Path.Combine(thumbDir, fileName), overwrite: true);
234
235 if (!config.GalleryFileNames.Contains(fileName))
236 config.GalleryFileNames.Add(fileName);
237 await _tenants.SaveAsync(config, ct).ConfigureAwait(false);
238
239 return fileName;
240 }
241
282 public async Task<string> UploadHeroAsync(string slug, IBrowserFile file, CancellationToken ct = default)
283 {
284 ValidateImageFile(file);
285 var config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false) ?? new TenantConfig { Slug = slug };
286 var policy = await _policyResolver.ResolveAsync(config, ct).ConfigureAwait(false);
287 await ValidateImageQuotaAsync(config, policy, file, countGalleryImage: false, ct).ConfigureAwait(false);
288
289 var dir = _tenants.GetTenantDataPath(slug);
290 Directory.CreateDirectory(dir);
291
292 var fileName = await SaveImageFileAsync(slug, file, dir, "hero", policy, ct).ConfigureAwait(false);
293 config.HeroImageFileName = fileName;
294 await _tenants.SaveAsync(config, ct).ConfigureAwait(false);
295
296 return fileName;
297 }
298
339 public async Task<string> UploadRoadMapAsync(string slug, IBrowserFile file, CancellationToken ct = default)
340 {
341 ValidateImageFile(file);
342 var config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false) ?? new TenantConfig { Slug = slug };
343 var policy = await _policyResolver.ResolveAsync(config, ct).ConfigureAwait(false);
344 await ValidateImageQuotaAsync(config, policy, file, countGalleryImage: false, ct).ConfigureAwait(false);
345
346 var dir = _tenants.GetTenantDataPath(slug);
347 Directory.CreateDirectory(dir);
348
349 var fileName = await SaveImageFileAsync(slug, file, dir, "roadmap", policy, ct).ConfigureAwait(false);
350 config.RoadMapFileName = fileName;
351 await _tenants.SaveAsync(config, ct).ConfigureAwait(false);
352
353 return fileName;
354 }
355
396 public async Task DeleteAsync(string slug, string fileName, CancellationToken ct = default)
397 {
398 var galleryFile = GalleryPath(slug, fileName);
399 var thumbFile = Path.Combine(_tenants.GetTenantDataPath(slug), "thumb", fileName);
400 if (File.Exists(galleryFile)) File.Delete(galleryFile);
401 if (File.Exists(thumbFile)) File.Delete(thumbFile);
402
403 var config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
404 if (config is null) return;
405 config.GalleryFileNames.Remove(fileName);
406 await _tenants.SaveAsync(config, ct).ConfigureAwait(false);
407 }
408
441 public string GetPhotoUrl(string slug, string fileName) =>
442 $"/wedding-data/{slug}/gallery/{Uri.EscapeDataString(fileName)}";
443
476 public string GetThumbUrl(string slug, string fileName) =>
477 $"/wedding-data/{slug}/thumb/{Uri.EscapeDataString(fileName)}";
478
511 public string GetHeroUrl(string slug, string fileName) =>
512 $"/wedding-data/{slug}/{Uri.EscapeDataString(fileName)}";
513
546 public string GetRoadMapUrl(string slug, string fileName) =>
547 $"/wedding-data/{slug}/{Uri.EscapeDataString(fileName)}";
548
597 public async Task<string> UploadMusicAsync(string slug, IBrowserFile file, CancellationToken ct = default)
598 {
599 var ext = Path.GetExtension(file.Name).ToLowerInvariant();
600 if (ext is not (".mp3" or ".ogg" or ".wav" or ".aac" or ".m4a"))
601 throw new InvalidOperationException($"허용되지 않는 오디오 형식입니다: {ext}");
602 if (file.Size > 30 * 1024 * 1024)
603 throw new InvalidOperationException("음악 파일은 30MB 이하여야 합니다.");
604
605 var dir = _tenants.GetTenantDataPath(slug);
606 Directory.CreateDirectory(dir);
607
608 var fileName = $"bgmusic{ext}";
609 var destPath = Path.Combine(dir, fileName);
610 await using (var dest = File.Create(destPath))
611 await file.OpenReadStream(30 * 1024 * 1024, ct).CopyToAsync(dest, ct).ConfigureAwait(false);
612
613 var config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false) ?? new TenantConfig { Slug = slug };
614 config.MusicFileName = fileName;
615 await _tenants.SaveAsync(config, ct).ConfigureAwait(false);
616
617 return fileName;
618 }
619
652 public string GetMusicUrl(string slug, string fileName) =>
653 $"/wedding-data/{slug}/{Uri.EscapeDataString(fileName)}";
654
695 public async Task<string> UploadOgImageAsync(string slug, IBrowserFile file, CancellationToken ct = default)
696 {
697 ValidateImageFile(file);
698 var config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false) ?? new TenantConfig { Slug = slug };
699 var policy = await _policyResolver.ResolveAsync(config, ct).ConfigureAwait(false);
700 await ValidateImageQuotaAsync(config, policy, file, countGalleryImage: false, ct).ConfigureAwait(false);
701
702 var dir = _tenants.GetTenantDataPath(slug);
703 Directory.CreateDirectory(dir);
704
705 var fileName = await SaveImageFileAsync(slug, file, dir, "og", policy, ct).ConfigureAwait(false);
706 config.OgImageFileName = fileName;
707 await _tenants.SaveAsync(config, ct).ConfigureAwait(false);
708
709 return fileName;
710 }
711
760 public async Task<string> UploadVideoAsync(string slug, IBrowserFile file, CancellationToken ct = default)
761 {
762 var ext = Path.GetExtension(file.Name).ToLowerInvariant();
763 if (ext is not (".mp4" or ".webm" or ".mov" or ".m4v"))
764 throw new InvalidOperationException($"허용되지 않는 동영상 형식입니다: {ext}. mp4/webm/mov 권장");
765
766 var config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false) ?? new TenantConfig { Slug = slug };
767 var policy = await _policyResolver.ResolveAsync(config, ct).ConfigureAwait(false);
768
769 var effectiveCount = policy.VideoMaxCount;
770 if (effectiveCount > 0 && config.VideoFileNames.Count >= effectiveCount)
771 throw new InvalidOperationException($"동영상은 최대 {effectiveCount}개까지 업로드할 수 있습니다.");
772
773 var effectiveMb = policy.VideoMaxFileSizeMb;
774 var maxBytes = policy.VideoMaxFileSizeBytes;
775
776 if (file.Size > maxBytes)
777 throw new InvalidOperationException($"동영상 파일은 {effectiveMb}MB 이하여야 합니다.");
778
779 if (policy.VideoMaxStorageMb > 0)
780 {
781 var usage = await _usageQuery.GetTenantUsageAsync(config, ct).ConfigureAwait(false);
782 if (usage.VideoBytes + file.Size > policy.VideoMaxStorageBytes)
783 {
784 throw new InvalidOperationException($"동영상 저장 용량은 최대 {policy.VideoMaxStorageMb}MB까지 사용할 수 있습니다.");
785 }
786 }
787
788 var dir = _tenants.GetTenantDataPath(slug);
789 Directory.CreateDirectory(dir);
790
791 var fileName = $"video-{DateTime.Now:yyyyMMdd_HHmmss}_{Guid.NewGuid().ToString("N")[..8]}{ext}";
792 var destPath = Path.Combine(dir, fileName);
793 await using (var dest = File.Create(destPath))
794 await file.OpenReadStream(maxBytes, ct).CopyToAsync(dest, ct).ConfigureAwait(false);
795
796 config.VideoFileNames.Add(fileName);
797 await _tenants.SaveAsync(config, ct).ConfigureAwait(false);
798
799 return fileName;
800 }
801
842 public async Task DeleteVideoAsync(string slug, string fileName, CancellationToken ct = default)
843 {
844 var path = Path.Combine(_tenants.GetTenantDataPath(slug), fileName);
845 if (File.Exists(path)) File.Delete(path);
846
847 var config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
848 if (config is null) return;
849 config.VideoFileNames.Remove(fileName);
850 await _tenants.SaveAsync(config, ct).ConfigureAwait(false);
851 }
852
885 public string GetVideoUrl(string slug, string fileName) =>
886 $"/wedding-data/{slug}/{Uri.EscapeDataString(fileName)}";
887
920 private string GalleryPath(string slug, string fileName) =>
921 Path.Combine(_tenants.GetTenantDataPath(slug), "gallery", fileName);
922
987 private async Task ValidateImageQuotaAsync(TenantConfig config, EffectiveMediaPolicy policy, IBrowserFile file, bool countGalleryImage, CancellationToken ct)
988 {
989 if (countGalleryImage && policy.ImageMaxCount > 0 && config.GalleryFileNames.Count >= policy.ImageMaxCount)
990 {
991 throw new InvalidOperationException($"이미지는 최대 {policy.ImageMaxCount}장까지 업로드할 수 있습니다.");
992 }
993
994 if (policy.ImageOptimizedMaxStorageMb > 0)
995 {
996 var usage = await _usageQuery.GetTenantUsageAsync(config, ct).ConfigureAwait(false);
997 if (usage.OptimizedImageBytes + file.Size > policy.ImageOptimizedMaxStorageBytes)
998 {
999 throw new InvalidOperationException($"이미지 저장 용량은 최대 {policy.ImageOptimizedMaxStorageMb}MB까지 사용할 수 있습니다.");
1000 }
1001 }
1002 }
1003
1068 private async Task<string> SaveImageFileAsync(string slug, IBrowserFile file, string destinationDirectory, string baseName, EffectiveMediaPolicy policy, CancellationToken ct)
1069 {
1070 var root = _tenants.GetTenantDataPath(slug);
1071 var originalDir = Path.Combine(root, "original");
1072 var originalExt = Path.GetExtension(file.Name).ToLowerInvariant();
1073 var tempPath = Path.Combine(destinationDirectory, $"{baseName}.upload{originalExt}");
1074 Directory.CreateDirectory(destinationDirectory);
1075
1076 await using (var dest = File.Create(tempPath))
1077 {
1078 await file.OpenReadStream(MaxImageUploadBytes, ct).CopyToAsync(dest, ct).ConfigureAwait(false);
1079 }
1080
1081 if (policy.KeepOriginalImages)
1082 {
1083 Directory.CreateDirectory(originalDir);
1084 File.Copy(tempPath, Path.Combine(originalDir, $"{baseName}{originalExt}"), overwrite: true);
1085 }
1086
1087 var outputFormat = ResolveWritableOutputFormat(policy.ImageOutputFormat, originalExt);
1088 if (!string.IsNullOrWhiteSpace(outputFormat))
1089 {
1090 var outputExt = $".{outputFormat}";
1091 var optimizedName = $"{baseName}{outputExt}";
1092 var optimizedPath = Path.Combine(destinationDirectory, optimizedName);
1093 var tempOptimizedPath = Path.Combine(destinationDirectory, $"{baseName}.optimized.tmp{outputExt}");
1094 var result = await _imageOptimization.OptimizeAsync(tempPath, tempOptimizedPath, policy, ct).ConfigureAwait(false);
1095 if (result.Succeeded && File.Exists(tempOptimizedPath))
1096 {
1097 File.Move(tempOptimizedPath, optimizedPath, overwrite: true);
1098 File.Delete(tempPath);
1099 return optimizedName;
1100 }
1101
1102 if (File.Exists(tempOptimizedPath))
1103 {
1104 File.Delete(tempOptimizedPath);
1105 }
1106 }
1107
1108 var fallbackName = $"{baseName}{originalExt}";
1109 var fallbackPath = Path.Combine(destinationDirectory, fallbackName);
1110 File.Move(tempPath, fallbackPath, overwrite: true);
1111 return fallbackName;
1112 }
1113
1146 private string ResolveWritableOutputFormat(string? preferredFormat, string? originalExtension)
1147 {
1148 var preferred = NormalizeOutputExtension(preferredFormat);
1149 if (_imageOptimization.CanEncode(preferred)) return preferred;
1150
1151 if (_imageOptimization.CanEncode("jpg")) return "jpg";
1152
1153 var original = NormalizeOutputExtension(originalExtension);
1154 return _imageOptimization.CanEncode(original) ? original : "";
1155 }
1156
1181 private static void ValidateImageFile(IBrowserFile file)
1182 {
1183 var ext = Path.GetExtension(file.Name).ToLowerInvariant();
1184 if (!Array.Exists(AllowedExts, e => e == ext))
1185 throw new InvalidOperationException($"허용되지 않는 파일 형식입니다: {ext}");
1186 if (file.Size > MaxImageUploadBytes)
1187 throw new InvalidOperationException("이미지 파일 크기는 200MB 이하여야 합니다.");
1188 }
1189
1214 private static string NormalizeOutputExtension(string? outputFormat)
1215 {
1216 var normalized = outputFormat?.Trim().TrimStart('.').ToLowerInvariant();
1217 return string.IsNullOrWhiteSpace(normalized) ? "webp" : normalized;
1218 }
1219
1244 private static string UniqueFileName(string originalName)
1245 {
1246 var ext = Path.GetExtension(originalName).ToLowerInvariant();
1247 return $"{DateTime.Now:yyyyMMdd_HHmmss}_{Guid.NewGuid():N}{ext}";
1248 }
1249}
async Task DeleteAsync(string slug, string fileName, CancellationToken ct=default)
readonly IImageOptimizationService _imageOptimization
string GetVideoUrl(string slug, string fileName)
string GetThumbUrl(string slug, string fileName)
static string UniqueFileName(string originalName)
string GalleryPath(string slug, string fileName)
async Task< string > UploadMusicAsync(string slug, IBrowserFile file, CancellationToken ct=default)
string GetMusicUrl(string slug, string fileName)
async Task< string > UploadVideoAsync(string slug, IBrowserFile file, CancellationToken ct=default)
async Task< IReadOnlyList< PhotoInfo > > GetGalleryAsync(string slug, CancellationToken ct=default)
async Task DeleteVideoAsync(string slug, string fileName, CancellationToken ct=default)
async Task< string > SaveImageFileAsync(string slug, IBrowserFile file, string destinationDirectory, string baseName, EffectiveMediaPolicy policy, CancellationToken ct)
string GetRoadMapUrl(string slug, string fileName)
static void ValidateImageFile(IBrowserFile file)
async Task ValidateImageQuotaAsync(TenantConfig config, EffectiveMediaPolicy policy, IBrowserFile file, bool countGalleryImage, CancellationToken ct)
string ResolveWritableOutputFormat(string? preferredFormat, string? originalExtension)
readonly IMediaUsageQueryService _usageQuery
readonly IMediaQuotaPolicyResolver _policyResolver
string GetPhotoUrl(string slug, string fileName)
async Task< string > UploadOgImageAsync(string slug, IBrowserFile file, CancellationToken ct=default)
async Task< string > UploadRoadMapAsync(string slug, IBrowserFile file, CancellationToken ct=default)
async Task< string > UploadAsync(string slug, IBrowserFile file, CancellationToken ct=default)
string GetHeroUrl(string slug, string fileName)
static string NormalizeOutputExtension(string? outputFormat)
LocalPhotoService(ITenantStore tenants, IMediaQuotaPolicyResolver policyResolver, IImageOptimizationService imageOptimization, IMediaUsageQueryService usageQuery)
async Task< string > UploadHeroAsync(string slug, IBrowserFile file, CancellationToken ct=default)