장기 방문자 통계 추가 v1.7.7

This commit is contained in:
2026-06-22 17:31:56 +09:00
parent e2ca365fa0
commit c2acecffcd
11 changed files with 84 additions and 12 deletions

View File

@@ -1,5 +1,10 @@
# 업데이트 요약
## v1.7.7
- Homepage에서 최근 30일·6개월·1년 방문자 통계를 선택해 표시할 수 있게 했다.
- 개인용 대시보드에 맞춰 권장 갱신 주기를 5분으로 정리했다.
## v1.7.6
- Homepage 대시보드에서 오늘·누적 방문자, 조회수, 게시글·댓글·페이지·태그·회원 수를 선택해 표시할 수 있도록 통계 API를 확장했다.

View File

@@ -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 마이그레이션은 없다.

View File

@@ -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`에서 필요한 필드만 선택하는 구조이므로 오늘·누적 방문과 콘텐츠 통계를 중첩 객체가 아닌 평면 숫자 필드로 제공한다. 게시글·댓글·페이지 수는 방문자가 실제로 볼 수 있는 공개 항목만 집계하고, 회원은 개인정보 없이 전체 계정 수만 제공한다. 누적 방문자는 영구 사용자 식별값이 아니라 날짜별 순방문자 수의 합계이므로 같은 방문자가 다른 날짜에 다시 포함될 수 있음을 사용 가이드에 명시한다.

View File

@@ -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분)으로 두면 충분하다.

View File

@@ -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 설정 시 일반 가입에 필수) |

View File

@@ -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` - 현재 접속자 요약·목록(로그인 사용자 닉네임·아바타 포함)

View File

@@ -1,5 +1,10 @@
# 업데이트 이력
## v1.7.7
- Homepage 통계 API에 최근 30일·180일(6개월)·365일(1년) 방문자 합계 추가.
- 기간별 방문자 합계를 단일 집계 쿼리로 처리하고 Homepage 권장 갱신 주기를 5분으로 정리.
## v1.7.6
- gethomepage Custom API에 누적 방문자·페이지뷰와 전체 게시글·댓글·페이지·태그·회원 통계 추가.

4
package-lock.json generated
View File

@@ -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",

View File

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

View File

@@ -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, '명'),

View File

@@ -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),