2656 lines
85 KiB
JavaScript
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
|
|
}
|
|
})
|
|
}
|