diff --git a/src/main/kotlin/me/wypark/blogbackend/BlogBackendApplication.kt b/src/main/kotlin/me/wypark/blogbackend/BlogBackendApplication.kt index 74957a5..b180a8b 100644 --- a/src/main/kotlin/me/wypark/blogbackend/BlogBackendApplication.kt +++ b/src/main/kotlin/me/wypark/blogbackend/BlogBackendApplication.kt @@ -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) { diff --git a/src/main/kotlin/me/wypark/blogbackend/api/dto/PostDtos.kt b/src/main/kotlin/me/wypark/blogbackend/api/dto/PostDtos.kt index f7f9046..4fbec54 100644 --- a/src/main/kotlin/me/wypark/blogbackend/api/dto/PostDtos.kt +++ b/src/main/kotlin/me/wypark/blogbackend/api/dto/PostDtos.kt @@ -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 ) } diff --git a/src/main/kotlin/me/wypark/blogbackend/core/config/CorsConfig.kt b/src/main/kotlin/me/wypark/blogbackend/core/config/CorsConfig.kt index 0b1dfc3..ce9e176 100644 --- a/src/main/kotlin/me/wypark/blogbackend/core/config/CorsConfig.kt +++ b/src/main/kotlin/me/wypark/blogbackend/core/config/CorsConfig.kt @@ -25,4 +25,5 @@ class CorsConfig { source.registerCorsConfiguration("/api/**", config) return CorsFilter(source) } -} \ No newline at end of file +} + diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/auth/AuthService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/auth/AuthService.kt index f60e0f1..6a40ac2 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/auth/AuthService.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/auth/AuthService.kt @@ -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 } diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepository.kt b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepository.kt index 1f7b7d1..c22cac3 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepository.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepository.kt @@ -22,8 +22,14 @@ interface PostRepository : JpaRepository, PostRepositoryCustom { // 4. 특정 카테고리의 글 목록 조회 fun findAllByCategory(category: Category, pageable: Pageable): Page - // 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) + + // 6. [추가] 이전 글 조회 (현재 ID보다 작은 것 중 가장 큰 ID = 바로 이전 과거 글) + fun findFirstByIdLessThanOrderByIdDesc(id: Long): Post? + + // 7. [추가] 다음 글 조회 (현재 ID보다 큰 것 중 가장 작은 ID = 바로 다음 최신 글) + fun findFirstByIdGreaterThanOrderByIdAsc(id: Long): Post? } \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryImpl.kt b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryImpl.kt index e057986..be51cbf 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryImpl.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryImpl.kt @@ -37,6 +37,7 @@ class PostRepositoryImpl( category.name, // 👈 post.category.name 대신 alias 사용 (NULL 안전) post.viewCount, post.createdAt, + post.updatedAt, post.content // 👈 [추가] 미리보기를 위해 본문 내용도 조회합니다. ) ) diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostService.kt index 6a21f9a..9b8ba10 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostService.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostService.kt @@ -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++