v1.3.5: 관리자 로그인·대시보드 차트·통계 보관 정리

운영 HTTP에서 관리자 세션이 유지되지 않던 문제를 쿠키 공통화로 수정하고, 통계 클라이언트 분리·조회 오류·기간별 차트를 보강했다. 방문자 해시는 32일 초과분만 정리하고 일별 집계는 누적 보관한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-20 13:54:38 +09:00
parent abb77dbb4d
commit c43873ce5f
16 changed files with 571 additions and 230 deletions

View File

@@ -1,10 +1,14 @@
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'
@@ -12,6 +16,9 @@ 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} 시크릿
@@ -124,6 +131,7 @@ export const recordAnalyticsPageview = async (input) => {
}
if (!postId) {
await purgeAnalyticsRetention(sql)
return { ok: true }
}
@@ -151,6 +159,8 @@ export const recordAnalyticsPageview = async (input) => {
`
}
await purgeAnalyticsRetention(sql)
return { ok: true }
}
@@ -198,6 +208,29 @@ const purgeStaleActiveSessions = async (sql) => {
`
}
/**
* 오래된 방문자 해시를 정리한다.
* @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 클라이언트
@@ -359,6 +392,7 @@ export const recordAnalyticsHeartbeat = async (input) => {
}
await purgeStaleActiveSessions(sql)
await purgeAnalyticsRetention(sql)
return { ok: true }
}
@@ -370,7 +404,7 @@ export const recordAnalyticsHeartbeat = async (input) => {
*/
export const getAnalyticsSummary = async (options = {}) => {
const sql = getPostgresClient()
const days = Math.min(Math.max(Number(options.days) || 30, 1), 365)
const days = Math.min(Math.max(Number(options.days) || 30, 1), ANALYTICS_CHART_MAX_DAYS)
if (!sql) {
return {
@@ -381,12 +415,17 @@ export const getAnalyticsSummary = async (options = {}) => {
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
@@ -398,13 +437,13 @@ export const getAnalyticsSummary = async (options = {}) => {
const last7Rows = await sql`
SELECT COALESCE(SUM(visitors), 0)::int AS visitors
FROM site_analytics_daily
WHERE day >= (${today}::date - 6)
WHERE day >= ${last7StartDay}::date
`
const pageViewRows = await sql`
SELECT COALESCE(SUM(page_views), 0)::int AS page_views
FROM site_analytics_daily
WHERE day >= (${today}::date - 29)
WHERE day >= ${last30StartDay}::date
`
const engagementRows = await sql`
@@ -412,13 +451,13 @@ export const getAnalyticsSummary = async (options = {}) => {
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})
WHERE day >= ${rangeStartDay}::date
`
const scrollRows = await sql`
SELECT COALESCE(SUM(scroll_50), 0)::int AS scroll_50
FROM post_analytics_daily
WHERE day >= (${today}::date - ${days - 1})
WHERE day >= ${rangeStartDay}::date
`
const onlineRows = await sql`
@@ -429,6 +468,33 @@ export const getAnalyticsSummary = async (options = {}) => {
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)
@@ -442,6 +508,12 @@ export const getAnalyticsSummary = async (options = {}) => {
? 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
}
}
@@ -462,6 +534,7 @@ export const getAnalyticsRealtimeSummary = async () => {
}
await purgeStaleActiveSessions(sql)
await purgeAnalyticsRetention(sql)
const rows = await sql`
SELECT
@@ -495,6 +568,7 @@ export const getAnalyticsActiveSessions = async (options = {}) => {
}
await purgeStaleActiveSessions(sql)
await purgeAnalyticsRetention(sql)
const rows = await sql`
SELECT
@@ -504,10 +578,12 @@ export const getAnalyticsActiveSessions = async (options = {}) => {
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
@@ -518,6 +594,7 @@ export const getAnalyticsActiveSessions = async (options = {}) => {
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,
@@ -539,7 +616,7 @@ export const getAnalyticsActiveSessions = async (options = {}) => {
*/
export const getAnalyticsTopPosts = async (options = {}) => {
const sql = getPostgresClient()
const days = Math.min(Math.max(Number(options.days) || 30, 1), 365)
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) {
@@ -547,6 +624,8 @@ export const getAnalyticsTopPosts = async (options = {}) => {
}
const today = getAnalyticsDayKey()
const rangeStartDay = getAnalyticsDayBefore(today, days - 1)
await purgeAnalyticsRetention(sql)
const rows = await sql`
SELECT
@@ -563,7 +642,7 @@ export const getAnalyticsTopPosts = async (options = {}) => {
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})
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}

View File

@@ -1,6 +1,7 @@
import { createHmac, timingSafeEqual } from 'node:crypto'
import { createError, deleteCookie, getCookie, setCookie } from 'h3'
import { getRuntimeEnvValue } from './runtime-env'
import { shouldUseSecureSessionCookie } from './session-cookie'
const adminSessionCookieName = 'sori_admin_session'
const sessionMaxAge = 60 * 60 * 12
@@ -109,8 +110,8 @@ export const setAdminSession = (event, adminUser) => {
setCookie(event, adminSessionCookieName, createAdminSessionToken(adminUser), {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/admin',
secure: shouldUseSecureSessionCookie(event),
path: '/',
maxAge: sessionMaxAge
})
}
@@ -122,7 +123,7 @@ export const setAdminSession = (event, adminUser) => {
*/
export const clearAdminSession = (event) => {
deleteCookie(event, adminSessionCookieName, {
path: '/admin'
path: '/'
})
}

View File

@@ -1,6 +1,7 @@
import { createHmac, timingSafeEqual } from 'node:crypto'
import { createError, deleteCookie, getCookie, setCookie } from 'h3'
import { getRuntimeEnvValue } from './runtime-env'
import { shouldUseSecureSessionCookie } from './session-cookie'
const memberSessionCookieName = 'sori_member_session'
const sessionMaxAge = 60 * 60 * 24 * 14
@@ -107,7 +108,7 @@ export const setMemberSession = (event, user) => {
setCookie(event, memberSessionCookieName, createMemberSessionToken(user), {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
secure: shouldUseSecureSessionCookie(event),
path: '/',
maxAge: sessionMaxAge
})

View File

@@ -0,0 +1,24 @@
import { getRequestHeader, getRequestURL } from 'h3'
/**
* HTTPS 요청 여부를 판별해 Secure 쿠키 사용 여부를 반환한다.
* 운영 환경이라도 HTTP로 접속하면 쿠키가 저장되지 않는 문제를 막는다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {boolean} Secure 쿠키 사용 여부
*/
export const shouldUseSecureSessionCookie = (event) => {
const forwarded = String(getRequestHeader(event, 'x-forwarded-proto') || '')
.split(',')[0]
.trim()
.toLowerCase()
if (forwarded) {
return forwarded === 'https'
}
try {
return getRequestURL(event).protocol === 'https:'
} catch {
return false
}
}