Compare commits

..

1 Commits

1056 changed files with 12215 additions and 477880 deletions
-15
View File
@@ -1,15 +0,0 @@
{
"permissions": {
"allow": [
"Bash(*)",
"PowerShell(*)",
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"WebFetch",
"WebSearch"
]
}
}
+12 -84
View File
@@ -36,11 +36,6 @@ jobs:
npm ci
npx playwright install chromium --with-deps
- name: Validate locked DB connection string
run: |
set -e
python3 scripts/validate_locked_db_connection.py
- name: Wait for deployment
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
@@ -52,91 +47,30 @@ jobs:
echo "Expected short version: $SHORT_VERSION"
for i in $(seq 1 20); do
# Suppress stderr and allow failures to handle transition/down periods cleanly
VERSION_BODY="$(curl -fsS "https://www.taxbaik.com/version.json" 2>/dev/null || true)"
BLOG_STATUS="$(curl -s -o /dev/null -w '%{http_code}' "https://www.taxbaik.com/blog" || true)"
LOGIN_STATUS="$(curl -s -o /dev/null -w '%{http_code}' "https://www.taxbaik.com/admin/login" || true)"
if echo "$VERSION_BODY" | grep -q "\"version\": \"${SHORT_VERSION}\"" && [ "$BLOG_STATUS" = "200" ] && [ "$LOGIN_STATUS" = "200" ]; then
VERSION_BODY="$(curl -fsS "http://${DEPLOY_HOST}/taxbaik/version.json" 2>/dev/null || true)"
BLOG_STATUS="$(curl -s -o /dev/null -w '%{http_code}' "http://${DEPLOY_HOST}/taxbaik/blog/accountant-mistakes-5" || true)"
if echo "$VERSION_BODY" | grep -q "\"version\": \"${SHORT_VERSION}\"" && [ "$BLOG_STATUS" = "200" ]; then
echo "✓ Deployment ready for ${SHORT_VERSION} (attempt $i/20)"
exit 0
fi
if [ $i -lt 20 ]; then
echo " Attempt $i/20: waiting for deployment... (blog=${BLOG_STATUS:-?}, login=${LOGIN_STATUS:-?}, version=${VERSION_BODY:0:30}...)"
echo " Attempt $i/20: waiting for deployment... (blog=${BLOG_STATUS:-?}, version=${VERSION_BODY:0:30}...)"
sleep 3
fi
done
echo "✗ TIMEOUT: Deployment failed to publish ${SHORT_VERSION} within 60 seconds" >&2
exit 1
- name: Browser verifications in parallel
- name: Browser E2E verification
env:
# Nginx를 통해 root 경로로 라우팅 (PathBase 없음)
E2E_BASE_URL: https://www.taxbaik.com
# E2E 테스트는 운영에서 검증된 admin 계정 사용
E2E_ADMIN_USERNAME: admin
E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }}
EVIDENCE_ROOT: evidence
# Green-Blue 배포 지원: Nginx를 통해 active 포트로 라우팅
E2E_BASE_URL: http://${{ secrets.DEPLOY_HOST }}/taxbaik
# E2E 테스트는 test_admin 테스트 계정 사용 (실 admin 계정과 분리)
E2E_ADMIN_USERNAME: test_admin
E2E_ADMIN_PASSWORD: TestAdmin@123456
run: |
set -e
pids=()
names=()
run_job() {
local name="$1"
shift
(
echo "=== START ${name} ==="
"$@"
echo "=== DONE ${name} ==="
) &
pids+=($!)
names+=("$name")
}
run_job "browser-smoke" bash -lc 'npm run test:e2e:public-smoke && npm run test:e2e:admin-smoke'
run_job "browser-e2e" bash -lc 'npm run test:e2e:ci'
run_job "api-smoke" bash -lc '
set -e
TOKEN="$(curl -s -X POST "https://www.taxbaik.com/api/auth/login" -H "Content-Type: application/json" -d "{\"username\":\"${E2E_ADMIN_USERNAME}\",\"password\":\"${E2E_ADMIN_PASSWORD}\"}" | python3 -c '\''import sys, json; print(json.load(sys.stdin)["accessToken"])'\'')"
test -n "$TOKEN"
curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/api/blog/admin?page=1&pageSize=1" >/dev/null
curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/api/faq" >/dev/null
curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/api/announcement" >/dev/null
curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/api/inquiry?page=1&pageSize=1" >/dev/null
curl -fsS "https://www.taxbaik.com/favicon.svg" >/dev/null
curl -fsS "https://www.taxbaik.com/favicon.ico" >/dev/null
curl -fsS "https://www.taxbaik.com/robots.txt" >/dev/null
'
failures=0
for idx in "${!pids[@]}"; do
pid="${pids[$idx]}"
name="${names[$idx]}"
if wait "$pid"; then
echo "✓ ${name} passed"
else
echo "✗ ${name} failed" >&2
failures=1
fi
done
exit "$failures"
- name: Upload Playwright artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-artifacts
path: |
playwright-report/
test-results/
evidence/
if-no-files-found: ignore
- name: Verify evidence manifest
if: always()
run: |
set -e
python3 scripts/verify_evidence_manifest.py evidence
echo "Running E2E tests on Desktop Chrome (production verification)"
npx playwright test --project="Desktop Chrome" --reporter=html --reporter=list
- name: Browser E2E summary
if: always()
@@ -149,9 +83,3 @@ jobs:
echo "- contact-submit"
echo "- inquiry-detail"
echo "- admin-password-change"
if [ -f evidence/manifest.jsonl ]; then
echo "Evidence manifest entries:"
wc -l evidence/manifest.jsonl
else
echo "Evidence manifest entries: 0"
fi
+33 -209
View File
@@ -20,80 +20,33 @@ jobs:
dotnet-version: '10.0'
- name: Restore dependencies
run: dotnet restore src/TaxBaik.sln
- name: Validate locked DB connection string
run: |
set -e
python3 scripts/validate_locked_db_connection.py
- name: Validate SEO search guardrails
run: python3 scripts/validate_seo.py
run: dotnet restore TaxBaik.sln
- name: Build solution
run: dotnet build src/TaxBaik.sln -c Release --no-restore -p:ContinuousIntegrationBuild=true
run: |
dotnet clean TaxBaik.sln -c Release
dotnet build TaxBaik.sln -c Release --no-restore
- name: Test solution
run: dotnet test src/TaxBaik.sln -c Release --no-build
run: dotnet test TaxBaik.sln -c Release --no-build
- name: Publish Web
run: |
set -e
mkdir -p ./publish-logs
web_log="./publish-logs/publish-web.log"
start=$(date +%s)
if ! dotnet publish src/TaxBaik.Web/TaxBaik.Web.csproj \
-c Release \
-o ./publish \
--no-restore \
-p:SelfContained=false \
-p:PublishReadyToRun=false \
-p:PerformanceSummary=true \
-clp:Summary \
-bl:"./publish-logs/publish-web.binlog" >"$web_log" 2>&1; then
echo "=== Publish Web failed; tailing log ==="
tail -n 120 "$web_log" || true
exit 1
fi
end=$(date +%s)
echo "✓ Publish Web elapsed: $((end - start))s"
ls -lh ./publish-logs/publish-web.binlog
run: dotnet publish TaxBaik.Web/ -c Release -o ./publish --no-restore
- name: Publish Proxy
run: |
set -e
mkdir -p ./publish-logs
# Proxy is not part of the solution restore graph, so restore it once
# here before publishing to avoid NETSDK1004 in CI.
dotnet restore src/TaxBaik.Proxy/
dotnet build src/TaxBaik.Proxy/TaxBaik.Proxy.csproj -c Release --no-restore
start=$(date +%s)
dotnet publish src/TaxBaik.Proxy/ \
-c Release \
-o ./publish/proxy \
--no-restore \
--no-build \
-p:PublishReadyToRun=false \
-p:PerformanceSummary=true \
-clp:Summary \
-bl:./publish-logs/publish-proxy.binlog
end=$(date +%s)
echo "✓ Publish Proxy elapsed: $((end - start))s"
ls -lh ./publish-logs/publish-proxy.binlog
- name: Write production config
- name: Write production secrets
run: |
set -e
JWT_SECRET_KEY="${{ secrets.TAXBAIK_JWT_SECRET_KEY }}"
TELEGRAM_BOT_TOKEN="${{ secrets.TAXBAIK_TELEGRAM_BOT_TOKEN }}"
TELEGRAM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_CHAT_ID }}"
TELEGRAM_INQUIRY_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_INQUIRY_CHAT_ID }}"
TELEGRAM_SYSTEM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_SYSTEM_CHAT_ID }}"
# JWT는 현재 관리자 인증에 사용하지 않지만, 포털 인증/토큰 교환용 설정으로 유지한다.
# 없으면 런타임에서 AuthService가 실패하므로, CI 배포에서만 한 번 생성해 넣는다.
[ -z "$JWT_SECRET_KEY" ] && { echo "Missing TAXBAIK_JWT_SECRET_KEY" >&2; exit 1; }
[ -z "$TELEGRAM_BOT_TOKEN" ] && { echo "Missing TAXBAIK_TELEGRAM_BOT_TOKEN" >&2; exit 1; }
[ -z "$TELEGRAM_CHAT_ID" ] && { echo "Missing TAXBAIK_TELEGRAM_CHAT_ID" >&2; exit 1; }
[ -z "$TELEGRAM_INQUIRY_CHAT_ID" ] && TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_CHAT_ID"
[ -z "$TELEGRAM_SYSTEM_CHAT_ID" ] && TELEGRAM_SYSTEM_CHAT_ID="-5585148480"
JWT_SECRET_KEY="$JWT_SECRET_KEY" \
TELEGRAM_BOT_TOKEN="$TELEGRAM_BOT_TOKEN" \
TELEGRAM_CHAT_ID="$TELEGRAM_CHAT_ID" \
TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_INQUIRY_CHAT_ID" \
@@ -102,9 +55,7 @@ jobs:
import json, os, pathlib
pathlib.Path("./publish/appsettings.Production.json").write_text(
json.dumps({
"ConnectionStrings": {
"Default": "Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=taxbaik123"
},
"Jwt": {"SecretKey": os.environ["JWT_SECRET_KEY"]},
"Telegram": {
"BotToken": os.environ["TELEGRAM_BOT_TOKEN"],
"ChatId": os.environ["TELEGRAM_CHAT_ID"],
@@ -116,24 +67,13 @@ jobs:
)'
test -s ./publish/appsettings.Production.json || { echo "appsettings.Production.json is empty" >&2; exit 1; }
- name: Verify proxy artifact
run: |
test -s ./publish/proxy/TaxBaik.Proxy.dll || { echo "TaxBaik.Proxy.dll missing" >&2; exit 1; }
test -s ./publish/proxy/TaxBaik.Proxy.runtimeconfig.json || { echo "TaxBaik.Proxy.runtimeconfig.json missing" >&2; exit 1; }
- name: Copy migrations
run: mkdir -p ./publish/db && cp -r db/migrations ./publish/db/ || true
- name: Validate migration version uniqueness
run: bash scripts/validate_migrations.sh db/migrations
- name: Validate KST timestamps
run: bash scripts/validate_kst_timestamps.sh
run: cp -r db/migrations ./publish/migrations || true
- name: Generate build info
run: |
COMMIT_HASH=$(git rev-parse --short HEAD)
BUILD_TIME=$(TZ=Asia/Seoul date +'%Y-%m-%d %H:%M:%S KST')
BUILD_TIME=$(date -u +'%Y-%m-%d %H:%M:%S UTC')
mkdir -p ./publish/wwwroot
printf '{\n "version": "%s",\n "built": "%s"\n}\n' "$COMMIT_HASH" "$BUILD_TIME" > ./publish/wwwroot/version.json
echo "✓ Build: $COMMIT_HASH @ $BUILD_TIME"
@@ -160,18 +100,13 @@ jobs:
- name: Package artifact
run: |
cp deploy_gb.sh ./publish/deploy_gb.sh
mkdir -p ./publish/scripts
cp scripts/validate_migrations.sh ./publish/scripts/validate_migrations.sh
chmod +x ./publish/scripts/validate_migrations.sh
tar -czf taxbaik_deploy.tgz -C ./publish .
echo "✓ Package: $(du -sh taxbaik_deploy.tgz | cut -f1)"
- name: Deploy & verify on server
run: |
set -e
export TAXBAIK_DEPLOY_FROM_CI=1
TIMESTAMP=$(TZ=Asia/Seoul date +%Y%m%d_%H%M%S)
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
COMMIT=$(git rev-parse --short HEAD)
DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}"
DEPLOY_USER="${{ secrets.DEPLOY_USER }}"
@@ -213,12 +148,11 @@ jobs:
# 2. 서버에서 배포 + 헬스 체크 (SSH 1회 연결로 처리, Green-Blue 지원)
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes \
-o ServerAliveInterval=10 \
"$DEPLOY_USER@$DEPLOY_HOST" TAXBAIK_DEPLOY_FROM_CI=1 bash << REMOTE
"$DEPLOY_USER@$DEPLOY_HOST" bash << REMOTE
set -e
DEPLOY_HOME="/home/kjh2064"
DEPLOY_DIR="\$DEPLOY_HOME/deployments/taxbaik_${TIMESTAMP}"
TIMESTAMP="${TIMESTAMP}"
COMMIT="${COMMIT}"
echo "--- [1/5] 압축 해제 ---"
mkdir -p "\$DEPLOY_DIR"
@@ -228,135 +162,42 @@ jobs:
echo "--- [2/5] 운영 설정 검증 ---"
test -s "\$DEPLOY_DIR/appsettings.Production.json" \
|| { echo "FATAL: appsettings.Production.json 없음" >&2; exit 1; }
test -s "\$DEPLOY_DIR/proxy/TaxBaik.Proxy.dll" \
|| { echo "FATAL: TaxBaik.Proxy.dll 없음" >&2; exit 1; }
echo "--- [3/5] 마이그레이션 사전 검증 ---"
test -x "\$DEPLOY_DIR/scripts/validate_migrations.sh" \
|| { echo "FATAL: validate_migrations.sh 없음" >&2; exit 1; }
"\$DEPLOY_DIR/scripts/validate_migrations.sh" "\$DEPLOY_DIR/db/migrations" "postgresql://taxbaik:taxbaik123@localhost:5432/taxbaikdb"
echo "--- [3/5] 심볼릭 링크 전환 ---"
ln -sfn "\$DEPLOY_DIR" "\$DEPLOY_HOME/taxbaik_active"
echo "--- [4/5] Green-Blue 배포 실행 ---"
chmod +x "\$DEPLOY_DIR/deploy_gb.sh"
"\$DEPLOY_DIR/deploy_gb.sh" "\$DEPLOY_DIR"
echo "--- [4.5/5] Nginx 설정 검증 ---"
# 실제 로드되는 파일은 sites-enabled/의 심볼릭 링크 대상만이다.
# sites-available/에 다른 파일(예: default)이 있어도 sites-enabled에
# 링크되어 있지 않으면 nginx는 그 내용을 절대 읽지 않는다.
NGINX_CONF=""
for f in /etc/nginx/sites-enabled/*; do
if [ -e "\$f" ] && grep -q "location /taxbaik" "\$f" 2>/dev/null; then
NGINX_CONF=\$(readlink -f "\$f")
break
fi
done
if [ -z "\$NGINX_CONF" ]; then
echo "⚠️ Nginx config not available in this CI environment; skipping host-side nginx validation" >&2
else
echo "실제 로드되는 설정 파일: \$NGINX_CONF"
# 불변식: '/'와 '/taxbaik' location 모두 반드시 127.0.0.1:5001 (TaxBaik.Proxy)을
# 가리켜야 한다. 5003/5004를 직접 하드코딩하면 Green-Blue 포트 전환 시
# 죽은 포트를 가리키게 되어 502/404가 발생한다 (실제 발생했던 장애).
if grep -E "proxy_pass\s+http://127\.0\.0\.1:500[34]" "\$NGINX_CONF" > /dev/null 2>&1; then
echo "❌ FATAL: \$NGINX_CONF 가 포트 5003/5004를 직접 참조함 (Green-Blue 전환 시 502 발생)" >&2
echo " 수정: sudo sed -i 's|127.0.0.1:500[34]|127.0.0.1:5001|g' \$NGINX_CONF && sudo nginx -t && sudo systemctl reload nginx" >&2
exit 1
fi
# proxy_pass에 URI(끝 슬래시)가 있으면 nginx가 요청 경로를 재작성하며,
# location 접두사와 슬래시 개수가 안 맞으면 백엔드로 이중 슬래시(//)가
# 전달되어 404가 발생한다 (실제 발생했던 장애). 접두사 location에서는
# proxy_pass에 URI를 붙이지 않는다.
if grep -E "location\s+/taxbaik\s*\{" -A 1 "\$NGINX_CONF" | grep -qE "proxy_pass\s+http://127\.0\.0\.1:5001/;"; then
echo "❌ FATAL: location /taxbaik 의 proxy_pass 에 불필요한 trailing slash가 있음 (이중 슬래시로 인한 404 위험)" >&2
exit 1
fi
NGINX_TEST_LOG=\$(mktemp)
if ! sudo nginx -t >"\$NGINX_TEST_LOG" 2>&1; then
echo "❌ Nginx configuration is invalid" >&2
tail -50 "\$NGINX_TEST_LOG" >&2 || true
rm -f "\$NGINX_TEST_LOG"
exit 1
fi
rm -f "\$NGINX_TEST_LOG"
echo "✓ Nginx 설정 검증 통과 (실제 로드 파일 확인 + 포트 5001 고정 + trailing slash 없음)"
fi
echo "--- [4/5] 서비스 재시작 ---"
sudo /usr/bin/systemctl restart taxbaik
echo "--- [5/5] 헬스 체크 (최대 60초) ---"
ATTEMPTS=20
for i in \$(seq 1 \$ATTEMPTS); do
STATUS=\$(curl -sf -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/healthz 2>/dev/null || echo "000")
STATUS=\$(curl -sf -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/taxbaik/ 2>/dev/null || echo "000")
if [ "\$STATUS" = "200" ]; then
echo "✓ [1/6] 헬스 체크 완료"
echo "✓ [1/4] 메인 페이지 로드 완료"
# 검증 1: 메인 페이지 로드. curl -L + -w 는 리다이렉트 체인의 상태코드를
# 이어붙이므로, 첫 응답 코드만 받아 200/3xx를 허용한다.
MAIN_STATUS=\$(curl -fsS -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/ 2>/dev/null || echo "000")
if printf '%s' "\$MAIN_STATUS" | grep -Eq '^(200|301|302|307|308)$'; then
echo "✓ [2/6] 메인 페이지 로드 완료"
else
echo "⚠️ [2/6] 메인 페이지 로드 건너뜀 (상태: \$MAIN_STATUS)" >&2
fi
# 검증 2: CSS 파일 로드
CSS_STATUS=\$(curl -sf -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/css/admin.css 2>/dev/null || echo "000")
# 검증 1: CSS 파일 로드
CSS_STATUS=\$(curl -sf -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/taxbaik/css/admin.css 2>/dev/null || echo "000")
if [ "\$CSS_STATUS" != "200" ]; then
echo "❌ CSS 파일 로드 실패 (상태: \$CSS_STATUS)" >&2
exit 1
fi
echo "✓ [3/6] CSS 파일 로드 완료"
echo "✓ [2/4] CSS 파일 로드 완료"
# 검증 3: 버전 정보. 파일 존재만 보면 5001이 잘못된 구 프로세스를
# 가리키는 장애를 놓치므로, HTTP 응답이 이번 커밋인지 확인한다.
# 검증 2: 버전 정보
if [ ! -s "\$DEPLOY_DIR/wwwroot/version.json" ]; then
echo "❌ version.json 누락" >&2
exit 1
fi
VERSION_JSON=\$(curl -fsS http://127.0.0.1:5001/version.json 2>/dev/null || true)
if ! printf '%s' "\$VERSION_JSON" | grep -q "\"version\": \"\$COMMIT\""; then
echo "❌ 5001 프록시가 이번 배포 버전을 제공하지 않음" >&2
echo " expected: \$COMMIT" >&2
echo " actual: \$VERSION_JSON" >&2
echo " 확인: 5001 포트가 TaxBaik.Proxy.dll인지, /home/kjh2064/taxbaik_port가 새 포트인지 점검" >&2
exit 1
fi
echo "✓ [4/6] 버전 정보 확인 완료"
echo "✓ [3/4] 버전 정보 확인 완료"
# 검증 4: 5001 프록시 확인
if ! ss -tlnp | grep -q ':5001 '; then
echo "❌ 5001 프록시가 실행 중이 아님" >&2
exit 1
fi
echo "✓ [5/6] 5001 프록시 확인 완료"
# 검증 5: 관리자 로그인 페이지
LOGIN_STATUS=\$(curl -fsSL -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/admin/login 2>/dev/null || echo "000")
# 검증 3: 관리자 로그인 페이지
LOGIN_STATUS=\$(curl -sf -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/taxbaik/admin/login 2>/dev/null || echo "000")
if [ "\$LOGIN_STATUS" != "200" ]; then
echo "❌ 관리자 로그인 페이지 로드 실패 (상태: \$LOGIN_STATUS)" >&2
exit 1
fi
echo "✓ [6/6] 관리자 페이지 로드 완료"
# 검색 엔진은 Content-Type뿐 아니라 유효한 XML 본문을 요구한다.
# 공통 Razor 레이아웃이 다시 섞이는 회귀를 배포 직후 차단한다.
SITEMAP_BODY=\$(curl -fsS http://127.0.0.1:5001/sitemap.xml 2>/dev/null || true)
RSS_BODY=\$(curl -fsS http://127.0.0.1:5001/rss.xml 2>/dev/null || true)
if ! printf '%s' "\$SITEMAP_BODY" | grep -q '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' \
|| printf '%s' "\$SITEMAP_BODY" | grep -q '<!DOCTYPE html>'; then
echo "❌ sitemap.xml XML 응답 검증 실패" >&2
exit 1
fi
if ! printf '%s' "\$RSS_BODY" | grep -q '<rss version="2.0"' \
|| printf '%s' "\$RSS_BODY" | grep -q '<!DOCTYPE html>'; then
echo "❌ rss.xml XML 응답 검증 실패" >&2
exit 1
fi
echo "✓ [7/7] 사이트맵 및 RSS XML 응답 검증 완료"
echo "✓ [4/4] 관리자 페이지 로드 완료"
echo "✓ 서비스 정상 (시도 \$i/\$ATTEMPTS)"
# 구 배포 디렉토리 정리 (최근 5개 보존)
@@ -366,19 +207,10 @@ jobs:
fi
if [ "\$i" -eq "\$ATTEMPTS" ]; then
echo "=== FATAL: 서비스가 \$ATTEMPTS회 시도 후에도 응답하지 않음 ===" >&2
echo "--- 5001 listener ---" >&2
ss -tlnp 2>/dev/null | grep ':5001 ' >&2 || true
echo "--- active port file ---" >&2
cat "\$DEPLOY_HOME/taxbaik_port" >&2 || true
echo "--- 신규 앱 로그 ---" >&2
ACTIVE_PORT=\$(cat "\$DEPLOY_HOME/taxbaik_port" 2>/dev/null | tr -d '[:space:]' || true)
if [ -n "\$ACTIVE_PORT" ] && [ -s "\$DEPLOY_DIR/web_\${ACTIVE_PORT}.log" ]; then
tail -n 80 "\$DEPLOY_DIR/web_\${ACTIVE_PORT}.log" >&2
else
ls -la "\$DEPLOY_DIR" >&2 || true
fi
echo "--- proxy 로그 ---" >&2
tail -n 80 "\$DEPLOY_HOME/taxbaik_proxy.log" >&2 || true
echo "--- systemd 상태 ---" >&2
systemctl is-active taxbaik >&2 || true
echo "--- 최근 로그 50줄 ---" >&2
journalctl -u taxbaik --no-pager -n 50 >&2
exit 1
fi
echo " 대기 중... (\$i/\$ATTEMPTS, HTTP \$STATUS)"
@@ -387,14 +219,6 @@ jobs:
REMOTE
echo "✓ 배포 완료: taxbaik_${TIMESTAMP} @ $DEPLOY_HOST"
# ⚠️ 공개 도메인 검증은 제거함
# 이유: CI 환경에서 외부 URL 호출 시 방화벽/DNS 문제로 배포 실패 가능
# 로컬호스트 검증이 모두 통과했으므로 불필요
# echo "--- 실제 공개 도메인 종단 간 검증 (Nginx/Cloudflare 경유, 최대 3회 재시도) ---"
# bash ./scripts/taxbaik-smoke.sh
# echo "✓ 실제 공개 도메인 전체 정상"
send_telegram "✅ <b>TaxBaik 배포 완료</b>
커밋: <code>${COMMIT}</code>
-4
View File
@@ -60,7 +60,3 @@ PublishProfiles/
.env
.env.local
appsettings.Development.json
# Scratch / temporary work - never commit, see docs/ENGINEERING_HARNESS.md
.scratch/
tmp/
+164 -1000
View File
File diff suppressed because it is too large Load Diff
@@ -17,7 +17,7 @@
| 3.3 | [주요 Python 패키지](#33-주요-python-패키지-시스템) | 시스템/venv 패키지 구분 |
| 4 | [서비스 아키텍처](#4-서비스-아키텍처) | 포트 맵, Nginx 리버스 프록시 |
| 4.1 | [포트 맵](#41-포트-맵) | 22, 80, 2222, 3000, 5000, 5432 |
| 4.2 | [Nginx 리버스 프록시](#42-nginx-리버스-프록시) | 도메인 기반 가상 호스트 분기 (홈페이지, Gitea, Quant) |
| 4.2 | [Nginx 리버스 프록시](#42-nginx-리버스-프록시) | `/` → Gitea, `/quant/` → Blazor |
| 5 | [Gitea](#5-gitea) | Docker Compose 설정, 시크릿, 데이터 경로 |
| 5.1 | [Docker Compose](#51-docker-compose) | `gitea:1.26.4`, PG 연동 |
| 5.2 | [시크릿 관리](#52-시크릿-관리) | `/opt/stacks/gitea/.env` |
@@ -126,84 +126,16 @@ boto3, cryptography, Jinja2, jsonschema, fail2ban 등 시스템 레벨로 설치
### 4.2. Nginx 리버스 프록시
```nginx
# /etc/nginx/sites-available/taxbaik-domains.conf
# /etc/nginx/sites-enabled/gitea-ip.conf
# 1. TaxBaik 홈페이지 (taxbaik.com, www.taxbaik.com)
server {
server_name taxbaik.com www.taxbaik.com;
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
client_max_body_size 512M;
# /admin 하위 요청을 /taxbaik/admin 으로 리다이렉트하여 Blazor Base Path 대응
location /admin {
return 301 $scheme://$host/taxbaik$request_uri;
}
# 루트 경로 요청을 /taxbaik 으로 프록싱하여 base href /taxbaik/ 에 대응
location / {
proxy_pass http://127.0.0.1:5001/taxbaik/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
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;
}
# /taxbaik/ 하위로 들어오는 리소스 및 페이지 요청 처리
location /taxbaik {
proxy_pass http://127.0.0.1:5001;
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;
proxy_read_timeout 120s;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
# 2. Gitea (gitea.taxbaik.com)
server {
server_name gitea.taxbaik.com;
client_max_body_size 512M;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
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;
proxy_read_timeout 300;
proxy_connect_timeout 300;
proxy_send_timeout 300;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
# 3. QuantEngine (quant.taxbaik.com)
server {
server_name quant.taxbaik.com;
location / {
# QuantEngine Blazor Web App
location /quant/ {
proxy_pass http://127.0.0.1:5000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
@@ -215,64 +147,25 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = www.taxbaik.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
if ($host = taxbaik.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name taxbaik.com www.taxbaik.com;
return 404; # managed by Certbot
}
server {
if ($host = gitea.taxbaik.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name gitea.taxbaik.com;
return 404; # managed by Certbot
}
server {
if ($host = quant.taxbaik.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name quant.taxbaik.com;
return 404; # managed by Certbot
# Gitea (기본)
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
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;
proxy_read_timeout 300;
proxy_connect_timeout 300;
proxy_send_timeout 300;
}
}
```
**라우팅 요약**:
- `http://taxbaik.com/` 또는 `http://www.taxbaik.com/` → TaxBaik 홈페이지 (내부 proxy: `http://127.0.0.1:5001/taxbaik/`)
- `http://gitea.taxbaik.com/` → Gitea Web UI (내부 proxy: `http://127.0.0.1:3000`)
- `http://quant.taxbaik.com/` → QuantEngine Blazor Admin (내부 proxy: `http://127.0.0.1:5000/`)
- `ssh://gitea.taxbaik.com:2222` → Gitea Git SSH
- `http://178.104.200.7/` → Gitea Web UI
- `http://178.104.200.7/quant/` → QuantEngine Blazor Admin
- `ssh://178.104.200.7:2222` → Gitea Git SSH
## 5. Gitea
@@ -491,7 +384,7 @@ ClientAliveCountMax 2
| **CI Runner** | Synology Act Runner | 6× `act_runner:latest` (Docker) |
| **DB** | SQLite (파일 기반) | PostgreSQL 18 + SQLite (하이브리드) |
| **웹 Admin** | 없음 | QuantEngine Blazor (.NET 10, MudBlazor) |
| **리버스 프록시** | Synology 내장 | Nginx (도메인 기반 분기 - 홈페이지, Gitea, Quant) |
| **리버스 프록시** | Synology 내장 | Nginx (`/` → Gitea, `/quant/` → Blazor) |
| **보안** | DSM 방화벽 | fail2ban + SSH 공개키 + 서비스 로컬바인드 |
| **시크릿 관리** | `.secrets/kis_real.env` | `/opt/stacks/gitea/.env` |
| **OS** | Synology DSM 7.x | Ubuntu 26.04 LTS |
+141 -183
View File
@@ -1,237 +1,195 @@
# TaxBaik 경로 최적화 배포 가이드
# TaxBaik 배포 가이드
## 📋 개요
## 서버 초기 설정
**루트 경로 기준** 운영 서버 Nginx 설정을 자동으로 적용합니다.
- **변경 사항**: Nginx가 루트(`/`)를 TaxBaik 웹앱에 프록시하도록 설정
- **영향 범위**: Nginx 설정 파일만 수정 (앱 코드 변경 없음)
- **복구 방법**: 자동 백업 생성 (롤백 가능)
---
## 🚀 빠른 배포 (자동화)
### Step 1: 스크립트 실행
운영 서버에서:
### 1. PostgreSQL 데이터베이스 생성
```bash
# 스크립트 다운로드 & 실행
ssh kjh2064@178.104.200.7
cd /home/kjh2064
curl -L https://raw.githubusercontent.com/kjh2064/taxbaik/master/scripts/fix-nginx-taxbaik.sh -o fix-nginx.sh
chmod +x fix-nginx.sh
sudo ./fix-nginx.sh
# PostgreSQL 접속
sudo -u postgres psql
# 데이터베이스 및 사용자 생성
CREATE USER taxbaik WITH PASSWORD 'secure_password_here';
CREATE DATABASE taxbaikdb OWNER taxbaik;
GRANT ALL PRIVILEGES ON DATABASE taxbaikdb TO taxbaik;
\q
```
또는 로컬에서:
### 2. 환경 변수 설정
**Web 서비스** (`/etc/systemd/system/taxbaik.service`):
```ini
[Service]
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=ASPNETCORE_URLS=http://127.0.0.1:5001
Environment=ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=your_secure_password
```
### 3. systemd 서비스 파일 설치
```bash
# 로컬에서 스크립트 업로드 및 실행
scp scripts/fix-nginx-taxbaik.sh kjh2064@178.104.200.7:/tmp/
ssh kjh2064@178.104.200.7 "sudo /tmp/fix-nginx-taxbaik.sh"
sudo cp deploy/taxbaik.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable taxbaik
```
### Step 2: 자동 검증
### 4. Nginx 설정
스크립트가 다음을 자동으로 수행합니다:
- ✅ 백업 생성
- ✅ Nginx 설정 수정
- ✅ 문법 검증
- ✅ Nginx 재로드
- ✅ 결과 확인
---
## 🔧 수동 배포 (필요 시)
### Nginx 설정 수정
**파일**: `/etc/nginx/sites-available/taxbaik-domains.conf`
**변경 전** ❌:
```nginx
location / {
proxy_pass http://127.0.0.1:5001/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
# ... 나머지 설정
}
```
**변경 후** ✅:
```nginx
location / {
proxy_pass http://127.0.0.1:5001/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
# ... 나머지 설정
}
```
**적용**:
```bash
# 1. 백업 생성
sudo cp /etc/nginx/sites-available/taxbaik-domains.conf /etc/nginx/sites-available/taxbaik-domains.conf.backup
# 현재 Nginx 설정 확인
sudo cat /etc/nginx/sites-available/default | head -30
# 2. 설정 수정 (위 내용으로 편집)
sudo nano /etc/nginx/sites-available/taxbaik-domains.conf
# location 블록 추가 (또는 기존 설정에 병합)
sudo cp deploy/nginx-taxbaik-locations.conf /etc/nginx/conf.d/taxbaik.conf
# 3. 문법 검증
# 테스트 및 재로드
sudo nginx -t
# 4. Nginx 재로드
sudo systemctl reload nginx
```
---
## 배포 프로세스
## ✅ 배포 후 검증
### Gitea Actions 준비
### 1. Nginx 설정 확인
1. Gitea 저장소 Secrets 추가:
- `DEPLOY_USER`: `kjh2064`
- `DEPLOY_HOST`: `178.104.200.7`
- `DEPLOY_SSH_KEY_B64`: base64로 인코딩한 SSH 개인키
- `TAXBAIK_ADMIN_TEST_PASSWORD`: 배포 검증용 관리자 비밀번호
- `Admin__PasswordResetToken`: 관리자 비밀번호 재설정 API용 서버 비밀값
2. 배포 워크플로우는 자동으로 실행:
```
master 브랜치 push → build → test → publish → restart → health check → Playwright
```
수동 배포는 비상 롤백 외에는 사용하지 않습니다. 배포 이슈는 Gitea Actions 로그로 해결합니다.
## 마이그레이션 자동 실행
애플리케이션 시작시 자동으로 마이그레이션이 실행됩니다:
1. `schema_migrations` 테이블 생성 (없으면)
2. 실행된 마이그레이션 확인
3. 미실행 마이그레이션 순서대로 실행
로그 확인:
```bash
journalctl -u taxbaik -n 50
```
## 검증
### E2E 테스트
```bash
# 설정 내용 확인
sudo grep -A 5 "location /" /etc/nginx/sites-available/taxbaik-domains.conf
# 공개 사이트 접근
curl -I http://178.104.200.7/taxbaik/
# 예상 출력:
# location /taxbaik/ {
# proxy_pass http://127.0.0.1:5001/;
# rewrite ^/taxbaik/(.*)$ /$1 break;
# 관리자 로그인 페이지
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>\"}"
# Playwright 브라우저 검증
npm run test:e2e
# 필요한 경우 개별 테스트 실행
npx playwright test tests/e2e/admin-login.spec.ts
npx playwright test tests/e2e/admin-smoke.spec.ts
npx playwright test tests/e2e/public-smoke.spec.ts
npx playwright test tests/e2e/blog-seo.spec.ts
npx playwright test tests/e2e/contact-submit.spec.ts
```
### 2. 웹사이트 접속 확인
### 블로그 포스트 확인
```bash
# 공개 사이트
curl -I http://www.taxbaik.com/
# 예상: HTTP 200
# 초기 5개 포스트 확인
curl http://178.104.200.7/taxbaik/blog
# 관리자 로그인
curl -I http://www.taxbaik.com/admin/login
# 예상: HTTP 200
# 첫 번째 포스트 상세
curl http://178.104.200.7/taxbaik/blog/accountant-mistakes-5
```
### 3. E2E 테스트
## 롤백
```bash
# 로컬에서 운영 서버 테스트
export E2E_BASE_URL="http://www.taxbaik.com"
npx playwright test tests/e2e/admin-smoke.spec.ts tests/e2e/public-smoke.spec.ts
# 이전 버전으로 복귀
ssh kjh2064@178.104.200.7
# 이전 배포 디렉토리 확인
ls -la ~/deployments/ | grep taxbaik
# 심링크 변경 (예: 이전 버전이 taxbaik_20260626_140000)
ln -sfn ~/deployments/taxbaik_20260626_140000 ~/taxbaik_active
sudo systemctl restart taxbaik
```
### 4. 실제 브라우저 확인
## 모니터링
- [ ] 공개 사이트: http://www.taxbaik.com/
- [ ] 로그인 페이지: http://www.taxbaik.com/admin/login
- [ ] 로그인 후 대시보드: http://www.taxbaik.com/admin/dashboard
- [ ] 메뉴 네비게이션 정상 작동
- [ ] API 호출 정상 (네트워크 탭 확인)
---
## 🔙 롤백 방법
설정 변경 후 문제가 발생하면:
### 서비스 상태 확인
```bash
# 자동 스크립트로 수정했을 경우:
# - 백업 파일명: /etc/nginx/sites-available/taxbaik-domains.conf.backup.YYYYMMDD_HHMMSS
sudo cp /etc/nginx/sites-available/taxbaik-domains.conf.backup.YYYYMMDD_HHMMSS /etc/nginx/sites-available/taxbaik-domains.conf
ssh kjh2064@178.104.200.7
# 또는 수동 설정으로 변경했을 경우:
sudo cp /etc/nginx/sites-available/taxbaik-domains.conf.backup /etc/nginx/sites-available/taxbaik-domains.conf
# 서비스 상태
systemctl status taxbaik
# Nginx 재로드
sudo systemctl reload nginx
# 포트 확인
netstat -tlnp | grep -E '5001'
# 프로세스 확인
ps aux | grep TaxBaik
```
---
### 성능 모니터링
## 📊 기술 상세
```bash
# Nginx 프록시 로그
tail -f /var/log/nginx/access.log | grep taxbaik
### 왜 이 변경이 필요한가?
1. **코드 변경**: PathBase 제거 → 앱이 루트 경로를 인식함
2. **로컬 동작**: localhost:5001에서는 `/admin/...`로 바로 접속
3. **운영 문제**: Nginx가 루트(`/`)를 전달해야 함
### 해결 원리
```
요청 경로: /admin/login
↓ (proxy_pass)
앱 수신: http://127.0.0.1:5001/admin/login
↓ (base href="/admin/")
브라우저 렌더: /admin/...
# 애플리케이션 로그
journalctl -u taxbaik -f
```
---
## 트러블슈팅
## 📝 체크리스트
| 증상 | 원인 | 해결 |
|------|------|------|
| 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 확인 |
| 503 Service Unavailable | 앱 미시작 | `sudo systemctl restart taxbaik` |
| 마이그레이션 실패 | DB 권한 문제 | `GRANT ALL PRIVILEGES ON DATABASE taxbaikdb TO taxbaik;` |
배포 전:
## 초기 데이터
- [ ] `git push origin master` 완료
- [ ] CI/CD 빌드 성공
- [ ] 운영 서버 배포 완료
### 관리자 계정
배포 후:
- **username**: `admin`
- **password**: `<TAXBAIK_ADMIN_TEST_PASSWORD>` (운영 검증용 비밀번호, Secrets로 관리)
- 초기 로그인 후 비밀번호 즉시 변경 권장
- [ ] Nginx 설정 변경 완료 (또는 스크립트 실행)
- [ ] `sudo nginx -t` 문법 검증 성공
- [ ] `sudo systemctl reload nginx` 재로드 성공
- [ ] 공개 사이트 HTTP 200 확인
- [ ] 관리자 로그인 페이지 HTTP 200 확인
- [ ] E2E 테스트 통과 (또는 브라우저 수동 테스트)
### 블로그 포스트
---
V003 마이그레이션에서 5개 포스트 자동 생성:
1. 사업자 기장 시 자주 하는 실수 5가지
2. 부동산 양도세 계산하기
3. 프리랜서를 위한 종합소득세 신고
4. 부가가치세 간이과세 vs 일반과세
5. 가족 자산 증여세 절세 방법
## 🆘 문제 해결
## 차후 작업
### 증상: 404 오류 (Nginx 적용 전)
```
원인: Nginx가 /taxbaik을 제거하지 않음
해결: 위 "배포" 단계 실행
```
### 증상: Nginx 문법 오류
```
해결:
1. 백업에서 복구
2. 문법 재확인
3. sudo nginx -t 로 검증
4. 스크립트 다시 실행
```
### 증상: 재로드 실패
```
원인: 다른 설정 충돌
해결:
1. sudo systemctl status nginx 로 상태 확인
2. sudo journalctl -u nginx -n 50 로 로그 확인
3. 백업에서 복구
4. 기술팀 연락
```
---
## 📞 기술 지원
문제 발생 시:
1. 로그 수집: `sudo journalctl -u nginx -n 100 > /tmp/nginx.log`
2. Nginx 상태: `sudo systemctl status nginx`
3. 포트 확인: `sudo netstat -tlnp | grep 5001`
4. DNS: `nslookup www.taxbaik.com 8.8.8.8`
---
**배포 완료!** 🚀
- [ ] SSL 인증서 적용 (Let's Encrypt)
- [ ] 도메인 연결 (현재는 IP 기반)
- [ ] 관리자 인증 보안 고도화 (rate limit, 비밀번호 교체 절차)
- [ ] 블로그 포스트 수정 화면 완성
- [ ] Naver/Google Search Console 등록
- [ ] 운영 관리자 비밀번호를 초기 시드값에서 교체하고 `TAXBAIK_ADMIN_TEST_PASSWORD` 갱신
+9
View File
@@ -0,0 +1,9 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
WORKDIR /app
COPY ./publish/ .
EXPOSE 5001
ENTRYPOINT ["dotnet", "TaxBaik.Web.dll"]
+9
View File
@@ -0,0 +1,9 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
WORKDIR /app
COPY ./publish/ .
EXPOSE 5001
ENTRYPOINT ["dotnet", "TaxBaik.Web.dll"]
-406
View File
@@ -1,406 +0,0 @@
# Nginx 설정 변경 가이드 (TaxBaik 경로 최적화)
## 📍 설정 파일 위치
```
/etc/nginx/sites-available/taxbaik-domains.conf
```
---
## 🔍 현재 설정 확인
### 1단계: 운영 서버에 접속
```bash
ssh kjh2064@178.104.200.7
```
### 2단계: 현재 설정 확인
```bash
sudo cat /etc/nginx/sites-available/taxbaik-domains.conf
```
또는 특정 부분만:
```bash
sudo grep -A 15 "location /" /etc/nginx/sites-available/taxbaik-domains.conf
```
---
## 📋 현재 설정 (변경 전)
```nginx
location / {
proxy_pass http://127.0.0.1:5001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_cache_bypass $http_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;
proxy_read_timeout 120s;
}
```
---
## ✅ 변경해야 할 부분
### 변경 1: location 경로 수정
```diff
- location /taxbaik {
+ location / {
```
### 변경 2: proxy_pass 수정
```diff
- proxy_pass http://127.0.0.1:5001;
+ proxy_pass http://127.0.0.1:5001/;
```
### 변경 3: rewrite 규칙 추가
루트 프록시에서는 rewrite가 필요하지 않습니다.
---
## 📝 변경 후 최종 설정
```nginx
location / {
proxy_pass http://127.0.0.1:5001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_cache_bypass $http_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;
proxy_read_timeout 120s;
}
```
---
## 🛠️ 설정 변경 방법
### 방법 1: nano 에디터 (권장)
```bash
# 1. 백업 생성
sudo cp /etc/nginx/sites-available/taxbaik-domains.conf /etc/nginx/sites-available/taxbaik-domains.conf.backup
# 2. 편집
sudo nano /etc/nginx/sites-available/taxbaik-domains.conf
# 3. 다음 변경사항 적용:
# - location /taxbaik { → location /
# - proxy_pass http://127.0.0.1:5001; → proxy_pass http://127.0.0.1:5001;
# 4. 저장 후 종료
# Ctrl+X → Y → Enter
```
### 방법 2: sed 명령어 (자동)
```bash
# 백업
sudo cp /etc/nginx/sites-available/taxbaik-domains.conf /etc/nginx/sites-available/taxbaik-domains.conf.backup
# 변경 1: location / 유지
sudo sed -i 's/location \/taxbaik {/location \//g' /etc/nginx/sites-available/taxbaik-domains.conf
# 변경 2: proxy_pass 유지
sudo sed -i 's|proxy_pass http://127.0.0.1:5001/;|proxy_pass http://127.0.0.1:5001;|g' /etc/nginx/sites-available/taxbaik-domains.conf
```
### 방법 3: Python 스크립트
```python
# nginx_fix.py
import re
nginx_file = "/etc/nginx/sites-available/taxbaik-domains.conf"
# 백업
import shutil
shutil.copy(nginx_file, nginx_file + ".backup")
# 설정 읽기
with open(nginx_file, 'r') as f:
content = f.read()
# 변경 1: location /taxbaik { → location /taxbaik/ {
content = content.replace('location /taxbaik {', 'location /taxbaik/ {')
# 변경 2: proxy_pass 수정
content = content.replace(
'proxy_pass http://127.0.0.1:5001;',
'proxy_pass http://127.0.0.1:5001/;'
)
# 변경 3: rewrite 규칙 추가 (한 번만)
if 'rewrite ^/taxbaik/' not in content:
content = content.replace(
'proxy_pass http://127.0.0.1:5001/;',
'proxy_pass http://127.0.0.1:5001/;\n rewrite ^/taxbaik/(.*)$ /$1 break;'
)
# 설정 저장
with open(nginx_file, 'w') as f:
f.write(content)
print("✅ Nginx 설정 변경 완료")
```
실행:
```bash
sudo python3 nginx_fix.py
```
---
## ✓ 설정 변경 후 검증
### Step 1: 문법 검증
```bash
sudo nginx -t
```
**예상 출력:**
```
nginx: the configuration file /etc/nginx/sites-available/taxbaik-domains.conf syntax is ok
nginx: configuration test is successful
```
**오류가 나면:**
- 백업에서 복구: `sudo cp /etc/nginx/sites-available/taxbaik-domains.conf.backup /etc/nginx/sites-available/taxbaik-domains.conf`
- 다시 변경 시도
### Step 2: Nginx 재로드
```bash
sudo systemctl reload nginx
```
또는
```bash
sudo systemctl restart nginx
```
**상태 확인:**
```bash
sudo systemctl status nginx
```
### Step 3: 포트 확인
```bash
# Nginx가 포트 80, 443을 사용하는지 확인
sudo netstat -tlnp | grep nginx
```
**예상 출력:**
```
tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN 1234/nginx
tcp 0 0 0.0.0.0:443 0.0.0.0:* LISTEN 1234/nginx
```
### Step 4: 웹사이트 테스트
```bash
# 공개 사이트
curl -I http://www.taxbaik.com/taxbaik/
# 예상: HTTP 200
# 관리자 로그인
curl -I http://www.taxbaik.com/taxbaik/admin/login
# 예상: HTTP 200
# API
curl -I http://www.taxbaik.com/taxbaik/api/auth/login
# 예상: HTTP 405 (POST only) 또는 HTTP 200
```
---
## 📊 변경 전후 비교
### 요청 흐름 (변경 전) ❌
```
공개 URL: http://www.taxbaik.com/taxbaik/admin/login
Nginx (location /taxbaik): proxy_pass http://127.0.0.1:5001
앱 수신: http://127.0.0.1:5001/taxbaik/admin/login
앱 경로: /taxbaik/admin/login ← PathBase 없음 → 경로 미인식
❌ 404 오류
```
### 요청 흐름 (변경 후) ✅
```
공개 URL: http://www.taxbaik.com/taxbaik/admin/login
Nginx (location /taxbaik/): rewrite 규칙
변환: /admin/login (taxbaik 제거)
앱 수신: http://127.0.0.1:5001/admin/login
앱 경로: /admin/login ← base href="/taxbaik/admin/" 적용
브라우저: /taxbaik/admin/dashboard (상대경로로 변환)
✅ 정상 작동
```
---
## 🔙 롤백 (문제 발생 시)
### 즉시 롤백
```bash
# 백업 복구
sudo cp /etc/nginx/sites-available/taxbaik-domains.conf.backup /etc/nginx/sites-available/taxbaik-domains.conf
# Nginx 재로드
sudo systemctl reload nginx
# 확인
sudo systemctl status nginx
```
### 변경 내용 확인
변경 전 내용 확인:
```bash
sudo diff /etc/nginx/sites-available/taxbaik-domains.conf.backup /etc/nginx/sites-available/taxbaik-domains.conf
```
---
## 🆘 문제 해결
### 문제 1: nginx -t 오류
**증상:**
```
nginx: [emerg] unknown directive "rewrite" in /etc/nginx/sites-available/taxbaik-domains.conf:XX
```
**해결:**
- rewrite 문법 확인: `rewrite ^/taxbaik/(.*)$ /$1 break;` (정확하게)
- 백업에서 복구 후 다시 시도
### 문제 2: 설정 적용 안 됨
```bash
# Nginx 완전 재시작
sudo systemctl stop nginx
sudo systemctl start nginx
# 또는
sudo nginx -s reload
```
### 문제 3: 여전히 404
```bash
# 1. 설정 재확인
sudo cat /etc/nginx/sites-available/taxbaik-domains.conf | grep -A 3 "location /taxbaik"
# 2. Nginx 로그 확인
sudo tail -100 /var/log/nginx/error.log
# 3. 앱 로그 확인
sudo systemctl status taxbaik
sudo journalctl -u taxbaik -n 50
# 4. DNS 확인
nslookup www.taxbaik.com 8.8.8.8
```
---
## 📋 체크리스트
배포 전:
- [ ] 운영 서버에 ssh 접속 확인
- [ ] 현재 설정 백업 생성
- [ ] 변경 계획 검토
배포:
- [ ] location /taxbaik → location /taxbaik/ (변경 1)
- [ ] proxy_pass 수정 (변경 2)
- [ ] rewrite 규칙 추가 (변경 3)
- [ ] nginx -t 문법 검증 성공
- [ ] systemctl reload nginx 실행
검증:
- [ ] curl -I http://www.taxbaik.com/taxbaik/ → HTTP 200
- [ ] curl -I http://www.taxbaik.com/taxbaik/admin/login → HTTP 200
- [ ] 브라우저에서 실제 접속 확인
- [ ] E2E 테스트 실행
---
## 💡 팁
1. **변경 전 항상 백업**
```bash
sudo cp /etc/nginx/sites-available/taxbaik-domains.conf /etc/nginx/sites-available/taxbaik-domains.conf.backup
```
2. **nano에서 수정할 때:**
- `Ctrl+W` → "location /taxbaik" 검색
- `Ctrl+X` → Y → Enter 저장
3. **변경 확인:**
```bash
sudo diff -u /etc/nginx/sites-available/taxbaik-domains.conf.backup /etc/nginx/sites-available/taxbaik-domains.conf
```
4. **전체 설정 보기:**
```bash
sudo cat /etc/nginx/sites-available/taxbaik-domains.conf | grep -v "^#" | grep -v "^$"
```
---
**변경 완료 후 운영 서버 확인:**
```bash
# 최종 검증
echo "=== 설정 확인 ===" && \
sudo grep -A 5 "location /taxbaik" /etc/nginx/sites-available/taxbaik-domains.conf && \
echo "" && \
echo "=== 문법 검증 ===" && \
sudo nginx -t && \
echo "" && \
echo "=== Nginx 상태 ===" && \
sudo systemctl status nginx --no-pager
```
---
**완료!**
@@ -48,7 +48,29 @@ ssh kjh2064@178.104.200.7 'bash ~/SERVER_SETUP.sh'
# ~/taxbaik_active
```
### 2단계: Gitea Actions 설정
### 2단계: 첫 배포 (수동)
```bash
# 로컬에서 실행
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
# SSH 키 설정 (필요시)
export DEPLOY_USER="kjh2064"
export DEPLOY_HOST="178.104.200.7"
# 배포
rsync -avz --delete ./publish/ \
$DEPLOY_USER@$DEPLOY_HOST:~/deployments/taxbaik_${TIMESTAMP}/
# 심링크 변경 및 시작
ssh $DEPLOY_USER@$DEPLOY_HOST << EOF
ln -sfn ~/deployments/taxbaik_${TIMESTAMP} ~/taxbaik_active
sudo systemctl start taxbaik
sudo systemctl status taxbaik
EOF
```
### 3단계: Gitea Actions 설정 (선택)
**Gitea 저장소 Settings → Secrets 추가**:
- `DEPLOY_USER`: `kjh2064`
@@ -195,8 +217,8 @@ curl -I -H "Accept-Encoding: gzip" http://178.104.200.7/taxbaik/ | grep -i encod
| 증상 | 원인 | 해결 방법 |
|------|------|----------|
| 404 /taxbaik | Nginx 설정 미적용 | `sudo nginx -t && sudo systemctl reload nginx` |
| 502 Bad Gateway | 프록시 또는 백엔드 미실행 | `sudo systemctl restart taxbaik-proxy taxbaik` |
| 503 Service Unavailable | 백엔드 충돌 또는 비밀값 누락 | 로그 확인: `journalctl -u taxbaik -n 50` |
| 502 Bad Gateway | 미실행 | `sudo systemctl restart taxbaik` |
| 503 Service Unavailable | 앱 충돌 | 로그 확인: `journalctl -u taxbaik -n 50` |
| DB 연결 오류 | 환경 변수 미설정 | systemd 파일의 ConnectionStrings__Default 확인 |
| HTTPS 오류 | SSL 미구성 | 개발 환경에서는 HTTP 사용 (IP 기반) |
| 마이그레이션 실패 | 테이블 존재 | `DROP DATABASE taxbaikdb;` 후 재시작 |
@@ -208,11 +230,11 @@ curl -I -H "Accept-Encoding: gzip" http://178.104.200.7/taxbaik/ | grep -i encod
### 실시간 모니터링
```bash
# 터미널 1: 백엔드 로그
# 터미널 1: 웹 서비스 로그
ssh kjh2064@178.104.200.7 'journalctl -u taxbaik -f'
# 터미널 2: 프록시 로그
ssh kjh2064@178.104.200.7 'journalctl -u taxbaik-proxy -f'
# 터미널 2: 통합 서비스 로그
ssh kjh2064@178.104.200.7 'journalctl -u taxbaik -f'
# 터미널 3: Nginx 로그
ssh kjh2064@178.104.200.7 'sudo tail -f /var/log/nginx/access.log | grep taxbaik'
@@ -224,7 +246,13 @@ ssh kjh2064@178.104.200.7 'watch -n 1 "ps aux | grep TaxBaik"'
### 정기적 검사
```bash
# 일일 체크는 CI 배포 후 자동 검증으로 대체
# 일일 체크 (cron job)
0 9 * * * /home/kjh2064/health-check.sh
# 내용:
#!/bin/bash
curl -f http://127.0.0.1:5001/taxbaik || systemctl restart taxbaik
curl -f http://127.0.0.1:5001/taxbaik/admin/login || systemctl restart taxbaik
```
---
@@ -240,6 +268,11 @@ git commit -m "기능: 새로운 기능 추가"
git push origin master
# 2. Gitea Actions가 자동으로 배포
# 또는 수동 배포:
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
dotnet publish TaxBaik.Web -c Release -o ./publish
rsync -avz ./publish/ kjh2064@178.104.200.7:~/deployments/taxbaik_${TIMESTAMP}/
ssh kjh2064@178.104.200.7 "ln -sfn ~/deployments/taxbaik_${TIMESTAMP} ~/taxbaik_active && sudo systemctl restart taxbaik"
```
### 롤백 절차
@@ -251,7 +284,6 @@ ssh kjh2064@178.104.200.7 'ls -la ~/deployments/ | grep taxbaik'
# 롤백 (예: 이전 버전이 taxbaik_20260625_100000)
ssh kjh2064@178.104.200.7 << EOF
ln -sfn ~/deployments/taxbaik_20260625_100000 ~/taxbaik_active
sudo systemctl restart taxbaik-proxy
sudo systemctl restart taxbaik
EOF
```
+19 -37
View File
@@ -123,20 +123,24 @@ psql -d taxbaikdb -f db/migrations/V002__SeedData.sql
psql -d taxbaikdb -f db/migrations/V003__SeedAdminAndBlogPosts.sql
psql -d taxbaikdb -f db/migrations/V004__CreateSiteSettings.sql
# 3. 로컬 개발 환경 실행
$env:ASPNETCORE_ENVIRONMENT="Development"
dotnet watch run --project .\src\TaxBaik.Web\TaxBaik.Web.csproj
# 3. 환경 변수 설정
export ConnectionStrings__Default="Host=localhost;Database=taxbaikdb;Username=postgres;Password=password"
# 4. 브라우저 열기
# 공개 사이트: http://localhost:5001/
# 관리자: http://localhost:5001/admin/login
# 4. 빌드 및 실행
dotnet build TaxBaik.sln
dotnet run --project TaxBaik.Web
# 5. 브라우저 열기
# 공개 사이트: http://localhost:5001/taxbaik
# 관리자: http://localhost:5001/taxbaik/admin/login
```
### 초기 로그인 정보
- 운영 관리자: `admin / Admin@123456`
- E2E 자동화: `test_admin / TestAdmin@123456`
- 프로덕션 배포 시 비밀번호 변경은 필수이며, 검증용 비밀번호는 Gitea Secrets로 관리합니다.
- **username**: `admin`
- **password**: `<TAXBAIK_ADMIN_TEST_PASSWORD>` or current rotated admin password
> ⚠️ **중요**: 프로덕션 배포 시 비밀번호 변경 필수이며, 검증용 비밀번호는 Gitea Secrets로 관리
---
@@ -153,7 +157,7 @@ master 브랜치에 푸시하면 파이프라인이 다음 단계를 수행합
4. `TaxBaik.Web` 게시
5. 원격 서버 배포 디렉토리 업로드 및 `taxbaik_active` 심링크 교체
6. systemd `taxbaik` 단일 서비스 재시작
7. `/`, `/admin/login`, `/blog/{slug}`, `/api/auth/login` 검증
7. `/taxbaik/`, `/taxbaik/admin/login`, `/taxbaik/blog/{slug}`, `/taxbaik/api/auth/login` 검증
배포 완료 판정은 위 단계가 모두 성공하고, 배포본 기준 Playwright E2E가 통과했을 때만 한다.
@@ -161,25 +165,10 @@ master 브랜치에 푸시하면 파이프라인이 다음 단계를 수행합
- `DEPLOY_USER`: kjh2064
- `DEPLOY_HOST`: 178.104.200.7
- `DEPLOY_SSH_KEY_B64`: base64로 인코딩한 SSH 개인키
- `TAXBAIK_ADMIN_TEST_PASSWORD`: 배포 검증용 관리자 비밀번호 (`test_admin / TestAdmin@123456`)
- `TAXBAIK_ADMIN_TEST_PASSWORD`: 배포 검증용 관리자 비밀번호
- `Admin__PasswordResetToken`: 관리자 비밀번호 재설정 API용 서버 비밀값
배포는 Gitea Actions CI/CD로만 수행합니다. 수동 배포 경로는 CI 하네스로 차단되어 있으며, 실패 시 [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)의 CI 점검 절차를 따릅니다.
## E2E / Smoke
공개/관리자 분리 검증은 아래 명령을 사용합니다.
로컬에서 실행할 때는 먼저 `npm run test:e2e`가 가리키는 대상 서버를 띄워둬야 합니다.
| 용도 | Bash | PowerShell |
| --- | --- | --- |
| Public smoke | `E2E_BASE_URL=https://www.taxbaik.com npm run test:e2e:public-smoke` | `$env:E2E_BASE_URL="https://www.taxbaik.com"; npm run test:e2e:public-smoke` |
| Admin smoke | `E2E_BASE_URL=https://www.taxbaik.com npm run test:e2e:admin-smoke` | `$env:E2E_BASE_URL="https://www.taxbaik.com"; npm run test:e2e:admin-smoke` |
직접 smoke 스크립트가 필요하면 이 한 줄만 쓰면 됩니다.
`ROOT_URL="https://www.taxbaik.com/" ADMIN_URL="https://www.taxbaik.com/admin/login" bash ./scripts/taxbaik-smoke.sh`
수동 배포는 비상 롤백 절차 외에는 사용하지 않습니다. 실패 시 [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)의 CI 점검 절차를 따릅니다.
---
@@ -273,22 +262,15 @@ kill -9 <PID>
# 연결 테스트
psql -U taxbaik -d taxbaikdb -c "SELECT 1;"
# 개발 환경에서는 appsettings.Development.json 우선
echo $env:ASPNETCORE_ENVIRONMENT
echo $env:ConnectionStrings__Default
# 환경 변수 확인
echo $ConnectionStrings__Default
```
---
## 문서
- [docs/INDEX.md](./docs/INDEX.md) - 현재 개발 기준 인덱스
- [docs/ENGINEERING_HARNESS.md](./docs/ENGINEERING_HARNESS.md) - 코드 품질, API-first, CI/CD 하네스
- [docs/DOUZONE_UX_GUIDE.md](./docs/DOUZONE_UX_GUIDE.md) - 더존식 어드민 UX 원칙과 템플릿 기준
- [docs/COMMON_CODE_POLICY.md](./docs/COMMON_CODE_POLICY.md) - 공통코드 저장값/컬럼 길이/하드코딩 금지 기준
- [docs/COMBO_POLICY.md](./docs/COMBO_POLICY.md) - 콤보/검색/선택 입력 정책
- [docs/ADMIN_PATTERN_CRITIQUE_WBS.md](./docs/ADMIN_PATTERN_CRITIQUE_WBS.md) - 어드민 패턴 비판 및 정량 WBS
- [CLAUDE.md](./CLAUDE.md) - 보조 LLM 개발 지침
- [CLAUDE.md](./CLAUDE.md) - LLM 개발 지침 (9개 섹션)
- [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) - 배포 완전 가이드
- [SERVER_SETUP.sh](./SERVER_SETUP.sh) - 서버 자동 설치 스크립트
+16 -59
View File
@@ -425,9 +425,9 @@ Todo:
- 텔레그램 전송 실패 시 로그만 남기고 앱 정상 운영 유지
Todo:
- [x] BackgroundService 또는 Hangfire 기반 스케줄러 추가
- [x] 일간/주간 리포트 메시지 템플릿
- [x] TelegramNotificationService에 리포트 메서드 추가
- [ ] BackgroundService 또는 Hangfire 기반 스케줄러 추가
- [ ] 일간/주간 리포트 메시지 템플릿
- [ ] TelegramNotificationService에 리포트 메서드 추가
## WBS-CRM-07 고객 포털 (읽기 전용) — Phase 3
@@ -439,9 +439,9 @@ Todo:
- 개인정보 열람 범위는 세무사가 허용한 항목만
Todo:
- [x] 고객 포털 설계 (인증 방식 결정 — WBS-CRM-08 선행)
- [x] 고객 전용 Razor Pages 추가
- [x] 세무사 허용 권한 설정 UI
- [ ] 고객 포털 설계 (인증 방식 결정 — WBS-CRM-08 선행)
- [ ] 고객 전용 Razor Pages 추가
- [ ] 세무사 허용 권한 설정 UI
## WBS-CRM-08 고객 회원가입 · 소셜 로그인 — Phase 3
@@ -485,16 +485,16 @@ DB 스키마:
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`
Todo:
- [x] WBS-CRM-07 고객 포털 기본 구조 완성 (선행)
- [x] OAuth 앱 등록 (네이버·카카오·구글 개발자 콘솔)
- [x] V011__CreatePortalUsers.sql 마이그레이션 (실제 V016__CreatePortalUsers.sql로 대체됨)
- [x] PortalUser 엔티티 / IPortalUserRepository / PortalUserRepository
- [x] 네이버 OAuth Handler 구현
- [x] 카카오·구글 패키지 추가 및 설정
- [x] 기본 계정 회원가입 폼 (`/taxbaik/portal/register`)
- [x] 소셜 로그인 콜백 처리 → portal_users 자동 생성
- [x] 신규 가입 시 clients 테이블 연결 또는 신규 생성
- [x] 포털 로그인 페이지 (`/taxbaik/portal/login`) — 소셜 버튼 + 이메일 폼
- [ ] WBS-CRM-07 고객 포털 기본 구조 완성 (선행)
- [ ] OAuth 앱 등록 (네이버·카카오·구글 개발자 콘솔)
- [ ] V011__CreatePortalUsers.sql 마이그레이션
- [ ] PortalUser 엔티티 / IPortalUserRepository / PortalUserRepository
- [ ] 네이버 OAuth Handler 구현
- [ ] 카카오·구글 패키지 추가 및 설정
- [ ] 기본 계정 회원가입 폼 (`/taxbaik/portal/register`)
- [ ] 소셜 로그인 콜백 처리 → portal_users 자동 생성
- [ ] 신규 가입 시 clients 테이블 연결 또는 신규 생성
- [ ] 포털 로그인 페이지 (`/taxbaik/portal/login`) — 소셜 버튼 + 이메일 폼
- [ ] Gitea Secrets에 OAuth 키 추가
- [ ] 배포 후 소셜 로그인 3종 E2E 테스트
@@ -522,46 +522,3 @@ Todo:
- WBS-UX-03/04 구현 완료
- WBS-CRM-01/02/03/04/05 구현 완료 (배포 후 검증 필요)
- WBS-CRM-06/07/08 (텔레그램·포털·소셜 로그인) Phase 3 미착수
---
## ── 홈페이지 · 어드민 · 포털 프리미엄 UX/UI 개편 (2026-06-30) ──────────────────
## WBS-UX-05 홈페이지 프리미엄 UI 및 마이크로 인터랙션
목표: 홈페이지 디자인을 극도로 모던하고 신뢰성 있는 프리미엄 스타일로 전면 개편한다.
성공 기준:
- Hero 섹션에 유려한 배경 그라데이션 및 부드러운 CSS 애니메이션 효과 적용
- 서비스 카드에 섀도우 및 보더 트랜지션, 골드/그린 그라데이션 호버 이펙트 추가
- 신뢰도 스트립 카드에 입체감 및 돋보이는 레이아웃 설계
- Noto Sans KR 외에 Outfit/Inter 등의 보조 영문 폰트 결합으로 타이포그래피 고급화
Todo:
- [x] `site.css` 내 Hero 섹션 그라데이션 및 CSS 애니메이션 보강
- [x] 서비스 카드 및 신뢰도 스트립 컴포넌트 프리미엄 스타일로 개편
- [x] 홈페이지 폰트 스택 확장 및 메인 레이아웃 적용
## WBS-PORTAL-01 고객 포털 UI/UX 고도화 및 글래스모피즘
목표: 고객 마이 포털 화면을 미려하고 현대적인 글래스모피즘 디자인으로 개편하여 이용 가치를 극대화한다.
성공 기준:
- 포털 메인 대시보드 카드를 Glassmorphism 스타일(blur, semi-transparent border)로 변경
- 세무 신고 현황 테이블 및 상담 이력 타임라인 컴포넌트의 모던 디자인화
Todo:
- [x] `site.css` 내 포털 전용 모던 글래스모피즘 클래스군 추가
- [x] `Portal/Index.cshtml` 레이아웃 및 컴포넌트 UI 고도화
## WBS-MAINT-02 코드 품질 및 경고 결함 차단
목표: 빌드 컴파일 타임 경고(Warnings)를 0으로 유지하여 미래 코드 결함을 방지한다.
성공 기준:
- `dotnet build` 수행 시 경고 0개 달성
Todo:
- [x] `CustomAuthenticationStateProvider.cs` Nullable 경고 수정
- [x] `Dashboard.razor` 미사용 변수 제거 및 UI 연계 바인딩 처리
+77
View File
@@ -0,0 +1,77 @@
#!/bin/bash
# TaxBaik Server Setup Script
# Run on Ubuntu 26.04 server as root or with sudo
set -e
echo "===== TaxBaik Server Setup ====="
# Colors for output
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
DEPLOY_USER="kjh2064"
DB_NAME="taxbaikdb"
DB_USER="taxbaik"
DB_PASSWORD="${DB_PASSWORD:-$(openssl rand -base64 12)}" # Use env var or generate
DEPLOY_DIR="/home/$DEPLOY_USER"
echo -e "${BLUE}1. Installing .NET 8 Runtime${NC}"
sudo apt-get update
sudo apt-get install -y dotnet-runtime-8.0 aspnetcore-runtime-8.0
echo -e "${BLUE}2. Installing PostgreSQL 18${NC}"
sudo apt-get install -y postgresql postgresql-contrib
echo -e "${BLUE}3. Creating database and user${NC}"
sudo -u postgres psql << EOF
CREATE USER $DB_USER WITH PASSWORD '$DB_PASSWORD';
CREATE DATABASE $DB_NAME OWNER $DB_USER;
GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;
EOF
echo -e "${BLUE}4. Creating deployment directories${NC}"
sudo -u $DEPLOY_USER mkdir -p $DEPLOY_DIR/deployments
sudo -u $DEPLOY_USER mkdir -p $DEPLOY_DIR/taxbaik_active
sudo -u $DEPLOY_USER mkdir -p $DEPLOY_DIR/taxbaik_admin_active
echo -e "${BLUE}5. Installing systemd service files${NC}"
sudo cp deploy/taxbaik.service /etc/systemd/system/
sudo cp deploy/taxbaik-admin.service /etc/systemd/system/
# Update environment variables in service files
sudo sed -i "s/YOUR_SECURE_PASSWORD_HERE/$DB_PASSWORD/g" /etc/systemd/system/taxbaik.service
sudo sed -i "s/YOUR_SECURE_PASSWORD_HERE/$DB_PASSWORD/g" /etc/systemd/system/taxbaik-admin.service
echo -e "${BLUE}6. Configuring Nginx${NC}"
sudo mkdir -p /etc/nginx/conf.d
sudo cp deploy/nginx-taxbaik-locations.conf /etc/nginx/conf.d/taxbaik.conf
sudo nginx -t
sudo systemctl reload nginx
echo -e "${BLUE}7. Enabling services${NC}"
sudo systemctl daemon-reload
sudo systemctl enable taxbaik taxbaik-admin
sudo systemctl enable postgresql
echo -e "${GREEN}===== Setup Complete ====="
echo ""
echo "Database credentials:"
echo " Host: localhost"
echo " Database: $DB_NAME"
echo " User: $DB_USER"
echo " Password: $DB_PASSWORD"
echo ""
echo "Next steps:"
echo " 1. Copy the first deployment to ~/deployments/taxbaik_TIMESTAMP/"
echo " 2. Create symlinks:"
echo " ln -s ~/deployments/taxbaik_TIMESTAMP ~/taxbaik_active"
echo " ln -s ~/deployments/taxbaik_admin_TIMESTAMP ~/taxbaik_admin_active"
echo " 3. Start services:"
echo " sudo systemctl start taxbaik taxbaik-admin"
echo " 4. Verify:"
echo " sudo systemctl status taxbaik taxbaik-admin"
echo " curl http://127.0.0.1:5001/taxbaik"
echo " curl http://127.0.0.1:5002/taxbaik/admin/login"
@@ -0,0 +1,90 @@
namespace TaxBaik.Application.Tests;
using TaxBaik.Application.DTOs;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Domain.Interfaces;
using Microsoft.Extensions.Caching.Memory;
using Xunit;
public class BlogServiceTests
{
[Fact]
public async Task CreateAsync_WhenPublishedWithoutSeoTitle_ThrowsValidationException()
{
var service = new BlogService(new FakeBlogPostRepository(), new MemoryCache(new MemoryCacheOptions()));
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, new MemoryCache(new MemoryCacheOptions()));
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>> GetByCategorySlugAsync(string categorySlug, int limit, CancellationToken cancellationToken = default) =>
Task.FromResult<IEnumerable<BlogPost>>(Posts.Where(x => x.IsPublished).Take(limit).ToList());
public Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<IEnumerable<BlogPost>>(Posts);
public Task<(IEnumerable<BlogPost> Items, int Total)> GetAdminPagedAsync(
int page, int pageSize, CancellationToken cancellationToken = default)
{
var items = Posts.ToList();
return Task.FromResult<(IEnumerable<BlogPost>, int)>((items, items.Count));
}
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;
}
}
@@ -1,7 +1,5 @@
namespace TaxBaik.Application.Tests;
using FluentValidation;
using TaxBaik.Application.DTOs;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Domain.Interfaces;
@@ -13,18 +11,18 @@ public class InquiryServiceTests
[Fact]
public async Task UpdateStatusAsync_WhenStatusIsInvalid_ThrowsValidationException()
{
var service = new InquiryService(new FakeInquiryRepository(), new FakeInquiryNotificationService(), new MemoryCache(new MemoryCacheOptions()), new PassThroughValidator<SubmitInquiryDto>(), new PassThroughValidator<UpdateInquiryDto>());
var service = new InquiryService(new FakeInquiryRepository(), new FakeInquiryNotificationService(), new MemoryCache(new MemoryCacheOptions()));
await Assert.ThrowsAsync<TaxBaik.Application.Services.ValidationException>(() => service.UpdateStatusAsync(1, "invalid"));
await Assert.ThrowsAsync<ValidationException>(() => service.UpdateStatusAsync(1, "invalid"));
}
[Fact]
public async Task SubmitAsync_StoresEmailAndNewStatus()
{
var repository = new FakeInquiryRepository();
var service = new InquiryService(repository, new FakeInquiryNotificationService(), new MemoryCache(new MemoryCacheOptions()), new PassThroughValidator<SubmitInquiryDto>(), new PassThroughValidator<UpdateInquiryDto>());
var service = new InquiryService(repository, new FakeInquiryNotificationService(), new MemoryCache(new MemoryCacheOptions()));
await service.SubmitAsync("홍길동", "010-1234-5678", "기장", "사업자 세무 관련해서 문의드립니다.", "user@example.com");
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);
@@ -82,22 +80,6 @@ public class InquiryServiceTests
return Task.CompletedTask;
}
public Task UpdateAsync(Inquiry inquiry, CancellationToken cancellationToken = default)
{
var existing = Inquiries.FirstOrDefault(x => x.Id == inquiry.Id);
if (existing != null)
{
existing.Name = inquiry.Name;
existing.Phone = inquiry.Phone;
existing.Email = inquiry.Email;
existing.ServiceType = inquiry.ServiceType;
existing.Message = inquiry.Message;
existing.Status = inquiry.Status;
existing.AdminMemo = inquiry.AdminMemo;
}
return Task.CompletedTask;
}
public Task LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default)
{
var inquiry = Inquiries.FirstOrDefault(x => x.Id == inquiryId);
@@ -123,14 +105,4 @@ public class InquiryServiceTests
public Task NotifyStatusChangedAsync(int inquiryId, string name, string phone, string serviceType, string previousStatus, string newStatus, string? changedBy = null, CancellationToken ct = default)
=> Task.CompletedTask;
}
private sealed class PassThroughValidator<T> : IValidator<T>
{
public FluentValidation.Results.ValidationResult Validate(T instance) => new();
public Task<FluentValidation.Results.ValidationResult> ValidateAsync(T instance, CancellationToken cancellation = default) => Task.FromResult(new FluentValidation.Results.ValidationResult());
public FluentValidation.Results.ValidationResult Validate(IValidationContext context) => new();
public Task<FluentValidation.Results.ValidationResult> ValidateAsync(IValidationContext context, CancellationToken cancellation = default) => Task.FromResult(new FluentValidation.Results.ValidationResult());
public IValidatorDescriptor CreateDescriptor() => throw new NotImplementedException();
public bool CanValidateInstancesOfType(Type type) => true;
}
}
@@ -18,6 +18,5 @@
<ItemGroup>
<ProjectReference Include="..\TaxBaik.Application\TaxBaik.Application.csproj" />
<ProjectReference Include="..\TaxBaik.Web\TaxBaik.Web.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,13 @@
namespace TaxBaik.Application.DTOs;
public class AnnouncementDto
{
public int Id { get; set; }
public string Title { get; set; } = "";
public string? Content { get; set; }
public string DisplayType { get; set; } = "info";
public bool IsActive { get; set; } = true;
public DateTime? StartsAt { get; set; }
public DateTime? EndsAt { get; set; }
public int SortOrder { get; set; }
}
@@ -1,7 +1,5 @@
namespace TaxBaik.Application.DTOs;
using System.ComponentModel.DataAnnotations;
public class ClientDto
{
public int Id { get; set; }
@@ -20,31 +18,13 @@ public class ClientDto
public class CreateClientDto
{
[Required(ErrorMessage = "고객명을 입력하세요.")]
[StringLength(100, ErrorMessage = "고객명은 최대 100자까지 입력 가능합니다.")]
public string Name { get; set; } = null!;
[StringLength(100, ErrorMessage = "회사명은 최대 100자까지 입력 가능합니다.")]
public string? CompanyName { get; set; }
[StringLength(20, ErrorMessage = "전화번호는 최대 20자까지 입력 가능합니다.")]
public string? Phone { get; set; }
[EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")]
public string? Email { get; set; }
[StringLength(50, ErrorMessage = "서비스 유형은 최대 50자까지 입력 가능합니다.")]
public string? ServiceType { get; set; }
[StringLength(50, ErrorMessage = "세금 유형은 최대 50자까지 입력 가능합니다.")]
public string? TaxType { get; set; }
[Required(ErrorMessage = "상태를 선택하세요.")]
public string Status { get; set; } = "active";
[StringLength(50, ErrorMessage = "유입 경로는 최대 50자까지 입력 가능합니다.")]
public string? Source { get; set; }
[StringLength(1000, ErrorMessage = "메모는 최대 1000자까지 입력 가능합니다.")]
public string? Memo { get; set; }
}
@@ -0,0 +1,14 @@
namespace TaxBaik.Application.DTOs;
public class CreateBlogPostDto
{
public required string Title { get; set; }
public required string Content { get; set; }
public int? CategoryId { get; set; }
public string? Tags { get; set; }
public string? SeoTitle { get; set; }
public string? SeoDescription { get; set; }
public string? ThumbnailUrl { get; set; }
public bool IsPublished { get; set; }
public int? AuthorId { get; set; }
}
@@ -27,7 +27,6 @@ public static class DependencyInjection
services.AddScoped<RevenueTrackingService>();
services.AddScoped<TelegramReportService>();
services.AddScoped<PortalUserService>();
services.AddScoped<CommonCodeService>();
return services;
}
}
@@ -66,7 +66,7 @@ public static class TaxSeasonCalendar
Name = "부가가치세 1기 확정신고",
StartMonth = 7, StartDay = 1,
EndMonth = 7, EndDay = 25,
HeroHeadline = "부가가치세 1기\n7월 27일 마감",
HeroHeadline = "부가가치세 1기\n7월 25일 마감",
HeroSubtext = "일반과세 사업자 1기 확정신고 · 매입세액 공제 점검",
UrgencyBadge = "D-{n}일 | 부가세 마감",
FocusService = "business-tax",
@@ -1,11 +1,10 @@
namespace TaxBaik.Application.Services;
using FluentValidation;
using TaxBaik.Application.DTOs;
using TaxBaik.Domain.Entities;
using TaxBaik.Domain.Interfaces;
public class AnnouncementService(IAnnouncementRepository repository, IValidator<AnnouncementDto> validator)
public class AnnouncementService(IAnnouncementRepository repository)
{
public Task<IEnumerable<Announcement>> GetActiveAsync(CancellationToken ct = default)
=> repository.GetActiveAsync(ct);
@@ -18,14 +17,12 @@ public class AnnouncementService(IAnnouncementRepository repository, IValidator<
public Task<int> CreateAsync(AnnouncementDto dto, CancellationToken ct = default)
{
validator.ValidateAndThrow(dto);
var entity = MapToEntity(dto);
return repository.CreateAsync(entity, ct);
}
public Task UpdateAsync(AnnouncementDto dto, CancellationToken ct = default)
{
validator.ValidateAndThrow(dto);
var entity = MapToEntity(dto);
return repository.UpdateAsync(entity, ct);
}
@@ -1,18 +1,13 @@
namespace TaxBaik.Application.Services;
using System.Text.RegularExpressions;
using FluentValidation;
using TaxBaik.Application.DTOs;
using TaxBaik.Domain.Entities;
using TaxBaik.Domain.Interfaces;
using Microsoft.Extensions.Caching.Memory;
public class BlogService(
IBlogPostRepository repository,
ICategoryRepository categoryRepository,
IMemoryCache memoryCache,
IValidator<CreateBlogPostDto> validator)
public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCache)
{
public async Task<BlogPost?> GetByIdAsync(int id, CancellationToken ct = default) =>
await repository.GetByIdAsync(id, ct);
@@ -47,14 +42,9 @@ public class BlogService(
int page, int pageSize, CancellationToken ct = default) =>
await repository.GetAdminPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), ct);
public async Task<(IEnumerable<BlogPost>, int)> GetArchivedPagedAsync(
int page, int pageSize, CancellationToken ct = default) =>
await repository.GetArchivedPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), ct);
public async Task<int> CreateAsync(BlogPost post, CancellationToken ct = default)
{
ValidatePost(post);
post.CategoryId ??= await EnsureCategoryIdAsync(0, ct);
post.Title = post.Title.Trim();
post.Content = post.Content.Trim();
post.Slug = await GenerateUniqueSlugAsync(post.Title, ct: ct);
@@ -66,8 +56,6 @@ public class BlogService(
public async Task<BlogPost> CreateAsync(CreateBlogPostDto dto, CancellationToken ct = default)
{
validator.ValidateAndThrow(dto);
dto.CategoryId = await EnsureCategoryIdAsync(dto.CategoryId, ct);
var post = new BlogPost
{
Title = dto.Title,
@@ -89,15 +77,12 @@ public class BlogService(
public async Task UpdateAsync(BlogPost post, CancellationToken ct = default)
{
post.CategoryId ??= await EnsureCategoryIdAsync(0, ct);
await repository.UpdateAsync(post, ct);
memoryCache.Remove(AdminDashboardService.CacheKey);
}
public async Task<BlogPost?> UpdateAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default)
{
validator.ValidateAndThrow(dto);
dto.CategoryId = await EnsureCategoryIdAsync(dto.CategoryId, ct);
var post = await repository.GetByIdAsync(id, ct);
if (post == null)
return null;
@@ -125,18 +110,6 @@ public class BlogService(
memoryCache.Remove(AdminDashboardService.CacheKey);
}
public async Task ArchiveAsync(int id, CancellationToken ct = default)
{
await repository.ArchiveAsync(id, ct);
memoryCache.Remove(AdminDashboardService.CacheKey);
}
public async Task RestoreAsync(int id, CancellationToken ct = default)
{
await repository.RestoreAsync(id, ct);
memoryCache.Remove(AdminDashboardService.CacheKey);
}
public async Task IncrementViewCountAsync(int id, CancellationToken ct = default) =>
await repository.IncrementViewCountAsync(id, ct);
@@ -186,15 +159,6 @@ public class BlogService(
private static int NormalizePageSize(int pageSize) => Math.Clamp(pageSize, 1, 100);
private async Task<int> EnsureCategoryIdAsync(int categoryId, CancellationToken ct)
{
if (categoryId > 0)
return categoryId;
var firstCategory = (await categoryRepository.GetAllAsync(ct)).FirstOrDefault();
return firstCategory?.Id ?? throw new ValidationException("블로그 카테고리를 먼저 생성하세요.");
}
public async Task<(int TotalPosts, int PublishedPosts)> GetStatsAsync(CancellationToken ct = default)
{
var posts = (await repository.GetAllForAdminAsync(ct)).ToList();
@@ -1,12 +1,20 @@
namespace TaxBaik.Application.Services;
using FluentValidation;
using TaxBaik.Application.DTOs;
using TaxBaik.Domain.Entities;
using TaxBaik.Domain.Interfaces;
public class ClientService(IClientRepository repository, IValidator<CreateClientDto> validator)
public class ClientService(IClientRepository repository)
{
public static readonly string[] ServiceTypes =
["기장", "부동산", "증여·상속", "종합소득세", "법인세", "부가가치세", "기타"];
public static readonly string[] TaxTypes =
["개인사업자", "법인사업자", "면세사업자", "근로소득자", "기타"];
public static readonly string[] Sources =
["홈페이지 문의", "소개", "직접 방문", "카카오 채널", "블로그", "기타"];
public async Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
int page, int pageSize, string? status = null, string? search = null, CancellationToken ct = default) =>
await repository.GetPagedAsync(Math.Max(1, page), Math.Clamp(pageSize, 1, 100), status, search, ct);
@@ -25,7 +33,8 @@ public class ClientService(IClientRepository repository, IValidator<CreateClient
public async Task<int> CreateAsync(CreateClientDto dto, CancellationToken ct = default)
{
validator.ValidateAndThrow(dto);
if (string.IsNullOrWhiteSpace(dto.Name))
throw new ValidationException("고객명을 입력하세요.");
var client = new Client
{
@@ -45,7 +54,8 @@ public class ClientService(IClientRepository repository, IValidator<CreateClient
public async Task UpdateAsync(int id, CreateClientDto dto, CancellationToken ct = default)
{
validator.ValidateAndThrow(dto);
if (string.IsNullOrWhiteSpace(dto.Name))
throw new ValidationException("고객명을 입력하세요.");
var client = await repository.GetByIdAsync(id, ct)
?? throw new KeyNotFoundException($"고객 ID {id}를 찾을 수 없습니다.");
@@ -71,7 +81,7 @@ public class ClientService(IClientRepository repository, IValidator<CreateClient
Phone = phone?.Trim(),
ServiceType = serviceType,
Status = "active",
Source = "홈페이지문의"
Source = "홈페이지 문의"
};
return await repository.CreateAsync(client, ct);
}
@@ -33,9 +33,6 @@ public class ConsultingActivityService(IConsultingActivityRepository repository)
public async Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct);
public async Task<IEnumerable<ConsultingActivity>> GetAllAsync(CancellationToken ct = default) =>
await repository.GetAllAsync(ct);
public async Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken ct = default) =>
await repository.GetPendingFollowupsAsync(ct);
@@ -36,9 +36,6 @@ public class ContractService(IContractRepository repository)
public async Task<Contract?> GetByIdAsync(int id, CancellationToken ct = default) =>
await repository.GetByIdAsync(id, ct);
public async Task<IEnumerable<Contract>> GetAllAsync(CancellationToken ct = default) =>
await repository.GetAllAsync(ct);
public async Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct);
@@ -1,13 +1,12 @@
namespace TaxBaik.Application.Services;
using FluentValidation;
using TaxBaik.Domain.Entities;
using TaxBaik.Domain.Interfaces;
public class FaqService(IFaqRepository repository, IValidator<Faq> validator)
public class FaqService(IFaqRepository repository)
{
public static readonly string[] Categories =
["기장세금신고", "부동산", "증여상속", "기타"];
["기장·세금신고", "부동산", "증여·상속", "기타"];
public async Task<IEnumerable<Faq>> GetActiveAsync(CancellationToken ct = default) =>
await repository.GetActiveAsync(ct);
@@ -20,17 +19,24 @@ public class FaqService(IFaqRepository repository, IValidator<Faq> validator)
public async Task<int> CreateAsync(Faq faq, CancellationToken ct = default)
{
validator.ValidateAndThrow(faq);
Validate(faq);
return await repository.CreateAsync(faq, ct);
}
public async Task UpdateAsync(Faq faq, CancellationToken ct = default)
{
validator.ValidateAndThrow(faq);
Validate(faq);
await repository.UpdateAsync(faq, ct);
}
public async Task DeleteAsync(int id, CancellationToken ct = default) =>
await repository.DeleteAsync(id, ct);
private static void Validate(Faq faq)
{
if (string.IsNullOrWhiteSpace(faq.Question))
throw new ValidationException("질문을 입력하세요.");
if (string.IsNullOrWhiteSpace(faq.Answer))
throw new ValidationException("답변을 입력하세요.");
}
}
@@ -0,0 +1,120 @@
namespace TaxBaik.Application.Services;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Caching.Memory;
using TaxBaik.Domain.Entities;
using TaxBaik.Domain.Enums;
using TaxBaik.Domain.Interfaces;
public class InquiryService(
IInquiryRepository repository,
IInquiryNotificationService notificationService,
IMemoryCache memoryCache)
{
private static readonly Regex PhoneRegex = new(@"^01[0-9]-\d{3,4}-\d{4}$");
public async Task<int> SubmitAsync(
string name, string phone, string serviceType, string message,
string? email = null, string? ipAddress = null, bool suppressNotification = false, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(name))
throw new ValidationException("이름을 입력하세요.");
if (!PhoneRegex.IsMatch(phone))
throw new ValidationException("올바른 전화번호를 입력하세요. (예: 010-1234-5678)");
if (string.IsNullOrWhiteSpace(message))
throw new ValidationException("문의 내용을 입력하세요.");
var inquiry = new Inquiry
{
Name = name.Trim(),
Phone = phone.Trim(),
Email = string.IsNullOrWhiteSpace(email) ? null : email.Trim(),
ServiceType = serviceType ?? "기타",
Message = message.Trim(),
IpAddress = ipAddress,
Status = InquiryStatusMapper.ToStorageValue(InquiryStatus.New),
CreatedAt = DateTime.UtcNow
};
var inquiryId = await repository.CreateAsync(inquiry, ct);
if (!suppressNotification)
{
await notificationService.NotifyCreatedAsync(inquiryId, inquiry.Name, inquiry.Phone, inquiry.ServiceType, inquiry.Message, inquiry.IpAddress, inquiry.CreatedAt, ct);
}
memoryCache.Remove(AdminDashboardService.CacheKey);
return inquiryId;
}
public async Task<Inquiry?> GetByIdAsync(int id, CancellationToken ct = default) =>
await repository.GetByIdAsync(id, ct);
public async Task<(IEnumerable<Inquiry>, int)> GetPagedAsync(
int page, int pageSize, string? status = null, CancellationToken ct = default) =>
await repository.GetPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), NormalizeOptionalStatus(status), ct);
public Task<int> CountAsync(CancellationToken ct = default)
=> repository.CountAsync(ct);
public Task<int> CountThisMonthAsync(CancellationToken ct = default)
=> repository.CountThisMonthAsync(ct);
public Task<int> CountByStatusAsync(string status, CancellationToken ct = default)
=> repository.CountByStatusAsync(status, ct);
public Task<int> CountByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken ct = default)
=> repository.CountByDateRangeAsync(startDate, endDate, ct);
public Task<int> CountByStatusAndDateAsync(string status, DateTime startDate, DateTime endDate, CancellationToken ct = default)
=> repository.CountByStatusAndDateAsync(status, startDate, endDate, ct);
public async Task UpdateAdminMemoAsync(int id, string? adminMemo, CancellationToken ct = default) =>
await repository.UpdateAdminMemoAsync(id, adminMemo, ct);
public async Task LinkClientAsync(int inquiryId, int clientId, CancellationToken ct = default) =>
await repository.LinkClientAsync(inquiryId, clientId, ct);
public async Task UpdateStatusAsync(int id, string status, string? changedBy = null, CancellationToken ct = default)
{
if (!InquiryStatusMapper.TryParse(status, out var parsed))
throw new ValidationException("지원하지 않는 문의 상태입니다.");
var inquiry = await repository.GetByIdAsync(id, ct);
if (inquiry == null)
return;
var previousStatus = inquiry.Status;
var newStatus = InquiryStatusMapper.ToStorageValue(parsed);
await repository.UpdateStatusAsync(id, newStatus, ct);
await notificationService.NotifyStatusChangedAsync(id, inquiry.Name, inquiry.Phone, inquiry.ServiceType, previousStatus, newStatus, changedBy, ct);
memoryCache.Remove(AdminDashboardService.CacheKey);
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
await repository.DeleteAsync(id, ct);
memoryCache.Remove(AdminDashboardService.CacheKey);
}
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 ValidationException(string message) : base(message) { }
}
@@ -4,29 +4,22 @@ using TaxBaik.Domain.Enums;
public static class InquiryStatusMapper
{
// Status storage values (database)
public const string StatusNew = "new";
public const string StatusConsulting = "consulting";
public const string StatusContracted = "contracted";
public const string StatusRejected = "rejected";
public const string StatusClosed = "closed";
public static readonly Dictionary<string, string> Labels = new()
{
["new"] = "신규",
["new"] = "신규",
["consulting"] = "상담중",
["contracted"] = "계약완료",
["rejected"] = "거절",
["closed"] = "종결",
["rejected"] = "거절",
["closed"] = "종결",
};
public static string ToStorageValue(InquiryStatus status) => status switch
{
InquiryStatus.New => "new",
InquiryStatus.New => "new",
InquiryStatus.Consulting => "consulting",
InquiryStatus.Contracted => "contracted",
InquiryStatus.Rejected => "rejected",
InquiryStatus.Closed => "closed",
InquiryStatus.Rejected => "rejected",
InquiryStatus.Closed => "closed",
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null)
};
@@ -35,11 +28,11 @@ public static class InquiryStatusMapper
var key = value?.Trim().ToLowerInvariant();
status = key switch
{
"new" => InquiryStatus.New,
"new" => InquiryStatus.New,
"consulting" => InquiryStatus.Consulting,
"contracted" => InquiryStatus.Contracted,
"rejected" => InquiryStatus.Rejected,
"closed" => InquiryStatus.Closed,
"rejected" => InquiryStatus.Rejected,
"closed" => InquiryStatus.Closed,
_ => default
};
return key is "new" or "consulting" or "contracted" or "rejected" or "closed";
@@ -34,9 +34,6 @@ public class RevenueTrackingService(IRevenueTrackingRepository repository)
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct);
public async Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken ct = default) =>
await repository.GetAllAsync(ct);
public async Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken ct = default) =>
await repository.GetPendingPaymentsAsync(ct);
@@ -15,8 +15,7 @@ public class SeasonalMarketingService
if (today >= start && today <= end)
{
var effectiveEnd = BusinessDayCalculator.GetEffectiveBusinessDate(DateOnly.FromDateTime(end)).ToDateTime(TimeOnly.MinValue);
var days = BusinessDayCalculator.GetBusinessDayDiff(DateOnly.FromDateTime(end), DateOnly.FromDateTime(today));
var days = (end - today).Days;
return new CurrentSeasonDto
{
Key = season.Key,
@@ -28,7 +27,7 @@ public class SeasonalMarketingService
RelatedCategorySlug = season.RelatedCategorySlug,
CtaText = season.CtaText,
DaysUntilDeadline = days,
Deadline = effectiveEnd
Deadline = end
};
}
}
@@ -33,9 +33,6 @@ public class TaxFilingScheduleService(ITaxFilingScheduleRepository repository)
public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken ct = default) =>
await repository.GetByIdAsync(id, ct);
public async Task<IEnumerable<TaxFilingSchedule>> GetAllAsync(CancellationToken ct = default) =>
await repository.GetAllAsync(ct);
public async Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct);
@@ -5,14 +5,17 @@ using TaxBaik.Domain.Interfaces;
public class TaxFilingService(ITaxFilingRepository repository)
{
public static readonly string[] FilingTypes =
["부가가치세", "종합소득세", "법인세", "원천징수", "종합부동산세", "증여세", "상속세", "기타"];
public static readonly string[] Statuses =
["pending", "filed", "overdue"];
public static readonly Dictionary<string, string> StatusLabels = new()
{
["pending"] = "신고 예정",
["filed"] = "신고 완료",
["overdue"] = "기한 초과",
["pending"] = "신고 예정",
["filed"] = "신고 완료",
["overdue"] = "기한 초과",
};
public async Task<IEnumerable<TaxFiling>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
@@ -31,16 +31,10 @@ public class TaxProfileService(ITaxProfileRepository repository)
public async Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct);
public async Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken ct = default) =>
await repository.GetAllAsync(ct);
public async Task UpdateAsync(int profileId, string? businessType, string? accountingMethod,
DateTime? nextFilingDueDate, string taxRiskLevel = "normal", CancellationToken ct = default)
{
var profile = await repository.GetByIdAsync(profileId, ct);
if (profile == null)
throw new ValidationException("세무 프로필을 찾을 수 없습니다.");
var profile = new TaxProfile { Id = profileId };
if (!string.IsNullOrWhiteSpace(businessType))
profile.BusinessType = businessType.Trim();
if (!string.IsNullOrWhiteSpace(accountingMethod))
@@ -6,7 +6,6 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.0" />
<PackageReference Include="FluentValidation" Version="11.11.0" />
</ItemGroup>
<PropertyGroup>
@@ -17,7 +17,6 @@ public class BlogPost
public bool IsPublished { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public DateTime? DeletedAt { get; set; }
// Navigation property (populated via LEFT JOIN, not stored in DB)
public string? CategoryName { get; set; }
@@ -12,12 +12,8 @@ public interface IBlogPostRepository
Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default);
Task<(IEnumerable<BlogPost> Items, int Total)> GetAdminPagedAsync(
int page, int pageSize, CancellationToken cancellationToken = default);
Task<(IEnumerable<BlogPost> Items, int Total)> GetArchivedPagedAsync(
int page, int pageSize, CancellationToken cancellationToken = default);
Task<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default);
Task UpdateAsync(BlogPost post, CancellationToken cancellationToken = default);
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
Task ArchiveAsync(int id, CancellationToken cancellationToken = default);
Task RestoreAsync(int id, CancellationToken cancellationToken = default);
Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default);
}
@@ -5,7 +5,6 @@ using TaxBaik.Domain.Entities;
public interface IConsultingActivityRepository
{
Task<int> CreateAsync(ConsultingActivity activity, CancellationToken cancellationToken = default);
Task<IEnumerable<ConsultingActivity>> GetAllAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<ConsultingActivity>> GetByConsultantAsync(int consultantId, DateTime fromDate, CancellationToken cancellationToken = default);
@@ -5,7 +5,6 @@ using TaxBaik.Domain.Entities;
public interface IContractRepository
{
Task<int> CreateAsync(Contract contract, CancellationToken cancellationToken = default);
Task<IEnumerable<Contract>> GetAllAsync(CancellationToken cancellationToken = default);
Task<Contract?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task<IEnumerable<Contract>> GetActiveContractsAsync(CancellationToken cancellationToken = default);
@@ -15,7 +15,6 @@ public interface IInquiryRepository
Task<int> CountByStatusAndDateAsync(string status, DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default);
Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default);
Task UpdateAdminMemoAsync(int id, string? adminMemo, CancellationToken cancellationToken = default);
Task UpdateAsync(Inquiry inquiry, CancellationToken cancellationToken = default);
Task LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default);
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
}
@@ -5,7 +5,6 @@ using TaxBaik.Domain.Entities;
public interface IRevenueTrackingRepository
{
Task<int> CreateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default);
Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<RevenueTracking>> GetByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default);
@@ -5,7 +5,6 @@ using TaxBaik.Domain.Entities;
public interface ITaxFilingScheduleRepository
{
Task<int> CreateAsync(TaxFilingSchedule schedule, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxFilingSchedule>> GetAllAsync(CancellationToken cancellationToken = default);
Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken cancellationToken = default);
@@ -5,8 +5,6 @@ using TaxBaik.Domain.Entities;
public interface ITaxProfileRepository
{
Task<int> CreateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
Task<TaxProfile?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken cancellationToken = default);
Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task UpdateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxProfile>> GetByRiskLevelAsync(string riskLevel, CancellationToken cancellationToken = default);
@@ -0,0 +1,176 @@
using System.Reflection;
using System.Text;
using Npgsql;
using TaxBaik.Domain.Interfaces;
namespace TaxBaik.Infrastructure.Data;
public class MigrationRunner
{
private readonly string _connectionString;
private readonly IDbConnectionFactory _connectionFactory;
public MigrationRunner(string connectionString, IDbConnectionFactory connectionFactory)
{
_connectionString = connectionString;
_connectionFactory = connectionFactory;
}
public async Task RunAsync()
{
await EnsureMigrationTableAsync();
await ExecutePendingMigrationsAsync();
}
private async Task EnsureMigrationTableAsync()
{
using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(50) PRIMARY KEY,
description VARCHAR(500),
installed_on TIMESTAMPTZ DEFAULT NOW()
);";
await cmd.ExecuteNonQueryAsync();
}
private async Task ExecutePendingMigrationsAsync()
{
var executedMigrations = await GetExecutedMigrationsAsync();
var migrations = GetAvailableMigrations();
foreach (var migration in migrations.OrderBy(x => x.Version))
{
if (!executedMigrations.Contains(migration.Version))
{
await ExecuteMigrationAsync(migration);
}
}
}
private async Task<HashSet<string>> GetExecutedMigrationsAsync()
{
var executed = new HashSet<string>();
using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT version FROM schema_migrations ORDER BY version;";
using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
executed.Add(reader.GetString(0));
}
return executed;
}
private List<Migration> GetAvailableMigrations()
{
var migrations = new List<Migration>();
// Try file system first (for deployment), then embedded resources
var migrationDirs = new[]
{
"./migrations", // relative
"/home/kjh2064/taxbaik_active/migrations" // deployment
};
var migrationPath = migrationDirs.FirstOrDefault(Directory.Exists);
if (migrationPath != null && Directory.Exists(migrationPath))
{
var files = Directory.GetFiles(migrationPath, "V*.sql").OrderBy(x => x);
foreach (var file in files)
{
var fileName = Path.GetFileNameWithoutExtension(file);
if (fileName.StartsWith("V"))
{
var version = fileName.Substring(1, fileName.IndexOf('_') - 1);
var description = fileName.Substring(fileName.IndexOf('_') + 2);
var sql = File.ReadAllText(file);
migrations.Add(new Migration { Version = version, Description = description, Sql = sql });
}
}
}
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;
}
private async Task ExecuteMigrationAsync(Migration migration)
{
using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync();
try
{
using var cmd = conn.CreateCommand();
cmd.CommandText = migration.Sql;
await cmd.ExecuteNonQueryAsync();
using var insertCmd = conn.CreateCommand();
insertCmd.CommandText =
"INSERT INTO schema_migrations (version, description) VALUES (@version, @description);";
insertCmd.Parameters.AddWithValue("@version", migration.Version);
insertCmd.Parameters.AddWithValue("@description", migration.Description);
await insertCmd.ExecuteNonQueryAsync();
Console.WriteLine($"✓ Migration {migration.Version} executed");
}
catch (Npgsql.PostgresException pgEx) when (pgEx.SqlState == "42P07") // relation already exists
{
// Already executed previously; mark as done
Console.WriteLine($" Migration {migration.Version} already applied");
using var insertCmd = conn.CreateCommand();
insertCmd.CommandText =
"INSERT INTO schema_migrations (version, description) VALUES (@version, @description) ON CONFLICT (version) DO NOTHING;";
insertCmd.Parameters.AddWithValue("@version", migration.Version);
insertCmd.Parameters.AddWithValue("@description", migration.Description);
await insertCmd.ExecuteNonQueryAsync();
}
catch (Exception ex)
{
Console.WriteLine($"✗ Migration {migration.Version} failed: {ex.Message}");
throw;
}
}
private class Migration
{
public required string Version { get; set; }
public required string Description { get; set; }
public required string Sql { get; set; }
}
}
@@ -27,7 +27,6 @@ public static class DependencyInjection
services.AddScoped<IConsultingActivityRepository, ConsultingActivityRepository>();
services.AddScoped<IContractRepository, ContractRepository>();
services.AddScoped<IRevenueTrackingRepository, RevenueTrackingRepository>();
services.AddScoped<ICommonCodeRepository, CommonCodeRepository>();
return services;
}
@@ -12,10 +12,10 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
return await conn.QueryFirstOrDefaultAsync<BlogPost>(
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
FROM blog_posts bp
LEFT JOIN categories c ON bp.category_id = c.id
WHERE bp.id = @Id AND bp.deleted_at IS NULL",
WHERE bp.id = @Id",
new { Id = id });
}
@@ -25,10 +25,10 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
return await conn.QueryFirstOrDefaultAsync<BlogPost>(
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
FROM blog_posts bp
LEFT JOIN categories c ON bp.category_id = c.id
WHERE bp.slug = @Slug AND bp.is_published = TRUE AND bp.deleted_at IS NULL",
WHERE bp.slug = @Slug AND bp.is_published = TRUE",
new { Slug = slug });
}
@@ -41,15 +41,15 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
using var reader = await conn.QueryMultipleAsync(
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
FROM blog_posts bp
LEFT JOIN categories c ON bp.category_id = c.id
WHERE bp.is_published = TRUE AND bp.deleted_at IS NULL AND (@CategoryId::int IS NULL OR bp.category_id = @CategoryId)
WHERE bp.is_published = TRUE AND (@CategoryId::int IS NULL OR bp.category_id = @CategoryId)
ORDER BY bp.published_at DESC
LIMIT @PageSize OFFSET @Offset;
SELECT COUNT(*) FROM blog_posts
WHERE is_published = TRUE AND deleted_at IS NULL AND (@CategoryId::int IS NULL OR category_id = @CategoryId);",
WHERE is_published = TRUE AND (@CategoryId::int IS NULL OR category_id = @CategoryId);",
new { CategoryId = categoryId, PageSize = pageSize, Offset = offset });
var items = (await reader.ReadAsync<BlogPost>()).ToList();
@@ -64,10 +64,10 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
return await conn.QueryAsync<BlogPost>(
@"SELECT bp.id, bp.title, bp.slug, bp.category_id, bp.tags,
bp.published_at, bp.view_count, bp.seo_description, bp.thumbnail_url,
bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
FROM blog_posts bp
LEFT JOIN categories c ON bp.category_id = c.id
WHERE bp.is_published = TRUE AND bp.deleted_at IS NULL AND c.slug = @CategorySlug
WHERE bp.is_published = TRUE AND c.slug = @CategorySlug
ORDER BY bp.published_at DESC
LIMIT @Limit",
new { CategorySlug = categorySlug, Limit = limit });
@@ -82,7 +82,6 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
FROM blog_posts bp
LEFT JOIN categories c ON bp.category_id = c.id
WHERE bp.deleted_at IS NULL
ORDER BY bp.created_at DESC");
}
@@ -95,14 +94,13 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
using var reader = await conn.QueryMultipleAsync(
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
FROM blog_posts bp
LEFT JOIN categories c ON bp.category_id = c.id
WHERE bp.deleted_at IS NULL
ORDER BY bp.created_at DESC
LIMIT @PageSize OFFSET @Offset;
SELECT COUNT(*) FROM blog_posts WHERE deleted_at IS NULL;",
SELECT COUNT(*) FROM blog_posts;",
new { PageSize = pageSize, Offset = offset });
var items = (await reader.ReadAsync<BlogPost>()).ToList();
@@ -111,30 +109,6 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
return (items, total);
}
public async Task<(IEnumerable<BlogPost> Items, int Total)> GetArchivedPagedAsync(
int page, int pageSize, CancellationToken cancellationToken = default)
{
using var conn = Conn();
var offset = (page - 1) * pageSize;
using var reader = await conn.QueryMultipleAsync(
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
FROM blog_posts bp
LEFT JOIN categories c ON bp.category_id = c.id
WHERE bp.deleted_at IS NOT NULL
ORDER BY bp.deleted_at DESC
LIMIT @PageSize OFFSET @Offset;
SELECT COUNT(*) FROM blog_posts WHERE deleted_at IS NOT NULL;",
new { PageSize = pageSize, Offset = offset });
var items = (await reader.ReadAsync<BlogPost>()).ToList();
var total = await reader.ReadFirstAsync<int>();
return (items, total);
}
public async Task<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default)
{
using var conn = Conn();
@@ -156,34 +130,19 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
tags = @Tags, author_id = @AuthorId, published_at = @PublishedAt,
seo_title = @SeoTitle, seo_description = @SeoDescription,
thumbnail_url = @ThumbnailUrl, is_published = @IsPublished, updated_at = NOW()
WHERE id = @Id AND deleted_at IS NULL",
WHERE id = @Id",
post);
}
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
{
await ArchiveAsync(id, cancellationToken);
}
public async Task ArchiveAsync(int id, CancellationToken cancellationToken = default)
{
using var conn = Conn();
await conn.ExecuteAsync(
"UPDATE blog_posts SET deleted_at = NOW(), updated_at = NOW() WHERE id = @Id AND deleted_at IS NULL",
new { Id = id });
}
public async Task RestoreAsync(int id, CancellationToken cancellationToken = default)
{
using var conn = Conn();
await conn.ExecuteAsync(
"UPDATE blog_posts SET deleted_at = NULL, updated_at = NOW() WHERE id = @Id AND deleted_at IS NOT NULL",
new { Id = id });
await conn.ExecuteAsync("DELETE FROM blog_posts WHERE id = @Id", new { Id = id });
}
public async Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default)
{
using var conn = Conn();
await conn.ExecuteAsync("UPDATE blog_posts SET view_count = view_count + 1 WHERE id = @Id AND deleted_at IS NULL", new { Id = id });
await conn.ExecuteAsync("UPDATE blog_posts SET view_count = view_count + 1 WHERE id = @Id", new { Id = id });
}
}

Some files were not shown because too many files have changed in this diff Show More