+ {{ card.label }} +
++ RPM {{ formatAdsenseRpm(card.pageViewsRpm) }} +
++ 페이지뷰 {{ formatNumber(card.pageViews) }} · 노출 {{ formatNumber(card.impressions) }} · 클릭 {{ formatNumber(card.clicks) }} +
+diff --git a/.env.example b/.env.example index 49998bd..23decc2 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,7 @@ NUXT_PUBLIC_SITE_TITLE=sori.studio # GOOGLE_ADSENSE_CLIENT_SECRET= # GOOGLE_ADSENSE_REFRESH_TOKEN= # GOOGLE_ADSENSE_ACCOUNT_NAME=accounts/pub-1234567890123456 +# GOOGLE_ADSENSE_CURRENCY_CODE=KRW # GOOGLE_ADSENSE_CREDENTIALS_PATH= ← 선택. 토큰 발급 스크립트에서 credentials.json을 직접 읽을 때만 사용. # GOOGLE_ADSENSE_OAUTH_PORT=43082 diff --git a/docs/changelog.md b/docs/changelog.md index 94fbf30..ff2f1ec 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,9 @@ # 업데이트 요약 +## v1.10.80 + +- 관리자 대시보드에 AdSense 예상 수익·페이지뷰·노출·클릭 요약 영역을 추가했다. + ## v1.10.79 - AdSense 통계 연동 준비를 위해 refresh token 발급 스크립트와 환경 변수 안내를 추가했다. diff --git a/docs/deploy.md b/docs/deploy.md index f17f58a..be3a25a 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,6 +1,6 @@ # 배포 가이드 -> 로컬 기준 v1.10.79에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. +> 로컬 기준 v1.10.80에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. ## 빌드 유형 @@ -129,7 +129,7 @@ cp .env.example .env.production # 운영 DB에 owner/admin이 없으면 /admin/login에서 ADMIN_EMAIL/ADMIN_PASSWORD로 최초 owner 계정이 생성됨 # MEMBER_SESSION_SECRET은 ADMIN_PASSWORD와 다른 긴 난수 문자열로 반드시 입력 # AdSense 통계를 대시보드에 연동하려면 GOOGLE_ADSENSE_CLIENT_ID, GOOGLE_ADSENSE_CLIENT_SECRET, -# GOOGLE_ADSENSE_REFRESH_TOKEN, GOOGLE_ADSENSE_ACCOUNT_NAME을 추가로 입력 +# GOOGLE_ADSENSE_REFRESH_TOKEN, GOOGLE_ADSENSE_ACCOUNT_NAME, GOOGLE_ADSENSE_CURRENCY_CODE를 추가로 입력 # Docker 네트워크 대역이 NAS 기존 컨테이너와 겹치면 DOCKER_SUBNET을 다른 사설 대역으로 변경 # Docker 내부 앱에서 PostgreSQL에 접근할 때는 sori-studio-db:5432 사용 @@ -197,7 +197,7 @@ GOOGLE_ADSENSE_CREDENTIALS_PATH=/path/to/credentials.json npm run adsense:token ENV_FILE=.env.production npm run adsense:token ``` -스크립트가 표시하는 `http://127.0.0.1:43082/oauth2callback` 주소는 Google Cloud OAuth 클라이언트의 승인된 리다이렉트 URI에 등록되어 있어야 한다. 브라우저 승인 후 터미널에 출력되는 `GOOGLE_ADSENSE_REFRESH_TOKEN`과 `GOOGLE_ADSENSE_ACCOUNT_NAME`을 `.env.production`에 저장한다. 광고 코드의 `data-ad-client="ca-pub-..."` 값은 API 계정 이름으로 쓸 때 `ca-`를 빼고 `accounts/pub-...` 형식으로 적는다. +스크립트가 표시하는 `http://127.0.0.1:43082/oauth2callback` 주소는 Google Cloud OAuth 클라이언트의 승인된 리다이렉트 URI에 등록되어 있어야 한다. 브라우저 승인 후 터미널에 출력되는 `GOOGLE_ADSENSE_REFRESH_TOKEN`과 `GOOGLE_ADSENSE_ACCOUNT_NAME`을 `.env.production`에 저장한다. 광고 코드의 `data-ad-client="ca-pub-..."` 값은 API 계정 이름으로 쓸 때 `ca-`를 빼고 `accounts/pub-...` 형식으로 적는다. 대시보드 표시 통화는 필요하면 `GOOGLE_ADSENSE_CURRENCY_CODE=KRW`처럼 지정한다. ### Docker 네트워크 충돌 대응 diff --git a/docs/history.md b/docs/history.md index 044966a..3992f86 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,3 +1,9 @@ +## 2026-07-20 v1.10.80 — AdSense 비밀값은 서버에서만 다룬다 + +관리자 대시보드에 AdSense 수익 통계를 표시하려면 Google OAuth client secret과 refresh token이 필요하다. 이 값은 브라우저에 노출되면 안 되므로 관리자 화면에서 직접 Google API를 호출하지 않고, 서버 전용 `/admin/api/adsense/summary`에서만 환경 변수를 읽어 access token을 발급한다. + +AdSense API 실패가 대시보드 전체 장애로 보이면 운영자가 현재 접속자나 자체 통계까지 확인하지 못하므로, API 오류와 설정 누락은 AdSense 카드 내부 안내로 제한한다. 외부 보고서 조회는 오늘·어제·이번 달 세 구간만 요청해 관리자 첫 화면에서 필요한 기념성/운영성 숫자를 가볍게 확인하는 데 집중한다. + ## 2026-07-20 v1.10.74 — 페이지 목록은 게시글 목록의 작업 화면 언어를 따른다 게시글과 페이지는 노출 방식은 다르지만 관리자 목록에서 운영자가 하는 일은 검색, 상태 확인, 통계 확인, 수정 진입으로 거의 같다. 따라서 페이지 목록도 게시글 목록과 같은 상단 검색·필터·정렬 구조와 추천/썸네일 열을 사용해 두 화면의 탐색 리듬을 맞춘다. 페이지 전용 서버 페이지네이션은 아직 두지 않고, 현재 API 응답 위에서 클라이언트 필터로 UI 일관성을 먼저 확보한다. diff --git a/docs/map.md b/docs/map.md index 1e97f48..a771092 100644 --- a/docs/map.md +++ b/docs/map.md @@ -352,6 +352,7 @@ | server/repositories/comment-repository.js | 댓글 조회/생성 저장소 | | server/repositories/analytics-repository.js | 방문·게시물·페이지·유입 통계 집계·관리자 요약/날짜 상세 조회·방문자 해시 보관 정리 | | server/utils/analytics-pageview-input.js | `POST /api/analytics/pageview` 검증·관리자 세션 제외·기록 | +| server/utils/adsense-report.js | AdSense OAuth access token 발급·보고서 조회·대시보드 요약 생성 | | server/utils/zip-writer.js | 게시물 Export ZIP 생성 유틸리티 | | server/utils/zip-reader.js | 게시물 Import ZIP 읽기 유틸리티 | | server/api/analytics/pageview.post.js | 공개 통계 수집 API | @@ -365,6 +366,7 @@ | server/routes/admin/api/analytics/realtime.get.js | 관리자 실시간 접속자 API | | server/routes/admin/api/analytics/traffic.get.js | 관리자 유입원·디바이스·키워드 통계 API | | server/routes/admin/api/analytics/range-detail.get.js | 관리자 통계 추이 날짜·구간 상세 API | +| server/routes/admin/api/adsense/summary.get.js | 관리자 대시보드 AdSense 예상 수익·페이지뷰·노출·클릭 요약 API | | plugins/site-analytics.client.js | 공개 라우트 pageview·heartbeat·read 클라이언트 전송 | | plugins/bfcache-page-restore.client.js | 뒤로가기 캐시(bfcache) 복원 시 라우터 경로와 실제 URL 어긋남을 감지해 새로고침(HTML 문서 모드 페이지 이동 후 뒤로가기 잔상 방지) | | plugins/navigation-lock.client.js | 페이지 이동 중 `html.site-navigating` 클래스로 링크·버튼 클릭을 잠가 중복 클릭 차단(안전 타임아웃 포함) | diff --git a/docs/spec.md b/docs/spec.md index 85b58f7..fa6cfc7 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -167,6 +167,7 @@ - 각 값은 관리자만 입력하는 신뢰 콘텐츠를 전제로 하며, 애드센스에서 제공하는 반응형 또는 고정 크기 HTML 코드를 그대로 붙여 넣는다. - 공개 화면은 `SiteAdSlot` 컴포넌트로 광고 슬롯을 렌더링한다. 값이 비어 있으면 DOM을 만들지 않고, 값이 있으면 클라이언트에서 HTML을 삽입한 뒤 내부 script를 실행 가능한 노드로 교체해 Nuxt 클라이언트 라우팅 후에도 광고 코드가 동작하게 한다. - 공통 헤더·푸터 코드와 ads.txt는 기존 사이트 코드 카드에서 관리하고, 화면 위치가 필요한 광고 단위는 Ads 카드에서 관리한다. +- 관리자 대시보드의 AdSense 요약은 `/admin/api/adsense/summary`에서 조회한다. 서버는 `GOOGLE_ADSENSE_CLIENT_ID`, `GOOGLE_ADSENSE_CLIENT_SECRET`, `GOOGLE_ADSENSE_REFRESH_TOKEN`, `GOOGLE_ADSENSE_ACCOUNT_NAME`을 사용해 Google access token을 발급하고 AdSense Management API `reports:generate`로 오늘·어제·이번 달 `ESTIMATED_EARNINGS`, `PAGE_VIEWS`, `IMPRESSIONS`, `CLICKS`, `PAGE_VIEWS_RPM`을 조회한다. `GOOGLE_ADSENSE_CURRENCY_CODE`가 있으면 보고서 통화 코드로 전달한다. 설정 누락 또는 API 오류 시 대시보드 전체를 실패시키지 않고 AdSense 카드에 안내 문구만 표시한다. ### 공개 인증 화면(초기) diff --git a/docs/update.md b/docs/update.md index d244df1..602a4f4 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,12 @@ # 업데이트 이력 +## v1.10.80 + +- 관리자 대시보드 상단 요약 아래에 AdSense 요약 카드 추가. +- `/admin/api/adsense/summary` API 추가. +- AdSense refresh token 기반 access token 발급과 오늘·어제·이번 달 보고서 조회 유틸 추가. +- AdSense API 통화 코드 환경 변수 예시 추가. + ## v1.10.79 - AdSense Management API refresh token 발급용 `npm run adsense:token` 스크립트 추가. diff --git a/package-lock.json b/package-lock.json index c5b25db..03fd0cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.10.79", + "version": "1.10.80", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.10.79", + "version": "1.10.80", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index f9f4ab8..4afc8e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.10.79", + "version": "1.10.80", "private": true, "type": "module", "imports": { diff --git a/pages/admin/index.vue b/pages/admin/index.vue index 6d093e8..6fccfeb 100644 --- a/pages/admin/index.vue +++ b/pages/admin/index.vue @@ -62,6 +62,33 @@ const { data: realtime, refresh: refreshRealtime } = await useFetch('/admin/api/ }) }) +const createEmptyAdsensePeriod = (label) => ({ + label, + estimatedEarnings: 0, + pageViews: 0, + impressions: 0, + clicks: 0, + pageViewsRpm: 0 +}) + +const { + data: adsenseSummary, + pending: isAdsenseLoading, + refresh: refreshAdsenseSummary +} = await useFetch('/admin/api/adsense/summary', { + server: false, + default: () => ({ + configured: false, + ok: false, + message: '', + currencyCode: 'KRW', + generatedAt: '', + today: createEmptyAdsensePeriod('오늘'), + yesterday: createEmptyAdsensePeriod('어제'), + month: createEmptyAdsensePeriod('이번 달') + }) +}) + const todayKey = getAnalyticsDayKey() const publishedCount = computed(() => posts.value.filter((post) => post.status === 'published').length) const draftCount = computed(() => posts.value.filter((post) => post.status === 'draft').length) @@ -152,6 +179,79 @@ const chartPagination = computed(() => { return paginateAnalyticsTrendRows(trendRows.value, selectedAnalyticsPeriod.value, analyticsChartPageOffset.value) }) +const adsenseCards = computed(() => [ + adsenseSummary.value.today || createEmptyAdsensePeriod('오늘'), + adsenseSummary.value.yesterday || createEmptyAdsensePeriod('어제'), + adsenseSummary.value.month || createEmptyAdsensePeriod('이번 달') +]) + +const adsenseGeneratedLabel = computed(() => { + const value = adsenseSummary.value?.generatedAt + + if (!value) { + return '' + } + + return formatCompactDateTime(value) +}) + +/** + * AdSense 통화 코드를 표시 가능한 값으로 보정한다. + * @returns {string} 통화 코드 + */ +const getAdsenseCurrencyCode = () => { + const currencyCode = adsenseSummary.value?.currencyCode || 'KRW' + + try { + new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: currencyCode + }).format(0) + return currencyCode + } catch { + return 'KRW' + } +} + +/** + * 숫자를 천 단위 구분 문자열로 변환한다. + * @param {number} value - 숫자 값 + * @returns {string} 표시 문자열 + */ +const formatNumber = (value) => { + return new Intl.NumberFormat('ko-KR').format(Number(value || 0)) +} + +/** + * AdSense 금액을 통화 문자열로 변환한다. + * @param {number} value - 금액 + * @returns {string} 표시 문자열 + */ +const formatAdsenseCurrency = (value) => { + const currencyCode = getAdsenseCurrencyCode() + + return new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: currencyCode, + maximumFractionDigits: currencyCode === 'KRW' ? 0 : 2 + }).format(Number(value || 0)) +} + +/** + * AdSense RPM을 표시 문자열로 변환한다. + * @param {number} value - RPM + * @returns {string} 표시 문자열 + */ +const formatAdsenseRpm = (value) => { + const currencyCode = getAdsenseCurrencyCode() + + return new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: currencyCode, + maximumFractionDigits: currencyCode === 'KRW' ? 0 : 2 + }).format(Number(value || 0)) +} + /** * 통계 차트 이전 기간으로 이동한다. * @returns {void} @@ -447,6 +547,29 @@ const formatCompactDate = (value) => { return `${year}.${month}.${day}` } +/** + * 통계 갱신 시각을 짧은 문자열로 변환한다. + * @param {string | null | undefined} value - ISO 날짜 + * @returns {string} 표시 날짜와 시간 + */ +const formatCompactDateTime = (value) => { + if (!value) { + return '-' + } + + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return '-' + } + + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + return `${year}.${month}.${day} ${hours}:${minutes}` +} + /** * 스크롤 도달 지표를 짧은 문자열로 반환한다. * @param {Object} row - 통계 행 @@ -562,6 +685,64 @@ watch(trendRows, () => {
++ 예상 수익·페이지뷰·클릭 기준 요약 + · {{ adsenseGeneratedLabel }} 갱신 +
++ {{ card.label }} +
++ RPM {{ formatAdsenseRpm(card.pageViewsRpm) }} +
++ 페이지뷰 {{ formatNumber(card.pageViews) }} · 노출 {{ formatNumber(card.impressions) }} · 클릭 {{ formatNumber(card.clicks) }} +
+