feat: add admin dashboard backend and deployment workflow
This commit is contained in:
108
.gitea/workflows/deploy.yml
Normal file
108
.gitea/workflows/deploy.yml
Normal 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
|
||||
802
admin-dashboard-backend-development-guide.md
Normal file
802
admin-dashboard-backend-development-guide.md
Normal 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에는 운영 집계가 노출되지 않는다.
|
||||
|
||||
@@ -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<Test> {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
driver: bridge
|
||||
|
||||
49
docs/ci-cd.md
Normal file
49
docs/ci-cd.md
Normal 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,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"))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val KOREA_ZONE_ID: ZoneId = ZoneId.of("Asia/Seoul")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 적용으로 장기 유효기간 허용)
|
||||
refresh-token-validity: 604800000 # 7일 (RTR 적용으로 장기 유효기간 허용)
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
62
src/test/resources/application-test.yml
Normal file
62
src/test/resources/application-test.yml
Normal 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
|
||||
12
src/test/resources/logback-test.xml
Normal file
12
src/test/resources/logback-test.xml
Normal 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>
|
||||
Reference in New Issue
Block a user