인기 페이지 통계와 추천 사이트 메타데이터 추가 v1.5.9

This commit is contained in:
2026-05-27 10:34:07 +09:00
parent d7a3149ea1
commit fd9416c0e4
22 changed files with 596 additions and 94 deletions

View File

@@ -30,7 +30,7 @@ const { data: navigation } = await useFetch('/api/navigation', {
/**
* 공개 추천 사이트 목록(비가시 제외)
* @returns {Array<{ id: string, label: string, url: string }>}
* @returns {Array<{ id: string, label: string, url: string, descriptionText?: string, thumbnailUrl?: string }>}
*/
const recommendedSites = computed(() => {
const list = navigation.value?.recommended
@@ -47,6 +47,25 @@ const recommendedSites = computed(() => {
*/
const isExternalNavUrl = (url) => /^https?:\/\//i.test(String(url || '').trim())
/**
* 추천 사이트 보조 문구를 반환한다.
* @param {Object} item - 추천 사이트 항목
* @returns {string} 표시 문구
*/
const getRecommendedDisplayText = (item) => {
return String(item?.descriptionText || '').trim() || String(item?.url || '').trim()
}
/**
* 추천 사이트 이미지 URL을 반환한다.
* @param {Object} item - 추천 사이트 항목
* @returns {string} 이미지 URL
*/
const getRecommendedImageUrl = (item) => {
const thumbnailUrl = String(item?.thumbnailUrl || '').trim()
return thumbnailUrl || getExternalFaviconUrl(item?.url, 64)
}
/** 소개 영역 공개 여부 */
const showAboutSection = false
</script>
@@ -201,12 +220,12 @@ const showAboutSection = false
>
<span class="right-sidebar__recommended-icon grid h-9 w-9 shrink-0 place-items-center overflow-hidden rounded-lg bg-[var(--site-paper)] text-xs font-bold text-[var(--site-text)] ring-1 ring-[var(--site-line)]">
<img
v-if="getExternalFaviconUrl(item.url)"
v-if="getRecommendedImageUrl(item)"
class="h-full w-full object-cover"
:src="getExternalFaviconUrl(item.url, 64)"
:src="getRecommendedImageUrl(item)"
width="36"
height="36"
alt=""
:alt="item.thumbnailUrl ? item.label : ''"
loading="lazy"
referrerpolicy="no-referrer"
>
@@ -214,7 +233,7 @@ const showAboutSection = false
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold text-[var(--site-text)]">{{ item.label }}</span>
<span class="mt-0.5 block truncate font-mono text-[11px] site-muted">{{ item.url }}</span>
<span class="mt-0.5 block truncate text-[11px] site-muted" :class="item.descriptionText ? '' : 'font-mono'">{{ getRecommendedDisplayText(item) }}</span>
</span>
<span class="shrink-0 text-xs site-muted" aria-hidden="true"></span>
</a>

View File

@@ -0,0 +1,37 @@
CREATE TABLE IF NOT EXISTS page_analytics_daily (
day DATE NOT NULL,
page_id UUID NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
views INTEGER NOT NULL DEFAULT 0,
visitors INTEGER NOT NULL DEFAULT 0,
engaged_views INTEGER NOT NULL DEFAULT 0,
total_engaged_seconds INTEGER NOT NULL DEFAULT 0,
scroll_25 INTEGER NOT NULL DEFAULT 0,
scroll_50 INTEGER NOT NULL DEFAULT 0,
scroll_75 INTEGER NOT NULL DEFAULT 0,
scroll_100 INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (day, page_id)
);
CREATE INDEX IF NOT EXISTS page_analytics_daily_day_idx
ON page_analytics_daily (day DESC);
ALTER TABLE analytics_daily_visitors
ADD COLUMN IF NOT EXISTS page_id UUID REFERENCES pages(id) ON DELETE CASCADE;
ALTER TABLE analytics_daily_visitors
DROP CONSTRAINT IF EXISTS analytics_daily_visitors_scope_check;
ALTER TABLE analytics_daily_visitors
ADD CONSTRAINT analytics_daily_visitors_scope_check CHECK (scope IN ('site', 'post', 'page'));
CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_visitors_page_uidx
ON analytics_daily_visitors (day, page_id, visitor_hash)
WHERE scope = 'page';
ALTER TABLE analytics_active_sessions
ADD COLUMN IF NOT EXISTS page_id UUID REFERENCES pages(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS page_slug TEXT NOT NULL DEFAULT '';
ALTER TABLE navigation_items
ADD COLUMN IF NOT EXISTS description_text TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS thumbnail_url TEXT NOT NULL DEFAULT '';

View File

@@ -1,5 +1,11 @@
# 업데이트 요약
## v1.5.9
- 관리자 대시보드에서 인기 페이지 통계를 볼 수 있게 했다.
- HTML 문서 모드 페이지도 서버에서 조회수를 기록하도록 보강했다.
- 추천 사이트에 대체 텍스트와 썸네일 URL을 추가하고, 공개 사이드바 표시에도 반영했다.
## v1.5.8
- 소유자가 본인 권한을 직접 낮춰 소유자가 사라지는 상황을 막았다.

View File

@@ -1,5 +1,9 @@
# 의사결정 이력
## 2026-05-27 v1.5.9 — 페이지 통계와 추천 사이트 메타데이터 확장
고정 페이지는 HTML 랜딩 페이지처럼 단독 URL로 쓰이기 때문에 게시물처럼 조회 추이를 볼 수 있어야 한다. 일반 Nuxt 페이지는 기존 클라이언트 통계 플러그인으로 pageSlug를 함께 보내고, 원문 HTML 문서 모드는 Nuxt 앱이 실행되지 않으므로 서버 미들웨어에서 GET 조회를 직접 기록한다. 추천 사이트는 URL 자체보다 운영자가 지정한 짧은 문구와 썸네일이 더 명확한 경우가 있으므로, 기존 파비콘 fallback은 유지하되 대체 텍스트와 썸네일 URL을 선택적으로 저장하게 했다.
## 2026-05-27 v1.5.8 — 소유자 권한 보호와 멤버 목록 등급 표시
소유자는 시스템을 복구할 수 있는 최상위 권한이므로 본인 계정을 관리자 이하로 직접 낮출 수 없게 한다. 기존의 마지막 소유자 보호는 동시에 여러 사용자가 권한을 바꾸는 상황을 막기 위한 장치로 유지하고, 이미 소유자가 0명이 된 개발 DB는 마이그레이션으로 가장 오래된 관리자 계정을 소유자로 되돌릴 수 있게 했다. 멤버 목록은 운영자가 등급을 빠르게 스캔해야 하므로 별도 열을 추가하지 않고 기존 상태 열에 등급을 먼저 보여주며, 활성 상태는 기본값이라 숨기고 비활성만 보조 상태로 표시한다.

View File

@@ -65,7 +65,7 @@
| components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 |
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+``sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), Authors 영역은 비공개, 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0`, 태그 카테고리·테마 점은 `site-sidebar-nav-row` 호버 |
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`+`site-sidebar-nav-row` 호버(`#F7F4EF` 라이트), `inject`·`localStorage` 펼침 |
| components/site/RightSidebar.vue | 오른쪽 사이드바, 공개 `GET /api/navigation``recommended` 카드 목록(외부 URL은 Google 파비콘 프록시 썸네일), Follow·구독 폼, About 영역은 비공개, `lg+` 스티키 |
| components/site/RightSidebar.vue | 오른쪽 사이드바, 공개 `GET /api/navigation``recommended` 카드 목록(대체 텍스트·썸네일 우선, 외부 URL은 Google 파비콘 fallback), Follow·구독 폼, About 영역은 비공개, `lg+` 스티키 |
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
| components/site/HomeHero.vue | 홈 상단 720px 커버 배너, 라이트·다크 테마별 이미지 교체, 왼쪽 하단 오버레이 제목·본문 |
| components/site/PostCard.vue | 목록의 게시물 카드, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |
@@ -123,7 +123,7 @@
| 파일 | 화면 |
|------|------|
| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 기간 선택형 방문자수·평균 체류·50% 스크롤 차트, 접속자 목록, 인기 게시물 참여 지표) |
| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 기간 선택형 방문자수·평균 체류·50% 스크롤 차트, 접속자 목록, 인기 게시물·인기 페이지 참여 지표) |
| pages/admin/login.vue | 관리자 로그인, 일반 로그인과 같은 다크 인증 스타일·우측 배치 및 내부 오른쪽 정렬 폼, 이메일·비밀번호 모두 입력 시에만 제출 버튼 활성 |
| pages/admin/posts/index.vue | 글 목록, 헤더 우측에 필터(상태·태그·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행/멤버십/비공개 텍스트 상태, 제목 옆 댓글 수, 첫 번째 태그 색상 대표 배지, 화면 기준 행 more vert 메뉴(추천·삭제) |
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·이탈 직전 플러시, 저장 토스트 |
@@ -133,7 +133,7 @@
| pages/admin/pages/new.vue | 전체 화면 페이지 작성, HTML 문서 기본 모드 저장, 저장 토스트 |
| pages/admin/pages/[id].vue | 전체 화면 페이지 수정, HTML 문서/일반 텍스트 모드 저장, 설정 패널 삭제, 저장/삭제 토스트 |
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/프로필 이미지** 탭, 이미지·비디오·오디오·파일 종류 필터, 미사용 필터, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 |
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단/추천 탭, 행 more vert(메뉴 항목 삭제), 드래그 정렬, `useAdminToast` |
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단/추천 탭, 추천 사이트 대체 텍스트·썸네일 URL, 행 more vert(메뉴 항목 삭제), 드래그 정렬, `useAdminToast` |
| components/admin/AdminRowMoreMenu.vue | 관리자 행 more vert 메뉴(트리거·화면 기준 팝오버) |
| composables/useAdminRowMenu.js | 관리자 행 메뉴 열림 상태·바깥 클릭 닫기 |
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·more vert 메뉴, 일반 태그 배지 more vert 메뉴·검색/정렬, 태그 추가 버튼), 액션 피드백 토스트 |
@@ -259,6 +259,7 @@
| server/utils/analytics-heartbeat-input.js | heartbeat 검증·기록 |
| server/routes/admin/api/analytics/summary.get.js | 관리자 통계 요약 API |
| server/routes/admin/api/analytics/posts.get.js | 관리자 인기 게시물 API |
| server/routes/admin/api/analytics/pages.get.js | 관리자 인기 페이지 API |
| server/routes/admin/api/analytics/realtime.get.js | 관리자 실시간 접속자 API |
| plugins/site-analytics.client.js | 공개 라우트 pageview·heartbeat·read 클라이언트 전송 |
@@ -294,6 +295,7 @@
| db/migrations/036_content_visibility_statuses.sql | 게시물 상태에 `members`/`private`, 페이지 상태에 `published`/`draft`/`private` 제약 추가 |
| db/migrations/037_add_vip_member_role.sql | 회원 권한 단계에 `vip` 허용 |
| db/migrations/038_restore_owner_when_missing.sql | 소유자가 없는 경우 기존 관리자 중 가장 오래된 계정을 소유자로 복구 |
| db/migrations/039_page_analytics_and_navigation_recommended_metadata.sql | 페이지 통계 테이블·추천 사이트 대체 텍스트/썸네일 컬럼 추가 |
## 설정/배포

View File

@@ -396,8 +396,9 @@ components/content/
|--------|------|------|
| site_analytics_daily | day, page_views, visitors, engaged_views, total_engaged_seconds | 사이트 일별 페이지뷰·방문자·체류 집계 |
| post_analytics_daily | day, post_id, views, reads, visitors, engaged_views, total_engaged_seconds, scroll_25~100 | 게시물 일별 조회·읽음·스크롤 구간 |
| analytics_daily_visitors | day, scope(`site`/`post`), post_id?, visitor_hash | 일별 방문자 해시 등록(중복 방문 제거용) |
| analytics_active_sessions | session_hash, user_id?, path, post_id?, post_slug, duration_seconds, max_scroll_ratio, last_seen_at | 실시간 접속 세션(TTL 90초) |
| page_analytics_daily | day, page_id, views, visitors, engaged_views, total_engaged_seconds, scroll_25~100 | 페이지 일별 조회·방문자·스크롤 구간 |
| analytics_daily_visitors | day, scope(`site`/`post`/`page`), post_id?, page_id?, visitor_hash | 일별 방문자 해시 등록(중복 방문 제거용) |
| analytics_active_sessions | session_hash, user_id?, path, post_id?, post_slug, page_id?, page_slug, duration_seconds, max_scroll_ratio, last_seen_at | 실시간 접속 세션(TTL 90초) |
- 추적 대상: 공개 경로만. `/admin`, `/signin`, `/signup`, `/forgot-password`, `/settings`는 제외.
- 봇 User-Agent는 서버에서 무시.
@@ -438,8 +439,8 @@ components/content/
- `GET /api/tags` - 태그 목록
- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만)
- `GET /api/site-settings` - 공개 사이트 설정(어나운스 바·홈 커버 등 포함)
- `POST /api/analytics/pageview` - 공개 방문·게시물 조회/읽음 집계. 본문: `path`(필수), `postSlug`(게시물일 때), `read`(읽음 이벤트). 발행된 게시물`postSlug` 집계. 응답 `{ ok: true }`.
- `POST /api/analytics/heartbeat` - 실시간 세션·체류·스크롤 집계. 본문: `path`, `postSlug`, `clientSessionId`, `durationSeconds`(최대 1800), `maxScrollRatio`(0~1). 로그인 시 서버가 회원 세션으로 사용자 연결.
- `POST /api/analytics/pageview` - 공개 방문·게시물·페이지 조회/읽음 집계. 본문: `path`(필수), `postSlug`(게시물일 때), `pageSlug`(페이지일 때), `read`(읽음 이벤트). 발행된 게시물과 공개 페이지만 개별 집계한다. 응답 `{ ok: true }`. HTML 문서 모드 페이지는 Nuxt 클라이언트 플러그인을 거치지 않으므로 서버 미들웨어가 GET 요청 시 페이지 조회를 직접 기록한다.
- `POST /api/analytics/heartbeat` - 실시간 세션·체류·스크롤 집계. 본문: `path`, `postSlug`, `pageSlug`, `clientSessionId`, `durationSeconds`(최대 1800), `maxScrollRatio`(0~1). 로그인 시 서버가 회원 세션으로 사용자 연결.
- `GET /api/navigation` - 공개 네비게이션(`primary`는 트리·`footer`·`recommended`는 평면, 상세는 위 메뉴/네비게이션 절)
- `POST /api/auth/signup` - 회원 가입. 본문: `username`, `email`, `password`, 선택 `emailOtp`(6자리 숫자). **`GET /api/auth/bootstrap-status``emailOtpConfigured`가 true이고 `needsAdminSetup`이 false일 때**(서버에 Resend·pepper 설정됨) `emailOtp`가 필수이며, 직전에 `POST /api/auth/email-otp/request`로 같은 이메일·`purpose: "signup"` 발송분과 일치해야 한다. 최초 관리자 부트스트랩(`needsAdminSetup: true`)에서는 `emailOtp` 없이 가입 가능. 이메일은 저장 시 소문자로 정규화한다.
- `GET /api/auth/bootstrap-status` - 최초 관리자 등록 필요 여부(`hasUsers`, `needsAdminSetup`) 및 **이메일 OTP 사용 가능 여부** `emailOtpConfigured`(서버 `RESEND_API_KEY`·`RESEND_FROM_EMAIL` 및 pepper: 비어 있지 않은 `EMAIL_OTP_PEPPER` **또는** `MEMBER_SESSION_SECRET`)를 반환한다. pepper는 OTP 6자리를 DB에 해시해 둘 때만 쓰이는 서버 비밀(긴 난문자열 권장)이다.
@@ -473,6 +474,7 @@ components/content/
- `GET /admin/api/auth/me` - 현재 관리자 세션 조회
- `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/pages?days=30&limit=5` - 기간 내 인기 페이지(조회·방문자·평균 체류·스크롤 구간)
- `GET /admin/api/analytics/realtime?limit=20` - 현재 접속자 요약·목록(로그인 사용자 닉네임·아바타 포함)
- `GET /admin/api/posts` - 글 목록
- `POST /admin/api/posts` - 글 작성
@@ -670,10 +672,10 @@ components/content/
- 네비게이션은 `navigation_items` 테이블로 관리한다.
- 컬럼: `parent_id`(nullable, self FK, `ON DELETE CASCADE`), `is_folder`(boolean, **자식이 있는 상단 항목이면 저장 시 서버가 true로 설정**), `location`(`primary`|`footer`|`recommended`), `sort_order`, `label`, `url`, `is_visible`(저장 시 항상 true로 정리). `(location, label, url)` 유니크 제약은 제거되었다.
- 동일 `location`·`parent_id`·`label`·`url` 조합은 중복 저장하지 않는다. 반복 마이그레이션으로 생긴 중복은 `019_dedupe_navigation_items.sql`이 정리하고, 표현식 유니크 인덱스로 재발을 막는다.
- `GET /api/navigation` 응답: `primary`**트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. 트리 조립 시 **동일 `id`는 한 번만 반영**하고, DB 평면 행에 `parent_id`가 있어 자식으로 붙은 항목은 **루트에 두지 않는다**. **상단은 루트 직속 자식만** 트리에 붙이며(부모의 부모가 있는 행은 자식으로 무시), `footer`·`recommended`**평면** 배열(`parent_id` 없음)이 `id`, `label`, `url`, `isVisible` 내려다.
- `GET /api/navigation` 응답: `primary`**트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. 트리 조립 시 **동일 `id`는 한 번만 반영**하고, DB 평면 행에 `parent_id`가 있어 자식으로 붙은 항목은 **루트에 두지 않는다**. **상단은 루트 직속 자식만** 트리에 붙이며(부모의 부모가 있는 행은 자식으로 무시), `footer`·`recommended`**평면** 배열(`parent_id` 없음)이다. `recommended` `id`, `label`, `url`, `descriptionText`, `thumbnailUrl`, `isVisible` 내려다.
- `PUT /admin/api/navigation` 요청 본문의 각 항목은 반드시 `id`(UUID)를 포함한다. 저장 시 `sort_order``primary`·`footer`·`recommended` 각각 위치별 트리(또는 평면 루트) DFS 순으로 다시 부여한다. `parent_id``primary`에서만 허용되며 **상단은 한 단계(루트→자식)만** 허용한다. `footer`·`recommended` 항목은 항상 루트다. `is_visible`·`is_folder`는 요청값과 무관하게 서버에서 **항상 표시·자식 유무 기준 폴더**로 덮어쓴다.
- URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#`를 허용한다.
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션**·**추천 사이트** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·개요 열에서 시작한다. 상단은 `buildNavigationEditorTree` 결과를 `flattenNavigationEditorWrappers`로 한 테이블에 평면 표시하고, **같은 행의 위 1/3·가운데·아래 1/3**에 드롭하면 각각 대상 **앞**(동일 부모)·**하위**(대상의 자식, **루트 행에만**)·**뒤**(동일 부모)로 이동한다. **상단은 루트→자식 한 단계만** 허용하며(하위 행에는 가운데 드롭이 형제 앞·뒤로 대체됨, 이미 하위가 있는 항목은 다른 항목의 하위로 넣을 수 없음), 드래그 중 하이라이트는 **형제 앞·뒤**를 파란 가로 끝선·연한 파란 배경, **하위 편입**을 앰버 링·연한 앰버 배경으로 구분하고, 개요 열에 `앞에 끼움` / `뒤에 끼움` / `하위로 넣기` 짧은 문구를 덧붙인다. 개요 열 표기는 `1`, `2.1`, `2.2`, `3`처럼 깊이별 계층 번호로 보이며, 라벨 열은 깊이만큼 `padding-left`로 들여쓴다. 자기 자신·자기 하위로의 편입은 거부한다. 하단·추천 사이트는 평면 목록만 드래그 정렬한다. 추천 사이트는 공개 홈 **오른쪽 사이드바** Recommended 영역에 카드로 노출되며, `https://` URL 클라이언트에서 호스트를 뽑아 Google Favicon 프록시(`https://www.google.com/s2/favicons?domain=…`) 이미지로 표시할 수 있다(내부 경로만 있으면 아이콘 생략).
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션**·**추천 사이트** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·개요 열에서 시작한다. 상단은 `buildNavigationEditorTree` 결과를 `flattenNavigationEditorWrappers`로 한 테이블에 평면 표시하고, **같은 행의 위 1/3·가운데·아래 1/3**에 드롭하면 각각 대상 **앞**(동일 부모)·**하위**(대상의 자식, **루트 행에만**)·**뒤**(동일 부모)로 이동한다. **상단은 루트→자식 한 단계만** 허용하며(하위 행에는 가운데 드롭이 형제 앞·뒤로 대체됨, 이미 하위가 있는 항목은 다른 항목의 하위로 넣을 수 없음), 드래그 중 하이라이트는 **형제 앞·뒤**를 파란 가로 끝선·연한 파란 배경, **하위 편입**을 앰버 링·연한 앰버 배경으로 구분하고, 개요 열에 `앞에 끼움` / `뒤에 끼움` / `하위로 넣기` 짧은 문구를 덧붙인다. 개요 열 표기는 `1`, `2.1`, `2.2`, `3`처럼 깊이별 계층 번호로 보이며, 라벨 열은 깊이만큼 `padding-left`로 들여쓴다. 자기 자신·자기 하위로의 편입은 거부한다. 하단·추천 사이트는 평면 목록만 드래그 정렬한다. 추천 사이트는 공개 홈 **오른쪽 사이드바** Recommended 영역에 카드로 노출되며, 대체 텍스트가 있으면 URL 대신 대체 텍스트를 표시하고 썸네일 URL이 있으면 Google Favicon 프록시 대신 썸네일을 표시한다. 썸네일이 없고 `https://` URL이면 클라이언트에서 호스트를 뽑아 Google Favicon 프록시(`https://www.google.com/s2/favicons?domain=…`) 이미지로 표시할 수 있다(내부 경로만 있으면 아이콘 생략).
- `parent_id` / `is_folder` 컬럼이 DB에 없을 때 저장은 실패한다. `npm run db:migrate:dev``017_navigation_hierarchy.sql`을 적용해야 한다(저장 API는 해당 경우 한국어 안내 메시지를 반환할 수 있다).
- 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링한다. **하위가 있는 노드**는 한 줄 `button`으로, **행 전체(이름·왼쪽 세로 데코·chevron) 클릭**으로 접기/펼친다. 부모·리프 모두 왼쪽 장식은 **기본 세로 막대(`--site-line`)**, **호버 시에만** 리프와 동일하게 **작은 원형**으로 전환한다. **내부 경로**(`/`로 시작, `//`·`http(s)` 제외)이고 현재 `route.path`와 정규화한 경로가 같으면 장식 색을 **`--site-accent`(브랜드 오렌지)**로 둔다. 외부 URL은 비교하지 않는다. 펼침 상태는 `localStorage``sori-primary-nav-expanded`에 저장된다. 상단·리프 링크·부모 버튼 행은 **`w-full`**로 `site-sidebar-nav-row` 호버 배경이 가로 전체를 쓴다(라이트 `#F7F4EF`, 다크는 `site-panel-hover`와 동일한 `color-mix` 패턴).
- 관리자 메뉴 관리 화면에서 저장 API로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.

View File

@@ -1,5 +1,13 @@
# 업데이트 이력
## v1.5.9
- 관리자 대시보드: 페이지별 조회·방문자·체류·스크롤 통계 수집 및 인기 페이지 목록 추가.
- 공개 HTML 문서 페이지: Nuxt 클라이언트 통계를 거치지 않는 원문 HTML 응답도 서버에서 페이지 조회를 기록하도록 수정.
- 관리자 네비게이션 추천 사이트: 대체 텍스트와 썸네일 URL 입력 추가.
- 공개 오른쪽 사이드바 추천 사이트: 대체 텍스트가 있으면 URL 대신 표시하고, 썸네일이 있으면 파비콘 대신 표시하도록 수정.
- DB: 페이지 통계 테이블과 추천 사이트 메타데이터 컬럼 추가.
## v1.5.8
- 관리자 멤버 권한 변경: 소유자가 본인 권한을 직접 낮출 수 없도록 수정.

View File

@@ -65,6 +65,13 @@ export const isBotUserAgent = (userAgent) => {
*/
export const normalizePostSlugForAnalytics = (slug) => (slug || '').trim()
/**
* 페이지 slug 정규화
* @param {string} slug - slug
* @returns {string} 정규화된 slug
*/
export const normalizePageSlugForAnalytics = (slug) => (slug || '').trim()
/** @type {number} heartbeat 체류시간 상한(초) */
export const ANALYTICS_MAX_DURATION_SECONDS = 1800

View File

@@ -15,6 +15,7 @@ export {
getNewScrollBucketColumns,
isBotUserAgent,
isTrackableAnalyticsPath,
normalizePageSlugForAnalytics,
normalizePostSlugForAnalytics
} from './analytics-shared.js'

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.5.8",
"version": "1.5.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.5.8",
"version": "1.5.9",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.5.8",
"version": "1.5.9",
"private": true,
"type": "module",
"imports": {

View File

@@ -43,6 +43,11 @@ const { data: topPosts, refresh: refreshTopPosts } = await useFetch('/admin/api/
default: () => []
})
const { data: topPages, refresh: refreshTopPages } = await useFetch('/admin/api/analytics/pages', {
query: topPostsQuery,
default: () => []
})
const { data: realtime, refresh: refreshRealtime } = await useFetch('/admin/api/analytics/realtime', {
query: { limit: 20 },
default: () => ({
@@ -250,6 +255,10 @@ const getSessionViewingTitle = (session) => {
return session.postTitle
}
if (session.pageTitle) {
return session.pageTitle
}
if (session.path === '/') {
return '홈'
}
@@ -263,6 +272,7 @@ onMounted(() => {
refreshTimer = window.setInterval(() => {
refreshSummary()
refreshTopPosts()
refreshTopPages()
refreshRealtime()
}, 30000)
})
@@ -276,6 +286,7 @@ onUnmounted(() => {
watch(selectedAnalyticsDays, () => {
refreshSummary()
refreshTopPosts()
refreshTopPages()
})
</script>
@@ -435,74 +446,145 @@ watch(selectedAnalyticsDays, () => {
</p>
</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">
인기 게시물 ({{ analyticsRangeLabel }})
</h2>
<ul
v-if="topPosts.length"
class="admin-dashboard__top-posts-list mt-4 space-y-3 text-sm"
>
<li
v-for="(item, index) in topPosts"
:key="item.id"
class="admin-dashboard__top-posts-item flex items-start justify-between gap-4 border-b border-line pb-3 last:border-b-0 last:pb-0"
<div class="admin-dashboard__popular-grid grid gap-6 xl:grid-cols-2">
<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">
인기 게시물 ({{ analyticsRangeLabel }})
</h2>
<ul
v-if="topPosts.length"
class="admin-dashboard__top-posts-list mt-4 space-y-3 text-sm"
>
<div class="min-w-0 flex-1">
<p class="admin-dashboard__top-posts-rank text-xs font-semibold uppercase text-muted">
#{{ index + 1 }}
</p>
<NuxtLink
:to="`/post/${item.slug}`"
class="admin-dashboard__top-posts-title mt-1 block truncate font-medium text-ink hover:underline"
target="_blank"
>
{{ item.title }}
</NuxtLink>
</div>
<dl class="admin-dashboard__top-posts-stats shrink-0 text-right text-xs text-muted">
<div>
<dt class="inline">
조회
</dt>
<dd class="inline font-semibold text-ink">
{{ item.views }}
</dd>
<li
v-for="(item, index) in topPosts"
:key="item.id"
class="admin-dashboard__top-posts-item flex items-start justify-between gap-4 border-b border-line pb-3 last:border-b-0 last:pb-0"
>
<div class="min-w-0 flex-1">
<p class="admin-dashboard__top-posts-rank text-xs font-semibold uppercase text-muted">
#{{ index + 1 }}
</p>
<NuxtLink
:to="`/post/${item.slug}`"
class="admin-dashboard__top-posts-title mt-1 block truncate font-medium text-ink hover:underline"
target="_blank"
>
{{ item.title }}
</NuxtLink>
</div>
<div class="mt-1">
<dt class="inline">
읽음
</dt>
<dd class="inline font-semibold text-ink">
{{ item.reads }}
</dd>
<dl class="admin-dashboard__top-posts-stats shrink-0 text-right text-xs text-muted">
<div>
<dt class="inline">
조회
</dt>
<dd class="inline font-semibold text-ink">
{{ item.views }}
</dd>
</div>
<div class="mt-1">
<dt class="inline">
읽음
</dt>
<dd class="inline font-semibold text-ink">
{{ item.reads }}
</dd>
</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>
</li>
</ul>
<p
v-else
class="admin-dashboard__top-posts-empty mt-4 text-sm text-muted"
>
아직 집계된 게시물 조회 데이터가 없습니다.
</p>
</section>
<section class="admin-dashboard__top-pages border border-line bg-white p-4">
<h2 class="admin-dashboard__section-title text-sm font-semibold text-ink">
인기 페이지 ({{ analyticsRangeLabel }})
</h2>
<ul
v-if="topPages.length"
class="admin-dashboard__top-pages-list mt-4 space-y-3 text-sm"
>
<li
v-for="(item, index) in topPages"
:key="item.id"
class="admin-dashboard__top-pages-item flex items-start justify-between gap-4 border-b border-line pb-3 last:border-b-0 last:pb-0"
>
<div class="min-w-0 flex-1">
<p class="admin-dashboard__top-pages-rank text-xs font-semibold uppercase text-muted">
#{{ index + 1 }}
</p>
<NuxtLink
:to="`/pages/${item.slug}`"
class="admin-dashboard__top-pages-title mt-1 block truncate font-medium text-ink hover:underline"
target="_blank"
>
{{ item.title }}
</NuxtLink>
</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>
</li>
</ul>
<p
v-else
class="admin-dashboard__top-posts-empty mt-4 text-sm text-muted"
>
아직 집계된 게시물 조회 데이터가 없습니다.
</p>
</section>
<dl class="admin-dashboard__top-pages-stats shrink-0 text-right text-xs text-muted">
<div>
<dt class="inline">
조회
</dt>
<dd class="inline font-semibold text-ink">
{{ item.views }}
</dd>
</div>
<div class="mt-1">
<dt class="inline">
방문자
</dt>
<dd class="inline font-semibold text-ink">
{{ item.visitors }}
</dd>
</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>
</li>
</ul>
<p
v-else
class="admin-dashboard__top-pages-empty mt-4 text-sm text-muted"
>
아직 집계된 페이지 조회 데이터가 없습니다.
</p>
</section>
</div>
</div>
</section>
</template>

View File

@@ -17,6 +17,8 @@ const { data: navigationItems } = await useFetch('/admin/api/navigation', {
const items = ref(navigationItems.value.map((item) => ({
...item,
descriptionText: item.descriptionText || '',
thumbnailUrl: item.thumbnailUrl || '',
parentId: item.parentId ?? null,
isFolder: Boolean(item.isFolder),
isVisible: true
@@ -50,6 +52,8 @@ const serializeNavigationItems = (list) => JSON.stringify(
id: String(item.id || '').trim(),
label: String(item.label || '').trim(),
url: String(item.url || '').trim(),
descriptionText: String(item.descriptionText || '').trim(),
thumbnailUrl: String(item.thumbnailUrl || '').trim(),
location: item.location,
sortOrder: Number(item.sortOrder || 0),
parentId: item.parentId ? String(item.parentId).trim() : null
@@ -601,6 +605,8 @@ const addPrimaryRoot = () => {
id: crypto.randomUUID(),
label: '새 메뉴',
url: '/',
descriptionText: '',
thumbnailUrl: '',
location: 'primary',
parentId: null,
sortOrder: maxOrder + 10,
@@ -620,6 +626,8 @@ const addFooterItem = () => {
id: crypto.randomUUID(),
label: '',
url: '/',
descriptionText: '',
thumbnailUrl: '',
location: 'footer',
parentId: null,
sortOrder: maxOrder + 10,
@@ -639,6 +647,8 @@ const addRecommendedItem = () => {
id: crypto.randomUUID(),
label: '',
url: 'https://',
descriptionText: '',
thumbnailUrl: '',
location: 'recommended',
parentId: null,
sortOrder: maxOrder + 10,
@@ -668,6 +678,8 @@ const saveNavigation = async () => {
id: item.id,
label: item.label,
url: item.url,
descriptionText: item.descriptionText || '',
thumbnailUrl: item.thumbnailUrl || '',
location: item.location,
sortOrder: Number(item.sortOrder || 0),
isVisible: true,
@@ -679,6 +691,8 @@ const saveNavigation = async () => {
items.value = savedItems.map((item) => ({
...item,
descriptionText: item.descriptionText || '',
thumbnailUrl: item.thumbnailUrl || '',
parentId: item.parentId ?? null,
isFolder: Boolean(item.isFolder),
isVisible: true
@@ -941,7 +955,7 @@ const saveNavigation = async () => {
<div v-show="activeTab === 'recommended'" class="admin-navigation__panel-recommended space-y-4">
<p class="admin-navigation__recommended-note max-w-xl text-sm text-muted">
공개 우측 사이드바 Recommended 영역에 표시됩니다. <code class="rounded bg-[#f0f1f3] px-1 py-0.5 text-xs">https://</code> 링크는 아이콘에 Google 파비콘 프록시를 사용합니다(내부 경로 <code class="rounded bg-[#f0f1f3] px-1 py-0.5 text-xs">/</code>만 있으면 아이콘은 생략).
공개 우측 사이드바 Recommended 영역에 표시됩니다. 대체 텍스트가 있으면 카드 하단에 URL 대신 표시하고, 썸네일 URL이 있으면 Google 파비콘 대신 썸네일을 사용합니다.
</p>
<div class="flex flex-wrap gap-2">
<button
@@ -970,6 +984,12 @@ const saveNavigation = async () => {
<th class="admin-navigation__recommended-cell px-4 py-3">
URL
</th>
<th class="admin-navigation__recommended-cell px-4 py-3">
대체 텍스트
</th>
<th class="admin-navigation__recommended-cell px-4 py-3">
썸네일 URL
</th>
<th class="admin-navigation__recommended-cell admin-navigation__cell-actions w-12 px-2 py-3 text-right">
<span class="sr-only">관리</span>
</th>
@@ -1008,6 +1028,22 @@ const saveNavigation = async () => {
required
>
</td>
<td class="admin-navigation__recommended-cell px-4 py-4">
<input
v-model="item.descriptionText"
class="w-full min-w-[10rem] rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="URL 대신 표시할 문구"
>
</td>
<td class="admin-navigation__recommended-cell px-4 py-4">
<input
v-model="item.thumbnailUrl"
class="w-full min-w-[12rem] rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="/uploads/…"
>
</td>
<td class="admin-navigation__recommended-cell admin-navigation__cell-actions relative w-12 px-2 py-4 text-right">
<AdminRowMoreMenu
v-model:open-menu-id="openMenuId"

View File

@@ -45,6 +45,9 @@ let currentPath = ''
/** @type {string} 현재 추적 게시물 slug */
let currentPostSlug = ''
/** @type {string} 현재 추적 페이지 slug */
let currentPageSlug = ''
/** @type {number} 현재 페이지 체류 시작 시각 */
let pageStartedAt = 0
@@ -85,6 +88,21 @@ const extractPostSlugFromRoute = (route) => {
return match?.[1] ? decodeURIComponent(match[1]).trim() : ''
}
/**
* 고정 페이지 경로에서 slug를 추출한다.
* @param {import('vue-router').RouteLocationNormalizedLoaded} route - 현재 라우트
* @returns {string} slug 또는 빈 문자열
*/
const extractPageSlugFromRoute = (route) => {
const paramSlug = String(route.params?.slug || '').trim()
if (paramSlug && String(route.path || '').startsWith('/pages/')) {
return paramSlug
}
const match = String(route.path || '').match(/^\/pages\/([^/?#]+)/)
return match?.[1] ? decodeURIComponent(match[1]).trim() : ''
}
/**
* 문서 스크롤 진행 비율(0~1)을 반환한다.
* @returns {number} 스크롤 비율
@@ -148,6 +166,7 @@ const clearPageTracking = () => {
readStartedAt = 0
currentPath = ''
currentPostSlug = ''
currentPageSlug = ''
pageStartedAt = 0
maxScrollRatio = 0
}
@@ -182,26 +201,28 @@ const sendAnalyticsPayload = (endpoint, payload) => {
/**
* pageview 이벤트를 전송한다.
* @param {{ path: string, postSlug?: string, read?: boolean }} payload - 전송 본문
* @param {{ path: string, postSlug?: string, pageSlug?: string, read?: boolean }} payload - 전송 본문
* @returns {void}
*/
const sendPageviewEvent = (payload) => {
sendAnalyticsPayload('/api/analytics/pageview', {
path: payload.path,
postSlug: payload.postSlug || '',
pageSlug: payload.pageSlug || '',
read: Boolean(payload.read)
})
}
/**
* heartbeat 이벤트를 전송한다.
* @param {{ path: string, postSlug?: string, durationSeconds: number, maxScrollRatio: number }} payload - 전송 본문
* @param {{ path: string, postSlug?: string, pageSlug?: string, durationSeconds: number, maxScrollRatio: number }} payload - 전송 본문
* @returns {void}
*/
const sendHeartbeatEvent = (payload) => {
sendAnalyticsPayload('/api/analytics/heartbeat', {
path: payload.path,
postSlug: payload.postSlug || '',
pageSlug: payload.pageSlug || '',
clientSessionId: getClientSessionId(),
durationSeconds: payload.durationSeconds,
maxScrollRatio: payload.maxScrollRatio
@@ -222,6 +243,7 @@ const sendCurrentHeartbeat = () => {
sendHeartbeatEvent({
path: currentPath,
postSlug: currentPostSlug,
pageSlug: currentPageSlug,
durationSeconds: getCurrentDurationSeconds(),
maxScrollRatio
})
@@ -308,13 +330,15 @@ const trackRouteAnalytics = (route) => {
sendCurrentHeartbeat()
const postSlug = extractPostSlugFromRoute(route)
const viewKey = `view:${path}:${postSlug}`
const pageSlug = postSlug ? '' : extractPageSlugFromRoute(route)
const viewKey = `view:${path}:${postSlug}:${pageSlug}`
if (!sentViewKeys.has(viewKey)) {
sentViewKeys.add(viewKey)
sendPageviewEvent({
path,
postSlug,
pageSlug,
read: false
})
}
@@ -323,6 +347,7 @@ const trackRouteAnalytics = (route) => {
currentPath = path
currentPostSlug = postSlug
currentPageSlug = pageSlug
pageStartedAt = Date.now()
maxScrollRatio = getDocumentScrollRatio()

View File

@@ -1,4 +1,9 @@
import { getMethod, getRequestURL, setResponseHeader } from 'h3'
import { getMethod, getRequestHeader, getRequestURL, setResponseHeader } from 'h3'
import { isBotUserAgent } from '../../lib/analytics'
import {
createVisitorHashFromEvent,
recordAnalyticsPageview
} from '../repositories/analytics-repository'
import { getPageBySlug } from '../repositories/content-repository'
/**
@@ -27,6 +32,16 @@ export default defineEventHandler(async (event) => {
return
}
if (method === 'GET' && !isBotUserAgent(String(getRequestHeader(event, 'user-agent') || ''))) {
await recordAnalyticsPageview({
visitorHash: createVisitorHashFromEvent(event),
pageId: page.id,
recordSite: true,
recordView: true,
recordRead: false
})
}
setResponseHeader(event, 'content-type', 'text/html; charset=utf-8')
setResponseHeader(event, 'cache-control', 'no-cache')

View File

@@ -60,6 +60,21 @@ const ensurePostDailyRow = async (sql, day, postId) => {
`
}
/**
* 일별 페이지 통계 행을 보장한다.
* @param {import('postgres').Sql} sql - DB 클라이언트
* @param {string} day - YYYY-MM-DD
* @param {string} pageId - 페이지 ID
* @returns {Promise<void>}
*/
const ensurePageDailyRow = async (sql, day, pageId) => {
await sql`
INSERT INTO page_analytics_daily (day, page_id)
VALUES (${day}, ${pageId})
ON CONFLICT (day, page_id) DO NOTHING
`
}
/**
* 사이트 방문자를 등록하고 신규 방문자면 true를 반환한다.
* @param {import('postgres').Sql} sql - DB 클라이언트
@@ -97,9 +112,28 @@ const registerPostVisitor = async (sql, day, postId, visitorHash) => {
return Boolean(rows[0])
}
/**
* 페이지 방문자를 등록하고 신규 방문자면 true를 반환한다.
* @param {import('postgres').Sql} sql - DB 클라이언트
* @param {string} day - YYYY-MM-DD
* @param {string} pageId - 페이지 ID
* @param {string} visitorHash - 방문자 해시
* @returns {Promise<boolean>} 신규 방문자 여부
*/
const registerPageVisitor = async (sql, day, pageId, visitorHash) => {
const rows = await sql`
INSERT INTO analytics_daily_visitors (day, scope, page_id, visitor_hash)
VALUES (${day}, 'page', ${pageId}, ${visitorHash})
ON CONFLICT DO NOTHING
RETURNING id
`
return Boolean(rows[0])
}
/**
* 페이지뷰·읽음 이벤트를 기록한다.
* @param {{ visitorHash: string, postId?: string | null, recordSite?: boolean, recordView?: boolean, recordRead?: boolean }} input - 기록 입력
* @param {{ visitorHash: string, postId?: string | null, pageId?: string | null, recordSite?: boolean, recordView?: boolean, recordRead?: boolean }} input - 기록 입력
* @returns {Promise<{ ok: true }>}
*/
export const recordAnalyticsPageview = async (input) => {
@@ -112,6 +146,7 @@ export const recordAnalyticsPageview = async (input) => {
const day = getAnalyticsDayKey()
const visitorHash = input.visitorHash
const postId = input.postId || null
const pageId = input.pageId || null
const recordSite = input.recordSite !== false
const recordView = Boolean(input.recordView)
const recordRead = Boolean(input.recordRead)
@@ -131,6 +166,21 @@ export const recordAnalyticsPageview = async (input) => {
}
if (!postId) {
if (pageId && recordView) {
await ensurePageDailyRow(sql, day, pageId)
const isNewPageVisitor = await registerPageVisitor(sql, day, pageId, visitorHash)
await sql`
UPDATE page_analytics_daily
SET
views = views + 1,
visitors = visitors + ${isNewPageVisitor ? 1 : 0}
WHERE day = ${day}
AND page_id = ${pageId}
`
}
await purgeAnalyticsRetention(sql)
return { ok: true }
}
@@ -279,9 +329,57 @@ const incrementPostScrollBuckets = async (sql, day, postId, columns) => {
}
}
/**
* 페이지 스크롤 구간 카운터를 증가시킨다.
* @param {import('postgres').Sql} sql - DB 클라이언트
* @param {string} day - YYYY-MM-DD
* @param {string} pageId - 페이지 ID
* @param {Array<'scroll_25' | 'scroll_50' | 'scroll_75' | 'scroll_100'>} columns - 구간 컬럼
* @returns {Promise<void>}
*/
const incrementPageScrollBuckets = async (sql, day, pageId, columns) => {
if (!columns.length) {
return
}
await ensurePageDailyRow(sql, day, pageId)
for (const column of columns) {
if (column === 'scroll_25') {
await sql`
UPDATE page_analytics_daily
SET scroll_25 = scroll_25 + 1
WHERE day = ${day}
AND page_id = ${pageId}
`
} else if (column === 'scroll_50') {
await sql`
UPDATE page_analytics_daily
SET scroll_50 = scroll_50 + 1
WHERE day = ${day}
AND page_id = ${pageId}
`
} else if (column === 'scroll_75') {
await sql`
UPDATE page_analytics_daily
SET scroll_75 = scroll_75 + 1
WHERE day = ${day}
AND page_id = ${pageId}
`
} else if (column === 'scroll_100') {
await sql`
UPDATE page_analytics_daily
SET scroll_100 = scroll_100 + 1
WHERE day = ${day}
AND page_id = ${pageId}
`
}
}
}
/**
* heartbeat·체류·스크롤·실시간 세션을 기록한다.
* @param {{ event: import('h3').H3Event, sessionHash: string, path: string, postId?: string | null, postSlug?: string, durationSeconds: number, maxScrollRatio: number }} input - 기록 입력
* @param {{ event: import('h3').H3Event, sessionHash: string, path: string, postId?: string | null, postSlug?: string, pageId?: string | null, pageSlug?: string, durationSeconds: number, maxScrollRatio: number }} input - 기록 입력
* @returns {Promise<{ ok: true }>}
*/
export const recordAnalyticsHeartbeat = async (input) => {
@@ -296,6 +394,8 @@ export const recordAnalyticsHeartbeat = async (input) => {
const path = input.path
const postId = input.postId || null
const postSlug = input.postSlug || ''
const pageId = input.pageId || null
const pageSlug = input.pageSlug || ''
const durationSeconds = clampAnalyticsDurationSeconds(input.durationSeconds)
const maxScrollRatio = clampAnalyticsScrollRatio(input.maxScrollRatio)
const memberSession = getMemberSession(input.event)
@@ -320,6 +420,8 @@ export const recordAnalyticsHeartbeat = async (input) => {
path,
post_id,
post_slug,
page_id,
page_slug,
duration_seconds,
max_scroll_ratio
)
@@ -329,6 +431,8 @@ export const recordAnalyticsHeartbeat = async (input) => {
${path},
${postId},
${postSlug},
${pageId},
${pageSlug},
${durationSeconds},
${maxScrollRatio}
)
@@ -338,6 +442,8 @@ export const recordAnalyticsHeartbeat = async (input) => {
path = EXCLUDED.path,
post_id = EXCLUDED.post_id,
post_slug = EXCLUDED.post_slug,
page_id = EXCLUDED.page_id,
page_slug = EXCLUDED.page_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()
@@ -391,6 +497,33 @@ export const recordAnalyticsHeartbeat = async (input) => {
await incrementPostScrollBuckets(sql, day, postId, scrollBuckets)
}
if (pageId && (durationDelta > 0 || scrollBuckets.length)) {
await ensurePageDailyRow(sql, day, pageId)
if (durationDelta > 0) {
await sql`
UPDATE page_analytics_daily
SET total_engaged_seconds = total_engaged_seconds + ${durationDelta}
WHERE day = ${day}
AND page_id = ${pageId}
`
const wasPageEngaged = previousDuration >= ANALYTICS_ENGAGED_MIN_SECONDS
const isPageEngaged = durationSeconds >= ANALYTICS_ENGAGED_MIN_SECONDS
if (!wasPageEngaged && isPageEngaged) {
await sql`
UPDATE page_analytics_daily
SET engaged_views = engaged_views + 1
WHERE day = ${day}
AND page_id = ${pageId}
`
}
}
await incrementPageScrollBuckets(sql, day, pageId, scrollBuckets)
}
await purgeStaleActiveSessions(sql)
await purgeAnalyticsRetention(sql)
@@ -575,15 +708,18 @@ export const getAnalyticsActiveSessions = async (options = {}) => {
analytics_active_sessions.session_hash,
analytics_active_sessions.path,
analytics_active_sessions.post_slug,
analytics_active_sessions.page_slug,
analytics_active_sessions.duration_seconds,
analytics_active_sessions.max_scroll_ratio,
analytics_active_sessions.last_seen_at,
posts.title AS post_title,
pages.title AS page_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 pages ON pages.id = analytics_active_sessions.page_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
@@ -594,7 +730,9 @@ export const getAnalyticsActiveSessions = async (options = {}) => {
sessionHash: row.session_hash,
path: row.path,
postSlug: row.post_slug || '',
pageSlug: row.page_slug || '',
postTitle: row.post_title || '',
pageTitle: row.page_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,
@@ -668,3 +806,61 @@ export const getAnalyticsTopPosts = async (options = {}) => {
}
})
}
/**
* 인기 페이지 통계를 조회한다.
* @param {{ days?: number, limit?: number }} [options] - 조회 옵션
* @returns {Promise<Array<Object>>} 인기 페이지 목록
*/
export const getAnalyticsTopPages = async (options = {}) => {
const sql = getPostgresClient()
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) {
return []
}
const today = getAnalyticsDayKey()
const rangeStartDay = getAnalyticsDayBefore(today, days - 1)
await purgeAnalyticsRetention(sql)
const rows = await sql`
SELECT
pages.id,
pages.title,
pages.slug,
COALESCE(SUM(page_analytics_daily.views), 0)::int AS views,
COALESCE(SUM(page_analytics_daily.visitors), 0)::int AS visitors,
COALESCE(SUM(page_analytics_daily.engaged_views), 0)::int AS engaged_views,
COALESCE(SUM(page_analytics_daily.total_engaged_seconds), 0)::int AS total_engaged_seconds,
COALESCE(SUM(page_analytics_daily.scroll_50), 0)::int AS scroll_50,
COALESCE(SUM(page_analytics_daily.scroll_75), 0)::int AS scroll_75,
COALESCE(SUM(page_analytics_daily.scroll_100), 0)::int AS scroll_100
FROM page_analytics_daily
INNER JOIN pages ON pages.id = page_analytics_daily.page_id
WHERE page_analytics_daily.day >= ${rangeStartDay}::date
GROUP BY pages.id, pages.title, pages.slug
ORDER BY views DESC, pages.updated_at DESC NULLS LAST
LIMIT ${limit}
`
return rows.map((row) => {
const engagedViews = Number(row.engaged_views || 0)
const totalEngagedSeconds = Number(row.total_engaged_seconds || 0)
return {
id: row.id,
title: row.title,
slug: row.slug,
views: Number(row.views || 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)
}
})
}

View File

@@ -122,6 +122,8 @@ const mapNavigationItemRow = (row) => ({
id: row.id,
label: row.label,
url: row.url,
descriptionText: row.description_text || '',
thumbnailUrl: row.thumbnail_url || '',
location: row.location,
sortOrder: row.sort_order,
isVisible: row.is_visible,
@@ -1046,6 +1048,8 @@ export const getPublicNavigation = async () => {
id: item.id,
label: item.label,
url: item.url,
descriptionText: item.descriptionText,
thumbnailUrl: item.thumbnailUrl,
isVisible: item.isVisible
}))
}
@@ -1075,6 +1079,8 @@ export const updateNavigationItems = async (items) => {
id,
label,
url,
description_text,
thumbnail_url,
location,
sort_order,
is_visible,
@@ -1085,6 +1091,8 @@ export const updateNavigationItems = async (items) => {
${item.id},
${item.label},
${item.url},
${item.descriptionText || ''},
${item.thumbnailUrl || ''},
${item.location},
${item.sortOrder},
${item.isVisible},

View File

@@ -0,0 +1,17 @@
import { requireAdminSession } from '../../../../utils/admin-auth.js'
import { getAnalyticsTopPages } from '../../../../repositories/analytics-repository.js'
/**
* 관리자 인기 페이지 통계 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Array<Object>>} 인기 페이지 목록
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const query = getQuery(event)
const days = Number(query.days) || 30
const limit = Number(query.limit) || 5
return getAnalyticsTopPages({ days, limit })
})

View File

@@ -45,6 +45,8 @@ export default defineEventHandler(async (event) => {
id: row.id,
label: row.label.trim(),
url: row.url.trim(),
descriptionText: row.descriptionText.trim(),
thumbnailUrl: row.thumbnailUrl.trim(),
location: row.location,
sortOrder: row.sortOrder,
isVisible: true,
@@ -70,10 +72,16 @@ export default defineEventHandler(async (event) => {
} catch (err) {
const msg = err?.message != null ? String(err.message) : String(err)
const code = err?.code != null ? String(err.code) : ''
if (msg.includes('parent_id') || msg.includes('is_folder') || code === '42703') {
if (
msg.includes('parent_id') ||
msg.includes('is_folder') ||
msg.includes('description_text') ||
msg.includes('thumbnail_url') ||
code === '42703'
) {
throw createError({
statusCode: 503,
message: 'DB에 navigation_items 확장 컬럼이 없습니다. 프로젝트 루트에서 npm run db:migrate:dev 로 017_navigation_hierarchy.sql을 적용한 뒤 다시 저장하세요.'
message: 'DB에 navigation_items 확장 컬럼이 없습니다. 프로젝트 루트에서 npm run db:migrate:dev 로 최신 마이그레이션을 적용한 뒤 다시 저장하세요.'
})
}
throw err

View File

@@ -4,6 +4,8 @@ export const adminNavigationItemInputSchema = z.object({
id: z.string().uuid(),
label: z.string().trim().min(1),
url: z.string().trim().min(1).regex(/^(\/|https?:\/\/|#)/),
descriptionText: z.string().trim().max(200).optional().default(''),
thumbnailUrl: z.string().trim().max(500).optional().default(''),
location: z.enum(['primary', 'footer', 'recommended']),
sortOrder: z.coerce.number().int().min(0).default(0),
isVisible: z.boolean().default(true),

View File

@@ -2,9 +2,10 @@ import { z } from 'zod'
import {
isBotUserAgent,
isTrackableAnalyticsPath,
normalizePageSlugForAnalytics,
normalizePostSlugForAnalytics
} from '../../lib/analytics.js'
import { getPostBySlug } from '../repositories/content-repository.js'
import { getPageBySlug, getPostBySlug } from '../repositories/content-repository.js'
import {
createSessionHashFromEvent,
recordAnalyticsHeartbeat
@@ -13,6 +14,7 @@ import {
const heartbeatInputSchema = z.object({
path: z.string().trim().min(1).max(500),
postSlug: z.string().trim().max(200).optional().default(''),
pageSlug: 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)
@@ -45,7 +47,9 @@ export const handleAnalyticsHeartbeat = async (event) => {
}
const postSlug = normalizePostSlugForAnalytics(body.postSlug)
const pageSlug = normalizePageSlugForAnalytics(body.pageSlug)
let postId = null
let pageId = null
if (postSlug) {
const post = await getPostBySlug(postSlug)
@@ -55,6 +59,14 @@ export const handleAnalyticsHeartbeat = async (event) => {
postId = post.id
}
if (!postId && pageSlug) {
const page = await getPageBySlug(pageSlug)
if (!page) {
return { ok: true }
}
pageId = page.id
}
const sessionHash = createSessionHashFromEvent(event, body.clientSessionId)
await recordAnalyticsHeartbeat({
@@ -63,6 +75,8 @@ export const handleAnalyticsHeartbeat = async (event) => {
path: body.path,
postId,
postSlug,
pageId,
pageSlug,
durationSeconds: body.durationSeconds,
maxScrollRatio: body.maxScrollRatio
})

View File

@@ -2,9 +2,10 @@ import { z } from 'zod'
import {
isBotUserAgent,
isTrackableAnalyticsPath,
normalizePageSlugForAnalytics,
normalizePostSlugForAnalytics
} from '../../lib/analytics.js'
import { getPostBySlug } from '../repositories/content-repository.js'
import { getPageBySlug, getPostBySlug } from '../repositories/content-repository.js'
import {
createVisitorHashFromEvent,
recordAnalyticsPageview
@@ -13,6 +14,7 @@ import {
const pageviewInputSchema = z.object({
path: z.string().trim().min(1).max(500),
postSlug: z.string().trim().max(200).optional().default(''),
pageSlug: z.string().trim().max(200).optional().default(''),
read: z.boolean().optional().default(false)
})
@@ -43,7 +45,9 @@ export const handleAnalyticsPageview = async (event) => {
}
const postSlug = normalizePostSlugForAnalytics(body.postSlug)
const pageSlug = normalizePageSlugForAnalytics(body.pageSlug)
let postId = null
let pageId = null
if (postSlug) {
const post = await getPostBySlug(postSlug)
@@ -53,14 +57,23 @@ export const handleAnalyticsPageview = async (event) => {
postId = post.id
}
if (!postId && pageSlug) {
const page = await getPageBySlug(pageSlug)
if (!page) {
return { ok: true }
}
pageId = page.id
}
const visitorHash = createVisitorHashFromEvent(event)
const isReadEvent = Boolean(body.read)
await recordAnalyticsPageview({
visitorHash,
postId,
pageId,
recordSite: !isReadEvent,
recordView: Boolean(postId) && !isReadEvent,
recordView: (Boolean(postId) || Boolean(pageId)) && !isReadEvent,
recordRead: Boolean(postId) && isReadEvent
})