From c0684737cee84856476176d12f2067a818fa9bea Mon Sep 17 00:00:00 2001 From: zenn Date: Mon, 20 Jul 2026 17:11:30 +0900 Subject: [PATCH] =?UTF-8?q?AdSense=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=20=EC=9A=94=EC=95=BD=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog.md | 5 + docs/deploy.md | 2 +- docs/history.md | 6 ++ docs/spec.md | 2 +- docs/update.md | 7 ++ package-lock.json | 4 +- package.json | 2 +- pages/admin/index.vue | 119 ++++++++++++++---------- server/utils/adsense-report.js | 163 ++++++++++++++++++++++++--------- 9 files changed, 215 insertions(+), 95 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index ff2f1ec..35472c4 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,10 @@ # 업데이트 요약 +## v1.10.81 + +- AdSense 대시보드를 공식 홈 카드에 가깝게 오늘·어제·지난 7일·이번 달·최근 28일·잔고 형태로 정리했다. +- AdSense 금액이 응답 통화와 소수점을 반영하도록 고쳤다. + ## v1.10.80 - 관리자 대시보드에 AdSense 예상 수익·페이지뷰·노출·클릭 요약 영역을 추가했다. diff --git a/docs/deploy.md b/docs/deploy.md index be3a25a..2ab5476 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,6 +1,6 @@ # 배포 가이드 -> 로컬 기준 v1.10.80에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. +> 로컬 기준 v1.10.81에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. ## 빌드 유형 diff --git a/docs/history.md b/docs/history.md index 3992f86..1ccadfd 100644 --- a/docs/history.md +++ b/docs/history.md @@ -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 비밀값은 서버에서만 다룬다 관리자 대시보드에 AdSense 수익 통계를 표시하려면 Google OAuth client secret과 refresh token이 필요하다. 이 값은 브라우저에 노출되면 안 되므로 관리자 화면에서 직접 Google API를 호출하지 않고, 서버 전용 `/admin/api/adsense/summary`에서만 환경 변수를 읽어 access token을 발급한다. diff --git a/docs/spec.md b/docs/spec.md index fa6cfc7..bc5f906 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -167,7 +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 카드에 안내 문구만 표시한다. +- 관리자 대시보드의 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 카드 내부 상태로 제한한다. ### 공개 인증 화면(초기) diff --git a/docs/update.md b/docs/update.md index 602a4f4..6539354 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,12 @@ # 업데이트 이력 +## v1.10.81 + +- 관리자 대시보드 AdSense 설정 안내 문구 제거. +- AdSense 보고서 응답의 `totals` 행을 우선 읽도록 수정. +- AdSense 요약을 오늘 현재까지·어제·지난 7일·이번 달·최근 28일·잔고 형태로 정리. +- AdSense 금액 표시가 보고서 응답 통화 코드와 소수점 두 자리를 반영하도록 수정. + ## v1.10.80 - 관리자 대시보드 상단 요약 아래에 AdSense 요약 카드 추가. diff --git a/package-lock.json b/package-lock.json index 03fd0cb..1072e93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.10.80", + "version": "1.10.81", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.10.80", + "version": "1.10.81", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 4afc8e2..d2b2989 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.10.80", + "version": "1.10.81", "private": true, "type": "module", "imports": { diff --git a/pages/admin/index.vue b/pages/admin/index.vue index 6fccfeb..eb98de7 100644 --- a/pages/admin/index.vue +++ b/pages/admin/index.vue @@ -64,11 +64,19 @@ const { data: realtime, refresh: refreshRealtime } = await useFetch('/admin/api/ const createEmptyAdsensePeriod = (label) => ({ label, + key: '', estimatedEarnings: 0, pageViews: 0, impressions: 0, clicks: 0, - pageViewsRpm: 0 + pageViewsRpm: 0, + currencyCode: '' +}) + +const createEmptyAdsenseBalance = () => ({ + balanceAmount: '', + lastPaymentAmount: '', + lastPaymentDate: '' }) const { @@ -81,11 +89,10 @@ const { configured: false, ok: false, message: '', - currencyCode: 'KRW', + currencyCode: 'USD', generatedAt: '', - today: createEmptyAdsensePeriod('오늘'), - yesterday: createEmptyAdsensePeriod('어제'), - month: createEmptyAdsensePeriod('이번 달') + periods: [], + balance: createEmptyAdsenseBalance() }) }) @@ -179,11 +186,8 @@ 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 adsenseCards = computed(() => adsenseSummary.value.periods || []) +const adsenseBalance = computed(() => adsenseSummary.value.balance || createEmptyAdsenseBalance()) const adsenseGeneratedLabel = computed(() => { const value = adsenseSummary.value?.generatedAt @@ -199,17 +203,17 @@ const adsenseGeneratedLabel = computed(() => { * AdSense 통화 코드를 표시 가능한 값으로 보정한다. * @returns {string} 통화 코드 */ -const getAdsenseCurrencyCode = () => { - const currencyCode = adsenseSummary.value?.currencyCode || 'KRW' +const getAdsenseCurrencyCode = (currencyCode) => { + const value = currencyCode || adsenseSummary.value?.currencyCode || 'USD' try { new Intl.NumberFormat('ko-KR', { style: 'currency', - currency: currencyCode + currency: value }).format(0) - return currencyCode + return value } catch { - return 'KRW' + return 'USD' } } @@ -227,13 +231,14 @@ const formatNumber = (value) => { * @param {number} value - 금액 * @returns {string} 표시 문자열 */ -const formatAdsenseCurrency = (value) => { - const currencyCode = getAdsenseCurrencyCode() +const formatAdsenseCurrency = (value, currencyCode = '') => { + const resolvedCurrencyCode = getAdsenseCurrencyCode(currencyCode) return new Intl.NumberFormat('ko-KR', { style: 'currency', - currency: currencyCode, - maximumFractionDigits: currencyCode === 'KRW' ? 0 : 2 + currency: resolvedCurrencyCode, + minimumFractionDigits: resolvedCurrencyCode === 'KRW' ? 0 : 2, + maximumFractionDigits: resolvedCurrencyCode === 'KRW' ? 0 : 2 }).format(Number(value || 0)) } @@ -242,13 +247,14 @@ const formatAdsenseCurrency = (value) => { * @param {number} value - RPM * @returns {string} 표시 문자열 */ -const formatAdsenseRpm = (value) => { - const currencyCode = getAdsenseCurrencyCode() +const formatAdsenseRpm = (value, currencyCode = '') => { + const resolvedCurrencyCode = getAdsenseCurrencyCode(currencyCode) return new Intl.NumberFormat('ko-KR', { style: 'currency', - currency: currencyCode, - maximumFractionDigits: currencyCode === 'KRW' ? 0 : 2 + currency: resolvedCurrencyCode, + minimumFractionDigits: resolvedCurrencyCode === 'KRW' ? 0 : 2, + maximumFractionDigits: resolvedCurrencyCode === 'KRW' ? 0 : 2 }).format(Number(value || 0)) } @@ -692,7 +698,7 @@ watch(trendRows, () => { AdSense

- 예상 수익·페이지뷰·클릭 기준 요약 + 예상 수입 · {{ adsenseGeneratedLabel }} 갱신

@@ -707,39 +713,54 @@ watch(trendRows, () => {

- AdSense 환경 변수를 설정하면 이곳에 수익 요약이 표시됩니다. -

-

{{ adsenseSummary.message || 'AdSense 통계를 불러오지 못했습니다.' }}

-
-
-
-

- {{ card.label }} -

-

- RPM {{ formatAdsenseRpm(card.pageViewsRpm) }} -

+
+
+

+ 예상 수입 +

+
+
+

+ {{ card.label }} +

+ + {{ formatAdsenseCurrency(card.estimatedEarnings, card.currencyCode) }} + +

+ 페이지뷰 {{ formatNumber(card.pageViews) }} · 노출 {{ formatNumber(card.impressions) }} · 클릭 {{ formatNumber(card.clicks) }} +

+

+ RPM {{ formatAdsenseRpm(card.pageViewsRpm, card.currencyCode) }} +

+
- - {{ formatAdsenseCurrency(card.estimatedEarnings) }} +
+ +
+

+ {{ adsenseBalance.lastPaymentDate || '-' }} + · {{ adsenseBalance.lastPaymentAmount }} +

+
diff --git a/server/utils/adsense-report.js b/server/utils/adsense-report.js index 86be98d..31b4290 100644 --- a/server/utils/adsense-report.js +++ b/server/utils/adsense-report.js @@ -1,5 +1,6 @@ const ADSENSE_TOKEN_URL = 'https://oauth2.googleapis.com/token' const ADSENSE_API_BASE_URL = 'https://adsense.googleapis.com/v2' +const ADSENSE_PAYMENT_UNPAID_SUFFIX = '/payments/unpaid' const ADSENSE_REPORT_METRICS = [ 'ESTIMATED_EARNINGS', 'PAGE_VIEWS', @@ -67,20 +68,31 @@ const addDays = (date, days) => { * @param {Date} date - 기준 날짜 * @returns {Date} 이번 달 시작 날짜 */ -const startOfLocalMonth = (date) => new Date(date.getFullYear(), date.getMonth(), 1) - /** * 빈 AdSense 지표를 만든다. * @param {string} label - 기간 라벨 + * @param {string} key - 기간 키 * @returns {Object} 빈 지표 */ -const createEmptyAdsenseMetric = (label) => ({ +const createEmptyAdsenseMetric = (label, key = '') => ({ + key, label, estimatedEarnings: 0, pageViews: 0, impressions: 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을 만든다. * @param {Object} options - 보고서 옵션 * @param {string} options.accountName - AdSense 계정 이름 - * @param {Date} options.startDate - 시작일 - * @param {Date} options.endDate - 종료일 + * @param {Date} [options.startDate] - 시작일 + * @param {Date} [options.endDate] - 종료일 + * @param {string} [options.dateRange] - AdSense 상대 기간 * @param {string} options.currencyCode - 통화 코드 * @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 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) + 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.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) { @@ -170,17 +189,20 @@ const createAdsenseReportUrl = ({ accountName, startDate, endDate, currencyCode * AdSense 단일 기간 보고서를 조회한다. * @param {Object} options - 조회 옵션 * @param {string} options.label - 기간 라벨 + * @param {string} options.key - 기간 키 * @param {string} options.accessToken - access token * @param {ReturnType} options.config - AdSense 설정 - * @param {Date} options.startDate - 시작일 - * @param {Date} options.endDate - 종료일 + * @param {Date} [options.startDate] - 시작일 + * @param {Date} [options.endDate] - 종료일 + * @param {string} [options.dateRange] - AdSense 상대 기간 * @returns {Promise} 기간 지표 */ -const fetchAdsensePeriodReport = async ({ label, accessToken, config, startDate, endDate }) => { +const fetchAdsensePeriodReport = async ({ label, key, accessToken, config, startDate, endDate, dateRange }) => { const url = createAdsenseReportUrl({ accountName: config.accountName, startDate, endDate, + dateRange, currencyCode: config.currencyCode }) 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}`) } - 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 headers = Array.isArray(data.headers) ? data.headers : [] - const metric = createEmptyAdsenseMetric(label) + const metric = createEmptyAdsenseMetric(label, key) headers.forEach((header, index) => { const value = toMetricNumber(cells[index]?.value) if (header.name === 'ESTIMATED_EARNINGS') { metric.estimatedEarnings = value + metric.currencyCode = header.currencyCode || metric.currencyCode return } @@ -230,6 +253,48 @@ const fetchAdsensePeriodReport = async ({ label, accessToken, config, startDate, return metric } +/** + * AdSense 지급 요약을 조회한다. + * @param {Object} options - 조회 옵션 + * @param {string} options.accessToken - access token + * @param {ReturnType} options.config - AdSense 설정 + * @returns {Promise>} 지급 요약 + */ +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} config - AdSense 설정 + * @returns {string} 통화 코드 + */ +const resolveAdsenseCurrencyCode = (periods, config) => { + return periods.find((period) => period.currencyCode)?.currencyCode || config.currencyCode || 'USD' +} + /** * 관리자 대시보드용 AdSense 요약을 조회한다. * @returns {Promise} AdSense 요약 @@ -242,63 +307,79 @@ export const getAdsenseDashboardSummary = async () => { configured: false, ok: false, message: 'AdSense API 환경 변수가 아직 설정되지 않았습니다.', - currencyCode: config.currencyCode || 'KRW', + currencyCode: config.currencyCode || 'USD', generatedAt: new Date().toISOString(), - today: createEmptyAdsenseMetric('오늘'), - yesterday: createEmptyAdsenseMetric('어제'), - month: createEmptyAdsenseMetric('이번 달') + periods: [], + balance: createEmptyAdsensePaymentSummary() } } try { const today = startOfLocalDay(new Date()) - const yesterday = addDays(today, -1) - const monthStart = startOfLocalMonth(today) + const recent28Start = addDays(today, -27) const accessToken = await getAdsenseAccessToken(config) - const [todayMetric, yesterdayMetric, monthMetric] = await Promise.all([ + const [todayMetric, yesterdayMetric, last7DaysMetric, monthMetric, recent28DaysMetric, balance] = await Promise.all([ fetchAdsensePeriodReport({ - label: '오늘', + label: '오늘 현재까지', + key: 'today', accessToken, config, - startDate: today, - endDate: today + dateRange: 'TODAY' }), fetchAdsensePeriodReport({ label: '어제', + key: 'yesterday', accessToken, config, - startDate: yesterday, - endDate: yesterday + dateRange: 'YESTERDAY' + }), + fetchAdsensePeriodReport({ + label: '지난 7일', + key: 'last7Days', + accessToken, + config, + dateRange: 'LAST_7_DAYS' }), fetchAdsensePeriodReport({ label: '이번 달', + key: 'month', accessToken, config, - startDate: monthStart, + dateRange: 'MONTH_TO_DATE' + }), + fetchAdsensePeriodReport({ + label: '최근 28일', + key: 'recent28Days', + accessToken, + config, + startDate: recent28Start, endDate: today + }), + fetchAdsensePaymentSummary({ + accessToken, + config }) ]) + const periods = [todayMetric, yesterdayMetric, last7DaysMetric, monthMetric, recent28DaysMetric] return { configured: true, ok: true, message: '', - currencyCode: config.currencyCode || 'KRW', + currencyCode: resolveAdsenseCurrencyCode(periods, config), generatedAt: new Date().toISOString(), - today: todayMetric, - yesterday: yesterdayMetric, - month: monthMetric + periods, + balance } } catch (error) { return { configured: true, ok: false, message: error instanceof Error ? error.message : 'AdSense 통계를 불러오지 못했습니다.', - currencyCode: config.currencyCode || 'KRW', + currencyCode: config.currencyCode || 'USD', generatedAt: new Date().toISOString(), - today: createEmptyAdsenseMetric('오늘'), - yesterday: createEmptyAdsenseMetric('어제'), - month: createEmptyAdsenseMetric('이번 달') + periods: [], + balance: createEmptyAdsensePaymentSummary() } } }