803 lines
19 KiB
Markdown
803 lines
19 KiB
Markdown
# 관리자 대시보드 백엔드 개발 문서
|
|
|
|
작성일: 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에는 운영 집계가 노출되지 않는다.
|
|
|