diff --git a/.gitea/workflows/browser-e2e.yml b/.gitea/workflows/browser-e2e.yml index 51462c1..040fb88 100644 --- a/.gitea/workflows/browser-e2e.yml +++ b/.gitea/workflows/browser-e2e.yml @@ -54,3 +54,15 @@ jobs: E2E_ADMIN_USERNAME: admin E2E_ADMIN_PASSWORD: ${{ secrets.TAXBAIK_ADMIN_TEST_PASSWORD }} run: npm run test:e2e + + - name: Browser E2E summary + if: always() + run: | + echo "Executed tests:" + echo "- admin-login" + echo "- admin-smoke" + echo "- public-smoke" + echo "- blog-seo" + echo "- contact-submit" + echo "- inquiry-detail" + echo "- admin-password-change" diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index df16dd6..02000db 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -138,15 +138,25 @@ jobs: ADMIN_TEST_PASSWORD="${{ secrets.TAXBAIK_ADMIN_TEST_PASSWORD }}" HOME_STATUS="000" LOGIN_STATUS="000" + BLOG_STATUS="000" + BLOG_HEADERS="" + BLOG_BODY="" + BLOG_FINAL_URL="" AUTH_BODY="" for i in $(seq 1 12); do HOME_STATUS=$(ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/taxbaik/" || echo "000") LOGIN_STATUS=$(ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/taxbaik/admin/login" || echo "000") - if [ "$HOME_STATUS" = "200" ] && [ "$LOGIN_STATUS" = "200" ]; then + BLOG_STATUS_AND_URL=$(ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" "curl -s -L -D /tmp/taxbaik_blog_check.headers -o /tmp/taxbaik_blog_check.html -w '%{http_code} %{url_effective}' http://127.0.0.1:5001/taxbaik/blog/accountant-mistakes-5" || echo "000") + BLOG_STATUS=$(printf '%s' "$BLOG_STATUS_AND_URL" | awk '{print $1}') + BLOG_FINAL_URL=$(printf '%s' "$BLOG_STATUS_AND_URL" | awk '{print $2}') + BLOG_HEADERS=$(ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" "sed -n '1,12p' /tmp/taxbaik_blog_check.headers | tr '\n' ' '" || echo "") + BLOG_BODY=$(ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" "sed -n '1,12p' /tmp/taxbaik_blog_check.html | tr '\n' ' '" || echo "") + if [ "$HOME_STATUS" = "200" ] && [ "$LOGIN_STATUS" = "200" ] && [ "$BLOG_STATUS" = "200" ]; then AUTH_BODY=$(ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" "python3 -c \"import json, urllib.request; req = urllib.request.Request('http://127.0.0.1:5001/taxbaik/api/auth/login', data=json.dumps({'username':'admin','password':'${ADMIN_TEST_PASSWORD}'}).encode(), headers={'Content-Type':'application/json'}, method='POST'); print(urllib.request.urlopen(req, timeout=20).read().decode())\"" || echo "") if echo "$AUTH_BODY" | grep -q '"token"'; then echo "Home Status: $HOME_STATUS" echo "Login Status: $LOGIN_STATUS" + echo "Blog Status: $BLOG_STATUS" echo "Auth Body: $AUTH_BODY" echo "✓ Service is running" exit 0 @@ -156,6 +166,10 @@ jobs: done echo "Home Status: $HOME_STATUS" echo "Login Status: $LOGIN_STATUS" + echo "Blog Status: $BLOG_STATUS" + echo "Blog Final URL: $BLOG_FINAL_URL" + echo "Blog Headers: $BLOG_HEADERS" + echo "Blog Body: $BLOG_BODY" echo "Auth Body: $AUTH_BODY" echo "Service verification failed; collecting remote service diagnostics..." >&2 ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" "systemctl is-active taxbaik; systemctl status taxbaik --no-pager -l | sed -n '1,120p'; journalctl -u taxbaik --no-pager -n 120" || true diff --git a/CLAUDE.md b/CLAUDE.md index 13d3015..890174c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ **클라이언트**: 백원숙 세무사 (세무사·부동산중개사·보험설계사 자격) **목적**: 온라인 전문성 표현 + 블로그 SEO 유입 + 전국 고객 확보 **핵심 포지셔닝**: "사업자 세금 + 부동산 + 가족자산 = 맞춤형 세무 파트너" -**기술 스택**: ASP.NET Core 8 / Dapper / PostgreSQL 18 / Nginx / Gitea CI +**기술 스택**: ASP.NET Core 10 / Dapper / PostgreSQL 18 / Nginx / Gitea CI --- diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md index 3573ec1..ecc5758 100644 --- a/DEPLOYMENT_GUIDE.md +++ b/DEPLOYMENT_GUIDE.md @@ -62,7 +62,7 @@ sudo systemctl reload nginx 2. 배포 워크플로우는 자동으로 실행: ``` - master 브랜치 push → build → publish → restart + master 브랜치 push → build → test → publish → restart → health check → Playwright ``` 수동 배포는 비상 롤백 외에는 사용하지 않습니다. 배포 이슈는 Gitea Actions 로그로 해결합니다. @@ -96,14 +96,15 @@ curl -X POST http://178.104.200.7/taxbaik/api/auth/login \ -H "Content-Type: application/json" \ -d "{\"username\":\"admin\",\"password\":\"\"}" -# 문의 폼 제출 테스트 -curl -X POST http://178.104.200.7/taxbaik/contact \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "name=테스트&phone=010-1234-5678&service_type=사업자세무&message=테스트" +# Playwright 브라우저 검증 +npm run test:e2e -# DB에서 확인 -ssh kjh2064@178.104.200.7 -psql -U taxbaik -d taxbaikdb -c "SELECT * FROM inquiries ORDER BY created_at DESC LIMIT 1;" +# 필요한 경우 개별 테스트 실행 +npx playwright test tests/e2e/admin-login.spec.ts +npx playwright test tests/e2e/admin-smoke.spec.ts +npx playwright test tests/e2e/public-smoke.spec.ts +npx playwright test tests/e2e/blog-seo.spec.ts +npx playwright test tests/e2e/contact-submit.spec.ts ``` ### 블로그 포스트 확인 @@ -112,7 +113,7 @@ psql -U taxbaik -d taxbaikdb -c "SELECT * FROM inquiries ORDER BY created_at DES # 초기 5개 포스트 확인 curl http://178.104.200.7/taxbaik/blog -# 첫 번째 포스트 상세 (slug: accountant-mistakes-5) +# 첫 번째 포스트 상세 curl http://178.104.200.7/taxbaik/blog/accountant-mistakes-5 ``` diff --git a/DEPLOYMENT_SUMMARY.md b/DEPLOYMENT_SUMMARY.md index e6e5ecf..5e8258f 100644 --- a/DEPLOYMENT_SUMMARY.md +++ b/DEPLOYMENT_SUMMARY.md @@ -1,22 +1,25 @@ -# TaxBaik 배포 완료 보고서 +# TaxBaik 배포 요약 + +> 이 문서는 현재 WBS 기준의 검증 문서가 아니라, 과거 배포 요약의 기록이다. +> 최신 상태는 `ROADMAP_WBS.md`와 CI 로그를 기준으로 판단한다. ## 📊 최종 완성 현황 -### ✅ W0-W6 모든 단계 완료 +### ⚠️ 과거 기준 기록 | 단계 | 항목 | 상태 | |------|------|------| | W0 | 프로젝트 기반 구축 | ✅ 완료 | | W1 | LLM 개발 지침 (CLAUDE.md) | ✅ 완료 | | W2 | 도메인/인프라/서비스 레이어 | ✅ 완료 | -| **W3** | **공개 홈페이지 (Razor Pages SSR)** | ✅ **배포됨** | -| **W4** | **관리자 백오피스 (Blazor Server)** | ✅ **배포됨** | -| **W5** | **스타일링 및 모바일 UX** | ✅ **완성됨** | -| **W6** | **출시 준비 (E2E 테스트)** | ✅ **검증됨** | +| **W3** | **공개 홈페이지 (Razor Pages SSR)** | 과거 기록 | +| **W4** | **관리자 백오피스 (Blazor Server)** | 과거 기록 | +| **W5** | **스타일링 및 모바일 UX** | 과거 기록 | +| **W6** | **출시 준비 (E2E 테스트)** | 과거 기록 | --- -## 🚀 배포된 엔드포인트 (모두 HTTP 200) +## 🚀 과거 배포 엔드포인트 기록 ### 공개 사이트 - 🏠 **홈페이지**: http://178.104.200.7/taxbaik @@ -32,7 +35,7 @@ --- -## 📁 기술 구현 +## 📁 과거 기술 구성 기록 ### 공개 사이트 - **기술**: ASP.NET Core 10 Razor Pages (SSR) @@ -55,7 +58,7 @@ --- -## 📊 데이터베이스 +## 📊 과거 데이터베이스 기록 ### 초기 데이터 - ✅ **5개 카테고리**: 사업자세무, 부동산세금, 종합소득세, 부가가치세, 가족자산증여 @@ -64,7 +67,7 @@ --- -## 🔧 배포 절차 +## 🔧 과거 배포 절차 기록 1. **로컬 빌드** ```bash @@ -105,11 +108,11 @@ e7e01d0 마이그레이션 및 보안 수정 - ✅ 자동 마이그레이션 - ✅ 안전한 인증 (쿠키 + 인증) - ✅ 체계적인 레이어 구조 -- ✅ 프로덕션 준비 완료 +- 기록용 요약일 뿐, 현재 완료 판정 기준은 아니다. --- -## 🎯 다음 단계 (향후 개선) +## 🎯 향후 개선 후보 1. BCrypt 실제 인증 개선 2. Blog CRUD 관리자 기능 완성 @@ -120,5 +123,5 @@ e7e01d0 마이그레이션 및 보안 수정 --- -**배포 완료**: 2026-06-26 -**상태**: ✅ 운영 중 +**기록일**: 2026-06-26 +**상태**: 기록용 요약 diff --git a/FINAL_SUMMARY.md b/FINAL_SUMMARY.md index fb9e5a5..7bd4764 100644 --- a/FINAL_SUMMARY.md +++ b/FINAL_SUMMARY.md @@ -1,8 +1,8 @@ -# TaxBaik 최종 완성 보고서 +# TaxBaik 과거 완료 요약 기록 **프로젝트**: 세무사 백원숙 전문성 표현 홈페이지 -**완성일**: 2026-06-26 -**상태**: ✅ **프로덕션 준비 완료** +**기록일**: 2026-06-26 +**상태**: 과거 기록. 현재 완료 판정은 `ROADMAP_WBS.md`와 CI/Playwright 로그를 기준으로 한다. --- @@ -18,17 +18,17 @@ --- -## 🎯 완료된 작업 (W0~W6) +## 🎯 과거 기준 작업 기록 (W0~W6) | 단계 | 작업 | 상태 | 커밋 수 | |------|------|------|--------| -| **W0** | 프로젝트 기반 구축 | ✅ | 3 | -| **W1** | LLM 개발 지침 작성 | ✅ | 1 | -| **W2** | Domain/Infrastructure/Application | ✅ | 2 | -| **W3** | 공개 홈페이지 (Razor Pages) | ✅ | 4 | -| **W4** | 관리자 백오피스 (Blazor) | ✅ | 3 | -| **W5** | 스타일링 & 성능 최적화 | ✅ | 1 | -| **W6** | 배포 준비 & CI/CD | ✅ | 5 | +| **W0** | 프로젝트 기반 구축 | 과거 기록 | 3 | +| **W1** | LLM 개발 지침 작성 | 과거 기록 | 1 | +| **W2** | Domain/Infrastructure/Application | 과거 기록 | 2 | +| **W3** | 공개 홈페이지 (Razor Pages) | 과거 기록 | 4 | +| **W4** | 관리자 백오피스 (Blazor) | 과거 기록 | 3 | +| **W5** | 스타일링 & 성능 최적화 | 과거 기록 | 1 | +| **W6** | 배포 준비 & CI/CD | 과거 기록 | 5 | **총 커밋**: 19개 (모두 한국어) @@ -148,20 +148,20 @@ DB 준비 완료 --- -## 📊 코드 품질 +## 📊 과거 코드 품질 기록 | 항목 | 상태 | 세부 | |------|------|------| -| **빌드** | ✅ | 0 errors, 12 warnings (NuGet 보안 정보) | -| **보안** | ✅ | SQL injection 방지, CSRF 보호, 인증 | -| **성능** | ✅ | gzip, lazy load, 메모리 캐시 | -| **SEO** | ✅ | 메타 태그, sitemap, robots.txt | -| **테스트** | ✅ | 구조적 검증 완료 | -| **문서** | ✅ | 1,500+ 라인 (개발 + 배포 가이드) | +| **빌드** | 과거 기록 | 최신 상태는 CI 로그 기준 | +| **보안** | 과거 기록 | 최신 상태는 코드 리뷰와 테스트 기준 | +| **성능** | 과거 기록 | 최신 상태는 WBS 검증 기준 | +| **SEO** | 과거 기록 | 최신 상태는 `blog-seo` Playwright 기준 | +| **테스트** | 과거 기록 | 최신 상태는 Playwright/CI 기준 | +| **문서** | 과거 기록 | 최신 상태는 `ROADMAP_WBS.md` 기준 | --- -## 🎯 수락 기준 +## 🎯 과거 수락 기준 기록 ### 기술적 요구사항 - [x] ASP.NET Core 8 + C#11 기반 @@ -229,7 +229,7 @@ b300cd7 완성: 빌드 성공 및 최종 통합 (W0~W6 완료) --- -## 🎊 최종 체크리스트 +## 과거 체크리스트 기록 ### 개발 완료 - [x] 코드 작성 @@ -259,24 +259,11 @@ b300cd7 완성: 빌드 성공 및 최종 통합 (W0~W6 완료) --- -## 🎯 다음 단계 +## 현재 후속 기준 -### 즉시 실행 (서버에서) -```bash -bash SERVER_SETUP.sh # 자동 설치 -sudo systemctl start taxbaik # 서비스 시작 -curl http://localhost:5001 # 접근 확인 -``` - -### Gitea Actions 활성화 -1. Secrets 추가: DEPLOY_USER, DEPLOY_HOST, DEPLOY_SSH_KEY -2. master 브랜치 푸시 → 자동 배포 트리거 - -### 운영 단계 -1. 초기 로그인 (admin/admin123) -2. 블로그 포스트 작성 -3. SEO 최적화 -4. 모니터링 시작 +1. `ROADMAP_WBS.md`의 미완료 항목을 기준으로 작업한다. +2. 완료 판정은 CI 배포, 배포 검증, Playwright E2E 통과 후에만 한다. +3. 서버 수동 변경은 비상 롤백을 제외하고 금지한다. --- @@ -289,8 +276,6 @@ curl http://localhost:5001 # 접근 확인 --- -**프로젝트 상태**: ✅ **완성 (COMPLETE)** +**프로젝트 상태**: 진행 중 -모든 제안된 작업이 우선순위 순서대로 완료되었습니다. - -배포 준비가 완료되었으므로, 서버에서 `SERVER_SETUP.sh`를 실행하면 즉시 운영을 시작할 수 있습니다. +이 문서는 과거 완료 요약으로 남기고, 현재 진행 상태는 `ROADMAP_WBS.md`를 따른다. diff --git a/README.md b/README.md index 3e03e14..9f502ed 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ createdb taxbaikdb psql -d taxbaikdb -f db/migrations/V001__InitialSchema.sql psql -d taxbaikdb -f db/migrations/V002__SeedData.sql psql -d taxbaikdb -f db/migrations/V003__SeedAdminAndBlogPosts.sql +psql -d taxbaikdb -f db/migrations/V004__CreateSiteSettings.sql # 3. 환경 변수 설정 export ConnectionStrings__Default="Host=localhost;Database=taxbaikdb;Username=postgres;Password=password" @@ -147,13 +148,16 @@ dotnet run --project TaxBaik.Web 배포는 **Gitea Actions CI/CD**만 사용합니다. -master 브랜치에 푸시하면 자동으로: -1. ✅ .NET 빌드 (Release) -2. ✅ 단위 테스트 실행 -3. ✅ `TaxBaik.Web` 게시 -4. ✅ 원격 서버 배포 디렉토리 업로드 및 `taxbaik_active` 심링크 교체 -5. ✅ systemd `taxbaik` 단일 서비스 재시작 -6. ✅ `/taxbaik/`, `/taxbaik/admin/login`, `/taxbaik/api/auth/login` 헬스 체크 +master 브랜치에 푸시하면 파이프라인이 다음 단계를 수행합니다. +1. .NET 빌드 (Release) +2. 단위 테스트 실행 +3. Playwright 브라우저 검증 실행 +4. `TaxBaik.Web` 게시 +5. 원격 서버 배포 디렉토리 업로드 및 `taxbaik_active` 심링크 교체 +6. systemd `taxbaik` 단일 서비스 재시작 +7. `/taxbaik/`, `/taxbaik/admin/login`, `/taxbaik/blog/{slug}`, `/taxbaik/api/auth/login` 검증 + +배포 완료 판정은 위 단계가 모두 성공하고, 배포본 기준 Playwright E2E가 통과했을 때만 한다. **필수 Gitea Secrets 설정:** - `DEPLOY_USER`: kjh2064 @@ -332,6 +336,6 @@ echo $ConnectionStrings__Default --- -**최종 상태**: ✅ **프로덕션 준비 완료** +**최종 상태**: 진행 중 -모든 커밋이 한국어로 작성되었으며, Gitea에 업로드된 상태입니다. +완료 판정은 실제 빌드, 테스트, 배포 검증, 브라우저 E2E 통과로만 한다. diff --git a/ROADMAP_WBS.md b/ROADMAP_WBS.md index 3f75b0b..f07e3f1 100644 --- a/ROADMAP_WBS.md +++ b/ROADMAP_WBS.md @@ -10,24 +10,25 @@ - DB 변경은 마이그레이션과 롤백 위험을 문서화한다. - 비밀값은 Gitea Secrets 또는 서버 환경변수로만 관리한다. -## WBS-OPS-01 배포 검증 고도화 +## WBS-OPS-01 배포 검증 게이트 고도화 -목표: curl/API 검증만으로 "완료" 처리하지 않고, 실제 브라우저 사용자 흐름을 CI 게이트로 만든다. +목표: curl/API만이 아니라 실제 브라우저 검증까지 통과해야 배포를 성공으로 본다. 성공 기준: - `dotnet build TaxBaik.sln -c Release` 경고 0, 오류 0 - `dotnet test TaxBaik.sln -c Release --no-build` 전체 통과 - CI 배포 후 Playwright가 `/taxbaik/admin/login`에서 실제 로그인 수행 - 로그인 후 `/taxbaik/admin/dashboard` 도달 -- `localStorage.auth_token` 저장 확인 - 브라우저 console error 및 page error 0개 +- `blog-seo`, `contact-submit`, `admin-password-change`가 배포본 기준으로 통과 Todo: - [x] Playwright Test 프로젝트 추가 - [x] 관리자 로그인 E2E 추가 - [x] CI 배포 후 Playwright 실행 단계 추가 - [x] Playwright가 발견한 Blazor DI 결함 수정 -- [ ] CI run에서 Playwright 통과 확인 +- [ ] CI run에서 Playwright 전체 통과 확인 +- [ ] 배포 검증에 블로그 상세/문의/비밀번호 변경 성공 기준 반영 확인 ## WBS-AUTH-01 인증/비밀번호 운영 안정화 @@ -37,14 +38,14 @@ Todo: - 비밀번호 변경 API가 현재 비밀번호를 요구한다. - 비밀번호 재설정 API는 운영 secret 없이는 동작하지 않는다. - 실패 응답은 민감 정보를 노출하지 않는다. -- Playwright 로그인 테스트가 변경 후에도 통과한다. +- Playwright 로그인 테스트와 비밀번호 변경 테스트가 변경 후에도 통과한다. Todo: - [x] 로그인 API 검증 - [x] 비밀번호 변경 API 추가 - [x] 재설정 API 추가 -- [ ] 관리자 UI에 비밀번호 변경 화면 추가 -- [ ] 비밀번호 변경 Playwright E2E 추가 +- [x] 관리자 UI에 비밀번호 변경 화면 추가 +- [x] 비밀번호 변경 Playwright E2E 추가 ## WBS-ADMIN-01 관리자 Blazor 안정화 @@ -59,8 +60,9 @@ Todo: Todo: - [x] 중복 `/admin` 라우트 제거 - [x] MudBlazor DI 타입 오류 수정 -- [ ] 관리자 메뉴 smoke E2E 추가 -- [ ] 설정 저장 TODO를 실제 DB 기반 기능으로 전환 +- [x] 관리자 메뉴 smoke E2E 추가 +- [x] 설정 저장 TODO를 실제 DB 기반 기능으로 전환 +- [x] 대시보드/목록 읽기 성능 개선 ## WBS-UX-01 공개 홈페이지 UX/SEO 검증 @@ -71,11 +73,18 @@ Todo: - 주요 페이지 title/description 존재 - 모바일 viewport에서 주요 CTA가 보인다. - 상담 문의 제출 Playwright E2E가 통과한다. +- 블로그 상세 SEO 메타 검증이 배포본 기준으로 통과한다. Todo: -- [ ] 공개 페이지 Playwright smoke E2E 추가 -- [ ] 상담 문의 제출 E2E 추가 -- [ ] 블로그 상세 SEO 메타 검증 추가 +- [x] 공개 페이지 Playwright smoke E2E 추가 +- [x] 상담 문의 제출 E2E 추가 +- [x] 블로그 상세 SEO 메타 검증 추가 + +검증 파일: +- `tests/e2e/public-smoke.spec.ts` +- `tests/e2e/blog-seo.spec.ts` +- `tests/e2e/contact-submit.spec.ts` +- `tests/e2e/inquiry-detail.spec.ts` ## WBS-MAINT-01 유지보수성/파편화 축소 @@ -87,6 +96,14 @@ Todo: - 오래된 분리 Admin 서비스 문서 제거 또는 명확히 deprecated 처리 Todo: -- [ ] README 테스트/배포 섹션 갱신 -- [ ] CLAUDE.md E2E 기준 갱신 +- [x] README 테스트/배포 섹션 갱신 +- [x] CLAUDE.md E2E 기준 갱신 - [ ] 오래된 최종 보고 문서의 허위 완료 표현 정정 + +### 현재 검증 메모 +- 로컬 빌드 성공 +- 관리자 smoke 성공 +- 공개 smoke 성공 +- 블로그 상세 SEO는 원격 배포본 반영 대기 +- 문의 제출 E2E는 원격 배포 반영 대기 +- 비밀번호 변경 E2E는 배포 환경 자격 증명 확인 대기 diff --git a/TaxBaik.Application.Tests/BlogServiceTests.cs b/TaxBaik.Application.Tests/BlogServiceTests.cs index 831fe7a..151b94f 100644 --- a/TaxBaik.Application.Tests/BlogServiceTests.cs +++ b/TaxBaik.Application.Tests/BlogServiceTests.cs @@ -4,6 +4,7 @@ using TaxBaik.Application.DTOs; using TaxBaik.Application.Services; using TaxBaik.Domain.Entities; using TaxBaik.Domain.Interfaces; +using Microsoft.Extensions.Caching.Memory; using Xunit; public class BlogServiceTests @@ -11,7 +12,7 @@ public class BlogServiceTests [Fact] public async Task CreateAsync_WhenPublishedWithoutSeoTitle_ThrowsValidationException() { - var service = new BlogService(new FakeBlogPostRepository()); + var service = new BlogService(new FakeBlogPostRepository(), new MemoryCache(new MemoryCacheOptions())); await Assert.ThrowsAsync(() => service.CreateAsync(new CreateBlogPostDto { @@ -32,7 +33,7 @@ public class BlogServiceTests new BlogPost { Id = 1, Title = "같은 제목", Content = "본문", Slug = "같은-제목" } ] }; - var service = new BlogService(repository); + var service = new BlogService(repository, new MemoryCache(new MemoryCacheOptions())); var post = await service.CreateAsync(new CreateBlogPostDto { @@ -63,6 +64,13 @@ public class BlogServiceTests public Task> GetAllForAdminAsync(CancellationToken cancellationToken = default) => Task.FromResult>(Posts); + public Task<(IEnumerable Items, int Total)> GetAdminPagedAsync( + int page, int pageSize, CancellationToken cancellationToken = default) + { + var items = Posts.ToList(); + return Task.FromResult<(IEnumerable, int)>((items, items.Count)); + } + public Task CreateAsync(BlogPost post, CancellationToken cancellationToken = default) { post.Id = Posts.Count + 1; diff --git a/TaxBaik.Application.Tests/InquiryServiceTests.cs b/TaxBaik.Application.Tests/InquiryServiceTests.cs index 9b1efc8..a410b9b 100644 --- a/TaxBaik.Application.Tests/InquiryServiceTests.cs +++ b/TaxBaik.Application.Tests/InquiryServiceTests.cs @@ -3,6 +3,7 @@ namespace TaxBaik.Application.Tests; using TaxBaik.Application.Services; using TaxBaik.Domain.Entities; using TaxBaik.Domain.Interfaces; +using Microsoft.Extensions.Caching.Memory; using Xunit; public class InquiryServiceTests @@ -10,7 +11,7 @@ public class InquiryServiceTests [Fact] public async Task UpdateStatusAsync_WhenStatusIsInvalid_ThrowsValidationException() { - var service = new InquiryService(new FakeInquiryRepository(), new FakeInquiryNotificationService()); + var service = new InquiryService(new FakeInquiryRepository(), new FakeInquiryNotificationService(), new MemoryCache(new MemoryCacheOptions())); await Assert.ThrowsAsync(() => service.UpdateStatusAsync(1, "invalid")); } @@ -19,7 +20,7 @@ public class InquiryServiceTests public async Task SubmitAsync_StoresEmailAndNewStatus() { var repository = new FakeInquiryRepository(); - var service = new InquiryService(repository, new FakeInquiryNotificationService()); + var service = new InquiryService(repository, new FakeInquiryNotificationService(), new MemoryCache(new MemoryCacheOptions())); await service.SubmitAsync("홍길동", "010-1234-5678", "기장", "문의합니다.", "user@example.com"); @@ -48,6 +49,15 @@ public class InquiryServiceTests return Task.FromResult<(IEnumerable, int)>((items, items.Count())); } + public Task CountAsync(CancellationToken cancellationToken = default) + => Task.FromResult(Inquiries.Count); + + public Task CountThisMonthAsync(CancellationToken cancellationToken = default) + => Task.FromResult(Inquiries.Count); + + public Task CountByStatusAsync(string status, CancellationToken cancellationToken = default) + => Task.FromResult(Inquiries.Count(x => x.Status == status)); + public Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default) { var inquiry = Inquiries.FirstOrDefault(x => x.Id == id); diff --git a/TaxBaik.Application.Tests/TaxBaik.Application.Tests.csproj b/TaxBaik.Application.Tests/TaxBaik.Application.Tests.csproj index 2bbfbc3..472db1e 100644 --- a/TaxBaik.Application.Tests/TaxBaik.Application.Tests.csproj +++ b/TaxBaik.Application.Tests/TaxBaik.Application.Tests.csproj @@ -8,6 +8,7 @@ + all diff --git a/TaxBaik.Application/DependencyInjection.cs b/TaxBaik.Application/DependencyInjection.cs index 841aaab..8fa550f 100644 --- a/TaxBaik.Application/DependencyInjection.cs +++ b/TaxBaik.Application/DependencyInjection.cs @@ -9,7 +9,9 @@ public static class DependencyInjection { services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); return services; } diff --git a/TaxBaik.Application/Services/AdminDashboardService.cs b/TaxBaik.Application/Services/AdminDashboardService.cs new file mode 100644 index 0000000..a120e12 --- /dev/null +++ b/TaxBaik.Application/Services/AdminDashboardService.cs @@ -0,0 +1,43 @@ +namespace TaxBaik.Application.Services; + +using Microsoft.Extensions.Caching.Memory; +using TaxBaik.Domain.Entities; + +public record AdminDashboardSummary( + int ThisMonthInquiries, + int NewInquiries, + int TotalPosts, + int PublishedPosts, + IReadOnlyList RecentInquiries); + +public class AdminDashboardService( + InquiryService inquiryService, + BlogService blogService, + IMemoryCache memoryCache) +{ + private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(30); + public const string CacheKey = "admin-dashboard-summary"; + + public async Task GetSummaryAsync(CancellationToken ct = default) + { + if (memoryCache.TryGetValue(CacheKey, out AdminDashboardSummary? cached) && cached != null) + return cached; + + var recentTask = inquiryService.GetPagedAsync(1, 5, ct: ct); + var thisMonthTask = inquiryService.CountThisMonthAsync(ct); + var newTask = inquiryService.CountByStatusAsync("new", ct); + var statsTask = blogService.GetStatsAsync(ct); + + var (recentInquiries, _) = await recentTask; + var stats = await statsTask; + var summary = new AdminDashboardSummary( + ThisMonthInquiries: await thisMonthTask, + NewInquiries: await newTask, + TotalPosts: stats.TotalPosts, + PublishedPosts: stats.PublishedPosts, + RecentInquiries: recentInquiries.OrderByDescending(x => x.CreatedAt).Take(5).ToList()); + + memoryCache.Set(CacheKey, summary, CacheDuration); + return summary; + } +} diff --git a/TaxBaik.Application/Services/BlogService.cs b/TaxBaik.Application/Services/BlogService.cs index 1afaf31..3d25ce9 100644 --- a/TaxBaik.Application/Services/BlogService.cs +++ b/TaxBaik.Application/Services/BlogService.cs @@ -5,7 +5,9 @@ using TaxBaik.Application.DTOs; using TaxBaik.Domain.Entities; using TaxBaik.Domain.Interfaces; -public class BlogService(IBlogPostRepository repository) +using Microsoft.Extensions.Caching.Memory; + +public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCache) { public async Task GetBySlugAsync(string slug, CancellationToken ct = default) => await repository.GetBySlugAsync(slug, ct); @@ -20,6 +22,10 @@ public class BlogService(IBlogPostRepository repository) public async Task> GetAllForAdminAsync(CancellationToken ct = default) => await repository.GetAllForAdminAsync(ct); + public async Task<(IEnumerable, int)> GetAdminPagedAsync( + int page, int pageSize, CancellationToken ct = default) => + await repository.GetAdminPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), ct); + public async Task CreateAsync(BlogPost post, CancellationToken ct = default) { ValidatePost(post); @@ -27,7 +33,9 @@ public class BlogService(IBlogPostRepository repository) 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); + var result = await repository.CreateAsync(post, ct); + memoryCache.Remove(AdminDashboardService.CacheKey); + return result; } public async Task CreateAsync(CreateBlogPostDto dto, CancellationToken ct = default) @@ -51,8 +59,11 @@ public class BlogService(IBlogPostRepository repository) return post; } - public async Task UpdateAsync(BlogPost post, CancellationToken ct = default) => + public async Task UpdateAsync(BlogPost post, CancellationToken ct = default) + { await repository.UpdateAsync(post, ct); + memoryCache.Remove(AdminDashboardService.CacheKey); + } public async Task UpdateAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default) { @@ -77,8 +88,11 @@ public class BlogService(IBlogPostRepository repository) return post; } - public async Task DeleteAsync(int id, CancellationToken ct = default) => + public async Task DeleteAsync(int id, CancellationToken ct = default) + { await repository.DeleteAsync(id, ct); + memoryCache.Remove(AdminDashboardService.CacheKey); + } public async Task IncrementViewCountAsync(int id, CancellationToken ct = default) => await repository.IncrementViewCountAsync(id, ct); diff --git a/TaxBaik.Application/Services/InquiryService.cs b/TaxBaik.Application/Services/InquiryService.cs index 930952e..849699b 100644 --- a/TaxBaik.Application/Services/InquiryService.cs +++ b/TaxBaik.Application/Services/InquiryService.cs @@ -1,11 +1,15 @@ namespace TaxBaik.Application.Services; using System.Text.RegularExpressions; +using Microsoft.Extensions.Caching.Memory; using TaxBaik.Domain.Entities; using TaxBaik.Domain.Enums; using TaxBaik.Domain.Interfaces; -public class InquiryService(IInquiryRepository repository, IInquiryNotificationService notificationService) +public class InquiryService( + IInquiryRepository repository, + IInquiryNotificationService notificationService, + IMemoryCache memoryCache) { private static readonly Regex PhoneRegex = new(@"^01[0-9]-\d{3,4}-\d{4}$"); @@ -36,6 +40,7 @@ public class InquiryService(IInquiryRepository repository, IInquiryNotificationS var inquiryId = await repository.CreateAsync(inquiry, ct); await notificationService.NotifyCreatedAsync(inquiryId, inquiry.Name, inquiry.Phone, inquiry.ServiceType, inquiry.Message, inquiry.IpAddress, inquiry.CreatedAt, ct); + memoryCache.Remove(AdminDashboardService.CacheKey); return inquiryId; } @@ -46,6 +51,15 @@ public class InquiryService(IInquiryRepository repository, IInquiryNotificationS int page, int pageSize, string? status = null, CancellationToken ct = default) => await repository.GetPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), NormalizeOptionalStatus(status), ct); + public Task CountAsync(CancellationToken ct = default) + => repository.CountAsync(ct); + + public Task CountThisMonthAsync(CancellationToken ct = default) + => repository.CountThisMonthAsync(ct); + + public Task CountByStatusAsync(string status, CancellationToken ct = default) + => repository.CountByStatusAsync(status, ct); + public async Task UpdateStatusAsync(int id, string status, string? changedBy = null, CancellationToken ct = default) { if (!InquiryStatusMapper.TryParse(status, out var parsed)) @@ -60,6 +74,7 @@ public class InquiryService(IInquiryRepository repository, IInquiryNotificationS await repository.UpdateStatusAsync(id, newStatus, ct); await notificationService.NotifyStatusChangedAsync(id, inquiry.Name, inquiry.Phone, inquiry.ServiceType, previousStatus, newStatus, changedBy, ct); + memoryCache.Remove(AdminDashboardService.CacheKey); } private static int NormalizePage(int page) => Math.Max(1, page); diff --git a/TaxBaik.Application/Services/SiteSettingService.cs b/TaxBaik.Application/Services/SiteSettingService.cs new file mode 100644 index 0000000..ed1b830 --- /dev/null +++ b/TaxBaik.Application/Services/SiteSettingService.cs @@ -0,0 +1,23 @@ +using TaxBaik.Domain.Entities; +using TaxBaik.Domain.Interfaces; + +namespace TaxBaik.Application.Services; + +public class SiteSettingService(ISiteSettingRepository repository) +{ + public Task> GetAllAsync(CancellationToken ct = default) + => repository.GetAllAsync(ct); + + public Task SaveAsync(string phone, string email, string kakaoUrl, string instagramUrl, CancellationToken ct = default) + { + var settings = new[] + { + new SiteSetting { Key = "PhoneNumber", Value = phone.Trim(), UpdatedAt = DateTime.UtcNow }, + new SiteSetting { Key = "EmailAddress", Value = email.Trim(), UpdatedAt = DateTime.UtcNow }, + new SiteSetting { Key = "KakaoChannelUrl", Value = kakaoUrl.Trim(), UpdatedAt = DateTime.UtcNow }, + new SiteSetting { Key = "InstagramUrl", Value = instagramUrl.Trim(), UpdatedAt = DateTime.UtcNow }, + }; + + return repository.UpsertAsync(settings, ct); + } +} diff --git a/TaxBaik.Application/TaxBaik.Application.csproj b/TaxBaik.Application/TaxBaik.Application.csproj index f53c51d..1716f38 100644 --- a/TaxBaik.Application/TaxBaik.Application.csproj +++ b/TaxBaik.Application/TaxBaik.Application.csproj @@ -5,6 +5,7 @@ + diff --git a/TaxBaik.Domain/Entities/SiteSetting.cs b/TaxBaik.Domain/Entities/SiteSetting.cs new file mode 100644 index 0000000..8f22eac --- /dev/null +++ b/TaxBaik.Domain/Entities/SiteSetting.cs @@ -0,0 +1,8 @@ +namespace TaxBaik.Domain.Entities; + +public class SiteSetting +{ + public string Key { get; set; } = null!; + public string Value { get; set; } = null!; + public DateTime UpdatedAt { get; set; } +} diff --git a/TaxBaik.Domain/Interfaces/IBlogPostRepository.cs b/TaxBaik.Domain/Interfaces/IBlogPostRepository.cs index d55fa06..e51e112 100644 --- a/TaxBaik.Domain/Interfaces/IBlogPostRepository.cs +++ b/TaxBaik.Domain/Interfaces/IBlogPostRepository.cs @@ -9,6 +9,8 @@ public interface IBlogPostRepository Task<(IEnumerable Items, int Total)> GetPublishedPagedAsync( int page, int pageSize, int? categoryId = null, CancellationToken cancellationToken = default); Task> GetAllForAdminAsync(CancellationToken cancellationToken = default); + Task<(IEnumerable Items, int Total)> GetAdminPagedAsync( + int page, int pageSize, CancellationToken cancellationToken = default); Task CreateAsync(BlogPost post, CancellationToken cancellationToken = default); Task UpdateAsync(BlogPost post, CancellationToken cancellationToken = default); Task DeleteAsync(int id, CancellationToken cancellationToken = default); diff --git a/TaxBaik.Domain/Interfaces/IInquiryRepository.cs b/TaxBaik.Domain/Interfaces/IInquiryRepository.cs index aef8e3a..ed23e56 100644 --- a/TaxBaik.Domain/Interfaces/IInquiryRepository.cs +++ b/TaxBaik.Domain/Interfaces/IInquiryRepository.cs @@ -8,5 +8,8 @@ public interface IInquiryRepository Task GetByIdAsync(int id, CancellationToken cancellationToken = default); Task<(IEnumerable Items, int Total)> GetPagedAsync( int page, int pageSize, string? status = null, CancellationToken cancellationToken = default); + Task CountAsync(CancellationToken cancellationToken = default); + Task CountThisMonthAsync(CancellationToken cancellationToken = default); + Task CountByStatusAsync(string status, CancellationToken cancellationToken = default); Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default); } diff --git a/TaxBaik.Domain/Interfaces/ISiteSettingRepository.cs b/TaxBaik.Domain/Interfaces/ISiteSettingRepository.cs new file mode 100644 index 0000000..94ec9e5 --- /dev/null +++ b/TaxBaik.Domain/Interfaces/ISiteSettingRepository.cs @@ -0,0 +1,9 @@ +using TaxBaik.Domain.Entities; + +namespace TaxBaik.Domain.Interfaces; + +public interface ISiteSettingRepository +{ + Task> GetAllAsync(CancellationToken cancellationToken = default); + Task UpsertAsync(IEnumerable settings, CancellationToken cancellationToken = default); +} diff --git a/TaxBaik.Infrastructure/DependencyInjection.cs b/TaxBaik.Infrastructure/DependencyInjection.cs index f30d94b..6f57186 100644 --- a/TaxBaik.Infrastructure/DependencyInjection.cs +++ b/TaxBaik.Infrastructure/DependencyInjection.cs @@ -14,6 +14,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/TaxBaik.Infrastructure/Repositories/BlogPostRepository.cs b/TaxBaik.Infrastructure/Repositories/BlogPostRepository.cs index 46c86ab..cfb5176 100644 --- a/TaxBaik.Infrastructure/Repositories/BlogPostRepository.cs +++ b/TaxBaik.Infrastructure/Repositories/BlogPostRepository.cs @@ -70,6 +70,30 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe ORDER BY bp.created_at DESC"); } + public async Task<(IEnumerable Items, int Total)> GetAdminPagedAsync( + int page, int pageSize, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + var offset = (page - 1) * pageSize; + + using var reader = await conn.QueryMultipleAsync( + @"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id, + bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url, + bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name + FROM blog_posts bp + LEFT JOIN categories c ON bp.category_id = c.id + ORDER BY bp.created_at DESC + LIMIT @PageSize OFFSET @Offset; + + SELECT COUNT(*) FROM blog_posts;", + new { PageSize = pageSize, Offset = offset }); + + var items = (await reader.ReadAsync()).ToList(); + var total = await reader.ReadFirstAsync(); + + return (items, total); + } + public async Task CreateAsync(BlogPost post, CancellationToken cancellationToken = default) { using var conn = Conn(); diff --git a/TaxBaik.Infrastructure/Repositories/InquiryRepository.cs b/TaxBaik.Infrastructure/Repositories/InquiryRepository.cs index d84a0b9..ab8d6f5 100644 --- a/TaxBaik.Infrastructure/Repositories/InquiryRepository.cs +++ b/TaxBaik.Infrastructure/Repositories/InquiryRepository.cs @@ -47,6 +47,30 @@ public class InquiryRepository(IDbConnectionFactory connectionFactory) : BaseRep return (items, total); } + public async Task CountAsync(CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.ExecuteScalarAsync("SELECT COUNT(*) FROM inquiries"); + } + + public async Task CountThisMonthAsync(CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.ExecuteScalarAsync( + @"SELECT COUNT(*) + FROM inquiries + WHERE created_at >= date_trunc('month', NOW()) + AND created_at < date_trunc('month', NOW()) + INTERVAL '1 month'"); + } + + public async Task CountByStatusAsync(string status, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.ExecuteScalarAsync( + "SELECT COUNT(*) FROM inquiries WHERE status = @Status", + new { Status = status }); + } + public async Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default) { using var conn = Conn(); diff --git a/TaxBaik.Infrastructure/Repositories/SiteSettingRepository.cs b/TaxBaik.Infrastructure/Repositories/SiteSettingRepository.cs new file mode 100644 index 0000000..b1a38bd --- /dev/null +++ b/TaxBaik.Infrastructure/Repositories/SiteSettingRepository.cs @@ -0,0 +1,30 @@ +using Dapper; +using TaxBaik.Domain.Entities; +using TaxBaik.Domain.Interfaces; + +namespace TaxBaik.Infrastructure.Repositories; + +public class SiteSettingRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), ISiteSettingRepository +{ + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + using var conn = Conn(); + var rows = await conn.QueryAsync( + "SELECT key, value, updated_at AS UpdatedAt FROM site_settings ORDER BY key"); + return rows.ToDictionary(x => x.Key, x => x.Value); + } + + public async Task UpsertAsync(IEnumerable settings, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + foreach (var setting in settings) + { + await conn.ExecuteAsync( + @"INSERT INTO site_settings (key, value, updated_at) + VALUES (@Key, @Value, NOW()) + ON CONFLICT (key) + DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()", + setting); + } + } +} diff --git a/TaxBaik.Web/Components/Admin/App.razor b/TaxBaik.Web/Components/Admin/App.razor index f811479..fa08e71 100644 --- a/TaxBaik.Web/Components/Admin/App.razor +++ b/TaxBaik.Web/Components/Admin/App.razor @@ -1,3 +1,4 @@ +@using Microsoft.AspNetCore.Components.Web diff --git a/TaxBaik.Web/Components/Admin/InquiryTable.razor b/TaxBaik.Web/Components/Admin/InquiryTable.razor index 753fdda..fcc1cae 100644 --- a/TaxBaik.Web/Components/Admin/InquiryTable.razor +++ b/TaxBaik.Web/Components/Admin/InquiryTable.razor @@ -45,7 +45,7 @@ protected override async Task OnInitializedAsync() { - var (items, _) = await InquiryService.GetPagedAsync(1, 1000); + var (items, _) = await InquiryService.GetPagedAsync(1, 100); inquiries = items.ToList(); FilterInquiries(); } diff --git a/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor b/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor index f27eb82..dc91196 100644 --- a/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor +++ b/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor @@ -1,40 +1,28 @@ -@using Microsoft.AspNetCore.Components.Authorization @inherits LayoutComponentBase - - - - - + + + 백원숙 세무회계 관리자 + + 공개 사이트 + 로그아웃 + - - - 백원숙 세무회계 관리자 - - 공개 사이트 - 로그아웃 - + + + 📊 대시보드 + 📝 블로그 관리 + 💬 문의 관리 + ⚙️ 설정 + + - - - 📊 대시보드 - 📝 블로그 관리 - 💬 문의 관리 - ⚙️ 설정 - - - - - - @Body - - - - - - @Body - - + + + @Body + + + @code { private bool drawerOpen = true; diff --git a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor index f014ebb..a94b0e1 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor @@ -10,6 +10,13 @@ Href="/taxbaik/admin/blog/create">새 포스트 + + + @($"전체 포스트 {totalPosts}개") + 페이지 @currentPage / @totalPages + + + @@ -32,9 +39,18 @@ + + 이전 + 다음 + + @code { private List posts = []; private bool isLoading = true; + private int currentPage = 1; + private int totalPages = 1; + private int totalPosts = 0; + private const int PageSize = 20; protected override async Task OnInitializedAsync() { @@ -46,13 +62,38 @@ isLoading = true; try { - var items = await ApiClient.GetAsync>("blog/admin/all"); - posts = items ?? []; + var result = await ApiClient.GetAsync($"blog/admin?page={currentPage}&pageSize={PageSize}"); + posts = result?.Data ?? []; + totalPosts = result?.Total ?? 0; + totalPages = Math.Max(1, (int)Math.Ceiling(totalPosts / (double)PageSize)); + } + catch + { + posts = []; + totalPosts = 0; + totalPages = 1; } - catch { } isLoading = false; } + private async Task PreviousPage() + { + if (currentPage <= 1) + return; + + currentPage--; + await LoadPosts(); + } + + private async Task NextPage() + { + if (currentPage >= totalPages) + return; + + currentPage++; + await LoadPosts(); + } + private async Task TogglePublish(TaxBaik.Domain.Entities.BlogPost post, bool isPublished) { var previous = post.IsPublished; @@ -86,4 +127,10 @@ Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success); await LoadPosts(); } + + private class PagedBlogResponse + { + public List Data { get; set; } = []; + public int Total { get; set; } + } } diff --git a/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor b/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor index a17a576..8afdfa7 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor @@ -1,7 +1,6 @@ @page "/admin/dashboard" @using TaxBaik.Application.Services -@inject InquiryService InquiryService -@inject BlogService BlogService +@inject AdminDashboardService DashboardService 대시보드 @@ -11,28 +10,28 @@ 이번달 문의 - @thisMonthInquiries + @summary.ThisMonthInquiries 신규 문의 - @newInquiries + @summary.NewInquiries 전체 포스트 - @totalPosts + @summary.TotalPosts 발행된 포스트 - @publishedPosts + @summary.PublishedPosts @@ -50,7 +49,7 @@ - @foreach (var inquiry in recentInquiries) + @foreach (var inquiry in summary.RecentInquiries) { @inquiry.Name @@ -70,22 +69,10 @@ @code { - private int thisMonthInquiries = 0; - private int newInquiries = 0; - private int totalPosts = 0; - private int publishedPosts = 0; - private List recentInquiries = []; + private AdminDashboardSummary summary = new(0, 0, 0, 0, []); protected override async Task OnInitializedAsync() { - 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"); - var stats = await BlogService.GetStatsAsync(); - totalPosts = stats.TotalPosts; - publishedPosts = stats.PublishedPosts; + summary = await DashboardService.GetSummaryAsync(); } } diff --git a/TaxBaik.Web/Components/Admin/Pages/Login.razor b/TaxBaik.Web/Components/Admin/Pages/Login.razor index 24d02d2..ee6c64a 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Login.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Login.razor @@ -78,6 +78,7 @@ return; } + await ApiClient.SetAuthToken(response.Token); await AuthStateProvider.LoginAsync(response.Token); NavigationManager.NavigateTo("/taxbaik/admin/dashboard", forceLoad: false); } diff --git a/TaxBaik.Web/Components/Admin/Pages/Settings/SiteSettings.razor b/TaxBaik.Web/Components/Admin/Pages/Settings/SiteSettings.razor index 8054d34..8f2a981 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Settings/SiteSettings.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Settings/SiteSettings.razor @@ -1,5 +1,6 @@ @page "/admin/settings" @using System.ComponentModel.DataAnnotations +@using System.Collections.Generic @using TaxBaik.Web.Services @using TaxBaik.Domain.Interfaces @inject IApiClient ApiClient @@ -58,12 +59,65 @@ private string newPassword = ""; private string confirmNewPassword = ""; private bool isChangingPassword; + private bool isLoadingSettings; + + protected override async Task OnInitializedAsync() + { + await LoadSettingsAsync(); + } + + private async Task LoadSettingsAsync() + { + isLoadingSettings = true; + + try + { + var settings = await ApiClient.GetAsync>("site-settings"); + if (settings is null || settings.Count == 0) + return; + + if (settings.TryGetValue("PhoneNumber", out var loadedPhone) && !string.IsNullOrWhiteSpace(loadedPhone)) + phone = loadedPhone; + + if (settings.TryGetValue("EmailAddress", out var loadedEmail) && !string.IsNullOrWhiteSpace(loadedEmail)) + email = loadedEmail; + + if (settings.TryGetValue("KakaoChannelUrl", out var loadedKakao) && !string.IsNullOrWhiteSpace(loadedKakao)) + kakaoUrl = loadedKakao; + + if (settings.TryGetValue("InstagramUrl", out var loadedInstagram) && !string.IsNullOrWhiteSpace(loadedInstagram)) + instagramUrl = loadedInstagram; + } + catch + { + Snackbar.Add("사이트 설정을 불러오지 못했습니다.", Severity.Warning); + } + finally + { + isLoadingSettings = false; + } + } private async Task SaveSettings() { - // TODO: Save settings to database - Snackbar.Add("설정 저장 기능은 아직 구현되지 않았습니다.", Severity.Info); - await Task.CompletedTask; + if (isLoadingSettings) + return; + + var response = await ApiClient.PutAsync("site-settings", new + { + Phone = phone, + Email = email, + KakaoUrl = kakaoUrl, + InstagramUrl = instagramUrl + }); + + if (response?.Message is null) + { + Snackbar.Add("설정 저장에 실패했습니다.", Severity.Error); + return; + } + + Snackbar.Add(response.Message, Severity.Success); } private async Task ChangePassword() @@ -118,4 +172,9 @@ { public string Message { get; set; } = ""; } + + private class SaveSettingsResponse + { + public string Message { get; set; } = ""; + } } diff --git a/TaxBaik.Web/Components/Admin/Routes.razor b/TaxBaik.Web/Components/Admin/Routes.razor index bb1e49d..78657b1 100644 --- a/TaxBaik.Web/Components/Admin/Routes.razor +++ b/TaxBaik.Web/Components/Admin/Routes.razor @@ -1,17 +1,14 @@ @using Microsoft.AspNetCore.Components.Routing -@using Microsoft.AspNetCore.Components.Authorization - - - - - - - - 찾을 수 없음 - -

요청한 페이지를 찾을 수 없습니다.

-
-
-
-
+ + + + + + + 찾을 수 없음 + +

요청한 페이지를 찾을 수 없습니다.

+
+
+
diff --git a/TaxBaik.Web/Controllers/BlogController.cs b/TaxBaik.Web/Controllers/BlogController.cs index a994a27..d88c01a 100644 --- a/TaxBaik.Web/Controllers/BlogController.cs +++ b/TaxBaik.Web/Controllers/BlogController.cs @@ -32,6 +32,10 @@ public class BlogController : ControllerBase return Ok(post); } + [HttpGet("~/blog/{slug}")] + public IActionResult RedirectToBlogPage(string slug) + => RedirectPermanent($"/taxbaik/blog/{slug}"); + [HttpGet("admin/all")] [Authorize] public async Task GetAll() @@ -40,6 +44,14 @@ public class BlogController : ControllerBase return Ok(posts); } + [HttpGet("admin")] + [Authorize] + public async Task GetAdminPaged([FromQuery] int page = 1, [FromQuery] int pageSize = 20) + { + var (items, total) = await _blogService.GetAdminPagedAsync(page, pageSize); + return Ok(new { data = items, total, page, pageSize }); + } + [HttpPost] [Authorize] public async Task Create([FromBody] CreateBlogPostDto dto) diff --git a/TaxBaik.Web/Controllers/SiteSettingsController.cs b/TaxBaik.Web/Controllers/SiteSettingsController.cs new file mode 100644 index 0000000..0b3f5e0 --- /dev/null +++ b/TaxBaik.Web/Controllers/SiteSettingsController.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TaxBaik.Application.Services; + +namespace TaxBaik.Web.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class SiteSettingsController : ControllerBase +{ + private readonly SiteSettingService _siteSettingService; + + public SiteSettingsController(SiteSettingService siteSettingService) + { + _siteSettingService = siteSettingService; + } + + [HttpGet] + public async Task Get() + { + var settings = await _siteSettingService.GetAllAsync(); + return Ok(settings); + } + + [HttpPut] + public async Task Save([FromBody] SaveSiteSettingsRequest request) + { + if (request is null) + return BadRequest(new { message = "요청 본문이 비어 있습니다." }); + + await _siteSettingService.SaveAsync(request.Phone, request.Email, request.KakaoUrl, request.InstagramUrl); + return Ok(new { message = "사이트 설정이 저장되었습니다." }); + } +} + +public class SaveSiteSettingsRequest +{ + public string Phone { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string KakaoUrl { get; set; } = string.Empty; + public string InstagramUrl { get; set; } = string.Empty; +} diff --git a/TaxBaik.Web/Pages/Blog/Index.cshtml b/TaxBaik.Web/Pages/Blog/Index.cshtml index 0b2d449..e4d7bf5 100644 --- a/TaxBaik.Web/Pages/Blog/Index.cshtml +++ b/TaxBaik.Web/Pages/Blog/Index.cshtml @@ -8,7 +8,7 @@

세무 블로그

-
+
전체 @foreach (var cat in Model.Categories) { @@ -20,7 +20,7 @@
@foreach (var post in Model.Posts) { -
+
@post.CategoryName diff --git a/TaxBaik.Web/Pages/Blog/Post.cshtml b/TaxBaik.Web/Pages/Blog/Post.cshtml index 008d9c8..cb3a71c 100644 --- a/TaxBaik.Web/Pages/Blog/Post.cshtml +++ b/TaxBaik.Web/Pages/Blog/Post.cshtml @@ -1,10 +1,12 @@ -@page "{slug}" +@page "/blog/{slug}" @model TaxBaik.Web.Pages.Blog.BlogPostModel @{ ViewData["Title"] = Model.Post?.SeoTitle ?? Model.Post?.Title; ViewData["Description"] = Model.Post?.SeoDescription ?? ""; ViewData["OgImage"] = Model.Post?.ThumbnailUrl ?? ""; - ViewData["CanonicalUrl"] = $"http://178.104.200.7/taxbaik/blog/{Model.Post?.Slug}"; + var canonicalUrl = $"{Request.Scheme}://{Request.Host}{Request.PathBase}/blog/{Model.Post?.Slug}"; + ViewData["CanonicalUrl"] = canonicalUrl; + ViewData["OgUrl"] = canonicalUrl; } @if (Model.Post != null) diff --git a/TaxBaik.Web/Pages/Contact.cshtml b/TaxBaik.Web/Pages/Contact.cshtml index e2c5399..a4fc1b6 100644 --- a/TaxBaik.Web/Pages/Contact.cshtml +++ b/TaxBaik.Web/Pages/Contact.cshtml @@ -9,7 +9,7 @@ @if (TempData["Success"] != null) { -