feat: add admin dashboard backend and deployment workflow
All checks were successful
Deploy Blog Backend / test (push) Successful in 2m41s
Deploy Blog Backend / deploy (push) Successful in 1m29s

This commit is contained in:
wypark
2026-05-28 21:44:05 +09:00
parent 4c1bd8197f
commit c14d123fcc
25 changed files with 2271 additions and 11 deletions

View File

@@ -0,0 +1,26 @@
package me.wypark.blogbackend.api.controller.admin
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.AdminDashboardResponse
import me.wypark.blogbackend.domain.dashboard.AdminDashboardService
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/admin/dashboard")
class AdminDashboardController(
private val adminDashboardService: AdminDashboardService
) {
@GetMapping
fun getDashboard(
@RequestParam(required = false) range: String?,
@RequestParam(required = false) timezone: String?
): ResponseEntity<ApiResponse<AdminDashboardResponse>> {
val dashboard = adminDashboardService.getDashboard(range, timezone)
return ResponseEntity.ok(ApiResponse.success(dashboard, "OK"))
}
}

View File

@@ -0,0 +1,89 @@
package me.wypark.blogbackend.api.dto
import java.math.BigDecimal
import java.time.LocalDate
import java.time.OffsetDateTime
data class DashboardMetric(
val value: Long,
val previousValue: Long? = null,
val changeRate: BigDecimal? = null
)
data class DashboardOverview(
val todayViews: DashboardMetric,
val weekViews: DashboardMetric,
val monthViews: DashboardMetric,
val totalPosts: Long,
val totalComments: Long,
val totalCategories: Long,
val lastPublishedAt: OffsetDateTime?,
val generatedAt: OffsetDateTime
)
data class DashboardTrafficPoint(
val date: LocalDate,
val views: Long
)
data class DashboardPostStat(
val id: Long,
val title: String,
val slug: String,
val categoryName: String,
val viewCount: Long,
val rangeViewCount: Long,
val commentCount: Long,
val createdAt: OffsetDateTime,
val updatedAt: OffsetDateTime?
)
data class DashboardCategoryStat(
val id: Long,
val name: String,
val parentId: Long?,
val postCount: Long,
val viewCount: Long,
val recentViewCount: Long,
val lastPublishedAt: OffsetDateTime?,
val childrenCount: Long
)
data class DashboardActionItems(
val unansweredComments: Long,
val uncategorizedPosts: Long,
val stalePopularPosts: Long
)
data class DashboardPostSummary(
val id: Long,
val title: String,
val slug: String,
val categoryName: String,
val viewCount: Long,
val createdAt: OffsetDateTime,
val tags: List<String>
)
data class AdminDashboardCommentSummary(
val id: Long,
val content: String,
val author: String?,
val guestNickname: String?,
val memberNickname: String?,
val postSlug: String?,
val postTitle: String?,
val createdAt: OffsetDateTime
)
data class AdminDashboardResponse(
val overview: DashboardOverview,
val traffic: List<DashboardTrafficPoint>,
val topPosts: List<DashboardPostStat>,
val risingPosts: List<DashboardPostStat>,
val stalePopularPosts: List<DashboardPostStat>,
val recentPosts: List<DashboardPostSummary>,
val recentComments: List<AdminDashboardCommentSummary>,
val categoryStats: List<DashboardCategoryStat>,
val actionItems: DashboardActionItems
)

View File

@@ -1,15 +1,21 @@
package me.wypark.blogbackend.core.config
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.servlet.http.HttpServletResponse
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.core.config.jwt.JwtAuthenticationFilter
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
import org.springframework.web.filter.CorsFilter
import java.nio.charset.StandardCharsets
/**
* [Spring Security 설정]
@@ -22,7 +28,8 @@ import org.springframework.web.filter.CorsFilter
@EnableWebSecurity
class SecurityConfig(
private val corsFilter: CorsFilter,
private val jwtAuthenticationFilter: JwtAuthenticationFilter
private val jwtAuthenticationFilter: JwtAuthenticationFilter,
private val objectMapper: ObjectMapper
) {
@Bean
@@ -44,6 +51,24 @@ class SecurityConfig(
.sessionManagement {
it.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
}
.exceptionHandling { exceptions ->
exceptions.authenticationEntryPoint { _, response, _ ->
writeErrorResponse(
response = response,
status = HttpStatus.UNAUTHORIZED,
code = "UNAUTHORIZED",
message = "인증이 필요합니다."
)
}
exceptions.accessDeniedHandler { _, response, _ ->
writeErrorResponse(
response = response,
status = HttpStatus.FORBIDDEN,
code = "FORBIDDEN",
message = "관리자 권한이 필요합니다."
)
}
}
// 4. URL별 접근 권한 관리 (인가)
// Principle of Least Privilege(최소 권한의 원칙)에 따라, 명시적으로 허용된 경로 외에는 모두 인증을 요구
@@ -70,4 +95,16 @@ class SecurityConfig(
return http.build()
}
}
private fun writeErrorResponse(
response: HttpServletResponse,
status: HttpStatus,
code: String,
message: String
) {
response.status = status.value()
response.contentType = MediaType.APPLICATION_JSON_VALUE
response.characterEncoding = StandardCharsets.UTF_8.name()
response.writer.write(objectMapper.writeValueAsString(ApiResponse.error(message, code)))
}
}

View File

@@ -0,0 +1,14 @@
package me.wypark.blogbackend.core.config
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.Clock
@Configuration
class TimeConfig {
@Bean
fun clock(): Clock {
return Clock.systemUTC()
}
}

View File

@@ -0,0 +1,329 @@
package me.wypark.blogbackend.domain.dashboard
import org.springframework.jdbc.core.RowMapper
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Repository
import java.sql.ResultSet
import java.time.LocalDate
import java.time.LocalDateTime
@Repository
class AdminDashboardQueryRepository(
private val jdbcTemplate: NamedParameterJdbcTemplate
) {
fun sumViewsBetween(startDate: LocalDate, endDate: LocalDate): Long {
return queryLong(
"""
SELECT COALESCE(SUM(view_count), 0)
FROM post_view_daily_stats
WHERE stat_date BETWEEN :startDate AND :endDate
""".trimIndent(),
dateRangeParams(startDate, endDate)
)
}
fun findTrafficBetween(startDate: LocalDate, endDate: LocalDate): List<DashboardTrafficRow> {
return jdbcTemplate.query(
"""
SELECT stat_date, COALESCE(SUM(view_count), 0) AS views
FROM post_view_daily_stats
WHERE stat_date BETWEEN :startDate AND :endDate
GROUP BY stat_date
ORDER BY stat_date ASC
""".trimIndent(),
dateRangeParams(startDate, endDate)
) { rs, _ ->
DashboardTrafficRow(
date = rs.getDate("stat_date").toLocalDate(),
views = rs.getLong("views")
)
}
}
fun findTopPosts(startDate: LocalDate, endDate: LocalDate, limit: Int): List<DashboardPostStatRow> {
return jdbcTemplate.query(
"""
WITH current_views AS (
SELECT post_id, SUM(view_count) AS view_count
FROM post_view_daily_stats
WHERE stat_date BETWEEN :startDate AND :endDate
GROUP BY post_id
)
SELECT p.id,
p.title,
p.slug,
COALESCE(c.name, '미분류') AS category_name,
p.view_count,
COALESCE(cv.view_count, 0) AS range_view_count,
COUNT(DISTINCT cm.id) AS comment_count,
p.created_at,
p.updated_at
FROM post p
JOIN current_views cv ON cv.post_id = p.id
LEFT JOIN category c ON c.id = p.category_id
LEFT JOIN comment cm ON cm.post_id = p.id
GROUP BY p.id, p.title, p.slug, c.name, p.view_count, cv.view_count, p.created_at, p.updated_at
ORDER BY range_view_count DESC, p.id DESC
LIMIT :limit
""".trimIndent(),
dateRangeParams(startDate, endDate).addValue("limit", limit),
postStatRowMapper
)
}
fun findRisingPosts(
currentStartDate: LocalDate,
currentEndDate: LocalDate,
previousStartDate: LocalDate,
previousEndDate: LocalDate,
minimumViewCount: Long,
limit: Int
): List<DashboardPostStatRow> {
return jdbcTemplate.query(
"""
WITH current_views AS (
SELECT post_id, SUM(view_count) AS view_count
FROM post_view_daily_stats
WHERE stat_date BETWEEN :currentStartDate AND :currentEndDate
GROUP BY post_id
),
previous_views AS (
SELECT post_id, SUM(view_count) AS view_count
FROM post_view_daily_stats
WHERE stat_date BETWEEN :previousStartDate AND :previousEndDate
GROUP BY post_id
)
SELECT p.id,
p.title,
p.slug,
COALESCE(c.name, '미분류') AS category_name,
p.view_count,
COALESCE(cv.view_count, 0) AS range_view_count,
COUNT(DISTINCT cm.id) AS comment_count,
p.created_at,
p.updated_at
FROM post p
LEFT JOIN current_views cv ON cv.post_id = p.id
LEFT JOIN previous_views pv ON pv.post_id = p.id
LEFT JOIN category c ON c.id = p.category_id
LEFT JOIN comment cm ON cm.post_id = p.id
WHERE COALESCE(cv.view_count, 0) >= :minimumViewCount
GROUP BY p.id, p.title, p.slug, c.name, p.view_count, cv.view_count, pv.view_count, p.created_at, p.updated_at
ORDER BY (COALESCE(cv.view_count, 0) - COALESCE(pv.view_count, 0)) DESC,
COALESCE(cv.view_count, 0) DESC,
p.id DESC
LIMIT :limit
""".trimIndent(),
MapSqlParameterSource()
.addValue("currentStartDate", currentStartDate)
.addValue("currentEndDate", currentEndDate)
.addValue("previousStartDate", previousStartDate)
.addValue("previousEndDate", previousEndDate)
.addValue("minimumViewCount", minimumViewCount)
.addValue("limit", limit),
postStatRowMapper
)
}
fun findStalePopularPosts(
startDate: LocalDate,
endDate: LocalDate,
staleBefore: LocalDateTime,
minimumViewCount: Long,
limit: Int
): List<DashboardPostStatRow> {
return jdbcTemplate.query(
"""
WITH current_views AS (
SELECT post_id, SUM(view_count) AS view_count
FROM post_view_daily_stats
WHERE stat_date BETWEEN :startDate AND :endDate
GROUP BY post_id
)
SELECT p.id,
p.title,
p.slug,
COALESCE(c.name, '미분류') AS category_name,
p.view_count,
COALESCE(cv.view_count, 0) AS range_view_count,
COUNT(DISTINCT cm.id) AS comment_count,
p.created_at,
p.updated_at
FROM post p
JOIN current_views cv ON cv.post_id = p.id
LEFT JOIN category c ON c.id = p.category_id
LEFT JOIN comment cm ON cm.post_id = p.id
WHERE COALESCE(cv.view_count, 0) >= :minimumViewCount
AND p.updated_at <= :staleBefore
GROUP BY p.id, p.title, p.slug, c.name, p.view_count, cv.view_count, p.created_at, p.updated_at
ORDER BY COALESCE(cv.view_count, 0) DESC, p.updated_at ASC, p.id DESC
LIMIT :limit
""".trimIndent(),
dateRangeParams(startDate, endDate)
.addValue("staleBefore", staleBefore)
.addValue("minimumViewCount", minimumViewCount)
.addValue("limit", limit),
postStatRowMapper
)
}
fun countStalePopularPosts(startDate: LocalDate, endDate: LocalDate, staleBefore: LocalDateTime, minimumViewCount: Long): Long {
return queryLong(
"""
WITH current_views AS (
SELECT post_id, SUM(view_count) AS view_count
FROM post_view_daily_stats
WHERE stat_date BETWEEN :startDate AND :endDate
GROUP BY post_id
)
SELECT COUNT(*)
FROM post p
JOIN current_views cv ON cv.post_id = p.id
WHERE COALESCE(cv.view_count, 0) >= :minimumViewCount
AND p.updated_at <= :staleBefore
""".trimIndent(),
dateRangeParams(startDate, endDate)
.addValue("staleBefore", staleBefore)
.addValue("minimumViewCount", minimumViewCount)
)
}
fun findCategoryStats(startDate: LocalDate, endDate: LocalDate): List<DashboardCategoryStatRow> {
return jdbcTemplate.query(
"""
SELECT c.id,
c.name,
c.parent_id,
COALESCE(pc.post_count, 0) AS post_count,
COALESCE(pc.view_count, 0) AS view_count,
COALESCE(rv.recent_view_count, 0) AS recent_view_count,
pc.last_published_at,
COALESCE(cc.children_count, 0) AS children_count
FROM category c
LEFT JOIN (
SELECT category_id,
COUNT(*) AS post_count,
COALESCE(SUM(view_count), 0) AS view_count,
MAX(created_at) AS last_published_at
FROM post
WHERE category_id IS NOT NULL
GROUP BY category_id
) pc ON pc.category_id = c.id
LEFT JOIN (
SELECT p.category_id,
COALESCE(SUM(s.view_count), 0) AS recent_view_count
FROM post p
JOIN post_view_daily_stats s ON s.post_id = p.id
WHERE p.category_id IS NOT NULL
AND s.stat_date BETWEEN :startDate AND :endDate
GROUP BY p.category_id
) rv ON rv.category_id = c.id
LEFT JOIN (
SELECT parent_id, COUNT(*) AS children_count
FROM category
WHERE parent_id IS NOT NULL
GROUP BY parent_id
) cc ON cc.parent_id = c.id
ORDER BY c.name ASC, c.id ASC
""".trimIndent(),
dateRangeParams(startDate, endDate)
) { rs, _ ->
DashboardCategoryStatRow(
id = rs.getLong("id"),
name = rs.getString("name"),
parentId = rs.getNullableLong("parent_id"),
postCount = rs.getLong("post_count"),
viewCount = rs.getLong("view_count"),
recentViewCount = rs.getLong("recent_view_count"),
lastPublishedAt = rs.getNullableLocalDateTime("last_published_at"),
childrenCount = rs.getLong("children_count")
)
}
}
fun countUncategorizedPosts(): Long {
return queryLong(
"""
SELECT COUNT(*)
FROM post p
LEFT JOIN category c ON c.id = p.category_id
WHERE p.category_id IS NULL
OR LOWER(c.name) IN ('uncategorized', '미분류')
""".trimIndent(),
MapSqlParameterSource()
)
}
fun countUnansweredComments(): Long {
return queryLong(
"""
SELECT COUNT(*)
FROM comment root
JOIN post p ON p.id = root.post_id
LEFT JOIN member root_member ON root_member.id = root.member_id
WHERE root.parent_id IS NULL
AND (
root.member_id IS NULL
OR (
root.member_id <> p.member_id
AND root_member.role <> 'ROLE_ADMIN'
)
)
AND NOT EXISTS (
SELECT 1
FROM comment child
LEFT JOIN member child_member ON child_member.id = child.member_id
WHERE child.parent_id = root.id
AND (
child.member_id = p.member_id
OR child_member.role = 'ROLE_ADMIN'
)
)
""".trimIndent(),
MapSqlParameterSource()
)
}
fun findLastPublishedAt(): LocalDateTime? {
return jdbcTemplate.query(
"SELECT MAX(created_at) AS last_published_at FROM post",
MapSqlParameterSource()
) { rs, _ -> rs.getNullableLocalDateTime("last_published_at") }
.firstOrNull()
}
private fun queryLong(sql: String, params: MapSqlParameterSource): Long {
return jdbcTemplate.queryForObject(sql, params, Number::class.java)?.toLong() ?: 0L
}
private fun dateRangeParams(startDate: LocalDate, endDate: LocalDate): MapSqlParameterSource {
return MapSqlParameterSource()
.addValue("startDate", startDate)
.addValue("endDate", endDate)
}
private val postStatRowMapper = RowMapper { rs: ResultSet, _: Int ->
DashboardPostStatRow(
id = rs.getLong("id"),
title = rs.getString("title"),
slug = rs.getString("slug"),
categoryName = rs.getString("category_name"),
viewCount = rs.getLong("view_count"),
rangeViewCount = rs.getLong("range_view_count"),
commentCount = rs.getLong("comment_count"),
createdAt = rs.getTimestamp("created_at").toLocalDateTime(),
updatedAt = rs.getNullableLocalDateTime("updated_at")
)
}
private fun ResultSet.getNullableLong(columnLabel: String): Long? {
val value = getLong(columnLabel)
return if (wasNull()) null else value
}
private fun ResultSet.getNullableLocalDateTime(columnLabel: String): LocalDateTime? {
return getTimestamp(columnLabel)?.toLocalDateTime()
}
}

View File

@@ -0,0 +1,32 @@
package me.wypark.blogbackend.domain.dashboard
import java.time.LocalDate
import java.time.LocalDateTime
data class DashboardPostStatRow(
val id: Long,
val title: String,
val slug: String,
val categoryName: String,
val viewCount: Long,
val rangeViewCount: Long,
val commentCount: Long,
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime?
)
data class DashboardCategoryStatRow(
val id: Long,
val name: String,
val parentId: Long?,
val postCount: Long,
val viewCount: Long,
val recentViewCount: Long,
val lastPublishedAt: LocalDateTime?,
val childrenCount: Long
)
data class DashboardTrafficRow(
val date: LocalDate,
val views: Long
)

View File

@@ -0,0 +1,220 @@
package me.wypark.blogbackend.domain.dashboard
import me.wypark.blogbackend.api.dto.AdminDashboardCommentSummary
import me.wypark.blogbackend.api.dto.AdminDashboardResponse
import me.wypark.blogbackend.api.dto.DashboardActionItems
import me.wypark.blogbackend.api.dto.DashboardCategoryStat
import me.wypark.blogbackend.api.dto.DashboardMetric
import me.wypark.blogbackend.api.dto.DashboardOverview
import me.wypark.blogbackend.api.dto.DashboardPostStat
import me.wypark.blogbackend.api.dto.DashboardPostSummary
import me.wypark.blogbackend.api.dto.DashboardTrafficPoint
import me.wypark.blogbackend.domain.category.CategoryRepository
import me.wypark.blogbackend.domain.comment.Comment
import me.wypark.blogbackend.domain.comment.CommentRepository
import me.wypark.blogbackend.domain.post.Post
import me.wypark.blogbackend.domain.post.PostRepository
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Clock
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneId
@Service
@Transactional(readOnly = true)
class AdminDashboardService(
private val dashboardQueryRepository: AdminDashboardQueryRepository,
private val postRepository: PostRepository,
private val commentRepository: CommentRepository,
private val categoryRepository: CategoryRepository,
private val clock: Clock
) {
fun getDashboard(rangeValue: String?, timezoneValue: String?): AdminDashboardResponse {
val range = DashboardRange.from(rangeValue)
val zoneId = DashboardDateUtils.resolveZoneId(timezoneValue)
val today = LocalDate.now(clock.withZone(zoneId))
val selectedWindow = DashboardDateUtils.currentWindow(today, range.days)
val previousSelectedWindow = DashboardDateUtils.previousWindow(selectedWindow)
val staleBefore = today.minusDays(STALE_POPULAR_DAYS).atStartOfDay()
val stalePopularPosts = dashboardQueryRepository.findStalePopularPosts(
startDate = selectedWindow.startDate,
endDate = selectedWindow.endDate,
staleBefore = staleBefore,
minimumViewCount = STALE_POPULAR_MINIMUM_VIEWS,
limit = POST_WIDGET_LIMIT
).map { it.toDashboardPostStat(zoneId) }
return AdminDashboardResponse(
overview = buildOverview(today, zoneId),
traffic = buildTraffic(selectedWindow),
topPosts = dashboardQueryRepository.findTopPosts(
selectedWindow.startDate,
selectedWindow.endDate,
POST_WIDGET_LIMIT
).map { it.toDashboardPostStat(zoneId) },
risingPosts = dashboardQueryRepository.findRisingPosts(
currentStartDate = selectedWindow.startDate,
currentEndDate = selectedWindow.endDate,
previousStartDate = previousSelectedWindow.startDate,
previousEndDate = previousSelectedWindow.endDate,
minimumViewCount = RISING_POST_MINIMUM_VIEWS,
limit = POST_WIDGET_LIMIT
).map { it.toDashboardPostStat(zoneId) },
stalePopularPosts = stalePopularPosts,
recentPosts = findRecentPosts(zoneId),
recentComments = findRecentComments(zoneId),
categoryStats = dashboardQueryRepository.findCategoryStats(
selectedWindow.startDate,
selectedWindow.endDate
).map { it.toDashboardCategoryStat(zoneId) },
actionItems = DashboardActionItems(
unansweredComments = dashboardQueryRepository.countUnansweredComments(),
uncategorizedPosts = dashboardQueryRepository.countUncategorizedPosts(),
stalePopularPosts = dashboardQueryRepository.countStalePopularPosts(
startDate = selectedWindow.startDate,
endDate = selectedWindow.endDate,
staleBefore = staleBefore,
minimumViewCount = STALE_POPULAR_MINIMUM_VIEWS
)
)
)
}
private fun buildOverview(today: LocalDate, zoneId: ZoneId): DashboardOverview {
val todayWindow = DashboardDateUtils.currentWindow(today, 1)
val yesterdayWindow = DashboardDateUtils.previousWindow(todayWindow)
val weekWindow = DashboardDateUtils.currentWindow(today, 7)
val previousWeekWindow = DashboardDateUtils.previousWindow(weekWindow)
val monthWindow = DashboardDateUtils.currentWindow(today, 30)
val previousMonthWindow = DashboardDateUtils.previousWindow(monthWindow)
return DashboardOverview(
todayViews = metric(todayWindow, yesterdayWindow),
weekViews = metric(weekWindow, previousWeekWindow),
monthViews = metric(monthWindow, previousMonthWindow),
totalPosts = postRepository.count(),
totalComments = commentRepository.count(),
totalCategories = categoryRepository.count(),
lastPublishedAt = dashboardQueryRepository.findLastPublishedAt().toOffsetDateTimeOrNull(zoneId),
generatedAt = OffsetDateTime.now(clock.withZone(zoneId))
)
}
private fun metric(currentWindow: DashboardDateWindow, previousWindow: DashboardDateWindow): DashboardMetric {
val currentValue = dashboardQueryRepository.sumViewsBetween(currentWindow.startDate, currentWindow.endDate)
val previousValue = dashboardQueryRepository.sumViewsBetween(previousWindow.startDate, previousWindow.endDate)
return DashboardMetric(
value = currentValue,
previousValue = previousValue,
changeRate = DashboardDateUtils.changeRate(currentValue, previousValue)
)
}
private fun buildTraffic(window: DashboardDateWindow): List<DashboardTrafficPoint> {
val viewsByDate = dashboardQueryRepository.findTrafficBetween(window.startDate, window.endDate)
.associate { it.date to it.views }
val points = mutableListOf<DashboardTrafficPoint>()
var date = window.startDate
while (!date.isAfter(window.endDate)) {
points.add(DashboardTrafficPoint(date = date, views = viewsByDate[date] ?: 0L))
date = date.plusDays(1)
}
return points
}
private fun findRecentPosts(zoneId: ZoneId): List<DashboardPostSummary> {
val pageable = PageRequest.of(
0,
RECENT_WIDGET_LIMIT,
Sort.by(Sort.Direction.DESC, "createdAt")
)
return postRepository.findAll(pageable).content.map { it.toDashboardPostSummary(zoneId) }
}
private fun findRecentComments(zoneId: ZoneId): List<AdminDashboardCommentSummary> {
val pageable = PageRequest.of(
0,
RECENT_WIDGET_LIMIT,
Sort.by(Sort.Direction.DESC, "createdAt")
)
return commentRepository.findAll(pageable).content.map { it.toDashboardCommentSummary(zoneId) }
}
private fun DashboardPostStatRow.toDashboardPostStat(zoneId: ZoneId): DashboardPostStat {
return DashboardPostStat(
id = id,
title = title,
slug = slug,
categoryName = categoryName,
viewCount = viewCount,
rangeViewCount = rangeViewCount,
commentCount = commentCount,
createdAt = createdAt.toOffsetDateTime(zoneId),
updatedAt = updatedAt.toOffsetDateTimeOrNull(zoneId)
)
}
private fun DashboardCategoryStatRow.toDashboardCategoryStat(zoneId: ZoneId): DashboardCategoryStat {
return DashboardCategoryStat(
id = id,
name = name,
parentId = parentId,
postCount = postCount,
viewCount = viewCount,
recentViewCount = recentViewCount,
lastPublishedAt = lastPublishedAt.toOffsetDateTimeOrNull(zoneId),
childrenCount = childrenCount
)
}
private fun Post.toDashboardPostSummary(zoneId: ZoneId): DashboardPostSummary {
return DashboardPostSummary(
id = id!!,
title = title,
slug = slug,
categoryName = category?.name ?: "미분류",
viewCount = viewCount,
createdAt = createdAt.toOffsetDateTime(zoneId),
tags = tags.map { it.tag.name }
)
}
private fun Comment.toDashboardCommentSummary(zoneId: ZoneId): AdminDashboardCommentSummary {
return AdminDashboardCommentSummary(
id = id!!,
content = content,
author = getAuthorName(),
guestNickname = guestNickname,
memberNickname = member?.nickname,
postSlug = post.slug,
postTitle = post.title,
createdAt = createdAt.toOffsetDateTime(zoneId)
)
}
private fun LocalDateTime.toOffsetDateTime(zoneId: ZoneId): OffsetDateTime {
return atZone(zoneId).toOffsetDateTime()
}
private fun LocalDateTime?.toOffsetDateTimeOrNull(zoneId: ZoneId): OffsetDateTime? {
return this?.toOffsetDateTime(zoneId)
}
companion object {
private const val POST_WIDGET_LIMIT = 5
private const val RECENT_WIDGET_LIMIT = 5
private const val RISING_POST_MINIMUM_VIEWS = 5L
private const val STALE_POPULAR_MINIMUM_VIEWS = 10L
private const val STALE_POPULAR_DAYS = 180L
}
}

View File

@@ -0,0 +1,63 @@
package me.wypark.blogbackend.domain.dashboard
import java.math.BigDecimal
import java.math.RoundingMode
import java.time.LocalDate
import java.time.ZoneId
import java.time.temporal.ChronoUnit
enum class DashboardRange(val queryValue: String, val days: Long) {
SEVEN_DAYS("7d", 7),
THIRTY_DAYS("30d", 30),
NINETY_DAYS("90d", 90);
companion object {
fun from(rawValue: String?): DashboardRange {
return entries.firstOrNull { it.queryValue == rawValue?.lowercase() } ?: THIRTY_DAYS
}
}
}
data class DashboardDateWindow(
val startDate: LocalDate,
val endDate: LocalDate
) {
val days: Long = ChronoUnit.DAYS.between(startDate, endDate) + 1
}
object DashboardDateUtils {
private val defaultZoneId: ZoneId = ZoneId.of("Asia/Seoul")
fun resolveZoneId(rawValue: String?): ZoneId {
if (rawValue.isNullOrBlank()) return defaultZoneId
return try {
ZoneId.of(rawValue)
} catch (e: Exception) {
defaultZoneId
}
}
fun currentWindow(today: LocalDate, days: Long): DashboardDateWindow {
return DashboardDateWindow(
startDate = today.minusDays(days - 1),
endDate = today
)
}
fun previousWindow(currentWindow: DashboardDateWindow): DashboardDateWindow {
val previousEndDate = currentWindow.startDate.minusDays(1)
return DashboardDateWindow(
startDate = previousEndDate.minusDays(currentWindow.days - 1),
endDate = previousEndDate
)
}
fun changeRate(currentValue: Long, previousValue: Long): BigDecimal? {
if (previousValue == 0L) return null
return BigDecimal.valueOf(currentValue - previousValue)
.multiply(BigDecimal.valueOf(100))
.divide(BigDecimal.valueOf(previousValue), 2, RoundingMode.HALF_UP)
}
}

View File

@@ -19,7 +19,8 @@ import java.util.*
@Service
class ImageService(
private val s3Client: S3Client,
@Value("\${spring.cloud.aws.s3.endpoint:http://minio:9000}") private val endpoint: String
@Value("\${spring.cloud.aws.s3.endpoint:http://minio:9000}") private val endpoint: String,
@Value("\${blog.image.initialize-bucket:true}") private val initializeBucket: Boolean
) {
private val bucketName = "blog-images"
@@ -29,7 +30,9 @@ class ImageService(
* 애플리케이션 레벨에서 인프라(Bucket & Policy)를 자동 프로비저닝(Auto-Provisioning)합니다.
*/
init {
createBucketIfNotExists()
if (initializeBucket) {
createBucketIfNotExists()
}
}
/**
@@ -111,4 +114,4 @@ class ImageService(
}
}
}
}
}

View File

@@ -15,6 +15,8 @@ import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDate
import java.time.ZoneId
/**
* [게시글 비즈니스 로직]
@@ -33,7 +35,8 @@ class PostService(
private val categoryRepository: CategoryRepository,
private val memberRepository: MemberRepository,
private val tagRepository: TagRepository,
private val imageService: ImageService
private val imageService: ImageService,
private val postViewDailyStatsJdbcRepository: PostViewDailyStatsJdbcRepository
) {
/**
@@ -58,6 +61,7 @@ class PostService(
?: throw IllegalArgumentException("해당 게시글을 찾을 수 없습니다: $slug")
post.increaseViewCount()
postViewDailyStatsJdbcRepository.incrementPostView(post.id!!, LocalDate.now(KOREA_ZONE_ID))
// 인접 게시글 조회 (Prev/Next Navigation)
// ID를 기준으로 정렬하여 바로 앞/뒤의 게시글을 1건씩 조회합니다.
@@ -249,4 +253,8 @@ class PostService(
names.add(category.name)
category.children.forEach { collectCategoryNames(it, names) }
}
}
companion object {
private val KOREA_ZONE_ID: ZoneId = ZoneId.of("Asia/Seoul")
}
}

View File

@@ -0,0 +1,36 @@
package me.wypark.blogbackend.domain.post
import jakarta.persistence.*
import me.wypark.blogbackend.domain.common.BaseTimeEntity
import java.time.LocalDate
@Entity
@Table(
name = "post_view_daily_stats",
uniqueConstraints = [
UniqueConstraint(
name = "uk_post_view_daily_stats_post_date",
columnNames = ["post_id", "stat_date"]
)
],
indexes = [
Index(name = "idx_post_view_daily_stats_date", columnList = "stat_date"),
Index(name = "idx_post_view_daily_stats_post_id", columnList = "post_id")
]
)
class PostViewDailyStats(
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id", nullable = false)
val post: Post,
@Column(name = "stat_date", nullable = false)
val statDate: LocalDate,
@Column(name = "view_count", nullable = false)
var viewCount: Long = 0
) : BaseTimeEntity() {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
}

View File

@@ -0,0 +1,80 @@
package me.wypark.blogbackend.domain.post
import org.springframework.dao.DuplicateKeyException
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Repository
import java.time.LocalDate
import javax.sql.DataSource
@Repository
class PostViewDailyStatsJdbcRepository(
private val jdbcTemplate: NamedParameterJdbcTemplate,
private val dataSource: DataSource
) {
private val isPostgreSql: Boolean by lazy {
dataSource.connection.use { connection ->
connection.metaData.databaseProductName.contains("PostgreSQL", ignoreCase = true)
}
}
fun incrementPostView(postId: Long, statDate: LocalDate) {
if (isPostgreSql) {
incrementPostViewWithPostgresUpsert(postId, statDate)
return
}
incrementPostViewGenerically(postId, statDate)
}
private fun incrementPostViewWithPostgresUpsert(postId: Long, statDate: LocalDate) {
jdbcTemplate.update(
"""
INSERT INTO post_view_daily_stats (post_id, stat_date, view_count, created_at, updated_at)
VALUES (:postId, :statDate, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (post_id, stat_date)
DO UPDATE SET
view_count = post_view_daily_stats.view_count + 1,
updated_at = CURRENT_TIMESTAMP
""".trimIndent(),
params(postId, statDate)
)
}
private fun incrementPostViewGenerically(postId: Long, statDate: LocalDate) {
val updatedRows = updateExistingRow(postId, statDate)
if (updatedRows > 0) return
try {
jdbcTemplate.update(
"""
INSERT INTO post_view_daily_stats (post_id, stat_date, view_count, created_at, updated_at)
VALUES (:postId, :statDate, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""".trimIndent(),
params(postId, statDate)
)
} catch (e: DuplicateKeyException) {
updateExistingRow(postId, statDate)
}
}
private fun updateExistingRow(postId: Long, statDate: LocalDate): Int {
return jdbcTemplate.update(
"""
UPDATE post_view_daily_stats
SET view_count = view_count + 1,
updated_at = CURRENT_TIMESTAMP
WHERE post_id = :postId
AND stat_date = :statDate
""".trimIndent(),
params(postId, statDate)
)
}
private fun params(postId: Long, statDate: LocalDate): MapSqlParameterSource {
return MapSqlParameterSource()
.addValue("postId", postId)
.addValue("statDate", statDate)
}
}

View File

@@ -15,6 +15,11 @@ spring:
password: ${SPRING_DATASOURCE_PASSWORD}
driver-class-name: org.postgresql.Driver
flyway:
enabled: true
baseline-on-migrate: true
baseline-version: 0
# [JPA / Hibernate 설정]
jpa:
hibernate:
@@ -82,4 +87,4 @@ jwt:
# CI/CD 파이프라인 변수(${JWT_SECRET})를 통해 주입받습니다.
secret: ${JWT_SECRET}
access-token-validity: 600000 # 10분 (짧은 만료 시간으로 탈취 시 피해 최소화)
refresh-token-validity: 604800000 # 7일 (RTR 적용으로 장기 유효기간 허용)
refresh-token-validity: 604800000 # 7일 (RTR 적용으로 장기 유효기간 허용)

View File

@@ -0,0 +1,18 @@
CREATE TABLE post_view_daily_stats (
id BIGSERIAL PRIMARY KEY,
post_id BIGINT NOT NULL,
stat_date DATE NOT NULL,
view_count BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_post_view_daily_stats_post
FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE,
CONSTRAINT uk_post_view_daily_stats_post_date
UNIQUE (post_id, stat_date)
);
CREATE INDEX idx_post_view_daily_stats_date
ON post_view_daily_stats (stat_date);
CREATE INDEX idx_post_view_daily_stats_post_id
ON post_view_daily_stats (post_id);

View File

@@ -2,8 +2,10 @@ package me.wypark.blogbackend
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
@SpringBootTest
@ActiveProfiles("test")
class BlogBackendApplicationTests {
@Test

View File

@@ -0,0 +1,225 @@
package me.wypark.blogbackend.api.controller.admin
import jakarta.persistence.EntityManager
import me.wypark.blogbackend.domain.category.Category
import me.wypark.blogbackend.domain.category.CategoryRepository
import me.wypark.blogbackend.domain.comment.Comment
import me.wypark.blogbackend.domain.comment.CommentRepository
import me.wypark.blogbackend.domain.post.Post
import me.wypark.blogbackend.domain.post.PostRepository
import me.wypark.blogbackend.domain.post.PostViewDailyStatsJdbcRepository
import me.wypark.blogbackend.domain.user.Member
import me.wypark.blogbackend.domain.user.MemberRepository
import me.wypark.blogbackend.domain.user.Role
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import kotlin.test.assertEquals
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Transactional
class AdminDashboardIntegrationTest {
@Autowired
lateinit var mockMvc: MockMvc
@Autowired
lateinit var memberRepository: MemberRepository
@Autowired
lateinit var categoryRepository: CategoryRepository
@Autowired
lateinit var postRepository: PostRepository
@Autowired
lateinit var commentRepository: CommentRepository
@Autowired
lateinit var postViewDailyStatsJdbcRepository: PostViewDailyStatsJdbcRepository
@Autowired
lateinit var jdbcTemplate: NamedParameterJdbcTemplate
@Autowired
lateinit var entityManager: EntityManager
@Test
fun `post detail view creates and increments daily stats`() {
val admin = saveMember("admin@example.com", Role.ROLE_ADMIN)
val post = postRepository.saveAndFlush(
Post(
title = "Daily Stats",
content = "content",
slug = "daily-stats",
member = admin
)
)
val today = LocalDate.now(ZoneId.of("Asia/Seoul"))
mockMvc.perform(get("/api/posts/{slug}", post.slug))
.andExpect(status().isOk)
mockMvc.perform(get("/api/posts/{slug}", post.slug))
.andExpect(status().isOk)
entityManager.flush()
entityManager.clear()
assertEquals(2L, findDailyViewCount(post.id!!, today))
assertEquals(2L, postRepository.findBySlug(post.slug)?.viewCount)
}
@Test
fun `dashboard requires admin authority`() {
mockMvc.perform(get("/api/admin/dashboard"))
.andExpect(status().isUnauthorized)
.andExpect(jsonPath("$.code").value("UNAUTHORIZED"))
mockMvc.perform(get("/api/admin/dashboard").with(user("user").roles("USER")))
.andExpect(status().isForbidden)
.andExpect(jsonPath("$.code").value("FORBIDDEN"))
mockMvc.perform(get("/api/admin/dashboard").with(user("admin").roles("ADMIN")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.code").value("SUCCESS"))
.andExpect(jsonPath("$.message").value("OK"))
}
@Test
fun `dashboard returns traffic metrics post stats categories and action items`() {
val admin = saveMember("admin-dashboard@example.com", Role.ROLE_ADMIN)
val category = categoryRepository.saveAndFlush(Category(name = "Backend"))
val post = postRepository.saveAndFlush(
Post(
title = "Dashboard API",
content = "content",
slug = "dashboard-api",
viewCount = 5,
member = admin,
category = category
)
)
val stalePost = postRepository.saveAndFlush(
Post(
title = "Old Popular API",
content = "content",
slug = "old-popular-api",
viewCount = 10,
member = admin,
category = category
)
)
val today = LocalDate.now(ZoneId.of("Asia/Seoul"))
repeat(3) { postViewDailyStatsJdbcRepository.incrementPostView(post.id!!, today) }
repeat(2) { postViewDailyStatsJdbcRepository.incrementPostView(post.id!!, today.minusDays(1)) }
repeat(10) { postViewDailyStatsJdbcRepository.incrementPostView(stalePost.id!!, today) }
markPostAsOld(stalePost.id!!, today.minusDays(181).atStartOfDay())
val unanswered = commentRepository.saveAndFlush(
Comment(
content = "Can you explain this?",
post = post,
guestNickname = "guest",
guestPassword = "pw"
)
)
val answered = commentRepository.saveAndFlush(
Comment(
content = "Already answered",
post = post,
guestNickname = "guest2",
guestPassword = "pw"
)
)
commentRepository.saveAndFlush(
Comment(
content = "Admin answer",
post = post,
parent = answered,
member = admin
)
)
entityManager.flush()
entityManager.clear()
mockMvc.perform(
get("/api/admin/dashboard")
.param("range", "7d")
.param("timezone", "Asia/Seoul")
.with(user("admin").roles("ADMIN"))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.traffic.length()").value(7))
.andExpect(jsonPath("$.data.overview.todayViews.value").value(13))
.andExpect(jsonPath("$.data.overview.weekViews.value").value(15))
.andExpect(jsonPath("$.data.topPosts[0].slug").value(stalePost.slug))
.andExpect(jsonPath("$.data.risingPosts[0].rangeViewCount").value(10))
.andExpect(jsonPath("$.data.stalePopularPosts[0].slug").value(stalePost.slug))
.andExpect(jsonPath("$.data.categoryStats[0].name").value(category.name))
.andExpect(jsonPath("$.data.categoryStats[0].postCount").value(2))
.andExpect(jsonPath("$.data.categoryStats[0].recentViewCount").value(15))
.andExpect(jsonPath("$.data.actionItems.unansweredComments").value(1))
.andExpect(jsonPath("$.data.actionItems.stalePopularPosts").value(1))
assertEquals(unanswered.id, commentRepository.findById(unanswered.id!!).orElseThrow().id)
}
private fun saveMember(email: String, role: Role): Member {
return memberRepository.saveAndFlush(
Member(
email = email,
password = "password",
nickname = email.substringBefore("@"),
role = role,
isVerified = true
)
)
}
private fun findDailyViewCount(postId: Long, date: LocalDate): Long {
return jdbcTemplate.queryForObject(
"""
SELECT view_count
FROM post_view_daily_stats
WHERE post_id = :postId
AND stat_date = :statDate
""".trimIndent(),
MapSqlParameterSource()
.addValue("postId", postId)
.addValue("statDate", date),
Number::class.java
)!!.toLong()
}
private fun markPostAsOld(postId: Long, dateTime: LocalDateTime) {
jdbcTemplate.update(
"""
UPDATE post
SET created_at = :dateTime,
updated_at = :dateTime
WHERE id = :postId
""".trimIndent(),
MapSqlParameterSource()
.addValue("postId", postId)
.addValue("dateTime", dateTime)
)
}
}

View File

@@ -0,0 +1,36 @@
package me.wypark.blogbackend.domain.dashboard
import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.time.LocalDate
import kotlin.test.assertEquals
import kotlin.test.assertNull
class DashboardDateUtilsTest {
@Test
fun `range falls back to 30d when invalid`() {
assertEquals(DashboardRange.SEVEN_DAYS, DashboardRange.from("7d"))
assertEquals(DashboardRange.THIRTY_DAYS, DashboardRange.from("wrong"))
assertEquals(DashboardRange.THIRTY_DAYS, DashboardRange.from(null))
}
@Test
fun `date windows include today`() {
val today = LocalDate.of(2026, 5, 28)
val currentWindow = DashboardDateUtils.currentWindow(today, 7)
val previousWindow = DashboardDateUtils.previousWindow(currentWindow)
assertEquals(LocalDate.of(2026, 5, 22), currentWindow.startDate)
assertEquals(today, currentWindow.endDate)
assertEquals(LocalDate.of(2026, 5, 15), previousWindow.startDate)
assertEquals(LocalDate.of(2026, 5, 21), previousWindow.endDate)
}
@Test
fun `change rate rounds to two decimals and hides zero previous value`() {
assertEquals(BigDecimal("12.27"), DashboardDateUtils.changeRate(311, 277))
assertEquals(BigDecimal("-25.00"), DashboardDateUtils.changeRate(75, 100))
assertNull(DashboardDateUtils.changeRate(10, 0))
}
}

View File

@@ -0,0 +1,62 @@
spring:
datasource:
url: jdbc:h2:mem:blog_test;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;NON_KEYWORDS=COMMENT,USER
username: sa
password:
driver-class-name: org.h2.Driver
flyway:
enabled: false
jpa:
hibernate:
ddl-auto: create-drop
properties:
hibernate:
format_sql: false
show_sql: false
open-in-view: false
mail:
host: localhost
port: 2525
username: test
password: test
properties:
mail:
smtp:
auth: false
starttls:
enable: false
required: false
connectiontimeout: 100
timeout: 100
writetimeout: 100
data:
redis:
host: localhost
port: 6379
cloud:
aws:
s3:
bucket: test-bucket
endpoint: http://localhost:9000
path-style-access-enabled: true
credentials:
access-key: test
secret-key: test
region:
static: ap-northeast-2
stack:
auto: false
jwt:
secret: c2VjcmV0LWtleS1mb3ItdGVzdC1hZG1pbi1kYXNoYm9hcmQtMzItYnl0ZXM=
access-token-validity: 600000
refresh-token-validity: 604800000
blog:
image:
initialize-bucket: false

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level --- [%thread] %logger{35} : %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>