import { ANALYTICS_ACTIVE_SESSION_TTL_SECONDS, ANALYTICS_CHART_MAX_DAYS, ANALYTICS_COLLECTED_SINCE_DEFAULT, ANALYTICS_ENGAGED_MIN_SECONDS, ANALYTICS_RETENTION_PURGE_INTERVAL_MS, ANALYTICS_VISITOR_HASH_RETENTION_DAYS, createContentSessionHash, clampAnalyticsDurationSeconds, clampAnalyticsScrollRatio, createDailyVisitorHash, createRealtimeSessionHash, getAnalyticsDayBefore, getAnalyticsDayKey, getNewScrollBucketColumns, normalizeAnalyticsCollectedSinceDay } from '../../lib/analytics.js' import { getAnalyticsChartPageSize, getAnalyticsChartTotalBuckets, getAnalyticsEffectiveTrendStartDay } from '../../lib/analytics-chart-ui.js' import { getPostgresClient } from './postgres-client.js' import { getRuntimeEnvValue } from '../utils/runtime-env.js' import { getMemberSession } from '../utils/member-auth.js' import { getClientIp } from '../utils/request-ip.js' /** @type {number} 마지막 통계 보관 정리 실행 시각 */ let lastAnalyticsRetentionPurgeAt = 0 /** * 통계 해시용 시크릿을 반환한다. * @returns {string} 시크릿 */ const getAnalyticsHashSecret = () => { return getRuntimeEnvValue( 'ANALYTICS_HASH_SECRET', 'analyticsHashSecret', getRuntimeEnvValue('MEMBER_SESSION_SECRET', 'memberSessionSecret', 'analytics-fallback-secret') ).trim() } /** * 비어 있지 않은 통계 세션 ID인지 확인한다. * @param {string} value - 세션 ID 후보 * @returns {boolean} 사용 가능 여부 */ const hasAnalyticsSessionId = (value) => { return Boolean(String(value || '').trim()) } /** * 통계 집계 시작일을 반환한다. * @returns {string} YYYY-MM-DD */ const getAnalyticsCollectedSinceDay = () => { return normalizeAnalyticsCollectedSinceDay( getRuntimeEnvValue( 'ANALYTICS_COLLECTED_SINCE', 'analyticsCollectedSince', ANALYTICS_COLLECTED_SINCE_DEFAULT ) ) } /** * 관리자 화면에 표시할 콘텐츠 참여 지표를 조회수 기준으로 정규화한다. * @param {{ views?: number, reads?: number, scroll50?: number, scroll75?: number, scroll100?: number }} values - 원시 지표 * @returns {{ views: number, reads: number, scroll50: number, scroll75: number, scroll100: number }} 정규화 지표 */ const normalizeContentEngagementStats = (values = {}) => { const views = Math.max(Number(values.views || 0), 0) const reads = Math.min(Math.max(Number(values.reads || 0), 0), views) const scroll50 = Math.min(Math.max(Number(values.scroll50 || 0), 0), views) const scroll75 = Math.min(Math.max(Number(values.scroll75 || 0), 0), scroll50) const scroll100 = Math.min(Math.max(Number(values.scroll100 || 0), 0), scroll75) return { views, reads, scroll50, scroll75, scroll100 } } /** * 일별 사이트 통계 행을 보장한다. * @param {import('postgres').Sql} sql - DB 클라이언트 * @param {string} day - YYYY-MM-DD * @returns {Promise} */ const ensureSiteDailyRow = async (sql, day) => { await sql` INSERT INTO site_analytics_daily (day) VALUES (${day}) ON CONFLICT (day) DO NOTHING ` } /** * 일별 게시물 통계 행을 보장한다. * @param {import('postgres').Sql} sql - DB 클라이언트 * @param {string} day - YYYY-MM-DD * @param {string} postId - 게시물 ID * @returns {Promise} */ const ensurePostDailyRow = async (sql, day, postId) => { await sql` INSERT INTO post_analytics_daily (day, post_id) VALUES (${day}, ${postId}) ON CONFLICT (day, post_id) DO NOTHING ` } /** * 일별 페이지 통계 행을 보장한다. * @param {import('postgres').Sql} sql - DB 클라이언트 * @param {string} day - YYYY-MM-DD * @param {string} pageId - 페이지 ID * @returns {Promise} */ const ensurePageDailyRow = async (sql, day, pageId) => { await sql` INSERT INTO page_analytics_daily (day, page_id) VALUES (${day}, ${pageId}) ON CONFLICT (day, page_id) DO NOTHING ` } /** * 일별 유입 통계 행을 보장한다. * @param {import('postgres').Sql} sql - DB 클라이언트 * @param {string} day - YYYY-MM-DD * @param {{ sourceGroup: string, sourceName: string, deviceType: string, osName: string, keyword?: string }} traffic - 유입 분류 * @returns {Promise} */ const ensureTrafficDailyRow = async (sql, day, traffic) => { await sql` INSERT INTO analytics_traffic_daily ( day, source_group, source_name, device_type, os_name, keyword ) VALUES ( ${day}, ${traffic.sourceGroup}, ${traffic.sourceName}, ${traffic.deviceType}, ${traffic.osName}, ${traffic.keyword || ''} ) ON CONFLICT (day, source_group, source_name, device_type, os_name, keyword) DO NOTHING ` } /** * 게시물별 일별 유입 통계 행을 보장한다. * @param {import('postgres').Sql} sql - DB 클라이언트 * @param {string} day - YYYY-MM-DD * @param {string} postId - 게시물 ID * @param {{ sourceGroup: string, sourceName: string, deviceType: string, osName: string, keyword?: string }} traffic - 유입 분류 * @returns {Promise} */ const ensurePostTrafficDailyRow = async (sql, day, postId, traffic) => { await sql` INSERT INTO post_analytics_traffic_daily ( day, post_id, source_group, source_name, device_type, os_name, keyword ) VALUES ( ${day}, ${postId}, ${traffic.sourceGroup}, ${traffic.sourceName}, ${traffic.deviceType}, ${traffic.osName}, ${traffic.keyword || ''} ) ON CONFLICT (day, post_id, source_group, source_name, device_type, os_name, keyword) DO NOTHING ` } /** * 사이트 방문자를 등록하고 신규 방문자면 true를 반환한다. * @param {import('postgres').Sql} sql - DB 클라이언트 * @param {string} day - YYYY-MM-DD * @param {string} visitorHash - 방문자 해시 * @returns {Promise} 신규 방문자 여부 */ const registerSiteVisitor = async (sql, day, visitorHash) => { const rows = await sql` INSERT INTO analytics_daily_visitors (day, scope, visitor_hash) VALUES (${day}, 'site', ${visitorHash}) ON CONFLICT DO NOTHING RETURNING id ` return Boolean(rows[0]) } /** * 게시물 방문자를 등록하고 신규 방문자면 true를 반환한다. * @param {import('postgres').Sql} sql - DB 클라이언트 * @param {string} day - YYYY-MM-DD * @param {string} postId - 게시물 ID * @param {string} visitorHash - 방문자 해시 * @returns {Promise} 신규 방문자 여부 */ const registerPostVisitor = async (sql, day, postId, visitorHash) => { const rows = await sql` INSERT INTO analytics_daily_visitors (day, scope, post_id, visitor_hash) VALUES (${day}, 'post', ${postId}, ${visitorHash}) ON CONFLICT DO NOTHING RETURNING id ` return Boolean(rows[0]) } /** * 페이지 방문자를 등록하고 신규 방문자면 true를 반환한다. * @param {import('postgres').Sql} sql - DB 클라이언트 * @param {string} day - YYYY-MM-DD * @param {string} pageId - 페이지 ID * @param {string} visitorHash - 방문자 해시 * @returns {Promise} 신규 방문자 여부 */ const registerPageVisitor = async (sql, day, pageId, visitorHash) => { const rows = await sql` INSERT INTO analytics_daily_visitors (day, scope, page_id, visitor_hash) VALUES (${day}, 'page', ${pageId}, ${visitorHash}) ON CONFLICT DO NOTHING RETURNING id ` return Boolean(rows[0]) } /** * 유입 분류별 방문자를 등록하고 신규 방문자면 true를 반환한다. * @param {import('postgres').Sql} sql - DB 클라이언트 * @param {string} day - YYYY-MM-DD * @param {string} visitorHash - 방문자 해시 * @param {{ sourceGroup: string, sourceName: string, deviceType: string, osName: string, keyword?: string }} traffic - 유입 분류 * @returns {Promise} 신규 방문자 여부 */ const registerTrafficVisitor = async (sql, day, visitorHash, traffic) => { const rows = await sql` INSERT INTO analytics_daily_visitors ( day, scope, visitor_hash, source_group, source_name, device_type, os_name, keyword ) VALUES ( ${day}, 'traffic', ${visitorHash}, ${traffic.sourceGroup}, ${traffic.sourceName}, ${traffic.deviceType}, ${traffic.osName}, ${traffic.keyword || ''} ) ON CONFLICT DO NOTHING RETURNING id ` return Boolean(rows[0]) } /** * 게시물별 유입 방문자를 등록하고 신규 방문자면 true를 반환한다. * @param {import('postgres').Sql} sql - DB 클라이언트 * @param {string} day - YYYY-MM-DD * @param {string} postId - 게시물 ID * @param {string} visitorHash - 방문자 해시 * @param {{ sourceGroup: string, sourceName: string, deviceType: string, osName: string, keyword?: string }} traffic - 유입 분류 * @returns {Promise} 신규 방문자 여부 */ const registerPostTrafficVisitor = async (sql, day, postId, visitorHash, traffic) => { const rows = await sql` INSERT INTO analytics_daily_visitors ( day, scope, post_id, visitor_hash, source_group, source_name, device_type, os_name, keyword ) VALUES ( ${day}, 'post_traffic', ${postId}, ${visitorHash}, ${traffic.sourceGroup}, ${traffic.sourceName}, ${traffic.deviceType}, ${traffic.osName}, ${traffic.keyword || ''} ) ON CONFLICT DO NOTHING RETURNING id ` return Boolean(rows[0]) } /** * 유입·디바이스 통계를 기록한다. * @param {import('postgres').Sql} sql - DB 클라이언트 * @param {string} day - YYYY-MM-DD * @param {string} visitorHash - 방문자 해시 * @param {{ sourceGroup: string, sourceName: string, deviceType: string, osName: string, keyword?: string }} traffic - 유입 분류 * @returns {Promise} */ const recordAnalyticsTraffic = async (sql, day, visitorHash, traffic) => { await ensureTrafficDailyRow(sql, day, traffic) const isNewTrafficVisitor = await registerTrafficVisitor(sql, day, visitorHash, traffic) await sql` UPDATE analytics_traffic_daily SET page_views = page_views + 1, visitors = visitors + ${isNewTrafficVisitor ? 1 : 0} WHERE day = ${day} AND source_group = ${traffic.sourceGroup} AND source_name = ${traffic.sourceName} AND device_type = ${traffic.deviceType} AND os_name = ${traffic.osName} AND keyword = ${traffic.keyword || ''} ` } /** * 게시물별 유입·디바이스 통계를 기록한다. * @param {import('postgres').Sql} sql - DB 클라이언트 * @param {string} day - YYYY-MM-DD * @param {string} postId - 게시물 ID * @param {string} visitorHash - 방문자 해시 * @param {{ sourceGroup: string, sourceName: string, deviceType: string, osName: string, keyword?: string }} traffic - 유입 분류 * @returns {Promise} */ const recordPostAnalyticsTraffic = async (sql, day, postId, visitorHash, traffic) => { await ensurePostTrafficDailyRow(sql, day, postId, traffic) const isNewPostTrafficVisitor = await registerPostTrafficVisitor(sql, day, postId, visitorHash, traffic) await sql` UPDATE post_analytics_traffic_daily SET page_views = page_views + 1, visitors = visitors + ${isNewPostTrafficVisitor ? 1 : 0} WHERE day = ${day} AND post_id = ${postId} AND source_group = ${traffic.sourceGroup} AND source_name = ${traffic.sourceName} AND device_type = ${traffic.deviceType} AND os_name = ${traffic.osName} AND keyword = ${traffic.keyword || ''} ` } /** * 페이지뷰·읽음 이벤트를 기록한다. * @param {{ visitorHash: string, sessionHash?: string, contentSessionHash?: string, path?: string, postId?: string | null, pageId?: string | null, traffic?: { sourceGroup: string, sourceName: string, deviceType: string, osName: string, keyword?: string }, recordSite?: boolean, recordView?: boolean, recordRead?: boolean }} input - 기록 입력 * @returns {Promise<{ ok: true }>} */ export const recordAnalyticsPageview = async (input) => { const sql = getPostgresClient() if (!sql) { return { ok: true } } const day = getAnalyticsDayKey() const visitorHash = input.visitorHash const sessionHash = input.sessionHash || '' const contentSessionHash = input.contentSessionHash || '' const path = input.path || '' const postId = input.postId || null const pageId = input.pageId || null const recordSite = input.recordSite !== false const recordView = Boolean(input.recordView) const recordRead = Boolean(input.recordRead) const traffic = input.traffic || null const shouldRecordPageview = recordView || recordSite ? await markContentSessionPageview(sql, { day, visitorHash, sessionHash, contentSessionHash, path, postId, pageId, traffic }) : true const shouldRecordRead = recordRead ? await markContentSessionRead(sql, { day, visitorHash, sessionHash, contentSessionHash, path, postId, pageId, traffic }) : false if (recordSite && shouldRecordPageview) { await ensureSiteDailyRow(sql, day) const isNewSiteVisitor = await registerSiteVisitor(sql, day, visitorHash) await sql` UPDATE site_analytics_daily SET page_views = page_views + 1, visitors = visitors + ${isNewSiteVisitor ? 1 : 0} WHERE day = ${day} ` if (traffic) { await recordAnalyticsTraffic(sql, day, visitorHash, traffic) } } if (!postId) { if (pageId && recordView && shouldRecordPageview) { await ensurePageDailyRow(sql, day, pageId) const isNewPageVisitor = await registerPageVisitor(sql, day, pageId, visitorHash) await sql` UPDATE page_analytics_daily SET views = views + 1, visitors = visitors + ${isNewPageVisitor ? 1 : 0} WHERE day = ${day} AND page_id = ${pageId} ` } await purgeAnalyticsRetention(sql) return { ok: true } } await ensurePostDailyRow(sql, day, postId) if (recordView && shouldRecordPageview) { const isNewPostVisitor = await registerPostVisitor(sql, day, postId, visitorHash) await sql` UPDATE post_analytics_daily SET views = views + 1, visitors = visitors + ${isNewPostVisitor ? 1 : 0} WHERE day = ${day} AND post_id = ${postId} ` if (traffic) { await recordPostAnalyticsTraffic(sql, day, postId, visitorHash, traffic) } } if (recordRead && shouldRecordRead) { await sql` UPDATE post_analytics_daily SET reads = reads + 1 WHERE day = ${day} AND post_id = ${postId} ` } await purgeAnalyticsRetention(sql) return { ok: true } } /** * 요청에서 일 단위 방문자 해시를 만든다. * @param {import('h3').H3Event} event - H3 이벤트 * @returns {string} visitor hash */ export const createVisitorHashFromEvent = (event) => { const day = getAnalyticsDayKey() const ip = String(getRequestIP(event, { xForwardedFor: true }) || '') const userAgent = String(getRequestHeader(event, 'user-agent') || '') return createDailyVisitorHash({ day, ip, userAgent, secret: getAnalyticsHashSecret() }) } /** * heartbeat용 실시간 세션 해시를 만든다. * @param {import('h3').H3Event} event - H3 이벤트 * @param {string} clientSessionId - 탭 단위 클라이언트 세션 ID * @returns {string} session hash */ export const createSessionHashFromEvent = (event, clientSessionId) => { return createRealtimeSessionHash({ clientSessionId: String(clientSessionId || '').trim(), visitorHash: createVisitorHashFromEvent(event), secret: getAnalyticsHashSecret() }) } /** * 콘텐츠 단위 세션 해시를 만든다. * @param {import('h3').H3Event} event - H3 이벤트 * @param {string} contentSessionId - 콘텐츠 단위 세션 ID * @returns {string} content session hash */ export const createContentSessionHashFromEvent = (event, contentSessionId) => { return createContentSessionHash({ contentSessionId: String(contentSessionId || '').trim(), visitorHash: createVisitorHashFromEvent(event), secret: getAnalyticsHashSecret() }) } /** * 만료된 실시간 세션 행을 정리한다. * @param {import('postgres').Sql} sql - DB 클라이언트 * @returns {Promise} */ const purgeStaleActiveSessions = async (sql) => { await sql` DELETE FROM analytics_active_sessions WHERE last_seen_at < now() - (${ANALYTICS_ACTIVE_SESSION_TTL_SECONDS} * interval '1 second') ` } /** * 오래된 방문자 해시를 정리한다. * @param {import('postgres').Sql} sql - DB 클라이언트 * @returns {Promise} */ const purgeAnalyticsRetention = async (sql) => { const now = Date.now() if (now - lastAnalyticsRetentionPurgeAt < ANALYTICS_RETENTION_PURGE_INTERVAL_MS) { return } const today = getAnalyticsDayKey() const visitorHashCutoffDay = getAnalyticsDayBefore(today, ANALYTICS_VISITOR_HASH_RETENTION_DAYS) await sql` DELETE FROM analytics_daily_visitors WHERE day < ${visitorHashCutoffDay}::date ` await sql` DELETE FROM analytics_content_sessions WHERE day < ${visitorHashCutoffDay}::date ` lastAnalyticsRetentionPurgeAt = now } /** * 콘텐츠 세션에 pageview 처리를 표시한다. * @param {import('postgres').Sql} sql - DB 클라이언트 * @param {{ day: string, contentSessionHash: string, visitorHash: string, sessionHash: string, path: string, postId?: string | null, pageId?: string | null, traffic?: { sourceGroup: string, sourceName: string, deviceType: string, osName: string, keyword?: string } | null }} input - 콘텐츠 세션 입력 * @returns {Promise} 새 pageview 집계 필요 여부 */ const markContentSessionPageview = async (sql, input) => { if (!hasAnalyticsSessionId(input.contentSessionHash)) { return true } const traffic = input.traffic || {} const rows = await sql` INSERT INTO analytics_content_sessions ( content_session_hash, day, visitor_hash, session_hash, path, post_id, page_id, source_group, source_name, device_type, os_name, keyword, view_recorded ) VALUES ( ${input.contentSessionHash}, ${input.day}, ${input.visitorHash}, ${input.sessionHash}, ${input.path}, ${input.postId || null}, ${input.pageId || null}, ${traffic.sourceGroup || ''}, ${traffic.sourceName || ''}, ${traffic.deviceType || ''}, ${traffic.osName || ''}, ${traffic.keyword || ''}, true ) ON CONFLICT (content_session_hash) DO UPDATE SET day = EXCLUDED.day, visitor_hash = EXCLUDED.visitor_hash, session_hash = EXCLUDED.session_hash, path = EXCLUDED.path, post_id = EXCLUDED.post_id, page_id = EXCLUDED.page_id, source_group = EXCLUDED.source_group, source_name = EXCLUDED.source_name, device_type = EXCLUDED.device_type, os_name = EXCLUDED.os_name, keyword = EXCLUDED.keyword, view_recorded = true, last_seen_at = now() WHERE analytics_content_sessions.view_recorded = false RETURNING content_session_hash ` return rows.length > 0 } /** * 콘텐츠 세션에 read 처리를 표시한다. * @param {import('postgres').Sql} sql - DB 클라이언트 * @param {{ day: string, contentSessionHash: string, visitorHash: string, sessionHash: string, path: string, postId?: string | null, pageId?: string | null, traffic?: { sourceGroup: string, sourceName: string, deviceType: string, osName: string, keyword?: string } | null }} input - 콘텐츠 세션 입력 * @returns {Promise} 새 read 집계 필요 여부 */ const markContentSessionRead = async (sql, input) => { if (!hasAnalyticsSessionId(input.contentSessionHash)) { return true } const traffic = input.traffic || {} const rows = await sql` INSERT INTO analytics_content_sessions ( content_session_hash, day, visitor_hash, session_hash, path, post_id, page_id, source_group, source_name, device_type, os_name, keyword, read_recorded, read_at ) VALUES ( ${input.contentSessionHash}, ${input.day}, ${input.visitorHash}, ${input.sessionHash}, ${input.path}, ${input.postId || null}, ${input.pageId || null}, ${traffic.sourceGroup || ''}, ${traffic.sourceName || ''}, ${traffic.deviceType || ''}, ${traffic.osName || ''}, ${traffic.keyword || ''}, true, now() ) ON CONFLICT (content_session_hash) DO UPDATE SET read_recorded = true, read_at = COALESCE(analytics_content_sessions.read_at, now()), last_seen_at = now() WHERE analytics_content_sessions.read_recorded = false RETURNING content_session_hash ` return rows.length > 0 } /** * 날짜 문자열이 통계 조회 형식인지 확인한다. * @param {string} value - 날짜 문자열 * @returns {boolean} 유효 여부 */ const isAnalyticsDateKey = (value) => { return /^\d{4}-\d{2}-\d{2}$/.test(String(value || '')) } /** * 두 날짜 사이의 일수를 계산한다. * @param {string} startDay - 시작일 * @param {string} endDay - 종료일 * @returns {number} 일수 */ const getAnalyticsDateRangeDays = (startDay, endDay) => { const startDate = new Date(`${startDay}T00:00:00.000Z`) const endDate = new Date(`${endDay}T00:00:00.000Z`) if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) { return 0 } return Math.floor((endDate.getTime() - startDate.getTime()) / 86400000) + 1 } /** * 날짜 객체를 통계 날짜 키로 변환한다. * @param {Date} date - 날짜 객체 * @returns {string} 날짜 키 */ const formatAnalyticsDateKey = (date) => { return date.toISOString().slice(0, 10) } /** * 통계 추이 집계 단위를 정리한다. * @param {string} period - 요청 집계 단위 * @returns {'daily' | 'weekly' | 'monthly' | 'yearly'} 집계 단위 */ const normalizeAnalyticsTrendPeriod = (period) => { if (period === 'daily' || period === 'weekly' || period === 'monthly' || period === 'yearly') { return period } return 'weekly' } /** * 통계 추이 조회 시작일을 반환한다. * @param {'daily' | 'weekly' | 'monthly' | 'yearly'} period - 집계 단위 * @param {string} today - 오늘 날짜 키 * @returns {string} 시작일 */ const getAnalyticsTrendStartDay = (period, today) => { const todayDate = new Date(`${today}T00:00:00.000Z`) const totalBuckets = getAnalyticsChartTotalBuckets(period) const pageSize = getAnalyticsChartPageSize(period) const bucketCount = Math.max(totalBuckets, pageSize) if (period === 'daily') { return getAnalyticsDayBefore(today, bucketCount - 1) } if (period === 'weekly') { const currentWeekStartDate = new Date(todayDate) currentWeekStartDate.setUTCDate(todayDate.getUTCDate() - todayDate.getUTCDay()) currentWeekStartDate.setUTCDate(currentWeekStartDate.getUTCDate() - ((bucketCount - 1) * 7)) return formatAnalyticsDateKey(currentWeekStartDate) } if (period === 'monthly') { const monthStartDate = new Date(Date.UTC(todayDate.getUTCFullYear(), todayDate.getUTCMonth() - (bucketCount - 1), 1)) return formatAnalyticsDateKey(monthStartDate) } const yearStartDate = new Date(Date.UTC(todayDate.getUTCFullYear() - (bucketCount - 1), 0, 1)) return formatAnalyticsDateKey(yearStartDate) } /** * 통계 추이 라벨을 만든다. * @param {'daily' | 'weekly' | 'monthly' | 'yearly'} period - 집계 단위 * @param {string} startDay - 시작일 * @param {string} endDay - 종료일 * @returns {string} 라벨 */ const formatAnalyticsTrendLabel = (period, startDay, endDay, today = getAnalyticsDayKey()) => { const startDate = new Date(`${startDay}T00:00:00.000Z`) const endDate = new Date(`${endDay}T00:00:00.000Z`) if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) { return '' } if (endDay === today) { if (period === 'daily') { return '오늘' } if (period === 'weekly') { return '이번주' } if (period === 'monthly') { return '이번달' } if (period === 'yearly') { return '올해' } } if (period === 'yearly') { return `${startDate.getUTCFullYear()}년` } if (period === 'monthly') { return `${startDate.getUTCMonth() + 1}월` } if (period === 'weekly') { const startLabel = `${startDate.getUTCMonth() + 1}.${String(startDate.getUTCDate()).padStart(2, '0')}` const endLabel = `${endDate.getUTCMonth() + 1}.${String(endDate.getUTCDate()).padStart(2, '0')}` return `${startLabel}-${endLabel}` } return `${String(startDate.getUTCMonth() + 1).padStart(2, '0')}.${String(startDate.getUTCDate()).padStart(2, '0')}` } /** * 통계 상세 조회 날짜 범위를 정리한다. * @param {{ startDay?: string, endDay?: string }} options - 조회 옵션 * @returns {{ startDay: string, endDay: string, rangeDays: number }} 날짜 범위 */ const normalizeAnalyticsDetailRange = (options = {}) => { const today = getAnalyticsDayKey() const rawStartDay = String(options.startDay || today).trim() const rawEndDay = String(options.endDay || rawStartDay).trim() let startDay = isAnalyticsDateKey(rawStartDay) ? rawStartDay : today let endDay = isAnalyticsDateKey(rawEndDay) ? rawEndDay : startDay let rangeDays = getAnalyticsDateRangeDays(startDay, endDay) if (rangeDays < 1) { const nextStartDay = endDay endDay = startDay startDay = nextStartDay rangeDays = getAnalyticsDateRangeDays(startDay, endDay) } if (rangeDays > 31) { const limitedStartDay = getAnalyticsDayBefore(endDay, 30) return { startDay: limitedStartDay, endDay, rangeDays: 31 } } return { startDay, endDay, rangeDays } } /** * 스크롤 구간 카운터를 증가시킨다. * @param {import('postgres').Sql} sql - DB 클라이언트 * @param {string} day - YYYY-MM-DD * @param {string} postId - 게시물 ID * @param {Array<'scroll_25' | 'scroll_50' | 'scroll_75' | 'scroll_100'>} columns - 구간 컬럼 * @returns {Promise} */ const incrementPostScrollBuckets = async (sql, day, postId, columns) => { if (!columns.length) { return } await ensurePostDailyRow(sql, day, postId) for (const column of columns) { if (column === 'scroll_25') { await sql` UPDATE post_analytics_daily SET scroll_25 = scroll_25 + 1 WHERE day = ${day} AND post_id = ${postId} ` } else if (column === 'scroll_50') { await sql` UPDATE post_analytics_daily SET scroll_50 = scroll_50 + 1 WHERE day = ${day} AND post_id = ${postId} ` } else if (column === 'scroll_75') { await sql` UPDATE post_analytics_daily SET scroll_75 = scroll_75 + 1 WHERE day = ${day} AND post_id = ${postId} ` } else if (column === 'scroll_100') { await sql` UPDATE post_analytics_daily SET scroll_100 = scroll_100 + 1 WHERE day = ${day} AND post_id = ${postId} ` } } } /** * 페이지 스크롤 구간 카운터를 증가시킨다. * @param {import('postgres').Sql} sql - DB 클라이언트 * @param {string} day - YYYY-MM-DD * @param {string} pageId - 페이지 ID * @param {Array<'scroll_25' | 'scroll_50' | 'scroll_75' | 'scroll_100'>} columns - 구간 컬럼 * @returns {Promise} */ const incrementPageScrollBuckets = async (sql, day, pageId, columns) => { if (!columns.length) { return } await ensurePageDailyRow(sql, day, pageId) for (const column of columns) { if (column === 'scroll_25') { await sql` UPDATE page_analytics_daily SET scroll_25 = scroll_25 + 1 WHERE day = ${day} AND page_id = ${pageId} ` } else if (column === 'scroll_50') { await sql` UPDATE page_analytics_daily SET scroll_50 = scroll_50 + 1 WHERE day = ${day} AND page_id = ${pageId} ` } else if (column === 'scroll_75') { await sql` UPDATE page_analytics_daily SET scroll_75 = scroll_75 + 1 WHERE day = ${day} AND page_id = ${pageId} ` } else if (column === 'scroll_100') { await sql` UPDATE page_analytics_daily SET scroll_100 = scroll_100 + 1 WHERE day = ${day} AND page_id = ${pageId} ` } } } /** * heartbeat·체류·스크롤·실시간 세션을 기록한다. * @param {{ event: import('h3').H3Event, sessionHash: string, contentSessionHash?: string, path: string, postId?: string | null, postSlug?: string, pageId?: string | null, pageSlug?: string, durationSeconds: number, maxScrollRatio: number }} input - 기록 입력 * @returns {Promise<{ ok: true }>} */ export const recordAnalyticsHeartbeat = async (input) => { const sql = getPostgresClient() if (!sql) { return { ok: true } } const day = getAnalyticsDayKey() const sessionHash = input.sessionHash const contentSessionHash = input.contentSessionHash || '' const path = input.path const postId = input.postId || null const postSlug = input.postSlug || '' const pageId = input.pageId || null const pageSlug = input.pageSlug || '' const durationSeconds = clampAnalyticsDurationSeconds(input.durationSeconds) const maxScrollRatio = clampAnalyticsScrollRatio(input.maxScrollRatio) const visitorHash = createVisitorHashFromEvent(input.event) const memberSession = getMemberSession(input.event) const userId = memberSession?.userId || null const ipAddress = getClientIp(input.event) const previousRows = contentSessionHash ? await sql` SELECT duration_seconds, max_scroll_ratio, engaged_recorded FROM analytics_content_sessions WHERE content_session_hash = ${contentSessionHash} LIMIT 1 ` : await sql` SELECT duration_seconds, max_scroll_ratio, false AS engaged_recorded FROM analytics_active_sessions WHERE session_hash = ${sessionHash} LIMIT 1 ` const previousDuration = Number(previousRows[0]?.duration_seconds || 0) const previousScrollRatio = Number(previousRows[0]?.max_scroll_ratio || 0) const wasEngaged = Boolean(previousRows[0]?.engaged_recorded) const durationDelta = Math.max(0, durationSeconds - previousDuration) const scrollBuckets = getNewScrollBucketColumns(previousScrollRatio, maxScrollRatio) if (contentSessionHash) { await sql` INSERT INTO analytics_content_sessions ( content_session_hash, day, visitor_hash, session_hash, path, post_id, page_id, duration_seconds, max_scroll_ratio, engaged_recorded, scroll_25_recorded, scroll_50_recorded, scroll_75_recorded, scroll_100_recorded ) VALUES ( ${contentSessionHash}, ${day}, ${visitorHash}, ${sessionHash}, ${path}, ${postId}, ${pageId}, ${durationSeconds}, ${maxScrollRatio}, ${durationSeconds >= ANALYTICS_ENGAGED_MIN_SECONDS}, ${maxScrollRatio >= 0.25}, ${maxScrollRatio >= 0.5}, ${maxScrollRatio >= 0.75}, ${maxScrollRatio >= 1} ) ON CONFLICT (content_session_hash) DO UPDATE SET day = EXCLUDED.day, visitor_hash = EXCLUDED.visitor_hash, session_hash = EXCLUDED.session_hash, path = EXCLUDED.path, post_id = EXCLUDED.post_id, page_id = EXCLUDED.page_id, duration_seconds = GREATEST(analytics_content_sessions.duration_seconds, EXCLUDED.duration_seconds), max_scroll_ratio = GREATEST(analytics_content_sessions.max_scroll_ratio, EXCLUDED.max_scroll_ratio), engaged_recorded = analytics_content_sessions.engaged_recorded OR EXCLUDED.engaged_recorded, scroll_25_recorded = analytics_content_sessions.scroll_25_recorded OR EXCLUDED.scroll_25_recorded, scroll_50_recorded = analytics_content_sessions.scroll_50_recorded OR EXCLUDED.scroll_50_recorded, scroll_75_recorded = analytics_content_sessions.scroll_75_recorded OR EXCLUDED.scroll_75_recorded, scroll_100_recorded = analytics_content_sessions.scroll_100_recorded OR EXCLUDED.scroll_100_recorded, last_seen_at = now() ` } await sql` INSERT INTO analytics_active_sessions ( session_hash, content_session_hash, user_id, path, post_id, post_slug, page_id, page_slug, ip_address, duration_seconds, max_scroll_ratio ) VALUES ( ${sessionHash}, ${contentSessionHash}, ${userId}, ${path}, ${postId}, ${postSlug}, ${pageId}, ${pageSlug}, ${ipAddress}, ${durationSeconds}, ${maxScrollRatio} ) ON CONFLICT (session_hash) DO UPDATE SET user_id = COALESCE(EXCLUDED.user_id, analytics_active_sessions.user_id), content_session_hash = EXCLUDED.content_session_hash, path = EXCLUDED.path, post_id = EXCLUDED.post_id, post_slug = EXCLUDED.post_slug, page_id = EXCLUDED.page_id, page_slug = EXCLUDED.page_slug, ip_address = EXCLUDED.ip_address, duration_seconds = CASE WHEN analytics_active_sessions.content_session_hash = EXCLUDED.content_session_hash THEN GREATEST(analytics_active_sessions.duration_seconds, EXCLUDED.duration_seconds) ELSE EXCLUDED.duration_seconds END, max_scroll_ratio = CASE WHEN analytics_active_sessions.content_session_hash = EXCLUDED.content_session_hash THEN GREATEST(analytics_active_sessions.max_scroll_ratio, EXCLUDED.max_scroll_ratio) ELSE EXCLUDED.max_scroll_ratio END, last_seen_at = now() ` if (durationDelta > 0) { await ensureSiteDailyRow(sql, day) await sql` UPDATE site_analytics_daily SET total_engaged_seconds = total_engaged_seconds + ${durationDelta} WHERE day = ${day} ` const wasSiteEngaged = contentSessionHash ? wasEngaged : previousDuration >= ANALYTICS_ENGAGED_MIN_SECONDS const isSiteEngaged = durationSeconds >= ANALYTICS_ENGAGED_MIN_SECONDS if (!wasSiteEngaged && isSiteEngaged) { await sql` UPDATE site_analytics_daily SET engaged_views = engaged_views + 1 WHERE day = ${day} ` } } if (postId && (durationDelta > 0 || scrollBuckets.length)) { await ensurePostDailyRow(sql, day, postId) if (durationDelta > 0) { await sql` UPDATE post_analytics_daily SET total_engaged_seconds = total_engaged_seconds + ${durationDelta} WHERE day = ${day} AND post_id = ${postId} ` const wasPostEngaged = contentSessionHash ? wasEngaged : previousDuration >= ANALYTICS_ENGAGED_MIN_SECONDS const isPostEngaged = durationSeconds >= ANALYTICS_ENGAGED_MIN_SECONDS if (!wasPostEngaged && isPostEngaged) { await sql` UPDATE post_analytics_daily SET engaged_views = engaged_views + 1 WHERE day = ${day} AND post_id = ${postId} ` } } await incrementPostScrollBuckets(sql, day, postId, scrollBuckets) } if (pageId && (durationDelta > 0 || scrollBuckets.length)) { await ensurePageDailyRow(sql, day, pageId) if (durationDelta > 0) { await sql` UPDATE page_analytics_daily SET total_engaged_seconds = total_engaged_seconds + ${durationDelta} WHERE day = ${day} AND page_id = ${pageId} ` const wasPageEngaged = contentSessionHash ? wasEngaged : previousDuration >= ANALYTICS_ENGAGED_MIN_SECONDS const isPageEngaged = durationSeconds >= ANALYTICS_ENGAGED_MIN_SECONDS if (!wasPageEngaged && isPageEngaged) { await sql` UPDATE page_analytics_daily SET engaged_views = engaged_views + 1 WHERE day = ${day} AND page_id = ${pageId} ` } } await incrementPageScrollBuckets(sql, day, pageId, scrollBuckets) } await purgeStaleActiveSessions(sql) await purgeAnalyticsRetention(sql) return { ok: true } } /** * 통계 요약을 조회한다. * @param {{ days?: number, period?: 'daily' | 'weekly' | 'monthly' | 'yearly' }} [options] - 조회 옵션 * @returns {Promise} 요약 통계 */ export const getAnalyticsSummary = async (options = {}) => { const sql = getPostgresClient() const period = normalizeAnalyticsTrendPeriod(options.period) const fallbackDays = period === 'daily' ? 7 : period === 'weekly' ? 56 : period === 'monthly' ? 365 : 365 * 8 const days = Math.min(Math.max(Number(options.days) || fallbackDays, 1), 365 * 8) if (!sql) { return { todayVisitors: 0, todayPageViews: 0, todayAvgEngagedSeconds: 0, visitorsLast7Days: 0, visitorsLast30Days: 0, visitorsLast180Days: 0, visitorsLast365Days: 0, pageViewsLast30Days: 0, onlineNow: 0, loggedInNow: 0, avgEngagedSeconds: 0, scroll50Reach: 0, trends: [], collectedSince: ANALYTICS_COLLECTED_SINCE_DEFAULT, period, days } } const today = getAnalyticsDayKey() const last7StartDay = getAnalyticsDayBefore(today, 6) const last30StartDay = getAnalyticsDayBefore(today, 29) const last180StartDay = getAnalyticsDayBefore(today, 179) const last365StartDay = getAnalyticsDayBefore(today, 364) const rangeStartDay = getAnalyticsTrendStartDay(period, today) await purgeStaleActiveSessions(sql) await purgeAnalyticsRetention(sql) const collectedSince = getAnalyticsCollectedSinceDay() const trendStartDay = getAnalyticsEffectiveTrendStartDay(period, rangeStartDay, collectedSince) const todayRows = await sql` SELECT visitors, page_views, engaged_views, total_engaged_seconds FROM site_analytics_daily WHERE day = ${today}::date LIMIT 1 ` const visitorPeriodRows = await sql` SELECT COALESCE(SUM(visitors) FILTER (WHERE day >= ${last7StartDay}::date), 0)::int AS visitors_last_7_days, COALESCE(SUM(visitors) FILTER (WHERE day >= ${last30StartDay}::date), 0)::int AS visitors_last_30_days, COALESCE(SUM(visitors) FILTER (WHERE day >= ${last180StartDay}::date), 0)::int AS visitors_last_180_days, COALESCE(SUM(visitors) FILTER (WHERE day >= ${last365StartDay}::date), 0)::int AS visitors_last_365_days FROM site_analytics_daily WHERE day >= ${last365StartDay}::date ` const pageViewRows = await sql` SELECT COALESCE(SUM(page_views), 0)::int AS page_views FROM site_analytics_daily WHERE day >= ${last30StartDay}::date ` const engagementRows = await sql` SELECT COALESCE(SUM(total_engaged_seconds), 0)::int AS total_engaged_seconds, COALESCE(SUM(engaged_views), 0)::int AS engaged_views FROM site_analytics_daily WHERE day >= ${rangeStartDay}::date ` const scrollRows = await sql` SELECT COALESCE(SUM(views), 0)::int AS views, COALESCE(SUM(scroll_50), 0)::int AS scroll_50 FROM post_analytics_daily WHERE day >= ${rangeStartDay}::date ` const onlineRows = await sql` SELECT COUNT(DISTINCT CASE WHEN user_id IS NOT NULL THEN 'user:' || user_id::text ELSE 'ip:' || COALESCE(NULLIF(ip_address, ''), session_hash) END)::int AS online_now, COUNT(DISTINCT user_id) FILTER (WHERE user_id IS NOT NULL)::int AS logged_in_now FROM analytics_active_sessions WHERE last_seen_at >= now() - (${ANALYTICS_ACTIVE_SESSION_TTL_SECONDS} * interval '1 second') ` let trendRows = [] const weeklyLookbackDays = (getAnalyticsChartTotalBuckets('weekly') - 1) * 7 const monthlyLookbackMonths = getAnalyticsChartTotalBuckets('monthly') - 1 const yearlyLookbackYears = getAnalyticsChartTotalBuckets('yearly') - 1 if (period === 'daily') { trendRows = await sql` WITH buckets AS ( SELECT bucket_day::date AS start_day, bucket_day::date AS end_day FROM generate_series(${trendStartDay}::date, ${today}::date, interval '1 day') AS bucket_day ), post_daily AS ( SELECT day, COALESCE(SUM(views), 0)::int AS views, COALESCE(SUM(scroll_50), 0)::int AS scroll_50 FROM post_analytics_daily WHERE day BETWEEN ${trendStartDay}::date AND ${today}::date GROUP BY day ) SELECT buckets.start_day, buckets.end_day, COALESCE(site_analytics_daily.visitors, 0)::int AS visitors, CASE WHEN COALESCE(site_analytics_daily.engaged_views, 0) > 0 THEN ROUND(site_analytics_daily.total_engaged_seconds::numeric / site_analytics_daily.engaged_views)::int ELSE 0 END AS avg_engaged_seconds, COALESCE(post_daily.views, 0)::int AS post_views, COALESCE(post_daily.scroll_50, 0)::int AS scroll_50 FROM buckets LEFT JOIN site_analytics_daily ON site_analytics_daily.day = buckets.start_day LEFT JOIN post_daily ON post_daily.day = buckets.start_day ORDER BY buckets.start_day ASC ` } else if (period === 'weekly') { trendRows = await sql` WITH bounds AS ( SELECT (date_trunc('week', ${today}::date + interval '1 day') - interval '1 day')::date AS current_week_start, ${today}::date AS today ), bucket_starts AS ( SELECT generate_series( GREATEST( (bounds.current_week_start - (${weeklyLookbackDays} * interval '1 day'))::date, ${trendStartDay}::date ), bounds.current_week_start, interval '7 day' )::date AS start_day, bounds.today FROM bounds ), buckets AS ( SELECT bucket_starts.start_day, LEAST((bucket_starts.start_day + interval '6 day')::date, bucket_starts.today) AS end_day FROM bucket_starts ) SELECT buckets.start_day, buckets.end_day, COALESCE(( SELECT SUM(visitors) FROM site_analytics_daily WHERE day BETWEEN buckets.start_day AND buckets.end_day AND day >= ${collectedSince}::date ), 0)::int AS visitors, CASE WHEN COALESCE(( SELECT SUM(engaged_views) FROM site_analytics_daily WHERE day BETWEEN buckets.start_day AND buckets.end_day AND day >= ${collectedSince}::date ), 0) > 0 THEN ROUND(( SELECT SUM(total_engaged_seconds)::numeric / NULLIF(SUM(engaged_views), 0) FROM site_analytics_daily WHERE day BETWEEN buckets.start_day AND buckets.end_day AND day >= ${collectedSince}::date ))::int ELSE 0 END AS avg_engaged_seconds, COALESCE(( SELECT SUM(views) FROM post_analytics_daily WHERE day BETWEEN buckets.start_day AND buckets.end_day AND day >= ${collectedSince}::date ), 0)::int AS post_views, COALESCE(( SELECT SUM(scroll_50) FROM post_analytics_daily WHERE day BETWEEN buckets.start_day AND buckets.end_day AND day >= ${collectedSince}::date ), 0)::int AS scroll_50 FROM buckets ORDER BY buckets.start_day ASC ` } else if (period === 'monthly') { trendRows = await sql` WITH buckets AS ( SELECT generate_series( GREATEST( (date_trunc('month', ${today}::date) - (${monthlyLookbackMonths} * interval '1 month'))::date, ${trendStartDay}::date ), date_trunc('month', ${today}::date), interval '1 month' )::date AS start_day ) SELECT buckets.start_day, LEAST((buckets.start_day + interval '1 month' - interval '1 day')::date, ${today}::date) AS end_day, COALESCE(( SELECT SUM(visitors) FROM site_analytics_daily WHERE day BETWEEN buckets.start_day AND LEAST((buckets.start_day + interval '1 month' - interval '1 day')::date, ${today}::date) AND day >= ${collectedSince}::date ), 0)::int AS visitors, CASE WHEN COALESCE(( SELECT SUM(engaged_views) FROM site_analytics_daily WHERE day BETWEEN buckets.start_day AND LEAST((buckets.start_day + interval '1 month' - interval '1 day')::date, ${today}::date) AND day >= ${collectedSince}::date ), 0) > 0 THEN ROUND(( SELECT SUM(total_engaged_seconds)::numeric / NULLIF(SUM(engaged_views), 0) FROM site_analytics_daily WHERE day BETWEEN buckets.start_day AND LEAST((buckets.start_day + interval '1 month' - interval '1 day')::date, ${today}::date) AND day >= ${collectedSince}::date ))::int ELSE 0 END AS avg_engaged_seconds, COALESCE(( SELECT SUM(views) FROM post_analytics_daily WHERE day BETWEEN buckets.start_day AND LEAST((buckets.start_day + interval '1 month' - interval '1 day')::date, ${today}::date) AND day >= ${collectedSince}::date ), 0)::int AS post_views, COALESCE(( SELECT SUM(scroll_50) FROM post_analytics_daily WHERE day BETWEEN buckets.start_day AND LEAST((buckets.start_day + interval '1 month' - interval '1 day')::date, ${today}::date) AND day >= ${collectedSince}::date ), 0)::int AS scroll_50 FROM buckets ORDER BY buckets.start_day ASC ` } else { trendRows = await sql` WITH bounds AS ( SELECT GREATEST( date_trunc('year', ${collectedSince}::date), (date_trunc('year', ${today}::date) - (${yearlyLookbackYears} * interval '1 year'))::date, ${trendStartDay}::date )::date AS first_year_start, date_trunc('year', ${today}::date)::date AS current_year_start ), buckets AS ( SELECT generate_series(bounds.first_year_start, bounds.current_year_start, interval '1 year')::date AS start_day FROM bounds ) SELECT buckets.start_day, LEAST((buckets.start_day + interval '1 year' - interval '1 day')::date, ${today}::date) AS end_day, COALESCE(( SELECT SUM(visitors) FROM site_analytics_daily WHERE day BETWEEN buckets.start_day AND LEAST((buckets.start_day + interval '1 year' - interval '1 day')::date, ${today}::date) AND day >= ${collectedSince}::date ), 0)::int AS visitors, CASE WHEN COALESCE(( SELECT SUM(engaged_views) FROM site_analytics_daily WHERE day BETWEEN buckets.start_day AND LEAST((buckets.start_day + interval '1 year' - interval '1 day')::date, ${today}::date) AND day >= ${collectedSince}::date ), 0) > 0 THEN ROUND(( SELECT SUM(total_engaged_seconds)::numeric / NULLIF(SUM(engaged_views), 0) FROM site_analytics_daily WHERE day BETWEEN buckets.start_day AND LEAST((buckets.start_day + interval '1 year' - interval '1 day')::date, ${today}::date) AND day >= ${collectedSince}::date ))::int ELSE 0 END AS avg_engaged_seconds, COALESCE(( SELECT SUM(views) FROM post_analytics_daily WHERE day BETWEEN buckets.start_day AND LEAST((buckets.start_day + interval '1 year' - interval '1 day')::date, ${today}::date) AND day >= ${collectedSince}::date ), 0)::int AS post_views, COALESCE(( SELECT SUM(scroll_50) FROM post_analytics_daily WHERE day BETWEEN buckets.start_day AND LEAST((buckets.start_day + interval '1 year' - interval '1 day')::date, ${today}::date) AND day >= ${collectedSince}::date ), 0)::int AS scroll_50 FROM buckets ORDER BY buckets.start_day ASC ` } const trafficDailyRows = await sql` SELECT day, source_group, COALESCE(SUM(visitors), 0)::int AS visitors FROM analytics_traffic_daily WHERE day BETWEEN ${trendStartDay}::date AND ${today}::date AND day >= ${collectedSince}::date GROUP BY day, source_group ` const engagedViews = Number(engagementRows[0]?.engaged_views || 0) const totalEngagedSeconds = Number(engagementRows[0]?.total_engaged_seconds || 0) const todayEngagedViews = Number(todayRows[0]?.engaged_views || 0) const todayTotalEngagedSeconds = Number(todayRows[0]?.total_engaged_seconds || 0) /** * 버킷 구간의 유입 방문자를 합산한다. * @param {string} startDay - 시작일 * @param {string} endDay - 종료일 * @returns {{ trafficSearch: number, trafficSns: number, trafficOther: number }} */ const aggregateTrafficBreakdownForRange = (startDay, endDay) => { let trafficSearch = 0 let trafficSns = 0 let trafficOther = 0 for (const row of trafficDailyRows) { const dayKey = row.day ? new Date(row.day).toISOString().slice(0, 10) : '' if (!dayKey || dayKey < startDay || dayKey > endDay) { continue } const visitors = Number(row.visitors || 0) if (row.source_group === 'search') { trafficSearch += visitors } else if (row.source_group === 'sns') { trafficSns += visitors } else { trafficOther += visitors } } return { trafficSearch, trafficSns, trafficOther } } return { todayVisitors: Number(todayRows[0]?.visitors || 0), todayPageViews: Number(todayRows[0]?.page_views || 0), todayAvgEngagedSeconds: todayEngagedViews > 0 ? Math.round(todayTotalEngagedSeconds / todayEngagedViews) : 0, visitorsLast7Days: Number(visitorPeriodRows[0]?.visitors_last_7_days || 0), visitorsLast30Days: Number(visitorPeriodRows[0]?.visitors_last_30_days || 0), visitorsLast180Days: Number(visitorPeriodRows[0]?.visitors_last_180_days || 0), visitorsLast365Days: Number(visitorPeriodRows[0]?.visitors_last_365_days || 0), pageViewsLast30Days: Number(pageViewRows[0]?.page_views || 0), onlineNow: Number(onlineRows[0]?.online_now || 0), loggedInNow: Number(onlineRows[0]?.logged_in_now || 0), avgEngagedSeconds: engagedViews > 0 ? Math.round(totalEngagedSeconds / engagedViews) : 0, scroll50Reach: Math.min( Number(scrollRows[0]?.scroll_50 || 0), Number(scrollRows[0]?.views || 0) ), collectedSince, trends: trendRows.map((row) => { const startDay = row.start_day ? new Date(row.start_day).toISOString().slice(0, 10) : '' const endDay = row.end_day ? new Date(row.end_day).toISOString().slice(0, 10) : '' const trafficBreakdown = startDay && endDay ? aggregateTrafficBreakdownForRange(startDay, endDay) : { trafficSearch: 0, trafficSns: 0, trafficOther: 0 } const scroll50Reach = Math.min( Number(row.scroll_50 || 0), Number(row.post_views || 0) ) return { day: startDay, startDay, endDay, label: startDay && endDay ? formatAnalyticsTrendLabel( period, startDay, endDay, today ) : '', visitors: Number(row.visitors || 0), trafficSearch: trafficBreakdown.trafficSearch, trafficSns: trafficBreakdown.trafficSns, trafficOther: trafficBreakdown.trafficOther, avgEngagedSeconds: Number(row.avg_engaged_seconds || 0), scroll50Reach } }), period, days } } /** * 실시간 접속 요약을 조회한다. * @returns {Promise} 실시간 요약 */ export const getAnalyticsRealtimeSummary = async () => { const sql = getPostgresClient() if (!sql) { return { onlineNow: 0, loggedInNow: 0, anonymousNow: 0 } } await purgeStaleActiveSessions(sql) await purgeAnalyticsRetention(sql) const rows = await sql` SELECT COUNT(DISTINCT CASE WHEN user_id IS NOT NULL THEN 'user:' || user_id::text ELSE 'ip:' || COALESCE(NULLIF(ip_address, ''), session_hash) END)::int AS online_now, COUNT(DISTINCT user_id) FILTER (WHERE user_id IS NOT NULL)::int AS logged_in_now, COUNT(DISTINCT COALESCE(NULLIF(ip_address, ''), session_hash)) FILTER (WHERE user_id IS NULL)::int AS anonymous_now FROM analytics_active_sessions WHERE last_seen_at >= now() - (${ANALYTICS_ACTIVE_SESSION_TTL_SECONDS} * interval '1 second') ` const onlineNow = Number(rows[0]?.online_now || 0) const loggedInNow = Number(rows[0]?.logged_in_now || 0) return { onlineNow, loggedInNow, anonymousNow: Number(rows[0]?.anonymous_now || 0) } } /** * 현재 접속 중인 세션 목록을 조회한다. * @param {{ limit?: number }} [options] - 조회 옵션 * @returns {Promise>} 접속자 목록 */ export const getAnalyticsActiveSessions = async (options = {}) => { const sql = getPostgresClient() const limit = Math.min(Math.max(Number(options.limit) || 20, 1), 50) const rawLimit = Math.min(limit * 8, 200) if (!sql) { return [] } await purgeStaleActiveSessions(sql) await purgeAnalyticsRetention(sql) const rows = await sql` SELECT analytics_active_sessions.session_hash, analytics_active_sessions.path, analytics_active_sessions.post_slug, analytics_active_sessions.page_slug, analytics_active_sessions.ip_address, analytics_active_sessions.duration_seconds, analytics_active_sessions.max_scroll_ratio, analytics_active_sessions.last_seen_at, posts.title AS post_title, pages.title AS page_title, users.id AS user_id, users.username, users.avatar_url FROM analytics_active_sessions LEFT JOIN posts ON posts.id = analytics_active_sessions.post_id LEFT JOIN pages ON pages.id = analytics_active_sessions.page_id LEFT JOIN users ON users.id = analytics_active_sessions.user_id WHERE analytics_active_sessions.last_seen_at >= now() - (${ANALYTICS_ACTIVE_SESSION_TTL_SECONDS} * interval '1 second') ORDER BY analytics_active_sessions.last_seen_at DESC LIMIT ${rawLimit} ` /** @type {Map} */ const activeVisitors = new Map() for (const row of rows) { const activeKey = row.user_id ? `user:${row.user_id}` : `ip:${row.ip_address || row.session_hash}` const existingVisitor = activeVisitors.get(activeKey) const durationSeconds = Number(row.duration_seconds || 0) const page = { key: row.path || row.session_hash, path: row.path, postSlug: row.post_slug || '', pageSlug: row.page_slug || '', postTitle: row.post_title || '', pageTitle: row.page_title || '', durationSeconds, lastSeenAt: row.last_seen_at ? new Date(row.last_seen_at).toISOString() : null } if (existingVisitor) { existingVisitor.pageCount += 1 existingVisitor.durationSeconds = Math.max(existingVisitor.durationSeconds, durationSeconds) existingVisitor.maxScrollRatio = Math.max( existingVisitor.maxScrollRatio, Number(row.max_scroll_ratio || 0) ) if (!existingVisitor.pages.some((item) => item.key === page.key)) { existingVisitor.pages.push(page) } continue } activeVisitors.set(activeKey, { activeKey, path: row.path, postSlug: row.post_slug || '', pageSlug: row.page_slug || '', ipAddress: row.ip_address || '', postTitle: row.post_title || '', pageTitle: row.page_title || '', pageCount: 1, pages: [page], durationSeconds, maxScrollRatio: Number(row.max_scroll_ratio || 0), lastSeenAt: row.last_seen_at ? new Date(row.last_seen_at).toISOString() : null, isLoggedIn: Boolean(row.user_id), user: row.user_id ? { id: row.user_id, username: row.username, avatarUrl: row.avatar_url || '' } : null }) } return Array.from(activeVisitors.values()).slice(0, limit) } /** * 관리자 유입·디바이스·키워드 통계를 조회한다. * @param {{ days?: number }} [options] - 조회 옵션 * @returns {Promise<{ sources: Array, devices: Array, keywords: Array, days: number }>} 유입 통계 */ export const getAnalyticsTrafficSummary = async (options = {}) => { const sql = getPostgresClient() const days = Math.min(Math.max(Number(options.days) || 30, 1), ANALYTICS_CHART_MAX_DAYS) if (!sql) { return { sources: [], devices: [], keywords: [], days } } const today = getAnalyticsDayKey() const collectedSince = getAnalyticsCollectedSinceDay() let rangeStartDay = getAnalyticsDayBefore(today, days - 1) rangeStartDay = getAnalyticsEffectiveTrendStartDay('daily', rangeStartDay, collectedSince) await purgeAnalyticsRetention(sql) const sourceRows = await sql` SELECT source_group, source_name, COALESCE(SUM(page_views), 0)::int AS page_views, COALESCE(SUM(visitors), 0)::int AS visitors FROM analytics_traffic_daily WHERE day >= ${rangeStartDay}::date AND day >= ${collectedSince}::date GROUP BY source_group, source_name ORDER BY page_views DESC, visitors DESC, source_name ASC ` const deviceRows = await sql` SELECT device_type, os_name, COALESCE(SUM(page_views), 0)::int AS page_views, COALESCE(SUM(visitors), 0)::int AS visitors FROM analytics_traffic_daily WHERE day >= ${rangeStartDay}::date AND day >= ${collectedSince}::date GROUP BY device_type, os_name ORDER BY page_views DESC, visitors DESC, device_type ASC, os_name ASC ` const keywordRows = await sql` SELECT keyword, source_name, COALESCE(SUM(page_views), 0)::int AS page_views, COALESCE(SUM(visitors), 0)::int AS visitors FROM analytics_traffic_daily WHERE day >= ${rangeStartDay}::date AND day >= ${collectedSince}::date AND keyword <> '' GROUP BY keyword, source_name ORDER BY page_views DESC, visitors DESC, keyword ASC LIMIT 12 ` return { sources: sourceRows.map((row) => ({ sourceGroup: row.source_group, sourceName: row.source_name, pageViews: Number(row.page_views || 0), visitors: Number(row.visitors || 0) })), devices: deviceRows.map((row) => ({ deviceType: row.device_type, osName: row.os_name, pageViews: Number(row.page_views || 0), visitors: Number(row.visitors || 0) })), keywords: keywordRows.map((row) => ({ keyword: row.keyword, sourceName: row.source_name, pageViews: Number(row.page_views || 0), visitors: Number(row.visitors || 0) })), days } } /** * 관리자 통계 차트 날짜 상세를 조회한다. * @param {{ startDay?: string, endDay?: string, limit?: number }} [options] - 조회 옵션 * @returns {Promise} 날짜 상세 */ export const getAnalyticsRangeDetail = async (options = {}) => { const sql = getPostgresClient() const { startDay, endDay, rangeDays } = normalizeAnalyticsDetailRange(options) const limit = Math.min(Math.max(Number(options.limit) || 5, 1), 12) if (!sql) { return { startDay, endDay, rangeDays, summary: { pageViews: 0, visitors: 0, avgEngagedSeconds: 0, scroll50Reach: 0 }, sources: [], devices: [], keywords: [], topPosts: [], topPages: [] } } await purgeAnalyticsRetention(sql) const monthlyStartDay = getAnalyticsDayBefore(endDay, 29) const summaryRows = await sql` SELECT COALESCE(SUM(site_analytics_daily.page_views), 0)::int AS page_views, COALESCE(SUM(site_analytics_daily.visitors), 0)::int AS visitors, COALESCE(SUM(site_analytics_daily.engaged_views), 0)::int AS engaged_views, COALESCE(SUM(site_analytics_daily.total_engaged_seconds), 0)::int AS total_engaged_seconds FROM site_analytics_daily WHERE site_analytics_daily.day BETWEEN ${startDay}::date AND ${endDay}::date ` const scrollRows = await sql` SELECT COALESCE(SUM(post_analytics_daily.views), 0)::int AS views, COALESCE(SUM(post_analytics_daily.scroll_50), 0)::int AS scroll_50 FROM post_analytics_daily WHERE post_analytics_daily.day BETWEEN ${startDay}::date AND ${endDay}::date ` const sourceRows = await sql` SELECT source_group, source_name, COALESCE(SUM(page_views), 0)::int AS page_views, COALESCE(SUM(visitors), 0)::int AS visitors FROM analytics_traffic_daily WHERE day BETWEEN ${startDay}::date AND ${endDay}::date GROUP BY source_group, source_name ORDER BY page_views DESC, visitors DESC, source_name ASC LIMIT ${limit} ` const deviceRows = await sql` SELECT device_type, os_name, COALESCE(SUM(page_views), 0)::int AS page_views, COALESCE(SUM(visitors), 0)::int AS visitors FROM analytics_traffic_daily WHERE day BETWEEN ${startDay}::date AND ${endDay}::date GROUP BY device_type, os_name ORDER BY page_views DESC, visitors DESC, device_type ASC, os_name ASC LIMIT ${limit} ` const keywordRows = await sql` SELECT keyword, source_name, COALESCE(SUM(page_views), 0)::int AS page_views, COALESCE(SUM(visitors), 0)::int AS visitors FROM analytics_traffic_daily WHERE day BETWEEN ${startDay}::date AND ${endDay}::date AND keyword <> '' GROUP BY keyword, source_name ORDER BY page_views DESC, visitors DESC, keyword ASC LIMIT ${limit} ` const postRows = await sql` SELECT posts.id, posts.title, posts.slug, posts.created_at, COALESCE(SUM(post_analytics_daily.views), 0)::int AS views, COALESCE(SUM(post_analytics_daily.views) FILTER ( WHERE post_analytics_daily.day >= ${monthlyStartDay}::date ), 0)::int AS monthly_views, COALESCE(SUM(post_analytics_daily.reads), 0)::int AS reads, COALESCE(SUM(post_analytics_daily.visitors), 0)::int AS visitors, COALESCE(SUM(post_analytics_daily.scroll_50), 0)::int AS scroll_50, COALESCE(SUM(post_analytics_daily.scroll_75), 0)::int AS scroll_75, COALESCE(SUM(post_analytics_daily.scroll_100), 0)::int AS scroll_100 FROM post_analytics_daily INNER JOIN posts ON posts.id = post_analytics_daily.post_id WHERE post_analytics_daily.day BETWEEN ${startDay}::date AND ${endDay}::date GROUP BY posts.id, posts.title, posts.slug, posts.created_at ORDER BY views DESC, reads DESC, posts.published_at DESC NULLS LAST LIMIT ${limit} ` const pageRows = await sql` SELECT pages.id, pages.title, pages.slug, pages.created_at, COALESCE(SUM(page_analytics_daily.views), 0)::int AS views, COALESCE(SUM(page_analytics_daily.views) FILTER ( WHERE page_analytics_daily.day >= ${monthlyStartDay}::date ), 0)::int AS monthly_views, COALESCE(SUM(page_analytics_daily.visitors), 0)::int AS visitors, COALESCE(SUM(page_analytics_daily.scroll_50), 0)::int AS scroll_50, COALESCE(SUM(page_analytics_daily.scroll_75), 0)::int AS scroll_75, COALESCE(SUM(page_analytics_daily.scroll_100), 0)::int AS scroll_100 FROM page_analytics_daily INNER JOIN pages ON pages.id = page_analytics_daily.page_id WHERE page_analytics_daily.day BETWEEN ${startDay}::date AND ${endDay}::date GROUP BY pages.id, pages.title, pages.slug, pages.created_at ORDER BY views DESC, visitors DESC, pages.updated_at DESC NULLS LAST LIMIT ${limit} ` const engagedViews = Number(summaryRows[0]?.engaged_views || 0) const totalEngagedSeconds = Number(summaryRows[0]?.total_engaged_seconds || 0) return { startDay, endDay, rangeDays, summary: { pageViews: Number(summaryRows[0]?.page_views || 0), visitors: Number(summaryRows[0]?.visitors || 0), avgEngagedSeconds: engagedViews > 0 ? Math.round(totalEngagedSeconds / engagedViews) : 0, scroll50Reach: Math.min( Number(scrollRows[0]?.scroll_50 || 0), Number(scrollRows[0]?.views || 0) ) }, sources: sourceRows.map((row) => ({ sourceGroup: row.source_group, sourceName: row.source_name, pageViews: Number(row.page_views || 0), visitors: Number(row.visitors || 0) })), devices: deviceRows.map((row) => ({ deviceType: row.device_type, osName: row.os_name, pageViews: Number(row.page_views || 0), visitors: Number(row.visitors || 0) })), keywords: keywordRows.map((row) => ({ keyword: row.keyword, sourceName: row.source_name, pageViews: Number(row.page_views || 0), visitors: Number(row.visitors || 0) })), topPosts: postRows.map((row) => { const engagementStats = normalizeContentEngagementStats({ views: row.views, reads: row.reads, scroll50: row.scroll_50, scroll75: row.scroll_75, scroll100: row.scroll_100 }) return { id: row.id, title: row.title, slug: row.slug, createdAt: row.created_at ? new Date(row.created_at).toISOString() : null, views: engagementStats.views, monthlyViews: Number(row.monthly_views || 0), reads: engagementStats.reads, visitors: Number(row.visitors || 0), scroll50: engagementStats.scroll50, scroll75: engagementStats.scroll75, scroll100: engagementStats.scroll100 } }), topPages: pageRows.map((row) => { const engagementStats = normalizeContentEngagementStats({ views: row.views, scroll50: row.scroll_50, scroll75: row.scroll_75, scroll100: row.scroll_100 }) return { id: row.id, title: row.title, slug: row.slug, createdAt: row.created_at ? new Date(row.created_at).toISOString() : null, views: engagementStats.views, monthlyViews: Number(row.monthly_views || 0), visitors: Number(row.visitors || 0), scroll50: engagementStats.scroll50, scroll75: engagementStats.scroll75, scroll100: engagementStats.scroll100 } }) } } /** * 게시물별 통계 상세를 조회한다. * @param {{ postId: string, days?: number }} options - 조회 옵션 * @returns {Promise} 게시물 통계 */ export const getPostAnalyticsDetail = async (options = {}) => { const sql = getPostgresClient() const postId = String(options.postId || '').trim() const trendPeriod = ['daily', 'weekly', 'monthly'].includes(options.period) ? options.period : 'daily' const defaultDaysByPeriod = { daily: 14, weekly: 84, monthly: 365 } const days = Math.min( Math.max(Number(options.days) || defaultDaysByPeriod[trendPeriod], 1), ANALYTICS_CHART_MAX_DAYS ) if (!sql || !postId) { return { postId, days, updatedAt: new Date().toISOString(), total: { views: 0, visitors: 0, reads: 0, avgEngagedSeconds: 0, scroll50: 0, scroll75: 0, scroll100: 0 }, period: { views: 0, visitors: 0, reads: 0, avgEngagedSeconds: 0, scroll50: 0, scroll75: 0, scroll100: 0 }, trends: [], sources: [], devices: [], keywords: [] } } const today = getAnalyticsDayKey() const rangeStartDay = getAnalyticsDayBefore(today, days - 1) await purgeAnalyticsRetention(sql) const totalRows = await sql` SELECT COALESCE(SUM(views), 0)::int AS views, COALESCE(SUM(visitors), 0)::int AS visitors, COALESCE(SUM(reads), 0)::int AS reads, COALESCE(SUM(engaged_views), 0)::int AS engaged_views, COALESCE(SUM(total_engaged_seconds), 0)::int AS total_engaged_seconds, COALESCE(SUM(scroll_50), 0)::int AS scroll_50, COALESCE(SUM(scroll_75), 0)::int AS scroll_75, COALESCE(SUM(scroll_100), 0)::int AS scroll_100 FROM post_analytics_daily WHERE post_id = ${postId} ` const periodRows = await sql` SELECT COALESCE(SUM(views), 0)::int AS views, COALESCE(SUM(visitors), 0)::int AS visitors, COALESCE(SUM(reads), 0)::int AS reads, COALESCE(SUM(engaged_views), 0)::int AS engaged_views, COALESCE(SUM(total_engaged_seconds), 0)::int AS total_engaged_seconds, COALESCE(SUM(scroll_50), 0)::int AS scroll_50, COALESCE(SUM(scroll_75), 0)::int AS scroll_75, COALESCE(SUM(scroll_100), 0)::int AS scroll_100 FROM post_analytics_daily WHERE post_id = ${postId} AND day >= ${rangeStartDay}::date ` let trendRows = [] if (trendPeriod === 'weekly') { trendRows = await sql` WITH bounds AS ( SELECT (date_trunc('week', ${rangeStartDay}::date + interval '1 day') - interval '1 day')::date AS start_day, (date_trunc('week', ${today}::date + interval '1 day') - interval '1 day')::date AS end_day ), weeks AS ( SELECT generate_series(bounds.start_day, bounds.end_day, interval '7 day')::date AS start_day FROM bounds ) SELECT weeks.start_day, (weeks.start_day + interval '6 day')::date AS end_day, COALESCE(SUM(post_analytics_daily.views), 0)::int AS views, COALESCE(SUM(post_analytics_daily.visitors), 0)::int AS visitors, COALESCE(SUM(post_analytics_daily.reads), 0)::int AS reads FROM weeks LEFT JOIN post_analytics_daily ON post_analytics_daily.day BETWEEN weeks.start_day AND (weeks.start_day + interval '6 day')::date AND post_analytics_daily.post_id = ${postId} GROUP BY weeks.start_day ORDER BY weeks.start_day ASC ` } else if (trendPeriod === 'monthly') { trendRows = await sql` WITH bounds AS ( SELECT date_trunc('month', ${rangeStartDay}::date)::date AS start_day, date_trunc('month', ${today}::date)::date AS end_day ), months AS ( SELECT generate_series(bounds.start_day, bounds.end_day, interval '1 month')::date AS start_day FROM bounds ) SELECT months.start_day, (months.start_day + interval '1 month' - interval '1 day')::date AS end_day, COALESCE(SUM(post_analytics_daily.views), 0)::int AS views, COALESCE(SUM(post_analytics_daily.visitors), 0)::int AS visitors, COALESCE(SUM(post_analytics_daily.reads), 0)::int AS reads FROM months LEFT JOIN post_analytics_daily ON post_analytics_daily.day >= months.start_day AND post_analytics_daily.day < months.start_day + interval '1 month' AND post_analytics_daily.post_id = ${postId} GROUP BY months.start_day ORDER BY months.start_day ASC ` } else { trendRows = await sql` WITH days AS ( SELECT generate_series(${rangeStartDay}::date, ${today}::date, interval '1 day')::date AS start_day ) SELECT days.start_day, days.start_day AS end_day, COALESCE(post_analytics_daily.views, 0)::int AS views, COALESCE(post_analytics_daily.visitors, 0)::int AS visitors, COALESCE(post_analytics_daily.reads, 0)::int AS reads FROM days LEFT JOIN post_analytics_daily ON post_analytics_daily.day = days.start_day AND post_analytics_daily.post_id = ${postId} ORDER BY days.start_day ASC ` } const sourceRows = await sql` SELECT source_group, source_name, COALESCE(SUM(page_views), 0)::int AS page_views, COALESCE(SUM(visitors), 0)::int AS visitors FROM post_analytics_traffic_daily WHERE post_id = ${postId} AND day >= ${rangeStartDay}::date GROUP BY source_group, source_name ORDER BY page_views DESC, visitors DESC, source_name ASC ` const deviceRows = await sql` SELECT device_type, os_name, COALESCE(SUM(page_views), 0)::int AS page_views, COALESCE(SUM(visitors), 0)::int AS visitors FROM post_analytics_traffic_daily WHERE post_id = ${postId} AND day >= ${rangeStartDay}::date GROUP BY device_type, os_name ORDER BY page_views DESC, visitors DESC, device_type ASC, os_name ASC ` const keywordRows = await sql` SELECT keyword, source_name, COALESCE(SUM(page_views), 0)::int AS page_views, COALESCE(SUM(visitors), 0)::int AS visitors FROM post_analytics_traffic_daily WHERE post_id = ${postId} AND day >= ${rangeStartDay}::date AND keyword <> '' GROUP BY keyword, source_name ORDER BY page_views DESC, visitors DESC, keyword ASC LIMIT 12 ` /** * 통계 행을 응답용 값으로 변환한다. * @param {Object} row - 통계 행 * @returns {Object} 응답 값 */ const mapSummaryRow = (row) => { const engagedViews = Number(row?.engaged_views || 0) const totalEngagedSeconds = Number(row?.total_engaged_seconds || 0) const engagementStats = normalizeContentEngagementStats({ views: row?.views, reads: row?.reads, scroll50: row?.scroll_50, scroll75: row?.scroll_75, scroll100: row?.scroll_100 }) return { views: engagementStats.views, visitors: Number(row?.visitors || 0), reads: engagementStats.reads, avgEngagedSeconds: engagedViews > 0 ? Math.round(totalEngagedSeconds / engagedViews) : 0, scroll50: engagementStats.scroll50, scroll75: engagementStats.scroll75, scroll100: engagementStats.scroll100 } } return { postId, days, trendPeriod, updatedAt: new Date().toISOString(), total: mapSummaryRow(totalRows[0]), period: mapSummaryRow(periodRows[0]), trends: trendRows.map((row) => { const engagementStats = normalizeContentEngagementStats({ views: row.views, reads: row.reads }) return { day: row.start_day ? new Date(row.start_day).toISOString().slice(0, 10) : '', startDay: row.start_day ? new Date(row.start_day).toISOString().slice(0, 10) : '', endDay: row.end_day ? new Date(row.end_day).toISOString().slice(0, 10) : '', views: engagementStats.views, visitors: Number(row.visitors || 0), reads: engagementStats.reads } }), sources: sourceRows.map((row) => ({ sourceGroup: row.source_group, sourceName: row.source_name, pageViews: Number(row.page_views || 0), visitors: Number(row.visitors || 0) })), devices: deviceRows.map((row) => ({ deviceType: row.device_type, osName: row.os_name, pageViews: Number(row.page_views || 0), visitors: Number(row.visitors || 0) })), keywords: keywordRows.map((row) => ({ keyword: row.keyword, sourceName: row.source_name, pageViews: Number(row.page_views || 0), visitors: Number(row.visitors || 0) })) } } /** * 페이지별 통계 상세를 조회한다. * @param {{ pageId: string, days?: number, period?: 'daily' | 'weekly' | 'monthly' }} options - 조회 옵션 * @returns {Promise} 페이지 통계 */ export const getPageAnalyticsDetail = async (options = {}) => { const sql = getPostgresClient() const pageId = String(options.pageId || '').trim() const trendPeriod = ['daily', 'weekly', 'monthly'].includes(options.period) ? options.period : 'daily' const defaultDaysByPeriod = { daily: 14, weekly: 84, monthly: 365 } const days = Math.min( Math.max(Number(options.days) || defaultDaysByPeriod[trendPeriod], 1), ANALYTICS_CHART_MAX_DAYS ) const emptySummary = { views: 0, visitors: 0, avgEngagedSeconds: 0, scroll50: 0, scroll75: 0, scroll100: 0 } if (!sql || !pageId) { return { pageId, days, trendPeriod, updatedAt: new Date().toISOString(), total: { ...emptySummary }, period: { ...emptySummary }, trends: [] } } const today = getAnalyticsDayKey() const rangeStartDay = getAnalyticsDayBefore(today, days - 1) await purgeAnalyticsRetention(sql) const totalRows = await sql` SELECT COALESCE(SUM(views), 0)::int AS views, COALESCE(SUM(visitors), 0)::int AS visitors, COALESCE(SUM(engaged_views), 0)::int AS engaged_views, COALESCE(SUM(total_engaged_seconds), 0)::int AS total_engaged_seconds, COALESCE(SUM(scroll_50), 0)::int AS scroll_50, COALESCE(SUM(scroll_75), 0)::int AS scroll_75, COALESCE(SUM(scroll_100), 0)::int AS scroll_100 FROM page_analytics_daily WHERE page_id = ${pageId} ` const periodRows = await sql` SELECT COALESCE(SUM(views), 0)::int AS views, COALESCE(SUM(visitors), 0)::int AS visitors, COALESCE(SUM(engaged_views), 0)::int AS engaged_views, COALESCE(SUM(total_engaged_seconds), 0)::int AS total_engaged_seconds, COALESCE(SUM(scroll_50), 0)::int AS scroll_50, COALESCE(SUM(scroll_75), 0)::int AS scroll_75, COALESCE(SUM(scroll_100), 0)::int AS scroll_100 FROM page_analytics_daily WHERE page_id = ${pageId} AND day >= ${rangeStartDay}::date ` let trendRows = [] if (trendPeriod === 'weekly') { trendRows = await sql` WITH bounds AS ( SELECT (date_trunc('week', ${rangeStartDay}::date + interval '1 day') - interval '1 day')::date AS start_day, (date_trunc('week', ${today}::date + interval '1 day') - interval '1 day')::date AS end_day ), weeks AS ( SELECT generate_series(bounds.start_day, bounds.end_day, interval '7 day')::date AS start_day FROM bounds ) SELECT weeks.start_day, LEAST((weeks.start_day + interval '6 day')::date, ${today}::date) AS end_day, COALESCE(SUM(page_analytics_daily.views), 0)::int AS views, COALESCE(SUM(page_analytics_daily.visitors), 0)::int AS visitors FROM weeks LEFT JOIN page_analytics_daily ON page_analytics_daily.day BETWEEN weeks.start_day AND LEAST((weeks.start_day + interval '6 day')::date, ${today}::date) AND page_analytics_daily.page_id = ${pageId} GROUP BY weeks.start_day ORDER BY weeks.start_day ASC ` } else if (trendPeriod === 'monthly') { trendRows = await sql` WITH bounds AS ( SELECT date_trunc('month', ${rangeStartDay}::date)::date AS start_day, date_trunc('month', ${today}::date)::date AS end_day ), months AS ( SELECT generate_series(bounds.start_day, bounds.end_day, interval '1 month')::date AS start_day FROM bounds ) SELECT months.start_day, LEAST((months.start_day + interval '1 month' - interval '1 day')::date, ${today}::date) AS end_day, COALESCE(SUM(page_analytics_daily.views), 0)::int AS views, COALESCE(SUM(page_analytics_daily.visitors), 0)::int AS visitors FROM months LEFT JOIN page_analytics_daily ON page_analytics_daily.day >= months.start_day AND page_analytics_daily.day < months.start_day + interval '1 month' AND page_analytics_daily.page_id = ${pageId} GROUP BY months.start_day ORDER BY months.start_day ASC ` } else { trendRows = await sql` WITH days AS ( SELECT generate_series(${rangeStartDay}::date, ${today}::date, interval '1 day')::date AS start_day ) SELECT days.start_day, days.start_day AS end_day, COALESCE(page_analytics_daily.views, 0)::int AS views, COALESCE(page_analytics_daily.visitors, 0)::int AS visitors FROM days LEFT JOIN page_analytics_daily ON page_analytics_daily.day = days.start_day AND page_analytics_daily.page_id = ${pageId} ORDER BY days.start_day ASC ` } /** * 페이지 통계 행을 응답용 값으로 변환한다. * @param {Object} row - 통계 행 * @returns {Object} 응답 값 */ const mapPageSummaryRow = (row) => { const engagedViews = Number(row?.engaged_views || 0) const totalEngagedSeconds = Number(row?.total_engaged_seconds || 0) const engagementStats = normalizeContentEngagementStats({ views: row?.views, scroll50: row?.scroll_50, scroll75: row?.scroll_75, scroll100: row?.scroll_100 }) return { views: engagementStats.views, visitors: Number(row?.visitors || 0), avgEngagedSeconds: engagedViews > 0 ? Math.round(totalEngagedSeconds / engagedViews) : 0, scroll50: engagementStats.scroll50, scroll75: engagementStats.scroll75, scroll100: engagementStats.scroll100 } } return { pageId, days, trendPeriod, updatedAt: new Date().toISOString(), total: mapPageSummaryRow(totalRows[0]), period: mapPageSummaryRow(periodRows[0]), trends: trendRows.map((row) => ({ day: row.start_day ? new Date(row.start_day).toISOString().slice(0, 10) : '', startDay: row.start_day ? new Date(row.start_day).toISOString().slice(0, 10) : '', endDay: row.end_day ? new Date(row.end_day).toISOString().slice(0, 10) : '', views: Number(row.views || 0), visitors: Number(row.visitors || 0) })) } } /** * 인기 게시물 통계를 조회한다. * @param {{ days?: number, limit?: number }} [options] - 조회 옵션 * @returns {Promise>} 인기 게시물 목록 */ export const getAnalyticsTopPosts = async (options = {}) => { const sql = getPostgresClient() const days = Math.min(Math.max(Number(options.days) || 30, 1), ANALYTICS_CHART_MAX_DAYS) const limit = Math.min(Math.max(Number(options.limit) || 5, 1), 20) if (!sql) { return [] } const today = getAnalyticsDayKey() const rangeStartDay = getAnalyticsDayBefore(today, days - 1) const monthlyStartDay = getAnalyticsDayBefore(today, 29) await purgeAnalyticsRetention(sql) const rows = await sql` SELECT posts.id, posts.title, posts.slug, posts.created_at, COALESCE(SUM(post_analytics_daily.views), 0)::int AS views, COALESCE(SUM(post_analytics_daily.views) FILTER ( WHERE post_analytics_daily.day >= ${monthlyStartDay}::date ), 0)::int AS monthly_views, COALESCE(SUM(post_analytics_daily.reads), 0)::int AS reads, COALESCE(SUM(post_analytics_daily.visitors), 0)::int AS visitors, COALESCE(SUM(post_analytics_daily.engaged_views), 0)::int AS engaged_views, COALESCE(SUM(post_analytics_daily.total_engaged_seconds), 0)::int AS total_engaged_seconds, COALESCE(SUM(post_analytics_daily.scroll_50), 0)::int AS scroll_50, COALESCE(SUM(post_analytics_daily.scroll_75), 0)::int AS scroll_75, COALESCE(SUM(post_analytics_daily.scroll_100), 0)::int AS scroll_100 FROM post_analytics_daily INNER JOIN posts ON posts.id = post_analytics_daily.post_id WHERE post_analytics_daily.day >= ${rangeStartDay}::date GROUP BY posts.id, posts.title, posts.slug, posts.created_at ORDER BY views DESC, reads DESC, posts.published_at DESC NULLS LAST LIMIT ${limit} ` return rows.map((row) => { const engagedViews = Number(row.engaged_views || 0) const totalEngagedSeconds = Number(row.total_engaged_seconds || 0) const engagementStats = normalizeContentEngagementStats({ views: row.views, reads: row.reads, scroll50: row.scroll_50, scroll75: row.scroll_75, scroll100: row.scroll_100 }) return { id: row.id, title: row.title, slug: row.slug, createdAt: row.created_at ? new Date(row.created_at).toISOString() : null, views: engagementStats.views, monthlyViews: Number(row.monthly_views || 0), reads: engagementStats.reads, visitors: Number(row.visitors || 0), avgEngagedSeconds: engagedViews > 0 ? Math.round(totalEngagedSeconds / engagedViews) : 0, scroll50: engagementStats.scroll50, scroll75: engagementStats.scroll75, scroll100: engagementStats.scroll100 } }) } /** * 인기 페이지 통계를 조회한다. * @param {{ days?: number, limit?: number }} [options] - 조회 옵션 * @returns {Promise>} 인기 페이지 목록 */ export const getAnalyticsTopPages = async (options = {}) => { const sql = getPostgresClient() const days = Math.min(Math.max(Number(options.days) || 30, 1), ANALYTICS_CHART_MAX_DAYS) const limit = Math.min(Math.max(Number(options.limit) || 5, 1), 20) if (!sql) { return [] } const today = getAnalyticsDayKey() const rangeStartDay = getAnalyticsDayBefore(today, days - 1) await purgeAnalyticsRetention(sql) const rows = await sql` SELECT pages.id, pages.title, pages.slug, COALESCE(SUM(page_analytics_daily.views), 0)::int AS views, COALESCE(SUM(page_analytics_daily.visitors), 0)::int AS visitors, COALESCE(SUM(page_analytics_daily.engaged_views), 0)::int AS engaged_views, COALESCE(SUM(page_analytics_daily.total_engaged_seconds), 0)::int AS total_engaged_seconds, COALESCE(SUM(page_analytics_daily.scroll_50), 0)::int AS scroll_50, COALESCE(SUM(page_analytics_daily.scroll_75), 0)::int AS scroll_75, COALESCE(SUM(page_analytics_daily.scroll_100), 0)::int AS scroll_100 FROM page_analytics_daily INNER JOIN pages ON pages.id = page_analytics_daily.page_id WHERE page_analytics_daily.day >= ${rangeStartDay}::date GROUP BY pages.id, pages.title, pages.slug ORDER BY views DESC, pages.updated_at DESC NULLS LAST LIMIT ${limit} ` return rows.map((row) => { const engagedViews = Number(row.engaged_views || 0) const totalEngagedSeconds = Number(row.total_engaged_seconds || 0) const engagementStats = normalizeContentEngagementStats({ views: row.views, scroll50: row.scroll_50, scroll75: row.scroll_75, scroll100: row.scroll_100 }) return { id: row.id, title: row.title, slug: row.slug, views: engagementStats.views, visitors: Number(row.visitors || 0), avgEngagedSeconds: engagedViews > 0 ? Math.round(totalEngagedSeconds / engagedViews) : 0, scroll50: engagementStats.scroll50, scroll75: engagementStats.scroll75, scroll100: engagementStats.scroll100 } }) }