From 60d645f47b9b1c3cec2fa15842f379a5790adee0 Mon Sep 17 00:00:00 2001 From: "pwy3282040@msecure.co" Date: Fri, 26 Dec 2025 14:47:48 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B8=94=EB=A1=9C=EA=B7=B8=20=ED=95=B5?= =?UTF-8?q?=EC=8B=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(?= =?UTF-8?q?=EB=8C=93=EA=B8=80,=20=ED=83=9C=EA=B7=B8,=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC,=20=EC=9D=B4=EB=AF=B8=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [댓글 시스템] - 계층형(대댓글) 구조 및 회원/비회원 하이브리드 작성 로직 구현 - 비회원 댓글용 비밀번호 암호화 저장 (PasswordEncoder 적용) - 관리자용 댓글 강제 삭제 및 전체 댓글 모니터링(Dashboard) API 구현 [태그 & 카테고리] - N:M 태그 시스템(PostTag) 엔티티 설계 및 게시글 작성 시 자동 저장 로직 - 계층형(Tree) 카테고리 구조 구현 및 관리자 생성/삭제 API - QueryDSL 검색 조건에 태그 및 카테고리 필터링 추가 [이미지 업로드] - AWS S3 (MinIO) 연동 및 Bucket 자동 생성/Public 정책 설정 - 마크다운 에디터용 이미지 업로드 API 구현 --- .../api/controller/CategoryController.kt | 21 +++ .../api/controller/CommentController.kt | 49 +++++++ .../api/controller/PostController.kt | 38 ++++++ .../admin/AdminCategoryController.kt | 26 ++++ .../admin/AdminCommentController.kt | 33 +++++ .../controller/admin/AdminImageController.kt | 26 ++++ .../controller/admin/AdminPostController.kt | 30 +++++ .../blogbackend/api/dto/CategoryDtos.kt | 51 ++++++++ .../wypark/blogbackend/api/dto/CommentDtos.kt | 41 ++++++ .../me/wypark/blogbackend/api/dto/PostDtos.kt | 62 +++++++++ .../blogbackend/core/config/S3Config.kt | 31 +++++ .../blogbackend/domain/category/Category.kt | 25 ++++ .../domain/category/CategoryRepository.kt | 17 +++ .../domain/category/CategoryService.kt | 61 +++++++++ .../blogbackend/domain/comment/Comment.kt | 50 ++++++++ .../domain/comment/CommentRepository.kt | 14 ++ .../domain/comment/CommentService.kt | 120 ++++++++++++++++++ .../blogbackend/domain/image/ImageService.kt | 72 +++++++++++ .../me/wypark/blogbackend/domain/post/Post.kt | 57 +++++++++ .../blogbackend/domain/post/PostRepository.kt | 21 +++ .../domain/post/PostRepositoryCustom.kt | 9 ++ .../domain/post/PostRepositoryImpl.kt | 112 ++++++++++++++++ .../blogbackend/domain/post/PostService.kt | 79 ++++++++++++ .../wypark/blogbackend/domain/tag/PostTag.kt | 19 +++ .../me/wypark/blogbackend/domain/tag/Tag.kt | 13 ++ .../blogbackend/domain/tag/TagRepository.kt | 7 + 26 files changed, 1084 insertions(+) create mode 100644 src/main/kotlin/me/wypark/blogbackend/api/controller/CategoryController.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/api/controller/CommentController.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/api/controller/PostController.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminCategoryController.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminCommentController.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminImageController.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminPostController.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/api/dto/CategoryDtos.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/api/dto/CommentDtos.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/api/dto/PostDtos.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/core/config/S3Config.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/category/Category.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/category/CategoryRepository.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/category/CategoryService.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/comment/Comment.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/comment/CommentRepository.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/comment/CommentService.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/image/ImageService.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/post/Post.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepository.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryCustom.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryImpl.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/post/PostService.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/tag/PostTag.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/tag/Tag.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/tag/TagRepository.kt diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/CategoryController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/CategoryController.kt new file mode 100644 index 0000000..391b390 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/CategoryController.kt @@ -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>> { + return ResponseEntity.ok(ApiResponse.success(categoryService.getCategoryTree())) + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/CommentController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/CommentController.kt new file mode 100644 index 0000000..556ba46 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/CommentController.kt @@ -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>> { + return ResponseEntity.ok(ApiResponse.success(commentService.getComments(postSlug))) + } + + // 댓글 작성 (회원 or 비회원) + @PostMapping + fun createComment( + @RequestBody request: CommentSaveRequest, + @AuthenticationPrincipal user: User? // 비회원이면 null이 들어옴 + ): ResponseEntity> { + 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> { + val email = user?.username + val password = request?.guestPassword + + commentService.deleteComment(id, email, password) + return ResponseEntity.ok(ApiResponse.success(message = "댓글이 삭제되었습니다.")) + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/PostController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/PostController.kt new file mode 100644 index 0000000..d561729 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/PostController.kt @@ -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>> { + + val result = postService.searchPosts(keyword, category, tag, pageable) + return ResponseEntity.ok(ApiResponse.success(result)) + } + + // 상세 조회 (Slug) + @GetMapping("/{slug}") + fun getPost(@PathVariable slug: String): ResponseEntity> { + return ResponseEntity.ok(ApiResponse.success(postService.getPostBySlug(slug))) + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminCategoryController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminCategoryController.kt new file mode 100644 index 0000000..2933da0 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminCategoryController.kt @@ -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> { + val id = categoryService.createCategory(request) + return ResponseEntity.ok(ApiResponse.success(id, "카테고리가 생성되었습니다.")) + } + + @DeleteMapping("/{id}") + fun deleteCategory(@PathVariable id: Long): ResponseEntity> { + categoryService.deleteCategory(id) + return ResponseEntity.ok(ApiResponse.success(message = "카테고리가 삭제되었습니다.")) + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminCommentController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminCommentController.kt new file mode 100644 index 0000000..4059a53 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminCommentController.kt @@ -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> { + commentService.deleteCommentByAdmin(id) + return ResponseEntity.ok(ApiResponse.success(message = "관리자 권한으로 댓글을 삭제했습니다.")) + } + + @GetMapping + fun getAllComments( + @PageableDefault(size = 20, sort = ["id"], direction = Sort.Direction.DESC) pageable: Pageable + ): ResponseEntity>> { + return ResponseEntity.ok(ApiResponse.success(commentService.getAllComments(pageable))) + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminImageController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminImageController.kt new file mode 100644 index 0000000..13c34d9 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminImageController.kt @@ -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> { + val imageUrl = imageService.uploadImage(image) + return ResponseEntity.ok(ApiResponse.success(imageUrl, "이미지 업로드 성공")) + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminPostController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminPostController.kt new file mode 100644 index 0000000..bc6a791 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminPostController.kt @@ -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> { + // user.username은 email입니다. + val postId = postService.createPost(request, user.username) + return ResponseEntity.ok(ApiResponse.success(postId, "게시글이 작성되었습니다.")) + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/api/dto/CategoryDtos.kt b/src/main/kotlin/me/wypark/blogbackend/api/dto/CategoryDtos.kt new file mode 100644 index 0000000..862d1e8 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/api/dto/CategoryDtos.kt @@ -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 // 자식들 +) { + 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 + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/api/dto/CommentDtos.kt b/src/main/kotlin/me/wypark/blogbackend/api/dto/CommentDtos.kt new file mode 100644 index 0000000..4df4317 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/api/dto/CommentDtos.kt @@ -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 // 대댓글 리스트 +) { + 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 +) \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/api/dto/PostDtos.kt b/src/main/kotlin/me/wypark/blogbackend/api/dto/PostDtos.kt new file mode 100644 index 0000000..369bad4 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/api/dto/PostDtos.kt @@ -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 = emptyList() // 태그는 나중에 구현 +) \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/core/config/S3Config.kt b/src/main/kotlin/me/wypark/blogbackend/core/config/S3Config.kt new file mode 100644 index 0000000..e4f123c --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/core/config/S3Config.kt @@ -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("\${cloud.aws.credentials.access-key}") private val accessKey: String, + @Value("\${cloud.aws.credentials.secret-key}") private val secretKey: String, + @Value("\${cloud.aws.region.static}") private val region: String, + @Value("\${cloud.aws.s3.endpoint}") private val endpoint: String +) { + + @Bean + fun s3Client(): S3Client { + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider( + StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)) + ) + .endpointOverride(URI.create(endpoint)) + .forcePathStyle(true) // MinIO 필수 설정 (도메인 방식이 아닌 경로 방식 사용) + .build() + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/category/Category.kt b/src/main/kotlin/me/wypark/blogbackend/domain/category/Category.kt new file mode 100644 index 0000000..2a7a3a7 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/category/Category.kt @@ -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 = mutableListOf() // 자식 카테고리들 +) { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null + + // 연관관계 편의 메서드 (부모-자식 연결) + fun addChild(child: Category) { + this.children.add(child) + child.parent = this + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/category/CategoryRepository.kt b/src/main/kotlin/me/wypark/blogbackend/domain/category/CategoryRepository.kt new file mode 100644 index 0000000..5c8687b --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/category/CategoryRepository.kt @@ -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 { + + // 부모가 없는 최상위 카테고리들만 조회 (이걸 가져오면 자식들은 줄줄이 딸려옵니다) + @Query("SELECT c FROM Category c JOIN FETCH c.children WHERE c.parent IS NULL") + fun findAllRoots(): List + + // 카테고리 이름 중복 검사 (같은 레벨에서 중복 방지용) + fun existsByName(name: String): Boolean + + // 이름으로 찾기 (게시글 작성 시 필요) + fun findByName(name: String): Category? +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/category/CategoryService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/category/CategoryService.kt new file mode 100644 index 0000000..853ffe6 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/category/CategoryService.kt @@ -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 { + 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) + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/comment/Comment.kt b/src/main/kotlin/me/wypark/blogbackend/domain/comment/Comment.kt new file mode 100644 index 0000000..cf72c01 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/comment/Comment.kt @@ -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 = 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 + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/comment/CommentRepository.kt b/src/main/kotlin/me/wypark/blogbackend/domain/comment/CommentRepository.kt new file mode 100644 index 0000000..0aef124 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/comment/CommentRepository.kt @@ -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 { + + // 특정 게시글의 모든 댓글 조회 (최상위 부모 댓글 기준 + 작성순) + // 자식 댓글은 Entity의 children 필드를 통해 가져오거나, BatchSize로 최적화합니다. + fun findAllByPostAndParentIsNullOrderByCreatedAtAsc(post: Post): List + + // 게시글 삭제 시 관련 댓글 전체 삭제용 + fun deleteAllByPost(post: Post) +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/comment/CommentService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/comment/CommentService.kt new file mode 100644 index 0000000..361cc15 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/comment/CommentService.kt @@ -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 { + 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 { + return commentRepository.findAll(pageable) + .map { AdminCommentResponse.from(it) } + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/image/ImageService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/image/ImageService.kt new file mode 100644 index 0000000..3fad935 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/image/ImageService.kt @@ -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("\${cloud.aws.s3.endpoint}") 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) + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/post/Post.kt b/src/main/kotlin/me/wypark/blogbackend/domain/post/Post.kt new file mode 100644 index 0000000..0bf8591 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/post/Post.kt @@ -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 = mutableListOf() + + fun addTags(newTags: List) { + this.tags.clear() + this.tags.addAll(newTags) + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepository.kt b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepository.kt new file mode 100644 index 0000000..d875bbd --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepository.kt @@ -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, PostRepositoryCustom{ + + // 1. Slug로 상세 조회 (URL이 깔끔해짐) + fun findBySlug(slug: String): Post? + + // 2. Slug 중복 검사 (글 작성/수정 시 필수) + fun existsBySlug(slug: String): Boolean + + // 3. 페이징된 목록 조회 (최신순 등은 Pageable로 해결) + override fun findAll(pageable: Pageable): Page + + // 4. 특정 카테고리의 글 목록 조회 + fun findAllByCategory(category: Category, pageable: Pageable): Page +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryCustom.kt b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryCustom.kt new file mode 100644 index 0000000..7a20994 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryCustom.kt @@ -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 +} \ 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 new file mode 100644 index 0000000..d002920 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryImpl.kt @@ -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 { + // 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> { + val orders = mutableListOf>() + + 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 + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostService.kt new file mode 100644 index 0000000..a6e9718 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostService.kt @@ -0,0 +1,79 @@ +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 { + 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 { + if (postRepository.existsBySlug(request.slug)) { throw IllegalArgumentException("이미 존재하는 Slug입니다.") } + val member = memberRepository.findByEmail(email) ?: throw IllegalArgumentException("회원 없음") + val category = request.categoryId?.let { categoryRepository.findByIdOrNull(it) } + + val post = Post( + title = request.title, + content = request.content, + slug = request.slug, + member = member, + category = category + ) + + val postTags = request.tags.map { tagName -> + val tag = tagRepository.findByName(tagName) + ?: tagRepository.save(Tag(name = tagName)) + + PostTag(post = post, tag = tag) + } + + post.addTags(postTags) + + return postRepository.save(post).id!! + } + + fun searchPosts(keyword: String?, categoryName: String?, tagName: String?, pageable: Pageable): Page { + return postRepository.search(keyword, categoryName, tagName, pageable) + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/tag/PostTag.kt b/src/main/kotlin/me/wypark/blogbackend/domain/tag/PostTag.kt new file mode 100644 index 0000000..dead851 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/tag/PostTag.kt @@ -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 +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/tag/Tag.kt b/src/main/kotlin/me/wypark/blogbackend/domain/tag/Tag.kt new file mode 100644 index 0000000..6c18083 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/tag/Tag.kt @@ -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 +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/tag/TagRepository.kt b/src/main/kotlin/me/wypark/blogbackend/domain/tag/TagRepository.kt new file mode 100644 index 0000000..c55b30e --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/tag/TagRepository.kt @@ -0,0 +1,7 @@ +package me.wypark.blogbackend.domain.tag + +import org.springframework.data.jpa.repository.JpaRepository + +interface TagRepository : JpaRepository { + fun findByName(name: String): Tag? +} \ No newline at end of file