feat: add Sitemap and RSS feed validation service
TaxBaik CI/CD / build-and-deploy (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 05:52:32 +09:00
parent 3bfb1bab7e
commit d59440efbc
3 changed files with 334 additions and 0 deletions
@@ -0,0 +1,73 @@
using FastEndpoints;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Endpoints.Admin;
/// <summary>
/// Sitemap 및 RSS 검증 엔드포인트 (관리자 전용)
/// </summary>
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);
}
}
+1
View File
@@ -301,6 +301,7 @@ builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
builder.Services.AddInfrastructure();
builder.Services.AddApplication();
builder.Services.AddScoped<IInquiryNotificationService, TelegramInquiryNotificationService>();
builder.Services.AddScoped<TaxBaik.Web.Services.SitemapValidationService>();
// Register version info
var versionInfo = new VersionInfo();
@@ -0,0 +1,260 @@
using System.Text.RegularExpressions;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Services;
/// <summary>
/// Sitemap 및 RSS 피드의 정합성 검증
/// </summary>
public class SitemapValidationService
{
private readonly BlogService _blogService;
private readonly ILogger<SitemapValidationService> _logger;
public SitemapValidationService(BlogService blogService, ILogger<SitemapValidationService> logger)
{
_blogService = blogService;
_logger = logger;
}
/// <summary>
/// Sitemap 검증 결과
/// </summary>
public class SitemapValidationResult
{
public bool IsValid { get; set; }
public List<string> Errors { get; set; } = [];
public List<string> Warnings { get; set; } = [];
public int TotalUrls { get; set; }
public int BlogPostUrls { get; set; }
public DateTime ValidatedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>
/// Sitemap 전체 검증
/// </summary>
public async Task<SitemapValidationResult> 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;
}
/// <summary>
/// RSS 피드 검증
/// </summary>
public async Task<SitemapValidationResult> 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<string>();
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;
}
/// <summary>
/// Sitemap과 RSS의 일관성 검증
/// </summary>
public async Task<SitemapValidationResult> 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;
}
/// <summary>
/// URL 형식 검증
/// </summary>
private static void ValidateUrls(List<string> 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}");
}
}
}
/// <summary>
/// 정적 페이지 URL 목록
/// </summary>
private static List<string> GetStaticUrls()
{
var baseUrl = "https://www.taxbaik.com/taxbaik";
return new List<string>
{
$"{baseUrl}",
$"{baseUrl}/about",
$"{baseUrl}/services",
$"{baseUrl}/contact",
$"{baseUrl}/privacy",
$"{baseUrl}/terms",
$"{baseUrl}/blog",
$"{baseUrl}/faq",
$"{baseUrl}/announcement",
$"{baseUrl}/inquiry"
};
}
}