Compare commits
4 Commits
feature/au
...
feature/po
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
884853586d | ||
|
|
ef6ffa5670 | ||
|
|
2f6cb41764 | ||
|
|
60d645f47b |
@@ -80,5 +80,5 @@ $ docker-compose logs -f blog-api
|
|||||||
|
|
||||||
---
|
---
|
||||||
## 📝 관련 문서
|
## 📝 관련 문서
|
||||||
- [**ENDPOINT**](https://affine.wypark.me/workspace/f85df0c4-a315-4166-94a8-6558cdafff1d/p13joh_beKNJ6R-I9KrFW)
|
- [**ENDPOINT**](https://affine.wypark.me/workspace/f85df0c4-a315-4166-94a8-6558cdafff1d/_csY9ZzOnSbt4bTnIA_9w)
|
||||||
- [**API 명세서**](https://affine.wypark.me/workspace/f85df0c4-a315-4166-94a8-6558cdafff1d/7fRNXK9utxG4mcekWivGK)
|
- [**API 명세서**](https://affine.wypark.me/workspace/f85df0c4-a315-4166-94a8-6558cdafff1d/9axPww7llEcSH3nOZzi-m)
|
||||||
@@ -15,10 +15,10 @@ services:
|
|||||||
- SPRING_DATA_REDIS_HOST=redis
|
- SPRING_DATA_REDIS_HOST=redis
|
||||||
- SPRING_DATA_REDIS_PORT=6379
|
- SPRING_DATA_REDIS_PORT=6379
|
||||||
# AWS S3 / MinIO (Docker 내부 통신용)
|
# AWS S3 / MinIO (Docker 내부 통신용)
|
||||||
- CLOUD_AWS_CREDENTIALS_ACCESS_KEY=admin
|
- SPRING_CLOUD_AWS_CREDENTIALS_ACCESS_KEY=admin
|
||||||
- CLOUD_AWS_CREDENTIALS_SECRET_KEY=password
|
- SPRING_CLOUD_AWS_CREDENTIALS_SECRET_KEY=password
|
||||||
- CLOUD_AWS_REGION_STATIC=ap-northeast-2
|
- SPRING_CLOUD_AWS_REGION_STATIC=ap-northeast-2
|
||||||
- CLOUD_AWS_S3_ENDPOINT=http://minio:9000
|
- SPRING_CLOUD_AWS_S3_ENDPOINT=http://minio:9000
|
||||||
# SMTP 메일 설정
|
# SMTP 메일 설정
|
||||||
- SPRING_MAIL_HOST=smtp.gmail.com
|
- SPRING_MAIL_HOST=smtp.gmail.com
|
||||||
- SPRING_MAIL_PORT=587
|
- SPRING_MAIL_PORT=587
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/main/kotlin/me/wypark/blogbackend/api/dto/CommentDtos.kt
Normal file
46
src/main/kotlin/me/wypark/blogbackend/api/dto/CommentDtos.kt
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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 isPostAuthor: Boolean, // 👈 [추가] 게시글 작성자 여부
|
||||||
|
val createdAt: LocalDateTime,
|
||||||
|
val children: List<CommentResponse>
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(comment: Comment): CommentResponse {
|
||||||
|
// 게시글 작성자 ID와 댓글 작성자(회원) ID가 같은지 비교
|
||||||
|
// comment.member는 비회원일 경우 null이므로 안전하게 처리됨
|
||||||
|
val isAuthor = comment.member?.id == comment.post.member.id
|
||||||
|
|
||||||
|
return CommentResponse(
|
||||||
|
id = comment.id!!,
|
||||||
|
content = comment.content,
|
||||||
|
author = comment.getAuthorName(),
|
||||||
|
isPostAuthor = isAuthor, // 👈 계산된 값 주입
|
||||||
|
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? = null,
|
||||||
|
val categoryId: Long? = null,
|
||||||
|
val tags: List<String> = emptyList() // 태그는 나중에 구현
|
||||||
|
)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package me.wypark.blogbackend.core.config
|
||||||
|
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import jakarta.persistence.EntityManager
|
||||||
|
import jakarta.persistence.PersistenceContext
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Configuration
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
class QueryDslConfig(
|
||||||
|
@PersistenceContext
|
||||||
|
private val entityManager: EntityManager
|
||||||
|
) {
|
||||||
|
@Bean
|
||||||
|
fun jpaQueryFactory(): JPAQueryFactory {
|
||||||
|
return JPAQueryFactory(entityManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package me.wypark.blogbackend.core.config
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Configuration
|
||||||
|
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials
|
||||||
|
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider
|
||||||
|
import software.amazon.awssdk.regions.Region
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
class S3Config(
|
||||||
|
@Value("\${spring.cloud.aws.credentials.access-key:admin}") private val accessKey: String,
|
||||||
|
@Value("\${spring.cloud.aws.credentials.secret-key:password}") private val secretKey: String,
|
||||||
|
@Value("\${spring.cloud.aws.region.static:ap-northeast-2}") private val regionStr: String, // 변수명 regionStr 확인
|
||||||
|
@Value("\${spring.cloud.aws.s3.endpoint:http://minio:9000}") private val endpoint: String
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun s3Client(): S3Client {
|
||||||
|
return S3Client.builder()
|
||||||
|
.region(Region.of(regionStr))
|
||||||
|
.credentialsProvider(
|
||||||
|
StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey))
|
||||||
|
)
|
||||||
|
.endpointOverride(URI.create(endpoint)) // MinIO 주소
|
||||||
|
.forcePathStyle(true) // MinIO 필수 설정
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ class SecurityConfig(
|
|||||||
auth.requestMatchers("/api/auth/**").permitAll()
|
auth.requestMatchers("/api/auth/**").permitAll()
|
||||||
auth.requestMatchers(HttpMethod.GET, "/api/posts/**", "/api/categories/**", "/api/tags/**").permitAll()
|
auth.requestMatchers(HttpMethod.GET, "/api/posts/**", "/api/categories/**", "/api/tags/**").permitAll()
|
||||||
auth.requestMatchers(HttpMethod.POST, "/api/comments/**").permitAll() // 비회원 댓글 허용
|
auth.requestMatchers(HttpMethod.POST, "/api/comments/**").permitAll() // 비회원 댓글 허용
|
||||||
auth.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
auth.requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN")
|
||||||
auth.anyRequest().authenticated()
|
auth.anyRequest().authenticated()
|
||||||
}
|
}
|
||||||
// 필터 등록: UsernamePasswordAuthenticationFilter 앞에 JwtFilter를 실행
|
// 필터 등록: UsernamePasswordAuthenticationFilter 앞에 JwtFilter를 실행
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package me.wypark.blogbackend.domain.category
|
||||||
|
|
||||||
|
import jakarta.persistence.*
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
class Category(
|
||||||
|
@Column(nullable = false)
|
||||||
|
var name: String,
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "parent_id")
|
||||||
|
var parent: Category? = null, // 부모 카테고리 (없으면 최상위)
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "parent", cascade = [CascadeType.ALL])
|
||||||
|
val children: MutableList<Category> = mutableListOf() // 자식 카테고리들
|
||||||
|
) {
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
val id: Long? = null
|
||||||
|
|
||||||
|
// 연관관계 편의 메서드 (부모-자식 연결)
|
||||||
|
fun addChild(child: Category) {
|
||||||
|
this.children.add(child)
|
||||||
|
child.parent = this
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package me.wypark.blogbackend.domain.category
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.data.jpa.repository.Query
|
||||||
|
|
||||||
|
interface CategoryRepository : JpaRepository<Category, Long> {
|
||||||
|
|
||||||
|
// 부모가 없는 최상위 카테고리들만 조회 (이걸 가져오면 자식들은 줄줄이 딸려옵니다)
|
||||||
|
@Query("SELECT DISTINCT c FROM Category c LEFT JOIN FETCH c.children WHERE c.parent IS NULL")
|
||||||
|
fun findAllRoots(): List<Category>
|
||||||
|
|
||||||
|
// 카테고리 이름 중복 검사 (같은 레벨에서 중복 방지용)
|
||||||
|
fun existsByName(name: String): Boolean
|
||||||
|
|
||||||
|
// 이름으로 찾기 (게시글 작성 시 필요)
|
||||||
|
fun findByName(name: String): Category?
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package me.wypark.blogbackend.domain.category
|
||||||
|
|
||||||
|
import me.wypark.blogbackend.api.dto.CategoryCreateRequest
|
||||||
|
import me.wypark.blogbackend.api.dto.CategoryResponse
|
||||||
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
class CategoryService(
|
||||||
|
private val categoryRepository: CategoryRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Public] 카테고리 트리 조회
|
||||||
|
* 최상위(Root)만 조회하면, Entity 설정을 통해 자식들도 딸려옵니다.
|
||||||
|
*/
|
||||||
|
fun getCategoryTree(): List<CategoryResponse> {
|
||||||
|
val roots = categoryRepository.findAllRoots()
|
||||||
|
return roots.map { CategoryResponse.from(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Admin] 카테고리 생성
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
fun createCategory(request: CategoryCreateRequest): Long {
|
||||||
|
// 이름 중복 체크 (선택 사항이지만 추천)
|
||||||
|
if (categoryRepository.existsByName(request.name)) {
|
||||||
|
throw IllegalArgumentException("이미 존재하는 카테고리 이름입니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부모 카테고리 확인
|
||||||
|
val parent = request.parentId?.let {
|
||||||
|
categoryRepository.findByIdOrNull(it)
|
||||||
|
?: throw IllegalArgumentException("부모 카테고리가 존재하지 않습니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카테고리 생성
|
||||||
|
val category = Category(
|
||||||
|
name = request.name,
|
||||||
|
parent = parent
|
||||||
|
)
|
||||||
|
|
||||||
|
// 부모와 연결 (연관관계 편의 메서드 활용)
|
||||||
|
parent?.addChild(category)
|
||||||
|
|
||||||
|
return categoryRepository.save(category).id!!
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Admin] 카테고리 삭제 (선택 구현)
|
||||||
|
* 자식이 있는 카테고리를 지울 때 어떻게 할지(전부 삭제? 연결 해제?) 정책 결정 필요
|
||||||
|
* 여기서는 일단 간단하게 id로 삭제만 구현합니다.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
fun deleteCategory(id: Long) {
|
||||||
|
categoryRepository.deleteById(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package me.wypark.blogbackend.domain.comment
|
||||||
|
|
||||||
|
import jakarta.persistence.*
|
||||||
|
import me.wypark.blogbackend.domain.common.BaseTimeEntity
|
||||||
|
import me.wypark.blogbackend.domain.post.Post
|
||||||
|
import me.wypark.blogbackend.domain.user.Member
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
class Comment(
|
||||||
|
@Column(nullable = false, columnDefinition = "TEXT")
|
||||||
|
var content: String,
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "post_id")
|
||||||
|
val post: Post,
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "parent_id")
|
||||||
|
var parent: Comment? = null, // 대댓글용 부모 댓글
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "parent", cascade = [CascadeType.ALL], orphanRemoval = true)
|
||||||
|
val children: MutableList<Comment> = mutableListOf(),
|
||||||
|
|
||||||
|
// --- 1. 회원일 경우 ---
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "member_id")
|
||||||
|
val member: Member? = null,
|
||||||
|
|
||||||
|
// --- 2. 비회원일 경우 ---
|
||||||
|
@Column
|
||||||
|
var guestNickname: String? = null,
|
||||||
|
|
||||||
|
@Column
|
||||||
|
var guestPassword: String? = null // 암호화해서 저장 권장
|
||||||
|
|
||||||
|
) : BaseTimeEntity() {
|
||||||
|
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
val id: Long? = null
|
||||||
|
|
||||||
|
// 댓글 작성자 이름 가져오기 (회원이면 닉네임, 비회원이면 입력한 이름)
|
||||||
|
fun getAuthorName(): String {
|
||||||
|
return member?.nickname ?: guestNickname ?: "알 수 없음"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비회원 비밀번호 검증
|
||||||
|
fun matchGuestPassword(password: String): Boolean {
|
||||||
|
return this.guestPassword == password
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package me.wypark.blogbackend.domain.comment
|
||||||
|
|
||||||
|
import me.wypark.blogbackend.domain.post.Post
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
|
||||||
|
interface CommentRepository : JpaRepository<Comment, Long> {
|
||||||
|
|
||||||
|
// 특정 게시글의 모든 댓글 조회 (최상위 부모 댓글 기준 + 작성순)
|
||||||
|
// 자식 댓글은 Entity의 children 필드를 통해 가져오거나, BatchSize로 최적화합니다.
|
||||||
|
fun findAllByPostAndParentIsNullOrderByCreatedAtAsc(post: Post): List<Comment>
|
||||||
|
|
||||||
|
// 게시글 삭제 시 관련 댓글 전체 삭제용
|
||||||
|
fun deleteAllByPost(post: Post)
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package me.wypark.blogbackend.domain.comment
|
||||||
|
|
||||||
|
import me.wypark.blogbackend.api.dto.AdminCommentResponse
|
||||||
|
import me.wypark.blogbackend.api.dto.CommentResponse
|
||||||
|
import me.wypark.blogbackend.api.dto.CommentSaveRequest
|
||||||
|
import me.wypark.blogbackend.domain.post.PostRepository
|
||||||
|
import me.wypark.blogbackend.domain.user.MemberRepository
|
||||||
|
import org.springframework.data.domain.Page
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
class CommentService(
|
||||||
|
private val commentRepository: CommentRepository,
|
||||||
|
private val postRepository: PostRepository,
|
||||||
|
private val memberRepository: MemberRepository,
|
||||||
|
private val passwordEncoder: PasswordEncoder // 비밀번호 암호화용
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Public] 특정 게시글의 댓글 목록 조회 (계층형)
|
||||||
|
*/
|
||||||
|
fun getComments(postSlug: String): List<CommentResponse> {
|
||||||
|
val post = postRepository.findBySlug(postSlug)
|
||||||
|
?: throw IllegalArgumentException("존재하지 않는 게시글입니다.")
|
||||||
|
|
||||||
|
// 최상위(부모가 null) 댓글만 가져오면, Entity 설정에 의해 자식들은 자동으로 딸려옴
|
||||||
|
val roots = commentRepository.findAllByPostAndParentIsNullOrderByCreatedAtAsc(post)
|
||||||
|
|
||||||
|
return roots.map { CommentResponse.from(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Hybrid] 댓글 작성 (회원/비회원 공용)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
fun createComment(request: CommentSaveRequest, userEmail: String?): Long {
|
||||||
|
// 1. 게시글 조회
|
||||||
|
val post = postRepository.findBySlug(request.postSlug)
|
||||||
|
?: throw IllegalArgumentException("존재하지 않는 게시글입니다.")
|
||||||
|
|
||||||
|
// 2. 부모 댓글 조회 (대댓글인 경우)
|
||||||
|
val parent = request.parentId?.let {
|
||||||
|
commentRepository.findByIdOrNull(it)
|
||||||
|
?: throw IllegalArgumentException("부모 댓글이 존재하지 않습니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 회원/비회원 구분 로직
|
||||||
|
val comment = if (userEmail != null) {
|
||||||
|
// [회원] DB에서 회원 정보 조회 후 연결
|
||||||
|
val member = memberRepository.findByEmail(userEmail)
|
||||||
|
?: throw IllegalArgumentException("회원 정보를 찾을 수 없습니다.")
|
||||||
|
|
||||||
|
Comment(
|
||||||
|
content = request.content,
|
||||||
|
post = post,
|
||||||
|
parent = parent,
|
||||||
|
member = member // 회원 연결
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// [비회원] 닉네임/비밀번호 필수 체크
|
||||||
|
if (request.guestNickname.isNullOrBlank() || request.guestPassword.isNullOrBlank()) {
|
||||||
|
throw IllegalArgumentException("비회원은 닉네임과 비밀번호가 필수입니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Comment(
|
||||||
|
content = request.content,
|
||||||
|
post = post,
|
||||||
|
parent = parent,
|
||||||
|
guestNickname = request.guestNickname,
|
||||||
|
guestPassword = passwordEncoder.encode(request.guestPassword)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 부모가 있다면 연결 (양방향 편의)
|
||||||
|
parent?.children?.add(comment)
|
||||||
|
|
||||||
|
return commentRepository.save(comment).id!!
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun deleteComment(commentId: Long, userEmail: String?, guestPassword: String?) {
|
||||||
|
val comment = commentRepository.findByIdOrNull(commentId)
|
||||||
|
?: throw IllegalArgumentException("존재하지 않는 댓글입니다.")
|
||||||
|
|
||||||
|
// 권한 검증
|
||||||
|
if (userEmail != null) {
|
||||||
|
// [회원] 본인 댓글인지 확인 (이메일 비교)
|
||||||
|
if (comment.member?.email != userEmail) {
|
||||||
|
throw IllegalArgumentException("본인의 댓글만 삭제할 수 있습니다.")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// [비회원] 비밀번호 일치 확인
|
||||||
|
if (comment.guestPassword == null || guestPassword == null ||
|
||||||
|
!passwordEncoder.matches(guestPassword, comment.guestPassword)) {
|
||||||
|
throw IllegalArgumentException("비밀번호가 일치하지 않습니다.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 삭제 진행
|
||||||
|
commentRepository.delete(comment)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun deleteCommentByAdmin(commentId: Long) {
|
||||||
|
val comment = commentRepository.findByIdOrNull(commentId)
|
||||||
|
?: throw IllegalArgumentException("존재하지 않는 댓글입니다.")
|
||||||
|
|
||||||
|
commentRepository.delete(comment)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAllComments(pageable: Pageable): Page<AdminCommentResponse> {
|
||||||
|
return commentRepository.findAll(pageable)
|
||||||
|
.map { AdminCommentResponse.from(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package me.wypark.blogbackend.domain.image
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
import software.amazon.awssdk.core.sync.RequestBody
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client
|
||||||
|
import software.amazon.awssdk.services.s3.model.GetUrlRequest
|
||||||
|
import software.amazon.awssdk.services.s3.model.PutObjectRequest
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class ImageService(
|
||||||
|
private val s3Client: S3Client,
|
||||||
|
@Value("\${spring.cloud.aws.s3.endpoint:http://minio:9000}") private val endpoint: String
|
||||||
|
) {
|
||||||
|
private val bucketName = "blog-images" // 버킷 이름
|
||||||
|
|
||||||
|
init {
|
||||||
|
createBucketIfNotExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun uploadImage(file: MultipartFile): String {
|
||||||
|
// 1. 파일명 중복 방지 (UUID 사용)
|
||||||
|
val originalName = file.originalFilename ?: "image.jpg"
|
||||||
|
val ext = originalName.substringAfterLast(".", "jpg")
|
||||||
|
val fileName = "${UUID.randomUUID()}.$ext"
|
||||||
|
|
||||||
|
// 2. S3(MinIO)로 업로드
|
||||||
|
val putObjectRequest = PutObjectRequest.builder()
|
||||||
|
.bucket(bucketName)
|
||||||
|
.key(fileName)
|
||||||
|
.contentType(file.contentType)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(file.inputStream, file.size))
|
||||||
|
|
||||||
|
// 3. 접속 가능한 URL 반환
|
||||||
|
// 로컬 개발 환경에서는 localhost 주소를 직접 조합해서 줍니다.
|
||||||
|
// 배포 시에는 실제 도메인이나 CloudFront 주소로 변경해야 합니다.
|
||||||
|
return "$endpoint/$bucketName/$fileName"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createBucketIfNotExists() {
|
||||||
|
try {
|
||||||
|
// 버킷 존재 여부 확인 (없으면 에러 발생하므로 try-catch)
|
||||||
|
s3Client.headBucket { it.bucket(bucketName) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// 버킷 생성
|
||||||
|
s3Client.createBucket { it.bucket(bucketName) }
|
||||||
|
|
||||||
|
// ⭐ 버킷을 Public(공개)으로 설정 (이미지 조회를 위해 필수)
|
||||||
|
val policy = """
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": { "AWS": ["*"] },
|
||||||
|
"Action": ["s3:GetObject"],
|
||||||
|
"Resource": ["arn:aws:s3:::$bucketName/*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
s3Client.putBucketPolicy {
|
||||||
|
it.bucket(bucketName).policy(policy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/main/kotlin/me/wypark/blogbackend/domain/post/Post.kt
Normal file
57
src/main/kotlin/me/wypark/blogbackend/domain/post/Post.kt
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package me.wypark.blogbackend.domain.post
|
||||||
|
|
||||||
|
import jakarta.persistence.*
|
||||||
|
import me.wypark.blogbackend.domain.category.Category
|
||||||
|
import me.wypark.blogbackend.domain.common.BaseTimeEntity
|
||||||
|
import me.wypark.blogbackend.domain.tag.PostTag
|
||||||
|
import me.wypark.blogbackend.domain.user.Member
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
class Post(
|
||||||
|
@Column(nullable = false)
|
||||||
|
var title: String,
|
||||||
|
|
||||||
|
// 마크다운 본문 (대용량 저장을 위해 TEXT 타입 지정)
|
||||||
|
@Column(nullable = false, columnDefinition = "TEXT")
|
||||||
|
var content: String,
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true)
|
||||||
|
var slug: String, // URL용 제목 (예: my-first-post)
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "member_id")
|
||||||
|
val member: Member, // 작성자 (관리자)
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "category_id")
|
||||||
|
var category: Category? = null // 카테고리 (없을 수도 있음)
|
||||||
|
|
||||||
|
) : BaseTimeEntity() { // 생성일, 수정일 자동 관리
|
||||||
|
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
val id: Long? = null
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
var viewCount: Long = 0
|
||||||
|
|
||||||
|
// 조회수 증가
|
||||||
|
fun increaseViewCount() {
|
||||||
|
this.viewCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 게시글 수정
|
||||||
|
fun update(title: String, content: String, slug: String, category: Category?) {
|
||||||
|
this.title = title
|
||||||
|
this.content = content
|
||||||
|
this.slug = slug
|
||||||
|
this.category = category
|
||||||
|
}
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "post", cascade = [CascadeType.ALL], orphanRemoval = true)
|
||||||
|
var tags: MutableList<PostTag> = mutableListOf()
|
||||||
|
|
||||||
|
fun addTags(newTags: List<PostTag>) {
|
||||||
|
this.tags.clear()
|
||||||
|
this.tags.addAll(newTags)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package me.wypark.blogbackend.domain.post
|
||||||
|
|
||||||
|
import me.wypark.blogbackend.domain.category.Category
|
||||||
|
import org.springframework.data.domain.Page
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
|
||||||
|
interface PostRepository : JpaRepository<Post, Long>, PostRepositoryCustom{
|
||||||
|
|
||||||
|
// 1. Slug로 상세 조회 (URL이 깔끔해짐)
|
||||||
|
fun findBySlug(slug: String): Post?
|
||||||
|
|
||||||
|
// 2. Slug 중복 검사 (글 작성/수정 시 필수)
|
||||||
|
fun existsBySlug(slug: String): Boolean
|
||||||
|
|
||||||
|
// 3. 페이징된 목록 조회 (최신순 등은 Pageable로 해결)
|
||||||
|
override fun findAll(pageable: Pageable): Page<Post>
|
||||||
|
|
||||||
|
// 4. 특정 카테고리의 글 목록 조회
|
||||||
|
fun findAllByCategory(category: Category, pageable: Pageable): Page<Post>
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package me.wypark.blogbackend.domain.post
|
||||||
|
|
||||||
|
import me.wypark.blogbackend.api.dto.PostSummaryResponse
|
||||||
|
import org.springframework.data.domain.Page
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
|
||||||
|
interface PostRepositoryCustom {
|
||||||
|
fun search(keyword: String?, categoryName: String?, tagName: String?, pageable: Pageable): Page<PostSummaryResponse>
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package me.wypark.blogbackend.domain.post
|
||||||
|
|
||||||
|
import com.querydsl.core.BooleanBuilder
|
||||||
|
import com.querydsl.core.types.Order
|
||||||
|
import com.querydsl.core.types.OrderSpecifier
|
||||||
|
import com.querydsl.core.types.Projections
|
||||||
|
import com.querydsl.core.types.dsl.BooleanExpression
|
||||||
|
import com.querydsl.core.types.dsl.PathBuilder
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import me.wypark.blogbackend.api.dto.PostSummaryResponse
|
||||||
|
import me.wypark.blogbackend.domain.post.QPost.post
|
||||||
|
import org.springframework.data.domain.Page
|
||||||
|
import org.springframework.data.domain.PageImpl
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.data.domain.Sort
|
||||||
|
import me.wypark.blogbackend.domain.tag.QPostTag.postTag
|
||||||
|
import me.wypark.blogbackend.domain.tag.QTag.tag
|
||||||
|
|
||||||
|
class PostRepositoryImpl(
|
||||||
|
private val queryFactory: JPAQueryFactory
|
||||||
|
) : PostRepositoryCustom {
|
||||||
|
override fun search(keyword: String?, categoryName: String?, tagName: String?, pageable: Pageable): Page<PostSummaryResponse> {
|
||||||
|
// 1. 동적 필터링 조건
|
||||||
|
val builder = BooleanBuilder()
|
||||||
|
builder.and(containsKeyword(keyword))
|
||||||
|
builder.and(eqCategory(categoryName))
|
||||||
|
builder.and(eqTagName(tagName)) // 👈 태그 조건 추가
|
||||||
|
|
||||||
|
// 2. 쿼리 실행 (Join 추가)
|
||||||
|
val query = queryFactory
|
||||||
|
.select(
|
||||||
|
Projections.constructor(
|
||||||
|
PostSummaryResponse::class.java,
|
||||||
|
post.id,
|
||||||
|
post.title,
|
||||||
|
post.slug,
|
||||||
|
post.category.name,
|
||||||
|
post.viewCount,
|
||||||
|
post.createdAt
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(post)
|
||||||
|
// 👇 태그 검색을 위해 테이블 Join
|
||||||
|
.leftJoin(post.tags, postTag)
|
||||||
|
.leftJoin(postTag.tag, tag)
|
||||||
|
.where(builder)
|
||||||
|
.distinct() // ⭐ 중요: 하나의 글에 태그가 여러 개면 글이 중복 조회될 수 있어서 제거
|
||||||
|
.offset(pageable.offset)
|
||||||
|
.limit(pageable.pageSize.toLong())
|
||||||
|
|
||||||
|
// 3. 정렬 적용
|
||||||
|
for (order in getOrderSpecifiers(pageable)) {
|
||||||
|
query.orderBy(order)
|
||||||
|
}
|
||||||
|
|
||||||
|
val content = query.fetch()
|
||||||
|
|
||||||
|
// 4. 전체 개수 (Count 쿼리에도 Join 필요)
|
||||||
|
val total = queryFactory
|
||||||
|
.select(post.countDistinct()) // ⭐ 개수 셀 때도 중복 제거
|
||||||
|
.from(post)
|
||||||
|
.leftJoin(post.tags, postTag)
|
||||||
|
.leftJoin(postTag.tag, tag)
|
||||||
|
.where(builder)
|
||||||
|
.fetchOne() ?: 0L
|
||||||
|
|
||||||
|
return PageImpl(content, pageable, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 조건 메서드들 ---
|
||||||
|
|
||||||
|
// 검색어 (제목 or 본문)
|
||||||
|
private fun containsKeyword(keyword: String?): BooleanBuilder {
|
||||||
|
val builder = BooleanBuilder()
|
||||||
|
if (!keyword.isNullOrBlank()) {
|
||||||
|
builder.or(post.title.containsIgnoreCase(keyword))
|
||||||
|
builder.or(post.content.containsIgnoreCase(keyword))
|
||||||
|
}
|
||||||
|
return builder
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카테고리 일치 (카테고리명이 없으면 무시)
|
||||||
|
private fun eqCategory(categoryName: String?): BooleanExpression? {
|
||||||
|
if (categoryName.isNullOrBlank()) return null
|
||||||
|
return post.category.name.eq(categoryName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun eqTagName(tagName: String?): BooleanExpression? {
|
||||||
|
if (tagName.isNullOrBlank()) return null
|
||||||
|
return tag.name.eq(tagName) // 태그 이름은 정확히 일치해야 함
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pageable Sort -> QueryDSL OrderSpecifier 변환
|
||||||
|
private fun getOrderSpecifiers(pageable: Pageable): List<OrderSpecifier<*>> {
|
||||||
|
val orders = mutableListOf<OrderSpecifier<*>>()
|
||||||
|
|
||||||
|
if (!pageable.sort.isEmpty) {
|
||||||
|
for (order in pageable.sort) {
|
||||||
|
val direction = if (order.direction.isAscending) Order.ASC else Order.DESC
|
||||||
|
|
||||||
|
// 들어온 정렬 기준값(property)에 따라 QClass 필드 매핑
|
||||||
|
when (order.property) {
|
||||||
|
"viewCount" -> orders.add(OrderSpecifier(direction, post.viewCount)) // 인기순
|
||||||
|
"createdAt" -> orders.add(OrderSpecifier(direction, post.createdAt)) // 최신순
|
||||||
|
"id" -> orders.add(OrderSpecifier(direction, post.id))
|
||||||
|
else -> orders.add(OrderSpecifier(Order.DESC, post.id)) // 기본
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return orders
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/main/kotlin/me/wypark/blogbackend/domain/post/PostService.kt
Normal file
100
src/main/kotlin/me/wypark/blogbackend/domain/post/PostService.kt
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package me.wypark.blogbackend.domain.post
|
||||||
|
|
||||||
|
import me.wypark.blogbackend.api.dto.PostResponse
|
||||||
|
import me.wypark.blogbackend.api.dto.PostSaveRequest
|
||||||
|
import me.wypark.blogbackend.api.dto.PostSummaryResponse
|
||||||
|
import me.wypark.blogbackend.domain.category.CategoryRepository
|
||||||
|
import me.wypark.blogbackend.domain.tag.PostTag
|
||||||
|
import me.wypark.blogbackend.domain.tag.Tag
|
||||||
|
import me.wypark.blogbackend.domain.tag.TagRepository
|
||||||
|
import me.wypark.blogbackend.domain.user.MemberRepository
|
||||||
|
import org.springframework.data.domain.Page
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
class PostService(
|
||||||
|
private val postRepository: PostRepository,
|
||||||
|
private val categoryRepository: CategoryRepository,
|
||||||
|
private val memberRepository: MemberRepository,
|
||||||
|
private val tagRepository: TagRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Public] 전체 게시글 목록 조회 (페이징)
|
||||||
|
*/
|
||||||
|
fun getPosts(pageable: Pageable): Page<PostSummaryResponse> {
|
||||||
|
return postRepository.findAll(pageable)
|
||||||
|
.map { PostSummaryResponse.from(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Public] 게시글 상세 조회 (Slug 기반) + 조회수 증가
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
fun getPostBySlug(slug: String): PostResponse {
|
||||||
|
val post = postRepository.findBySlug(slug)
|
||||||
|
?: throw IllegalArgumentException("해당 게시글을 찾을 수 없습니다: $slug")
|
||||||
|
|
||||||
|
post.increaseViewCount() // 조회수 1 증가 (Dirty Checking)
|
||||||
|
|
||||||
|
return PostResponse.from(post)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Admin] 게시글 작성
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
fun createPost(request: PostSaveRequest, email: String): Long {
|
||||||
|
val member = memberRepository.findByEmail(email)
|
||||||
|
?: throw IllegalArgumentException("회원 없음")
|
||||||
|
|
||||||
|
val category = request.categoryId?.let { categoryRepository.findByIdOrNull(it) }
|
||||||
|
|
||||||
|
val rawSlug = if (!request.slug.isNullOrBlank()) {
|
||||||
|
request.slug
|
||||||
|
} else {
|
||||||
|
request.title.trim().replace("\\s+".toRegex(), "-").lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// (2) DB 중복 검사: 중복되면 -1, -2, -3... 붙여나감
|
||||||
|
var uniqueSlug = rawSlug
|
||||||
|
var count = 1
|
||||||
|
|
||||||
|
while (postRepository.existsBySlug(uniqueSlug)) {
|
||||||
|
uniqueSlug = "$rawSlug-$count"
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
|
||||||
|
// 2. 게시글 객체 생성 (uniqueSlug 사용)
|
||||||
|
val post = Post(
|
||||||
|
title = request.title,
|
||||||
|
content = request.content,
|
||||||
|
slug = uniqueSlug, // 👈 중복 처리된 슬러그
|
||||||
|
member = member,
|
||||||
|
category = category
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. 태그 처리 (작성하신 로직 그대로 활용)
|
||||||
|
// 리스트를 순회하며 없으면 저장(save), 있으면 조회(find)
|
||||||
|
val postTags = request.tags.map { tagName ->
|
||||||
|
val tag = tagRepository.findByName(tagName)
|
||||||
|
?: tagRepository.save(Tag(name = tagName))
|
||||||
|
|
||||||
|
PostTag(post = post, tag = tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 연관관계 편의 메서드 사용 (Post 내부에 구현되어 있다고 가정)
|
||||||
|
post.addTags(postTags)
|
||||||
|
|
||||||
|
return postRepository.save(post).id!!
|
||||||
|
}
|
||||||
|
|
||||||
|
fun searchPosts(keyword: String?, categoryName: String?, tagName: String?, pageable: Pageable): Page<PostSummaryResponse> {
|
||||||
|
return postRepository.search(keyword, categoryName, tagName, pageable)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/main/kotlin/me/wypark/blogbackend/domain/tag/PostTag.kt
Normal file
19
src/main/kotlin/me/wypark/blogbackend/domain/tag/PostTag.kt
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package me.wypark.blogbackend.domain.tag
|
||||||
|
|
||||||
|
import jakarta.persistence.*
|
||||||
|
import me.wypark.blogbackend.domain.post.Post
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "post_tag")
|
||||||
|
class PostTag(
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "post_id")
|
||||||
|
val post: Post,
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "tag_id")
|
||||||
|
val tag: Tag
|
||||||
|
) {
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
val id: Long? = null
|
||||||
|
}
|
||||||
13
src/main/kotlin/me/wypark/blogbackend/domain/tag/Tag.kt
Normal file
13
src/main/kotlin/me/wypark/blogbackend/domain/tag/Tag.kt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package me.wypark.blogbackend.domain.tag
|
||||||
|
|
||||||
|
import jakarta.persistence.*
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "tag")
|
||||||
|
class Tag(
|
||||||
|
@Column(nullable = false, unique = true)
|
||||||
|
val name: String
|
||||||
|
) {
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
val id: Long? = null
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package me.wypark.blogbackend.domain.tag
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
|
||||||
|
interface TagRepository : JpaRepository<Tag, Long> {
|
||||||
|
fun findByName(name: String): Tag?
|
||||||
|
}
|
||||||
@@ -43,12 +43,11 @@ spring:
|
|||||||
host: redis # Docker 서비스명
|
host: redis # Docker 서비스명
|
||||||
port: 6379
|
port: 6379
|
||||||
|
|
||||||
# 4. AWS S3 / MinIO 설정 (아까 이야기한 부분)
|
|
||||||
cloud:
|
cloud:
|
||||||
aws:
|
aws:
|
||||||
s3:
|
s3:
|
||||||
bucket: my-blog-bucket # MinIO 콘솔에서 미리 생성해야 함
|
bucket: my-blog-bucket
|
||||||
endpoint: http://minio:9000 # Docker 내부 통신용
|
endpoint: http://minio:9000
|
||||||
credentials:
|
credentials:
|
||||||
access-key: admin
|
access-key: admin
|
||||||
secret-key: password
|
secret-key: password
|
||||||
|
|||||||
Reference in New Issue
Block a user