From e2ca365fa0bbd4d5dc32a1f52a39974236861b74 Mon Sep 17 00:00:00 2001 From: zenn Date: Mon, 22 Jun 2026 17:24:29 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B8=94=EB=A1=9C=EA=B7=B8=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=20=EC=9C=84=EC=A0=AF=20=ED=99=95=EC=9E=A5=20v1.7.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog.md | 5 + docs/deploy.md | 9 +- docs/history.md | 4 + docs/homepage-widget.md | 159 ++++++++++++++++++ docs/map.md | 3 +- docs/spec.md | 2 +- docs/todo.md | 1 + docs/update.md | 5 + package-lock.json | 4 +- package.json | 2 +- server/api/homepage-widget.get.js | 30 +++- .../blog-statistics-repository.js | 80 +++++++++ 12 files changed, 296 insertions(+), 8 deletions(-) create mode 100644 docs/homepage-widget.md create mode 100644 server/repositories/blog-statistics-repository.js diff --git a/docs/changelog.md b/docs/changelog.md index d0ca613..ca33ece 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,10 @@ # 업데이트 요약 +## v1.7.6 + +- Homepage 대시보드에서 오늘·누적 방문자, 조회수, 게시글·댓글·페이지·태그·회원 수를 선택해 표시할 수 있도록 통계 API를 확장했다. +- Homepage Custom API 연결 예시와 필드별 사용 가이드를 추가했다. + ## v1.6.0 - 카드 썸네일과 프로필 이미지 탭에서도 목록 카드 오른쪽 위에 사용 개수 배지가 보이도록 했다. diff --git a/docs/deploy.md b/docs/deploy.md index 37e2454..c4253c5 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,6 +1,6 @@ # 배포 가이드 -> 로컬 기준 v1.6.2에서 `npm run lint` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. +> 로컬 기준 v1.7.6에서 `npm run verify` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. ## 빌드 유형 @@ -16,6 +16,13 @@ ## 로컬 개발 +### v1.7.6 참고 + +- 추가 DB 마이그레이션은 없다. +- 배포 후 `/api/homepage-widget`에서 당일·누적 방문, 페이지뷰, 공개 콘텐츠 수가 반환되는지 확인한다. +- Homepage 설정 예시는 `docs/homepage-widget.md`를 사용한다. +- Homepage가 Docker에서 실행되면 `localhost:43080` 대신 공개 도메인, NAS 내부 IP 또는 같은 Docker 네트워크의 `http://sori-studio:3000`을 사용한다. + ### v1.6.2 참고 - DB 마이그레이션 `057_navigation_recommended_favicon_cache.sql` 적용이 필요하다. diff --git a/docs/history.md b/docs/history.md index ad19fd5..974266c 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-06-22 v1.7.6 — 공개 블로그 통계는 평면 숫자 필드로 제공한다 + +Homepage Custom API는 운영자가 `mappings`에서 필요한 필드만 선택하는 구조이므로 오늘·누적 방문과 콘텐츠 통계를 중첩 객체가 아닌 평면 숫자 필드로 제공한다. 게시글·댓글·페이지 수는 방문자가 실제로 볼 수 있는 공개 항목만 집계하고, 회원은 개인정보 없이 전체 계정 수만 제공한다. 누적 방문자는 영구 사용자 식별값이 아니라 날짜별 순방문자 수의 합계이므로 같은 방문자가 다른 날짜에 다시 포함될 수 있음을 사용 가이드에 명시한다. + ## 2026-06-17 v1.6.2 — 추천 사이트 파비콘은 서버 캐시와 월간 갱신으로 둔다 방문자마다 Google 파비콘 프록시를 호출하면 외부 요청이 누적되고 캐시 주기도 통제하기 어렵다. 추천 사이트 파비콘은 서버가 Google faviconV2에서 한 번 받아 `/uploads/system/`에 저장하고, 공개 화면은 그 로컬 URL을 우선 사용한다. 자동 갱신은 매월 1일 03:00(KST)에 전체 추천 사이트를 대상으로 하고, 운영자는 관리자 추천 사이트 화면에서 수동 새로고침할 수 있다. 수동 썸네일 URL은 기존처럼 최우선이며, 관리자 화면에는 현재 적용될 아이콘 미리보기 열을 둔다. diff --git a/docs/homepage-widget.md b/docs/homepage-widget.md new file mode 100644 index 0000000..a6c39b0 --- /dev/null +++ b/docs/homepage-widget.md @@ -0,0 +1,159 @@ +# Homepage Custom API 위젯 가이드 + +## 엔드포인트 + +- 공개 URL: `GET /api/homepage-widget` +- 운영 예시: `https://sori.studio/api/homepage-widget` +- NAS 호스트 포트 예시: `http://NAS_LAN_IP:43080/api/homepage-widget` +- 같은 Docker 네트워크 예시: `http://sori-studio:3000/api/homepage-widget` + +Homepage가 Docker 컨테이너에서 실행 중이면 `localhost`는 Homepage 컨테이너 자신을 가리킨다. 같은 서버의 sori.studio에 연결할 때는 공개 도메인, NAS 내부 IP 또는 같은 Docker 네트워크의 서비스 이름을 사용한다. + +## 제공 필드 + +| 필드 | 형식 | 설명 | +|------|------|------| +| `title` | text | 사이트 제목 | +| `updatedAt` | date | API 응답 생성 시각 | +| `todayVisitors` | number | 오늘 순방문자 수 | +| `totalVisitors` | number | 일별 순방문자 수의 누적 합계 | +| `todayPageViews` | number | 오늘 페이지뷰 | +| `totalPageViews` | number | 전체 누적 페이지뷰 | +| `visitorsLast7Days` | number | 최근 7일 방문자 합계 | +| `pageViewsLast30Days` | number | 최근 30일 페이지뷰 | +| `onlineNow` | number | 최근 90초 안에 활동한 접속자 | +| `loggedInNow` | number | 현재 접속자 중 로그인 회원 | +| `avgEngagedSeconds` | duration | 오늘 평균 체류시간(오늘 값이 없으면 조회 범위 평균) | +| `totalPosts` | number | 현재 공개된 발행 게시글 수 | +| `totalComments` | number | 공개 게시글에 달린 공개 댓글 수 | +| `totalPages` | number | 공개 고정 페이지 수 | +| `totalTags` | number | 전체 태그 수 | +| `totalMembers` | number | 전체 가입 계정 수 | +| `items` | array | 이름·표시 문자열·원본 값을 포함한 기본 통계 목록 | + +`totalVisitors`는 사이트 전체에서 동일인을 영구 식별한 값이 아니다. 날짜별 중복을 제거한 방문자 수를 누적하므로 같은 사람이 다른 날짜에 방문하면 다시 집계된다. + +## 추천 기본 설정 + +`services.yaml`에서 필요한 항목만 골라 `mappings`에 넣는다. + +```yaml +- sori.studio: + href: https://sori.studio + description: 블로그 통계 + widget: + type: customapi + url: https://sori.studio/api/homepage-widget + refreshInterval: 30000 + mappings: + - field: todayVisitors + label: 오늘 방문자 + format: number + suffix: 명 + - field: totalVisitors + label: 누적 방문자 + format: number + suffix: 명 + - field: totalPosts + label: 전체 게시글 + format: number + suffix: 개 + - field: totalComments + label: 전체 댓글 + format: number + suffix: 개 +``` + +## 선택 가능한 설정 예시 + +### 방문·조회 통계 + +```yaml +mappings: + - field: todayVisitors + label: 오늘 방문자 + format: number + suffix: 명 + - field: totalVisitors + label: 누적 방문자 + format: number + suffix: 명 + - field: todayPageViews + label: 오늘 조회 + format: number + suffix: 회 + - field: totalPageViews + label: 누적 조회 + format: number + suffix: 회 +``` + +### 콘텐츠 통계 + +```yaml +mappings: + - field: totalPosts + label: 게시글 + format: number + suffix: 개 + - field: totalComments + label: 댓글 + format: number + suffix: 개 + - field: totalPages + label: 페이지 + format: number + suffix: 개 + - field: totalTags + label: 태그 + format: number + suffix: 개 +``` + +### 기간·실시간 통계 + +```yaml +mappings: + - field: visitorsLast7Days + label: 최근 7일 방문 + format: number + suffix: 명 + - field: pageViewsLast30Days + label: 최근 30일 조회 + format: number + suffix: 회 + - field: onlineNow + label: 현재 접속 + format: number + suffix: 명 + - field: avgEngagedSeconds + label: 평균 체류 + format: duration +``` + +### 회원·갱신 정보 + +```yaml +mappings: + - field: totalMembers + label: 전체 회원 + format: number + suffix: 명 + - field: loggedInNow + label: 로그인 접속 + format: number + suffix: 명 + - field: updatedAt + label: 갱신 + format: relativeDate + locale: ko + numeric: auto +``` + +## 확인 방법 + +```bash +curl https://sori.studio/api/homepage-widget +``` + +응답이 정상인데 Homepage에 값이 나오지 않으면 Homepage 컨테이너 내부에서 해당 URL에 접근할 수 있는지 먼저 확인한다. diff --git a/docs/map.md b/docs/map.md index fc23765..23a7b8d 100644 --- a/docs/map.md +++ b/docs/map.md @@ -210,7 +210,8 @@ | 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용 당일·누적·콘텐츠 통계 위젯 API | +| server/repositories/blog-statistics-repository.js | 공개 게시글·댓글·페이지·태그·회원·누적 방문 통계 집계 | | server/api/navigation.get.js | 공개 네비게이션 API | | server/api/auth/signup.post.js | 회원 가입 API(선택 `emailOtp`, Resend 설정 시 일반 가입에 필수) | | server/api/auth/bootstrap-status.get.js | 최초 관리자 여부 + `emailOtpConfigured` | diff --git a/docs/spec.md b/docs/spec.md index b6c542d..7a33f9c 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`, `todayPageViews`, `onlineNow`, `loggedInNow`, `avgEngagedSeconds`, `items[]`를 반환한다. +- `GET /api/homepage-widget` - gethomepage customapi용 공개 블로그 통계. 기본 필드 `title`, `updatedAt`, `todayVisitors`, `totalVisitors`, `todayPageViews`, `totalPageViews`, `visitorsLast7Days`, `pageViewsLast30Days`, `onlineNow`, `loggedInNow`, `avgEngagedSeconds`, `totalPosts`, `totalComments`, `totalPages`, `totalTags`, `totalMembers`, `items[]`를 반환한다. `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`는 평면, 상세는 위 메뉴/네비게이션 절) diff --git a/docs/todo.md b/docs/todo.md index b5e8d56..1b4146c 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -36,6 +36,7 @@ ## 배포 +- [ ] `npm run preview`가 Nuxt 3.21에서 `127.0.0.1`을 빌드 경로로 해석하는 문제 확인 및 preview 스크립트 옵션 정리 - [ ] NAS 운영 환경 변수 최종 점검 - [ ] NAS 실제 컨테이너 기동 및 도메인/프록시 접속 QA - [ ] 백업/복구 시스템 설계 및 구현(다음달 작업): PostgreSQL `pg_dump` 자동 백업, `public/uploads/` 압축 백업, 보관 주기, 복구 절차, 실제 복구 테스트 포함 diff --git a/docs/update.md b/docs/update.md index d1b5932..90c4169 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 이력 +## v1.7.6 + +- gethomepage Custom API에 누적 방문자·페이지뷰와 전체 게시글·댓글·페이지·태그·회원 통계 추가. +- Homepage에서 필요한 통계 필드를 선택해 사용할 수 있도록 `docs/homepage-widget.md` 가이드 추가. + ## v1.7.5 - 전체 포트 재정검 결과에 맞춰 앱 포트를 `43080`, PostgreSQL 외부 포트를 `43081`로 변경. diff --git a/package-lock.json b/package-lock.json index b2ef95c..df827b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.7.5", + "version": "1.7.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.7.5", + "version": "1.7.6", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 45fe810..f4ea821 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.7.5", + "version": "1.7.6", "private": true, "type": "module", "imports": { diff --git a/server/api/homepage-widget.get.js b/server/api/homepage-widget.get.js index 2ff3544..2752b57 100644 --- a/server/api/homepage-widget.get.js +++ b/server/api/homepage-widget.get.js @@ -1,5 +1,6 @@ import { getSiteSettings } from '../repositories/content-repository' import { getAnalyticsSummary } from '../repositories/analytics-repository' +import { getPublicBlogStatistics } from '../repositories/blog-statistics-repository' /** * 초 단위 체류 시간을 짧은 한국어 문자열로 변환한다. @@ -32,9 +33,10 @@ const formatNumberLabel = (value, unit) => `${Number(value || 0).toLocaleString( * @returns {Promise} 위젯 데이터 */ export default defineEventHandler(async () => { - const [settings, summary] = await Promise.all([ + const [settings, summary, statistics] = await Promise.all([ getSiteSettings(), - getAnalyticsSummary({ days: 1 }) + getAnalyticsSummary({ days: 1 }), + getPublicBlogStatistics() ]) const avgEngagedSeconds = summary.todayAvgEngagedSeconds || summary.avgEngagedSeconds || 0 @@ -42,10 +44,19 @@ export default defineEventHandler(async () => { title: settings.title || 'sori.studio', updatedAt: new Date().toISOString(), todayVisitors: summary.todayVisitors, + totalVisitors: statistics.totalVisitors, todayPageViews: summary.todayPageViews, + totalPageViews: statistics.totalPageViews, onlineNow: summary.onlineNow, loggedInNow: summary.loggedInNow, avgEngagedSeconds, + visitorsLast7Days: summary.visitorsLast7Days, + pageViewsLast30Days: summary.pageViewsLast30Days, + totalPosts: statistics.totalPosts, + totalComments: statistics.totalComments, + totalPages: statistics.totalPages, + totalTags: statistics.totalTags, + totalMembers: statistics.totalMembers, items: [ { name: '오늘 방문자', @@ -66,6 +77,21 @@ export default defineEventHandler(async () => { name: '평균 체류', label: formatDuration(avgEngagedSeconds), value: avgEngagedSeconds + }, + { + name: '누적 방문자', + label: formatNumberLabel(statistics.totalVisitors, '명'), + value: statistics.totalVisitors + }, + { + name: '전체 게시글', + label: formatNumberLabel(statistics.totalPosts, '개'), + value: statistics.totalPosts + }, + { + name: '전체 댓글', + label: formatNumberLabel(statistics.totalComments, '개'), + value: statistics.totalComments } ] } diff --git a/server/repositories/blog-statistics-repository.js b/server/repositories/blog-statistics-repository.js new file mode 100644 index 0000000..12f0caf --- /dev/null +++ b/server/repositories/blog-statistics-repository.js @@ -0,0 +1,80 @@ +import { getSamplePosts } from '../utils/sample-content.js' +import { getPostgresClient } from './postgres-client.js' + +/** + * 공개 블로그 누적 통계를 조회한다. + * @returns {Promise<{ + * totalVisitors: number, + * totalPageViews: number, + * totalPosts: number, + * totalComments: number, + * totalPages: number, + * totalTags: number, + * totalMembers: number + * }>} 공개 블로그 누적 통계 + */ +export const getPublicBlogStatistics = async () => { + const sql = getPostgresClient() + + if (!sql) { + return { + totalVisitors: 0, + totalPageViews: 0, + totalPosts: getSamplePosts().filter((post) => post.status === 'published').length, + totalComments: 0, + totalPages: 0, + totalTags: 0, + totalMembers: 0 + } + } + + const rows = await sql` + SELECT + ( + SELECT COALESCE(SUM(visitors), 0)::int + FROM site_analytics_daily + ) AS "totalVisitors", + ( + SELECT COALESCE(SUM(page_views), 0)::int + FROM site_analytics_daily + ) AS "totalPageViews", + ( + SELECT COUNT(*)::int + FROM posts + WHERE status = 'published' + AND (published_at IS NULL OR published_at <= now()) + ) AS "totalPosts", + ( + SELECT COUNT(*)::int + FROM comments + INNER JOIN posts ON posts.id = comments.post_id + WHERE comments.status = 'published' + AND posts.status = 'published' + AND (posts.published_at IS NULL OR posts.published_at <= now()) + ) AS "totalComments", + ( + SELECT COUNT(*)::int + FROM pages + WHERE status = 'published' + ) AS "totalPages", + ( + SELECT COUNT(*)::int + FROM tags + ) AS "totalTags", + ( + SELECT COUNT(*)::int + FROM users + ) AS "totalMembers" + ` + const row = rows[0] || {} + + return { + totalVisitors: Number(row.totalVisitors || 0), + totalPageViews: Number(row.totalPageViews || 0), + totalPosts: Number(row.totalPosts || 0), + totalComments: Number(row.totalComments || 0), + totalPages: Number(row.totalPages || 0), + totalTags: Number(row.totalTags || 0), + totalMembers: Number(row.totalMembers || 0) + } +}