From c14d123fcc0e322a8023639dd567ea84ff4bfefe Mon Sep 17 00:00:00 2001 From: wypark Date: Thu, 28 May 2026 21:44:05 +0900 Subject: [PATCH] feat: add admin dashboard backend and deployment workflow --- .gitea/workflows/deploy.yml | 108 +++ admin-dashboard-backend-development-guide.md | 802 ++++++++++++++++++ build.gradle.kts | 5 +- docker-compose.yml | 5 +- docs/ci-cd.md | 49 ++ gradlew | 0 .../admin/AdminDashboardController.kt | 26 + .../blogbackend/api/dto/AdminDashboardDtos.kt | 89 ++ .../blogbackend/core/config/SecurityConfig.kt | 41 +- .../blogbackend/core/config/TimeConfig.kt | 14 + .../AdminDashboardQueryRepository.kt | 329 +++++++ .../domain/dashboard/AdminDashboardRows.kt | 32 + .../domain/dashboard/AdminDashboardService.kt | 220 +++++ .../domain/dashboard/DashboardDateUtils.kt | 63 ++ .../blogbackend/domain/image/ImageService.kt | 9 +- .../blogbackend/domain/post/PostService.kt | 12 +- .../domain/post/PostViewDailyStats.kt | 36 + .../post/PostViewDailyStatsJdbcRepository.kt | 80 ++ src/main/resources/application-prod.yml | 7 +- ...60528_01__create_post_view_daily_stats.sql | 18 + .../BlogBackendApplicationTests.kt | 2 + .../admin/AdminDashboardIntegrationTest.kt | 225 +++++ .../dashboard/DashboardDateUtilsTest.kt | 36 + src/test/resources/application-test.yml | 62 ++ src/test/resources/logback-test.xml | 12 + 25 files changed, 2271 insertions(+), 11 deletions(-) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 admin-dashboard-backend-development-guide.md create mode 100644 docs/ci-cd.md mode change 100644 => 100755 gradlew create mode 100644 src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminDashboardController.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/api/dto/AdminDashboardDtos.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/core/config/TimeConfig.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/dashboard/AdminDashboardQueryRepository.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/dashboard/AdminDashboardRows.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/dashboard/AdminDashboardService.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/dashboard/DashboardDateUtils.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/post/PostViewDailyStats.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/post/PostViewDailyStatsJdbcRepository.kt create mode 100644 src/main/resources/db/migration/V20260528_01__create_post_view_daily_stats.sql create mode 100644 src/test/kotlin/me/wypark/blogbackend/api/controller/admin/AdminDashboardIntegrationTest.kt create mode 100644 src/test/kotlin/me/wypark/blogbackend/domain/dashboard/DashboardDateUtilsTest.kt create mode 100644 src/test/resources/application-test.yml create mode 100644 src/test/resources/logback-test.xml diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..fa18857 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,108 @@ +name: Deploy Blog Backend + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Java 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Run tests + run: ./gradlew test --no-daemon + + deploy: + runs-on: ubuntu-latest + needs: test + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Java 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Build boot jar + run: ./gradlew clean bootJar -x test --no-daemon + + - name: Deploy jar over SSH + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} + run: | + set -euo pipefail + + port="${DEPLOY_PORT:-22}" + remote_dir="/root/blog-backend" + service_name="blog-api.service" + jar_name="blog-backend.jar" + remote_tmp="/tmp/${jar_name}.new" + + jar_file="$(find build/libs -maxdepth 1 -type f -name '*.jar' ! -name '*plain.jar' | sort | head -n 1)" + if [ -z "$jar_file" ]; then + echo "No boot jar found under build/libs" + exit 1 + fi + + mkdir -p ~/.ssh + printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/blog-backend-deploy + chmod 600 ~/.ssh/blog-backend-deploy + ssh-keyscan -p "$port" "$DEPLOY_HOST" >> ~/.ssh/known_hosts + + scp -i ~/.ssh/blog-backend-deploy -P "$port" "$jar_file" "$DEPLOY_USER@$DEPLOY_HOST:$remote_tmp" + + ssh -i ~/.ssh/blog-backend-deploy -p "$port" "$DEPLOY_USER@$DEPLOY_HOST" \ + "REMOTE_DIR='$remote_dir' SERVICE_NAME='$service_name' JAR_NAME='$jar_name' REMOTE_TMP='$remote_tmp' bash -s" <<'EOF' + set -euo pipefail + + mkdir -p "$REMOTE_DIR" + cd "$REMOTE_DIR" + + backup_path="" + if [ -f "$JAR_NAME" ]; then + backup_path="$JAR_NAME.$(date +%Y%m%d%H%M%S).bak" + cp "$JAR_NAME" "$backup_path" + fi + + mv "$REMOTE_TMP" "$JAR_NAME" + chown root:root "$JAR_NAME" + chmod 0644 "$JAR_NAME" + + rollback() { + if [ -n "$backup_path" ] && [ -f "$backup_path" ]; then + cp "$backup_path" "$JAR_NAME" + systemctl restart "$SERVICE_NAME" || true + fi + } + + if ! systemctl restart "$SERVICE_NAME"; then + rollback + journalctl -u "$SERVICE_NAME" -n 120 --no-pager + exit 1 + fi + + sleep 8 + + if ! systemctl is-active --quiet "$SERVICE_NAME"; then + rollback + journalctl -u "$SERVICE_NAME" -n 120 --no-pager + exit 1 + fi + + systemctl is-active "$SERVICE_NAME" + EOF diff --git a/admin-dashboard-backend-development-guide.md b/admin-dashboard-backend-development-guide.md new file mode 100644 index 0000000..dfec950 --- /dev/null +++ b/admin-dashboard-backend-development-guide.md @@ -0,0 +1,802 @@ +# 관리자 대시보드 백엔드 개발 문서 + +작성일: 2026-05-28 + +대상: WYPark Blog backend API + +## 1. 목표 + +프론트엔드의 Apple 스타일 리뉴얼과 함께 관리자 대시보드를 실제 운영 도구로 만들기 위한 백엔드 API를 추가한다. 공개 홈에는 운영 집계를 노출하지 않고, 관리자 권한을 가진 사용자만 `/admin`에서 하루 조회수, 최근 7일 조회수, 최근 30일 조회수, 인기 글, 카테고리 상태, 답변 필요한 댓글 등을 모니터링할 수 있게 한다. + +프론트엔드는 먼저 `/api/admin/dashboard` 계약을 기준으로 개발할 수 있다. 백엔드는 이 문서의 응답 구조를 맞추면 프론트 변경을 최소화하고 바로 연결할 수 있다. + +## 2. 현재 프론트에서 사용하는 기존 API + +현재 프론트엔드는 다음 API를 사용한다. + +공개 API: + +```http +GET /api/posts +GET /api/posts/{slug} +GET /api/categories +GET /api/profile +GET /api/comments?postSlug={slug} +POST /api/comments +DELETE /api/comments/{id} +``` + +관리자 API: + +```http +POST /api/admin/posts +PUT /api/admin/posts/{id} +DELETE /api/admin/posts/{id} +GET /api/admin/comments?page={page}&size={size} +DELETE /api/admin/comments/{id} +POST /api/admin/categories +PUT /api/admin/categories/{id} +DELETE /api/admin/categories/{id} +PUT /api/admin/profile +POST /api/admin/images +``` + +신규 대시보드 기능은 기존 API를 깨지 않고 관리자 API만 추가한다. + +## 3. 신규 API 요약 + +MVP에서 반드시 구현할 엔드포인트: + +```http +GET /api/admin/dashboard?range=30d&timezone=Asia/Seoul +``` + +향후 세부 화면이 커질 때 선택적으로 분리할 수 있는 엔드포인트: + +```http +GET /api/admin/dashboard/traffic?range=30d&timezone=Asia/Seoul +GET /api/admin/dashboard/posts/top?range=7d&size=10&timezone=Asia/Seoul +GET /api/admin/dashboard/categories?range=30d&timezone=Asia/Seoul +GET /api/admin/dashboard/action-items?timezone=Asia/Seoul +``` + +프론트 1차 구현은 통합 엔드포인트인 `/api/admin/dashboard`만 사용한다. 분리 엔드포인트는 응답이 커지거나 위젯별 캐싱이 필요할 때 추가한다. + +## 4. 공통 응답 규격 + +프론트는 기존 `ApiResponse` 규격을 사용한다. + +```ts +interface ApiResponse { + code: string; + message: string; + data: T; +} +``` + +성공 예: + +```json +{ + "code": "SUCCESS", + "message": "OK", + "data": {} +} +``` + +권장 오류: + +```json +{ + "code": "FORBIDDEN", + "message": "관리자 권한이 필요합니다.", + "data": null +} +``` + +권한: + +- 모든 `/api/admin/dashboard*` 엔드포인트는 관리자 전용이다. +- 프론트에서 관리자 UI를 숨기더라도 백엔드가 최종 권한 검사를 수행한다. +- 비로그인: 401 +- 로그인했지만 관리자 아님: 403 + +## 5. 날짜와 집계 기준 + +기본 timezone: + +```text +Asia/Seoul +``` + +쿼리 파라미터: + +```http +timezone=Asia/Seoul +range=7d | 30d | 90d +``` + +기간 정의: + +- 오늘 조회수: `timezone` 기준 오늘 00:00:00부터 23:59:59까지 +- 최근 7일 조회수: 오늘 포함 7일, 즉 오늘 + 이전 6일 +- 최근 30일 조회수: 오늘 포함 30일, 즉 오늘 + 이전 29일 +- 최근 90일 조회수: 오늘 포함 90일, 즉 오늘 + 이전 89일 + +전 기간 대비: + +- 오늘 비교값: 어제 +- 최근 7일 비교값: 직전 7일 +- 최근 30일 비교값: 직전 30일 +- 최근 90일 비교값: 직전 90일 + +`changeRate` 계산: + +```text +previousValue = 0이고 currentValue > 0이면 changeRate는 null 또는 100으로 고정하지 않는다. +권장: null + +previousValue > 0이면: +((currentValue - previousValue) / previousValue) * 100 +소수점 둘째 자리까지 반올림 +``` + +프론트는 `changeRate`가 없으면 변화율 badge를 숨긴다. + +## 6. 조회수 수집 방식 + +현재 `Post.viewCount`는 총 누적 조회수로 사용된다. 대시보드에는 일별 집계가 필요하므로 누적 카운트만으로는 충분하지 않다. + +### 6.1 권장 MVP 방식 + +글 상세 조회 시 기존 누적 조회수를 증가시키는 흐름에 일별 집계 upsert를 추가한다. + +대상 요청: + +```http +GET /api/posts/{slug} +``` + +처리: + +1. 게시글 조회 +2. 조회수 증가 정책에 따라 `post.viewCount` 증가 +3. `post_view_daily_stats`에서 `(post_id, stat_date)` row upsert +4. 관리자 대시보드는 이 daily stats를 조회 + +주의: + +- 새로고침마다 증가되는 현재 정책이 있다면 MVP에서는 그대로 따른다. +- 중복 조회 방지는 별도 정책이므로 2차에서 다룬다. +- 검색 봇 제외 정책이 있다면 view 증가 전에 적용한다. + +### 6.2 선택적 고도화 + +정확한 unique visitor가 필요해지면 다음 중 하나를 선택한다. + +- 익명 daily visitor key cookie 발급 +- IP + User-Agent + 날짜를 salt와 함께 hash +- 로그인 회원 ID 기준 unique 집계 + +개인 블로그 운영 통계 목적이라면 MVP에서는 단순 view count로 충분하다. + +## 7. 데이터 모델 제안 + +기존 테이블 이름은 백엔드 프로젝트 관례에 맞춘다. 아래는 개념 모델이다. + +### 7.1 post_view_daily_stats + +글별 일간 조회수 집계 테이블. + +```sql +CREATE TABLE post_view_daily_stats ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + post_id BIGINT NOT NULL, + stat_date DATE NOT NULL, + view_count BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_post_view_daily_stats_post_date (post_id, stat_date), + INDEX idx_post_view_daily_stats_date (stat_date), + INDEX idx_post_view_daily_stats_post_id (post_id) +); +``` + +설명: + +- `stat_date`는 `timezone` 기준 날짜다. +- 한국 블로그 운영만 고려하면 `Asia/Seoul` 기준 LocalDate를 저장한다. +- 다국가 timezone을 엄격히 지원하려면 UTC timestamp event table이 필요하지만 MVP에서는 과하다. + +### 7.2 site_view_daily_stats, 선택 + +전체 조회수를 빠르게 보여주고 싶다면 별도 site daily table을 둘 수 있다. + +```sql +CREATE TABLE site_view_daily_stats ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stat_date DATE NOT NULL, + view_count BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_site_view_daily_stats_date (stat_date) +); +``` + +하지만 `post_view_daily_stats`를 날짜별로 sum해도 되므로 처음에는 생략 가능하다. + +### 7.3 comment reply 계산 + +답변 필요한 댓글을 계산하려면 댓글 구조에 다음 정보가 필요하다. + +필수: + +- `comment.id` +- `comment.post_id` +- `comment.parent_id` +- `comment.member_id` 또는 작성자 식별 정보 +- `comment.created_at` + +권장: + +- 댓글 작성자의 role 또는 `isPostAuthor` +- 삭제 여부 + +MVP 기준: + +- 루트 댓글이고 삭제되지 않음 +- 작성자가 관리자/글 작성자가 아님 +- 해당 댓글의 자식 댓글 중 관리자/글 작성자 댓글이 없음 + +이 조건을 만족하는 댓글 수를 `actionItems.unansweredComments`로 반환한다. + +## 8. 통합 대시보드 API + +### 8.1 Request + +```http +GET /api/admin/dashboard?range=30d&timezone=Asia/Seoul +Authorization: Bearer {accessToken} +``` + +Query params: + +| 이름 | 타입 | 필수 | 기본값 | 설명 | +| --- | --- | --- | --- | --- | +| `range` | `7d`, `30d`, `90d` | 아니오 | `30d` | 트래픽 차트와 성과 위젯 기준 기간 | +| `timezone` | string | 아니오 | `Asia/Seoul` | 날짜 집계 기준 timezone | + +Validation: + +- `range`가 허용값이 아니면 `30d`로 fallback하거나 400을 반환한다. +- 권장: 400보다 fallback이 운영 UI에는 부드럽다. +- `timezone`이 유효하지 않으면 `Asia/Seoul` 사용. + +### 8.2 Response Type + +```ts +type DashboardRange = '7d' | '30d' | '90d'; + +interface DashboardMetric { + value: number; + previousValue?: number; + changeRate?: number | null; +} + +interface DashboardOverview { + todayViews: DashboardMetric; + weekViews: DashboardMetric; + monthViews: DashboardMetric; + totalPosts: number; + totalComments: number; + totalCategories: number; + lastPublishedAt?: string | null; + generatedAt: string; +} + +interface DashboardTrafficPoint { + date: string; + views: number; +} + +interface DashboardPostStat { + id: number; + title: string; + slug: string; + categoryName: string; + viewCount: number; + rangeViewCount: number; + commentCount?: number; + createdAt: string; + updatedAt?: string | null; +} + +interface DashboardCategoryStat { + id: number; + name: string; + parentId?: number | null; + postCount: number; + viewCount: number; + recentViewCount: number; + lastPublishedAt?: string | null; + childrenCount: number; +} + +interface DashboardActionItems { + unansweredComments: number; + uncategorizedPosts: number; + stalePopularPosts: number; +} + +interface AdminDashboardResponse { + overview: DashboardOverview; + traffic: DashboardTrafficPoint[]; + topPosts: DashboardPostStat[]; + risingPosts: DashboardPostStat[]; + stalePopularPosts: DashboardPostStat[]; + recentPosts: PostSummary[]; + recentComments: AdminCommentSummary[]; + categoryStats: DashboardCategoryStat[]; + actionItems: DashboardActionItems; +} +``` + +`PostSummary`는 기존 `/api/posts`의 `Post` 응답과 최대한 맞춘다. + +```ts +interface PostSummary { + id: number; + title: string; + slug: string; + categoryName: string; + viewCount: number; + createdAt: string; + tags: string[]; +} +``` + +`AdminCommentSummary`는 현재 프론트의 `AdminComment`와 맞춘다. + +```ts +interface AdminCommentSummary { + id: number; + content: string; + author?: string; + guestNickname?: string; + memberNickname?: string; + postSlug?: string; + postTitle?: string; + createdAt: string; +} +``` + +### 8.3 Response Example + +```json +{ + "code": "SUCCESS", + "message": "OK", + "data": { + "overview": { + "todayViews": { + "value": 311, + "previousValue": 277, + "changeRate": 12.27 + }, + "weekViews": { + "value": 1830, + "previousValue": 1504, + "changeRate": 21.68 + }, + "monthViews": { + "value": 7124, + "previousValue": 6302, + "changeRate": 13.04 + }, + "totalPosts": 102, + "totalComments": 18, + "totalCategories": 14, + "lastPublishedAt": "2026-01-02T12:04:00+09:00", + "generatedAt": "2026-05-28T20:00:00+09:00" + }, + "traffic": [ + { "date": "2026-05-22", "views": 210 }, + { "date": "2026-05-23", "views": 245 }, + { "date": "2026-05-24", "views": 199 }, + { "date": "2026-05-25", "views": 287 }, + { "date": "2026-05-26", "views": 301 }, + { "date": "2026-05-27", "views": 277 }, + { "date": "2026-05-28", "views": 311 } + ], + "topPosts": [ + { + "id": 1, + "title": "RTR (Refresh Token Rotation)", + "slug": "rtr-(refresh-token-rotation)", + "categoryName": "Network", + "viewCount": 126, + "rangeViewCount": 44, + "commentCount": 0, + "createdAt": "2026-01-02T12:04:00+09:00", + "updatedAt": null + } + ], + "risingPosts": [ + { + "id": 2, + "title": "JWT - 토큰처리 방법", + "slug": "jwt---토큰처리-방법", + "categoryName": "Network", + "viewCount": 500, + "rangeViewCount": 38, + "commentCount": 1, + "createdAt": "2023-07-15T09:00:00+09:00", + "updatedAt": null + } + ], + "stalePopularPosts": [], + "recentPosts": [], + "recentComments": [], + "categoryStats": [ + { + "id": 10, + "name": "Network", + "parentId": 3, + "postCount": 12, + "viewCount": 1200, + "recentViewCount": 210, + "lastPublishedAt": "2026-01-02T12:04:00+09:00", + "childrenCount": 0 + } + ], + "actionItems": { + "unansweredComments": 0, + "uncategorizedPosts": 0, + "stalePopularPosts": 0 + } + } +} +``` + +## 9. 필드별 계산 방법 + +### 9.1 overview + +`todayViews.value`: + +```sql +SELECT COALESCE(SUM(view_count), 0) +FROM post_view_daily_stats +WHERE stat_date = :today; +``` + +`todayViews.previousValue`: + +```sql +SELECT COALESCE(SUM(view_count), 0) +FROM post_view_daily_stats +WHERE stat_date = :yesterday; +``` + +`weekViews.value`: + +```sql +SELECT COALESCE(SUM(view_count), 0) +FROM post_view_daily_stats +WHERE stat_date BETWEEN :todayMinus6 AND :today; +``` + +`monthViews.value`: + +```sql +SELECT COALESCE(SUM(view_count), 0) +FROM post_view_daily_stats +WHERE stat_date BETWEEN :todayMinus29 AND :today; +``` + +`totalPosts`: + +- 삭제되지 않은 공개 글 수 +- 비공개/임시저장 개념이 생기면 `published` 글만 count하거나 별도 필드 추가 + +`totalComments`: + +- 삭제되지 않은 댓글 수 + +`totalCategories`: + +- 전체 카테고리 수, 하위 포함 + +### 9.2 traffic + +range 기준 날짜마다 point를 반드시 채운다. + +예: + +- 조회수가 없는 날짜도 `{ "date": "2026-05-23", "views": 0 }` 포함 +- 프론트 차트가 날짜 간격을 안정적으로 그릴 수 있다. + +### 9.3 topPosts + +range 기간 내 조회수 기준 상위 글. + +```sql +SELECT p.id, p.title, p.slug, c.name AS category_name, + p.view_count, + SUM(s.view_count) AS range_view_count, + p.created_at, + p.updated_at +FROM posts p +JOIN post_view_daily_stats s ON s.post_id = p.id +LEFT JOIN categories c ON c.id = p.category_id +WHERE s.stat_date BETWEEN :fromDate AND :toDate +GROUP BY p.id +ORDER BY range_view_count DESC +LIMIT 5; +``` + +### 9.4 risingPosts + +최근 기간과 이전 같은 길이 기간을 비교해 증가량이 큰 글. + +계산: + +```text +currentRangeViews = 이번 기간 조회수 +previousRangeViews = 직전 같은 기간 조회수 +growth = currentRangeViews - previousRangeViews +``` + +정렬: + +1. `growth DESC` +2. `currentRangeViews DESC` + +조회수가 아주 낮은 글이 0에서 1이 되어 상승률 100%처럼 보이는 문제를 피하려면 최소 조회수 기준을 둔다. + +권장: + +- `currentRangeViews >= 5` + +### 9.5 stalePopularPosts + +최근에도 많이 읽히지만 오래 업데이트되지 않은 글. + +MVP 기준: + +- `rangeViewCount >= 10` +- `updatedAt`이 있으면 `updatedAt <= today - 180 days` +- `updatedAt`이 없으면 `createdAt <= today - 180 days` + +정렬: + +1. `rangeViewCount DESC` +2. 오래된 순 + +### 9.6 categoryStats + +카테고리별 글 수, 누적 조회수, 최근 조회수. + +하위 카테고리 포함 여부: + +- MVP는 해당 카테고리에 직접 속한 글만 계산 +- 추후 옵션으로 하위 포함 계산 가능 + +응답 필드: + +- `postCount`: 직접 속한 글 수 +- `viewCount`: 직접 속한 글의 누적 조회수 합 +- `recentViewCount`: range 기간 직접 속한 글의 조회수 합 +- `lastPublishedAt`: 직접 속한 글 중 최신 발행일 +- `childrenCount`: 바로 아래 하위 카테고리 수 + +### 9.7 actionItems + +`unansweredComments`: + +- 7.3 기준 참고 + +`uncategorizedPosts`: + +- category가 null이거나 `미분류` 카테고리에 속한 글 수 + +`stalePopularPosts`: + +- 9.5 기준으로 계산된 글 수 + +## 10. 선택 분리 엔드포인트 + +통합 대시보드가 무거워지면 다음 엔드포인트를 추가한다. 프론트는 1차에서 쓰지 않아도 된다. + +### 10.1 Traffic + +```http +GET /api/admin/dashboard/traffic?range=30d&timezone=Asia/Seoul +``` + +응답: + +```json +{ + "code": "SUCCESS", + "message": "OK", + "data": [ + { "date": "2026-05-22", "views": 210 } + ] +} +``` + +### 10.2 Top posts + +```http +GET /api/admin/dashboard/posts/top?range=7d&size=10&timezone=Asia/Seoul +``` + +응답: + +```json +{ + "code": "SUCCESS", + "message": "OK", + "data": [] +} +``` + +### 10.3 Category stats + +```http +GET /api/admin/dashboard/categories?range=30d&timezone=Asia/Seoul +``` + +응답: + +```json +{ + "code": "SUCCESS", + "message": "OK", + "data": [] +} +``` + +### 10.4 Action items + +```http +GET /api/admin/dashboard/action-items?timezone=Asia/Seoul +``` + +응답: + +```json +{ + "code": "SUCCESS", + "message": "OK", + "data": { + "unansweredComments": 0, + "uncategorizedPosts": 0, + "stalePopularPosts": 0 + } +} +``` + +## 11. 성능과 캐싱 + +관리자 대시보드는 실시간 초단위 정확도가 필요하지 않다. + +권장: + +- `/api/admin/dashboard` 응답은 30-60초 서버 캐싱 가능 +- 조회수 증가 write는 가볍게 처리 +- 일별 통계 upsert는 DB index를 반드시 둔다 +- traffic range가 90일이어도 row 수가 작으므로 부담이 낮다 + +동시성: + +- 여러 요청이 동시에 같은 글을 조회할 수 있으므로 `(post_id, stat_date)` upsert는 atomic해야 한다. +- MySQL이면 `INSERT ... ON DUPLICATE KEY UPDATE view_count = view_count + 1` +- PostgreSQL이면 `INSERT ... ON CONFLICT ... DO UPDATE` + +예: + +```sql +INSERT INTO post_view_daily_stats (post_id, stat_date, view_count) +VALUES (:postId, :statDate, 1) +ON DUPLICATE KEY UPDATE + view_count = view_count + 1, + updated_at = CURRENT_TIMESTAMP; +``` + +## 12. 보안과 개인정보 + +- 관리자 대시보드 API는 public cache에 저장하지 않는다. +- access token, refresh token, IP, User-Agent를 로그에 직접 남기지 않는다. +- unique visitor를 도입하기 전에는 개인정보성 식별자를 저장하지 않는다. +- IP 기반 unique 집계를 도입한다면 salt hash와 보관 기간을 명확히 정한다. +- public API에는 오늘/주간/월간 조회수 집계를 추가하지 않는다. + +## 13. 백엔드 구현 순서 + +### Phase 1: 일별 조회수 집계 기반 + +1. `post_view_daily_stats` migration 추가 +2. 글 상세 조회 시 daily stats upsert 추가 +3. 기존 `Post.viewCount` 증가 로직 유지 +4. 인덱스와 unique key 확인 +5. 기존 글 상세 API 회귀 테스트 + +### Phase 2: `/api/admin/dashboard` MVP + +1. `DashboardOverview` 계산 +2. `traffic` range points 계산 +3. `topPosts` 계산 +4. `recentPosts`, `recentComments`는 기존 repository/service 재사용 +5. `categoryStats` 계산 +6. `actionItems` 계산 +7. 관리자 권한 검사 적용 + +### Phase 3: 고도화 + +1. `risingPosts` 계산 추가 +2. `stalePopularPosts` 계산 추가 +3. 위젯별 캐싱 추가 +4. 필요하면 분리 엔드포인트 추가 +5. unique visitor 정책 검토 + +## 14. 테스트 계획 + +### 14.1 단위 테스트 + +- `changeRate` 계산 +- range 날짜 계산 +- traffic 날짜 빈 구간 0 채우기 +- stale popular 기준 +- unanswered comment 기준 + +### 14.2 통합 테스트 + +관리자 권한: + +- 비로그인 요청은 401 +- 일반 회원 요청은 403 +- 관리자 요청은 200 + +대시보드 응답: + +- `range=7d`는 traffic 7개 +- `range=30d`는 traffic 30개 +- 조회 없는 날짜는 views 0 +- `todayViews`, `weekViews`, `monthViews`가 올바른 합계를 반환 +- `topPosts`가 range 조회수 기준으로 정렬 + +조회수 집계: + +- 글 상세 조회 1회 후 해당 날짜 row 생성 +- 같은 글 같은 날짜 추가 조회 시 row 증가 +- 다른 날짜는 별도 row + +### 14.3 프론트 연동 확인 + +프론트에서 호출할 URL: + +```http +GET https://blogserver.wypark.me/api/admin/dashboard?range=30d&timezone=Asia/Seoul +``` + +확인: + +- 응답이 `ApiResponse` 구조와 일치 +- CORS, cookie, Authorization 헤더가 기존 admin API와 동일하게 동작 +- 프론트 `/admin`에서 오늘/7일/30일 조회수가 표시 +- 공개 `/`에서는 대시보드 집계가 표시되지 않음 + +## 15. 완료 기준 + +백엔드 MVP 완료 기준: + +- `post_view_daily_stats`가 운영 DB에 반영된다. +- 글 상세 조회 시 일별 조회수가 누적된다. +- 관리자만 `/api/admin/dashboard`를 조회할 수 있다. +- `todayViews`, `weekViews`, `monthViews`, `traffic`, `topPosts`, `recentPosts`, `recentComments`, `categoryStats`, `actionItems`가 응답된다. +- `range=7d`, `30d`, `90d`가 동작한다. +- 프론트가 fallback 없이 실제 API 데이터를 표시할 수 있다. +- 공개 API에는 운영 집계가 노출되지 않는다. + diff --git a/build.gradle.kts b/build.gradle.kts index f013cd2..1ff8e96 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,6 +31,8 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-data-redis") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-mail") + implementation("org.flywaydb:flyway-core") + implementation("org.flywaydb:flyway-database-postgresql") implementation("io.github.cdimascio:dotenv-java:3.0.0") // 2. Kotlin Modules @@ -59,6 +61,7 @@ dependencies { testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("org.springframework.security:spring-security-test") + testRuntimeOnly("com.h2database:h2") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } @@ -76,4 +79,4 @@ allOpen { tasks.withType { useJUnitPlatform() -} \ No newline at end of file +} diff --git a/docker-compose.yml b/docker-compose.yml index 1cae445..479d6ff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,9 +8,10 @@ services: - "8080:8080" environment: # Database - - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/blog_db + - SPRING_DATASOURCE_URL=db:5432/blog_db - SPRING_DATASOURCE_USERNAME=${DB_USER} - SPRING_DATASOURCE_PASSWORD=${DB_PASS} + - JWT_SECRET=${JWT_SECRET} # Redis - SPRING_DATA_REDIS_HOST=redis - SPRING_DATA_REDIS_PORT=6379 @@ -89,4 +90,4 @@ services: networks: blog-net: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/docs/ci-cd.md b/docs/ci-cd.md new file mode 100644 index 0000000..7fbf1ef --- /dev/null +++ b/docs/ci-cd.md @@ -0,0 +1,49 @@ +# CI/CD 운영 메모 + +이 저장소는 Gitea Actions 기준으로 `main` 브랜치에 push되면 자동 배포되도록 구성한다. +현재 운영 서버는 Docker Compose가 아니라 Java 21 + systemd 방식으로 실행 중이다. + +## 현재 운영 방식 + +- 운영 서비스: `blog-api.service` +- 실행 디렉터리: `/root/blog-backend` +- 실행 파일: `/root/blog-backend/blog-backend.jar` +- 실행 방식: `java -jar -Duser.timezone=Asia/Seoul -Dspring.profiles.active=prod blog-backend.jar` +- 운영 환경 변수와 외부 DB/Redis/S3 설정은 systemd service에 등록되어 있다. + +## 동작 흐름 + +1. `main` 브랜치 push 또는 수동 실행(`workflow_dispatch`) +2. Java 21로 `./gradlew test --no-daemon` 실행 +3. 테스트 성공 시 `./gradlew clean bootJar -x test --no-daemon`으로 Spring Boot jar 빌드 +4. 빌드된 jar를 SSH/SCP로 서버의 `/tmp/blog-backend.jar.new`에 업로드 +5. 서버에서 기존 `/root/blog-backend/blog-backend.jar`를 timestamp 백업 +6. 새 jar를 `/root/blog-backend/blog-backend.jar`로 교체 +7. `systemctl restart blog-api.service` 실행 후 active 상태 확인 +8. 재시작 실패 또는 비정상 종료 시 직전 jar로 rollback 시도 + +## Gitea Actions 준비 + +- Repository Settings에서 Actions를 활성화한다. +- Gitea Runner가 `ubuntu-latest` label을 처리할 수 있어야 한다. +- 현재 백엔드 서버에는 `blog-backend` runner가 `ubuntu-latest` label로 등록되어 있다. +- Docker는 runner job 실행에 쓰일 수 있지만, 애플리케이션 배포 방식은 systemd jar 교체 방식이다. + +## Repository Secrets + +Gitea repository secrets에 다음 값을 등록한다. + +| 이름 | 설명 | +| --- | --- | +| `DEPLOY_HOST` | 배포 서버 호스트 또는 IP | +| `DEPLOY_PORT` | SSH 포트, 비우면 22 | +| `DEPLOY_USER` | 배포 서버 SSH 사용자 | +| `DEPLOY_KEY` | 배포 서버 접속용 private key | + +## 서버 준비 사항 + +- 서버에는 Java 21이 설치되어 있어야 한다. +- `blog-api.service`가 `/root/blog-backend/blog-backend.jar`를 실행하도록 구성되어 있어야 한다. +- `DEPLOY_USER`는 `/root/blog-backend`에 jar를 쓸 수 있고 `systemctl restart blog-api.service`를 실행할 수 있어야 한다. +- 운영 DB는 배포 전에 백업하는 것을 권장한다. Flyway migration은 애플리케이션 시작 시 자동 적용된다. +- `docker-compose.yml`은 현재 CI/CD 배포 경로에서 사용하지 않는다. diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminDashboardController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminDashboardController.kt new file mode 100644 index 0000000..c1f2aa4 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminDashboardController.kt @@ -0,0 +1,26 @@ +package me.wypark.blogbackend.api.controller.admin + +import me.wypark.blogbackend.api.common.ApiResponse +import me.wypark.blogbackend.api.dto.AdminDashboardResponse +import me.wypark.blogbackend.domain.dashboard.AdminDashboardService +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/admin/dashboard") +class AdminDashboardController( + private val adminDashboardService: AdminDashboardService +) { + + @GetMapping + fun getDashboard( + @RequestParam(required = false) range: String?, + @RequestParam(required = false) timezone: String? + ): ResponseEntity> { + val dashboard = adminDashboardService.getDashboard(range, timezone) + return ResponseEntity.ok(ApiResponse.success(dashboard, "OK")) + } +} diff --git a/src/main/kotlin/me/wypark/blogbackend/api/dto/AdminDashboardDtos.kt b/src/main/kotlin/me/wypark/blogbackend/api/dto/AdminDashboardDtos.kt new file mode 100644 index 0000000..e06fec0 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/api/dto/AdminDashboardDtos.kt @@ -0,0 +1,89 @@ +package me.wypark.blogbackend.api.dto + +import java.math.BigDecimal +import java.time.LocalDate +import java.time.OffsetDateTime + +data class DashboardMetric( + val value: Long, + val previousValue: Long? = null, + val changeRate: BigDecimal? = null +) + +data class DashboardOverview( + val todayViews: DashboardMetric, + val weekViews: DashboardMetric, + val monthViews: DashboardMetric, + val totalPosts: Long, + val totalComments: Long, + val totalCategories: Long, + val lastPublishedAt: OffsetDateTime?, + val generatedAt: OffsetDateTime +) + +data class DashboardTrafficPoint( + val date: LocalDate, + val views: Long +) + +data class DashboardPostStat( + val id: Long, + val title: String, + val slug: String, + val categoryName: String, + val viewCount: Long, + val rangeViewCount: Long, + val commentCount: Long, + val createdAt: OffsetDateTime, + val updatedAt: OffsetDateTime? +) + +data class DashboardCategoryStat( + val id: Long, + val name: String, + val parentId: Long?, + val postCount: Long, + val viewCount: Long, + val recentViewCount: Long, + val lastPublishedAt: OffsetDateTime?, + val childrenCount: Long +) + +data class DashboardActionItems( + val unansweredComments: Long, + val uncategorizedPosts: Long, + val stalePopularPosts: Long +) + +data class DashboardPostSummary( + val id: Long, + val title: String, + val slug: String, + val categoryName: String, + val viewCount: Long, + val createdAt: OffsetDateTime, + val tags: List +) + +data class AdminDashboardCommentSummary( + val id: Long, + val content: String, + val author: String?, + val guestNickname: String?, + val memberNickname: String?, + val postSlug: String?, + val postTitle: String?, + val createdAt: OffsetDateTime +) + +data class AdminDashboardResponse( + val overview: DashboardOverview, + val traffic: List, + val topPosts: List, + val risingPosts: List, + val stalePopularPosts: List, + val recentPosts: List, + val recentComments: List, + val categoryStats: List, + val actionItems: DashboardActionItems +) diff --git a/src/main/kotlin/me/wypark/blogbackend/core/config/SecurityConfig.kt b/src/main/kotlin/me/wypark/blogbackend/core/config/SecurityConfig.kt index aa821b0..5b21873 100644 --- a/src/main/kotlin/me/wypark/blogbackend/core/config/SecurityConfig.kt +++ b/src/main/kotlin/me/wypark/blogbackend/core/config/SecurityConfig.kt @@ -1,15 +1,21 @@ package me.wypark.blogbackend.core.config +import com.fasterxml.jackson.databind.ObjectMapper +import jakarta.servlet.http.HttpServletResponse +import me.wypark.blogbackend.api.common.ApiResponse import me.wypark.blogbackend.core.config.jwt.JwtAuthenticationFilter import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter import org.springframework.web.filter.CorsFilter +import java.nio.charset.StandardCharsets /** * [Spring Security 설정] @@ -22,7 +28,8 @@ import org.springframework.web.filter.CorsFilter @EnableWebSecurity class SecurityConfig( private val corsFilter: CorsFilter, - private val jwtAuthenticationFilter: JwtAuthenticationFilter + private val jwtAuthenticationFilter: JwtAuthenticationFilter, + private val objectMapper: ObjectMapper ) { @Bean @@ -44,6 +51,24 @@ class SecurityConfig( .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .exceptionHandling { exceptions -> + exceptions.authenticationEntryPoint { _, response, _ -> + writeErrorResponse( + response = response, + status = HttpStatus.UNAUTHORIZED, + code = "UNAUTHORIZED", + message = "인증이 필요합니다." + ) + } + exceptions.accessDeniedHandler { _, response, _ -> + writeErrorResponse( + response = response, + status = HttpStatus.FORBIDDEN, + code = "FORBIDDEN", + message = "관리자 권한이 필요합니다." + ) + } + } // 4. URL별 접근 권한 관리 (인가) // Principle of Least Privilege(최소 권한의 원칙)에 따라, 명시적으로 허용된 경로 외에는 모두 인증을 요구 @@ -70,4 +95,16 @@ class SecurityConfig( return http.build() } -} \ No newline at end of file + + private fun writeErrorResponse( + response: HttpServletResponse, + status: HttpStatus, + code: String, + message: String + ) { + response.status = status.value() + response.contentType = MediaType.APPLICATION_JSON_VALUE + response.characterEncoding = StandardCharsets.UTF_8.name() + response.writer.write(objectMapper.writeValueAsString(ApiResponse.error(message, code))) + } +} diff --git a/src/main/kotlin/me/wypark/blogbackend/core/config/TimeConfig.kt b/src/main/kotlin/me/wypark/blogbackend/core/config/TimeConfig.kt new file mode 100644 index 0000000..0d8d746 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/core/config/TimeConfig.kt @@ -0,0 +1,14 @@ +package me.wypark.blogbackend.core.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.time.Clock + +@Configuration +class TimeConfig { + + @Bean + fun clock(): Clock { + return Clock.systemUTC() + } +} diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/dashboard/AdminDashboardQueryRepository.kt b/src/main/kotlin/me/wypark/blogbackend/domain/dashboard/AdminDashboardQueryRepository.kt new file mode 100644 index 0000000..207fbf5 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/dashboard/AdminDashboardQueryRepository.kt @@ -0,0 +1,329 @@ +package me.wypark.blogbackend.domain.dashboard + +import org.springframework.jdbc.core.RowMapper +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.stereotype.Repository +import java.sql.ResultSet +import java.time.LocalDate +import java.time.LocalDateTime + +@Repository +class AdminDashboardQueryRepository( + private val jdbcTemplate: NamedParameterJdbcTemplate +) { + + fun sumViewsBetween(startDate: LocalDate, endDate: LocalDate): Long { + return queryLong( + """ + SELECT COALESCE(SUM(view_count), 0) + FROM post_view_daily_stats + WHERE stat_date BETWEEN :startDate AND :endDate + """.trimIndent(), + dateRangeParams(startDate, endDate) + ) + } + + fun findTrafficBetween(startDate: LocalDate, endDate: LocalDate): List { + return jdbcTemplate.query( + """ + SELECT stat_date, COALESCE(SUM(view_count), 0) AS views + FROM post_view_daily_stats + WHERE stat_date BETWEEN :startDate AND :endDate + GROUP BY stat_date + ORDER BY stat_date ASC + """.trimIndent(), + dateRangeParams(startDate, endDate) + ) { rs, _ -> + DashboardTrafficRow( + date = rs.getDate("stat_date").toLocalDate(), + views = rs.getLong("views") + ) + } + } + + fun findTopPosts(startDate: LocalDate, endDate: LocalDate, limit: Int): List { + return jdbcTemplate.query( + """ + WITH current_views AS ( + SELECT post_id, SUM(view_count) AS view_count + FROM post_view_daily_stats + WHERE stat_date BETWEEN :startDate AND :endDate + GROUP BY post_id + ) + SELECT p.id, + p.title, + p.slug, + COALESCE(c.name, '미분류') AS category_name, + p.view_count, + COALESCE(cv.view_count, 0) AS range_view_count, + COUNT(DISTINCT cm.id) AS comment_count, + p.created_at, + p.updated_at + FROM post p + JOIN current_views cv ON cv.post_id = p.id + LEFT JOIN category c ON c.id = p.category_id + LEFT JOIN comment cm ON cm.post_id = p.id + GROUP BY p.id, p.title, p.slug, c.name, p.view_count, cv.view_count, p.created_at, p.updated_at + ORDER BY range_view_count DESC, p.id DESC + LIMIT :limit + """.trimIndent(), + dateRangeParams(startDate, endDate).addValue("limit", limit), + postStatRowMapper + ) + } + + fun findRisingPosts( + currentStartDate: LocalDate, + currentEndDate: LocalDate, + previousStartDate: LocalDate, + previousEndDate: LocalDate, + minimumViewCount: Long, + limit: Int + ): List { + return jdbcTemplate.query( + """ + WITH current_views AS ( + SELECT post_id, SUM(view_count) AS view_count + FROM post_view_daily_stats + WHERE stat_date BETWEEN :currentStartDate AND :currentEndDate + GROUP BY post_id + ), + previous_views AS ( + SELECT post_id, SUM(view_count) AS view_count + FROM post_view_daily_stats + WHERE stat_date BETWEEN :previousStartDate AND :previousEndDate + GROUP BY post_id + ) + SELECT p.id, + p.title, + p.slug, + COALESCE(c.name, '미분류') AS category_name, + p.view_count, + COALESCE(cv.view_count, 0) AS range_view_count, + COUNT(DISTINCT cm.id) AS comment_count, + p.created_at, + p.updated_at + FROM post p + LEFT JOIN current_views cv ON cv.post_id = p.id + LEFT JOIN previous_views pv ON pv.post_id = p.id + LEFT JOIN category c ON c.id = p.category_id + LEFT JOIN comment cm ON cm.post_id = p.id + WHERE COALESCE(cv.view_count, 0) >= :minimumViewCount + GROUP BY p.id, p.title, p.slug, c.name, p.view_count, cv.view_count, pv.view_count, p.created_at, p.updated_at + ORDER BY (COALESCE(cv.view_count, 0) - COALESCE(pv.view_count, 0)) DESC, + COALESCE(cv.view_count, 0) DESC, + p.id DESC + LIMIT :limit + """.trimIndent(), + MapSqlParameterSource() + .addValue("currentStartDate", currentStartDate) + .addValue("currentEndDate", currentEndDate) + .addValue("previousStartDate", previousStartDate) + .addValue("previousEndDate", previousEndDate) + .addValue("minimumViewCount", minimumViewCount) + .addValue("limit", limit), + postStatRowMapper + ) + } + + fun findStalePopularPosts( + startDate: LocalDate, + endDate: LocalDate, + staleBefore: LocalDateTime, + minimumViewCount: Long, + limit: Int + ): List { + return jdbcTemplate.query( + """ + WITH current_views AS ( + SELECT post_id, SUM(view_count) AS view_count + FROM post_view_daily_stats + WHERE stat_date BETWEEN :startDate AND :endDate + GROUP BY post_id + ) + SELECT p.id, + p.title, + p.slug, + COALESCE(c.name, '미분류') AS category_name, + p.view_count, + COALESCE(cv.view_count, 0) AS range_view_count, + COUNT(DISTINCT cm.id) AS comment_count, + p.created_at, + p.updated_at + FROM post p + JOIN current_views cv ON cv.post_id = p.id + LEFT JOIN category c ON c.id = p.category_id + LEFT JOIN comment cm ON cm.post_id = p.id + WHERE COALESCE(cv.view_count, 0) >= :minimumViewCount + AND p.updated_at <= :staleBefore + GROUP BY p.id, p.title, p.slug, c.name, p.view_count, cv.view_count, p.created_at, p.updated_at + ORDER BY COALESCE(cv.view_count, 0) DESC, p.updated_at ASC, p.id DESC + LIMIT :limit + """.trimIndent(), + dateRangeParams(startDate, endDate) + .addValue("staleBefore", staleBefore) + .addValue("minimumViewCount", minimumViewCount) + .addValue("limit", limit), + postStatRowMapper + ) + } + + fun countStalePopularPosts(startDate: LocalDate, endDate: LocalDate, staleBefore: LocalDateTime, minimumViewCount: Long): Long { + return queryLong( + """ + WITH current_views AS ( + SELECT post_id, SUM(view_count) AS view_count + FROM post_view_daily_stats + WHERE stat_date BETWEEN :startDate AND :endDate + GROUP BY post_id + ) + SELECT COUNT(*) + FROM post p + JOIN current_views cv ON cv.post_id = p.id + WHERE COALESCE(cv.view_count, 0) >= :minimumViewCount + AND p.updated_at <= :staleBefore + """.trimIndent(), + dateRangeParams(startDate, endDate) + .addValue("staleBefore", staleBefore) + .addValue("minimumViewCount", minimumViewCount) + ) + } + + fun findCategoryStats(startDate: LocalDate, endDate: LocalDate): List { + return jdbcTemplate.query( + """ + SELECT c.id, + c.name, + c.parent_id, + COALESCE(pc.post_count, 0) AS post_count, + COALESCE(pc.view_count, 0) AS view_count, + COALESCE(rv.recent_view_count, 0) AS recent_view_count, + pc.last_published_at, + COALESCE(cc.children_count, 0) AS children_count + FROM category c + LEFT JOIN ( + SELECT category_id, + COUNT(*) AS post_count, + COALESCE(SUM(view_count), 0) AS view_count, + MAX(created_at) AS last_published_at + FROM post + WHERE category_id IS NOT NULL + GROUP BY category_id + ) pc ON pc.category_id = c.id + LEFT JOIN ( + SELECT p.category_id, + COALESCE(SUM(s.view_count), 0) AS recent_view_count + FROM post p + JOIN post_view_daily_stats s ON s.post_id = p.id + WHERE p.category_id IS NOT NULL + AND s.stat_date BETWEEN :startDate AND :endDate + GROUP BY p.category_id + ) rv ON rv.category_id = c.id + LEFT JOIN ( + SELECT parent_id, COUNT(*) AS children_count + FROM category + WHERE parent_id IS NOT NULL + GROUP BY parent_id + ) cc ON cc.parent_id = c.id + ORDER BY c.name ASC, c.id ASC + """.trimIndent(), + dateRangeParams(startDate, endDate) + ) { rs, _ -> + DashboardCategoryStatRow( + id = rs.getLong("id"), + name = rs.getString("name"), + parentId = rs.getNullableLong("parent_id"), + postCount = rs.getLong("post_count"), + viewCount = rs.getLong("view_count"), + recentViewCount = rs.getLong("recent_view_count"), + lastPublishedAt = rs.getNullableLocalDateTime("last_published_at"), + childrenCount = rs.getLong("children_count") + ) + } + } + + fun countUncategorizedPosts(): Long { + return queryLong( + """ + SELECT COUNT(*) + FROM post p + LEFT JOIN category c ON c.id = p.category_id + WHERE p.category_id IS NULL + OR LOWER(c.name) IN ('uncategorized', '미분류') + """.trimIndent(), + MapSqlParameterSource() + ) + } + + fun countUnansweredComments(): Long { + return queryLong( + """ + SELECT COUNT(*) + FROM comment root + JOIN post p ON p.id = root.post_id + LEFT JOIN member root_member ON root_member.id = root.member_id + WHERE root.parent_id IS NULL + AND ( + root.member_id IS NULL + OR ( + root.member_id <> p.member_id + AND root_member.role <> 'ROLE_ADMIN' + ) + ) + AND NOT EXISTS ( + SELECT 1 + FROM comment child + LEFT JOIN member child_member ON child_member.id = child.member_id + WHERE child.parent_id = root.id + AND ( + child.member_id = p.member_id + OR child_member.role = 'ROLE_ADMIN' + ) + ) + """.trimIndent(), + MapSqlParameterSource() + ) + } + + fun findLastPublishedAt(): LocalDateTime? { + return jdbcTemplate.query( + "SELECT MAX(created_at) AS last_published_at FROM post", + MapSqlParameterSource() + ) { rs, _ -> rs.getNullableLocalDateTime("last_published_at") } + .firstOrNull() + } + + private fun queryLong(sql: String, params: MapSqlParameterSource): Long { + return jdbcTemplate.queryForObject(sql, params, Number::class.java)?.toLong() ?: 0L + } + + private fun dateRangeParams(startDate: LocalDate, endDate: LocalDate): MapSqlParameterSource { + return MapSqlParameterSource() + .addValue("startDate", startDate) + .addValue("endDate", endDate) + } + + private val postStatRowMapper = RowMapper { rs: ResultSet, _: Int -> + DashboardPostStatRow( + id = rs.getLong("id"), + title = rs.getString("title"), + slug = rs.getString("slug"), + categoryName = rs.getString("category_name"), + viewCount = rs.getLong("view_count"), + rangeViewCount = rs.getLong("range_view_count"), + commentCount = rs.getLong("comment_count"), + createdAt = rs.getTimestamp("created_at").toLocalDateTime(), + updatedAt = rs.getNullableLocalDateTime("updated_at") + ) + } + + private fun ResultSet.getNullableLong(columnLabel: String): Long? { + val value = getLong(columnLabel) + return if (wasNull()) null else value + } + + private fun ResultSet.getNullableLocalDateTime(columnLabel: String): LocalDateTime? { + return getTimestamp(columnLabel)?.toLocalDateTime() + } +} diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/dashboard/AdminDashboardRows.kt b/src/main/kotlin/me/wypark/blogbackend/domain/dashboard/AdminDashboardRows.kt new file mode 100644 index 0000000..0195fa5 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/dashboard/AdminDashboardRows.kt @@ -0,0 +1,32 @@ +package me.wypark.blogbackend.domain.dashboard + +import java.time.LocalDate +import java.time.LocalDateTime + +data class DashboardPostStatRow( + val id: Long, + val title: String, + val slug: String, + val categoryName: String, + val viewCount: Long, + val rangeViewCount: Long, + val commentCount: Long, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime? +) + +data class DashboardCategoryStatRow( + val id: Long, + val name: String, + val parentId: Long?, + val postCount: Long, + val viewCount: Long, + val recentViewCount: Long, + val lastPublishedAt: LocalDateTime?, + val childrenCount: Long +) + +data class DashboardTrafficRow( + val date: LocalDate, + val views: Long +) diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/dashboard/AdminDashboardService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/dashboard/AdminDashboardService.kt new file mode 100644 index 0000000..f68e3c9 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/dashboard/AdminDashboardService.kt @@ -0,0 +1,220 @@ +package me.wypark.blogbackend.domain.dashboard + +import me.wypark.blogbackend.api.dto.AdminDashboardCommentSummary +import me.wypark.blogbackend.api.dto.AdminDashboardResponse +import me.wypark.blogbackend.api.dto.DashboardActionItems +import me.wypark.blogbackend.api.dto.DashboardCategoryStat +import me.wypark.blogbackend.api.dto.DashboardMetric +import me.wypark.blogbackend.api.dto.DashboardOverview +import me.wypark.blogbackend.api.dto.DashboardPostStat +import me.wypark.blogbackend.api.dto.DashboardPostSummary +import me.wypark.blogbackend.api.dto.DashboardTrafficPoint +import me.wypark.blogbackend.domain.category.CategoryRepository +import me.wypark.blogbackend.domain.comment.Comment +import me.wypark.blogbackend.domain.comment.CommentRepository +import me.wypark.blogbackend.domain.post.Post +import me.wypark.blogbackend.domain.post.PostRepository +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.Clock +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneId + +@Service +@Transactional(readOnly = true) +class AdminDashboardService( + private val dashboardQueryRepository: AdminDashboardQueryRepository, + private val postRepository: PostRepository, + private val commentRepository: CommentRepository, + private val categoryRepository: CategoryRepository, + private val clock: Clock +) { + + fun getDashboard(rangeValue: String?, timezoneValue: String?): AdminDashboardResponse { + val range = DashboardRange.from(rangeValue) + val zoneId = DashboardDateUtils.resolveZoneId(timezoneValue) + val today = LocalDate.now(clock.withZone(zoneId)) + val selectedWindow = DashboardDateUtils.currentWindow(today, range.days) + val previousSelectedWindow = DashboardDateUtils.previousWindow(selectedWindow) + val staleBefore = today.minusDays(STALE_POPULAR_DAYS).atStartOfDay() + + val stalePopularPosts = dashboardQueryRepository.findStalePopularPosts( + startDate = selectedWindow.startDate, + endDate = selectedWindow.endDate, + staleBefore = staleBefore, + minimumViewCount = STALE_POPULAR_MINIMUM_VIEWS, + limit = POST_WIDGET_LIMIT + ).map { it.toDashboardPostStat(zoneId) } + + return AdminDashboardResponse( + overview = buildOverview(today, zoneId), + traffic = buildTraffic(selectedWindow), + topPosts = dashboardQueryRepository.findTopPosts( + selectedWindow.startDate, + selectedWindow.endDate, + POST_WIDGET_LIMIT + ).map { it.toDashboardPostStat(zoneId) }, + risingPosts = dashboardQueryRepository.findRisingPosts( + currentStartDate = selectedWindow.startDate, + currentEndDate = selectedWindow.endDate, + previousStartDate = previousSelectedWindow.startDate, + previousEndDate = previousSelectedWindow.endDate, + minimumViewCount = RISING_POST_MINIMUM_VIEWS, + limit = POST_WIDGET_LIMIT + ).map { it.toDashboardPostStat(zoneId) }, + stalePopularPosts = stalePopularPosts, + recentPosts = findRecentPosts(zoneId), + recentComments = findRecentComments(zoneId), + categoryStats = dashboardQueryRepository.findCategoryStats( + selectedWindow.startDate, + selectedWindow.endDate + ).map { it.toDashboardCategoryStat(zoneId) }, + actionItems = DashboardActionItems( + unansweredComments = dashboardQueryRepository.countUnansweredComments(), + uncategorizedPosts = dashboardQueryRepository.countUncategorizedPosts(), + stalePopularPosts = dashboardQueryRepository.countStalePopularPosts( + startDate = selectedWindow.startDate, + endDate = selectedWindow.endDate, + staleBefore = staleBefore, + minimumViewCount = STALE_POPULAR_MINIMUM_VIEWS + ) + ) + ) + } + + private fun buildOverview(today: LocalDate, zoneId: ZoneId): DashboardOverview { + val todayWindow = DashboardDateUtils.currentWindow(today, 1) + val yesterdayWindow = DashboardDateUtils.previousWindow(todayWindow) + val weekWindow = DashboardDateUtils.currentWindow(today, 7) + val previousWeekWindow = DashboardDateUtils.previousWindow(weekWindow) + val monthWindow = DashboardDateUtils.currentWindow(today, 30) + val previousMonthWindow = DashboardDateUtils.previousWindow(monthWindow) + + return DashboardOverview( + todayViews = metric(todayWindow, yesterdayWindow), + weekViews = metric(weekWindow, previousWeekWindow), + monthViews = metric(monthWindow, previousMonthWindow), + totalPosts = postRepository.count(), + totalComments = commentRepository.count(), + totalCategories = categoryRepository.count(), + lastPublishedAt = dashboardQueryRepository.findLastPublishedAt().toOffsetDateTimeOrNull(zoneId), + generatedAt = OffsetDateTime.now(clock.withZone(zoneId)) + ) + } + + private fun metric(currentWindow: DashboardDateWindow, previousWindow: DashboardDateWindow): DashboardMetric { + val currentValue = dashboardQueryRepository.sumViewsBetween(currentWindow.startDate, currentWindow.endDate) + val previousValue = dashboardQueryRepository.sumViewsBetween(previousWindow.startDate, previousWindow.endDate) + + return DashboardMetric( + value = currentValue, + previousValue = previousValue, + changeRate = DashboardDateUtils.changeRate(currentValue, previousValue) + ) + } + + private fun buildTraffic(window: DashboardDateWindow): List { + val viewsByDate = dashboardQueryRepository.findTrafficBetween(window.startDate, window.endDate) + .associate { it.date to it.views } + + val points = mutableListOf() + var date = window.startDate + while (!date.isAfter(window.endDate)) { + points.add(DashboardTrafficPoint(date = date, views = viewsByDate[date] ?: 0L)) + date = date.plusDays(1) + } + return points + } + + private fun findRecentPosts(zoneId: ZoneId): List { + val pageable = PageRequest.of( + 0, + RECENT_WIDGET_LIMIT, + Sort.by(Sort.Direction.DESC, "createdAt") + ) + + return postRepository.findAll(pageable).content.map { it.toDashboardPostSummary(zoneId) } + } + + private fun findRecentComments(zoneId: ZoneId): List { + val pageable = PageRequest.of( + 0, + RECENT_WIDGET_LIMIT, + Sort.by(Sort.Direction.DESC, "createdAt") + ) + + return commentRepository.findAll(pageable).content.map { it.toDashboardCommentSummary(zoneId) } + } + + private fun DashboardPostStatRow.toDashboardPostStat(zoneId: ZoneId): DashboardPostStat { + return DashboardPostStat( + id = id, + title = title, + slug = slug, + categoryName = categoryName, + viewCount = viewCount, + rangeViewCount = rangeViewCount, + commentCount = commentCount, + createdAt = createdAt.toOffsetDateTime(zoneId), + updatedAt = updatedAt.toOffsetDateTimeOrNull(zoneId) + ) + } + + private fun DashboardCategoryStatRow.toDashboardCategoryStat(zoneId: ZoneId): DashboardCategoryStat { + return DashboardCategoryStat( + id = id, + name = name, + parentId = parentId, + postCount = postCount, + viewCount = viewCount, + recentViewCount = recentViewCount, + lastPublishedAt = lastPublishedAt.toOffsetDateTimeOrNull(zoneId), + childrenCount = childrenCount + ) + } + + private fun Post.toDashboardPostSummary(zoneId: ZoneId): DashboardPostSummary { + return DashboardPostSummary( + id = id!!, + title = title, + slug = slug, + categoryName = category?.name ?: "미분류", + viewCount = viewCount, + createdAt = createdAt.toOffsetDateTime(zoneId), + tags = tags.map { it.tag.name } + ) + } + + private fun Comment.toDashboardCommentSummary(zoneId: ZoneId): AdminDashboardCommentSummary { + return AdminDashboardCommentSummary( + id = id!!, + content = content, + author = getAuthorName(), + guestNickname = guestNickname, + memberNickname = member?.nickname, + postSlug = post.slug, + postTitle = post.title, + createdAt = createdAt.toOffsetDateTime(zoneId) + ) + } + + private fun LocalDateTime.toOffsetDateTime(zoneId: ZoneId): OffsetDateTime { + return atZone(zoneId).toOffsetDateTime() + } + + private fun LocalDateTime?.toOffsetDateTimeOrNull(zoneId: ZoneId): OffsetDateTime? { + return this?.toOffsetDateTime(zoneId) + } + + companion object { + private const val POST_WIDGET_LIMIT = 5 + private const val RECENT_WIDGET_LIMIT = 5 + private const val RISING_POST_MINIMUM_VIEWS = 5L + private const val STALE_POPULAR_MINIMUM_VIEWS = 10L + private const val STALE_POPULAR_DAYS = 180L + } +} diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/dashboard/DashboardDateUtils.kt b/src/main/kotlin/me/wypark/blogbackend/domain/dashboard/DashboardDateUtils.kt new file mode 100644 index 0000000..6f5ef23 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/dashboard/DashboardDateUtils.kt @@ -0,0 +1,63 @@ +package me.wypark.blogbackend.domain.dashboard + +import java.math.BigDecimal +import java.math.RoundingMode +import java.time.LocalDate +import java.time.ZoneId +import java.time.temporal.ChronoUnit + +enum class DashboardRange(val queryValue: String, val days: Long) { + SEVEN_DAYS("7d", 7), + THIRTY_DAYS("30d", 30), + NINETY_DAYS("90d", 90); + + companion object { + fun from(rawValue: String?): DashboardRange { + return entries.firstOrNull { it.queryValue == rawValue?.lowercase() } ?: THIRTY_DAYS + } + } +} + +data class DashboardDateWindow( + val startDate: LocalDate, + val endDate: LocalDate +) { + val days: Long = ChronoUnit.DAYS.between(startDate, endDate) + 1 +} + +object DashboardDateUtils { + private val defaultZoneId: ZoneId = ZoneId.of("Asia/Seoul") + + fun resolveZoneId(rawValue: String?): ZoneId { + if (rawValue.isNullOrBlank()) return defaultZoneId + + return try { + ZoneId.of(rawValue) + } catch (e: Exception) { + defaultZoneId + } + } + + fun currentWindow(today: LocalDate, days: Long): DashboardDateWindow { + return DashboardDateWindow( + startDate = today.minusDays(days - 1), + endDate = today + ) + } + + fun previousWindow(currentWindow: DashboardDateWindow): DashboardDateWindow { + val previousEndDate = currentWindow.startDate.minusDays(1) + return DashboardDateWindow( + startDate = previousEndDate.minusDays(currentWindow.days - 1), + endDate = previousEndDate + ) + } + + fun changeRate(currentValue: Long, previousValue: Long): BigDecimal? { + if (previousValue == 0L) return null + + return BigDecimal.valueOf(currentValue - previousValue) + .multiply(BigDecimal.valueOf(100)) + .divide(BigDecimal.valueOf(previousValue), 2, RoundingMode.HALF_UP) + } +} diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/image/ImageService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/image/ImageService.kt index 14c2e51..eb00dbf 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/image/ImageService.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/image/ImageService.kt @@ -19,7 +19,8 @@ import java.util.* @Service class ImageService( private val s3Client: S3Client, - @Value("\${spring.cloud.aws.s3.endpoint:http://minio:9000}") private val endpoint: String + @Value("\${spring.cloud.aws.s3.endpoint:http://minio:9000}") private val endpoint: String, + @Value("\${blog.image.initialize-bucket:true}") private val initializeBucket: Boolean ) { private val bucketName = "blog-images" @@ -29,7 +30,9 @@ class ImageService( * 애플리케이션 레벨에서 인프라(Bucket & Policy)를 자동 프로비저닝(Auto-Provisioning)합니다. */ init { - createBucketIfNotExists() + if (initializeBucket) { + createBucketIfNotExists() + } } /** @@ -111,4 +114,4 @@ class ImageService( } } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostService.kt index 011ddd8..6f33074 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostService.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostService.kt @@ -15,6 +15,8 @@ import org.springframework.data.domain.Pageable import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate +import java.time.ZoneId /** * [게시글 비즈니스 로직] @@ -33,7 +35,8 @@ class PostService( private val categoryRepository: CategoryRepository, private val memberRepository: MemberRepository, private val tagRepository: TagRepository, - private val imageService: ImageService + private val imageService: ImageService, + private val postViewDailyStatsJdbcRepository: PostViewDailyStatsJdbcRepository ) { /** @@ -58,6 +61,7 @@ class PostService( ?: throw IllegalArgumentException("해당 게시글을 찾을 수 없습니다: $slug") post.increaseViewCount() + postViewDailyStatsJdbcRepository.incrementPostView(post.id!!, LocalDate.now(KOREA_ZONE_ID)) // 인접 게시글 조회 (Prev/Next Navigation) // ID를 기준으로 정렬하여 바로 앞/뒤의 게시글을 1건씩 조회합니다. @@ -249,4 +253,8 @@ class PostService( names.add(category.name) category.children.forEach { collectCategoryNames(it, names) } } -} \ No newline at end of file + + companion object { + private val KOREA_ZONE_ID: ZoneId = ZoneId.of("Asia/Seoul") + } +} diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostViewDailyStats.kt b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostViewDailyStats.kt new file mode 100644 index 0000000..683c815 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostViewDailyStats.kt @@ -0,0 +1,36 @@ +package me.wypark.blogbackend.domain.post + +import jakarta.persistence.* +import me.wypark.blogbackend.domain.common.BaseTimeEntity +import java.time.LocalDate + +@Entity +@Table( + name = "post_view_daily_stats", + uniqueConstraints = [ + UniqueConstraint( + name = "uk_post_view_daily_stats_post_date", + columnNames = ["post_id", "stat_date"] + ) + ], + indexes = [ + Index(name = "idx_post_view_daily_stats_date", columnList = "stat_date"), + Index(name = "idx_post_view_daily_stats_post_id", columnList = "post_id") + ] +) +class PostViewDailyStats( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + val post: Post, + + @Column(name = "stat_date", nullable = false) + val statDate: LocalDate, + + @Column(name = "view_count", nullable = false) + var viewCount: Long = 0 +) : BaseTimeEntity() { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null +} diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostViewDailyStatsJdbcRepository.kt b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostViewDailyStatsJdbcRepository.kt new file mode 100644 index 0000000..9fed963 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostViewDailyStatsJdbcRepository.kt @@ -0,0 +1,80 @@ +package me.wypark.blogbackend.domain.post + +import org.springframework.dao.DuplicateKeyException +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.stereotype.Repository +import java.time.LocalDate +import javax.sql.DataSource + +@Repository +class PostViewDailyStatsJdbcRepository( + private val jdbcTemplate: NamedParameterJdbcTemplate, + private val dataSource: DataSource +) { + + private val isPostgreSql: Boolean by lazy { + dataSource.connection.use { connection -> + connection.metaData.databaseProductName.contains("PostgreSQL", ignoreCase = true) + } + } + + fun incrementPostView(postId: Long, statDate: LocalDate) { + if (isPostgreSql) { + incrementPostViewWithPostgresUpsert(postId, statDate) + return + } + + incrementPostViewGenerically(postId, statDate) + } + + private fun incrementPostViewWithPostgresUpsert(postId: Long, statDate: LocalDate) { + jdbcTemplate.update( + """ + INSERT INTO post_view_daily_stats (post_id, stat_date, view_count, created_at, updated_at) + VALUES (:postId, :statDate, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (post_id, stat_date) + DO UPDATE SET + view_count = post_view_daily_stats.view_count + 1, + updated_at = CURRENT_TIMESTAMP + """.trimIndent(), + params(postId, statDate) + ) + } + + private fun incrementPostViewGenerically(postId: Long, statDate: LocalDate) { + val updatedRows = updateExistingRow(postId, statDate) + if (updatedRows > 0) return + + try { + jdbcTemplate.update( + """ + INSERT INTO post_view_daily_stats (post_id, stat_date, view_count, created_at, updated_at) + VALUES (:postId, :statDate, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """.trimIndent(), + params(postId, statDate) + ) + } catch (e: DuplicateKeyException) { + updateExistingRow(postId, statDate) + } + } + + private fun updateExistingRow(postId: Long, statDate: LocalDate): Int { + return jdbcTemplate.update( + """ + UPDATE post_view_daily_stats + SET view_count = view_count + 1, + updated_at = CURRENT_TIMESTAMP + WHERE post_id = :postId + AND stat_date = :statDate + """.trimIndent(), + params(postId, statDate) + ) + } + + private fun params(postId: Long, statDate: LocalDate): MapSqlParameterSource { + return MapSqlParameterSource() + .addValue("postId", postId) + .addValue("statDate", statDate) + } +} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 8064685..9d82b42 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -15,6 +15,11 @@ spring: password: ${SPRING_DATASOURCE_PASSWORD} driver-class-name: org.postgresql.Driver + flyway: + enabled: true + baseline-on-migrate: true + baseline-version: 0 + # [JPA / Hibernate 설정] jpa: hibernate: @@ -82,4 +87,4 @@ jwt: # CI/CD 파이프라인 변수(${JWT_SECRET})를 통해 주입받습니다. secret: ${JWT_SECRET} access-token-validity: 600000 # 10분 (짧은 만료 시간으로 탈취 시 피해 최소화) - refresh-token-validity: 604800000 # 7일 (RTR 적용으로 장기 유효기간 허용) \ No newline at end of file + refresh-token-validity: 604800000 # 7일 (RTR 적용으로 장기 유효기간 허용) diff --git a/src/main/resources/db/migration/V20260528_01__create_post_view_daily_stats.sql b/src/main/resources/db/migration/V20260528_01__create_post_view_daily_stats.sql new file mode 100644 index 0000000..ed94585 --- /dev/null +++ b/src/main/resources/db/migration/V20260528_01__create_post_view_daily_stats.sql @@ -0,0 +1,18 @@ +CREATE TABLE post_view_daily_stats ( + id BIGSERIAL PRIMARY KEY, + post_id BIGINT NOT NULL, + stat_date DATE NOT NULL, + view_count BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_post_view_daily_stats_post + FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE, + CONSTRAINT uk_post_view_daily_stats_post_date + UNIQUE (post_id, stat_date) +); + +CREATE INDEX idx_post_view_daily_stats_date + ON post_view_daily_stats (stat_date); + +CREATE INDEX idx_post_view_daily_stats_post_id + ON post_view_daily_stats (post_id); diff --git a/src/test/kotlin/me/wypark/blogbackend/BlogBackendApplicationTests.kt b/src/test/kotlin/me/wypark/blogbackend/BlogBackendApplicationTests.kt index c963420..bcbe89a 100644 --- a/src/test/kotlin/me/wypark/blogbackend/BlogBackendApplicationTests.kt +++ b/src/test/kotlin/me/wypark/blogbackend/BlogBackendApplicationTests.kt @@ -2,8 +2,10 @@ package me.wypark.blogbackend import org.junit.jupiter.api.Test import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles @SpringBootTest +@ActiveProfiles("test") class BlogBackendApplicationTests { @Test diff --git a/src/test/kotlin/me/wypark/blogbackend/api/controller/admin/AdminDashboardIntegrationTest.kt b/src/test/kotlin/me/wypark/blogbackend/api/controller/admin/AdminDashboardIntegrationTest.kt new file mode 100644 index 0000000..d5154c3 --- /dev/null +++ b/src/test/kotlin/me/wypark/blogbackend/api/controller/admin/AdminDashboardIntegrationTest.kt @@ -0,0 +1,225 @@ +package me.wypark.blogbackend.api.controller.admin + +import jakarta.persistence.EntityManager +import me.wypark.blogbackend.domain.category.Category +import me.wypark.blogbackend.domain.category.CategoryRepository +import me.wypark.blogbackend.domain.comment.Comment +import me.wypark.blogbackend.domain.comment.CommentRepository +import me.wypark.blogbackend.domain.post.Post +import me.wypark.blogbackend.domain.post.PostRepository +import me.wypark.blogbackend.domain.post.PostViewDailyStatsJdbcRepository +import me.wypark.blogbackend.domain.user.Member +import me.wypark.blogbackend.domain.user.MemberRepository +import me.wypark.blogbackend.domain.user.Role +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import kotlin.test.assertEquals + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class AdminDashboardIntegrationTest { + + @Autowired + lateinit var mockMvc: MockMvc + + @Autowired + lateinit var memberRepository: MemberRepository + + @Autowired + lateinit var categoryRepository: CategoryRepository + + @Autowired + lateinit var postRepository: PostRepository + + @Autowired + lateinit var commentRepository: CommentRepository + + @Autowired + lateinit var postViewDailyStatsJdbcRepository: PostViewDailyStatsJdbcRepository + + @Autowired + lateinit var jdbcTemplate: NamedParameterJdbcTemplate + + @Autowired + lateinit var entityManager: EntityManager + + @Test + fun `post detail view creates and increments daily stats`() { + val admin = saveMember("admin@example.com", Role.ROLE_ADMIN) + val post = postRepository.saveAndFlush( + Post( + title = "Daily Stats", + content = "content", + slug = "daily-stats", + member = admin + ) + ) + val today = LocalDate.now(ZoneId.of("Asia/Seoul")) + + mockMvc.perform(get("/api/posts/{slug}", post.slug)) + .andExpect(status().isOk) + + mockMvc.perform(get("/api/posts/{slug}", post.slug)) + .andExpect(status().isOk) + + entityManager.flush() + entityManager.clear() + + assertEquals(2L, findDailyViewCount(post.id!!, today)) + assertEquals(2L, postRepository.findBySlug(post.slug)?.viewCount) + } + + @Test + fun `dashboard requires admin authority`() { + mockMvc.perform(get("/api/admin/dashboard")) + .andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.code").value("UNAUTHORIZED")) + + mockMvc.perform(get("/api/admin/dashboard").with(user("user").roles("USER"))) + .andExpect(status().isForbidden) + .andExpect(jsonPath("$.code").value("FORBIDDEN")) + + mockMvc.perform(get("/api/admin/dashboard").with(user("admin").roles("ADMIN"))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.code").value("SUCCESS")) + .andExpect(jsonPath("$.message").value("OK")) + } + + @Test + fun `dashboard returns traffic metrics post stats categories and action items`() { + val admin = saveMember("admin-dashboard@example.com", Role.ROLE_ADMIN) + val category = categoryRepository.saveAndFlush(Category(name = "Backend")) + val post = postRepository.saveAndFlush( + Post( + title = "Dashboard API", + content = "content", + slug = "dashboard-api", + viewCount = 5, + member = admin, + category = category + ) + ) + val stalePost = postRepository.saveAndFlush( + Post( + title = "Old Popular API", + content = "content", + slug = "old-popular-api", + viewCount = 10, + member = admin, + category = category + ) + ) + val today = LocalDate.now(ZoneId.of("Asia/Seoul")) + + repeat(3) { postViewDailyStatsJdbcRepository.incrementPostView(post.id!!, today) } + repeat(2) { postViewDailyStatsJdbcRepository.incrementPostView(post.id!!, today.minusDays(1)) } + repeat(10) { postViewDailyStatsJdbcRepository.incrementPostView(stalePost.id!!, today) } + markPostAsOld(stalePost.id!!, today.minusDays(181).atStartOfDay()) + + val unanswered = commentRepository.saveAndFlush( + Comment( + content = "Can you explain this?", + post = post, + guestNickname = "guest", + guestPassword = "pw" + ) + ) + val answered = commentRepository.saveAndFlush( + Comment( + content = "Already answered", + post = post, + guestNickname = "guest2", + guestPassword = "pw" + ) + ) + commentRepository.saveAndFlush( + Comment( + content = "Admin answer", + post = post, + parent = answered, + member = admin + ) + ) + + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get("/api/admin/dashboard") + .param("range", "7d") + .param("timezone", "Asia/Seoul") + .with(user("admin").roles("ADMIN")) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.traffic.length()").value(7)) + .andExpect(jsonPath("$.data.overview.todayViews.value").value(13)) + .andExpect(jsonPath("$.data.overview.weekViews.value").value(15)) + .andExpect(jsonPath("$.data.topPosts[0].slug").value(stalePost.slug)) + .andExpect(jsonPath("$.data.risingPosts[0].rangeViewCount").value(10)) + .andExpect(jsonPath("$.data.stalePopularPosts[0].slug").value(stalePost.slug)) + .andExpect(jsonPath("$.data.categoryStats[0].name").value(category.name)) + .andExpect(jsonPath("$.data.categoryStats[0].postCount").value(2)) + .andExpect(jsonPath("$.data.categoryStats[0].recentViewCount").value(15)) + .andExpect(jsonPath("$.data.actionItems.unansweredComments").value(1)) + .andExpect(jsonPath("$.data.actionItems.stalePopularPosts").value(1)) + + assertEquals(unanswered.id, commentRepository.findById(unanswered.id!!).orElseThrow().id) + } + + private fun saveMember(email: String, role: Role): Member { + return memberRepository.saveAndFlush( + Member( + email = email, + password = "password", + nickname = email.substringBefore("@"), + role = role, + isVerified = true + ) + ) + } + + private fun findDailyViewCount(postId: Long, date: LocalDate): Long { + return jdbcTemplate.queryForObject( + """ + SELECT view_count + FROM post_view_daily_stats + WHERE post_id = :postId + AND stat_date = :statDate + """.trimIndent(), + MapSqlParameterSource() + .addValue("postId", postId) + .addValue("statDate", date), + Number::class.java + )!!.toLong() + } + + private fun markPostAsOld(postId: Long, dateTime: LocalDateTime) { + jdbcTemplate.update( + """ + UPDATE post + SET created_at = :dateTime, + updated_at = :dateTime + WHERE id = :postId + """.trimIndent(), + MapSqlParameterSource() + .addValue("postId", postId) + .addValue("dateTime", dateTime) + ) + } +} diff --git a/src/test/kotlin/me/wypark/blogbackend/domain/dashboard/DashboardDateUtilsTest.kt b/src/test/kotlin/me/wypark/blogbackend/domain/dashboard/DashboardDateUtilsTest.kt new file mode 100644 index 0000000..d1707ab --- /dev/null +++ b/src/test/kotlin/me/wypark/blogbackend/domain/dashboard/DashboardDateUtilsTest.kt @@ -0,0 +1,36 @@ +package me.wypark.blogbackend.domain.dashboard + +import org.junit.jupiter.api.Test +import java.math.BigDecimal +import java.time.LocalDate +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DashboardDateUtilsTest { + + @Test + fun `range falls back to 30d when invalid`() { + assertEquals(DashboardRange.SEVEN_DAYS, DashboardRange.from("7d")) + assertEquals(DashboardRange.THIRTY_DAYS, DashboardRange.from("wrong")) + assertEquals(DashboardRange.THIRTY_DAYS, DashboardRange.from(null)) + } + + @Test + fun `date windows include today`() { + val today = LocalDate.of(2026, 5, 28) + val currentWindow = DashboardDateUtils.currentWindow(today, 7) + val previousWindow = DashboardDateUtils.previousWindow(currentWindow) + + assertEquals(LocalDate.of(2026, 5, 22), currentWindow.startDate) + assertEquals(today, currentWindow.endDate) + assertEquals(LocalDate.of(2026, 5, 15), previousWindow.startDate) + assertEquals(LocalDate.of(2026, 5, 21), previousWindow.endDate) + } + + @Test + fun `change rate rounds to two decimals and hides zero previous value`() { + assertEquals(BigDecimal("12.27"), DashboardDateUtils.changeRate(311, 277)) + assertEquals(BigDecimal("-25.00"), DashboardDateUtils.changeRate(75, 100)) + assertNull(DashboardDateUtils.changeRate(10, 0)) + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..edcb266 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,62 @@ +spring: + datasource: + url: jdbc:h2:mem:blog_test;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;NON_KEYWORDS=COMMENT,USER + username: sa + password: + driver-class-name: org.h2.Driver + + flyway: + enabled: false + + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + format_sql: false + show_sql: false + open-in-view: false + + mail: + host: localhost + port: 2525 + username: test + password: test + properties: + mail: + smtp: + auth: false + starttls: + enable: false + required: false + connectiontimeout: 100 + timeout: 100 + writetimeout: 100 + + data: + redis: + host: localhost + port: 6379 + + cloud: + aws: + s3: + bucket: test-bucket + endpoint: http://localhost:9000 + path-style-access-enabled: true + credentials: + access-key: test + secret-key: test + region: + static: ap-northeast-2 + stack: + auto: false + +jwt: + secret: c2VjcmV0LWtleS1mb3ItdGVzdC1hZG1pbi1kYXNoYm9hcmQtMzItYnl0ZXM= + access-token-validity: 600000 + refresh-token-validity: 604800000 + +blog: + image: + initialize-bucket: false diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 0000000..061d1b8 --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,12 @@ + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level --- [%thread] %logger{35} : %msg%n + + + + + + +