From 7b6fc45bf367687b2af398311502c0c8e8ee532c Mon Sep 17 00:00:00 2001 From: zenn Date: Wed, 21 Oct 2026 18:45:44 +0900 Subject: [PATCH] =?UTF-8?q?v1.12.30=20=EA=B8=B4=20=EC=A0=91=EC=86=8D=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=ED=91=9C=EC=8B=9C=EC=99=80=20=EC=A4=91?= =?UTF-8?q?=EB=8B=A8=20=ED=9B=84=20=EC=83=88=20=EB=B0=A9=EB=AC=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/history.md | 4 + docs/map.md | 12 +-- docs/spec.md | 19 +++-- docs/todo.md | 2 +- docs/update.md | 10 +++ lib/analytics-display.js | 21 +++++ lib/analytics-duration.js | 30 +++++++ lib/analytics-shared.js | 19 ++++- lib/analytics.js | 2 + package-lock.json | 4 +- package.json | 2 +- pages/admin/index.vue | 6 +- plugins/site-analytics.client.js | 83 +++++++++----------- server/repositories/analytics-repository.js | 4 +- server/utils/analytics-heartbeat-input.js | 3 +- tests/analytics-display.test.js | 13 ++- tests/analytics-duration.test.js | 48 +++++++++++ tests/playwright/api/core-api.spec.js | 36 +++++++++ tests/playwright/e2e/admin-dashboard.spec.js | 60 +++++++++++++- 19 files changed, 310 insertions(+), 68 deletions(-) create mode 100644 lib/analytics-duration.js create mode 100644 tests/analytics-duration.test.js diff --git a/docs/history.md b/docs/history.md index 08ee41b..00fe9e7 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,3 +1,7 @@ +## 2026-10-21 v1.12.30 — 긴 접속 시간과 방문 평균을 구분한다 + +현재 접속자 시간은 30분 이후도 표시하되 슬립으로 오래 멈춘 구간을 포함하지 않도록 페이지 진입부터의 단순 경과 대신 heartbeat 실행 간격을 누적한다. 운영체제 슬립을 직접 판정할 수 없으므로 접속 TTL과 같은 90초 이상 중단 구간을 제외한다. 짧은 중단마다 초기화하지 않도록 30분 미만이면 기존 방문을 유지하고, 30분 이상이면 새 콘텐츠 세션과 페이지뷰로 시작해 이전 접속 시간·읽음 판정을 이어받지 않는다. 별도 폴링 없이 기존 20초 주기를 유지하고 탭 복귀·페이지 복원 때 즉시 갱신한다. 실시간 값만 기존 정수 컬럼 범위로 확장하고 평균 체류의 콘텐츠별 30분 상한은 유지해 오래 열린 탭 때문에 기간 통계가 크게 바뀌지 않게 한다. 개별 접속 시간과 기간 평균은 대상이 다르므로 접속자 목록에는 개별 시간 하나만, 통계에는 기존 평균을 표시한다. + ## 2026-10-21 v1.12.29 — 작성 이력 열과 접속자 요약의 의미를 정리한다 멤버별 이력에서는 작성자 정보가 반복되므로 게시글·내용·작성일만 표시하고 종류 선택은 기존 필터로 유지한다. 대시보드 댓글은 제목·내용·작성자·날짜 순서로 맞춘다. 접속자 목록에서는 여러 페이지를 보는 경우만 색상과 굵은 숫자로 강조하고 상세 팝오버를 제공한다. 전체 열린 화면 수는 표시된 최대 20명의 페이지를 더하지 않고 최근 90초 내 전체 활성 탭 세션을 집계한다. 같은 경로를 연 중복 탭도 화면 수에 포함하되 방문자별 팝오버는 기존 고유 페이지 기준을 유지한다. 30분 표시가 실제 읽은 시간으로 오해되지 않도록 기존 경과 시간의 상한과 백그라운드 포함 기준을 안내한다. diff --git a/docs/map.md b/docs/map.md index 646492e..fd9f75c 100644 --- a/docs/map.md +++ b/docs/map.md @@ -112,7 +112,7 @@ | lib/media-catalog.js | 업로드 URL·저장 키 정규화, 미디어 종류·원본/파생 역할 판별, 콘텐츠 미디어 참조 추출, PostgreSQL LIKE 리터럴 이스케이프 | | lib/social-links.js | 사이트 설정 SNS 링크 아이콘 프리셋·사용자 SVG 정리·URL 자동 보정·레거시 JSON 문자열 복구·공개 노출 목록 생성 | | server/utils/backup-storage-summary.js | 관리자 사이트 설정 백업 현황용 업로드 파일 스캔·원본/자동 생성 이미지/프로필/내보내기 파일 용량 집계 | -| plugins/site-analytics.client.js | 공개 페이지 pageview/read/heartbeat 전송, 탭 단위 client session과 라우트 진입 단위 content session ID 관리 | +| plugins/site-analytics.client.js | 공개 페이지 pageview/read/heartbeat 전송, 탭 단위 client session과 라우트 진입 단위 content session ID 관리·긴 실행 중단 제외 측정·탭 복귀/페이지 복원 heartbeat | | server/repositories/analytics-repository.js | 자체 통계 일별 집계, 콘텐츠 세션 원장, 실시간 접속자·전체 활성 탭 수, 유입·디바이스·게시물·페이지 통계 조회 | ## 문서 @@ -212,7 +212,7 @@ | tests/markdown-table.test.js | Markdown 표 행·정렬·블록 파싱 단위 회귀 테스트 | | tests/admin-pagination.test.js | 관리자 페이지네이션 페이지 보정·ellipsis 항목 단위 테스트 | | tests/playwright/e2e/admin-media-interactions.spec.js | 관리자 미디어 전체 선택·선택 유지 폴더 생성·다중 드래그/드롭 강조·이동 진행·취소/실패·목록 지연/최신 요청/재시도 E2E 회귀 검증 | -| tests/playwright/api/core-api.spec.js | 인증·부분 설정 저장·공개 목록·댓글/회원 작성 이력·실시간 활성 탭 집계·CSP·미디어 reconcile/fallback·세션 무효화 API 통합 14건 | +| tests/playwright/api/core-api.spec.js | 인증·부분 설정 저장·공개 목록·댓글/회원 작성 이력·실시간 활성 탭 집계·긴 접속 시간/평균 체류 상한·CSP·미디어 reconcile/fallback·세션 무효화 API 통합 15건 | | tests/playwright/e2e/admin-guard-and-settings.spec.js | 설정 카드 독립 저장과 미저장 이탈 취소 브라우저 E2E 2건 | | tests/playwright/e2e/admin-publish.spec.js | 관리자 로그인·게시물 발행·공개 화면 확인 브라우저 E2E 1건 | | tests/playwright/e2e/admin-regressions.spec.js | 태그 추천 연속 선택·미디어 파생 탭 배지 숨김·단일 이미지 `{size=}` 너비 브라우저 회귀 테스트 | @@ -646,10 +646,12 @@ | server/routes/admin/api/members/[id]/posts.get.js | 관리자 회원 작성 글 조회 인증·Zod 검증·검색·페이지 API | | server/repositories/member-activity-repository.js | 회원별 게시글·댓글 통합 최신순 검색·종류 필터·전체 건수 및 페이지 조회 | | server/routes/admin/api/members/[id]/activity.get.js | 관리자 회원 통합 작성 이력 인증·Zod 조건 검증 API | -| lib/analytics-display.js | 통계 천 단위 숫자 표시·접속 페이지 제목/공개 링크·최근순 고유 페이지 정리 | +| lib/analytics-duration.js | 페이지별 heartbeat 실행 간격 누적·90초 이상 중단 제외·30분 이상 중단 후 새 방문 | +| tests/analytics-duration.test.js | 30분 초과 누적·짧은 중단 유지/제외·긴 중단 후 새 방문·시계 역행·통계/실시간 상한 분리 검증 | +| lib/analytics-display.js | 통계 천 단위 숫자 표시·접속 시간의 시간/일 단위 압축·접속 페이지 제목/공개 링크·최근순 고유 페이지 정리 | | components/admin/AdminRealtimePages.vue | 접속자 최근 페이지 요약·외 N개 색상/숫자 굵기·단일 페이지 링크·다중 페이지 전용 화면 안 팝오버 위치·열린 페이지 링크·hover/클릭/키보드 접근 | -| tests/analytics-display.test.js | 천 단위 표시·최근순 중복 제거·공개 경로 검증 | -| tests/playwright/e2e/admin-dashboard.spec.js | 대시보드 쉼표·섹션 순서·전체/표시 인원·전체 화면 수·다중 페이지 강조/팝오버·단일 페이지 링크 검증 | +| tests/analytics-display.test.js | 천 단위·긴 접속 시간 표시·최근순 중복 제거·공개 경로 검증 | +| tests/playwright/e2e/admin-dashboard.spec.js | 대시보드 쉼표·섹션 순서·전체/표시 인원·전체 화면 수·긴 접속 시간·다중 페이지 강조/팝오버·단일 페이지 링크·가상 시계로 짧은 중단/장기 중단 후 새 방문 heartbeat·페이지뷰 검증 | | tests/production-compose.test.js | 운영 마이그레이션·썸네일 백필 스크립트의 Compose 파일·환경 파일 전달 격리 검증 | | docker-compose.yaml | NAS Docker 앱용 독립 실행 구성·사전 빌드한 로컬 앱 이미지 사용·앱 원격 pull 금지 | | scripts/build-production-image.sh | 운영 환경 파일을 사용해 앱 이미지만 빌드·Docker 오류 반환·컨테이너/DB 미변경 | diff --git a/docs/spec.md b/docs/spec.md index 3ab604c..5087f36 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -600,9 +600,9 @@ components/content/ - 페이지뷰 수집은 탭 최초 referrer와 현재 URL을 서버로 보내며, 서버는 원문 referrer를 저장하지 않고 검색·SNS·직접·기타, 디바이스·OS, 검색 키워드로 축약해 `analytics_traffic_daily`에 집계한다. - 검색 유입은 네이버·다음·구글·줌·빙을 우선 분류하고, SNS 유입은 카카오톡·페이스북·인스타그램·트위터/X·유튜브를 우선 분류한다. 검색 키워드는 referrer query에 남아 있는 경우만 표시한다. - 클라이언트는 탭 단위 `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명으로 표시하되, 묶음 안의 활성 페이지는 목록으로 함께 표시한다. 접속자는 이름·최근 페이지 제목과 `외 N개`·접속 유지시간·IP를 한 줄로 표시한다. 활성 페이지 목록은 최근 `lastSeenAt` 순으로 정렬하고 동일 경로를 중복 제거하며 `N`도 고유 페이지 수 기준이다. 제목에 hover/포커스/클릭하면 열린 페이지 전체 목록과 새 창 링크를 제공한다. ArrowDown으로 목록에 진입하고 Escape·닫기·바깥 클릭으로 닫는다. 작은 화면의 IP는 페이지 목록 안에서도 확인할 수 있다. 상단 요약은 현재 접속자·오늘 방문자·누적 방문자·게시물 수를 표시한다. +- 게시물 `reads`는 클라이언트에서 90초 이상 실행 중단 구간을 제외한 접속 시간이 15초 이상이고 50% 이상 스크롤한 뒤 별도 전송한다. 콘텐츠 세션 원장이 있는 경우 같은 콘텐츠 세션의 `read_recorded`가 처음 켜질 때만 일별 `reads`를 증가시킨다. +- `POST /api/analytics/heartbeat`는 20초 간격으로 체류시간·스크롤·현재 경로를 전송하고 탭 복귀·pageshow 시에도 즉시 전송한다. 페이지별 시간은 heartbeat 실행 간격이 90초 미만인 구간을 누적하며 90초 이상 중단된 구간은 제외한다. 30분 미만 중단은 기존 방문을 유지하고, 30분 이상 중단되면 콘텐츠 세션 ID를 새로 발급해 페이지뷰를 한 번 기록하고 접속 시간·읽음 판정을 처음부터 시작한다. 숨겨진 탭도 실행이 이어지면 포함하고, 마우스·키보드 유휴 여부는 판정하지 않는다. 로그인 사용자는 서버 세션으로 `user_id`를 연결한다. 체류·스크롤 증분은 `analytics_content_sessions`의 콘텐츠 세션별 이전 값 기준으로 계산해 같은 탭에서 여러 게시물·페이지를 이동해도 스크롤 구간과 engaged 집계가 서로 섞이지 않게 한다. +- 관리자 대시보드는 `GET /admin/api/analytics/realtime`으로 현재 접속자 목록(닉네임·아바타·대표 게시물 제목·접속 유지시간·IP·열린 페이지 수·열린 페이지 제목 목록)을 조회한다. 현재 접속자는 로그인 사용자는 회원 기준, 익명 방문자는 IP 기준으로 묶어 같은 사람이 여러 페이지를 열어도 1명으로 표시하되, 묶음 안의 활성 페이지는 목록으로 함께 표시한다. 접속자는 이름·최근 페이지 제목과 `외 N개`·접속 유지시간·IP를 한 줄로 표시한다. 활성 페이지 목록은 최근 `lastSeenAt` 순으로 정렬하고 동일 경로를 중복 제거하며 `N`도 고유 페이지 수 기준이다. 여러 고유 페이지를 연 경우에만 제목에 hover/포커스/클릭하면 열린 페이지 전체 목록과 새 창 링크를 제공한다. ArrowDown으로 목록에 진입하고 Escape·닫기·바깥 클릭으로 닫는다. 작은 화면의 IP는 페이지 목록 안에서도 확인할 수 있다. 상단 요약은 현재 접속자·오늘 방문자·누적 방문자·게시물 수를 표시한다. - 관리자 대시보드 **통계 추이**는 `trends` 데이터를 3개 막대 차트(방문자수·평균 체류시간·50% 스크롤 도달)로 표시한다. 선택지는 일간·주간·월간·년간이며 기본값은 일간이다. 집계는 `ANALYTICS_COLLECTED_SINCE` 환경 변수(기본 `2026-06-01`) 이후 데이터만 합산하고 그 이전 구간은 0으로 표시한다. 평균 체류시간과 50% 스크롤 도달은 차트 버킷이 콘텐츠 세션 원장 보관 범위 안에 있고 원장 데이터가 있으면 원장 기준으로 보정하고, 없는 버킷은 기존 일별 누적 집계 값을 유지한다. **방문자수** 막대는 검색(빨강)·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% 스크롤 <= 조회` 범위로 정규화한다. 유입 프로그래스 바는 보조 지표로 보이도록 카드 폭의 절반만 사용한다. 검색엔진이 대부분 검색어를 전달하지 않는 현실을 반영해 대시보드의 유입 키워드 영역은 표시하지 않는다. - 관리자 대시보드 요약·선택한 기간 상세와 게시물·페이지별 통계 상세의 기간 요약에서 평균 체류·스크롤·읽음처럼 같은 콘텐츠 세션 중복 제거가 중요한 지표는 선택 기간이 `analytics_content_sessions` 보관 범위 안에 있고 원장 데이터가 있을 때 원장 기준으로 계산한다. 보관 범위 밖 기간이나 원장 데이터가 없는 기간은 기존 일별 누적 집계로 fallback한다. @@ -645,7 +645,7 @@ components/content/ - `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`(페이지일 때), `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개 쓰기 요청만 처리한다. +- `POST /api/analytics/heartbeat` - 실시간 세션·체류·스크롤 집계. 본문: `path`, `postSlug`, `pageSlug`, `clientSessionId`, `contentSessionId`(선택), `durationSeconds`(0~2,147,483,647 정수 초, 통계 집계에는 기존 1,800초 상한 적용), `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` 없이 가입 가능. 이메일은 저장 시 소문자로 정규화한다. @@ -1108,13 +1108,13 @@ APP_PORT=43080 - 응답 `{ items: [{ kind, id, postId, postTitle, postSlug, postStatus, publishedAt, createdAt, username, body, publicUrl }], pagination: { page, pageSize, total, pageCount } }`. `kind`는 `post`/`comment`. 게시글 body는 최대 320자 일반 텍스트 요약, 댓글은 원문 텍스트. 공개 접근 가능한 게시글은 `publicUrl` 제공(댓글은 `#comments`), 초안·비공개·예약 글은 null이며 관리자 편집으로 이동. - 기존 `/admin/api/comments`, `/admin/api/members/:id/posts` 응답 계약은 유지하며 멤버 작성한 글 화면은 통합 `/activity`를 사용한다. -## 대시보드 표시 정리 (v1.12.29) +## 대시보드 표시 정리 (v1.12.30) - 화면 순서: 상단 요약 → 예상 수입 → 통계 추이 → 선택한 기간 상세 → 최근 댓글 → 현재 접속자. - 방문자·게시물 수·조회·스크롤·유입·디바이스·차트 수치·댓글/작성 이력 건수는 `ko-KR` 천 단위 구분(예: `10,413`)으로 표시. API 숫자 형식은 유지. - 접속자 대표 페이지는 포커스 여부가 아닌 마지막 확인 시각 기준. 현재 API의 회원별·익명 IP별 묶음은 유지. 인원 표시는 `전체 N명 중 M명 표시 · 열린 화면 K개` 형식으로 전체·표시 인원과 전체 활성 탭 세션 수를 구분. 목록은 최대 20명이며 다음 페이지는 없음. 원본 세션을 최대 160행 조회 후 묶으므로 한 사람이 많은 탭을 열면 표시 인원은 20명보다 적을 수 있음. - 고유 페이지가 여러 개면 `외 N개`를 파란색으로, 숫자는 굵게 표시. 고유 페이지가 하나면 공개 화면 링크만 제공하고 팝오버를 표시하지 않음. 열린 페이지 팝오버는 hover 시 일시 표시, 클릭/Enter로 고정 전환. 포인터를 목록으로 이동해도 링크 접근을 유지하며, 화면 범위에 맞춰 위/아래 배치하고 긴 목록은 내부 스크롤 제공. -- 접속자 시간은 열린 페이지 중 가장 긴 경과 시간이며, 페이지 진입 시 초기화하고 백그라운드 시간 포함·최대 1,800초(30분)로 기록. 실제 읽은 시간이나 만료까지 남은 시간이 아니며 마우스 안내로 의미 표시. 현재 접속 판정은 이 시간과 별도로 마지막 접속 신호 90초 기준. +- 접속자 목록은 열린 페이지 중 가장 긴 개별 접속 시간 하나만 표시. 페이지 이동·새로고침 시 측정 초기화. 30분 이후도 누적하며 1시간 이상은 시간·분, 하루 이상은 일·시간으로 압축. heartbeat 실행이 90초 이상 중단된 구간은 시간에서 제외. 30분 미만이면 기존 방문 유지, 30분 이상이면 새 방문으로 0초부터 시작. 90초 미만의 짧은 슬립과 브라우저 지연은 구분할 수 없어 포함될 수 있음. 신호가 이어지는 백그라운드 시간은 포함. 현재 접속 판정은 별도로 마지막 접속 신호 90초 기준. ## 운영 Compose 식별 (v1.12.27) @@ -1128,3 +1128,10 @@ APP_PORT=43080 - `docker-compose.yaml`: GUI에 독립 구성으로 적용. 앱은 기본 빌드 이미지와 동일한 `sori-studio:latest`, `pull_policy: never`, `build` 없음. 프로젝트명·네트워크·외부 DB 볼륨·포트·업로드/마이그레이션 경로는 기본 Compose와 동일. - GUI가 pull 정책을 덮어쓰는 실제 동작은 NAS에서 확인해야 하며 이미지 다운로드/업데이트 옵션이 있으면 앱 이미지에 대해 해제. `docker-compose.build.yml`은 CLI 빌드·배포용으로 유지. - NAS에 기존 비추적 `docker-compose.yaml`이 있으면 첫 git pull 전에 이름을 바꿔 백업한다. 두 구성을 모두 저장소에서 관리하며 실행용 기본 파일과 빌드용 파일의 이름을 구분한다. + +## 접속 시간 측정·집계 구분 (v1.12.30) + +- `POST /api/analytics/heartbeat`의 `durationSeconds`는 0~2,147,483,647 정수 초로 확장. 기존 필드·응답은 유지하며 범위 밖은 400. 활성 세션의 기존 INTEGER `duration_seconds`에 실시간 누적값을 저장하므로 DB 마이그레이션 없음. +- 실시간 API의 세션·페이지 `durationSeconds`는 위 누적값을 제공. 현재 접속자는 해당 회원/익명 IP 묶음의 페이지별 최댓값 하나를 표시하며 실제 읽은 시간은 아님. +- 평균 체류는 선택한 기간의 여러 콘텐츠 방문 시간을 집계한 통계. 콘텐츠 세션 원장·일별 집계에는 기존 1,800초 상한을 유지해 오래 열린 한 탭의 영향을 제한. 클라이언트의 중단 구간 제외 방식은 두 용도에 공통 적용하되 과거 통계는 재계산하지 않음. 배포 후 새로 열거나 새로고친 페이지부터 새 측정 적용, 기존 클라이언트의 30분 이하 전송도 계속 허용. +- 브라우저는 운영체제 슬립 여부를 직접 제공하지 않으므로 마지막 클라이언트 측정 이후 90초 이상 지난 간격 전체를 제외하며, 중단이 30분 이상이면 이전 누적을 초기화하고 새 방문으로 처리하는 기준을 사용. 실제 기기 슬립·타이머 제한·프로세스 중단을 구분하지 않음. 네트워크만 끊기고 클라이언트 실행은 이어지는 경우 시간 측정은 계속됨. diff --git a/docs/todo.md b/docs/todo.md index 7c48a1d..68ed516 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -4,7 +4,7 @@ ## 2차 관리자 개발 -- `npm run test:api`의 기존 미디어 카탈로그 회귀 실패 조사: `core-api.spec.js`의 프로필 이미지 `usage`에 `type=user`가 없어 `typeLabel` 확인 실패. 댓글·회원 통합 작성 이력 검색/페이지·실시간 활성 탭 집계 API 검증은 통과. v1.12.29 API 검증은 12건 통과·기존 실패 1건·후속 미실행 1건. 격리 DB에서 사용처 계산·카탈로그 캐시 반영 순서 확인. +- `npm run test:api`의 기존 미디어 카탈로그 회귀 실패 조사: `core-api.spec.js`의 프로필 이미지 `usage`에 `type=user`가 없어 `typeLabel` 확인 실패. 댓글·회원 통합 작성 이력 검색/페이지·실시간 활성 탭 집계 API 검증은 통과. 긴 접속 시간과 평균 체류 상한 분리 API 검증도 통과. v1.12.30 API 검증은 13건 통과·기존 실패 1건·후속 미실행 1건. 격리 DB에서 사용처 계산·카탈로그 캐시 반영 순서 확인. ## 프론트엔드 개발 diff --git a/docs/update.md b/docs/update.md index 6b94845..af55713 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,15 @@ # 업데이트 이력 +## v1.12.30 + +- 현재 접속자 시간의 30분 표시 제한 해제 및 시간·일 단위 압축 표시 추가. +- heartbeat 실행이 90초 이상 중단된 구간 제외 및 30분 이상 중단 후 새 방문·0초 시작 추가. +- 탭 복귀·페이지 복원 시 접속 신호 즉시 전송 추가 및 기존 20초 주기 유지. +- 읽음 조건도 중단 구간을 제외한 같은 시간 기준으로 통일하고 새 방문 시 초기화. +- 실시간 시간과 평균 체류 집계를 분리하고 통계의 기존 콘텐츠별 30분 상한 유지. +- JavaScript 360개 파일 문법·단위 테스트 289건·프로덕션 빌드 검증 진행. +- Chromium E2E 13건·API 13건 통과, 기존 미디어 사용처 실패 1건·후속 미실행 1건 유지. + ## v1.12.29 - 현재 접속자 외 N개 파란색·숫자 굵기 강조 및 여러 페이지일 때만 목록 팝오버 표시 수정. diff --git a/lib/analytics-display.js b/lib/analytics-display.js index a6097e7..813f9ac 100644 --- a/lib/analytics-display.js +++ b/lib/analytics-display.js @@ -8,6 +8,27 @@ export const formatAnalyticsCount = (value) => { return new Intl.NumberFormat('ko-KR').format(Number.isFinite(number) ? number : 0) } +/** + * 현재 접속 시간을 최대 두 단위로 짧게 표시한다. + * @param {number} seconds - 접속 시간(초) + * @returns {string} 일·시간·분·초 표시 + */ +export const formatRealtimeDuration = (seconds) => { + const input = Number(seconds) + const value = Number.isFinite(input) ? Math.max(0, Math.floor(input)) : 0 + if (value >= 86400) { + const hours = Math.floor(value % 86400 / 3600) + return `${formatAnalyticsCount(Math.floor(value / 86400))}일${hours ? ` ${hours}시간` : ''}` + } + if (value >= 3600) { + const minutes = Math.floor(value % 3600 / 60) + return `${formatAnalyticsCount(Math.floor(value / 3600))}시간${minutes ? ` ${minutes}분` : ''}` + } + const minutes = Math.floor(value / 60) + const remainder = value % 60 + return minutes ? `${minutes}분${remainder ? ` ${remainder}초` : ''}` : `${remainder}초` +} + /** * 접속 페이지의 표시 제목을 반환한다. * @param {Object} page - 활성 페이지 diff --git a/lib/analytics-duration.js b/lib/analytics-duration.js new file mode 100644 index 0000000..194d011 --- /dev/null +++ b/lib/analytics-duration.js @@ -0,0 +1,30 @@ +import { ANALYTICS_ACTIVE_SESSION_TTL_SECONDS, ANALYTICS_MAX_REALTIME_DURATION_SECONDS, ANALYTICS_NEW_VISIT_IDLE_SECONDS } from './analytics-shared.js' + +/** + * heartbeat 실행이 끊긴 긴 구간을 제외하는 페이지별 시간 측정기를 만든다. + * @param {number} [startedAt] - 측정 시작 시각(ms) + * @returns {(now?: number) => { durationSeconds: number, restarted: boolean }} 접속 시간과 새 방문 여부를 반환하는 측정 함수 + */ +export const createAnalyticsDurationTracker = (startedAt = Date.now()) => { + let lastSampleAt = startedAt + let durationMs = 0 + + /** + * 직전 측정 이후 실행이 이어진 구간만 누적한다. + * @param {number} [now] - 현재 시각(ms) + * @returns {{ durationSeconds: number, restarted: boolean }} 중단 구간을 제외한 시간과 새 방문 여부 + */ + const sample = (now = Date.now()) => { + if (!Number.isFinite(now)) return { durationSeconds: Math.floor(durationMs / 1000), restarted: false } + const gapMs = now - lastSampleAt + lastSampleAt = now + const restarted = gapMs >= ANALYTICS_NEW_VISIT_IDLE_SECONDS * 1000 + if (restarted) durationMs = 0 + else if (gapMs > 0 && gapMs < ANALYTICS_ACTIVE_SESSION_TTL_SECONDS * 1000) { + durationMs = Math.min(durationMs + gapMs, ANALYTICS_MAX_REALTIME_DURATION_SECONDS * 1000) + } + return { durationSeconds: Math.floor(durationMs / 1000), restarted } + } + + return sample +} diff --git a/lib/analytics-shared.js b/lib/analytics-shared.js index 84aade1..4840203 100644 --- a/lib/analytics-shared.js +++ b/lib/analytics-shared.js @@ -72,15 +72,21 @@ export const normalizePostSlugForAnalytics = (slug) => (slug || '').trim() */ export const normalizePageSlugForAnalytics = (slug) => (slug || '').trim() -/** @type {number} heartbeat 체류시간 상한(초) */ +/** @type {number} 평균 체류 통계에 반영할 콘텐츠별 시간 상한(초) */ export const ANALYTICS_MAX_DURATION_SECONDS = 1800 +/** @type {number} 실시간 접속 시간의 PostgreSQL 정수 저장 상한(초) */ +export const ANALYTICS_MAX_REALTIME_DURATION_SECONDS = 2147483647 + /** @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_NEW_VISIT_IDLE_SECONDS = 1800 + /** @type {number} 관리자 차트 최대 조회 기간(일) */ export const ANALYTICS_CHART_MAX_DAYS = 365 @@ -122,6 +128,17 @@ export const clampAnalyticsDurationSeconds = (seconds) => { return Math.min(Math.floor(value), ANALYTICS_MAX_DURATION_SECONDS) } +/** + * 실시간 접속 시간을 데이터베이스 정수 범위로 보정한다. + * @param {number} seconds - 접속 시간 + * @returns {number} 보정된 초 + */ +export const clampAnalyticsRealtimeDurationSeconds = (seconds) => { + const value = Number(seconds) + if (!Number.isFinite(value) || value < 0) return 0 + return Math.min(Math.floor(value), ANALYTICS_MAX_REALTIME_DURATION_SECONDS) +} + /** * 스크롤 비율을 0~1로 보정한다. * @param {number} ratio - 스크롤 비율 diff --git a/lib/analytics.js b/lib/analytics.js index 1fd7f70..a257d49 100644 --- a/lib/analytics.js +++ b/lib/analytics.js @@ -6,11 +6,13 @@ export { ANALYTICS_COLLECTED_SINCE_DEFAULT, ANALYTICS_ENGAGED_MIN_SECONDS, ANALYTICS_MAX_DURATION_SECONDS, + ANALYTICS_MAX_REALTIME_DURATION_SECONDS, ANALYTICS_RETENTION_PURGE_INTERVAL_MS, ANALYTICS_SCROLL_THRESHOLDS, ANALYTICS_VISITOR_HASH_RETENTION_DAYS, normalizeAnalyticsCollectedSinceDay, clampAnalyticsDurationSeconds, + clampAnalyticsRealtimeDurationSeconds, clampAnalyticsScrollRatio, getAnalyticsDayBefore, getAnalyticsDayKey, diff --git a/package-lock.json b/package-lock.json index bfccc00..5fe6528 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.12.29", + "version": "1.12.30", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.12.29", + "version": "1.12.30", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index bc0b6cc..670a0e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.12.29", + "version": "1.12.30", "private": true, "type": "module", "imports": { diff --git a/pages/admin/index.vue b/pages/admin/index.vue index b9e54f6..db7feb8 100644 --- a/pages/admin/index.vue +++ b/pages/admin/index.vue @@ -1,6 +1,6 @@