Files
sori.studio/server/repositories/analytics-repository.js
zenn c43873ce5f v1.3.5: 관리자 로그인·대시보드 차트·통계 보관 정리
운영 HTTP에서 관리자 세션이 유지되지 않던 문제를 쿠키 공통화로 수정하고, 통계 클라이언트 분리·조회 오류·기간별 차트를 보강했다. 방문자 해시는 32일 초과분만 정리하고 일별 집계는 누적 보관한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 13:54:38 +09:00

671 lines
20 KiB
JavaScript

import {
ANALYTICS_ACTIVE_SESSION_TTL_SECONDS,
ANALYTICS_CHART_MAX_DAYS,
ANALYTICS_ENGAGED_MIN_SECONDS,
ANALYTICS_RETENTION_PURGE_INTERVAL_MS,
ANALYTICS_VISITOR_HASH_RETENTION_DAYS,
clampAnalyticsDurationSeconds,
clampAnalyticsScrollRatio,
createDailyVisitorHash,
createRealtimeSessionHash,
getAnalyticsDayBefore,
getAnalyticsDayKey,
getNewScrollBucketColumns
} from '../../lib/analytics.js'
import { getPostgresClient } from './postgres-client.js'
import { getRuntimeEnvValue } from '../utils/runtime-env.js'
import { getMemberSession } from '../utils/member-auth.js'
/** @type {number} 마지막 통계 보관 정리 실행 시각 */
let lastAnalyticsRetentionPurgeAt = 0
/**
* 통계 해시용 시크릿을 반환한다.
* @returns {string} 시크릿
*/
const getAnalyticsHashSecret = () => {
return getRuntimeEnvValue(
'ANALYTICS_HASH_SECRET',
'analyticsHashSecret',
getRuntimeEnvValue('MEMBER_SESSION_SECRET', 'memberSessionSecret', 'analytics-fallback-secret')
).trim()
}
/**
* 일별 사이트 통계 행을 보장한다.
* @param {import('postgres').Sql} sql - DB 클라이언트
* @param {string} day - YYYY-MM-DD
* @returns {Promise<void>}
*/
const ensureSiteDailyRow = async (sql, day) => {
await sql`
INSERT INTO site_analytics_daily (day)
VALUES (${day})
ON CONFLICT (day) DO NOTHING
`
}
/**
* 일별 게시물 통계 행을 보장한다.
* @param {import('postgres').Sql} sql - DB 클라이언트
* @param {string} day - YYYY-MM-DD
* @param {string} postId - 게시물 ID
* @returns {Promise<void>}
*/
const ensurePostDailyRow = async (sql, day, postId) => {
await sql`
INSERT INTO post_analytics_daily (day, post_id)
VALUES (${day}, ${postId})
ON CONFLICT (day, post_id) DO NOTHING
`
}
/**
* 사이트 방문자를 등록하고 신규 방문자면 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])
}
/**
* 페이지뷰·읽음 이벤트를 기록한다.
* @param {{ visitorHash: string, postId?: string | null, recordSite?: boolean, recordView?: boolean, recordRead?: boolean }} input - 기록 입력
* @returns {Promise<{ ok: true }>}
*/
export const recordAnalyticsPageview = async (input) => {
const sql = getPostgresClient()
if (!sql) {
return { ok: true }
}
const day = getAnalyticsDayKey()
const visitorHash = input.visitorHash
const postId = input.postId || null
const recordSite = input.recordSite !== false
const recordView = Boolean(input.recordView)
const recordRead = Boolean(input.recordRead)
if (recordSite) {
await ensureSiteDailyRow(sql, day)
const isNewSiteVisitor = await registerSiteVisitor(sql, day, visitorHash)
await sql`
UPDATE site_analytics_daily
SET
page_views = page_views + 1,
visitors = visitors + ${isNewSiteVisitor ? 1 : 0}
WHERE day = ${day}
`
}
if (!postId) {
await purgeAnalyticsRetention(sql)
return { ok: true }
}
await ensurePostDailyRow(sql, day, postId)
if (recordView) {
const isNewPostVisitor = await registerPostVisitor(sql, day, postId, visitorHash)
await sql`
UPDATE post_analytics_daily
SET
views = views + 1,
visitors = visitors + ${isNewPostVisitor ? 1 : 0}
WHERE day = ${day}
AND post_id = ${postId}
`
}
if (recordRead) {
await sql`
UPDATE post_analytics_daily
SET reads = reads + 1
WHERE day = ${day}
AND post_id = ${postId}
`
}
await purgeAnalyticsRetention(sql)
return { ok: true }
}
/**
* 요청에서 일 단위 방문자 해시를 만든다.
* @param {import('h3').H3Event} event - H3 이벤트
* @returns {string} visitor hash
*/
export const createVisitorHashFromEvent = (event) => {
const day = getAnalyticsDayKey()
const ip = String(getRequestIP(event, { xForwardedFor: true }) || '')
const userAgent = String(getRequestHeader(event, 'user-agent') || '')
return createDailyVisitorHash({
day,
ip,
userAgent,
secret: getAnalyticsHashSecret()
})
}
/**
* heartbeat용 실시간 세션 해시를 만든다.
* @param {import('h3').H3Event} event - H3 이벤트
* @param {string} clientSessionId - 탭 단위 클라이언트 세션 ID
* @returns {string} session hash
*/
export const createSessionHashFromEvent = (event, clientSessionId) => {
return createRealtimeSessionHash({
clientSessionId: String(clientSessionId || '').trim(),
visitorHash: createVisitorHashFromEvent(event),
secret: getAnalyticsHashSecret()
})
}
/**
* 만료된 실시간 세션 행을 정리한다.
* @param {import('postgres').Sql} sql - DB 클라이언트
* @returns {Promise<void>}
*/
const purgeStaleActiveSessions = async (sql) => {
await sql`
DELETE FROM analytics_active_sessions
WHERE last_seen_at < now() - (${ANALYTICS_ACTIVE_SESSION_TTL_SECONDS} * interval '1 second')
`
}
/**
* 오래된 방문자 해시를 정리한다.
* @param {import('postgres').Sql} sql - DB 클라이언트
* @returns {Promise<void>}
*/
const purgeAnalyticsRetention = async (sql) => {
const now = Date.now()
if (now - lastAnalyticsRetentionPurgeAt < ANALYTICS_RETENTION_PURGE_INTERVAL_MS) {
return
}
const today = getAnalyticsDayKey()
const visitorHashCutoffDay = getAnalyticsDayBefore(today, ANALYTICS_VISITOR_HASH_RETENTION_DAYS)
await sql`
DELETE FROM analytics_daily_visitors
WHERE day < ${visitorHashCutoffDay}::date
`
lastAnalyticsRetentionPurgeAt = now
}
/**
* 스크롤 구간 카운터를 증가시킨다.
* @param {import('postgres').Sql} sql - DB 클라이언트
* @param {string} day - YYYY-MM-DD
* @param {string} postId - 게시물 ID
* @param {Array<'scroll_25' | 'scroll_50' | 'scroll_75' | 'scroll_100'>} columns - 구간 컬럼
* @returns {Promise<void>}
*/
const incrementPostScrollBuckets = async (sql, day, postId, columns) => {
if (!columns.length) {
return
}
await ensurePostDailyRow(sql, day, postId)
for (const column of columns) {
if (column === 'scroll_25') {
await sql`
UPDATE post_analytics_daily
SET scroll_25 = scroll_25 + 1
WHERE day = ${day}
AND post_id = ${postId}
`
} else if (column === 'scroll_50') {
await sql`
UPDATE post_analytics_daily
SET scroll_50 = scroll_50 + 1
WHERE day = ${day}
AND post_id = ${postId}
`
} else if (column === 'scroll_75') {
await sql`
UPDATE post_analytics_daily
SET scroll_75 = scroll_75 + 1
WHERE day = ${day}
AND post_id = ${postId}
`
} else if (column === 'scroll_100') {
await sql`
UPDATE post_analytics_daily
SET scroll_100 = scroll_100 + 1
WHERE day = ${day}
AND post_id = ${postId}
`
}
}
}
/**
* 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)
await purgeAnalyticsRetention(sql)
return { ok: true }
}
/**
* 통계 요약을 조회한다.
* @param {{ days?: number }} [options] - 조회 옵션
* @returns {Promise<Object>} 요약 통계
*/
export const getAnalyticsSummary = async (options = {}) => {
const sql = getPostgresClient()
const days = Math.min(Math.max(Number(options.days) || 30, 1), ANALYTICS_CHART_MAX_DAYS)
if (!sql) {
return {
todayVisitors: 0,
visitorsLast7Days: 0,
pageViewsLast30Days: 0,
onlineNow: 0,
loggedInNow: 0,
avgEngagedSeconds: 0,
scroll50Reach: 0,
trends: [],
days
}
}
const today = getAnalyticsDayKey()
const last7StartDay = getAnalyticsDayBefore(today, 6)
const last30StartDay = getAnalyticsDayBefore(today, 29)
const rangeStartDay = getAnalyticsDayBefore(today, days - 1)
await purgeStaleActiveSessions(sql)
await purgeAnalyticsRetention(sql)
const todayRows = await sql`
SELECT visitors, page_views, engaged_views, total_engaged_seconds
FROM site_analytics_daily
WHERE day = ${today}::date
LIMIT 1
`
const last7Rows = await sql`
SELECT COALESCE(SUM(visitors), 0)::int AS visitors
FROM site_analytics_daily
WHERE day >= ${last7StartDay}::date
`
const pageViewRows = await sql`
SELECT COALESCE(SUM(page_views), 0)::int AS page_views
FROM site_analytics_daily
WHERE day >= ${last30StartDay}::date
`
const engagementRows = await sql`
SELECT
COALESCE(SUM(total_engaged_seconds), 0)::int AS total_engaged_seconds,
COALESCE(SUM(engaged_views), 0)::int AS engaged_views
FROM site_analytics_daily
WHERE day >= ${rangeStartDay}::date
`
const scrollRows = await sql`
SELECT COALESCE(SUM(scroll_50), 0)::int AS scroll_50
FROM post_analytics_daily
WHERE day >= ${rangeStartDay}::date
`
const onlineRows = await sql`
SELECT
COUNT(*)::int AS online_now,
COUNT(*) FILTER (WHERE user_id IS NOT NULL)::int AS logged_in_now
FROM analytics_active_sessions
WHERE last_seen_at >= now() - (${ANALYTICS_ACTIVE_SESSION_TTL_SECONDS} * interval '1 second')
`
const trendRows = await sql`
WITH days AS (
SELECT generate_series(${rangeStartDay}::date, ${today}::date, interval '1 day')::date AS day
),
post_daily AS (
SELECT
day,
COALESCE(SUM(scroll_50), 0)::int AS scroll_50
FROM post_analytics_daily
WHERE day >= ${rangeStartDay}::date
GROUP BY day
)
SELECT
days.day,
COALESCE(site_analytics_daily.visitors, 0)::int AS visitors,
CASE
WHEN COALESCE(site_analytics_daily.engaged_views, 0) > 0
THEN ROUND(site_analytics_daily.total_engaged_seconds::numeric / site_analytics_daily.engaged_views)::int
ELSE 0
END AS avg_engaged_seconds,
COALESCE(post_daily.scroll_50, 0)::int AS scroll_50
FROM days
LEFT JOIN site_analytics_daily ON site_analytics_daily.day = days.day
LEFT JOIN post_daily ON post_daily.day = days.day
ORDER BY days.day ASC
`
const engagedViews = Number(engagementRows[0]?.engaged_views || 0)
const totalEngagedSeconds = Number(engagementRows[0]?.total_engaged_seconds || 0)
return {
todayVisitors: Number(todayRows[0]?.visitors || 0),
visitorsLast7Days: Number(last7Rows[0]?.visitors || 0),
pageViewsLast30Days: Number(pageViewRows[0]?.page_views || 0),
onlineNow: Number(onlineRows[0]?.online_now || 0),
loggedInNow: Number(onlineRows[0]?.logged_in_now || 0),
avgEngagedSeconds: engagedViews > 0
? Math.round(totalEngagedSeconds / engagedViews)
: 0,
scroll50Reach: Number(scrollRows[0]?.scroll_50 || 0),
trends: trendRows.map((row) => ({
day: row.day ? new Date(row.day).toISOString().slice(0, 10) : '',
visitors: Number(row.visitors || 0),
avgEngagedSeconds: Number(row.avg_engaged_seconds || 0),
scroll50Reach: Number(row.scroll_50 || 0)
})),
days
}
}
/**
* 실시간 접속 요약을 조회한다.
* @returns {Promise<Object>} 실시간 요약
*/
export const getAnalyticsRealtimeSummary = async () => {
const sql = getPostgresClient()
if (!sql) {
return {
onlineNow: 0,
loggedInNow: 0,
anonymousNow: 0
}
}
await purgeStaleActiveSessions(sql)
await purgeAnalyticsRetention(sql)
const rows = await sql`
SELECT
COUNT(*)::int AS online_now,
COUNT(*) FILTER (WHERE user_id IS NOT NULL)::int AS logged_in_now
FROM analytics_active_sessions
WHERE last_seen_at >= now() - (${ANALYTICS_ACTIVE_SESSION_TTL_SECONDS} * interval '1 second')
`
const onlineNow = Number(rows[0]?.online_now || 0)
const loggedInNow = Number(rows[0]?.logged_in_now || 0)
return {
onlineNow,
loggedInNow,
anonymousNow: Math.max(onlineNow - loggedInNow, 0)
}
}
/**
* 현재 접속 중인 세션 목록을 조회한다.
* @param {{ limit?: number }} [options] - 조회 옵션
* @returns {Promise<Array<Object>>} 접속자 목록
*/
export const getAnalyticsActiveSessions = async (options = {}) => {
const sql = getPostgresClient()
const limit = Math.min(Math.max(Number(options.limit) || 20, 1), 50)
if (!sql) {
return []
}
await purgeStaleActiveSessions(sql)
await purgeAnalyticsRetention(sql)
const rows = await sql`
SELECT
analytics_active_sessions.session_hash,
analytics_active_sessions.path,
analytics_active_sessions.post_slug,
analytics_active_sessions.duration_seconds,
analytics_active_sessions.max_scroll_ratio,
analytics_active_sessions.last_seen_at,
posts.title AS post_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 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 || '',
postTitle: row.post_title || '',
durationSeconds: Number(row.duration_seconds || 0),
maxScrollRatio: Number(row.max_scroll_ratio || 0),
lastSeenAt: row.last_seen_at ? new Date(row.last_seen_at).toISOString() : null,
isLoggedIn: Boolean(row.user_id),
user: row.user_id
? {
id: row.user_id,
username: row.username,
avatarUrl: row.avatar_url || ''
}
: null
}))
}
/**
* 인기 게시물 통계를 조회한다.
* @param {{ days?: number, limit?: number }} [options] - 조회 옵션
* @returns {Promise<Array<Object>>} 인기 게시물 목록
*/
export const getAnalyticsTopPosts = async (options = {}) => {
const sql = getPostgresClient()
const days = Math.min(Math.max(Number(options.days) || 30, 1), ANALYTICS_CHART_MAX_DAYS)
const limit = Math.min(Math.max(Number(options.limit) || 5, 1), 20)
if (!sql) {
return []
}
const today = getAnalyticsDayKey()
const rangeStartDay = getAnalyticsDayBefore(today, days - 1)
await purgeAnalyticsRetention(sql)
const rows = await sql`
SELECT
posts.id,
posts.title,
posts.slug,
COALESCE(SUM(post_analytics_daily.views), 0)::int AS views,
COALESCE(SUM(post_analytics_daily.reads), 0)::int AS reads,
COALESCE(SUM(post_analytics_daily.visitors), 0)::int AS visitors,
COALESCE(SUM(post_analytics_daily.engaged_views), 0)::int AS engaged_views,
COALESCE(SUM(post_analytics_daily.total_engaged_seconds), 0)::int AS total_engaged_seconds,
COALESCE(SUM(post_analytics_daily.scroll_50), 0)::int AS scroll_50,
COALESCE(SUM(post_analytics_daily.scroll_75), 0)::int AS scroll_75,
COALESCE(SUM(post_analytics_daily.scroll_100), 0)::int AS scroll_100
FROM post_analytics_daily
INNER JOIN posts ON posts.id = post_analytics_daily.post_id
WHERE post_analytics_daily.day >= ${rangeStartDay}::date
GROUP BY posts.id, posts.title, posts.slug
ORDER BY views DESC, reads DESC, posts.published_at DESC NULLS LAST
LIMIT ${limit}
`
return rows.map((row) => {
const engagedViews = Number(row.engaged_views || 0)
const totalEngagedSeconds = Number(row.total_engaged_seconds || 0)
return {
id: row.id,
title: row.title,
slug: row.slug,
views: Number(row.views || 0),
reads: Number(row.reads || 0),
visitors: Number(row.visitors || 0),
avgEngagedSeconds: engagedViews > 0
? Math.round(totalEngagedSeconds / engagedViews)
: 0,
scroll50: Number(row.scroll_50 || 0),
scroll75: Number(row.scroll_75 || 0),
scroll100: Number(row.scroll_100 || 0)
}
})
}