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

@@ -31,6 +31,7 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-redis") implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-mail") implementation("org.springframework.boot:spring-boot-starter-mail")
implementation("io.github.cdimascio:dotenv-java:3.0.0")
// 2. Kotlin Modules // 2. Kotlin Modules
implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("com.fasterxml.jackson.module:jackson-module-kotlin")

View File

@@ -1,5 +1,6 @@
package me.wypark.blogbackend package me.wypark.blogbackend
import io.github.cdimascio.dotenv.Dotenv
import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication import org.springframework.boot.runApplication
@@ -7,5 +8,14 @@ import org.springframework.boot.runApplication
class BlogBackendApplication class BlogBackendApplication
fun main(args: Array<String>) { 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) runApplication<BlogBackendApplication>(*args)
} }

View File

@@ -17,22 +17,28 @@ class PostController(
private val postService: PostService private val postService: PostService
) { ) {
// 목록 조회 (기본값: 최신순, 10개씩)
@GetMapping @GetMapping
fun getPosts( fun getPosts(
@RequestParam(required = false) keyword: String?, @RequestParam(required = false) keyword: String?,
@RequestParam(required = false) category: String?, @RequestParam(required = false) category: String?, // 👈 프론트는 'category'로 보냄
@RequestParam(required = false) tag: String?, // 👈 파라미터 추가 @RequestParam(required = false) tag: String?,
@PageableDefault(size = 10, sort = ["id"], direction = Sort.Direction.DESC) pageable: Pageable @PageableDefault(size = 10, sort = ["createdAt"], direction = Sort.Direction.DESC) pageable: Pageable
): ResponseEntity<ApiResponse<Page<PostSummaryResponse>>> { ): ResponseEntity<ApiResponse<Page<PostSummaryResponse>>> {
val result = postService.searchPosts(keyword, category, tag, pageable) // 검색 조건이 하나라도 있으면 searchPosts 호출 (검색 + 카테고리 필터링)
return ResponseEntity.ok(ApiResponse.success(result)) 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}") @GetMapping("/{slug}")
fun getPost(@PathVariable slug: String): ResponseEntity<ApiResponse<PostResponse>> { 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.common.ApiResponse
import me.wypark.blogbackend.api.dto.CategoryCreateRequest import me.wypark.blogbackend.api.dto.CategoryCreateRequest
import me.wypark.blogbackend.api.dto.CategoryUpdateRequest
import me.wypark.blogbackend.domain.category.CategoryService import me.wypark.blogbackend.domain.category.CategoryService
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
@@ -18,6 +19,16 @@ class AdminCategoryController(
return ResponseEntity.ok(ApiResponse.success(id, "카테고리가 생성되었습니다.")) 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}") @DeleteMapping("/{id}")
fun deleteCategory(@PathVariable id: Long): ResponseEntity<ApiResponse<Nothing>> { fun deleteCategory(@PathVariable id: Long): ResponseEntity<ApiResponse<Nothing>> {
categoryService.deleteCategory(id) categoryService.deleteCategory(id)

View File

@@ -7,10 +7,7 @@ import me.wypark.blogbackend.domain.post.PostService
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.User import org.springframework.security.core.userdetails.User
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.*
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController @RestController
@RequestMapping("/api/admin/posts") @RequestMapping("/api/admin/posts")
@@ -23,8 +20,23 @@ class AdminPostController(
@RequestBody @Valid request: PostSaveRequest, @RequestBody @Valid request: PostSaveRequest,
@AuthenticationPrincipal user: User @AuthenticationPrincipal user: User
): ResponseEntity<ApiResponse<Long>> { ): ResponseEntity<ApiResponse<Long>> {
// user.username은 email입니다.
val postId = postService.createPost(request, user.username) val postId = postService.createPost(request, user.username)
return ResponseEntity.ok(ApiResponse.success(postId, "게시글이 작성되었습니다.")) 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) 카테고리 val parentId: Long? = null // null이면 최상위(Root) 카테고리
) )
// [요청] 카테고리 수정 (이름 + 부모 이동)
data class CategoryUpdateRequest(
val name: String,
val parentId: Long? // null이면 최상위(Root)로 이동
)
// [응답] 카테고리 트리 구조 (재귀) // [응답] 카테고리 트리 구조 (재귀)
data class CategoryResponse( data class CategoryResponse(
val id: Long, val id: Long,

View File

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

View File

@@ -36,7 +36,8 @@ data class PostSummaryResponse(
val slug: String, val slug: String,
val categoryName: String?, val categoryName: String?,
val viewCount: Long, val viewCount: Long,
val createdAt: LocalDateTime val createdAt: LocalDateTime,
val content: String?
) { ) {
companion object { companion object {
fun from(post: Post): PostSummaryResponse { fun from(post: Post): PostSummaryResponse {
@@ -46,7 +47,8 @@ data class PostSummaryResponse(
slug = post.slug, slug = post.slug,
categoryName = post.category?.name, categoryName = post.category?.name,
viewCount = post.viewCount, 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 source = UrlBasedCorsConfigurationSource()
val config = CorsConfiguration() val config = CorsConfiguration()
config.allowCredentials = true // 쿠키/토큰 허용 config.allowCredentials = true
config.addAllowedOriginPattern("*") // 개발용 (배포 시 프론트 도메인으로 변경 추천) config.addAllowedOrigin("https://blog.wypark.me") // 프론트 도메인
config.addAllowedHeader("*") config.addAllowedHeader("*") // 클라이언트가 보내는 모든 헤더 허용 (Authorization 포함)
config.addAllowedMethod("*") // GET, POST, PUT, DELETE 등 모두 허용 config.addAllowedMethod("*")
// [중요] 클라이언트가 응답 헤더에서 'Authorization'이나 커스텀 토큰 헤더를 읽을 수 있게 허용
config.addExposedHeader("Authorization")
config.addExposedHeader("Refresh-Token") // 리프레시 토큰도 헤더로 준다면 추가
source.registerCorsConfiguration("/api/**", config) source.registerCorsConfiguration("/api/**", config)
return CorsFilter(source) return CorsFilter(source)

View File

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

View File

@@ -4,6 +4,7 @@ import io.jsonwebtoken.*
import io.jsonwebtoken.io.Decoders import io.jsonwebtoken.io.Decoders
import io.jsonwebtoken.security.Keys import io.jsonwebtoken.security.Keys
import me.wypark.blogbackend.api.dto.TokenDto 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.beans.factory.annotation.Value
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication import org.springframework.security.core.Authentication
@@ -27,11 +28,18 @@ class JwtProvider(
val authorities = authentication.authorities.joinToString(",") { it.authority } val authorities = authentication.authorities.joinToString(",") { it.authority }
val now = Date().time val now = Date().time
// 👇 [수정] Principal을 CustomUserDetails로 캐스팅하여 정보 추출
val principal = authentication.principal as CustomUserDetails
val memberId = principal.memberId
val nickname = principal.nickname
// Access Token 생성 // Access Token 생성
val accessTokenExpiresIn = Date(now + accessTokenValidity) val accessTokenExpiresIn = Date(now + accessTokenValidity)
val accessToken = Jwts.builder() val accessToken = Jwts.builder()
.subject(authentication.name) // email 또는 id .subject(authentication.name) // email
.claim("auth", authorities) // 권한 정보 (ROLE_USER 등) .claim("auth", authorities) // 권한 정보 (ROLE_USER 등)
.claim("memberId", memberId) // 👈 [추가] 프론트엔드 식별용 ID
.claim("nickname", nickname) // 👈 [추가] 프론트엔드 표기용 닉네임
.expiration(accessTokenExpiresIn) .expiration(accessTokenExpiresIn)
.signWith(key) .signWith(key)
.compact() .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.Member
import me.wypark.blogbackend.domain.user.MemberRepository import me.wypark.blogbackend.domain.user.MemberRepository
import org.springframework.security.core.authority.SimpleGrantedAuthority 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.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException import org.springframework.security.core.userdetails.UsernameNotFoundException
@@ -21,10 +20,13 @@ class CustomUserDetailsService(
} }
private fun createUserDetails(member: Member): UserDetails { private fun createUserDetails(member: Member): UserDetails {
return User( // [수정] 표준 User 객체 대신, ID와 닉네임을 포함하는 CustomUserDetails 반환
member.email, return CustomUserDetails(
member.password, memberId = member.id!!, // 토큰에 넣을 ID
listOf(SimpleGrantedAuthority(member.role.name)) 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) this.children.add(child)
child.parent = this 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.CategoryCreateRequest
import me.wypark.blogbackend.api.dto.CategoryResponse 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.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@@ -9,53 +11,108 @@ import org.springframework.transaction.annotation.Transactional
@Service @Service
@Transactional(readOnly = true) @Transactional(readOnly = true)
class CategoryService( class CategoryService(
private val categoryRepository: CategoryRepository private val categoryRepository: CategoryRepository,
private val postRepository: PostRepository
) { ) {
/** // 예약어 검증 메서드
* [Public] 카테고리 트리 조회 private fun validateReservedName(name: String) {
* 최상위(Root)만 조회하면, Entity 설정을 통해 자식들도 딸려옵니다. if (name.equals("uncategorized", ignoreCase = true)) {
*/ throw IllegalArgumentException("'uncategorized'는 시스템 예약어이므로 사용할 수 없습니다.")
}
}
fun getCategoryTree(): List<CategoryResponse> { fun getCategoryTree(): List<CategoryResponse> {
val roots = categoryRepository.findAllRoots() val roots = categoryRepository.findAllRoots()
return roots.map { CategoryResponse.from(it) } return roots.map { CategoryResponse.from(it) }
} }
/**
* [Admin] 카테고리 생성
*/
@Transactional @Transactional
fun createCategory(request: CategoryCreateRequest): Long { fun createCategory(request: CategoryCreateRequest): Long {
// 이름 중복 체크 (선택 사항이지만 추천) // 1. 예약어 검증
validateReservedName(request.name)
// 2. 중복 체크
if (categoryRepository.existsByName(request.name)) { if (categoryRepository.existsByName(request.name)) {
throw IllegalArgumentException("이미 존재하는 카테고리 이름입니다.") throw IllegalArgumentException("이미 존재하는 카테고리 이름입니다.")
} }
// 부모 카테고리 확인
val parent = request.parentId?.let { val parent = request.parentId?.let {
categoryRepository.findByIdOrNull(it) categoryRepository.findByIdOrNull(it)
?: throw IllegalArgumentException("부모 카테고리가 존재하지 않습니다.") ?: throw IllegalArgumentException("부모 카테고리가 존재하지 않습니다.")
} }
// 카테고리 생성
val category = Category( val category = Category(
name = request.name, name = request.name,
parent = parent parent = parent
) )
// 부모와 연결 (연관관계 편의 메서드 활용)
parent?.addChild(category) parent?.addChild(category)
return categoryRepository.save(category).id!! return categoryRepository.save(category).id!!
} }
/** @Transactional
* [Admin] 카테고리 삭제 (선택 구현) fun updateCategory(id: Long, request: CategoryUpdateRequest) {
* 자식이 있는 카테고리를 지울 때 어떻게 할지(전부 삭제? 연결 해제?) 정책 결정 필요 val category = categoryRepository.findByIdOrNull(id)
* 여기서는 일단 간단하게 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 @Transactional
fun deleteCategory(id: Long) { 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 org.springframework.web.multipart.MultipartFile
import software.amazon.awssdk.core.sync.RequestBody import software.amazon.awssdk.core.sync.RequestBody
import software.amazon.awssdk.services.s3.S3Client 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 software.amazon.awssdk.services.s3.model.PutObjectRequest
import java.util.* import java.util.*
@@ -36,11 +36,24 @@ class ImageService(
s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(file.inputStream, file.size)) s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(file.inputStream, file.size))
// 3. 접속 가능한 URL 반환 // 3. 접속 가능한 URL 반환
// 로컬 개발 환경에서는 localhost 주소를 직접 조합해서 줍니다.
// 배포 시에는 실제 도메인이나 CloudFront 주소로 변경해야 합니다.
return "$endpoint/$bucketName/$fileName" 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() { private fun createBucketIfNotExists() {
try { try {
// 버킷 존재 여부 확인 (없으면 에러 발생하므로 try-catch) // 버킷 존재 여부 확인 (없으면 에러 발생하므로 try-catch)

View File

@@ -11,35 +11,39 @@ class Post(
@Column(nullable = false) @Column(nullable = false)
var title: String, var title: String,
// 마크다운 본문 (대용량 저장을 위해 TEXT 타입 지정) @Column(columnDefinition = "TEXT", nullable = false)
@Column(nullable = false, columnDefinition = "TEXT")
var content: String, var content: String,
@Column(nullable = false, unique = true) @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) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id") @JoinColumn(name = "member_id")
val member: Member, // 작성자 (관리자) val member: Member,
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id") @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) @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null val id: Long? = null
@Column(nullable = false)
var viewCount: Long = 0
// 조회수 증가
fun increaseViewCount() { fun increaseViewCount() {
this.viewCount++ this.viewCount++
} }
// 게시글 수정 fun addTags(postTags: List<PostTag>) {
this.tags.addAll(postTags)
}
// 👈 [추가] 게시글 수정 메서드
fun update(title: String, content: String, slug: String, category: Category?) { fun update(title: String, content: String, slug: String, category: Category?) {
this.title = title this.title = title
this.content = content this.content = content
@@ -47,11 +51,9 @@ class Post(
this.category = category this.category = category
} }
@OneToMany(mappedBy = "post", cascade = [CascadeType.ALL], orphanRemoval = true) // 👈 [추가] 태그 전체 교체 편의 메서드
var tags: MutableList<PostTag> = mutableListOf() fun updateTags(newTags: List<PostTag>) {
this.tags.clear() // orphanRemoval = true 덕분에 기존 태그 매핑이 삭제됨
fun addTags(newTags: List<PostTag>) {
this.tags.clear()
this.tags.addAll(newTags) 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.Page
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository 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이 깔끔해짐) // 1. Slug로 상세 조회 (URL이 깔끔해짐)
fun findBySlug(slug: String): Post? fun findBySlug(slug: String): Post?
@@ -18,4 +21,9 @@ interface PostRepository : JpaRepository<Post, Long>, PostRepositoryCustom{
// 4. 특정 카테고리의 글 목록 조회 // 4. 특정 카테고리의 글 목록 조회
fun findAllByCategory(category: Category, pageable: Pageable): Page<Post> 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 import org.springframework.data.domain.Pageable
interface PostRepositoryCustom { 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.OrderSpecifier
import com.querydsl.core.types.Projections import com.querydsl.core.types.Projections
import com.querydsl.core.types.dsl.BooleanExpression import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.core.types.dsl.PathBuilder
import com.querydsl.jpa.impl.JPAQueryFactory import com.querydsl.jpa.impl.JPAQueryFactory
import me.wypark.blogbackend.api.dto.PostSummaryResponse import me.wypark.blogbackend.api.dto.PostSummaryResponse
import me.wypark.blogbackend.domain.post.QPost.post 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.Page
import org.springframework.data.domain.PageImpl import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.Pageable 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( class PostRepositoryImpl(
private val queryFactory: JPAQueryFactory private val queryFactory: JPAQueryFactory
) : PostRepositoryCustom { ) : PostRepositoryCustom {
override fun search(keyword: String?, categoryName: String?, tagName: String?, pageable: Pageable): Page<PostSummaryResponse> { override fun search(keyword: String?, categoryNames: List<String>?, tagName: String?, pageable: Pageable): Page<PostSummaryResponse> {
// 1. 동적 필터링 조건
val builder = BooleanBuilder() val builder = BooleanBuilder()
builder.and(containsKeyword(keyword)) 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 val query = queryFactory
.select( .select(
Projections.constructor( Projections.constructor(
@@ -34,31 +34,31 @@ class PostRepositoryImpl(
post.id, post.id,
post.title, post.title,
post.slug, post.slug,
post.category.name, category.name, // 👈 post.category.name 대신 alias 사용 (NULL 안전)
post.viewCount, post.viewCount,
post.createdAt post.createdAt,
post.content // 👈 [추가] 미리보기를 위해 본문 내용도 조회합니다.
) )
) )
.from(post) .from(post)
// 👇 태그 검색을 위해 테이블 Join .leftJoin(post.category, category) // 👈 명시적 Left Join 추가 (카테고리 없어도 글 조회 가능하게 함)
.leftJoin(post.tags, postTag) .leftJoin(post.tags, postTag)
.leftJoin(postTag.tag, tag) .leftJoin(postTag.tag, tag)
.where(builder) .where(builder)
.distinct() // ⭐ 중요: 하나의 글에 태그가 여러 개면 글이 중복 조회될 수 있어서 제거 .distinct()
.offset(pageable.offset) .offset(pageable.offset)
.limit(pageable.pageSize.toLong()) .limit(pageable.pageSize.toLong())
// 3. 정렬 적용
for (order in getOrderSpecifiers(pageable)) { for (order in getOrderSpecifiers(pageable)) {
query.orderBy(order) query.orderBy(order)
} }
val content = query.fetch() val content = query.fetch()
// 4. 전체 개수 (Count 쿼리에도 Join 필요)
val total = queryFactory val total = queryFactory
.select(post.countDistinct()) // ⭐ 개수 셀 때도 중복 제거 .select(post.countDistinct())
.from(post) .from(post)
.leftJoin(post.category, category) // 👈 Count 쿼리에도 Left Join 추가
.leftJoin(post.tags, postTag) .leftJoin(post.tags, postTag)
.leftJoin(postTag.tag, tag) .leftJoin(postTag.tag, tag)
.where(builder) .where(builder)
@@ -67,9 +67,6 @@ class PostRepositoryImpl(
return PageImpl(content, pageable, total) return PageImpl(content, pageable, total)
} }
// --- 조건 메서드들 ---
// 검색어 (제목 or 본문)
private fun containsKeyword(keyword: String?): BooleanBuilder { private fun containsKeyword(keyword: String?): BooleanBuilder {
val builder = BooleanBuilder() val builder = BooleanBuilder()
if (!keyword.isNullOrBlank()) { if (!keyword.isNullOrBlank()) {
@@ -79,31 +76,59 @@ class PostRepositoryImpl(
return builder return builder
} }
// 카테고리 일치 (카테고리명이 없으면 무시) private fun inCategoryNames(categoryNames: List<String>?): BooleanExpression? {
private fun eqCategory(categoryName: String?): BooleanExpression? { if (categoryNames.isNullOrEmpty()) return null
if (categoryName.isNullOrBlank()) return null
return post.category.name.eq(categoryName) // 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? { private fun eqTagName(tagName: String?): BooleanExpression? {
if (tagName.isNullOrBlank()) return null 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<*>> { private fun getOrderSpecifiers(pageable: Pageable): List<OrderSpecifier<*>> {
val orders = mutableListOf<OrderSpecifier<*>>() val orders = mutableListOf<OrderSpecifier<*>>()
if (!pageable.sort.isEmpty) { if (!pageable.sort.isEmpty) {
for (order in pageable.sort) { for (order in pageable.sort) {
val direction = if (order.direction.isAscending) Order.ASC else Order.DESC val direction = if (order.direction.isAscending) Order.ASC else Order.DESC
// 들어온 정렬 기준값(property)에 따라 QClass 필드 매핑
when (order.property) { when (order.property) {
"viewCount" -> orders.add(OrderSpecifier(direction, post.viewCount)) // 인기순 "viewCount" -> orders.add(OrderSpecifier(direction, post.viewCount))
"createdAt" -> orders.add(OrderSpecifier(direction, post.createdAt)) // 최신순 "createdAt" -> orders.add(OrderSpecifier(direction, post.createdAt))
"id" -> orders.add(OrderSpecifier(direction, post.id)) "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.PostResponse
import me.wypark.blogbackend.api.dto.PostSaveRequest import me.wypark.blogbackend.api.dto.PostSaveRequest
import me.wypark.blogbackend.api.dto.PostSummaryResponse 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.category.CategoryRepository
import me.wypark.blogbackend.domain.image.ImageService
import me.wypark.blogbackend.domain.tag.PostTag import me.wypark.blogbackend.domain.tag.PostTag
import me.wypark.blogbackend.domain.tag.Tag import me.wypark.blogbackend.domain.tag.Tag
import me.wypark.blogbackend.domain.tag.TagRepository import me.wypark.blogbackend.domain.tag.TagRepository
@@ -20,33 +22,25 @@ class PostService(
private val postRepository: PostRepository, private val postRepository: PostRepository,
private val categoryRepository: CategoryRepository, private val categoryRepository: CategoryRepository,
private val memberRepository: MemberRepository, private val memberRepository: MemberRepository,
private val tagRepository: TagRepository private val tagRepository: TagRepository,
private val imageService: ImageService
) { ) {
/**
* [Public] 전체 게시글 목록 조회 (페이징)
*/
fun getPosts(pageable: Pageable): Page<PostSummaryResponse> { fun getPosts(pageable: Pageable): Page<PostSummaryResponse> {
return postRepository.findAll(pageable) return postRepository.findAll(pageable)
.map { PostSummaryResponse.from(it) } .map { PostSummaryResponse.from(it) }
} }
/**
* [Public] 게시글 상세 조회 (Slug 기반) + 조회수 증가
*/
@Transactional @Transactional
fun getPostBySlug(slug: String): PostResponse { fun getPostBySlug(slug: String): PostResponse {
val post = postRepository.findBySlug(slug) val post = postRepository.findBySlug(slug)
?: throw IllegalArgumentException("해당 게시글을 찾을 수 없습니다: $slug") ?: throw IllegalArgumentException("해당 게시글을 찾을 수 없습니다: $slug")
post.increaseViewCount() // 조회수 1 증가 (Dirty Checking) post.increaseViewCount()
return PostResponse.from(post) return PostResponse.from(post)
} }
/**
* [Admin] 게시글 작성
*/
@Transactional @Transactional
fun createPost(request: PostSaveRequest, email: String): Long { fun createPost(request: PostSaveRequest, email: String): Long {
val member = memberRepository.findByEmail(email) val member = memberRepository.findByEmail(email)
@@ -54,47 +48,132 @@ class PostService(
val category = request.categoryId?.let { categoryRepository.findByIdOrNull(it) } val category = request.categoryId?.let { categoryRepository.findByIdOrNull(it) }
val rawSlug = if (!request.slug.isNullOrBlank()) { // Slug 생성 로직
request.slug val uniqueSlug = generateUniqueSlug(request.slug, request.title)
} 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( val post = Post(
title = request.title, title = request.title,
content = request.content, content = request.content,
slug = uniqueSlug, // 👈 중복 처리된 슬러그 slug = uniqueSlug,
member = member, member = member,
category = category category = category
) )
// 3. 태그 처리 (작성하신 로직 그대로 활용) val postTags = resolveTags(request.tags, post)
// 리스트를 순회하며 없으면 저장(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) post.addTags(postTags)
return postRepository.save(post).id!! 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> { 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: application:
name: blog-api name: blog-api
servlet:
multipart:
max-file-size: 100MB # 파일 하나당 최대 크기 (기본 1MB -> 10MB로 증량)
max-request-size: 100MB # 요청 전체 최대 크기 (여러 파일 합산)
# 1. 데이터베이스 설정 # 1. 데이터베이스 설정
datasource: datasource:
# Docker 내부 통신용 # Docker 내부 통신용
url: jdbc:postgresql://db:5432/blog_db url: jdbc:postgresql://${SPRING_DATASOURCE_URL}
username: ${SPRING_DATASOURCE_USERNAME:wypark} username: ${SPRING_DATASOURCE_USERNAME}
password: ${SPRING_DATASOURCE_PASSWORD:your_password} password: ${SPRING_DATASOURCE_PASSWORD}
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
# 2. JPA 설정 # 2. JPA 설정
jpa: jpa:
hibernate: hibernate:
ddl-auto: update ddl-auto: validate
properties: properties:
hibernate: hibernate:
format_sql: true # 쿼리 줄바꿈 format_sql: false # 쿼리 줄바꿈
show_sql: true # 쿼리 출력 show_sql: false # 쿼리 출력
highlight_sql: true # 쿼리 색상 강조 (가독성 UP) highlight_sql: false # 쿼리 색상 강조 (가독성 UP)
open-in-view: false # OSIV 종료 (DB 커넥션 최적화) open-in-view: false # OSIV 종료 (DB 커넥션 최적화)
mail: mail:
@@ -40,17 +45,18 @@ spring:
# 3. Redis 설정 # 3. Redis 설정
data: data:
redis: redis:
host: redis # Docker 서비스명 host: ${REDIS_HOST:192.168.0.36}
port: 6379 port: 6379
cloud: cloud:
aws: aws:
s3: s3:
bucket: my-blog-bucket bucket: blog-bucket
endpoint: http://minio:9000 endpoint: https://s3.wypark.me
path-style-access-enabled: true
credentials: credentials:
access-key: admin access-key: ${S3_ACCESS_KEY}
secret-key: password secret-key: ${S3_SECRET_KEY}
region: region:
static: ap-northeast-2 static: ap-northeast-2
stack: stack:
@@ -59,6 +65,6 @@ spring:
# 5. JWT 설정 # 5. JWT 설정
jwt: jwt:
# 보안 경고: 실제 배포 시에는 절대 코드에 비밀키를 남기지 마세요. 환경변수로 주입받으세요. # 보안 경고: 실제 배포 시에는 절대 코드에 비밀키를 남기지 마세요. 환경변수로 주입받으세요.
secret: ${JWT_SECRET:v3ryS3cr3tK3yF0rMyB10gPr0j3ctM4k3ItL0ng3rTh4n32Byt3s!!} secret: ${JWT_SECRET}
access-token-validity: 600000 access-token-validity: 600000
refresh-token-validity: 604800000 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>