diff --git a/TaxBaik.Domain/Entities/AdminUser.cs b/TaxBaik.Domain/Entities/AdminUser.cs new file mode 100644 index 0000000..9f3255d --- /dev/null +++ b/TaxBaik.Domain/Entities/AdminUser.cs @@ -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; } +} diff --git a/TaxBaik.Domain/Entities/BlogPost.cs b/TaxBaik.Domain/Entities/BlogPost.cs new file mode 100644 index 0000000..71a542c --- /dev/null +++ b/TaxBaik.Domain/Entities/BlogPost.cs @@ -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; } +} diff --git a/TaxBaik.Domain/Entities/Category.cs b/TaxBaik.Domain/Entities/Category.cs new file mode 100644 index 0000000..274b199 --- /dev/null +++ b/TaxBaik.Domain/Entities/Category.cs @@ -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; } +} diff --git a/TaxBaik.Domain/Entities/Inquiry.cs b/TaxBaik.Domain/Entities/Inquiry.cs new file mode 100644 index 0000000..8da2378 --- /dev/null +++ b/TaxBaik.Domain/Entities/Inquiry.cs @@ -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; } +} diff --git a/TaxBaik.Domain/Enums/InquiryStatus.cs b/TaxBaik.Domain/Enums/InquiryStatus.cs new file mode 100644 index 0000000..4041f4e --- /dev/null +++ b/TaxBaik.Domain/Enums/InquiryStatus.cs @@ -0,0 +1,9 @@ +namespace TaxBaik.Domain.Enums; + +public enum InquiryStatus +{ + New = 0, // 새로운 문의 + Contacted = 1, // 연락 완료 + Contracted = 2, // 계약 체결 + Closed = 3 // 종료 +} diff --git a/TaxBaik.Domain/Enums/ServiceType.cs b/TaxBaik.Domain/Enums/ServiceType.cs new file mode 100644 index 0000000..30e091e --- /dev/null +++ b/TaxBaik.Domain/Enums/ServiceType.cs @@ -0,0 +1,11 @@ +namespace TaxBaik.Domain.Enums; + +public enum ServiceType +{ + Bookkeeping = 0, // 기장 + IncomeTax = 1, // 종소세 + VatTax = 2, // 부가세 + CapitalGainsTax = 3, // 양도세 + GiftInheritanceTax = 4,// 증여·상속 + Other = 5 // 기타 +} diff --git a/TaxBaik.Domain/Interfaces/IBlogPostRepository.cs b/TaxBaik.Domain/Interfaces/IBlogPostRepository.cs new file mode 100644 index 0000000..d55fa06 --- /dev/null +++ b/TaxBaik.Domain/Interfaces/IBlogPostRepository.cs @@ -0,0 +1,16 @@ +namespace TaxBaik.Domain.Interfaces; + +using TaxBaik.Domain.Entities; + +public interface IBlogPostRepository +{ + Task GetByIdAsync(int id, CancellationToken cancellationToken = default); + Task GetBySlugAsync(string slug, CancellationToken cancellationToken = default); + Task<(IEnumerable Items, int Total)> GetPublishedPagedAsync( + int page, int pageSize, int? categoryId = null, CancellationToken cancellationToken = default); + Task> GetAllForAdminAsync(CancellationToken cancellationToken = default); + Task 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); +} diff --git a/TaxBaik.Domain/Interfaces/ICategoryRepository.cs b/TaxBaik.Domain/Interfaces/ICategoryRepository.cs new file mode 100644 index 0000000..4250746 --- /dev/null +++ b/TaxBaik.Domain/Interfaces/ICategoryRepository.cs @@ -0,0 +1,9 @@ +namespace TaxBaik.Domain.Interfaces; + +using TaxBaik.Domain.Entities; + +public interface ICategoryRepository +{ + Task> GetAllAsync(CancellationToken cancellationToken = default); + Task GetBySlugAsync(string slug, CancellationToken cancellationToken = default); +} diff --git a/TaxBaik.Domain/Interfaces/IDbConnectionFactory.cs b/TaxBaik.Domain/Interfaces/IDbConnectionFactory.cs new file mode 100644 index 0000000..d72344b --- /dev/null +++ b/TaxBaik.Domain/Interfaces/IDbConnectionFactory.cs @@ -0,0 +1,8 @@ +namespace TaxBaik.Domain.Interfaces; + +using System.Data; + +public interface IDbConnectionFactory +{ + IDbConnection CreateConnection(); +} diff --git a/TaxBaik.Domain/Interfaces/IInquiryRepository.cs b/TaxBaik.Domain/Interfaces/IInquiryRepository.cs new file mode 100644 index 0000000..aef8e3a --- /dev/null +++ b/TaxBaik.Domain/Interfaces/IInquiryRepository.cs @@ -0,0 +1,12 @@ +namespace TaxBaik.Domain.Interfaces; + +using TaxBaik.Domain.Entities; + +public interface IInquiryRepository +{ + Task CreateAsync(Inquiry inquiry, CancellationToken cancellationToken = default); + Task GetByIdAsync(int id, CancellationToken cancellationToken = default); + Task<(IEnumerable Items, int Total)> GetPagedAsync( + int page, int pageSize, string? status = null, CancellationToken cancellationToken = default); + Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default); +} diff --git a/TaxBaik.Infrastructure/Data/DbConnectionFactory.cs b/TaxBaik.Infrastructure/Data/DbConnectionFactory.cs new file mode 100644 index 0000000..4cc6230 --- /dev/null +++ b/TaxBaik.Infrastructure/Data/DbConnectionFactory.cs @@ -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); +} diff --git a/TaxBaik.Infrastructure/DependencyInjection.cs b/TaxBaik.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..9948050 --- /dev/null +++ b/TaxBaik.Infrastructure/DependencyInjection.cs @@ -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(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/TaxBaik.Infrastructure/Repositories/BaseRepository.cs b/TaxBaik.Infrastructure/Repositories/BaseRepository.cs new file mode 100644 index 0000000..e5b5049 --- /dev/null +++ b/TaxBaik.Infrastructure/Repositories/BaseRepository.cs @@ -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(); +} diff --git a/TaxBaik.Infrastructure/Repositories/BlogPostRepository.cs b/TaxBaik.Infrastructure/Repositories/BlogPostRepository.cs new file mode 100644 index 0000000..46c86ab --- /dev/null +++ b/TaxBaik.Infrastructure/Repositories/BlogPostRepository.cs @@ -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 GetByIdAsync(int id, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryFirstOrDefaultAsync( + @"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 GetBySlugAsync(string slug, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryFirstOrDefaultAsync( + @"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 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()).ToList(); + var total = await reader.ReadFirstAsync(); + + return (items, total); + } + + public async Task> GetAllForAdminAsync(CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryAsync( + @"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 CreateAsync(BlogPost post, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryFirstAsync( + @"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 }); + } +} diff --git a/TaxBaik.Infrastructure/Repositories/CategoryRepository.cs b/TaxBaik.Infrastructure/Repositories/CategoryRepository.cs new file mode 100644 index 0000000..bad0949 --- /dev/null +++ b/TaxBaik.Infrastructure/Repositories/CategoryRepository.cs @@ -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> GetAllAsync(CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryAsync( + "SELECT id, name, slug, sort_order FROM categories ORDER BY sort_order"); + } + + public async Task GetBySlugAsync(string slug, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryFirstOrDefaultAsync( + "SELECT id, name, slug, sort_order FROM categories WHERE slug = @Slug", + new { Slug = slug }); + } +} diff --git a/TaxBaik.Infrastructure/Repositories/InquiryRepository.cs b/TaxBaik.Infrastructure/Repositories/InquiryRepository.cs new file mode 100644 index 0000000..d84a0b9 --- /dev/null +++ b/TaxBaik.Infrastructure/Repositories/InquiryRepository.cs @@ -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 CreateAsync(Inquiry inquiry, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryFirstAsync( + @"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 GetByIdAsync(int id, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryFirstOrDefaultAsync( + "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 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()).ToList(); + var total = await reader.ReadFirstAsync(); + + 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 }); + } +} diff --git a/TaxBaik.Infrastructure/TaxBaik.Infrastructure.csproj b/TaxBaik.Infrastructure/TaxBaik.Infrastructure.csproj index c342112..4f138d2 100644 --- a/TaxBaik.Infrastructure/TaxBaik.Infrastructure.csproj +++ b/TaxBaik.Infrastructure/TaxBaik.Infrastructure.csproj @@ -2,6 +2,12 @@ + + + + + + net8.0 enable