Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28060b71be | |||
| a6ca30eec8 | |||
| da505d8966 | |||
| 6b8a5724fa | |||
| 9ba1887e1d | |||
| 89fc75b567 | |||
| 56475a2ef5 | |||
| a73503f96e | |||
| 788dd8d336 | |||
| 9563a1ba5a | |||
| 43881e5fd9 | |||
| 0dab03a0c8 | |||
| 716f1f668f | |||
| 3e196da7dd | |||
| d526817a00 | |||
| 0df5d2d31c | |||
| 1d7dd71011 | |||
| 3c36554164 | |||
| a825713ad2 | |||
| 0f6d22cbbe | |||
| b4bfc4d237 | |||
| 239fa367df | |||
| 6afdcaa2c3 |
@@ -1 +0,0 @@
|
|||||||
{"sessionId":"c3cb93c0-7adf-4d3a-817d-6c01e0e0f09f","pid":26816,"acquiredAt":1782481349474}
|
|
||||||
@@ -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
|
||||||
+51
-15
@@ -22,10 +22,15 @@ jobs:
|
|||||||
run: dotnet restore TaxBaik.sln
|
run: dotnet restore TaxBaik.sln
|
||||||
|
|
||||||
- name: Build solution
|
- name: Build solution
|
||||||
run: dotnet build TaxBaik.sln -c Release --no-restore
|
run: |
|
||||||
|
dotnet clean TaxBaik.sln -c Release
|
||||||
|
dotnet build TaxBaik.sln -c Release --no-restore
|
||||||
|
|
||||||
|
- name: Test solution
|
||||||
|
run: dotnet test TaxBaik.sln -c Release --no-build
|
||||||
|
|
||||||
- name: Publish Web (통합 앱)
|
- name: Publish Web (통합 앱)
|
||||||
run: dotnet publish TaxBaik.Web/ -c Release -o ./publish
|
run: dotnet publish TaxBaik.Web/ -c Release -o ./publish --no-restore
|
||||||
|
|
||||||
- name: Copy migrations to publish
|
- name: Copy migrations to publish
|
||||||
run: |
|
run: |
|
||||||
@@ -38,24 +43,55 @@ jobs:
|
|||||||
BUILD_TIME=$(date -u +'%Y-%m-%d %H:%M:%S UTC')
|
BUILD_TIME=$(date -u +'%Y-%m-%d %H:%M:%S UTC')
|
||||||
echo "Version: $COMMIT_HASH" > ./publish/wwwroot/version.txt
|
echo "Version: $COMMIT_HASH" > ./publish/wwwroot/version.txt
|
||||||
echo "Built: $BUILD_TIME" >> ./publish/wwwroot/version.txt
|
echo "Built: $BUILD_TIME" >> ./publish/wwwroot/version.txt
|
||||||
echo "✓ Version files created:"
|
echo "✓ Version: $COMMIT_HASH"
|
||||||
cat ./publish/wwwroot/version.txt
|
|
||||||
|
|
||||||
- name: Deploy (통합 Web + Admin)
|
- name: Deploy (CI only, 통합 Web)
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
DEPLOY_HOME="/home/kjh2064"
|
DEPLOY_HOME="/home/kjh2064"
|
||||||
DEPLOY_DIR="$DEPLOY_HOME/deployments/taxbaik_${TIMESTAMP}"
|
DEPLOY_DIR="$DEPLOY_HOME/deployments/taxbaik_${TIMESTAMP}"
|
||||||
|
DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}"
|
||||||
|
DEPLOY_USER="${{ secrets.DEPLOY_USER }}"
|
||||||
|
|
||||||
echo "=== Deploying TaxBaik (통합 Web + Admin) (v$(git rev-parse --short HEAD)) ==="
|
echo "=== Deploying TaxBaik v$(git rev-parse --short HEAD) ==="
|
||||||
echo "Deploy dir: $DEPLOY_DIR"
|
mkdir -p ~/.ssh
|
||||||
mkdir -p "$DEPLOY_DIR" || { echo "Failed to mkdir"; exit 1; }
|
printf '%s' "${{ secrets.DEPLOY_SSH_KEY_B64 }}" | base64 -d > ~/.ssh/id_ed25519
|
||||||
cp -r ./publish/* "$DEPLOY_DIR/" || { echo "Failed to copy"; exit 1; }
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
ln -sfn "$DEPLOY_DIR" "$DEPLOY_HOME/taxbaik_active" || { echo "Failed to symlink"; exit 1; }
|
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||||
echo "✓ Deployed to $DEPLOY_DIR"
|
|
||||||
|
|
||||||
# systemd 서비스 재시작 (SSH를 통해 진행)
|
tar -czf taxbaik_publish.tgz -C ./publish .
|
||||||
echo "=== Restarting TaxBaik service via systemd ==="
|
scp -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes taxbaik_publish.tgz "$DEPLOY_USER@$DEPLOY_HOST:/tmp/taxbaik_publish_${TIMESTAMP}.tgz"
|
||||||
ssh -o StrictHostKeyChecking=no $SSH_USER@$SSH_HOST "sudo systemctl restart taxbaik && systemctl status taxbaik --no-pager | head -5" || echo "Service restart may require password"
|
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" "
|
||||||
echo "✓ Deployment complete"
|
set -e
|
||||||
|
mkdir -p '$DEPLOY_DIR'
|
||||||
|
tar -xzf '/tmp/taxbaik_publish_${TIMESTAMP}.tgz' -C '$DEPLOY_DIR'
|
||||||
|
rm -f '/tmp/taxbaik_publish_${TIMESTAMP}.tgz'
|
||||||
|
ln -sfn '$DEPLOY_DIR' '$DEPLOY_HOME/taxbaik_active'
|
||||||
|
sudo systemctl restart taxbaik
|
||||||
|
"
|
||||||
|
sleep 5
|
||||||
|
echo "✓ Deployed to $DEPLOY_HOST:$DEPLOY_DIR"
|
||||||
|
|
||||||
|
- name: Verify deployment
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}"
|
||||||
|
DEPLOY_USER="${{ secrets.DEPLOY_USER }}"
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s' "${{ secrets.DEPLOY_SSH_KEY_B64 }}" | base64 -d > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||||
|
sleep 10
|
||||||
|
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")
|
||||||
|
ADMIN_TEST_PASSWORD="${{ secrets.TAXBAIK_ADMIN_TEST_PASSWORD }}"
|
||||||
|
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 "")
|
||||||
|
echo "Home Status: $HOME_STATUS"
|
||||||
|
echo "Login Status: $LOGIN_STATUS"
|
||||||
|
echo "Auth Body: $AUTH_BODY"
|
||||||
|
if [ "$HOME_STATUS" = "200" ] && [ "$LOGIN_STATUS" = "200" ] && echo "$AUTH_BODY" | grep -q '"token"'; then
|
||||||
|
echo "✓ Service is running"
|
||||||
|
else
|
||||||
|
echo "⚠ Service may not be running (home: $HOME_STATUS, login: $LOGIN_STATUS, auth: $AUTH_BODY)"
|
||||||
|
fi
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
### 2.1 프로젝트 구조 (통합)
|
### 2.1 프로젝트 구조 (통합)
|
||||||
|
|
||||||
**단일 앱 구조** (소규모 프로젝트 최적화):
|
**단일 앱 구조** (공개 사이트 + 관리자까지 하나의 ASP.NET Core 앱):
|
||||||
|
|
||||||
```
|
```
|
||||||
TaxBaik.Domain 클래스 라이브러리 (엔티티, 인터페이스, enum)
|
TaxBaik.Domain 클래스 라이브러리 (엔티티, 인터페이스, enum)
|
||||||
@@ -32,15 +32,21 @@ TaxBaik.Web ASP.NET Core 앱 (포트 5001)
|
|||||||
|
|
||||||
**경로:**
|
**경로:**
|
||||||
- 홈페이지: `/taxbaik` (Razor Pages)
|
- 홈페이지: `/taxbaik` (Razor Pages)
|
||||||
- 관리자: `/taxbaik/admin` (Blazor)
|
- 관리자: `/taxbaik/admin` (Blazor Server)
|
||||||
- 로그인: `/taxbaik/admin/login`
|
- 로그인: `/taxbaik/admin/login`
|
||||||
|
|
||||||
|
**운영 원칙:**
|
||||||
|
- 단일 앱, 단일 서비스, 단일 배포 경로를 유지한다.
|
||||||
|
- 운영 변경은 코드 또는 CI에서만 반영한다.
|
||||||
|
- 서버에 임시 수동 수정이나 파일 드리프트가 생기지 않도록 한다.
|
||||||
|
- 공개 사이트와 관리자 UI는 같은 앱에서 처리하되, 보안 경계는 인증과 권한으로 분리한다.
|
||||||
|
|
||||||
### 2.2 계층 책임
|
### 2.2 계층 책임
|
||||||
- **Domain**: 비즈니스 규칙, 엔티티 정의
|
- **Domain**: 비즈니스 규칙, 엔티티 정의
|
||||||
- **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 기술 결정 이유
|
||||||
@@ -50,21 +56,27 @@ 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에서 운영하면 라우팅과 인증 구성이 단순함
|
||||||
- **개발**: 터미널 1개, 포트 1개 (5001)
|
- **개발**: 터미널 1개, 포트 1개 (5001)
|
||||||
- **배포**: 앱 1개, DB 마이그레이션 1회
|
- **배포**: 앱 1개, DB 마이그레이션 1회
|
||||||
- **유지보수**: 모든 비즈니스 로직 한 곳 (Application)
|
- **유지보수**: 모든 비즈니스 로직 한 곳 (Application)
|
||||||
- **장점**: 기존 분리 구조의 모든 기능 + 간단한 개발 경험
|
- **장점**: 블로그 SEO와 관리자 기능을 하나의 실행 단위로 운영
|
||||||
|
|
||||||
**왜 Dapper인가?**
|
**왜 Dapper인가?**
|
||||||
- 팀 기존 지식 (QuantEngine에서 사용)
|
- 팀 기존 지식 (QuantEngine에서 사용)
|
||||||
- 복잡한 조인, 페이징, 성능 제어 용이
|
- 복잡한 조인, 페이징, 성능 제어 용이
|
||||||
- EF Core 대비 SQL 완전 제어 가능
|
- EF Core 대비 SQL 완전 제어 가능
|
||||||
|
|
||||||
|
**왜 이 운영 모델인가?**
|
||||||
|
- 운영 복잡도를 낮춰 장애 포인트를 줄인다.
|
||||||
|
- 배포를 CI로 고정하면 서버 간 상태 드리프트를 줄인다.
|
||||||
|
- 민감 정보는 코드/문서/로그에 남기지 않고 환경 변수와 서버 비밀 저장소에만 둔다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 로컬 개발 환경 설정
|
## 3. 로컬 개발 환경 설정
|
||||||
@@ -112,13 +124,13 @@ dotnet run -p TaxBaik.Web
|
|||||||
# 터미널 1: SSH 터널 유지
|
# 터미널 1: SSH 터널 유지
|
||||||
ssh -L 5432:127.0.0.1:5432 kjh2064@178.104.200.7
|
ssh -L 5432:127.0.0.1:5432 kjh2064@178.104.200.7
|
||||||
|
|
||||||
# 터미널 2: 통합 Web 앱 (Razor Pages + Blazor)
|
# 터미널 2: 통합 Web 앱 (Razor Pages + Blazor Server Admin)
|
||||||
cd TaxBaik.Web
|
cd TaxBaik.Web
|
||||||
dotnet run
|
dotnet run
|
||||||
# 접속:
|
# 접속:
|
||||||
# - 홈페이지: http://localhost:5001/taxbaik
|
# - 홈페이지: http://localhost:5001/taxbaik
|
||||||
# - 관리자: http://localhost:5001/taxbaik/admin/login
|
# - 관리자: http://localhost:5001/taxbaik/admin/login
|
||||||
# - 로그인: admin / admin123
|
# - 로그인: admin / <TAXBAIK_ADMIN_TEST_PASSWORD>
|
||||||
```
|
```
|
||||||
|
|
||||||
**장점**:
|
**장점**:
|
||||||
@@ -139,6 +151,11 @@ dotnet run
|
|||||||
|
|
||||||
**중요**: 로컬 appsettings.json은 버전 관리에서 제외 또는 .local suffix 사용
|
**중요**: 로컬 appsettings.json은 버전 관리에서 제외 또는 .local suffix 사용
|
||||||
|
|
||||||
|
**보안 규칙**:
|
||||||
|
- `appsettings.Production.json`에는 비밀값을 두지 않는다.
|
||||||
|
- JWT Secret, DB 비밀번호, 외부 API 키는 환경 변수 또는 서버 전용 비밀 경로에서만 읽는다.
|
||||||
|
- 값이 비어 있으면 조용히 넘어가지 말고 시작 시 즉시 실패시킨다.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 로컬 오버라이드
|
# 로컬 오버라이드
|
||||||
appsettings.Development.json # gitignore에 추가
|
appsettings.Development.json # gitignore에 추가
|
||||||
@@ -164,10 +181,14 @@ CREATE TABLE IF NOT EXISTS new_table (
|
|||||||
### 3.4 블로그 & 문의 테스트 데이터
|
### 3.4 블로그 & 문의 테스트 데이터
|
||||||
|
|
||||||
마이그레이션 V003에서 자동 생성:
|
마이그레이션 V003에서 자동 생성:
|
||||||
- 테스트 관리자: `admin` / `admin123`
|
- 테스트 관리자: `admin` / `<TAXBAIK_ADMIN_TEST_PASSWORD>`
|
||||||
- 테스트 블로그 포스트 5개
|
- 테스트 블로그 포스트 5개
|
||||||
- 테스트 카테고리 5개
|
- 테스트 카테고리 5개
|
||||||
|
|
||||||
|
**운영 보안 주의**:
|
||||||
|
- 시드 계정은 운영 초기화용이다. 배포 후에는 반드시 별도 강한 비밀번호로 교체한다.
|
||||||
|
- 테스트 계정이 운영에 남아 있으면, 배포 후 즉시 비밀번호 재설정 또는 계정 비활성화를 수행한다.
|
||||||
|
|
||||||
수동 추가:
|
수동 추가:
|
||||||
```sql
|
```sql
|
||||||
-- Admin 추가
|
-- Admin 추가
|
||||||
@@ -179,6 +200,61 @@ INSERT INTO blog_posts (title, content, slug, category_id, is_published, created
|
|||||||
VALUES ('제목', '내용', 'slug-text', 1, true, NOW());
|
VALUES ('제목', '내용', 'slug-text', 1, true, NOW());
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 3.5 Git Push with Gitea Token (Windows)
|
||||||
|
|
||||||
|
**환경 변수 설정** (한 번만 필요):
|
||||||
|
1. 시스템 환경 변수 편집 (`Win+X` → 시스템)
|
||||||
|
2. "환경 변수" 버튼 클릭
|
||||||
|
3. 새로 만들기 → `GITEA_TOKEN_TAXBAIK` = `[토큰값]`
|
||||||
|
4. PowerShell 재시작 필수
|
||||||
|
|
||||||
|
**Git Push 방법** (권장: SSH 터널):
|
||||||
|
|
||||||
|
#### 방법 A: SSH 터널 + HTTP Push (권장)
|
||||||
|
|
||||||
|
**단계 1: 터미널 1 - SSH 터널 유지**
|
||||||
|
```bash
|
||||||
|
ssh -L 3000:127.0.0.1:3000 kjh2064@178.104.200.7
|
||||||
|
# 터널이 열린 상태 유지
|
||||||
|
```
|
||||||
|
|
||||||
|
**단계 2: 터미널 2 - Git Push**
|
||||||
|
```powershell
|
||||||
|
cd D:\JobRoomz\taxbaik
|
||||||
|
$token = $env:GITEA_TOKEN_TAXBAIK
|
||||||
|
git push "http://kjh2064:${token}@localhost:3000/kjh2064/taxbaik.git" master
|
||||||
|
```
|
||||||
|
|
||||||
|
**장점**:
|
||||||
|
- ✅ 로컬 네트워크 차단 회피 (SSH는 열림)
|
||||||
|
- ✅ 안전 (token은 로컬 루프백)
|
||||||
|
- ✅ 신뢰성 높음
|
||||||
|
|
||||||
|
**보안 규칙**:
|
||||||
|
- 토큰은 채팅/문서/스크린샷에 붙이지 않는다.
|
||||||
|
- push URL에 토큰이 남아 있으면 즉시 제거한다.
|
||||||
|
- 가능하면 SSH key 기반 인증을 우선 사용한다.
|
||||||
|
|
||||||
|
#### 방법 B: SSH로 직접 Push (SSH key 필요)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH key가 이미 설정되어 있으면
|
||||||
|
git push ssh://git@178.104.200.7:2222/kjh2064/taxbaik.git master
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 방법 C: HTTPS Direct (네트워크 차단이 없으면)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$token = $env:GITEA_TOKEN_TAXBAIK
|
||||||
|
git push "https://kjh2064:${token}@178.104.200.7/kjh2064/taxbaik.git" master
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gitea Actions 자동 배포**:
|
||||||
|
1. git push 성공 → master 브랜치에 커밋
|
||||||
|
2. Gitea Actions CI/CD 자동 trigger (.gitea/workflows/deploy.yml)
|
||||||
|
3. 빌드 → 배포 → 서비스 재시작 자동 실행
|
||||||
|
4. 배포 진행 상황: `http://localhost:3000/kjh2064/taxbaik/actions` (SSH 터널 사용 시)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 서버 & 배포
|
## 6. 서버 & 배포
|
||||||
@@ -198,48 +274,26 @@ ssh kjh2064@178.104.200.7
|
|||||||
5432 : PostgreSQL (localhost 바인드)
|
5432 : PostgreSQL (localhost 바인드)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.3 배포 절차 (Shadow Copy를 통한 Hot Deploy)
|
### 3.3 배포 절차 (CI only)
|
||||||
|
|
||||||
**핵심 전략**: .NET Core shadow copy로 배포 중 무중단 실행
|
배포는 수동 실행이 아니라 **Gitea Actions CI/CD**만 사용한다.
|
||||||
|
|
||||||
1. **로컬 빌드** (단일 앱 통합):
|
1. `master` 브랜치에 push
|
||||||
```bash
|
2. Gitea Actions가 `TaxBaik.Web`을 build/publish
|
||||||
dotnet clean TaxBaik.sln
|
3. CI가 서버의 `taxbaik` 서비스와 `~/taxbaik_active`를 갱신
|
||||||
dotnet publish TaxBaik.Web -c Release -o ./publish
|
4. CI가 서비스 재시작 후 `/taxbaik/admin/login`으로 헬스 체크
|
||||||
```
|
|
||||||
|
|
||||||
2. **CI/CD 배포** (Gitea Actions):
|
**운영 규칙**:
|
||||||
- 새 버전을 `~/deployments/taxbaik_TIMESTAMP/` 에 업로드
|
- 로컬 또는 서버에서 수동 `dotnet publish`로 운영 배포하지 않는다
|
||||||
- 기존 프로세스는 계속 실행 (원본 DLL은 영향 없음)
|
- `rsync`로 직접 아티팩트를 올리지 않는다
|
||||||
|
- 배포 실패 시 CI 로그를 먼저 본다
|
||||||
|
- 배포된 아티팩트는 CI가 만든 것만 신뢰한다
|
||||||
|
- 배포 후 검증은 홈, 관리자 로그인 페이지, 로그인 API를 모두 포함한다
|
||||||
|
|
||||||
3. **Shadow Copy 메커니즘**:
|
**롤백**:
|
||||||
- .NET Core 런타임이 어셈블리를 메모리에 로드
|
- 이전 정상 커밋을 `master`에 revert 또는 hotfix로 되돌린다
|
||||||
- `~/deployments/` 아래의 새 DLL들을 준비
|
- 서버 파일을 수동으로 복구하지 않는다
|
||||||
- 심링크만 변경 (`ln -sfn ~/deployments/taxbaik_TIMESTAMP ~/taxbaik_active`)
|
- 롤백은 커밋 단위로 추적 가능해야 한다
|
||||||
|
|
||||||
4. **Graceful Restart**:
|
|
||||||
- 기존 요청 완료 대기 (max 30초)
|
|
||||||
- `sudo systemctl restart taxbaik` 실행
|
|
||||||
- 새 프로세스가 새 DLL 로드
|
|
||||||
|
|
||||||
5. **롤백 (1초 이내)**:
|
|
||||||
```bash
|
|
||||||
ln -sfn ~/deployments/taxbaik_PREVIOUS_TIMESTAMP ~/taxbaik_active
|
|
||||||
sudo systemctl restart taxbaik
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **오래된 배포 정리** (매 배포마다):
|
|
||||||
```bash
|
|
||||||
# 최근 5개 배포만 유지
|
|
||||||
ls -dt ~/deployments/taxbaik_* | tail -n +6 | xargs -r rm -rf
|
|
||||||
```
|
|
||||||
|
|
||||||
**systemd 서비스 graceful shutdown 설정**:
|
|
||||||
```ini
|
|
||||||
[Service]
|
|
||||||
TimeoutStopSec=35 # 기존 요청 완료 대기 (30초) + 여유
|
|
||||||
KillMode=mixed # SIGTERM → 30초 대기 → SIGKILL
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.4 서비스 파일 위치
|
### 3.4 서비스 파일 위치
|
||||||
```
|
```
|
||||||
@@ -247,14 +301,7 @@ KillMode=mixed # SIGTERM → 30초 대기 → SIGKILL
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 5.5 배포 디렉토리 구조 (서버)
|
### 5.5 배포 디렉토리 구조 (서버)
|
||||||
```
|
배포 디렉토리는 CI가 관리한다. 로컬에서 구조를 맞추거나 수동으로 갱신하지 않는다.
|
||||||
/home/kjh2064/
|
|
||||||
├── taxbaik_active → ./deployments/taxbaik_20260626_150000/
|
|
||||||
└── deployments/
|
|
||||||
├── taxbaik_20260626_150000/ (통합 Web publish 출력)
|
|
||||||
├── taxbaik_20260626_140000/ (이전 버전)
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -270,6 +317,7 @@ location /taxbaik {
|
|||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "Upgrade";
|
proxy_set_header Connection "Upgrade";
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
@@ -278,7 +326,12 @@ location /taxbaik {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**참고**: 단일 `/taxbaik` 블록이 공개 사이트와 관리자 (Blazor WebSocket)를 모두 처리합니다.
|
**참고**: 단일 `/taxbaik` 블록이 공개 사이트와 관리자 Blazor 회로를 모두 처리합니다. 운영은 `5001` 통합 앱 기준이며, 설정 반영은 CI 배포로만 수행한다.
|
||||||
|
|
||||||
|
**Nginx 보안**:
|
||||||
|
- `Upgrade` 헤더는 Blazor WebSocket 경로에만 허용하고, 필요 없는 location에는 넣지 않는다.
|
||||||
|
- `Host`와 `X-Forwarded-Proto`는 유지해 원본 URL과 스킴을 보존한다.
|
||||||
|
- `/taxbaik/admin`는 robots.txt에서 차단한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -293,6 +346,12 @@ Environment=ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Usernam
|
|||||||
|
|
||||||
**절대 appsettings.Production.json에 비밀값을 하드코딩하지 말 것.**
|
**절대 appsettings.Production.json에 비밀값을 하드코딩하지 말 것.**
|
||||||
|
|
||||||
|
**운영 보안 규칙**:
|
||||||
|
- DB 계정은 애플리케이션 전용 최소 권한으로 둔다.
|
||||||
|
- 관리자 비밀번호는 bcrypt로 해시하고, 평문 저장/전송을 금지한다.
|
||||||
|
- `PasswordHash`는 null이 되면 안 되며, null이면 인증 실패로 즉시 처리한다.
|
||||||
|
- 로그인 실패 로그는 사용자 이름만 남기고 비밀번호/해시를 절대 남기지 않는다.
|
||||||
|
|
||||||
### 3.2 Dapper 사용 패턴
|
### 3.2 Dapper 사용 패턴
|
||||||
|
|
||||||
**DbConnectionFactory.cs**:
|
**DbConnectionFactory.cs**:
|
||||||
@@ -323,7 +382,8 @@ public async Task<BlogPost?> GetBySlugAsync(string slug, CancellationToken ct)
|
|||||||
- 항상 `using var conn = Conn();` 사용 (자동 닫기)
|
- 항상 `using var conn = Conn();` 사용 (자동 닫기)
|
||||||
- 항상 `@ParameterName` 파라미터 사용 (SQL injection 방지)
|
- 항상 `@ParameterName` 파라미터 사용 (SQL injection 방지)
|
||||||
- 절대 문자열 연결 금지
|
- 절대 문자열 연결 금지
|
||||||
- 대소문자 구분 안 함 (Dapper가 매핑)
|
- PostgreSQL `snake_case` 컬럼은 Dapper underscore 매핑을 전제로 함
|
||||||
|
- 조회 쿼리는 필요한 컬럼만 명시한다. `SELECT *`는 스키마 변경 시 매핑 사고를 만든다.
|
||||||
|
|
||||||
### 3.3 마이그레이션
|
### 3.3 마이그레이션
|
||||||
|
|
||||||
@@ -477,6 +537,11 @@ builder.Services.AddAuthorizationCore();
|
|||||||
|
|
||||||
토큰은 localStorage에 저장되며, `CustomAuthenticationStateProvider`가 자동으로 복원:
|
토큰은 localStorage에 저장되며, `CustomAuthenticationStateProvider`가 자동으로 복원:
|
||||||
|
|
||||||
|
**보안 규칙**:
|
||||||
|
- JWT 만료 시간을 짧고 명확하게 유지한다.
|
||||||
|
- localStorage 토큰은 XSS가 없다는 전제 없이 다뤄야 한다.
|
||||||
|
- 관리자 기능은 `[Authorize]`로 감싸고, 클라이언트 렌더링만으로 권한을 믿지 않는다.
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// CustomAuthenticationStateProvider.cs
|
// CustomAuthenticationStateProvider.cs
|
||||||
public async Task LoginAsync(string token)
|
public async Task LoginAsync(string token)
|
||||||
@@ -560,6 +625,9 @@ Admin 로그인 페이지만 [AllowAnonymous]:
|
|||||||
- [x] 카테고리 목록 캐시 (IMemoryCache, 10분 유효)
|
- [x] 카테고리 목록 캐시 (IMemoryCache, 10분 유효)
|
||||||
- [x] 비밀값은 환경 변수에서 읽기
|
- [x] 비밀값은 환경 변수에서 읽기
|
||||||
- [x] `[ValidateAntiForgeryToken]` POST 메서드에 추가
|
- [x] `[ValidateAntiForgeryToken]` POST 메서드에 추가
|
||||||
|
- [x] 운영 배포는 CI-only
|
||||||
|
- [x] 관리자 로그인은 서버에서 직접 bypass하지 않기
|
||||||
|
- [x] DB/인증 문제는 로그와 쿼리로 먼저 확인
|
||||||
|
|
||||||
### DON'T ❌
|
### DON'T ❌
|
||||||
- [ ] 비밀값을 appsettings.Production.json에 하드코딩
|
- [ ] 비밀값을 appsettings.Production.json에 하드코딩
|
||||||
@@ -571,6 +639,9 @@ Admin 로그인 페이지만 [AllowAnonymous]:
|
|||||||
- [ ] robots.txt에서 `/taxbaik/admin` allow 금지 (disallow 필수)
|
- [ ] robots.txt에서 `/taxbaik/admin` allow 금지 (disallow 필수)
|
||||||
- [ ] 폼 제출 후 redirect (fire-and-forget 또는 same-page 응답)
|
- [ ] 폼 제출 후 redirect (fire-and-forget 또는 same-page 응답)
|
||||||
- [ ] 절대 `Thread.Sleep` 또는 `Task.Delay` in request handler
|
- [ ] 절대 `Thread.Sleep` 또는 `Task.Delay` in request handler
|
||||||
|
- [ ] 운영 서버에서 수동 publish/rsync/파일 교체
|
||||||
|
- [ ] 비밀번호/토큰을 로그에 출력
|
||||||
|
- [ ] `SELECT *`로 인증/권한 테이블 조회
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -633,7 +704,7 @@ dotnet build TaxBaik.sln
|
|||||||
ssh kjh2064@178.104.200.7
|
ssh kjh2064@178.104.200.7
|
||||||
|
|
||||||
# DB 확인
|
# DB 확인
|
||||||
psql -U kjh2064 -d taxbaikdb -c "\dt"
|
psql -U taxbaik -d taxbaikdb -c "\dt"
|
||||||
|
|
||||||
# 서비스 상태 (통합 Web 앱만)
|
# 서비스 상태 (통합 Web 앱만)
|
||||||
systemctl status taxbaik
|
systemctl status taxbaik
|
||||||
@@ -654,7 +725,7 @@ curl -X POST http://178.104.200.7/taxbaik/contact \
|
|||||||
|
|
||||||
# 관리자 DB에서 확인
|
# 관리자 DB에서 확인
|
||||||
ssh kjh2064@178.104.200.7
|
ssh kjh2064@178.104.200.7
|
||||||
psql -U kjh2064 -d taxbaikdb
|
psql -U taxbaik -d taxbaikdb
|
||||||
SELECT * FROM inquiries ORDER BY created_at DESC LIMIT 1;
|
SELECT * FROM inquiries ORDER BY created_at DESC LIMIT 1;
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -667,8 +738,9 @@ SELECT * FROM inquiries ORDER BY created_at DESC LIMIT 1;
|
|||||||
| 앱 시작 안 됨 | `journalctl -u taxbaik -n 50` 로그 확인 |
|
| 앱 시작 안 됨 | `journalctl -u taxbaik -n 50` 로그 확인 |
|
||||||
| DB 연결 실패 | 환경 변수 `ConnectionStrings__Default` 확인 (systemd unit file) |
|
| DB 연결 실패 | 환경 변수 `ConnectionStrings__Default` 확인 (systemd unit file) |
|
||||||
| 404 /taxbaik | Nginx 설정 재로드: `sudo nginx -t && sudo systemctl reload nginx` |
|
| 404 /taxbaik | Nginx 설정 재로드: `sudo nginx -t && sudo systemctl reload nginx` |
|
||||||
| Blazor WebSocket 안 됨 | `/taxbaik/admin` 경로에 Upgrade 헤더 필요 (Nginx 설정 확인) |
|
| Blazor WebSocket 안 됨 | `/taxbaik` location에 `proxy_http_version 1.1`, `Upgrade`, `Connection "Upgrade"` 헤더가 모두 있는지 확인 |
|
||||||
| 배포 후 503 | 서비스 시작 대기 (startup 시간 ~5초), `systemctl status taxbaik` 확인 |
|
| 배포 후 503 | 서비스 시작 대기 (startup 시간 ~5초), `systemctl status taxbaik` 확인 |
|
||||||
|
| 로그인 실패 | `admin_users.password_hash`와 bcrypt 해시, `AuthService` 로그, `/api/auth/login` 응답 확인 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -678,5 +750,5 @@ SELECT * FROM inquiries ORDER BY created_at DESC LIMIT 1;
|
|||||||
- [ ] DB 마이그레이션 SQL 파일 생성
|
- [ ] DB 마이그레이션 SQL 파일 생성
|
||||||
- [ ] systemd 서비스 파일 서버에 설치
|
- [ ] systemd 서비스 파일 서버에 설치
|
||||||
- [ ] Nginx location 블록 설정
|
- [ ] Nginx location 블록 설정
|
||||||
- [ ] Gitea Secrets (DEPLOY_USER, DEPLOY_HOST, DEPLOY_SSH_KEY) 추가
|
- [ ] Gitea Secrets (DEPLOY_USER, DEPLOY_HOST, DEPLOY_SSH_KEY_B64) 추가
|
||||||
- [ ] 초기 커밋 및 git push
|
- [ ] 초기 커밋 및 git push
|
||||||
|
|||||||
+19
-38
@@ -27,21 +27,12 @@ Environment=ASPNETCORE_URLS=http://127.0.0.1:5001
|
|||||||
Environment=ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=your_secure_password
|
Environment=ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=your_secure_password
|
||||||
```
|
```
|
||||||
|
|
||||||
**Admin 서비스** (`/etc/systemd/system/taxbaik-admin.service`):
|
|
||||||
```ini
|
|
||||||
[Service]
|
|
||||||
Environment=ASPNETCORE_ENVIRONMENT=Production
|
|
||||||
Environment=ASPNETCORE_URLS=http://127.0.0.1:5002
|
|
||||||
Environment=ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=your_secure_password
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. systemd 서비스 파일 설치
|
### 3. systemd 서비스 파일 설치
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo cp deploy/taxbaik.service /etc/systemd/system/
|
sudo cp deploy/taxbaik.service /etc/systemd/system/
|
||||||
sudo cp deploy/taxbaik-admin.service /etc/systemd/system/
|
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable taxbaik taxbaik-admin
|
sudo systemctl enable taxbaik
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Nginx 설정
|
### 4. Nginx 설정
|
||||||
@@ -65,31 +56,16 @@ sudo systemctl reload nginx
|
|||||||
1. Gitea 저장소 Secrets 추가:
|
1. Gitea 저장소 Secrets 추가:
|
||||||
- `DEPLOY_USER`: `kjh2064`
|
- `DEPLOY_USER`: `kjh2064`
|
||||||
- `DEPLOY_HOST`: `178.104.200.7`
|
- `DEPLOY_HOST`: `178.104.200.7`
|
||||||
- `DEPLOY_SSH_KEY`: SSH 개인키 (줄바꿈 포함)
|
- `DEPLOY_SSH_KEY_B64`: base64로 인코딩한 SSH 개인키
|
||||||
|
- `TAXBAIK_ADMIN_TEST_PASSWORD`: 배포 검증용 관리자 비밀번호
|
||||||
|
- `Admin__PasswordResetToken`: 관리자 비밀번호 재설정 API용 서버 비밀값
|
||||||
|
|
||||||
2. 배포 워크플로우는 자동으로 실행:
|
2. 배포 워크플로우는 자동으로 실행:
|
||||||
```
|
```
|
||||||
master 브랜치 push → build → publish → rsync → restart
|
master 브랜치 push → build → publish → restart
|
||||||
```
|
```
|
||||||
|
|
||||||
### 수동 배포 (필요시)
|
수동 배포는 비상 롤백 외에는 사용하지 않습니다. 배포 이슈는 Gitea Actions 로그로 해결합니다.
|
||||||
|
|
||||||
```bash
|
|
||||||
# 로컬에서 빌드
|
|
||||||
dotnet publish TaxBaik.sln -c Release -o ./publish
|
|
||||||
|
|
||||||
# 서버에 배포
|
|
||||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
|
||||||
rsync -az ./publish/web/ kjh2064@178.104.200.7:~/deployments/taxbaik_${TIMESTAMP}/
|
|
||||||
rsync -az ./publish/admin/ kjh2064@178.104.200.7:~/deployments/taxbaik_admin_${TIMESTAMP}/
|
|
||||||
|
|
||||||
# 서버에서 심링크 변경 및 재시작
|
|
||||||
ssh kjh2064@178.104.200.7 << EOF
|
|
||||||
ln -sfn ~/deployments/taxbaik_${TIMESTAMP} ~/taxbaik_active
|
|
||||||
ln -sfn ~/deployments/taxbaik_admin_${TIMESTAMP} ~/taxbaik_admin_active
|
|
||||||
sudo systemctl restart taxbaik taxbaik-admin
|
|
||||||
EOF
|
|
||||||
```
|
|
||||||
|
|
||||||
## 마이그레이션 자동 실행
|
## 마이그레이션 자동 실행
|
||||||
|
|
||||||
@@ -102,7 +78,6 @@ EOF
|
|||||||
로그 확인:
|
로그 확인:
|
||||||
```bash
|
```bash
|
||||||
journalctl -u taxbaik -n 50
|
journalctl -u taxbaik -n 50
|
||||||
journalctl -u taxbaik-admin -n 50
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 검증
|
## 검증
|
||||||
@@ -116,6 +91,11 @@ curl -I http://178.104.200.7/taxbaik/
|
|||||||
# 관리자 로그인 페이지
|
# 관리자 로그인 페이지
|
||||||
curl -I http://178.104.200.7/taxbaik/admin/login
|
curl -I http://178.104.200.7/taxbaik/admin/login
|
||||||
|
|
||||||
|
# 로그인 API 확인
|
||||||
|
curl -X POST http://178.104.200.7/taxbaik/api/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"username\":\"admin\",\"password\":\"<TAXBAIK_ADMIN_TEST_PASSWORD>\"}"
|
||||||
|
|
||||||
# 문의 폼 제출 테스트
|
# 문의 폼 제출 테스트
|
||||||
curl -X POST http://178.104.200.7/taxbaik/contact \
|
curl -X POST http://178.104.200.7/taxbaik/contact \
|
||||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
@@ -158,10 +138,10 @@ sudo systemctl restart taxbaik
|
|||||||
ssh kjh2064@178.104.200.7
|
ssh kjh2064@178.104.200.7
|
||||||
|
|
||||||
# 서비스 상태
|
# 서비스 상태
|
||||||
systemctl status taxbaik taxbaik-admin
|
systemctl status taxbaik
|
||||||
|
|
||||||
# 포트 확인
|
# 포트 확인
|
||||||
netstat -tlnp | grep -E '5001|5002'
|
netstat -tlnp | grep -E '5001'
|
||||||
|
|
||||||
# 프로세스 확인
|
# 프로세스 확인
|
||||||
ps aux | grep TaxBaik
|
ps aux | grep TaxBaik
|
||||||
@@ -175,7 +155,6 @@ tail -f /var/log/nginx/access.log | grep taxbaik
|
|||||||
|
|
||||||
# 애플리케이션 로그
|
# 애플리케이션 로그
|
||||||
journalctl -u taxbaik -f
|
journalctl -u taxbaik -f
|
||||||
journalctl -u taxbaik-admin -f
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 트러블슈팅
|
## 트러블슈팅
|
||||||
@@ -183,6 +162,7 @@ journalctl -u taxbaik-admin -f
|
|||||||
| 증상 | 원인 | 해결 |
|
| 증상 | 원인 | 해결 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 404 /taxbaik | Nginx 설정 미적용 | `sudo nginx -t && sudo systemctl reload nginx` |
|
| 404 /taxbaik | Nginx 설정 미적용 | `sudo nginx -t && sudo systemctl reload nginx` |
|
||||||
|
| Blazor WebSocket 안 됨 | `/taxbaik` location에 `proxy_http_version 1.1`, `Upgrade`, `Connection \"Upgrade\"` 헤더가 모두 있는지 확인 |
|
||||||
| DB 연결 오류 | 환경 변수 미설정 | systemd service 파일의 ConnectionStrings__Default 확인 |
|
| DB 연결 오류 | 환경 변수 미설정 | systemd service 파일의 ConnectionStrings__Default 확인 |
|
||||||
| 503 Service Unavailable | 앱 미시작 | `sudo systemctl restart taxbaik` |
|
| 503 Service Unavailable | 앱 미시작 | `sudo systemctl restart taxbaik` |
|
||||||
| 마이그레이션 실패 | DB 권한 문제 | `GRANT ALL PRIVILEGES ON DATABASE taxbaikdb TO taxbaik;` |
|
| 마이그레이션 실패 | DB 권한 문제 | `GRANT ALL PRIVILEGES ON DATABASE taxbaikdb TO taxbaik;` |
|
||||||
@@ -192,8 +172,8 @@ journalctl -u taxbaik-admin -f
|
|||||||
### 관리자 계정
|
### 관리자 계정
|
||||||
|
|
||||||
- **username**: `admin`
|
- **username**: `admin`
|
||||||
- **password**: `admin123` (bcrypt 해시됨)
|
- **password**: `<TAXBAIK_ADMIN_TEST_PASSWORD>` (운영 검증용 비밀번호, Secrets로 관리)
|
||||||
- 초기 로그인 후 비밀번호 변경 권장
|
- 초기 로그인 후 비밀번호 즉시 변경 권장
|
||||||
|
|
||||||
### 블로그 포스트
|
### 블로그 포스트
|
||||||
|
|
||||||
@@ -208,6 +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` 갱신
|
||||||
|
|||||||
+9
-13
@@ -12,7 +12,6 @@
|
|||||||
# 1단계: 빌드 (이미 완료됨)
|
# 1단계: 빌드 (이미 완료됨)
|
||||||
cd C:\Temp\taxbaik
|
cd C:\Temp\taxbaik
|
||||||
dotnet publish TaxBaik.Web -c Release -o ./publish/web
|
dotnet publish TaxBaik.Web -c Release -o ./publish/web
|
||||||
dotnet publish TaxBaik.Admin -c Release -o ./publish/admin
|
|
||||||
|
|
||||||
# 2단계: Docker Compose 실행
|
# 2단계: Docker Compose 실행
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
@@ -31,10 +30,10 @@ docker-compose ps
|
|||||||
- 상담 신청 폼
|
- 상담 신청 폼
|
||||||
|
|
||||||
### 관리자 백오피스 (Blazor Server)
|
### 관리자 백오피스 (Blazor Server)
|
||||||
- **URL**: http://localhost:5002/taxbaik/admin/login
|
- **URL**: http://localhost:5001/taxbaik/admin/login
|
||||||
- **초기 계정**:
|
- **초기 계정**:
|
||||||
- username: `admin`
|
- username: `admin`
|
||||||
- password: `admin123`
|
- password: `<TAXBAIK_ADMIN_TEST_PASSWORD>`
|
||||||
- **기능**:
|
- **기능**:
|
||||||
- 대시보드 확인
|
- 대시보드 확인
|
||||||
- 블로그 관리
|
- 블로그 관리
|
||||||
@@ -71,9 +70,6 @@ SELECT username FROM admin_users;
|
|||||||
# Web 앱 로그
|
# Web 앱 로그
|
||||||
docker-compose logs -f taxbaik-web
|
docker-compose logs -f taxbaik-web
|
||||||
|
|
||||||
# Admin 앱 로그
|
|
||||||
docker-compose logs -f taxbaik-admin
|
|
||||||
|
|
||||||
# 데이터베이스 로그
|
# 데이터베이스 로그
|
||||||
docker-compose logs -f postgres
|
docker-compose logs -f postgres
|
||||||
```
|
```
|
||||||
@@ -103,16 +99,16 @@ curl -X POST http://localhost:5001/taxbaik/contact \
|
|||||||
### 6.3 관리자 테스트
|
### 6.3 관리자 테스트
|
||||||
```bash
|
```bash
|
||||||
# 로그인 페이지
|
# 로그인 페이지
|
||||||
curl -I http://localhost:5002/taxbaik/admin/login
|
curl -I http://localhost:5001/taxbaik/admin/login
|
||||||
# 예상: 200 OK
|
# 예상: 200 OK
|
||||||
|
|
||||||
# 로그인 (쿠키 저장)
|
# 로그인 (쿠키 저장)
|
||||||
curl -c cookies.txt -X POST http://localhost:5002/taxbaik/admin/login \
|
curl -c cookies.txt -X POST http://localhost:5001/taxbaik/admin/login \
|
||||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
-d "username=admin&password=admin123"
|
-d "username=admin&password=<TAXBAIK_ADMIN_TEST_PASSWORD>"
|
||||||
|
|
||||||
# 대시보드 접근 (쿠키 사용)
|
# 대시보드 접근 (쿠키 사용)
|
||||||
curl -b cookies.txt http://localhost:5002/taxbaik/admin/dashboard
|
curl -b cookies.txt http://localhost:5001/taxbaik/admin/dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
## 7. 종료 및 정리
|
## 7. 종료 및 정리
|
||||||
@@ -125,14 +121,14 @@ docker-compose down
|
|||||||
docker-compose down -v
|
docker-compose down -v
|
||||||
|
|
||||||
# 이미지 삭제
|
# 이미지 삭제
|
||||||
docker rmi taxbaik-web taxbaik-admin
|
docker rmi taxbaik-web
|
||||||
```
|
```
|
||||||
|
|
||||||
## 8. 트러블슈팅
|
## 8. 트러블슈팅
|
||||||
|
|
||||||
| 문제 | 해결 방법 |
|
| 문제 | 해결 방법 |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| 포트 5001/5002 사용 중 | `netstat -ano \| findstr :5001` 후 프로세스 종료 |
|
| 포트 5001 사용 중 | `netstat -ano \| findstr :5001` 후 프로세스 종료 |
|
||||||
| 데이터베이스 연결 실패 | `docker-compose logs postgres` 로그 확인 |
|
| 데이터베이스 연결 실패 | `docker-compose logs postgres` 로그 확인 |
|
||||||
| 마이그레이션 오류 | `docker-compose down -v` 후 재시작 |
|
| 마이그레이션 오류 | `docker-compose down -v` 후 재시작 |
|
||||||
| 메모리 부족 | Docker Desktop 설정에서 메모리 증가 |
|
| 메모리 부족 | Docker Desktop 설정에서 메모리 증가 |
|
||||||
@@ -169,5 +165,5 @@ docker-compose exec postgres psql -U taxbaik -d taxbaikdb \
|
|||||||
|
|
||||||
**상태 확인 URL**:
|
**상태 확인 URL**:
|
||||||
- 공개 사이트: http://localhost:5001/taxbaik
|
- 공개 사이트: http://localhost:5001/taxbaik
|
||||||
- 관리자: http://localhost:5002/taxbaik/admin/login
|
- 관리자: http://localhost:5001/taxbaik/admin/login
|
||||||
- 데이터베이스: localhost:5432
|
- 데이터베이스: localhost:5432
|
||||||
|
|||||||
+4
-4
@@ -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
@@ -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
@@ -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 압축 확인
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -40,8 +40,7 @@ TaxBaik/
|
|||||||
├── TaxBaik.Domain/ # 비즈니스 규칙, 엔티티, 인터페이스
|
├── TaxBaik.Domain/ # 비즈니스 규칙, 엔티티, 인터페이스
|
||||||
├── TaxBaik.Infrastructure/ # DB 접근, Dapper 구현체, 마이그레이션
|
├── TaxBaik.Infrastructure/ # DB 접근, Dapper 구현체, 마이그레이션
|
||||||
├── TaxBaik.Application/ # 서비스, DTO, 비즈니스 워크플로우
|
├── TaxBaik.Application/ # 서비스, DTO, 비즈니스 워크플로우
|
||||||
├── TaxBaik.Web/ # Razor Pages 공개 사이트 (port 5001)
|
├── TaxBaik.Web/ # Razor Pages + 관리자 통합 앱 (port 5001)
|
||||||
├── TaxBaik.Admin/ # Blazor Server 관리자 (port 5002)
|
|
||||||
├── db/migrations/ # 데이터베이스 마이그레이션 SQL
|
├── db/migrations/ # 데이터베이스 마이그레이션 SQL
|
||||||
├── deploy/ # systemd 서비스 파일, Nginx 설정
|
├── deploy/ # systemd 서비스 파일, Nginx 설정
|
||||||
└── .gitea/workflows/ # CI/CD 파이프라인
|
└── .gitea/workflows/ # CI/CD 파이프라인
|
||||||
@@ -75,7 +74,7 @@ TaxBaik/
|
|||||||
- 이미지 lazy load
|
- 이미지 lazy load
|
||||||
- CSS/JS 최적화
|
- CSS/JS 최적화
|
||||||
|
|
||||||
### 관리자 백오피스 (TaxBaik.Admin)
|
### 관리자 백오피스 (TaxBaik.Web 내 Blazor Server)
|
||||||
|
|
||||||
- **대시보드**
|
- **대시보드**
|
||||||
- 이번달 문의 수
|
- 이번달 문의 수
|
||||||
@@ -104,7 +103,7 @@ TaxBaik/
|
|||||||
### 개발 환경 설정
|
### 개발 환경 설정
|
||||||
|
|
||||||
**필수 요구사항:**
|
**필수 요구사항:**
|
||||||
- .NET 8.0 SDK
|
- .NET 10.0 SDK
|
||||||
- PostgreSQL 18.4
|
- PostgreSQL 18.4
|
||||||
- Git
|
- Git
|
||||||
|
|
||||||
@@ -130,38 +129,40 @@ dotnet run --project TaxBaik.Web
|
|||||||
|
|
||||||
# 5. 브라우저 열기
|
# 5. 브라우저 열기
|
||||||
# 공개 사이트: http://localhost:5001/taxbaik
|
# 공개 사이트: http://localhost:5001/taxbaik
|
||||||
# 관리자: http://localhost:5002/taxbaik/admin
|
# 관리자: http://localhost:5001/taxbaik/admin/login
|
||||||
```
|
```
|
||||||
|
|
||||||
### 초기 로그인 정보
|
### 초기 로그인 정보
|
||||||
|
|
||||||
- **username**: `admin`
|
- **username**: `admin`
|
||||||
- **password**: `admin123`
|
- **password**: `<TAXBAIK_ADMIN_TEST_PASSWORD>` or current rotated admin password
|
||||||
|
|
||||||
> ⚠️ **중요**: 프로덕션 배포 시 비밀번호 변경 필수
|
> ⚠️ **중요**: 프로덕션 배포 시 비밀번호 변경 필수이며, 검증용 비밀번호는 Gitea Secrets로 관리
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 배포
|
## 배포
|
||||||
|
|
||||||
### 자동 배포 (Gitea Actions)
|
### 배포 방식
|
||||||
|
|
||||||
|
배포는 **Gitea Actions CI/CD**만 사용합니다.
|
||||||
|
|
||||||
master 브랜치에 푸시하면 자동으로:
|
master 브랜치에 푸시하면 자동으로:
|
||||||
1. ✅ .NET 빌드 (Release)
|
1. ✅ .NET 빌드 (Release)
|
||||||
2. ✅ 단위 테스트 실행
|
2. ✅ 단위 테스트 실행
|
||||||
3. ✅ Web & Admin 게시
|
3. ✅ `TaxBaik.Web` 게시
|
||||||
4. ✅ 서버에 rsync로 업로드
|
4. ✅ 원격 서버 배포 디렉토리 업로드 및 `taxbaik_active` 심링크 교체
|
||||||
5. ✅ 심링크 스왑 (무중단 배포)
|
5. ✅ systemd `taxbaik` 단일 서비스 재시작
|
||||||
6. ✅ 서비스 재시작
|
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`: SSH 개인키 (줄바꿈 포함)
|
- `DEPLOY_SSH_KEY_B64`: base64로 인코딩한 SSH 개인키
|
||||||
|
- `TAXBAIK_ADMIN_TEST_PASSWORD`: 배포 검증용 관리자 비밀번호
|
||||||
|
- `Admin__PasswordResetToken`: 관리자 비밀번호 재설정 API용 서버 비밀값
|
||||||
|
|
||||||
### 수동 배포
|
수동 배포는 비상 롤백 절차 외에는 사용하지 않습니다. 실패 시 [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)의 CI 점검 절차를 따릅니다.
|
||||||
|
|
||||||
[DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) 참고
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -243,7 +244,7 @@ psql -U taxbaik -d taxbaikdb -c "DELETE FROM schema_migrations WHERE version='00
|
|||||||
```bash
|
```bash
|
||||||
# 포트 확인
|
# 포트 확인
|
||||||
lsof -i :5001
|
lsof -i :5001
|
||||||
lsof -i :5002
|
lsof -i :5001
|
||||||
|
|
||||||
# 프로세스 종료
|
# 프로세스 종료
|
||||||
kill -9 <PID>
|
kill -9 <PID>
|
||||||
|
|||||||
+4
-38
@@ -126,28 +126,23 @@ SELECT * FROM categories;
|
|||||||
```bash
|
```bash
|
||||||
# 로컬에서:
|
# 로컬에서:
|
||||||
scp deploy/taxbaik.service kjh2064@178.104.200.7:~/
|
scp deploy/taxbaik.service kjh2064@178.104.200.7:~/
|
||||||
scp deploy/taxbaik-admin.service kjh2064@178.104.200.7:~/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
서버에서:
|
서버에서:
|
||||||
```bash
|
```bash
|
||||||
# 파일 복사
|
# 파일 복사
|
||||||
sudo cp ~/taxbaik.service /etc/systemd/system/
|
sudo cp ~/taxbaik.service /etc/systemd/system/
|
||||||
sudo cp ~/taxbaik-admin.service /etc/systemd/system/
|
|
||||||
|
|
||||||
# 환경 변수 추가 (DB 연결 문자열)
|
# 환경 변수 추가 (DB 연결 문자열)
|
||||||
sudo nano /etc/systemd/system/taxbaik.service
|
sudo nano /etc/systemd/system/taxbaik.service
|
||||||
# 아래 줄을 [Service] 섹션에서 주석 해제:
|
# 아래 줄을 [Service] 섹션에서 주석 해제:
|
||||||
# Environment=ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=your_password
|
# Environment=ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=your_password
|
||||||
|
|
||||||
# 같은 작업을 taxbaik-admin.service에도 반복
|
|
||||||
|
|
||||||
# systemd 재로드
|
# systemd 재로드
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
|
|
||||||
# 서비스 활성화 (부팅 시 자동 시작)
|
# 서비스 활성화 (부팅 시 자동 시작)
|
||||||
sudo systemctl enable taxbaik
|
sudo systemctl enable taxbaik
|
||||||
sudo systemctl enable taxbaik-admin
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -170,24 +165,14 @@ ls -la /etc/nginx/sites-available/
|
|||||||
location /taxbaik {
|
location /taxbaik {
|
||||||
proxy_pass http://127.0.0.1:5001;
|
proxy_pass http://127.0.0.1:5001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Connection keep-alive;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_read_timeout 120s;
|
proxy_read_timeout 120s;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /taxbaik/admin {
|
|
||||||
proxy_pass http://127.0.0.1:5002;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "Upgrade";
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
설정 검증 및 재로드:
|
설정 검증 및 재로드:
|
||||||
@@ -229,8 +214,6 @@ Gitea Secret 창에 전체 붙여넣기.
|
|||||||
```bash
|
```bash
|
||||||
mkdir -p ~/deployments
|
mkdir -p ~/deployments
|
||||||
mkdir -p ~/taxbaik_active
|
mkdir -p ~/taxbaik_active
|
||||||
mkdir -p ~/taxbaik_admin_active
|
|
||||||
|
|
||||||
# 권한 확인
|
# 권한 확인
|
||||||
ls -la ~/ | grep taxbaik
|
ls -la ~/ | grep taxbaik
|
||||||
```
|
```
|
||||||
@@ -244,21 +227,8 @@ ls -la ~/ | grep taxbaik
|
|||||||
# 솔루션 빌드
|
# 솔루션 빌드
|
||||||
dotnet build TaxBaik.sln -c Release
|
dotnet build TaxBaik.sln -c Release
|
||||||
|
|
||||||
# Web 앱 발행
|
# 배포는 Gitea Actions가 처리
|
||||||
dotnet publish src/TaxBaik.Web/ -c Release -o ./publish/web
|
# 수동 publish/rsync 절차는 사용하지 않음
|
||||||
|
|
||||||
# 서버에 배포
|
|
||||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
|
||||||
rsync -az ./publish/web/ kjh2064@178.104.200.7:~/deployments/taxbaik_$TIMESTAMP/
|
|
||||||
|
|
||||||
# 서버에서 심링크 설정
|
|
||||||
ssh kjh2064@178.104.200.7 "ln -sfn ~/deployments/taxbaik_$TIMESTAMP ~/taxbaik_active"
|
|
||||||
|
|
||||||
# 서비스 시작
|
|
||||||
ssh kjh2064@178.104.200.7 "sudo systemctl start taxbaik"
|
|
||||||
|
|
||||||
# 확인
|
|
||||||
curl http://178.104.200.7/taxbaik
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -274,18 +244,14 @@ psql -U taxbaik -d taxbaikdb -c "\dt"
|
|||||||
|
|
||||||
# 2. 서비스 상태
|
# 2. 서비스 상태
|
||||||
sudo systemctl status taxbaik
|
sudo systemctl status taxbaik
|
||||||
sudo systemctl status taxbaik-admin
|
|
||||||
|
|
||||||
# 3. Nginx 로그 확인
|
# 3. Nginx 로그 확인
|
||||||
sudo tail -f /var/log/nginx/error.log
|
sudo tail -f /var/log/nginx/error.log
|
||||||
|
|
||||||
# 4. 앱 로그 확인
|
# 4. 앱 로그 확인
|
||||||
journalctl -u taxbaik -n 50
|
journalctl -u taxbaik -n 50
|
||||||
journalctl -u taxbaik-admin -n 50
|
|
||||||
|
|
||||||
# 5. 엔드포인트 테스트
|
# 5. 엔드포인트 테스트
|
||||||
curl -v http://127.0.0.1:5001/health
|
curl -v http://127.0.0.1:5001/health
|
||||||
curl -v http://127.0.0.1:5002/health
|
|
||||||
curl -v http://127.0.0.1/taxbaik
|
curl -v http://127.0.0.1/taxbaik
|
||||||
|
|
||||||
# 6. 문의 폼 E2E 테스트
|
# 6. 문의 폼 E2E 테스트
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,17 @@ namespace TaxBaik.Infrastructure.Data;
|
|||||||
using System.Data;
|
using System.Data;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
using Dapper;
|
||||||
using TaxBaik.Domain.Interfaces;
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
public sealed class DbConnectionFactory : IDbConnectionFactory
|
public sealed class DbConnectionFactory : IDbConnectionFactory
|
||||||
{
|
{
|
||||||
|
static DbConnectionFactory()
|
||||||
|
{
|
||||||
|
// Keep PostgreSQL snake_case columns aligned with C# PascalCase properties.
|
||||||
|
DefaultTypeMap.MatchNamesWithUnderscores = true;
|
||||||
|
}
|
||||||
|
|
||||||
private readonly string _connectionString;
|
private readonly string _connectionString;
|
||||||
|
|
||||||
public DbConnectionFactory(IConfiguration configuration)
|
public DbConnectionFactory(IConfiguration configuration)
|
||||||
|
|||||||
@@ -98,6 +98,33 @@ public class MigrationRunner
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var assembly = Assembly.GetExecutingAssembly();
|
||||||
|
var resourceNames = assembly.GetManifestResourceNames()
|
||||||
|
.Where(x => x.Contains(".Migrations.V") && x.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderBy(x => x);
|
||||||
|
|
||||||
|
foreach (var resourceName in resourceNames)
|
||||||
|
{
|
||||||
|
using var stream = assembly.GetManifestResourceStream(resourceName);
|
||||||
|
if (stream == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
var sql = reader.ReadToEnd();
|
||||||
|
var fileName = Path.GetFileNameWithoutExtension(resourceName);
|
||||||
|
var versionStart = fileName.IndexOf('V');
|
||||||
|
var versionEnd = fileName.IndexOf('_', versionStart + 1);
|
||||||
|
if (versionStart < 0 || versionEnd < 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var version = fileName.Substring(versionStart + 1, versionEnd - versionStart - 1);
|
||||||
|
var description = fileName.Substring(versionEnd + 1);
|
||||||
|
|
||||||
|
migrations.Add(new Migration { Version = version, Description = description, Sql = sql });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return migrations;
|
return migrations;
|
||||||
}
|
}
|
||||||
@@ -142,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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,16 @@ public class AdminUserRepository : BaseRepository, IAdminUserRepository
|
|||||||
{
|
{
|
||||||
using var conn = _connectionFactory.CreateConnection();
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
return await conn.QueryFirstOrDefaultAsync<AdminUser>(
|
return await conn.QueryFirstOrDefaultAsync<AdminUser>(
|
||||||
"SELECT * FROM admin_users WHERE username = @username",
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
password_hash AS PasswordHash,
|
||||||
|
last_login_at AS LastLoginAt,
|
||||||
|
created_at AS CreatedAt
|
||||||
|
FROM admin_users
|
||||||
|
WHERE username = @username
|
||||||
|
""",
|
||||||
new { username });
|
new { username });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,7 +29,16 @@ public class AdminUserRepository : BaseRepository, IAdminUserRepository
|
|||||||
{
|
{
|
||||||
using var conn = _connectionFactory.CreateConnection();
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
return await conn.QueryFirstOrDefaultAsync<AdminUser>(
|
return await conn.QueryFirstOrDefaultAsync<AdminUser>(
|
||||||
"SELECT * FROM admin_users WHERE id = @id",
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
password_hash AS PasswordHash,
|
||||||
|
last_login_at AS LastLoginAt,
|
||||||
|
created_at AS CreatedAt
|
||||||
|
FROM admin_users
|
||||||
|
WHERE id = @id
|
||||||
|
""",
|
||||||
new { id });
|
new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,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>
|
||||||
|
|||||||
@@ -7,13 +7,13 @@
|
|||||||
<base href="/taxbaik/" />
|
<base href="/taxbaik/" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||||
<link href="https://cdn.jsdelivr.net/npm/@mudblazor/mudblazor@6.9.4/MudBlazor.min.css" rel="stylesheet" />
|
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
|
||||||
<link rel="stylesheet" href="~/css/admin.css" />
|
<link rel="stylesheet" href="/taxbaik/css/admin.css" />
|
||||||
<component type="typeof(HeadOutlet)" render-mode="InteractiveServer" />
|
<component type="typeof(HeadOutlet)" render-mode="InteractiveServer" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<Routes />
|
<Routes @rendermode="RenderMode.InteractiveServer" />
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@mudblazor/mudblazor@6.9.4/MudBlazor.min.js"></script>
|
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||||
<script src="_framework/blazor.web.js"></script>
|
<script src="_framework/blazor.web.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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,7 +1,3 @@
|
|||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
<MudThemeProvider />
|
|
||||||
<MudDialogProvider />
|
|
||||||
<MudSnackbarProvider />
|
|
||||||
|
|
||||||
@Body
|
@Body
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
@page "/admin"
|
||||||
|
@page "/admin/"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
|
@code {
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
NavigationManager.NavigateTo("/taxbaik/admin/dashboard", replace: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,27 +9,35 @@
|
|||||||
<PageTitle>로그인</PageTitle>
|
<PageTitle>로그인</PageTitle>
|
||||||
|
|
||||||
<MudThemeProvider />
|
<MudThemeProvider />
|
||||||
<MudDialogProvider />
|
|
||||||
<MudSnackbarProvider />
|
|
||||||
|
|
||||||
<MudContainer MaxWidth="MaxWidth.Small" Class="d-flex align-center justify-center" Style="min-height: 100vh;">
|
<MudContainer MaxWidth="MaxWidth.Small" Class="d-flex align-center justify-center" Style="min-height: 100vh;">
|
||||||
<MudPaper Class="pa-8" Elevation="3" Style="width: 100%; max-width: 400px;">
|
<MudPaper Class="pa-8" Elevation="3" Style="width: 100%; max-width: 400px;">
|
||||||
<MudText Typo="Typo.h4" Class="mb-6 text-center">관리자 로그인</MudText>
|
<MudText Typo="Typo.h4" Class="mb-6 text-center">관리자 로그인</MudText>
|
||||||
|
|
||||||
<MudForm @ref="form" @bind-IsValid="@isFormValid">
|
<div>
|
||||||
<MudTextField @bind-Value="model.Username" Label="사용자명"
|
<InputText class="mud-input mud-input-outlined mud-input-root mud-input-root-adorned-start mb-4"
|
||||||
Variant="Variant.Outlined" Required="true" Class="mb-4" />
|
style="width: 100%; min-height: 56px; padding: 16px 14px;"
|
||||||
|
placeholder="사용자명"
|
||||||
|
autocomplete="username"
|
||||||
|
@bind-Value="model.Username" />
|
||||||
|
|
||||||
<MudTextField @bind-Value="model.Password" Label="비밀번호" InputType="InputType.Password"
|
<InputText type="password"
|
||||||
Variant="Variant.Outlined" Required="true" Class="mb-4" />
|
class="mud-input mud-input-outlined mud-input-root mud-input-root-adorned-start mb-4"
|
||||||
|
style="width: 100%; min-height: 56px; padding: 16px 14px;"
|
||||||
|
placeholder="비밀번호"
|
||||||
|
autocomplete="current-password"
|
||||||
|
@bind-Value="model.Password" />
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(errorMessage))
|
@if (!string.IsNullOrEmpty(errorMessage))
|
||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Error" Class="mb-4">@errorMessage</MudAlert>
|
<MudAlert Severity="Severity.Error" Class="mb-4">@errorMessage</MudAlert>
|
||||||
}
|
}
|
||||||
|
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="true"
|
<button type="button"
|
||||||
Size="Size.Large" OnClick="HandleLogin" Disabled="isLoading">
|
class="mud-button-root mud-button mud-button-filled mud-button-filled-primary mud-elevation-0"
|
||||||
|
style="width: 100%; min-height: 52px; border: 0; border-radius: 4px; color: white;"
|
||||||
|
@onclick="HandleLogin"
|
||||||
|
disabled="@isLoading">
|
||||||
@if (isLoading)
|
@if (isLoading)
|
||||||
{
|
{
|
||||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
|
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
|
||||||
@@ -39,14 +47,12 @@
|
|||||||
{
|
{
|
||||||
<span>로그인</span>
|
<span>로그인</span>
|
||||||
}
|
}
|
||||||
</MudButton>
|
</button>
|
||||||
</MudForm>
|
</div>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
</MudContainer>
|
</MudContainer>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private MudForm form;
|
|
||||||
private bool isFormValid = false;
|
|
||||||
private bool isLoading = false;
|
private bool isLoading = false;
|
||||||
private string errorMessage = "";
|
private string errorMessage = "";
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}")]
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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("", "시스템 오류가 발생했습니다. 잠시 후 다시 시도해주세요.");
|
||||||
|
|||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
@if (Context.RequestServices.GetService(typeof(VersionInfo)) is VersionInfo version)
|
@if (Context.RequestServices.GetService(typeof(VersionInfo)) is VersionInfo version)
|
||||||
{
|
{
|
||||||
<div class="mt-2 text-muted" style="font-size: 0.75rem; opacity: 0.6;">
|
<div class="mt-2 text-muted" style="font-size: 0.75rem; opacity: 0.6;">
|
||||||
v@version.Version · @version.Built
|
v@(version.Version) · @version.Built
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+26
-6
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -36,6 +38,12 @@ public class AuthService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(user.PasswordHash))
|
||||||
|
{
|
||||||
|
_logger.LogError("로그인 실패: 사용자 '{Username}'의 PasswordHash가 비어 있습니다.", username);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
|
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("로그인 시도: 잘못된 비밀번호 '{Username}'", username);
|
_logger.LogWarning("로그인 시도: 잘못된 비밀번호 '{Username}'", username);
|
||||||
@@ -43,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));
|
||||||
@@ -93,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -22,19 +22,9 @@ cp -r "$1/web" "$WEB_DEPLOY_DIR/"
|
|||||||
ln -sfn "$WEB_DEPLOY_DIR/web" "$DEPLOY_HOME/taxbaik_active"
|
ln -sfn "$WEB_DEPLOY_DIR/web" "$DEPLOY_HOME/taxbaik_active"
|
||||||
echo "✓ Web symlink updated: $WEB_DEPLOY_DIR/web"
|
echo "✓ Web symlink updated: $WEB_DEPLOY_DIR/web"
|
||||||
|
|
||||||
# Admin 배포
|
|
||||||
echo "=== Deploying Admin ==="
|
|
||||||
ADMIN_TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
|
||||||
ADMIN_DEPLOY_DIR="$DEPLOY_HOME/deployments/taxbaik_admin_${ADMIN_TIMESTAMP}"
|
|
||||||
mkdir -p "$ADMIN_DEPLOY_DIR"
|
|
||||||
cp -r "$1/admin" "$ADMIN_DEPLOY_DIR/"
|
|
||||||
ln -sfn "$ADMIN_DEPLOY_DIR/admin" "$DEPLOY_HOME/taxbaik_admin_active"
|
|
||||||
echo "✓ Admin symlink updated: $ADMIN_DEPLOY_DIR/admin"
|
|
||||||
|
|
||||||
# 프로세스 재시작
|
# 프로세스 재시작
|
||||||
echo "=== Restarting processes ==="
|
echo "=== Restarting processes ==="
|
||||||
pkill -9 -f "TaxBaik.Web" || true
|
pkill -9 -f "TaxBaik.Web" || true
|
||||||
pkill -9 -f "TaxBaik.Admin" || true
|
|
||||||
sleep 3
|
sleep 3
|
||||||
|
|
||||||
echo "=== Starting Web ==="
|
echo "=== Starting Web ==="
|
||||||
@@ -46,13 +36,6 @@ nohup /usr/local/dotnet/dotnet TaxBaik.Web.dll > web.log 2>&1 &
|
|||||||
sleep 2
|
sleep 2
|
||||||
ps aux | grep TaxBaik.Web | grep -v grep && echo "✓ Web started" || echo "✗ Web failed"
|
ps aux | grep TaxBaik.Web | grep -v grep && echo "✓ Web started" || echo "✗ Web failed"
|
||||||
|
|
||||||
echo "=== Starting Admin ==="
|
|
||||||
cd "$DEPLOY_HOME/taxbaik_admin_active"
|
|
||||||
export ASPNETCORE_URLS=http://127.0.0.1:5002
|
|
||||||
nohup /usr/local/dotnet/dotnet TaxBaik.Admin.dll > admin.log 2>&1 &
|
|
||||||
sleep 2
|
|
||||||
ps aux | grep TaxBaik.Admin | grep -v grep && echo "✓ Admin started" || echo "✗ Admin failed"
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "===== ✅ 배포 완료 ====="
|
echo "===== ✅ 배포 완료 ====="
|
||||||
cat "$DEPLOY_HOME/taxbaik_active/wwwroot/version.txt" 2>/dev/null || echo "Version file not found"
|
cat "$DEPLOY_HOME/taxbaik_active/wwwroot/version.txt" 2>/dev/null || echo "Version file not found"
|
||||||
|
|||||||
@@ -1,26 +1,16 @@
|
|||||||
# TaxBaik Nginx Location Blocks
|
# TaxBaik Nginx Location Blocks
|
||||||
# Add these to your main Nginx config (e.g., /etc/nginx/sites-available/default)
|
# Add these to your main Nginx config (e.g., /etc/nginx/sites-available/default)
|
||||||
|
|
||||||
# TaxBaik 공개 사이트 (Razor Pages)
|
# TaxBaik 공개 사이트 + 관리자 (통합 앱)
|
||||||
location /taxbaik {
|
location /taxbaik {
|
||||||
proxy_pass http://127.0.0.1:5001;
|
proxy_pass http://127.0.0.1:5001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Connection keep-alive;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_read_timeout 120s;
|
proxy_read_timeout 120s;
|
||||||
}
|
}
|
||||||
|
|
||||||
# TaxBaik 관리자 (Blazor Server - WebSocket 필요)
|
|
||||||
location /taxbaik/admin {
|
|
||||||
proxy_pass http://127.0.0.1:5002;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "Upgrade";
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=TaxBaik Admin Backoffice (.NET 8 Blazor Server)
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=kjh2064
|
|
||||||
WorkingDirectory=/home/kjh2064/taxbaik_admin_active
|
|
||||||
ExecStart=/usr/bin/dotnet TaxBaik.Admin.dll
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
|
|
||||||
# Graceful Shutdown (Hot Deploy용)
|
|
||||||
TimeoutStopSec=35
|
|
||||||
KillMode=mixed
|
|
||||||
KillSignal=SIGTERM
|
|
||||||
|
|
||||||
SyslogIdentifier=taxbaik-admin
|
|
||||||
Environment=ASPNETCORE_ENVIRONMENT=Production
|
|
||||||
Environment=ASPNETCORE_URLS=http://127.0.0.1:5002
|
|
||||||
Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false
|
|
||||||
# 아래 줄은 서버에서 직접 편집 (git에 커밋하지 않음)
|
|
||||||
# Environment=ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=CHANGE_ME
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user