diff --git a/.env.example b/.env.example index b22196f..e30846f 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/db/migrations/030_analytics_daily_stats.sql b/db/migrations/030_analytics_daily_stats.sql new file mode 100644 index 0000000..2b0ab0a --- /dev/null +++ b/db/migrations/030_analytics_daily_stats.sql @@ -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'; diff --git a/docs/deploy.md b/docs/deploy.md index a6246c3..230e26a 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -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 네트워크 충돌 대응 diff --git a/docs/map.md b/docs/map.md index e8130d8..7b1b7fc 100644 --- a/docs/map.md +++ b/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 | 사이트·게시물 일별 통계·일별 방문자 해시 테이블 | ## 설정/배포 diff --git a/docs/spec.md b/docs/spec.md index 2be45af..5a3a916 100644 --- a/docs/spec.md +++ b/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 diff --git a/docs/update.md b/docs/update.md index 89d1e6f..1c089ef 100644 --- a/docs/update.md +++ b/docs/update.md @@ -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일 스누즈. diff --git a/lib/analytics.js b/lib/analytics.js new file mode 100644 index 0000000..9900883 --- /dev/null +++ b/lib/analytics.js @@ -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() diff --git a/lib/site-theme-init.js b/lib/site-theme-init.js index 47befe1..2eb727f 100644 --- a/lib/site-theme-init.js +++ b/lib/site-theme-init.js @@ -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){}})();` diff --git a/nuxt.config.js b/nuxt.config.js index 13d1a3c..7239a29 100644 --- a/nuxt.config.js +++ b/nuxt.config.js @@ -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 || '', diff --git a/package.json b/package.json index 18cc7b0..eacbdd6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.3.2", + "version": "1.3.3", "private": true, "type": "module", "imports": { diff --git a/pages/admin/index.vue b/pages/admin/index.vue index d8ef999..150d8af 100644 --- a/pages/admin/index.vue +++ b/pages/admin/index.vue @@ -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) @@ -21,30 +34,96 @@ const draftCount = computed(() => posts.value.filter((post) => post.status === ' 대시보드 -
-
-

- Posts -

- - {{ posts.length }} - +
+
+
+

+ 오늘 방문 +

+ + {{ analyticsSummary.todayVisitors }} + +
+
+

+ 7일 방문 +

+ + {{ analyticsSummary.visitorsLast7Days }} + +
+
+

+ 30일 조회 +

+ + {{ analyticsSummary.pageViewsLast30Days }} + +
+
+

+ 게시물 +

+ + {{ posts.length }} + +

+ 발행 {{ publishedCount }} · 초안 {{ draftCount }} +

+
-
-

- Published + +

+

+ 인기 게시물 (30일) +

+
    +
  • +
    +

    + #{{ index + 1 }} +

    + + {{ item.title }} + +
    +
    +
    +
    + 조회 +
    +
    + {{ item.views }} +
    +
    +
    +
    + 읽음 +
    +
    + {{ item.reads }} +
    +
    +
    +
  • +
+

+ 아직 집계된 게시물 조회 데이터가 없습니다.

- - {{ publishedCount }} - -
-
-

- Draft -

- - {{ draftCount }} -
diff --git a/plugins/site-analytics.client.js b/plugins/site-analytics.client.js new file mode 100644 index 0000000..ff4315a --- /dev/null +++ b/plugins/site-analytics.client.js @@ -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} 세션 내 전송 완료된 pageview 키 */ +const sentViewKeys = new Set() + +/** @type {Set} 세션 내 전송 완료된 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) + }) +}) diff --git a/plugins/site-app-ready.client.js b/plugins/site-app-ready.client.js index 42e3602..49b5aac 100644 --- a/plugins/site-app-ready.client.js +++ b/plugins/site-app-ready.client.js @@ -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) } /** diff --git a/server/api/analytics/pageview.post.js b/server/api/analytics/pageview.post.js new file mode 100644 index 0000000..cf2cb8e --- /dev/null +++ b/server/api/analytics/pageview.post.js @@ -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)) diff --git a/server/repositories/analytics-repository.js b/server/repositories/analytics-repository.js new file mode 100644 index 0000000..0efe36d --- /dev/null +++ b/server/repositories/analytics-repository.js @@ -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} + */ +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} + */ +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} 신규 방문자 여부 + */ +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} 신규 방문자 여부 + */ +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} 요약 통계 + */ +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>} 인기 게시물 목록 + */ +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) + })) +} diff --git a/server/routes/admin/api/analytics/posts.get.js b/server/routes/admin/api/analytics/posts.get.js new file mode 100644 index 0000000..74da29d --- /dev/null +++ b/server/routes/admin/api/analytics/posts.get.js @@ -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>} 인기 게시물 목록 + */ +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 }) +}) diff --git a/server/routes/admin/api/analytics/summary.get.js b/server/routes/admin/api/analytics/summary.get.js new file mode 100644 index 0000000..9c85901 --- /dev/null +++ b/server/routes/admin/api/analytics/summary.get.js @@ -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} 통계 요약 + */ +export default defineEventHandler(async (event) => { + requireAdminSession(event) + + const query = getQuery(event) + const days = Number(query.days) || 30 + + return getAnalyticsSummary({ days }) +}) diff --git a/server/utils/analytics-pageview-input.js b/server/utils/analytics-pageview-input.js new file mode 100644 index 0000000..8939935 --- /dev/null +++ b/server/utils/analytics-pageview-input.js @@ -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 } +}