Files
sori.studio/server/repositories/analytics-repository.js

2656 lines
85 KiB
JavaScript

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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<boolean>} 신규 방문자 여부
*/
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<boolean>} 신규 방문자 여부
*/
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<boolean>} 신규 방문자 여부
*/
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<boolean>} 신규 방문자 여부
*/
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<boolean>} 신규 방문자 여부
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<boolean>} 새 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<boolean>} 새 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<void>}
*/
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<void>}
*/
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<Object>} 요약 통계
*/
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<Object>} 실시간 요약
*/
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<Array<Object>>} 접속자 목록
*/
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<string, Object>} */
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<Object>, devices: Array<Object>, keywords: Array<Object>, 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<Object>} 날짜 상세
*/
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<Object>} 게시물 통계
*/
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<Object>} 페이지 통계
*/
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<Array<Object>>} 인기 게시물 목록
*/
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<Array<Object>>} 인기 페이지 목록
*/
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
}
})
}