대시보드 AdSense 요약 추가
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.10.80
|
||||
|
||||
- 관리자 대시보드에 AdSense 예상 수익·페이지뷰·노출·클릭 요약 영역을 추가했다.
|
||||
|
||||
## v1.10.79
|
||||
|
||||
- AdSense 통계 연동 준비를 위해 refresh token 발급 스크립트와 환경 변수 안내를 추가했다.
|
||||
|
||||
@@ -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 네트워크 충돌 대응
|
||||
|
||||
|
||||
@@ -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 일관성을 먼저 확보한다.
|
||||
|
||||
@@ -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` 클래스로 링크·버튼 클릭을 잠가 중복 클릭 차단(안전 타임아웃 포함) |
|
||||
|
||||
@@ -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 카드에 안내 문구만 표시한다.
|
||||
|
||||
### 공개 인증 화면(초기)
|
||||
|
||||
|
||||
@@ -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` 스크립트 추가.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.79",
|
||||
"version": "1.10.80",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -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, () => {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="admin-dashboard__adsense border border-line bg-white p-4">
|
||||
<div class="admin-dashboard__adsense-header flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="admin-dashboard__section-title text-sm font-semibold text-ink">
|
||||
AdSense
|
||||
</h2>
|
||||
<p class="admin-dashboard__section-description mt-1 text-xs text-muted">
|
||||
예상 수익·페이지뷰·클릭 기준 요약
|
||||
<span v-if="adsenseGeneratedLabel">· {{ adsenseGeneratedLabel }} 갱신</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-dashboard__adsense-refresh border border-line bg-white px-3 py-1 text-xs font-medium text-muted transition hover:bg-paper hover:text-ink disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isAdsenseLoading"
|
||||
@click="refreshAdsenseSummary"
|
||||
>
|
||||
{{ isAdsenseLoading ? '갱신 중' : '새로고침' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="!adsenseSummary.configured"
|
||||
class="admin-dashboard__adsense-message mt-3 border border-dashed border-line bg-paper px-3 py-2 text-xs text-muted"
|
||||
>
|
||||
AdSense 환경 변수를 설정하면 이곳에 수익 요약이 표시됩니다.
|
||||
</p>
|
||||
<p
|
||||
v-else-if="!adsenseSummary.ok"
|
||||
class="admin-dashboard__adsense-message mt-3 border border-red-100 bg-red-50 px-3 py-2 text-xs text-red-700"
|
||||
>
|
||||
{{ adsenseSummary.message || 'AdSense 통계를 불러오지 못했습니다.' }}
|
||||
</p>
|
||||
|
||||
<div class="admin-dashboard__adsense-grid mt-3 grid gap-3 lg:grid-cols-3">
|
||||
<article
|
||||
v-for="card in adsenseCards"
|
||||
:key="card.label"
|
||||
class="admin-dashboard__adsense-card border border-line bg-paper p-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<p class="text-xs font-semibold uppercase text-muted">
|
||||
{{ card.label }}
|
||||
</p>
|
||||
<p class="text-[11px] text-muted">
|
||||
RPM {{ formatAdsenseRpm(card.pageViewsRpm) }}
|
||||
</p>
|
||||
</div>
|
||||
<strong class="mt-2 block text-xl text-ink">
|
||||
{{ formatAdsenseCurrency(card.estimatedEarnings) }}
|
||||
</strong>
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
페이지뷰 {{ formatNumber(card.pageViews) }} · 노출 {{ formatNumber(card.impressions) }} · 클릭 {{ formatNumber(card.clicks) }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-dashboard__charts border border-line bg-white p-4">
|
||||
<div class="admin-dashboard__charts-header flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
|
||||
13
server/routes/admin/api/adsense/summary.get.js
Normal file
13
server/routes/admin/api/adsense/summary.get.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth.js'
|
||||
import { getAdsenseDashboardSummary } from '../../../../utils/adsense-report.js'
|
||||
|
||||
/**
|
||||
* 관리자 대시보드 AdSense 요약 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Object>} AdSense 요약
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
return getAdsenseDashboardSummary()
|
||||
})
|
||||
304
server/utils/adsense-report.js
Normal file
304
server/utils/adsense-report.js
Normal file
@@ -0,0 +1,304 @@
|
||||
const ADSENSE_TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
||||
const ADSENSE_API_BASE_URL = 'https://adsense.googleapis.com/v2'
|
||||
const ADSENSE_REPORT_METRICS = [
|
||||
'ESTIMATED_EARNINGS',
|
||||
'PAGE_VIEWS',
|
||||
'IMPRESSIONS',
|
||||
'CLICKS',
|
||||
'PAGE_VIEWS_RPM'
|
||||
]
|
||||
|
||||
let cachedAccessToken = ''
|
||||
let cachedAccessTokenExpiresAt = 0
|
||||
|
||||
/**
|
||||
* AdSense API 연동 설정을 반환한다.
|
||||
* @returns {{ clientId: string, clientSecret: string, refreshToken: string, accountName: string, currencyCode: string }} AdSense 설정
|
||||
*/
|
||||
const getAdsenseConfig = () => ({
|
||||
clientId: process.env.GOOGLE_ADSENSE_CLIENT_ID || '',
|
||||
clientSecret: process.env.GOOGLE_ADSENSE_CLIENT_SECRET || '',
|
||||
refreshToken: process.env.GOOGLE_ADSENSE_REFRESH_TOKEN || '',
|
||||
accountName: process.env.GOOGLE_ADSENSE_ACCOUNT_NAME || '',
|
||||
currencyCode: process.env.GOOGLE_ADSENSE_CURRENCY_CODE || ''
|
||||
})
|
||||
|
||||
/**
|
||||
* AdSense API 호출에 필요한 설정이 모두 있는지 확인한다.
|
||||
* @param {ReturnType<typeof getAdsenseConfig>} config - AdSense 설정
|
||||
* @returns {boolean} 설정 완료 여부
|
||||
*/
|
||||
const isAdsenseConfigured = (config) => {
|
||||
return Boolean(config.clientId && config.clientSecret && config.refreshToken && config.accountName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Date 객체를 Google API Date 필드로 변환한다.
|
||||
* @param {Date} date - 날짜
|
||||
* @returns {{ year: string, month: string, day: string }} Google API Date 필드
|
||||
*/
|
||||
const toGoogleDateFields = (date) => ({
|
||||
year: String(date.getFullYear()),
|
||||
month: String(date.getMonth() + 1),
|
||||
day: String(date.getDate())
|
||||
})
|
||||
|
||||
/**
|
||||
* 로컬 시간 기준 날짜 시작점을 만든다.
|
||||
* @param {Date} date - 기준 날짜
|
||||
* @returns {Date} 날짜 시작점
|
||||
*/
|
||||
const startOfLocalDay = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
|
||||
/**
|
||||
* 날짜에 일 단위를 더한다.
|
||||
* @param {Date} date - 기준 날짜
|
||||
* @param {number} days - 더할 일수
|
||||
* @returns {Date} 계산된 날짜
|
||||
*/
|
||||
const addDays = (date, days) => {
|
||||
const nextDate = new Date(date)
|
||||
nextDate.setDate(nextDate.getDate() + days)
|
||||
return nextDate
|
||||
}
|
||||
|
||||
/**
|
||||
* 이번 달 시작 날짜를 만든다.
|
||||
* @param {Date} date - 기준 날짜
|
||||
* @returns {Date} 이번 달 시작 날짜
|
||||
*/
|
||||
const startOfLocalMonth = (date) => new Date(date.getFullYear(), date.getMonth(), 1)
|
||||
|
||||
/**
|
||||
* 빈 AdSense 지표를 만든다.
|
||||
* @param {string} label - 기간 라벨
|
||||
* @returns {Object} 빈 지표
|
||||
*/
|
||||
const createEmptyAdsenseMetric = (label) => ({
|
||||
label,
|
||||
estimatedEarnings: 0,
|
||||
pageViews: 0,
|
||||
impressions: 0,
|
||||
clicks: 0,
|
||||
pageViewsRpm: 0
|
||||
})
|
||||
|
||||
/**
|
||||
* 숫자 셀 값을 안전하게 변환한다.
|
||||
* @param {unknown} value - 원본 값
|
||||
* @returns {number} 숫자 값
|
||||
*/
|
||||
const toMetricNumber = (value) => {
|
||||
const number = Number(value || 0)
|
||||
return Number.isFinite(number) ? number : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Google OAuth refresh token으로 access token을 발급한다.
|
||||
* @param {ReturnType<typeof getAdsenseConfig>} config - AdSense 설정
|
||||
* @returns {Promise<string>} access token
|
||||
*/
|
||||
const getAdsenseAccessToken = async (config) => {
|
||||
const now = Date.now()
|
||||
|
||||
if (cachedAccessToken && cachedAccessTokenExpiresAt > now + 60000) {
|
||||
return cachedAccessToken
|
||||
}
|
||||
|
||||
const response = await fetch(ADSENSE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
refresh_token: config.refreshToken,
|
||||
grant_type: 'refresh_token'
|
||||
})
|
||||
})
|
||||
const data = await response.json().catch(() => ({}))
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error_description || data.error || 'AdSense access token 발급 실패')
|
||||
}
|
||||
|
||||
cachedAccessToken = data.access_token || ''
|
||||
cachedAccessTokenExpiresAt = now + (Number(data.expires_in || 0) * 1000)
|
||||
|
||||
if (!cachedAccessToken) {
|
||||
throw new Error('AdSense access token 응답이 비어 있습니다.')
|
||||
}
|
||||
|
||||
return cachedAccessToken
|
||||
}
|
||||
|
||||
/**
|
||||
* AdSense 보고서 URL을 만든다.
|
||||
* @param {Object} options - 보고서 옵션
|
||||
* @param {string} options.accountName - AdSense 계정 이름
|
||||
* @param {Date} options.startDate - 시작일
|
||||
* @param {Date} options.endDate - 종료일
|
||||
* @param {string} options.currencyCode - 통화 코드
|
||||
* @returns {URL} 보고서 URL
|
||||
*/
|
||||
const createAdsenseReportUrl = ({ accountName, startDate, endDate, currencyCode }) => {
|
||||
const url = new URL(`${ADSENSE_API_BASE_URL}/${accountName}/reports:generate`)
|
||||
const start = toGoogleDateFields(startDate)
|
||||
const end = toGoogleDateFields(endDate)
|
||||
|
||||
for (const metric of ADSENSE_REPORT_METRICS) {
|
||||
url.searchParams.append('metrics', metric)
|
||||
}
|
||||
|
||||
url.searchParams.set('startDate.year', start.year)
|
||||
url.searchParams.set('startDate.month', start.month)
|
||||
url.searchParams.set('startDate.day', start.day)
|
||||
url.searchParams.set('endDate.year', end.year)
|
||||
url.searchParams.set('endDate.month', end.month)
|
||||
url.searchParams.set('endDate.day', end.day)
|
||||
url.searchParams.set('languageCode', 'ko-KR')
|
||||
|
||||
if (currencyCode) {
|
||||
url.searchParams.set('currencyCode', currencyCode)
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* AdSense 단일 기간 보고서를 조회한다.
|
||||
* @param {Object} options - 조회 옵션
|
||||
* @param {string} options.label - 기간 라벨
|
||||
* @param {string} options.accessToken - access token
|
||||
* @param {ReturnType<typeof getAdsenseConfig>} options.config - AdSense 설정
|
||||
* @param {Date} options.startDate - 시작일
|
||||
* @param {Date} options.endDate - 종료일
|
||||
* @returns {Promise<Object>} 기간 지표
|
||||
*/
|
||||
const fetchAdsensePeriodReport = async ({ label, accessToken, config, startDate, endDate }) => {
|
||||
const url = createAdsenseReportUrl({
|
||||
accountName: config.accountName,
|
||||
startDate,
|
||||
endDate,
|
||||
currencyCode: config.currencyCode
|
||||
})
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
})
|
||||
const data = await response.json().catch(() => ({}))
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error?.message || `AdSense 보고서 조회 실패: ${response.status}`)
|
||||
}
|
||||
|
||||
const row = Array.isArray(data.rows) ? data.rows[0] : null
|
||||
const cells = row && Array.isArray(row.cells) ? row.cells : []
|
||||
const headers = Array.isArray(data.headers) ? data.headers : []
|
||||
const metric = createEmptyAdsenseMetric(label)
|
||||
|
||||
headers.forEach((header, index) => {
|
||||
const value = toMetricNumber(cells[index]?.value)
|
||||
|
||||
if (header.name === 'ESTIMATED_EARNINGS') {
|
||||
metric.estimatedEarnings = value
|
||||
return
|
||||
}
|
||||
|
||||
if (header.name === 'PAGE_VIEWS') {
|
||||
metric.pageViews = value
|
||||
return
|
||||
}
|
||||
|
||||
if (header.name === 'IMPRESSIONS') {
|
||||
metric.impressions = value
|
||||
return
|
||||
}
|
||||
|
||||
if (header.name === 'CLICKS') {
|
||||
metric.clicks = value
|
||||
return
|
||||
}
|
||||
|
||||
if (header.name === 'PAGE_VIEWS_RPM') {
|
||||
metric.pageViewsRpm = value
|
||||
}
|
||||
})
|
||||
|
||||
return metric
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 대시보드용 AdSense 요약을 조회한다.
|
||||
* @returns {Promise<Object>} AdSense 요약
|
||||
*/
|
||||
export const getAdsenseDashboardSummary = async () => {
|
||||
const config = getAdsenseConfig()
|
||||
|
||||
if (!isAdsenseConfigured(config)) {
|
||||
return {
|
||||
configured: false,
|
||||
ok: false,
|
||||
message: 'AdSense API 환경 변수가 아직 설정되지 않았습니다.',
|
||||
currencyCode: config.currencyCode || 'KRW',
|
||||
generatedAt: new Date().toISOString(),
|
||||
today: createEmptyAdsenseMetric('오늘'),
|
||||
yesterday: createEmptyAdsenseMetric('어제'),
|
||||
month: createEmptyAdsenseMetric('이번 달')
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const today = startOfLocalDay(new Date())
|
||||
const yesterday = addDays(today, -1)
|
||||
const monthStart = startOfLocalMonth(today)
|
||||
const accessToken = await getAdsenseAccessToken(config)
|
||||
const [todayMetric, yesterdayMetric, monthMetric] = await Promise.all([
|
||||
fetchAdsensePeriodReport({
|
||||
label: '오늘',
|
||||
accessToken,
|
||||
config,
|
||||
startDate: today,
|
||||
endDate: today
|
||||
}),
|
||||
fetchAdsensePeriodReport({
|
||||
label: '어제',
|
||||
accessToken,
|
||||
config,
|
||||
startDate: yesterday,
|
||||
endDate: yesterday
|
||||
}),
|
||||
fetchAdsensePeriodReport({
|
||||
label: '이번 달',
|
||||
accessToken,
|
||||
config,
|
||||
startDate: monthStart,
|
||||
endDate: today
|
||||
})
|
||||
])
|
||||
|
||||
return {
|
||||
configured: true,
|
||||
ok: true,
|
||||
message: '',
|
||||
currencyCode: config.currencyCode || 'KRW',
|
||||
generatedAt: new Date().toISOString(),
|
||||
today: todayMetric,
|
||||
yesterday: yesterdayMetric,
|
||||
month: monthMetric
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
configured: true,
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : 'AdSense 통계를 불러오지 못했습니다.',
|
||||
currencyCode: config.currencyCode || 'KRW',
|
||||
generatedAt: new Date().toISOString(),
|
||||
today: createEmptyAdsenseMetric('오늘'),
|
||||
yesterday: createEmptyAdsenseMetric('어제'),
|
||||
month: createEmptyAdsenseMetric('이번 달')
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user