Files
sori.studio/server/repositories/analytics-repository.js
zenn 3623305119 v1.3.3: 자체 최소 통계 및 스플래시 localStorage 정리
- 일별 익명 방문자 해시·사이트/게시물 통계(030 마이그레이션)
- POST /api/analytics/pageview, 관리자 analytics API, 클라이언트 트래커
- 관리자 대시보드 통계 카드·인기 게시물 Top 5
- 스플래시: SITE_BRAND_LOGO_TEXT localStorage 제거
2026-05-20 12:15:13 +09:00

256 lines
6.9 KiB
JavaScript

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<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) {
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<Object>} 요약 통계
*/
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<Array<Object>>} 인기 게시물 목록
*/
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)
}))
}