통계 기간 지표 원장 기준 보정

This commit is contained in:
2026-07-29 14:57:51 +09:00
parent 4e7e12d8fe
commit 378afbe79c
8 changed files with 184 additions and 19 deletions

View File

@@ -703,6 +703,124 @@ const markContentSessionRead = async (sql, input) => {
return rows.length > 0
}
/**
* 콘텐츠 세션 집계 행이 실제 원장 데이터를 포함하는지 확인한다.
* @param {Object | undefined} row - 원장 집계 행
* @returns {boolean} 원장 데이터 존재 여부
*/
const hasContentSessionAggregateRows = (row) => {
return Number(row?.content_sessions || 0) > 0
}
/**
* 선택 기간이 콘텐츠 세션 원장 보관 범위 안에 있는지 확인한다.
* @param {string} startDay - 시작일
* @param {string} today - 오늘 날짜 키
* @returns {boolean} 원장 기준 계산 가능 여부
*/
const canUseContentSessionAggregateForRange = (startDay, today = getAnalyticsDayKey()) => {
const retentionStartDay = getAnalyticsDayBefore(today, ANALYTICS_VISITOR_HASH_RETENTION_DAYS)
return String(startDay || '') >= retentionStartDay
}
/**
* 사이트 콘텐츠 세션 집계를 조회한다.
* @param {import('postgres').Sql} sql - DB 클라이언트
* @param {string} startDay - 시작일
* @param {string} endDay - 종료일
* @returns {Promise<Object>} 원장 집계 행
*/
const getSiteContentSessionAggregate = async (sql, startDay, endDay) => {
const rows = await sql`
SELECT
COUNT(*) FILTER (WHERE view_recorded)::int AS views,
COUNT(DISTINCT visitor_hash) FILTER (WHERE view_recorded)::int AS visitors,
COUNT(*) FILTER (WHERE engaged_recorded)::int AS engaged_views,
COALESCE(SUM(duration_seconds), 0)::int AS total_engaged_seconds,
COUNT(*)::int AS content_sessions
FROM analytics_content_sessions
WHERE day BETWEEN ${startDay}::date AND ${endDay}::date
`
return rows[0] || {}
}
/**
* 게시물 콘텐츠 세션 집계를 조회한다.
* @param {import('postgres').Sql} sql - DB 클라이언트
* @param {string} postId - 게시물 ID
* @param {string} startDay - 시작일
* @param {string} endDay - 종료일
* @returns {Promise<Object>} 원장 집계 행
*/
const getPostContentSessionAggregate = async (sql, postId, startDay, endDay) => {
const rows = await sql`
SELECT
COUNT(*) FILTER (WHERE view_recorded)::int AS views,
COUNT(DISTINCT visitor_hash) FILTER (WHERE view_recorded)::int AS visitors,
COUNT(*) FILTER (WHERE read_recorded)::int AS reads,
COUNT(*) FILTER (WHERE engaged_recorded)::int AS engaged_views,
COALESCE(SUM(duration_seconds), 0)::int AS total_engaged_seconds,
COUNT(*) FILTER (WHERE scroll_50_recorded)::int AS scroll_50,
COUNT(*) FILTER (WHERE scroll_75_recorded)::int AS scroll_75,
COUNT(*) FILTER (WHERE scroll_100_recorded)::int AS scroll_100,
COUNT(*)::int AS content_sessions
FROM analytics_content_sessions
WHERE post_id = ${postId}
AND day BETWEEN ${startDay}::date AND ${endDay}::date
`
return rows[0] || {}
}
/**
* 페이지 콘텐츠 세션 집계를 조회한다.
* @param {import('postgres').Sql} sql - DB 클라이언트
* @param {string} pageId - 페이지 ID
* @param {string} startDay - 시작일
* @param {string} endDay - 종료일
* @returns {Promise<Object>} 원장 집계 행
*/
const getPageContentSessionAggregate = async (sql, pageId, startDay, endDay) => {
const rows = await sql`
SELECT
COUNT(*) FILTER (WHERE view_recorded)::int AS views,
COUNT(DISTINCT visitor_hash) FILTER (WHERE view_recorded)::int AS visitors,
COUNT(*) FILTER (WHERE engaged_recorded)::int AS engaged_views,
COALESCE(SUM(duration_seconds), 0)::int AS total_engaged_seconds,
COUNT(*) FILTER (WHERE scroll_50_recorded)::int AS scroll_50,
COUNT(*) FILTER (WHERE scroll_75_recorded)::int AS scroll_75,
COUNT(*) FILTER (WHERE scroll_100_recorded)::int AS scroll_100,
COUNT(*)::int AS content_sessions
FROM analytics_content_sessions
WHERE page_id = ${pageId}
AND day BETWEEN ${startDay}::date AND ${endDay}::date
`
return rows[0] || {}
}
/**
* 게시물 전체 콘텐츠 세션 스크롤 집계를 조회한다.
* @param {import('postgres').Sql} sql - DB 클라이언트
* @param {string} startDay - 시작일
* @param {string} endDay - 종료일
* @returns {Promise<Object>} 원장 집계 행
*/
const getAllPostContentSessionScrollAggregate = async (sql, startDay, endDay) => {
const rows = await sql`
SELECT
COUNT(*) FILTER (WHERE view_recorded)::int AS views,
COUNT(*) FILTER (WHERE scroll_50_recorded)::int AS scroll_50,
COUNT(*)::int AS content_sessions
FROM analytics_content_sessions
WHERE post_id IS NOT NULL
AND day BETWEEN ${startDay}::date AND ${endDay}::date
`
return rows[0] || {}
}
/**
* 날짜 문자열이 통계 조회 형식인지 확인한다.
* @param {string} value - 날짜 문자열
@@ -1285,6 +1403,20 @@ export const getAnalyticsSummary = async (options = {}) => {
WHERE day >= ${rangeStartDay}::date
`
const todayContentRows = await getSiteContentSessionAggregate(sql, today, today)
const contentEngagementRows = await getSiteContentSessionAggregate(sql, rangeStartDay, today)
const contentScrollRows = await getAllPostContentSessionScrollAggregate(sql, rangeStartDay, today)
const shouldUseContentRows = canUseContentSessionAggregateForRange(rangeStartDay, today)
const todayEngagementRow = hasContentSessionAggregateRows(todayContentRows)
? todayContentRows
: todayRows[0]
const engagementRow = shouldUseContentRows && hasContentSessionAggregateRows(contentEngagementRows)
? contentEngagementRows
: engagementRows[0]
const scrollRow = shouldUseContentRows && hasContentSessionAggregateRows(contentScrollRows)
? contentScrollRows
: scrollRows[0]
const onlineRows = await sql`
SELECT
COUNT(DISTINCT CASE
@@ -1519,10 +1651,10 @@ export const getAnalyticsSummary = async (options = {}) => {
GROUP BY day, source_group
`
const engagedViews = Number(engagementRows[0]?.engaged_views || 0)
const totalEngagedSeconds = Number(engagementRows[0]?.total_engaged_seconds || 0)
const todayEngagedViews = Number(todayRows[0]?.engaged_views || 0)
const todayTotalEngagedSeconds = Number(todayRows[0]?.total_engaged_seconds || 0)
const engagedViews = Number(engagementRow?.engaged_views || 0)
const totalEngagedSeconds = Number(engagementRow?.total_engaged_seconds || 0)
const todayEngagedViews = Number(todayEngagementRow?.engaged_views || 0)
const todayTotalEngagedSeconds = Number(todayEngagementRow?.total_engaged_seconds || 0)
/**
* 버킷 구간의 유입 방문자를 합산한다.
@@ -1573,8 +1705,8 @@ export const getAnalyticsSummary = async (options = {}) => {
? Math.round(totalEngagedSeconds / engagedViews)
: 0,
scroll50Reach: Math.min(
Number(scrollRows[0]?.scroll_50 || 0),
Number(scrollRows[0]?.views || 0)
Number(scrollRow?.scroll_50 || 0),
Number(scrollRow?.views || 0)
),
collectedSince,
trends: trendRows.map((row) => {
@@ -1893,6 +2025,16 @@ export const getAnalyticsRangeDetail = async (options = {}) => {
WHERE post_analytics_daily.day BETWEEN ${startDay}::date AND ${endDay}::date
`
const contentSummaryRows = await getSiteContentSessionAggregate(sql, startDay, endDay)
const contentScrollRows = await getAllPostContentSessionScrollAggregate(sql, startDay, endDay)
const shouldUseContentRows = canUseContentSessionAggregateForRange(startDay, endDay)
const summaryEngagementRow = shouldUseContentRows && hasContentSessionAggregateRows(contentSummaryRows)
? contentSummaryRows
: summaryRows[0]
const summaryScrollRow = shouldUseContentRows && hasContentSessionAggregateRows(contentScrollRows)
? contentScrollRows
: scrollRows[0]
const sourceRows = await sql`
SELECT
source_group,
@@ -1978,8 +2120,8 @@ export const getAnalyticsRangeDetail = async (options = {}) => {
LIMIT ${limit}
`
const engagedViews = Number(summaryRows[0]?.engaged_views || 0)
const totalEngagedSeconds = Number(summaryRows[0]?.total_engaged_seconds || 0)
const engagedViews = Number(summaryEngagementRow?.engaged_views || 0)
const totalEngagedSeconds = Number(summaryEngagementRow?.total_engaged_seconds || 0)
return {
startDay,
@@ -1992,8 +2134,8 @@ export const getAnalyticsRangeDetail = async (options = {}) => {
? Math.round(totalEngagedSeconds / engagedViews)
: 0,
scroll50Reach: Math.min(
Number(scrollRows[0]?.scroll_50 || 0),
Number(scrollRows[0]?.views || 0)
Number(summaryScrollRow?.scroll_50 || 0),
Number(summaryScrollRow?.views || 0)
)
},
sources: sourceRows.map((row) => ({
@@ -2145,6 +2287,12 @@ export const getPostAnalyticsDetail = async (options = {}) => {
AND day >= ${rangeStartDay}::date
`
const periodContentRows = await getPostContentSessionAggregate(sql, postId, rangeStartDay, today)
const shouldUseContentRows = canUseContentSessionAggregateForRange(rangeStartDay, today)
const periodSummaryRow = shouldUseContentRows && hasContentSessionAggregateRows(periodContentRows)
? periodContentRows
: periodRows[0]
let trendRows = []
if (trendPeriod === 'weekly') {
@@ -2293,7 +2441,7 @@ export const getPostAnalyticsDetail = async (options = {}) => {
trendPeriod,
updatedAt: new Date().toISOString(),
total: mapSummaryRow(totalRows[0]),
period: mapSummaryRow(periodRows[0]),
period: mapSummaryRow(periodSummaryRow),
trends: trendRows.map((row) => {
const engagementStats = normalizeContentEngagementStats({
views: row.views,
@@ -2403,6 +2551,12 @@ export const getPageAnalyticsDetail = async (options = {}) => {
AND day >= ${rangeStartDay}::date
`
const periodContentRows = await getPageContentSessionAggregate(sql, pageId, rangeStartDay, today)
const shouldUseContentRows = canUseContentSessionAggregateForRange(rangeStartDay, today)
const periodSummaryRow = shouldUseContentRows && hasContentSessionAggregateRows(periodContentRows)
? periodContentRows
: periodRows[0]
let trendRows = []
if (trendPeriod === 'weekly') {
@@ -2505,7 +2659,7 @@ export const getPageAnalyticsDetail = async (options = {}) => {
trendPeriod,
updatedAt: new Date().toISOString(),
total: mapPageSummaryRow(totalRows[0]),
period: mapPageSummaryRow(periodRows[0]),
period: mapPageSummaryRow(periodSummaryRow),
trends: trendRows.map((row) => ({
day: row.start_day ? new Date(row.start_day).toISOString().slice(0, 10) : '',
startDay: row.start_day ? new Date(row.start_day).toISOString().slice(0, 10) : '',