diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e3613b6 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +ASPNETCORE_ENVIRONMENT=Development +ASPNETCORE_URLS=http://0.0.0.0:5001 +ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=change-me +Jwt__SecretKey=dev-secret-key-change-in-production-min-32-chars! +Admin__PasswordResetToken=change-this-reset-token diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 91a9930..d0b9c98 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -26,6 +26,9 @@ jobs: dotnet clean TaxBaik.sln -c Release dotnet build TaxBaik.sln -c Release --no-restore + - name: Test solution + run: dotnet test TaxBaik.sln -c Release --no-build + - name: Publish Web (통합 앱) run: dotnet publish TaxBaik.Web/ -c Release -o ./publish --no-restore @@ -52,20 +55,23 @@ jobs: DEPLOY_USER="${{ secrets.DEPLOY_USER }}" echo "=== Deploying TaxBaik v$(git rev-parse --short HEAD) ===" - mkdir -p "$DEPLOY_DIR" - cp -r ./publish/* "$DEPLOY_DIR/" - ln -sfn "$DEPLOY_DIR" "$DEPLOY_HOME/taxbaik_active" - echo "✓ Deployed to $DEPLOY_DIR" - - # 서버에서 systemd로 서비스를 재시작 - echo "=== Restarting service on server ===" mkdir -p ~/.ssh printf '%s' "${{ secrets.DEPLOY_SSH_KEY_B64 }}" | base64 -d > ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519 ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true - ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" "sudo systemctl restart taxbaik" + + tar -czf taxbaik_publish.tgz -C ./publish . + scp -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes taxbaik_publish.tgz "$DEPLOY_USER@$DEPLOY_HOST:/tmp/taxbaik_publish_${TIMESTAMP}.tgz" + ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" " + set -e + mkdir -p '$DEPLOY_DIR' + tar -xzf '/tmp/taxbaik_publish_${TIMESTAMP}.tgz' -C '$DEPLOY_DIR' + rm -f '/tmp/taxbaik_publish_${TIMESTAMP}.tgz' + ln -sfn '$DEPLOY_DIR' '$DEPLOY_HOME/taxbaik_active' + sudo systemctl restart taxbaik + " sleep 5 - echo "✓ Deployment complete" + echo "✓ Deployed to $DEPLOY_HOST:$DEPLOY_DIR" - name: Verify deployment run: | diff --git a/CLAUDE.md b/CLAUDE.md index f1aa0e8..13d3015 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,7 @@ TaxBaik.Web ASP.NET Core 앱 (포트 5001) - **Infrastructure**: DB 접근, Dapper 구현체, 마이그레이션 실행 - **Application**: 서비스, DTO 매핑, 비즈니스 워크플로우 - **Web (Pages/)**: 공개 홈페이지 (SEO 최적화, Razor Pages SSR) -- **Web (Components/Admin)**: 관리자 백오피스 (실시간 UI, Blazor Server) +- **Web (Components/Admin)**: 관리자 백오피스 (Blazor Server, 사용자 액션 기반 갱신) - **Web (Services/)**: 인증(JWT), 블로그, 문의 관리 등 ### 2.3 기술 결정 이유 @@ -56,8 +56,9 @@ TaxBaik.Web ASP.NET Core 앱 (포트 5001) - Blazor는 초기 응답이 shell HTML → SEO 불리 (블로그는 검색 유입이 핵심) **왜 Blazor Server (관리자)인가?** -- 관리자는 SEO 불필요 → WebSocket으로 실시간 UI 업데이트 가능 -- 복잡한 관리 UI를 쉽게 구현 +- 관리자는 SEO 불필요 → 복잡한 관리 UI를 .NET 컴포넌트로 구현 가능 +- 데이터 변경 시 전체 사용자에게 push/broadcast하는 기능은 기본값으로 두지 않는다. +- 관리자 화면은 일반 웹페이지처럼 조회/저장/상태 변경 요청 시점에만 데이터를 갱신한다. **왜 단일 앱 (통합 Web)인가?** - 공개 사이트와 관리자 화면을 같은 호스트와 PathBase에서 운영하면 라우팅과 인증 구성이 단순함 @@ -325,7 +326,7 @@ location /taxbaik { } ``` -**참고**: 단일 `/taxbaik` 블록이 공개 사이트와 관리자(Blazor WebSocket)를 모두 처리합니다. 운영은 `5001` 통합 앱 기준이며, 설정 반영은 CI 배포로만 수행한다. +**참고**: 단일 `/taxbaik` 블록이 공개 사이트와 관리자 Blazor 회로를 모두 처리합니다. 운영은 `5001` 통합 앱 기준이며, 설정 반영은 CI 배포로만 수행한다. **Nginx 보안**: - `Upgrade` 헤더는 Blazor WebSocket 경로에만 허용하고, 필요 없는 location에는 넣지 않는다. diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md index 04d0a18..3573ec1 100644 --- a/DEPLOYMENT_GUIDE.md +++ b/DEPLOYMENT_GUIDE.md @@ -58,13 +58,14 @@ sudo systemctl reload nginx - `DEPLOY_HOST`: `178.104.200.7` - `DEPLOY_SSH_KEY_B64`: base64로 인코딩한 SSH 개인키 - `TAXBAIK_ADMIN_TEST_PASSWORD`: 배포 검증용 관리자 비밀번호 + - `Admin__PasswordResetToken`: 관리자 비밀번호 재설정 API용 서버 비밀값 2. 배포 워크플로우는 자동으로 실행: ``` master 브랜치 push → build → publish → restart ``` -수동 배포는 사용하지 않습니다. 배포 이슈는 Gitea Actions 로그로 해결합니다. +수동 배포는 비상 롤백 외에는 사용하지 않습니다. 배포 이슈는 Gitea Actions 로그로 해결합니다. ## 마이그레이션 자동 실행 @@ -187,7 +188,7 @@ V003 마이그레이션에서 5개 포스트 자동 생성: - [ ] SSL 인증서 적용 (Let's Encrypt) - [ ] 도메인 연결 (현재는 IP 기반) -- [ ] 관리자 인증 로직 구현 (현재는 플레이스홀더) -- [ ] 블로그 포스트 CRUD 기능 완성 +- [ ] 관리자 인증 보안 고도화 (rate limit, 비밀번호 교체 절차) +- [ ] 블로그 포스트 수정 화면 완성 - [ ] Naver/Google Search Console 등록 - [ ] 운영 관리자 비밀번호를 초기 시드값에서 교체하고 `TAXBAIK_ADMIN_TEST_PASSWORD` 갱신 diff --git a/Dockerfile.admin b/Dockerfile.admin index 626a5e5..e389e49 100644 --- a/Dockerfile.admin +++ b/Dockerfile.admin @@ -1,9 +1,9 @@ -FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine WORKDIR /app -COPY ./publish/admin/ . +COPY ./publish/ . -EXPOSE 5002 +EXPOSE 5001 -ENTRYPOINT ["dotnet", "TaxBaik.Admin.dll"] +ENTRYPOINT ["dotnet", "TaxBaik.Web.dll"] diff --git a/Dockerfile.web b/Dockerfile.web index 8b8df8d..e389e49 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -1,8 +1,8 @@ -FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine WORKDIR /app -COPY ./publish/web/ . +COPY ./publish/ . EXPOSE 5001 diff --git a/PRODUCTION_CHECKLIST.md b/PRODUCTION_CHECKLIST.md index c5b485c..3420227 100644 --- a/PRODUCTION_CHECKLIST.md +++ b/PRODUCTION_CHECKLIST.md @@ -1,7 +1,7 @@ # TaxBaik 프로덕션 배포 체크리스트 **작성일**: 2026-06-26 -**상태**: 배포 준비 완료 +**상태**: 운영 기준 정비 중 **대상**: 178.104.200.7 (Ubuntu 26.04) --- @@ -11,7 +11,7 @@ ### 1. 코드 검증 - [ ] `dotnet build TaxBaik.sln -c Release` 성공 - [ ] 모든 컴파일 오류 0개 -- [ ] 경고 무시 (NuGet 보안 정보만) +- [ ] 경고 0개 유지 ### 2. Git 상태 확인 - [ ] 모든 변경사항 커밋됨 @@ -20,12 +20,10 @@ ### 3. 발행 검증 ```bash -dotnet publish TaxBaik.Web -c Release -o ./publish/web -dotnet publish TaxBaik.Admin -c Release -o ./publish/admin +dotnet publish TaxBaik.Web -c Release -o ./publish # 확인 -ls -lh ./publish/web/TaxBaik.Web.dll -ls -lh ./publish/admin/TaxBaik.Admin.dll +ls -lh ./publish/TaxBaik.Web.dll ``` --- @@ -47,7 +45,7 @@ ssh kjh2064@178.104.200.7 'bash ~/SERVER_SETUP.sh' # 3. 배포 디렉토리 생성 (자동으로 진행됨) # ~/deployments/ # ~/taxbaik_active -# ~/taxbaik_admin_active +# ~/taxbaik_active ``` ### 2단계: 첫 배포 (수동) @@ -61,18 +59,14 @@ export DEPLOY_USER="kjh2064" export DEPLOY_HOST="178.104.200.7" # 배포 -rsync -avz --delete ./publish/web/ \ +rsync -avz --delete ./publish/ \ $DEPLOY_USER@$DEPLOY_HOST:~/deployments/taxbaik_${TIMESTAMP}/ -rsync -avz --delete ./publish/admin/ \ - $DEPLOY_USER@$DEPLOY_HOST:~/deployments/taxbaik_admin_${TIMESTAMP}/ - # 심링크 변경 및 시작 ssh $DEPLOY_USER@$DEPLOY_HOST << EOF ln -sfn ~/deployments/taxbaik_${TIMESTAMP} ~/taxbaik_active -ln -sfn ~/deployments/taxbaik_admin_${TIMESTAMP} ~/taxbaik_admin_active -sudo systemctl start taxbaik taxbaik-admin -sudo systemctl status taxbaik taxbaik-admin +sudo systemctl start taxbaik +sudo systemctl status taxbaik EOF ``` @@ -95,13 +89,13 @@ EOF ssh kjh2064@178.104.200.7 # 서비스 상태 -sudo systemctl status taxbaik taxbaik-admin +sudo systemctl status taxbaik # 프로세스 확인 ps aux | grep TaxBaik # 포트 확인 -netstat -tlnp | grep -E '5001|5002' +netstat -tlnp | grep -E '5001' ``` ### 2. 엔드포인트 테스트 @@ -145,9 +139,6 @@ SELECT COUNT(*) FROM categories; # 웹 서비스 journalctl -u taxbaik -n 50 -# 관리자 서비스 -journalctl -u taxbaik-admin -n 50 - # Nginx sudo tail -f /var/log/nginx/access.log | grep taxbaik ``` @@ -197,7 +188,7 @@ curl -I -H "Accept-Encoding: gzip" http://178.104.200.7/taxbaik/ | grep -i encod - [ ] 로그인 폼 표시 - [ ] 초기 계정 로그인 - username: `admin` - - password: `admin123` + - password: `` #### 대시보드 - [ ] 로그인 후 대시보드 로드 @@ -242,8 +233,8 @@ curl -I -H "Accept-Encoding: gzip" http://178.104.200.7/taxbaik/ | grep -i encod # 터미널 1: 웹 서비스 로그 ssh kjh2064@178.104.200.7 'journalctl -u taxbaik -f' -# 터미널 2: 관리자 서비스 로그 -ssh kjh2064@178.104.200.7 'journalctl -u taxbaik-admin -f' +# 터미널 2: 통합 서비스 로그 +ssh kjh2064@178.104.200.7 'journalctl -u taxbaik -f' # 터미널 3: Nginx 로그 ssh kjh2064@178.104.200.7 'sudo tail -f /var/log/nginx/access.log | grep taxbaik' @@ -261,7 +252,7 @@ ssh kjh2064@178.104.200.7 'watch -n 1 "ps aux | grep TaxBaik"' # 내용: #!/bin/bash curl -f http://127.0.0.1:5001/taxbaik || systemctl restart taxbaik -curl -f http://127.0.0.1:5002/taxbaik/admin || systemctl restart taxbaik-admin +curl -f http://127.0.0.1:5001/taxbaik/admin/login || systemctl restart taxbaik ``` --- @@ -279,8 +270,8 @@ git push origin master # 2. Gitea Actions가 자동으로 배포 # 또는 수동 배포: TIMESTAMP=$(date +%Y%m%d_%H%M%S) -dotnet publish TaxBaik.Web -c Release -o ./publish/web -rsync -avz ./publish/web/ kjh2064@178.104.200.7:~/deployments/taxbaik_${TIMESTAMP}/ +dotnet publish TaxBaik.Web -c Release -o ./publish +rsync -avz ./publish/ kjh2064@178.104.200.7:~/deployments/taxbaik_${TIMESTAMP}/ ssh kjh2064@178.104.200.7 "ln -sfn ~/deployments/taxbaik_${TIMESTAMP} ~/taxbaik_active && sudo systemctl restart taxbaik" ``` @@ -306,7 +297,7 @@ EOF - [ ] 모든 엔드포인트 HTTP 200 응답 - [ ] 데이터베이스 마이그레이션 완료 (schema_migrations 테이블 확인) - [ ] 초기 5개 블로그 포스트 DB에 존재 -- [ ] 로그인 기능 정상 (admin/admin123) +- [ ] 로그인 기능 정상 (admin/) - [ ] 문의 폼 제출 → DB 저장 확인 - [ ] Nginx 프록시 정상 작동 - [ ] 응답 gzip 압축 확인 diff --git a/README.md b/README.md index cce0e55..3e03e14 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,13 @@ TaxBaik는 세무사 백원숙의 전문성을 온라인으로 표현하기 위 | 계층 | 기술 | |-----|------| -| **백엔드** | ASP.NET Core 8, C# | +| **백엔드** | ASP.NET Core 10, C# | | **공개 사이트** | Razor Pages (SSR) | | **관리자** | Blazor Server + MudBlazor | | **데이터베이스** | PostgreSQL 18.4 | | **ORM** | Dapper | | **리버스 프록시** | Nginx | -| **배포** | Gitea Actions CI/CD, systemd | +| **배포** | Gitea Actions CI/CD, systemd 단일 서비스 | | **아키텍처** | DDD (Domain-Driven Design), Layered Architecture | --- @@ -103,7 +103,7 @@ TaxBaik/ ### 개발 환경 설정 **필수 요구사항:** -- .NET 8.0 SDK +- .NET 10.0 SDK - PostgreSQL 18.4 - Git @@ -151,16 +151,18 @@ master 브랜치에 푸시하면 자동으로: 1. ✅ .NET 빌드 (Release) 2. ✅ 단위 테스트 실행 3. ✅ `TaxBaik.Web` 게시 -4. ✅ 서버 반영 및 서비스 재시작 -5. ✅ `/taxbaik/`, `/taxbaik/admin/login`, `/taxbaik/api/auth/login` 헬스 체크 +4. ✅ 원격 서버 배포 디렉토리 업로드 및 `taxbaik_active` 심링크 교체 +5. ✅ systemd `taxbaik` 단일 서비스 재시작 +6. ✅ `/taxbaik/`, `/taxbaik/admin/login`, `/taxbaik/api/auth/login` 헬스 체크 **필수 Gitea Secrets 설정:** - `DEPLOY_USER`: kjh2064 - `DEPLOY_HOST`: 178.104.200.7 - `DEPLOY_SSH_KEY_B64`: base64로 인코딩한 SSH 개인키 - `TAXBAIK_ADMIN_TEST_PASSWORD`: 배포 검증용 관리자 비밀번호 +- `Admin__PasswordResetToken`: 관리자 비밀번호 재설정 API용 서버 비밀값 -수동 배포는 사용하지 않습니다. 실패 시 [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)의 CI 점검 절차를 따릅니다. +수동 배포는 비상 롤백 절차 외에는 사용하지 않습니다. 실패 시 [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)의 CI 점검 절차를 따릅니다. --- diff --git a/TaxBaik.Application.Tests/BlogServiceTests.cs b/TaxBaik.Application.Tests/BlogServiceTests.cs new file mode 100644 index 0000000..831fe7a --- /dev/null +++ b/TaxBaik.Application.Tests/BlogServiceTests.cs @@ -0,0 +1,79 @@ +namespace TaxBaik.Application.Tests; + +using TaxBaik.Application.DTOs; +using TaxBaik.Application.Services; +using TaxBaik.Domain.Entities; +using TaxBaik.Domain.Interfaces; +using Xunit; + +public class BlogServiceTests +{ + [Fact] + public async Task CreateAsync_WhenPublishedWithoutSeoTitle_ThrowsValidationException() + { + var service = new BlogService(new FakeBlogPostRepository()); + + await Assert.ThrowsAsync(() => service.CreateAsync(new CreateBlogPostDto + { + Title = "테스트 포스트", + Content = "본문", + SeoDescription = "설명", + IsPublished = true + })); + } + + [Fact] + public async Task CreateAsync_WhenTitleDuplicates_GeneratesUniqueSlug() + { + var repository = new FakeBlogPostRepository + { + Posts = + [ + new BlogPost { Id = 1, Title = "같은 제목", Content = "본문", Slug = "같은-제목" } + ] + }; + var service = new BlogService(repository); + + var post = await service.CreateAsync(new CreateBlogPostDto + { + Title = "같은 제목", + Content = "본문" + }); + + Assert.Equal("같은-제목-2", post.Slug); + } + + private sealed class FakeBlogPostRepository : IBlogPostRepository + { + public List Posts { get; init; } = []; + + public Task GetByIdAsync(int id, CancellationToken cancellationToken = default) => + Task.FromResult(Posts.FirstOrDefault(x => x.Id == id)); + + public Task GetBySlugAsync(string slug, CancellationToken cancellationToken = default) => + Task.FromResult(Posts.FirstOrDefault(x => x.Slug == slug && x.IsPublished)); + + public Task<(IEnumerable Items, int Total)> GetPublishedPagedAsync( + int page, int pageSize, int? categoryId = null, CancellationToken cancellationToken = default) + { + var items = Posts.Where(x => x.IsPublished).ToList(); + return Task.FromResult<(IEnumerable, int)>((items, items.Count)); + } + + public Task> GetAllForAdminAsync(CancellationToken cancellationToken = default) => + Task.FromResult>(Posts); + + public Task CreateAsync(BlogPost post, CancellationToken cancellationToken = default) + { + post.Id = Posts.Count + 1; + Posts.Add(post); + return Task.FromResult(post.Id); + } + + public Task UpdateAsync(BlogPost post, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task DeleteAsync(int id, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default) => Task.CompletedTask; + } +} diff --git a/TaxBaik.Application.Tests/InquiryServiceTests.cs b/TaxBaik.Application.Tests/InquiryServiceTests.cs new file mode 100644 index 0000000..44d2cda --- /dev/null +++ b/TaxBaik.Application.Tests/InquiryServiceTests.cs @@ -0,0 +1,59 @@ +namespace TaxBaik.Application.Tests; + +using TaxBaik.Application.Services; +using TaxBaik.Domain.Entities; +using TaxBaik.Domain.Interfaces; +using Xunit; + +public class InquiryServiceTests +{ + [Fact] + public async Task UpdateStatusAsync_WhenStatusIsInvalid_ThrowsValidationException() + { + var service = new InquiryService(new FakeInquiryRepository()); + + await Assert.ThrowsAsync(() => service.UpdateStatusAsync(1, "invalid")); + } + + [Fact] + public async Task SubmitAsync_StoresEmailAndNewStatus() + { + var repository = new FakeInquiryRepository(); + var service = new InquiryService(repository); + + await service.SubmitAsync("홍길동", "010-1234-5678", "기장", "문의합니다.", "user@example.com"); + + Assert.Equal("user@example.com", repository.Inquiries.Single().Email); + Assert.Equal("new", repository.Inquiries.Single().Status); + } + + private sealed class FakeInquiryRepository : IInquiryRepository + { + public List Inquiries { get; } = []; + + public Task CreateAsync(Inquiry inquiry, CancellationToken cancellationToken = default) + { + inquiry.Id = Inquiries.Count + 1; + Inquiries.Add(inquiry); + return Task.FromResult(inquiry.Id); + } + + public Task GetByIdAsync(int id, CancellationToken cancellationToken = default) => + Task.FromResult(Inquiries.FirstOrDefault(x => x.Id == id)); + + public Task<(IEnumerable Items, int Total)> GetPagedAsync( + int page, int pageSize, string? status = null, CancellationToken cancellationToken = default) + { + var items = status == null ? Inquiries : Inquiries.Where(x => x.Status == status).ToList(); + return Task.FromResult<(IEnumerable, int)>((items, items.Count())); + } + + public Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default) + { + var inquiry = Inquiries.FirstOrDefault(x => x.Id == id); + if (inquiry != null) + inquiry.Status = status; + return Task.CompletedTask; + } + } +} diff --git a/TaxBaik.Application.Tests/TaxBaik.Application.Tests.csproj b/TaxBaik.Application.Tests/TaxBaik.Application.Tests.csproj new file mode 100644 index 0000000..2bbfbc3 --- /dev/null +++ b/TaxBaik.Application.Tests/TaxBaik.Application.Tests.csproj @@ -0,0 +1,21 @@ + + + net10.0 + enable + enable + false + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/TaxBaik.Application/DTOs/CreateBlogPostDto.cs b/TaxBaik.Application/DTOs/CreateBlogPostDto.cs index 777cc61..0e4d2bc 100644 --- a/TaxBaik.Application/DTOs/CreateBlogPostDto.cs +++ b/TaxBaik.Application/DTOs/CreateBlogPostDto.cs @@ -2,13 +2,13 @@ namespace TaxBaik.Application.DTOs; public class CreateBlogPostDto { - public string Title { get; set; } - public string Content { get; set; } + public required string Title { get; set; } + public required string Content { get; set; } public int? CategoryId { get; set; } - public string Tags { get; set; } - public string SeoTitle { get; set; } - public string SeoDescription { get; set; } - public string ThumbnailUrl { get; set; } + public string? Tags { get; set; } + public string? SeoTitle { get; set; } + public string? SeoDescription { get; set; } + public string? ThumbnailUrl { get; set; } public bool IsPublished { get; set; } - public int AuthorId { get; set; } + public int? AuthorId { get; set; } } diff --git a/TaxBaik.Application/Services/BlogService.cs b/TaxBaik.Application/Services/BlogService.cs index 7d65791..1afaf31 100644 --- a/TaxBaik.Application/Services/BlogService.cs +++ b/TaxBaik.Application/Services/BlogService.cs @@ -12,7 +12,7 @@ public class BlogService(IBlogPostRepository repository) public async Task<(IEnumerable, int)> GetPublishedPagedAsync( int page, int pageSize, int? categoryId = null, CancellationToken ct = default) => - await repository.GetPublishedPagedAsync(page, pageSize, categoryId, ct); + await repository.GetPublishedPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), categoryId, ct); public async Task> GetAllAsync(CancellationToken ct = default) => await repository.GetAllForAdminAsync(ct); @@ -22,8 +22,11 @@ public class BlogService(IBlogPostRepository repository) public async Task CreateAsync(BlogPost post, CancellationToken ct = default) { - post.Slug = GenerateSlug(post.Title); - post.IsPublished = false; + 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; return await repository.CreateAsync(post, ct); } @@ -65,6 +68,10 @@ public class BlogService(IBlogPostRepository repository) 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; @@ -81,6 +88,50 @@ public class BlogService(IBlogPostRepository repository) 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)); + } } diff --git a/TaxBaik.Application/Services/InquiryService.cs b/TaxBaik.Application/Services/InquiryService.cs index c990674..41df299 100644 --- a/TaxBaik.Application/Services/InquiryService.cs +++ b/TaxBaik.Application/Services/InquiryService.cs @@ -2,6 +2,7 @@ namespace TaxBaik.Application.Services; using System.Text.RegularExpressions; using TaxBaik.Domain.Entities; +using TaxBaik.Domain.Enums; using TaxBaik.Domain.Interfaces; public class InquiryService(IInquiryRepository repository) @@ -10,7 +11,7 @@ public class InquiryService(IInquiryRepository repository) public async Task SubmitAsync( string name, string phone, string serviceType, string message, - string? ipAddress = null, CancellationToken ct = default) + string? email = null, string? ipAddress = null, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(name)) throw new ValidationException("이름을 입력하세요."); @@ -25,10 +26,11 @@ public class InquiryService(IInquiryRepository repository) { Name = name.Trim(), Phone = phone.Trim(), + Email = string.IsNullOrWhiteSpace(email) ? null : email.Trim(), ServiceType = serviceType ?? "기타", Message = message.Trim(), IpAddress = ipAddress, - Status = "new", + Status = InquiryStatusMapper.ToStorageValue(InquiryStatus.New), CreatedAt = DateTime.UtcNow }; @@ -40,10 +42,30 @@ public class InquiryService(IInquiryRepository repository) public async Task<(IEnumerable, int)> GetPagedAsync( int page, int pageSize, string? status = null, CancellationToken ct = default) => - await repository.GetPagedAsync(page, pageSize, status, ct); + await repository.GetPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), NormalizeOptionalStatus(status), ct); - public async Task UpdateStatusAsync(int id, string status, CancellationToken ct = default) => - await repository.UpdateStatusAsync(id, status, ct); + public async Task UpdateStatusAsync(int id, string status, CancellationToken ct = default) + { + if (!InquiryStatusMapper.TryParse(status, out var parsed)) + throw new ValidationException("지원하지 않는 문의 상태입니다."); + + await repository.UpdateStatusAsync(id, InquiryStatusMapper.ToStorageValue(parsed), ct); + } + + private static int NormalizePage(int page) => Math.Max(1, page); + + private static int NormalizePageSize(int pageSize) => Math.Clamp(pageSize, 1, 100); + + private static string? NormalizeOptionalStatus(string? status) + { + if (string.IsNullOrWhiteSpace(status)) + return null; + + if (!InquiryStatusMapper.TryParse(status, out var parsed)) + throw new ValidationException("지원하지 않는 문의 상태입니다."); + + return InquiryStatusMapper.ToStorageValue(parsed); + } } public class ValidationException : Exception diff --git a/TaxBaik.Application/Services/InquiryStatusMapper.cs b/TaxBaik.Application/Services/InquiryStatusMapper.cs new file mode 100644 index 0000000..912d741 --- /dev/null +++ b/TaxBaik.Application/Services/InquiryStatusMapper.cs @@ -0,0 +1,27 @@ +namespace TaxBaik.Application.Services; + +using TaxBaik.Domain.Enums; + +public static class InquiryStatusMapper +{ + public static string ToStorageValue(InquiryStatus status) => status switch + { + InquiryStatus.New => "new", + InquiryStatus.Contacted => "contacted", + InquiryStatus.Completed => "completed", + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null) + }; + + public static bool TryParse(string? value, out InquiryStatus status) + { + status = value?.Trim().ToLowerInvariant() switch + { + "new" => InquiryStatus.New, + "contacted" => InquiryStatus.Contacted, + "completed" => InquiryStatus.Completed, + _ => default + }; + + return value?.Trim().ToLowerInvariant() is "new" or "contacted" or "completed"; + } +} diff --git a/TaxBaik.Domain/Entities/Inquiry.cs b/TaxBaik.Domain/Entities/Inquiry.cs index 8da2378..c839ef7 100644 --- a/TaxBaik.Domain/Entities/Inquiry.cs +++ b/TaxBaik.Domain/Entities/Inquiry.cs @@ -1,7 +1,5 @@ namespace TaxBaik.Domain.Entities; -using TaxBaik.Domain.Enums; - public class Inquiry { public int Id { get; set; } diff --git a/TaxBaik.Domain/Enums/InquiryStatus.cs b/TaxBaik.Domain/Enums/InquiryStatus.cs index 4041f4e..afe67e0 100644 --- a/TaxBaik.Domain/Enums/InquiryStatus.cs +++ b/TaxBaik.Domain/Enums/InquiryStatus.cs @@ -2,8 +2,7 @@ namespace TaxBaik.Domain.Enums; public enum InquiryStatus { - New = 0, // 새로운 문의 - Contacted = 1, // 연락 완료 - Contracted = 2, // 계약 체결 - Closed = 3 // 종료 + New = 0, + Contacted = 1, + Completed = 2 } diff --git a/TaxBaik.Domain/Interfaces/IAdminUserRepository.cs b/TaxBaik.Domain/Interfaces/IAdminUserRepository.cs index 1d10c0a..9be9f26 100644 --- a/TaxBaik.Domain/Interfaces/IAdminUserRepository.cs +++ b/TaxBaik.Domain/Interfaces/IAdminUserRepository.cs @@ -5,4 +5,6 @@ public interface IAdminUserRepository Task GetByUsernameAsync(string username); Task GetByIdAsync(int id); Task CreateAsync(Entities.AdminUser user); + Task UpdatePasswordHashAsync(int id, string passwordHash); + Task UpdateLastLoginAtAsync(int id); } diff --git a/TaxBaik.Infrastructure/Data/MigrationRunner.cs b/TaxBaik.Infrastructure/Data/MigrationRunner.cs index de1ebbd..9156935 100644 --- a/TaxBaik.Infrastructure/Data/MigrationRunner.cs +++ b/TaxBaik.Infrastructure/Data/MigrationRunner.cs @@ -169,8 +169,8 @@ public class MigrationRunner private class Migration { - public string Version { get; set; } - public string Description { get; set; } - public string Sql { get; set; } + public required string Version { get; set; } + public required string Description { get; set; } + public required string Sql { get; set; } } } diff --git a/TaxBaik.Infrastructure/Repositories/AdminUserRepository.cs b/TaxBaik.Infrastructure/Repositories/AdminUserRepository.cs index da1bda3..b047a82 100644 --- a/TaxBaik.Infrastructure/Repositories/AdminUserRepository.cs +++ b/TaxBaik.Infrastructure/Repositories/AdminUserRepository.cs @@ -49,4 +49,20 @@ public class AdminUserRepository : BaseRepository, IAdminUserRepository "INSERT INTO admin_users (username, password_hash, created_at) VALUES (@username, @passwordHash, NOW())", new { username = user.Username, passwordHash = user.PasswordHash }); } + + public async Task UpdatePasswordHashAsync(int id, string passwordHash) + { + using var conn = _connectionFactory.CreateConnection(); + await conn.ExecuteAsync( + "UPDATE admin_users SET password_hash = @passwordHash WHERE id = @id", + new { id, passwordHash }); + } + + public async Task UpdateLastLoginAtAsync(int id) + { + using var conn = _connectionFactory.CreateConnection(); + await conn.ExecuteAsync( + "UPDATE admin_users SET last_login_at = NOW() WHERE id = @id", + new { id }); + } } diff --git a/TaxBaik.Infrastructure/TaxBaik.Infrastructure.csproj b/TaxBaik.Infrastructure/TaxBaik.Infrastructure.csproj index 8417733..826412d 100644 --- a/TaxBaik.Infrastructure/TaxBaik.Infrastructure.csproj +++ b/TaxBaik.Infrastructure/TaxBaik.Infrastructure.csproj @@ -6,7 +6,7 @@ - + diff --git a/TaxBaik.Web/Components/Admin/ConfirmDialog.razor b/TaxBaik.Web/Components/Admin/ConfirmDialog.razor index ee3288d..876557d 100644 --- a/TaxBaik.Web/Components/Admin/ConfirmDialog.razor +++ b/TaxBaik.Web/Components/Admin/ConfirmDialog.razor @@ -11,8 +11,8 @@ @code { - [CascadingParameter] MudDialogInstance MudDialog { get; set; } + [CascadingParameter] MudDialogInstance? MudDialog { get; set; } - void Cancel() => MudDialog.Cancel(); - void Confirm() => MudDialog.Close(DialogResult.Ok(true)); + void Cancel() => MudDialog?.Cancel(); + void Confirm() => MudDialog?.Close(DialogResult.Ok(true)); } diff --git a/TaxBaik.Web/Components/Admin/InquiryTable.razor b/TaxBaik.Web/Components/Admin/InquiryTable.razor index 324f433..0fdabb2 100644 --- a/TaxBaik.Web/Components/Admin/InquiryTable.razor +++ b/TaxBaik.Web/Components/Admin/InquiryTable.razor @@ -1,5 +1,5 @@ -@using TaxBaik.Domain.Interfaces -@inject IInquiryRepository InquiryRepository +@using TaxBaik.Application.Services +@inject InquiryService InquiryService @@ -39,7 +39,7 @@ protected override async Task OnInitializedAsync() { - var (items, _) = await InquiryRepository.GetPagedAsync(1, 1000); + var (items, _) = await InquiryService.GetPagedAsync(1, 1000); inquiries = items.ToList(); FilterInquiries(); } diff --git a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogCreate.razor b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogCreate.razor index 173486f..f839e34 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogCreate.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogCreate.razor @@ -1,11 +1,12 @@ @page "/admin/blog/create" +@using TaxBaik.Application.DTOs @using TaxBaik.Application.Services @using TaxBaik.Domain.Interfaces @attribute [Authorize] @inject BlogService BlogService @inject ICategoryRepository CategoryRepository @inject NavigationManager Navigation -@inject Snackbar Snackbar +@inject ISnackbar Snackbar 새 포스트 작성 @@ -49,7 +50,7 @@ @code { - private MudForm form; + private MudForm? form; private List categories = []; private CreatePostModel model = new(); @@ -60,18 +61,43 @@ private async Task SavePost() { - // TODO: Implement BlogService.CreateAsync - Navigation.NavigateTo("/taxbaik/admin/blog"); + if (form == null) + return; + + await form.Validate(); + if (!form.IsValid) + return; + + try + { + await BlogService.CreateAsync(new CreateBlogPostDto + { + Title = model.Title, + Content = model.Content, + CategoryId = model.CategoryId, + Tags = model.Tags, + SeoTitle = model.SeoTitle, + SeoDescription = model.SeoDescription, + IsPublished = model.IsPublished + }); + + Snackbar.Add("포스트가 저장되었습니다.", Severity.Success); + Navigation.NavigateTo("/taxbaik/admin/blog"); + } + catch (ValidationException ex) + { + Snackbar.Add(ex.Message, Severity.Error); + } } private class CreatePostModel { - public string Title { get; set; } - public string Content { get; set; } - public int CategoryId { get; set; } - public string Tags { get; set; } - public string SeoTitle { get; set; } - public string SeoDescription { get; set; } + public string Title { get; set; } = ""; + public string Content { get; set; } = ""; + public int? CategoryId { get; set; } + public string? Tags { get; set; } + public string? SeoTitle { get; set; } + public string? SeoDescription { get; set; } public bool IsPublished { get; set; } } } diff --git a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor index 0151419..b439879 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor @@ -2,7 +2,7 @@ @attribute [Authorize] @inject IApiClient ApiClient @inject DialogService DialogService -@inject Snackbar Snackbar +@inject ISnackbar Snackbar 블로그 관리 @@ -17,7 +17,8 @@ - + @@ -54,14 +55,37 @@ isLoading = false; } - private async Task TogglePublish(int postId, bool isPublished) + private async Task TogglePublish(TaxBaik.Domain.Entities.BlogPost post, bool isPublished) { - // Publish status update via API + var previous = post.IsPublished; + post.IsPublished = isPublished; + var result = await ApiClient.PutAsync($"blog/{post.Id}", new + { + post.Title, + post.Content, + post.CategoryId, + post.Tags, + post.SeoTitle, + post.SeoDescription, + post.ThumbnailUrl, + IsPublished = isPublished, + post.AuthorId + }); + + if (result == null) + { + post.IsPublished = previous; + Snackbar.Add("발행 상태 변경에 실패했습니다.", Severity.Error); + return; + } + + Snackbar.Add("발행 상태가 변경되었습니다.", Severity.Success); } private async Task DeletePost(int postId) { await ApiClient.DeleteAsync($"blog/{postId}"); + Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success); await LoadPosts(); } } diff --git a/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor b/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor index a3d0836..dfe57a3 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor @@ -1,8 +1,7 @@ @page "/admin/dashboard" @using TaxBaik.Application.Services -@using TaxBaik.Domain.Interfaces @attribute [Authorize] -@inject IInquiryRepository InquiryRepository +@inject InquiryService InquiryService @inject BlogService BlogService 대시보드 @@ -80,13 +79,14 @@ protected override async Task OnInitializedAsync() { - var (inquiries, total) = await InquiryRepository.GetPagedAsync(1, 100); + var (inquiries, _) = await InquiryService.GetPagedAsync(1, 100); recentInquiries = inquiries.OrderByDescending(x => x.CreatedAt).Take(5).ToList(); var now = DateTime.UtcNow; thisMonthInquiries = inquiries.Count(x => x.CreatedAt.Year == now.Year && x.CreatedAt.Month == now.Month); newInquiries = inquiries.Count(x => x.Status == "new"); - totalPosts = 0; // TODO: get from blog service - publishedPosts = 0; // TODO: get from blog service + var stats = await BlogService.GetStatsAsync(); + totalPosts = stats.TotalPosts; + publishedPosts = stats.PublishedPosts; } } diff --git a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryDetail.razor b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryDetail.razor index f7e9012..e269bbd 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryDetail.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryDetail.razor @@ -1,8 +1,9 @@ @page "/admin/inquiries/{InquiryId:int}" -@using TaxBaik.Domain.Interfaces +@using TaxBaik.Application.Services @attribute [Authorize] -@inject IInquiryRepository InquiryRepository +@inject InquiryService InquiryService @inject NavigationManager Navigation +@inject ISnackbar Snackbar 문의 상세 @@ -36,7 +37,7 @@ 상태 - + 신규 연락함 완료 @@ -54,11 +55,27 @@ else [Parameter] public int InquiryId { get; set; } - private Domain.Entities.Inquiry inquiry; + private Domain.Entities.Inquiry? inquiry; protected override async Task OnInitializedAsync() { - var (inquiries, _) = await InquiryRepository.GetPagedAsync(1, 1000); - inquiry = inquiries.FirstOrDefault(x => x.Id == InquiryId); + inquiry = await InquiryService.GetByIdAsync(InquiryId); + } + + private async Task OnStatusChanged(string status) + { + if (inquiry == null) + return; + + try + { + await InquiryService.UpdateStatusAsync(inquiry.Id, status); + inquiry.Status = status; + Snackbar.Add("상태가 변경되었습니다.", Severity.Success); + } + catch (ValidationException ex) + { + Snackbar.Add(ex.Message, Severity.Error); + } } } diff --git a/TaxBaik.Web/Controllers/AuthController.cs b/TaxBaik.Web/Controllers/AuthController.cs index 0f98900..fac5a11 100644 --- a/TaxBaik.Web/Controllers/AuthController.cs +++ b/TaxBaik.Web/Controllers/AuthController.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; using TaxBaik.Web.Services; namespace TaxBaik.Web.Controllers; @@ -18,14 +19,61 @@ public class AuthController : ControllerBase public async Task Login([FromBody] LoginRequest request) { if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) - return BadRequest(new { message = "Username and password are required" }); + return BadRequest(new ProblemDetails { Title = "로그인 정보가 필요합니다.", Status = StatusCodes.Status400BadRequest }); var token = await _authService.AuthenticateAndGenerateTokenAsync(request.Username, request.Password); if (token == null) - return Unauthorized(new { message = "Invalid username or password" }); + return Unauthorized(new ProblemDetails { Title = "아이디 또는 비밀번호가 올바르지 않습니다.", Status = StatusCodes.Status401Unauthorized }); return Ok(new { token, expiresIn = 28800 }); } + + [HttpPost("change-password")] + [Microsoft.AspNetCore.Authorization.Authorize] + public async Task ChangePassword([FromBody] ChangePasswordRequest request) + { + var username = User.FindFirstValue(ClaimTypes.Name); + if (string.IsNullOrWhiteSpace(username)) + return Unauthorized(new ProblemDetails { Title = "인증 정보가 올바르지 않습니다.", Status = StatusCodes.Status401Unauthorized }); + + try + { + var changed = await _authService.ChangePasswordAsync(username, request.CurrentPassword, request.NewPassword); + if (!changed) + return Unauthorized(new ProblemDetails { Title = "현재 비밀번호가 올바르지 않습니다.", Status = StatusCodes.Status401Unauthorized }); + + return Ok(new { message = "비밀번호가 변경되었습니다." }); + } + catch (ArgumentException ex) + { + return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest }); + } + } + + [HttpPost("reset-password")] + public async Task ResetPassword([FromBody] ResetPasswordRequest request) + { + try + { + var reset = await _authService.ResetPasswordAsync(request.Username, request.NewPassword, request.ResetToken); + if (!reset) + return Unauthorized(new ProblemDetails { Title = "재설정 토큰 또는 사용자 정보가 올바르지 않습니다.", Status = StatusCodes.Status401Unauthorized }); + + return Ok(new { message = "비밀번호가 재설정되었습니다." }); + } + catch (InvalidOperationException) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, new ProblemDetails + { + Title = "비밀번호 재설정 토큰이 서버에 설정되어 있지 않습니다.", + Status = StatusCodes.Status503ServiceUnavailable + }); + } + catch (ArgumentException ex) + { + return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest }); + } + } } public class LoginRequest @@ -33,3 +81,16 @@ public class LoginRequest public string Username { get; set; } = string.Empty; public string Password { get; set; } = string.Empty; } + +public class ChangePasswordRequest +{ + public string CurrentPassword { get; set; } = string.Empty; + public string NewPassword { get; set; } = string.Empty; +} + +public class ResetPasswordRequest +{ + public string Username { get; set; } = string.Empty; + public string NewPassword { get; set; } = string.Empty; + public string ResetToken { get; set; } = string.Empty; +} diff --git a/TaxBaik.Web/Controllers/BlogController.cs b/TaxBaik.Web/Controllers/BlogController.cs index 33721af..a994a27 100644 --- a/TaxBaik.Web/Controllers/BlogController.cs +++ b/TaxBaik.Web/Controllers/BlogController.cs @@ -28,7 +28,7 @@ public class BlogController : ControllerBase { var post = await _blogService.GetBySlugAsync(slug); if (post == null) - return NotFound(new { message = "Post not found" }); + return NotFound(new ProblemDetails { Title = "포스트를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound }); return Ok(post); } @@ -44,21 +44,32 @@ public class BlogController : ControllerBase [Authorize] public async Task Create([FromBody] CreateBlogPostDto dto) { - if (string.IsNullOrWhiteSpace(dto.Title) || string.IsNullOrWhiteSpace(dto.Content)) - return BadRequest(new { message = "Title and content are required" }); - - var result = await _blogService.CreateAsync(dto); - return CreatedAtAction(nameof(GetBySlug), new { slug = result.Slug }, result); + try + { + var result = await _blogService.CreateAsync(dto); + return CreatedAtAction(nameof(GetBySlug), new { slug = result.Slug }, result); + } + catch (ValidationException ex) + { + return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest }); + } } [HttpPut("{id}")] [Authorize] public async Task Update(int id, [FromBody] CreateBlogPostDto dto) { - var result = await _blogService.UpdateAsync(id, dto); - if (result == null) - return NotFound(new { message = "Post not found" }); - return Ok(result); + try + { + var result = await _blogService.UpdateAsync(id, dto); + if (result == null) + return NotFound(new ProblemDetails { Title = "포스트를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound }); + return Ok(result); + } + catch (ValidationException ex) + { + return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest }); + } } [HttpDelete("{id}")] diff --git a/TaxBaik.Web/Controllers/InquiryController.cs b/TaxBaik.Web/Controllers/InquiryController.cs index 0836acf..3283066 100644 --- a/TaxBaik.Web/Controllers/InquiryController.cs +++ b/TaxBaik.Web/Controllers/InquiryController.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using TaxBaik.Application.Services; -using TaxBaik.Domain.Interfaces; namespace TaxBaik.Web.Controllers; @@ -10,29 +9,40 @@ namespace TaxBaik.Web.Controllers; public class InquiryController : ControllerBase { private readonly InquiryService _inquiryService; - private readonly IInquiryRepository _inquiryRepository; - public InquiryController(InquiryService inquiryService, IInquiryRepository inquiryRepository) + public InquiryController(InquiryService inquiryService) { _inquiryService = inquiryService; - _inquiryRepository = inquiryRepository; } [HttpPost] public async Task Submit([FromBody] SubmitInquiryRequest request) { if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Phone)) - return BadRequest(new { message = "Name and phone are required" }); + return BadRequest(new ProblemDetails { Title = "이름과 전화번호를 입력하세요.", Status = StatusCodes.Status400BadRequest }); - await _inquiryService.SubmitAsync(request.Name, request.Phone, request.ServiceType, request.Message); - return Ok(new { message = "Inquiry submitted successfully" }); + try + { + await _inquiryService.SubmitAsync( + request.Name, + request.Phone, + request.ServiceType, + request.Message, + request.Email, + HttpContext.Connection.RemoteIpAddress?.ToString()); + return Ok(new { message = "상담 신청이 접수되었습니다." }); + } + catch (ValidationException ex) + { + return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest }); + } } [HttpGet] [Authorize] public async Task GetPaged([FromQuery] int page = 1, [FromQuery] int pageSize = 20) { - var (inquiries, total) = await _inquiryRepository.GetPagedAsync(page, pageSize); + var (inquiries, total) = await _inquiryService.GetPagedAsync(page, pageSize); return Ok(new { data = inquiries, total, page, pageSize }); } @@ -40,9 +50,9 @@ public class InquiryController : ControllerBase [Authorize] public async Task GetById(int id) { - var inquiry = await _inquiryRepository.GetByIdAsync(id); + var inquiry = await _inquiryService.GetByIdAsync(id); if (inquiry == null) - return NotFound(new { message = "Inquiry not found" }); + return NotFound(new ProblemDetails { Title = "문의를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound }); return Ok(inquiry); } @@ -50,12 +60,19 @@ public class InquiryController : ControllerBase [Authorize] public async Task UpdateStatus(int id, [FromBody] UpdateStatusRequest request) { - var inquiry = await _inquiryRepository.GetByIdAsync(id); + var inquiry = await _inquiryService.GetByIdAsync(id); if (inquiry == null) - return NotFound(new { message = "Inquiry not found" }); + return NotFound(new ProblemDetails { Title = "문의를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound }); - await _inquiryRepository.UpdateStatusAsync(id, request.Status); - return Ok(new { message = "Status updated" }); + try + { + await _inquiryService.UpdateStatusAsync(id, request.Status); + return Ok(new { message = "상태가 변경되었습니다." }); + } + catch (ValidationException ex) + { + return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest }); + } } } diff --git a/TaxBaik.Web/Pages/Blog/Index.cshtml.cs b/TaxBaik.Web/Pages/Blog/Index.cshtml.cs index 5777376..69a96d6 100644 --- a/TaxBaik.Web/Pages/Blog/Index.cshtml.cs +++ b/TaxBaik.Web/Pages/Blog/Index.cshtml.cs @@ -1,12 +1,13 @@ using Microsoft.AspNetCore.Mvc.RazorPages; +using TaxBaik.Application.Services; using TaxBaik.Domain.Entities; -using TaxBaik.Web.Services; namespace TaxBaik.Web.Pages.Blog; public class BlogIndexModel : PageModel { - private readonly IApiClient _apiClient; + private readonly BlogService _blogService; + private readonly CategoryService _categoryService; public List Posts { get; set; } = []; public List Categories { get; set; } = []; @@ -15,9 +16,10 @@ public class BlogIndexModel : PageModel public int? SelectedCategoryId { get; set; } private const int PageSize = 12; - public BlogIndexModel(IApiClient apiClient) + public BlogIndexModel(BlogService blogService, CategoryService categoryService) { - _apiClient = apiClient; + _blogService = blogService; + _categoryService = categoryService; } public async Task OnGetAsync(int page = 1, int? categoryId = null) @@ -27,15 +29,11 @@ public class BlogIndexModel : PageModel CurrentPage = page; SelectedCategoryId = categoryId; - var categories = await _apiClient.GetAsync>("category"); - Categories = categories ?? []; + Categories = (await _categoryService.GetAllAsync()).ToList(); - var blogsResponse = await _apiClient.GetAsync($"blog?page={page}&pageSize={PageSize}"); - if (blogsResponse != null) - { - Posts = blogsResponse.Data ?? []; - TotalPages = (blogsResponse.Total + PageSize - 1) / PageSize; - } + var (posts, total) = await _blogService.GetPublishedPagedAsync(page, PageSize, categoryId); + Posts = posts.ToList(); + TotalPages = (total + PageSize - 1) / PageSize; } catch { diff --git a/TaxBaik.Web/Pages/Contact.cshtml b/TaxBaik.Web/Pages/Contact.cshtml index 0f96982..e2c5399 100644 --- a/TaxBaik.Web/Pages/Contact.cshtml +++ b/TaxBaik.Web/Pages/Contact.cshtml @@ -16,6 +16,8 @@ }
+
+
diff --git a/TaxBaik.Web/Pages/Contact.cshtml.cs b/TaxBaik.Web/Pages/Contact.cshtml.cs index 3e690d7..4d729ac 100644 --- a/TaxBaik.Web/Pages/Contact.cshtml.cs +++ b/TaxBaik.Web/Pages/Contact.cshtml.cs @@ -1,12 +1,12 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; -using TaxBaik.Web.Services; +using TaxBaik.Application.Services; namespace TaxBaik.Web.Pages; public class ContactModel : PageModel { - private readonly IApiClient _apiClient; + private readonly InquiryService _inquiryService; [BindProperty] public string Name { get; set; } = ""; @@ -26,9 +26,9 @@ public class ContactModel : PageModel [BindProperty] public bool Agree { get; set; } - public ContactModel(IApiClient apiClient) + public ContactModel(InquiryService inquiryService) { - _apiClient = apiClient; + _inquiryService = inquiryService; } public async Task OnPostAsync() @@ -38,19 +38,21 @@ public class ContactModel : PageModel try { - var inquiry = new - { + await _inquiryService.SubmitAsync( Name, Phone, - Email, ServiceType, - Message - }; - - await _apiClient.PostAsync("inquiry", inquiry); + Message, + Email, + HttpContext.Connection.RemoteIpAddress?.ToString()); TempData["Success"] = "상담 신청이 접수되었습니다. 빠른 시간 내에 연락드리겠습니다."; return RedirectToPage(); } + catch (ValidationException ex) + { + ModelState.AddModelError("", ex.Message); + return Page(); + } catch { ModelState.AddModelError("", "시스템 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); diff --git a/TaxBaik.Web/Pages/Index.cshtml.cs b/TaxBaik.Web/Pages/Index.cshtml.cs index 97546ac..1e0ac5f 100644 --- a/TaxBaik.Web/Pages/Index.cshtml.cs +++ b/TaxBaik.Web/Pages/Index.cshtml.cs @@ -1,27 +1,26 @@ using Microsoft.AspNetCore.Mvc.RazorPages; +using TaxBaik.Application.Services; using TaxBaik.Domain.Entities; -using TaxBaik.Web.Services; namespace TaxBaik.Web.Pages; public class IndexModel : PageModel { - private readonly IApiClient _apiClient; + private readonly BlogService _blogService; public List RecentPosts { get; set; } = []; - public IndexModel(IApiClient apiClient) + public IndexModel(BlogService blogService) { - _apiClient = apiClient; + _blogService = blogService; } public async Task OnGetAsync() { try { - var response = await _apiClient.GetAsync("blog?page=1&pageSize=3"); - if (response?.Data != null) - RecentPosts = response.Data.ToList(); + var (posts, _) = await _blogService.GetPublishedPagedAsync(1, 3); + RecentPosts = posts.ToList(); } catch { @@ -29,11 +28,3 @@ public class IndexModel : PageModel } } } - -public class BlogApiResponse -{ - public List Data { get; set; } = []; - public int Total { get; set; } - public int Page { get; set; } - public int PageSize { get; set; } -} diff --git a/TaxBaik.Web/Program.cs b/TaxBaik.Web/Program.cs index d56c4f7..9489282 100644 --- a/TaxBaik.Web/Program.cs +++ b/TaxBaik.Web/Program.cs @@ -4,6 +4,7 @@ using System.Text.Encodings.Web; using System.Text.Unicode; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.ResponseCompression; using Microsoft.IdentityModel.Tokens; using MudBlazor.Services; @@ -12,16 +13,23 @@ using TaxBaik.Infrastructure; using TaxBaik.Web.Services; var builder = WebApplication.CreateBuilder(args); +var isProduction = builder.Environment.IsProduction(); // Controllers (API) builder.Services.AddControllers(); +builder.Services.AddProblemDetails(); +builder.Services.AddHealthChecks(); // Razor Pages + Blazor Server 통합 builder.Services.AddRazorPages(); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); // JWT 인증 +var connectionString = builder.Configuration.GetConnectionString("Default") + ?? throw new InvalidOperationException("Missing connection string"); var jwtKey = builder.Configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("Missing JWT SecretKey"); +if (isProduction && jwtKey.Contains("dev-secret", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Production JWT SecretKey must not use the development default."); var key = Encoding.ASCII.GetBytes(jwtKey); builder.Services.AddAuthentication(opts => @@ -35,8 +43,12 @@ builder.Services.AddAuthentication(opts => { ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(key), - ValidateIssuer = false, - ValidateAudience = false + ValidateIssuer = true, + ValidIssuer = "taxbaik-admin", + ValidateAudience = true, + ValidAudience = "taxbaik-admin-client", + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(1) }; }); @@ -46,6 +58,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(sp => sp.GetRequiredService()); builder.Services.AddScoped(); builder.Services.AddCascadingAuthenticationState(); +builder.Services.AddAuthorization(); builder.Services.AddAuthorizationCore(); // HTTP Client for API @@ -82,21 +95,27 @@ builder.Services.AddSingleton(versionInfo); var app = builder.Build(); +app.UseForwardedHeaders(new ForwardedHeadersOptions +{ + ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto +}); + // Run migrations on startup (non-blocking for development) try { using (var scope = app.Services.CreateScope()) { var connectionFactory = scope.ServiceProvider.GetRequiredService(); - var cs = builder.Configuration.GetConnectionString("Default") - ?? throw new InvalidOperationException("Missing connection string"); - var migrationRunner = new TaxBaik.Infrastructure.Data.MigrationRunner(cs, connectionFactory); + var migrationRunner = new TaxBaik.Infrastructure.Data.MigrationRunner(connectionString, connectionFactory); await migrationRunner.RunAsync(); } } catch (Exception ex) { - Console.WriteLine($"⚠️ Migration warning (non-blocking): {ex.Message}"); + if (!app.Environment.IsDevelopment()) + throw; + + Console.WriteLine($"Migration warning (development only): {ex.Message}"); } app.UsePathBase("/taxbaik"); @@ -115,6 +134,7 @@ if (!app.Environment.IsDevelopment()) // API + Razor Pages + Blazor 매핑 app.MapControllers(); +app.MapHealthChecks("/healthz"); app.MapRazorPages(); app.MapRazorComponents().AddInteractiveServerRenderMode(); diff --git a/TaxBaik.Web/Services/ApiClient.cs b/TaxBaik.Web/Services/ApiClient.cs index f6933f7..103acfd 100644 --- a/TaxBaik.Web/Services/ApiClient.cs +++ b/TaxBaik.Web/Services/ApiClient.cs @@ -1,5 +1,6 @@ namespace TaxBaik.Web.Services; +using Microsoft.AspNetCore.Components; using System.Text.Json; public interface IApiClient @@ -14,11 +15,13 @@ public interface IApiClient public class ApiClient : IApiClient { private readonly HttpClient _httpClient; + private readonly NavigationManager _navigationManager; private string? _authToken; - public ApiClient(HttpClient httpClient) + public ApiClient(HttpClient httpClient, NavigationManager navigationManager) { _httpClient = httpClient; + _navigationManager = navigationManager; } public async Task SetAuthToken(string? token) @@ -34,7 +37,7 @@ public class ApiClient : IApiClient { try { - var response = await _httpClient.GetAsync($"/taxbaik/api/{endpoint}"); + var response = await _httpClient.GetAsync(BuildApiUri(endpoint)); if (!response.IsSuccessStatusCode) return default; @@ -53,7 +56,7 @@ public class ApiClient : IApiClient { var json = JsonSerializer.Serialize(data); var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); - var response = await _httpClient.PostAsync($"/taxbaik/api/{endpoint}", content); + var response = await _httpClient.PostAsync(BuildApiUri(endpoint), content); if (!response.IsSuccessStatusCode) return default; @@ -73,7 +76,7 @@ public class ApiClient : IApiClient { var json = JsonSerializer.Serialize(data); var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); - var response = await _httpClient.PutAsync($"/taxbaik/api/{endpoint}", content); + var response = await _httpClient.PutAsync(BuildApiUri(endpoint), content); if (!response.IsSuccessStatusCode) return default; @@ -91,11 +94,17 @@ public class ApiClient : IApiClient { try { - await _httpClient.DeleteAsync($"/taxbaik/api/{endpoint}"); + await _httpClient.DeleteAsync(BuildApiUri(endpoint)); } catch { // Ignore } } + + private Uri BuildApiUri(string endpoint) + { + var relative = $"api/{endpoint.TrimStart('/')}"; + return new Uri(new Uri(_navigationManager.BaseUri), relative); + } } diff --git a/TaxBaik.Web/Services/AuthService.cs b/TaxBaik.Web/Services/AuthService.cs index c27b562..eba86bf 100644 --- a/TaxBaik.Web/Services/AuthService.cs +++ b/TaxBaik.Web/Services/AuthService.cs @@ -13,6 +13,7 @@ public class AuthService private readonly IAdminUserRepository _adminUserRepository; private readonly ILogger _logger; private readonly string _jwtSecretKey; + private readonly string? _passwordResetToken; private readonly int _tokenExpirationMinutes = 480; // 8시간 public AuthService(IAdminUserRepository adminUserRepository, ILogger logger, IConfiguration configuration) @@ -20,6 +21,7 @@ public class AuthService _adminUserRepository = adminUserRepository; _logger = logger; _jwtSecretKey = configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("Missing 'Jwt:SecretKey' configuration."); + _passwordResetToken = configuration["Admin:PasswordResetToken"]; } public async Task AuthenticateAndGenerateTokenAsync(string username, string password) @@ -49,9 +51,47 @@ public class AuthService } _logger.LogInformation("로그인 성공: {Username}", username); + await _adminUserRepository.UpdateLastLoginAtAsync(user.Id); return GenerateJwtToken(user); } + public async Task ChangePasswordAsync(string username, string currentPassword, string newPassword) + { + if (!IsValidPassword(newPassword)) + throw new ArgumentException("새 비밀번호는 12자 이상이어야 합니다.", nameof(newPassword)); + + var user = await _adminUserRepository.GetByUsernameAsync(username); + if (user == null || string.IsNullOrWhiteSpace(user.PasswordHash)) + return false; + + if (!BCrypt.Net.BCrypt.Verify(currentPassword, user.PasswordHash)) + return false; + + await _adminUserRepository.UpdatePasswordHashAsync(user.Id, BCrypt.Net.BCrypt.HashPassword(newPassword)); + _logger.LogInformation("관리자 비밀번호 변경: {Username}", username); + return true; + } + + public async Task ResetPasswordAsync(string username, string newPassword, string resetToken) + { + if (string.IsNullOrWhiteSpace(_passwordResetToken)) + throw new InvalidOperationException("Admin:PasswordResetToken is not configured."); + + if (!TimeConstantEquals(resetToken, _passwordResetToken)) + return false; + + if (!IsValidPassword(newPassword)) + throw new ArgumentException("새 비밀번호는 12자 이상이어야 합니다.", nameof(newPassword)); + + var user = await _adminUserRepository.GetByUsernameAsync(username); + if (user == null) + return false; + + await _adminUserRepository.UpdatePasswordHashAsync(user.Id, BCrypt.Net.BCrypt.HashPassword(newPassword)); + _logger.LogWarning("관리자 비밀번호 재설정 API 사용: {Username}", username); + return true; + } + private string GenerateJwtToken(AdminUser user) { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecretKey)); @@ -99,4 +139,14 @@ public class AuthService return null; } } + + private static bool IsValidPassword(string password) => !string.IsNullOrWhiteSpace(password) && password.Length >= 12; + + private static bool TimeConstantEquals(string value, string expected) + { + var valueBytes = Encoding.UTF8.GetBytes(value); + var expectedBytes = Encoding.UTF8.GetBytes(expected); + return valueBytes.Length == expectedBytes.Length + && System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(valueBytes, expectedBytes); + } } diff --git a/TaxBaik.Web/TaxBaik.Web.csproj b/TaxBaik.Web/TaxBaik.Web.csproj index 92fa684..b17e71b 100644 --- a/TaxBaik.Web/TaxBaik.Web.csproj +++ b/TaxBaik.Web/TaxBaik.Web.csproj @@ -12,11 +12,11 @@ - + - - - + + + diff --git a/TaxBaik.sln b/TaxBaik.sln index f12ea19..10b5d07 100644 --- a/TaxBaik.sln +++ b/TaxBaik.sln @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaxBaik.Application", "TaxB EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaxBaik.Web", "TaxBaik.Web\TaxBaik.Web.csproj", "{C40CB56B-D9A6-47B3-A0A2-7736D83425C5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaxBaik.Application.Tests", "TaxBaik.Application.Tests\TaxBaik.Application.Tests.csproj", "{47D1F07D-F11B-4343-A3C3-1872F0C46AE3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -69,6 +71,18 @@ Global {C40CB56B-D9A6-47B3-A0A2-7736D83425C5}.Release|x64.Build.0 = Release|Any CPU {C40CB56B-D9A6-47B3-A0A2-7736D83425C5}.Release|x86.ActiveCfg = Release|Any CPU {C40CB56B-D9A6-47B3-A0A2-7736D83425C5}.Release|x86.Build.0 = Release|Any CPU + {47D1F07D-F11B-4343-A3C3-1872F0C46AE3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {47D1F07D-F11B-4343-A3C3-1872F0C46AE3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {47D1F07D-F11B-4343-A3C3-1872F0C46AE3}.Debug|x64.ActiveCfg = Debug|Any CPU + {47D1F07D-F11B-4343-A3C3-1872F0C46AE3}.Debug|x64.Build.0 = Debug|Any CPU + {47D1F07D-F11B-4343-A3C3-1872F0C46AE3}.Debug|x86.ActiveCfg = Debug|Any CPU + {47D1F07D-F11B-4343-A3C3-1872F0C46AE3}.Debug|x86.Build.0 = Debug|Any CPU + {47D1F07D-F11B-4343-A3C3-1872F0C46AE3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {47D1F07D-F11B-4343-A3C3-1872F0C46AE3}.Release|Any CPU.Build.0 = Release|Any CPU + {47D1F07D-F11B-4343-A3C3-1872F0C46AE3}.Release|x64.ActiveCfg = Release|Any CPU + {47D1F07D-F11B-4343-A3C3-1872F0C46AE3}.Release|x64.Build.0 = Release|Any CPU + {47D1F07D-F11B-4343-A3C3-1872F0C46AE3}.Release|x86.ActiveCfg = Release|Any CPU + {47D1F07D-F11B-4343-A3C3-1872F0C46AE3}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/deploy/taxbaik.service b/deploy/taxbaik.service index 9d63659..135d535 100644 --- a/deploy/taxbaik.service +++ b/deploy/taxbaik.service @@ -1,5 +1,5 @@ [Unit] -Description=TaxBaik Public Website (.NET 8) +Description=TaxBaik Website and Admin (.NET 10) After=network.target [Service] diff --git a/docker-compose.yml b/docker-compose.yml index e5db314..63cf903 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,30 +27,14 @@ services: ASPNETCORE_ENVIRONMENT: Development ASPNETCORE_URLS: http://0.0.0.0:5001 ConnectionStrings__Default: "Host=postgres;Database=taxbaikdb;Username=taxbaik;Password=taxbaik123" + Jwt__SecretKey: "dev-secret-key-change-in-production-min-32-chars!" ports: - "5001:5001" depends_on: postgres: condition: service_healthy volumes: - - ./publish/web:/app - - taxbaik-admin: - build: - context: . - dockerfile: Dockerfile.admin - container_name: taxbaik-admin - environment: - ASPNETCORE_ENVIRONMENT: Development - ASPNETCORE_URLS: http://0.0.0.0:5002 - ConnectionStrings__Default: "Host=postgres;Database=taxbaikdb;Username=taxbaik;Password=taxbaik123" - ports: - - "5002:5002" - depends_on: - postgres: - condition: service_healthy - volumes: - - ./publish/admin:/app + - ./publish:/app volumes: postgres_data: