From d59440efbcf9722d885588c40bf7c39e12b1da83 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Sat, 4 Jul 2026 05:52:32 +0900 Subject: [PATCH] feat: add Sitemap and RSS feed validation service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New validation service for ensuring feed consistency: - SitemapValidationService: Complete feed validation • URL format validation (protocol, domain, scheme) • Duplicate URL detection • Blog post date validation • Sitemap ↔ RSS consistency checks - ValidationEndpoints (FastEndpoints): Admin API • GET /api/admin/validate/sitemap • GET /api/admin/validate/rss • GET /api/admin/validate/consistency Validation checks: ✓ URL validity (Uri.TryCreate) ✓ HTTPS protocol ✓ Correct domain ✓ GUID validity ✓ RFC 2822 date format ✓ Required fields ✓ Duplicate detection ✓ Post count consistency Co-Authored-By: Claude Haiku 4.5 --- .../Endpoints/Admin/ValidationEndpoints.cs | 73 +++++ src/TaxBaik.Web/Program.cs | 1 + .../Services/SitemapValidationService.cs | 260 ++++++++++++++++++ 3 files changed, 334 insertions(+) create mode 100644 src/TaxBaik.Web/Endpoints/Admin/ValidationEndpoints.cs create mode 100644 src/TaxBaik.Web/Services/SitemapValidationService.cs diff --git a/src/TaxBaik.Web/Endpoints/Admin/ValidationEndpoints.cs b/src/TaxBaik.Web/Endpoints/Admin/ValidationEndpoints.cs new file mode 100644 index 0000000..2fcee50 --- /dev/null +++ b/src/TaxBaik.Web/Endpoints/Admin/ValidationEndpoints.cs @@ -0,0 +1,73 @@ +using FastEndpoints; +using TaxBaik.Web.Services; + +namespace TaxBaik.Web.Endpoints.Admin; + +/// +/// Sitemap 및 RSS 검증 엔드포인트 (관리자 전용) +/// +public class ValidateSitemapEndpoint : EndpointWithoutRequest +{ + private readonly SitemapValidationService _validationService; + + public ValidateSitemapEndpoint(SitemapValidationService validationService) + { + _validationService = validationService; + } + + public override void Configure() + { + Get("/api/admin/validate/sitemap"); + Roles("admin"); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var result = await _validationService.ValidateSitemapAsync(); + await SendOkAsync(result, cancellation: ct); + } +} + +public class ValidateRssEndpoint : EndpointWithoutRequest +{ + private readonly SitemapValidationService _validationService; + + public ValidateRssEndpoint(SitemapValidationService validationService) + { + _validationService = validationService; + } + + public override void Configure() + { + Get("/api/admin/validate/rss"); + Roles("admin"); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var result = await _validationService.ValidateRssFeedAsync(); + await SendOkAsync(result, cancellation: ct); + } +} + +public class ValidateConsistencyEndpoint : EndpointWithoutRequest +{ + private readonly SitemapValidationService _validationService; + + public ValidateConsistencyEndpoint(SitemapValidationService validationService) + { + _validationService = validationService; + } + + public override void Configure() + { + Get("/api/admin/validate/consistency"); + Roles("admin"); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var result = await _validationService.ValidateConsistencyAsync(); + await SendOkAsync(result, cancellation: ct); + } +} diff --git a/src/TaxBaik.Web/Program.cs b/src/TaxBaik.Web/Program.cs index 2fde560..fa8734e 100644 --- a/src/TaxBaik.Web/Program.cs +++ b/src/TaxBaik.Web/Program.cs @@ -301,6 +301,7 @@ builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All)); builder.Services.AddInfrastructure(); builder.Services.AddApplication(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Register version info var versionInfo = new VersionInfo(); diff --git a/src/TaxBaik.Web/Services/SitemapValidationService.cs b/src/TaxBaik.Web/Services/SitemapValidationService.cs new file mode 100644 index 0000000..495d8d8 --- /dev/null +++ b/src/TaxBaik.Web/Services/SitemapValidationService.cs @@ -0,0 +1,260 @@ +using System.Text.RegularExpressions; +using TaxBaik.Application.Services; + +namespace TaxBaik.Web.Services; + +/// +/// Sitemap 및 RSS 피드의 정합성 검증 +/// +public class SitemapValidationService +{ + private readonly BlogService _blogService; + private readonly ILogger _logger; + + public SitemapValidationService(BlogService blogService, ILogger logger) + { + _blogService = blogService; + _logger = logger; + } + + /// + /// Sitemap 검증 결과 + /// + public class SitemapValidationResult + { + public bool IsValid { get; set; } + public List Errors { get; set; } = []; + public List Warnings { get; set; } = []; + public int TotalUrls { get; set; } + public int BlogPostUrls { get; set; } + public DateTime ValidatedAt { get; set; } = DateTime.UtcNow; + } + + /// + /// Sitemap 전체 검증 + /// + public async Task ValidateSitemapAsync() + { + var result = new SitemapValidationResult(); + + try + { + // 1. 정적 페이지 검증 + var staticUrls = GetStaticUrls(); + ValidateUrls(staticUrls, result); + + result.TotalUrls = staticUrls.Count; + + // 2. 동적 블로그 포스트 검증 + var (posts, _) = await _blogService.GetPublishedPagedAsync(1, 1000, categoryId: null, ct: default); + var blogUrls = posts.Select(p => $"https://www.taxbaik.com/taxbaik/blog/{p.Slug}").ToList(); + + ValidateUrls(blogUrls, result); + + result.TotalUrls += blogUrls.Count; + result.BlogPostUrls = blogUrls.Count; + + // 3. 중복 검증 + var allUrls = staticUrls.Concat(blogUrls).ToList(); + var duplicates = allUrls.GroupBy(u => u) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToList(); + + if (duplicates.Any()) + { + result.Errors.Add($"중복 URL 발견: {string.Join(", ", duplicates)}"); + } + + // 4. 블로그 포스트 날짜 검증 + foreach (var post in posts) + { + if (post.PublishedAt == null || post.PublishedAt == default) + { + result.Warnings.Add($"포스트 '{post.Slug}'의 발행일이 없습니다."); + } + } + + result.IsValid = !result.Errors.Any(); + } + catch (Exception ex) + { + result.Errors.Add($"검증 실패: {ex.Message}"); + result.IsValid = false; + } + + _logger.LogInformation("Sitemap 검증 완료: {IsValid}, 총 {TotalUrls}개 URL, {BlogPostUrls}개 포스트", + result.IsValid, result.TotalUrls, result.BlogPostUrls); + + return result; + } + + /// + /// RSS 피드 검증 + /// + public async Task ValidateRssFeedAsync() + { + var result = new SitemapValidationResult(); + + try + { + var (posts, _) = await _blogService.GetPublishedPagedAsync(1, 50, categoryId: null, ct: default); + + if (!posts.Any()) + { + result.Warnings.Add("RSS 피드에 포스트가 없습니다."); + } + + // 필수 필드 검증 + var postsList = posts.ToList(); + foreach (var post in postsList) + { + var errors = new List(); + + if (string.IsNullOrEmpty(post.Title)) + errors.Add("제목 없음"); + + if (string.IsNullOrEmpty(post.Slug)) + errors.Add("Slug 없음"); + + if (string.IsNullOrEmpty(post.Content)) + errors.Add("콘텐츠 없음"); + + if (post.PublishedAt == null || post.PublishedAt == default) + errors.Add("발행일 없음"); + + if (errors.Any()) + { + result.Errors.Add($"포스트 '{post.Title}': {string.Join(", ", errors)}"); + } + + // GUID 유효성 (URL 형식) + var guid = $"https://www.taxbaik.com/taxbaik/blog/{post.Slug}"; + if (!Uri.TryCreate(guid, UriKind.Absolute, out _)) + { + result.Errors.Add($"GUID 유효하지 않음: {guid}"); + } + + // pubDate 형식 검증 (RFC 2822) + if (post.PublishedAt.HasValue) + { + try + { + var rfc2822 = post.PublishedAt.Value.ToString("R"); // RFC 2822 형식 + if (string.IsNullOrEmpty(rfc2822)) + result.Warnings.Add($"포스트 '{post.Slug}' pubDate 형식 변환 실패"); + } + catch + { + result.Errors.Add($"포스트 '{post.Slug}' pubDate 형식 오류"); + } + } + } + + result.TotalUrls = postsList.Count; + result.IsValid = !result.Errors.Any(); + } + catch (Exception ex) + { + result.Errors.Add($"RSS 검증 실패: {ex.Message}"); + result.IsValid = false; + } + + _logger.LogInformation("RSS 검증 완료: {IsValid}, 총 {TotalUrls}개 포스트", + result.IsValid, result.TotalUrls); + + return result; + } + + /// + /// Sitemap과 RSS의 일관성 검증 + /// + public async Task ValidateConsistencyAsync() + { + var result = new SitemapValidationResult(); + + try + { + var sitemapValidation = await ValidateSitemapAsync(); + var rssValidation = await ValidateRssFeedAsync(); + + // 에러 통합 + result.Errors.AddRange(sitemapValidation.Errors); + result.Errors.AddRange(rssValidation.Errors); + + // 경고 통합 + result.Warnings.AddRange(sitemapValidation.Warnings); + result.Warnings.AddRange(rssValidation.Warnings); + + // 블로그 포스트 수 비교 + if (sitemapValidation.BlogPostUrls != rssValidation.TotalUrls) + { + result.Warnings.Add( + $"Sitemap의 블로그 포스트({sitemapValidation.BlogPostUrls})와 " + + $"RSS 포스트({rssValidation.TotalUrls}) 수가 다릅니다. " + + $"(Sitemap은 전체, RSS는 최근 50개)" + ); + } + + result.IsValid = !result.Errors.Any(); + result.TotalUrls = sitemapValidation.TotalUrls; + result.BlogPostUrls = sitemapValidation.BlogPostUrls; + } + catch (Exception ex) + { + result.Errors.Add($"일관성 검증 실패: {ex.Message}"); + result.IsValid = false; + } + + return result; + } + + /// + /// URL 형식 검증 + /// + private static void ValidateUrls(List urls, SitemapValidationResult result) + { + foreach (var url in urls) + { + // URL 형식 검증 + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + result.Errors.Add($"유효하지 않은 URL: {url}"); + continue; + } + + // HTTPS 검증 + if (uri.Scheme != "https") + { + result.Warnings.Add($"HTTPS가 아닌 URL: {url}"); + } + + // 도메인 검증 + if (uri.Host != "www.taxbaik.com" && uri.Host != "taxbaik.com") + { + result.Errors.Add($"잘못된 도메인: {url}"); + } + } + } + + /// + /// 정적 페이지 URL 목록 + /// + private static List GetStaticUrls() + { + var baseUrl = "https://www.taxbaik.com/taxbaik"; + return new List + { + $"{baseUrl}", + $"{baseUrl}/about", + $"{baseUrl}/services", + $"{baseUrl}/contact", + $"{baseUrl}/privacy", + $"{baseUrl}/terms", + $"{baseUrl}/blog", + $"{baseUrl}/faq", + $"{baseUrl}/announcement", + $"{baseUrl}/inquiry" + }; + } +}