.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = "게시글과 포함된 이미지가 삭제되었습니다."))
|
||||
}
|
||||
}
|
||||
@@ -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 = "프로필이 수정되었습니다."))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
31
src/main/kotlin/me/wypark/blogbackend/api/dto/ProfileDtos.kt
Normal file
31
src/main/kotlin/me/wypark/blogbackend/api/dto/ProfileDtos.kt
Normal 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?
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package me.wypark.blogbackend.domain.profile
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface BlogProfileRepository : JpaRepository<BlogProfile, Long> {
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
30
src/main/resources/logback-spring.xml
Normal file
30
src/main/resources/logback-spring.xml
Normal 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>
|
||||
Reference in New Issue
Block a user