diff --git a/db/migrations/031_analytics_engagement_and_realtime.sql b/db/migrations/031_analytics_engagement_and_realtime.sql new file mode 100644 index 0000000..4249088 --- /dev/null +++ b/db/migrations/031_analytics_engagement_and_realtime.sql @@ -0,0 +1,30 @@ +ALTER TABLE site_analytics_daily + ADD COLUMN IF NOT EXISTS engaged_views INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS total_engaged_seconds INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE post_analytics_daily + ADD COLUMN IF NOT EXISTS engaged_views INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS total_engaged_seconds INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS scroll_25 INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS scroll_50 INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS scroll_75 INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS scroll_100 INTEGER NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS analytics_active_sessions ( + session_hash TEXT PRIMARY KEY, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + path TEXT NOT NULL, + post_id UUID REFERENCES posts(id) ON DELETE SET NULL, + post_slug TEXT NOT NULL DEFAULT '', + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + duration_seconds INTEGER NOT NULL DEFAULT 0, + max_scroll_ratio REAL NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS analytics_active_sessions_last_seen_idx + ON analytics_active_sessions (last_seen_at DESC); + +CREATE INDEX IF NOT EXISTS analytics_active_sessions_user_idx + ON analytics_active_sessions (user_id) + WHERE user_id IS NOT NULL; diff --git a/docs/deploy.md b/docs/deploy.md index 230e26a..0fa56f8 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -146,6 +146,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/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 compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/031_analytics_engagement_and_realtime.sql ``` ### Docker 네트워크 충돌 대응 diff --git a/docs/map.md b/docs/map.md index 7b1b7fc..3e99562 100644 --- a/docs/map.md +++ b/docs/map.md @@ -115,7 +115,7 @@ | 파일 | 화면 | |------|------| -| pages/admin/index.vue | 대시보드(오늘·7일 방문, 30일 조회, 인기 게시물 Top 5) | +| pages/admin/index.vue | 대시보드(실시간 접속자·체류·스크롤·인기 게시물 참여 지표) | | pages/admin/login.vue | 관리자 로그인(이메일·비밀번호 모두 입력 시에만 제출 버튼 활성) | | pages/admin/posts/index.vue | 글 목록, 헤더 우측에 필터(상태·태그·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행 텍스트 상태, 제목 옆 댓글 수, 읽기 전용 태그 배지, 행 끝 more vert 메뉴(추천·삭제) | | pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·이탈 직전 플러시, 저장 토스트 | @@ -245,9 +245,12 @@ | server/repositories/analytics-repository.js | 방문·게시물 통계 집계·관리자 요약 조회 | | server/utils/analytics-pageview-input.js | `POST /api/analytics/pageview` 검증·기록 | | server/api/analytics/pageview.post.js | 공개 통계 수집 API | +| server/api/analytics/heartbeat.post.js | 공개 heartbeat·체류·스크롤 수집 API | +| server/utils/analytics-heartbeat-input.js | heartbeat 검증·기록 | | 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 클라이언트 전송 | +| server/routes/admin/api/analytics/realtime.get.js | 관리자 실시간 접속자 API | +| plugins/site-analytics.client.js | 공개 라우트 pageview·heartbeat·read 클라이언트 전송 | ## 데이터베이스 @@ -273,6 +276,7 @@ | 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 | 사이트·게시물 일별 통계·일별 방문자 해시 테이블 | +| db/migrations/031_analytics_engagement_and_realtime.sql | 체류·스크롤 집계 컬럼·실시간 접속 세션 테이블 | ## 설정/배포 diff --git a/docs/spec.md b/docs/spec.md index 5a3a916..584a2d4 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -357,13 +357,16 @@ components/content/ | 테이블 | 필드 | 설명 | |--------|------|------| -| site_analytics_daily | day, page_views, visitors | 사이트 일별 페이지뷰·방문자(중복 제거) | -| post_analytics_daily | day, post_id, views, reads, visitors | 게시물 일별 조회·읽음·방문자 | +| site_analytics_daily | day, page_views, visitors, engaged_views, total_engaged_seconds | 사이트 일별 페이지뷰·방문자·체류 집계 | +| post_analytics_daily | day, post_id, views, reads, visitors, engaged_views, total_engaged_seconds, scroll_25~100 | 게시물 일별 조회·읽음·스크롤 구간 | | analytics_daily_visitors | day, scope(`site`/`post`), post_id?, visitor_hash | 일별 방문자 해시 등록(중복 방문 제거용) | +| analytics_active_sessions | session_hash, user_id?, path, post_id?, post_slug, duration_seconds, max_scroll_ratio, last_seen_at | 실시간 접속 세션(TTL 90초) | - 추적 대상: 공개 경로만. `/admin`, `/signin`, `/signup`, `/forgot-password`, `/settings`는 제외. - 봇 User-Agent는 서버에서 무시. - 게시물 `reads`는 클라이언트에서 15초 이상 체류·50% 이상 스크롤 후 별도 전송. +- `POST /api/analytics/heartbeat`는 20초 간격으로 체류시간·스크롤·현재 경로를 전송한다. 로그인 사용자는 서버 세션으로 `user_id`를 연결한다. +- 관리자 대시보드는 `GET /admin/api/analytics/realtime`으로 현재 접속자 목록(닉네임·아바타·경로·마지막 활동)을 조회한다. --- @@ -394,6 +397,7 @@ components/content/ - `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만) - `GET /api/site-settings` - 공개 사이트 설정(어나운스 바·홈 커버 등 포함) - `POST /api/analytics/pageview` - 공개 방문·게시물 조회/읽음 집계. 본문: `path`(필수), `postSlug`(게시물일 때), `read`(읽음 이벤트). 발행된 게시물만 `postSlug` 집계. 응답 `{ ok: true }`. +- `POST /api/analytics/heartbeat` - 실시간 세션·체류·스크롤 집계. 본문: `path`, `postSlug`, `clientSessionId`, `durationSeconds`(최대 1800), `maxScrollRatio`(0~1). 로그인 시 서버가 회원 세션으로 사용자 연결. - `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에 해시해 둘 때만 쓰이는 서버 비밀(긴 난문자열 권장)이다. @@ -425,8 +429,9 @@ 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/analytics/summary?days=30` - 통계 요약(오늘/7일 방문, 30일 조회, 현재 접속자, 평균 체류, 50% 스크롤 도달) +- `GET /admin/api/analytics/posts?days=30&limit=5` - 기간 내 인기 게시물(조회·읽음·평균 체류·스크롤 구간) +- `GET /admin/api/analytics/realtime?limit=20` - 현재 접속자 요약·목록(로그인 사용자 닉네임·아바타 포함) - `GET /admin/api/posts` - 글 목록 - `POST /admin/api/posts` - 글 작성 - `GET /admin/api/posts/:id` - 글 상세 diff --git a/docs/update.md b/docs/update.md index 1c089ef..01baf1e 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v1.3.4 + +- 통계 확장: 체류시간·스크롤 구간(25/50/75/100%)·실시간 접속 세션. 마이그레이션 `031_analytics_engagement_and_realtime.sql`. +- `POST /api/analytics/heartbeat`, `GET /admin/api/analytics/realtime`. 로그인 사용자는 닉네임·아바타·현재 경로로 접속자 목록 표시. +- 관리자 대시보드: 현재 접속자·평균 체류·50% 스크롤·실시간 접속자 목록·인기 글 참여 지표 추가. + ## v1.3.3 - 자체 최소 통계: 일별 익명 방문자 해시·사이트/게시물 일별 집계. 마이그레이션 `030_analytics_daily_stats.sql`. 원문 IP·User-Agent 미저장. diff --git a/lib/analytics.js b/lib/analytics.js index 9900883..a1657ce 100644 --- a/lib/analytics.js +++ b/lib/analytics.js @@ -63,3 +63,80 @@ export const createDailyVisitorHash = ({ day, ip, userAgent, secret }) => { * @returns {string} 정규화된 slug */ export const normalizePostSlugForAnalytics = (slug) => (slug || '').trim() + +/** @type {number} heartbeat 체류시간 상한(초) */ +export const ANALYTICS_MAX_DURATION_SECONDS = 1800 + +/** @type {number} engaged_views 집계 최소 체류(초) */ +export const ANALYTICS_ENGAGED_MIN_SECONDS = 10 + +/** @type {number} 현재 접속자 판정 TTL(초) */ +export const ANALYTICS_ACTIVE_SESSION_TTL_SECONDS = 90 + +/** @type {number[]} 스크롤 구간 임계값 */ +export const ANALYTICS_SCROLL_THRESHOLDS = [0.25, 0.5, 0.75, 1] + +/** + * 체류시간(초)을 상한 내로 보정한다. + * @param {number} seconds - 체류시간 + * @returns {number} 보정된 초 + */ +export const clampAnalyticsDurationSeconds = (seconds) => { + const value = Number(seconds) + if (!Number.isFinite(value) || value < 0) { + return 0 + } + + return Math.min(Math.floor(value), ANALYTICS_MAX_DURATION_SECONDS) +} + +/** + * 스크롤 비율을 0~1로 보정한다. + * @param {number} ratio - 스크롤 비율 + * @returns {number} 보정된 비율 + */ +export const clampAnalyticsScrollRatio = (ratio) => { + const value = Number(ratio) + if (!Number.isFinite(value) || value < 0) { + return 0 + } + + return Math.min(value, 1) +} + +/** + * 새로 통과한 스크롤 구간 컬럼명 목록을 반환한다. + * @param {number} previousRatio - 이전 최대 스크롤 + * @param {number} nextRatio - 갱신된 최대 스크롤 + * @returns {Array<'scroll_25' | 'scroll_50' | 'scroll_75' | 'scroll_100'>} 신규 구간 + */ +export const getNewScrollBucketColumns = (previousRatio, nextRatio) => { + const previous = clampAnalyticsScrollRatio(previousRatio) + const next = clampAnalyticsScrollRatio(nextRatio) + const columns = [] + + if (previous < 0.25 && next >= 0.25) { + columns.push('scroll_25') + } + if (previous < 0.5 && next >= 0.5) { + columns.push('scroll_50') + } + if (previous < 0.75 && next >= 0.75) { + columns.push('scroll_75') + } + if (previous < 1 && next >= 1) { + columns.push('scroll_100') + } + + return columns +} + +/** + * 실시간 세션 해시를 생성한다. + * @param {{ clientSessionId: string, visitorHash: string, secret: string }} input - 해시 입력 + * @returns {string} session hash + */ +export const createRealtimeSessionHash = ({ clientSessionId, visitorHash, secret }) => { + const payload = `${clientSessionId}|${visitorHash}|${secret}` + return createHash('sha256').update(payload).digest('hex') +} diff --git a/package.json b/package.json index eacbdd6..5c2676f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.3.3", + "version": "1.3.4", "private": true, "type": "module", "imports": { diff --git a/pages/admin/index.vue b/pages/admin/index.vue index 150d8af..9acecbb 100644 --- a/pages/admin/index.vue +++ b/pages/admin/index.vue @@ -7,11 +7,15 @@ const { data: posts } = await useFetch('/admin/api/posts', { default: () => [] }) -const { data: analyticsSummary } = await useFetch('/admin/api/analytics/summary', { +const { data: analyticsSummary, refresh: refreshSummary } = await useFetch('/admin/api/analytics/summary', { default: () => ({ todayVisitors: 0, visitorsLast7Days: 0, - pageViewsLast30Days: 0 + pageViewsLast30Days: 0, + onlineNow: 0, + loggedInNow: 0, + avgEngagedSeconds: 0, + scroll50Reach: 0 }) }) @@ -20,8 +24,72 @@ const { data: topPosts } = await useFetch('/admin/api/analytics/posts', { default: () => [] }) +const { data: realtime, refresh: refreshRealtime } = await useFetch('/admin/api/analytics/realtime', { + query: { limit: 20 }, + default: () => ({ + summary: { + onlineNow: 0, + loggedInNow: 0, + anonymousNow: 0 + }, + sessions: [] + }) +}) + const publishedCount = computed(() => posts.value.filter((post) => post.status === 'published').length) const draftCount = computed(() => posts.value.filter((post) => post.status === 'draft').length) + +/** + * 초 단위 체류시간을 읽기 쉬운 문자열로 변환한다. + * @param {number} seconds - 초 + * @returns {string} 표시 문자열 + */ +const formatEngagedDuration = (seconds) => { + const value = Number(seconds) || 0 + if (value < 60) { + return `${value}초` + } + + const minutes = Math.floor(value / 60) + const remainSeconds = value % 60 + return remainSeconds > 0 ? `${minutes}분 ${remainSeconds}초` : `${minutes}분` +} + +/** + * 마지막 활동 시각을 상대 표시로 변환한다. + * @param {string|null} iso - ISO 시각 + * @returns {string} 상대 시각 + */ +const formatLastSeen = (iso) => { + if (!iso) { + return '-' + } + + const diffMs = Date.now() - new Date(iso).getTime() + const diffSec = Math.max(Math.floor(diffMs / 1000), 0) + + if (diffSec < 60) { + return `${diffSec}초 전` + } + + const diffMin = Math.floor(diffSec / 60) + return `${diffMin}분 전` +} + +let refreshTimer = null + +onMounted(() => { + refreshTimer = window.setInterval(() => { + refreshSummary() + refreshRealtime() + }, 30000) +}) + +onUnmounted(() => { + if (refreshTimer) { + window.clearInterval(refreshTimer) + } +})