This commit is contained in:
ParkWonYeop
2025-12-29 14:13:51 +09:00
parent 46a8a43163
commit 0c72a603b3
7 changed files with 70 additions and 19 deletions

View File

@@ -3,8 +3,10 @@ package me.wypark.blogbackend
import io.github.cdimascio.dotenv.Dotenv
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.data.web.config.EnableSpringDataWebSupport
@SpringBootApplication
@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO)
class BlogBackendApplication
fun main(args: Array<String>) {

View File

@@ -3,6 +3,21 @@ package me.wypark.blogbackend.api.dto
import me.wypark.blogbackend.domain.post.Post
import java.time.LocalDateTime
// [응답] 인접 게시글 정보 (이전글/다음글)
data class PostNeighborResponse(
val slug: String,
val title: String
) {
companion object {
fun from(post: Post): PostNeighborResponse {
return PostNeighborResponse(
slug = post.slug,
title = post.title
)
}
}
}
// [응답] 게시글 상세 정보
data class PostResponse(
val id: Long,
@@ -11,11 +26,14 @@ data class PostResponse(
val slug: String,
val categoryName: String?,
val viewCount: Long,
val createdAt: LocalDateTime
val createdAt: LocalDateTime,
// 👈 [추가] 이전/다음 게시글 정보
val prevPost: PostNeighborResponse?,
val nextPost: PostNeighborResponse?
) {
// Entity -> DTO 변환 편의 메서드
companion object {
fun from(post: Post): PostResponse {
fun from(post: Post, prevPost: Post? = null, nextPost: Post? = null): PostResponse {
return PostResponse(
id = post.id!!,
title = post.title,
@@ -23,7 +41,9 @@ data class PostResponse(
slug = post.slug,
categoryName = post.category?.name,
viewCount = post.viewCount,
createdAt = post.createdAt
createdAt = post.createdAt,
prevPost = prevPost?.let { PostNeighborResponse.from(it) },
nextPost = nextPost?.let { PostNeighborResponse.from(it) }
)
}
}
@@ -37,6 +57,7 @@ data class PostSummaryResponse(
val categoryName: String?,
val viewCount: Long,
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime,
val content: String?
) {
companion object {
@@ -48,6 +69,7 @@ data class PostSummaryResponse(
categoryName = post.category?.name,
viewCount = post.viewCount,
createdAt = post.createdAt,
updatedAt = post.updatedAt,
content = post.content
)
}

View File

@@ -26,3 +26,4 @@ class CorsConfig {
return CorsFilter(source)
}
}

View File

@@ -9,6 +9,7 @@ import me.wypark.blogbackend.domain.user.MemberRepository
import me.wypark.blogbackend.domain.user.Role
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -21,7 +22,9 @@ class AuthService(
private val authenticationManagerBuilder: AuthenticationManagerBuilder,
private val jwtProvider: JwtProvider,
private val refreshTokenRepository: RefreshTokenRepository,
private val emailService: EmailService
private val emailService: EmailService,
// 👇 추가: DB에서 유저 정보를 다시 로드하기 위해 필요
private val userDetailsService: UserDetailsService
) {
/**
@@ -71,7 +74,6 @@ class AuthService(
val authenticationToken = UsernamePasswordAuthenticationToken(request.email, request.password)
// 2. 실제 검증 (사용자 비밀번호 체크)
// authenticate() 실행 시 CustomUserDetailsService.loadUserByUsername 실행됨
val authentication = authenticationManagerBuilder.`object`.authenticate(authenticationToken)
// 3. 인증 정보를 기반으로 JWT 토큰 생성
@@ -93,24 +95,34 @@ class AuthService(
throw IllegalArgumentException("유효하지 않은 Refresh Token입니다.")
}
// 2. 액세스 토큰에서 User ID(Email) 가져오기 (만료된 토큰이어도 파싱 가능하도록 JwtProvider가 설계됨)
val authentication = jwtProvider.getAuthentication(accessToken)
// 2. 액세스 토큰에서 User ID(Email) 가져오기
// (주의: 여기서 authentication.principal은 CustomUserDetails가 아닐 수 있음)
val tempAuthentication = jwtProvider.getAuthentication(accessToken)
// 3. Redis에서 저장된 Refresh Token 가져오기
val savedRefreshToken = refreshTokenRepository.findByEmail(authentication.name)
val savedRefreshToken = refreshTokenRepository.findByEmail(tempAuthentication.name)
?: throw IllegalArgumentException("로그아웃 된 사용자입니다.")
// 4. 토큰 일치 여부 확인 (재사용 방지)
if (savedRefreshToken != refreshToken) {
refreshTokenRepository.delete(authentication.name)
refreshTokenRepository.delete(tempAuthentication.name)
throw IllegalArgumentException("토큰 정보가 일치하지 않습니다.")
}
// 5. 새 토큰 생성 (Rotation)
val newTokenDto = jwtProvider.generateTokenDto(authentication)
// 5. [수정됨] DB에서 유저 정보(CustomUserDetails) 다시 로드
// JwtProvider.generateTokenDto()가 CustomUserDetails를 필요로 하므로 필수
val userDetails = userDetailsService.loadUserByUsername(tempAuthentication.name)
// 6. Redis 업데이트 (한번 쓴 토큰 폐기 -> 새 토큰 저장)
refreshTokenRepository.save(authentication.name, newTokenDto.refreshToken)
// ✨ 로드한 userDetails로 새로운 Authentication 생성
val newAuthentication = UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.authorities
)
// 6. 새 토큰 생성 (Rotation)
val newTokenDto = jwtProvider.generateTokenDto(newAuthentication)
// 7. Redis 업데이트 (한번 쓴 토큰 폐기 -> 새 토큰 저장)
refreshTokenRepository.save(newAuthentication.name, newTokenDto.refreshToken)
return newTokenDto
}

View File

@@ -22,8 +22,14 @@ interface PostRepository : JpaRepository<Post, Long>, PostRepositoryCustom {
// 4. 특정 카테고리의 글 목록 조회
fun findAllByCategory(category: Category, pageable: Pageable): Page<Post>
// 5. [추가] 카테고리 삭제 시 해당 카테고리(및 하위)에 속한 글들의 카테고리를 null로 변경 (미분류 처리)
// 5. 카테고리 삭제 시 해당 카테고리(및 하위)에 속한 글들의 카테고리를 null로 변경 (미분류 처리)
@Modifying(clearAutomatically = true)
@Query("UPDATE Post p SET p.category = null WHERE p.category IN :categories")
fun bulkUpdateCategoryToNull(@Param("categories") categories: List<Category>)
// 6. [추가] 이전 글 조회 (현재 ID보다 작은 것 중 가장 큰 ID = 바로 이전 과거 글)
fun findFirstByIdLessThanOrderByIdDesc(id: Long): Post?
// 7. [추가] 다음 글 조회 (현재 ID보다 큰 것 중 가장 작은 ID = 바로 다음 최신 글)
fun findFirstByIdGreaterThanOrderByIdAsc(id: Long): Post?
}

View File

@@ -37,6 +37,7 @@ class PostRepositoryImpl(
category.name, // 👈 post.category.name 대신 alias 사용 (NULL 안전)
post.viewCount,
post.createdAt,
post.updatedAt,
post.content // 👈 [추가] 미리보기를 위해 본문 내용도 조회합니다.
)
)

View File

@@ -38,7 +38,13 @@ class PostService(
post.increaseViewCount()
return PostResponse.from(post)
// 👈 [추가] 이전/다음 게시글 조회
// prevPost: 현재 글보다 ID가 작으면서 가장 가까운 글 (과거 글)
val prevPost = postRepository.findFirstByIdLessThanOrderByIdDesc(post.id!!)
// nextPost: 현재 글보다 ID가 크면서 가장 가까운 글 (최신 글)
val nextPost = postRepository.findFirstByIdGreaterThanOrderByIdAsc(post.id!!)
return PostResponse.from(post, prevPost, nextPost)
}
@Transactional
@@ -65,7 +71,7 @@ class PostService(
return postRepository.save(post).id!!
}
// 👈 [추가] 게시글 수정
// 게시글 수정
@Transactional
fun updatePost(id: Long, request: PostSaveRequest): Long {
val post = postRepository.findByIdOrNull(id)
@@ -133,8 +139,9 @@ class PostService(
var uniqueSlug = rawSlug
var count = 1
// 본인 제외하고 체크해야 하지만, create/update 공용 로직이므로 단순 exists 체크
// (update 시 본인 slug 유지하면 exists에 걸리므로, 호출부에서 slug 변경 감지 후 호출해야 함)
uniqueSlug = uniqueSlug.replace("?", "")
uniqueSlug = uniqueSlug.replace(";", "")
while (postRepository.existsBySlug(uniqueSlug)) {
uniqueSlug = "$rawSlug-$count"
count++