운영 기준선 및 인증/배포 고도화
TaxBaik CI/CD / build-and-deploy (push) Failing after 37s

feat: harden auth ops and deployment baseline
This commit was merged in pull request #1.
This commit is contained in:
2026-06-27 10:55:16 +09:00
41 changed files with 714 additions and 208 deletions
+5
View File
@@ -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
+15 -9
View File
@@ -26,6 +26,9 @@ jobs:
dotnet clean TaxBaik.sln -c Release dotnet clean TaxBaik.sln -c Release
dotnet build TaxBaik.sln -c Release --no-restore dotnet build TaxBaik.sln -c Release --no-restore
- name: Test solution
run: dotnet test TaxBaik.sln -c Release --no-build
- name: Publish Web (통합 앱) - name: Publish Web (통합 앱)
run: dotnet publish TaxBaik.Web/ -c Release -o ./publish --no-restore run: dotnet publish TaxBaik.Web/ -c Release -o ./publish --no-restore
@@ -52,20 +55,23 @@ jobs:
DEPLOY_USER="${{ secrets.DEPLOY_USER }}" DEPLOY_USER="${{ secrets.DEPLOY_USER }}"
echo "=== Deploying TaxBaik v$(git rev-parse --short HEAD) ===" 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 mkdir -p ~/.ssh
printf '%s' "${{ secrets.DEPLOY_SSH_KEY_B64 }}" | base64 -d > ~/.ssh/id_ed25519 printf '%s' "${{ secrets.DEPLOY_SSH_KEY_B64 }}" | base64 -d > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true 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 sleep 5
echo "✓ Deployment complete" echo "✓ Deployed to $DEPLOY_HOST:$DEPLOY_DIR"
- name: Verify deployment - name: Verify deployment
run: | run: |
+5 -4
View File
@@ -46,7 +46,7 @@ TaxBaik.Web ASP.NET Core 앱 (포트 5001)
- **Infrastructure**: DB 접근, Dapper 구현체, 마이그레이션 실행 - **Infrastructure**: DB 접근, Dapper 구현체, 마이그레이션 실행
- **Application**: 서비스, DTO 매핑, 비즈니스 워크플로우 - **Application**: 서비스, DTO 매핑, 비즈니스 워크플로우
- **Web (Pages/)**: 공개 홈페이지 (SEO 최적화, Razor Pages SSR) - **Web (Pages/)**: 공개 홈페이지 (SEO 최적화, Razor Pages SSR)
- **Web (Components/Admin)**: 관리자 백오피스 (실시간 UI, Blazor Server) - **Web (Components/Admin)**: 관리자 백오피스 (Blazor Server, 사용자 액션 기반 갱신)
- **Web (Services/)**: 인증(JWT), 블로그, 문의 관리 등 - **Web (Services/)**: 인증(JWT), 블로그, 문의 관리 등
### 2.3 기술 결정 이유 ### 2.3 기술 결정 이유
@@ -56,8 +56,9 @@ TaxBaik.Web ASP.NET Core 앱 (포트 5001)
- Blazor는 초기 응답이 shell HTML → SEO 불리 (블로그는 검색 유입이 핵심) - Blazor는 초기 응답이 shell HTML → SEO 불리 (블로그는 검색 유입이 핵심)
**왜 Blazor Server (관리자)인가?** **왜 Blazor Server (관리자)인가?**
- 관리자는 SEO 불필요 → WebSocket으로 실시간 UI 업데이트 가능 - 관리자는 SEO 불필요 → 복잡한 관리 UI를 .NET 컴포넌트로 구현 가능
- 복잡한 관리 UI를 쉽게 구현 - 데이터 변경 시 전체 사용자에게 push/broadcast하는 기능은 기본값으로 두지 않는다.
- 관리자 화면은 일반 웹페이지처럼 조회/저장/상태 변경 요청 시점에만 데이터를 갱신한다.
**왜 단일 앱 (통합 Web)인가?** **왜 단일 앱 (통합 Web)인가?**
- 공개 사이트와 관리자 화면을 같은 호스트와 PathBase에서 운영하면 라우팅과 인증 구성이 단순함 - 공개 사이트와 관리자 화면을 같은 호스트와 PathBase에서 운영하면 라우팅과 인증 구성이 단순함
@@ -325,7 +326,7 @@ location /taxbaik {
} }
``` ```
**참고**: 단일 `/taxbaik` 블록이 공개 사이트와 관리자(Blazor WebSocket)를 모두 처리합니다. 운영은 `5001` 통합 앱 기준이며, 설정 반영은 CI 배포로만 수행한다. **참고**: 단일 `/taxbaik` 블록이 공개 사이트와 관리자 Blazor 회로를 모두 처리합니다. 운영은 `5001` 통합 앱 기준이며, 설정 반영은 CI 배포로만 수행한다.
**Nginx 보안**: **Nginx 보안**:
- `Upgrade` 헤더는 Blazor WebSocket 경로에만 허용하고, 필요 없는 location에는 넣지 않는다. - `Upgrade` 헤더는 Blazor WebSocket 경로에만 허용하고, 필요 없는 location에는 넣지 않는다.
+4 -3
View File
@@ -58,13 +58,14 @@ sudo systemctl reload nginx
- `DEPLOY_HOST`: `178.104.200.7` - `DEPLOY_HOST`: `178.104.200.7`
- `DEPLOY_SSH_KEY_B64`: base64로 인코딩한 SSH 개인키 - `DEPLOY_SSH_KEY_B64`: base64로 인코딩한 SSH 개인키
- `TAXBAIK_ADMIN_TEST_PASSWORD`: 배포 검증용 관리자 비밀번호 - `TAXBAIK_ADMIN_TEST_PASSWORD`: 배포 검증용 관리자 비밀번호
- `Admin__PasswordResetToken`: 관리자 비밀번호 재설정 API용 서버 비밀값
2. 배포 워크플로우는 자동으로 실행: 2. 배포 워크플로우는 자동으로 실행:
``` ```
master 브랜치 push → build → publish → restart master 브랜치 push → build → publish → restart
``` ```
수동 배포는 사용하지 않습니다. 배포 이슈는 Gitea Actions 로그로 해결합니다. 수동 배포는 비상 롤백 외에는 사용하지 않습니다. 배포 이슈는 Gitea Actions 로그로 해결합니다.
## 마이그레이션 자동 실행 ## 마이그레이션 자동 실행
@@ -187,7 +188,7 @@ V003 마이그레이션에서 5개 포스트 자동 생성:
- [ ] SSL 인증서 적용 (Let's Encrypt) - [ ] SSL 인증서 적용 (Let's Encrypt)
- [ ] 도메인 연결 (현재는 IP 기반) - [ ] 도메인 연결 (현재는 IP 기반)
- [ ] 관리자 인증 로직 구현 (현재는 플레이스홀더) - [ ] 관리자 인증 보안 고도화 (rate limit, 비밀번호 교체 절차)
- [ ] 블로그 포스트 CRUD 기능 완성 - [ ] 블로그 포스트 수정 화면 완성
- [ ] Naver/Google Search Console 등록 - [ ] Naver/Google Search Console 등록
- [ ] 운영 관리자 비밀번호를 초기 시드값에서 교체하고 `TAXBAIK_ADMIN_TEST_PASSWORD` 갱신 - [ ] 운영 관리자 비밀번호를 초기 시드값에서 교체하고 `TAXBAIK_ADMIN_TEST_PASSWORD` 갱신
+4 -4
View File
@@ -1,9 +1,9 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
WORKDIR /app WORKDIR /app
COPY ./publish/admin/ . COPY ./publish/ .
EXPOSE 5002 EXPOSE 5001
ENTRYPOINT ["dotnet", "TaxBaik.Admin.dll"] ENTRYPOINT ["dotnet", "TaxBaik.Web.dll"]
+2 -2
View File
@@ -1,8 +1,8 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
WORKDIR /app WORKDIR /app
COPY ./publish/web/ . COPY ./publish/ .
EXPOSE 5001 EXPOSE 5001
+17 -26
View File
@@ -1,7 +1,7 @@
# TaxBaik 프로덕션 배포 체크리스트 # TaxBaik 프로덕션 배포 체크리스트
**작성일**: 2026-06-26 **작성일**: 2026-06-26
**상태**: 배포 준비 완료 **상태**: 운영 기준 정비 중
**대상**: 178.104.200.7 (Ubuntu 26.04) **대상**: 178.104.200.7 (Ubuntu 26.04)
--- ---
@@ -11,7 +11,7 @@
### 1. 코드 검증 ### 1. 코드 검증
- [ ] `dotnet build TaxBaik.sln -c Release` 성공 - [ ] `dotnet build TaxBaik.sln -c Release` 성공
- [ ] 모든 컴파일 오류 0개 - [ ] 모든 컴파일 오류 0개
- [ ] 경고 무시 (NuGet 보안 정보만) - [ ] 경고 0개 유지
### 2. Git 상태 확인 ### 2. Git 상태 확인
- [ ] 모든 변경사항 커밋됨 - [ ] 모든 변경사항 커밋됨
@@ -20,12 +20,10 @@
### 3. 발행 검증 ### 3. 발행 검증
```bash ```bash
dotnet publish TaxBaik.Web -c Release -o ./publish/web dotnet publish TaxBaik.Web -c Release -o ./publish
dotnet publish TaxBaik.Admin -c Release -o ./publish/admin
# 확인 # 확인
ls -lh ./publish/web/TaxBaik.Web.dll ls -lh ./publish/TaxBaik.Web.dll
ls -lh ./publish/admin/TaxBaik.Admin.dll
``` ```
--- ---
@@ -47,7 +45,7 @@ ssh kjh2064@178.104.200.7 'bash ~/SERVER_SETUP.sh'
# 3. 배포 디렉토리 생성 (자동으로 진행됨) # 3. 배포 디렉토리 생성 (자동으로 진행됨)
# ~/deployments/ # ~/deployments/
# ~/taxbaik_active # ~/taxbaik_active
# ~/taxbaik_admin_active # ~/taxbaik_active
``` ```
### 2단계: 첫 배포 (수동) ### 2단계: 첫 배포 (수동)
@@ -61,18 +59,14 @@ export DEPLOY_USER="kjh2064"
export DEPLOY_HOST="178.104.200.7" export DEPLOY_HOST="178.104.200.7"
# 배포 # 배포
rsync -avz --delete ./publish/web/ \ rsync -avz --delete ./publish/ \
$DEPLOY_USER@$DEPLOY_HOST:~/deployments/taxbaik_${TIMESTAMP}/ $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 ssh $DEPLOY_USER@$DEPLOY_HOST << EOF
ln -sfn ~/deployments/taxbaik_${TIMESTAMP} ~/taxbaik_active ln -sfn ~/deployments/taxbaik_${TIMESTAMP} ~/taxbaik_active
ln -sfn ~/deployments/taxbaik_admin_${TIMESTAMP} ~/taxbaik_admin_active sudo systemctl start taxbaik
sudo systemctl start taxbaik taxbaik-admin sudo systemctl status taxbaik
sudo systemctl status taxbaik taxbaik-admin
EOF EOF
``` ```
@@ -95,13 +89,13 @@ EOF
ssh kjh2064@178.104.200.7 ssh kjh2064@178.104.200.7
# 서비스 상태 # 서비스 상태
sudo systemctl status taxbaik taxbaik-admin sudo systemctl status taxbaik
# 프로세스 확인 # 프로세스 확인
ps aux | grep TaxBaik ps aux | grep TaxBaik
# 포트 확인 # 포트 확인
netstat -tlnp | grep -E '5001|5002' netstat -tlnp | grep -E '5001'
``` ```
### 2. 엔드포인트 테스트 ### 2. 엔드포인트 테스트
@@ -145,9 +139,6 @@ SELECT COUNT(*) FROM categories;
# 웹 서비스 # 웹 서비스
journalctl -u taxbaik -n 50 journalctl -u taxbaik -n 50
# 관리자 서비스
journalctl -u taxbaik-admin -n 50
# Nginx # Nginx
sudo tail -f /var/log/nginx/access.log | grep taxbaik 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` - username: `admin`
- password: `admin123` - password: `<TAXBAIK_ADMIN_TEST_PASSWORD>`
#### 대시보드 #### 대시보드
- [ ] 로그인 후 대시보드 로드 - [ ] 로그인 후 대시보드 로드
@@ -242,8 +233,8 @@ curl -I -H "Accept-Encoding: gzip" http://178.104.200.7/taxbaik/ | grep -i encod
# 터미널 1: 웹 서비스 로그 # 터미널 1: 웹 서비스 로그
ssh kjh2064@178.104.200.7 'journalctl -u taxbaik -f' ssh kjh2064@178.104.200.7 'journalctl -u taxbaik -f'
# 터미널 2: 관리자 서비스 로그 # 터미널 2: 통합 서비스 로그
ssh kjh2064@178.104.200.7 'journalctl -u taxbaik-admin -f' ssh kjh2064@178.104.200.7 'journalctl -u taxbaik -f'
# 터미널 3: Nginx 로그 # 터미널 3: Nginx 로그
ssh kjh2064@178.104.200.7 'sudo tail -f /var/log/nginx/access.log | grep taxbaik' 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 #!/bin/bash
curl -f http://127.0.0.1:5001/taxbaik || systemctl restart taxbaik 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가 자동으로 배포 # 2. Gitea Actions가 자동으로 배포
# 또는 수동 배포: # 또는 수동 배포:
TIMESTAMP=$(date +%Y%m%d_%H%M%S) TIMESTAMP=$(date +%Y%m%d_%H%M%S)
dotnet publish TaxBaik.Web -c Release -o ./publish/web dotnet publish TaxBaik.Web -c Release -o ./publish
rsync -avz ./publish/web/ kjh2064@178.104.200.7:~/deployments/taxbaik_${TIMESTAMP}/ 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" ssh kjh2064@178.104.200.7 "ln -sfn ~/deployments/taxbaik_${TIMESTAMP} ~/taxbaik_active && sudo systemctl restart taxbaik"
``` ```
@@ -306,7 +297,7 @@ EOF
- [ ] 모든 엔드포인트 HTTP 200 응답 - [ ] 모든 엔드포인트 HTTP 200 응답
- [ ] 데이터베이스 마이그레이션 완료 (schema_migrations 테이블 확인) - [ ] 데이터베이스 마이그레이션 완료 (schema_migrations 테이블 확인)
- [ ] 초기 5개 블로그 포스트 DB에 존재 - [ ] 초기 5개 블로그 포스트 DB에 존재
- [ ] 로그인 기능 정상 (admin/admin123) - [ ] 로그인 기능 정상 (admin/<TAXBAIK_ADMIN_TEST_PASSWORD>)
- [ ] 문의 폼 제출 → DB 저장 확인 - [ ] 문의 폼 제출 → DB 저장 확인
- [ ] Nginx 프록시 정상 작동 - [ ] Nginx 프록시 정상 작동
- [ ] 응답 gzip 압축 확인 - [ ] 응답 gzip 압축 확인
+8 -6
View File
@@ -22,13 +22,13 @@ TaxBaik는 세무사 백원숙의 전문성을 온라인으로 표현하기 위
| 계층 | 기술 | | 계층 | 기술 |
|-----|------| |-----|------|
| **백엔드** | ASP.NET Core 8, C# | | **백엔드** | ASP.NET Core 10, C# |
| **공개 사이트** | Razor Pages (SSR) | | **공개 사이트** | Razor Pages (SSR) |
| **관리자** | Blazor Server + MudBlazor | | **관리자** | Blazor Server + MudBlazor |
| **데이터베이스** | PostgreSQL 18.4 | | **데이터베이스** | PostgreSQL 18.4 |
| **ORM** | Dapper | | **ORM** | Dapper |
| **리버스 프록시** | Nginx | | **리버스 프록시** | Nginx |
| **배포** | Gitea Actions CI/CD, systemd | | **배포** | Gitea Actions CI/CD, systemd 단일 서비스 |
| **아키텍처** | DDD (Domain-Driven Design), Layered Architecture | | **아키텍처** | DDD (Domain-Driven Design), Layered Architecture |
--- ---
@@ -103,7 +103,7 @@ TaxBaik/
### 개발 환경 설정 ### 개발 환경 설정
**필수 요구사항:** **필수 요구사항:**
- .NET 8.0 SDK - .NET 10.0 SDK
- PostgreSQL 18.4 - PostgreSQL 18.4
- Git - Git
@@ -151,16 +151,18 @@ master 브랜치에 푸시하면 자동으로:
1. ✅ .NET 빌드 (Release) 1. ✅ .NET 빌드 (Release)
2. ✅ 단위 테스트 실행 2. ✅ 단위 테스트 실행
3.`TaxBaik.Web` 게시 3.`TaxBaik.Web` 게시
4.서버 반영 및 서비스 재시작 4.원격 서버 배포 디렉토리 업로드 및 `taxbaik_active` 심링크 교체
5.`/taxbaik/`, `/taxbaik/admin/login`, `/taxbaik/api/auth/login` 헬스 체크 5.systemd `taxbaik` 단일 서비스 재시작
6.`/taxbaik/`, `/taxbaik/admin/login`, `/taxbaik/api/auth/login` 헬스 체크
**필수 Gitea Secrets 설정:** **필수 Gitea Secrets 설정:**
- `DEPLOY_USER`: kjh2064 - `DEPLOY_USER`: kjh2064
- `DEPLOY_HOST`: 178.104.200.7 - `DEPLOY_HOST`: 178.104.200.7
- `DEPLOY_SSH_KEY_B64`: base64로 인코딩한 SSH 개인키 - `DEPLOY_SSH_KEY_B64`: base64로 인코딩한 SSH 개인키
- `TAXBAIK_ADMIN_TEST_PASSWORD`: 배포 검증용 관리자 비밀번호 - `TAXBAIK_ADMIN_TEST_PASSWORD`: 배포 검증용 관리자 비밀번호
- `Admin__PasswordResetToken`: 관리자 비밀번호 재설정 API용 서버 비밀값
수동 배포는 사용하지 않습니다. 실패 시 [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)의 CI 점검 절차를 따릅니다. 수동 배포는 비상 롤백 절차 외에는 사용하지 않습니다. 실패 시 [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)의 CI 점검 절차를 따릅니다.
--- ---
@@ -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<ValidationException>(() => 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<BlogPost> Posts { get; init; } = [];
public Task<BlogPost?> GetByIdAsync(int id, CancellationToken cancellationToken = default) =>
Task.FromResult(Posts.FirstOrDefault(x => x.Id == id));
public Task<BlogPost?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default) =>
Task.FromResult(Posts.FirstOrDefault(x => x.Slug == slug && x.IsPublished));
public Task<(IEnumerable<BlogPost> 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<BlogPost>, int)>((items, items.Count));
}
public Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<IEnumerable<BlogPost>>(Posts);
public Task<int> 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;
}
}
@@ -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<ValidationException>(() => 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<Inquiry> Inquiries { get; } = [];
public Task<int> CreateAsync(Inquiry inquiry, CancellationToken cancellationToken = default)
{
inquiry.Id = Inquiries.Count + 1;
Inquiries.Add(inquiry);
return Task.FromResult(inquiry.Id);
}
public Task<Inquiry?> GetByIdAsync(int id, CancellationToken cancellationToken = default) =>
Task.FromResult(Inquiries.FirstOrDefault(x => x.Id == id));
public Task<(IEnumerable<Inquiry> 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<Inquiry>, 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;
}
}
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\TaxBaik.Application\TaxBaik.Application.csproj" />
</ItemGroup>
</Project>
@@ -2,13 +2,13 @@ namespace TaxBaik.Application.DTOs;
public class CreateBlogPostDto public class CreateBlogPostDto
{ {
public string Title { get; set; } public required string Title { get; set; }
public string Content { get; set; } public required string Content { get; set; }
public int? CategoryId { get; set; } public int? CategoryId { get; set; }
public string Tags { get; set; } public string? Tags { get; set; }
public string SeoTitle { get; set; } public string? SeoTitle { get; set; }
public string SeoDescription { get; set; } public string? SeoDescription { get; set; }
public string ThumbnailUrl { get; set; } public string? ThumbnailUrl { get; set; }
public bool IsPublished { get; set; } public bool IsPublished { get; set; }
public int AuthorId { get; set; } public int? AuthorId { get; set; }
} }
+54 -3
View File
@@ -12,7 +12,7 @@ public class BlogService(IBlogPostRepository repository)
public async Task<(IEnumerable<BlogPost>, int)> GetPublishedPagedAsync( public async Task<(IEnumerable<BlogPost>, int)> GetPublishedPagedAsync(
int page, int pageSize, int? categoryId = null, CancellationToken ct = default) => 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<IEnumerable<BlogPost>> GetAllAsync(CancellationToken ct = default) => public async Task<IEnumerable<BlogPost>> GetAllAsync(CancellationToken ct = default) =>
await repository.GetAllForAdminAsync(ct); await repository.GetAllForAdminAsync(ct);
@@ -22,8 +22,11 @@ public class BlogService(IBlogPostRepository repository)
public async Task<int> CreateAsync(BlogPost post, CancellationToken ct = default) public async Task<int> CreateAsync(BlogPost post, CancellationToken ct = default)
{ {
post.Slug = GenerateSlug(post.Title); ValidatePost(post);
post.IsPublished = false; 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); return await repository.CreateAsync(post, ct);
} }
@@ -65,6 +68,10 @@ public class BlogService(IBlogPostRepository repository)
post.SeoDescription = dto.SeoDescription; post.SeoDescription = dto.SeoDescription;
post.ThumbnailUrl = dto.ThumbnailUrl; post.ThumbnailUrl = dto.ThumbnailUrl;
post.IsPublished = dto.IsPublished; post.IsPublished = dto.IsPublished;
post.PublishedAt = dto.IsPublished
? post.PublishedAt ?? DateTime.UtcNow
: null;
ValidatePost(post);
await UpdateAsync(post, ct); await UpdateAsync(post, ct);
return post; return post;
@@ -81,6 +88,50 @@ public class BlogService(IBlogPostRepository repository)
var slug = Regex.Replace(title.ToLowerInvariant(), @"[^\w\s-]", ""); var slug = Regex.Replace(title.ToLowerInvariant(), @"[^\w\s-]", "");
slug = Regex.Replace(slug, @"\s+", "-"); slug = Regex.Replace(slug, @"\s+", "-");
slug = Regex.Replace(slug, @"-+", "-").Trim('-'); slug = Regex.Replace(slug, @"-+", "-").Trim('-');
if (string.IsNullOrWhiteSpace(slug))
slug = $"post-{DateTime.UtcNow:yyyyMMddHHmmss}";
return slug.Length > 100 ? slug[..100] : slug; return slug.Length > 100 ? slug[..100] : slug;
} }
private async Task<string> GenerateUniqueSlugAsync(string title, int? existingPostId = null, CancellationToken ct = default)
{
var baseSlug = GenerateSlug(title);
var slug = baseSlug;
var suffix = 2;
var allPosts = (await repository.GetAllForAdminAsync(ct)).ToList();
while (allPosts.Any(x => x.Id != existingPostId && string.Equals(x.Slug, slug, StringComparison.OrdinalIgnoreCase)))
{
var suffixText = $"-{suffix++}";
var maxBaseLength = Math.Max(1, 100 - suffixText.Length);
slug = $"{baseSlug[..Math.Min(baseSlug.Length, maxBaseLength)]}{suffixText}";
}
return slug;
}
private static void ValidatePost(BlogPost post)
{
if (string.IsNullOrWhiteSpace(post.Title))
throw new ValidationException("제목을 입력하세요.");
if (string.IsNullOrWhiteSpace(post.Content))
throw new ValidationException("본문을 입력하세요.");
if (post.IsPublished && string.IsNullOrWhiteSpace(post.SeoTitle))
throw new ValidationException("발행하려면 SEO 제목을 입력하세요.");
if (post.IsPublished && string.IsNullOrWhiteSpace(post.SeoDescription))
throw new ValidationException("발행하려면 SEO 설명을 입력하세요.");
}
private static int NormalizePage(int page) => Math.Max(1, page);
private static int NormalizePageSize(int pageSize) => Math.Clamp(pageSize, 1, 100);
public async Task<(int TotalPosts, int PublishedPosts)> GetStatsAsync(CancellationToken ct = default)
{
var posts = (await repository.GetAllForAdminAsync(ct)).ToList();
return (posts.Count, posts.Count(x => x.IsPublished));
}
} }
+27 -5
View File
@@ -2,6 +2,7 @@ namespace TaxBaik.Application.Services;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using TaxBaik.Domain.Entities; using TaxBaik.Domain.Entities;
using TaxBaik.Domain.Enums;
using TaxBaik.Domain.Interfaces; using TaxBaik.Domain.Interfaces;
public class InquiryService(IInquiryRepository repository) public class InquiryService(IInquiryRepository repository)
@@ -10,7 +11,7 @@ public class InquiryService(IInquiryRepository repository)
public async Task<int> SubmitAsync( public async Task<int> SubmitAsync(
string name, string phone, string serviceType, string message, 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)) if (string.IsNullOrWhiteSpace(name))
throw new ValidationException("이름을 입력하세요."); throw new ValidationException("이름을 입력하세요.");
@@ -25,10 +26,11 @@ public class InquiryService(IInquiryRepository repository)
{ {
Name = name.Trim(), Name = name.Trim(),
Phone = phone.Trim(), Phone = phone.Trim(),
Email = string.IsNullOrWhiteSpace(email) ? null : email.Trim(),
ServiceType = serviceType ?? "기타", ServiceType = serviceType ?? "기타",
Message = message.Trim(), Message = message.Trim(),
IpAddress = ipAddress, IpAddress = ipAddress,
Status = "new", Status = InquiryStatusMapper.ToStorageValue(InquiryStatus.New),
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
@@ -40,10 +42,30 @@ public class InquiryService(IInquiryRepository repository)
public async Task<(IEnumerable<Inquiry>, int)> GetPagedAsync( public async Task<(IEnumerable<Inquiry>, int)> GetPagedAsync(
int page, int pageSize, string? status = null, CancellationToken ct = default) => 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) => public async Task UpdateStatusAsync(int id, string status, CancellationToken ct = default)
await repository.UpdateStatusAsync(id, status, ct); {
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 public class ValidationException : Exception
@@ -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";
}
}
-2
View File
@@ -1,7 +1,5 @@
namespace TaxBaik.Domain.Entities; namespace TaxBaik.Domain.Entities;
using TaxBaik.Domain.Enums;
public class Inquiry public class Inquiry
{ {
public int Id { get; set; } public int Id { get; set; }
+3 -4
View File
@@ -2,8 +2,7 @@ namespace TaxBaik.Domain.Enums;
public enum InquiryStatus public enum InquiryStatus
{ {
New = 0, // 새로운 문의 New = 0,
Contacted = 1, // 연락 완료 Contacted = 1,
Contracted = 2, // 계약 체결 Completed = 2
Closed = 3 // 종료
} }
@@ -5,4 +5,6 @@ public interface IAdminUserRepository
Task<Entities.AdminUser?> GetByUsernameAsync(string username); Task<Entities.AdminUser?> GetByUsernameAsync(string username);
Task<Entities.AdminUser?> GetByIdAsync(int id); Task<Entities.AdminUser?> GetByIdAsync(int id);
Task CreateAsync(Entities.AdminUser user); Task CreateAsync(Entities.AdminUser user);
Task UpdatePasswordHashAsync(int id, string passwordHash);
Task UpdateLastLoginAtAsync(int id);
} }
@@ -169,8 +169,8 @@ public class MigrationRunner
private class Migration private class Migration
{ {
public string Version { get; set; } public required string Version { get; set; }
public string Description { get; set; } public required string Description { get; set; }
public string Sql { get; set; } public required string Sql { get; set; }
} }
} }
@@ -49,4 +49,20 @@ public class AdminUserRepository : BaseRepository, IAdminUserRepository
"INSERT INTO admin_users (username, password_hash, created_at) VALUES (@username, @passwordHash, NOW())", "INSERT INTO admin_users (username, password_hash, created_at) VALUES (@username, @passwordHash, NOW())",
new { username = user.Username, passwordHash = user.PasswordHash }); 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 });
}
} }
@@ -6,7 +6,7 @@
<PackageReference Include="Dapper" Version="2.1.15" /> <PackageReference Include="Dapper" Version="2.1.15" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Npgsql" Version="8.0.1" /> <PackageReference Include="Npgsql" Version="10.0.3" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -11,8 +11,8 @@
</MudDialog> </MudDialog>
@code { @code {
[CascadingParameter] MudDialogInstance MudDialog { get; set; } [CascadingParameter] MudDialogInstance? MudDialog { get; set; }
void Cancel() => MudDialog.Cancel(); void Cancel() => MudDialog?.Cancel();
void Confirm() => MudDialog.Close(DialogResult.Ok(true)); void Confirm() => MudDialog?.Close(DialogResult.Ok(true));
} }
@@ -1,5 +1,5 @@
@using TaxBaik.Domain.Interfaces @using TaxBaik.Application.Services
@inject IInquiryRepository InquiryRepository @inject InquiryService InquiryService
<MudSimpleTable Striped="true" Dense="true" Class="mt-4"> <MudSimpleTable Striped="true" Dense="true" Class="mt-4">
<thead> <thead>
@@ -39,7 +39,7 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var (items, _) = await InquiryRepository.GetPagedAsync(1, 1000); var (items, _) = await InquiryService.GetPagedAsync(1, 1000);
inquiries = items.ToList(); inquiries = items.ToList();
FilterInquiries(); FilterInquiries();
} }
@@ -1,11 +1,12 @@
@page "/admin/blog/create" @page "/admin/blog/create"
@using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services @using TaxBaik.Application.Services
@using TaxBaik.Domain.Interfaces @using TaxBaik.Domain.Interfaces
@attribute [Authorize] @attribute [Authorize]
@inject BlogService BlogService @inject BlogService BlogService
@inject ICategoryRepository CategoryRepository @inject ICategoryRepository CategoryRepository
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject Snackbar Snackbar @inject ISnackbar Snackbar
<PageTitle>새 포스트 작성</PageTitle> <PageTitle>새 포스트 작성</PageTitle>
@@ -49,7 +50,7 @@
</MudPaper> </MudPaper>
@code { @code {
private MudForm form; private MudForm? form;
private List<Domain.Entities.Category> categories = []; private List<Domain.Entities.Category> categories = [];
private CreatePostModel model = new(); private CreatePostModel model = new();
@@ -60,18 +61,43 @@
private async Task SavePost() private async Task SavePost()
{ {
// TODO: Implement BlogService.CreateAsync if (form == null)
Navigation.NavigateTo("/taxbaik/admin/blog"); 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 private class CreatePostModel
{ {
public string Title { get; set; } public string Title { get; set; } = "";
public string Content { get; set; } public string Content { get; set; } = "";
public int CategoryId { get; set; } public int? CategoryId { get; set; }
public string Tags { get; set; } public string? Tags { get; set; }
public string SeoTitle { get; set; } public string? SeoTitle { get; set; }
public string SeoDescription { get; set; } public string? SeoDescription { get; set; }
public bool IsPublished { get; set; } public bool IsPublished { get; set; }
} }
} }
@@ -2,7 +2,7 @@
@attribute [Authorize] @attribute [Authorize]
@inject IApiClient ApiClient @inject IApiClient ApiClient
@inject DialogService DialogService @inject DialogService DialogService
@inject Snackbar Snackbar @inject ISnackbar Snackbar
<PageTitle>블로그 관리</PageTitle> <PageTitle>블로그 관리</PageTitle>
@@ -17,7 +17,8 @@
<PropertyColumn Property="x => x.Title" Title="제목" /> <PropertyColumn Property="x => x.Title" Title="제목" />
<PropertyColumn Property="x => x.IsPublished" Title="발행"> <PropertyColumn Property="x => x.IsPublished" Title="발행">
<CellTemplate Context="cell"> <CellTemplate Context="cell">
<MudCheckBox @bind-Checked="@cell.Item.IsPublished" /> <MudCheckBox T="bool" Value="@cell.Item.IsPublished"
ValueChanged="@(async (bool value) => await TogglePublish(cell.Item, value))" />
</CellTemplate> </CellTemplate>
</PropertyColumn> </PropertyColumn>
<PropertyColumn Property="x => x.ViewCount" Title="조회수" /> <PropertyColumn Property="x => x.ViewCount" Title="조회수" />
@@ -54,14 +55,37 @@
isLoading = false; 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<TaxBaik.Domain.Entities.BlogPost>($"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) private async Task DeletePost(int postId)
{ {
await ApiClient.DeleteAsync($"blog/{postId}"); await ApiClient.DeleteAsync($"blog/{postId}");
Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success);
await LoadPosts(); await LoadPosts();
} }
} }
@@ -1,8 +1,7 @@
@page "/admin/dashboard" @page "/admin/dashboard"
@using TaxBaik.Application.Services @using TaxBaik.Application.Services
@using TaxBaik.Domain.Interfaces
@attribute [Authorize] @attribute [Authorize]
@inject IInquiryRepository InquiryRepository @inject InquiryService InquiryService
@inject BlogService BlogService @inject BlogService BlogService
<PageTitle>대시보드</PageTitle> <PageTitle>대시보드</PageTitle>
@@ -80,13 +79,14 @@
protected override async Task OnInitializedAsync() 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(); recentInquiries = inquiries.OrderByDescending(x => x.CreatedAt).Take(5).ToList();
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
thisMonthInquiries = inquiries.Count(x => x.CreatedAt.Year == now.Year && x.CreatedAt.Month == now.Month); thisMonthInquiries = inquiries.Count(x => x.CreatedAt.Year == now.Year && x.CreatedAt.Month == now.Month);
newInquiries = inquiries.Count(x => x.Status == "new"); newInquiries = inquiries.Count(x => x.Status == "new");
totalPosts = 0; // TODO: get from blog service var stats = await BlogService.GetStatsAsync();
publishedPosts = 0; // TODO: get from blog service totalPosts = stats.TotalPosts;
publishedPosts = stats.PublishedPosts;
} }
} }
@@ -1,8 +1,9 @@
@page "/admin/inquiries/{InquiryId:int}" @page "/admin/inquiries/{InquiryId:int}"
@using TaxBaik.Domain.Interfaces @using TaxBaik.Application.Services
@attribute [Authorize] @attribute [Authorize]
@inject IInquiryRepository InquiryRepository @inject InquiryService InquiryService
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar
<PageTitle>문의 상세</PageTitle> <PageTitle>문의 상세</PageTitle>
@@ -36,7 +37,7 @@
</MudItem> </MudItem>
<MudItem xs="12"> <MudItem xs="12">
<MudText Typo="Typo.subtitle1">상태</MudText> <MudText Typo="Typo.subtitle1">상태</MudText>
<MudSelect @bind-Value="inquiry.Status" Label="상태 변경"> <MudSelect T="string" Value="inquiry.Status" ValueChanged="@((string status) => OnStatusChanged(status))" Label="상태 변경">
<MudSelectItem Value="@("new")">신규</MudSelectItem> <MudSelectItem Value="@("new")">신규</MudSelectItem>
<MudSelectItem Value="@("contacted")">연락함</MudSelectItem> <MudSelectItem Value="@("contacted")">연락함</MudSelectItem>
<MudSelectItem Value="@("completed")">완료</MudSelectItem> <MudSelectItem Value="@("completed")">완료</MudSelectItem>
@@ -54,11 +55,27 @@ else
[Parameter] [Parameter]
public int InquiryId { get; set; } public int InquiryId { get; set; }
private Domain.Entities.Inquiry inquiry; private Domain.Entities.Inquiry? inquiry;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var (inquiries, _) = await InquiryRepository.GetPagedAsync(1, 1000); inquiry = await InquiryService.GetByIdAsync(InquiryId);
inquiry = inquiries.FirstOrDefault(x => x.Id == 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);
}
} }
} }
+63 -2
View File
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
using TaxBaik.Web.Services; using TaxBaik.Web.Services;
namespace TaxBaik.Web.Controllers; namespace TaxBaik.Web.Controllers;
@@ -18,14 +19,61 @@ public class AuthController : ControllerBase
public async Task<IActionResult> Login([FromBody] LoginRequest request) public async Task<IActionResult> Login([FromBody] LoginRequest request)
{ {
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) 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); var token = await _authService.AuthenticateAndGenerateTokenAsync(request.Username, request.Password);
if (token == null) 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 }); return Ok(new { token, expiresIn = 28800 });
} }
[HttpPost("change-password")]
[Microsoft.AspNetCore.Authorization.Authorize]
public async Task<IActionResult> 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<IActionResult> 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 public class LoginRequest
@@ -33,3 +81,16 @@ public class LoginRequest
public string Username { get; set; } = string.Empty; public string Username { get; set; } = string.Empty;
public string Password { 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;
}
+21 -10
View File
@@ -28,7 +28,7 @@ public class BlogController : ControllerBase
{ {
var post = await _blogService.GetBySlugAsync(slug); var post = await _blogService.GetBySlugAsync(slug);
if (post == null) if (post == null)
return NotFound(new { message = "Post not found" }); return NotFound(new ProblemDetails { Title = "포스트를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
return Ok(post); return Ok(post);
} }
@@ -44,21 +44,32 @@ public class BlogController : ControllerBase
[Authorize] [Authorize]
public async Task<IActionResult> Create([FromBody] CreateBlogPostDto dto) public async Task<IActionResult> Create([FromBody] CreateBlogPostDto dto)
{ {
if (string.IsNullOrWhiteSpace(dto.Title) || string.IsNullOrWhiteSpace(dto.Content)) try
return BadRequest(new { message = "Title and content are required" }); {
var result = await _blogService.CreateAsync(dto);
var result = await _blogService.CreateAsync(dto); return CreatedAtAction(nameof(GetBySlug), new { slug = result.Slug }, result);
return CreatedAtAction(nameof(GetBySlug), new { slug = result.Slug }, result); }
catch (ValidationException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
} }
[HttpPut("{id}")] [HttpPut("{id}")]
[Authorize] [Authorize]
public async Task<IActionResult> Update(int id, [FromBody] CreateBlogPostDto dto) public async Task<IActionResult> Update(int id, [FromBody] CreateBlogPostDto dto)
{ {
var result = await _blogService.UpdateAsync(id, dto); try
if (result == null) {
return NotFound(new { message = "Post not found" }); var result = await _blogService.UpdateAsync(id, dto);
return Ok(result); 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}")] [HttpDelete("{id}")]
+31 -14
View File
@@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using TaxBaik.Application.Services; using TaxBaik.Application.Services;
using TaxBaik.Domain.Interfaces;
namespace TaxBaik.Web.Controllers; namespace TaxBaik.Web.Controllers;
@@ -10,29 +9,40 @@ namespace TaxBaik.Web.Controllers;
public class InquiryController : ControllerBase public class InquiryController : ControllerBase
{ {
private readonly InquiryService _inquiryService; private readonly InquiryService _inquiryService;
private readonly IInquiryRepository _inquiryRepository;
public InquiryController(InquiryService inquiryService, IInquiryRepository inquiryRepository) public InquiryController(InquiryService inquiryService)
{ {
_inquiryService = inquiryService; _inquiryService = inquiryService;
_inquiryRepository = inquiryRepository;
} }
[HttpPost] [HttpPost]
public async Task<IActionResult> Submit([FromBody] SubmitInquiryRequest request) public async Task<IActionResult> Submit([FromBody] SubmitInquiryRequest request)
{ {
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Phone)) 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); try
return Ok(new { message = "Inquiry submitted successfully" }); {
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] [HttpGet]
[Authorize] [Authorize]
public async Task<IActionResult> GetPaged([FromQuery] int page = 1, [FromQuery] int pageSize = 20) public async Task<IActionResult> 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 }); return Ok(new { data = inquiries, total, page, pageSize });
} }
@@ -40,9 +50,9 @@ public class InquiryController : ControllerBase
[Authorize] [Authorize]
public async Task<IActionResult> GetById(int id) public async Task<IActionResult> GetById(int id)
{ {
var inquiry = await _inquiryRepository.GetByIdAsync(id); var inquiry = await _inquiryService.GetByIdAsync(id);
if (inquiry == null) if (inquiry == null)
return NotFound(new { message = "Inquiry not found" }); return NotFound(new ProblemDetails { Title = "문의를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
return Ok(inquiry); return Ok(inquiry);
} }
@@ -50,12 +60,19 @@ public class InquiryController : ControllerBase
[Authorize] [Authorize]
public async Task<IActionResult> UpdateStatus(int id, [FromBody] UpdateStatusRequest request) public async Task<IActionResult> UpdateStatus(int id, [FromBody] UpdateStatusRequest request)
{ {
var inquiry = await _inquiryRepository.GetByIdAsync(id); var inquiry = await _inquiryService.GetByIdAsync(id);
if (inquiry == null) if (inquiry == null)
return NotFound(new { message = "Inquiry not found" }); return NotFound(new ProblemDetails { Title = "문의를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
await _inquiryRepository.UpdateStatusAsync(id, request.Status); try
return Ok(new { message = "Status updated" }); {
await _inquiryService.UpdateStatusAsync(id, request.Status);
return Ok(new { message = "상태가 변경되었습니다." });
}
catch (ValidationException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
} }
} }
+10 -12
View File
@@ -1,12 +1,13 @@
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities; using TaxBaik.Domain.Entities;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Blog; namespace TaxBaik.Web.Pages.Blog;
public class BlogIndexModel : PageModel public class BlogIndexModel : PageModel
{ {
private readonly IApiClient _apiClient; private readonly BlogService _blogService;
private readonly CategoryService _categoryService;
public List<BlogPost> Posts { get; set; } = []; public List<BlogPost> Posts { get; set; } = [];
public List<Category> Categories { get; set; } = []; public List<Category> Categories { get; set; } = [];
@@ -15,9 +16,10 @@ public class BlogIndexModel : PageModel
public int? SelectedCategoryId { get; set; } public int? SelectedCategoryId { get; set; }
private const int PageSize = 12; 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) public async Task OnGetAsync(int page = 1, int? categoryId = null)
@@ -27,15 +29,11 @@ public class BlogIndexModel : PageModel
CurrentPage = page; CurrentPage = page;
SelectedCategoryId = categoryId; SelectedCategoryId = categoryId;
var categories = await _apiClient.GetAsync<List<Category>>("category"); Categories = (await _categoryService.GetAllAsync()).ToList();
Categories = categories ?? [];
var blogsResponse = await _apiClient.GetAsync<BlogApiResponse>($"blog?page={page}&pageSize={PageSize}"); var (posts, total) = await _blogService.GetPublishedPagedAsync(page, PageSize, categoryId);
if (blogsResponse != null) Posts = posts.ToList();
{ TotalPages = (total + PageSize - 1) / PageSize;
Posts = blogsResponse.Data ?? [];
TotalPages = (blogsResponse.Total + PageSize - 1) / PageSize;
}
} }
catch catch
{ {
+2
View File
@@ -16,6 +16,8 @@
} }
<form method="post"> <form method="post">
<div asp-validation-summary="ModelOnly" class="text-danger mb-3"></div>
<div class="mb-3"> <div class="mb-3">
<label for="name" class="form-label">이름 <span class="text-danger">*</span></label> <label for="name" class="form-label">이름 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="Name" required /> <input type="text" class="form-control" id="name" name="Name" required />
+13 -11
View File
@@ -1,12 +1,12 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Web.Services; using TaxBaik.Application.Services;
namespace TaxBaik.Web.Pages; namespace TaxBaik.Web.Pages;
public class ContactModel : PageModel public class ContactModel : PageModel
{ {
private readonly IApiClient _apiClient; private readonly InquiryService _inquiryService;
[BindProperty] [BindProperty]
public string Name { get; set; } = ""; public string Name { get; set; } = "";
@@ -26,9 +26,9 @@ public class ContactModel : PageModel
[BindProperty] [BindProperty]
public bool Agree { get; set; } public bool Agree { get; set; }
public ContactModel(IApiClient apiClient) public ContactModel(InquiryService inquiryService)
{ {
_apiClient = apiClient; _inquiryService = inquiryService;
} }
public async Task<IActionResult> OnPostAsync() public async Task<IActionResult> OnPostAsync()
@@ -38,19 +38,21 @@ public class ContactModel : PageModel
try try
{ {
var inquiry = new await _inquiryService.SubmitAsync(
{
Name, Name,
Phone, Phone,
Email,
ServiceType, ServiceType,
Message Message,
}; Email,
HttpContext.Connection.RemoteIpAddress?.ToString());
await _apiClient.PostAsync<object>("inquiry", inquiry);
TempData["Success"] = "상담 신청이 접수되었습니다. 빠른 시간 내에 연락드리겠습니다."; TempData["Success"] = "상담 신청이 접수되었습니다. 빠른 시간 내에 연락드리겠습니다.";
return RedirectToPage(); return RedirectToPage();
} }
catch (ValidationException ex)
{
ModelState.AddModelError("", ex.Message);
return Page();
}
catch catch
{ {
ModelState.AddModelError("", "시스템 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); ModelState.AddModelError("", "시스템 오류가 발생했습니다. 잠시 후 다시 시도해주세요.");
+6 -15
View File
@@ -1,27 +1,26 @@
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities; using TaxBaik.Domain.Entities;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages; namespace TaxBaik.Web.Pages;
public class IndexModel : PageModel public class IndexModel : PageModel
{ {
private readonly IApiClient _apiClient; private readonly BlogService _blogService;
public List<BlogPost> RecentPosts { get; set; } = []; public List<BlogPost> RecentPosts { get; set; } = [];
public IndexModel(IApiClient apiClient) public IndexModel(BlogService blogService)
{ {
_apiClient = apiClient; _blogService = blogService;
} }
public async Task OnGetAsync() public async Task OnGetAsync()
{ {
try try
{ {
var response = await _apiClient.GetAsync<BlogApiResponse>("blog?page=1&pageSize=3"); var (posts, _) = await _blogService.GetPublishedPagedAsync(1, 3);
if (response?.Data != null) RecentPosts = posts.ToList();
RecentPosts = response.Data.ToList();
} }
catch catch
{ {
@@ -29,11 +28,3 @@ public class IndexModel : PageModel
} }
} }
} }
public class BlogApiResponse
{
public List<BlogPost> Data { get; set; } = [];
public int Total { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
}
+26 -6
View File
@@ -4,6 +4,7 @@ using System.Text.Encodings.Web;
using System.Text.Unicode; using System.Text.Unicode;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.ResponseCompression; using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using MudBlazor.Services; using MudBlazor.Services;
@@ -12,16 +13,23 @@ using TaxBaik.Infrastructure;
using TaxBaik.Web.Services; using TaxBaik.Web.Services;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
var isProduction = builder.Environment.IsProduction();
// Controllers (API) // Controllers (API)
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
// Razor Pages + Blazor Server 통합 // Razor Pages + Blazor Server 통합
builder.Services.AddRazorPages(); builder.Services.AddRazorPages();
builder.Services.AddRazorComponents().AddInteractiveServerComponents(); builder.Services.AddRazorComponents().AddInteractiveServerComponents();
// JWT 인증 // 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"); 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); var key = Encoding.ASCII.GetBytes(jwtKey);
builder.Services.AddAuthentication(opts => builder.Services.AddAuthentication(opts =>
@@ -35,8 +43,12 @@ builder.Services.AddAuthentication(opts =>
{ {
ValidateIssuerSigningKey = true, ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(key), IssuerSigningKey = new SymmetricSecurityKey(key),
ValidateIssuer = false, ValidateIssuer = true,
ValidateAudience = false ValidIssuer = "taxbaik-admin",
ValidateAudience = true,
ValidAudience = "taxbaik-admin-client",
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
}; };
}); });
@@ -46,6 +58,7 @@ builder.Services.AddScoped<CustomAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<CustomAuthenticationStateProvider>()); builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<CustomAuthenticationStateProvider>());
builder.Services.AddScoped<ILocalStorageService, LocalStorageService>(); builder.Services.AddScoped<ILocalStorageService, LocalStorageService>();
builder.Services.AddCascadingAuthenticationState(); builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorization();
builder.Services.AddAuthorizationCore(); builder.Services.AddAuthorizationCore();
// HTTP Client for API // HTTP Client for API
@@ -82,21 +95,27 @@ builder.Services.AddSingleton(versionInfo);
var app = builder.Build(); var app = builder.Build();
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
// Run migrations on startup (non-blocking for development) // Run migrations on startup (non-blocking for development)
try try
{ {
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
var connectionFactory = scope.ServiceProvider.GetRequiredService<TaxBaik.Domain.Interfaces.IDbConnectionFactory>(); var connectionFactory = scope.ServiceProvider.GetRequiredService<TaxBaik.Domain.Interfaces.IDbConnectionFactory>();
var cs = builder.Configuration.GetConnectionString("Default") var migrationRunner = new TaxBaik.Infrastructure.Data.MigrationRunner(connectionString, connectionFactory);
?? throw new InvalidOperationException("Missing connection string");
var migrationRunner = new TaxBaik.Infrastructure.Data.MigrationRunner(cs, connectionFactory);
await migrationRunner.RunAsync(); await migrationRunner.RunAsync();
} }
} }
catch (Exception ex) 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"); app.UsePathBase("/taxbaik");
@@ -115,6 +134,7 @@ if (!app.Environment.IsDevelopment())
// API + Razor Pages + Blazor 매핑 // API + Razor Pages + Blazor 매핑
app.MapControllers(); app.MapControllers();
app.MapHealthChecks("/healthz");
app.MapRazorPages(); app.MapRazorPages();
app.MapRazorComponents<TaxBaik.Web.Components.Admin.App>().AddInteractiveServerRenderMode(); app.MapRazorComponents<TaxBaik.Web.Components.Admin.App>().AddInteractiveServerRenderMode();
+14 -5
View File
@@ -1,5 +1,6 @@
namespace TaxBaik.Web.Services; namespace TaxBaik.Web.Services;
using Microsoft.AspNetCore.Components;
using System.Text.Json; using System.Text.Json;
public interface IApiClient public interface IApiClient
@@ -14,11 +15,13 @@ public interface IApiClient
public class ApiClient : IApiClient public class ApiClient : IApiClient
{ {
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly NavigationManager _navigationManager;
private string? _authToken; private string? _authToken;
public ApiClient(HttpClient httpClient) public ApiClient(HttpClient httpClient, NavigationManager navigationManager)
{ {
_httpClient = httpClient; _httpClient = httpClient;
_navigationManager = navigationManager;
} }
public async Task SetAuthToken(string? token) public async Task SetAuthToken(string? token)
@@ -34,7 +37,7 @@ public class ApiClient : IApiClient
{ {
try try
{ {
var response = await _httpClient.GetAsync($"/taxbaik/api/{endpoint}"); var response = await _httpClient.GetAsync(BuildApiUri(endpoint));
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
return default; return default;
@@ -53,7 +56,7 @@ public class ApiClient : IApiClient
{ {
var json = JsonSerializer.Serialize(data); var json = JsonSerializer.Serialize(data);
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); 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) if (!response.IsSuccessStatusCode)
return default; return default;
@@ -73,7 +76,7 @@ public class ApiClient : IApiClient
{ {
var json = JsonSerializer.Serialize(data); var json = JsonSerializer.Serialize(data);
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); 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) if (!response.IsSuccessStatusCode)
return default; return default;
@@ -91,11 +94,17 @@ public class ApiClient : IApiClient
{ {
try try
{ {
await _httpClient.DeleteAsync($"/taxbaik/api/{endpoint}"); await _httpClient.DeleteAsync(BuildApiUri(endpoint));
} }
catch catch
{ {
// Ignore // Ignore
} }
} }
private Uri BuildApiUri(string endpoint)
{
var relative = $"api/{endpoint.TrimStart('/')}";
return new Uri(new Uri(_navigationManager.BaseUri), relative);
}
} }
+50
View File
@@ -13,6 +13,7 @@ public class AuthService
private readonly IAdminUserRepository _adminUserRepository; private readonly IAdminUserRepository _adminUserRepository;
private readonly ILogger<AuthService> _logger; private readonly ILogger<AuthService> _logger;
private readonly string _jwtSecretKey; private readonly string _jwtSecretKey;
private readonly string? _passwordResetToken;
private readonly int _tokenExpirationMinutes = 480; // 8시간 private readonly int _tokenExpirationMinutes = 480; // 8시간
public AuthService(IAdminUserRepository adminUserRepository, ILogger<AuthService> logger, IConfiguration configuration) public AuthService(IAdminUserRepository adminUserRepository, ILogger<AuthService> logger, IConfiguration configuration)
@@ -20,6 +21,7 @@ public class AuthService
_adminUserRepository = adminUserRepository; _adminUserRepository = adminUserRepository;
_logger = logger; _logger = logger;
_jwtSecretKey = configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("Missing 'Jwt:SecretKey' configuration."); _jwtSecretKey = configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("Missing 'Jwt:SecretKey' configuration.");
_passwordResetToken = configuration["Admin:PasswordResetToken"];
} }
public async Task<string?> AuthenticateAndGenerateTokenAsync(string username, string password) public async Task<string?> AuthenticateAndGenerateTokenAsync(string username, string password)
@@ -49,9 +51,47 @@ public class AuthService
} }
_logger.LogInformation("로그인 성공: {Username}", username); _logger.LogInformation("로그인 성공: {Username}", username);
await _adminUserRepository.UpdateLastLoginAtAsync(user.Id);
return GenerateJwtToken(user); return GenerateJwtToken(user);
} }
public async Task<bool> 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<bool> 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) private string GenerateJwtToken(AdminUser user)
{ {
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecretKey)); var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecretKey));
@@ -99,4 +139,14 @@ public class AuthService
return null; 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);
}
} }
+4 -4
View File
@@ -12,11 +12,11 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MudBlazor" Version="6.9.4" /> <PackageReference Include="MudBlazor" Version="6.10.0" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.2.1" /> <PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.19.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+14
View File
@@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaxBaik.Application", "TaxB
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaxBaik.Web", "TaxBaik.Web\TaxBaik.Web.csproj", "{C40CB56B-D9A6-47B3-A0A2-7736D83425C5}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaxBaik.Web", "TaxBaik.Web\TaxBaik.Web.csproj", "{C40CB56B-D9A6-47B3-A0A2-7736D83425C5}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaxBaik.Application.Tests", "TaxBaik.Application.Tests\TaxBaik.Application.Tests.csproj", "{47D1F07D-F11B-4343-A3C3-1872F0C46AE3}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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|x64.Build.0 = Release|Any CPU
{C40CB56B-D9A6-47B3-A0A2-7736D83425C5}.Release|x86.ActiveCfg = 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 {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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=TaxBaik Public Website (.NET 8) Description=TaxBaik Website and Admin (.NET 10)
After=network.target After=network.target
[Service] [Service]
+2 -18
View File
@@ -27,30 +27,14 @@ services:
ASPNETCORE_ENVIRONMENT: Development ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: http://0.0.0.0:5001 ASPNETCORE_URLS: http://0.0.0.0:5001
ConnectionStrings__Default: "Host=postgres;Database=taxbaikdb;Username=taxbaik;Password=taxbaik123" ConnectionStrings__Default: "Host=postgres;Database=taxbaikdb;Username=taxbaik;Password=taxbaik123"
Jwt__SecretKey: "dev-secret-key-change-in-production-min-32-chars!"
ports: ports:
- "5001:5001" - "5001:5001"
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
volumes: volumes:
- ./publish/web:/app - ./publish:/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
volumes: volumes:
postgres_data: postgres_data: