refactor: move buildable .NET source into src/, update CI/doc paths
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m7s

Groups the repo root into src (buildable source), docs (already existed),
and everything else (db/, scripts/, tests/, deploy/ - deployment/ops/test
assets that aren't compiled, already organized as their own folders). CI
now only needs src/ to build: dotnet restore/build/test/publish all point
at src/TaxBaik.sln, src/TaxBaik.Web/, src/TaxBaik.Proxy/.

- git mv every project (Domain, Infrastructure, Application,
  Application.Tests, Web, Web.Client, Proxy) and TaxBaik.sln into src/ as a
  unit, so relative ProjectReference/.sln paths stay valid unchanged.
- .gitea/workflows/deploy.yml: 6 dotnet restore/clean/build/test/publish
  invocations now point at src/. db/migrations and scripts/ stay at root
  (deploy_gb.sh and browser-e2e.yml only touch published output and the
  deployed URL, not source paths - verified, no changes needed there).
- scripts/validate_admin_render.sh: admin render-mode file paths now
  src/TaxBaik.Web.Client/...
- scripts/validate_kst_timestamps.sh: dropped deploy.sh from its target
  list - that script was removed in the prior cleanup commit (dead, no
  CI workflow referenced it) but this validator still expected it to exist.
- CLAUDE.md, docs/ENGINEERING_HARNESS.md, docs/ADMIN_PATTERN_CRITIQUE_WBS.md:
  updated project-structure diagram, dotnet run/build commands, and grep
  targets to the new src/ paths (also fixed a pre-existing stale path in
  ADMIN_PATTERN_CRITIQUE_WBS.md that still said TaxBaik.Web/Components/Admin
  from before that ever moved to TaxBaik.Web.Client).
- Added a Repo Root harness rule + Architecture Guardrail entries: new files
  belong under src/docs/tests/scripts/db/deploy, not loose at root; temp
  work stays outside the repo (or under a gitignored .scratch/) and is
  never committed.

Verified locally: dotnet build/test src/TaxBaik.sln (26/26 tests), and all
three scripts/validate_*.sh pass against the new layout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 10:37:37 +09:00
parent c00d002972
commit ea447495d3
277 changed files with 36 additions and 29 deletions
@@ -0,0 +1,183 @@
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?> GetByIdAsync(int id, CancellationToken ct = default) =>
await repository.GetByIdAsync(id, ct);
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<(IEnumerable<BlogPost>, int)> GetArchivedPagedAsync(
int page, int pageSize, CancellationToken ct = default) =>
await repository.GetArchivedPagedAsync(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 ArchiveAsync(int id, CancellationToken ct = default)
{
await repository.ArchiveAsync(id, ct);
memoryCache.Remove(AdminDashboardService.CacheKey);
}
public async Task RestoreAsync(int id, CancellationToken ct = default)
{
await repository.RestoreAsync(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));
}
}