블로그 통계 위젯 확장 v1.7.6
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.7.6
|
||||
|
||||
- Homepage 대시보드에서 오늘·누적 방문자, 조회수, 게시글·댓글·페이지·태그·회원 수를 선택해 표시할 수 있도록 통계 API를 확장했다.
|
||||
- Homepage Custom API 연결 예시와 필드별 사용 가이드를 추가했다.
|
||||
|
||||
## v1.6.0
|
||||
|
||||
- 카드 썸네일과 프로필 이미지 탭에서도 목록 카드 오른쪽 위에 사용 개수 배지가 보이도록 했다.
|
||||
|
||||
@@ -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` 적용이 필요하다.
|
||||
|
||||
@@ -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은 기존처럼 최우선이며, 관리자 화면에는 현재 적용될 아이콘 미리보기 열을 둔다.
|
||||
|
||||
159
docs/homepage-widget.md
Normal file
159
docs/homepage-widget.md
Normal file
@@ -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에 접근할 수 있는지 먼저 확인한다.
|
||||
@@ -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` |
|
||||
|
||||
@@ -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`는 평면, 상세는 위 메뉴/네비게이션 절)
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
|
||||
## 배포
|
||||
|
||||
- [ ] `npm run preview`가 Nuxt 3.21에서 `127.0.0.1`을 빌드 경로로 해석하는 문제 확인 및 preview 스크립트 옵션 정리
|
||||
- [ ] NAS 운영 환경 변수 최종 점검
|
||||
- [ ] NAS 실제 컨테이너 기동 및 도메인/프록시 접속 QA
|
||||
- [ ] 백업/복구 시스템 설계 및 구현(다음달 작업): PostgreSQL `pg_dump` 자동 백업, `public/uploads/` 압축 백업, 보관 주기, 복구 절차, 실제 복구 테스트 포함
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.7.6
|
||||
|
||||
- gethomepage Custom API에 누적 방문자·페이지뷰와 전체 게시글·댓글·페이지·태그·회원 통계 추가.
|
||||
- Homepage에서 필요한 통계 필드를 선택해 사용할 수 있도록 `docs/homepage-widget.md` 가이드 추가.
|
||||
|
||||
## v1.7.5
|
||||
|
||||
- 전체 포트 재정검 결과에 맞춰 앱 포트를 `43080`, PostgreSQL 외부 포트를 `43081`로 변경.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.7.5",
|
||||
"version": "1.7.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -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<Object>} 위젯 데이터
|
||||
*/
|
||||
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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
80
server/repositories/blog-statistics-repository.js
Normal file
80
server/repositories/blog-statistics-repository.js
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user