AdSense 대시보드 요약 정리
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# 업데이트 요약
|
# 업데이트 요약
|
||||||
|
|
||||||
|
## v1.10.81
|
||||||
|
|
||||||
|
- AdSense 대시보드를 공식 홈 카드에 가깝게 오늘·어제·지난 7일·이번 달·최근 28일·잔고 형태로 정리했다.
|
||||||
|
- AdSense 금액이 응답 통화와 소수점을 반영하도록 고쳤다.
|
||||||
|
|
||||||
## v1.10.80
|
## v1.10.80
|
||||||
|
|
||||||
- 관리자 대시보드에 AdSense 예상 수익·페이지뷰·노출·클릭 요약 영역을 추가했다.
|
- 관리자 대시보드에 AdSense 예상 수익·페이지뷰·노출·클릭 요약 영역을 추가했다.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 배포 가이드
|
# 배포 가이드
|
||||||
|
|
||||||
> 로컬 기준 v1.10.80에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
> 로컬 기준 v1.10.81에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||||
|
|
||||||
## 빌드 유형
|
## 빌드 유형
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
## 2026-07-20 v1.10.81 — AdSense 홈 요약은 API totals와 응답 통화를 기준으로 본다
|
||||||
|
|
||||||
|
AdSense Management API의 보고서 응답은 차원 없이 요약을 요청할 때 `rows`보다 `totals`가 실제 합계를 담는 기준이 된다. 관리자 대시보드가 `rows[0]`만 읽으면 공식 AdSense 화면에 수익이 있어도 0으로 보일 수 있으므로, 보고서 합계는 `ReportResult.totals`를 먼저 읽고 필요한 경우에만 첫 row로 보완한다.
|
||||||
|
|
||||||
|
또한 수익 통화는 운영자가 임의로 지정한 기본값보다 API 응답 헤더의 `currencyCode`가 공식 화면과 맞을 가능성이 높다. 따라서 금액 표시는 응답 통화 코드를 우선 사용하고, AdSense 홈 화면의 확인 리듬에 맞춰 오늘 현재까지·어제·지난 7일·이번 달·최근 28일·잔고를 한 영역 안에서 보여준다.
|
||||||
|
|
||||||
## 2026-07-20 v1.10.80 — AdSense 비밀값은 서버에서만 다룬다
|
## 2026-07-20 v1.10.80 — AdSense 비밀값은 서버에서만 다룬다
|
||||||
|
|
||||||
관리자 대시보드에 AdSense 수익 통계를 표시하려면 Google OAuth client secret과 refresh token이 필요하다. 이 값은 브라우저에 노출되면 안 되므로 관리자 화면에서 직접 Google API를 호출하지 않고, 서버 전용 `/admin/api/adsense/summary`에서만 환경 변수를 읽어 access token을 발급한다.
|
관리자 대시보드에 AdSense 수익 통계를 표시하려면 Google OAuth client secret과 refresh token이 필요하다. 이 값은 브라우저에 노출되면 안 되므로 관리자 화면에서 직접 Google API를 호출하지 않고, 서버 전용 `/admin/api/adsense/summary`에서만 환경 변수를 읽어 access token을 발급한다.
|
||||||
|
|||||||
@@ -167,7 +167,7 @@
|
|||||||
- 각 값은 관리자만 입력하는 신뢰 콘텐츠를 전제로 하며, 애드센스에서 제공하는 반응형 또는 고정 크기 HTML 코드를 그대로 붙여 넣는다.
|
- 각 값은 관리자만 입력하는 신뢰 콘텐츠를 전제로 하며, 애드센스에서 제공하는 반응형 또는 고정 크기 HTML 코드를 그대로 붙여 넣는다.
|
||||||
- 공개 화면은 `SiteAdSlot` 컴포넌트로 광고 슬롯을 렌더링한다. 값이 비어 있으면 DOM을 만들지 않고, 값이 있으면 클라이언트에서 HTML을 삽입한 뒤 내부 script를 실행 가능한 노드로 교체해 Nuxt 클라이언트 라우팅 후에도 광고 코드가 동작하게 한다.
|
- 공개 화면은 `SiteAdSlot` 컴포넌트로 광고 슬롯을 렌더링한다. 값이 비어 있으면 DOM을 만들지 않고, 값이 있으면 클라이언트에서 HTML을 삽입한 뒤 내부 script를 실행 가능한 노드로 교체해 Nuxt 클라이언트 라우팅 후에도 광고 코드가 동작하게 한다.
|
||||||
- 공통 헤더·푸터 코드와 ads.txt는 기존 사이트 코드 카드에서 관리하고, 화면 위치가 필요한 광고 단위는 Ads 카드에서 관리한다.
|
- 공통 헤더·푸터 코드와 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 카드에 안내 문구만 표시한다.
|
- 관리자 대시보드의 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`로 오늘 현재까지·어제·지난 7일·이번 달·최근 28일의 `ESTIMATED_EARNINGS`, `PAGE_VIEWS`, `IMPRESSIONS`, `CLICKS`, `PAGE_VIEWS_RPM`을 조회한다. 보고서 값은 `ReportResult.totals`를 우선 사용하고, 통화 표시는 응답 헤더의 currencyCode를 우선 따른다. 잔고는 AdSense Management API `payments.list`의 unpaid payment를 사용한다. `GOOGLE_ADSENSE_CURRENCY_CODE`가 있으면 보고서 통화 코드로 전달한다. 설정 누락 또는 API 오류 시 대시보드 전체를 실패시키지 않고 AdSense 카드 내부 상태로 제한한다.
|
||||||
|
|
||||||
### 공개 인증 화면(초기)
|
### 공개 인증 화면(초기)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.10.81
|
||||||
|
|
||||||
|
- 관리자 대시보드 AdSense 설정 안내 문구 제거.
|
||||||
|
- AdSense 보고서 응답의 `totals` 행을 우선 읽도록 수정.
|
||||||
|
- AdSense 요약을 오늘 현재까지·어제·지난 7일·이번 달·최근 28일·잔고 형태로 정리.
|
||||||
|
- AdSense 금액 표시가 보고서 응답 통화 코드와 소수점 두 자리를 반영하도록 수정.
|
||||||
|
|
||||||
## v1.10.80
|
## v1.10.80
|
||||||
|
|
||||||
- 관리자 대시보드 상단 요약 아래에 AdSense 요약 카드 추가.
|
- 관리자 대시보드 상단 요약 아래에 AdSense 요약 카드 추가.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.10.80",
|
"version": "1.10.81",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.10.80",
|
"version": "1.10.81",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.10.80",
|
"version": "1.10.81",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -64,11 +64,19 @@ const { data: realtime, refresh: refreshRealtime } = await useFetch('/admin/api/
|
|||||||
|
|
||||||
const createEmptyAdsensePeriod = (label) => ({
|
const createEmptyAdsensePeriod = (label) => ({
|
||||||
label,
|
label,
|
||||||
|
key: '',
|
||||||
estimatedEarnings: 0,
|
estimatedEarnings: 0,
|
||||||
pageViews: 0,
|
pageViews: 0,
|
||||||
impressions: 0,
|
impressions: 0,
|
||||||
clicks: 0,
|
clicks: 0,
|
||||||
pageViewsRpm: 0
|
pageViewsRpm: 0,
|
||||||
|
currencyCode: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const createEmptyAdsenseBalance = () => ({
|
||||||
|
balanceAmount: '',
|
||||||
|
lastPaymentAmount: '',
|
||||||
|
lastPaymentDate: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -81,11 +89,10 @@ const {
|
|||||||
configured: false,
|
configured: false,
|
||||||
ok: false,
|
ok: false,
|
||||||
message: '',
|
message: '',
|
||||||
currencyCode: 'KRW',
|
currencyCode: 'USD',
|
||||||
generatedAt: '',
|
generatedAt: '',
|
||||||
today: createEmptyAdsensePeriod('오늘'),
|
periods: [],
|
||||||
yesterday: createEmptyAdsensePeriod('어제'),
|
balance: createEmptyAdsenseBalance()
|
||||||
month: createEmptyAdsensePeriod('이번 달')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -179,11 +186,8 @@ const chartPagination = computed(() => {
|
|||||||
return paginateAnalyticsTrendRows(trendRows.value, selectedAnalyticsPeriod.value, analyticsChartPageOffset.value)
|
return paginateAnalyticsTrendRows(trendRows.value, selectedAnalyticsPeriod.value, analyticsChartPageOffset.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
const adsenseCards = computed(() => [
|
const adsenseCards = computed(() => adsenseSummary.value.periods || [])
|
||||||
adsenseSummary.value.today || createEmptyAdsensePeriod('오늘'),
|
const adsenseBalance = computed(() => adsenseSummary.value.balance || createEmptyAdsenseBalance())
|
||||||
adsenseSummary.value.yesterday || createEmptyAdsensePeriod('어제'),
|
|
||||||
adsenseSummary.value.month || createEmptyAdsensePeriod('이번 달')
|
|
||||||
])
|
|
||||||
|
|
||||||
const adsenseGeneratedLabel = computed(() => {
|
const adsenseGeneratedLabel = computed(() => {
|
||||||
const value = adsenseSummary.value?.generatedAt
|
const value = adsenseSummary.value?.generatedAt
|
||||||
@@ -199,17 +203,17 @@ const adsenseGeneratedLabel = computed(() => {
|
|||||||
* AdSense 통화 코드를 표시 가능한 값으로 보정한다.
|
* AdSense 통화 코드를 표시 가능한 값으로 보정한다.
|
||||||
* @returns {string} 통화 코드
|
* @returns {string} 통화 코드
|
||||||
*/
|
*/
|
||||||
const getAdsenseCurrencyCode = () => {
|
const getAdsenseCurrencyCode = (currencyCode) => {
|
||||||
const currencyCode = adsenseSummary.value?.currencyCode || 'KRW'
|
const value = currencyCode || adsenseSummary.value?.currencyCode || 'USD'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
new Intl.NumberFormat('ko-KR', {
|
new Intl.NumberFormat('ko-KR', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: currencyCode
|
currency: value
|
||||||
}).format(0)
|
}).format(0)
|
||||||
return currencyCode
|
return value
|
||||||
} catch {
|
} catch {
|
||||||
return 'KRW'
|
return 'USD'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,13 +231,14 @@ const formatNumber = (value) => {
|
|||||||
* @param {number} value - 금액
|
* @param {number} value - 금액
|
||||||
* @returns {string} 표시 문자열
|
* @returns {string} 표시 문자열
|
||||||
*/
|
*/
|
||||||
const formatAdsenseCurrency = (value) => {
|
const formatAdsenseCurrency = (value, currencyCode = '') => {
|
||||||
const currencyCode = getAdsenseCurrencyCode()
|
const resolvedCurrencyCode = getAdsenseCurrencyCode(currencyCode)
|
||||||
|
|
||||||
return new Intl.NumberFormat('ko-KR', {
|
return new Intl.NumberFormat('ko-KR', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: currencyCode,
|
currency: resolvedCurrencyCode,
|
||||||
maximumFractionDigits: currencyCode === 'KRW' ? 0 : 2
|
minimumFractionDigits: resolvedCurrencyCode === 'KRW' ? 0 : 2,
|
||||||
|
maximumFractionDigits: resolvedCurrencyCode === 'KRW' ? 0 : 2
|
||||||
}).format(Number(value || 0))
|
}).format(Number(value || 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,13 +247,14 @@ const formatAdsenseCurrency = (value) => {
|
|||||||
* @param {number} value - RPM
|
* @param {number} value - RPM
|
||||||
* @returns {string} 표시 문자열
|
* @returns {string} 표시 문자열
|
||||||
*/
|
*/
|
||||||
const formatAdsenseRpm = (value) => {
|
const formatAdsenseRpm = (value, currencyCode = '') => {
|
||||||
const currencyCode = getAdsenseCurrencyCode()
|
const resolvedCurrencyCode = getAdsenseCurrencyCode(currencyCode)
|
||||||
|
|
||||||
return new Intl.NumberFormat('ko-KR', {
|
return new Intl.NumberFormat('ko-KR', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: currencyCode,
|
currency: resolvedCurrencyCode,
|
||||||
maximumFractionDigits: currencyCode === 'KRW' ? 0 : 2
|
minimumFractionDigits: resolvedCurrencyCode === 'KRW' ? 0 : 2,
|
||||||
|
maximumFractionDigits: resolvedCurrencyCode === 'KRW' ? 0 : 2
|
||||||
}).format(Number(value || 0))
|
}).format(Number(value || 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,7 +698,7 @@ watch(trendRows, () => {
|
|||||||
AdSense
|
AdSense
|
||||||
</h2>
|
</h2>
|
||||||
<p class="admin-dashboard__section-description mt-1 text-xs text-muted">
|
<p class="admin-dashboard__section-description mt-1 text-xs text-muted">
|
||||||
예상 수익·페이지뷰·클릭 기준 요약
|
예상 수입
|
||||||
<span v-if="adsenseGeneratedLabel">· {{ adsenseGeneratedLabel }} 갱신</span>
|
<span v-if="adsenseGeneratedLabel">· {{ adsenseGeneratedLabel }} 갱신</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -707,40 +713,55 @@ watch(trendRows, () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="!adsenseSummary.configured"
|
v-if="adsenseSummary.configured && !adsenseSummary.ok"
|
||||||
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"
|
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 통계를 불러오지 못했습니다.' }}
|
{{ adsenseSummary.message || 'AdSense 통계를 불러오지 못했습니다.' }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="admin-dashboard__adsense-grid mt-3 grid gap-3 lg:grid-cols-3">
|
<div class="admin-dashboard__adsense-layout mt-3 grid gap-3 xl:grid-cols-[minmax(0,5fr)_minmax(220px,1fr)]">
|
||||||
|
<div class="admin-dashboard__adsense-earnings bg-[#2f6cc4] p-4 text-white shadow-sm">
|
||||||
|
<h3 class="text-sm font-semibold">
|
||||||
|
예상 수입
|
||||||
|
</h3>
|
||||||
|
<div class="mt-5 grid gap-5 md:grid-cols-3 xl:grid-cols-5">
|
||||||
<article
|
<article
|
||||||
v-for="card in adsenseCards"
|
v-for="card in adsenseCards"
|
||||||
:key="card.label"
|
:key="card.key || card.label"
|
||||||
class="admin-dashboard__adsense-card border border-line bg-paper p-3"
|
class="admin-dashboard__adsense-card min-w-0"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between gap-3">
|
<p class="text-xs font-semibold text-white/90">
|
||||||
<p class="text-xs font-semibold uppercase text-muted">
|
|
||||||
{{ card.label }}
|
{{ card.label }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-[11px] text-muted">
|
<strong class="mt-2 block text-2xl font-medium leading-tight text-white">
|
||||||
RPM {{ formatAdsenseRpm(card.pageViewsRpm) }}
|
{{ formatAdsenseCurrency(card.estimatedEarnings, card.currencyCode) }}
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<strong class="mt-2 block text-xl text-ink">
|
|
||||||
{{ formatAdsenseCurrency(card.estimatedEarnings) }}
|
|
||||||
</strong>
|
</strong>
|
||||||
<p class="mt-2 text-xs text-muted">
|
<p class="mt-2 text-[11px] leading-snug text-white/80">
|
||||||
페이지뷰 {{ formatNumber(card.pageViews) }} · 노출 {{ formatNumber(card.impressions) }} · 클릭 {{ formatNumber(card.clicks) }}
|
페이지뷰 {{ formatNumber(card.pageViews) }} · 노출 {{ formatNumber(card.impressions) }} · 클릭 {{ formatNumber(card.clicks) }}
|
||||||
</p>
|
</p>
|
||||||
|
<p class="mt-1 text-[11px] leading-snug text-white/80">
|
||||||
|
RPM {{ formatAdsenseRpm(card.pageViewsRpm, card.currencyCode) }}
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="admin-dashboard__adsense-balance bg-[#2f6cc4] p-4 text-white shadow-sm">
|
||||||
|
<h3 class="text-sm font-semibold">
|
||||||
|
잔고
|
||||||
|
</h3>
|
||||||
|
<strong class="mt-8 block text-2xl font-medium leading-tight text-white">
|
||||||
|
{{ adsenseBalance.balanceAmount || '-' }}
|
||||||
|
</strong>
|
||||||
|
<p class="mt-2 text-[11px] leading-snug text-white/85">
|
||||||
|
최종 지급일
|
||||||
|
</p>
|
||||||
|
<p class="mt-0.5 text-[11px] leading-snug text-white/85">
|
||||||
|
{{ adsenseBalance.lastPaymentDate || '-' }}
|
||||||
|
<span v-if="adsenseBalance.lastPaymentAmount"> · {{ adsenseBalance.lastPaymentAmount }}</span>
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="admin-dashboard__charts border border-line bg-white p-4">
|
<section class="admin-dashboard__charts border border-line bg-white p-4">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const ADSENSE_TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
const ADSENSE_TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
||||||
const ADSENSE_API_BASE_URL = 'https://adsense.googleapis.com/v2'
|
const ADSENSE_API_BASE_URL = 'https://adsense.googleapis.com/v2'
|
||||||
|
const ADSENSE_PAYMENT_UNPAID_SUFFIX = '/payments/unpaid'
|
||||||
const ADSENSE_REPORT_METRICS = [
|
const ADSENSE_REPORT_METRICS = [
|
||||||
'ESTIMATED_EARNINGS',
|
'ESTIMATED_EARNINGS',
|
||||||
'PAGE_VIEWS',
|
'PAGE_VIEWS',
|
||||||
@@ -67,20 +68,31 @@ const addDays = (date, days) => {
|
|||||||
* @param {Date} date - 기준 날짜
|
* @param {Date} date - 기준 날짜
|
||||||
* @returns {Date} 이번 달 시작 날짜
|
* @returns {Date} 이번 달 시작 날짜
|
||||||
*/
|
*/
|
||||||
const startOfLocalMonth = (date) => new Date(date.getFullYear(), date.getMonth(), 1)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 빈 AdSense 지표를 만든다.
|
* 빈 AdSense 지표를 만든다.
|
||||||
* @param {string} label - 기간 라벨
|
* @param {string} label - 기간 라벨
|
||||||
|
* @param {string} key - 기간 키
|
||||||
* @returns {Object} 빈 지표
|
* @returns {Object} 빈 지표
|
||||||
*/
|
*/
|
||||||
const createEmptyAdsenseMetric = (label) => ({
|
const createEmptyAdsenseMetric = (label, key = '') => ({
|
||||||
|
key,
|
||||||
label,
|
label,
|
||||||
estimatedEarnings: 0,
|
estimatedEarnings: 0,
|
||||||
pageViews: 0,
|
pageViews: 0,
|
||||||
impressions: 0,
|
impressions: 0,
|
||||||
clicks: 0,
|
clicks: 0,
|
||||||
pageViewsRpm: 0
|
pageViewsRpm: 0,
|
||||||
|
currencyCode: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 빈 AdSense 지급 요약을 만든다.
|
||||||
|
* @returns {{ balanceAmount: string, lastPaymentAmount: string, lastPaymentDate: string }} 지급 요약
|
||||||
|
*/
|
||||||
|
const createEmptyAdsensePaymentSummary = () => ({
|
||||||
|
balanceAmount: '',
|
||||||
|
lastPaymentAmount: '',
|
||||||
|
lastPaymentDate: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -137,26 +149,33 @@ const getAdsenseAccessToken = async (config) => {
|
|||||||
* AdSense 보고서 URL을 만든다.
|
* AdSense 보고서 URL을 만든다.
|
||||||
* @param {Object} options - 보고서 옵션
|
* @param {Object} options - 보고서 옵션
|
||||||
* @param {string} options.accountName - AdSense 계정 이름
|
* @param {string} options.accountName - AdSense 계정 이름
|
||||||
* @param {Date} options.startDate - 시작일
|
* @param {Date} [options.startDate] - 시작일
|
||||||
* @param {Date} options.endDate - 종료일
|
* @param {Date} [options.endDate] - 종료일
|
||||||
|
* @param {string} [options.dateRange] - AdSense 상대 기간
|
||||||
* @param {string} options.currencyCode - 통화 코드
|
* @param {string} options.currencyCode - 통화 코드
|
||||||
* @returns {URL} 보고서 URL
|
* @returns {URL} 보고서 URL
|
||||||
*/
|
*/
|
||||||
const createAdsenseReportUrl = ({ accountName, startDate, endDate, currencyCode }) => {
|
const createAdsenseReportUrl = ({ accountName, startDate, endDate, dateRange, currencyCode }) => {
|
||||||
const url = new URL(`${ADSENSE_API_BASE_URL}/${accountName}/reports:generate`)
|
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) {
|
for (const metric of ADSENSE_REPORT_METRICS) {
|
||||||
url.searchParams.append('metrics', metric)
|
url.searchParams.append('metrics', metric)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dateRange) {
|
||||||
|
url.searchParams.set('dateRange', dateRange)
|
||||||
|
} else if (startDate && endDate) {
|
||||||
|
const start = toGoogleDateFields(startDate)
|
||||||
|
const end = toGoogleDateFields(endDate)
|
||||||
|
|
||||||
url.searchParams.set('startDate.year', start.year)
|
url.searchParams.set('startDate.year', start.year)
|
||||||
url.searchParams.set('startDate.month', start.month)
|
url.searchParams.set('startDate.month', start.month)
|
||||||
url.searchParams.set('startDate.day', start.day)
|
url.searchParams.set('startDate.day', start.day)
|
||||||
url.searchParams.set('endDate.year', end.year)
|
url.searchParams.set('endDate.year', end.year)
|
||||||
url.searchParams.set('endDate.month', end.month)
|
url.searchParams.set('endDate.month', end.month)
|
||||||
url.searchParams.set('endDate.day', end.day)
|
url.searchParams.set('endDate.day', end.day)
|
||||||
|
}
|
||||||
|
|
||||||
url.searchParams.set('languageCode', 'ko-KR')
|
url.searchParams.set('languageCode', 'ko-KR')
|
||||||
|
|
||||||
if (currencyCode) {
|
if (currencyCode) {
|
||||||
@@ -170,17 +189,20 @@ const createAdsenseReportUrl = ({ accountName, startDate, endDate, currencyCode
|
|||||||
* AdSense 단일 기간 보고서를 조회한다.
|
* AdSense 단일 기간 보고서를 조회한다.
|
||||||
* @param {Object} options - 조회 옵션
|
* @param {Object} options - 조회 옵션
|
||||||
* @param {string} options.label - 기간 라벨
|
* @param {string} options.label - 기간 라벨
|
||||||
|
* @param {string} options.key - 기간 키
|
||||||
* @param {string} options.accessToken - access token
|
* @param {string} options.accessToken - access token
|
||||||
* @param {ReturnType<typeof getAdsenseConfig>} options.config - AdSense 설정
|
* @param {ReturnType<typeof getAdsenseConfig>} options.config - AdSense 설정
|
||||||
* @param {Date} options.startDate - 시작일
|
* @param {Date} [options.startDate] - 시작일
|
||||||
* @param {Date} options.endDate - 종료일
|
* @param {Date} [options.endDate] - 종료일
|
||||||
|
* @param {string} [options.dateRange] - AdSense 상대 기간
|
||||||
* @returns {Promise<Object>} 기간 지표
|
* @returns {Promise<Object>} 기간 지표
|
||||||
*/
|
*/
|
||||||
const fetchAdsensePeriodReport = async ({ label, accessToken, config, startDate, endDate }) => {
|
const fetchAdsensePeriodReport = async ({ label, key, accessToken, config, startDate, endDate, dateRange }) => {
|
||||||
const url = createAdsenseReportUrl({
|
const url = createAdsenseReportUrl({
|
||||||
accountName: config.accountName,
|
accountName: config.accountName,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
|
dateRange,
|
||||||
currencyCode: config.currencyCode
|
currencyCode: config.currencyCode
|
||||||
})
|
})
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@@ -194,16 +216,17 @@ const fetchAdsensePeriodReport = async ({ label, accessToken, config, startDate,
|
|||||||
throw new Error(data.error?.message || `AdSense 보고서 조회 실패: ${response.status}`)
|
throw new Error(data.error?.message || `AdSense 보고서 조회 실패: ${response.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = Array.isArray(data.rows) ? data.rows[0] : null
|
const row = data.totals || (Array.isArray(data.rows) ? data.rows[0] : null)
|
||||||
const cells = row && Array.isArray(row.cells) ? row.cells : []
|
const cells = row && Array.isArray(row.cells) ? row.cells : []
|
||||||
const headers = Array.isArray(data.headers) ? data.headers : []
|
const headers = Array.isArray(data.headers) ? data.headers : []
|
||||||
const metric = createEmptyAdsenseMetric(label)
|
const metric = createEmptyAdsenseMetric(label, key)
|
||||||
|
|
||||||
headers.forEach((header, index) => {
|
headers.forEach((header, index) => {
|
||||||
const value = toMetricNumber(cells[index]?.value)
|
const value = toMetricNumber(cells[index]?.value)
|
||||||
|
|
||||||
if (header.name === 'ESTIMATED_EARNINGS') {
|
if (header.name === 'ESTIMATED_EARNINGS') {
|
||||||
metric.estimatedEarnings = value
|
metric.estimatedEarnings = value
|
||||||
|
metric.currencyCode = header.currencyCode || metric.currencyCode
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +253,48 @@ const fetchAdsensePeriodReport = async ({ label, accessToken, config, startDate,
|
|||||||
return metric
|
return metric
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AdSense 지급 요약을 조회한다.
|
||||||
|
* @param {Object} options - 조회 옵션
|
||||||
|
* @param {string} options.accessToken - access token
|
||||||
|
* @param {ReturnType<typeof getAdsenseConfig>} options.config - AdSense 설정
|
||||||
|
* @returns {Promise<ReturnType<typeof createEmptyAdsensePaymentSummary>>} 지급 요약
|
||||||
|
*/
|
||||||
|
const fetchAdsensePaymentSummary = async ({ accessToken, config }) => {
|
||||||
|
const response = await fetch(`${ADSENSE_API_BASE_URL}/${config.accountName}/payments`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const data = await response.json().catch(() => ({}))
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error?.message || `AdSense 지급 정보 조회 실패: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payments = Array.isArray(data.payments) ? data.payments : []
|
||||||
|
const unpaid = payments.find((payment) => String(payment.name || '').endsWith(ADSENSE_PAYMENT_UNPAID_SUFFIX))
|
||||||
|
const paid = payments.find((payment) => !String(payment.name || '').includes('/youtube-') && payment.date)
|
||||||
|
|
||||||
|
return {
|
||||||
|
balanceAmount: unpaid?.amount || '',
|
||||||
|
lastPaymentAmount: paid?.amount || '',
|
||||||
|
lastPaymentDate: paid?.date
|
||||||
|
? [paid.date.year, String(paid.date.month).padStart(2, '0'), String(paid.date.day).padStart(2, '0')].join('.')
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AdSense 지표 목록에서 표시 통화 코드를 고른다.
|
||||||
|
* @param {Object[]} periods - 기간 지표 목록
|
||||||
|
* @param {ReturnType<typeof getAdsenseConfig>} config - AdSense 설정
|
||||||
|
* @returns {string} 통화 코드
|
||||||
|
*/
|
||||||
|
const resolveAdsenseCurrencyCode = (periods, config) => {
|
||||||
|
return periods.find((period) => period.currencyCode)?.currencyCode || config.currencyCode || 'USD'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 관리자 대시보드용 AdSense 요약을 조회한다.
|
* 관리자 대시보드용 AdSense 요약을 조회한다.
|
||||||
* @returns {Promise<Object>} AdSense 요약
|
* @returns {Promise<Object>} AdSense 요약
|
||||||
@@ -242,63 +307,79 @@ export const getAdsenseDashboardSummary = async () => {
|
|||||||
configured: false,
|
configured: false,
|
||||||
ok: false,
|
ok: false,
|
||||||
message: 'AdSense API 환경 변수가 아직 설정되지 않았습니다.',
|
message: 'AdSense API 환경 변수가 아직 설정되지 않았습니다.',
|
||||||
currencyCode: config.currencyCode || 'KRW',
|
currencyCode: config.currencyCode || 'USD',
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
today: createEmptyAdsenseMetric('오늘'),
|
periods: [],
|
||||||
yesterday: createEmptyAdsenseMetric('어제'),
|
balance: createEmptyAdsensePaymentSummary()
|
||||||
month: createEmptyAdsenseMetric('이번 달')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const today = startOfLocalDay(new Date())
|
const today = startOfLocalDay(new Date())
|
||||||
const yesterday = addDays(today, -1)
|
const recent28Start = addDays(today, -27)
|
||||||
const monthStart = startOfLocalMonth(today)
|
|
||||||
const accessToken = await getAdsenseAccessToken(config)
|
const accessToken = await getAdsenseAccessToken(config)
|
||||||
const [todayMetric, yesterdayMetric, monthMetric] = await Promise.all([
|
const [todayMetric, yesterdayMetric, last7DaysMetric, monthMetric, recent28DaysMetric, balance] = await Promise.all([
|
||||||
fetchAdsensePeriodReport({
|
fetchAdsensePeriodReport({
|
||||||
label: '오늘',
|
label: '오늘 현재까지',
|
||||||
|
key: 'today',
|
||||||
accessToken,
|
accessToken,
|
||||||
config,
|
config,
|
||||||
startDate: today,
|
dateRange: 'TODAY'
|
||||||
endDate: today
|
|
||||||
}),
|
}),
|
||||||
fetchAdsensePeriodReport({
|
fetchAdsensePeriodReport({
|
||||||
label: '어제',
|
label: '어제',
|
||||||
|
key: 'yesterday',
|
||||||
accessToken,
|
accessToken,
|
||||||
config,
|
config,
|
||||||
startDate: yesterday,
|
dateRange: 'YESTERDAY'
|
||||||
endDate: yesterday
|
}),
|
||||||
|
fetchAdsensePeriodReport({
|
||||||
|
label: '지난 7일',
|
||||||
|
key: 'last7Days',
|
||||||
|
accessToken,
|
||||||
|
config,
|
||||||
|
dateRange: 'LAST_7_DAYS'
|
||||||
}),
|
}),
|
||||||
fetchAdsensePeriodReport({
|
fetchAdsensePeriodReport({
|
||||||
label: '이번 달',
|
label: '이번 달',
|
||||||
|
key: 'month',
|
||||||
accessToken,
|
accessToken,
|
||||||
config,
|
config,
|
||||||
startDate: monthStart,
|
dateRange: 'MONTH_TO_DATE'
|
||||||
|
}),
|
||||||
|
fetchAdsensePeriodReport({
|
||||||
|
label: '최근 28일',
|
||||||
|
key: 'recent28Days',
|
||||||
|
accessToken,
|
||||||
|
config,
|
||||||
|
startDate: recent28Start,
|
||||||
endDate: today
|
endDate: today
|
||||||
|
}),
|
||||||
|
fetchAdsensePaymentSummary({
|
||||||
|
accessToken,
|
||||||
|
config
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
|
const periods = [todayMetric, yesterdayMetric, last7DaysMetric, monthMetric, recent28DaysMetric]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
configured: true,
|
configured: true,
|
||||||
ok: true,
|
ok: true,
|
||||||
message: '',
|
message: '',
|
||||||
currencyCode: config.currencyCode || 'KRW',
|
currencyCode: resolveAdsenseCurrencyCode(periods, config),
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
today: todayMetric,
|
periods,
|
||||||
yesterday: yesterdayMetric,
|
balance
|
||||||
month: monthMetric
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
configured: true,
|
configured: true,
|
||||||
ok: false,
|
ok: false,
|
||||||
message: error instanceof Error ? error.message : 'AdSense 통계를 불러오지 못했습니다.',
|
message: error instanceof Error ? error.message : 'AdSense 통계를 불러오지 못했습니다.',
|
||||||
currencyCode: config.currencyCode || 'KRW',
|
currencyCode: config.currencyCode || 'USD',
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
today: createEmptyAdsenseMetric('오늘'),
|
periods: [],
|
||||||
yesterday: createEmptyAdsenseMetric('어제'),
|
balance: createEmptyAdsensePaymentSummary()
|
||||||
month: createEmptyAdsenseMetric('이번 달')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user