feat: 블로그 핵심 기능 구현 (댓글, 태그, 카테고리, 이미지)

[댓글 시스템]
- 계층형(대댓글) 구조 및 회원/비회원 하이브리드 작성 로직 구현
- 비회원 댓글용 비밀번호 암호화 저장 (PasswordEncoder 적용)
- 관리자용 댓글 강제 삭제 및 전체 댓글 모니터링(Dashboard) API 구현

[태그 & 카테고리]
- N:M 태그 시스템(PostTag) 엔티티 설계 및 게시글 작성 시 자동 저장 로직
- 계층형(Tree) 카테고리 구조 구현 및 관리자 생성/삭제 API
- QueryDSL 검색 조건에 태그 및 카테고리 필터링 추가

[이미지 업로드]
- AWS S3 (MinIO) 연동 및 Bucket 자동 생성/Public 정책 설정
- 마크다운 에디터용 이미지 업로드 API 구현
This commit is contained in:
pwy3282040@msecure.co
2025-12-26 14:47:48 +09:00
parent 6fbfcaf90b
commit 60d645f47b
26 changed files with 1084 additions and 0 deletions

View File

@@ -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()))
}
}

View File

@@ -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 = "댓글이 삭제되었습니다."))
}
}

View File

@@ -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)))
}
}

View File

@@ -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 = "카테고리가 삭제되었습니다."))
}
}

View File

@@ -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)))
}
}

View File

@@ -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, "이미지 업로드 성공"))
}
}

View File

@@ -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, "게시글이 작성되었습니다."))
}
}