|
|
|
|
@@ -589,16 +589,18 @@ components/content/
|
|
|
|
|
| page_analytics_daily | day, page_id, views, visitors, engaged_views, total_engaged_seconds, scroll_25~100 | 페이지 일별 조회·방문자·스크롤 구간 |
|
|
|
|
|
| analytics_traffic_daily | day, source_group, source_name, device_type, os_name, keyword, page_views, visitors | 유입원·디바이스·키워드 일별 축약 집계 |
|
|
|
|
|
| analytics_daily_visitors | day, scope(`site`/`post`/`page`/`traffic`), post_id?, page_id?, visitor_hash, source_group?, source_name?, device_type?, os_name?, keyword? | 일별 방문자 해시 등록(중복 방문 제거용) |
|
|
|
|
|
| analytics_active_sessions | session_hash, user_id?, path, post_id?, post_slug, page_id?, page_slug, ip_address, duration_seconds, max_scroll_ratio, last_seen_at | 실시간 접속 세션(TTL 90초) |
|
|
|
|
|
| analytics_content_sessions | content_session_hash, day, visitor_hash, session_hash, path, post_id?, page_id?, source_group, source_name, device_type, os_name, keyword, view_recorded, duration_seconds, max_scroll_ratio, engaged_recorded, scroll_25~100_recorded, read_recorded, read_at, last_seen_at | 콘텐츠 단위 방문 세션 원장 |
|
|
|
|
|
| analytics_active_sessions | session_hash, content_session_hash, user_id?, path, post_id?, post_slug, page_id?, page_slug, ip_address, duration_seconds, max_scroll_ratio, last_seen_at | 실시간 접속 세션(TTL 90초) |
|
|
|
|
|
|
|
|
|
|
- 추적 대상: 공개 경로만. `/admin`, `/signin`, `/signup`, `/forgot-password`, `/settings`는 제외.
|
|
|
|
|
- 관리자 세션(`sori_admin_session`)이 있는 요청은 공개 경로라도 페이지뷰, 게시물 읽음, heartbeat 체류·스크롤, 실시간 접속자 집계에 포함하지 않는다.
|
|
|
|
|
- 봇 User-Agent는 서버에서 무시.
|
|
|
|
|
- 페이지뷰 수집은 탭 최초 referrer와 현재 URL을 서버로 보내며, 서버는 원문 referrer를 저장하지 않고 검색·SNS·직접·기타, 디바이스·OS, 검색 키워드로 축약해 `analytics_traffic_daily`에 집계한다.
|
|
|
|
|
- 검색 유입은 네이버·다음·구글·줌·빙을 우선 분류하고, SNS 유입은 카카오톡·페이스북·인스타그램·트위터/X·유튜브를 우선 분류한다. 검색 키워드는 referrer query에 남아 있는 경우만 표시한다.
|
|
|
|
|
- 게시물 `reads`는 클라이언트에서 15초 이상 체류·50% 이상 스크롤 후 별도 전송.
|
|
|
|
|
- `POST /api/analytics/heartbeat`는 20초 간격으로 체류시간·스크롤·현재 경로를 전송한다. 로그인 사용자는 서버 세션으로 `user_id`를 연결한다.
|
|
|
|
|
- 관리자 대시보드는 `GET /admin/api/analytics/realtime`으로 현재 접속자 목록(닉네임·아바타·대표 게시물 제목·접속 유지시간·IP·열린 페이지 수·열린 페이지 제목 목록)을 조회한다. 현재 접속자는 로그인 사용자는 회원 기준, 익명 방문자는 IP 기준으로 묶어 같은 사람이 여러 페이지를 열어도 1명으로 표시하되, 묶음 안의 활성 페이지는 목록으로 함께 표시한다. 상단 요약은 현재 접속자·오늘 방문자·누적 방문자·게시물 수를 표시한다.
|
|
|
|
|
- 클라이언트는 탭 단위 `clientSessionId`와 라우트 진입 단위 `contentSessionId`를 분리해 전송한다. 서버는 원문 ID를 저장하지 않고 visitor hash와 통계 시크릿으로 `session_hash`, `content_session_hash`를 만든다.
|
|
|
|
|
- 게시물 `reads`는 클라이언트에서 15초 이상 체류·50% 이상 스크롤 후 별도 전송한다. 콘텐츠 세션 원장이 있는 경우 같은 콘텐츠 세션의 `read_recorded`가 처음 켜질 때만 일별 `reads`를 증가시킨다.
|
|
|
|
|
- `POST /api/analytics/heartbeat`는 20초 간격으로 체류시간·스크롤·현재 경로를 전송한다. 로그인 사용자는 서버 세션으로 `user_id`를 연결한다. 체류·스크롤 증분은 `analytics_content_sessions`의 콘텐츠 세션별 이전 값 기준으로 계산해 같은 탭에서 여러 게시물·페이지를 이동해도 스크롤 구간과 engaged 집계가 서로 섞이지 않게 한다.
|
|
|
|
|
- 관리자 대시보드는 `GET /admin/api/analytics/realtime`으로 현재 접속자 목록(닉네임·아바타·대표 게시물 제목·접속 유지시간·IP·열린 페이지 수·열린 페이지 제목 목록)을 조회한다. 현재 접속자는 로그인 사용자는 회원 기준, 익명 방문자는 IP 기준으로 묶어 같은 사람이 여러 페이지를 열어도 1명으로 표시하되, 묶음 안의 활성 페이지는 목록으로 함께 표시한다. 활성 페이지 행에 마우스를 올리면 공개 경로 새 창 바로가기 버튼을 표시한다. 상단 요약은 현재 접속자·오늘 방문자·누적 방문자·게시물 수를 표시한다.
|
|
|
|
|
- 관리자 대시보드 **통계 추이**는 `trends` 데이터를 3개 막대 차트(방문자수·평균 체류시간·50% 스크롤 도달)로 표시한다. 선택지는 일간·주간·월간·년간이며 기본값은 일간이다. 집계는 `ANALYTICS_COLLECTED_SINCE` 환경 변수(기본 `2026-06-01`) 이후 데이터만 합산하고 그 이전 구간은 0으로 표시한다. **방문자수** 막대는 검색(빨강)·SNS(청록)·기타(회색) 유입 비율을 스택으로 표시하며, hover 시 해당 막대만 색상을 강조한다. 안내 문구는 `통계는 2026년 6월 1일부터 집계한 데이터를 표시합니다.` 형식이다. 막대 hover/focus 시 기간과 정확한 값을 툴팁으로 표시하며, 막대 클릭 시 하단 **선택한 기간 상세** 패널을 해당 날짜 또는 묶음 구간 기준으로 갱신한다. 표(table)나 외부 차트 라이브러리는 사용하지 않는다.
|
|
|
|
|
- 관리자 대시보드 **선택한 기간 상세**는 기본값으로 오늘 통계를 표시하고, 차트 막대 클릭 시 선택 범위의 페이지뷰·방문자·평균 체류·50% 스크롤·검색/SNS/직접 포함 유입원·디바이스/OS·인기 게시물·인기 페이지를 표시한다. 자동 갱신 중에는 사용자가 선택한 날짜·기간을 유지하고, 상세 재조회 중에도 기존 내용을 유지해 화면 높이가 줄어들지 않게 한다. 인기 게시물·인기 페이지는 데스크톱에서 왼쪽 75% 영역에 세로 목록으로 표시하고, 유입·디바이스는 오른쪽 25% 영역에 세로 배치한다. 디바이스는 모바일·PC 같은 상위 기기 기준으로 한 카드에 합산하고, 상위 기기 제목 오른쪽에 방문자 수·조회수·전체 디바이스 방문자 대비 비율을 한 줄로 표시한다. 카드 안의 Android/iOS/Windows 같은 OS별 목록은 방문자 수와 전체 디바이스 방문자 대비 비율만 표시한다. 인기 콘텐츠 메타는 `V 조회`, `R 읽음/방문자`, `M 최근 30일 조회`, `C 작성일`, `S 50/75/100% 스크롤` 형식의 한 줄 압축 표기를 사용한다. `V/R/M` 알파벳만 서로 다른 색상으로 강조하고 숫자는 보조 텍스트 색상으로 표시한다. 구분자 `|`는 연한 회색으로 표시하며, `S`의 50/75/100 스크롤 값은 단계가 깊어질수록 진하게 표시한다. 관리자 통계 응답은 표시 안정성을 위해 `읽음 <= 조회`, `100% 스크롤 <= 75% 스크롤 <= 50% 스크롤 <= 조회` 범위로 정규화한다. 유입 프로그래스 바는 보조 지표로 보이도록 카드 폭의 절반만 사용한다. 검색엔진이 대부분 검색어를 전달하지 않는 현실을 반영해 대시보드의 유입 키워드 영역은 표시하지 않는다.
|
|
|
|
|
- 관리자 글 목록은 게시물별 `post_analytics_daily.views` 누적 합계를 별도 조회수 열에 `1,000`처럼 숫자만 표시하고, 같은 셀 아래 작은 회색 통계 버튼으로 게시물별 통계 모달을 연다. 통계 버튼은 hover 시에만 파란색으로 표시한다.
|
|
|
|
|
@@ -606,6 +608,7 @@ components/content/
|
|
|
|
|
- 관리자 대시보드 추이 차트는 일일 7개, 주간 최대 8개, 월간 12개, 년간 최대 8개 버킷을 조회한다. 게시물별 통계와 기타 인기/유입 목록 API는 기존 최대 365일 조회 제한을 유지한다.
|
|
|
|
|
- `site_analytics_daily`, `post_analytics_daily`, `analytics_traffic_daily`는 사이트 전체 방문자와 게시물별 조회수, 유입 통계 누적 원본이므로 자동 삭제하지 않는다.
|
|
|
|
|
- `analytics_daily_visitors`는 일별 중복 방문 제거용이며, 수집·조회 흐름에서 32일보다 오래된 행을 주기적으로 삭제한다.
|
|
|
|
|
- `analytics_content_sessions`는 콘텐츠 세션별 중복 방지와 체류·스크롤 증분 계산용 원장이며, 일별 누적 집계는 보존하되 원장 행은 방문자 해시와 같은 32일 보관 정책으로 주기적으로 삭제한다.
|
|
|
|
|
- `analytics_active_sessions`는 현재 접속자 목록용이며, 90초보다 오래된 행을 삭제한다. IP는 실시간 익명 접속자 묶음 표시용으로만 저장하고, 방문자 통계 고유성은 기존 해시 기반 집계를 유지한다.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
@@ -637,8 +640,8 @@ components/content/
|
|
|
|
|
- `GET /api/search?q=` - 통합 검색. 태그는 `name`·`slug`, 게시물은 DB 트리거가 갱신하는 `posts.search_text`, 홈 표시 페이지는 `pages.search_text` 기준으로 부분 일치한다. `search_text`는 제목·요약·본문·페이지 섹션/목록 설명에서 업로드 경로와 이미지 마크다운 토큰을 제거한 정규화 문자열이며 `pg_trgm` GIN 인덱스를 사용한다.
|
|
|
|
|
- `GET /api/site-settings` - 공개 사이트 설정(어나운스 바·홈 커버 등 포함)
|
|
|
|
|
- `GET /api/homepage-widget` - gethomepage customapi용 공개 블로그 통계. 기본 필드 `title`, `updatedAt`, `todayVisitors`, `totalVisitors`, `todayPageViews`, `totalPageViews`, `visitorsLast7Days`, `visitorsLast30Days`, `visitorsLast180Days`, `visitorsLast365Days`, `pageViewsLast30Days`, `onlineNow`, `loggedInNow`, `avgEngagedSeconds`, `totalPosts`, `totalComments`, `totalPages`, `totalTags`, `totalMembers`, `items[]`를 반환한다. 기간별 방문자는 최근 7·30·180·365일의 일별 순방문자 합계다. `totalVisitors`는 날짜별 순방문자 수의 누적 합계라 동일 방문자가 다른 날짜에 다시 포함될 수 있다. 게시글·댓글·페이지 수는 현재 공개 가능한 항목만 집계한다.
|
|
|
|
|
- `POST /api/analytics/pageview` - 공개 방문·게시물·페이지 조회/읽음 집계. 본문: `path`(필수), `postSlug`(게시물일 때), `pageSlug`(페이지일 때), `referrer`, `currentUrl`, `read`(읽음 이벤트). 발행된 게시물과 공개 페이지만 개별 집계한다. 응답 `{ ok: true }`. 관리자 세션 요청은 저장하지 않고 `{ ok: true }`만 반환한다. HTML 문서 모드 페이지는 Nuxt 클라이언트 플러그인을 거치지 않으므로 서버 미들웨어가 GET 요청 시 페이지 조회를 직접 기록하되, 관리자 세션은 제외한다. 단일 IP 해시는 1분에 최대 120개 쓰기 요청만 처리하고 초과 요청은 성공 응답만 반환한다.
|
|
|
|
|
- `POST /api/analytics/heartbeat` - 실시간 세션·체류·스크롤 집계. 본문: `path`, `postSlug`, `pageSlug`, `clientSessionId`, `durationSeconds`(최대 1800), `maxScrollRatio`(0~1). 로그인 시 서버가 회원 세션으로 사용자 연결하며, 관리자 세션 요청은 저장하지 않는다. 단일 IP 해시는 1분에 최대 240개 쓰기 요청만 처리한다.
|
|
|
|
|
- `POST /api/analytics/pageview` - 공개 방문·게시물·페이지 조회/읽음 집계. 본문: `path`(필수), `postSlug`(게시물일 때), `pageSlug`(페이지일 때), `clientSessionId`(선택), `contentSessionId`(선택), `referrer`, `currentUrl`, `read`(읽음 이벤트). 발행된 게시물과 공개 페이지만 개별 집계한다. `contentSessionId`가 있으면 같은 콘텐츠 세션의 중복 pageview/read는 일별 집계에 다시 더하지 않는다. 응답 `{ ok: true }`. 관리자 세션 요청은 저장하지 않고 `{ ok: true }`만 반환한다. HTML 문서 모드 페이지는 Nuxt 클라이언트 플러그인을 거치지 않으므로 서버 미들웨어가 GET 요청 시 페이지 조회를 직접 기록하되, 관리자 세션은 제외한다. 단일 IP 해시는 1분에 최대 120개 쓰기 요청만 처리하고 초과 요청은 성공 응답만 반환한다.
|
|
|
|
|
- `POST /api/analytics/heartbeat` - 실시간 세션·체류·스크롤 집계. 본문: `path`, `postSlug`, `pageSlug`, `clientSessionId`, `contentSessionId`(선택), `durationSeconds`(최대 1800), `maxScrollRatio`(0~1). 로그인 시 서버가 회원 세션으로 사용자 연결하며, 관리자 세션 요청은 저장하지 않는다. `contentSessionId`가 있으면 콘텐츠 단위 세션 원장의 이전 체류·스크롤 기준으로 증분을 계산한다. 단일 IP 해시는 1분에 최대 240개 쓰기 요청만 처리한다.
|
|
|
|
|
- `GET /api/navigation` - 공개 네비게이션(`primary`는 트리·`footer`·`recommended`는 평면, 상세는 위 메뉴/네비게이션 절)
|
|
|
|
|
- `GET /ads.txt` - 사이트 설정의 ads.txt 본문을 `text/plain`으로 반환한다. 값이 없으면 빈 본문을 반환한다.
|
|
|
|
|
- `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` 없이 가입 가능. 이메일은 저장 시 소문자로 정규화한다.
|
|
|
|
|
|