주석 수정
This commit is contained in:
@@ -1,15 +1,38 @@
|
||||
package me.wypark.blogbackend.api.common
|
||||
|
||||
/**
|
||||
* [API 공통 응답 규격]
|
||||
*
|
||||
* 클라이언트(Frontend)와 서버 간의 통신 프로토콜을 통일하기 위한 Wrapper 클래스입니다.
|
||||
* 모든 REST API 응답은 이 클래스로 감싸서 반환되며, 이를 통해 예외 발생 시에도
|
||||
* 일관된 JSON 구조를 보장하여 클라이언트의 에러 핸들링 복잡도를 낮춥니다.
|
||||
*
|
||||
* @param T 실제 응답 데이터의 타입 (Generic)
|
||||
*/
|
||||
data class ApiResponse<T>(
|
||||
// 비즈니스 로직 처리 결과 코드 (HTTP Status와는 별개로 세부적인 에러 코드를 정의하여 사용 가능)
|
||||
val code: String = "SUCCESS",
|
||||
|
||||
// 클라이언트에게 노출할 알림 메시지 (Toast UI 등에서 활용)
|
||||
val message: String = "요청이 성공했습니다.",
|
||||
|
||||
// 실제 전송할 데이터 Payload (실패 시 null)
|
||||
val data: T? = null
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* 성공 응답을 생성하는 정적 팩토리 메서드입니다.
|
||||
* 데이터가 없는 경우(예: 삭제/수정 완료)에도 일관된 형식을 유지하기 위해 기본 메시지를 제공합니다.
|
||||
*/
|
||||
fun <T> success(data: T? = null, message: String = "요청이 성공했습니다."): ApiResponse<T> {
|
||||
return ApiResponse("SUCCESS", message, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패 응답을 생성하는 정적 팩토리 메서드입니다.
|
||||
* 에러 상황에서는 data 필드가 불필요하므로, <Nothing> 타입을 사용하여
|
||||
* 타입 안정성(Type Safety)을 확보하고 불필요한 객체 생성을 방지합니다.
|
||||
*/
|
||||
fun error(message: String, code: String = "ERROR"): ApiResponse<Nothing> {
|
||||
return ApiResponse(code, message, null)
|
||||
}
|
||||
|
||||
@@ -9,41 +9,77 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.core.userdetails.User
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
/**
|
||||
* [인증/인가 컨트롤러]
|
||||
*
|
||||
* JWT(Json Web Token) 기반의 Stateless 인증 처리를 담당하는 엔드포인트 집합입니다.
|
||||
* 표준적인 Access/Refresh Token 패턴을 사용하며, 보안 강화를 위해
|
||||
* Refresh Token Rotation(RTR) 전략을 적용하여 탈취된 토큰의 재사용을 방지합니다.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
class AuthController(
|
||||
private val authService: AuthService
|
||||
) {
|
||||
|
||||
/**
|
||||
* 신규 회원 가입을 요청합니다.
|
||||
*
|
||||
* 봇(Bot)이나 무분별한 가입을 방지하기 위해, 가입 요청 즉시 인증 메일을 발송합니다.
|
||||
* 이메일 인증이 완료(`isVerified = true`)되기 전까지는 로그인이 제한됩니다.
|
||||
*/
|
||||
@PostMapping("/signup")
|
||||
fun signup(@RequestBody @Valid request: SignupRequest): ResponseEntity<ApiResponse<Nothing>> {
|
||||
authService.signup(request)
|
||||
return ResponseEntity.ok(ApiResponse.success(message = "회원가입에 성공했습니다. 이메일 인증을 완료해주세요."))
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일 인증 코드를 검증하여 계정을 활성화합니다.
|
||||
* Redis에 TTL(Time-To-Live)로 저장된 임시 코드와 사용자의 입력을 대조합니다.
|
||||
*/
|
||||
@PostMapping("/verify")
|
||||
fun verifyEmail(@RequestBody @Valid request: VerifyEmailRequest): ResponseEntity<ApiResponse<Nothing>> {
|
||||
authService.verifyEmail(request.email, request.code)
|
||||
return ResponseEntity.ok(ApiResponse.success(message = "이메일 인증이 완료되었습니다."))
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 자격 증명(Email/Password)을 검증하고 토큰 쌍을 발급합니다.
|
||||
* 인증 성공 시 Access Token과 Refresh Token이 모두 반환됩니다.
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
fun login(@RequestBody @Valid request: LoginRequest): ResponseEntity<ApiResponse<TokenDto>> {
|
||||
val tokenDto = authService.login(request)
|
||||
return ResponseEntity.ok(ApiResponse.success(tokenDto))
|
||||
}
|
||||
|
||||
/**
|
||||
* Access Token 만료 시, Refresh Token을 사용하여 토큰을 갱신합니다 (Silent Refresh).
|
||||
*
|
||||
* [보안 전략: Refresh Token Rotation]
|
||||
* 토큰 갱신 시 기존 Refresh Token은 폐기되고 새로운 Refresh Token이 발급됩니다.
|
||||
* 만약 이미 폐기된 토큰으로 재요청이 들어올 경우, 탈취된 것으로 간주하여 해당 유저의 모든 토큰을 무효화합니다.
|
||||
*/
|
||||
@PostMapping("/reissue")
|
||||
fun reissue(@RequestBody request: ReissueRequest): ResponseEntity<ApiResponse<TokenDto>> {
|
||||
val tokenDto = authService.reissue(request.accessToken, request.refreshToken)
|
||||
return ResponseEntity.ok(ApiResponse.success(tokenDto))
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃 처리를 수행합니다.
|
||||
*
|
||||
* JWT 특성상 클라이언트가 토큰을 삭제하는 것이 기본이지만,
|
||||
* 서버 측에서도 Redis에 저장된 Refresh Token을 즉시 삭제(Evict)하여
|
||||
* 더 이상 해당 토큰으로 액세스 토큰을 재발급받지 못하도록 차단합니다.
|
||||
*/
|
||||
@PostMapping("/logout")
|
||||
fun logout(@AuthenticationPrincipal user: User): ResponseEntity<ApiResponse<Nothing>> {
|
||||
authService.logout(user.username) // user.username은 email입니다.
|
||||
authService.logout(user.username) // user.username은 SecurityContext에 저장된 email입니다.
|
||||
return ResponseEntity.ok(ApiResponse.success(message = "로그아웃 되었습니다."))
|
||||
}
|
||||
}
|
||||
|
||||
// DTO가 다른 곳에서 재사용 않아 응집도를 위해 같은 파일 내에 정의
|
||||
data class ReissueRequest(val accessToken: String, val refreshToken: String)
|
||||
@@ -8,12 +8,24 @@ import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
/**
|
||||
* [카테고리 조회 API]
|
||||
*
|
||||
* 일반 사용자(Public)에게 노출되는 카테고리 관련 엔드포인트입니다.
|
||||
* 블로그의 탐색(Navigation) 기능을 담당하며, 데이터 변경이 없는 읽기 전용(Read-Only) 작업만을 수행합니다.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/categories")
|
||||
class CategoryController(
|
||||
private val categoryService: CategoryService
|
||||
) {
|
||||
|
||||
/**
|
||||
* 카테고리 전체 계층 구조를 조회합니다.
|
||||
*
|
||||
* 프론트엔드 사이드바나 헤더 메뉴 렌더링을 위해 설계되었으며,
|
||||
* 불필요한 네트워크 왕복(Round Trip)을 줄이기 위해 한 번의 요청으로 중첩된(Nested) 트리 형태의 전체 데이터를 반환합니다.
|
||||
*/
|
||||
@GetMapping
|
||||
fun getCategoryTree(): ResponseEntity<ApiResponse<List<CategoryResponse>>> {
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryService.getCategoryTree()))
|
||||
|
||||
@@ -10,34 +10,58 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.core.userdetails.User
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
/**
|
||||
* [일반 사용자용 댓글 API]
|
||||
*
|
||||
* 게시글에 대한 사용자 참여(Social Interaction)를 담당하는 컨트롤러입니다.
|
||||
* 사용자 경험(UX)을 고려하여, 회원가입 없이도 자유롭게 소통할 수 있도록
|
||||
* 회원(Member)과 비회원(Guest)의 접근을 동시에 허용하는 하이브리드 로직을 수행합니다.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/comments")
|
||||
class CommentController(
|
||||
private val commentService: CommentService
|
||||
) {
|
||||
|
||||
// 댓글 목록 조회
|
||||
/**
|
||||
* 특정 게시글의 전체 댓글 목록을 조회합니다.
|
||||
*
|
||||
* 단순 리스트가 아닌, 대댓글(Nested Comments) 구조를 유지한 상태로 반환하여
|
||||
* 클라이언트가 별도의 재귀 로직 구현 없이 트리 형태로 즉시 렌더링할 수 있도록 지원합니다.
|
||||
*/
|
||||
@GetMapping
|
||||
fun getComments(@RequestParam postSlug: String): ResponseEntity<ApiResponse<List<CommentResponse>>> {
|
||||
return ResponseEntity.ok(ApiResponse.success(commentService.getComments(postSlug)))
|
||||
}
|
||||
|
||||
// 댓글 작성 (회원 or 비회원)
|
||||
/**
|
||||
* 댓글을 작성합니다 (회원/비회원 공용).
|
||||
*
|
||||
* 참여 장벽을 낮추기 위해 로그인 여부를 강제하지 않습니다.
|
||||
* Security Context의 User 객체가 null일 경우 비회원으로 간주하며,
|
||||
* 이 경우 RequestBody에 포함된 닉네임과 비밀번호를 사용하여 임시 신원을 생성합니다.
|
||||
*/
|
||||
@PostMapping
|
||||
fun createComment(
|
||||
@RequestBody request: CommentSaveRequest,
|
||||
@AuthenticationPrincipal user: User? // 비회원이면 null이 들어옴
|
||||
@AuthenticationPrincipal user: User? // 비회원 접근 시 null (Optional Principal)
|
||||
): ResponseEntity<ApiResponse<Long>> {
|
||||
val email = user?.username // null이면 비회원
|
||||
val email = user?.username // 인증된 사용자라면 email 추출
|
||||
val commentId = commentService.createComment(request, email)
|
||||
return ResponseEntity.ok(ApiResponse.success(commentId, "댓글이 등록되었습니다."))
|
||||
}
|
||||
|
||||
// 댓글 삭제
|
||||
/**
|
||||
* 댓글을 삭제합니다.
|
||||
*
|
||||
* 작성자 유형(회원/비회원)에 따라 검증 전략(Strategy)이 달라집니다.
|
||||
* - 회원: 현재 로그인한 사용자의 ID와 댓글 작성자 ID의 일치 여부를 검증
|
||||
* - 비회원: 댓글 작성 시 설정한 비밀번호(Guest Password)의 일치 여부를 검증
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
fun deleteComment(
|
||||
@PathVariable id: Long,
|
||||
@RequestBody(required = false) request: CommentDeleteRequest?, // 비회원용 비밀번호 바디
|
||||
@RequestBody(required = false) request: CommentDeleteRequest?, // 비회원일 경우에만 바디가 필요함
|
||||
@AuthenticationPrincipal user: User?
|
||||
): ResponseEntity<ApiResponse<Nothing>> {
|
||||
val email = user?.username
|
||||
|
||||
@@ -11,31 +11,54 @@ import org.springframework.data.web.PageableDefault
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
/**
|
||||
* [일반 사용자용 게시글 조회 API]
|
||||
*
|
||||
* 블로그의 핵심 콘텐츠인 게시글(Post) 데이터를 제공하는 Public 컨트롤러입니다.
|
||||
* 방문자의 조회 요청을 처리하며, 검색 엔진 최적화(SEO)를 고려하여
|
||||
* 내부 식별자(ID)가 아닌 의미 있는 문자열(Slug) 기반의 URL 설계를 채택했습니다.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/posts")
|
||||
class PostController(
|
||||
private val postService: PostService
|
||||
) {
|
||||
|
||||
/**
|
||||
* 게시글 목록을 조회하거나 조건에 맞춰 검색합니다.
|
||||
*
|
||||
* [통합 검색 엔드포인트]
|
||||
* 단순 목록 조회와 필터링(검색) 로직을 하나의 API로 통합하여 프론트엔드 구현을 단순화했습니다.
|
||||
* 필터 조건(keyword, category, tag) 유무에 따라 동적 쿼리(QueryDSL) 또는 기본 페이징 쿼리로 분기 처리됩니다.
|
||||
*
|
||||
* @param keyword 제목 또는 본문 검색어 (Optional)
|
||||
* @param category 카테고리 이름 (Optional, 프론트엔드 파라미터명: category)
|
||||
* @param tag 태그 이름 (Optional)
|
||||
*/
|
||||
@GetMapping
|
||||
fun getPosts(
|
||||
@RequestParam(required = false) keyword: String?,
|
||||
@RequestParam(required = false) category: String?, // 👈 프론트는 'category'로 보냄
|
||||
@RequestParam(required = false) category: String?,
|
||||
@RequestParam(required = false) tag: String?,
|
||||
@PageableDefault(size = 10, sort = ["createdAt"], direction = Sort.Direction.DESC) pageable: Pageable
|
||||
): ResponseEntity<ApiResponse<Page<PostSummaryResponse>>> {
|
||||
|
||||
// 검색 조건이 하나라도 있으면 searchPosts 호출 (검색 + 카테고리 필터링)
|
||||
// 필터 조건이 하나라도 존재하면 동적 쿼리(Search) 실행, 없으면 기본 목록 조회(List) 수행
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 상세 정보를 조회합니다.
|
||||
*
|
||||
* URL에 ID(숫자) 대신 제목 기반의 Slug를 사용하여 가독성과 SEO 점수를 높입니다.
|
||||
* 상세 조회 성공 시, 서비스 레이어에서 조회수(View Count) 증가 트랜잭션이 함께 수행됩니다.
|
||||
*/
|
||||
@GetMapping("/{slug}")
|
||||
fun getPost(@PathVariable slug: String): ResponseEntity<ApiResponse<PostResponse>> {
|
||||
val post = postService.getPostBySlug(slug)
|
||||
|
||||
@@ -8,12 +8,26 @@ import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
/**
|
||||
* [일반 사용자용 프로필 조회 API]
|
||||
*
|
||||
* 블로그 방문자들에게 운영자(Owner)의 정보를 제공하는 Public 컨트롤러입니다.
|
||||
* 수정 권한이 필요한 관리자 영역(AdminProfileController)과 분리하여,
|
||||
* 불필요한 인증 로직 없이 누구나 빠르게 조회할 수 있도록 설계된 읽기 전용(Read-Only) 엔드포인트입니다.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/profile")
|
||||
class ProfileController(
|
||||
private val blogProfileService: BlogProfileService
|
||||
) {
|
||||
|
||||
/**
|
||||
* 블로그 운영자의 프로필 정보를 조회합니다.
|
||||
*
|
||||
* 단일 사용자 블로그(Single User Blog) 특성상 별도의 사용자 ID 파라미터 없이
|
||||
* 시스템에 설정된 유일한 프로필 데이터를 반환합니다.
|
||||
* (주로 메인 화면의 사이드바나 About 페이지 렌더링에 사용됩니다.)
|
||||
*/
|
||||
@GetMapping
|
||||
fun getProfile(): ResponseEntity<ApiResponse<ProfileResponse>> {
|
||||
return ResponseEntity.ok(ApiResponse.success(blogProfileService.getProfile()))
|
||||
|
||||
@@ -7,19 +7,37 @@ import me.wypark.blogbackend.domain.category.CategoryService
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
/**
|
||||
* [관리자용 카테고리 관리 API]
|
||||
*
|
||||
* 블로그의 카테고리 계층 구조(Tree Structure)를 조작하는 컨트롤러입니다.
|
||||
* 단순한 CRUD 외에도 부모-자식 관계 설정 및 구조 변경(이동) 로직을 포함하고 있습니다.
|
||||
*
|
||||
* Note:
|
||||
* - 데이터 무결성을 위해 모든 변경 작업은 트랜잭션 범위 안에서 유효성 검증(순환 참조 방지 등) 후 수행됩니다.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/categories")
|
||||
class AdminCategoryController(
|
||||
private val categoryService: CategoryService
|
||||
) {
|
||||
|
||||
/**
|
||||
* 신규 카테고리를 생성합니다.
|
||||
* parentId가 없을 경우 최상위(Root) 카테고리로 생성되며, 있을 경우 해당 노드의 자식으로 연결됩니다.
|
||||
*/
|
||||
@PostMapping
|
||||
fun createCategory(@RequestBody request: CategoryCreateRequest): ResponseEntity<ApiResponse<Long>> {
|
||||
val id = categoryService.createCategory(request)
|
||||
return ResponseEntity.ok(ApiResponse.success(id, "카테고리가 생성되었습니다."))
|
||||
}
|
||||
|
||||
// 👈 [추가] 카테고리 수정 (이름, 위치 이동)
|
||||
/**
|
||||
* 카테고리 정보(이름 및 계층 위치)를 수정합니다.
|
||||
*
|
||||
* 단순 이름 변경뿐만 아니라, 부모 카테고리를 변경하여 트리 구조 내에서 위치를 이동시키는 기능도 수행합니다.
|
||||
* 위치 이동 시 순환 참조(Cycle)가 발생하지 않도록 서비스 레이어에서 검증 로직이 수행됩니다.
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
fun updateCategory(
|
||||
@PathVariable id: Long,
|
||||
@@ -29,6 +47,13 @@ class AdminCategoryController(
|
||||
return ResponseEntity.ok(ApiResponse.success(message = "카테고리가 수정되었습니다."))
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리를 삭제합니다.
|
||||
*
|
||||
* [삭제 정책]
|
||||
* - 하위 카테고리(Children)는 재귀적으로 함께 삭제됩니다 (Cascade).
|
||||
* - 해당 카테고리에 속해있던 게시글(Post)들은 삭제되지 않고 '미분류(Category = NULL)' 상태로 변경되어 보존됩니다.
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
fun deleteCategory(@PathVariable id: Long): ResponseEntity<ApiResponse<Nothing>> {
|
||||
categoryService.deleteCategory(id)
|
||||
|
||||
@@ -11,19 +11,36 @@ import org.springframework.data.web.PageableDefault
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
/**
|
||||
* [관리자용 댓글 관리 API]
|
||||
*
|
||||
* 블로그 내 모든 댓글 활동을 모니터링하고 중재(Moderation)하는 컨트롤러입니다.
|
||||
* 개별 게시글 단위로 조회하는 일반 API와 달리, 시스템 전체의 댓글 흐름을 파악하는 데 초점이 맞춰져 있습니다.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/comments")
|
||||
class AdminCommentController(
|
||||
private val commentService: CommentService
|
||||
) {
|
||||
|
||||
// 관리자 권한으로 댓글 삭제
|
||||
/**
|
||||
* 부적절한 댓글을 강제로 삭제합니다 (Moderation).
|
||||
*
|
||||
* 일반 사용자의 삭제 요청과 달리 작성자 본인 확인 절차(Ownership Check)를 건너뛰고,
|
||||
* 관리자 권한으로 즉시 데이터를 제거합니다.
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
fun deleteComment(@PathVariable id: Long): ResponseEntity<ApiResponse<Nothing>> {
|
||||
commentService.deleteCommentByAdmin(id)
|
||||
return ResponseEntity.ok(ApiResponse.success(message = "관리자 권한으로 댓글을 삭제했습니다."))
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 대시보드용 전체 댓글 목록을 조회합니다.
|
||||
*
|
||||
* 특정 게시글에 종속되지 않고, 최근 작성된 순서대로 모든 댓글을 페이징하여 반환합니다.
|
||||
* 이를 통해 관리자는 스팸이나 악성 댓글 발생 여부를 한눈에 파악할 수 있습니다.
|
||||
*/
|
||||
@GetMapping
|
||||
fun getAllComments(
|
||||
@PageableDefault(size = 20, sort = ["id"], direction = Sort.Direction.DESC) pageable: Pageable
|
||||
|
||||
@@ -10,12 +10,30 @@ import org.springframework.web.bind.annotation.RequestPart
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
|
||||
/**
|
||||
* [관리자용 이미지 업로드 API]
|
||||
*
|
||||
* 게시글 본문(Markdown) 삽입용 이미지나 프로필 사진 등, 블로그 운영에 필요한
|
||||
* 정적 리소스(Static Resources)를 처리하는 컨트롤러입니다.
|
||||
*
|
||||
* 스토리지 저장소(S3/MinIO)와의 직접적인 통신은 Service Layer에 위임하며,
|
||||
* 클라이언트에게는 업로드된 리소스의 접근 가능한 URL을 반환하여 즉시 렌더링 가능하도록 합니다.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/images")
|
||||
class AdminImageController(
|
||||
private val imageService: ImageService
|
||||
) {
|
||||
|
||||
/**
|
||||
* 이미지를 업로드하고 접근 가능한 URL을 반환합니다.
|
||||
*
|
||||
* 주로 에디터(Toast UI 등)에서 이미지 첨부 이벤트가 발생했을 때 비동기로 호출되며,
|
||||
* 업로드 성공 시 반환된 URL은 클라이언트 측에서 즉시 Markdown 문법()으로 변환되어 본문에 삽입됩니다.
|
||||
*
|
||||
* @param image 클라이언트가 전송한 바이너리 파일 (MultipartFile)
|
||||
* @return CDN 또는 스토리지의 접근 가능한 절대 경로 (URL)
|
||||
*/
|
||||
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
|
||||
fun uploadImage(
|
||||
@RequestPart("image") image: MultipartFile
|
||||
|
||||
@@ -9,12 +9,25 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.core.userdetails.User
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
/**
|
||||
* [관리자용 게시글 관리 API]
|
||||
*
|
||||
* 블로그 콘텐츠(Post)의 전체 수명 주기(Lifecycle)를 관리하는 컨트롤러입니다.
|
||||
* 게시글의 작성, 수정, 삭제 기능을 제공하며, 이 과정에서 입력값 검증(@Valid)과
|
||||
* 데이터 무결성 유지를 위한 다양한 비즈니스 로직(Slug 생성, 태그 처리 등)을 조율합니다.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/posts")
|
||||
class AdminPostController(
|
||||
private val postService: PostService
|
||||
) {
|
||||
|
||||
/**
|
||||
* 신규 게시글을 작성 및 발행합니다.
|
||||
*
|
||||
* Security Context에서 현재 로그인한 관리자 정보를 추출하여 작성자(Author)로 매핑함으로써,
|
||||
* 클라이언트가 임의로 작성자를 변조하는 것을 방지합니다.
|
||||
*/
|
||||
@PostMapping
|
||||
fun createPost(
|
||||
@RequestBody @Valid request: PostSaveRequest,
|
||||
@@ -24,7 +37,12 @@ class AdminPostController(
|
||||
return ResponseEntity.ok(ApiResponse.success(postId, "게시글이 작성되었습니다."))
|
||||
}
|
||||
|
||||
// 👈 [추가] 게시글 수정 엔드포인트
|
||||
/**
|
||||
* 기존 게시글을 수정합니다.
|
||||
*
|
||||
* 제목이나 본문 변경뿐만 아니라, 카테고리 이동이나 태그 재설정과 같은 메타데이터 변경도 함께 처리합니다.
|
||||
* 수정 시 사용되지 않게 된 이미지를 정리하거나, URL(Slug) 변경에 따른 리다이렉트 고려 등이 서비스 레이어에서 처리됩니다.
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
fun updatePost(
|
||||
@PathVariable id: Long,
|
||||
@@ -34,6 +52,14 @@ class AdminPostController(
|
||||
return ResponseEntity.ok(ApiResponse.success(postId, "게시글이 수정되었습니다."))
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글을 영구 삭제합니다.
|
||||
*
|
||||
* [리소스 정리 전략]
|
||||
* 단순히 DB 레코드(Row)만 삭제하는 것이 아니라, 해당 게시글 본문에 포함되었던
|
||||
* S3 업로드 이미지 파일들을 추적하여 함께 삭제(Cleanup)합니다.
|
||||
* 이를 통해 스토리지에 불필요한 고아 파일(Orphaned Files)이 누적되는 것을 방지하여 비용을 최적화합니다.
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
fun deletePost(@PathVariable id: Long): ResponseEntity<ApiResponse<Nothing>> {
|
||||
postService.deletePost(id)
|
||||
|
||||
@@ -9,12 +9,26 @@ import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
/**
|
||||
* [관리자용 프로필 설정 API]
|
||||
*
|
||||
* 블로그 운영자의 소개(Bio), 프로필 사진, 소셜 링크 등을 관리하는 컨트롤러입니다.
|
||||
* 개인 블로그 특성상 단일 사용자(Owner)에 대한 정보만 존재하므로,
|
||||
* 별도의 ID 파라미터 없이 싱글톤(Singleton) 리소스처럼 관리됩니다.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/profile")
|
||||
class AdminProfileController(
|
||||
private val blogProfileService: BlogProfileService
|
||||
) {
|
||||
|
||||
/**
|
||||
* 블로그 프로필 정보를 수정합니다.
|
||||
*
|
||||
* 단순 텍스트 정보(이름, 소개) 수정뿐만 아니라,
|
||||
* 변경된 프로필 이미지 URL을 반영하고 기존 이미지를 정리하는 로직이 서비스 레이어에 포함되어 있습니다.
|
||||
* 초기 데이터가 없을 경우(First Run), 수정 요청 시 기본 프로필이 생성(Upsert)됩니다.
|
||||
*/
|
||||
@PutMapping
|
||||
fun updateProfile(@RequestBody request: ProfileUpdateRequest): ResponseEntity<ApiResponse<Nothing>> {
|
||||
blogProfileService.updateProfile(request)
|
||||
|
||||
@@ -4,12 +4,26 @@ import jakarta.validation.constraints.Email
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
import jakarta.validation.constraints.Size
|
||||
|
||||
// 회원가입 요청
|
||||
/**
|
||||
* [회원가입 요청 DTO]
|
||||
*
|
||||
* 사용자 등록을 위한 데이터 전송 객체입니다.
|
||||
* Controller 진입 시점(@Valid)에서 입력값의 형식 검증을 수행하여,
|
||||
* 비즈니스 로직(Service) 단계에서의 불필요한 연산을 방지합니다 (Fail-Fast 전략).
|
||||
*/
|
||||
data class SignupRequest(
|
||||
@field:NotBlank(message = "이메일은 필수입니다.")
|
||||
@field:Email(message = "올바른 이메일 형식이 아닙니다.")
|
||||
val email: String,
|
||||
|
||||
/**
|
||||
* 비밀번호 복잡도 정책: 최소 8자 ~ 최대 20자
|
||||
*
|
||||
* Note:
|
||||
* 이 필드는 클라이언트로부터 평문(Plain Text)으로 전달되므로,
|
||||
* 전송 구간 암호화(HTTPS/TLS)가 보장된 환경에서만 사용되어야 합니다.
|
||||
* DB 저장 시에는 반드시 단방향 해시 함수(BCrypt 등)를 통해 암호화됩니다.
|
||||
*/
|
||||
@field:NotBlank(message = "비밀번호는 필수입니다.")
|
||||
@field:Size(min = 8, max = 20, message = "비밀번호는 8자 이상 20자 이하로 입력해주세요.")
|
||||
val password: String,
|
||||
@@ -19,7 +33,11 @@ data class SignupRequest(
|
||||
val nickname: String
|
||||
)
|
||||
|
||||
// 로그인 요청
|
||||
/**
|
||||
* [로그인 요청 DTO]
|
||||
*
|
||||
* JWT 토큰 발급을 위한 사용자 자격 증명(Credentials)을 전달받는 객체입니다.
|
||||
*/
|
||||
data class LoginRequest(
|
||||
@field:NotBlank(message = "이메일을 입력해주세요.")
|
||||
val email: String,
|
||||
@@ -28,6 +46,12 @@ data class LoginRequest(
|
||||
val password: String
|
||||
)
|
||||
|
||||
/**
|
||||
* [이메일 인증 확인 DTO]
|
||||
*
|
||||
* 회원가입 직후 발송된 OTP(One Time Password) 코드를 검증하기 위한 요청 객체입니다.
|
||||
* 이메일 소유권 확인(Proof of Ownership)을 위해 사용됩니다.
|
||||
*/
|
||||
data class VerifyEmailRequest(
|
||||
@field:NotBlank(message = "이메일을 입력해주세요")
|
||||
val email: String,
|
||||
|
||||
@@ -2,44 +2,73 @@ package me.wypark.blogbackend.api.dto
|
||||
|
||||
import me.wypark.blogbackend.domain.category.Category
|
||||
|
||||
// [요청] 카테고리 생성
|
||||
/**
|
||||
* [카테고리 생성 요청 DTO]
|
||||
*
|
||||
* 새로운 카테고리 노드(Node)를 생성하기 위한 요청 객체입니다.
|
||||
* 계층형 게시판 구조를 지원하기 위해 부모 카테고리 ID(parentId)를 선택적으로 받습니다.
|
||||
*
|
||||
* @property parentId null일 경우 최상위(Root) 레벨에 생성되며, 값이 있을 경우 해당 카테고리의 하위(Child)로 연결됩니다.
|
||||
*/
|
||||
data class CategoryCreateRequest(
|
||||
val name: String,
|
||||
val parentId: Long? = null // null이면 최상위(Root) 카테고리
|
||||
val parentId: Long? = null
|
||||
)
|
||||
|
||||
// [요청] 카테고리 수정 (이름 + 부모 이동)
|
||||
/**
|
||||
* [카테고리 수정 요청 DTO]
|
||||
*
|
||||
* 카테고리의 속성 변경(Rename)과 구조 변경(Move)을 동시에 처리하는 객체입니다.
|
||||
*
|
||||
* Note:
|
||||
* 트리 구조 내에서의 노드 이동(Move)은 데이터베이스 부하가 발생할 수 있고
|
||||
* 순환 참조(Cycle) 위험이 있으므로, 서비스 레이어에서 별도의 정합성 검증 로직을 거칩니다.
|
||||
*/
|
||||
data class CategoryUpdateRequest(
|
||||
val name: String,
|
||||
val parentId: Long? // null이면 최상위(Root)로 이동
|
||||
val parentId: Long? // 변경할 부모 ID (null이면 최상위로 이동)
|
||||
)
|
||||
|
||||
// [응답] 카테고리 트리 구조 (재귀)
|
||||
/**
|
||||
* [카테고리 응답 DTO - Tree Structure]
|
||||
*
|
||||
* 프론트엔드 네비게이션 바(Sidebar) 등에서 계층형 메뉴를 렌더링하기 위한 재귀적 구조의 객체입니다.
|
||||
*
|
||||
* [성능 고려사항]
|
||||
* N+1 문제를 방지하기 위해, 엔티티 조회 시점에는 Fetch Join을 사용하거나
|
||||
* Batch Size를 설정하여 쿼리를 최적화한 후, 메모리 상에서 이 DTO 구조로 변환하여 반환합니다.
|
||||
*/
|
||||
data class CategoryResponse(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val children: List<CategoryResponse> // 자식들
|
||||
val children: List<CategoryResponse> // 자식 노드 리스트 (Recursive)
|
||||
) {
|
||||
companion object {
|
||||
// Entity -> DTO 변환 (재귀 호출)
|
||||
// 엔티티 그래프를 순회하며 DTO 트리로 변환
|
||||
fun from(category: Category): CategoryResponse {
|
||||
return CategoryResponse(
|
||||
id = category.id!!,
|
||||
name = category.name,
|
||||
// 자식들을 DTO로 변환하여 리스트에 담음
|
||||
// 자식 카테고리들을 재귀적으로 DTO 변환하여 리스트에 매핑
|
||||
children = category.children.map { from(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// [Admin용 응답] 관리자 대시보드 목록용
|
||||
/**
|
||||
* [관리자용 댓글 모니터링 DTO - Flat List]
|
||||
*
|
||||
* 관리자 대시보드에서 최근 댓글 흐름을 파악하기 위한 객체입니다.
|
||||
* 계층형 구조(Nested)가 필요한 일반 사용자 뷰와 달리, 관리 목적상 시간순 나열이 중요하므로
|
||||
* 모든 댓글을 평탄화(Flatten)하여 게시글 정보와 함께 제공합니다.
|
||||
*/
|
||||
data class AdminCommentResponse(
|
||||
val id: Long,
|
||||
val content: String,
|
||||
val author: String,
|
||||
val postTitle: String, // 어떤 글인지 식별
|
||||
val postSlug: String, // 클릭 시 해당 글로 이동용
|
||||
val postTitle: String, // 문맥 파악을 위한 원본 게시글 제목
|
||||
val postSlug: String, // 클릭 시 해당 게시글로 바로 이동(Deep Link)하기 위한 식별자
|
||||
val createdAt: java.time.LocalDateTime
|
||||
) {
|
||||
companion object {
|
||||
|
||||
@@ -3,20 +3,32 @@ package me.wypark.blogbackend.api.dto
|
||||
import me.wypark.blogbackend.domain.comment.Comment
|
||||
import java.time.LocalDateTime
|
||||
|
||||
// [응답] 댓글 (계층형 구조)
|
||||
/**
|
||||
* [댓글 응답 DTO - Hierarchical]
|
||||
*
|
||||
* 게시글 상세 화면에서 댓글 목록을 렌더링하기 위한 데이터 객체입니다.
|
||||
* 대댓글(Nested Comment)을 포함하는 재귀적 구조를 가지며, 프론트엔드에서의 추가 가공 없이
|
||||
* 즉시 트리 형태로 렌더링할 수 있도록 설계되었습니다.
|
||||
*/
|
||||
data class CommentResponse(
|
||||
val id: Long,
|
||||
val content: String,
|
||||
val author: String,
|
||||
|
||||
// UI에서 게시글 작성자의 댓글을 강조(Highlight)하기 위한 플래그
|
||||
val isPostAuthor: Boolean,
|
||||
|
||||
// 회원일 경우 프로필 링크 연결 등을 위해 ID 제공 (비회원은 null)
|
||||
val memberId: Long?,
|
||||
|
||||
val createdAt: LocalDateTime,
|
||||
|
||||
// 자식 댓글 리스트 (Recursive)
|
||||
val children: List<CommentResponse>
|
||||
) {
|
||||
companion object {
|
||||
fun from(comment: Comment): CommentResponse {
|
||||
// 게시글 작성자 ID와 댓글 작성자(회원) ID가 같은지 비교
|
||||
// comment.member는 비회원일 경우 null이므로 안전하게 처리됨
|
||||
// 게시글 작성자 본인이 쓴 댓글인지 확인 (비회원은 member가 null이므로 항상 false)
|
||||
val isAuthor = comment.member?.id == comment.post.member.id
|
||||
|
||||
return CommentResponse(
|
||||
@@ -26,23 +38,37 @@ data class CommentResponse(
|
||||
isPostAuthor = isAuthor,
|
||||
memberId = comment.member?.id,
|
||||
createdAt = comment.createdAt,
|
||||
children = comment.children.map { from(it) }
|
||||
children = comment.children.map { from(it) } // 재귀 호출로 트리 구성
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// [요청] 댓글 작성
|
||||
|
||||
/**
|
||||
* [댓글 작성 요청 DTO]
|
||||
*
|
||||
* 회원과 비회원(Guest) 모두가 사용하는 통합 요청 객체입니다.
|
||||
*
|
||||
* [검증 로직]
|
||||
* - 회원: Security Context에서 유저 정보를 가져오므로 guest 필드는 무시됩니다.
|
||||
* - 비회원: guestNickname과 guestPassword가 필수값으로 요구됩니다.
|
||||
*/
|
||||
data class CommentSaveRequest(
|
||||
val postSlug: String,
|
||||
val content: String,
|
||||
val parentId: Long? = null, // 대댓글일 경우 부모 ID
|
||||
val parentId: Long? = null, // 대댓글(Reply)일 경우 상위 댓글 ID
|
||||
|
||||
// 비회원 전용 필드 (회원은 null 가능)
|
||||
// --- 비회원 전용 필드 (Anonymous User) ---
|
||||
val guestNickname: String? = null,
|
||||
val guestPassword: String? = null
|
||||
val guestPassword: String? = null // 수정/삭제 권한 인증용 비밀번호 (DB 저장 시 암호화됨)
|
||||
)
|
||||
|
||||
// [요청] 댓글 삭제 (비회원용 비밀번호 전달)
|
||||
/**
|
||||
* [댓글 삭제 요청 DTO]
|
||||
*
|
||||
* 비회원이 본인의 댓글을 삭제할 때 비밀번호 검증을 위해 사용됩니다.
|
||||
* 회원의 경우 JWT 토큰으로 본인 확인이 가능하므로 이 DTO의 필드는 사용되지 않습니다.
|
||||
*/
|
||||
data class CommentDeleteRequest(
|
||||
val guestPassword: String? = null
|
||||
)
|
||||
@@ -3,7 +3,13 @@ package me.wypark.blogbackend.api.dto
|
||||
import me.wypark.blogbackend.domain.post.Post
|
||||
import java.time.LocalDateTime
|
||||
|
||||
// [응답] 인접 게시글 정보 (이전글/다음글)
|
||||
/**
|
||||
* [인접 게시글 응답 DTO]
|
||||
*
|
||||
* 게시글 상세 화면 하단에 위치할 '이전 글 / 다음 글' 네비게이션 링크를 위한 객체입니다.
|
||||
* 전체 데이터를 로딩하는 대신, 링크 생성에 필요한 최소한의 식별자(Slug)와 제목(Title)만 포함하여
|
||||
* 페이로드 크기를 최적화했습니다.
|
||||
*/
|
||||
data class PostNeighborResponse(
|
||||
val slug: String,
|
||||
val title: String
|
||||
@@ -18,7 +24,15 @@ data class PostNeighborResponse(
|
||||
}
|
||||
}
|
||||
|
||||
// [응답] 게시글 상세 정보
|
||||
/**
|
||||
* [게시글 상세 응답 DTO]
|
||||
*
|
||||
* 단일 게시글의 모든 정보(Full Content)를 클라이언트에게 전달하는 객체입니다.
|
||||
*
|
||||
* [설계 의도]
|
||||
* - SEO: ID 대신 Slug를 사용하여 검색 엔진 친화적인 URL 구조 지원
|
||||
* - UX: 별도의 추가 요청 없이 이전/다음 글 정보를 함께 반환하여 페이지 이동성(Navigability) 향상
|
||||
*/
|
||||
data class PostResponse(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
@@ -27,11 +41,11 @@ data class PostResponse(
|
||||
val categoryName: String?,
|
||||
val viewCount: Long,
|
||||
val createdAt: LocalDateTime,
|
||||
// 👈 [추가] 이전/다음 게시글 정보
|
||||
|
||||
// 현재 글을 기준으로 앞/뒤 글 정보 (없으면 null)
|
||||
val prevPost: PostNeighborResponse?,
|
||||
val nextPost: PostNeighborResponse?
|
||||
) {
|
||||
// Entity -> DTO 변환 편의 메서드
|
||||
companion object {
|
||||
fun from(post: Post, prevPost: Post? = null, nextPost: Post? = null): PostResponse {
|
||||
return PostResponse(
|
||||
@@ -49,7 +63,15 @@ data class PostResponse(
|
||||
}
|
||||
}
|
||||
|
||||
// [응답] 게시글 목록용 (본문 제외, 가볍게)
|
||||
/**
|
||||
* [게시글 목록 응답 DTO]
|
||||
*
|
||||
* 메인 화면이나 카테고리 목록에서 사용되는 경량화(Lightweight) 객체입니다.
|
||||
*
|
||||
* [최적화 전략]
|
||||
* 다수의 아이템을 렌더링해야 하므로, 데이터 전송량(Network Overhead)을 줄이기 위해
|
||||
* 무거운 본문(content)은 제외하거나 미리보기용으로 일부만 포함하도록 설계되었습니다.
|
||||
*/
|
||||
data class PostSummaryResponse(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
@@ -58,7 +80,7 @@ data class PostSummaryResponse(
|
||||
val viewCount: Long,
|
||||
val createdAt: LocalDateTime,
|
||||
val updatedAt: LocalDateTime,
|
||||
val content: String?
|
||||
val content: String? // 목록에서는 본문 미리보기 용도로 사용 (혹은 null)
|
||||
) {
|
||||
companion object {
|
||||
fun from(post: Post): PostSummaryResponse {
|
||||
@@ -76,11 +98,18 @@ data class PostSummaryResponse(
|
||||
}
|
||||
}
|
||||
|
||||
// [요청] 게시글 작성/수정
|
||||
/**
|
||||
* [게시글 작성/수정 요청 DTO]
|
||||
*
|
||||
* 게시글의 생명주기(생성/수정)를 담당하는 통합 커맨드 객체입니다.
|
||||
*
|
||||
* - Slug: 클라이언트가 직접 지정하지 않으면(null), 서버에서 제목을 기반으로 자동 생성(Generate)합니다.
|
||||
* - Content: Markdown 포맷의 원문 텍스트를 저장합니다.
|
||||
*/
|
||||
data class PostSaveRequest(
|
||||
val title: String,
|
||||
val content: String, // 마크다운 원문
|
||||
val content: String,
|
||||
val slug: String? = null,
|
||||
val categoryId: Long? = null,
|
||||
val tags: List<String> = emptyList() // 태그는 나중에 구현
|
||||
val tags: List<String> = emptyList() // 태그는 서비스 레이어에서 별도 로직으로 매핑(Many-to-Many) 처리
|
||||
)
|
||||
@@ -2,6 +2,15 @@ package me.wypark.blogbackend.api.dto
|
||||
|
||||
import me.wypark.blogbackend.domain.profile.BlogProfile
|
||||
|
||||
/**
|
||||
* [프로필 응답 DTO]
|
||||
*
|
||||
* 블로그 운영자(Owner)의 공개 정보를 렌더링하기 위한 View Object입니다.
|
||||
*
|
||||
* [설계 의도]
|
||||
* 데이터베이스 엔티티(Entity)를 직접 반환하지 않고 DTO로 변환하여,
|
||||
* 내부 구현의 변경이 클라이언트(View)에 영향을 미치지 않도록 결합도(Coupling)를 낮췄습니다.
|
||||
*/
|
||||
data class ProfileResponse(
|
||||
val name: String,
|
||||
val bio: String,
|
||||
@@ -10,6 +19,7 @@ data class ProfileResponse(
|
||||
val email: String?
|
||||
) {
|
||||
companion object {
|
||||
// Entity -> DTO 변환 (Static Factory Method)
|
||||
fun from(profile: BlogProfile): ProfileResponse {
|
||||
return ProfileResponse(
|
||||
name = profile.name,
|
||||
@@ -22,6 +32,15 @@ data class ProfileResponse(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [프로필 수정 요청 DTO]
|
||||
*
|
||||
* 관리자 대시보드에서 블로그 설정(운영자 정보)을 변경하기 위한 요청 객체입니다.
|
||||
*
|
||||
* [유효성 정책]
|
||||
* - 이름(name)과 소개(bio)는 블로그의 정체성을 나타내는 필수 항목입니다.
|
||||
* - 프로필 이미지나 소셜 링크 등은 선택적(Optional)으로 입력할 수 있도록 Nullable로 설계되었습니다.
|
||||
*/
|
||||
data class ProfileUpdateRequest(
|
||||
val name: String,
|
||||
val bio: String,
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
package me.wypark.blogbackend.api.dto
|
||||
|
||||
/**
|
||||
* [JWT 토큰 응답 DTO]
|
||||
*
|
||||
* 로그인 또는 토큰 재발급 성공 시 클라이언트에게 반환되는 인증 정보 객체입니다.
|
||||
* RFC 6750 (Bearer Token Usage) 표준을 따르며, 클라이언트가 인증 헤더(Authorization)를
|
||||
* 올바르게 구성할 수 있도록 필요한 메타데이터를 함께 제공합니다.
|
||||
*
|
||||
* @property grantType 인증 타입 (Default: "Bearer")
|
||||
* @property accessToken 리소스 접근을 위한 단기 유효 토큰 (Stateless)
|
||||
* @property refreshToken Access Token 갱신을 위한 장기 유효 토큰 (Rotation 전략 적용)
|
||||
* @property accessTokenExpiresIn Access Token의 유효 기간(ms). 클라이언트가 만료 시점을 예측하여 미리 갱신 요청을 보낼 수 있도록 함.
|
||||
*/
|
||||
data class TokenDto(
|
||||
val grantType: String = "Bearer",
|
||||
val accessToken: String,
|
||||
|
||||
Reference in New Issue
Block a user