.
This commit is contained in:
@@ -3,8 +3,10 @@ package me.wypark.blogbackend
|
|||||||
import io.github.cdimascio.dotenv.Dotenv
|
import io.github.cdimascio.dotenv.Dotenv
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||||
import org.springframework.boot.runApplication
|
import org.springframework.boot.runApplication
|
||||||
|
import org.springframework.data.web.config.EnableSpringDataWebSupport
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO)
|
||||||
class BlogBackendApplication
|
class BlogBackendApplication
|
||||||
|
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
|
|||||||
@@ -3,6 +3,21 @@ package me.wypark.blogbackend.api.dto
|
|||||||
import me.wypark.blogbackend.domain.post.Post
|
import me.wypark.blogbackend.domain.post.Post
|
||||||
import java.time.LocalDateTime
|
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(
|
data class PostResponse(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
@@ -11,11 +26,14 @@ data class PostResponse(
|
|||||||
val slug: String,
|
val slug: String,
|
||||||
val categoryName: String?,
|
val categoryName: String?,
|
||||||
val viewCount: Long,
|
val viewCount: Long,
|
||||||
val createdAt: LocalDateTime
|
val createdAt: LocalDateTime,
|
||||||
|
// 👈 [추가] 이전/다음 게시글 정보
|
||||||
|
val prevPost: PostNeighborResponse?,
|
||||||
|
val nextPost: PostNeighborResponse?
|
||||||
) {
|
) {
|
||||||
// Entity -> DTO 변환 편의 메서드
|
// Entity -> DTO 변환 편의 메서드
|
||||||
companion object {
|
companion object {
|
||||||
fun from(post: Post): PostResponse {
|
fun from(post: Post, prevPost: Post? = null, nextPost: Post? = null): PostResponse {
|
||||||
return PostResponse(
|
return PostResponse(
|
||||||
id = post.id!!,
|
id = post.id!!,
|
||||||
title = post.title,
|
title = post.title,
|
||||||
@@ -23,7 +41,9 @@ data class PostResponse(
|
|||||||
slug = post.slug,
|
slug = post.slug,
|
||||||
categoryName = post.category?.name,
|
categoryName = post.category?.name,
|
||||||
viewCount = post.viewCount,
|
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 categoryName: String?,
|
||||||
val viewCount: Long,
|
val viewCount: Long,
|
||||||
val createdAt: LocalDateTime,
|
val createdAt: LocalDateTime,
|
||||||
|
val updatedAt: LocalDateTime,
|
||||||
val content: String?
|
val content: String?
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -48,6 +69,7 @@ data class PostSummaryResponse(
|
|||||||
categoryName = post.category?.name,
|
categoryName = post.category?.name,
|
||||||
viewCount = post.viewCount,
|
viewCount = post.viewCount,
|
||||||
createdAt = post.createdAt,
|
createdAt = post.createdAt,
|
||||||
|
updatedAt = post.updatedAt,
|
||||||
content = post.content
|
content = post.content
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,3 +26,4 @@ class CorsConfig {
|
|||||||
return CorsFilter(source)
|
return CorsFilter(source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import me.wypark.blogbackend.domain.user.MemberRepository
|
|||||||
import me.wypark.blogbackend.domain.user.Role
|
import me.wypark.blogbackend.domain.user.Role
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||||
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
|
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.security.crypto.password.PasswordEncoder
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
@@ -21,7 +22,9 @@ class AuthService(
|
|||||||
private val authenticationManagerBuilder: AuthenticationManagerBuilder,
|
private val authenticationManagerBuilder: AuthenticationManagerBuilder,
|
||||||
private val jwtProvider: JwtProvider,
|
private val jwtProvider: JwtProvider,
|
||||||
private val refreshTokenRepository: RefreshTokenRepository,
|
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)
|
val authenticationToken = UsernamePasswordAuthenticationToken(request.email, request.password)
|
||||||
|
|
||||||
// 2. 실제 검증 (사용자 비밀번호 체크)
|
// 2. 실제 검증 (사용자 비밀번호 체크)
|
||||||
// authenticate() 실행 시 CustomUserDetailsService.loadUserByUsername 실행됨
|
|
||||||
val authentication = authenticationManagerBuilder.`object`.authenticate(authenticationToken)
|
val authentication = authenticationManagerBuilder.`object`.authenticate(authenticationToken)
|
||||||
|
|
||||||
// 3. 인증 정보를 기반으로 JWT 토큰 생성
|
// 3. 인증 정보를 기반으로 JWT 토큰 생성
|
||||||
@@ -93,24 +95,34 @@ class AuthService(
|
|||||||
throw IllegalArgumentException("유효하지 않은 Refresh Token입니다.")
|
throw IllegalArgumentException("유효하지 않은 Refresh Token입니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 액세스 토큰에서 User ID(Email) 가져오기 (만료된 토큰이어도 파싱 가능하도록 JwtProvider가 설계됨)
|
// 2. 액세스 토큰에서 User ID(Email) 가져오기
|
||||||
val authentication = jwtProvider.getAuthentication(accessToken)
|
// (주의: 여기서 authentication.principal은 CustomUserDetails가 아닐 수 있음)
|
||||||
|
val tempAuthentication = jwtProvider.getAuthentication(accessToken)
|
||||||
|
|
||||||
// 3. Redis에서 저장된 Refresh Token 가져오기
|
// 3. Redis에서 저장된 Refresh Token 가져오기
|
||||||
val savedRefreshToken = refreshTokenRepository.findByEmail(authentication.name)
|
val savedRefreshToken = refreshTokenRepository.findByEmail(tempAuthentication.name)
|
||||||
?: throw IllegalArgumentException("로그아웃 된 사용자입니다.")
|
?: throw IllegalArgumentException("로그아웃 된 사용자입니다.")
|
||||||
|
|
||||||
// 4. 토큰 일치 여부 확인 (재사용 방지)
|
// 4. 토큰 일치 여부 확인 (재사용 방지)
|
||||||
if (savedRefreshToken != refreshToken) {
|
if (savedRefreshToken != refreshToken) {
|
||||||
refreshTokenRepository.delete(authentication.name)
|
refreshTokenRepository.delete(tempAuthentication.name)
|
||||||
throw IllegalArgumentException("토큰 정보가 일치하지 않습니다.")
|
throw IllegalArgumentException("토큰 정보가 일치하지 않습니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 새 토큰 생성 (Rotation)
|
// ✨ 5. [수정됨] DB에서 유저 정보(CustomUserDetails) 다시 로드
|
||||||
val newTokenDto = jwtProvider.generateTokenDto(authentication)
|
// JwtProvider.generateTokenDto()가 CustomUserDetails를 필요로 하므로 필수
|
||||||
|
val userDetails = userDetailsService.loadUserByUsername(tempAuthentication.name)
|
||||||
|
|
||||||
// 6. Redis 업데이트 (한번 쓴 토큰 폐기 -> 새 토큰 저장)
|
// ✨ 로드한 userDetails로 새로운 Authentication 생성
|
||||||
refreshTokenRepository.save(authentication.name, newTokenDto.refreshToken)
|
val newAuthentication = UsernamePasswordAuthenticationToken(
|
||||||
|
userDetails, null, userDetails.authorities
|
||||||
|
)
|
||||||
|
|
||||||
|
// 6. 새 토큰 생성 (Rotation)
|
||||||
|
val newTokenDto = jwtProvider.generateTokenDto(newAuthentication)
|
||||||
|
|
||||||
|
// 7. Redis 업데이트 (한번 쓴 토큰 폐기 -> 새 토큰 저장)
|
||||||
|
refreshTokenRepository.save(newAuthentication.name, newTokenDto.refreshToken)
|
||||||
|
|
||||||
return newTokenDto
|
return newTokenDto
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,14 @@ interface PostRepository : JpaRepository<Post, Long>, PostRepositoryCustom {
|
|||||||
// 4. 특정 카테고리의 글 목록 조회
|
// 4. 특정 카테고리의 글 목록 조회
|
||||||
fun findAllByCategory(category: Category, pageable: Pageable): Page<Post>
|
fun findAllByCategory(category: Category, pageable: Pageable): Page<Post>
|
||||||
|
|
||||||
// 5. [추가] 카테고리 삭제 시 해당 카테고리(및 하위)에 속한 글들의 카테고리를 null로 변경 (미분류 처리)
|
// 5. 카테고리 삭제 시 해당 카테고리(및 하위)에 속한 글들의 카테고리를 null로 변경 (미분류 처리)
|
||||||
@Modifying(clearAutomatically = true)
|
@Modifying(clearAutomatically = true)
|
||||||
@Query("UPDATE Post p SET p.category = null WHERE p.category IN :categories")
|
@Query("UPDATE Post p SET p.category = null WHERE p.category IN :categories")
|
||||||
fun bulkUpdateCategoryToNull(@Param("categories") categories: List<Category>)
|
fun bulkUpdateCategoryToNull(@Param("categories") categories: List<Category>)
|
||||||
|
|
||||||
|
// 6. [추가] 이전 글 조회 (현재 ID보다 작은 것 중 가장 큰 ID = 바로 이전 과거 글)
|
||||||
|
fun findFirstByIdLessThanOrderByIdDesc(id: Long): Post?
|
||||||
|
|
||||||
|
// 7. [추가] 다음 글 조회 (현재 ID보다 큰 것 중 가장 작은 ID = 바로 다음 최신 글)
|
||||||
|
fun findFirstByIdGreaterThanOrderByIdAsc(id: Long): Post?
|
||||||
}
|
}
|
||||||
@@ -37,6 +37,7 @@ class PostRepositoryImpl(
|
|||||||
category.name, // 👈 post.category.name 대신 alias 사용 (NULL 안전)
|
category.name, // 👈 post.category.name 대신 alias 사용 (NULL 안전)
|
||||||
post.viewCount,
|
post.viewCount,
|
||||||
post.createdAt,
|
post.createdAt,
|
||||||
|
post.updatedAt,
|
||||||
post.content // 👈 [추가] 미리보기를 위해 본문 내용도 조회합니다.
|
post.content // 👈 [추가] 미리보기를 위해 본문 내용도 조회합니다.
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,7 +38,13 @@ class PostService(
|
|||||||
|
|
||||||
post.increaseViewCount()
|
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
|
@Transactional
|
||||||
@@ -65,7 +71,7 @@ class PostService(
|
|||||||
return postRepository.save(post).id!!
|
return postRepository.save(post).id!!
|
||||||
}
|
}
|
||||||
|
|
||||||
// 👈 [추가] 게시글 수정
|
// 게시글 수정
|
||||||
@Transactional
|
@Transactional
|
||||||
fun updatePost(id: Long, request: PostSaveRequest): Long {
|
fun updatePost(id: Long, request: PostSaveRequest): Long {
|
||||||
val post = postRepository.findByIdOrNull(id)
|
val post = postRepository.findByIdOrNull(id)
|
||||||
@@ -133,8 +139,9 @@ class PostService(
|
|||||||
var uniqueSlug = rawSlug
|
var uniqueSlug = rawSlug
|
||||||
var count = 1
|
var count = 1
|
||||||
|
|
||||||
// 본인 제외하고 체크해야 하지만, create/update 공용 로직이므로 단순 exists 체크
|
uniqueSlug = uniqueSlug.replace("?", "")
|
||||||
// (update 시 본인 slug 유지하면 exists에 걸리므로, 호출부에서 slug 변경 감지 후 호출해야 함)
|
uniqueSlug = uniqueSlug.replace(";", "")
|
||||||
|
|
||||||
while (postRepository.existsBySlug(uniqueSlug)) {
|
while (postRepository.existsBySlug(uniqueSlug)) {
|
||||||
uniqueSlug = "$rawSlug-$count"
|
uniqueSlug = "$rawSlug-$count"
|
||||||
count++
|
count++
|
||||||
|
|||||||
Reference in New Issue
Block a user