import { ANALYTICS_ACTIVE_SESSION_TTL_SECONDS, ANALYTICS_CHART_MAX_DAYS, ANALYTICS_ENGAGED_MIN_SECONDS, ANALYTICS_RETENTION_PURGE_INTERVAL_MS, ANALYTICS_VISITOR_HASH_RETENTION_DAYS, clampAnalyticsDurationSeconds, clampAnalyticsScrollRatio, createDailyVisitorHash, createRealtimeSessionHash, getAnalyticsDayBefore, getAnalyticsDayKey, getNewScrollBucketColumns } from '../../lib/analytics.js' import { getPostgresClient } from './postgres-client.js' import { getRuntimeEnvValue } from '../utils/runtime-env.js' import { getMemberSession } from '../utils/member-auth.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() } /** * 일별 사이트 통계 행을 보장한다. * @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 ` } /** * 사이트 방문자를 등록하고 신규 방문자면 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]) } /** * 유입·디바이스 통계를 기록한다. * @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 {{ visitorHash: 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 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 if (recordSite) { 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) { 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) { 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 (recordRead) { 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('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 ` lastAnalyticsRetentionPurgeAt = now } /** * 스크롤 구간 카운터를 증가시킨다. * @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, 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 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 memberSession = getMemberSession(input.event) const userId = memberSession?.userId || null const previousRows = await sql` SELECT duration_seconds, max_scroll_ratio 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 durationDelta = Math.max(0, durationSeconds - previousDuration) const scrollBuckets = getNewScrollBucketColumns(previousScrollRatio, maxScrollRatio) await sql` INSERT INTO analytics_active_sessions ( session_hash, user_id, path, post_id, post_slug, page_id, page_slug, duration_seconds, max_scroll_ratio ) VALUES ( ${sessionHash}, ${userId}, ${path}, ${postId}, ${postSlug}, ${pageId}, ${pageSlug}, ${durationSeconds}, ${maxScrollRatio} ) ON CONFLICT (session_hash) DO UPDATE SET user_id = COALESCE(EXCLUDED.user_id, analytics_active_sessions.user_id), path = EXCLUDED.path, post_id = EXCLUDED.post_id, post_slug = EXCLUDED.post_slug, page_id = EXCLUDED.page_id, page_slug = EXCLUDED.page_slug, duration_seconds = GREATEST(analytics_active_sessions.duration_seconds, EXCLUDED.duration_seconds), max_scroll_ratio = GREATEST(analytics_active_sessions.max_scroll_ratio, EXCLUDED.max_scroll_ratio), 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 = 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 = 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 = 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 }} [options] - 조회 옵션 * @returns {Promise} 요약 통계 */ export const getAnalyticsSummary = async (options = {}) => { const sql = getPostgresClient() const days = Math.min(Math.max(Number(options.days) || 30, 1), ANALYTICS_CHART_MAX_DAYS) if (!sql) { return { todayVisitors: 0, todayPageViews: 0, todayAvgEngagedSeconds: 0, visitorsLast7Days: 0, pageViewsLast30Days: 0, onlineNow: 0, loggedInNow: 0, avgEngagedSeconds: 0, scroll50Reach: 0, trends: [], days } } const today = getAnalyticsDayKey() const last7StartDay = getAnalyticsDayBefore(today, 6) const last30StartDay = getAnalyticsDayBefore(today, 29) const rangeStartDay = getAnalyticsDayBefore(today, days - 1) await purgeStaleActiveSessions(sql) await purgeAnalyticsRetention(sql) const todayRows = await sql` SELECT visitors, page_views, engaged_views, total_engaged_seconds FROM site_analytics_daily WHERE day = ${today}::date LIMIT 1 ` const last7Rows = await sql` SELECT COALESCE(SUM(visitors), 0)::int AS visitors FROM site_analytics_daily WHERE day >= ${last7StartDay}::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(scroll_50), 0)::int AS scroll_50 FROM post_analytics_daily WHERE day >= ${rangeStartDay}::date ` const onlineRows = await sql` SELECT COUNT(*)::int AS online_now, COUNT(*) 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') ` const trendRows = await sql` WITH days AS ( SELECT generate_series(${rangeStartDay}::date, ${today}::date, interval '1 day')::date AS day ), post_daily AS ( SELECT day, COALESCE(SUM(scroll_50), 0)::int AS scroll_50 FROM post_analytics_daily WHERE day >= ${rangeStartDay}::date GROUP BY day ) SELECT days.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.scroll_50, 0)::int AS scroll_50 FROM days LEFT JOIN site_analytics_daily ON site_analytics_daily.day = days.day LEFT JOIN post_daily ON post_daily.day = days.day ORDER BY days.day ASC ` 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) return { todayVisitors: Number(todayRows[0]?.visitors || 0), todayPageViews: Number(todayRows[0]?.page_views || 0), todayAvgEngagedSeconds: todayEngagedViews > 0 ? Math.round(todayTotalEngagedSeconds / todayEngagedViews) : 0, visitorsLast7Days: Number(last7Rows[0]?.visitors || 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: Number(scrollRows[0]?.scroll_50 || 0), trends: trendRows.map((row) => ({ day: row.day ? new Date(row.day).toISOString().slice(0, 10) : '', visitors: Number(row.visitors || 0), avgEngagedSeconds: Number(row.avg_engaged_seconds || 0), scroll50Reach: Number(row.scroll_50 || 0) })), 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(*)::int AS online_now, COUNT(*) 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') ` const onlineNow = Number(rows[0]?.online_now || 0) const loggedInNow = Number(rows[0]?.logged_in_now || 0) return { onlineNow, loggedInNow, anonymousNow: Math.max(onlineNow - loggedInNow, 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) 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.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 ${limit} ` return rows.map((row) => ({ sessionHash: row.session_hash, path: row.path, postSlug: row.post_slug || '', pageSlug: row.page_slug || '', postTitle: row.post_title || '', pageTitle: row.page_title || '', durationSeconds: Number(row.duration_seconds || 0), 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 })) } /** * 관리자 유입·디바이스·키워드 통계를 조회한다. * @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 rangeStartDay = getAnalyticsDayBefore(today, days - 1) 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 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 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 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 {{ 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) return { id: row.id, title: row.title, slug: row.slug, createdAt: row.created_at ? new Date(row.created_at).toISOString() : null, views: Number(row.views || 0), monthlyViews: Number(row.monthly_views || 0), reads: Number(row.reads || 0), visitors: Number(row.visitors || 0), avgEngagedSeconds: engagedViews > 0 ? Math.round(totalEngagedSeconds / engagedViews) : 0, scroll50: Number(row.scroll_50 || 0), scroll75: Number(row.scroll_75 || 0), scroll100: Number(row.scroll_100 || 0) } }) } /** * 인기 페이지 통계를 조회한다. * @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) return { id: row.id, title: row.title, slug: row.slug, views: Number(row.views || 0), visitors: Number(row.visitors || 0), avgEngagedSeconds: engagedViews > 0 ? Math.round(totalEngagedSeconds / engagedViews) : 0, scroll50: Number(row.scroll_50 || 0), scroll75: Number(row.scroll_75 || 0), scroll100: Number(row.scroll_100 || 0) } }) }