46951d871a
- TaxSeason / CurrentSeasonDto에 RelatedCategorySlug 추가 - TaxSeasonCalendar 각 시즌에 카테고리 슬러그 매핑 (income-tax→income-tax, vat-1st/2nd→vat, 종부세→real-estate-tax 등) - IBlogPostRepository.GetByCategorySlugAsync 추가 - BlogService.GetSeasonalPostsAsync: 시즌 관련 글 2개 우선 + 나머지 최신 글로 채움 - IndexModel: SeasonalPosts / RecentPosts 분리 로드 - Index.cshtml 블로그 섹션: 시즌 중 "이번 시즌 추천" 배지 + 시즌별 전체보기 버튼 - site.css: blog-card--seasonal, seasonal-blog-tag, btn-seasonal 스타일 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
165 lines
6.5 KiB
C#
165 lines
6.5 KiB
C#
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<BlogPost?> GetBySlugAsync(string slug, CancellationToken ct = default) =>
|
|
await repository.GetBySlugAsync(slug, ct);
|
|
|
|
/// <summary>카테고리 슬러그 기준 시즌 관련 글 조회. 부족 시 최신 글로 채워 total개 반환.</summary>
|
|
public async Task<(IEnumerable<BlogPost> Seasonal, IEnumerable<BlogPost> 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<BlogPost>, int)> GetPublishedPagedAsync(
|
|
int page, int pageSize, int? categoryId = null, CancellationToken ct = default) =>
|
|
await repository.GetPublishedPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), categoryId, ct);
|
|
|
|
public async Task<IEnumerable<BlogPost>> GetAllAsync(CancellationToken ct = default) =>
|
|
await repository.GetAllForAdminAsync(ct);
|
|
|
|
public async Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken ct = default) =>
|
|
await repository.GetAllForAdminAsync(ct);
|
|
|
|
public async Task<(IEnumerable<BlogPost>, int)> GetAdminPagedAsync(
|
|
int page, int pageSize, CancellationToken ct = default) =>
|
|
await repository.GetAdminPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), ct);
|
|
|
|
public async Task<int> 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<BlogPost> 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<BlogPost?> 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<string> 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));
|
|
}
|
|
}
|