v1.3.4: 통계 확장(체류·스크롤·실시간 접속자)
- 031 마이그레이션: 체류·스크롤 집계, analytics_active_sessions - heartbeat API, 관리자 realtime API, 클라이언트 heartbeat - 대시보드: 현재 접속자 목록(로그인 닉네임·아바타), 참여 지표
This commit is contained in:
30
db/migrations/031_analytics_engagement_and_realtime.sql
Normal file
30
db/migrations/031_analytics_engagement_and_realtime.sql
Normal file
@@ -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;
|
||||||
@@ -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/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/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/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 네트워크 충돌 대응
|
### Docker 네트워크 충돌 대응
|
||||||
|
|||||||
@@ -115,7 +115,7 @@
|
|||||||
|
|
||||||
| 파일 | 화면 |
|
| 파일 | 화면 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| pages/admin/index.vue | 대시보드(오늘·7일 방문, 30일 조회, 인기 게시물 Top 5) |
|
| pages/admin/index.vue | 대시보드(실시간 접속자·체류·스크롤·인기 게시물 참여 지표) |
|
||||||
| pages/admin/login.vue | 관리자 로그인(이메일·비밀번호 모두 입력 시에만 제출 버튼 활성) |
|
| pages/admin/login.vue | 관리자 로그인(이메일·비밀번호 모두 입력 시에만 제출 버튼 활성) |
|
||||||
| pages/admin/posts/index.vue | 글 목록, 헤더 우측에 필터(상태·태그·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행 텍스트 상태, 제목 옆 댓글 수, 읽기 전용 태그 배지, 행 끝 more vert 메뉴(추천·삭제) |
|
| pages/admin/posts/index.vue | 글 목록, 헤더 우측에 필터(상태·태그·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행 텍스트 상태, 제목 옆 댓글 수, 읽기 전용 태그 배지, 행 끝 more vert 메뉴(추천·삭제) |
|
||||||
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·이탈 직전 플러시, 저장 토스트 |
|
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·이탈 직전 플러시, 저장 토스트 |
|
||||||
@@ -245,9 +245,12 @@
|
|||||||
| server/repositories/analytics-repository.js | 방문·게시물 통계 집계·관리자 요약 조회 |
|
| server/repositories/analytics-repository.js | 방문·게시물 통계 집계·관리자 요약 조회 |
|
||||||
| server/utils/analytics-pageview-input.js | `POST /api/analytics/pageview` 검증·기록 |
|
| server/utils/analytics-pageview-input.js | `POST /api/analytics/pageview` 검증·기록 |
|
||||||
| server/api/analytics/pageview.post.js | 공개 통계 수집 API |
|
| 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/summary.get.js | 관리자 통계 요약 API |
|
||||||
| server/routes/admin/api/analytics/posts.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/013_add_user_admin_role.sql | 회원 관리자 권한 컬럼 추가 및 첫 사용자 관리자 승격 |
|
||||||
| db/migrations/015_add_tag_type_and_reorder_support.sql | 태그 유형(`managed`/`general`) 컬럼 추가 |
|
| db/migrations/015_add_tag_type_and_reorder_support.sql | 태그 유형(`managed`/`general`) 컬럼 추가 |
|
||||||
| db/migrations/030_analytics_daily_stats.sql | 사이트·게시물 일별 통계·일별 방문자 해시 테이블 |
|
| db/migrations/030_analytics_daily_stats.sql | 사이트·게시물 일별 통계·일별 방문자 해시 테이블 |
|
||||||
|
| db/migrations/031_analytics_engagement_and_realtime.sql | 체류·스크롤 집계 컬럼·실시간 접속 세션 테이블 |
|
||||||
|
|
||||||
## 설정/배포
|
## 설정/배포
|
||||||
|
|
||||||
|
|||||||
13
docs/spec.md
13
docs/spec.md
@@ -357,13 +357,16 @@ components/content/
|
|||||||
|
|
||||||
| 테이블 | 필드 | 설명 |
|
| 테이블 | 필드 | 설명 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| site_analytics_daily | day, page_views, visitors | 사이트 일별 페이지뷰·방문자(중복 제거) |
|
| site_analytics_daily | day, page_views, visitors, engaged_views, total_engaged_seconds | 사이트 일별 페이지뷰·방문자·체류 집계 |
|
||||||
| post_analytics_daily | day, post_id, views, reads, visitors | 게시물 일별 조회·읽음·방문자 |
|
| 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_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`는 제외.
|
- 추적 대상: 공개 경로만. `/admin`, `/signin`, `/signup`, `/forgot-password`, `/settings`는 제외.
|
||||||
- 봇 User-Agent는 서버에서 무시.
|
- 봇 User-Agent는 서버에서 무시.
|
||||||
- 게시물 `reads`는 클라이언트에서 15초 이상 체류·50% 이상 스크롤 후 별도 전송.
|
- 게시물 `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/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만)
|
||||||
- `GET /api/site-settings` - 공개 사이트 설정(어나운스 바·홈 커버 등 포함)
|
- `GET /api/site-settings` - 공개 사이트 설정(어나운스 바·홈 커버 등 포함)
|
||||||
- `POST /api/analytics/pageview` - 공개 방문·게시물 조회/읽음 집계. 본문: `path`(필수), `postSlug`(게시물일 때), `read`(읽음 이벤트). 발행된 게시물만 `postSlug` 집계. 응답 `{ ok: true }`.
|
- `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`는 평면, 상세는 위 메뉴/네비게이션 절)
|
- `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` 없이 가입 가능. 이메일은 저장 시 소문자로 정규화한다.
|
- `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에 해시해 둘 때만 쓰이는 서버 비밀(긴 난문자열 권장)이다.
|
- `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/login` - 로그인
|
||||||
- `POST /admin/api/auth/logout` - 로그아웃
|
- `POST /admin/api/auth/logout` - 로그아웃
|
||||||
- `GET /admin/api/auth/me` - 현재 관리자 세션 조회
|
- `GET /admin/api/auth/me` - 현재 관리자 세션 조회
|
||||||
- `GET /admin/api/analytics/summary?days=30` - 통계 요약(오늘 방문, 최근 7일 방문 합, 최근 30일 페이지뷰 합)
|
- `GET /admin/api/analytics/summary?days=30` - 통계 요약(오늘/7일 방문, 30일 조회, 현재 접속자, 평균 체류, 50% 스크롤 도달)
|
||||||
- `GET /admin/api/analytics/posts?days=30&limit=5` - 기간 내 인기 게시물(조회·읽음·방문자 합, 조회수 내림차순)
|
- `GET /admin/api/analytics/posts?days=30&limit=5` - 기간 내 인기 게시물(조회·읽음·평균 체류·스크롤 구간)
|
||||||
|
- `GET /admin/api/analytics/realtime?limit=20` - 현재 접속자 요약·목록(로그인 사용자 닉네임·아바타 포함)
|
||||||
- `GET /admin/api/posts` - 글 목록
|
- `GET /admin/api/posts` - 글 목록
|
||||||
- `POST /admin/api/posts` - 글 작성
|
- `POST /admin/api/posts` - 글 작성
|
||||||
- `GET /admin/api/posts/:id` - 글 상세
|
- `GET /admin/api/posts/:id` - 글 상세
|
||||||
|
|||||||
@@ -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
|
## v1.3.3
|
||||||
|
|
||||||
- 자체 최소 통계: 일별 익명 방문자 해시·사이트/게시물 일별 집계. 마이그레이션 `030_analytics_daily_stats.sql`. 원문 IP·User-Agent 미저장.
|
- 자체 최소 통계: 일별 익명 방문자 해시·사이트/게시물 일별 집계. 마이그레이션 `030_analytics_daily_stats.sql`. 원문 IP·User-Agent 미저장.
|
||||||
|
|||||||
@@ -63,3 +63,80 @@ export const createDailyVisitorHash = ({ day, ip, userAgent, secret }) => {
|
|||||||
* @returns {string} 정규화된 slug
|
* @returns {string} 정규화된 slug
|
||||||
*/
|
*/
|
||||||
export const normalizePostSlugForAnalytics = (slug) => (slug || '').trim()
|
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')
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ const { data: posts } = await useFetch('/admin/api/posts', {
|
|||||||
default: () => []
|
default: () => []
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: analyticsSummary } = await useFetch('/admin/api/analytics/summary', {
|
const { data: analyticsSummary, refresh: refreshSummary } = await useFetch('/admin/api/analytics/summary', {
|
||||||
default: () => ({
|
default: () => ({
|
||||||
todayVisitors: 0,
|
todayVisitors: 0,
|
||||||
visitorsLast7Days: 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: () => []
|
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 publishedCount = computed(() => posts.value.filter((post) => post.status === 'published').length)
|
||||||
const draftCount = computed(() => posts.value.filter((post) => post.status === 'draft').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)
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -35,7 +103,18 @@ const draftCount = computed(() => posts.value.filter((post) => post.status === '
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-dashboard__body space-y-6 bg-paper p-6">
|
<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">
|
<section class="admin-dashboard__analytics grid gap-4 md:grid-cols-2 xl: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">
|
||||||
|
{{ realtime.summary.onlineNow }}
|
||||||
|
</strong>
|
||||||
|
<p class="admin-dashboard__metric-note mt-2 text-xs text-muted">
|
||||||
|
로그인 {{ realtime.summary.loggedInNow }} · 익명 {{ realtime.summary.anonymousNow }}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
<article class="admin-dashboard__metric border border-line bg-white p-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 class="admin-dashboard__metric-label text-xs font-semibold uppercase text-muted">
|
||||||
오늘 방문
|
오늘 방문
|
||||||
@@ -43,23 +122,89 @@ const draftCount = computed(() => posts.value.filter((post) => post.status === '
|
|||||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||||
{{ analyticsSummary.todayVisitors }}
|
{{ analyticsSummary.todayVisitors }}
|
||||||
</strong>
|
</strong>
|
||||||
|
<p class="admin-dashboard__metric-note mt-2 text-xs text-muted">
|
||||||
|
7일 {{ analyticsSummary.visitorsLast7Days }}
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="admin-dashboard__metric border border-line bg-white p-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 class="admin-dashboard__metric-label text-xs font-semibold uppercase text-muted">
|
||||||
7일 방문
|
평균 체류
|
||||||
</p>
|
</p>
|
||||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||||
{{ analyticsSummary.visitorsLast7Days }}
|
{{ formatEngagedDuration(analyticsSummary.avgEngagedSeconds) }}
|
||||||
</strong>
|
</strong>
|
||||||
|
<p class="admin-dashboard__metric-note mt-2 text-xs text-muted">
|
||||||
|
30일 기준
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="admin-dashboard__metric border border-line bg-white p-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 class="admin-dashboard__metric-label text-xs font-semibold uppercase text-muted">
|
||||||
30일 조회
|
50% 스크롤
|
||||||
</p>
|
</p>
|
||||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||||
{{ analyticsSummary.pageViewsLast30Days }}
|
{{ analyticsSummary.scroll50Reach }}
|
||||||
</strong>
|
</strong>
|
||||||
|
<p class="admin-dashboard__metric-note mt-2 text-xs text-muted">
|
||||||
|
30일 조회 {{ analyticsSummary.pageViewsLast30Days }}
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-dashboard__live border border-line bg-white p-4">
|
||||||
|
<div class="admin-dashboard__live-header flex items-center justify-between gap-4">
|
||||||
|
<h2 class="admin-dashboard__section-title text-sm font-semibold text-ink">
|
||||||
|
현재 접속자
|
||||||
|
</h2>
|
||||||
|
<p class="admin-dashboard__live-count text-xs text-muted">
|
||||||
|
{{ realtime.sessions.length }}명 표시
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
v-if="realtime.sessions.length"
|
||||||
|
class="admin-dashboard__live-list mt-4 divide-y divide-line"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="session in realtime.sessions"
|
||||||
|
:key="session.sessionHash"
|
||||||
|
class="admin-dashboard__live-item flex items-center gap-3 py-3"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="session.user?.avatarUrl"
|
||||||
|
:src="session.user.avatarUrl"
|
||||||
|
:alt="session.user.username"
|
||||||
|
class="admin-dashboard__live-avatar h-9 w-9 shrink-0 rounded-full object-cover"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="admin-dashboard__live-avatar admin-dashboard__live-avatar--placeholder flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-paper text-xs font-semibold text-muted"
|
||||||
|
>
|
||||||
|
{{ session.isLoggedIn ? (session.user?.username || '?').slice(0, 1) : '?' }}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="admin-dashboard__live-name text-sm font-medium text-ink">
|
||||||
|
{{ session.isLoggedIn ? session.user?.username : '익명 방문자' }}
|
||||||
|
</p>
|
||||||
|
<p class="admin-dashboard__live-path mt-0.5 truncate text-xs text-muted">
|
||||||
|
{{ session.path }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="admin-dashboard__live-meta shrink-0 text-right text-xs text-muted">
|
||||||
|
<p>{{ formatLastSeen(session.lastSeenAt) }}</p>
|
||||||
|
<p class="mt-1">
|
||||||
|
{{ formatEngagedDuration(session.durationSeconds) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="admin-dashboard__live-empty mt-4 text-sm text-muted"
|
||||||
|
>
|
||||||
|
현재 접속 중인 방문자가 없습니다.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-dashboard__posts-meta grid gap-4 md:grid-cols-3">
|
||||||
<article class="admin-dashboard__metric border border-line bg-white p-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 class="admin-dashboard__metric-label text-xs font-semibold uppercase text-muted">
|
||||||
게시물
|
게시물
|
||||||
@@ -115,6 +260,22 @@ const draftCount = computed(() => posts.value.filter((post) => post.status === '
|
|||||||
{{ item.reads }}
|
{{ item.reads }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-1">
|
||||||
|
<dt class="inline">
|
||||||
|
체류
|
||||||
|
</dt>
|
||||||
|
<dd class="inline font-semibold text-ink">
|
||||||
|
{{ formatEngagedDuration(item.avgEngagedSeconds) }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1">
|
||||||
|
<dt class="inline">
|
||||||
|
50/75/100%
|
||||||
|
</dt>
|
||||||
|
<dd class="inline font-semibold text-ink">
|
||||||
|
{{ item.scroll50 }}/{{ item.scroll75 }}/{{ item.scroll100 }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { isTrackableAnalyticsPath } from '../lib/analytics.js'
|
import {
|
||||||
|
ANALYTICS_MAX_DURATION_SECONDS,
|
||||||
|
isTrackableAnalyticsPath
|
||||||
|
} from '../lib/analytics.js'
|
||||||
|
|
||||||
|
/** @type {string} 탭 단위 클라이언트 세션 storage 키 */
|
||||||
|
const CLIENT_SESSION_STORAGE_KEY = 'sori_analytics_client_session'
|
||||||
|
|
||||||
/** @type {number} 읽음 판정 최소 체류 시간(ms) */
|
/** @type {number} 읽음 판정 최소 체류 시간(ms) */
|
||||||
const READ_MIN_DURATION_MS = 15000
|
const READ_MIN_DURATION_MS = 15000
|
||||||
@@ -6,6 +12,9 @@ const READ_MIN_DURATION_MS = 15000
|
|||||||
/** @type {number} 읽음 판정 최소 스크롤 비율 */
|
/** @type {number} 읽음 판정 최소 스크롤 비율 */
|
||||||
const READ_MIN_SCROLL_RATIO = 0.5
|
const READ_MIN_SCROLL_RATIO = 0.5
|
||||||
|
|
||||||
|
/** @type {number} heartbeat 전송 간격(ms) */
|
||||||
|
const HEARTBEAT_INTERVAL_MS = 20000
|
||||||
|
|
||||||
/** @type {Set<string>} 세션 내 전송 완료된 pageview 키 */
|
/** @type {Set<string>} 세션 내 전송 완료된 pageview 키 */
|
||||||
const sentViewKeys = new Set()
|
const sentViewKeys = new Set()
|
||||||
|
|
||||||
@@ -15,6 +24,9 @@ const sentReadSlugs = new Set()
|
|||||||
/** @type {number | null} read 폴링 타이머 */
|
/** @type {number | null} read 폴링 타이머 */
|
||||||
let readPollTimer = null
|
let readPollTimer = null
|
||||||
|
|
||||||
|
/** @type {number | null} heartbeat 타이머 */
|
||||||
|
let heartbeatTimer = null
|
||||||
|
|
||||||
/** @type {(() => void) | null} scroll 리스너 */
|
/** @type {(() => void) | null} scroll 리스너 */
|
||||||
let readScrollListener = null
|
let readScrollListener = null
|
||||||
|
|
||||||
@@ -27,6 +39,37 @@ let readPostSlug = ''
|
|||||||
/** @type {string} read 추적 중인 경로 */
|
/** @type {string} read 추적 중인 경로 */
|
||||||
let readPath = ''
|
let readPath = ''
|
||||||
|
|
||||||
|
/** @type {string} 현재 추적 경로 */
|
||||||
|
let currentPath = ''
|
||||||
|
|
||||||
|
/** @type {string} 현재 추적 게시물 slug */
|
||||||
|
let currentPostSlug = ''
|
||||||
|
|
||||||
|
/** @type {number} 현재 페이지 체류 시작 시각 */
|
||||||
|
let pageStartedAt = 0
|
||||||
|
|
||||||
|
/** @type {number} 현재 페이지 최대 스크롤 비율 */
|
||||||
|
let maxScrollRatio = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 탭 단위 클라이언트 세션 ID를 반환한다.
|
||||||
|
* @returns {string} 클라이언트 세션 ID
|
||||||
|
*/
|
||||||
|
const getClientSessionId = () => {
|
||||||
|
try {
|
||||||
|
const existing = sessionStorage.getItem(CLIENT_SESSION_STORAGE_KEY)
|
||||||
|
if (existing) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = crypto.randomUUID()
|
||||||
|
sessionStorage.setItem(CLIENT_SESSION_STORAGE_KEY, created)
|
||||||
|
return created
|
||||||
|
} catch {
|
||||||
|
return `fallback-${Date.now()}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 게시물 상세 경로에서 slug를 추출한다.
|
* 게시물 상세 경로에서 slug를 추출한다.
|
||||||
* @param {import('vue-router').RouteLocationNormalizedLoaded} route - 현재 라우트
|
* @param {import('vue-router').RouteLocationNormalizedLoaded} route - 현재 라우트
|
||||||
@@ -60,15 +103,41 @@ const getDocumentScrollRatio = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* read 추적 리스너를 해제한다.
|
* 현재 페이지 체류시간(초)을 반환한다.
|
||||||
|
* @returns {number} 체류시간(초)
|
||||||
|
*/
|
||||||
|
const getCurrentDurationSeconds = () => {
|
||||||
|
if (!pageStartedAt) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsedMs = Date.now() - pageStartedAt
|
||||||
|
return Math.min(Math.floor(elapsedMs / 1000), ANALYTICS_MAX_DURATION_SECONDS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스크롤 비율을 갱신한다.
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const clearReadTracking = () => {
|
const updateMaxScrollRatio = () => {
|
||||||
|
maxScrollRatio = Math.max(maxScrollRatio, getDocumentScrollRatio())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* read·heartbeat 리스너를 해제한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const clearPageTracking = () => {
|
||||||
if (readPollTimer) {
|
if (readPollTimer) {
|
||||||
clearInterval(readPollTimer)
|
clearInterval(readPollTimer)
|
||||||
readPollTimer = null
|
readPollTimer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (heartbeatTimer) {
|
||||||
|
clearInterval(heartbeatTimer)
|
||||||
|
heartbeatTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
if (readScrollListener) {
|
if (readScrollListener) {
|
||||||
window.removeEventListener('scroll', readScrollListener)
|
window.removeEventListener('scroll', readScrollListener)
|
||||||
readScrollListener = null
|
readScrollListener = null
|
||||||
@@ -77,20 +146,21 @@ const clearReadTracking = () => {
|
|||||||
readPostSlug = ''
|
readPostSlug = ''
|
||||||
readPath = ''
|
readPath = ''
|
||||||
readStartedAt = 0
|
readStartedAt = 0
|
||||||
|
currentPath = ''
|
||||||
|
currentPostSlug = ''
|
||||||
|
pageStartedAt = 0
|
||||||
|
maxScrollRatio = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 통계 이벤트를 서버로 전송한다.
|
* 통계 이벤트를 서버로 전송한다.
|
||||||
* @param {{ path: string, postSlug?: string, read?: boolean }} payload - 전송 본문
|
* @param {string} endpoint - API 경로
|
||||||
|
* @param {Object} payload - 전송 본문
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const sendAnalyticsEvent = (payload) => {
|
const sendAnalyticsPayload = (endpoint, payload) => {
|
||||||
const url = '/api/analytics/pageview'
|
const url = endpoint
|
||||||
const body = JSON.stringify({
|
const body = JSON.stringify(payload)
|
||||||
path: payload.path,
|
|
||||||
postSlug: payload.postSlug || '',
|
|
||||||
read: Boolean(payload.read)
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
|
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
|
||||||
@@ -110,6 +180,53 @@ const sendAnalyticsEvent = (payload) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pageview 이벤트를 전송한다.
|
||||||
|
* @param {{ path: string, postSlug?: string, read?: boolean }} payload - 전송 본문
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const sendPageviewEvent = (payload) => {
|
||||||
|
sendAnalyticsPayload('/api/analytics/pageview', {
|
||||||
|
path: payload.path,
|
||||||
|
postSlug: payload.postSlug || '',
|
||||||
|
read: Boolean(payload.read)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* heartbeat 이벤트를 전송한다.
|
||||||
|
* @param {{ path: string, postSlug?: string, durationSeconds: number, maxScrollRatio: number }} payload - 전송 본문
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const sendHeartbeatEvent = (payload) => {
|
||||||
|
sendAnalyticsPayload('/api/analytics/heartbeat', {
|
||||||
|
path: payload.path,
|
||||||
|
postSlug: payload.postSlug || '',
|
||||||
|
clientSessionId: getClientSessionId(),
|
||||||
|
durationSeconds: payload.durationSeconds,
|
||||||
|
maxScrollRatio: payload.maxScrollRatio
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 페이지 heartbeat를 전송한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const sendCurrentHeartbeat = () => {
|
||||||
|
if (!currentPath || !isTrackableAnalyticsPath(currentPath)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMaxScrollRatio()
|
||||||
|
|
||||||
|
sendHeartbeatEvent({
|
||||||
|
path: currentPath,
|
||||||
|
postSlug: currentPostSlug,
|
||||||
|
durationSeconds: getCurrentDurationSeconds(),
|
||||||
|
maxScrollRatio
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 읽음 조건을 만족하면 read 이벤트를 한 번 전송한다.
|
* 읽음 조건을 만족하면 read 이벤트를 한 번 전송한다.
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
@@ -124,12 +241,14 @@ const trySendReadEvent = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getDocumentScrollRatio() < READ_MIN_SCROLL_RATIO) {
|
updateMaxScrollRatio()
|
||||||
|
|
||||||
|
if (maxScrollRatio < READ_MIN_SCROLL_RATIO) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sentReadSlugs.add(readPostSlug)
|
sentReadSlugs.add(readPostSlug)
|
||||||
sendAnalyticsEvent({
|
sendPageviewEvent({
|
||||||
path: readPath,
|
path: readPath,
|
||||||
postSlug: readPostSlug,
|
postSlug: readPostSlug,
|
||||||
read: true
|
read: true
|
||||||
@@ -137,34 +256,44 @@ const trySendReadEvent = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 게시물 상세에서 read 추적을 시작한다.
|
* 게시물 상세 read 추적을 시작한다.
|
||||||
* @param {string} path - 경로
|
* @param {string} path - 경로
|
||||||
* @param {string} postSlug - 게시물 slug
|
* @param {string} postSlug - 게시물 slug
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const startReadTracking = (path, postSlug) => {
|
const startReadTracking = (path, postSlug) => {
|
||||||
clearReadTracking()
|
readPath = path
|
||||||
|
readPostSlug = postSlug
|
||||||
|
readStartedAt = Date.now()
|
||||||
|
|
||||||
if (!postSlug) {
|
if (!postSlug) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
readPath = path
|
|
||||||
readPostSlug = postSlug
|
|
||||||
readStartedAt = Date.now()
|
|
||||||
|
|
||||||
readScrollListener = () => {
|
|
||||||
trySendReadEvent()
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('scroll', readScrollListener, { passive: true })
|
|
||||||
readPollTimer = window.setInterval(() => {
|
readPollTimer = window.setInterval(() => {
|
||||||
trySendReadEvent()
|
trySendReadEvent()
|
||||||
}, 2000)
|
}, 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 라우트 변경 시 pageview를 기록한다.
|
* heartbeat·스크롤 추적을 시작한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const startHeartbeatTracking = () => {
|
||||||
|
readScrollListener = () => {
|
||||||
|
updateMaxScrollRatio()
|
||||||
|
trySendReadEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('scroll', readScrollListener, { passive: true })
|
||||||
|
|
||||||
|
heartbeatTimer = window.setInterval(() => {
|
||||||
|
sendCurrentHeartbeat()
|
||||||
|
}, HEARTBEAT_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라우트 변경 시 pageview·heartbeat 추적을 시작한다.
|
||||||
* @param {import('vue-router').RouteLocationNormalizedLoaded} route - 대상 라우트
|
* @param {import('vue-router').RouteLocationNormalizedLoaded} route - 대상 라우트
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
@@ -172,27 +301,38 @@ const trackRouteAnalytics = (route) => {
|
|||||||
const path = String(route.path || '')
|
const path = String(route.path || '')
|
||||||
|
|
||||||
if (!isTrackableAnalyticsPath(path)) {
|
if (!isTrackableAnalyticsPath(path)) {
|
||||||
clearReadTracking()
|
clearPageTracking()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendCurrentHeartbeat()
|
||||||
|
|
||||||
const postSlug = extractPostSlugFromRoute(route)
|
const postSlug = extractPostSlugFromRoute(route)
|
||||||
const viewKey = `view:${path}:${postSlug}`
|
const viewKey = `view:${path}:${postSlug}`
|
||||||
|
|
||||||
if (!sentViewKeys.has(viewKey)) {
|
if (!sentViewKeys.has(viewKey)) {
|
||||||
sentViewKeys.add(viewKey)
|
sentViewKeys.add(viewKey)
|
||||||
sendAnalyticsEvent({
|
sendPageviewEvent({
|
||||||
path,
|
path,
|
||||||
postSlug,
|
postSlug,
|
||||||
read: false
|
read: false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearPageTracking()
|
||||||
|
|
||||||
|
currentPath = path
|
||||||
|
currentPostSlug = postSlug
|
||||||
|
pageStartedAt = Date.now()
|
||||||
|
maxScrollRatio = getDocumentScrollRatio()
|
||||||
|
|
||||||
startReadTracking(path, postSlug)
|
startReadTracking(path, postSlug)
|
||||||
|
startHeartbeatTracking()
|
||||||
|
sendCurrentHeartbeat()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 공개 페이지 방문·게시물 읽음 통계 클라이언트 트래커
|
* 공개 페이지 방문·게시물 읽음·실시간 heartbeat 클라이언트 트래커
|
||||||
*/
|
*/
|
||||||
export default defineNuxtPlugin(() => {
|
export default defineNuxtPlugin(() => {
|
||||||
if (!import.meta.client) {
|
if (!import.meta.client) {
|
||||||
@@ -201,10 +341,26 @@ export default defineNuxtPlugin(() => {
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
const handlePageExit = () => {
|
||||||
|
sendCurrentHeartbeat()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
handlePageExit()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
window.addEventListener('pagehide', handlePageExit)
|
||||||
|
|
||||||
router.isReady().then(() => {
|
router.isReady().then(() => {
|
||||||
trackRouteAnalytics(router.currentRoute.value)
|
trackRouteAnalytics(router.currentRoute.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.beforeEach(() => {
|
||||||
|
sendCurrentHeartbeat()
|
||||||
|
})
|
||||||
|
|
||||||
router.afterEach((to) => {
|
router.afterEach((to) => {
|
||||||
trackRouteAnalytics(to)
|
trackRouteAnalytics(to)
|
||||||
})
|
})
|
||||||
|
|||||||
8
server/api/analytics/heartbeat.post.js
Normal file
8
server/api/analytics/heartbeat.post.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { handleAnalyticsHeartbeat } from '../../utils/analytics-heartbeat-input.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 heartbeat·체류·스크롤 통계 수집 API
|
||||||
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
|
* @returns {Promise<{ ok: true }>}
|
||||||
|
*/
|
||||||
|
export default defineEventHandler((event) => handleAnalyticsHeartbeat(event))
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
|
ANALYTICS_ACTIVE_SESSION_TTL_SECONDS,
|
||||||
|
ANALYTICS_ENGAGED_MIN_SECONDS,
|
||||||
|
clampAnalyticsDurationSeconds,
|
||||||
|
clampAnalyticsScrollRatio,
|
||||||
createDailyVisitorHash,
|
createDailyVisitorHash,
|
||||||
getAnalyticsDayKey
|
createRealtimeSessionHash,
|
||||||
|
getAnalyticsDayKey,
|
||||||
|
getNewScrollBucketColumns
|
||||||
} from '../../lib/analytics.js'
|
} from '../../lib/analytics.js'
|
||||||
import { getPostgresClient } from './postgres-client.js'
|
import { getPostgresClient } from './postgres-client.js'
|
||||||
import { getRuntimeEnvValue } from '../utils/runtime-env.js'
|
import { getRuntimeEnvValue } from '../utils/runtime-env.js'
|
||||||
|
import { getMemberSession } from '../utils/member-auth.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 통계 해시용 시크릿을 반환한다.
|
* 통계 해시용 시크릿을 반환한다.
|
||||||
@@ -165,6 +172,197 @@ export const createVisitorHashFromEvent = (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* heartbeat용 실시간 세션 해시를 만든다.
|
||||||
|
* @param {import('h3').H3Event} event - H3 이벤트
|
||||||
|
* @param {string} clientSessionId - 탭 단위 클라이언트 세션 ID
|
||||||
|
* @returns {string} session hash
|
||||||
|
*/
|
||||||
|
export const createSessionHashFromEvent = (event, clientSessionId) => {
|
||||||
|
return createRealtimeSessionHash({
|
||||||
|
clientSessionId: String(clientSessionId || '').trim(),
|
||||||
|
visitorHash: createVisitorHashFromEvent(event),
|
||||||
|
secret: getAnalyticsHashSecret()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 만료된 실시간 세션 행을 정리한다.
|
||||||
|
* @param {import('postgres').Sql} sql - DB 클라이언트
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const purgeStaleActiveSessions = async (sql) => {
|
||||||
|
await sql`
|
||||||
|
DELETE FROM analytics_active_sessions
|
||||||
|
WHERE last_seen_at < now() - (${ANALYTICS_ACTIVE_SESSION_TTL_SECONDS} * interval '1 second')
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스크롤 구간 카운터를 증가시킨다.
|
||||||
|
* @param {import('postgres').Sql} sql - DB 클라이언트
|
||||||
|
* @param {string} day - YYYY-MM-DD
|
||||||
|
* @param {string} postId - 게시물 ID
|
||||||
|
* @param {Array<'scroll_25' | 'scroll_50' | 'scroll_75' | 'scroll_100'>} columns - 구간 컬럼
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const incrementPostScrollBuckets = async (sql, day, postId, columns) => {
|
||||||
|
if (!columns.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensurePostDailyRow(sql, day, postId)
|
||||||
|
|
||||||
|
for (const column of columns) {
|
||||||
|
if (column === 'scroll_25') {
|
||||||
|
await sql`
|
||||||
|
UPDATE post_analytics_daily
|
||||||
|
SET scroll_25 = scroll_25 + 1
|
||||||
|
WHERE day = ${day}
|
||||||
|
AND post_id = ${postId}
|
||||||
|
`
|
||||||
|
} else if (column === 'scroll_50') {
|
||||||
|
await sql`
|
||||||
|
UPDATE post_analytics_daily
|
||||||
|
SET scroll_50 = scroll_50 + 1
|
||||||
|
WHERE day = ${day}
|
||||||
|
AND post_id = ${postId}
|
||||||
|
`
|
||||||
|
} else if (column === 'scroll_75') {
|
||||||
|
await sql`
|
||||||
|
UPDATE post_analytics_daily
|
||||||
|
SET scroll_75 = scroll_75 + 1
|
||||||
|
WHERE day = ${day}
|
||||||
|
AND post_id = ${postId}
|
||||||
|
`
|
||||||
|
} else if (column === 'scroll_100') {
|
||||||
|
await sql`
|
||||||
|
UPDATE post_analytics_daily
|
||||||
|
SET scroll_100 = scroll_100 + 1
|
||||||
|
WHERE day = ${day}
|
||||||
|
AND post_id = ${postId}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* heartbeat·체류·스크롤·실시간 세션을 기록한다.
|
||||||
|
* @param {{ event: import('h3').H3Event, sessionHash: string, path: string, postId?: string | null, postSlug?: string, durationSeconds: number, maxScrollRatio: number }} input - 기록 입력
|
||||||
|
* @returns {Promise<{ ok: true }>}
|
||||||
|
*/
|
||||||
|
export const recordAnalyticsHeartbeat = async (input) => {
|
||||||
|
const sql = getPostgresClient()
|
||||||
|
|
||||||
|
if (!sql) {
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const day = getAnalyticsDayKey()
|
||||||
|
const sessionHash = input.sessionHash
|
||||||
|
const path = input.path
|
||||||
|
const postId = input.postId || null
|
||||||
|
const postSlug = input.postSlug || ''
|
||||||
|
const durationSeconds = clampAnalyticsDurationSeconds(input.durationSeconds)
|
||||||
|
const maxScrollRatio = clampAnalyticsScrollRatio(input.maxScrollRatio)
|
||||||
|
const memberSession = getMemberSession(input.event)
|
||||||
|
const userId = memberSession?.userId || null
|
||||||
|
|
||||||
|
const previousRows = await sql`
|
||||||
|
SELECT duration_seconds, max_scroll_ratio
|
||||||
|
FROM analytics_active_sessions
|
||||||
|
WHERE session_hash = ${sessionHash}
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
const previousDuration = Number(previousRows[0]?.duration_seconds || 0)
|
||||||
|
const previousScrollRatio = Number(previousRows[0]?.max_scroll_ratio || 0)
|
||||||
|
const durationDelta = Math.max(0, durationSeconds - previousDuration)
|
||||||
|
const scrollBuckets = getNewScrollBucketColumns(previousScrollRatio, maxScrollRatio)
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
INSERT INTO analytics_active_sessions (
|
||||||
|
session_hash,
|
||||||
|
user_id,
|
||||||
|
path,
|
||||||
|
post_id,
|
||||||
|
post_slug,
|
||||||
|
duration_seconds,
|
||||||
|
max_scroll_ratio
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
${sessionHash},
|
||||||
|
${userId},
|
||||||
|
${path},
|
||||||
|
${postId},
|
||||||
|
${postSlug},
|
||||||
|
${durationSeconds},
|
||||||
|
${maxScrollRatio}
|
||||||
|
)
|
||||||
|
ON CONFLICT (session_hash) DO UPDATE
|
||||||
|
SET
|
||||||
|
user_id = COALESCE(EXCLUDED.user_id, analytics_active_sessions.user_id),
|
||||||
|
path = EXCLUDED.path,
|
||||||
|
post_id = EXCLUDED.post_id,
|
||||||
|
post_slug = EXCLUDED.post_slug,
|
||||||
|
duration_seconds = GREATEST(analytics_active_sessions.duration_seconds, EXCLUDED.duration_seconds),
|
||||||
|
max_scroll_ratio = GREATEST(analytics_active_sessions.max_scroll_ratio, EXCLUDED.max_scroll_ratio),
|
||||||
|
last_seen_at = now()
|
||||||
|
`
|
||||||
|
|
||||||
|
if (durationDelta > 0) {
|
||||||
|
await ensureSiteDailyRow(sql, day)
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
UPDATE site_analytics_daily
|
||||||
|
SET total_engaged_seconds = total_engaged_seconds + ${durationDelta}
|
||||||
|
WHERE day = ${day}
|
||||||
|
`
|
||||||
|
|
||||||
|
const wasSiteEngaged = previousDuration >= ANALYTICS_ENGAGED_MIN_SECONDS
|
||||||
|
const isSiteEngaged = durationSeconds >= ANALYTICS_ENGAGED_MIN_SECONDS
|
||||||
|
|
||||||
|
if (!wasSiteEngaged && isSiteEngaged) {
|
||||||
|
await sql`
|
||||||
|
UPDATE site_analytics_daily
|
||||||
|
SET engaged_views = engaged_views + 1
|
||||||
|
WHERE day = ${day}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (postId && (durationDelta > 0 || scrollBuckets.length)) {
|
||||||
|
await ensurePostDailyRow(sql, day, postId)
|
||||||
|
|
||||||
|
if (durationDelta > 0) {
|
||||||
|
await sql`
|
||||||
|
UPDATE post_analytics_daily
|
||||||
|
SET total_engaged_seconds = total_engaged_seconds + ${durationDelta}
|
||||||
|
WHERE day = ${day}
|
||||||
|
AND post_id = ${postId}
|
||||||
|
`
|
||||||
|
|
||||||
|
const wasPostEngaged = previousDuration >= ANALYTICS_ENGAGED_MIN_SECONDS
|
||||||
|
const isPostEngaged = durationSeconds >= ANALYTICS_ENGAGED_MIN_SECONDS
|
||||||
|
|
||||||
|
if (!wasPostEngaged && isPostEngaged) {
|
||||||
|
await sql`
|
||||||
|
UPDATE post_analytics_daily
|
||||||
|
SET engaged_views = engaged_views + 1
|
||||||
|
WHERE day = ${day}
|
||||||
|
AND post_id = ${postId}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await incrementPostScrollBuckets(sql, day, postId, scrollBuckets)
|
||||||
|
}
|
||||||
|
|
||||||
|
await purgeStaleActiveSessions(sql)
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 통계 요약을 조회한다.
|
* 통계 요약을 조회한다.
|
||||||
* @param {{ days?: number }} [options] - 조회 옵션
|
* @param {{ days?: number }} [options] - 조회 옵션
|
||||||
@@ -179,14 +377,19 @@ export const getAnalyticsSummary = async (options = {}) => {
|
|||||||
todayVisitors: 0,
|
todayVisitors: 0,
|
||||||
visitorsLast7Days: 0,
|
visitorsLast7Days: 0,
|
||||||
pageViewsLast30Days: 0,
|
pageViewsLast30Days: 0,
|
||||||
|
onlineNow: 0,
|
||||||
|
loggedInNow: 0,
|
||||||
|
avgEngagedSeconds: 0,
|
||||||
|
scroll50Reach: 0,
|
||||||
days
|
days
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = getAnalyticsDayKey()
|
const today = getAnalyticsDayKey()
|
||||||
|
await purgeStaleActiveSessions(sql)
|
||||||
|
|
||||||
const todayRows = await sql`
|
const todayRows = await sql`
|
||||||
SELECT visitors, page_views
|
SELECT visitors, page_views, engaged_views, total_engaged_seconds
|
||||||
FROM site_analytics_daily
|
FROM site_analytics_daily
|
||||||
WHERE day = ${today}::date
|
WHERE day = ${today}::date
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -204,14 +407,131 @@ export const getAnalyticsSummary = async (options = {}) => {
|
|||||||
WHERE day >= (${today}::date - 29)
|
WHERE day >= (${today}::date - 29)
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const engagementRows = await sql`
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(total_engaged_seconds), 0)::int AS total_engaged_seconds,
|
||||||
|
COALESCE(SUM(engaged_views), 0)::int AS engaged_views
|
||||||
|
FROM site_analytics_daily
|
||||||
|
WHERE day >= (${today}::date - ${days - 1})
|
||||||
|
`
|
||||||
|
|
||||||
|
const scrollRows = await sql`
|
||||||
|
SELECT COALESCE(SUM(scroll_50), 0)::int AS scroll_50
|
||||||
|
FROM post_analytics_daily
|
||||||
|
WHERE day >= (${today}::date - ${days - 1})
|
||||||
|
`
|
||||||
|
|
||||||
|
const onlineRows = await sql`
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::int AS online_now,
|
||||||
|
COUNT(*) FILTER (WHERE user_id IS NOT NULL)::int AS logged_in_now
|
||||||
|
FROM analytics_active_sessions
|
||||||
|
WHERE last_seen_at >= now() - (${ANALYTICS_ACTIVE_SESSION_TTL_SECONDS} * interval '1 second')
|
||||||
|
`
|
||||||
|
|
||||||
|
const engagedViews = Number(engagementRows[0]?.engaged_views || 0)
|
||||||
|
const totalEngagedSeconds = Number(engagementRows[0]?.total_engaged_seconds || 0)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
todayVisitors: Number(todayRows[0]?.visitors || 0),
|
todayVisitors: Number(todayRows[0]?.visitors || 0),
|
||||||
visitorsLast7Days: Number(last7Rows[0]?.visitors || 0),
|
visitorsLast7Days: Number(last7Rows[0]?.visitors || 0),
|
||||||
pageViewsLast30Days: Number(pageViewRows[0]?.page_views || 0),
|
pageViewsLast30Days: Number(pageViewRows[0]?.page_views || 0),
|
||||||
|
onlineNow: Number(onlineRows[0]?.online_now || 0),
|
||||||
|
loggedInNow: Number(onlineRows[0]?.logged_in_now || 0),
|
||||||
|
avgEngagedSeconds: engagedViews > 0
|
||||||
|
? Math.round(totalEngagedSeconds / engagedViews)
|
||||||
|
: 0,
|
||||||
|
scroll50Reach: Number(scrollRows[0]?.scroll_50 || 0),
|
||||||
days
|
days
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실시간 접속 요약을 조회한다.
|
||||||
|
* @returns {Promise<Object>} 실시간 요약
|
||||||
|
*/
|
||||||
|
export const getAnalyticsRealtimeSummary = async () => {
|
||||||
|
const sql = getPostgresClient()
|
||||||
|
|
||||||
|
if (!sql) {
|
||||||
|
return {
|
||||||
|
onlineNow: 0,
|
||||||
|
loggedInNow: 0,
|
||||||
|
anonymousNow: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await purgeStaleActiveSessions(sql)
|
||||||
|
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::int AS online_now,
|
||||||
|
COUNT(*) FILTER (WHERE user_id IS NOT NULL)::int AS logged_in_now
|
||||||
|
FROM analytics_active_sessions
|
||||||
|
WHERE last_seen_at >= now() - (${ANALYTICS_ACTIVE_SESSION_TTL_SECONDS} * interval '1 second')
|
||||||
|
`
|
||||||
|
|
||||||
|
const onlineNow = Number(rows[0]?.online_now || 0)
|
||||||
|
const loggedInNow = Number(rows[0]?.logged_in_now || 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
onlineNow,
|
||||||
|
loggedInNow,
|
||||||
|
anonymousNow: Math.max(onlineNow - loggedInNow, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 접속 중인 세션 목록을 조회한다.
|
||||||
|
* @param {{ limit?: number }} [options] - 조회 옵션
|
||||||
|
* @returns {Promise<Array<Object>>} 접속자 목록
|
||||||
|
*/
|
||||||
|
export const getAnalyticsActiveSessions = async (options = {}) => {
|
||||||
|
const sql = getPostgresClient()
|
||||||
|
const limit = Math.min(Math.max(Number(options.limit) || 20, 1), 50)
|
||||||
|
|
||||||
|
if (!sql) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
await purgeStaleActiveSessions(sql)
|
||||||
|
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT
|
||||||
|
analytics_active_sessions.session_hash,
|
||||||
|
analytics_active_sessions.path,
|
||||||
|
analytics_active_sessions.post_slug,
|
||||||
|
analytics_active_sessions.duration_seconds,
|
||||||
|
analytics_active_sessions.max_scroll_ratio,
|
||||||
|
analytics_active_sessions.last_seen_at,
|
||||||
|
users.id AS user_id,
|
||||||
|
users.username,
|
||||||
|
users.avatar_url
|
||||||
|
FROM analytics_active_sessions
|
||||||
|
LEFT JOIN users ON users.id = analytics_active_sessions.user_id
|
||||||
|
WHERE analytics_active_sessions.last_seen_at >= now() - (${ANALYTICS_ACTIVE_SESSION_TTL_SECONDS} * interval '1 second')
|
||||||
|
ORDER BY analytics_active_sessions.last_seen_at DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
sessionHash: row.session_hash,
|
||||||
|
path: row.path,
|
||||||
|
postSlug: row.post_slug || '',
|
||||||
|
durationSeconds: Number(row.duration_seconds || 0),
|
||||||
|
maxScrollRatio: Number(row.max_scroll_ratio || 0),
|
||||||
|
lastSeenAt: row.last_seen_at ? new Date(row.last_seen_at).toISOString() : null,
|
||||||
|
isLoggedIn: Boolean(row.user_id),
|
||||||
|
user: row.user_id
|
||||||
|
? {
|
||||||
|
id: row.user_id,
|
||||||
|
username: row.username,
|
||||||
|
avatarUrl: row.avatar_url || ''
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 인기 게시물 통계를 조회한다.
|
* 인기 게시물 통계를 조회한다.
|
||||||
* @param {{ days?: number, limit?: number }} [options] - 조회 옵션
|
* @param {{ days?: number, limit?: number }} [options] - 조회 옵션
|
||||||
@@ -235,7 +555,12 @@ export const getAnalyticsTopPosts = async (options = {}) => {
|
|||||||
posts.slug,
|
posts.slug,
|
||||||
COALESCE(SUM(post_analytics_daily.views), 0)::int AS views,
|
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.reads), 0)::int AS reads,
|
||||||
COALESCE(SUM(post_analytics_daily.visitors), 0)::int AS visitors
|
COALESCE(SUM(post_analytics_daily.visitors), 0)::int AS visitors,
|
||||||
|
COALESCE(SUM(post_analytics_daily.engaged_views), 0)::int AS engaged_views,
|
||||||
|
COALESCE(SUM(post_analytics_daily.total_engaged_seconds), 0)::int AS total_engaged_seconds,
|
||||||
|
COALESCE(SUM(post_analytics_daily.scroll_50), 0)::int AS scroll_50,
|
||||||
|
COALESCE(SUM(post_analytics_daily.scroll_75), 0)::int AS scroll_75,
|
||||||
|
COALESCE(SUM(post_analytics_daily.scroll_100), 0)::int AS scroll_100
|
||||||
FROM post_analytics_daily
|
FROM post_analytics_daily
|
||||||
INNER JOIN posts ON posts.id = post_analytics_daily.post_id
|
INNER JOIN posts ON posts.id = post_analytics_daily.post_id
|
||||||
WHERE post_analytics_daily.day >= (${today}::date - ${days - 1})
|
WHERE post_analytics_daily.day >= (${today}::date - ${days - 1})
|
||||||
@@ -244,12 +569,23 @@ export const getAnalyticsTopPosts = async (options = {}) => {
|
|||||||
LIMIT ${limit}
|
LIMIT ${limit}
|
||||||
`
|
`
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => {
|
||||||
id: row.id,
|
const engagedViews = Number(row.engaged_views || 0)
|
||||||
title: row.title,
|
const totalEngagedSeconds = Number(row.total_engaged_seconds || 0)
|
||||||
slug: row.slug,
|
|
||||||
views: Number(row.views || 0),
|
return {
|
||||||
reads: Number(row.reads || 0),
|
id: row.id,
|
||||||
visitors: Number(row.visitors || 0)
|
title: row.title,
|
||||||
}))
|
slug: row.slug,
|
||||||
|
views: Number(row.views || 0),
|
||||||
|
reads: Number(row.reads || 0),
|
||||||
|
visitors: Number(row.visitors || 0),
|
||||||
|
avgEngagedSeconds: engagedViews > 0
|
||||||
|
? Math.round(totalEngagedSeconds / engagedViews)
|
||||||
|
: 0,
|
||||||
|
scroll50: Number(row.scroll_50 || 0),
|
||||||
|
scroll75: Number(row.scroll_75 || 0),
|
||||||
|
scroll100: Number(row.scroll_100 || 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
27
server/routes/admin/api/analytics/realtime.get.js
Normal file
27
server/routes/admin/api/analytics/realtime.get.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { requireAdminSession } from '../../../../utils/admin-auth.js'
|
||||||
|
import {
|
||||||
|
getAnalyticsActiveSessions,
|
||||||
|
getAnalyticsRealtimeSummary
|
||||||
|
} 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 limit = Number(query.limit) || 20
|
||||||
|
|
||||||
|
const [summary, sessions] = await Promise.all([
|
||||||
|
getAnalyticsRealtimeSummary(),
|
||||||
|
getAnalyticsActiveSessions({ limit })
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary,
|
||||||
|
sessions
|
||||||
|
}
|
||||||
|
})
|
||||||
71
server/utils/analytics-heartbeat-input.js
Normal file
71
server/utils/analytics-heartbeat-input.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import {
|
||||||
|
isBotUserAgent,
|
||||||
|
isTrackableAnalyticsPath,
|
||||||
|
normalizePostSlugForAnalytics
|
||||||
|
} from '../../lib/analytics.js'
|
||||||
|
import { getPostBySlug } from '../repositories/content-repository.js'
|
||||||
|
import {
|
||||||
|
createSessionHashFromEvent,
|
||||||
|
recordAnalyticsHeartbeat
|
||||||
|
} from '../repositories/analytics-repository.js'
|
||||||
|
|
||||||
|
const heartbeatInputSchema = z.object({
|
||||||
|
path: z.string().trim().min(1).max(500),
|
||||||
|
postSlug: z.string().trim().max(200).optional().default(''),
|
||||||
|
clientSessionId: z.string().trim().min(8).max(120),
|
||||||
|
durationSeconds: z.number().int().min(0).max(1800),
|
||||||
|
maxScrollRatio: z.number().min(0).max(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* heartbeat 추적 요청을 처리한다.
|
||||||
|
* @param {import('h3').H3Event} event - H3 이벤트
|
||||||
|
* @returns {Promise<{ ok: true }>}
|
||||||
|
*/
|
||||||
|
export const handleAnalyticsHeartbeat = async (event) => {
|
||||||
|
const parsedBody = heartbeatInputSchema.safeParse(await readBody(event))
|
||||||
|
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: '통계 heartbeat 형식이 올바르지 않습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 sessionHash = createSessionHashFromEvent(event, body.clientSessionId)
|
||||||
|
|
||||||
|
await recordAnalyticsHeartbeat({
|
||||||
|
event,
|
||||||
|
sessionHash,
|
||||||
|
path: body.path,
|
||||||
|
postId,
|
||||||
|
postSlug,
|
||||||
|
durationSeconds: body.durationSeconds,
|
||||||
|
maxScrollRatio: body.maxScrollRatio
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user