WeddingThankYou 1.0.0.0
결혼식 이후 감사 인사와 사진, 계좌 안내, 연락처를 모바일 페이지로 전달하는 감사장 서비스입니다.
로딩중...
검색중...
일치하는것 없음
AdminViewModel.cs
이 파일의 문서화 페이지로 가기
1using System.Net.Http;
2using System.Net.Http.Json;
3using System.Text.Json;
4using Dreamine.Identity;
5using Microsoft.AspNetCore.Components.Forms;
6using Wedding.Common;
9
11{
20 public sealed class AdminViewModel
21 {
30 private readonly ITenantStore _tenants;
39 private readonly IPhotoService _photos;
48 private readonly WeddingOptions _opts;
76
85 private static readonly HttpClient _geocodeHttp = new()
86 {
87 DefaultRequestHeaders = { { "User-Agent", "WeddingThankYou/1.0 (contact: admin@codemaru.co.kr)" } }
88 };
89
147 ITenantStore tenants,
148 IPhotoService photos,
149 WeddingOptions opts,
150 ThankYouUserContext userContext,
151 IMediaQuotaPolicyResolver mediaPolicyResolver,
152 ISuperAdminSessionTokenService superAdminTokens)
153 {
154 _tenants = tenants;
155 _photos = photos;
156 _opts = opts;
157 _userContext = userContext;
158 _mediaPolicyResolver = mediaPolicyResolver;
159 _superAdminTokens = superAdminTokens;
160 }
161
170 public string MaxVideoSizeLabel { get; private set; } = "최대 200MB";
179 public int MaxVideoCount { get; private set; } = 6;
188 public EffectiveMediaPolicy? EffectiveMediaPolicy { get; private set; }
189
198 public TenantConfig? Config { get; private set; }
207 public IReadOnlyList<PhotoInfo> Gallery { get; private set; } = [];
216 public bool IsLoaded { get; private set; }
225 public bool IsAuthenticated { get; private set; }
234 public bool IsSignedIn { get; private set; }
243 public bool IsLinkedToCurrentUser { get; private set; }
252 public bool IsOwner { get; private set; }
261 public IReadOnlyList<ThankYouAdminUser> EffectiveAdminUsers { get; private set; } = [];
270 public string StatusMessage { get; private set; } = "";
279 public string CurrentUserLabel { get; private set; } = "";
288 public bool IsUploading { get; private set; }
297 public bool IsGeocoding { get; private set; }
306 public string GeocodeStatus { get; private set; } = "";
307
316 public string LoginPassword { get; set; } = "";
317
334 public void SetStatusMessage(string message)
335 {
336 StatusMessage = message;
337 }
338
371 public async Task InitializeAsync(string slug, CancellationToken ct = default)
372 {
373 StatusMessage = "";
374 await RefreshCurrentUserAsync().ConfigureAwait(false);
375
376 var config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
377 if (config is null)
378 {
379 return;
380 }
381
382 var user = await _userContext.GetCurrentAsync().ConfigureAwait(false);
383 IsOwner = IsOwnerUser(config, user);
384 IsLinkedToCurrentUser = IsAdminUser(config, user);
386
388 {
389 IsAuthenticated = true;
390 await LoadAsync(slug, ct).ConfigureAwait(false);
391 }
392 }
393
426 public async Task<bool> LoginAsync(string slug, CancellationToken ct = default)
427 {
428 var config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
429 if (config is null) { StatusMessage = "존재하지 않는 슬러그입니다."; return false; }
430
431 var user = await _userContext.GetCurrentAsync().ConfigureAwait(false);
432 await RefreshCurrentUserAsync().ConfigureAwait(false);
433
434 if (IsAdminUser(config, user))
435 {
436 IsAuthenticated = true;
438 IsOwner = IsOwnerUser(config, user);
440 StatusMessage = "";
441 return true;
442 }
443
444 var verification = DreaminePasswordHasher.VerifyPassword(LoginPassword, config.PasswordHash, out var upgradedHash);
445 IsAuthenticated = verification is not PasswordHashVerificationResult.Failed;
446 if (!IsAuthenticated)
447 {
448 StatusMessage = "비밀번호가 틀렸습니다.";
449 return false;
450 }
451
452 if (verification is PasswordHashVerificationResult.SuccessRehashNeeded && upgradedHash is not null)
453 {
454 config.PasswordHash = upgradedHash;
455 await _tenants.SaveAsync(config, ct).ConfigureAwait(false);
456 }
457
458 if (user.IsAuthenticated && string.IsNullOrWhiteSpace(config.OwnerUserId))
459 {
460 config.OwnerUserId = user.Id;
461 config.OwnerProvider = user.Provider;
462 config.OwnerEmail = user.Email;
463 config.OwnerDisplayName = user.DisplayName;
464 config.OwnerLinkedAt = DateTime.Now;
465 EnsureAdminUser(config, user, "Owner");
466 await _tenants.SaveAsync(config, ct).ConfigureAwait(false);
468 IsOwner = true;
470 StatusMessage = "CodeMaru/Dreamine 계정에 대표 관리자로 연결되었습니다. 다음부터는 공용 로그인으로 관리할 수 있습니다.";
471 }
472 else if (user.IsAuthenticated)
473 {
474 EnsureAdminUser(config, user, "Admin");
475 await _tenants.SaveAsync(config, ct).ConfigureAwait(false);
477 IsOwner = IsOwnerUser(config, user);
479 StatusMessage = "현재 CodeMaru/Dreamine 계정이 이 감사장의 관리자로 추가되었습니다.";
480 }
481 else if (!user.IsAuthenticated && string.IsNullOrWhiteSpace(config.OwnerUserId))
482 {
483 StatusMessage = "로그인은 성공했습니다. 공용 계정에 연결하려면 먼저 CodeMaru/Dreamine 로그인을 해주세요.";
484 }
485 else
486 {
487 StatusMessage = "";
488 }
489
490 return IsAuthenticated;
491 }
492
533 public async Task<bool> LoginAsSuperAdminAsync(string slug, string? sessionToken, CancellationToken ct = default)
534 {
535 if (!_superAdminTokens.ValidateToken(sessionToken))
536 {
537 return false;
538 }
539
540 var config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
541 if (config is null)
542 {
543 StatusMessage = "존재하지 않는 슬러그입니다.";
544 return false;
545 }
546
547 IsAuthenticated = true;
548 IsLinkedToCurrentUser = false;
549 IsOwner = false;
551 StatusMessage = "슈퍼관리자 권한으로 접속했습니다.";
552 await LoadAsync(slug, ct).ConfigureAwait(false);
553 return true;
554 }
555
588 public async Task LoadAsync(string slug, CancellationToken ct = default)
589 {
590 Config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false)
591 ?? new TenantConfig { Slug = slug };
593 Gallery = await _photos.GetGalleryAsync(slug, ct).ConfigureAwait(false);
595
596 EffectiveMediaPolicy = await _mediaPolicyResolver.ResolveAsync(Config, ct).ConfigureAwait(false);
597
598 var effectiveMb = EffectiveMediaPolicy.VideoMaxFileSizeMb;
599 MaxVideoSizeLabel = effectiveMb <= 0 ? "무제한" : $"최대 {effectiveMb}MB";
600
601 MaxVideoCount = EffectiveMediaPolicy.VideoMaxCount;
602
603 IsLoaded = true;
604 }
605
622 private async Task RefreshCurrentUserAsync()
623 {
624 var user = await _userContext.GetCurrentAsync().ConfigureAwait(false);
625 IsSignedIn = user.IsAuthenticated;
626 CurrentUserLabel = user.IsAuthenticated
627 ? string.IsNullOrWhiteSpace(user.DisplayName)
628 ? user.Provider
629 : $"{user.DisplayName} ({user.Provider})"
630 : "";
631 }
632
657 public async Task SaveConfigAsync(CancellationToken ct = default)
658 {
659 if (Config is null) return;
660 try
661 {
663 {
664 return;
665 }
666
668 {
669 return;
670 }
671
673 Config.PasswordHash = DreaminePasswordHasher.HashPlainTextForStorage(Config.PasswordHash);
674 await _tenants.SaveAsync(Config, ct).ConfigureAwait(false);
676 StatusMessage = "설정이 저장되었습니다.";
677 }
678 catch (Exception ex) { StatusMessage = $"저장 오류: {ex.Message}"; }
679 }
680
713 public async Task SaveStoryChapterAsync(StoryChapter chapter, CancellationToken ct = default)
714 {
715 if (Config is null) return;
716 try
717 {
719 var chapters = Config.StoryChapters;
720 var index = chapters.FindIndex(x => x.ChapterNumber == chapter.ChapterNumber);
721 var normalized = WeddingStoryChapterDefaults.Clone(chapter);
722 normalized.Label = string.IsNullOrWhiteSpace(normalized.Label)
723 ? $"CHAPTER {Math.Max(1, normalized.ChapterNumber):00}"
724 : normalized.Label.Trim();
725 normalized.Title = string.IsNullOrWhiteSpace(normalized.Title)
726 ? "스토리"
727 : normalized.Title.Trim();
728
729 if (index >= 0)
730 {
731 chapters[index] = normalized;
732 }
733 else
734 {
735 chapters.Add(normalized);
736 Config.StoryChapters = WeddingStoryChapterDefaults.Normalize(chapters);
737 }
738
739 await _tenants.SaveAsync(Config, ct).ConfigureAwait(false);
740 StatusMessage = $"{normalized.Label} 챕터가 저장되었습니다.";
741 }
742 catch (Exception ex)
743 {
744 StatusMessage = $"챕터 저장 오류: {ex.Message}";
745 }
746 }
747
772 public async Task AddStoryChapterAsync(CancellationToken ct = default)
773 {
774 if (Config is null) return;
775 try
776 {
778 var chapters = Config.StoryChapters;
779 var nextNumber = chapters.Count == 0 ? 1 : chapters.Max(x => x.ChapterNumber) + 1;
780 chapters.Add(new StoryChapter
781 {
782 ChapterNumber = nextNumber,
783 Label = $"CHAPTER {nextNumber:00}",
784 Title = "새로운 이야기",
785 });
786
787 await _tenants.SaveAsync(Config, ct).ConfigureAwait(false);
788 StatusMessage = $"CHAPTER {nextNumber:00} 챕터가 추가되었습니다.";
789 }
790 catch (Exception ex)
791 {
792 StatusMessage = $"챕터 추가 오류: {ex.Message}";
793 }
794 }
795
828 public async Task DeleteStoryChapterAsync(int chapterNumber, CancellationToken ct = default)
829 {
830 if (Config is null) return;
831 try
832 {
834 if (chapterNumber <= 4)
835 {
836 StatusMessage = "기본 4개 챕터는 유지됩니다.";
837 return;
838 }
839
840 var removed = Config.StoryChapters.RemoveAll(x => x.ChapterNumber == chapterNumber);
841 if (removed == 0)
842 {
843 StatusMessage = "삭제할 챕터를 찾을 수 없습니다.";
844 return;
845 }
846
847 await _tenants.SaveAsync(Config, ct).ConfigureAwait(false);
848 StatusMessage = $"CHAPTER {chapterNumber:00} 챕터가 삭제되었습니다.";
849 }
850 catch (Exception ex)
851 {
852 StatusMessage = $"챕터 삭제 오류: {ex.Message}";
853 }
854 }
855
881 {
882 if (!WeddingLayoutCatalog.IsKnownKey(config.ThankYouStyle))
883 {
884 StatusMessage = "저장 오류: 존재하지 않는 레이아웃입니다.";
885 return false;
886 }
887
889 var option = WeddingLayoutCatalog.Instance.Find(mode);
890 if (option is null)
891 {
892 StatusMessage = "저장 오류: 존재하지 않는 레이아웃입니다.";
893 return false;
894 }
895
896 if (!option.IsImplemented)
897 {
898 StatusMessage = "저장 오류: 아직 준비 중인 레이아웃입니다.";
899 return false;
900 }
901
902 var access = new WeddingLayoutAccessState
903 {
904 HasPremiumPlan = config.HasPremiumPlan,
905 UnlockedLayouts = config.UnlockedLayoutModes
906 .Select(WeddingLayoutCatalog.FromLegacyKey)
907 .Where(x => x != WeddingLayoutMode.Unknown)
908 .ToArray(),
909 };
910
911 if (!new WeddingLayoutAccessPolicy().CanUse(option, access))
912 {
913 StatusMessage = "저장 오류: 프리미엄 레이아웃은 플랜 또는 잠금 해제 후 저장할 수 있습니다.";
914 return false;
915 }
916
917 return true;
918 }
919
945 {
946 config.UnlockedThemeKeys ??= new();
947 if (!WeddingThemeCatalog.IsKnownKey(config.ThemeName))
948 {
949 StatusMessage = "저장 오류: 존재하지 않는 테마입니다.";
950 return false;
951 }
952
953 var option = WeddingThemeCatalog.Instance.Find(config.ThemeName);
954 if (option is null)
955 {
956 StatusMessage = "저장 오류: 존재하지 않는 테마입니다.";
957 return false;
958 }
959
960 if (!option.IsImplemented)
961 {
962 StatusMessage = "저장 오류: 아직 준비 중인 테마입니다.";
963 return false;
964 }
965
966 var access = new WeddingThemeAccessState
967 {
968 HasPremiumPlan = config.HasPremiumPlan,
969 UnlockedThemeKeys = config.UnlockedThemeKeys
970 .Select(WeddingThemeCatalog.NormalizeKey)
971 .Where(WeddingThemeCatalog.IsKnownKey)
972 .ToArray(),
973 };
974
975 if (!new WeddingThemeAccessPolicy().CanUse(option, access))
976 {
977 StatusMessage = "저장 오류: 프리미엄 테마는 플랜 또는 잠금 해제 후 저장할 수 있습니다.";
978 return false;
979 }
980
981 config.ThemeName = option.Key;
982 return true;
983 }
984
1017 public async Task RemoveAdminAsync(string userId, CancellationToken ct = default)
1018 {
1019 if (Config is null) return;
1020 var user = await _userContext.GetCurrentAsync().ConfigureAwait(false);
1021 if (!IsOwnerUser(Config, user))
1022 {
1023 StatusMessage = "대표 관리자만 관리자 계정을 삭제할 수 있습니다.";
1024 return;
1025 }
1026
1027 if (string.Equals(Config.OwnerUserId, userId, StringComparison.Ordinal))
1028 {
1029 StatusMessage = "대표 관리자는 삭제할 수 없습니다.";
1030 return;
1031 }
1032
1033 var removed = GetAdminUsers(Config).RemoveAll(x => string.Equals(x.UserId, userId, StringComparison.Ordinal));
1034 if (removed == 0)
1035 {
1036 StatusMessage = "삭제할 관리자를 찾을 수 없습니다.";
1037 return;
1038 }
1039
1040 await _tenants.SaveAsync(Config, ct).ConfigureAwait(false);
1042 StatusMessage = "관리자 계정이 삭제되었습니다.";
1043 }
1044
1085 public async Task UploadGalleryAsync(string slug, IBrowserFile file, CancellationToken ct = default)
1086 {
1087 IsUploading = true;
1088 StatusMessage = "";
1089 try
1090 {
1091 await _photos.UploadAsync(slug, file, ct).ConfigureAwait(false);
1092 // Config도 갱신해야 이후 설정 저장 시 GalleryFileNames가 날아가지 않음
1093 Config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
1094 Gallery = await _photos.GetGalleryAsync(slug, ct).ConfigureAwait(false);
1095 StatusMessage = $"{file.Name} 업로드 완료";
1096 }
1097 catch (Exception ex) { StatusMessage = $"업로드 오류: {ex.Message}"; }
1098 finally { IsUploading = false; }
1099 }
1100
1141 public async Task UploadHeroAsync(string slug, IBrowserFile file, CancellationToken ct = default)
1142 {
1143 IsUploading = true;
1144 try
1145 {
1146 await _photos.UploadHeroAsync(slug, file, ct).ConfigureAwait(false);
1147 Config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
1148 StatusMessage = "히어로 이미지 업로드 완료";
1149 }
1150 catch (Exception ex) { StatusMessage = $"히어로 업로드 오류: {ex.Message}"; }
1151 finally { IsUploading = false; }
1152 }
1153
1194 public async Task DeletePhotoAsync(string slug, string fileName, CancellationToken ct = default)
1195 {
1196 try
1197 {
1198 await _photos.DeleteAsync(slug, fileName, ct).ConfigureAwait(false);
1199 Config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
1200 Gallery = await _photos.GetGalleryAsync(slug, ct).ConfigureAwait(false);
1201 StatusMessage = "삭제 완료";
1202 }
1203 catch (Exception ex) { StatusMessage = $"삭제 오류: {ex.Message}"; }
1204 }
1205
1246 public async Task MoveGalleryPhotoAsync(string fileName, int offset, CancellationToken ct = default)
1247 {
1248 if (Config is null) return;
1249 try
1250 {
1252 var index = Config.GalleryFileNames.FindIndex(x => string.Equals(x, fileName, StringComparison.OrdinalIgnoreCase));
1253 if (index < 0) return;
1254
1255 var target = Math.Clamp(index + offset, 0, Config.GalleryFileNames.Count - 1);
1256 if (index == target) return;
1257
1258 Config.GalleryFileNames.RemoveAt(index);
1259 Config.GalleryFileNames.Insert(target, fileName);
1260 await _tenants.SaveAsync(Config, ct).ConfigureAwait(false);
1261 Gallery = await _photos.GetGalleryAsync(Config.Slug, ct).ConfigureAwait(false);
1262 StatusMessage = "사진 노출 순서가 저장되었습니다.";
1263 }
1264 catch (Exception ex) { StatusMessage = $"순서 변경 오류: {ex.Message}"; }
1265 }
1266
1307 public async Task MoveGalleryPhotoToAsync(string fileName, bool first, CancellationToken ct = default)
1308 {
1309 if (Config is null) return;
1310 try
1311 {
1313 var index = Config.GalleryFileNames.FindIndex(x => string.Equals(x, fileName, StringComparison.OrdinalIgnoreCase));
1314 if (index < 0) return;
1315
1316 Config.GalleryFileNames.RemoveAt(index);
1317 Config.GalleryFileNames.Insert(first ? 0 : Config.GalleryFileNames.Count, fileName);
1318 await _tenants.SaveAsync(Config, ct).ConfigureAwait(false);
1319 Gallery = await _photos.GetGalleryAsync(Config.Slug, ct).ConfigureAwait(false);
1320 StatusMessage = "사진 노출 순서가 저장되었습니다.";
1321 }
1322 catch (Exception ex) { StatusMessage = $"순서 변경 오류: {ex.Message}"; }
1323 }
1324
1334 {
1335 if (Config is null) return;
1336 var existing = Gallery.Select(x => x.FileName).ToHashSet(StringComparer.OrdinalIgnoreCase);
1337 Config.GalleryFileNames = Config.GalleryFileNames
1338 .Where(existing.Contains)
1339 .Distinct(StringComparer.OrdinalIgnoreCase)
1340 .ToList();
1341
1342 foreach (var photo in Gallery)
1343 {
1344 if (!Config.GalleryFileNames.Contains(photo.FileName, StringComparer.OrdinalIgnoreCase))
1345 {
1346 Config.GalleryFileNames.Add(photo.FileName);
1347 }
1348 }
1349 }
1350
1391 public async Task UploadRoadMapAsync(string slug, IBrowserFile file, CancellationToken ct = default)
1392 {
1393 IsUploading = true;
1394 try
1395 {
1396 await _photos.UploadRoadMapAsync(slug, file, ct).ConfigureAwait(false);
1397 Config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
1398 StatusMessage = "약도 이미지 업로드 완료";
1399 }
1400 catch (Exception ex) { StatusMessage = $"약도 업로드 오류: {ex.Message}"; }
1401 finally { IsUploading = false; }
1402 }
1403
1428 public async Task GeocodeAsync(CancellationToken ct = default)
1429 {
1430 if (Config is null) return;
1431 var query = string.IsNullOrWhiteSpace(Config.VenueAddress) ? Config.VenueName : Config.VenueAddress;
1432 if (string.IsNullOrWhiteSpace(query)) { GeocodeStatus = "❗ 예식장 이름 또는 주소를 먼저 입력해주세요."; return; }
1433
1434 IsGeocoding = true;
1435 GeocodeStatus = "";
1436 try
1437 {
1438 var url = $"https://nominatim.openstreetmap.org/search?q={Uri.EscapeDataString(query)}&format=json&limit=1&accept-language=ko";
1439 var results = await _geocodeHttp.GetFromJsonAsync<JsonElement[]>(url, ct).ConfigureAwait(false);
1440 if (results is null || results.Length == 0)
1441 {
1442 GeocodeStatus = "❌ 좌표를 찾을 수 없습니다. 주소를 더 자세히 입력해 보세요.";
1443 return;
1444 }
1445 var item = results[0];
1446 Config.VenueLat = double.Parse(item.GetProperty("lat").GetString()!, System.Globalization.CultureInfo.InvariantCulture);
1447 Config.VenueLng = double.Parse(item.GetProperty("lon").GetString()!, System.Globalization.CultureInfo.InvariantCulture);
1448 GeocodeStatus = $"✅ 좌표 검색 완료 — {item.GetProperty("display_name").GetString()}";
1449 }
1450 catch { GeocodeStatus = "❌ 좌표 검색 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요."; }
1451 finally { IsGeocoding = false; }
1452 }
1453
1462 public void GenerateMapLinks()
1463 {
1464 if (Config is null) return;
1465
1466 var name = Uri.EscapeDataString(Config.VenueName);
1467 var addr = Uri.EscapeDataString(
1468 string.IsNullOrWhiteSpace(Config.VenueAddress) ? Config.VenueName : Config.VenueAddress);
1469 var lat = Config.VenueLat;
1470 var lng = Config.VenueLng;
1471 bool hasCoords = lat != 0 && lng != 0;
1472 var latS = lat.ToString(System.Globalization.CultureInfo.InvariantCulture);
1473 var lngS = lng.ToString(System.Globalization.CultureInfo.InvariantCulture);
1474
1475 // 카카오맵 — 좌표 있으면 핀, 없으면 검색
1476 Config.MapLinkKakao = hasCoords
1477 ? $"https://map.kakao.com/link/map/{name},{latS},{lngS}"
1478 : $"https://map.kakao.com/link/search/{addr}";
1479
1480 // 네이버지도 — 좌표 있으면 좌표 검색, 없으면 주소 검색
1481 Config.MapLinkNaver = hasCoords
1482 ? $"https://map.naver.com/v5/search/{addr}?c={lngS},{latS},15,0,0,0,dh"
1483 : $"https://map.naver.com/v5/search/{addr}";
1484
1485 // 아틀란
1486 if (!string.IsNullOrWhiteSpace(_opts.AtlanAuthKey) && hasCoords)
1487 Config.MapLinkAtlan = $"http://m.atlan.co.kr/searchPlus/linkAtlan.do?shareType=kakao&coordX={lngS}&coordY={latS}&title={name}&AuthKey={_opts.AtlanAuthKey}";
1488
1489 // T맵
1490 if (!string.IsNullOrWhiteSpace(_opts.TmapAppKey) && hasCoords)
1491 Config.MapLinkTmap = $"https://apis.openapi.sk.com/tmap/app/routes?appKey={_opts.TmapAppKey}&name={name}&lon={lngS}&lat={latS}";
1492 }
1493
1526 public string GetThumbUrl(string slug, string fileName) => _photos.GetThumbUrl(slug, fileName);
1559 public string GetPhotoUrl(string slug, string fileName) => _photos.GetPhotoUrl(slug, fileName);
1592 public string GetHeroUrl(string slug, string fileName) => _photos.GetHeroUrl(slug, fileName);
1625 public string GetRoadMapUrl(string slug, string fileName) => _photos.GetRoadMapUrl(slug, fileName);
1658 public string GetMusicUrl(string slug, string fileName) => _photos.GetMusicUrl(slug, fileName);
1691 public string GetOgImageUrl(string slug, string fileName) => _photos.GetHeroUrl(slug, fileName);
1724 public string GetVideoUrl(string slug, string fileName) => _photos.GetVideoUrl(slug, fileName);
1725
1766 public async Task UploadOgImageAsync(string slug, IBrowserFile file, CancellationToken ct = default)
1767 {
1768 IsUploading = true;
1769 try
1770 {
1771 await _photos.UploadOgImageAsync(slug, file, ct).ConfigureAwait(false);
1772 Config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
1773 StatusMessage = "미리보기 이미지 업로드 완료";
1774 }
1775 catch (Exception ex) { StatusMessage = $"미리보기 이미지 오류: {ex.Message}"; }
1776 finally { IsUploading = false; }
1777 }
1778
1819 public async Task UploadVideoAsync(string slug, IBrowserFile file, CancellationToken ct = default)
1820 {
1821 IsUploading = true;
1822 try
1823 {
1824 await _photos.UploadVideoAsync(slug, file, ct).ConfigureAwait(false);
1825 Config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
1826 StatusMessage = "동영상 업로드 완료";
1827 }
1828 catch (Exception ex) { StatusMessage = $"동영상 업로드 오류: {ex.Message}"; }
1829 finally { IsUploading = false; }
1830 }
1831
1872 public async Task DeleteVideoAsync(string slug, string fileName, CancellationToken ct = default)
1873 {
1874 try
1875 {
1876 await _photos.DeleteVideoAsync(slug, fileName, ct).ConfigureAwait(false);
1877 Config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
1878 StatusMessage = "동영상 삭제 완료";
1879 }
1880 catch (Exception ex) { StatusMessage = $"동영상 삭제 오류: {ex.Message}"; }
1881 }
1882
1923 public async Task UploadMusicAsync(string slug, IBrowserFile file, CancellationToken ct = default)
1924 {
1925 IsUploading = true;
1926 try
1927 {
1928 await _photos.UploadMusicAsync(slug, file, ct).ConfigureAwait(false);
1929 Config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
1930 StatusMessage = "배경 음악 업로드 완료";
1931 }
1932 catch (Exception ex) { StatusMessage = $"음악 업로드 오류: {ex.Message}"; }
1933 finally { IsUploading = false; }
1934 }
1935
1960 private static List<ThankYouAdminUser> GetAdminUsers(TenantConfig config) => config.AdminUsers ??= [];
1961
1994 private static bool IsOwnerUser(TenantConfig config, ThankYouCurrentUser user) =>
1995 user.IsAuthenticated &&
1996 !string.IsNullOrWhiteSpace(config.OwnerUserId) &&
1997 string.Equals(config.OwnerUserId, user.Id, StringComparison.Ordinal);
1998
2031 private static bool IsAdminUser(TenantConfig config, ThankYouCurrentUser user)
2032 {
2033 if (!user.IsAuthenticated) return false;
2034 if (IsOwnerUser(config, user)) return true;
2035 return GetAdminUsers(config).Any(x => string.Equals(x.UserId, user.Id, StringComparison.Ordinal));
2036 }
2037
2070 private static void EnsureAdminUser(TenantConfig config, ThankYouCurrentUser user, string role)
2071 {
2072 if (!user.IsAuthenticated) return;
2073
2074 var users = GetAdminUsers(config);
2075 var existing = users.FirstOrDefault(x => string.Equals(x.UserId, user.Id, StringComparison.Ordinal));
2076 if (existing is null)
2077 {
2078 users.Add(new ThankYouAdminUser
2079 {
2080 UserId = user.Id,
2081 Provider = user.Provider,
2082 Email = user.Email,
2083 DisplayName = user.DisplayName,
2084 Role = role,
2085 AddedAt = DateTime.Now
2086 });
2087 return;
2088 }
2089
2090 existing.Provider = user.Provider;
2091 existing.Email = user.Email;
2092 existing.DisplayName = user.DisplayName;
2093 if (role == "Owner")
2094 {
2095 existing.Role = "Owner";
2096 }
2097 }
2098
2123 private static IReadOnlyList<ThankYouAdminUser> BuildEffectiveAdminUsers(TenantConfig config)
2124 {
2125 var users = GetAdminUsers(config);
2126 if (!string.IsNullOrWhiteSpace(config.OwnerUserId) &&
2127 users.All(x => !string.Equals(x.UserId, config.OwnerUserId, StringComparison.Ordinal)))
2128 {
2129 users.Insert(0, new ThankYouAdminUser
2130 {
2131 UserId = config.OwnerUserId,
2132 Provider = config.OwnerProvider,
2133 Email = config.OwnerEmail,
2134 DisplayName = config.OwnerDisplayName,
2135 Role = "Owner",
2136 AddedAt = config.OwnerLinkedAt ?? config.CreatedAt
2137 });
2138 }
2139
2140 foreach (var user in users.Where(x => string.Equals(x.UserId, config.OwnerUserId, StringComparison.Ordinal)))
2141 {
2142 user.Role = "Owner";
2143 }
2144
2145 return users
2146 .OrderByDescending(x => string.Equals(x.Role, "Owner", StringComparison.OrdinalIgnoreCase))
2147 .ThenBy(x => x.AddedAt)
2148 .ToList();
2149 }
2150 }
2151}
record ThankYouCurrentUser(string Id, string Provider, string Email, string DisplayName)
static void Normalize(TenantConfig config)
static WeddingLayoutMode ResolveLayoutMode(string? legacyStyle)
static IReadOnlyList< ThankYouAdminUser > BuildEffectiveAdminUsers(TenantConfig config)
static bool IsOwnerUser(TenantConfig config, ThankYouCurrentUser user)
async Task DeleteVideoAsync(string slug, string fileName, CancellationToken ct=default)
async Task SaveStoryChapterAsync(StoryChapter chapter, CancellationToken ct=default)
async Task UploadVideoAsync(string slug, IBrowserFile file, CancellationToken ct=default)
bool ValidateThemeForSave(TenantConfig config)
async Task LoadAsync(string slug, CancellationToken ct=default)
bool ValidateLayoutForSave(TenantConfig config)
static void EnsureAdminUser(TenantConfig config, ThankYouCurrentUser user, string role)
async Task UploadGalleryAsync(string slug, IBrowserFile file, CancellationToken ct=default)
static List< ThankYouAdminUser > GetAdminUsers(TenantConfig config)
async Task UploadRoadMapAsync(string slug, IBrowserFile file, CancellationToken ct=default)
async Task MoveGalleryPhotoAsync(string fileName, int offset, CancellationToken ct=default)
string GetHeroUrl(string slug, string fileName)
async Task DeleteStoryChapterAsync(int chapterNumber, CancellationToken ct=default)
async Task RemoveAdminAsync(string userId, CancellationToken ct=default)
async Task DeletePhotoAsync(string slug, string fileName, CancellationToken ct=default)
static readonly HttpClient _geocodeHttp
async Task InitializeAsync(string slug, CancellationToken ct=default)
async Task UploadMusicAsync(string slug, IBrowserFile file, CancellationToken ct=default)
static bool IsAdminUser(TenantConfig config, ThankYouCurrentUser user)
string GetOgImageUrl(string slug, string fileName)
string GetMusicUrl(string slug, string fileName)
AdminViewModel(ITenantStore tenants, IPhotoService photos, WeddingOptions opts, ThankYouUserContext userContext, IMediaQuotaPolicyResolver mediaPolicyResolver, ISuperAdminSessionTokenService superAdminTokens)
string GetRoadMapUrl(string slug, string fileName)
readonly ISuperAdminSessionTokenService _superAdminTokens
async Task< bool > LoginAsync(string slug, CancellationToken ct=default)
async Task UploadOgImageAsync(string slug, IBrowserFile file, CancellationToken ct=default)
async Task MoveGalleryPhotoToAsync(string fileName, bool first, CancellationToken ct=default)
async Task< bool > LoginAsSuperAdminAsync(string slug, string? sessionToken, CancellationToken ct=default)
readonly IMediaQuotaPolicyResolver _mediaPolicyResolver
async Task SaveConfigAsync(CancellationToken ct=default)
async Task AddStoryChapterAsync(CancellationToken ct=default)
string GetVideoUrl(string slug, string fileName)
IReadOnlyList< ThankYouAdminUser > EffectiveAdminUsers
readonly ThankYouUserContext _userContext
async Task GeocodeAsync(CancellationToken ct=default)
string GetThumbUrl(string slug, string fileName)
async Task UploadHeroAsync(string slug, IBrowserFile file, CancellationToken ct=default)
string GetPhotoUrl(string slug, string fileName)