v1.3.3: 자체 최소 통계 및 스플래시 localStorage 정리
- 일별 익명 방문자 해시·사이트/게시물 통계(030 마이그레이션) - POST /api/analytics/pageview, 관리자 analytics API, 클라이언트 트래커 - 관리자 대시보드 통계 카드·인기 게시물 Top 5 - 스플래시: SITE_BRAND_LOGO_TEXT localStorage 제거
This commit is contained in:
@@ -10,6 +10,7 @@ DB_PORT=43119
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=replace-with-random-password
|
||||
MEMBER_SESSION_SECRET=replace-with-random-password
|
||||
# ANALYTICS_HASH_SECRET= ← 선택. 일별 방문자 해시용 비밀. 비우면 MEMBER_SESSION_SECRET을 대신 사용.
|
||||
|
||||
# Upload
|
||||
UPLOAD_DIR=/uploads
|
||||
|
||||
35
db/migrations/030_analytics_daily_stats.sql
Normal file
35
db/migrations/030_analytics_daily_stats.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
CREATE TABLE IF NOT EXISTS site_analytics_daily (
|
||||
day DATE PRIMARY KEY,
|
||||
page_views INTEGER NOT NULL DEFAULT 0,
|
||||
visitors INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_analytics_daily (
|
||||
day DATE NOT NULL,
|
||||
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
views INTEGER NOT NULL DEFAULT 0,
|
||||
reads INTEGER NOT NULL DEFAULT 0,
|
||||
visitors INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (day, post_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS post_analytics_daily_day_idx
|
||||
ON post_analytics_daily (day DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS analytics_daily_visitors (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
day DATE NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
post_id UUID REFERENCES posts(id) ON DELETE CASCADE,
|
||||
visitor_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT analytics_daily_visitors_scope_check CHECK (scope IN ('site', 'post'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_visitors_site_uidx
|
||||
ON analytics_daily_visitors (day, visitor_hash)
|
||||
WHERE scope = 'site';
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_visitors_post_uidx
|
||||
ON analytics_daily_visitors (day, post_id, visitor_hash)
|
||||
WHERE scope = 'post';
|
||||
@@ -145,6 +145,7 @@ docker compose --env-file .env.production exec sori-studio-db psql -U sori_studi
|
||||
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/027_site_settings_home_cover.sql
|
||||
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/028_site_settings_announcement.sql
|
||||
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/029_site_settings_signup_blocked_usernames.sql
|
||||
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/030_analytics_daily_stats.sql
|
||||
```
|
||||
|
||||
### Docker 네트워크 충돌 대응
|
||||
|
||||
10
docs/map.md
10
docs/map.md
@@ -26,6 +26,7 @@
|
||||
| lib/external-favicon-url.js | 외부 URL 호스트 기준 Google `s2/favicons` 썸네일 URL 생성(내부 경로는 빈 문자열) |
|
||||
| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree`, 관리자 상단 네비 평면 표용 `flattenNavigationEditorWrappers` |
|
||||
| lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 |
|
||||
| lib/analytics.js | 통계 추적 경로 필터·봇 UA·일별 visitor hash |
|
||||
|
||||
## Nuxt 모듈
|
||||
|
||||
@@ -114,7 +115,7 @@
|
||||
|
||||
| 파일 | 화면 |
|
||||
|------|------|
|
||||
| pages/admin/index.vue | 대시보드 |
|
||||
| pages/admin/index.vue | 대시보드(오늘·7일 방문, 30일 조회, 인기 게시물 Top 5) |
|
||||
| pages/admin/login.vue | 관리자 로그인(이메일·비밀번호 모두 입력 시에만 제출 버튼 활성) |
|
||||
| pages/admin/posts/index.vue | 글 목록, 헤더 우측에 필터(상태·태그·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행 텍스트 상태, 제목 옆 댓글 수, 읽기 전용 태그 배지, 행 끝 more vert 메뉴(추천·삭제) |
|
||||
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·이탈 직전 플러시, 저장 토스트 |
|
||||
@@ -241,6 +242,12 @@
|
||||
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
|
||||
| server/repositories/member-repository.js | 회원 조회/생성 저장소 |
|
||||
| server/repositories/comment-repository.js | 댓글 조회/생성 저장소 |
|
||||
| server/repositories/analytics-repository.js | 방문·게시물 통계 집계·관리자 요약 조회 |
|
||||
| server/utils/analytics-pageview-input.js | `POST /api/analytics/pageview` 검증·기록 |
|
||||
| server/api/analytics/pageview.post.js | 공개 통계 수집 API |
|
||||
| server/routes/admin/api/analytics/summary.get.js | 관리자 통계 요약 API |
|
||||
| server/routes/admin/api/analytics/posts.get.js | 관리자 인기 게시물 API |
|
||||
| plugins/site-analytics.client.js | 공개 라우트 pageview·게시물 read 클라이언트 전송 |
|
||||
|
||||
## 데이터베이스
|
||||
|
||||
@@ -265,6 +272,7 @@
|
||||
| db/migrations/014_add_user_role_levels.sql | 회원 권한 3단계(owner/admin/member) 컬럼 추가 |
|
||||
| db/migrations/013_add_user_admin_role.sql | 회원 관리자 권한 컬럼 추가 및 첫 사용자 관리자 승격 |
|
||||
| db/migrations/015_add_tag_type_and_reorder_support.sql | 태그 유형(`managed`/`general`) 컬럼 추가 |
|
||||
| db/migrations/030_analytics_daily_stats.sql | 사이트·게시물 일별 통계·일별 방문자 해시 테이블 |
|
||||
|
||||
## 설정/배포
|
||||
|
||||
|
||||
20
docs/spec.md
20
docs/spec.md
@@ -45,7 +45,7 @@
|
||||
- 기본 배경, 패널, 라인, 텍스트, 보조 텍스트, 입력, 강조 버튼 색상을 분리
|
||||
- 라이트 모드 기본 배경은 `#fcfcfc`로 통일하고 패널 구분은 보더로 처리
|
||||
- 시스템 다크 모드는 `prefers-color-scheme: dark` 기준으로 우선 대응
|
||||
- 사용자 수동 테마 전환은 `html[data-theme]`와 `localStorage.SITE_THEME`로 관리한다. 첫 페인트 전 `lib/site-theme-init.js` 인라인 스크립트가 테마를 적용해 시스템 다크·저장 라이트 불일치 시 깜빡임을 줄인다. 공개 페이지 로딩 중에는 `#site-splash`에 캐시된 로고(`SITE_BRAND_LOGO_URL`) 또는 로고 텍스트를 잠깐 표시하고, 앱 마운트 후 `site-app-ready`로 숨긴다.
|
||||
- 사용자 수동 테마 전환은 `html[data-theme]`와 `localStorage.SITE_THEME`로 관리한다. 첫 페인트 전 `lib/site-theme-init.js` 인라인 스크립트가 테마를 적용해 시스템 다크·저장 라이트 불일치 시 깜빡임을 줄인다. 공개 페이지 로딩 중에는 `#site-splash`에 캐시된 로고 이미지 URL(`SITE_BRAND_LOGO_URL`, localStorage) 또는 사이트 제목(`NUXT_PUBLIC_SITE_TITLE`)을 잠깐 표시하고, 앱 마운트 후 `site-app-ready`로 숨긴다. `site_settings.logo_text`(기본 `井`)는 **이미지 로고가 없을 때** 헤더·사이드바에 쓰는 짧은 기호이며 localStorage·스플래시와는 별개다.
|
||||
- Thred 참고 화면처럼 사이드바와 본문은 같은 화면 안에서 구분되는 패널과 라인으로 표현
|
||||
|
||||
### 홈 Featured (인덱스)
|
||||
@@ -351,6 +351,20 @@ components/content/
|
||||
| tag_id | UUID | FK → Tags |
|
||||
| created_at | DateTime | 생성일 |
|
||||
|
||||
### Analytics (자체 최소 통계)
|
||||
|
||||
> 마이그레이션 `030_analytics_daily_stats.sql`. 원문 IP·User-Agent·쿠키 ID는 저장하지 않는다. 서버는 `date + IP + User-Agent + secret`으로 일 단위 `visitor_hash`만 생성·저장한다.
|
||||
|
||||
| 테이블 | 필드 | 설명 |
|
||||
|--------|------|------|
|
||||
| site_analytics_daily | day, page_views, visitors | 사이트 일별 페이지뷰·방문자(중복 제거) |
|
||||
| post_analytics_daily | day, post_id, views, reads, visitors | 게시물 일별 조회·읽음·방문자 |
|
||||
| analytics_daily_visitors | day, scope(`site`/`post`), post_id?, visitor_hash | 일별 방문자 해시 등록(중복 방문 제거용) |
|
||||
|
||||
- 추적 대상: 공개 경로만. `/admin`, `/signin`, `/signup`, `/forgot-password`, `/settings`는 제외.
|
||||
- 봇 User-Agent는 서버에서 무시.
|
||||
- 게시물 `reads`는 클라이언트에서 15초 이상 체류·50% 이상 스크롤 후 별도 전송.
|
||||
|
||||
---
|
||||
|
||||
## API 구조
|
||||
@@ -379,6 +393,7 @@ components/content/
|
||||
- `GET /api/tags` - 태그 목록
|
||||
- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만)
|
||||
- `GET /api/site-settings` - 공개 사이트 설정(어나운스 바·홈 커버 등 포함)
|
||||
- `POST /api/analytics/pageview` - 공개 방문·게시물 조회/읽음 집계. 본문: `path`(필수), `postSlug`(게시물일 때), `read`(읽음 이벤트). 발행된 게시물만 `postSlug` 집계. 응답 `{ ok: true }`.
|
||||
- `GET /api/navigation` - 공개 네비게이션(`primary`는 트리·`footer`·`recommended`는 평면, 상세는 위 메뉴/네비게이션 절)
|
||||
- `POST /api/auth/signup` - 회원 가입. 본문: `username`, `email`, `password`, 선택 `emailOtp`(6자리 숫자). **`GET /api/auth/bootstrap-status`의 `emailOtpConfigured`가 true이고 `needsAdminSetup`이 false일 때**(서버에 Resend·pepper 설정됨) `emailOtp`가 필수이며, 직전에 `POST /api/auth/email-otp/request`로 같은 이메일·`purpose: "signup"` 발송분과 일치해야 한다. 최초 관리자 부트스트랩(`needsAdminSetup: true`)에서는 `emailOtp` 없이 가입 가능. 이메일은 저장 시 소문자로 정규화한다.
|
||||
- `GET /api/auth/bootstrap-status` - 최초 관리자 등록 필요 여부(`hasUsers`, `needsAdminSetup`) 및 **이메일 OTP 사용 가능 여부** `emailOtpConfigured`(서버 `RESEND_API_KEY`·`RESEND_FROM_EMAIL` 및 pepper: 비어 있지 않은 `EMAIL_OTP_PEPPER` **또는** `MEMBER_SESSION_SECRET`)를 반환한다. pepper는 OTP 6자리를 DB에 해시해 둘 때만 쓰이는 서버 비밀(긴 난문자열 권장)이다.
|
||||
@@ -410,6 +425,8 @@ components/content/
|
||||
- `POST /admin/api/auth/login` - 로그인
|
||||
- `POST /admin/api/auth/logout` - 로그아웃
|
||||
- `GET /admin/api/auth/me` - 현재 관리자 세션 조회
|
||||
- `GET /admin/api/analytics/summary?days=30` - 통계 요약(오늘 방문, 최근 7일 방문 합, 최근 30일 페이지뷰 합)
|
||||
- `GET /admin/api/analytics/posts?days=30&limit=5` - 기간 내 인기 게시물(조회·읽음·방문자 합, 조회수 내림차순)
|
||||
- `GET /admin/api/posts` - 글 목록
|
||||
- `POST /admin/api/posts` - 글 작성
|
||||
- `GET /admin/api/posts/:id` - 글 상세
|
||||
@@ -677,6 +694,7 @@ DB_PORT=43119
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=replace-with-random-password
|
||||
MEMBER_SESSION_SECRET=replace-with-random-password
|
||||
ANALYTICS_HASH_SECRET=replace-with-random-password
|
||||
|
||||
# Upload
|
||||
UPLOAD_DIR=/uploads
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.3.3
|
||||
|
||||
- 자체 최소 통계: 일별 익명 방문자 해시·사이트/게시물 일별 집계. 마이그레이션 `030_analytics_daily_stats.sql`. 원문 IP·User-Agent 미저장.
|
||||
- `POST /api/analytics/pageview`, `plugins/site-analytics.client.js`(pageview·15초+50% 스크롤 read).
|
||||
- 관리자 대시보드: 오늘/7일 방문·30일 조회·인기 게시물 Top 5. `GET /admin/api/analytics/summary`, `GET /admin/api/analytics/posts`.
|
||||
|
||||
## v1.3.2
|
||||
|
||||
- 스플래시: `SITE_BRAND_LOGO_TEXT` localStorage 사용 중단·레거시 키 제거. 스플래시 문구는 사이트 제목, 이미지는 `SITE_BRAND_LOGO_URL`만 캐시.
|
||||
- 관리자 설정 내비: 타임존·메인 화면·어나운스 바·Import/Export·스팸 필터 아이콘 추가.
|
||||
- 어나운스 바: 클라이언트에서 숨김 여부 확인 후 아래로 슬라이드 인. 닫기·7일간 보지 않기 시 위로 슬라이드 아웃 후 제거(깜빡임 방지).
|
||||
- 어나운스 바: X는 이번 방문(세션)만 숨김, `7일간 보지 않기` 텍스트 버튼으로 localStorage 7일 스누즈.
|
||||
|
||||
65
lib/analytics.js
Normal file
65
lib/analytics.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
/** @type {RegExp} 추적 제외 경로 */
|
||||
const EXCLUDED_PATH_PATTERN = /^\/(admin|signin|signup|forgot-password|settings)(\/|$)/
|
||||
|
||||
/** @type {RegExp} 봇 User-Agent 패턴 */
|
||||
const BOT_USER_AGENT_PATTERN = /bot|crawl|spider|slurp|preview|headless|lighthouse|bytespider|facebookexternalhit/i
|
||||
|
||||
/**
|
||||
* 오늘 날짜(UTC)를 YYYY-MM-DD로 반환한다.
|
||||
* @returns {string} 날짜 문자열
|
||||
*/
|
||||
export const getAnalyticsDayKey = () => {
|
||||
const now = new Date()
|
||||
return now.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 추적 대상 경로인지 확인한다.
|
||||
* @param {string} path - 요청 경로
|
||||
* @returns {boolean} 추적 가능 여부
|
||||
*/
|
||||
export const isTrackableAnalyticsPath = (path) => {
|
||||
const normalized = (path || '').trim()
|
||||
if (!normalized.startsWith('/')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (EXCLUDED_PATH_PATTERN.test(normalized)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇 User-Agent 여부
|
||||
* @param {string} userAgent - User-Agent
|
||||
* @returns {boolean} 봇 여부
|
||||
*/
|
||||
export const isBotUserAgent = (userAgent) => {
|
||||
const value = (userAgent || '').trim()
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
|
||||
return BOT_USER_AGENT_PATTERN.test(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 일 단위 익명 방문자 해시를 생성한다. 원문 IP·UA는 저장하지 않는다.
|
||||
* @param {{ day: string, ip: string, userAgent: string, secret: string }} input - 해시 입력
|
||||
* @returns {string} visitor hash
|
||||
*/
|
||||
export const createDailyVisitorHash = ({ day, ip, userAgent, secret }) => {
|
||||
const payload = `${day}|${ip}|${userAgent}|${secret}`
|
||||
return createHash('sha256').update(payload).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 slug 정규화
|
||||
* @param {string} slug - slug
|
||||
* @returns {string} 정규화된 slug
|
||||
*/
|
||||
export const normalizePostSlugForAnalytics = (slug) => (slug || '').trim()
|
||||
@@ -10,7 +10,9 @@ export const SITE_THEME_STORAGE_KEY = 'SITE_THEME'
|
||||
/** localStorage 키: 스플래시용 로고 이미지 URL(이전 방문에서 캐시) */
|
||||
export const SITE_BRAND_LOGO_URL_KEY = 'SITE_BRAND_LOGO_URL'
|
||||
|
||||
/** localStorage 키: 스플래시용 로고 텍스트 fallback */
|
||||
/**
|
||||
* @deprecated 스플래시 문구는 사이트 제목을 사용한다. 이전 버전 잔여 키 정리용.
|
||||
*/
|
||||
export const SITE_BRAND_LOGO_TEXT_KEY = 'SITE_BRAND_LOGO_TEXT'
|
||||
|
||||
/**
|
||||
@@ -29,6 +31,7 @@ export const resolveSiteTheme = (savedTheme, prefersDark) => {
|
||||
|
||||
/**
|
||||
* 첫 페인트 전에 테마·스플래시를 준비하는 head 인라인 스크립트 본문
|
||||
* @param {string} [siteTitle] - 스플래시에 쓸 사이트 제목(로고 이미지 없을 때)
|
||||
* @returns {string}
|
||||
*/
|
||||
export const buildSiteBootInlineScript = () => `(function(){try{var sk=${JSON.stringify(SITE_THEME_STORAGE_KEY)};var lk=${JSON.stringify(SITE_BRAND_LOGO_URL_KEY)};var tk=${JSON.stringify(SITE_BRAND_LOGO_TEXT_KEY)};var root=document.documentElement;var prefersDark=window.matchMedia("(prefers-color-scheme: dark)").matches;var saved=localStorage.getItem(sk);var theme=(saved==="light"||saved==="dark")?saved:(prefersDark?"dark":"light");root.dataset.theme=theme;root.style.colorScheme=theme;if(/^\\/(admin|signin|signup|forgot-password)(\\/|$)/.test(location.pathname)){root.classList.add("site-app-ready");return}var splash=document.getElementById("site-splash");if(!splash){return}var logoEl=document.getElementById("site-splash-logo");var textEl=document.getElementById("site-splash-text");var logoUrl=localStorage.getItem(lk)||"";var logoText=localStorage.getItem(tk)||"sori.studio";if(logoUrl&&logoEl){logoEl.src=logoUrl;logoEl.hidden=false;if(textEl){textEl.hidden=true}}else if(textEl){textEl.textContent=logoText;textEl.hidden=false}}catch(e){}})();`
|
||||
export const buildSiteBootInlineScript = (siteTitle = 'sori.studio') => `(function(){try{var sk=${JSON.stringify(SITE_THEME_STORAGE_KEY)};var lk=${JSON.stringify(SITE_BRAND_LOGO_URL_KEY)};var legacyTk=${JSON.stringify(SITE_BRAND_LOGO_TEXT_KEY)};var splashTitle=${JSON.stringify(siteTitle)};var root=document.documentElement;var prefersDark=window.matchMedia("(prefers-color-scheme: dark)").matches;var saved=localStorage.getItem(sk);var theme=(saved==="light"||saved==="dark")?saved:(prefersDark?"dark":"light");root.dataset.theme=theme;root.style.colorScheme=theme;try{localStorage.removeItem(legacyTk)}catch(e){}if(/^\\/(admin|signin|signup|forgot-password)(\\/|$)/.test(location.pathname)){root.classList.add("site-app-ready");return}var splash=document.getElementById("site-splash");if(!splash){return}var logoEl=document.getElementById("site-splash-logo");var textEl=document.getElementById("site-splash-text");var logoUrl=localStorage.getItem(lk)||"";if(logoUrl&&logoEl){logoEl.src=logoUrl;logoEl.hidden=false;if(textEl){textEl.hidden=true}}else if(textEl){textEl.textContent=splashTitle;textEl.hidden=false}}catch(e){}})();`
|
||||
|
||||
@@ -50,7 +50,7 @@ export default defineNuxtConfig({
|
||||
key: 'site-boot',
|
||||
type: 'text/javascript',
|
||||
tagPriority: 'critical',
|
||||
innerHTML: buildSiteBootInlineScript()
|
||||
innerHTML: buildSiteBootInlineScript(process.env.NUXT_PUBLIC_SITE_TITLE || 'sori.studio')
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -61,6 +61,7 @@ export default defineNuxtConfig({
|
||||
adminEmail: process.env.ADMIN_EMAIL || '',
|
||||
adminPassword: process.env.ADMIN_PASSWORD || '',
|
||||
memberSessionSecret: process.env.MEMBER_SESSION_SECRET || '',
|
||||
analyticsHashSecret: process.env.ANALYTICS_HASH_SECRET || '',
|
||||
resendApiKey: process.env.RESEND_API_KEY || '',
|
||||
resendFromEmail: process.env.RESEND_FROM_EMAIL || '',
|
||||
emailOtpPepper: process.env.EMAIL_OTP_PEPPER || '',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -7,6 +7,19 @@ const { data: posts } = await useFetch('/admin/api/posts', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const { data: analyticsSummary } = await useFetch('/admin/api/analytics/summary', {
|
||||
default: () => ({
|
||||
todayVisitors: 0,
|
||||
visitorsLast7Days: 0,
|
||||
pageViewsLast30Days: 0
|
||||
})
|
||||
})
|
||||
|
||||
const { data: topPosts } = await useFetch('/admin/api/analytics/posts', {
|
||||
query: { days: 30, limit: 5 },
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const publishedCount = computed(() => posts.value.filter((post) => post.status === 'published').length)
|
||||
const draftCount = computed(() => posts.value.filter((post) => post.status === 'draft').length)
|
||||
</script>
|
||||
@@ -21,30 +34,96 @@ const draftCount = computed(() => posts.value.filter((post) => post.status === '
|
||||
대시보드
|
||||
</h1>
|
||||
</div>
|
||||
<div class="admin-dashboard__body grid gap-4 bg-paper p-6 text-sm text-muted md:grid-cols-3">
|
||||
<section class="admin-dashboard__metric border border-line bg-white p-4">
|
||||
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase">
|
||||
Posts
|
||||
</p>
|
||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||
{{ posts.length }}
|
||||
</strong>
|
||||
<div class="admin-dashboard__body space-y-6 bg-paper p-6">
|
||||
<section class="admin-dashboard__analytics grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<article class="admin-dashboard__metric border border-line bg-white p-4">
|
||||
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase text-muted">
|
||||
오늘 방문
|
||||
</p>
|
||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||
{{ analyticsSummary.todayVisitors }}
|
||||
</strong>
|
||||
</article>
|
||||
<article class="admin-dashboard__metric border border-line bg-white p-4">
|
||||
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase text-muted">
|
||||
7일 방문
|
||||
</p>
|
||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||
{{ analyticsSummary.visitorsLast7Days }}
|
||||
</strong>
|
||||
</article>
|
||||
<article class="admin-dashboard__metric border border-line bg-white p-4">
|
||||
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase text-muted">
|
||||
30일 조회
|
||||
</p>
|
||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||
{{ analyticsSummary.pageViewsLast30Days }}
|
||||
</strong>
|
||||
</article>
|
||||
<article class="admin-dashboard__metric border border-line bg-white p-4">
|
||||
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase text-muted">
|
||||
게시물
|
||||
</p>
|
||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||
{{ posts.length }}
|
||||
</strong>
|
||||
<p class="admin-dashboard__metric-note mt-2 text-xs text-muted">
|
||||
발행 {{ publishedCount }} · 초안 {{ draftCount }}
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
<section class="admin-dashboard__metric border border-line bg-white p-4">
|
||||
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase">
|
||||
Published
|
||||
|
||||
<section class="admin-dashboard__top-posts border border-line bg-white p-4">
|
||||
<h2 class="admin-dashboard__section-title text-sm font-semibold text-ink">
|
||||
인기 게시물 (30일)
|
||||
</h2>
|
||||
<ul
|
||||
v-if="topPosts.length"
|
||||
class="admin-dashboard__top-posts-list mt-4 space-y-3 text-sm"
|
||||
>
|
||||
<li
|
||||
v-for="(item, index) in topPosts"
|
||||
:key="item.id"
|
||||
class="admin-dashboard__top-posts-item flex items-start justify-between gap-4 border-b border-line pb-3 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="admin-dashboard__top-posts-rank text-xs font-semibold uppercase text-muted">
|
||||
#{{ index + 1 }}
|
||||
</p>
|
||||
<NuxtLink
|
||||
:to="`/post/${item.slug}`"
|
||||
class="admin-dashboard__top-posts-title mt-1 block truncate font-medium text-ink hover:underline"
|
||||
target="_blank"
|
||||
>
|
||||
{{ item.title }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<dl class="admin-dashboard__top-posts-stats shrink-0 text-right text-xs text-muted">
|
||||
<div>
|
||||
<dt class="inline">
|
||||
조회
|
||||
</dt>
|
||||
<dd class="inline font-semibold text-ink">
|
||||
{{ item.views }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<dt class="inline">
|
||||
읽음
|
||||
</dt>
|
||||
<dd class="inline font-semibold text-ink">
|
||||
{{ item.reads }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
v-else
|
||||
class="admin-dashboard__top-posts-empty mt-4 text-sm text-muted"
|
||||
>
|
||||
아직 집계된 게시물 조회 데이터가 없습니다.
|
||||
</p>
|
||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||
{{ publishedCount }}
|
||||
</strong>
|
||||
</section>
|
||||
<section class="admin-dashboard__metric border border-line bg-white p-4">
|
||||
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase">
|
||||
Draft
|
||||
</p>
|
||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||
{{ draftCount }}
|
||||
</strong>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
211
plugins/site-analytics.client.js
Normal file
211
plugins/site-analytics.client.js
Normal file
@@ -0,0 +1,211 @@
|
||||
import { isTrackableAnalyticsPath } from '../lib/analytics.js'
|
||||
|
||||
/** @type {number} 읽음 판정 최소 체류 시간(ms) */
|
||||
const READ_MIN_DURATION_MS = 15000
|
||||
|
||||
/** @type {number} 읽음 판정 최소 스크롤 비율 */
|
||||
const READ_MIN_SCROLL_RATIO = 0.5
|
||||
|
||||
/** @type {Set<string>} 세션 내 전송 완료된 pageview 키 */
|
||||
const sentViewKeys = new Set()
|
||||
|
||||
/** @type {Set<string>} 세션 내 전송 완료된 read slug */
|
||||
const sentReadSlugs = new Set()
|
||||
|
||||
/** @type {number | null} read 폴링 타이머 */
|
||||
let readPollTimer = null
|
||||
|
||||
/** @type {(() => void) | null} scroll 리스너 */
|
||||
let readScrollListener = null
|
||||
|
||||
/** @type {number} read 추적 시작 시각 */
|
||||
let readStartedAt = 0
|
||||
|
||||
/** @type {string} read 추적 중인 게시물 slug */
|
||||
let readPostSlug = ''
|
||||
|
||||
/** @type {string} read 추적 중인 경로 */
|
||||
let readPath = ''
|
||||
|
||||
/**
|
||||
* 게시물 상세 경로에서 slug를 추출한다.
|
||||
* @param {import('vue-router').RouteLocationNormalizedLoaded} route - 현재 라우트
|
||||
* @returns {string} slug 또는 빈 문자열
|
||||
*/
|
||||
const extractPostSlugFromRoute = (route) => {
|
||||
const paramSlug = String(route.params?.slug || '').trim()
|
||||
if (paramSlug && String(route.path || '').startsWith('/post/')) {
|
||||
return paramSlug
|
||||
}
|
||||
|
||||
const match = String(route.path || '').match(/^\/post\/([^/?#]+)/)
|
||||
return match?.[1] ? decodeURIComponent(match[1]).trim() : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 스크롤 진행 비율(0~1)을 반환한다.
|
||||
* @returns {number} 스크롤 비율
|
||||
*/
|
||||
const getDocumentScrollRatio = () => {
|
||||
const scrollTop = window.scrollY || document.documentElement.scrollTop || 0
|
||||
const viewport = window.innerHeight || document.documentElement.clientHeight || 0
|
||||
const scrollHeight = document.documentElement.scrollHeight || 0
|
||||
const maxScroll = Math.max(scrollHeight - viewport, 0)
|
||||
|
||||
if (maxScroll <= 0) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return Math.min(scrollTop / maxScroll, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* read 추적 리스너를 해제한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const clearReadTracking = () => {
|
||||
if (readPollTimer) {
|
||||
clearInterval(readPollTimer)
|
||||
readPollTimer = null
|
||||
}
|
||||
|
||||
if (readScrollListener) {
|
||||
window.removeEventListener('scroll', readScrollListener)
|
||||
readScrollListener = null
|
||||
}
|
||||
|
||||
readPostSlug = ''
|
||||
readPath = ''
|
||||
readStartedAt = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 이벤트를 서버로 전송한다.
|
||||
* @param {{ path: string, postSlug?: string, read?: boolean }} payload - 전송 본문
|
||||
* @returns {void}
|
||||
*/
|
||||
const sendAnalyticsEvent = (payload) => {
|
||||
const url = '/api/analytics/pageview'
|
||||
const body = JSON.stringify({
|
||||
path: payload.path,
|
||||
postSlug: payload.postSlug || '',
|
||||
read: Boolean(payload.read)
|
||||
})
|
||||
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
|
||||
navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }))
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// sendBeacon 실패 시 fetch로 대체
|
||||
}
|
||||
|
||||
$fetch(url, {
|
||||
method: 'POST',
|
||||
body: JSON.parse(body),
|
||||
keepalive: true
|
||||
}).catch(() => {
|
||||
// 통계 실패는 사용자 경험에 영향을 주지 않는다
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 읽음 조건을 만족하면 read 이벤트를 한 번 전송한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const trySendReadEvent = () => {
|
||||
if (!readPostSlug || sentReadSlugs.has(readPostSlug)) {
|
||||
return
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - readStartedAt
|
||||
if (elapsed < READ_MIN_DURATION_MS) {
|
||||
return
|
||||
}
|
||||
|
||||
if (getDocumentScrollRatio() < READ_MIN_SCROLL_RATIO) {
|
||||
return
|
||||
}
|
||||
|
||||
sentReadSlugs.add(readPostSlug)
|
||||
sendAnalyticsEvent({
|
||||
path: readPath,
|
||||
postSlug: readPostSlug,
|
||||
read: true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 상세에서 read 추적을 시작한다.
|
||||
* @param {string} path - 경로
|
||||
* @param {string} postSlug - 게시물 slug
|
||||
* @returns {void}
|
||||
*/
|
||||
const startReadTracking = (path, postSlug) => {
|
||||
clearReadTracking()
|
||||
|
||||
if (!postSlug) {
|
||||
return
|
||||
}
|
||||
|
||||
readPath = path
|
||||
readPostSlug = postSlug
|
||||
readStartedAt = Date.now()
|
||||
|
||||
readScrollListener = () => {
|
||||
trySendReadEvent()
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', readScrollListener, { passive: true })
|
||||
readPollTimer = window.setInterval(() => {
|
||||
trySendReadEvent()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 라우트 변경 시 pageview를 기록한다.
|
||||
* @param {import('vue-router').RouteLocationNormalizedLoaded} route - 대상 라우트
|
||||
* @returns {void}
|
||||
*/
|
||||
const trackRouteAnalytics = (route) => {
|
||||
const path = String(route.path || '')
|
||||
|
||||
if (!isTrackableAnalyticsPath(path)) {
|
||||
clearReadTracking()
|
||||
return
|
||||
}
|
||||
|
||||
const postSlug = extractPostSlugFromRoute(route)
|
||||
const viewKey = `view:${path}:${postSlug}`
|
||||
|
||||
if (!sentViewKeys.has(viewKey)) {
|
||||
sentViewKeys.add(viewKey)
|
||||
sendAnalyticsEvent({
|
||||
path,
|
||||
postSlug,
|
||||
read: false
|
||||
})
|
||||
}
|
||||
|
||||
startReadTracking(path, postSlug)
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 페이지 방문·게시물 읽음 통계 클라이언트 트래커
|
||||
*/
|
||||
export default defineNuxtPlugin(() => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
router.isReady().then(() => {
|
||||
trackRouteAnalytics(router.currentRoute.value)
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
trackRouteAnalytics(to)
|
||||
})
|
||||
})
|
||||
@@ -8,22 +8,23 @@ import {
|
||||
const siteSettingsFetchKey = 'site-settings-public'
|
||||
|
||||
/**
|
||||
* 공개 사이트 설정에서 스플래시용 브랜드를 localStorage에 캐시한다.
|
||||
* 공개 사이트 설정에서 스플래시용 로고 이미지 URL만 localStorage에 캐시한다.
|
||||
* logoText(井 등)는 헤더 이미지 없을 때 짧은 기호용이며 스플래시·브랜드명과 무관하다.
|
||||
* @param {Object|null|undefined} settings - 사이트 설정
|
||||
* @returns {void}
|
||||
*/
|
||||
const cacheSiteBrandForSplash = (settings) => {
|
||||
if (!settings?.logoUrl && !settings?.logoText) {
|
||||
try {
|
||||
localStorage.removeItem(SITE_BRAND_LOGO_TEXT_KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (!settings?.logoUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
if (settings.logoUrl) {
|
||||
localStorage.setItem(SITE_BRAND_LOGO_URL_KEY, settings.logoUrl)
|
||||
}
|
||||
|
||||
if (settings.logoText) {
|
||||
localStorage.setItem(SITE_BRAND_LOGO_TEXT_KEY, settings.logoText)
|
||||
}
|
||||
localStorage.setItem(SITE_BRAND_LOGO_URL_KEY, settings.logoUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
8
server/api/analytics/pageview.post.js
Normal file
8
server/api/analytics/pageview.post.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { handleAnalyticsPageview } from '../../utils/analytics-pageview-input.js'
|
||||
|
||||
/**
|
||||
* 공개 페이지뷰·읽음 통계 수집 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ ok: true }>}
|
||||
*/
|
||||
export default defineEventHandler((event) => handleAnalyticsPageview(event))
|
||||
255
server/repositories/analytics-repository.js
Normal file
255
server/repositories/analytics-repository.js
Normal file
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
createDailyVisitorHash,
|
||||
getAnalyticsDayKey
|
||||
} from '../../lib/analytics.js'
|
||||
import { getPostgresClient } from './postgres-client.js'
|
||||
import { getRuntimeEnvValue } from '../utils/runtime-env.js'
|
||||
|
||||
/**
|
||||
* 통계 해시용 시크릿을 반환한다.
|
||||
* @returns {string} 시크릿
|
||||
*/
|
||||
const getAnalyticsHashSecret = () => {
|
||||
return getRuntimeEnvValue(
|
||||
'ANALYTICS_HASH_SECRET',
|
||||
'analyticsHashSecret',
|
||||
getRuntimeEnvValue('MEMBER_SESSION_SECRET', 'memberSessionSecret', 'analytics-fallback-secret')
|
||||
).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 일별 사이트 통계 행을 보장한다.
|
||||
* @param {import('postgres').Sql} sql - DB 클라이언트
|
||||
* @param {string} day - YYYY-MM-DD
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const ensureSiteDailyRow = async (sql, day) => {
|
||||
await sql`
|
||||
INSERT INTO site_analytics_daily (day)
|
||||
VALUES (${day})
|
||||
ON CONFLICT (day) DO NOTHING
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 일별 게시물 통계 행을 보장한다.
|
||||
* @param {import('postgres').Sql} sql - DB 클라이언트
|
||||
* @param {string} day - YYYY-MM-DD
|
||||
* @param {string} postId - 게시물 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const ensurePostDailyRow = async (sql, day, postId) => {
|
||||
await sql`
|
||||
INSERT INTO post_analytics_daily (day, post_id)
|
||||
VALUES (${day}, ${postId})
|
||||
ON CONFLICT (day, post_id) DO NOTHING
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트 방문자를 등록하고 신규 방문자면 true를 반환한다.
|
||||
* @param {import('postgres').Sql} sql - DB 클라이언트
|
||||
* @param {string} day - YYYY-MM-DD
|
||||
* @param {string} visitorHash - 방문자 해시
|
||||
* @returns {Promise<boolean>} 신규 방문자 여부
|
||||
*/
|
||||
const registerSiteVisitor = async (sql, day, visitorHash) => {
|
||||
const rows = await sql`
|
||||
INSERT INTO analytics_daily_visitors (day, scope, visitor_hash)
|
||||
VALUES (${day}, 'site', ${visitorHash})
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
return Boolean(rows[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 방문자를 등록하고 신규 방문자면 true를 반환한다.
|
||||
* @param {import('postgres').Sql} sql - DB 클라이언트
|
||||
* @param {string} day - YYYY-MM-DD
|
||||
* @param {string} postId - 게시물 ID
|
||||
* @param {string} visitorHash - 방문자 해시
|
||||
* @returns {Promise<boolean>} 신규 방문자 여부
|
||||
*/
|
||||
const registerPostVisitor = async (sql, day, postId, visitorHash) => {
|
||||
const rows = await sql`
|
||||
INSERT INTO analytics_daily_visitors (day, scope, post_id, visitor_hash)
|
||||
VALUES (${day}, 'post', ${postId}, ${visitorHash})
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
return Boolean(rows[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지뷰·읽음 이벤트를 기록한다.
|
||||
* @param {{ visitorHash: string, postId?: string | null, recordSite?: boolean, recordView?: boolean, recordRead?: boolean }} input - 기록 입력
|
||||
* @returns {Promise<{ ok: true }>}
|
||||
*/
|
||||
export const recordAnalyticsPageview = async (input) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const day = getAnalyticsDayKey()
|
||||
const visitorHash = input.visitorHash
|
||||
const postId = input.postId || null
|
||||
const recordSite = input.recordSite !== false
|
||||
const recordView = Boolean(input.recordView)
|
||||
const recordRead = Boolean(input.recordRead)
|
||||
|
||||
if (recordSite) {
|
||||
await ensureSiteDailyRow(sql, day)
|
||||
|
||||
const isNewSiteVisitor = await registerSiteVisitor(sql, day, visitorHash)
|
||||
|
||||
await sql`
|
||||
UPDATE site_analytics_daily
|
||||
SET
|
||||
page_views = page_views + 1,
|
||||
visitors = visitors + ${isNewSiteVisitor ? 1 : 0}
|
||||
WHERE day = ${day}
|
||||
`
|
||||
}
|
||||
|
||||
if (!postId) {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
await ensurePostDailyRow(sql, day, postId)
|
||||
|
||||
if (recordView) {
|
||||
const isNewPostVisitor = await registerPostVisitor(sql, day, postId, visitorHash)
|
||||
|
||||
await sql`
|
||||
UPDATE post_analytics_daily
|
||||
SET
|
||||
views = views + 1,
|
||||
visitors = visitors + ${isNewPostVisitor ? 1 : 0}
|
||||
WHERE day = ${day}
|
||||
AND post_id = ${postId}
|
||||
`
|
||||
}
|
||||
|
||||
if (recordRead) {
|
||||
await sql`
|
||||
UPDATE post_analytics_daily
|
||||
SET reads = reads + 1
|
||||
WHERE day = ${day}
|
||||
AND post_id = ${postId}
|
||||
`
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청에서 일 단위 방문자 해시를 만든다.
|
||||
* @param {import('h3').H3Event} event - H3 이벤트
|
||||
* @returns {string} visitor hash
|
||||
*/
|
||||
export const createVisitorHashFromEvent = (event) => {
|
||||
const day = getAnalyticsDayKey()
|
||||
const ip = String(getRequestIP(event, { xForwardedFor: true }) || '')
|
||||
const userAgent = String(getRequestHeader(event, 'user-agent') || '')
|
||||
|
||||
return createDailyVisitorHash({
|
||||
day,
|
||||
ip,
|
||||
userAgent,
|
||||
secret: getAnalyticsHashSecret()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 요약을 조회한다.
|
||||
* @param {{ days?: number }} [options] - 조회 옵션
|
||||
* @returns {Promise<Object>} 요약 통계
|
||||
*/
|
||||
export const getAnalyticsSummary = async (options = {}) => {
|
||||
const sql = getPostgresClient()
|
||||
const days = Math.min(Math.max(Number(options.days) || 30, 1), 365)
|
||||
|
||||
if (!sql) {
|
||||
return {
|
||||
todayVisitors: 0,
|
||||
visitorsLast7Days: 0,
|
||||
pageViewsLast30Days: 0,
|
||||
days
|
||||
}
|
||||
}
|
||||
|
||||
const today = getAnalyticsDayKey()
|
||||
|
||||
const todayRows = await sql`
|
||||
SELECT visitors, page_views
|
||||
FROM site_analytics_daily
|
||||
WHERE day = ${today}::date
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
const last7Rows = await sql`
|
||||
SELECT COALESCE(SUM(visitors), 0)::int AS visitors
|
||||
FROM site_analytics_daily
|
||||
WHERE day >= (${today}::date - 6)
|
||||
`
|
||||
|
||||
const pageViewRows = await sql`
|
||||
SELECT COALESCE(SUM(page_views), 0)::int AS page_views
|
||||
FROM site_analytics_daily
|
||||
WHERE day >= (${today}::date - 29)
|
||||
`
|
||||
|
||||
return {
|
||||
todayVisitors: Number(todayRows[0]?.visitors || 0),
|
||||
visitorsLast7Days: Number(last7Rows[0]?.visitors || 0),
|
||||
pageViewsLast30Days: Number(pageViewRows[0]?.page_views || 0),
|
||||
days
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인기 게시물 통계를 조회한다.
|
||||
* @param {{ days?: number, limit?: number }} [options] - 조회 옵션
|
||||
* @returns {Promise<Array<Object>>} 인기 게시물 목록
|
||||
*/
|
||||
export const getAnalyticsTopPosts = async (options = {}) => {
|
||||
const sql = getPostgresClient()
|
||||
const days = Math.min(Math.max(Number(options.days) || 30, 1), 365)
|
||||
const limit = Math.min(Math.max(Number(options.limit) || 5, 1), 20)
|
||||
|
||||
if (!sql) {
|
||||
return []
|
||||
}
|
||||
|
||||
const today = getAnalyticsDayKey()
|
||||
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
posts.id,
|
||||
posts.title,
|
||||
posts.slug,
|
||||
COALESCE(SUM(post_analytics_daily.views), 0)::int AS views,
|
||||
COALESCE(SUM(post_analytics_daily.reads), 0)::int AS reads,
|
||||
COALESCE(SUM(post_analytics_daily.visitors), 0)::int AS visitors
|
||||
FROM post_analytics_daily
|
||||
INNER JOIN posts ON posts.id = post_analytics_daily.post_id
|
||||
WHERE post_analytics_daily.day >= (${today}::date - ${days - 1})
|
||||
GROUP BY posts.id, posts.title, posts.slug
|
||||
ORDER BY views DESC, reads DESC, posts.published_at DESC NULLS LAST
|
||||
LIMIT ${limit}
|
||||
`
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
slug: row.slug,
|
||||
views: Number(row.views || 0),
|
||||
reads: Number(row.reads || 0),
|
||||
visitors: Number(row.visitors || 0)
|
||||
}))
|
||||
}
|
||||
17
server/routes/admin/api/analytics/posts.get.js
Normal file
17
server/routes/admin/api/analytics/posts.get.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth.js'
|
||||
import { getAnalyticsTopPosts } from '../../../../repositories/analytics-repository.js'
|
||||
|
||||
/**
|
||||
* 관리자 인기 게시물 통계 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Array<Object>>} 인기 게시물 목록
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
const query = getQuery(event)
|
||||
const days = Number(query.days) || 30
|
||||
const limit = Number(query.limit) || 5
|
||||
|
||||
return getAnalyticsTopPosts({ days, limit })
|
||||
})
|
||||
16
server/routes/admin/api/analytics/summary.get.js
Normal file
16
server/routes/admin/api/analytics/summary.get.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth.js'
|
||||
import { getAnalyticsSummary } from '../../../../repositories/analytics-repository.js'
|
||||
|
||||
/**
|
||||
* 관리자 통계 요약 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Object>} 통계 요약
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
const query = getQuery(event)
|
||||
const days = Number(query.days) || 30
|
||||
|
||||
return getAnalyticsSummary({ days })
|
||||
})
|
||||
68
server/utils/analytics-pageview-input.js
Normal file
68
server/utils/analytics-pageview-input.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
isBotUserAgent,
|
||||
isTrackableAnalyticsPath,
|
||||
normalizePostSlugForAnalytics
|
||||
} from '../../lib/analytics.js'
|
||||
import { getPostBySlug } from '../repositories/content-repository.js'
|
||||
import {
|
||||
createVisitorHashFromEvent,
|
||||
recordAnalyticsPageview
|
||||
} from '../repositories/analytics-repository.js'
|
||||
|
||||
const pageviewInputSchema = z.object({
|
||||
path: z.string().trim().min(1).max(500),
|
||||
postSlug: z.string().trim().max(200).optional().default(''),
|
||||
read: z.boolean().optional().default(false)
|
||||
})
|
||||
|
||||
/**
|
||||
* 페이지뷰 추적 요청을 처리한다.
|
||||
* @param {import('h3').H3Event} event - H3 이벤트
|
||||
* @returns {Promise<{ ok: true }>}
|
||||
*/
|
||||
export const handleAnalyticsPageview = async (event) => {
|
||||
const parsedBody = pageviewInputSchema.safeParse(await readBody(event))
|
||||
|
||||
if (!parsedBody.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '통계 요청 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const body = parsedBody.data
|
||||
const userAgent = String(getRequestHeader(event, 'user-agent') || '')
|
||||
|
||||
if (isBotUserAgent(userAgent)) {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
if (!isTrackableAnalyticsPath(body.path)) {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const postSlug = normalizePostSlugForAnalytics(body.postSlug)
|
||||
let postId = null
|
||||
|
||||
if (postSlug) {
|
||||
const post = await getPostBySlug(postSlug)
|
||||
if (!post) {
|
||||
return { ok: true }
|
||||
}
|
||||
postId = post.id
|
||||
}
|
||||
|
||||
const visitorHash = createVisitorHashFromEvent(event)
|
||||
const isReadEvent = Boolean(body.read)
|
||||
|
||||
await recordAnalyticsPageview({
|
||||
visitorHash,
|
||||
postId,
|
||||
recordSite: !isReadEvent,
|
||||
recordView: Boolean(postId) && !isReadEvent,
|
||||
recordRead: Boolean(postId) && isReadEvent
|
||||
})
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
Reference in New Issue
Block a user