feat: add admin dashboard backend and deployment workflow
This commit is contained in:
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 적용으로 장기 유효기간 허용)
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
62
src/test/resources/application-test.yml
Normal file
62
src/test/resources/application-test.yml
Normal 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
|
||||
12
src/test/resources/logback-test.xml
Normal file
12
src/test/resources/logback-test.xml
Normal 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>
|
||||
Reference in New Issue
Block a user