This commit is contained in:
ParkWonYeop
2025-12-27 17:52:40 +09:00
parent 884853586d
commit 46a8a43163
29 changed files with 646 additions and 149 deletions

View File

@@ -1,5 +1,6 @@
package me.wypark.blogbackend
import io.github.cdimascio.dotenv.Dotenv
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@@ -7,5 +8,14 @@ import org.springframework.boot.runApplication
class BlogBackendApplication
fun main(args: Array<String>) {
// 1. .env 파일 로드
val dotenv = Dotenv.configure().ignoreIfMissing().load()
// 2. 로드한 내용을 시스템 프로퍼티에 설정 (그래야 application.yml에서 ${}로 읽음)
dotenv.entries().forEach { entry ->
System.setProperty(entry.key, entry.value)
}
// 3. 스프링 실행
runApplication<BlogBackendApplication>(*args)
}

View File

@@ -17,22 +17,28 @@ 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
@RequestParam(required = false) category: String?, // 👈 프론트는 'category'로 보냄
@RequestParam(required = false) tag: String?,
@PageableDefault(size = 10, sort = ["createdAt"], direction = Sort.Direction.DESC) pageable: Pageable
): ResponseEntity<ApiResponse<Page<PostSummaryResponse>>> {
val result = postService.searchPosts(keyword, category, tag, pageable)
return ResponseEntity.ok(ApiResponse.success(result))
// 검색 조건이 하나라도 있으면 searchPosts 호출 (검색 + 카테고리 필터링)
return if (keyword != null || category != null || tag != null) {
val posts = postService.searchPosts(keyword, category, tag, pageable)
ResponseEntity.ok(ApiResponse.success(posts))
} else {
// 조건이 없으면 전체 목록 조회
val posts = postService.getPosts(pageable)
ResponseEntity.ok(ApiResponse.success(posts))
}
}
// 상세 조회 (Slug)
@GetMapping("/{slug}")
fun getPost(@PathVariable slug: String): ResponseEntity<ApiResponse<PostResponse>> {
return ResponseEntity.ok(ApiResponse.success(postService.getPostBySlug(slug)))
val post = postService.getPostBySlug(slug)
return ResponseEntity.ok(ApiResponse.success(post))
}
}

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.ProfileResponse
import me.wypark.blogbackend.domain.profile.BlogProfileService
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/profile")
class ProfileController(
private val blogProfileService: BlogProfileService
) {
@GetMapping
fun getProfile(): ResponseEntity<ApiResponse<ProfileResponse>> {
return ResponseEntity.ok(ApiResponse.success(blogProfileService.getProfile()))
}
}

View File

@@ -2,6 +2,7 @@ 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.api.dto.CategoryUpdateRequest
import me.wypark.blogbackend.domain.category.CategoryService
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
@@ -18,6 +19,16 @@ class AdminCategoryController(
return ResponseEntity.ok(ApiResponse.success(id, "카테고리가 생성되었습니다."))
}
// 👈 [추가] 카테고리 수정 (이름, 위치 이동)
@PutMapping("/{id}")
fun updateCategory(
@PathVariable id: Long,
@RequestBody request: CategoryUpdateRequest
): ResponseEntity<ApiResponse<Nothing>> {
categoryService.updateCategory(id, request)
return ResponseEntity.ok(ApiResponse.success(message = "카테고리가 수정되었습니다."))
}
@DeleteMapping("/{id}")
fun deleteCategory(@PathVariable id: Long): ResponseEntity<ApiResponse<Nothing>> {
categoryService.deleteCategory(id)

View File

@@ -7,10 +7,7 @@ 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
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/admin/posts")
@@ -23,8 +20,23 @@ class AdminPostController(
@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, "게시글이 작성되었습니다."))
}
// 👈 [추가] 게시글 수정 엔드포인트
@PutMapping("/{id}")
fun updatePost(
@PathVariable id: Long,
@RequestBody @Valid request: PostSaveRequest
): ResponseEntity<ApiResponse<Long>> {
val postId = postService.updatePost(id, request)
return ResponseEntity.ok(ApiResponse.success(postId, "게시글이 수정되었습니다."))
}
@DeleteMapping("/{id}")
fun deletePost(@PathVariable id: Long): ResponseEntity<ApiResponse<Nothing>> {
postService.deletePost(id)
return ResponseEntity.ok(ApiResponse.success(message = "게시글과 포함된 이미지가 삭제되었습니다."))
}
}

View File

@@ -0,0 +1,23 @@
package me.wypark.blogbackend.api.controller.admin
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.ProfileUpdateRequest
import me.wypark.blogbackend.domain.profile.BlogProfileService
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PutMapping
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/profile")
class AdminProfileController(
private val blogProfileService: BlogProfileService
) {
@PutMapping
fun updateProfile(@RequestBody request: ProfileUpdateRequest): ResponseEntity<ApiResponse<Nothing>> {
blogProfileService.updateProfile(request)
return ResponseEntity.ok(ApiResponse.success(message = "프로필이 수정되었습니다."))
}
}

View File

@@ -8,6 +8,12 @@ data class CategoryCreateRequest(
val parentId: Long? = null // null이면 최상위(Root) 카테고리
)
// [요청] 카테고리 수정 (이름 + 부모 이동)
data class CategoryUpdateRequest(
val name: String,
val parentId: Long? // null이면 최상위(Root)로 이동
)
// [응답] 카테고리 트리 구조 (재귀)
data class CategoryResponse(
val id: Long,

View File

@@ -8,7 +8,8 @@ data class CommentResponse(
val id: Long,
val content: String,
val author: String,
val isPostAuthor: Boolean, // 👈 [추가] 게시글 작성자 여부
val isPostAuthor: Boolean,
val memberId: Long?,
val createdAt: LocalDateTime,
val children: List<CommentResponse>
) {
@@ -22,7 +23,8 @@ data class CommentResponse(
id = comment.id!!,
content = comment.content,
author = comment.getAuthorName(),
isPostAuthor = isAuthor, // 👈 계산된 값 주입
isPostAuthor = isAuthor,
memberId = comment.member?.id,
createdAt = comment.createdAt,
children = comment.children.map { from(it) }
)

View File

@@ -36,7 +36,8 @@ data class PostSummaryResponse(
val slug: String,
val categoryName: String?,
val viewCount: Long,
val createdAt: LocalDateTime
val createdAt: LocalDateTime,
val content: String?
) {
companion object {
fun from(post: Post): PostSummaryResponse {
@@ -46,7 +47,8 @@ data class PostSummaryResponse(
slug = post.slug,
categoryName = post.category?.name,
viewCount = post.viewCount,
createdAt = post.createdAt
createdAt = post.createdAt,
content = post.content
)
}
}

View File

@@ -0,0 +1,31 @@
package me.wypark.blogbackend.api.dto
import me.wypark.blogbackend.domain.profile.BlogProfile
data class ProfileResponse(
val name: String,
val bio: String,
val imageUrl: String?,
val githubUrl: String?,
val email: String?
) {
companion object {
fun from(profile: BlogProfile): ProfileResponse {
return ProfileResponse(
name = profile.name,
bio = profile.bio,
imageUrl = profile.imageUrl,
githubUrl = profile.githubUrl,
email = profile.email
)
}
}
}
data class ProfileUpdateRequest(
val name: String,
val bio: String,
val imageUrl: String?,
val githubUrl: String?,
val email: String?
)

View File

@@ -13,10 +13,14 @@ class CorsConfig {
val source = UrlBasedCorsConfigurationSource()
val config = CorsConfiguration()
config.allowCredentials = true // 쿠키/토큰 허용
config.addAllowedOriginPattern("*") // 개발용 (배포 시 프론트 도메인으로 변경 추천)
config.addAllowedHeader("*")
config.addAllowedMethod("*") // GET, POST, PUT, DELETE 등 모두 허용
config.allowCredentials = true
config.addAllowedOrigin("https://blog.wypark.me") // 프론트 도메인
config.addAllowedHeader("*") // 클라이언트가 보내는 모든 헤더 허용 (Authorization 포함)
config.addAllowedMethod("*")
// [중요] 클라이언트가 응답 헤더에서 'Authorization'이나 커스텀 토큰 헤더를 읽을 수 있게 허용
config.addExposedHeader("Authorization")
config.addExposedHeader("Refresh-Token") // 리프레시 토큰도 헤더로 준다면 추가
source.registerCorsConfiguration("/api/**", config)
return CorsFilter(source)

View File

@@ -31,7 +31,8 @@ class SecurityConfig(
.authorizeHttpRequests { auth ->
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/comments/**").permitAll() // 비회원 댓글 허용
auth.requestMatchers(HttpMethod.GET, "/api/profile").permitAll()
auth.requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN")
auth.anyRequest().authenticated()
}

View File

@@ -4,6 +4,7 @@ import io.jsonwebtoken.*
import io.jsonwebtoken.io.Decoders
import io.jsonwebtoken.security.Keys
import me.wypark.blogbackend.api.dto.TokenDto
import me.wypark.blogbackend.domain.auth.CustomUserDetails // 👈 Import 추가
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
@@ -27,11 +28,18 @@ class JwtProvider(
val authorities = authentication.authorities.joinToString(",") { it.authority }
val now = Date().time
// 👇 [수정] Principal을 CustomUserDetails로 캐스팅하여 정보 추출
val principal = authentication.principal as CustomUserDetails
val memberId = principal.memberId
val nickname = principal.nickname
// Access Token 생성
val accessTokenExpiresIn = Date(now + accessTokenValidity)
val accessToken = Jwts.builder()
.subject(authentication.name) // email 또는 id
.subject(authentication.name) // email
.claim("auth", authorities) // 권한 정보 (ROLE_USER 등)
.claim("memberId", memberId) // 👈 [추가] 프론트엔드 식별용 ID
.claim("nickname", nickname) // 👈 [추가] 프론트엔드 표기용 닉네임
.expiration(accessTokenExpiresIn)
.signWith(key)
.compact()

View File

@@ -0,0 +1,12 @@
package me.wypark.blogbackend.domain.auth
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.userdetails.User
class CustomUserDetails(
val memberId: Long,
val nickname: String,
username: String,
password: String,
authorities: Collection<GrantedAuthority>
) : User(username, password, authorities)

View File

@@ -3,7 +3,6 @@ package me.wypark.blogbackend.domain.auth
import me.wypark.blogbackend.domain.user.Member
import me.wypark.blogbackend.domain.user.MemberRepository
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
@@ -21,10 +20,13 @@ class CustomUserDetailsService(
}
private fun createUserDetails(member: Member): UserDetails {
return User(
member.email,
member.password,
listOf(SimpleGrantedAuthority(member.role.name))
// [수정] 표준 User 객체 대신, ID와 닉네임을 포함하는 CustomUserDetails 반환
return CustomUserDetails(
memberId = member.id!!, // 토큰에 넣을 ID
nickname = member.nickname, // 토큰에 넣을 닉네임
username = member.email,
password = member.password,
authorities = listOf(SimpleGrantedAuthority(member.role.name))
)
}
}

View File

@@ -22,4 +22,21 @@ class Category(
this.children.add(child)
child.parent = this
}
// 이름 변경
fun updateName(name: String) {
this.name = name
}
// 부모 변경 (계층 이동)
fun changeParent(newParent: Category?) {
// 1. 기존 부모와의 관계 끊기
this.parent?.children?.remove(this)
// 2. 새 부모 설정
this.parent = newParent
// 3. 새 부모의 자식 목록에 추가 (null이 아니면)
newParent?.children?.add(this)
}
}

View File

@@ -2,6 +2,8 @@ package me.wypark.blogbackend.domain.category
import me.wypark.blogbackend.api.dto.CategoryCreateRequest
import me.wypark.blogbackend.api.dto.CategoryResponse
import me.wypark.blogbackend.api.dto.CategoryUpdateRequest
import me.wypark.blogbackend.domain.post.PostRepository
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -9,53 +11,108 @@ import org.springframework.transaction.annotation.Transactional
@Service
@Transactional(readOnly = true)
class CategoryService(
private val categoryRepository: CategoryRepository
private val categoryRepository: CategoryRepository,
private val postRepository: PostRepository
) {
/**
* [Public] 카테고리 트리 조회
* 최상위(Root)만 조회하면, Entity 설정을 통해 자식들도 딸려옵니다.
*/
// 예약어 검증 메서드
private fun validateReservedName(name: String) {
if (name.equals("uncategorized", ignoreCase = true)) {
throw IllegalArgumentException("'uncategorized'는 시스템 예약어이므로 사용할 수 없습니다.")
}
}
fun getCategoryTree(): List<CategoryResponse> {
val roots = categoryRepository.findAllRoots()
return roots.map { CategoryResponse.from(it) }
}
/**
* [Admin] 카테고리 생성
*/
@Transactional
fun createCategory(request: CategoryCreateRequest): Long {
// 이름 중복 체크 (선택 사항이지만 추천)
// 1. 예약어 검증
validateReservedName(request.name)
// 2. 중복 체크
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 updateCategory(id: Long, request: CategoryUpdateRequest) {
val category = categoryRepository.findByIdOrNull(id)
?: throw IllegalArgumentException("존재하지 않는 카테고리입니다.")
// 1. 이름 변경 (변경 시에만 검증)
if (request.name != null && category.name != request.name) {
validateReservedName(request.name) // 예약어 검증
if (categoryRepository.existsByName(request.name)) {
throw IllegalArgumentException("이미 존재하는 카테고리 이름입니다.")
}
category.updateName(request.name)
}
// 2. 부모 변경
val currentParentId = category.parent?.id
val newParentId = request.parentId
if (currentParentId != newParentId) {
if (newParentId == null) {
category.changeParent(null)
} else {
val newParent = categoryRepository.findByIdOrNull(newParentId)
?: throw IllegalArgumentException("이동하려는 부모 카테고리가 존재하지 않습니다.")
validateHierarchy(category, newParent)
category.changeParent(newParent)
}
}
}
private fun validateHierarchy(target: Category, newParent: Category) {
if (target.id == newParent.id) {
throw IllegalArgumentException("자기 자신을 부모로 설정할 수 없습니다.")
}
var parent = newParent.parent
while (parent != null) {
if (parent.id == target.id) {
throw IllegalArgumentException("자신의 하위 카테고리 밑으로 이동할 수 없습니다.")
}
parent = parent.parent
}
}
@Transactional
fun deleteCategory(id: Long) {
categoryRepository.deleteById(id)
val category = categoryRepository.findByIdOrNull(id)
?: throw IllegalArgumentException("존재하지 않는 카테고리입니다.")
val categoriesToDelete = mutableListOf<Category>()
collectAllCategories(category, categoriesToDelete)
postRepository.bulkUpdateCategoryToNull(categoriesToDelete)
categoryRepository.delete(category)
}
private fun collectAllCategories(category: Category, list: MutableList<Category>) {
list.add(category)
category.children.forEach { collectAllCategories(it, list) }
}
}

View File

@@ -5,7 +5,7 @@ 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.DeleteObjectRequest
import software.amazon.awssdk.services.s3.model.PutObjectRequest
import java.util.*
@@ -36,11 +36,24 @@ class ImageService(
s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(file.inputStream, file.size))
// 3. 접속 가능한 URL 반환
// 로컬 개발 환경에서는 localhost 주소를 직접 조합해서 줍니다.
// 배포 시에는 실제 도메인이나 CloudFront 주소로 변경해야 합니다.
return "$endpoint/$bucketName/$fileName"
}
// 👈 [추가] 이미지 삭제 로직
fun deleteImage(fileName: String) {
try {
val deleteObjectRequest = DeleteObjectRequest.builder()
.bucket(bucketName)
.key(fileName)
.build()
s3Client.deleteObject(deleteObjectRequest)
} catch (e: Exception) {
// 이미지가 이미 없거나 삭제 실패 시 로그만 남기고 진행 (게시글 삭제 자체를 막지 않기 위해)
e.printStackTrace()
}
}
private fun createBucketIfNotExists() {
try {
// 버킷 존재 여부 확인 (없으면 에러 발생하므로 try-catch)

View File

@@ -11,35 +11,39 @@ class Post(
@Column(nullable = false)
var title: String,
// 마크다운 본문 (대용량 저장을 위해 TEXT 타입 지정)
@Column(nullable = false, columnDefinition = "TEXT")
@Column(columnDefinition = "TEXT", nullable = false)
var content: String,
@Column(nullable = false, unique = true)
var slug: String, // URL용 제목 (예: my-first-post)
var slug: String,
@Column(nullable = false)
var viewCount: Long = 0,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
val member: Member, // 작성자 (관리자)
val member: Member,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
var category: Category? = null // 카테고리 (없을 수도 있음)
var category: Category? = null,
) : BaseTimeEntity() { // 생성일, 수정일 자동 관리
@OneToMany(mappedBy = "post", cascade = [CascadeType.ALL], orphanRemoval = true)
val tags: MutableList<PostTag> = mutableListOf()
) : BaseTimeEntity() {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
@Column(nullable = false)
var viewCount: Long = 0
// 조회수 증가
fun increaseViewCount() {
this.viewCount++
}
// 게시글 수정
fun addTags(postTags: List<PostTag>) {
this.tags.addAll(postTags)
}
// 👈 [추가] 게시글 수정 메서드
fun update(title: String, content: String, slug: String, category: Category?) {
this.title = title
this.content = content
@@ -47,11 +51,9 @@ class Post(
this.category = category
}
@OneToMany(mappedBy = "post", cascade = [CascadeType.ALL], orphanRemoval = true)
var tags: MutableList<PostTag> = mutableListOf()
fun addTags(newTags: List<PostTag>) {
this.tags.clear()
// 👈 [추가] 태그 전체 교체 편의 메서드
fun updateTags(newTags: List<PostTag>) {
this.tags.clear() // orphanRemoval = true 덕분에 기존 태그 매핑이 삭제됨
this.tags.addAll(newTags)
}
}

View File

@@ -4,8 +4,11 @@ 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
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
interface PostRepository : JpaRepository<Post, Long>, PostRepositoryCustom{
interface PostRepository : JpaRepository<Post, Long>, PostRepositoryCustom {
// 1. Slug로 상세 조회 (URL이 깔끔해짐)
fun findBySlug(slug: String): Post?
@@ -18,4 +21,9 @@ interface PostRepository : JpaRepository<Post, Long>, PostRepositoryCustom{
// 4. 특정 카테고리의 글 목록 조회
fun findAllByCategory(category: Category, pageable: Pageable): Page<Post>
// 5. [추가] 카테고리 삭제 시 해당 카테고리(및 하위)에 속한 글들의 카테고리를 null로 변경 (미분류 처리)
@Modifying(clearAutomatically = true)
@Query("UPDATE Post p SET p.category = null WHERE p.category IN :categories")
fun bulkUpdateCategoryToNull(@Param("categories") categories: List<Category>)
}

View File

@@ -5,5 +5,6 @@ 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>
// categoryName(String) -> categoryNames(List<String>) 변경
fun search(keyword: String?, categoryNames: List<String>?, tagName: String?, pageable: Pageable): Page<PostSummaryResponse>
}

View File

@@ -5,28 +5,28 @@ 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 me.wypark.blogbackend.domain.tag.QPostTag.postTag
import me.wypark.blogbackend.domain.tag.QTag.tag
import me.wypark.blogbackend.domain.category.QCategory.category // 👈 QCategory import 추가
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. 동적 필터링 조건
override fun search(keyword: String?, categoryNames: List<String>?, tagName: String?, pageable: Pageable): Page<PostSummaryResponse> {
val builder = BooleanBuilder()
builder.and(containsKeyword(keyword))
builder.and(eqCategory(categoryName))
builder.and(eqTagName(tagName)) // 👈 태그 조건 추가
// 2. 쿼리 실행 (Join 추가)
// "uncategorized" (또는 "미분류") 요청 시 post.category.isNull 조건으로 변환하여 처리
builder.and(inCategoryNames(categoryNames))
builder.and(eqTagName(tagName))
val query = queryFactory
.select(
Projections.constructor(
@@ -34,31 +34,31 @@ class PostRepositoryImpl(
post.id,
post.title,
post.slug,
post.category.name,
category.name, // 👈 post.category.name 대신 alias 사용 (NULL 안전)
post.viewCount,
post.createdAt
post.createdAt,
post.content // 👈 [추가] 미리보기를 위해 본문 내용도 조회합니다.
)
)
.from(post)
// 👇 태그 검색을 위해 테이블 Join
.leftJoin(post.category, category) // 👈 명시적 Left Join 추가 (카테고리 없어도 글 조회 가능하게 함)
.leftJoin(post.tags, postTag)
.leftJoin(postTag.tag, tag)
.where(builder)
.distinct() // ⭐ 중요: 하나의 글에 태그가 여러 개면 글이 중복 조회될 수 있어서 제거
.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()) // ⭐ 개수 셀 때도 중복 제거
.select(post.countDistinct())
.from(post)
.leftJoin(post.category, category) // 👈 Count 쿼리에도 Left Join 추가
.leftJoin(post.tags, postTag)
.leftJoin(postTag.tag, tag)
.where(builder)
@@ -67,9 +67,6 @@ class PostRepositoryImpl(
return PageImpl(content, pageable, total)
}
// --- 조건 메서드들 ---
// 검색어 (제목 or 본문)
private fun containsKeyword(keyword: String?): BooleanBuilder {
val builder = BooleanBuilder()
if (!keyword.isNullOrBlank()) {
@@ -79,31 +76,59 @@ class PostRepositoryImpl(
return builder
}
// 카테고리 일치 (카테고리명이 없으면 무시)
private fun eqCategory(categoryName: String?): BooleanExpression? {
if (categoryName.isNullOrBlank()) return null
return post.category.name.eq(categoryName)
private fun inCategoryNames(categoryNames: List<String>?): BooleanExpression? {
if (categoryNames.isNullOrEmpty()) return null
// 1. 요청에 "uncategorized" 또는 "미분류"가 포함되어 있는지 확인
// (프론트엔드에서 한글로 "미분류"를 보내는 경우가 많으므로 둘 다 체크)
val hasUncategorized = categoryNames.any {
it.equals("uncategorized", ignoreCase = true) || it.equals("미분류", ignoreCase = true)
}
// 2. 그 외 일반 카테고리 이름들만 따로 추림
val normalNames = categoryNames.filter {
!it.equals("uncategorized", ignoreCase = true) && !it.equals("미분류", ignoreCase = true)
}
var expression: BooleanExpression? = null
// A. 일반 카테고리 이름 조건 (IN 절)
if (normalNames.isNotEmpty()) {
expression = category.name.`in`(normalNames) // 👈 alias 사용
}
// B. uncategorized 조건 (IS NULL) 추가
if (hasUncategorized) {
val isNullExpr = post.category.isNull // FK가 NULL인지 확인
expression = if (expression != null) {
// (일반 카테고리들) OR (카테고리 없음) -> 둘 중 하나라도 만족하면 조회
expression.or(isNullExpr)
} else {
// (카테고리 없음) -> 카테고리 없는 글만 모아서 조회
isNullExpr
}
}
return expression
}
private fun eqTagName(tagName: String?): BooleanExpression? {
if (tagName.isNullOrBlank()) return null
return tag.name.eq(tagName) // 태그 이름은 정확히 일치해야 함
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)) // 최신순
"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)) // 기본
else -> orders.add(OrderSpecifier(Order.DESC, post.id))
}
}
}

View File

@@ -3,7 +3,9 @@ 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.Category
import me.wypark.blogbackend.domain.category.CategoryRepository
import me.wypark.blogbackend.domain.image.ImageService
import me.wypark.blogbackend.domain.tag.PostTag
import me.wypark.blogbackend.domain.tag.Tag
import me.wypark.blogbackend.domain.tag.TagRepository
@@ -20,33 +22,25 @@ class PostService(
private val postRepository: PostRepository,
private val categoryRepository: CategoryRepository,
private val memberRepository: MemberRepository,
private val tagRepository: TagRepository
private val tagRepository: TagRepository,
private val imageService: ImageService
) {
/**
* [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)
post.increaseViewCount()
return PostResponse.from(post)
}
/**
* [Admin] 게시글 작성
*/
@Transactional
fun createPost(request: PostSaveRequest, email: String): Long {
val member = memberRepository.findByEmail(email)
@@ -54,47 +48,132 @@ class PostService(
val category = request.categoryId?.let { categoryRepository.findByIdOrNull(it) }
val rawSlug = if (!request.slug.isNullOrBlank()) {
request.slug
} else {
request.title.trim().replace("\\s+".toRegex(), "-").lowercase()
}
// Slug 생성 로직
val uniqueSlug = generateUniqueSlug(request.slug, request.title)
// (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, // 👈 중복 처리된 슬러그
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 내부에 구현되어 있다고 가정)
val postTags = resolveTags(request.tags, post)
post.addTags(postTags)
return postRepository.save(post).id!!
}
// 👈 [추가] 게시글 수정
@Transactional
fun updatePost(id: Long, request: PostSaveRequest): Long {
val post = postRepository.findByIdOrNull(id)
?: throw IllegalArgumentException("존재하지 않는 게시글입니다.")
// 1. 이미지 정리: (기존 본문 이미지) - (새 본문 이미지) = 삭제 대상
val oldImages = extractImageNamesFromContent(post.content)
val newImages = extractImageNamesFromContent(request.content)
val removedImages = oldImages - newImages.toSet()
removedImages.forEach { imageService.deleteImage(it) }
// 2. 카테고리 조회
val category = request.categoryId?.let { categoryRepository.findByIdOrNull(it) }
// 3. Slug 갱신 (변경 요청이 있고, 기존과 다를 경우에만)
var newSlug = post.slug
if (!request.slug.isNullOrBlank() && request.slug != post.slug) {
newSlug = generateUniqueSlug(request.slug, request.title)
}
// 4. 정보 업데이트
post.update(request.title, request.content, newSlug, category)
// 5. 태그 업데이트
val newPostTags = resolveTags(request.tags, post)
post.updateTags(newPostTags)
return post.id!!
}
@Transactional
fun deletePost(id: Long) {
val post = postRepository.findByIdOrNull(id)
?: throw IllegalArgumentException("존재하지 않는 게시글입니다.")
val imageNames = extractImageNamesFromContent(post.content)
imageNames.forEach { fileName ->
imageService.deleteImage(fileName)
}
postRepository.delete(post)
}
fun searchPosts(keyword: String?, categoryName: String?, tagName: String?, pageable: Pageable): Page<PostSummaryResponse> {
return postRepository.search(keyword, categoryName, tagName, pageable)
val categoryNames = if (categoryName != null) {
getCategoryAndDescendants(categoryName)
} else {
null
}
return postRepository.search(keyword, categoryNames, tagName, pageable)
}
// --- Helper Methods ---
// Slug 중복 처리 로직 분리
private fun generateUniqueSlug(inputSlug: String?, title: String): String {
val rawSlug = if (!inputSlug.isNullOrBlank()) {
inputSlug
} else {
title.trim().replace("\\s+".toRegex(), "-").lowercase()
}
var uniqueSlug = rawSlug
var count = 1
// 본인 제외하고 체크해야 하지만, create/update 공용 로직이므로 단순 exists 체크
// (update 시 본인 slug 유지하면 exists에 걸리므로, 호출부에서 slug 변경 감지 후 호출해야 함)
while (postRepository.existsBySlug(uniqueSlug)) {
uniqueSlug = "$rawSlug-$count"
count++
}
return uniqueSlug
}
// 태그 이름 -> PostTag 변환 로직 분리
private fun resolveTags(tagNames: List<String>, post: Post): List<PostTag> {
return tagNames.map { tagName ->
val tag = tagRepository.findByName(tagName)
?: tagRepository.save(Tag(name = tagName))
PostTag(post = post, tag = tag)
}
}
private fun extractImageNamesFromContent(content: String): List<String> {
val regex = Regex("!\\[.*?\\]\\((.*?)\\)")
return regex.findAll(content)
.map { it.groupValues[1] }
.map { it.substringAfterLast("/") }
.toList()
}
private fun getCategoryAndDescendants(categoryName: String): List<String> {
if (categoryName.equals("uncategorized", ignoreCase = true)) {
return listOf("uncategorized")
}
val category = categoryRepository.findByName(categoryName)
if (category == null) return listOf(categoryName)
val names = mutableListOf<String>()
collectCategoryNames(category, names)
return names
}
private fun collectCategoryNames(category: Category, names: MutableList<String>) {
names.add(category.name)
category.children.forEach { collectCategoryNames(it, names) }
}
}

View File

@@ -0,0 +1,36 @@
package me.wypark.blogbackend.domain.profile
import jakarta.persistence.*
import me.wypark.blogbackend.domain.common.BaseTimeEntity
@Entity
@Table(name = "blog_profile")
class BlogProfile(
@Column(nullable = false)
var name: String,
@Column(columnDefinition = "TEXT")
var bio: String,
@Column
var imageUrl: String? = null,
@Column
var githubUrl: String? = null,
@Column
var email: String? = null
) : BaseTimeEntity() {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
fun update(name: String, bio: String, imageUrl: String?, githubUrl: String?, email: String?) {
this.name = name
this.bio = bio
this.imageUrl = imageUrl
this.githubUrl = githubUrl
this.email = email
}
}

View File

@@ -0,0 +1,6 @@
package me.wypark.blogbackend.domain.profile
import org.springframework.data.jpa.repository.JpaRepository
interface BlogProfileRepository : JpaRepository<BlogProfile, Long> {
}

View File

@@ -0,0 +1,65 @@
package me.wypark.blogbackend.domain.profile
import me.wypark.blogbackend.api.dto.ProfileResponse
import me.wypark.blogbackend.api.dto.ProfileUpdateRequest
import me.wypark.blogbackend.domain.image.ImageService
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
@Transactional(readOnly = true)
class BlogProfileService(
private val blogProfileRepository: BlogProfileRepository,
private val imageService: ImageService // 👈 이미지 서비스 주입
) {
// 프로필 조회 (없으면 기본값 생성 후 반환)
@Transactional
fun getProfile(): ProfileResponse {
val profile = blogProfileRepository.findAll().firstOrNull()
?: blogProfileRepository.save(
BlogProfile(
name = "Blog User",
bio = "안녕하세요. 블로그에 오신 것을 환영합니다.",
imageUrl = null,
githubUrl = null,
email = null
)
)
return ProfileResponse.from(profile)
}
// 프로필 수정
@Transactional
fun updateProfile(request: ProfileUpdateRequest) {
val profile = blogProfileRepository.findAll().firstOrNull()
?: blogProfileRepository.save(
BlogProfile(
name = request.name,
bio = request.bio,
imageUrl = request.imageUrl,
githubUrl = request.githubUrl,
email = request.email
)
)
// 1. 이미지 변경 감지 로직 추가
// 요청된 URL이 기존 URL과 다르면 (새 이미지로 교체 or 삭제됨)
if (profile.imageUrl != request.imageUrl) {
// 기존에 설정된 이미지가 있었다면 S3에서 삭제
if (!profile.imageUrl.isNullOrBlank()) {
val oldFileName = profile.imageUrl!!.substringAfterLast("/")
imageService.deleteImage(oldFileName)
}
}
// 2. 정보 업데이트
profile.update(
name = request.name,
bio = request.bio,
imageUrl = request.imageUrl,
githubUrl = request.githubUrl,
email = request.email
)
}
}

View File

@@ -2,23 +2,28 @@ spring:
application:
name: blog-api
servlet:
multipart:
max-file-size: 100MB # 파일 하나당 최대 크기 (기본 1MB -> 10MB로 증량)
max-request-size: 100MB # 요청 전체 최대 크기 (여러 파일 합산)
# 1. 데이터베이스 설정
datasource:
# Docker 내부 통신용
url: jdbc:postgresql://db:5432/blog_db
username: ${SPRING_DATASOURCE_USERNAME:wypark}
password: ${SPRING_DATASOURCE_PASSWORD:your_password}
url: jdbc:postgresql://${SPRING_DATASOURCE_URL}
username: ${SPRING_DATASOURCE_USERNAME}
password: ${SPRING_DATASOURCE_PASSWORD}
driver-class-name: org.postgresql.Driver
# 2. JPA 설정
jpa:
hibernate:
ddl-auto: update
ddl-auto: validate
properties:
hibernate:
format_sql: true # 쿼리 줄바꿈
show_sql: true # 쿼리 출력
highlight_sql: true # 쿼리 색상 강조 (가독성 UP)
format_sql: false # 쿼리 줄바꿈
show_sql: false # 쿼리 출력
highlight_sql: false # 쿼리 색상 강조 (가독성 UP)
open-in-view: false # OSIV 종료 (DB 커넥션 최적화)
mail:
@@ -40,17 +45,18 @@ spring:
# 3. Redis 설정
data:
redis:
host: redis # Docker 서비스명
host: ${REDIS_HOST:192.168.0.36}
port: 6379
cloud:
aws:
s3:
bucket: my-blog-bucket
endpoint: http://minio:9000
bucket: blog-bucket
endpoint: https://s3.wypark.me
path-style-access-enabled: true
credentials:
access-key: admin
secret-key: password
access-key: ${S3_ACCESS_KEY}
secret-key: ${S3_SECRET_KEY}
region:
static: ap-northeast-2
stack:
@@ -59,6 +65,6 @@ spring:
# 5. JWT 설정
jwt:
# 보안 경고: 실제 배포 시에는 절대 코드에 비밀키를 남기지 마세요. 환경변수로 주입받으세요.
secret: ${JWT_SECRET:v3ryS3cr3tK3yF0rMyB10gPr0j3ctM4k3ItL0ng3rTh4n32Byt3s!!}
secret: ${JWT_SECRET}
access-token-validity: 600000
refresh-token-validity: 604800000

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<property name="LOG_PATH" value="/mnt/data/blog-server/logs/"/>
<property name="LOG_FILE_NAME" value="blog-server"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) --- [%thread] %cyan(%logger{35}) : %msg%n</pattern>
</encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${LOG_FILE_NAME}.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/archive/${LOG_FILE_NAME}-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
<maxHistory>30</maxHistory>
<totalSizeCap>3GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level --- [%thread] %logger{35} : %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/> <appender-ref ref="FILE"/> </root>
</configuration>