v1.3.4: 통계 확장(체류·스크롤·실시간 접속자)
- 031 마이그레이션: 체류·스크롤 집계, analytics_active_sessions - heartbeat API, 관리자 realtime API, 클라이언트 heartbeat - 대시보드: 현재 접속자 목록(로그인 닉네임·아바타), 참여 지표
This commit is contained in:
8
server/api/analytics/heartbeat.post.js
Normal file
8
server/api/analytics/heartbeat.post.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { handleAnalyticsHeartbeat } from '../../utils/analytics-heartbeat-input.js'
|
||||
|
||||
/**
|
||||
* 공개 heartbeat·체류·스크롤 통계 수집 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ ok: true }>}
|
||||
*/
|
||||
export default defineEventHandler((event) => handleAnalyticsHeartbeat(event))
|
||||
@@ -1,9 +1,16 @@
|
||||
import {
|
||||
ANALYTICS_ACTIVE_SESSION_TTL_SECONDS,
|
||||
ANALYTICS_ENGAGED_MIN_SECONDS,
|
||||
clampAnalyticsDurationSeconds,
|
||||
clampAnalyticsScrollRatio,
|
||||
createDailyVisitorHash,
|
||||
getAnalyticsDayKey
|
||||
createRealtimeSessionHash,
|
||||
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'
|
||||
|
||||
/**
|
||||
* 통계 해시용 시크릿을 반환한다.
|
||||
@@ -165,6 +172,197 @@ export const createVisitorHashFromEvent = (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 클라이언트
|
||||
* @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}
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* heartbeat·체류·스크롤·실시간 세션을 기록한다.
|
||||
* @param {{ event: import('h3').H3Event, sessionHash: string, path: string, postId?: string | null, postSlug?: 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 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,
|
||||
duration_seconds,
|
||||
max_scroll_ratio
|
||||
)
|
||||
VALUES (
|
||||
${sessionHash},
|
||||
${userId},
|
||||
${path},
|
||||
${postId},
|
||||
${postSlug},
|
||||
${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,
|
||||
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)
|
||||
}
|
||||
|
||||
await purgeStaleActiveSessions(sql)
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 요약을 조회한다.
|
||||
* @param {{ days?: number }} [options] - 조회 옵션
|
||||
@@ -179,14 +377,19 @@ export const getAnalyticsSummary = async (options = {}) => {
|
||||
todayVisitors: 0,
|
||||
visitorsLast7Days: 0,
|
||||
pageViewsLast30Days: 0,
|
||||
onlineNow: 0,
|
||||
loggedInNow: 0,
|
||||
avgEngagedSeconds: 0,
|
||||
scroll50Reach: 0,
|
||||
days
|
||||
}
|
||||
}
|
||||
|
||||
const today = getAnalyticsDayKey()
|
||||
await purgeStaleActiveSessions(sql)
|
||||
|
||||
const todayRows = await sql`
|
||||
SELECT visitors, page_views
|
||||
SELECT visitors, page_views, engaged_views, total_engaged_seconds
|
||||
FROM site_analytics_daily
|
||||
WHERE day = ${today}::date
|
||||
LIMIT 1
|
||||
@@ -204,14 +407,131 @@ export const getAnalyticsSummary = async (options = {}) => {
|
||||
WHERE day >= (${today}::date - 29)
|
||||
`
|
||||
|
||||
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 >= (${today}::date - ${days - 1})
|
||||
`
|
||||
|
||||
const scrollRows = await sql`
|
||||
SELECT COALESCE(SUM(scroll_50), 0)::int AS scroll_50
|
||||
FROM post_analytics_daily
|
||||
WHERE day >= (${today}::date - ${days - 1})
|
||||
`
|
||||
|
||||
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 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),
|
||||
days
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 접속 요약을 조회한다.
|
||||
* @returns {Promise<Object>} 실시간 요약
|
||||
*/
|
||||
export const getAnalyticsRealtimeSummary = async () => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
return {
|
||||
onlineNow: 0,
|
||||
loggedInNow: 0,
|
||||
anonymousNow: 0
|
||||
}
|
||||
}
|
||||
|
||||
await purgeStaleActiveSessions(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)
|
||||
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
analytics_active_sessions.session_hash,
|
||||
analytics_active_sessions.path,
|
||||
analytics_active_sessions.post_slug,
|
||||
analytics_active_sessions.duration_seconds,
|
||||
analytics_active_sessions.max_scroll_ratio,
|
||||
analytics_active_sessions.last_seen_at,
|
||||
users.id AS user_id,
|
||||
users.username,
|
||||
users.avatar_url
|
||||
FROM analytics_active_sessions
|
||||
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 || '',
|
||||
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] - 조회 옵션
|
||||
@@ -235,7 +555,12 @@ export const getAnalyticsTopPosts = async (options = {}) => {
|
||||
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.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 >= (${today}::date - ${days - 1})
|
||||
@@ -244,12 +569,23 @@ export const getAnalyticsTopPosts = async (options = {}) => {
|
||||
LIMIT ${limit}
|
||||
`
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
slug: row.slug,
|
||||
views: Number(row.views || 0),
|
||||
reads: Number(row.reads || 0),
|
||||
visitors: Number(row.visitors || 0)
|
||||
}))
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
27
server/routes/admin/api/analytics/realtime.get.js
Normal file
27
server/routes/admin/api/analytics/realtime.get.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth.js'
|
||||
import {
|
||||
getAnalyticsActiveSessions,
|
||||
getAnalyticsRealtimeSummary
|
||||
} from '../../../../repositories/analytics-repository.js'
|
||||
|
||||
/**
|
||||
* 관리자 실시간 접속 통계 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Object>} 실시간 요약·접속자 목록
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
const query = getQuery(event)
|
||||
const limit = Number(query.limit) || 20
|
||||
|
||||
const [summary, sessions] = await Promise.all([
|
||||
getAnalyticsRealtimeSummary(),
|
||||
getAnalyticsActiveSessions({ limit })
|
||||
])
|
||||
|
||||
return {
|
||||
summary,
|
||||
sessions
|
||||
}
|
||||
})
|
||||
71
server/utils/analytics-heartbeat-input.js
Normal file
71
server/utils/analytics-heartbeat-input.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
isBotUserAgent,
|
||||
isTrackableAnalyticsPath,
|
||||
normalizePostSlugForAnalytics
|
||||
} from '../../lib/analytics.js'
|
||||
import { getPostBySlug } from '../repositories/content-repository.js'
|
||||
import {
|
||||
createSessionHashFromEvent,
|
||||
recordAnalyticsHeartbeat
|
||||
} from '../repositories/analytics-repository.js'
|
||||
|
||||
const heartbeatInputSchema = z.object({
|
||||
path: z.string().trim().min(1).max(500),
|
||||
postSlug: z.string().trim().max(200).optional().default(''),
|
||||
clientSessionId: z.string().trim().min(8).max(120),
|
||||
durationSeconds: z.number().int().min(0).max(1800),
|
||||
maxScrollRatio: z.number().min(0).max(1)
|
||||
})
|
||||
|
||||
/**
|
||||
* heartbeat 추적 요청을 처리한다.
|
||||
* @param {import('h3').H3Event} event - H3 이벤트
|
||||
* @returns {Promise<{ ok: true }>}
|
||||
*/
|
||||
export const handleAnalyticsHeartbeat = async (event) => {
|
||||
const parsedBody = heartbeatInputSchema.safeParse(await readBody(event))
|
||||
|
||||
if (!parsedBody.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '통계 heartbeat 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const body = parsedBody.data
|
||||
const userAgent = String(getRequestHeader(event, 'user-agent') || '')
|
||||
|
||||
if (isBotUserAgent(userAgent)) {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
if (!isTrackableAnalyticsPath(body.path)) {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const postSlug = normalizePostSlugForAnalytics(body.postSlug)
|
||||
let postId = null
|
||||
|
||||
if (postSlug) {
|
||||
const post = await getPostBySlug(postSlug)
|
||||
if (!post) {
|
||||
return { ok: true }
|
||||
}
|
||||
postId = post.id
|
||||
}
|
||||
|
||||
const sessionHash = createSessionHashFromEvent(event, body.clientSessionId)
|
||||
|
||||
await recordAnalyticsHeartbeat({
|
||||
event,
|
||||
sessionHash,
|
||||
path: body.path,
|
||||
postId,
|
||||
postSlug,
|
||||
durationSeconds: body.durationSeconds,
|
||||
maxScrollRatio: body.maxScrollRatio
|
||||
})
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
Reference in New Issue
Block a user