WeddingThankYou 1.0.0.0
결혼식 이후 감사 인사와 사진, 계좌 안내, 연락처를 모바일 페이지로 전달하는 감사장 서비스입니다.
로딩중...
검색중...
일치하는것 없음
JsonMediaMigrationService.cs
이 파일의 문서화 페이지로 가기
1using System.IO;
2using System.Text.Json;
4
6
16{
25 private readonly ITenantStore _tenants;
34 private readonly WeddingOptions _options;
61 private readonly SemaphoreSlim _gate = new(1, 1);
70 private readonly object _runningGate = new();
79 private readonly HashSet<string> _runningSlugs = new(StringComparer.OrdinalIgnoreCase);
88 private readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
89
98 private Dictionary<string, MediaMigrationTenantStatus>? _cache;
99
141 ITenantStore tenants,
142 WeddingOptions options,
143 IMediaQuotaPolicyResolver policyResolver,
144 IImageOptimizationService imageOptimization)
145 {
146 _tenants = tenants;
147 _options = options;
148 _policyResolver = policyResolver;
149 _imageOptimization = imageOptimization;
150 }
151
176 public async Task<IReadOnlyDictionary<string, MediaMigrationTenantStatus>> GetAllAsync(CancellationToken ct = default)
177 {
178 return await LoadAsync(ct).ConfigureAwait(false);
179 }
180
213 public async Task<MediaMigrationTenantStatus?> GetTenantStatusAsync(string slug, CancellationToken ct = default)
214 {
215 var all = await LoadAsync(ct).ConfigureAwait(false);
216 return all.TryGetValue(slug, out var status) ? status : null;
217 }
218
251 public async Task QueueTenantAsync(string slug, CancellationToken ct = default)
252 {
253 var all = await LoadAsync(ct).ConfigureAwait(false);
254 if (!all.TryGetValue(slug, out var status))
255 {
256 status = new MediaMigrationTenantStatus { Slug = slug, State = MediaMigrationState.Pending };
257 all[slug] = status;
258 }
259
260 if (status.State is MediaMigrationState.Processing && IsRunning(slug))
261 {
262 return;
263 }
264
265 status.State = MediaMigrationState.Pending;
266 status.Message = "변환 대기";
267 status.UpdatedAt = DateTime.Now;
268 await SaveAsync(all, ct).ConfigureAwait(false);
269 StartBackground(slug);
270 }
271
304 public async Task RetryTenantAsync(string slug, CancellationToken ct = default)
305 {
306 var all = await LoadAsync(ct).ConfigureAwait(false);
307 if (all.TryGetValue(slug, out var status))
308 {
309 foreach (var file in status.Files.Where(x => x.State == MediaMigrationState.Failed))
310 {
311 file.State = MediaMigrationState.Pending;
312 file.Message = "재시도 대기";
313 file.UpdatedAt = DateTime.Now;
314 }
315 }
316
317 await SaveAsync(all, ct).ConfigureAwait(false);
318 await QueueTenantAsync(slug, ct).ConfigureAwait(false);
319 }
320
353 private async Task ProcessTenantSafeAsync(string slug, CancellationToken ct)
354 {
355 try
356 {
357 await ProcessTenantAsync(slug, ct).ConfigureAwait(false);
358 }
359 catch (Exception ex)
360 {
361 var all = await LoadAsync(CancellationToken.None).ConfigureAwait(false);
362 var status = GetOrCreate(all, slug);
363 status.State = MediaMigrationState.Failed;
364 status.Message = ex.Message;
365 status.UpdatedAt = DateTime.Now;
366 await SaveAsync(all, CancellationToken.None).ConfigureAwait(false);
367 }
368 }
369
410 private async Task ProcessTenantAsync(string slug, CancellationToken ct)
411 {
412 var config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
413 if (config is null) return;
414
415 var policy = await _policyResolver.ResolveAsync(config, ct).ConfigureAwait(false);
416 var all = await LoadAsync(ct).ConfigureAwait(false);
417 var status = GetOrCreate(all, slug);
418 status.State = MediaMigrationState.Processing;
419 status.Message = "처리 중";
420 status.UpdatedAt = DateTime.Now;
421 await SaveAsync(all, ct).ConfigureAwait(false);
422
423 var root = _tenants.GetTenantDataPath(slug);
424 var galleryDir = Path.Combine(root, "gallery");
425 var thumbDir = Path.Combine(root, "thumb");
426 var originalDir = Path.Combine(root, "original");
427 Directory.CreateDirectory(galleryDir);
428 Directory.CreateDirectory(thumbDir);
429 Directory.CreateDirectory(originalDir);
430
431 if (config.GalleryFileNames.Count == 0)
432 {
433 status.State = MediaMigrationState.Skipped;
434 status.Message = "변환할 이미지가 없습니다.";
435 status.UpdatedAt = DateTime.Now;
436 await SaveAsync(all, ct).ConfigureAwait(false);
437 return;
438 }
439
440 var anyFailed = false;
441 var anyProcessed = false;
442 var allSkipped = true;
443 var total = config.GalleryFileNames.Count;
444 var processed = 0;
445
446 foreach (var fileName in config.GalleryFileNames.ToArray())
447 {
448 ct.ThrowIfCancellationRequested();
449 var fileStatus = status.Files.FirstOrDefault(x => string.Equals(x.SourceFileName, fileName, StringComparison.OrdinalIgnoreCase));
450 if (fileStatus is { State: MediaMigrationState.Completed or MediaMigrationState.Skipped })
451 {
452 processed++;
453 continue;
454 }
455
456 fileStatus ??= new MediaMigrationFileStatus { SourceFileName = fileName };
457 if (!status.Files.Contains(fileStatus)) status.Files.Add(fileStatus);
458
459 var sourcePath = Path.Combine(galleryDir, fileName);
460 if (!File.Exists(sourcePath))
461 {
462 MarkFile(fileStatus, MediaMigrationState.Skipped, "원본 파일 없음");
463 continue;
464 }
465
466 var outputFormat = ResolveWritableOutputFormat(policy.ImageOutputFormat, fileName);
467 if (string.IsNullOrWhiteSpace(outputFormat))
468 {
469 MarkFile(fileStatus, MediaMigrationState.Skipped, $"{policy.ImageOutputFormat} 인코더 없음");
470 processed++;
471 continue;
472 }
473
474 try
475 {
476 fileStatus.State = MediaMigrationState.Processing;
477 fileStatus.Message = $"처리 중 ({processed + 1}/{total})";
478 fileStatus.UpdatedAt = DateTime.Now;
479 status.Message = fileStatus.Message;
480 await SaveAsync(all, ct).ConfigureAwait(false);
481
482 var targetName = $"{Path.GetFileNameWithoutExtension(fileName)}.{outputFormat}";
483 var tempPath = Path.Combine(galleryDir, $"{Path.GetFileNameWithoutExtension(fileName)}.migration.tmp.{outputFormat}");
484 var finalPath = Path.Combine(galleryDir, targetName);
485 var result = await _imageOptimization.OptimizeAsync(sourcePath, tempPath, policy, ct).ConfigureAwait(false);
486 if (!result.Succeeded || !File.Exists(tempPath) || new FileInfo(tempPath).Length == 0)
487 {
488 if (File.Exists(tempPath)) File.Delete(tempPath);
489 throw new InvalidOperationException(result.Message);
490 }
491
492 if (policy.KeepOriginalImages)
493 {
494 File.Copy(sourcePath, Path.Combine(originalDir, fileName), overwrite: true);
495 }
496
497 File.Move(tempPath, finalPath, overwrite: true);
498 File.Copy(finalPath, Path.Combine(thumbDir, targetName), overwrite: true);
499
500 var index = config.GalleryFileNames.FindIndex(x => string.Equals(x, fileName, StringComparison.OrdinalIgnoreCase));
501 if (index >= 0)
502 {
503 config.GalleryFileNames[index] = targetName;
504 await _tenants.SaveAsync(config, ct).ConfigureAwait(false);
505 }
506
507 if (!policy.KeepOriginalImages && !string.Equals(sourcePath, finalPath, StringComparison.OrdinalIgnoreCase))
508 {
509 File.Delete(sourcePath);
510 var oldThumb = Path.Combine(thumbDir, fileName);
511 if (File.Exists(oldThumb)) File.Delete(oldThumb);
512 }
513
514 fileStatus.TargetFileName = targetName;
515 var requestedFormat = NormalizeFormat(policy.ImageOutputFormat);
516 var formatMessage = string.Equals(requestedFormat, outputFormat, StringComparison.OrdinalIgnoreCase)
517 ? "완료"
518 : $"완료 ({policy.ImageOutputFormat} 미지원, {outputFormat.ToUpperInvariant()}로 최적화)";
519 MarkFile(fileStatus, MediaMigrationState.Completed, formatMessage);
520 anyProcessed = true;
521 allSkipped = false;
522 }
523 catch (Exception ex)
524 {
525 anyFailed = true;
526 MarkFile(fileStatus, MediaMigrationState.Failed, ex.Message);
527 }
528
529 processed++;
530 status.Message = BuildProgressMessage(status, processed, total);
531 await SaveAsync(all, ct).ConfigureAwait(false);
532 }
533
534 status.State = anyFailed
536 : anyProcessed
538 : allSkipped
540 : MediaMigrationState.Completed;
541 status.Message = status.State switch
542 {
544 MediaMigrationState.Failed => "실패 항목 있음",
545 MediaMigrationState.Skipped => "건너뜀",
546 _ => status.Message
547 };
548 status.UpdatedAt = DateTime.Now;
549 await SaveAsync(all, ct).ConfigureAwait(false);
550 }
551
576 private async Task<Dictionary<string, MediaMigrationTenantStatus>> LoadAsync(CancellationToken ct)
577 {
578 if (_cache is not null) return _cache;
579
580 await _gate.WaitAsync(ct).ConfigureAwait(false);
581 try
582 {
583 if (_cache is not null) return _cache;
584 var path = StatusPath;
585 if (!File.Exists(path))
586 {
587 _cache = new Dictionary<string, MediaMigrationTenantStatus>(StringComparer.OrdinalIgnoreCase);
588 return _cache;
589 }
590
591 await using var stream = File.OpenRead(path);
592 _cache = await JsonSerializer.DeserializeAsync<Dictionary<string, MediaMigrationTenantStatus>>(stream, _jsonOptions, ct).ConfigureAwait(false)
593 ?? new Dictionary<string, MediaMigrationTenantStatus>(StringComparer.OrdinalIgnoreCase);
594 return _cache;
595 }
596 finally
597 {
598 _gate.Release();
599 }
600 }
601
634 private async Task SaveAsync(Dictionary<string, MediaMigrationTenantStatus> status, CancellationToken ct)
635 {
636 await _gate.WaitAsync(ct).ConfigureAwait(false);
637 try
638 {
639 Directory.CreateDirectory(Path.GetDirectoryName(StatusPath)!);
640 var tmp = StatusPath + ".tmp";
641 await using (var stream = File.Create(tmp))
642 {
643 await JsonSerializer.SerializeAsync(stream, status, _jsonOptions, ct).ConfigureAwait(false);
644 }
645
646 File.Copy(tmp, StatusPath, overwrite: true);
647 File.Delete(tmp);
648 _cache = status;
649 }
650 finally
651 {
652 _gate.Release();
653 }
654 }
655
664 private string StatusPath
665 {
666 get
667 {
668 var dataRoot = Path.GetDirectoryName(_options.ResolvedDataPath.TrimEnd(Path.DirectorySeparatorChar))
669 ?? Path.Combine(AppContext.BaseDirectory, "App_Data");
670 return Path.Combine(dataRoot, "media-migration-status.json");
671 }
672 }
673
698 private bool IsRunning(string slug)
699 {
700 lock (_runningGate)
701 {
702 return _runningSlugs.Contains(slug);
703 }
704 }
705
722 private void StartBackground(string slug)
723 {
724 lock (_runningGate)
725 {
726 if (!_runningSlugs.Add(slug))
727 {
728 return;
729 }
730 }
731
732 _ = Task.Run(async () =>
733 {
734 try
735 {
736 await ProcessTenantSafeAsync(slug, CancellationToken.None).ConfigureAwait(false);
737 }
738 finally
739 {
740 lock (_runningGate)
741 {
742 _runningSlugs.Remove(slug);
743 }
744 }
745 });
746 }
747
780 private static MediaMigrationTenantStatus GetOrCreate(Dictionary<string, MediaMigrationTenantStatus> all, string slug)
781 {
782 if (all.TryGetValue(slug, out var status)) return status;
783 status = new MediaMigrationTenantStatus { Slug = slug, State = MediaMigrationState.Pending };
784 all[slug] = status;
785 return status;
786 }
787
820 private static void MarkFile(MediaMigrationFileStatus fileStatus, MediaMigrationState state, string message)
821 {
822 fileStatus.State = state;
823 fileStatus.Message = message;
824 fileStatus.UpdatedAt = DateTime.Now;
825 }
826
859 private string ResolveWritableOutputFormat(string? preferredFormat, string? sourceFileName)
860 {
861 var preferred = NormalizeFormat(preferredFormat);
862 if (_imageOptimization.CanEncode(preferred)) return preferred;
863
864 if (_imageOptimization.CanEncode("jpg")) return "jpg";
865
866 var original = NormalizeFormat(Path.GetExtension(sourceFileName));
867 return _imageOptimization.CanEncode(original) ? original : "";
868 }
869
910 private static string BuildProgressMessage(MediaMigrationTenantStatus status, int processed, int total)
911 {
912 var failed = status.Files.Count(x => x.State == MediaMigrationState.Failed);
913 var completed = status.Files.Count(x => x.State == MediaMigrationState.Completed);
914 var skipped = status.Files.Count(x => x.State == MediaMigrationState.Skipped);
915 if (failed > 0) return $"처리 중 ({processed}/{total}, 실패 {failed})";
916 return $"처리 중 ({processed}/{total}, 완료 {completed}, 건너뜀 {skipped})";
917 }
918
943 private static string NormalizeFormat(string? outputFormat)
944 {
945 var normalized = outputFormat?.Trim().TrimStart('.').ToLowerInvariant();
946 return string.IsNullOrWhiteSpace(normalized) ? "webp" : normalized;
947 }
948}
List< MediaMigrationFileStatus > Files
async Task< Dictionary< string, MediaMigrationTenantStatus > > LoadAsync(CancellationToken ct)
async Task QueueTenantAsync(string slug, CancellationToken ct=default)
JsonMediaMigrationService(ITenantStore tenants, WeddingOptions options, IMediaQuotaPolicyResolver policyResolver, IImageOptimizationService imageOptimization)
Dictionary< string, MediaMigrationTenantStatus >? _cache
async Task< MediaMigrationTenantStatus?> GetTenantStatusAsync(string slug, CancellationToken ct=default)
async Task RetryTenantAsync(string slug, CancellationToken ct=default)
async Task ProcessTenantSafeAsync(string slug, CancellationToken ct)
static string BuildProgressMessage(MediaMigrationTenantStatus status, int processed, int total)
async Task ProcessTenantAsync(string slug, CancellationToken ct)
static void MarkFile(MediaMigrationFileStatus fileStatus, MediaMigrationState state, string message)
static MediaMigrationTenantStatus GetOrCreate(Dictionary< string, MediaMigrationTenantStatus > all, string slug)
async Task SaveAsync(Dictionary< string, MediaMigrationTenantStatus > status, CancellationToken ct)
async Task< IReadOnlyDictionary< string, MediaMigrationTenantStatus > > GetAllAsync(CancellationToken ct=default)
string ResolveWritableOutputFormat(string? preferredFormat, string? sourceFileName)