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) }}
+
+
+
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