v1.3.3: 자체 최소 통계 및 스플래시 localStorage 정리

- 일별 익명 방문자 해시·사이트/게시물 통계(030 마이그레이션)
- POST /api/analytics/pageview, 관리자 analytics API, 클라이언트 트래커
- 관리자 대시보드 통계 카드·인기 게시물 Top 5
- 스플래시: SITE_BRAND_LOGO_TEXT localStorage 제거
This commit is contained in:
2026-05-20 12:15:13 +09:00
parent b6a3228b09
commit 3623305119
18 changed files with 831 additions and 37 deletions

View File

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

View 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';

View File

@@ -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 네트워크 충돌 대응

View File

@@ -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 | 사이트·게시물 일별 통계·일별 방문자 해시 테이블 |
## 설정/배포

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.3.2",
"version": "1.3.3",
"private": true,
"type": "module",
"imports": {

View File

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

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

View File

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

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

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

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

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

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