v1.3.3: 자체 최소 통계 및 스플래시 localStorage 정리
- 일별 익명 방문자 해시·사이트/게시물 통계(030 마이그레이션) - POST /api/analytics/pageview, 관리자 analytics API, 클라이언트 트래커 - 관리자 대시보드 통계 카드·인기 게시물 Top 5 - 스플래시: SITE_BRAND_LOGO_TEXT localStorage 제거
This commit is contained in:
8
server/api/analytics/pageview.post.js
Normal file
8
server/api/analytics/pageview.post.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { handleAnalyticsPageview } from '../../utils/analytics-pageview-input.js'
|
||||
|
||||
/**
|
||||
* 공개 페이지뷰·읽음 통계 수집 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ ok: true }>}
|
||||
*/
|
||||
export default defineEventHandler((event) => handleAnalyticsPageview(event))
|
||||
255
server/repositories/analytics-repository.js
Normal file
255
server/repositories/analytics-repository.js
Normal file
@@ -0,0 +1,255 @@
|
||||
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)
|
||||
}))
|
||||
}
|
||||
17
server/routes/admin/api/analytics/posts.get.js
Normal file
17
server/routes/admin/api/analytics/posts.get.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth.js'
|
||||
import { getAnalyticsTopPosts } from '../../../../repositories/analytics-repository.js'
|
||||
|
||||
/**
|
||||
* 관리자 인기 게시물 통계 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Array<Object>>} 인기 게시물 목록
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
const query = getQuery(event)
|
||||
const days = Number(query.days) || 30
|
||||
const limit = Number(query.limit) || 5
|
||||
|
||||
return getAnalyticsTopPosts({ days, limit })
|
||||
})
|
||||
16
server/routes/admin/api/analytics/summary.get.js
Normal file
16
server/routes/admin/api/analytics/summary.get.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth.js'
|
||||
import { getAnalyticsSummary } 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 days = Number(query.days) || 30
|
||||
|
||||
return getAnalyticsSummary({ days })
|
||||
})
|
||||
68
server/utils/analytics-pageview-input.js
Normal file
68
server/utils/analytics-pageview-input.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
isBotUserAgent,
|
||||
isTrackableAnalyticsPath,
|
||||
normalizePostSlugForAnalytics
|
||||
} from '../../lib/analytics.js'
|
||||
import { getPostBySlug } from '../repositories/content-repository.js'
|
||||
import {
|
||||
createVisitorHashFromEvent,
|
||||
recordAnalyticsPageview
|
||||
} from '../repositories/analytics-repository.js'
|
||||
|
||||
const pageviewInputSchema = z.object({
|
||||
path: z.string().trim().min(1).max(500),
|
||||
postSlug: z.string().trim().max(200).optional().default(''),
|
||||
read: z.boolean().optional().default(false)
|
||||
})
|
||||
|
||||
/**
|
||||
* 페이지뷰 추적 요청을 처리한다.
|
||||
* @param {import('h3').H3Event} event - H3 이벤트
|
||||
* @returns {Promise<{ ok: true }>}
|
||||
*/
|
||||
export const handleAnalyticsPageview = async (event) => {
|
||||
const parsedBody = pageviewInputSchema.safeParse(await readBody(event))
|
||||
|
||||
if (!parsedBody.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '통계 요청 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
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 visitorHash = createVisitorHashFromEvent(event)
|
||||
const isReadEvent = Boolean(body.read)
|
||||
|
||||
await recordAnalyticsPageview({
|
||||
visitorHash,
|
||||
postId,
|
||||
recordSite: !isReadEvent,
|
||||
recordView: Boolean(postId) && !isReadEvent,
|
||||
recordRead: Boolean(postId) && isReadEvent
|
||||
})
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
Reference in New Issue
Block a user