4 Commits

Author SHA1 Message Date
pwy3282040@msecure.co
884853586d feat: 댓글 구조 변경
작성자 표시를 위해 CommentResponse에 isPostAuthor 필드 추가
2025-12-27 00:14:57 +09:00
pwy3282040@msecure.co
ef6ffa5670 feat: 댓글 구조 변경
작성자 표시를 위해 CommentResponse에 isPostAuthor 필드 추가
2025-12-26 15:07:35 +09:00
pwy3282040@msecure.co
2f6cb41764 docs: README.md 수정
관련 문서 링크 변경
2025-12-26 15:02:57 +09:00
pwy3282040@msecure.co
60d645f47b feat: 블로그 핵심 기능 구현 (댓글, 태그, 카테고리, 이미지)
[댓글 시스템]
- 계층형(대댓글) 구조 및 회원/비회원 하이브리드 작성 로직 구현
- 비회원 댓글용 비밀번호 암호화 저장 (PasswordEncoder 적용)
- 관리자용 댓글 강제 삭제 및 전체 댓글 모니터링(Dashboard) API 구현

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

[이미지 업로드]
- AWS S3 (MinIO) 연동 및 Bucket 자동 생성/Public 정책 설정
- 마크다운 에디터용 이미지 업로드 API 구현
2025-12-26 14:47:48 +09:00
31 changed files with 1147 additions and 20 deletions

View File

@@ -80,5 +80,5 @@ $ docker-compose logs -f blog-api
---
## 📝 관련 문서
- [**ENDPOINT**](https://affine.wypark.me/workspace/f85df0c4-a315-4166-94a8-6558cdafff1d/p13joh_beKNJ6R-I9KrFW)
- [**API 명세서**](https://affine.wypark.me/workspace/f85df0c4-a315-4166-94a8-6558cdafff1d/7fRNXK9utxG4mcekWivGK)
- [**ENDPOINT**](https://affine.wypark.me/workspace/f85df0c4-a315-4166-94a8-6558cdafff1d/_csY9ZzOnSbt4bTnIA_9w)
- [**API 명세서**](https://affine.wypark.me/workspace/f85df0c4-a315-4166-94a8-6558cdafff1d/9axPww7llEcSH3nOZzi-m)

View File

@@ -15,10 +15,10 @@ services:
- SPRING_DATA_REDIS_HOST=redis
- SPRING_DATA_REDIS_PORT=6379
# AWS S3 / MinIO (Docker 내부 통신용)
- CLOUD_AWS_CREDENTIALS_ACCESS_KEY=admin
- CLOUD_AWS_CREDENTIALS_SECRET_KEY=password
- CLOUD_AWS_REGION_STATIC=ap-northeast-2
- CLOUD_AWS_S3_ENDPOINT=http://minio:9000
- SPRING_CLOUD_AWS_CREDENTIALS_ACCESS_KEY=admin
- SPRING_CLOUD_AWS_CREDENTIALS_SECRET_KEY=password
- SPRING_CLOUD_AWS_REGION_STATIC=ap-northeast-2
- SPRING_CLOUD_AWS_S3_ENDPOINT=http://minio:9000
# SMTP 메일 설정
- SPRING_MAIL_HOST=smtp.gmail.com
- SPRING_MAIL_PORT=587

View File

@@ -0,0 +1,21 @@
package me.wypark.blogbackend.api.controller
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.CategoryResponse
import me.wypark.blogbackend.domain.category.CategoryService
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/categories")
class CategoryController(
private val categoryService: CategoryService
) {
@GetMapping
fun getCategoryTree(): ResponseEntity<ApiResponse<List<CategoryResponse>>> {
return ResponseEntity.ok(ApiResponse.success(categoryService.getCategoryTree()))
}
}

View File

@@ -0,0 +1,49 @@
package me.wypark.blogbackend.api.controller
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.CommentDeleteRequest
import me.wypark.blogbackend.api.dto.CommentResponse
import me.wypark.blogbackend.api.dto.CommentSaveRequest
import me.wypark.blogbackend.domain.comment.CommentService
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.User
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/comments")
class CommentController(
private val commentService: CommentService
) {
// 댓글 목록 조회
@GetMapping
fun getComments(@RequestParam postSlug: String): ResponseEntity<ApiResponse<List<CommentResponse>>> {
return ResponseEntity.ok(ApiResponse.success(commentService.getComments(postSlug)))
}
// 댓글 작성 (회원 or 비회원)
@PostMapping
fun createComment(
@RequestBody request: CommentSaveRequest,
@AuthenticationPrincipal user: User? // 비회원이면 null이 들어옴
): ResponseEntity<ApiResponse<Long>> {
val email = user?.username // null이면 비회원
val commentId = commentService.createComment(request, email)
return ResponseEntity.ok(ApiResponse.success(commentId, "댓글이 등록되었습니다."))
}
// 댓글 삭제
@DeleteMapping("/{id}")
fun deleteComment(
@PathVariable id: Long,
@RequestBody(required = false) request: CommentDeleteRequest?, // 비회원용 비밀번호 바디
@AuthenticationPrincipal user: User?
): ResponseEntity<ApiResponse<Nothing>> {
val email = user?.username
val password = request?.guestPassword
commentService.deleteComment(id, email, password)
return ResponseEntity.ok(ApiResponse.success(message = "댓글이 삭제되었습니다."))
}
}

View File

@@ -0,0 +1,38 @@
package me.wypark.blogbackend.api.controller
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.PostResponse
import me.wypark.blogbackend.api.dto.PostSummaryResponse
import me.wypark.blogbackend.domain.post.PostService
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.data.web.PageableDefault
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/posts")
class PostController(
private val postService: PostService
) {
// 목록 조회 (기본값: 최신순, 10개씩)
@GetMapping
fun getPosts(
@RequestParam(required = false) keyword: String?,
@RequestParam(required = false) category: String?,
@RequestParam(required = false) tag: String?, // 👈 파라미터 추가
@PageableDefault(size = 10, sort = ["id"], direction = Sort.Direction.DESC) pageable: Pageable
): ResponseEntity<ApiResponse<Page<PostSummaryResponse>>> {
val result = postService.searchPosts(keyword, category, tag, pageable)
return ResponseEntity.ok(ApiResponse.success(result))
}
// 상세 조회 (Slug)
@GetMapping("/{slug}")
fun getPost(@PathVariable slug: String): ResponseEntity<ApiResponse<PostResponse>> {
return ResponseEntity.ok(ApiResponse.success(postService.getPostBySlug(slug)))
}
}

View File

@@ -0,0 +1,26 @@
package me.wypark.blogbackend.api.controller.admin
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.CategoryCreateRequest
import me.wypark.blogbackend.domain.category.CategoryService
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/admin/categories")
class AdminCategoryController(
private val categoryService: CategoryService
) {
@PostMapping
fun createCategory(@RequestBody request: CategoryCreateRequest): ResponseEntity<ApiResponse<Long>> {
val id = categoryService.createCategory(request)
return ResponseEntity.ok(ApiResponse.success(id, "카테고리가 생성되었습니다."))
}
@DeleteMapping("/{id}")
fun deleteCategory(@PathVariable id: Long): ResponseEntity<ApiResponse<Nothing>> {
categoryService.deleteCategory(id)
return ResponseEntity.ok(ApiResponse.success(message = "카테고리가 삭제되었습니다."))
}
}

View File

@@ -0,0 +1,33 @@
package me.wypark.blogbackend.api.controller.admin
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.AdminCommentResponse
import me.wypark.blogbackend.api.dto.CommentResponse
import me.wypark.blogbackend.domain.comment.CommentService
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.data.web.PageableDefault
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/admin/comments")
class AdminCommentController(
private val commentService: CommentService
) {
// 관리자 권한으로 댓글 삭제
@DeleteMapping("/{id}")
fun deleteComment(@PathVariable id: Long): ResponseEntity<ApiResponse<Nothing>> {
commentService.deleteCommentByAdmin(id)
return ResponseEntity.ok(ApiResponse.success(message = "관리자 권한으로 댓글을 삭제했습니다."))
}
@GetMapping
fun getAllComments(
@PageableDefault(size = 20, sort = ["id"], direction = Sort.Direction.DESC) pageable: Pageable
): ResponseEntity<ApiResponse<Page<AdminCommentResponse>>> {
return ResponseEntity.ok(ApiResponse.success(commentService.getAllComments(pageable)))
}
}

View File

@@ -0,0 +1,26 @@
package me.wypark.blogbackend.api.controller.admin
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.domain.image.ImageService
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
@RestController
@RequestMapping("/api/admin/images")
class AdminImageController(
private val imageService: ImageService
) {
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun uploadImage(
@RequestPart("image") image: MultipartFile
): ResponseEntity<ApiResponse<String>> {
val imageUrl = imageService.uploadImage(image)
return ResponseEntity.ok(ApiResponse.success(imageUrl, "이미지 업로드 성공"))
}
}

View File

@@ -0,0 +1,30 @@
package me.wypark.blogbackend.api.controller.admin
import jakarta.validation.Valid
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.PostSaveRequest
import me.wypark.blogbackend.domain.post.PostService
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.User
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/admin/posts")
class AdminPostController(
private val postService: PostService
) {
@PostMapping
fun createPost(
@RequestBody @Valid request: PostSaveRequest,
@AuthenticationPrincipal user: User
): ResponseEntity<ApiResponse<Long>> {
// user.username은 email입니다.
val postId = postService.createPost(request, user.username)
return ResponseEntity.ok(ApiResponse.success(postId, "게시글이 작성되었습니다."))
}
}

View File

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

View 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
)

View 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() // 태그는 나중에 구현
)

View File

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

View File

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

View File

@@ -32,7 +32,7 @@ class SecurityConfig(
auth.requestMatchers("/api/auth/**").permitAll()
auth.requestMatchers(HttpMethod.GET, "/api/posts/**", "/api/categories/**", "/api/tags/**").permitAll()
auth.requestMatchers(HttpMethod.POST, "/api/comments/**").permitAll() // 비회원 댓글 허용
auth.requestMatchers("/api/admin/**").hasRole("ADMIN")
auth.requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN")
auth.anyRequest().authenticated()
}
// 필터 등록: UsernamePasswordAuthenticationFilter 앞에 JwtFilter를 실행

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View 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
}

View 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
}

View File

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

View File

@@ -43,12 +43,11 @@ spring:
host: redis # Docker 서비스명
port: 6379
# 4. AWS S3 / MinIO 설정 (아까 이야기한 부분)
cloud:
cloud:
aws:
s3:
bucket: my-blog-bucket # MinIO 콘솔에서 미리 생성해야 함
endpoint: http://minio:9000 # Docker 내부 통신용
bucket: my-blog-bucket
endpoint: http://minio:9000
credentials:
access-key: admin
secret-key: password