namespace TaxBaik.Application.Services; using System.Text.RegularExpressions; using TaxBaik.Application.DTOs; using TaxBaik.Domain.Entities; using TaxBaik.Domain.Interfaces; using Microsoft.Extensions.Caching.Memory; public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCache) { public async Task GetByIdAsync(int id, CancellationToken ct = default) => await repository.GetByIdAsync(id, ct); public async Task GetBySlugAsync(string slug, CancellationToken ct = default) => await repository.GetBySlugAsync(slug, ct); /// 카테고리 슬러그 기준 시즌 관련 글 조회. 부족 시 최신 글로 채워 total개 반환. public async Task<(IEnumerable Seasonal, IEnumerable Latest)> GetSeasonalPostsAsync( string categorySlug, int seasonalCount, int totalCount, CancellationToken ct = default) { var seasonal = (await repository.GetByCategorySlugAsync(categorySlug, seasonalCount, ct)).ToList(); var seasonalIds = seasonal.Select(p => p.Id).ToHashSet(); var (latestAll, _) = await repository.GetPublishedPagedAsync(1, totalCount + seasonalCount, null, ct); var latest = latestAll.Where(p => !seasonalIds.Contains(p.Id)).Take(totalCount - seasonal.Count).ToList(); return (seasonal, latest); } public async Task<(IEnumerable, int)> GetPublishedPagedAsync( int page, int pageSize, int? categoryId = null, CancellationToken ct = default) => await repository.GetPublishedPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), categoryId, ct); public async Task> GetAllAsync(CancellationToken ct = default) => await repository.GetAllForAdminAsync(ct); public async Task> GetAllForAdminAsync(CancellationToken ct = default) => await repository.GetAllForAdminAsync(ct); public async Task<(IEnumerable, int)> GetAdminPagedAsync( int page, int pageSize, CancellationToken ct = default) => await repository.GetAdminPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), ct); public async Task CreateAsync(BlogPost post, CancellationToken ct = default) { ValidatePost(post); post.Title = post.Title.Trim(); post.Content = post.Content.Trim(); post.Slug = await GenerateUniqueSlugAsync(post.Title, ct: ct); post.PublishedAt = post.IsPublished ? DateTime.UtcNow : null; var result = await repository.CreateAsync(post, ct); memoryCache.Remove(AdminDashboardService.CacheKey); return result; } public async Task CreateAsync(CreateBlogPostDto dto, CancellationToken ct = default) { var post = new BlogPost { Title = dto.Title, Content = dto.Content, CategoryId = dto.CategoryId, Tags = dto.Tags, SeoTitle = dto.SeoTitle, SeoDescription = dto.SeoDescription, ThumbnailUrl = dto.ThumbnailUrl, IsPublished = dto.IsPublished, AuthorId = dto.AuthorId, CreatedAt = DateTime.UtcNow }; var id = await CreateAsync(post, ct); post.Id = id; return post; } public async Task UpdateAsync(BlogPost post, CancellationToken ct = default) { await repository.UpdateAsync(post, ct); memoryCache.Remove(AdminDashboardService.CacheKey); } public async Task UpdateAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default) { var post = await repository.GetByIdAsync(id, ct); if (post == null) return null; post.Title = dto.Title; post.Content = dto.Content; post.CategoryId = dto.CategoryId; post.Tags = dto.Tags; post.SeoTitle = dto.SeoTitle; post.SeoDescription = dto.SeoDescription; post.ThumbnailUrl = dto.ThumbnailUrl; post.IsPublished = dto.IsPublished; post.PublishedAt = dto.IsPublished ? post.PublishedAt ?? DateTime.UtcNow : null; ValidatePost(post); await UpdateAsync(post, ct); return post; } public async Task DeleteAsync(int id, CancellationToken ct = default) { await repository.DeleteAsync(id, ct); memoryCache.Remove(AdminDashboardService.CacheKey); } public async Task IncrementViewCountAsync(int id, CancellationToken ct = default) => await repository.IncrementViewCountAsync(id, ct); private static string GenerateSlug(string title) { var slug = Regex.Replace(title.ToLowerInvariant(), @"[^\w\s-]", ""); slug = Regex.Replace(slug, @"\s+", "-"); slug = Regex.Replace(slug, @"-+", "-").Trim('-'); if (string.IsNullOrWhiteSpace(slug)) slug = $"post-{DateTime.UtcNow:yyyyMMddHHmmss}"; return slug.Length > 100 ? slug[..100] : slug; } private async Task GenerateUniqueSlugAsync(string title, int? existingPostId = null, CancellationToken ct = default) { var baseSlug = GenerateSlug(title); var slug = baseSlug; var suffix = 2; var allPosts = (await repository.GetAllForAdminAsync(ct)).ToList(); while (allPosts.Any(x => x.Id != existingPostId && string.Equals(x.Slug, slug, StringComparison.OrdinalIgnoreCase))) { var suffixText = $"-{suffix++}"; var maxBaseLength = Math.Max(1, 100 - suffixText.Length); slug = $"{baseSlug[..Math.Min(baseSlug.Length, maxBaseLength)]}{suffixText}"; } return slug; } private static void ValidatePost(BlogPost post) { if (string.IsNullOrWhiteSpace(post.Title)) throw new ValidationException("제목을 입력하세요."); if (string.IsNullOrWhiteSpace(post.Content)) throw new ValidationException("본문을 입력하세요."); if (post.IsPublished && string.IsNullOrWhiteSpace(post.SeoTitle)) throw new ValidationException("발행하려면 SEO 제목을 입력하세요."); if (post.IsPublished && string.IsNullOrWhiteSpace(post.SeoDescription)) throw new ValidationException("발행하려면 SEO 설명을 입력하세요."); } private static int NormalizePage(int page) => Math.Max(1, page); private static int NormalizePageSize(int pageSize) => Math.Clamp(pageSize, 1, 100); public async Task<(int TotalPosts, int PublishedPosts)> GetStatsAsync(CancellationToken ct = default) { var posts = (await repository.GetAllForAdminAsync(ct)).ToList(); return (posts.Count, posts.Count(x => x.IsPublished)); } }