867 lines
26 KiB
JavaScript
867 lines
26 KiB
JavaScript
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<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
|
|
`
|
|
}
|
|
|
|
/**
|
|
* 사이트 방문자를 등록하고 신규 방문자면 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])
|
|
}
|
|
|
|
/**
|
|
* 페이지뷰·읽음 이벤트를 기록한다.
|
|
* @param {{ visitorHash: string, postId?: string | null, pageId?: string | null, 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)
|
|
|
|
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 (!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<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
|
|
`
|
|
|
|
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<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, 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<Object>} 요약 통계
|
|
*/
|
|
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,
|
|
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)
|
|
|
|
return {
|
|
todayVisitors: Number(todayRows[0]?.visitors || 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<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(*)::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<Array<Object>>} 접속자 목록
|
|
*/
|
|
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, 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)
|
|
await purgeAnalyticsRetention(sql)
|
|
|
|
const rows = await sql`
|
|
SELECT
|
|
posts.id,
|
|
posts.title,
|
|
posts.slug,
|
|
COALESCE(SUM(post_analytics_daily.views), 0)::int AS 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
|
|
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,
|
|
views: Number(row.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<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)
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|