feat: add admin dashboard backend and deployment workflow
All checks were successful
Deploy Blog Backend / test (push) Successful in 2m41s
Deploy Blog Backend / deploy (push) Successful in 1m29s

This commit is contained in:
wypark
2026-05-28 21:44:05 +09:00
parent 4c1bd8197f
commit c14d123fcc
25 changed files with 2271 additions and 11 deletions

108
.gitea/workflows/deploy.yml Normal file
View File

@@ -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

View File

@@ -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<T>` 규격을 사용한다.
```ts
interface ApiResponse<T> {
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<AdminDashboardResponse>` 구조와 일치
- 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에는 운영 집계가 노출되지 않는다.

View File

@@ -31,6 +31,8 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-redis") implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-mail") 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") implementation("io.github.cdimascio:dotenv-java:3.0.0")
// 2. Kotlin Modules // 2. Kotlin Modules
@@ -59,6 +61,7 @@ dependencies {
testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.springframework.security:spring-security-test") testImplementation("org.springframework.security:spring-security-test")
testRuntimeOnly("com.h2database:h2")
testRuntimeOnly("org.junit.platform:junit-platform-launcher") testRuntimeOnly("org.junit.platform:junit-platform-launcher")
} }

View File

@@ -8,9 +8,10 @@ services:
- "8080:8080" - "8080:8080"
environment: environment:
# Database # Database
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/blog_db - SPRING_DATASOURCE_URL=db:5432/blog_db
- SPRING_DATASOURCE_USERNAME=${DB_USER} - SPRING_DATASOURCE_USERNAME=${DB_USER}
- SPRING_DATASOURCE_PASSWORD=${DB_PASS} - SPRING_DATASOURCE_PASSWORD=${DB_PASS}
- JWT_SECRET=${JWT_SECRET}
# Redis # Redis
- SPRING_DATA_REDIS_HOST=redis - SPRING_DATA_REDIS_HOST=redis
- SPRING_DATA_REDIS_PORT=6379 - SPRING_DATA_REDIS_PORT=6379

49
docs/ci-cd.md Normal file
View File

@@ -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 배포 경로에서 사용하지 않는다.

0
gradlew vendored Normal file → Executable file
View File

View File

@@ -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<ApiResponse<AdminDashboardResponse>> {
val dashboard = adminDashboardService.getDashboard(range, timezone)
return ResponseEntity.ok(ApiResponse.success(dashboard, "OK"))
}
}

View File

@@ -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<String>
)
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<DashboardTrafficPoint>,
val topPosts: List<DashboardPostStat>,
val risingPosts: List<DashboardPostStat>,
val stalePopularPosts: List<DashboardPostStat>,
val recentPosts: List<DashboardPostSummary>,
val recentComments: List<AdminDashboardCommentSummary>,
val categoryStats: List<DashboardCategoryStat>,
val actionItems: DashboardActionItems
)

View File

@@ -1,15 +1,21 @@
package me.wypark.blogbackend.core.config 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 me.wypark.blogbackend.core.config.jwt.JwtAuthenticationFilter
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpMethod 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.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
import org.springframework.web.filter.CorsFilter import org.springframework.web.filter.CorsFilter
import java.nio.charset.StandardCharsets
/** /**
* [Spring Security 설정] * [Spring Security 설정]
@@ -22,7 +28,8 @@ import org.springframework.web.filter.CorsFilter
@EnableWebSecurity @EnableWebSecurity
class SecurityConfig( class SecurityConfig(
private val corsFilter: CorsFilter, private val corsFilter: CorsFilter,
private val jwtAuthenticationFilter: JwtAuthenticationFilter private val jwtAuthenticationFilter: JwtAuthenticationFilter,
private val objectMapper: ObjectMapper
) { ) {
@Bean @Bean
@@ -44,6 +51,24 @@ class SecurityConfig(
.sessionManagement { .sessionManagement {
it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) 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별 접근 권한 관리 (인가) // 4. URL별 접근 권한 관리 (인가)
// Principle of Least Privilege(최소 권한의 원칙)에 따라, 명시적으로 허용된 경로 외에는 모두 인증을 요구 // Principle of Least Privilege(최소 권한의 원칙)에 따라, 명시적으로 허용된 경로 외에는 모두 인증을 요구
@@ -70,4 +95,16 @@ class SecurityConfig(
return http.build() return http.build()
} }
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)))
}
} }

View File

@@ -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()
}
}

View File

@@ -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<DashboardTrafficRow> {
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<DashboardPostStatRow> {
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<DashboardPostStatRow> {
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<DashboardPostStatRow> {
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<DashboardCategoryStatRow> {
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()
}
}

View File

@@ -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
)

View File

@@ -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<DashboardTrafficPoint> {
val viewsByDate = dashboardQueryRepository.findTrafficBetween(window.startDate, window.endDate)
.associate { it.date to it.views }
val points = mutableListOf<DashboardTrafficPoint>()
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<DashboardPostSummary> {
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<AdminDashboardCommentSummary> {
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
}
}

View File

@@ -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)
}
}

View File

@@ -19,7 +19,8 @@ import java.util.*
@Service @Service
class ImageService( class ImageService(
private val s3Client: S3Client, 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" private val bucketName = "blog-images"
@@ -29,7 +30,9 @@ class ImageService(
* 애플리케이션 레벨에서 인프라(Bucket & Policy)를 자동 프로비저닝(Auto-Provisioning)합니다. * 애플리케이션 레벨에서 인프라(Bucket & Policy)를 자동 프로비저닝(Auto-Provisioning)합니다.
*/ */
init { init {
createBucketIfNotExists() if (initializeBucket) {
createBucketIfNotExists()
}
} }
/** /**

View File

@@ -15,6 +15,8 @@ import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional 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 categoryRepository: CategoryRepository,
private val memberRepository: MemberRepository, private val memberRepository: MemberRepository,
private val tagRepository: TagRepository, 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") ?: throw IllegalArgumentException("해당 게시글을 찾을 수 없습니다: $slug")
post.increaseViewCount() post.increaseViewCount()
postViewDailyStatsJdbcRepository.incrementPostView(post.id!!, LocalDate.now(KOREA_ZONE_ID))
// 인접 게시글 조회 (Prev/Next Navigation) // 인접 게시글 조회 (Prev/Next Navigation)
// ID를 기준으로 정렬하여 바로 앞/뒤의 게시글을 1건씩 조회합니다. // ID를 기준으로 정렬하여 바로 앞/뒤의 게시글을 1건씩 조회합니다.
@@ -249,4 +253,8 @@ class PostService(
names.add(category.name) names.add(category.name)
category.children.forEach { collectCategoryNames(it, names) } category.children.forEach { collectCategoryNames(it, names) }
} }
companion object {
private val KOREA_ZONE_ID: ZoneId = ZoneId.of("Asia/Seoul")
}
} }

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -15,6 +15,11 @@ spring:
password: ${SPRING_DATASOURCE_PASSWORD} password: ${SPRING_DATASOURCE_PASSWORD}
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
flyway:
enabled: true
baseline-on-migrate: true
baseline-version: 0
# [JPA / Hibernate 설정] # [JPA / Hibernate 설정]
jpa: jpa:
hibernate: hibernate:

View File

@@ -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);

View File

@@ -2,8 +2,10 @@ package me.wypark.blogbackend
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
@SpringBootTest @SpringBootTest
@ActiveProfiles("test")
class BlogBackendApplicationTests { class BlogBackendApplicationTests {
@Test @Test

View File

@@ -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)
)
}
}

View File

@@ -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))
}
}

View File

@@ -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

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level --- [%thread] %logger{35} : %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>