Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0dc05c58c |
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(*)",
|
|
||||||
"PowerShell(*)",
|
|
||||||
"Read",
|
|
||||||
"Write",
|
|
||||||
"Edit",
|
|
||||||
"Glob",
|
|
||||||
"Grep",
|
|
||||||
"WebFetch",
|
|
||||||
"WebSearch"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -36,11 +36,6 @@ jobs:
|
|||||||
npm ci
|
npm ci
|
||||||
npx playwright install chromium --with-deps
|
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
|
- name: Wait for deployment
|
||||||
env:
|
env:
|
||||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||||
@@ -52,91 +47,30 @@ jobs:
|
|||||||
echo "Expected short version: $SHORT_VERSION"
|
echo "Expected short version: $SHORT_VERSION"
|
||||||
for i in $(seq 1 20); do
|
for i in $(seq 1 20); do
|
||||||
# Suppress stderr and allow failures to handle transition/down periods cleanly
|
# 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)"
|
VERSION_BODY="$(curl -fsS "http://${DEPLOY_HOST}/taxbaik/version.json" 2>/dev/null || true)"
|
||||||
BLOG_STATUS="$(curl -s -o /dev/null -w '%{http_code}' "https://www.taxbaik.com/blog" || 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}' "https://www.taxbaik.com/admin/login" || true)"
|
if echo "$VERSION_BODY" | grep -q "\"version\": \"${SHORT_VERSION}\"" && [ "$BLOG_STATUS" = "200" ]; then
|
||||||
if echo "$VERSION_BODY" | grep -q "\"version\": \"${SHORT_VERSION}\"" && [ "$BLOG_STATUS" = "200" ] && [ "$LOGIN_STATUS" = "200" ]; then
|
|
||||||
echo "✓ Deployment ready for ${SHORT_VERSION} (attempt $i/20)"
|
echo "✓ Deployment ready for ${SHORT_VERSION} (attempt $i/20)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if [ $i -lt 20 ]; then
|
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
|
sleep 3
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
echo "✗ TIMEOUT: Deployment failed to publish ${SHORT_VERSION} within 60 seconds" >&2
|
echo "✗ TIMEOUT: Deployment failed to publish ${SHORT_VERSION} within 60 seconds" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Browser verifications in parallel
|
- name: Browser E2E verification
|
||||||
env:
|
env:
|
||||||
# Nginx를 통해 root 경로로 라우팅 (PathBase 없음)
|
# Green-Blue 배포 지원: Nginx를 통해 active 포트로 라우팅
|
||||||
E2E_BASE_URL: https://www.taxbaik.com
|
E2E_BASE_URL: http://${{ secrets.DEPLOY_HOST }}/taxbaik
|
||||||
# E2E 테스트는 운영에서 검증된 admin 계정 사용
|
# E2E 테스트는 test_admin 테스트 계정 사용 (실 admin 계정과 분리)
|
||||||
E2E_ADMIN_USERNAME: admin
|
E2E_ADMIN_USERNAME: test_admin
|
||||||
E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }}
|
E2E_ADMIN_PASSWORD: TestAdmin@123456
|
||||||
EVIDENCE_ROOT: evidence
|
|
||||||
run: |
|
run: |
|
||||||
set -e
|
echo "Running E2E tests on Desktop Chrome (production verification)"
|
||||||
pids=()
|
npx playwright test --project="Desktop Chrome" --reporter=html --reporter=list
|
||||||
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
|
|
||||||
|
|
||||||
- name: Browser E2E summary
|
- name: Browser E2E summary
|
||||||
if: always()
|
if: always()
|
||||||
@@ -149,9 +83,3 @@ jobs:
|
|||||||
echo "- contact-submit"
|
echo "- contact-submit"
|
||||||
echo "- inquiry-detail"
|
echo "- inquiry-detail"
|
||||||
echo "- admin-password-change"
|
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
@@ -20,80 +20,33 @@ jobs:
|
|||||||
dotnet-version: '10.0'
|
dotnet-version: '10.0'
|
||||||
|
|
||||||
- name: Restore dependencies
|
- name: Restore dependencies
|
||||||
run: dotnet restore src/TaxBaik.sln
|
run: dotnet restore 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
|
|
||||||
|
|
||||||
- name: Build solution
|
- 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
|
- 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
|
- name: Publish Web
|
||||||
run: |
|
run: dotnet publish TaxBaik.Web/ -c Release -o ./publish --no-restore
|
||||||
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
|
|
||||||
|
|
||||||
- name: Publish Proxy
|
- name: Write production secrets
|
||||||
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
|
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
|
JWT_SECRET_KEY="${{ secrets.TAXBAIK_JWT_SECRET_KEY }}"
|
||||||
TELEGRAM_BOT_TOKEN="${{ secrets.TAXBAIK_TELEGRAM_BOT_TOKEN }}"
|
TELEGRAM_BOT_TOKEN="${{ secrets.TAXBAIK_TELEGRAM_BOT_TOKEN }}"
|
||||||
TELEGRAM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_CHAT_ID }}"
|
TELEGRAM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_CHAT_ID }}"
|
||||||
TELEGRAM_INQUIRY_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_INQUIRY_CHAT_ID }}"
|
TELEGRAM_INQUIRY_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_INQUIRY_CHAT_ID }}"
|
||||||
TELEGRAM_SYSTEM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_SYSTEM_CHAT_ID }}"
|
TELEGRAM_SYSTEM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_SYSTEM_CHAT_ID }}"
|
||||||
# JWT는 현재 관리자 인증에 사용하지 않지만, 포털 인증/토큰 교환용 설정으로 유지한다.
|
[ -z "$JWT_SECRET_KEY" ] && { echo "Missing TAXBAIK_JWT_SECRET_KEY" >&2; exit 1; }
|
||||||
# 없으면 런타임에서 AuthService가 실패하므로, CI 배포에서만 한 번 생성해 넣는다.
|
|
||||||
[ -z "$TELEGRAM_BOT_TOKEN" ] && { echo "Missing TAXBAIK_TELEGRAM_BOT_TOKEN" >&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_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_INQUIRY_CHAT_ID" ] && TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_CHAT_ID"
|
||||||
[ -z "$TELEGRAM_SYSTEM_CHAT_ID" ] && TELEGRAM_SYSTEM_CHAT_ID="-5585148480"
|
[ -z "$TELEGRAM_SYSTEM_CHAT_ID" ] && TELEGRAM_SYSTEM_CHAT_ID="-5585148480"
|
||||||
|
JWT_SECRET_KEY="$JWT_SECRET_KEY" \
|
||||||
TELEGRAM_BOT_TOKEN="$TELEGRAM_BOT_TOKEN" \
|
TELEGRAM_BOT_TOKEN="$TELEGRAM_BOT_TOKEN" \
|
||||||
TELEGRAM_CHAT_ID="$TELEGRAM_CHAT_ID" \
|
TELEGRAM_CHAT_ID="$TELEGRAM_CHAT_ID" \
|
||||||
TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_INQUIRY_CHAT_ID" \
|
TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_INQUIRY_CHAT_ID" \
|
||||||
@@ -102,9 +55,7 @@ jobs:
|
|||||||
import json, os, pathlib
|
import json, os, pathlib
|
||||||
pathlib.Path("./publish/appsettings.Production.json").write_text(
|
pathlib.Path("./publish/appsettings.Production.json").write_text(
|
||||||
json.dumps({
|
json.dumps({
|
||||||
"ConnectionStrings": {
|
"Jwt": {"SecretKey": os.environ["JWT_SECRET_KEY"]},
|
||||||
"Default": "Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=taxbaik123"
|
|
||||||
},
|
|
||||||
"Telegram": {
|
"Telegram": {
|
||||||
"BotToken": os.environ["TELEGRAM_BOT_TOKEN"],
|
"BotToken": os.environ["TELEGRAM_BOT_TOKEN"],
|
||||||
"ChatId": os.environ["TELEGRAM_CHAT_ID"],
|
"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; }
|
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
|
- name: Copy migrations
|
||||||
run: mkdir -p ./publish/db && cp -r db/migrations ./publish/db/ || true
|
run: cp -r db/migrations ./publish/migrations || 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
|
|
||||||
|
|
||||||
- name: Generate build info
|
- name: Generate build info
|
||||||
run: |
|
run: |
|
||||||
COMMIT_HASH=$(git rev-parse --short HEAD)
|
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
|
mkdir -p ./publish/wwwroot
|
||||||
printf '{\n "version": "%s",\n "built": "%s"\n}\n' "$COMMIT_HASH" "$BUILD_TIME" > ./publish/wwwroot/version.json
|
printf '{\n "version": "%s",\n "built": "%s"\n}\n' "$COMMIT_HASH" "$BUILD_TIME" > ./publish/wwwroot/version.json
|
||||||
echo "✓ Build: $COMMIT_HASH @ $BUILD_TIME"
|
echo "✓ Build: $COMMIT_HASH @ $BUILD_TIME"
|
||||||
@@ -160,18 +100,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Package artifact
|
- name: Package artifact
|
||||||
run: |
|
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 .
|
tar -czf taxbaik_deploy.tgz -C ./publish .
|
||||||
echo "✓ Package: $(du -sh taxbaik_deploy.tgz | cut -f1)"
|
echo "✓ Package: $(du -sh taxbaik_deploy.tgz | cut -f1)"
|
||||||
|
|
||||||
- name: Deploy & verify on server
|
- name: Deploy & verify on server
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
export TAXBAIK_DEPLOY_FROM_CI=1
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
TIMESTAMP=$(TZ=Asia/Seoul date +%Y%m%d_%H%M%S)
|
|
||||||
COMMIT=$(git rev-parse --short HEAD)
|
COMMIT=$(git rev-parse --short HEAD)
|
||||||
DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}"
|
DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}"
|
||||||
DEPLOY_USER="${{ secrets.DEPLOY_USER }}"
|
DEPLOY_USER="${{ secrets.DEPLOY_USER }}"
|
||||||
@@ -213,12 +148,11 @@ jobs:
|
|||||||
# 2. 서버에서 배포 + 헬스 체크 (SSH 1회 연결로 처리, Green-Blue 지원)
|
# 2. 서버에서 배포 + 헬스 체크 (SSH 1회 연결로 처리, Green-Blue 지원)
|
||||||
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes \
|
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes \
|
||||||
-o ServerAliveInterval=10 \
|
-o ServerAliveInterval=10 \
|
||||||
"$DEPLOY_USER@$DEPLOY_HOST" TAXBAIK_DEPLOY_FROM_CI=1 bash << REMOTE
|
"$DEPLOY_USER@$DEPLOY_HOST" bash << REMOTE
|
||||||
set -e
|
set -e
|
||||||
DEPLOY_HOME="/home/kjh2064"
|
DEPLOY_HOME="/home/kjh2064"
|
||||||
DEPLOY_DIR="\$DEPLOY_HOME/deployments/taxbaik_${TIMESTAMP}"
|
DEPLOY_DIR="\$DEPLOY_HOME/deployments/taxbaik_${TIMESTAMP}"
|
||||||
TIMESTAMP="${TIMESTAMP}"
|
TIMESTAMP="${TIMESTAMP}"
|
||||||
COMMIT="${COMMIT}"
|
|
||||||
|
|
||||||
echo "--- [1/5] 압축 해제 ---"
|
echo "--- [1/5] 압축 해제 ---"
|
||||||
mkdir -p "\$DEPLOY_DIR"
|
mkdir -p "\$DEPLOY_DIR"
|
||||||
@@ -228,135 +162,42 @@ jobs:
|
|||||||
echo "--- [2/5] 운영 설정 검증 ---"
|
echo "--- [2/5] 운영 설정 검증 ---"
|
||||||
test -s "\$DEPLOY_DIR/appsettings.Production.json" \
|
test -s "\$DEPLOY_DIR/appsettings.Production.json" \
|
||||||
|| { echo "FATAL: appsettings.Production.json 없음" >&2; exit 1; }
|
|| { 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] 마이그레이션 사전 검증 ---"
|
echo "--- [3/5] 심볼릭 링크 전환 ---"
|
||||||
test -x "\$DEPLOY_DIR/scripts/validate_migrations.sh" \
|
ln -sfn "\$DEPLOY_DIR" "\$DEPLOY_HOME/taxbaik_active"
|
||||||
|| { 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 "--- [4/5] 서비스 재시작 ---"
|
||||||
chmod +x "\$DEPLOY_DIR/deploy_gb.sh"
|
sudo /usr/bin/systemctl restart taxbaik
|
||||||
"\$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 "--- [5/5] 헬스 체크 (최대 60초) ---"
|
echo "--- [5/5] 헬스 체크 (최대 60초) ---"
|
||||||
ATTEMPTS=20
|
ATTEMPTS=20
|
||||||
for i in \$(seq 1 \$ATTEMPTS); do
|
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
|
if [ "\$STATUS" = "200" ]; then
|
||||||
echo "✓ [1/6] 헬스 체크 완료"
|
echo "✓ [1/4] 메인 페이지 로드 완료"
|
||||||
|
|
||||||
# 검증 1: 메인 페이지 로드. curl -L + -w 는 리다이렉트 체인의 상태코드를
|
# 검증 1: CSS 파일 로드
|
||||||
# 이어붙이므로, 첫 응답 코드만 받아 200/3xx를 허용한다.
|
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")
|
||||||
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")
|
|
||||||
if [ "\$CSS_STATUS" != "200" ]; then
|
if [ "\$CSS_STATUS" != "200" ]; then
|
||||||
echo "❌ CSS 파일 로드 실패 (상태: \$CSS_STATUS)" >&2
|
echo "❌ CSS 파일 로드 실패 (상태: \$CSS_STATUS)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "✓ [3/6] CSS 파일 로드 완료"
|
echo "✓ [2/4] CSS 파일 로드 완료"
|
||||||
|
|
||||||
# 검증 3: 버전 정보. 파일 존재만 보면 5001이 잘못된 구 프로세스를
|
# 검증 2: 버전 정보
|
||||||
# 가리키는 장애를 놓치므로, HTTP 응답이 이번 커밋인지 확인한다.
|
|
||||||
if [ ! -s "\$DEPLOY_DIR/wwwroot/version.json" ]; then
|
if [ ! -s "\$DEPLOY_DIR/wwwroot/version.json" ]; then
|
||||||
echo "❌ version.json 누락" >&2
|
echo "❌ version.json 누락" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
VERSION_JSON=\$(curl -fsS http://127.0.0.1:5001/version.json 2>/dev/null || true)
|
echo "✓ [3/4] 버전 정보 확인 완료"
|
||||||
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] 버전 정보 확인 완료"
|
|
||||||
|
|
||||||
# 검증 4: 5001 프록시 확인
|
# 검증 3: 관리자 로그인 페이지
|
||||||
if ! ss -tlnp | grep -q ':5001 '; then
|
LOGIN_STATUS=\$(curl -sf -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/taxbaik/admin/login 2>/dev/null || echo "000")
|
||||||
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")
|
|
||||||
if [ "\$LOGIN_STATUS" != "200" ]; then
|
if [ "\$LOGIN_STATUS" != "200" ]; then
|
||||||
echo "❌ 관리자 로그인 페이지 로드 실패 (상태: \$LOGIN_STATUS)" >&2
|
echo "❌ 관리자 로그인 페이지 로드 실패 (상태: \$LOGIN_STATUS)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "✓ [6/6] 관리자 페이지 로드 완료"
|
echo "✓ [4/4] 관리자 페이지 로드 완료"
|
||||||
|
|
||||||
# 검색 엔진은 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 "✓ 서비스 정상 (시도 \$i/\$ATTEMPTS)"
|
echo "✓ 서비스 정상 (시도 \$i/\$ATTEMPTS)"
|
||||||
# 구 배포 디렉토리 정리 (최근 5개 보존)
|
# 구 배포 디렉토리 정리 (최근 5개 보존)
|
||||||
@@ -366,19 +207,10 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
if [ "\$i" -eq "\$ATTEMPTS" ]; then
|
if [ "\$i" -eq "\$ATTEMPTS" ]; then
|
||||||
echo "=== FATAL: 서비스가 \$ATTEMPTS회 시도 후에도 응답하지 않음 ===" >&2
|
echo "=== FATAL: 서비스가 \$ATTEMPTS회 시도 후에도 응답하지 않음 ===" >&2
|
||||||
echo "--- 5001 listener ---" >&2
|
echo "--- systemd 상태 ---" >&2
|
||||||
ss -tlnp 2>/dev/null | grep ':5001 ' >&2 || true
|
systemctl is-active taxbaik >&2 || true
|
||||||
echo "--- active port file ---" >&2
|
echo "--- 최근 로그 50줄 ---" >&2
|
||||||
cat "\$DEPLOY_HOME/taxbaik_port" >&2 || true
|
journalctl -u taxbaik --no-pager -n 50 >&2
|
||||||
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
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo " 대기 중... (\$i/\$ATTEMPTS, HTTP \$STATUS)"
|
echo " 대기 중... (\$i/\$ATTEMPTS, HTTP \$STATUS)"
|
||||||
@@ -387,14 +219,6 @@ jobs:
|
|||||||
REMOTE
|
REMOTE
|
||||||
|
|
||||||
echo "✓ 배포 완료: taxbaik_${TIMESTAMP} @ $DEPLOY_HOST"
|
echo "✓ 배포 완료: taxbaik_${TIMESTAMP} @ $DEPLOY_HOST"
|
||||||
|
|
||||||
# ⚠️ 공개 도메인 검증은 제거함
|
|
||||||
# 이유: CI 환경에서 외부 URL 호출 시 방화벽/DNS 문제로 배포 실패 가능
|
|
||||||
# 로컬호스트 검증이 모두 통과했으므로 불필요
|
|
||||||
# echo "--- 실제 공개 도메인 종단 간 검증 (Nginx/Cloudflare 경유, 최대 3회 재시도) ---"
|
|
||||||
# bash ./scripts/taxbaik-smoke.sh
|
|
||||||
# echo "✓ 실제 공개 도메인 전체 정상"
|
|
||||||
|
|
||||||
send_telegram "✅ <b>TaxBaik 배포 완료</b>
|
send_telegram "✅ <b>TaxBaik 배포 완료</b>
|
||||||
|
|
||||||
커밋: <code>${COMMIT}</code>
|
커밋: <code>${COMMIT}</code>
|
||||||
|
|||||||
@@ -60,7 +60,3 @@ PublishProfiles/
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
appsettings.Development.json
|
appsettings.Development.json
|
||||||
|
|
||||||
# Scratch / temporary work - never commit, see docs/ENGINEERING_HARNESS.md
|
|
||||||
.scratch/
|
|
||||||
tmp/
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
| 3.3 | [주요 Python 패키지](#33-주요-python-패키지-시스템) | 시스템/venv 패키지 구분 |
|
| 3.3 | [주요 Python 패키지](#33-주요-python-패키지-시스템) | 시스템/venv 패키지 구분 |
|
||||||
| 4 | [서비스 아키텍처](#4-서비스-아키텍처) | 포트 맵, Nginx 리버스 프록시 |
|
| 4 | [서비스 아키텍처](#4-서비스-아키텍처) | 포트 맵, Nginx 리버스 프록시 |
|
||||||
| 4.1 | [포트 맵](#41-포트-맵) | 22, 80, 2222, 3000, 5000, 5432 |
|
| 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 | [Gitea](#5-gitea) | Docker Compose 설정, 시크릿, 데이터 경로 |
|
||||||
| 5.1 | [Docker Compose](#51-docker-compose) | `gitea:1.26.4`, PG 연동 |
|
| 5.1 | [Docker Compose](#51-docker-compose) | `gitea:1.26.4`, PG 연동 |
|
||||||
| 5.2 | [시크릿 관리](#52-시크릿-관리) | `/opt/stacks/gitea/.env` |
|
| 5.2 | [시크릿 관리](#52-시크릿-관리) | `/opt/stacks/gitea/.env` |
|
||||||
@@ -126,84 +126,16 @@ boto3, cryptography, Jinja2, jsonschema, fail2ban 등 시스템 레벨로 설치
|
|||||||
### 4.2. Nginx 리버스 프록시
|
### 4.2. Nginx 리버스 프록시
|
||||||
|
|
||||||
```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 {
|
||||||
server_name taxbaik.com www.taxbaik.com;
|
listen 80 default_server;
|
||||||
|
listen [::]:80 default_server;
|
||||||
|
server_name _;
|
||||||
client_max_body_size 512M;
|
client_max_body_size 512M;
|
||||||
|
|
||||||
|
# QuantEngine Blazor Web App
|
||||||
# /admin 하위 요청을 /taxbaik/admin 으로 리다이렉트하여 Blazor Base Path 대응
|
location /quant/ {
|
||||||
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 / {
|
|
||||||
proxy_pass http://127.0.0.1:5000/;
|
proxy_pass http://127.0.0.1:5000/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
@@ -215,64 +147,25 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
listen 443 ssl; # managed by Certbot
|
# Gitea (기본)
|
||||||
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem; # managed by Certbot
|
location / {
|
||||||
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem; # managed by Certbot
|
proxy_pass http://127.0.0.1:3000;
|
||||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
proxy_http_version 1.1;
|
||||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
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;
|
||||||
server {
|
proxy_read_timeout 300;
|
||||||
if ($host = www.taxbaik.com) {
|
proxy_connect_timeout 300;
|
||||||
return 301 https://$host$request_uri;
|
proxy_send_timeout 300;
|
||||||
} # 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
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**라우팅 요약**:
|
**라우팅 요약**:
|
||||||
- `http://taxbaik.com/` 또는 `http://www.taxbaik.com/` → TaxBaik 홈페이지 (내부 proxy: `http://127.0.0.1:5001/taxbaik/`)
|
- `http://178.104.200.7/` → Gitea Web UI
|
||||||
- `http://gitea.taxbaik.com/` → Gitea Web UI (내부 proxy: `http://127.0.0.1:3000`)
|
- `http://178.104.200.7/quant/` → QuantEngine Blazor Admin
|
||||||
- `http://quant.taxbaik.com/` → QuantEngine Blazor Admin (내부 proxy: `http://127.0.0.1:5000/`)
|
- `ssh://178.104.200.7:2222` → Gitea Git SSH
|
||||||
- `ssh://gitea.taxbaik.com:2222` → Gitea Git SSH
|
|
||||||
|
|
||||||
## 5. Gitea
|
## 5. Gitea
|
||||||
|
|
||||||
@@ -491,7 +384,7 @@ ClientAliveCountMax 2
|
|||||||
| **CI Runner** | Synology Act Runner | 6× `act_runner:latest` (Docker) |
|
| **CI Runner** | Synology Act Runner | 6× `act_runner:latest` (Docker) |
|
||||||
| **DB** | SQLite (파일 기반) | PostgreSQL 18 + SQLite (하이브리드) |
|
| **DB** | SQLite (파일 기반) | PostgreSQL 18 + SQLite (하이브리드) |
|
||||||
| **웹 Admin** | 없음 | QuantEngine Blazor (.NET 10, MudBlazor) |
|
| **웹 Admin** | 없음 | QuantEngine Blazor (.NET 10, MudBlazor) |
|
||||||
| **리버스 프록시** | Synology 내장 | Nginx (도메인 기반 분기 - 홈페이지, Gitea, Quant) |
|
| **리버스 프록시** | Synology 내장 | Nginx (`/` → Gitea, `/quant/` → Blazor) |
|
||||||
| **보안** | DSM 방화벽 | fail2ban + SSH 공개키 + 서비스 로컬바인드 |
|
| **보안** | DSM 방화벽 | fail2ban + SSH 공개키 + 서비스 로컬바인드 |
|
||||||
| **시크릿 관리** | `.secrets/kis_real.env` | `/opt/stacks/gitea/.env` |
|
| **시크릿 관리** | `.secrets/kis_real.env` | `/opt/stacks/gitea/.env` |
|
||||||
| **OS** | Synology DSM 7.x | Ubuntu 26.04 LTS |
|
| **OS** | Synology DSM 7.x | Ubuntu 26.04 LTS |
|
||||||
+141
-183
@@ -1,237 +1,195 @@
|
|||||||
# TaxBaik 경로 최적화 배포 가이드
|
# TaxBaik 배포 가이드
|
||||||
|
|
||||||
## 📋 개요
|
## 서버 초기 설정
|
||||||
|
|
||||||
**루트 경로 기준** 운영 서버 Nginx 설정을 자동으로 적용합니다.
|
### 1. PostgreSQL 데이터베이스 생성
|
||||||
|
|
||||||
- **변경 사항**: Nginx가 루트(`/`)를 TaxBaik 웹앱에 프록시하도록 설정
|
|
||||||
- **영향 범위**: Nginx 설정 파일만 수정 (앱 코드 변경 없음)
|
|
||||||
- **복구 방법**: 자동 백업 생성 (롤백 가능)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 빠른 배포 (자동화)
|
|
||||||
|
|
||||||
### Step 1: 스크립트 실행
|
|
||||||
|
|
||||||
운영 서버에서:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 스크립트 다운로드 & 실행
|
|
||||||
ssh kjh2064@178.104.200.7
|
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
|
# PostgreSQL 접속
|
||||||
chmod +x fix-nginx.sh
|
sudo -u postgres psql
|
||||||
sudo ./fix-nginx.sh
|
|
||||||
|
# 데이터베이스 및 사용자 생성
|
||||||
|
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
|
```bash
|
||||||
# 로컬에서 스크립트 업로드 및 실행
|
sudo cp deploy/taxbaik.service /etc/systemd/system/
|
||||||
scp scripts/fix-nginx-taxbaik.sh kjh2064@178.104.200.7:/tmp/
|
sudo systemctl daemon-reload
|
||||||
ssh kjh2064@178.104.200.7 "sudo /tmp/fix-nginx-taxbaik.sh"
|
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
|
```bash
|
||||||
# 1. 백업 생성
|
# 현재 Nginx 설정 확인
|
||||||
sudo cp /etc/nginx/sites-available/taxbaik-domains.conf /etc/nginx/sites-available/taxbaik-domains.conf.backup
|
sudo cat /etc/nginx/sites-available/default | head -30
|
||||||
|
|
||||||
# 2. 설정 수정 (위 내용으로 편집)
|
# location 블록 추가 (또는 기존 설정에 병합)
|
||||||
sudo nano /etc/nginx/sites-available/taxbaik-domains.conf
|
sudo cp deploy/nginx-taxbaik-locations.conf /etc/nginx/conf.d/taxbaik.conf
|
||||||
|
|
||||||
# 3. 문법 검증
|
# 테스트 및 재로드
|
||||||
sudo nginx -t
|
sudo nginx -t
|
||||||
|
|
||||||
# 4. Nginx 재로드
|
|
||||||
sudo systemctl reload 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
|
```bash
|
||||||
# 설정 내용 확인
|
# 공개 사이트 접근
|
||||||
sudo grep -A 5 "location /" /etc/nginx/sites-available/taxbaik-domains.conf
|
curl -I http://178.104.200.7/taxbaik/
|
||||||
|
|
||||||
# 예상 출력:
|
# 관리자 로그인 페이지
|
||||||
# location /taxbaik/ {
|
curl -I http://178.104.200.7/taxbaik/admin/login
|
||||||
# proxy_pass http://127.0.0.1:5001/;
|
|
||||||
# rewrite ^/taxbaik/(.*)$ /$1 break;
|
# 로그인 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
|
```bash
|
||||||
# 공개 사이트
|
# 초기 5개 포스트 확인
|
||||||
curl -I http://www.taxbaik.com/
|
curl http://178.104.200.7/taxbaik/blog
|
||||||
# 예상: HTTP 200
|
|
||||||
|
|
||||||
# 관리자 로그인
|
# 첫 번째 포스트 상세
|
||||||
curl -I http://www.taxbaik.com/admin/login
|
curl http://178.104.200.7/taxbaik/blog/accountant-mistakes-5
|
||||||
# 예상: HTTP 200
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. E2E 테스트
|
## 롤백
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 로컬에서 운영 서버 테스트
|
# 이전 버전으로 복귀
|
||||||
export E2E_BASE_URL="http://www.taxbaik.com"
|
ssh kjh2064@178.104.200.7
|
||||||
npx playwright test tests/e2e/admin-smoke.spec.ts tests/e2e/public-smoke.spec.ts
|
|
||||||
|
# 이전 배포 디렉토리 확인
|
||||||
|
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
|
```bash
|
||||||
# 자동 스크립트로 수정했을 경우:
|
ssh kjh2064@178.104.200.7
|
||||||
# - 백업 파일명: /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
|
|
||||||
|
|
||||||
# 또는 수동 설정으로 변경했을 경우:
|
# 서비스 상태
|
||||||
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
|
||||||
|
|
||||||
### 왜 이 변경이 필요한가?
|
# 애플리케이션 로그
|
||||||
|
journalctl -u taxbaik -f
|
||||||
1. **코드 변경**: PathBase 제거 → 앱이 루트 경로를 인식함
|
|
||||||
2. **로컬 동작**: localhost:5001에서는 `/admin/...`로 바로 접속
|
|
||||||
3. **운영 문제**: Nginx가 루트(`/`)를 전달해야 함
|
|
||||||
|
|
||||||
### 해결 원리
|
|
||||||
|
|
||||||
```
|
|
||||||
요청 경로: /admin/login
|
|
||||||
↓ (proxy_pass)
|
|
||||||
앱 수신: http://127.0.0.1:5001/admin/login
|
|
||||||
↓ (base href="/admin/")
|
|
||||||
브라우저 렌더: /admin/...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## 트러블슈팅
|
||||||
|
|
||||||
## 📝 체크리스트
|
| 증상 | 원인 | 해결 |
|
||||||
|
|------|------|------|
|
||||||
|
| 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 적용 전)
|
- [ ] SSL 인증서 적용 (Let's Encrypt)
|
||||||
|
- [ ] 도메인 연결 (현재는 IP 기반)
|
||||||
```
|
- [ ] 관리자 인증 보안 고도화 (rate limit, 비밀번호 교체 절차)
|
||||||
원인: Nginx가 /taxbaik을 제거하지 않음
|
- [ ] 블로그 포스트 수정 화면 완성
|
||||||
해결: 위 "배포" 단계 실행
|
- [ ] Naver/Google Search Console 등록
|
||||||
```
|
- [ ] 운영 관리자 비밀번호를 초기 시드값에서 교체하고 `TAXBAIK_ADMIN_TEST_PASSWORD` 갱신
|
||||||
|
|
||||||
### 증상: 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`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**배포 완료!** 🚀
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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
|
# ~/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 추가**:
|
**Gitea 저장소 Settings → Secrets 추가**:
|
||||||
- `DEPLOY_USER`: `kjh2064`
|
- `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` |
|
| 404 /taxbaik | Nginx 설정 미적용 | `sudo nginx -t && sudo systemctl reload nginx` |
|
||||||
| 502 Bad Gateway | 프록시 또는 백엔드 미실행 | `sudo systemctl restart taxbaik-proxy taxbaik` |
|
| 502 Bad Gateway | 앱 미실행 | `sudo systemctl restart taxbaik` |
|
||||||
| 503 Service Unavailable | 백엔드 충돌 또는 비밀값 누락 | 로그 확인: `journalctl -u taxbaik -n 50` |
|
| 503 Service Unavailable | 앱 충돌 | 로그 확인: `journalctl -u taxbaik -n 50` |
|
||||||
| DB 연결 오류 | 환경 변수 미설정 | systemd 파일의 ConnectionStrings__Default 확인 |
|
| DB 연결 오류 | 환경 변수 미설정 | systemd 파일의 ConnectionStrings__Default 확인 |
|
||||||
| HTTPS 오류 | SSL 미구성 | 개발 환경에서는 HTTP 사용 (IP 기반) |
|
| HTTPS 오류 | SSL 미구성 | 개발 환경에서는 HTTP 사용 (IP 기반) |
|
||||||
| 마이그레이션 실패 | 테이블 존재 | `DROP DATABASE taxbaikdb;` 후 재시작 |
|
| 마이그레이션 실패 | 테이블 존재 | `DROP DATABASE taxbaikdb;` 후 재시작 |
|
||||||
@@ -208,11 +230,11 @@ curl -I -H "Accept-Encoding: gzip" http://178.104.200.7/taxbaik/ | grep -i encod
|
|||||||
### 실시간 모니터링
|
### 실시간 모니터링
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 터미널 1: 백엔드 로그
|
# 터미널 1: 웹 서비스 로그
|
||||||
ssh kjh2064@178.104.200.7 'journalctl -u taxbaik -f'
|
ssh kjh2064@178.104.200.7 'journalctl -u taxbaik -f'
|
||||||
|
|
||||||
# 터미널 2: 프록시 로그
|
# 터미널 2: 통합 서비스 로그
|
||||||
ssh kjh2064@178.104.200.7 'journalctl -u taxbaik-proxy -f'
|
ssh kjh2064@178.104.200.7 'journalctl -u taxbaik -f'
|
||||||
|
|
||||||
# 터미널 3: Nginx 로그
|
# 터미널 3: Nginx 로그
|
||||||
ssh kjh2064@178.104.200.7 'sudo tail -f /var/log/nginx/access.log | grep taxbaik'
|
ssh kjh2064@178.104.200.7 'sudo tail -f /var/log/nginx/access.log | grep taxbaik'
|
||||||
@@ -224,7 +246,13 @@ ssh kjh2064@178.104.200.7 'watch -n 1 "ps aux | grep TaxBaik"'
|
|||||||
### 정기적 검사
|
### 정기적 검사
|
||||||
|
|
||||||
```bash
|
```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
|
git push origin master
|
||||||
|
|
||||||
# 2. Gitea Actions가 자동으로 배포
|
# 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)
|
# 롤백 (예: 이전 버전이 taxbaik_20260625_100000)
|
||||||
ssh kjh2064@178.104.200.7 << EOF
|
ssh kjh2064@178.104.200.7 << EOF
|
||||||
ln -sfn ~/deployments/taxbaik_20260625_100000 ~/taxbaik_active
|
ln -sfn ~/deployments/taxbaik_20260625_100000 ~/taxbaik_active
|
||||||
sudo systemctl restart taxbaik-proxy
|
|
||||||
sudo systemctl restart taxbaik
|
sudo systemctl restart taxbaik
|
||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
@@ -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/V003__SeedAdminAndBlogPosts.sql
|
||||||
psql -d taxbaikdb -f db/migrations/V004__CreateSiteSettings.sql
|
psql -d taxbaikdb -f db/migrations/V004__CreateSiteSettings.sql
|
||||||
|
|
||||||
# 3. 로컬 개발 환경 실행
|
# 3. 환경 변수 설정
|
||||||
$env:ASPNETCORE_ENVIRONMENT="Development"
|
export ConnectionStrings__Default="Host=localhost;Database=taxbaikdb;Username=postgres;Password=password"
|
||||||
dotnet watch run --project .\src\TaxBaik.Web\TaxBaik.Web.csproj
|
|
||||||
|
|
||||||
# 4. 브라우저 열기
|
# 4. 빌드 및 실행
|
||||||
# 공개 사이트: http://localhost:5001/
|
dotnet build TaxBaik.sln
|
||||||
# 관리자: http://localhost:5001/admin/login
|
dotnet run --project TaxBaik.Web
|
||||||
|
|
||||||
|
# 5. 브라우저 열기
|
||||||
|
# 공개 사이트: http://localhost:5001/taxbaik
|
||||||
|
# 관리자: http://localhost:5001/taxbaik/admin/login
|
||||||
```
|
```
|
||||||
|
|
||||||
### 초기 로그인 정보
|
### 초기 로그인 정보
|
||||||
|
|
||||||
- 운영 관리자: `admin / Admin@123456`
|
- **username**: `admin`
|
||||||
- E2E 자동화: `test_admin / TestAdmin@123456`
|
- **password**: `<TAXBAIK_ADMIN_TEST_PASSWORD>` or current rotated admin password
|
||||||
- 프로덕션 배포 시 비밀번호 변경은 필수이며, 검증용 비밀번호는 Gitea Secrets로 관리합니다.
|
|
||||||
|
> ⚠️ **중요**: 프로덕션 배포 시 비밀번호 변경 필수이며, 검증용 비밀번호는 Gitea Secrets로 관리
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -153,7 +157,7 @@ master 브랜치에 푸시하면 파이프라인이 다음 단계를 수행합
|
|||||||
4. `TaxBaik.Web` 게시
|
4. `TaxBaik.Web` 게시
|
||||||
5. 원격 서버 배포 디렉토리 업로드 및 `taxbaik_active` 심링크 교체
|
5. 원격 서버 배포 디렉토리 업로드 및 `taxbaik_active` 심링크 교체
|
||||||
6. systemd `taxbaik` 단일 서비스 재시작
|
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가 통과했을 때만 한다.
|
배포 완료 판정은 위 단계가 모두 성공하고, 배포본 기준 Playwright E2E가 통과했을 때만 한다.
|
||||||
|
|
||||||
@@ -161,25 +165,10 @@ master 브랜치에 푸시하면 파이프라인이 다음 단계를 수행합
|
|||||||
- `DEPLOY_USER`: kjh2064
|
- `DEPLOY_USER`: kjh2064
|
||||||
- `DEPLOY_HOST`: 178.104.200.7
|
- `DEPLOY_HOST`: 178.104.200.7
|
||||||
- `DEPLOY_SSH_KEY_B64`: base64로 인코딩한 SSH 개인키
|
- `DEPLOY_SSH_KEY_B64`: base64로 인코딩한 SSH 개인키
|
||||||
- `TAXBAIK_ADMIN_TEST_PASSWORD`: 배포 검증용 관리자 비밀번호 (`test_admin / TestAdmin@123456`)
|
- `TAXBAIK_ADMIN_TEST_PASSWORD`: 배포 검증용 관리자 비밀번호
|
||||||
- `Admin__PasswordResetToken`: 관리자 비밀번호 재설정 API용 서버 비밀값
|
- `Admin__PasswordResetToken`: 관리자 비밀번호 재설정 API용 서버 비밀값
|
||||||
|
|
||||||
배포는 Gitea Actions CI/CD로만 수행합니다. 수동 배포 경로는 CI 하네스로 차단되어 있으며, 실패 시 [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)의 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`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -273,22 +262,15 @@ kill -9 <PID>
|
|||||||
# 연결 테스트
|
# 연결 테스트
|
||||||
psql -U taxbaik -d taxbaikdb -c "SELECT 1;"
|
psql -U taxbaik -d taxbaikdb -c "SELECT 1;"
|
||||||
|
|
||||||
# 개발 환경에서는 appsettings.Development.json 우선
|
# 환경 변수 확인
|
||||||
echo $env:ASPNETCORE_ENVIRONMENT
|
echo $ConnectionStrings__Default
|
||||||
echo $env:ConnectionStrings__Default
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 문서
|
## 문서
|
||||||
|
|
||||||
- [docs/INDEX.md](./docs/INDEX.md) - 현재 개발 기준 인덱스
|
- [CLAUDE.md](./CLAUDE.md) - LLM 개발 지침 (9개 섹션)
|
||||||
- [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 개발 지침
|
|
||||||
- [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) - 배포 완전 가이드
|
- [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) - 배포 완전 가이드
|
||||||
- [SERVER_SETUP.sh](./SERVER_SETUP.sh) - 서버 자동 설치 스크립트
|
- [SERVER_SETUP.sh](./SERVER_SETUP.sh) - 서버 자동 설치 스크립트
|
||||||
|
|
||||||
|
|||||||
@@ -425,9 +425,9 @@ Todo:
|
|||||||
- 텔레그램 전송 실패 시 로그만 남기고 앱 정상 운영 유지
|
- 텔레그램 전송 실패 시 로그만 남기고 앱 정상 운영 유지
|
||||||
|
|
||||||
Todo:
|
Todo:
|
||||||
- [x] BackgroundService 또는 Hangfire 기반 스케줄러 추가
|
- [ ] BackgroundService 또는 Hangfire 기반 스케줄러 추가
|
||||||
- [x] 일간/주간 리포트 메시지 템플릿
|
- [ ] 일간/주간 리포트 메시지 템플릿
|
||||||
- [x] TelegramNotificationService에 리포트 메서드 추가
|
- [ ] TelegramNotificationService에 리포트 메서드 추가
|
||||||
|
|
||||||
## WBS-CRM-07 고객 포털 (읽기 전용) — Phase 3
|
## WBS-CRM-07 고객 포털 (읽기 전용) — Phase 3
|
||||||
|
|
||||||
@@ -439,9 +439,9 @@ Todo:
|
|||||||
- 개인정보 열람 범위는 세무사가 허용한 항목만
|
- 개인정보 열람 범위는 세무사가 허용한 항목만
|
||||||
|
|
||||||
Todo:
|
Todo:
|
||||||
- [x] 고객 포털 설계 (인증 방식 결정 — WBS-CRM-08 선행)
|
- [ ] 고객 포털 설계 (인증 방식 결정 — WBS-CRM-08 선행)
|
||||||
- [x] 고객 전용 Razor Pages 추가
|
- [ ] 고객 전용 Razor Pages 추가
|
||||||
- [x] 세무사 허용 권한 설정 UI
|
- [ ] 세무사 허용 권한 설정 UI
|
||||||
|
|
||||||
## WBS-CRM-08 고객 회원가입 · 소셜 로그인 — Phase 3
|
## WBS-CRM-08 고객 회원가입 · 소셜 로그인 — Phase 3
|
||||||
|
|
||||||
@@ -485,16 +485,16 @@ DB 스키마:
|
|||||||
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`
|
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`
|
||||||
|
|
||||||
Todo:
|
Todo:
|
||||||
- [x] WBS-CRM-07 고객 포털 기본 구조 완성 (선행)
|
- [ ] WBS-CRM-07 고객 포털 기본 구조 완성 (선행)
|
||||||
- [x] OAuth 앱 등록 (네이버·카카오·구글 개발자 콘솔)
|
- [ ] OAuth 앱 등록 (네이버·카카오·구글 개발자 콘솔)
|
||||||
- [x] V011__CreatePortalUsers.sql 마이그레이션 (실제 V016__CreatePortalUsers.sql로 대체됨)
|
- [ ] V011__CreatePortalUsers.sql 마이그레이션
|
||||||
- [x] PortalUser 엔티티 / IPortalUserRepository / PortalUserRepository
|
- [ ] PortalUser 엔티티 / IPortalUserRepository / PortalUserRepository
|
||||||
- [x] 네이버 OAuth Handler 구현
|
- [ ] 네이버 OAuth Handler 구현
|
||||||
- [x] 카카오·구글 패키지 추가 및 설정
|
- [ ] 카카오·구글 패키지 추가 및 설정
|
||||||
- [x] 기본 계정 회원가입 폼 (`/taxbaik/portal/register`)
|
- [ ] 기본 계정 회원가입 폼 (`/taxbaik/portal/register`)
|
||||||
- [x] 소셜 로그인 콜백 처리 → portal_users 자동 생성
|
- [ ] 소셜 로그인 콜백 처리 → portal_users 자동 생성
|
||||||
- [x] 신규 가입 시 clients 테이블 연결 또는 신규 생성
|
- [ ] 신규 가입 시 clients 테이블 연결 또는 신규 생성
|
||||||
- [x] 포털 로그인 페이지 (`/taxbaik/portal/login`) — 소셜 버튼 + 이메일 폼
|
- [ ] 포털 로그인 페이지 (`/taxbaik/portal/login`) — 소셜 버튼 + 이메일 폼
|
||||||
- [ ] Gitea Secrets에 OAuth 키 추가
|
- [ ] Gitea Secrets에 OAuth 키 추가
|
||||||
- [ ] 배포 후 소셜 로그인 3종 E2E 테스트
|
- [ ] 배포 후 소셜 로그인 3종 E2E 테스트
|
||||||
|
|
||||||
@@ -522,46 +522,3 @@ Todo:
|
|||||||
- WBS-UX-03/04 구현 완료
|
- WBS-UX-03/04 구현 완료
|
||||||
- WBS-CRM-01/02/03/04/05 구현 완료 (배포 후 검증 필요)
|
- WBS-CRM-01/02/03/04/05 구현 완료 (배포 후 검증 필요)
|
||||||
- WBS-CRM-06/07/08 (텔레그램·포털·소셜 로그인) Phase 3 미착수
|
- 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 연계 바인딩 처리
|
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-32
@@ -1,7 +1,5 @@
|
|||||||
namespace TaxBaik.Application.Tests;
|
namespace TaxBaik.Application.Tests;
|
||||||
|
|
||||||
using FluentValidation;
|
|
||||||
using TaxBaik.Application.DTOs;
|
|
||||||
using TaxBaik.Application.Services;
|
using TaxBaik.Application.Services;
|
||||||
using TaxBaik.Domain.Entities;
|
using TaxBaik.Domain.Entities;
|
||||||
using TaxBaik.Domain.Interfaces;
|
using TaxBaik.Domain.Interfaces;
|
||||||
@@ -13,18 +11,18 @@ public class InquiryServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateStatusAsync_WhenStatusIsInvalid_ThrowsValidationException()
|
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]
|
[Fact]
|
||||||
public async Task SubmitAsync_StoresEmailAndNewStatus()
|
public async Task SubmitAsync_StoresEmailAndNewStatus()
|
||||||
{
|
{
|
||||||
var repository = new FakeInquiryRepository();
|
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("user@example.com", repository.Inquiries.Single().Email);
|
||||||
Assert.Equal("new", repository.Inquiries.Single().Status);
|
Assert.Equal("new", repository.Inquiries.Single().Status);
|
||||||
@@ -82,22 +80,6 @@ public class InquiryServiceTests
|
|||||||
return Task.CompletedTask;
|
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)
|
public Task LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var inquiry = Inquiries.FirstOrDefault(x => x.Id == inquiryId);
|
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)
|
public Task NotifyStatusChangedAsync(int inquiryId, string name, string phone, string serviceType, string previousStatus, string newStatus, string? changedBy = null, CancellationToken ct = default)
|
||||||
=> Task.CompletedTask;
|
=> 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
-1
@@ -18,6 +18,5 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\TaxBaik.Application\TaxBaik.Application.csproj" />
|
<ProjectReference Include="..\TaxBaik.Application\TaxBaik.Application.csproj" />
|
||||||
<ProjectReference Include="..\TaxBaik.Web\TaxBaik.Web.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</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;
|
namespace TaxBaik.Application.DTOs;
|
||||||
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
|
|
||||||
public class ClientDto
|
public class ClientDto
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
@@ -20,31 +18,13 @@ public class ClientDto
|
|||||||
|
|
||||||
public class CreateClientDto
|
public class CreateClientDto
|
||||||
{
|
{
|
||||||
[Required(ErrorMessage = "고객명을 입력하세요.")]
|
|
||||||
[StringLength(100, ErrorMessage = "고객명은 최대 100자까지 입력 가능합니다.")]
|
|
||||||
public string Name { get; set; } = null!;
|
public string Name { get; set; } = null!;
|
||||||
|
|
||||||
[StringLength(100, ErrorMessage = "회사명은 최대 100자까지 입력 가능합니다.")]
|
|
||||||
public string? CompanyName { get; set; }
|
public string? CompanyName { get; set; }
|
||||||
|
|
||||||
[StringLength(20, ErrorMessage = "전화번호는 최대 20자까지 입력 가능합니다.")]
|
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
|
|
||||||
[EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")]
|
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
|
|
||||||
[StringLength(50, ErrorMessage = "서비스 유형은 최대 50자까지 입력 가능합니다.")]
|
|
||||||
public string? ServiceType { get; set; }
|
public string? ServiceType { get; set; }
|
||||||
|
|
||||||
[StringLength(50, ErrorMessage = "세금 유형은 최대 50자까지 입력 가능합니다.")]
|
|
||||||
public string? TaxType { get; set; }
|
public string? TaxType { get; set; }
|
||||||
|
|
||||||
[Required(ErrorMessage = "상태를 선택하세요.")]
|
|
||||||
public string Status { get; set; } = "active";
|
public string Status { get; set; } = "active";
|
||||||
|
|
||||||
[StringLength(50, ErrorMessage = "유입 경로는 최대 50자까지 입력 가능합니다.")]
|
|
||||||
public string? Source { get; set; }
|
public string? Source { get; set; }
|
||||||
|
|
||||||
[StringLength(1000, ErrorMessage = "메모는 최대 1000자까지 입력 가능합니다.")]
|
|
||||||
public string? Memo { get; set; }
|
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; }
|
||||||
|
}
|
||||||
-1
@@ -27,7 +27,6 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<RevenueTrackingService>();
|
services.AddScoped<RevenueTrackingService>();
|
||||||
services.AddScoped<TelegramReportService>();
|
services.AddScoped<TelegramReportService>();
|
||||||
services.AddScoped<PortalUserService>();
|
services.AddScoped<PortalUserService>();
|
||||||
services.AddScoped<CommonCodeService>();
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -66,7 +66,7 @@ public static class TaxSeasonCalendar
|
|||||||
Name = "부가가치세 1기 확정신고",
|
Name = "부가가치세 1기 확정신고",
|
||||||
StartMonth = 7, StartDay = 1,
|
StartMonth = 7, StartDay = 1,
|
||||||
EndMonth = 7, EndDay = 25,
|
EndMonth = 7, EndDay = 25,
|
||||||
HeroHeadline = "부가가치세 1기\n7월 27일 마감",
|
HeroHeadline = "부가가치세 1기\n7월 25일 마감",
|
||||||
HeroSubtext = "일반과세 사업자 1기 확정신고 · 매입세액 공제 점검",
|
HeroSubtext = "일반과세 사업자 1기 확정신고 · 매입세액 공제 점검",
|
||||||
UrgencyBadge = "D-{n}일 | 부가세 마감",
|
UrgencyBadge = "D-{n}일 | 부가세 마감",
|
||||||
FocusService = "business-tax",
|
FocusService = "business-tax",
|
||||||
+1
-4
@@ -1,11 +1,10 @@
|
|||||||
namespace TaxBaik.Application.Services;
|
namespace TaxBaik.Application.Services;
|
||||||
|
|
||||||
using FluentValidation;
|
|
||||||
using TaxBaik.Application.DTOs;
|
using TaxBaik.Application.DTOs;
|
||||||
using TaxBaik.Domain.Entities;
|
using TaxBaik.Domain.Entities;
|
||||||
using TaxBaik.Domain.Interfaces;
|
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)
|
public Task<IEnumerable<Announcement>> GetActiveAsync(CancellationToken ct = default)
|
||||||
=> repository.GetActiveAsync(ct);
|
=> repository.GetActiveAsync(ct);
|
||||||
@@ -18,14 +17,12 @@ public class AnnouncementService(IAnnouncementRepository repository, IValidator<
|
|||||||
|
|
||||||
public Task<int> CreateAsync(AnnouncementDto dto, CancellationToken ct = default)
|
public Task<int> CreateAsync(AnnouncementDto dto, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
validator.ValidateAndThrow(dto);
|
|
||||||
var entity = MapToEntity(dto);
|
var entity = MapToEntity(dto);
|
||||||
return repository.CreateAsync(entity, ct);
|
return repository.CreateAsync(entity, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task UpdateAsync(AnnouncementDto dto, CancellationToken ct = default)
|
public Task UpdateAsync(AnnouncementDto dto, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
validator.ValidateAndThrow(dto);
|
|
||||||
var entity = MapToEntity(dto);
|
var entity = MapToEntity(dto);
|
||||||
return repository.UpdateAsync(entity, ct);
|
return repository.UpdateAsync(entity, ct);
|
||||||
}
|
}
|
||||||
+1
-37
@@ -1,18 +1,13 @@
|
|||||||
namespace TaxBaik.Application.Services;
|
namespace TaxBaik.Application.Services;
|
||||||
|
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using FluentValidation;
|
|
||||||
using TaxBaik.Application.DTOs;
|
using TaxBaik.Application.DTOs;
|
||||||
using TaxBaik.Domain.Entities;
|
using TaxBaik.Domain.Entities;
|
||||||
using TaxBaik.Domain.Interfaces;
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
public class BlogService(
|
public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCache)
|
||||||
IBlogPostRepository repository,
|
|
||||||
ICategoryRepository categoryRepository,
|
|
||||||
IMemoryCache memoryCache,
|
|
||||||
IValidator<CreateBlogPostDto> validator)
|
|
||||||
{
|
{
|
||||||
public async Task<BlogPost?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
public async Task<BlogPost?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||||
await repository.GetByIdAsync(id, ct);
|
await repository.GetByIdAsync(id, ct);
|
||||||
@@ -47,14 +42,9 @@ public class BlogService(
|
|||||||
int page, int pageSize, CancellationToken ct = default) =>
|
int page, int pageSize, CancellationToken ct = default) =>
|
||||||
await repository.GetAdminPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), ct);
|
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)
|
public async Task<int> CreateAsync(BlogPost post, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ValidatePost(post);
|
ValidatePost(post);
|
||||||
post.CategoryId ??= await EnsureCategoryIdAsync(0, ct);
|
|
||||||
post.Title = post.Title.Trim();
|
post.Title = post.Title.Trim();
|
||||||
post.Content = post.Content.Trim();
|
post.Content = post.Content.Trim();
|
||||||
post.Slug = await GenerateUniqueSlugAsync(post.Title, ct: ct);
|
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)
|
public async Task<BlogPost> CreateAsync(CreateBlogPostDto dto, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
validator.ValidateAndThrow(dto);
|
|
||||||
dto.CategoryId = await EnsureCategoryIdAsync(dto.CategoryId, ct);
|
|
||||||
var post = new BlogPost
|
var post = new BlogPost
|
||||||
{
|
{
|
||||||
Title = dto.Title,
|
Title = dto.Title,
|
||||||
@@ -89,15 +77,12 @@ public class BlogService(
|
|||||||
|
|
||||||
public async Task UpdateAsync(BlogPost post, CancellationToken ct = default)
|
public async Task UpdateAsync(BlogPost post, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
post.CategoryId ??= await EnsureCategoryIdAsync(0, ct);
|
|
||||||
await repository.UpdateAsync(post, ct);
|
await repository.UpdateAsync(post, ct);
|
||||||
memoryCache.Remove(AdminDashboardService.CacheKey);
|
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<BlogPost?> UpdateAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default)
|
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);
|
var post = await repository.GetByIdAsync(id, ct);
|
||||||
if (post == null)
|
if (post == null)
|
||||||
return null;
|
return null;
|
||||||
@@ -125,18 +110,6 @@ public class BlogService(
|
|||||||
memoryCache.Remove(AdminDashboardService.CacheKey);
|
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) =>
|
public async Task IncrementViewCountAsync(int id, CancellationToken ct = default) =>
|
||||||
await repository.IncrementViewCountAsync(id, ct);
|
await repository.IncrementViewCountAsync(id, ct);
|
||||||
|
|
||||||
@@ -186,15 +159,6 @@ public class BlogService(
|
|||||||
|
|
||||||
private static int NormalizePageSize(int pageSize) => Math.Clamp(pageSize, 1, 100);
|
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)
|
public async Task<(int TotalPosts, int PublishedPosts)> GetStatsAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var posts = (await repository.GetAllForAdminAsync(ct)).ToList();
|
var posts = (await repository.GetAllForAdminAsync(ct)).ToList();
|
||||||
+15
-5
@@ -1,12 +1,20 @@
|
|||||||
namespace TaxBaik.Application.Services;
|
namespace TaxBaik.Application.Services;
|
||||||
|
|
||||||
using FluentValidation;
|
|
||||||
using TaxBaik.Application.DTOs;
|
using TaxBaik.Application.DTOs;
|
||||||
using TaxBaik.Domain.Entities;
|
using TaxBaik.Domain.Entities;
|
||||||
using TaxBaik.Domain.Interfaces;
|
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(
|
public async Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
|
||||||
int page, int pageSize, string? status = null, string? search = null, CancellationToken ct = default) =>
|
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);
|
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)
|
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
|
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)
|
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)
|
var client = await repository.GetByIdAsync(id, ct)
|
||||||
?? throw new KeyNotFoundException($"고객 ID {id}를 찾을 수 없습니다.");
|
?? throw new KeyNotFoundException($"고객 ID {id}를 찾을 수 없습니다.");
|
||||||
@@ -71,7 +81,7 @@ public class ClientService(IClientRepository repository, IValidator<CreateClient
|
|||||||
Phone = phone?.Trim(),
|
Phone = phone?.Trim(),
|
||||||
ServiceType = serviceType,
|
ServiceType = serviceType,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Source = "홈페이지문의"
|
Source = "홈페이지 문의"
|
||||||
};
|
};
|
||||||
return await repository.CreateAsync(client, ct);
|
return await repository.CreateAsync(client, ct);
|
||||||
}
|
}
|
||||||
-3
@@ -33,9 +33,6 @@ public class ConsultingActivityService(IConsultingActivityRepository repository)
|
|||||||
public async Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
public async Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||||
await repository.GetByClientIdAsync(clientId, ct);
|
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) =>
|
public async Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken ct = default) =>
|
||||||
await repository.GetPendingFollowupsAsync(ct);
|
await repository.GetPendingFollowupsAsync(ct);
|
||||||
|
|
||||||
-3
@@ -36,9 +36,6 @@ public class ContractService(IContractRepository repository)
|
|||||||
public async Task<Contract?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
public async Task<Contract?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||||
await repository.GetByIdAsync(id, ct);
|
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) =>
|
public async Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||||
await repository.GetByClientIdAsync(clientId, ct);
|
await repository.GetByClientIdAsync(clientId, ct);
|
||||||
|
|
||||||
+11
-5
@@ -1,13 +1,12 @@
|
|||||||
namespace TaxBaik.Application.Services;
|
namespace TaxBaik.Application.Services;
|
||||||
|
|
||||||
using FluentValidation;
|
|
||||||
using TaxBaik.Domain.Entities;
|
using TaxBaik.Domain.Entities;
|
||||||
using TaxBaik.Domain.Interfaces;
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
public class FaqService(IFaqRepository repository, IValidator<Faq> validator)
|
public class FaqService(IFaqRepository repository)
|
||||||
{
|
{
|
||||||
public static readonly string[] Categories =
|
public static readonly string[] Categories =
|
||||||
["기장세금신고", "부동산", "증여상속", "기타"];
|
["기장·세금신고", "부동산", "증여·상속", "기타"];
|
||||||
|
|
||||||
public async Task<IEnumerable<Faq>> GetActiveAsync(CancellationToken ct = default) =>
|
public async Task<IEnumerable<Faq>> GetActiveAsync(CancellationToken ct = default) =>
|
||||||
await repository.GetActiveAsync(ct);
|
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)
|
public async Task<int> CreateAsync(Faq faq, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
validator.ValidateAndThrow(faq);
|
Validate(faq);
|
||||||
return await repository.CreateAsync(faq, ct);
|
return await repository.CreateAsync(faq, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateAsync(Faq faq, CancellationToken ct = default)
|
public async Task UpdateAsync(Faq faq, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
validator.ValidateAndThrow(faq);
|
Validate(faq);
|
||||||
await repository.UpdateAsync(faq, ct);
|
await repository.UpdateAsync(faq, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(int id, CancellationToken ct = default) =>
|
public async Task DeleteAsync(int id, CancellationToken ct = default) =>
|
||||||
await repository.DeleteAsync(id, ct);
|
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) { }
|
||||||
|
}
|
||||||
+9
-16
@@ -4,29 +4,22 @@ using TaxBaik.Domain.Enums;
|
|||||||
|
|
||||||
public static class InquiryStatusMapper
|
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()
|
public static readonly Dictionary<string, string> Labels = new()
|
||||||
{
|
{
|
||||||
["new"] = "신규",
|
["new"] = "신규",
|
||||||
["consulting"] = "상담중",
|
["consulting"] = "상담중",
|
||||||
["contracted"] = "계약완료",
|
["contracted"] = "계약완료",
|
||||||
["rejected"] = "거절",
|
["rejected"] = "거절",
|
||||||
["closed"] = "종결",
|
["closed"] = "종결",
|
||||||
};
|
};
|
||||||
|
|
||||||
public static string ToStorageValue(InquiryStatus status) => status switch
|
public static string ToStorageValue(InquiryStatus status) => status switch
|
||||||
{
|
{
|
||||||
InquiryStatus.New => "new",
|
InquiryStatus.New => "new",
|
||||||
InquiryStatus.Consulting => "consulting",
|
InquiryStatus.Consulting => "consulting",
|
||||||
InquiryStatus.Contracted => "contracted",
|
InquiryStatus.Contracted => "contracted",
|
||||||
InquiryStatus.Rejected => "rejected",
|
InquiryStatus.Rejected => "rejected",
|
||||||
InquiryStatus.Closed => "closed",
|
InquiryStatus.Closed => "closed",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null)
|
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,11 +28,11 @@ public static class InquiryStatusMapper
|
|||||||
var key = value?.Trim().ToLowerInvariant();
|
var key = value?.Trim().ToLowerInvariant();
|
||||||
status = key switch
|
status = key switch
|
||||||
{
|
{
|
||||||
"new" => InquiryStatus.New,
|
"new" => InquiryStatus.New,
|
||||||
"consulting" => InquiryStatus.Consulting,
|
"consulting" => InquiryStatus.Consulting,
|
||||||
"contracted" => InquiryStatus.Contracted,
|
"contracted" => InquiryStatus.Contracted,
|
||||||
"rejected" => InquiryStatus.Rejected,
|
"rejected" => InquiryStatus.Rejected,
|
||||||
"closed" => InquiryStatus.Closed,
|
"closed" => InquiryStatus.Closed,
|
||||||
_ => default
|
_ => default
|
||||||
};
|
};
|
||||||
return key is "new" or "consulting" or "contracted" or "rejected" or "closed";
|
return key is "new" or "consulting" or "contracted" or "rejected" or "closed";
|
||||||
-3
@@ -34,9 +34,6 @@ public class RevenueTrackingService(IRevenueTrackingRepository repository)
|
|||||||
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||||
await repository.GetByClientIdAsync(clientId, ct);
|
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) =>
|
public async Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken ct = default) =>
|
||||||
await repository.GetPendingPaymentsAsync(ct);
|
await repository.GetPendingPaymentsAsync(ct);
|
||||||
|
|
||||||
+2
-3
@@ -15,8 +15,7 @@ public class SeasonalMarketingService
|
|||||||
|
|
||||||
if (today >= start && today <= end)
|
if (today >= start && today <= end)
|
||||||
{
|
{
|
||||||
var effectiveEnd = BusinessDayCalculator.GetEffectiveBusinessDate(DateOnly.FromDateTime(end)).ToDateTime(TimeOnly.MinValue);
|
var days = (end - today).Days;
|
||||||
var days = BusinessDayCalculator.GetBusinessDayDiff(DateOnly.FromDateTime(end), DateOnly.FromDateTime(today));
|
|
||||||
return new CurrentSeasonDto
|
return new CurrentSeasonDto
|
||||||
{
|
{
|
||||||
Key = season.Key,
|
Key = season.Key,
|
||||||
@@ -28,7 +27,7 @@ public class SeasonalMarketingService
|
|||||||
RelatedCategorySlug = season.RelatedCategorySlug,
|
RelatedCategorySlug = season.RelatedCategorySlug,
|
||||||
CtaText = season.CtaText,
|
CtaText = season.CtaText,
|
||||||
DaysUntilDeadline = days,
|
DaysUntilDeadline = days,
|
||||||
Deadline = effectiveEnd
|
Deadline = end
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
-3
@@ -33,9 +33,6 @@ public class TaxFilingScheduleService(ITaxFilingScheduleRepository repository)
|
|||||||
public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||||
await repository.GetByIdAsync(id, ct);
|
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) =>
|
public async Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||||
await repository.GetByClientIdAsync(clientId, ct);
|
await repository.GetByClientIdAsync(clientId, ct);
|
||||||
|
|
||||||
+6
-3
@@ -5,14 +5,17 @@ using TaxBaik.Domain.Interfaces;
|
|||||||
|
|
||||||
public class TaxFilingService(ITaxFilingRepository repository)
|
public class TaxFilingService(ITaxFilingRepository repository)
|
||||||
{
|
{
|
||||||
|
public static readonly string[] FilingTypes =
|
||||||
|
["부가가치세", "종합소득세", "법인세", "원천징수", "종합부동산세", "증여세", "상속세", "기타"];
|
||||||
|
|
||||||
public static readonly string[] Statuses =
|
public static readonly string[] Statuses =
|
||||||
["pending", "filed", "overdue"];
|
["pending", "filed", "overdue"];
|
||||||
|
|
||||||
public static readonly Dictionary<string, string> StatusLabels = new()
|
public static readonly Dictionary<string, string> StatusLabels = new()
|
||||||
{
|
{
|
||||||
["pending"] = "신고 예정",
|
["pending"] = "신고 예정",
|
||||||
["filed"] = "신고 완료",
|
["filed"] = "신고 완료",
|
||||||
["overdue"] = "기한 초과",
|
["overdue"] = "기한 초과",
|
||||||
};
|
};
|
||||||
|
|
||||||
public async Task<IEnumerable<TaxFiling>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
public async Task<IEnumerable<TaxFiling>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||||
+1
-7
@@ -31,16 +31,10 @@ public class TaxProfileService(ITaxProfileRepository repository)
|
|||||||
public async Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
public async Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||||
await repository.GetByClientIdAsync(clientId, ct);
|
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,
|
public async Task UpdateAsync(int profileId, string? businessType, string? accountingMethod,
|
||||||
DateTime? nextFilingDueDate, string taxRiskLevel = "normal", CancellationToken ct = default)
|
DateTime? nextFilingDueDate, string taxRiskLevel = "normal", CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var profile = await repository.GetByIdAsync(profileId, ct);
|
var profile = new TaxProfile { Id = profileId };
|
||||||
if (profile == null)
|
|
||||||
throw new ValidationException("세무 프로필을 찾을 수 없습니다.");
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(businessType))
|
if (!string.IsNullOrWhiteSpace(businessType))
|
||||||
profile.BusinessType = businessType.Trim();
|
profile.BusinessType = businessType.Trim();
|
||||||
if (!string.IsNullOrWhiteSpace(accountingMethod))
|
if (!string.IsNullOrWhiteSpace(accountingMethod))
|
||||||
-1
@@ -6,7 +6,6 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.0" />
|
||||||
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
@@ -17,7 +17,6 @@ public class BlogPost
|
|||||||
public bool IsPublished { get; set; }
|
public bool IsPublished { get; set; }
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public DateTime UpdatedAt { get; set; }
|
public DateTime UpdatedAt { get; set; }
|
||||||
public DateTime? DeletedAt { get; set; }
|
|
||||||
|
|
||||||
// Navigation property (populated via LEFT JOIN, not stored in DB)
|
// Navigation property (populated via LEFT JOIN, not stored in DB)
|
||||||
public string? CategoryName { get; set; }
|
public string? CategoryName { get; set; }
|
||||||
-4
@@ -12,12 +12,8 @@ public interface IBlogPostRepository
|
|||||||
Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default);
|
Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default);
|
||||||
Task<(IEnumerable<BlogPost> Items, int Total)> GetAdminPagedAsync(
|
Task<(IEnumerable<BlogPost> Items, int Total)> GetAdminPagedAsync(
|
||||||
int page, int pageSize, CancellationToken cancellationToken = default);
|
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<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default);
|
||||||
Task UpdateAsync(BlogPost post, CancellationToken cancellationToken = default);
|
Task UpdateAsync(BlogPost post, CancellationToken cancellationToken = default);
|
||||||
Task DeleteAsync(int id, 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);
|
Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
-1
@@ -5,7 +5,6 @@ using TaxBaik.Domain.Entities;
|
|||||||
public interface IConsultingActivityRepository
|
public interface IConsultingActivityRepository
|
||||||
{
|
{
|
||||||
Task<int> CreateAsync(ConsultingActivity activity, CancellationToken cancellationToken = default);
|
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>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||||
Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken cancellationToken = default);
|
Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken cancellationToken = default);
|
||||||
Task<IEnumerable<ConsultingActivity>> GetByConsultantAsync(int consultantId, DateTime fromDate, CancellationToken cancellationToken = default);
|
Task<IEnumerable<ConsultingActivity>> GetByConsultantAsync(int consultantId, DateTime fromDate, CancellationToken cancellationToken = default);
|
||||||
-1
@@ -5,7 +5,6 @@ using TaxBaik.Domain.Entities;
|
|||||||
public interface IContractRepository
|
public interface IContractRepository
|
||||||
{
|
{
|
||||||
Task<int> CreateAsync(Contract contract, CancellationToken cancellationToken = default);
|
Task<int> CreateAsync(Contract contract, CancellationToken cancellationToken = default);
|
||||||
Task<IEnumerable<Contract>> GetAllAsync(CancellationToken cancellationToken = default);
|
|
||||||
Task<Contract?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
Task<Contract?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||||
Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||||
Task<IEnumerable<Contract>> GetActiveContractsAsync(CancellationToken cancellationToken = default);
|
Task<IEnumerable<Contract>> GetActiveContractsAsync(CancellationToken cancellationToken = default);
|
||||||
-1
@@ -15,7 +15,6 @@ public interface IInquiryRepository
|
|||||||
Task<int> CountByStatusAndDateAsync(string status, DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default);
|
Task<int> CountByStatusAndDateAsync(string status, DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default);
|
||||||
Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default);
|
Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default);
|
||||||
Task UpdateAdminMemoAsync(int id, string? adminMemo, 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 LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default);
|
||||||
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
-1
@@ -5,7 +5,6 @@ using TaxBaik.Domain.Entities;
|
|||||||
public interface IRevenueTrackingRepository
|
public interface IRevenueTrackingRepository
|
||||||
{
|
{
|
||||||
Task<int> CreateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default);
|
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>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||||
Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken cancellationToken = default);
|
Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken cancellationToken = default);
|
||||||
Task<IEnumerable<RevenueTracking>> GetByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default);
|
Task<IEnumerable<RevenueTracking>> GetByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default);
|
||||||
-1
@@ -5,7 +5,6 @@ using TaxBaik.Domain.Entities;
|
|||||||
public interface ITaxFilingScheduleRepository
|
public interface ITaxFilingScheduleRepository
|
||||||
{
|
{
|
||||||
Task<int> CreateAsync(TaxFilingSchedule schedule, CancellationToken cancellationToken = default);
|
Task<int> CreateAsync(TaxFilingSchedule schedule, CancellationToken cancellationToken = default);
|
||||||
Task<IEnumerable<TaxFilingSchedule>> GetAllAsync(CancellationToken cancellationToken = default);
|
|
||||||
Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||||
Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||||
Task<IEnumerable<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken cancellationToken = default);
|
Task<IEnumerable<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken cancellationToken = default);
|
||||||
-2
@@ -5,8 +5,6 @@ using TaxBaik.Domain.Entities;
|
|||||||
public interface ITaxProfileRepository
|
public interface ITaxProfileRepository
|
||||||
{
|
{
|
||||||
Task<int> CreateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
|
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<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||||
Task UpdateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
|
Task UpdateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
|
||||||
Task<IEnumerable<TaxProfile>> GetByRiskLevelAsync(string riskLevel, 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
-1
@@ -27,7 +27,6 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IConsultingActivityRepository, ConsultingActivityRepository>();
|
services.AddScoped<IConsultingActivityRepository, ConsultingActivityRepository>();
|
||||||
services.AddScoped<IContractRepository, ContractRepository>();
|
services.AddScoped<IContractRepository, ContractRepository>();
|
||||||
services.AddScoped<IRevenueTrackingRepository, RevenueTrackingRepository>();
|
services.AddScoped<IRevenueTrackingRepository, RevenueTrackingRepository>();
|
||||||
services.AddScoped<ICommonCodeRepository, CommonCodeRepository>();
|
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
+14
-55
@@ -12,10 +12,10 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
|||||||
return await conn.QueryFirstOrDefaultAsync<BlogPost>(
|
return await conn.QueryFirstOrDefaultAsync<BlogPost>(
|
||||||
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
@"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.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
|
FROM blog_posts bp
|
||||||
LEFT JOIN categories c ON bp.category_id = c.id
|
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 });
|
new { Id = id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,10 +25,10 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
|||||||
return await conn.QueryFirstOrDefaultAsync<BlogPost>(
|
return await conn.QueryFirstOrDefaultAsync<BlogPost>(
|
||||||
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
@"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.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
|
FROM blog_posts bp
|
||||||
LEFT JOIN categories c ON bp.category_id = c.id
|
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 });
|
new { Slug = slug });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,15 +41,15 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
|||||||
using var reader = await conn.QueryMultipleAsync(
|
using var reader = await conn.QueryMultipleAsync(
|
||||||
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
@"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.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
|
FROM blog_posts bp
|
||||||
LEFT JOIN categories c ON bp.category_id = c.id
|
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
|
ORDER BY bp.published_at DESC
|
||||||
LIMIT @PageSize OFFSET @Offset;
|
LIMIT @PageSize OFFSET @Offset;
|
||||||
|
|
||||||
SELECT COUNT(*) FROM blog_posts
|
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 });
|
new { CategoryId = categoryId, PageSize = pageSize, Offset = offset });
|
||||||
|
|
||||||
var items = (await reader.ReadAsync<BlogPost>()).ToList();
|
var items = (await reader.ReadAsync<BlogPost>()).ToList();
|
||||||
@@ -64,10 +64,10 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
|||||||
return await conn.QueryAsync<BlogPost>(
|
return await conn.QueryAsync<BlogPost>(
|
||||||
@"SELECT bp.id, bp.title, bp.slug, bp.category_id, bp.tags,
|
@"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.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
|
FROM blog_posts bp
|
||||||
LEFT JOIN categories c ON bp.category_id = c.id
|
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
|
ORDER BY bp.published_at DESC
|
||||||
LIMIT @Limit",
|
LIMIT @Limit",
|
||||||
new { CategorySlug = categorySlug, 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
|
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
|
||||||
FROM blog_posts bp
|
FROM blog_posts bp
|
||||||
LEFT JOIN categories c ON bp.category_id = c.id
|
LEFT JOIN categories c ON bp.category_id = c.id
|
||||||
WHERE bp.deleted_at IS NULL
|
|
||||||
ORDER BY bp.created_at DESC");
|
ORDER BY bp.created_at DESC");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,14 +94,13 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
|||||||
using var reader = await conn.QueryMultipleAsync(
|
using var reader = await conn.QueryMultipleAsync(
|
||||||
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
@"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.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
|
FROM blog_posts bp
|
||||||
LEFT JOIN categories c ON bp.category_id = c.id
|
LEFT JOIN categories c ON bp.category_id = c.id
|
||||||
WHERE bp.deleted_at IS NULL
|
|
||||||
ORDER BY bp.created_at DESC
|
ORDER BY bp.created_at DESC
|
||||||
LIMIT @PageSize OFFSET @Offset;
|
LIMIT @PageSize OFFSET @Offset;
|
||||||
|
|
||||||
SELECT COUNT(*) FROM blog_posts WHERE deleted_at IS NULL;",
|
SELECT COUNT(*) FROM blog_posts;",
|
||||||
new { PageSize = pageSize, Offset = offset });
|
new { PageSize = pageSize, Offset = offset });
|
||||||
|
|
||||||
var items = (await reader.ReadAsync<BlogPost>()).ToList();
|
var items = (await reader.ReadAsync<BlogPost>()).ToList();
|
||||||
@@ -111,30 +109,6 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
|||||||
return (items, total);
|
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)
|
public async Task<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var conn = Conn();
|
using var conn = Conn();
|
||||||
@@ -156,34 +130,19 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
|||||||
tags = @Tags, author_id = @AuthorId, published_at = @PublishedAt,
|
tags = @Tags, author_id = @AuthorId, published_at = @PublishedAt,
|
||||||
seo_title = @SeoTitle, seo_description = @SeoDescription,
|
seo_title = @SeoTitle, seo_description = @SeoDescription,
|
||||||
thumbnail_url = @ThumbnailUrl, is_published = @IsPublished, updated_at = NOW()
|
thumbnail_url = @ThumbnailUrl, is_published = @IsPublished, updated_at = NOW()
|
||||||
WHERE id = @Id AND deleted_at IS NULL",
|
WHERE id = @Id",
|
||||||
post);
|
post);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
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();
|
using var conn = Conn();
|
||||||
await conn.ExecuteAsync(
|
await conn.ExecuteAsync("DELETE FROM blog_posts WHERE id = @Id", new { Id = id });
|
||||||
"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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default)
|
public async Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var conn = Conn();
|
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
Reference in New Issue
Block a user