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', '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} 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} 이번 달 시작 날짜 */ /** * 빈 AdSense 지표를 만든다. * @param {string} label - 기간 라벨 * @param {string} key - 기간 키 * @returns {Object} 빈 지표 */ const createEmptyAdsenseMetric = (label, key = '') => ({ key, label, estimatedEarnings: 0, pageViews: 0, impressions: 0, clicks: 0, pageViewsRpm: 0, currencyCode: '' }) /** * 빈 AdSense 지급 요약을 만든다. * @returns {{ balanceAmount: string, lastPaymentAmount: string, lastPaymentDate: string }} 지급 요약 */ const createEmptyAdsensePaymentSummary = () => ({ balanceAmount: '', lastPaymentAmount: '', lastPaymentDate: '' }) /** * 숫자 셀 값을 안전하게 변환한다. * @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} config - AdSense 설정 * @returns {Promise} 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.dateRange] - AdSense 상대 기간 * @param {string} options.currencyCode - 통화 코드 * @returns {URL} 보고서 URL */ const createAdsenseReportUrl = ({ accountName, startDate, endDate, dateRange, currencyCode }) => { const url = new URL(`${ADSENSE_API_BASE_URL}/${accountName}/reports:generate`) for (const metric of ADSENSE_REPORT_METRICS) { 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.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.key - 기간 키 * @param {string} options.accessToken - access token * @param {ReturnType} options.config - AdSense 설정 * @param {Date} [options.startDate] - 시작일 * @param {Date} [options.endDate] - 종료일 * @param {string} [options.dateRange] - AdSense 상대 기간 * @returns {Promise} 기간 지표 */ 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, { headers: { Authorization: `Bearer ${accessToken}` } }) const data = await response.json().catch(() => ({})) if (!response.ok) { throw new Error(data.error?.message || `AdSense 보고서 조회 실패: ${response.status}`) } 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, 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 } 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 지급 요약을 조회한다. * @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 요약 */ export const getAdsenseDashboardSummary = async () => { const config = getAdsenseConfig() if (!isAdsenseConfigured(config)) { return { configured: false, ok: false, message: 'AdSense API 환경 변수가 아직 설정되지 않았습니다.', currencyCode: config.currencyCode || 'USD', generatedAt: new Date().toISOString(), periods: [], balance: createEmptyAdsensePaymentSummary() } } try { const today = startOfLocalDay(new Date()) const recent28Start = addDays(today, -27) const accessToken = await getAdsenseAccessToken(config) const [todayMetric, yesterdayMetric, last7DaysMetric, monthMetric, recent28DaysMetric, balance] = await Promise.all([ fetchAdsensePeriodReport({ label: '오늘 현재까지', key: 'today', accessToken, config, dateRange: 'TODAY' }), fetchAdsensePeriodReport({ label: '어제', key: 'yesterday', accessToken, config, dateRange: 'YESTERDAY' }), fetchAdsensePeriodReport({ label: '지난 7일', key: 'last7Days', accessToken, config, dateRange: 'LAST_7_DAYS' }), fetchAdsensePeriodReport({ label: '이번 달', key: 'month', accessToken, config, 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: resolveAdsenseCurrencyCode(periods, config), generatedAt: new Date().toISOString(), periods, balance } } catch (error) { return { configured: true, ok: false, message: error instanceof Error ? error.message : 'AdSense 통계를 불러오지 못했습니다.', currencyCode: config.currencyCode || 'USD', generatedAt: new Date().toISOString(), periods: [], balance: createEmptyAdsensePaymentSummary() } } }