Compare commits
2 Commits
master
..
85dbd34a51
| Author | SHA1 | Date | |
|---|---|---|---|
| 85dbd34a51 | |||
| c82ba73327 |
@@ -49,13 +49,12 @@ jobs:
|
||||
# Suppress stderr and allow failures to handle transition/down periods cleanly
|
||||
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)"
|
||||
LOGIN_STATUS="$(curl -s -o /dev/null -w '%{http_code}' "http://${DEPLOY_HOST}/taxbaik/admin/login" || true)"
|
||||
if echo "$VERSION_BODY" | grep -q "\"version\": \"${SHORT_VERSION}\"" && [ "$BLOG_STATUS" = "200" ] && [ "$LOGIN_STATUS" = "200" ]; then
|
||||
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
|
||||
@@ -73,23 +72,6 @@ jobs:
|
||||
echo "Running E2E tests on Desktop Chrome (production verification)"
|
||||
npx playwright test --project="Desktop Chrome" --reporter=html --reporter=list
|
||||
|
||||
- name: API smoke verification
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
E2E_ADMIN_USERNAME: test_admin
|
||||
E2E_ADMIN_PASSWORD: TestAdmin@123456
|
||||
run: |
|
||||
set -e
|
||||
TOKEN="$(curl -s -X POST "http://${DEPLOY_HOST}/taxbaik/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" "http://${DEPLOY_HOST}/taxbaik/api/blog/admin?page=1&pageSize=1" >/dev/null
|
||||
curl -fsS -H "Authorization: Bearer $TOKEN" "http://${DEPLOY_HOST}/taxbaik/api/faq" >/dev/null
|
||||
curl -fsS -H "Authorization: Bearer $TOKEN" "http://${DEPLOY_HOST}/taxbaik/api/announcement" >/dev/null
|
||||
curl -fsS -H "Authorization: Bearer $TOKEN" "http://${DEPLOY_HOST}/taxbaik/api/inquiry?page=1&pageSize=1" >/dev/null
|
||||
curl -fsS "http://${DEPLOY_HOST}/taxbaik/favicon.svg" >/dev/null
|
||||
curl -fsS "http://${DEPLOY_HOST}/taxbaik/favicon.ico" >/dev/null
|
||||
curl -fsS "http://${DEPLOY_HOST}/taxbaik/robots.txt" >/dev/null
|
||||
|
||||
- name: Browser E2E summary
|
||||
if: always()
|
||||
run: |
|
||||
|
||||
+25
-192
@@ -20,47 +20,18 @@ jobs:
|
||||
dotnet-version: '10.0'
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore src/TaxBaik.sln
|
||||
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 (auto-includes WASM from referenced TaxBaik.Web.Client)
|
||||
run: |
|
||||
set -e
|
||||
mkdir -p ./publish-logs
|
||||
start=$(date +%s)
|
||||
dotnet publish src/TaxBaik.Web/ \
|
||||
-c Release \
|
||||
-o ./publish \
|
||||
--no-restore \
|
||||
-p:SelfContained=false \
|
||||
-p:PublishReadyToRun=false \
|
||||
-p:PerformanceSummary=true \
|
||||
-clp:Summary \
|
||||
-bl:./publish-logs/publish-web.binlog
|
||||
end=$(date +%s)
|
||||
echo "✓ Publish Web elapsed: $((end - start))s"
|
||||
ls -lh ./publish-logs/publish-web.binlog
|
||||
|
||||
- name: Publish Proxy
|
||||
run: |
|
||||
set -e
|
||||
mkdir -p ./publish-logs
|
||||
start=$(date +%s)
|
||||
dotnet publish src/TaxBaik.Proxy/ \
|
||||
-c Release \
|
||||
-o ./publish/proxy \
|
||||
-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: Publish Web
|
||||
run: dotnet publish TaxBaik.Web/ -c Release -o ./publish --no-restore
|
||||
|
||||
- name: Write production secrets
|
||||
run: |
|
||||
@@ -96,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"
|
||||
@@ -141,17 +101,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 }}"
|
||||
@@ -193,12 +149,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"
|
||||
@@ -208,111 +163,40 @@ 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 "--- [4/5] Green-Blue 배포 실행 ---"
|
||||
echo "--- [3/4] 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 "❌ FATAL: sites-enabled/ 안에서 'location /taxbaik'를 정의한 파일을 찾을 수 없음" >&2
|
||||
echo " sites-available/에 파일을 수정해도 sites-enabled에 심볼릭 링크되어 있지 않으면 반영되지 않는다." >&2
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
|
||||
echo "✓ Nginx 설정 검증 통과 (실제 로드 파일 확인 + 포트 5001 고정 + trailing slash 없음)"
|
||||
|
||||
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/taxbaik/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/taxbaik/ 2>/dev/null || echo "000")
|
||||
if ! printf '%s' "\$MAIN_STATUS" | grep -Eq '^(200|301|302|307|308)$'; then
|
||||
echo "❌ 메인 페이지 로드 실패 (상태: \$MAIN_STATUS)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ [2/6] 메인 페이지 로드 완료"
|
||||
|
||||
# 검증 2: CSS 파일 로드
|
||||
# 검증 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/taxbaik/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/taxbaik/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] 관리자 페이지 로드 완료"
|
||||
echo "✓ [4/4] 관리자 페이지 로드 완료"
|
||||
|
||||
echo "✓ 서비스 정상 (시도 \$i/\$ATTEMPTS)"
|
||||
# 구 배포 디렉토리 정리 (최근 5개 보존)
|
||||
@@ -322,19 +206,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)"
|
||||
@@ -343,48 +218,6 @@ jobs:
|
||||
REMOTE
|
||||
|
||||
echo "✓ 배포 완료: taxbaik_${TIMESTAMP} @ $DEPLOY_HOST"
|
||||
|
||||
# 내부 127.0.0.1:5001 헬스 체크는 Nginx/Cloudflare를 거치지 않으므로
|
||||
# Nginx 설정 오류(잘못된 파일 수정, 죽은 포트 하드코딩 등)를 잡지 못한다.
|
||||
# 실제 사용자가 접속하는 경로 그대로 외부에서 검증해야 이런 장애를 CI가 스스로 잡는다.
|
||||
check_public() {
|
||||
local url="$1"
|
||||
local allow_redirect="${2:-0}"
|
||||
local status
|
||||
status=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$url" || echo "000")
|
||||
if [ "$allow_redirect" = "1" ]; then
|
||||
if ! printf '%s' "$status" | grep -Eq '^(200|301|302|303|307|308)$'; then
|
||||
echo " ✗ $url → HTTP $status" >&2
|
||||
return 1
|
||||
fi
|
||||
elif [ "$status" != "200" ]; then
|
||||
echo " ✗ $url → HTTP $status" >&2
|
||||
return 1
|
||||
fi
|
||||
echo " ✓ $url → HTTP $status"
|
||||
return 0
|
||||
}
|
||||
|
||||
echo "--- 실제 공개 도메인 종단 간 검증 (Nginx/Cloudflare 경유, 최대 3회 재시도) ---"
|
||||
PUBLIC_OK=false
|
||||
for i in 1 2 3; do
|
||||
if check_public "https://www.taxbaik.com/" 1 \
|
||||
&& check_public "https://www.taxbaik.com/taxbaik/" 1 \
|
||||
&& check_public "https://www.taxbaik.com/taxbaik/admin/login"; then
|
||||
PUBLIC_OK=true
|
||||
break
|
||||
fi
|
||||
echo " 재시도 대기 중... ($i/3)"
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if [ "$PUBLIC_OK" != "true" ]; then
|
||||
echo "❌ FATAL: 실제 공개 도메인 검증 실패. Nginx가 죽은 포트를 가리키거나 잘못된 파일을 수정했을 가능성이 높다." >&2
|
||||
echo " 확인: sites-enabled/의 실제 파일에서 location / 와 location /taxbaik 모두 127.0.0.1:5001을 가리키는지 점검" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ 실제 공개 도메인 전체 정상"
|
||||
|
||||
send_telegram "✅ <b>TaxBaik 배포 완료</b>
|
||||
|
||||
커밋: <code>${COMMIT}</code>
|
||||
|
||||
@@ -60,6 +60,3 @@ PublishProfiles/
|
||||
.env
|
||||
.env.local
|
||||
appsettings.Development.json
|
||||
|
||||
# Scratch / temporary work - never commit, see docs/ENGINEERING_HARNESS.md
|
||||
.scratch/
|
||||
|
||||
@@ -1,221 +1,4 @@
|
||||
# CLAUDE.md — TaxBaik 운영 메모
|
||||
|
||||
## 우선 기준
|
||||
|
||||
1. [docs/INDEX.md](./docs/INDEX.md)
|
||||
2. [docs/ENGINEERING_HARNESS.md](./docs/ENGINEERING_HARNESS.md)
|
||||
3. [docs/DOUZONE_UX_GUIDE.md](./docs/DOUZONE_UX_GUIDE.md)
|
||||
4. [docs/COMMON_CODE_POLICY.md](./docs/COMMON_CODE_POLICY.md)
|
||||
5. [docs/COMBO_POLICY.md](./docs/COMBO_POLICY.md)
|
||||
|
||||
이 파일은 실행 절차, 서버 메모, 과거 이력만 둔다. 아키텍처/UX/콤보 기준은 위 문서를 따른다.
|
||||
|
||||
## 🎯 **개발 핵심 지침 (Development Standards 2026-07)**
|
||||
|
||||
### 입력 검증 패턴 (Validation Pattern)
|
||||
|
||||
**원칙: 클라이언트 + 서버 이중 검증**
|
||||
|
||||
#### 1. 클라이언트 (프론트엔드)
|
||||
- ✅ 실시간 마스킹 (사용자 입력하면서 자동 포맷팅)
|
||||
- ✅ 실시간 피드백 (에러 메시지 즉시 표시)
|
||||
- ✅ 유효성 검사 후 제출 차단
|
||||
- ✅ 문자 카운터 (예: "현재: 50/5000")
|
||||
|
||||
**예시: 전화번호**
|
||||
```javascript
|
||||
// 입력: 01012345678 → 표시: 010-1234-5678
|
||||
// 정규식: ^(0(2|3[1-3]|4[1-4]|...|70|50[5-9])\d{7,8}|0\d{9,10})$
|
||||
```
|
||||
|
||||
#### 2. 서버 (백엔드)
|
||||
- ✅ DTO 어노테이션 (DataAnnotations)
|
||||
- ✅ 서비스 로직 검증 (명확한 에러 메시지)
|
||||
- ✅ 데이터베이스 제약 조건
|
||||
|
||||
**패턴: InquiryService.cs**
|
||||
```csharp
|
||||
// 1. DTO 레벨
|
||||
public class SubmitInquiryDto
|
||||
{
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string Name { get; set; }
|
||||
|
||||
[Required]
|
||||
[RegularExpression(@"^(0(2|3[1-3]|...")]
|
||||
public string Phone { get; set; }
|
||||
|
||||
[StringLength(5000, MinimumLength = 10)]
|
||||
public string Message { get; set; }
|
||||
}
|
||||
|
||||
// 2. 서비스 로직
|
||||
public async Task<int> SubmitAsync(...)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
throw new ValidationException("문의 내용을 입력하세요.");
|
||||
|
||||
var trimmedMessage = message.Trim();
|
||||
if (trimmedMessage.Length < 10)
|
||||
throw new ValidationException("문의 내용은 최소 10자 이상이어야 합니다.");
|
||||
}
|
||||
```
|
||||
|
||||
### 한국 전화번호 처리 표준
|
||||
|
||||
**지원 형식:**
|
||||
- 고정전화: `02-123-4567`, `031-1234-5678`
|
||||
- 휴대폰: `010-1234-5678`, `011-1234-5678`
|
||||
- VoIP: `070-1234-5678`, `0505-1234-5678`
|
||||
|
||||
**포맷팅 규칙:**
|
||||
- 2-3자리 국번 + 3-4자리 국번 뒤 + 4자리 번호
|
||||
- 고정전화(10자): `XXXX-XXX-XXXX` (4-3-3)
|
||||
- 휴대폰(11자): `XXX-XXXX-XXXX` (3-4-4)
|
||||
|
||||
**정규식:**
|
||||
```csharp
|
||||
private static readonly Regex PhoneRegex = new(
|
||||
@"^(0(?:2|3[1-3]|4[1-4]|5[1-5]|6[1-4]|70|50[5-9]|[7-9](?:\d{1,2})?)\d{7,8}|0\d{9,10})$");
|
||||
```
|
||||
|
||||
### 메시지 내용 길이 제한 표준
|
||||
|
||||
**규칙:**
|
||||
- 최소: 10자 (너무 짧은 내용 방지)
|
||||
- 최대: 5000자 (DB 및 성능)
|
||||
|
||||
**적용:**
|
||||
- 프론트엔드: `<textarea maxlength="5000">`, 실시간 카운터
|
||||
- 백엔드: `[StringLength(5000, MinimumLength = 10)]`
|
||||
- 서비스: 길이 검증 + 명확한 에러 메시지
|
||||
|
||||
### DTO 및 데이터 어노테이션 규칙
|
||||
|
||||
**필수 어노테이션:**
|
||||
```csharp
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
public class SubmitInquiryDto
|
||||
{
|
||||
[Required(ErrorMessage = "이름을 입력하세요.")]
|
||||
[StringLength(100, ErrorMessage = "이름은 최대 100자입니다.")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[Required]
|
||||
[RegularExpression(pattern, ErrorMessage = "올바른 형식이 아닙니다.")]
|
||||
public string Phone { get; set; }
|
||||
|
||||
[StringLength(5000, MinimumLength = 10)]
|
||||
public string Message { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**원칙:**
|
||||
- DTO는 기본 데이터 검증만 담당 (필수, 길이, 형식)
|
||||
- 복잡한 검증은 서비스 로직에서 처리
|
||||
- 모든 에러 메시지는 사용자 친화적
|
||||
|
||||
### DataAnnotations vs FluentValidation 선택 기준
|
||||
|
||||
**DataAnnotations 사용 (현재 기본)**
|
||||
|
||||
언제 사용:
|
||||
- 필수/선택, 길이, 정규식 등 기본 검증
|
||||
- 대부분의 DTO 검증
|
||||
|
||||
장점:
|
||||
- 프레임워크 내장 (추가 패키지 불필요)
|
||||
- 간단하고 빠름
|
||||
- DTO에서 한눈에 볼 수 있음
|
||||
|
||||
```csharp
|
||||
public class SubmitInquiryDto
|
||||
{
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string Name { get; set; }
|
||||
|
||||
[Required]
|
||||
[RegularExpression(@"^01[0-9]\d{7,8}$")]
|
||||
public string Phone { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**FluentValidation 사용 (필요시 도입)**
|
||||
|
||||
언제 사용:
|
||||
- 조건부 검증 (필드 A가 있으면 필드 B는 필수)
|
||||
- 데이터베이스 조회 필요한 검증 (중복 체크)
|
||||
- 복잡한 비즈니스 규칙 검증
|
||||
- 여러 필드 간 관계 검증
|
||||
|
||||
도입 절차:
|
||||
1. `FluentValidation` NuGet 패키지 추가
|
||||
2. `AbstractValidator<DTO>` 상속한 검증기 클래스 생성
|
||||
3. `Program.cs`에 등록
|
||||
4. 서비스 또는 엔드포인트에서 검증기 주입
|
||||
|
||||
```csharp
|
||||
public class SubmitInquiryValidator : AbstractValidator<SubmitInquiryDto>
|
||||
{
|
||||
public SubmitInquiryValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty().WithMessage("이름을 입력하세요.")
|
||||
.MaximumLength(100);
|
||||
|
||||
RuleFor(x => x.Phone)
|
||||
.NotEmpty()
|
||||
.Matches(@"^01[0-9]\d{7,8}$").WithMessage("올바른 전화번호");
|
||||
|
||||
// 복잡한 검증
|
||||
RuleFor(x => x.ServiceType)
|
||||
.NotEmpty()
|
||||
.When(x => x.Name.Contains("법인")).WithMessage("법인은 상담분야 필수");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**결정 규칙:**
|
||||
- ✅ **기본 (지금)**: DataAnnotations
|
||||
- ⏳ **필요시 (향후)**: FluentValidation 도입
|
||||
- ❌ **혼용 금지**: 같은 DTO에 두 방식 섞지 않기
|
||||
|
||||
### Telegram 알림 통합 패턴
|
||||
|
||||
**구현 단계:**
|
||||
1. DTO에 검증 어노테이션 추가
|
||||
2. 서비스에서 비즈니스 로직 검증
|
||||
3. 데이터 저장 후 비동기 알림 발송
|
||||
4. 알림 실패해도 저장 데이터는 유지
|
||||
|
||||
**예시: Contact.cshtml.cs**
|
||||
```csharp
|
||||
await _inquiryService.SubmitAsync(...); // await로 완료 대기
|
||||
// 서비스 내부에서 TelegramInquiryNotificationService 호출
|
||||
```
|
||||
|
||||
### 현장 검증 체크리스트
|
||||
|
||||
새 폼/페이지 추가 시:
|
||||
|
||||
- [ ] 클라이언트 검증: 실시간 마스킹 & 피드백
|
||||
- [ ] DTO: DataAnnotations 어노테이션 완성
|
||||
- [ ] 서버 검증: 서비스에 명확한 메시지 추가
|
||||
- [ ] 길이 제한: 적절한 Min/Max 설정
|
||||
- [ ] Telegram 알림: 서비스에 통지 로직 추가
|
||||
- [ ] 테스트: 유효/무효 데이터 모두 테스트
|
||||
- [ ] 로그: 제출 성공/실패 모두 기록
|
||||
|
||||
---
|
||||
|
||||
## Gitea Token Rule
|
||||
|
||||
- `GITEA_TOKEN_TAXBAIK`만 사용한다.
|
||||
- `GITEA_TOKEN`은 사용하지 않는다.
|
||||
- dispatch 전에는 `GET /api/v1/user`로 토큰 유효성을 먼저 확인한다.
|
||||
# CLAUDE.md — TaxBaik 개발 지침
|
||||
|
||||
## 🏗️ **아키텍처 리팩토링 (API-First 전환)**
|
||||
|
||||
@@ -289,102 +72,13 @@ _refreshTokenExpirationMinutes = 10080;
|
||||
- [x] 공개 콘텐츠 & 기본 관리 (Clients, TaxFilings, FAQs, Announcements)
|
||||
- [x] CRM & 세무관리 (TaxProfile, TaxFilingSchedule, Contract, ConsultingActivity, RevenueTracking)
|
||||
|
||||
**완료**: 2026-06-28 / 모든 도메인 API-First 마이그레이션 완료
|
||||
|
||||
#### Phase 8: WebAssembly 렌더 모드 전환 ✅ (2026-07-03)
|
||||
- [x] InteractiveWebAssemblyRenderMode 적용 (Blazor Server → WebAssembly)
|
||||
- [x] Admin 컴포넌트 WebAssembly 클라이언트 전환
|
||||
- [x] 서버 상태 관리 제거 (Circuit 불필요)
|
||||
- [x] 클라이언트-서버 완전 분리
|
||||
- [x] E2E 테스트 검증 (20/20 통과 - 프로덕션)
|
||||
|
||||
**구현 상세**:
|
||||
```csharp
|
||||
// Program.cs - Admin UI 렌더 모드
|
||||
app.MapRazorComponents<TaxBaik.WasmClient.Components.Admin.App>()
|
||||
.AddInteractiveWebAssemblyRenderMode()
|
||||
.AddAdditionalAssemblies(typeof(TaxBaik.WasmClient._Imports).Assembly) // ⭐ 필수!
|
||||
.AllowAnonymous();
|
||||
```
|
||||
|
||||
**⚠️ 중요: AddAdditionalAssemblies 필수 이유**:
|
||||
- Root 컴포넌트(App.razor)만으로는 모든 WASM 컴포넌트를 탐색할 수 없음
|
||||
- Routes.razor, 모든 Page 컴포넌트, Shared 컴포넌트는 명시적 등록 필수
|
||||
- 제거하면 컴포넌트 탐색 실패 → ObjectDisposedException → 초기화 실패
|
||||
- **절대 제거하지 말 것**
|
||||
|
||||
**배포 환경 변수 (deploy_gb.sh)**:
|
||||
```bash
|
||||
# ✅ 반드시 설정해야 함
|
||||
export ASPNETCORE_ENVIRONMENT=Production
|
||||
export ASPNETCORE_URLS="http://127.0.0.1:$TARGET_PORT"
|
||||
export ConnectionStrings__Default="Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=<실제비밀>"
|
||||
export DOTNET_PRINT_TELEMETRY_MESSAGE=false
|
||||
|
||||
# ❌ 누락하면 배포 실패 (Missing connection string)
|
||||
```
|
||||
|
||||
**효과**:
|
||||
- ✅ 무상태 서버 (stateless)
|
||||
- ✅ 클라이언트 사이드 렌더링 (CSR)
|
||||
- ✅ 서버 부하 0 (Circuit 메모리 해제)
|
||||
- ✅ 동시 접속 무제한 (확장성 ∞)
|
||||
- ✅ Green-Blue 무중단 배포 검증됨
|
||||
- ✅ E2E 테스트로 모든 페이지 검증됨
|
||||
- ✅ ERP 프로젝트 아키텍처 준비 완료
|
||||
|
||||
**완료**: 2026-07-03 / WebAssembly 기반 아키텍처 확정 + 프로덕션 검증
|
||||
|
||||
**⚠️ Phase 8 알려진 한계 (Phase 9에서 수정됨)**:
|
||||
Phase 8에서는 `<Routes>`(App.razor)와 `<Router>`(Routes.razor)에 전역 `@rendermode`를 지정해 `prerender: false`로 고정했다. 그 결과 로그인 화면을 포함한 모든 어드민 페이지가 WASM 다운로드 완료 전까지 빈 화면/스피너만 보여주는 문제가 있었다(`scripts/validate_admin_render.sh`에 이 트레이드오프가 "기능 우선, 흰 화면 0.5~2초 감수"로 기록되어 있었음). 이는 `docs/ENGINEERING_HARNESS.md`의 "로그인 화면은 예외적으로 서버 프리렌더 허용" 규칙을 충족하지 못한 상태였다. Phase 9에서 페이지별 개별 렌더모드 지정으로 교체했다.
|
||||
|
||||
#### Phase 9: 어드민 페이지별 렌더모드 정상화 ✅ (2026-07-03)
|
||||
- [x] `App.razor`/`Routes.razor`에서 전역 `@rendermode` 제거 (Router/Routes 자체는 렌더모드를 강제하지 않음)
|
||||
- [x] `Login.razor`만 `@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: true))`로 명시 → 로그인 폼이 최초 HTML 응답에 정적으로 포함되어 WASM 다운로드 중에도 즉시 표시됨
|
||||
- [x] 나머지 `[Authorize]` 어드민 페이지는 `@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))`로 명시 유지 → 인증 컨텍스트 없이 prerender될 때 `AuthorizeRouteView`가 빈 화면을 그리는 문제(Phase 8 초기에 겪었던 문제) 재발 방지
|
||||
- [x] WASM 부팅 완료 전 로그인 버튼은 "준비 중" 비활성 상태로 표시, 부팅 완료 시 정상 상태로 전환(업데이트 스플래시)
|
||||
|
||||
**핵심 원칙**: Blazor Web App은 "전역 렌더모드" 또는 "페이지별 렌더모드" 중 하나만 선택할 수 있다. Router/Routes에 렌더모드를 지정하면 그 하위 모든 페이지의 개별 `@rendermode` 지시자는 무시된다. 로그인만 예외적으로 prerender가 필요하므로 전역 방식을 버리고 페이지별 방식으로 전환했다.
|
||||
|
||||
**완료**: 2026-07-03 / 로그인 흰 화면 제거 + 인증 페이지 안정성 유지
|
||||
|
||||
#### Phase 13: FastEndpoints 마이그레이션 ✅ (2026-07-03)
|
||||
- [x] AdminDashboardController → FastEndpoints 마이그레이션
|
||||
- GetSummaryEndpoint.cs (GET /api/admin-dashboard/summary)
|
||||
- GetUpcomingFilingsEndpoint.cs (GET /api/admin-dashboard/upcoming-filings)
|
||||
- GetRecentInquiriesEndpoint.cs (GET /api/admin-dashboard/recent-inquiries)
|
||||
- GetMonthlyStatsEndpoint.cs (GET /api/admin-dashboard/monthly-stats)
|
||||
- [x] AdminDashboardDtos.cs (요청/응답 DTO 정의)
|
||||
- [x] 기존 AdminDashboardController.cs 제거
|
||||
- [x] AdminDashboardClient와 호환성 유지 (엔드포인트 경로 동일)
|
||||
- [x] FastEndpoints 자동 등록 (Program.cs AddFastEndpoints 활용)
|
||||
|
||||
**이점**:
|
||||
- 컨트롤러 기반에서 FastEndpoints 기반으로 일관성 강화
|
||||
- 모든 API 엔드포인트가 FastEndpoints로 통일됨
|
||||
- 더 간결한 엔드포인트 구조 (try-catch 불필요)
|
||||
- 자동 매핑 및 검증
|
||||
|
||||
**완료**: 2026-07-03 / AdminDashboard 엔드포인트 FastEndpoints 마이그레이션 완료
|
||||
|
||||
**보류된 결정 (2026-07-03, 향후 별도 Phase)**:
|
||||
- 공개 홈페이지 Razor Pages → MVC(Controller+View) 전면 재작성: 기능적 이득 없이 운영 중인 SEO 트래픽 페이지 전체를 기계적으로 재작성하는 고비용 작업이라 이번엔 보류. 필요 시 Phase 10으로 별도 진행.
|
||||
- 포털(고객용, `Pages/Portal/*`, 현재 Razor Pages + 쿠키/OAuth) → 어드민과 동일한 MudBlazor+WASM 전환: 완전히 새로운 프로젝트 구조가 필요해 이번 범위에서 제외. 필요 시 Phase 11로 별도 진행.
|
||||
|
||||
**현재 상태**: **✅ Phase 1-9, Phase 13 COMPLETE & VERIFIED (2026-07-03)**
|
||||
- ✅ 모든 API 엔드포인트 구현됨
|
||||
- ✅ 모든 Browser Client 구현됨
|
||||
- ✅ 16개 Blazor 페이지 API-First 마이그레이션 완료
|
||||
- ✅ MudDataGrid 더존 세무회계프로그램 UX 수준 적용
|
||||
- ✅ MudDialog 모달 패턴 (흰 화면 플래시 제거)
|
||||
- ✅ ConfirmDialog 삭제 확인 컴포넌트
|
||||
- ✅ **WebAssembly 렌더 모드 완전 적용** (Admin UI 클라이언트 사이드)
|
||||
- ✅ **E2E 테스트 검증 완료** (20/20 테스트 통과 - 프로덕션 환경)
|
||||
- Desktop Chrome: 5/5
|
||||
- iPhone 12: 5/5
|
||||
- iPad Pro: 5/5
|
||||
- Galaxy S9+: 5/5
|
||||
- ✅ 배포 스크립트 환경 변수 강화
|
||||
**현재 상태**: **✅ Phase 1-7 COMPLETE (2026-06-28)**
|
||||
- 모든 API 엔드포인트 구현됨
|
||||
- 모든 Browser Client 구현됨
|
||||
- 16개 Blazor 페이지 API-First 마이그레이션 완료
|
||||
- MudDataGrid Douzone ERP 수준 UX 적용
|
||||
- MudDialog 모달 패턴 (흰 화면 플래시 제거)
|
||||
- ConfirmDialog 삭제 확인 컴포넌트
|
||||
|
||||
---
|
||||
|
||||
@@ -425,7 +119,7 @@ Phase 8에서는 `<Routes>`(App.razor)와 `<Router>`(Routes.razor)에 전역 `@r
|
||||
- 5개 API Controller (TaxProfile, TaxFilingSchedule, ConsultingActivity, Contract, RevenueTracking)
|
||||
- 5개 Browser Client (API-First 패턴)
|
||||
- 5개 Blazor 페이지 (MudDataGrid Dense, Virtualize, Modal Dialog)
|
||||
- 더존 세무회계프로그램 수준의 그리드 UX (32px 행 높이, 데이터 밀도 최적화)
|
||||
- Douzone ERP 수준의 그리드 UX (32px 행 높이, 데이터 밀도 최적화)
|
||||
|
||||
| 페이지 | API | Client | Blazor | 핵심 기능 |
|
||||
|------|---|---|---|---------|
|
||||
@@ -450,42 +144,27 @@ Phase 8에서는 `<Routes>`(App.razor)와 `<Router>`(Routes.razor)에 전역 `@r
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ **최종 아키텍처 (Phase 8: WebAssembly)**
|
||||
## 🏗️ **최종 아키텍처**
|
||||
|
||||
```
|
||||
🌐 브라우저 (클라이언트)
|
||||
↓ (WebAssembly 런타임)
|
||||
Admin Pages (CSR - 클라이언트 사이드 렌더링)
|
||||
Blazor Pages (UI 계층)
|
||||
↓ (Browser Client 주입)
|
||||
IXxxBrowserClient 추상화 (HttpClient 기반)
|
||||
↓ (HTTP/REST API)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
🖥️ 서버 (ASP.NET Core 10 - 무상태/Stateless)
|
||||
IXxxBrowserClient 추상화 (클라이언트 계층)
|
||||
↓ (HTTP)
|
||||
API Controllers (애플리케이션 계층)
|
||||
↓ (서비스 호출)
|
||||
Services (비즈니스 로직)
|
||||
↓ (저장소 호출)
|
||||
Repositories (데이터 계층)
|
||||
↓ (SQL/Dapper)
|
||||
🗄️ PostgreSQL 18
|
||||
↓ (SQL)
|
||||
PostgreSQL Database
|
||||
```
|
||||
|
||||
**WebAssembly 렌더 모드 (Phase 8)**:
|
||||
- Admin UI는 **클라이언트 사이드에서 완전 렌더링** (WebAssembly)
|
||||
- 서버는 **순수 API 역할** (Circuit 메모리 0)
|
||||
- 모든 비즈니스 로직은 서버 API에만 존재
|
||||
- 클라이언트는 API 호출 + 상태 관리만 담당
|
||||
|
||||
**API-First 데이터 패턴**:
|
||||
- Blazor Server의 자동 연결/Circuit 미사용
|
||||
- 사용자 액션 후 필요한 데이터는 API로 조회
|
||||
- 데이터 변경 broadcast/push 금지
|
||||
- 각 도메인 CRUD는 REST API 엔드포인트만 사용
|
||||
|
||||
**확장성 (ERP 대비)**:
|
||||
- 서버 메모리: Circuit 해제로 무제한 확장 가능
|
||||
- 동시 접속: Stateless 아키텍처로 수평 확장
|
||||
- WebAssembly 클라이언트: 독립적 배포 가능 (향후 WASM-only 앱 지원)
|
||||
**Lite Blazor 데이터 갱신**:
|
||||
- Blazor Server 자동 연결은 컴포넌트 상호작용용 기본 회선으로만 사용한다.
|
||||
- 데이터 변경 알림용 별도 Hub, 그룹, broadcast, client subscription을 추가하지 않는다.
|
||||
- 저장/삭제/완료 같은 사용자 액션 이후 필요한 목록만 API로 다시 조회한다.
|
||||
- 공지사항, 문의, 고객, 신고 등 도메인 CRUD 기능은 그대로 유지한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -517,25 +196,10 @@ Repositories (데이터 계층)
|
||||
- [x] 클라이언트 링크 (상세 페이지 연동)
|
||||
- [x] D-day 추적, MRR 계산, 팔로업 자동 추적
|
||||
|
||||
**WebAssembly 렌더 모드 (Phase 8 - 2026-07-03)**:
|
||||
- [x] InteractiveWebAssemblyRenderMode 적용
|
||||
- [x] Admin 컴포넌트 클라이언트 사이드 렌더링
|
||||
- [x] 서버 Circuit 메모리 완전 해제
|
||||
- [x] Stateless 아키텍처 확정
|
||||
- [x] ERP 프로젝트 아키텍처 준비
|
||||
|
||||
**FastEndpoints 마이그레이션 (Phase 13 - 2026-07-03)**:
|
||||
- [x] AdminDashboardController → FastEndpoints 4개 엔드포인트
|
||||
- [x] AdminDashboardDtos 요청/응답 정의
|
||||
- [x] 기존 컨트롤러 제거
|
||||
- [x] 엔드포인트 경로 호환성 유지 (AdminDashboardClient 미수정)
|
||||
|
||||
**빌드 & 배포**:
|
||||
- [x] 0 오류, 모든 경고 기록됨
|
||||
- [x] 모든 커밋 Gitea에 푸시됨
|
||||
- [x] CI/CD 자동 배포 준비 완료
|
||||
- [x] WebAssembly 렌더 모드 검증 완료
|
||||
- [x] FastEndpoints 마이그레이션 완료
|
||||
|
||||
---
|
||||
|
||||
@@ -577,37 +241,25 @@ Repositories (데이터 계층)
|
||||
**단일 앱 구조** (공개 사이트 + 관리자까지 하나의 ASP.NET Core 앱):
|
||||
|
||||
```
|
||||
src/ 빌드 가능한 .NET 소스 전체 (CI는 이 폴더만 빌드 대상으로 참조)
|
||||
TaxBaik.Domain 클래스 라이브러리 (엔티티, 인터페이스, enum)
|
||||
TaxBaik.Infrastructure 클래스 라이브러리 (Dapper repository, DB 마이그레이션)
|
||||
TaxBaik.Application 클래스 라이브러리 (서비스, DTO, 비즈니스 로직)
|
||||
TaxBaik.Web ASP.NET Core 앱 (포트 5001 - 서버는 순수 API)
|
||||
├─ Pages/ Razor Pages (공개 홈페이지, 블로그, 문의폼)
|
||||
├─ Components/
|
||||
│ ├─ (Web pages)
|
||||
│ └─ App.razor Blazor Root (WebAssembly 렌더링)
|
||||
└─ Services/ 인증, 블로그, 문의 등 (API만 제공)
|
||||
|
||||
TaxBaik.Web.Client (NEW) Blazor WebAssembly WASM 클라이언트
|
||||
├─ _Imports.razor 네임스페이스 임포트
|
||||
└─ Components/
|
||||
└─ Admin/ 관리자 페이지 (클라이언트 사이드)
|
||||
├─ Pages/ (모든 페이지)
|
||||
├─ Layout/ (레이아웃)
|
||||
├─ Shared/ (공유 컴포넌트)
|
||||
├─ App.razor Root 컴포넌트
|
||||
└─ Routes.razor 라우팅 정의
|
||||
TaxBaik.Domain 클래스 라이브러리 (엔티티, 인터페이스, enum)
|
||||
TaxBaik.Infrastructure 클래스 라이브러리 (Dapper repository, DB 마이그레이션)
|
||||
TaxBaik.Application 클래스 라이브러리 (서비스, DTO, 비즈니스 로직)
|
||||
TaxBaik.Web ASP.NET Core 앱 (포트 5001)
|
||||
├─ Pages/ Razor Pages (공개 홈페이지, 블로그, 문의폼)
|
||||
├─ Components/
|
||||
│ ├─ (Web pages)
|
||||
│ └─ Admin/ Blazor Server (관리자 백오피스)
|
||||
│ ├─ Pages/
|
||||
│ ├─ Layout/
|
||||
│ └─ App.razor
|
||||
└─ Services/ 인증, 블로그, 문의 등
|
||||
```
|
||||
|
||||
**경로:**
|
||||
- 홈페이지: `/taxbaik` (Razor Pages)
|
||||
- 관리자: `/taxbaik/admin` (Blazor WebAssembly - CSR)
|
||||
- 관리자: `/taxbaik/admin` (Blazor Server)
|
||||
- 로그인: `/taxbaik/admin/login`
|
||||
|
||||
**렌더링 방식**:
|
||||
- 공개 사이트: SSR (Razor Pages) - SEO 최적화
|
||||
- 관리자 페이지: CSR (Blazor WebAssembly) - 클라이언트 사이드
|
||||
|
||||
**운영 원칙:**
|
||||
- 단일 앱, 단일 서비스, 단일 배포 경로를 유지한다.
|
||||
- 운영 변경은 코드 또는 CI에서만 반영한다.
|
||||
@@ -688,7 +340,7 @@ ssh taxbaik-tunnel # 터널 유지
|
||||
psql -h localhost -U taxbaik -d taxbaikdb -c "\dt"
|
||||
|
||||
# 또는 .NET 앱 실행 (자동으로 마이그레이션 실행)
|
||||
dotnet run -p src/TaxBaik.Web
|
||||
dotnet run -p TaxBaik.Web
|
||||
```
|
||||
|
||||
#### 단계 3: 개발 워크플로우 (단일 앱 통합)
|
||||
@@ -698,7 +350,7 @@ dotnet run -p src/TaxBaik.Web
|
||||
ssh -L 5432:127.0.0.1:5432 kjh2064@178.104.200.7
|
||||
|
||||
# 터미널 2: 통합 Web 앱 (Razor Pages + Blazor Server Admin)
|
||||
cd src/TaxBaik.Web
|
||||
cd TaxBaik.Web
|
||||
dotnet run
|
||||
# 접속:
|
||||
# - 홈페이지: http://localhost:5001/taxbaik
|
||||
@@ -923,35 +575,13 @@ ssh kjh2064@178.104.200.7
|
||||
- 기존 포트에서 동작하던 구버전 .NET 프로세스를 종료(`kill -15`)합니다.
|
||||
- 만약 헬스 체크 실패 시 새 프로세스만 강제 종료하고 배포를 롤백하여 실서비스 다운타임을 방지합니다.
|
||||
|
||||
**배포 환경 변수 (deploy_gb.sh에서 반드시 설정)**:
|
||||
```bash
|
||||
export ASPNETCORE_ENVIRONMENT=Production
|
||||
export ASPNETCORE_URLS="http://127.0.0.1:$TARGET_PORT"
|
||||
export ConnectionStrings__Default="Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=taxbaik123"
|
||||
export DOTNET_PRINT_TELEMETRY_MESSAGE=false
|
||||
```
|
||||
|
||||
⚠️ **필수 주의사항**:
|
||||
- `ConnectionStrings__Default` 누락 시 배포 실패 (Missing connection string)
|
||||
- 환경 변수는 dotnet 프로세스 시작 전에 export되어야 함
|
||||
- deploy_gb.sh의 "Starting New App on Port" 섹션에서 설정 필수
|
||||
|
||||
**운영 규칙**:
|
||||
- 로컬 또는 서버에서 수동 `dotnet publish`로 운영 배포하지 않는다.
|
||||
- 배포 실패 시 Gitea Actions CI/CD 로그 및 `~/deployments/taxbaik_timestamp/web_*.log`를 먼저 확인한다.
|
||||
- `Missing connection string` → deploy_gb.sh 환경 변수 확인
|
||||
- `core dumped` + `Health check failed` → Program.cs 초기화 에러 확인
|
||||
- 배포 후 최종 검증:
|
||||
- ✅ E2E 테스트 (20/20 통과 기준)
|
||||
- ✅ 프록시 포트 경유 (www.taxbaik.com)
|
||||
- ✅ 메인 홈페이지 HTTP 200
|
||||
- ✅ 관리자 로그인 페이지 로드
|
||||
- ✅ 로그인 API 응답
|
||||
- 배포 후 최종 검증은 프록시 포트를 경유하는 메인 홈페이지, 관리자 로그인 페이지, 로그인 API를 모두 포함한다.
|
||||
|
||||
**롤백**:
|
||||
- 배포 실패 시 자동 롤백 (이전 포트로 즉시 복구)
|
||||
- 수동 롤백: 이전 정상 커밋을 `master`에 revert 후 다시 배포
|
||||
- 긴급 복구: 서버의 `taxbaik_port` 파일 수동 수정
|
||||
- 이전 정상 커밋을 `master`에 revert 또는 hotfix로 되돌려 다시 배포를 수행하거나, 비상시 서버의 `taxbaik_port` 파일의 포트 번호를 수동 수정하여 이전 버전 포트로 즉시 원상복구한다.
|
||||
|
||||
### 3.4 서비스 파일 위치
|
||||
```
|
||||
@@ -968,8 +598,7 @@ export DOTNET_PRINT_TELEMETRY_MESSAGE=false
|
||||
기존 Gitea (`/`)와 QuantEngine (`/quant/`)을 유지하면서 TaxBaik 추가:
|
||||
|
||||
```nginx
|
||||
# 실제 로드되는 파일: /etc/nginx/sites-available/taxbaik-domains.conf
|
||||
# (sites-enabled/taxbaik-domains.conf 심볼릭 링크로 활성화됨)
|
||||
# /etc/nginx/sites-available/default (또는 현재 설정 파일)에 아래 블록 추가
|
||||
|
||||
location /taxbaik {
|
||||
proxy_pass http://127.0.0.1:5001;
|
||||
@@ -987,21 +616,6 @@ location /taxbaik {
|
||||
|
||||
**참고**: 단일 `/taxbaik` 블록이 공개 사이트와 관리자 Blazor 회로를 모두 처리합니다. 운영은 `5001` 통합 앱 기준이며, 설정 반영은 CI 배포로만 수행한다.
|
||||
|
||||
**⚠️ 중요: 실제 로드되는 Nginx 설정 파일 확인 필수 (2026-07-03 장애로 확인)**:
|
||||
- `nginx.conf`는 `include /etc/nginx/sites-enabled/*;`만 로드한다. `/etc/nginx/sites-available/`에 파일이 있어도 `sites-enabled/`에 심볼릭 링크되어 있지 않으면 **절대 반영되지 않는다**.
|
||||
- 이 서버는 `sites-available/default`가 아니라 `sites-available/taxbaik-domains.conf` (→ `sites-enabled/taxbaik-domains.conf`)가 실제로 로드되는 파일이다. `default`를 아무리 수정해도 효과가 없다.
|
||||
- 실제 로드 파일을 찾는 법: `ls -la /etc/nginx/sites-enabled/` 로 심볼릭 링크 대상을 먼저 확인한 뒤 그 파일을 수정한다.
|
||||
- **불변식**: `taxbaik-domains.conf`의 `location /`와 `location /taxbaik` 모두 항상 `127.0.0.1:5001` (TaxBaik.Proxy)만 가리켜야 한다. `5003`/`5004`(Green-Blue 앱 포트)를 직접 하드코딩하면 포트 전환 시 죽은 포트를 가리키게 되어 502/404가 발생한다. 이 설정은 배포마다 바뀔 필요가 없다 — 프록시가 `~/taxbaik_port` 파일을 읽어 자동으로 활성 포트에 연결한다.
|
||||
- **trailing slash 주의**: `proxy_pass http://127.0.0.1:5001;` (슬래시 없음)은 원본 요청 경로를 그대로 전달한다. `proxy_pass http://127.0.0.1:5001/;` (슬래시 있음)은 URI를 재작성하는데, `location` 접두사와 슬래시 개수가 안 맞으면 백엔드로 이중 슬래시(`//`)가 전달되어 404가 발생한다. 접두사 매칭 `location`에서는 `proxy_pass`에 trailing slash를 붙이지 않는다.
|
||||
- **디버깅 팁**: `curl http://127.0.0.1/taxbaik/`처럼 IP로 직접 테스트하면 `Host: 127.0.0.1` 헤더가 `server_name taxbaik.com www.taxbaik.com`과 매칭되지 않아 엉뚱한(또는 기본) server block으로 라우팅될 수 있다. 실제 도메인 기준 server block을 로컬에서 테스트하려면 Host 헤더/SNI를 강제로 지정한다:
|
||||
```bash
|
||||
# HTTP
|
||||
curl -I -H "Host: www.taxbaik.com" http://127.0.0.1/taxbaik/
|
||||
# HTTPS (SNI 포함)
|
||||
curl -sk -I --resolve www.taxbaik.com:443:127.0.0.1 https://www.taxbaik.com/taxbaik/
|
||||
```
|
||||
- CI 배포(`deploy.yml`)는 매 배포마다 `sites-enabled/`의 실제 파일을 찾아 위 불변식을 검증하고, 위반 시 배포를 실패 처리한다. 또한 내부 `127.0.0.1:5001` 체크와 별개로 실제 공개 도메인(`https://www.taxbaik.com/`)을 외부에서 호출해 Nginx/Cloudflare 경로 전체를 검증한다 — 내부 체크만으로는 Nginx 설정 오류를 잡지 못하기 때문이다.
|
||||
|
||||
**Nginx 보안**:
|
||||
- `Upgrade` 헤더는 Blazor WebSocket 경로에만 허용하고, 필요 없는 location에는 넣지 않는다.
|
||||
- `Host`와 `X-Forwarded-Proto`는 유지해 원본 URL과 스킴을 보존한다.
|
||||
@@ -1131,22 +745,6 @@ ssh kjh2064@178.104.200.7 crontab -l | grep backup
|
||||
|
||||
---
|
||||
|
||||
## 5-1. 블로그 & FAQ 콘텐츠 작성 규칙
|
||||
|
||||
**핵심**: 고객 임파워먼트 (당신도 할 수 있습니다!)
|
||||
- ✅ 주변에서 흔히 보는 실제 사례 (이름, 나이, 직업 구체화)
|
||||
- ✅ 절세 효과 수치화 ("세금을 X만 원 절약했습니다")
|
||||
- ✅ 중학교 2학년도 이해 가능한 수준
|
||||
- ✅ 단계별 설명 + 표로 시각화
|
||||
- ✅ 결론: "정확하게 하면 이런 이점이 있습니다" (임파워먼트)
|
||||
|
||||
**피해야 할 톤**: "복잡하니까 맡기세요" (세무사 의존성 강화)
|
||||
**세무사 언급**: "더 복잡하면 전문가와 상담하세요" (선택지)
|
||||
|
||||
**자세한 템플릿 및 체크리스트**: `BLOG_TEMPLATE.md` 참고
|
||||
|
||||
---
|
||||
|
||||
## 6. 코드 규칙
|
||||
|
||||
### 6.1 C# 네이밍
|
||||
@@ -1358,9 +956,9 @@ Admin 로그인 페이지만 [AllowAnonymous]:
|
||||
- 페이지 로드 시 `OnInitializedAsync`에서 데이터 가져오기
|
||||
- 업데이트는 `StateHasChanged()` 호출
|
||||
|
||||
### 8.6 어드민 그리드 UX (더존 세무회계프로그램 수준)
|
||||
### 8.6 어드민 그리드 UX (Dorsum ERP 수준)
|
||||
|
||||
**목표**: 패드/PC에 특화된 고밀도 데이터 표시 + 더존식 상호작용성
|
||||
**목표**: 패드/PC에 특화된 고밀도 데이터 표시 + ERP 수준의 상호작용성
|
||||
|
||||
#### 그리드 기본 원칙
|
||||
- **데이터 밀도**: 줄 높이 32px, 최대 주요 정보 5-7개 컬럼 (시각적 혼잡 제거)
|
||||
@@ -2006,7 +1604,7 @@ public interface INtsApiClient
|
||||
|
||||
### 빌드
|
||||
```bash
|
||||
dotnet build src/TaxBaik.sln
|
||||
dotnet build TaxBaik.sln
|
||||
```
|
||||
|
||||
### 서버 상태 확인 (SSH)
|
||||
@@ -2296,7 +1894,7 @@ else
|
||||
|
||||
| 항목 | 이전 | 현재 | 개선 |
|
||||
|------|------|------|------|
|
||||
| **Blazor 프리렌더링** | 전역 `prerender: false` (로그인 포함 전체 흰 화면) | 페이지별 지정 (로그인만 `prerender: true`, 나머지 `false`) | 로그인 흰 화면 제거, 인증 페이지는 그대로 안정 |
|
||||
| **Blazor 프리렌더링** | `prerender: false` | `prerender: true` | 흰 화면 제거 |
|
||||
| **배포 헬스 체크** | 40 × 3초 = 120초 | 20 × 3초 = 60초 | -50% |
|
||||
| **E2E 배포 대기** | 30 × 5초 = 150초 | 20 × 3초 = 60초 | -60% |
|
||||
| **Playwright 병렬** | `fullyParallel: false` | CI에서 `true` | 테스트 병렬화 |
|
||||
@@ -2359,29 +1957,11 @@ else
|
||||
```
|
||||
빌드/테스트/배포/헬스체크 중 어느 단계인지 먼저 분리한다.
|
||||
|
||||
5. **"CI는 성공인데 실제 사이트는 502/404" 의심 시 — 반드시 Nginx 레이어부터 확인**
|
||||
내부 헬스체크(`http://127.0.0.1:5001/...`)는 Nginx를 거치지 않으므로 Nginx 설정 오류를 잡지 못한다. CI 성공과 실제 접속 가능 여부는 별개다.
|
||||
```bash
|
||||
# 1) 실제 로드되는 파일 확인 (sites-available에 있어도 sites-enabled에 링크 안 되면 무효)
|
||||
ls -la /etc/nginx/sites-enabled/
|
||||
# 2) 그 파일에서 location / 와 location /taxbaik 이 5001을 가리키는지 확인 (5003/5004 하드코딩 금지)
|
||||
grep -A 2 'location /' /etc/nginx/sites-available/taxbaik-domains.conf
|
||||
# 3) 실제 도메인 기준 server block으로 로컬 검증 (Host/SNI 강제)
|
||||
curl -sk -I --resolve www.taxbaik.com:443:127.0.0.1 https://www.taxbaik.com/taxbaik/
|
||||
```
|
||||
|
||||
**이번 장애 원인 기록 (2026-06-28, YAML 파싱)**:
|
||||
**이번 장애 원인 기록**:
|
||||
- `deploy.yml`의 Telegram 여러 줄 메시지 일부가 YAML 블록 들여쓰기 밖에 있어 Gitea workflow 파서가 실패했다.
|
||||
- 이후 배포 실행은 되었지만, 운영 `Authentication:*:ClientId`가 빈 값인데 OAuth provider를 무조건 등록해 `ClientId` 예외로 500이 발생했다.
|
||||
- 외부 OAuth provider는 ClientId/ClientSecret이 모두 있을 때만 등록한다.
|
||||
|
||||
**이번 장애 원인 기록 (2026-07-03, Nginx 이중 설정 파일 + 죽은 포트 + trailing slash)**:
|
||||
- CI 배포는 매번 성공으로 표시됐지만 실제 `https://www.taxbaik.com/`은 502, `/taxbaik/`는 404였다. 원인은 세 가지가 겹쳐 있었다.
|
||||
1. 서버에 Nginx 설정 파일이 두 개 존재했다: `sites-available/default`(문서에 기록되어 있었지만 `sites-enabled/`에 링크되지 않아 **전혀 로드되지 않음**)와 `sites-available/taxbaik-domains.conf`(→ `sites-enabled/`에 실제로 링크되어 로드됨, 문서에는 없었음). 디버깅 초반에 로드되지 않는 `default` 파일만 계속 수정하며 시간을 허비했다.
|
||||
2. `taxbaik-domains.conf`의 `location /`와 `location /taxbaik`에 Green-Blue 앱 포트(`5003`)가 직접 하드코딩되어 있었다. 포트가 `5004`로 전환된 뒤에도 Nginx는 죽은 `5003`을 계속 가리켜 502가 발생했다.
|
||||
3. `location /taxbaik`의 `proxy_pass`를 `http://127.0.0.1:5001/`(trailing slash 있음)로 고치자, nginx가 URI를 재작성하며 백엔드로 `//`(이중 슬래시)를 전달해 404가 발생했다. `curl http://backend//` 로 재현 확인 후 trailing slash를 제거해 해결했다.
|
||||
- 근본 대책: 위 5번 체크리스트를 표준 절차로 추가했고, `deploy.yml`이 매 배포마다 (a) `sites-enabled/`의 실제 파일을 찾아 (b) 5003/5004 하드코딩과 trailing slash 오설정을 하드 실패로 검증하고, (c) 내부 체크와 별개로 `https://www.taxbaik.com/` 실도메인을 외부에서 호출해 Nginx/Cloudflare 경로 전체를 검증하도록 했다 (§6 Nginx 라우팅 참고).
|
||||
|
||||
---
|
||||
|
||||
## 12. 문제 해결
|
||||
@@ -2428,7 +2008,7 @@ else
|
||||
| 11/15 ~ 11/30 | 종합부동산세 납부 | `comprehensive-real-estate-tax` | real-estate-tax |
|
||||
| 12/1 ~ 12/31 | 연말 증여·절세 플래닝 | `year-end-gift` | family-asset |
|
||||
|
||||
캘린더 정의 위치: `src/TaxBaik.Application/Seasonal/TaxSeasonCalendar.cs`
|
||||
캘린더 정의 위치: `TaxBaik.Application/Seasonal/TaxSeasonCalendar.cs`
|
||||
|
||||
시즌 추가/수정은 이 파일만 변경하면 된다. DB·마이그레이션 변경 없음.
|
||||
|
||||
|
||||
@@ -130,15 +130,10 @@ boto3, cryptography, Jinja2, jsonschema, fail2ban 등 시스템 레벨로 설치
|
||||
|
||||
# 1. TaxBaik 홈페이지 (taxbaik.com, www.taxbaik.com)
|
||||
server {
|
||||
listen 80;
|
||||
server_name taxbaik.com www.taxbaik.com;
|
||||
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/;
|
||||
@@ -164,18 +159,11 @@ server {
|
||||
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 {
|
||||
listen 80;
|
||||
server_name gitea.taxbaik.com;
|
||||
client_max_body_size 512M;
|
||||
|
||||
@@ -190,17 +178,11 @@ server {
|
||||
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 {
|
||||
listen 80;
|
||||
server_name quant.taxbaik.com;
|
||||
|
||||
location / {
|
||||
@@ -214,57 +196,6 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
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
|
||||
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
@@ -19,30 +19,20 @@ GRANT ALL PRIVILEGES ON DATABASE taxbaikdb TO taxbaik;
|
||||
|
||||
### 2. 환경 변수 설정
|
||||
|
||||
**Web 서비스** (`/etc/systemd/system/taxbaik.service`, 백엔드 전용):
|
||||
**Web 서비스** (`/etc/systemd/system/taxbaik.service`):
|
||||
```ini
|
||||
[Service]
|
||||
Environment=ASPNETCORE_ENVIRONMENT=Production
|
||||
Environment=ASPNETCORE_URLS=http://127.0.0.1:5004
|
||||
Environment=ASPNETCORE_URLS=http://127.0.0.1:5001
|
||||
Environment=ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=your_secure_password
|
||||
```
|
||||
|
||||
**프록시 서비스** (`/etc/systemd/system/taxbaik-proxy.service`, 5001 진입점):
|
||||
```ini
|
||||
[Service]
|
||||
ExecStart=/usr/bin/dotnet TaxBaik.Proxy.dll
|
||||
WorkingDirectory=/home/kjh2064/taxbaik_active
|
||||
Restart=always
|
||||
```
|
||||
|
||||
### 3. systemd 서비스 파일 설치
|
||||
|
||||
```bash
|
||||
sudo cp deploy/taxbaik.service /etc/systemd/system/
|
||||
sudo cp deploy/taxbaik-proxy.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable taxbaik
|
||||
sudo systemctl enable taxbaik-proxy
|
||||
```
|
||||
|
||||
### 4. Nginx 설정
|
||||
@@ -79,7 +69,7 @@ sudo systemctl reload nginx
|
||||
master 브랜치 push → build → test → publish → restart → health check → Playwright
|
||||
```
|
||||
|
||||
수동 배포는 사용하지 않습니다. `deploy_gb.sh`는 `TAXBAIK_DEPLOY_FROM_CI=1`이 없으면 즉시 종료하므로, 배포는 반드시 Gitea Actions에서만 실행됩니다.
|
||||
수동 배포는 비상 롤백 외에는 사용하지 않습니다. 배포 이슈는 Gitea Actions 로그로 해결합니다.
|
||||
|
||||
## 마이그레이션 자동 실행
|
||||
|
||||
@@ -142,7 +132,6 @@ ls -la ~/deployments/ | grep taxbaik
|
||||
|
||||
# 심링크 변경 (예: 이전 버전이 taxbaik_20260626_140000)
|
||||
ln -sfn ~/deployments/taxbaik_20260626_140000 ~/taxbaik_active
|
||||
sudo systemctl restart taxbaik-proxy
|
||||
sudo systemctl restart taxbaik
|
||||
```
|
||||
|
||||
@@ -154,10 +143,10 @@ sudo systemctl restart taxbaik
|
||||
ssh kjh2064@178.104.200.7
|
||||
|
||||
# 서비스 상태
|
||||
systemctl status taxbaik taxbaik-proxy
|
||||
systemctl status taxbaik
|
||||
|
||||
# 포트 확인
|
||||
netstat -tlnp | grep -E '5001|5004'
|
||||
netstat -tlnp | grep -E '5001'
|
||||
|
||||
# 프로세스 확인
|
||||
ps aux | grep TaxBaik
|
||||
@@ -180,27 +169,9 @@ 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-proxy taxbaik` |
|
||||
| 503 Service Unavailable | 앱 미시작 | `sudo systemctl restart taxbaik` |
|
||||
| 마이그레이션 실패 | DB 권한 문제 | `GRANT ALL PRIVILEGES ON DATABASE taxbaikdb TO taxbaik;` |
|
||||
|
||||
## 운영 복구 순서
|
||||
|
||||
```bash
|
||||
ssh kjh2064@178.104.200.7
|
||||
sudo cp /home/kjh2064/taxbaik.service /etc/systemd/system/taxbaik.service
|
||||
sudo cp /home/kjh2064/taxbaik-proxy.service /etc/systemd/system/taxbaik-proxy.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart taxbaik-proxy
|
||||
sudo systemctl restart taxbaik
|
||||
curl -I http://127.0.0.1:5001/taxbaik/admin/login
|
||||
```
|
||||
|
||||
## 원라인 점검
|
||||
|
||||
```bash
|
||||
ssh kjh2064@178.104.200.7 'systemctl status taxbaik taxbaik-proxy --no-pager --lines=3 && ss -tlnp | grep -E ":5001 |:5004 " && curl -fsSI http://127.0.0.1:5001/taxbaik/admin/login && curl -fsS http://127.0.0.1:5001/taxbaik/favicon.svg >/dev/null && curl -fsS http://127.0.0.1:5001/taxbaik/robots.txt >/dev/null'
|
||||
```
|
||||
|
||||
## 초기 데이터
|
||||
|
||||
### 관리자 계정
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./publish/ .
|
||||
|
||||
EXPOSE 5001
|
||||
|
||||
ENTRYPOINT ["dotnet", "TaxBaik.Web.dll"]
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./publish/ .
|
||||
|
||||
EXPOSE 5001
|
||||
|
||||
ENTRYPOINT ["dotnet", "TaxBaik.Web.dll"]
|
||||
@@ -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
|
||||
```
|
||||
@@ -168,7 +168,7 @@ master 브랜치에 푸시하면 파이프라인이 다음 단계를 수행합
|
||||
- `TAXBAIK_ADMIN_TEST_PASSWORD`: 배포 검증용 관리자 비밀번호
|
||||
- `Admin__PasswordResetToken`: 관리자 비밀번호 재설정 API용 서버 비밀값
|
||||
|
||||
배포는 Gitea Actions CI/CD로만 수행합니다. 수동 배포 경로는 CI 하네스로 차단되어 있으며, 실패 시 [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)의 CI 점검 절차를 따릅니다.
|
||||
수동 배포는 비상 롤백 절차 외에는 사용하지 않습니다. 실패 시 [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)의 CI 점검 절차를 따릅니다.
|
||||
|
||||
---
|
||||
|
||||
@@ -270,13 +270,7 @@ 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) - 서버 자동 설치 스크립트
|
||||
|
||||
|
||||
@@ -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"
|
||||
+3
-45
@@ -44,34 +44,15 @@ public class BlogServiceTests
|
||||
Assert.Equal("같은-제목-2", post.Slug);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_SoftDeletesPost_AndExcludesFromSlugLookup()
|
||||
{
|
||||
var repository = new FakeBlogPostRepository
|
||||
{
|
||||
Posts =
|
||||
[
|
||||
new BlogPost { Id = 1, Title = "삭제 대상", Content = "본문", Slug = "delete-me", IsPublished = true }
|
||||
]
|
||||
};
|
||||
var service = new BlogService(repository, new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
await service.DeleteAsync(1);
|
||||
|
||||
Assert.NotNull(repository.Posts.Single().DeletedAt);
|
||||
Assert.Null(await service.GetBySlugAsync("delete-me"));
|
||||
Assert.Null(await service.GetByIdAsync(1));
|
||||
}
|
||||
|
||||
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 && x.DeletedAt == null));
|
||||
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 && x.DeletedAt == null));
|
||||
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)
|
||||
@@ -93,13 +74,6 @@ public class BlogServiceTests
|
||||
return Task.FromResult<(IEnumerable<BlogPost>, int)>((items, items.Count));
|
||||
}
|
||||
|
||||
public Task<(IEnumerable<BlogPost> Items, int Total)> GetArchivedPagedAsync(
|
||||
int page, int pageSize, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = Posts.Where(x => x.DeletedAt != null).ToList();
|
||||
return Task.FromResult<(IEnumerable<BlogPost>, int)>((items, items.Count));
|
||||
}
|
||||
|
||||
public Task<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default)
|
||||
{
|
||||
post.Id = Posts.Count + 1;
|
||||
@@ -109,23 +83,7 @@ public class BlogServiceTests
|
||||
|
||||
public Task UpdateAsync(BlogPost post, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
public Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var post = Posts.FirstOrDefault(x => x.Id == id);
|
||||
if (post != null)
|
||||
post.DeletedAt = DateTime.UtcNow;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ArchiveAsync(int id, CancellationToken cancellationToken = default) => DeleteAsync(id, cancellationToken);
|
||||
|
||||
public Task RestoreAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var post = Posts.FirstOrDefault(x => x.Id == id);
|
||||
if (post != null)
|
||||
post.DeletedAt = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task DeleteAsync(int id, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
public Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
+1
-17
@@ -22,7 +22,7 @@ public class InquiryServiceTests
|
||||
var repository = new FakeInquiryRepository();
|
||||
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);
|
||||
@@ -80,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);
|
||||
-1
@@ -18,6 +18,5 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TaxBaik.Application\TaxBaik.Application.csproj" />
|
||||
<ProjectReference Include="..\TaxBaik.Web\TaxBaik.Web.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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; }
|
||||
}
|
||||
+1
-1
@@ -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",
|
||||
-16
@@ -42,10 +42,6 @@ public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCach
|
||||
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);
|
||||
@@ -114,18 +110,6 @@ public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCach
|
||||
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);
|
||||
|
||||
+10
-1
@@ -6,6 +6,15 @@ using TaxBaik.Domain.Interfaces;
|
||||
|
||||
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);
|
||||
@@ -72,7 +81,7 @@ public class ClientService(IClientRepository repository)
|
||||
Phone = phone?.Trim(),
|
||||
ServiceType = serviceType,
|
||||
Status = "active",
|
||||
Source = "홈페이지문의"
|
||||
Source = "홈페이지 문의"
|
||||
};
|
||||
return await repository.CreateAsync(client, ct);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
public class CommonCodeService(ICommonCodeRepository commonCodeRepository)
|
||||
{
|
||||
public async Task<IEnumerable<CommonCode>> GetByGroupAsync(string codeGroup, CancellationToken ct = default)
|
||||
{
|
||||
return await commonCodeRepository.GetByGroupAsync(codeGroup, ct);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CommonCode>> GetAllActiveAsync(CancellationToken ct = default)
|
||||
{
|
||||
return await commonCodeRepository.GetAllActiveAsync(ct);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@ using TaxBaik.Domain.Interfaces;
|
||||
public class FaqService(IFaqRepository repository)
|
||||
{
|
||||
public static readonly string[] Categories =
|
||||
["기장세금신고", "부동산", "증여상속", "기타"];
|
||||
["기장·세금신고", "부동산", "증여·상속", "기타"];
|
||||
|
||||
public async Task<IEnumerable<Faq>> GetActiveAsync(CancellationToken ct = default) =>
|
||||
await repository.GetActiveAsync(ct);
|
||||
@@ -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) { }
|
||||
}
|
||||
-7
@@ -4,13 +4,6 @@ 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"] = "신규",
|
||||
+2
-3
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
+3
@@ -5,6 +5,9 @@ using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class TaxFilingService(ITaxFilingRepository repository)
|
||||
{
|
||||
public static readonly string[] FilingTypes =
|
||||
["부가가치세", "종합소득세", "법인세", "원천징수", "종합부동산세", "증여세", "상속세", "기타"];
|
||||
|
||||
public static readonly string[] Statuses =
|
||||
["pending", "filed", "overdue"];
|
||||
|
||||
@@ -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; }
|
||||
-4
@@ -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);
|
||||
}
|
||||
-4
@@ -7,10 +7,6 @@ namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
public interface ICommonCodeRepository
|
||||
{
|
||||
Task<IEnumerable<string>> GetAllGroupsAsync(CancellationToken ct = default);
|
||||
Task<IEnumerable<CommonCode>> GetByGroupAsync(string codeGroup, CancellationToken ct = default);
|
||||
Task<IEnumerable<CommonCode>> GetAllActiveAsync(CancellationToken ct = default);
|
||||
Task<CommonCode?> GetAsync(string codeGroup, string codeValue, CancellationToken ct = default);
|
||||
Task UpsertAsync(CommonCode code, CancellationToken ct = default);
|
||||
Task DeleteAsync(string codeGroup, string codeValue, CancellationToken ct = default);
|
||||
}
|
||||
-1
@@ -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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user