주석 수정

This commit is contained in:
2026-02-03 15:05:28 +09:00
parent 0c72a603b3
commit 5869b8fe14
47 changed files with 1383 additions and 251 deletions

View File

@@ -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)

View File

@@ -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()))

View File

@@ -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

View File

@@ -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)

View File

@@ -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()))

View File

@@ -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)

View File

@@ -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

View File

@@ -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 문법(![alt](url))으로 변환되어 본문에 삽입됩니다.
*
* @param image 클라이언트가 전송한 바이너리 파일 (MultipartFile)
* @return CDN 또는 스토리지의 접근 가능한 절대 경로 (URL)
*/
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun uploadImage(
@RequestPart("image") image: MultipartFile

View File

@@ -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)

View File

@@ -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)