import { createDailyVisitorHash, getAnalyticsDayKey } from '../../lib/analytics.js' import { getPostgresClient } from './postgres-client.js' import { getRuntimeEnvValue } from '../utils/runtime-env.js' /** * 통계 해시용 시크릿을 반환한다. * @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} */ 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} */ 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} 신규 방문자 여부 */ 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} 신규 방문자 여부 */ 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) { 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} ` } 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() }) } /** * 통계 요약을 조회한다. * @param {{ days?: number }} [options] - 조회 옵션 * @returns {Promise} 요약 통계 */ export const getAnalyticsSummary = async (options = {}) => { const sql = getPostgresClient() const days = Math.min(Math.max(Number(options.days) || 30, 1), 365) if (!sql) { return { todayVisitors: 0, visitorsLast7Days: 0, pageViewsLast30Days: 0, days } } const today = getAnalyticsDayKey() const todayRows = await sql` SELECT visitors, page_views 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 >= (${today}::date - 6) ` const pageViewRows = await sql` SELECT COALESCE(SUM(page_views), 0)::int AS page_views FROM site_analytics_daily WHERE day >= (${today}::date - 29) ` return { todayVisitors: Number(todayRows[0]?.visitors || 0), visitorsLast7Days: Number(last7Rows[0]?.visitors || 0), pageViewsLast30Days: Number(pageViewRows[0]?.page_views || 0), days } } /** * 인기 게시물 통계를 조회한다. * @param {{ days?: number, limit?: number }} [options] - 조회 옵션 * @returns {Promise>} 인기 게시물 목록 */ export const getAnalyticsTopPosts = async (options = {}) => { const sql = getPostgresClient() const days = Math.min(Math.max(Number(options.days) || 30, 1), 365) const limit = Math.min(Math.max(Number(options.limit) || 5, 1), 20) if (!sql) { return [] } const today = getAnalyticsDayKey() 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 FROM post_analytics_daily INNER JOIN posts ON posts.id = post_analytics_daily.post_id WHERE post_analytics_daily.day >= (${today}::date - ${days - 1}) 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) => ({ id: row.id, title: row.title, slug: row.slug, views: Number(row.views || 0), reads: Number(row.reads || 0), visitors: Number(row.visitors || 0) })) }