v1.3.5: 관리자 로그인·대시보드 차트·통계 보관 정리
운영 HTTP에서 관리자 세션이 유지되지 않던 문제를 쿠키 공통화로 수정하고, 통계 클라이언트 분리·조회 오류·기간별 차트를 보강했다. 방문자 해시는 32일 초과분만 정리하고 일별 집계는 누적 보관한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -204,6 +204,13 @@ docker compose --env-file .env.production up -d --build
|
||||
- 회원 마지막 로그인 표시(`previous_last_seen_at`, `previous_last_seen_ip`)는 `021_add_member_previous_login.sql` 적용 후 정상 동작한다.
|
||||
- 사이트 로고와 파비콘 저장(`logo_url`, `favicon_url`)은 `022_add_site_logo_urls.sql` 적용 후 정상 동작한다.
|
||||
|
||||
### 통계 데이터 보관 정책
|
||||
|
||||
- `site_analytics_daily`, `post_analytics_daily`: 사이트 전체 방문자와 게시물별 조회수의 누적 원본이므로 자동 삭제하지 않는다.
|
||||
- `analytics_daily_visitors`: 일별 중복 방문 제거용 해시만 담으므로 32일 초과 행은 통계 수집·관리자 조회 흐름에서 주기적으로 삭제한다.
|
||||
- `analytics_active_sessions`: 현재 접속자 목록용 임시 데이터이며 90초 초과 행은 조회·수집 시 삭제한다.
|
||||
- 관리자 대시보드 차트는 최대 365일 범위를 조회하며, 차트 범위를 넘는 집계도 누적 통계 원본으로 보관한다.
|
||||
|
||||
### 개발/운영 DB 분리 검증 절차
|
||||
|
||||
검증 전제는 실제 비밀번호나 전체 `DATABASE_URL`을 화면 공유, 문서, 커밋 메시지에 노출하지 않는 것이다. 확인할 때는 호스트, 포트, DB 이름, 파일명만 대조한다.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-20 v1.3.5
|
||||
|
||||
### 관리자 로그인·대시보드 차트·통계 보관 후속
|
||||
|
||||
v1.3.4 통계 확장 이후 운영에서 로그인 쿠키·클라이언트 번들·차트 조회 오류가 겹쳐 후속 정리가 필요했다. 세션 쿠키는 공통 유틸로 묶고, 통계 상수는 `analytics-shared`로 분리했다. 대시보드는 기간별 `trends` 차트와 접속자 목록 가독성을 맞췄다. 저장 용량은 일별 집계는 누적 원본으로 두고 방문자 해시만 32일 초과 시 정리한다.
|
||||
|
||||
## 2026-05-15 v1.1.18
|
||||
|
||||
### 에디터 미디어 UX·발행일·수정일 표시 설정
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
| 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 |
|
||||
| lib/analytics-shared.js | 통계 추적 경로 필터·체류/스크롤 상수(클라이언트·서버 공용) |
|
||||
| lib/analytics.js | 서버 전용 visitor/session hash(`node:crypto`) |
|
||||
|
||||
## Nuxt 모듈
|
||||
|
||||
@@ -115,7 +116,7 @@
|
||||
|
||||
| 파일 | 화면 |
|
||||
|------|------|
|
||||
| pages/admin/index.vue | 대시보드(실시간 접속자·체류·스크롤·인기 게시물 참여 지표) |
|
||||
| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 기간 선택형 방문자수·평균 체류·50% 스크롤 차트, 접속자 목록, 인기 게시물 참여 지표) |
|
||||
| pages/admin/login.vue | 관리자 로그인(이메일·비밀번호 모두 입력 시에만 제출 버튼 활성) |
|
||||
| pages/admin/posts/index.vue | 글 목록, 헤더 우측에 필터(상태·태그·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행 텍스트 상태, 제목 옆 댓글 수, 읽기 전용 태그 배지, 행 끝 more vert 메뉴(추천·삭제) |
|
||||
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·이탈 직전 플러시, 저장 토스트 |
|
||||
@@ -242,7 +243,7 @@
|
||||
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
|
||||
| server/repositories/member-repository.js | 회원 조회/생성 저장소 |
|
||||
| server/repositories/comment-repository.js | 댓글 조회/생성 저장소 |
|
||||
| server/repositories/analytics-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/api/analytics/heartbeat.post.js | 공개 heartbeat·체류·스크롤 수집 API |
|
||||
|
||||
12
docs/spec.md
12
docs/spec.md
@@ -366,7 +366,11 @@ components/content/
|
||||
- 봇 User-Agent는 서버에서 무시.
|
||||
- 게시물 `reads`는 클라이언트에서 15초 이상 체류·50% 이상 스크롤 후 별도 전송.
|
||||
- `POST /api/analytics/heartbeat`는 20초 간격으로 체류시간·스크롤·현재 경로를 전송한다. 로그인 사용자는 서버 세션으로 `user_id`를 연결한다.
|
||||
- 관리자 대시보드는 `GET /admin/api/analytics/realtime`으로 현재 접속자 목록(닉네임·아바타·경로·마지막 활동)을 조회한다.
|
||||
- 관리자 대시보드는 `GET /admin/api/analytics/realtime`으로 현재 접속자 목록(닉네임·아바타·게시물 제목·접속 유지시간)을 조회한다.
|
||||
- 관리자 차트는 최대 365일 범위를 조회한다.
|
||||
- `site_analytics_daily`, `post_analytics_daily`는 사이트 전체 방문자와 게시물별 조회수 누적 원본이므로 자동 삭제하지 않는다.
|
||||
- `analytics_daily_visitors`는 일별 중복 방문 제거용이며, 수집·조회 흐름에서 32일보다 오래된 행을 주기적으로 삭제한다.
|
||||
- `analytics_active_sessions`는 현재 접속자 목록용이며, 90초보다 오래된 행을 삭제한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -429,7 +433,7 @@ 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일 조회, 현재 접속자, 평균 체류, 50% 스크롤 도달)
|
||||
- `GET /admin/api/analytics/summary?days=30` - 통계 요약(오늘/7일 방문, 30일 조회, 현재 접속자, 평균 체류, 50% 스크롤 도달, 일자별 `trends`). `days`는 대시보드에서 7/30/90/180/365로 전환한다.
|
||||
- `GET /admin/api/analytics/posts?days=30&limit=5` - 기간 내 인기 게시물(조회·읽음·평균 체류·스크롤 구간)
|
||||
- `GET /admin/api/analytics/realtime?limit=20` - 현재 접속자 요약·목록(로그인 사용자 닉네임·아바타 포함)
|
||||
- `GET /admin/api/posts` - 글 목록
|
||||
@@ -614,8 +618,8 @@ components/content/
|
||||
- DB에 사용자가 없으면 `/signup`에서 관리자 등록 모드(관리자 이름/이메일/비밀번호/비밀번호 확인)로 첫 계정을 생성한다.
|
||||
- DB에 owner/admin 계정이 없는 최초 상태에서 `/admin/login`에 `ADMIN_EMAIL`/`ADMIN_PASSWORD`와 같은 값을 입력하면 첫 owner 계정을 DB에 생성한 뒤 로그인한다. 같은 이메일의 일반 회원이 이미 있으면 해당 회원을 owner로 승격하고 비밀번호를 `ADMIN_PASSWORD` 기준 bcrypt 해시로 갱신한다. 이미 owner/admin 계정이 있으면 환경 변수 계정으로 우회 로그인하지 않고 DB의 관리자 계정만 사용한다.
|
||||
- 최초 owner 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다. 최초 관리자 판정은 동시 요청에서 중복 owner가 생기지 않도록 `users` 테이블 잠금 안에서 처리한다.
|
||||
- 관리자 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정한다.
|
||||
- `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
||||
- 관리자 로그인 성공 시 httpOnly 세션 쿠키(`sori_admin_session`)를 `/` 경로에 설정한다. Secure는 실제 HTTPS 요청(`x-forwarded-proto` 포함)일 때만 사용한다.
|
||||
- `/admin/login` 로그인 제출 버튼은 제출 중일 때만 비활성화한다. 빈 값은 브라우저 `required`와 서버 검증으로 처리하며, 자동완성 값은 제출 직전 동기화한다.
|
||||
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정하고 `users.previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다.
|
||||
- 관리자 설정 화면은 사이트 자체 설정만 관리한다. 관리자 프로필과 비밀번호는 멤버 편집 화면의 계정 작업에서 처리한다.
|
||||
- 관리자 사이드바 하단 사용자 메뉴의 `내 프로필`은 `/admin/members/:id` 멤버 편집 화면으로 이동한다.
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.3.5
|
||||
|
||||
- 관리자 로그인: 자동완성 값 동기화·제출 중만 버튼 비활성. 운영 HTTP에서 Secure 쿠키 미저장으로 로그인 루프되던 문제 수정. `server/utils/session-cookie.js`로 path `/`·`x-forwarded-proto` Secure 통일.
|
||||
- 통계: `lib/analytics-shared.js` 분리로 클라이언트 `node:crypto` 오류 수정. 통계 조회 시작일 JS 계산으로 `date >= integer` 오류 수정.
|
||||
- 관리자 대시보드: 상단 요약 한 줄·7일~12개월 차트·접속자 목록(게시물 제목·유지시간). `trends` 일자별 0 채움.
|
||||
- 통계 보관: 일별 집계 누적 보관, 방문자 해시 32일 초과 정리, 실시간 세션 90초 TTL.
|
||||
|
||||
## v1.3.4
|
||||
|
||||
- 통계 확장: 체류시간·스크롤 구간(25/50/75/100%)·실시간 접속 세션. 마이그레이션 `031_analytics_engagement_and_realtime.sql`.
|
||||
|
||||
142
lib/analytics-shared.js
Normal file
142
lib/analytics-shared.js
Normal file
@@ -0,0 +1,142 @@
|
||||
/** @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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 기준일에서 지정 일수만큼 이전 날짜를 YYYY-MM-DD로 반환한다.
|
||||
* @param {string} dayKey - 기준일(YYYY-MM-DD)
|
||||
* @param {number} daysBefore - 며칠 전(0이면 같은 날)
|
||||
* @returns {string} 시작일
|
||||
*/
|
||||
export const getAnalyticsDayBefore = (dayKey, daysBefore) => {
|
||||
const offset = Math.max(Number(daysBefore) || 0, 0)
|
||||
const base = new Date(`${dayKey}T00:00:00.000Z`)
|
||||
base.setUTCDate(base.getUTCDate() - offset)
|
||||
return base.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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 slug 정규화
|
||||
* @param {string} slug - slug
|
||||
* @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_CHART_MAX_DAYS = 365
|
||||
|
||||
/** @type {number} 일별 방문자 해시 보관 기간(일) */
|
||||
export const ANALYTICS_VISITOR_HASH_RETENTION_DAYS = 32
|
||||
|
||||
/** @type {number} 통계 정리 최소 실행 간격(ms) */
|
||||
export const ANALYTICS_RETENTION_PURGE_INTERVAL_MS = 6 * 60 * 60 * 1000
|
||||
|
||||
/** @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
|
||||
}
|
||||
137
lib/analytics.js
137
lib/analytics.js
@@ -1,51 +1,22 @@
|
||||
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)
|
||||
}
|
||||
export {
|
||||
ANALYTICS_ACTIVE_SESSION_TTL_SECONDS,
|
||||
ANALYTICS_CHART_MAX_DAYS,
|
||||
ANALYTICS_ENGAGED_MIN_SECONDS,
|
||||
ANALYTICS_MAX_DURATION_SECONDS,
|
||||
ANALYTICS_RETENTION_PURGE_INTERVAL_MS,
|
||||
ANALYTICS_SCROLL_THRESHOLDS,
|
||||
ANALYTICS_VISITOR_HASH_RETENTION_DAYS,
|
||||
clampAnalyticsDurationSeconds,
|
||||
clampAnalyticsScrollRatio,
|
||||
getAnalyticsDayBefore,
|
||||
getAnalyticsDayKey,
|
||||
getNewScrollBucketColumns,
|
||||
isBotUserAgent,
|
||||
isTrackableAnalyticsPath,
|
||||
normalizePostSlugForAnalytics
|
||||
} from './analytics-shared.js'
|
||||
|
||||
/**
|
||||
* 일 단위 익명 방문자 해시를 생성한다. 원문 IP·UA는 저장하지 않는다.
|
||||
@@ -57,80 +28,6 @@ export const createDailyVisitorHash = ({ 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()
|
||||
|
||||
/** @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 - 해시 입력
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.2.4",
|
||||
"version": "1.3.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.2.4",
|
||||
"version": "1.3.4",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -7,7 +7,20 @@ const { data: posts } = await useFetch('/admin/api/posts', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const analyticsRangeOptions = [
|
||||
{ label: '7일', days: 7 },
|
||||
{ label: '30일', days: 30 },
|
||||
{ label: '3개월', days: 90 },
|
||||
{ label: '6개월', days: 180 },
|
||||
{ label: '12개월', days: 365 }
|
||||
]
|
||||
const selectedAnalyticsDays = ref(30)
|
||||
const analyticsQuery = computed(() => ({
|
||||
days: selectedAnalyticsDays.value
|
||||
}))
|
||||
|
||||
const { data: analyticsSummary, refresh: refreshSummary } = await useFetch('/admin/api/analytics/summary', {
|
||||
query: analyticsQuery,
|
||||
default: () => ({
|
||||
todayVisitors: 0,
|
||||
visitorsLast7Days: 0,
|
||||
@@ -15,12 +28,18 @@ const { data: analyticsSummary, refresh: refreshSummary } = await useFetch('/adm
|
||||
onlineNow: 0,
|
||||
loggedInNow: 0,
|
||||
avgEngagedSeconds: 0,
|
||||
scroll50Reach: 0
|
||||
scroll50Reach: 0,
|
||||
trends: []
|
||||
})
|
||||
})
|
||||
|
||||
const { data: topPosts } = await useFetch('/admin/api/analytics/posts', {
|
||||
query: { days: 30, limit: 5 },
|
||||
const topPostsQuery = computed(() => ({
|
||||
days: selectedAnalyticsDays.value,
|
||||
limit: 5
|
||||
}))
|
||||
|
||||
const { data: topPosts, refresh: refreshTopPosts } = await useFetch('/admin/api/analytics/posts', {
|
||||
query: topPostsQuery,
|
||||
default: () => []
|
||||
})
|
||||
|
||||
@@ -38,6 +57,12 @@ const { data: realtime, refresh: refreshRealtime } = await useFetch('/admin/api/
|
||||
|
||||
const publishedCount = computed(() => posts.value.filter((post) => post.status === 'published').length)
|
||||
const draftCount = computed(() => posts.value.filter((post) => post.status === 'draft').length)
|
||||
const analyticsRangeLabel = computed(() => {
|
||||
return analyticsRangeOptions.find((option) => option.days === selectedAnalyticsDays.value)?.label || '30일'
|
||||
})
|
||||
const trendRows = computed(() => analyticsSummary.value.trends || [])
|
||||
const trendStartDay = computed(() => trendRows.value[0]?.day || '')
|
||||
const trendEndDay = computed(() => trendRows.value[trendRows.value.length - 1]?.day || '')
|
||||
|
||||
/**
|
||||
* 초 단위 체류시간을 읽기 쉬운 문자열로 변환한다.
|
||||
@@ -56,24 +81,104 @@ const formatEngagedDuration = (seconds) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 마지막 활동 시각을 상대 표시로 변환한다.
|
||||
* @param {string|null} iso - ISO 시각
|
||||
* @returns {string} 상대 시각
|
||||
* 선택 기간의 추세 합계를 반환한다.
|
||||
* @param {'visitors' | 'avgEngagedSeconds' | 'scroll50Reach'} key - 추세 키
|
||||
* @returns {number} 합계 또는 평균
|
||||
*/
|
||||
const formatLastSeen = (iso) => {
|
||||
if (!iso) {
|
||||
return '-'
|
||||
const getTrendSummaryValue = (key) => {
|
||||
const rows = trendRows.value
|
||||
if (key === 'avgEngagedSeconds') {
|
||||
const nonZeroRows = rows.filter((row) => Number(row.avgEngagedSeconds || 0) > 0)
|
||||
if (!nonZeroRows.length) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const total = nonZeroRows.reduce((sum, row) => sum + Number(row.avgEngagedSeconds || 0), 0)
|
||||
return Math.round(total / nonZeroRows.length)
|
||||
}
|
||||
|
||||
const diffMs = Date.now() - new Date(iso).getTime()
|
||||
const diffSec = Math.max(Math.floor(diffMs / 1000), 0)
|
||||
return rows.reduce((sum, row) => sum + Number(row[key] || 0), 0)
|
||||
}
|
||||
|
||||
if (diffSec < 60) {
|
||||
return `${diffSec}초 전`
|
||||
/**
|
||||
* 추세 값 표시 문자열을 반환한다.
|
||||
* @param {'visitors' | 'avgEngagedSeconds' | 'scroll50Reach'} key - 추세 키
|
||||
* @param {number} value - 값
|
||||
* @returns {string} 표시 문자열
|
||||
*/
|
||||
const formatTrendValue = (key, value) => {
|
||||
if (key === 'avgEngagedSeconds') {
|
||||
return formatEngagedDuration(value)
|
||||
}
|
||||
|
||||
const diffMin = Math.floor(diffSec / 60)
|
||||
return `${diffMin}분 전`
|
||||
return `${Number(value || 0)}`
|
||||
}
|
||||
|
||||
const chartMetrics = computed(() => [
|
||||
{
|
||||
key: 'visitors',
|
||||
title: '방문자수',
|
||||
label: `${getTrendSummaryValue('visitors')}명`
|
||||
},
|
||||
{
|
||||
key: 'avgEngagedSeconds',
|
||||
title: '평균 체류시간',
|
||||
label: formatEngagedDuration(getTrendSummaryValue('avgEngagedSeconds'))
|
||||
},
|
||||
{
|
||||
key: 'scroll50Reach',
|
||||
title: '50% 스크롤 도달',
|
||||
label: `${getTrendSummaryValue('scroll50Reach')}회`
|
||||
}
|
||||
])
|
||||
|
||||
/**
|
||||
* 차트 막대 높이 비율을 반환한다.
|
||||
* @param {'visitors' | 'avgEngagedSeconds' | 'scroll50Reach'} key - 추세 키
|
||||
* @param {Object} row - 추세 행
|
||||
* @returns {number} 높이 %
|
||||
*/
|
||||
const getTrendBarHeight = (key, row) => {
|
||||
const rows = trendRows.value
|
||||
const maxValue = Math.max(...rows.map((item) => Number(item[key] || 0)), 0)
|
||||
|
||||
if (maxValue <= 0) {
|
||||
return 3
|
||||
}
|
||||
|
||||
const value = Number(row[key] || 0)
|
||||
return Math.max(Math.round((value / maxValue) * 100), value > 0 ? 8 : 3)
|
||||
}
|
||||
|
||||
/**
|
||||
* 추세 시작·종료 날짜 라벨을 반환한다.
|
||||
* @param {string} day - YYYY-MM-DD
|
||||
* @returns {string} 날짜 라벨
|
||||
*/
|
||||
const formatTrendDayLabel = (day) => {
|
||||
if (!day) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const [, month, date] = day.split('-')
|
||||
return `${month}.${date}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 접속자가 보고 있는 화면명을 반환한다.
|
||||
* @param {Object} session - 접속 세션
|
||||
* @returns {string} 화면명
|
||||
*/
|
||||
const getSessionViewingTitle = (session) => {
|
||||
if (session.postTitle) {
|
||||
return session.postTitle
|
||||
}
|
||||
|
||||
if (session.path === '/') {
|
||||
return '홈'
|
||||
}
|
||||
|
||||
return session.path || '알 수 없음'
|
||||
}
|
||||
|
||||
let refreshTimer = null
|
||||
@@ -81,6 +186,7 @@ let refreshTimer = null
|
||||
onMounted(() => {
|
||||
refreshTimer = window.setInterval(() => {
|
||||
refreshSummary()
|
||||
refreshTopPosts()
|
||||
refreshRealtime()
|
||||
}, 30000)
|
||||
})
|
||||
@@ -90,6 +196,11 @@ onUnmounted(() => {
|
||||
window.clearInterval(refreshTimer)
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedAnalyticsDays, () => {
|
||||
refreshSummary()
|
||||
refreshTopPosts()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -103,51 +214,80 @@ onUnmounted(() => {
|
||||
</h1>
|
||||
</div>
|
||||
<div class="admin-dashboard__body space-y-6 bg-paper p-6">
|
||||
<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">
|
||||
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase text-muted">
|
||||
오늘 방문
|
||||
</p>
|
||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||
{{ analyticsSummary.todayVisitors }}
|
||||
</strong>
|
||||
<p class="admin-dashboard__metric-note mt-2 text-xs text-muted">
|
||||
7일 {{ analyticsSummary.visitorsLast7Days }}
|
||||
</p>
|
||||
</article>
|
||||
<article class="admin-dashboard__metric border border-line bg-white p-4">
|
||||
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase text-muted">
|
||||
평균 체류
|
||||
</p>
|
||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||
{{ formatEngagedDuration(analyticsSummary.avgEngagedSeconds) }}
|
||||
</strong>
|
||||
<p class="admin-dashboard__metric-note mt-2 text-xs text-muted">
|
||||
30일 기준
|
||||
</p>
|
||||
</article>
|
||||
<article class="admin-dashboard__metric border border-line bg-white p-4">
|
||||
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase text-muted">
|
||||
50% 스크롤
|
||||
</p>
|
||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||
{{ analyticsSummary.scroll50Reach }}
|
||||
</strong>
|
||||
<p class="admin-dashboard__metric-note mt-2 text-xs text-muted">
|
||||
30일 조회 {{ analyticsSummary.pageViewsLast30Days }}
|
||||
</p>
|
||||
</article>
|
||||
<section class="admin-dashboard__summary flex flex-wrap items-center gap-x-8 gap-y-2 border border-line bg-white px-4 py-3 text-sm">
|
||||
<p class="admin-dashboard__summary-item text-muted">
|
||||
현재 접속자
|
||||
<strong class="ml-2 text-lg text-ink">{{ realtime.summary.onlineNow }}</strong>
|
||||
</p>
|
||||
<p class="admin-dashboard__summary-item text-muted">
|
||||
오늘 접속자
|
||||
<strong class="ml-2 text-lg text-ink">{{ analyticsSummary.todayVisitors }}</strong>
|
||||
</p>
|
||||
<p class="admin-dashboard__summary-item text-muted">
|
||||
게시물 수
|
||||
<strong class="ml-2 text-lg text-ink">{{ posts.length }}</strong>
|
||||
<span class="ml-1 text-xs">발행 {{ publishedCount }} · 초안 {{ draftCount }}</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="admin-dashboard__charts border border-line bg-white p-4">
|
||||
<div class="admin-dashboard__charts-header flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="admin-dashboard__section-title text-sm font-semibold text-ink">
|
||||
통계 추이
|
||||
</h2>
|
||||
<p class="admin-dashboard__section-description mt-1 text-xs text-muted">
|
||||
선택한 기간의 방문자수, 평균 체류시간, 50% 스크롤 도달 추이
|
||||
</p>
|
||||
</div>
|
||||
<div class="admin-dashboard__range flex flex-wrap gap-1">
|
||||
<button
|
||||
v-for="option in analyticsRangeOptions"
|
||||
:key="option.days"
|
||||
type="button"
|
||||
class="admin-dashboard__range-button border border-line px-3 py-1 text-xs font-medium text-muted hover:bg-paper hover:text-ink"
|
||||
:class="option.days === selectedAnalyticsDays ? 'bg-[#15171a] text-white hover:bg-[#15171a] hover:text-white' : 'bg-white'"
|
||||
@click="selectedAnalyticsDays = option.days"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-dashboard__chart-grid mt-4 grid gap-4 lg:grid-cols-3">
|
||||
<article
|
||||
v-for="metric in chartMetrics"
|
||||
:key="metric.key"
|
||||
class="admin-dashboard__chart border border-line bg-paper p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-xs font-semibold uppercase text-muted">
|
||||
{{ metric.title }}
|
||||
</p>
|
||||
<strong class="text-sm text-ink">
|
||||
{{ metric.label }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="admin-dashboard__chart-bars mt-4 flex h-32 items-end gap-1 border-b border-line">
|
||||
<div
|
||||
v-for="row in trendRows"
|
||||
:key="`${metric.key}-${row.day}`"
|
||||
class="admin-dashboard__chart-bar-wrap flex min-w-[3px] flex-1 items-end"
|
||||
:title="`${row.day} · ${formatTrendValue(metric.key, row[metric.key])}`"
|
||||
>
|
||||
<div
|
||||
class="admin-dashboard__chart-bar w-full bg-[#15171a]/80"
|
||||
:style="{ height: `${getTrendBarHeight(metric.key, row)}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex justify-between text-[11px] text-muted">
|
||||
<span>{{ formatTrendDayLabel(trendStartDay) }}</span>
|
||||
<span>{{ analyticsRangeLabel }}</span>
|
||||
<span>{{ formatTrendDayLabel(trendEndDay) }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-dashboard__live border border-line bg-white p-4">
|
||||
@@ -185,13 +325,12 @@ onUnmounted(() => {
|
||||
{{ session.isLoggedIn ? session.user?.username : '익명 방문자' }}
|
||||
</p>
|
||||
<p class="admin-dashboard__live-path mt-0.5 truncate text-xs text-muted">
|
||||
{{ session.path }}
|
||||
{{ getSessionViewingTitle(session) }}
|
||||
</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 class="font-medium text-ink">
|
||||
접속 유지 {{ formatEngagedDuration(session.durationSeconds) }}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
@@ -204,23 +343,9 @@ onUnmounted(() => {
|
||||
</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">
|
||||
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase text-muted">
|
||||
게시물
|
||||
</p>
|
||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||
{{ posts.length }}
|
||||
</strong>
|
||||
<p class="admin-dashboard__metric-note mt-2 text-xs text-muted">
|
||||
발행 {{ publishedCount }} · 초안 {{ draftCount }}
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="admin-dashboard__top-posts border border-line bg-white p-4">
|
||||
<h2 class="admin-dashboard__section-title text-sm font-semibold text-ink">
|
||||
인기 게시물 (30일)
|
||||
인기 게시물 ({{ analyticsRangeLabel }})
|
||||
</h2>
|
||||
<ul
|
||||
v-if="topPosts.length"
|
||||
|
||||
@@ -10,12 +10,9 @@ const form = reactive({
|
||||
|
||||
const pending = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const emailInput = ref(null)
|
||||
const passwordInput = ref(null)
|
||||
|
||||
/**
|
||||
* 로그인 제출 가능 여부(이메일·비밀번호가 모두 채워졌는지)
|
||||
* @returns {boolean} 제출 가능 여부
|
||||
*/
|
||||
const canSubmitAdminLogin = computed(() => Boolean(form.email.trim()) && Boolean(form.password))
|
||||
const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
|
||||
default: () => ({
|
||||
hasUsers: true,
|
||||
@@ -23,26 +20,68 @@ const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 브라우저/비밀번호 관리자 자동완성 값을 로그인 폼 상태에 반영한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const syncAdminLoginAutofill = () => {
|
||||
const emailValue = emailInput.value?.value || ''
|
||||
const passwordValue = passwordInput.value?.value || ''
|
||||
|
||||
if (emailValue && emailValue !== form.email) {
|
||||
form.email = emailValue
|
||||
}
|
||||
if (passwordValue && passwordValue !== form.password) {
|
||||
form.password = passwordValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 로그인 제출
|
||||
* @returns {Promise<void>} 로그인 처리 결과
|
||||
*/
|
||||
const submitLogin = async () => {
|
||||
syncAdminLoginAutofill()
|
||||
|
||||
pending.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch('/admin/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: form
|
||||
body: {
|
||||
email: form.email.trim(),
|
||||
password: form.password
|
||||
},
|
||||
credentials: 'include'
|
||||
})
|
||||
|
||||
if (import.meta.client) {
|
||||
window.location.assign('/admin')
|
||||
return
|
||||
}
|
||||
|
||||
await navigateTo('/admin')
|
||||
} catch {
|
||||
errorMessage.value = '이메일 또는 비밀번호를 확인해 주세요.'
|
||||
} catch (error) {
|
||||
const statusCode = Number(error?.statusCode || error?.response?.status || 0)
|
||||
|
||||
if (statusCode === 401) {
|
||||
errorMessage.value = '이메일 또는 비밀번호를 확인해 주세요.'
|
||||
} else if (statusCode >= 500) {
|
||||
errorMessage.value = '서버 또는 데이터베이스 연결을 확인해 주세요.'
|
||||
} else {
|
||||
errorMessage.value = '로그인에 실패했습니다. 잠시 후 다시 시도해 주세요.'
|
||||
}
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
syncAdminLoginAutofill()
|
||||
window.setTimeout(syncAdminLoginAutofill, 100)
|
||||
window.setTimeout(syncAdminLoginAutofill, 500)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -68,21 +107,29 @@ const submitLogin = async () => {
|
||||
<label class="admin-login__field grid gap-2 text-sm">
|
||||
<span class="admin-login__label font-medium">이메일</span>
|
||||
<input
|
||||
ref="emailInput"
|
||||
v-model="form.email"
|
||||
class="admin-login__input rounded border border-line bg-white px-3 py-2"
|
||||
type="email"
|
||||
autocomplete="username"
|
||||
required
|
||||
@change="syncAdminLoginAutofill"
|
||||
@focus="syncAdminLoginAutofill"
|
||||
@input="syncAdminLoginAutofill"
|
||||
>
|
||||
</label>
|
||||
<label class="admin-login__field grid gap-2 text-sm">
|
||||
<span class="admin-login__label font-medium">비밀번호</span>
|
||||
<input
|
||||
ref="passwordInput"
|
||||
v-model="form.password"
|
||||
class="admin-login__input rounded border border-line bg-white px-3 py-2"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
@change="syncAdminLoginAutofill"
|
||||
@focus="syncAdminLoginAutofill"
|
||||
@input="syncAdminLoginAutofill"
|
||||
>
|
||||
</label>
|
||||
<p v-if="errorMessage" class="admin-login__error text-sm text-red-600">
|
||||
@@ -91,7 +138,7 @@ const submitLogin = async () => {
|
||||
<button
|
||||
class="admin-login__button rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="submit"
|
||||
:disabled="pending || !canSubmitAdminLogin"
|
||||
:disabled="pending"
|
||||
>
|
||||
{{ pending ? '확인 중' : '로그인' }}
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
ANALYTICS_MAX_DURATION_SECONDS,
|
||||
isTrackableAnalyticsPath
|
||||
} from '../lib/analytics.js'
|
||||
} from '../lib/analytics-shared.js'
|
||||
|
||||
/** @type {string} 탭 단위 클라이언트 세션 storage 키 */
|
||||
const CLIENT_SESSION_STORAGE_KEY = 'sori_analytics_client_session'
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import {
|
||||
ANALYTICS_ACTIVE_SESSION_TTL_SECONDS,
|
||||
ANALYTICS_CHART_MAX_DAYS,
|
||||
ANALYTICS_ENGAGED_MIN_SECONDS,
|
||||
ANALYTICS_RETENTION_PURGE_INTERVAL_MS,
|
||||
ANALYTICS_VISITOR_HASH_RETENTION_DAYS,
|
||||
clampAnalyticsDurationSeconds,
|
||||
clampAnalyticsScrollRatio,
|
||||
createDailyVisitorHash,
|
||||
createRealtimeSessionHash,
|
||||
getAnalyticsDayBefore,
|
||||
getAnalyticsDayKey,
|
||||
getNewScrollBucketColumns
|
||||
} from '../../lib/analytics.js'
|
||||
@@ -12,6 +16,9 @@ import { getPostgresClient } from './postgres-client.js'
|
||||
import { getRuntimeEnvValue } from '../utils/runtime-env.js'
|
||||
import { getMemberSession } from '../utils/member-auth.js'
|
||||
|
||||
/** @type {number} 마지막 통계 보관 정리 실행 시각 */
|
||||
let lastAnalyticsRetentionPurgeAt = 0
|
||||
|
||||
/**
|
||||
* 통계 해시용 시크릿을 반환한다.
|
||||
* @returns {string} 시크릿
|
||||
@@ -124,6 +131,7 @@ export const recordAnalyticsPageview = async (input) => {
|
||||
}
|
||||
|
||||
if (!postId) {
|
||||
await purgeAnalyticsRetention(sql)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
@@ -151,6 +159,8 @@ export const recordAnalyticsPageview = async (input) => {
|
||||
`
|
||||
}
|
||||
|
||||
await purgeAnalyticsRetention(sql)
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
@@ -198,6 +208,29 @@ const purgeStaleActiveSessions = async (sql) => {
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 오래된 방문자 해시를 정리한다.
|
||||
* @param {import('postgres').Sql} sql - DB 클라이언트
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const purgeAnalyticsRetention = async (sql) => {
|
||||
const now = Date.now()
|
||||
|
||||
if (now - lastAnalyticsRetentionPurgeAt < ANALYTICS_RETENTION_PURGE_INTERVAL_MS) {
|
||||
return
|
||||
}
|
||||
|
||||
const today = getAnalyticsDayKey()
|
||||
const visitorHashCutoffDay = getAnalyticsDayBefore(today, ANALYTICS_VISITOR_HASH_RETENTION_DAYS)
|
||||
|
||||
await sql`
|
||||
DELETE FROM analytics_daily_visitors
|
||||
WHERE day < ${visitorHashCutoffDay}::date
|
||||
`
|
||||
|
||||
lastAnalyticsRetentionPurgeAt = now
|
||||
}
|
||||
|
||||
/**
|
||||
* 스크롤 구간 카운터를 증가시킨다.
|
||||
* @param {import('postgres').Sql} sql - DB 클라이언트
|
||||
@@ -359,6 +392,7 @@ export const recordAnalyticsHeartbeat = async (input) => {
|
||||
}
|
||||
|
||||
await purgeStaleActiveSessions(sql)
|
||||
await purgeAnalyticsRetention(sql)
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -370,7 +404,7 @@ export const recordAnalyticsHeartbeat = async (input) => {
|
||||
*/
|
||||
export const getAnalyticsSummary = async (options = {}) => {
|
||||
const sql = getPostgresClient()
|
||||
const days = Math.min(Math.max(Number(options.days) || 30, 1), 365)
|
||||
const days = Math.min(Math.max(Number(options.days) || 30, 1), ANALYTICS_CHART_MAX_DAYS)
|
||||
|
||||
if (!sql) {
|
||||
return {
|
||||
@@ -381,12 +415,17 @@ export const getAnalyticsSummary = async (options = {}) => {
|
||||
loggedInNow: 0,
|
||||
avgEngagedSeconds: 0,
|
||||
scroll50Reach: 0,
|
||||
trends: [],
|
||||
days
|
||||
}
|
||||
}
|
||||
|
||||
const today = getAnalyticsDayKey()
|
||||
const last7StartDay = getAnalyticsDayBefore(today, 6)
|
||||
const last30StartDay = getAnalyticsDayBefore(today, 29)
|
||||
const rangeStartDay = getAnalyticsDayBefore(today, days - 1)
|
||||
await purgeStaleActiveSessions(sql)
|
||||
await purgeAnalyticsRetention(sql)
|
||||
|
||||
const todayRows = await sql`
|
||||
SELECT visitors, page_views, engaged_views, total_engaged_seconds
|
||||
@@ -398,13 +437,13 @@ export const getAnalyticsSummary = async (options = {}) => {
|
||||
const last7Rows = await sql`
|
||||
SELECT COALESCE(SUM(visitors), 0)::int AS visitors
|
||||
FROM site_analytics_daily
|
||||
WHERE day >= (${today}::date - 6)
|
||||
WHERE day >= ${last7StartDay}::date
|
||||
`
|
||||
|
||||
const pageViewRows = await sql`
|
||||
SELECT COALESCE(SUM(page_views), 0)::int AS page_views
|
||||
FROM site_analytics_daily
|
||||
WHERE day >= (${today}::date - 29)
|
||||
WHERE day >= ${last30StartDay}::date
|
||||
`
|
||||
|
||||
const engagementRows = await sql`
|
||||
@@ -412,13 +451,13 @@ export const getAnalyticsSummary = async (options = {}) => {
|
||||
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})
|
||||
WHERE day >= ${rangeStartDay}::date
|
||||
`
|
||||
|
||||
const scrollRows = await sql`
|
||||
SELECT COALESCE(SUM(scroll_50), 0)::int AS scroll_50
|
||||
FROM post_analytics_daily
|
||||
WHERE day >= (${today}::date - ${days - 1})
|
||||
WHERE day >= ${rangeStartDay}::date
|
||||
`
|
||||
|
||||
const onlineRows = await sql`
|
||||
@@ -429,6 +468,33 @@ export const getAnalyticsSummary = async (options = {}) => {
|
||||
WHERE last_seen_at >= now() - (${ANALYTICS_ACTIVE_SESSION_TTL_SECONDS} * interval '1 second')
|
||||
`
|
||||
|
||||
const trendRows = await sql`
|
||||
WITH days AS (
|
||||
SELECT generate_series(${rangeStartDay}::date, ${today}::date, interval '1 day')::date AS day
|
||||
),
|
||||
post_daily AS (
|
||||
SELECT
|
||||
day,
|
||||
COALESCE(SUM(scroll_50), 0)::int AS scroll_50
|
||||
FROM post_analytics_daily
|
||||
WHERE day >= ${rangeStartDay}::date
|
||||
GROUP BY day
|
||||
)
|
||||
SELECT
|
||||
days.day,
|
||||
COALESCE(site_analytics_daily.visitors, 0)::int AS visitors,
|
||||
CASE
|
||||
WHEN COALESCE(site_analytics_daily.engaged_views, 0) > 0
|
||||
THEN ROUND(site_analytics_daily.total_engaged_seconds::numeric / site_analytics_daily.engaged_views)::int
|
||||
ELSE 0
|
||||
END AS avg_engaged_seconds,
|
||||
COALESCE(post_daily.scroll_50, 0)::int AS scroll_50
|
||||
FROM days
|
||||
LEFT JOIN site_analytics_daily ON site_analytics_daily.day = days.day
|
||||
LEFT JOIN post_daily ON post_daily.day = days.day
|
||||
ORDER BY days.day ASC
|
||||
`
|
||||
|
||||
const engagedViews = Number(engagementRows[0]?.engaged_views || 0)
|
||||
const totalEngagedSeconds = Number(engagementRows[0]?.total_engaged_seconds || 0)
|
||||
|
||||
@@ -442,6 +508,12 @@ export const getAnalyticsSummary = async (options = {}) => {
|
||||
? Math.round(totalEngagedSeconds / engagedViews)
|
||||
: 0,
|
||||
scroll50Reach: Number(scrollRows[0]?.scroll_50 || 0),
|
||||
trends: trendRows.map((row) => ({
|
||||
day: row.day ? new Date(row.day).toISOString().slice(0, 10) : '',
|
||||
visitors: Number(row.visitors || 0),
|
||||
avgEngagedSeconds: Number(row.avg_engaged_seconds || 0),
|
||||
scroll50Reach: Number(row.scroll_50 || 0)
|
||||
})),
|
||||
days
|
||||
}
|
||||
}
|
||||
@@ -462,6 +534,7 @@ export const getAnalyticsRealtimeSummary = async () => {
|
||||
}
|
||||
|
||||
await purgeStaleActiveSessions(sql)
|
||||
await purgeAnalyticsRetention(sql)
|
||||
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
@@ -495,6 +568,7 @@ export const getAnalyticsActiveSessions = async (options = {}) => {
|
||||
}
|
||||
|
||||
await purgeStaleActiveSessions(sql)
|
||||
await purgeAnalyticsRetention(sql)
|
||||
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
@@ -504,10 +578,12 @@ export const getAnalyticsActiveSessions = async (options = {}) => {
|
||||
analytics_active_sessions.duration_seconds,
|
||||
analytics_active_sessions.max_scroll_ratio,
|
||||
analytics_active_sessions.last_seen_at,
|
||||
posts.title AS post_title,
|
||||
users.id AS user_id,
|
||||
users.username,
|
||||
users.avatar_url
|
||||
FROM analytics_active_sessions
|
||||
LEFT JOIN posts ON posts.id = analytics_active_sessions.post_id
|
||||
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
|
||||
@@ -518,6 +594,7 @@ export const getAnalyticsActiveSessions = async (options = {}) => {
|
||||
sessionHash: row.session_hash,
|
||||
path: row.path,
|
||||
postSlug: row.post_slug || '',
|
||||
postTitle: row.post_title || '',
|
||||
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,
|
||||
@@ -539,7 +616,7 @@ export const getAnalyticsActiveSessions = async (options = {}) => {
|
||||
*/
|
||||
export const getAnalyticsTopPosts = async (options = {}) => {
|
||||
const sql = getPostgresClient()
|
||||
const days = Math.min(Math.max(Number(options.days) || 30, 1), 365)
|
||||
const days = Math.min(Math.max(Number(options.days) || 30, 1), ANALYTICS_CHART_MAX_DAYS)
|
||||
const limit = Math.min(Math.max(Number(options.limit) || 5, 1), 20)
|
||||
|
||||
if (!sql) {
|
||||
@@ -547,6 +624,8 @@ export const getAnalyticsTopPosts = async (options = {}) => {
|
||||
}
|
||||
|
||||
const today = getAnalyticsDayKey()
|
||||
const rangeStartDay = getAnalyticsDayBefore(today, days - 1)
|
||||
await purgeAnalyticsRetention(sql)
|
||||
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
@@ -563,7 +642,7 @@ export const getAnalyticsTopPosts = async (options = {}) => {
|
||||
COALESCE(SUM(post_analytics_daily.scroll_100), 0)::int AS scroll_100
|
||||
FROM post_analytics_daily
|
||||
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 >= ${rangeStartDay}::date
|
||||
GROUP BY posts.id, posts.title, posts.slug
|
||||
ORDER BY views DESC, reads DESC, posts.published_at DESC NULLS LAST
|
||||
LIMIT ${limit}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto'
|
||||
import { createError, deleteCookie, getCookie, setCookie } from 'h3'
|
||||
import { getRuntimeEnvValue } from './runtime-env'
|
||||
import { shouldUseSecureSessionCookie } from './session-cookie'
|
||||
|
||||
const adminSessionCookieName = 'sori_admin_session'
|
||||
const sessionMaxAge = 60 * 60 * 12
|
||||
@@ -109,8 +110,8 @@ export const setAdminSession = (event, adminUser) => {
|
||||
setCookie(event, adminSessionCookieName, createAdminSessionToken(adminUser), {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/admin',
|
||||
secure: shouldUseSecureSessionCookie(event),
|
||||
path: '/',
|
||||
maxAge: sessionMaxAge
|
||||
})
|
||||
}
|
||||
@@ -122,7 +123,7 @@ export const setAdminSession = (event, adminUser) => {
|
||||
*/
|
||||
export const clearAdminSession = (event) => {
|
||||
deleteCookie(event, adminSessionCookieName, {
|
||||
path: '/admin'
|
||||
path: '/'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto'
|
||||
import { createError, deleteCookie, getCookie, setCookie } from 'h3'
|
||||
import { getRuntimeEnvValue } from './runtime-env'
|
||||
import { shouldUseSecureSessionCookie } from './session-cookie'
|
||||
|
||||
const memberSessionCookieName = 'sori_member_session'
|
||||
const sessionMaxAge = 60 * 60 * 24 * 14
|
||||
@@ -107,7 +108,7 @@ export const setMemberSession = (event, user) => {
|
||||
setCookie(event, memberSessionCookieName, createMemberSessionToken(user), {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
secure: shouldUseSecureSessionCookie(event),
|
||||
path: '/',
|
||||
maxAge: sessionMaxAge
|
||||
})
|
||||
|
||||
24
server/utils/session-cookie.js
Normal file
24
server/utils/session-cookie.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { getRequestHeader, getRequestURL } from 'h3'
|
||||
|
||||
/**
|
||||
* HTTPS 요청 여부를 판별해 Secure 쿠키 사용 여부를 반환한다.
|
||||
* 운영 환경이라도 HTTP로 접속하면 쿠키가 저장되지 않는 문제를 막는다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {boolean} Secure 쿠키 사용 여부
|
||||
*/
|
||||
export const shouldUseSecureSessionCookie = (event) => {
|
||||
const forwarded = String(getRequestHeader(event, 'x-forwarded-proto') || '')
|
||||
.split(',')[0]
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
|
||||
if (forwarded) {
|
||||
return forwarded === 'https'
|
||||
}
|
||||
|
||||
try {
|
||||
return getRequestURL(event).protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user