관리자 유입 통계 추가 v1.5.35
This commit is contained in:
@@ -75,6 +75,35 @@ const ensurePageDailyRow = async (sql, day, pageId) => {
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 일별 유입 통계 행을 보장한다.
|
||||
* @param {import('postgres').Sql} sql - DB 클라이언트
|
||||
* @param {string} day - YYYY-MM-DD
|
||||
* @param {{ sourceGroup: string, sourceName: string, deviceType: string, osName: string, keyword?: string }} traffic - 유입 분류
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const ensureTrafficDailyRow = async (sql, day, traffic) => {
|
||||
await sql`
|
||||
INSERT INTO analytics_traffic_daily (
|
||||
day,
|
||||
source_group,
|
||||
source_name,
|
||||
device_type,
|
||||
os_name,
|
||||
keyword
|
||||
)
|
||||
VALUES (
|
||||
${day},
|
||||
${traffic.sourceGroup},
|
||||
${traffic.sourceName},
|
||||
${traffic.deviceType},
|
||||
${traffic.osName},
|
||||
${traffic.keyword || ''}
|
||||
)
|
||||
ON CONFLICT (day, source_group, source_name, device_type, os_name, keyword) DO NOTHING
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트 방문자를 등록하고 신규 방문자면 true를 반환한다.
|
||||
* @param {import('postgres').Sql} sql - DB 클라이언트
|
||||
@@ -131,9 +160,72 @@ const registerPageVisitor = async (sql, day, pageId, visitorHash) => {
|
||||
return Boolean(rows[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* 유입 분류별 방문자를 등록하고 신규 방문자면 true를 반환한다.
|
||||
* @param {import('postgres').Sql} sql - DB 클라이언트
|
||||
* @param {string} day - YYYY-MM-DD
|
||||
* @param {string} visitorHash - 방문자 해시
|
||||
* @param {{ sourceGroup: string, sourceName: string, deviceType: string, osName: string, keyword?: string }} traffic - 유입 분류
|
||||
* @returns {Promise<boolean>} 신규 방문자 여부
|
||||
*/
|
||||
const registerTrafficVisitor = async (sql, day, visitorHash, traffic) => {
|
||||
const rows = await sql`
|
||||
INSERT INTO analytics_daily_visitors (
|
||||
day,
|
||||
scope,
|
||||
visitor_hash,
|
||||
source_group,
|
||||
source_name,
|
||||
device_type,
|
||||
os_name,
|
||||
keyword
|
||||
)
|
||||
VALUES (
|
||||
${day},
|
||||
'traffic',
|
||||
${visitorHash},
|
||||
${traffic.sourceGroup},
|
||||
${traffic.sourceName},
|
||||
${traffic.deviceType},
|
||||
${traffic.osName},
|
||||
${traffic.keyword || ''}
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
return Boolean(rows[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* 유입·디바이스 통계를 기록한다.
|
||||
* @param {import('postgres').Sql} sql - DB 클라이언트
|
||||
* @param {string} day - YYYY-MM-DD
|
||||
* @param {string} visitorHash - 방문자 해시
|
||||
* @param {{ sourceGroup: string, sourceName: string, deviceType: string, osName: string, keyword?: string }} traffic - 유입 분류
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const recordAnalyticsTraffic = async (sql, day, visitorHash, traffic) => {
|
||||
await ensureTrafficDailyRow(sql, day, traffic)
|
||||
const isNewTrafficVisitor = await registerTrafficVisitor(sql, day, visitorHash, traffic)
|
||||
|
||||
await sql`
|
||||
UPDATE analytics_traffic_daily
|
||||
SET
|
||||
page_views = page_views + 1,
|
||||
visitors = visitors + ${isNewTrafficVisitor ? 1 : 0}
|
||||
WHERE day = ${day}
|
||||
AND source_group = ${traffic.sourceGroup}
|
||||
AND source_name = ${traffic.sourceName}
|
||||
AND device_type = ${traffic.deviceType}
|
||||
AND os_name = ${traffic.osName}
|
||||
AND keyword = ${traffic.keyword || ''}
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지뷰·읽음 이벤트를 기록한다.
|
||||
* @param {{ visitorHash: string, postId?: string | null, pageId?: string | null, recordSite?: boolean, recordView?: boolean, recordRead?: boolean }} input - 기록 입력
|
||||
* @param {{ visitorHash: string, postId?: string | null, pageId?: string | null, traffic?: { sourceGroup: string, sourceName: string, deviceType: string, osName: string, keyword?: string }, recordSite?: boolean, recordView?: boolean, recordRead?: boolean }} input - 기록 입력
|
||||
* @returns {Promise<{ ok: true }>}
|
||||
*/
|
||||
export const recordAnalyticsPageview = async (input) => {
|
||||
@@ -150,6 +242,7 @@ export const recordAnalyticsPageview = async (input) => {
|
||||
const recordSite = input.recordSite !== false
|
||||
const recordView = Boolean(input.recordView)
|
||||
const recordRead = Boolean(input.recordRead)
|
||||
const traffic = input.traffic || null
|
||||
|
||||
if (recordSite) {
|
||||
await ensureSiteDailyRow(sql, day)
|
||||
@@ -163,6 +256,10 @@ export const recordAnalyticsPageview = async (input) => {
|
||||
visitors = visitors + ${isNewSiteVisitor ? 1 : 0}
|
||||
WHERE day = ${day}
|
||||
`
|
||||
|
||||
if (traffic) {
|
||||
await recordAnalyticsTraffic(sql, day, visitorHash, traffic)
|
||||
}
|
||||
}
|
||||
|
||||
if (!postId) {
|
||||
@@ -755,6 +852,89 @@ export const getAnalyticsActiveSessions = async (options = {}) => {
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 유입·디바이스·키워드 통계를 조회한다.
|
||||
* @param {{ days?: number }} [options] - 조회 옵션
|
||||
* @returns {Promise<{ sources: Array<Object>, devices: Array<Object>, keywords: Array<Object>, days: number }>} 유입 통계
|
||||
*/
|
||||
export const getAnalyticsTrafficSummary = async (options = {}) => {
|
||||
const sql = getPostgresClient()
|
||||
const days = Math.min(Math.max(Number(options.days) || 30, 1), ANALYTICS_CHART_MAX_DAYS)
|
||||
|
||||
if (!sql) {
|
||||
return {
|
||||
sources: [],
|
||||
devices: [],
|
||||
keywords: [],
|
||||
days
|
||||
}
|
||||
}
|
||||
|
||||
const today = getAnalyticsDayKey()
|
||||
const rangeStartDay = getAnalyticsDayBefore(today, days - 1)
|
||||
await purgeAnalyticsRetention(sql)
|
||||
|
||||
const sourceRows = await sql`
|
||||
SELECT
|
||||
source_group,
|
||||
source_name,
|
||||
COALESCE(SUM(page_views), 0)::int AS page_views,
|
||||
COALESCE(SUM(visitors), 0)::int AS visitors
|
||||
FROM analytics_traffic_daily
|
||||
WHERE day >= ${rangeStartDay}::date
|
||||
GROUP BY source_group, source_name
|
||||
ORDER BY page_views DESC, visitors DESC, source_name ASC
|
||||
`
|
||||
|
||||
const deviceRows = await sql`
|
||||
SELECT
|
||||
device_type,
|
||||
os_name,
|
||||
COALESCE(SUM(page_views), 0)::int AS page_views,
|
||||
COALESCE(SUM(visitors), 0)::int AS visitors
|
||||
FROM analytics_traffic_daily
|
||||
WHERE day >= ${rangeStartDay}::date
|
||||
GROUP BY device_type, os_name
|
||||
ORDER BY page_views DESC, visitors DESC, device_type ASC, os_name ASC
|
||||
`
|
||||
|
||||
const keywordRows = await sql`
|
||||
SELECT
|
||||
keyword,
|
||||
source_name,
|
||||
COALESCE(SUM(page_views), 0)::int AS page_views,
|
||||
COALESCE(SUM(visitors), 0)::int AS visitors
|
||||
FROM analytics_traffic_daily
|
||||
WHERE day >= ${rangeStartDay}::date
|
||||
AND keyword <> ''
|
||||
GROUP BY keyword, source_name
|
||||
ORDER BY page_views DESC, visitors DESC, keyword ASC
|
||||
LIMIT 12
|
||||
`
|
||||
|
||||
return {
|
||||
sources: sourceRows.map((row) => ({
|
||||
sourceGroup: row.source_group,
|
||||
sourceName: row.source_name,
|
||||
pageViews: Number(row.page_views || 0),
|
||||
visitors: Number(row.visitors || 0)
|
||||
})),
|
||||
devices: deviceRows.map((row) => ({
|
||||
deviceType: row.device_type,
|
||||
osName: row.os_name,
|
||||
pageViews: Number(row.page_views || 0),
|
||||
visitors: Number(row.visitors || 0)
|
||||
})),
|
||||
keywords: keywordRows.map((row) => ({
|
||||
keyword: row.keyword,
|
||||
sourceName: row.source_name,
|
||||
pageViews: Number(row.page_views || 0),
|
||||
visitors: Number(row.visitors || 0)
|
||||
})),
|
||||
days
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인기 게시물 통계를 조회한다.
|
||||
* @param {{ days?: number, limit?: number }} [options] - 조회 옵션
|
||||
@@ -771,6 +951,7 @@ export const getAnalyticsTopPosts = async (options = {}) => {
|
||||
|
||||
const today = getAnalyticsDayKey()
|
||||
const rangeStartDay = getAnalyticsDayBefore(today, days - 1)
|
||||
const monthlyStartDay = getAnalyticsDayBefore(today, 29)
|
||||
await purgeAnalyticsRetention(sql)
|
||||
|
||||
const rows = await sql`
|
||||
@@ -778,7 +959,11 @@ export const getAnalyticsTopPosts = async (options = {}) => {
|
||||
posts.id,
|
||||
posts.title,
|
||||
posts.slug,
|
||||
posts.created_at,
|
||||
COALESCE(SUM(post_analytics_daily.views), 0)::int AS views,
|
||||
COALESCE(SUM(post_analytics_daily.views) FILTER (
|
||||
WHERE post_analytics_daily.day >= ${monthlyStartDay}::date
|
||||
), 0)::int AS monthly_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,
|
||||
@@ -789,7 +974,7 @@ export const getAnalyticsTopPosts = async (options = {}) => {
|
||||
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
|
||||
GROUP BY posts.id, posts.title, posts.slug, posts.created_at
|
||||
ORDER BY views DESC, reads DESC, posts.published_at DESC NULLS LAST
|
||||
LIMIT ${limit}
|
||||
`
|
||||
@@ -802,7 +987,9 @@ export const getAnalyticsTopPosts = async (options = {}) => {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
slug: row.slug,
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : null,
|
||||
views: Number(row.views || 0),
|
||||
monthlyViews: Number(row.monthly_views || 0),
|
||||
reads: Number(row.reads || 0),
|
||||
visitors: Number(row.visitors || 0),
|
||||
avgEngagedSeconds: engagedViews > 0
|
||||
|
||||
16
server/routes/admin/api/analytics/traffic.get.js
Normal file
16
server/routes/admin/api/analytics/traffic.get.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth.js'
|
||||
import { getAnalyticsTrafficSummary } 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 getAnalyticsTrafficSummary({ days })
|
||||
})
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
normalizePageSlugForAnalytics,
|
||||
normalizePostSlugForAnalytics
|
||||
} from '../../lib/analytics.js'
|
||||
import {
|
||||
classifyAnalyticsDevice,
|
||||
classifyAnalyticsTrafficSource
|
||||
} from '../../lib/analytics-traffic.js'
|
||||
import { getPageBySlug, getPostBySlug } from '../repositories/content-repository.js'
|
||||
import {
|
||||
createVisitorHashFromEvent,
|
||||
@@ -15,6 +19,8 @@ const pageviewInputSchema = z.object({
|
||||
path: z.string().trim().min(1).max(500),
|
||||
postSlug: z.string().trim().max(200).optional().default(''),
|
||||
pageSlug: z.string().trim().max(200).optional().default(''),
|
||||
referrer: z.string().trim().max(1000).optional().default(''),
|
||||
currentUrl: z.string().trim().max(1000).optional().default(''),
|
||||
read: z.boolean().optional().default(false)
|
||||
})
|
||||
|
||||
@@ -67,11 +73,19 @@ export const handleAnalyticsPageview = async (event) => {
|
||||
|
||||
const visitorHash = createVisitorHashFromEvent(event)
|
||||
const isReadEvent = Boolean(body.read)
|
||||
const traffic = {
|
||||
...classifyAnalyticsTrafficSource({
|
||||
referrer: body.referrer,
|
||||
currentUrl: body.currentUrl
|
||||
}),
|
||||
...classifyAnalyticsDevice(userAgent)
|
||||
}
|
||||
|
||||
await recordAnalyticsPageview({
|
||||
visitorHash,
|
||||
postId,
|
||||
pageId,
|
||||
traffic,
|
||||
recordSite: !isReadEvent,
|
||||
recordView: (Boolean(postId) || Boolean(pageId)) && !isReadEvent,
|
||||
recordRead: Boolean(postId) && isReadEvent
|
||||
|
||||
Reference in New Issue
Block a user