장기 방문자 통계 추가 v1.7.7
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.7.7
|
||||
|
||||
- Homepage에서 최근 30일·6개월·1년 방문자 통계를 선택해 표시할 수 있게 했다.
|
||||
- 개인용 대시보드에 맞춰 권장 갱신 주기를 5분으로 정리했다.
|
||||
|
||||
## v1.7.6
|
||||
|
||||
- Homepage 대시보드에서 오늘·누적 방문자, 조회수, 게시글·댓글·페이지·태그·회원 수를 선택해 표시할 수 있도록 통계 API를 확장했다.
|
||||
|
||||
@@ -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 마이그레이션은 없다.
|
||||
|
||||
@@ -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`에서 필요한 필드만 선택하는 구조이므로 오늘·누적 방문과 콘텐츠 통계를 중첩 객체가 아닌 평면 숫자 필드로 제공한다. 게시글·댓글·페이지 수는 방문자가 실제로 볼 수 있는 공개 항목만 집계하고, 회원은 개인정보 없이 전체 계정 수만 제공한다. 누적 방문자는 영구 사용자 식별값이 아니라 날짜별 순방문자 수의 합계이므로 같은 방문자가 다른 날짜에 다시 포함될 수 있음을 사용 가이드에 명시한다.
|
||||
|
||||
@@ -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분)으로 두면 충분하다.
|
||||
|
||||
@@ -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 설정 시 일반 가입에 필수) |
|
||||
|
||||
@@ -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` - 현재 접속자 요약·목록(로그인 사용자 닉네임·아바타 포함)
|
||||
|
||||
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.7.6",
|
||||
"version": "1.7.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -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, '명'),
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user