feat: 블로그 핵심 기능 구현 (댓글, 태그, 카테고리, 이미지)
[댓글 시스템] - 계층형(대댓글) 구조 및 회원/비회원 하이브리드 작성 로직 구현 - 비회원 댓글용 비밀번호 암호화 저장 (PasswordEncoder 적용) - 관리자용 댓글 강제 삭제 및 전체 댓글 모니터링(Dashboard) API 구현 [태그 & 카테고리] - N:M 태그 시스템(PostTag) 엔티티 설계 및 게시글 작성 시 자동 저장 로직 - 계층형(Tree) 카테고리 구조 구현 및 관리자 생성/삭제 API - QueryDSL 검색 조건에 태그 및 카테고리 필터링 추가 [이미지 업로드] - AWS S3 (MinIO) 연동 및 Bucket 자동 생성/Public 정책 설정 - 마크다운 에디터용 이미지 업로드 API 구현
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
package me.wypark.blogbackend.api.controller
|
||||
|
||||
import me.wypark.blogbackend.api.common.ApiResponse
|
||||
import me.wypark.blogbackend.api.dto.CategoryResponse
|
||||
import me.wypark.blogbackend.domain.category.CategoryService
|
||||
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.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/categories")
|
||||
class CategoryController(
|
||||
private val categoryService: CategoryService
|
||||
) {
|
||||
|
||||
@GetMapping
|
||||
fun getCategoryTree(): ResponseEntity<ApiResponse<List<CategoryResponse>>> {
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryService.getCategoryTree()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package me.wypark.blogbackend.api.controller
|
||||
|
||||
import me.wypark.blogbackend.api.common.ApiResponse
|
||||
import me.wypark.blogbackend.api.dto.CommentDeleteRequest
|
||||
import me.wypark.blogbackend.api.dto.CommentResponse
|
||||
import me.wypark.blogbackend.api.dto.CommentSaveRequest
|
||||
import me.wypark.blogbackend.domain.comment.CommentService
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.core.userdetails.User
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/comments")
|
||||
class CommentController(
|
||||
private val commentService: CommentService
|
||||
) {
|
||||
|
||||
// 댓글 목록 조회
|
||||
@GetMapping
|
||||
fun getComments(@RequestParam postSlug: String): ResponseEntity<ApiResponse<List<CommentResponse>>> {
|
||||
return ResponseEntity.ok(ApiResponse.success(commentService.getComments(postSlug)))
|
||||
}
|
||||
|
||||
// 댓글 작성 (회원 or 비회원)
|
||||
@PostMapping
|
||||
fun createComment(
|
||||
@RequestBody request: CommentSaveRequest,
|
||||
@AuthenticationPrincipal user: User? // 비회원이면 null이 들어옴
|
||||
): ResponseEntity<ApiResponse<Long>> {
|
||||
val email = user?.username // null이면 비회원
|
||||
val commentId = commentService.createComment(request, email)
|
||||
return ResponseEntity.ok(ApiResponse.success(commentId, "댓글이 등록되었습니다."))
|
||||
}
|
||||
|
||||
// 댓글 삭제
|
||||
@DeleteMapping("/{id}")
|
||||
fun deleteComment(
|
||||
@PathVariable id: Long,
|
||||
@RequestBody(required = false) request: CommentDeleteRequest?, // 비회원용 비밀번호 바디
|
||||
@AuthenticationPrincipal user: User?
|
||||
): ResponseEntity<ApiResponse<Nothing>> {
|
||||
val email = user?.username
|
||||
val password = request?.guestPassword
|
||||
|
||||
commentService.deleteComment(id, email, password)
|
||||
return ResponseEntity.ok(ApiResponse.success(message = "댓글이 삭제되었습니다."))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package me.wypark.blogbackend.api.controller
|
||||
|
||||
import me.wypark.blogbackend.api.common.ApiResponse
|
||||
import me.wypark.blogbackend.api.dto.PostResponse
|
||||
import me.wypark.blogbackend.api.dto.PostSummaryResponse
|
||||
import me.wypark.blogbackend.domain.post.PostService
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.data.web.PageableDefault
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/posts")
|
||||
class PostController(
|
||||
private val postService: PostService
|
||||
) {
|
||||
|
||||
// 목록 조회 (기본값: 최신순, 10개씩)
|
||||
@GetMapping
|
||||
fun getPosts(
|
||||
@RequestParam(required = false) keyword: String?,
|
||||
@RequestParam(required = false) category: String?,
|
||||
@RequestParam(required = false) tag: String?, // 👈 파라미터 추가
|
||||
@PageableDefault(size = 10, sort = ["id"], direction = Sort.Direction.DESC) pageable: Pageable
|
||||
): ResponseEntity<ApiResponse<Page<PostSummaryResponse>>> {
|
||||
|
||||
val result = postService.searchPosts(keyword, category, tag, pageable)
|
||||
return ResponseEntity.ok(ApiResponse.success(result))
|
||||
}
|
||||
|
||||
// 상세 조회 (Slug)
|
||||
@GetMapping("/{slug}")
|
||||
fun getPost(@PathVariable slug: String): ResponseEntity<ApiResponse<PostResponse>> {
|
||||
return ResponseEntity.ok(ApiResponse.success(postService.getPostBySlug(slug)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package me.wypark.blogbackend.api.controller.admin
|
||||
|
||||
import me.wypark.blogbackend.api.common.ApiResponse
|
||||
import me.wypark.blogbackend.api.dto.CategoryCreateRequest
|
||||
import me.wypark.blogbackend.domain.category.CategoryService
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/categories")
|
||||
class AdminCategoryController(
|
||||
private val categoryService: CategoryService
|
||||
) {
|
||||
|
||||
@PostMapping
|
||||
fun createCategory(@RequestBody request: CategoryCreateRequest): ResponseEntity<ApiResponse<Long>> {
|
||||
val id = categoryService.createCategory(request)
|
||||
return ResponseEntity.ok(ApiResponse.success(id, "카테고리가 생성되었습니다."))
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
fun deleteCategory(@PathVariable id: Long): ResponseEntity<ApiResponse<Nothing>> {
|
||||
categoryService.deleteCategory(id)
|
||||
return ResponseEntity.ok(ApiResponse.success(message = "카테고리가 삭제되었습니다."))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package me.wypark.blogbackend.api.controller.admin
|
||||
|
||||
import me.wypark.blogbackend.api.common.ApiResponse
|
||||
import me.wypark.blogbackend.api.dto.AdminCommentResponse
|
||||
import me.wypark.blogbackend.api.dto.CommentResponse
|
||||
import me.wypark.blogbackend.domain.comment.CommentService
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.data.web.PageableDefault
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/comments")
|
||||
class AdminCommentController(
|
||||
private val commentService: CommentService
|
||||
) {
|
||||
|
||||
// 관리자 권한으로 댓글 삭제
|
||||
@DeleteMapping("/{id}")
|
||||
fun deleteComment(@PathVariable id: Long): ResponseEntity<ApiResponse<Nothing>> {
|
||||
commentService.deleteCommentByAdmin(id)
|
||||
return ResponseEntity.ok(ApiResponse.success(message = "관리자 권한으로 댓글을 삭제했습니다."))
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
fun getAllComments(
|
||||
@PageableDefault(size = 20, sort = ["id"], direction = Sort.Direction.DESC) pageable: Pageable
|
||||
): ResponseEntity<ApiResponse<Page<AdminCommentResponse>>> {
|
||||
return ResponseEntity.ok(ApiResponse.success(commentService.getAllComments(pageable)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package me.wypark.blogbackend.api.controller.admin
|
||||
|
||||
import me.wypark.blogbackend.api.common.ApiResponse
|
||||
import me.wypark.blogbackend.domain.image.ImageService
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestPart
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/images")
|
||||
class AdminImageController(
|
||||
private val imageService: ImageService
|
||||
) {
|
||||
|
||||
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
|
||||
fun uploadImage(
|
||||
@RequestPart("image") image: MultipartFile
|
||||
): ResponseEntity<ApiResponse<String>> {
|
||||
val imageUrl = imageService.uploadImage(image)
|
||||
return ResponseEntity.ok(ApiResponse.success(imageUrl, "이미지 업로드 성공"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package me.wypark.blogbackend.api.controller.admin
|
||||
|
||||
import jakarta.validation.Valid
|
||||
import me.wypark.blogbackend.api.common.ApiResponse
|
||||
import me.wypark.blogbackend.api.dto.PostSaveRequest
|
||||
import me.wypark.blogbackend.domain.post.PostService
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.core.userdetails.User
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/posts")
|
||||
class AdminPostController(
|
||||
private val postService: PostService
|
||||
) {
|
||||
|
||||
@PostMapping
|
||||
fun createPost(
|
||||
@RequestBody @Valid request: PostSaveRequest,
|
||||
@AuthenticationPrincipal user: User
|
||||
): ResponseEntity<ApiResponse<Long>> {
|
||||
// user.username은 email입니다.
|
||||
val postId = postService.createPost(request, user.username)
|
||||
return ResponseEntity.ok(ApiResponse.success(postId, "게시글이 작성되었습니다."))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package me.wypark.blogbackend.api.dto
|
||||
|
||||
import me.wypark.blogbackend.domain.category.Category
|
||||
|
||||
// [요청] 카테고리 생성
|
||||
data class CategoryCreateRequest(
|
||||
val name: String,
|
||||
val parentId: Long? = null // null이면 최상위(Root) 카테고리
|
||||
)
|
||||
|
||||
// [응답] 카테고리 트리 구조 (재귀)
|
||||
data class CategoryResponse(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val children: List<CategoryResponse> // 자식들
|
||||
) {
|
||||
companion object {
|
||||
// Entity -> DTO 변환 (재귀 호출)
|
||||
fun from(category: Category): CategoryResponse {
|
||||
return CategoryResponse(
|
||||
id = category.id!!,
|
||||
name = category.name,
|
||||
// 자식들을 DTO로 변환하여 리스트에 담음
|
||||
children = category.children.map { from(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// [Admin용 응답] 관리자 대시보드 목록용
|
||||
data class AdminCommentResponse(
|
||||
val id: Long,
|
||||
val content: String,
|
||||
val author: String,
|
||||
val postTitle: String, // 어떤 글인지 식별
|
||||
val postSlug: String, // 클릭 시 해당 글로 이동용
|
||||
val createdAt: java.time.LocalDateTime
|
||||
) {
|
||||
companion object {
|
||||
fun from(comment: me.wypark.blogbackend.domain.comment.Comment): AdminCommentResponse {
|
||||
return AdminCommentResponse(
|
||||
id = comment.id!!,
|
||||
content = comment.content,
|
||||
author = comment.getAuthorName(),
|
||||
postTitle = comment.post.title,
|
||||
postSlug = comment.post.slug,
|
||||
createdAt = comment.createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
41
src/main/kotlin/me/wypark/blogbackend/api/dto/CommentDtos.kt
Normal file
41
src/main/kotlin/me/wypark/blogbackend/api/dto/CommentDtos.kt
Normal file
@@ -0,0 +1,41 @@
|
||||
package me.wypark.blogbackend.api.dto
|
||||
|
||||
import me.wypark.blogbackend.domain.comment.Comment
|
||||
import java.time.LocalDateTime
|
||||
|
||||
// [응답] 댓글 (계층형 구조)
|
||||
data class CommentResponse(
|
||||
val id: Long,
|
||||
val content: String,
|
||||
val author: String, // 회원 닉네임 또는 비회원 닉네임
|
||||
val createdAt: LocalDateTime,
|
||||
val children: List<CommentResponse> // 대댓글 리스트
|
||||
) {
|
||||
companion object {
|
||||
fun from(comment: Comment): CommentResponse {
|
||||
return CommentResponse(
|
||||
id = comment.id!!,
|
||||
content = comment.content,
|
||||
author = comment.getAuthorName(), // Entity에 만들어둔 편의 메서드 사용
|
||||
createdAt = comment.createdAt,
|
||||
children = comment.children.map { from(it) } // 재귀적으로 자식 변환
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// [요청] 댓글 작성
|
||||
data class CommentSaveRequest(
|
||||
val postSlug: String,
|
||||
val content: String,
|
||||
val parentId: Long? = null, // 대댓글일 경우 부모 ID
|
||||
|
||||
// 비회원 전용 필드 (회원은 null 가능)
|
||||
val guestNickname: String? = null,
|
||||
val guestPassword: String? = null
|
||||
)
|
||||
|
||||
// [요청] 댓글 삭제 (비회원용 비밀번호 전달)
|
||||
data class CommentDeleteRequest(
|
||||
val guestPassword: String? = null
|
||||
)
|
||||
62
src/main/kotlin/me/wypark/blogbackend/api/dto/PostDtos.kt
Normal file
62
src/main/kotlin/me/wypark/blogbackend/api/dto/PostDtos.kt
Normal file
@@ -0,0 +1,62 @@
|
||||
package me.wypark.blogbackend.api.dto
|
||||
|
||||
import me.wypark.blogbackend.domain.post.Post
|
||||
import java.time.LocalDateTime
|
||||
|
||||
// [응답] 게시글 상세 정보
|
||||
data class PostResponse(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val content: String,
|
||||
val slug: String,
|
||||
val categoryName: String?,
|
||||
val viewCount: Long,
|
||||
val createdAt: LocalDateTime
|
||||
) {
|
||||
// Entity -> DTO 변환 편의 메서드
|
||||
companion object {
|
||||
fun from(post: Post): PostResponse {
|
||||
return PostResponse(
|
||||
id = post.id!!,
|
||||
title = post.title,
|
||||
content = post.content,
|
||||
slug = post.slug,
|
||||
categoryName = post.category?.name,
|
||||
viewCount = post.viewCount,
|
||||
createdAt = post.createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// [응답] 게시글 목록용 (본문 제외, 가볍게)
|
||||
data class PostSummaryResponse(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val slug: String,
|
||||
val categoryName: String?,
|
||||
val viewCount: Long,
|
||||
val createdAt: LocalDateTime
|
||||
) {
|
||||
companion object {
|
||||
fun from(post: Post): PostSummaryResponse {
|
||||
return PostSummaryResponse(
|
||||
id = post.id!!,
|
||||
title = post.title,
|
||||
slug = post.slug,
|
||||
categoryName = post.category?.name,
|
||||
viewCount = post.viewCount,
|
||||
createdAt = post.createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// [요청] 게시글 작성/수정
|
||||
data class PostSaveRequest(
|
||||
val title: String,
|
||||
val content: String, // 마크다운 원문
|
||||
val slug: String,
|
||||
val categoryId: Long? = null,
|
||||
val tags: List<String> = emptyList() // 태그는 나중에 구현
|
||||
)
|
||||
Reference in New Issue
Block a user