diff --git a/docs/changelog.md b/docs/changelog.md index ca33ece..f6c56e5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,10 @@ # 업데이트 요약 +## v1.7.7 + +- Homepage에서 최근 30일·6개월·1년 방문자 통계를 선택해 표시할 수 있게 했다. +- 개인용 대시보드에 맞춰 권장 갱신 주기를 5분으로 정리했다. + ## v1.7.6 - Homepage 대시보드에서 오늘·누적 방문자, 조회수, 게시글·댓글·페이지·태그·회원 수를 선택해 표시할 수 있도록 통계 API를 확장했다. diff --git a/docs/deploy.md b/docs/deploy.md index c4253c5..9255c4e 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,6 +1,6 @@ # 배포 가이드 -> 로컬 기준 v1.7.6에서 `npm run verify` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. +> 로컬 기준 v1.7.7에서 `npm run verify` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. ## 빌드 유형 @@ -16,6 +16,12 @@ ## 로컬 개발 +### v1.7.7 참고 + +- 추가 DB 마이그레이션은 없다. +- `/api/homepage-widget`의 `visitorsLast30Days`, `visitorsLast180Days`, `visitorsLast365Days`가 숫자로 반환되는지 확인한다. +- Homepage 개인용 설정은 `refreshInterval: 300000`(5분)을 권장한다. + ### v1.7.6 참고 - 추가 DB 마이그레이션은 없다. diff --git a/docs/history.md b/docs/history.md index 974266c..4cfdffe 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-06-22 v1.7.7 — 장기 방문 통계는 일별 집계를 한 번에 합산한다 + +Homepage에서 최근 7일·30일·6개월·1년 방문자를 선택해 볼 수 있도록 기간별 평면 필드를 제공한다. 장기 범위 추가로 쿼리 횟수가 늘지 않게 `site_analytics_daily`의 최근 365일 행을 한 번 읽고 PostgreSQL `FILTER` 집계로 각 기간을 동시에 계산한다. 6개월은 대시보드의 기간 기준과 맞춰 최근 180일로 정의하고, 개인용 Homepage의 권장 갱신 주기는 5분으로 둔다. + ## 2026-06-22 v1.7.6 — 공개 블로그 통계는 평면 숫자 필드로 제공한다 Homepage Custom API는 운영자가 `mappings`에서 필요한 필드만 선택하는 구조이므로 오늘·누적 방문과 콘텐츠 통계를 중첩 객체가 아닌 평면 숫자 필드로 제공한다. 게시글·댓글·페이지 수는 방문자가 실제로 볼 수 있는 공개 항목만 집계하고, 회원은 개인정보 없이 전체 계정 수만 제공한다. 누적 방문자는 영구 사용자 식별값이 아니라 날짜별 순방문자 수의 합계이므로 같은 방문자가 다른 날짜에 다시 포함될 수 있음을 사용 가이드에 명시한다. diff --git a/docs/homepage-widget.md b/docs/homepage-widget.md index a6c39b0..a5333ca 100644 --- a/docs/homepage-widget.md +++ b/docs/homepage-widget.md @@ -20,6 +20,9 @@ Homepage가 Docker 컨테이너에서 실행 중이면 `localhost`는 Homepage | `todayPageViews` | number | 오늘 페이지뷰 | | `totalPageViews` | number | 전체 누적 페이지뷰 | | `visitorsLast7Days` | number | 최근 7일 방문자 합계 | +| `visitorsLast30Days` | number | 최근 30일 방문자 합계 | +| `visitorsLast180Days` | number | 최근 180일(약 6개월) 방문자 합계 | +| `visitorsLast365Days` | number | 최근 365일(1년) 방문자 합계 | | `pageViewsLast30Days` | number | 최근 30일 페이지뷰 | | `onlineNow` | number | 최근 90초 안에 활동한 접속자 | | `loggedInNow` | number | 현재 접속자 중 로그인 회원 | @@ -44,7 +47,7 @@ Homepage가 Docker 컨테이너에서 실행 중이면 `localhost`는 Homepage widget: type: customapi url: https://sori.studio/api/homepage-widget - refreshInterval: 30000 + refreshInterval: 300000 # 5분 mappings: - field: todayVisitors label: 오늘 방문자 @@ -118,6 +121,18 @@ mappings: label: 최근 7일 방문 format: number suffix: 명 + - field: visitorsLast30Days + label: 최근 30일 방문 + format: number + suffix: 명 + - field: visitorsLast180Days + label: 최근 6개월 방문 + format: number + suffix: 명 + - field: visitorsLast365Days + label: 최근 1년 방문 + format: number + suffix: 명 - field: pageViewsLast30Days label: 최근 30일 조회 format: number @@ -157,3 +172,5 @@ curl https://sori.studio/api/homepage-widget ``` 응답이 정상인데 Homepage에 값이 나오지 않으면 Homepage 컨테이너 내부에서 해당 URL에 접근할 수 있는지 먼저 확인한다. + +방문 기간 통계는 일별 집계 테이블에서 최근 7·30·180·365일을 한 번에 합산한다. Homepage를 개인용으로 간헐적으로 확인한다면 `refreshInterval: 300000`(5분) 또는 `600000`(10분)으로 두면 충분하다. diff --git a/docs/map.md b/docs/map.md index 23a7b8d..4fa831f 100644 --- a/docs/map.md +++ b/docs/map.md @@ -210,7 +210,7 @@ | server/api/tags.get.js | 태그 목록 샘플 API | | server/api/search.get.js | 통합 검색 API(`q` 쿼리) | | server/api/site-settings.get.js | 공개 사이트 설정 API | -| server/api/homepage-widget.get.js | gethomepage customapi용 당일·누적·콘텐츠 통계 위젯 API | +| server/api/homepage-widget.get.js | gethomepage customapi용 당일·7일·30일·180일·365일·누적·콘텐츠 통계 위젯 API | | server/repositories/blog-statistics-repository.js | 공개 게시글·댓글·페이지·태그·회원·누적 방문 통계 집계 | | server/api/navigation.get.js | 공개 네비게이션 API | | server/api/auth/signup.post.js | 회원 가입 API(선택 `emailOtp`, Resend 설정 시 일반 가입에 필수) | diff --git a/docs/spec.md b/docs/spec.md index 7a33f9c..568b34f 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -531,7 +531,7 @@ components/content/ - `GET /api/tags` - 태그 목록 - `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만) - `GET /api/site-settings` - 공개 사이트 설정(어나운스 바·홈 커버 등 포함) -- `GET /api/homepage-widget` - gethomepage customapi용 공개 블로그 통계. 기본 필드 `title`, `updatedAt`, `todayVisitors`, `totalVisitors`, `todayPageViews`, `totalPageViews`, `visitorsLast7Days`, `pageViewsLast30Days`, `onlineNow`, `loggedInNow`, `avgEngagedSeconds`, `totalPosts`, `totalComments`, `totalPages`, `totalTags`, `totalMembers`, `items[]`를 반환한다. `totalVisitors`는 날짜별 순방문자 수의 누적 합계라 동일 방문자가 다른 날짜에 다시 포함될 수 있다. 게시글·댓글·페이지 수는 현재 공개 가능한 항목만 집계한다. +- `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 }`. HTML 문서 모드 페이지는 Nuxt 클라이언트 플러그인을 거치지 않으므로 서버 미들웨어가 GET 요청 시 페이지 조회를 직접 기록한다. - `POST /api/analytics/heartbeat` - 실시간 세션·체류·스크롤 집계. 본문: `path`, `postSlug`, `pageSlug`, `clientSessionId`, `durationSeconds`(최대 1800), `maxScrollRatio`(0~1). 로그인 시 서버가 회원 세션으로 사용자 연결. - `GET /api/navigation` - 공개 네비게이션(`primary`는 트리·`footer`·`recommended`는 평면, 상세는 위 메뉴/네비게이션 절) @@ -566,7 +566,7 @@ components/content/ - `POST /admin/api/auth/login` - 로그인 - `POST /admin/api/auth/logout` - 로그아웃 - `GET /admin/api/auth/me` - 현재 관리자 세션 조회. `optional=1` 쿼리를 붙이면 비로그인 상태에서 401 대신 `null`을 반환하며, 관리자 라우트 보호용 기본 호출은 401을 유지한다. -- `GET /admin/api/analytics/summary?days=30` - 통계 요약(오늘/7일 방문, 30일 조회, 현재 접속자, 평균 체류, 50% 스크롤 도달, 일자별 `trends`). `days`는 대시보드에서 7/30/90/180/365로 전환한다. +- `GET /admin/api/analytics/summary?days=30` - 통계 요약(오늘 및 최근 7/30/180/365일 방문, 30일 조회, 현재 접속자, 평균 체류, 50% 스크롤 도달, 일자별 `trends`). `days`는 대시보드에서 7/30/90/180/365로 전환한다. - `GET /admin/api/analytics/posts?days=30&limit=5` - 기간 내 인기 게시물(조회·최근 30일 월간 조회·작성일·읽음·평균 체류·스크롤 구간) - `GET /admin/api/analytics/pages?days=30&limit=5` - 기간 내 인기 페이지(조회·방문자·평균 체류·스크롤 구간) - `GET /admin/api/analytics/realtime?limit=20` - 현재 접속자 요약·목록(로그인 사용자 닉네임·아바타 포함) diff --git a/docs/update.md b/docs/update.md index 90c4169..614c9fb 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 이력 +## v1.7.7 + +- Homepage 통계 API에 최근 30일·180일(6개월)·365일(1년) 방문자 합계 추가. +- 기간별 방문자 합계를 단일 집계 쿼리로 처리하고 Homepage 권장 갱신 주기를 5분으로 정리. + ## v1.7.6 - gethomepage Custom API에 누적 방문자·페이지뷰와 전체 게시글·댓글·페이지·태그·회원 통계 추가. diff --git a/package-lock.json b/package-lock.json index df827b1..e4e3e50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.7.6", + "version": "1.7.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.7.6", + "version": "1.7.7", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index f4ea821..1feffaf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.7.6", + "version": "1.7.7", "private": true, "type": "module", "imports": { diff --git a/server/api/homepage-widget.get.js b/server/api/homepage-widget.get.js index 2752b57..65f3764 100644 --- a/server/api/homepage-widget.get.js +++ b/server/api/homepage-widget.get.js @@ -51,6 +51,9 @@ export default defineEventHandler(async () => { loggedInNow: summary.loggedInNow, avgEngagedSeconds, visitorsLast7Days: summary.visitorsLast7Days, + visitorsLast30Days: summary.visitorsLast30Days, + visitorsLast180Days: summary.visitorsLast180Days, + visitorsLast365Days: summary.visitorsLast365Days, pageViewsLast30Days: summary.pageViewsLast30Days, totalPosts: statistics.totalPosts, totalComments: statistics.totalComments, @@ -68,6 +71,26 @@ export default defineEventHandler(async () => { label: formatNumberLabel(summary.todayPageViews, '회'), value: summary.todayPageViews }, + { + name: '최근 7일 방문자', + label: formatNumberLabel(summary.visitorsLast7Days, '명'), + value: summary.visitorsLast7Days + }, + { + name: '최근 30일 방문자', + label: formatNumberLabel(summary.visitorsLast30Days, '명'), + value: summary.visitorsLast30Days + }, + { + name: '최근 6개월 방문자', + label: formatNumberLabel(summary.visitorsLast180Days, '명'), + value: summary.visitorsLast180Days + }, + { + name: '최근 1년 방문자', + label: formatNumberLabel(summary.visitorsLast365Days, '명'), + value: summary.visitorsLast365Days + }, { name: '현재 접속자', label: formatNumberLabel(summary.onlineNow, '명'), diff --git a/server/repositories/analytics-repository.js b/server/repositories/analytics-repository.js index 6459ee1..9b06745 100644 --- a/server/repositories/analytics-repository.js +++ b/server/repositories/analytics-repository.js @@ -642,6 +642,9 @@ export const getAnalyticsSummary = async (options = {}) => { todayPageViews: 0, todayAvgEngagedSeconds: 0, visitorsLast7Days: 0, + visitorsLast30Days: 0, + visitorsLast180Days: 0, + visitorsLast365Days: 0, pageViewsLast30Days: 0, onlineNow: 0, loggedInNow: 0, @@ -655,6 +658,8 @@ export const getAnalyticsSummary = async (options = {}) => { const today = getAnalyticsDayKey() const last7StartDay = getAnalyticsDayBefore(today, 6) const last30StartDay = getAnalyticsDayBefore(today, 29) + const last180StartDay = getAnalyticsDayBefore(today, 179) + const last365StartDay = getAnalyticsDayBefore(today, 364) const rangeStartDay = getAnalyticsDayBefore(today, days - 1) await purgeStaleActiveSessions(sql) await purgeAnalyticsRetention(sql) @@ -666,10 +671,14 @@ export const getAnalyticsSummary = async (options = {}) => { LIMIT 1 ` - const last7Rows = await sql` - SELECT COALESCE(SUM(visitors), 0)::int AS visitors + const visitorPeriodRows = await sql` + SELECT + COALESCE(SUM(visitors) FILTER (WHERE day >= ${last7StartDay}::date), 0)::int AS visitors_last_7_days, + COALESCE(SUM(visitors) FILTER (WHERE day >= ${last30StartDay}::date), 0)::int AS visitors_last_30_days, + COALESCE(SUM(visitors) FILTER (WHERE day >= ${last180StartDay}::date), 0)::int AS visitors_last_180_days, + COALESCE(SUM(visitors) FILTER (WHERE day >= ${last365StartDay}::date), 0)::int AS visitors_last_365_days FROM site_analytics_daily - WHERE day >= ${last7StartDay}::date + WHERE day >= ${last365StartDay}::date ` const pageViewRows = await sql` @@ -738,7 +747,10 @@ export const getAnalyticsSummary = async (options = {}) => { todayAvgEngagedSeconds: todayEngagedViews > 0 ? Math.round(todayTotalEngagedSeconds / todayEngagedViews) : 0, - visitorsLast7Days: Number(last7Rows[0]?.visitors || 0), + visitorsLast7Days: Number(visitorPeriodRows[0]?.visitors_last_7_days || 0), + visitorsLast30Days: Number(visitorPeriodRows[0]?.visitors_last_30_days || 0), + visitorsLast180Days: Number(visitorPeriodRows[0]?.visitors_last_180_days || 0), + visitorsLast365Days: Number(visitorPeriodRows[0]?.visitors_last_365_days || 0), pageViewsLast30Days: Number(pageViewRows[0]?.page_views || 0), onlineNow: Number(onlineRows[0]?.online_now || 0), loggedInNow: Number(onlineRows[0]?.logged_in_now || 0),