WeddingPlatform.Web 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 ProcessTenantSafeAsync(string slug, CancellationToken ct)
JsonMediaMigrationService(ITenantStore tenants, WeddingOptions options, IMediaQuotaPolicyResolver policyResolver, IImageOptimizationService imageOptimization)
async Task SaveAsync(Dictionary< string, MediaMigrationTenantStatus > status, CancellationToken ct)
string ResolveWritableOutputFormat(string? preferredFormat, string? sourceFileName)
async Task QueueTenantAsync(string slug, CancellationToken ct=default)
static MediaMigrationTenantStatus GetOrCreate(Dictionary< string, MediaMigrationTenantStatus > all, string slug)
async Task< Dictionary< string, MediaMigrationTenantStatus > > LoadAsync(CancellationToken ct)
async Task< IReadOnlyDictionary< string, MediaMigrationTenantStatus > > GetAllAsync(CancellationToken ct=default)
static string BuildProgressMessage(MediaMigrationTenantStatus status, int processed, int total)
async Task< MediaMigrationTenantStatus?> GetTenantStatusAsync(string slug, CancellationToken ct=default)
Dictionary< string, MediaMigrationTenantStatus >? _cache
async Task ProcessTenantAsync(string slug, CancellationToken ct)
static void MarkFile(MediaMigrationFileStatus fileStatus, MediaMigrationState state, string message)
async Task RetryTenantAsync(string slug, CancellationToken ct=default)