diff --git a/build.gradle.kts b/build.gradle.kts index 307b0cf..f013cd2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-data-redis") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-mail") + implementation("io.github.cdimascio:dotenv-java:3.0.0") // 2. Kotlin Modules implementation("com.fasterxml.jackson.module:jackson-module-kotlin") diff --git a/src/main/kotlin/me/wypark/blogbackend/BlogBackendApplication.kt b/src/main/kotlin/me/wypark/blogbackend/BlogBackendApplication.kt index 915d7e2..74957a5 100644 --- a/src/main/kotlin/me/wypark/blogbackend/BlogBackendApplication.kt +++ b/src/main/kotlin/me/wypark/blogbackend/BlogBackendApplication.kt @@ -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) { + // 1. .env 파일 로드 + val dotenv = Dotenv.configure().ignoreIfMissing().load() + + // 2. 로드한 내용을 시스템 프로퍼티에 설정 (그래야 application.yml에서 ${}로 읽음) + dotenv.entries().forEach { entry -> + System.setProperty(entry.key, entry.value) + } + + // 3. 스프링 실행 runApplication(*args) } diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/PostController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/PostController.kt index d561729..a75212e 100644 --- a/src/main/kotlin/me/wypark/blogbackend/api/controller/PostController.kt +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/PostController.kt @@ -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>> { - 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> { - return ResponseEntity.ok(ApiResponse.success(postService.getPostBySlug(slug))) + val post = postService.getPostBySlug(slug) + return ResponseEntity.ok(ApiResponse.success(post)) } } \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/ProfileController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/ProfileController.kt new file mode 100644 index 0000000..1bc1532 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/ProfileController.kt @@ -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> { + return ResponseEntity.ok(ApiResponse.success(blogProfileService.getProfile())) + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminCategoryController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminCategoryController.kt index 2933da0..30ed70f 100644 --- a/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminCategoryController.kt +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminCategoryController.kt @@ -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> { + categoryService.updateCategory(id, request) + return ResponseEntity.ok(ApiResponse.success(message = "카테고리가 수정되었습니다.")) + } + @DeleteMapping("/{id}") fun deleteCategory(@PathVariable id: Long): ResponseEntity> { categoryService.deleteCategory(id) diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminPostController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminPostController.kt index bc6a791..945e197 100644 --- a/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminPostController.kt +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminPostController.kt @@ -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> { - // 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> { + val postId = postService.updatePost(id, request) + return ResponseEntity.ok(ApiResponse.success(postId, "게시글이 수정되었습니다.")) + } + + @DeleteMapping("/{id}") + fun deletePost(@PathVariable id: Long): ResponseEntity> { + postService.deletePost(id) + return ResponseEntity.ok(ApiResponse.success(message = "게시글과 포함된 이미지가 삭제되었습니다.")) + } } \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminProfileController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminProfileController.kt new file mode 100644 index 0000000..aa400bf --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminProfileController.kt @@ -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> { + blogProfileService.updateProfile(request) + return ResponseEntity.ok(ApiResponse.success(message = "프로필이 수정되었습니다.")) + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/api/dto/CategoryDtos.kt b/src/main/kotlin/me/wypark/blogbackend/api/dto/CategoryDtos.kt index 862d1e8..998ac82 100644 --- a/src/main/kotlin/me/wypark/blogbackend/api/dto/CategoryDtos.kt +++ b/src/main/kotlin/me/wypark/blogbackend/api/dto/CategoryDtos.kt @@ -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, diff --git a/src/main/kotlin/me/wypark/blogbackend/api/dto/CommentDtos.kt b/src/main/kotlin/me/wypark/blogbackend/api/dto/CommentDtos.kt index 75adaca..dccee85 100644 --- a/src/main/kotlin/me/wypark/blogbackend/api/dto/CommentDtos.kt +++ b/src/main/kotlin/me/wypark/blogbackend/api/dto/CommentDtos.kt @@ -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 ) { @@ -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) } ) diff --git a/src/main/kotlin/me/wypark/blogbackend/api/dto/PostDtos.kt b/src/main/kotlin/me/wypark/blogbackend/api/dto/PostDtos.kt index 2958643..f7f9046 100644 --- a/src/main/kotlin/me/wypark/blogbackend/api/dto/PostDtos.kt +++ b/src/main/kotlin/me/wypark/blogbackend/api/dto/PostDtos.kt @@ -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 ) } } diff --git a/src/main/kotlin/me/wypark/blogbackend/api/dto/ProfileDtos.kt b/src/main/kotlin/me/wypark/blogbackend/api/dto/ProfileDtos.kt new file mode 100644 index 0000000..6c268bb --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/api/dto/ProfileDtos.kt @@ -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? +) \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/core/config/CorsConfig.kt b/src/main/kotlin/me/wypark/blogbackend/core/config/CorsConfig.kt index 7000255..0b1dfc3 100644 --- a/src/main/kotlin/me/wypark/blogbackend/core/config/CorsConfig.kt +++ b/src/main/kotlin/me/wypark/blogbackend/core/config/CorsConfig.kt @@ -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) diff --git a/src/main/kotlin/me/wypark/blogbackend/core/config/SecurityConfig.kt b/src/main/kotlin/me/wypark/blogbackend/core/config/SecurityConfig.kt index 0e8bb80..fa37fd6 100644 --- a/src/main/kotlin/me/wypark/blogbackend/core/config/SecurityConfig.kt +++ b/src/main/kotlin/me/wypark/blogbackend/core/config/SecurityConfig.kt @@ -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() } diff --git a/src/main/kotlin/me/wypark/blogbackend/core/config/jwt/JwtProvider.kt b/src/main/kotlin/me/wypark/blogbackend/core/config/jwt/JwtProvider.kt index df2ec34..c79f182 100644 --- a/src/main/kotlin/me/wypark/blogbackend/core/config/jwt/JwtProvider.kt +++ b/src/main/kotlin/me/wypark/blogbackend/core/config/jwt/JwtProvider.kt @@ -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() diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/auth/CustomUserDetails.kt b/src/main/kotlin/me/wypark/blogbackend/domain/auth/CustomUserDetails.kt new file mode 100644 index 0000000..7da1c12 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/auth/CustomUserDetails.kt @@ -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 +) : User(username, password, authorities) \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/auth/CustomUserDetailsService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/auth/CustomUserDetailsService.kt index 08e1c37..7c047ce 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/auth/CustomUserDetailsService.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/auth/CustomUserDetailsService.kt @@ -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)) ) } } \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/category/Category.kt b/src/main/kotlin/me/wypark/blogbackend/domain/category/Category.kt index 2a7a3a7..b8fb135 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/category/Category.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/category/Category.kt @@ -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) + } } \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/category/CategoryService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/category/CategoryService.kt index 853ffe6..b6a0966 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/category/CategoryService.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/category/CategoryService.kt @@ -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 { 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() + collectAllCategories(category, categoriesToDelete) + + postRepository.bulkUpdateCategoryToNull(categoriesToDelete) + + categoryRepository.delete(category) + } + + private fun collectAllCategories(category: Category, list: MutableList) { + list.add(category) + category.children.forEach { collectAllCategories(it, list) } } } \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/image/ImageService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/image/ImageService.kt index cfb9ac6..b4ff4e3 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/image/ImageService.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/image/ImageService.kt @@ -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) diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/post/Post.kt b/src/main/kotlin/me/wypark/blogbackend/domain/post/Post.kt index 0bf8591..eef8b75 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/post/Post.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/post/Post.kt @@ -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 = 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) { + 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 = mutableListOf() - - fun addTags(newTags: List) { - this.tags.clear() + // 👈 [추가] 태그 전체 교체 편의 메서드 + fun updateTags(newTags: List) { + this.tags.clear() // orphanRemoval = true 덕분에 기존 태그 매핑이 삭제됨 this.tags.addAll(newTags) } } \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepository.kt b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepository.kt index d875bbd..1f7b7d1 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepository.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepository.kt @@ -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, PostRepositoryCustom{ +interface PostRepository : JpaRepository, PostRepositoryCustom { // 1. Slug로 상세 조회 (URL이 깔끔해짐) fun findBySlug(slug: String): Post? @@ -18,4 +21,9 @@ interface PostRepository : JpaRepository, PostRepositoryCustom{ // 4. 특정 카테고리의 글 목록 조회 fun findAllByCategory(category: Category, pageable: Pageable): Page + + // 5. [추가] 카테고리 삭제 시 해당 카테고리(및 하위)에 속한 글들의 카테고리를 null로 변경 (미분류 처리) + @Modifying(clearAutomatically = true) + @Query("UPDATE Post p SET p.category = null WHERE p.category IN :categories") + fun bulkUpdateCategoryToNull(@Param("categories") categories: List) } \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryCustom.kt b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryCustom.kt index 7a20994..6849394 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryCustom.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryCustom.kt @@ -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 + // categoryName(String) -> categoryNames(List) 변경 + fun search(keyword: String?, categoryNames: List?, tagName: String?, pageable: Pageable): Page } \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryImpl.kt b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryImpl.kt index d002920..e057986 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryImpl.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryImpl.kt @@ -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 { - // 1. 동적 필터링 조건 + override fun search(keyword: String?, categoryNames: List?, tagName: String?, pageable: Pageable): Page { 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?): 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> { val orders = mutableListOf>() if (!pageable.sort.isEmpty) { for (order in pageable.sort) { val direction = if (order.direction.isAscending) Order.ASC else Order.DESC - - // 들어온 정렬 기준값(property)에 따라 QClass 필드 매핑 when (order.property) { - "viewCount" -> orders.add(OrderSpecifier(direction, post.viewCount)) // 인기순 - "createdAt" -> orders.add(OrderSpecifier(direction, post.createdAt)) // 최신순 + "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)) } } } diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostService.kt index 2c4cf77..6a21f9a 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostService.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostService.kt @@ -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 { 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 { - 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, post: Post): List { + 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 { + val regex = Regex("!\\[.*?\\]\\((.*?)\\)") + return regex.findAll(content) + .map { it.groupValues[1] } + .map { it.substringAfterLast("/") } + .toList() + } + + private fun getCategoryAndDescendants(categoryName: String): List { + if (categoryName.equals("uncategorized", ignoreCase = true)) { + return listOf("uncategorized") + } + + val category = categoryRepository.findByName(categoryName) + if (category == null) return listOf(categoryName) + + val names = mutableListOf() + collectCategoryNames(category, names) + return names + } + + private fun collectCategoryNames(category: Category, names: MutableList) { + names.add(category.name) + category.children.forEach { collectCategoryNames(it, names) } } } \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/profile/BlogProfile.kt b/src/main/kotlin/me/wypark/blogbackend/domain/profile/BlogProfile.kt new file mode 100644 index 0000000..b904ca4 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/profile/BlogProfile.kt @@ -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 + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/profile/BlogProfileRepository.kt b/src/main/kotlin/me/wypark/blogbackend/domain/profile/BlogProfileRepository.kt new file mode 100644 index 0000000..350eb91 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/profile/BlogProfileRepository.kt @@ -0,0 +1,6 @@ +package me.wypark.blogbackend.domain.profile + +import org.springframework.data.jpa.repository.JpaRepository + +interface BlogProfileRepository : JpaRepository { +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/profile/BlogProfileService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/profile/BlogProfileService.kt new file mode 100644 index 0000000..2379c29 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/profile/BlogProfileService.kt @@ -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 + ) + } +} \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index fa27978..776639a 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -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 \ No newline at end of file diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..7f7e552 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,30 @@ + + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) --- [%thread] %cyan(%logger{35}) : %msg%n + + + + + ${LOG_PATH}/${LOG_FILE_NAME}.log + + + ${LOG_PATH}/archive/${LOG_FILE_NAME}-%d{yyyy-MM-dd}.log.gz + + 30 + + 3GB + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level --- [%thread] %logger{35} : %msg%n + + + + + + \ No newline at end of file