구현: W2 도메인·인프라·서비스 레이어
Domain 레이어: - Enums: InquiryStatus (New/Contacted/Contracted/Closed) - Enums: ServiceType (기장/종소세/부가세/양도세/증여상속/기타) - Entities: Category, BlogPost, Inquiry, AdminUser (DB 매핑) - Interfaces: IBlogPostRepository, ICategoryRepository, IInquiryRepository, IDbConnectionFactory Infrastructure 레이어: - DbConnectionFactory: PostgreSQL 연결 팩토리 (Npgsql) - BlogPostRepository: GetBySlug, GetPublishedPaged, Create, Update, Delete, IncrementViewCount - CategoryRepository: GetAll, GetBySlug - InquiryRepository: Create, GetPaged, UpdateStatus - NuGet 의존성: Dapper 2.1.15, Npgsql 8.0.1, Configuration, DependencyInjection - DependencyInjection: AddInfrastructure() 확장 메서드 기술 결정: - Dapper로 SQL 완전 제어 - PostgreSQL 다중 쿼리 (QueryMultiple) 페이징 최적화 - 한국어 파라미터 처리 (::int, ::text 타입 명시) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
namespace TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public class AdminUser
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Username { get; set; } = null!;
|
||||||
|
public string PasswordHash { get; set; } = null!;
|
||||||
|
public DateTime? LastLoginAt { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public class BlogPost
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Title { get; set; } = null!;
|
||||||
|
public string Content { get; set; } = null!;
|
||||||
|
public string Slug { get; set; } = null!;
|
||||||
|
public int? CategoryId { get; set; }
|
||||||
|
public string? Tags { get; set; }
|
||||||
|
public int? AuthorId { get; set; }
|
||||||
|
public DateTime? PublishedAt { get; set; }
|
||||||
|
public int ViewCount { get; set; }
|
||||||
|
public string? SeoTitle { get; set; }
|
||||||
|
public string? SeoDescription { get; set; }
|
||||||
|
public string? ThumbnailUrl { get; set; }
|
||||||
|
public bool IsPublished { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
// Navigation property (populated via LEFT JOIN, not stored in DB)
|
||||||
|
public string? CategoryName { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public class Category
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = null!;
|
||||||
|
public string Slug { get; set; } = null!;
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Enums;
|
||||||
|
|
||||||
|
public class Inquiry
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = null!;
|
||||||
|
public string Phone { get; set; } = null!;
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string ServiceType { get; set; } = null!;
|
||||||
|
public string Message { get; set; } = null!;
|
||||||
|
public string Status { get; set; } = "new";
|
||||||
|
public string? IpAddress { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace TaxBaik.Domain.Enums;
|
||||||
|
|
||||||
|
public enum InquiryStatus
|
||||||
|
{
|
||||||
|
New = 0, // 새로운 문의
|
||||||
|
Contacted = 1, // 연락 완료
|
||||||
|
Contracted = 2, // 계약 체결
|
||||||
|
Closed = 3 // 종료
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace TaxBaik.Domain.Enums;
|
||||||
|
|
||||||
|
public enum ServiceType
|
||||||
|
{
|
||||||
|
Bookkeeping = 0, // 기장
|
||||||
|
IncomeTax = 1, // 종소세
|
||||||
|
VatTax = 2, // 부가세
|
||||||
|
CapitalGainsTax = 3, // 양도세
|
||||||
|
GiftInheritanceTax = 4,// 증여·상속
|
||||||
|
Other = 5 // 기타
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface IBlogPostRepository
|
||||||
|
{
|
||||||
|
Task<BlogPost?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
Task<BlogPost?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default);
|
||||||
|
Task<(IEnumerable<BlogPost> Items, int Total)> GetPublishedPagedAsync(
|
||||||
|
int page, int pageSize, int? categoryId = null, CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default);
|
||||||
|
Task UpdateAsync(BlogPost post, CancellationToken cancellationToken = default);
|
||||||
|
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface ICategoryRepository
|
||||||
|
{
|
||||||
|
Task<IEnumerable<Category>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<Category?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
using System.Data;
|
||||||
|
|
||||||
|
public interface IDbConnectionFactory
|
||||||
|
{
|
||||||
|
IDbConnection CreateConnection();
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface IInquiryRepository
|
||||||
|
{
|
||||||
|
Task<int> CreateAsync(Inquiry inquiry, CancellationToken cancellationToken = default);
|
||||||
|
Task<Inquiry?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
Task<(IEnumerable<Inquiry> Items, int Total)> GetPagedAsync(
|
||||||
|
int page, int pageSize, string? status = null, CancellationToken cancellationToken = default);
|
||||||
|
Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace TaxBaik.Infrastructure.Data;
|
||||||
|
|
||||||
|
using System.Data;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Npgsql;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public sealed class DbConnectionFactory : IDbConnectionFactory
|
||||||
|
{
|
||||||
|
private readonly string _connectionString;
|
||||||
|
|
||||||
|
public DbConnectionFactory(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
_connectionString = configuration.GetConnectionString("Default")
|
||||||
|
?? throw new InvalidOperationException("Missing 'Default' connection string in configuration.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public IDbConnection CreateConnection() => new NpgsqlConnection(_connectionString);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace TaxBaik.Infrastructure;
|
||||||
|
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
using TaxBaik.Infrastructure.Data;
|
||||||
|
using TaxBaik.Infrastructure.Repositories;
|
||||||
|
|
||||||
|
public static class DependencyInjection
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddSingleton<IDbConnectionFactory, DbConnectionFactory>();
|
||||||
|
services.AddScoped<ICategoryRepository, CategoryRepository>();
|
||||||
|
services.AddScoped<IBlogPostRepository, BlogPostRepository>();
|
||||||
|
services.AddScoped<IInquiryRepository, InquiryRepository>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace TaxBaik.Infrastructure.Repositories;
|
||||||
|
|
||||||
|
using System.Data;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public abstract class BaseRepository(IDbConnectionFactory connectionFactory)
|
||||||
|
{
|
||||||
|
protected IDbConnection Conn() => connectionFactory.CreateConnection();
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
namespace TaxBaik.Infrastructure.Repositories;
|
||||||
|
|
||||||
|
using Dapper;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IBlogPostRepository
|
||||||
|
{
|
||||||
|
public async Task<BlogPost?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstOrDefaultAsync<BlogPost>(
|
||||||
|
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
||||||
|
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
|
||||||
|
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
|
||||||
|
FROM blog_posts bp
|
||||||
|
LEFT JOIN categories c ON bp.category_id = c.id
|
||||||
|
WHERE bp.id = @Id",
|
||||||
|
new { Id = id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BlogPost?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstOrDefaultAsync<BlogPost>(
|
||||||
|
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
||||||
|
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
|
||||||
|
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
|
||||||
|
FROM blog_posts bp
|
||||||
|
LEFT JOIN categories c ON bp.category_id = c.id
|
||||||
|
WHERE bp.slug = @Slug AND bp.is_published = TRUE",
|
||||||
|
new { Slug = slug });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(IEnumerable<BlogPost> Items, int Total)> GetPublishedPagedAsync(
|
||||||
|
int page, int pageSize, int? categoryId = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
var offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
using var reader = await conn.QueryMultipleAsync(
|
||||||
|
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
||||||
|
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
|
||||||
|
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
|
||||||
|
FROM blog_posts bp
|
||||||
|
LEFT JOIN categories c ON bp.category_id = c.id
|
||||||
|
WHERE bp.is_published = TRUE AND (@CategoryId::int IS NULL OR bp.category_id = @CategoryId)
|
||||||
|
ORDER BY bp.published_at DESC
|
||||||
|
LIMIT @PageSize OFFSET @Offset;
|
||||||
|
|
||||||
|
SELECT COUNT(*) FROM blog_posts
|
||||||
|
WHERE is_published = TRUE AND (@CategoryId::int IS NULL OR category_id = @CategoryId);",
|
||||||
|
new { CategoryId = categoryId, PageSize = pageSize, Offset = offset });
|
||||||
|
|
||||||
|
var items = (await reader.ReadAsync<BlogPost>()).ToList();
|
||||||
|
var total = await reader.ReadFirstAsync<int>();
|
||||||
|
|
||||||
|
return (items, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<BlogPost>(
|
||||||
|
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
||||||
|
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
|
||||||
|
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
|
||||||
|
FROM blog_posts bp
|
||||||
|
LEFT JOIN categories c ON bp.category_id = c.id
|
||||||
|
ORDER BY bp.created_at DESC");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstAsync<int>(
|
||||||
|
@"INSERT INTO blog_posts (title, content, slug, category_id, tags, author_id, published_at,
|
||||||
|
seo_title, seo_description, thumbnail_url, is_published, created_at, updated_at)
|
||||||
|
VALUES (@Title, @Content, @Slug, @CategoryId, @Tags, @AuthorId, @PublishedAt,
|
||||||
|
@SeoTitle, @SeoDescription, @ThumbnailUrl, @IsPublished, NOW(), NOW())
|
||||||
|
RETURNING id",
|
||||||
|
post);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(BlogPost post, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"UPDATE blog_posts
|
||||||
|
SET title = @Title, content = @Content, slug = @Slug, category_id = @CategoryId,
|
||||||
|
tags = @Tags, author_id = @AuthorId, published_at = @PublishedAt,
|
||||||
|
seo_title = @SeoTitle, seo_description = @SeoDescription,
|
||||||
|
thumbnail_url = @ThumbnailUrl, is_published = @IsPublished, updated_at = NOW()
|
||||||
|
WHERE id = @Id",
|
||||||
|
post);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync("DELETE FROM blog_posts WHERE id = @Id", new { Id = id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync("UPDATE blog_posts SET view_count = view_count + 1 WHERE id = @Id", new { Id = id });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace TaxBaik.Infrastructure.Repositories;
|
||||||
|
|
||||||
|
using Dapper;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class CategoryRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), ICategoryRepository
|
||||||
|
{
|
||||||
|
public async Task<IEnumerable<Category>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<Category>(
|
||||||
|
"SELECT id, name, slug, sort_order FROM categories ORDER BY sort_order");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Category?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstOrDefaultAsync<Category>(
|
||||||
|
"SELECT id, name, slug, sort_order FROM categories WHERE slug = @Slug",
|
||||||
|
new { Slug = slug });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
namespace TaxBaik.Infrastructure.Repositories;
|
||||||
|
|
||||||
|
using Dapper;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class InquiryRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IInquiryRepository
|
||||||
|
{
|
||||||
|
public async Task<int> CreateAsync(Inquiry inquiry, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstAsync<int>(
|
||||||
|
@"INSERT INTO inquiries (name, phone, email, service_type, message, status, ip_address, created_at)
|
||||||
|
VALUES (@Name, @Phone, @Email, @ServiceType, @Message, @Status, @IpAddress, NOW())
|
||||||
|
RETURNING id",
|
||||||
|
inquiry);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Inquiry?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstOrDefaultAsync<Inquiry>(
|
||||||
|
"SELECT id, name, phone, email, service_type, message, status, ip_address, created_at FROM inquiries WHERE id = @Id",
|
||||||
|
new { Id = id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(IEnumerable<Inquiry> Items, int Total)> GetPagedAsync(
|
||||||
|
int page, int pageSize, string? status = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
var offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
using var reader = await conn.QueryMultipleAsync(
|
||||||
|
@"SELECT id, name, phone, email, service_type, message, status, ip_address, created_at
|
||||||
|
FROM inquiries
|
||||||
|
WHERE @Status::text IS NULL OR status = @Status
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT @PageSize OFFSET @Offset;
|
||||||
|
|
||||||
|
SELECT COUNT(*) FROM inquiries
|
||||||
|
WHERE @Status::text IS NULL OR status = @Status;",
|
||||||
|
new { Status = status, PageSize = pageSize, Offset = offset });
|
||||||
|
|
||||||
|
var items = (await reader.ReadAsync<Inquiry>()).ToList();
|
||||||
|
var total = await reader.ReadFirstAsync<int>();
|
||||||
|
|
||||||
|
return (items, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync("UPDATE inquiries SET status = @Status WHERE id = @Id", new { Id = id, Status = status });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,12 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\TaxBaik.Domain\TaxBaik.Domain.csproj" />
|
<ProjectReference Include="..\TaxBaik.Domain\TaxBaik.Domain.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Dapper" Version="2.1.15" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Npgsql" Version="8.0.1" />
|
||||||
|
</ItemGroup>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
Reference in New Issue
Block a user