6 Commits

Author SHA1 Message Date
pwy3282040@msecure.co
884853586d feat: 댓글 구조 변경
작성자 표시를 위해 CommentResponse에 isPostAuthor 필드 추가
2025-12-27 00:14:57 +09:00
pwy3282040@msecure.co
ef6ffa5670 feat: 댓글 구조 변경
작성자 표시를 위해 CommentResponse에 isPostAuthor 필드 추가
2025-12-26 15:07:35 +09:00
pwy3282040@msecure.co
2f6cb41764 docs: README.md 수정
관련 문서 링크 변경
2025-12-26 15:02:57 +09:00
pwy3282040@msecure.co
60d645f47b feat: 블로그 핵심 기능 구현 (댓글, 태그, 카테고리, 이미지)
[댓글 시스템]
- 계층형(대댓글) 구조 및 회원/비회원 하이브리드 작성 로직 구현
- 비회원 댓글용 비밀번호 암호화 저장 (PasswordEncoder 적용)
- 관리자용 댓글 강제 삭제 및 전체 댓글 모니터링(Dashboard) API 구현

[태그 & 카테고리]
- N:M 태그 시스템(PostTag) 엔티티 설계 및 게시글 작성 시 자동 저장 로직
- 계층형(Tree) 카테고리 구조 구현 및 관리자 생성/삭제 API
- QueryDSL 검색 조건에 태그 및 카테고리 필터링 추가

[이미지 업로드]
- AWS S3 (MinIO) 연동 및 Bucket 자동 생성/Public 정책 설정
- 마크다운 에디터용 이미지 업로드 API 구현
2025-12-26 14:47:48 +09:00
pwy3282040@msecure.co
6fbfcaf90b feat: JWT 기반 인증 시스템 및 이메일 가입 구현
[인프라]
- Docker Compose 구성 (DB, Redis, MinIO)
- Spring Boot 3.5.9 + Kotlin + Gradle 설정

[인증/보안]
- Spring Security 및 JWT 필터 설정
- RTR(Refresh Token Rotation) 방식의 토큰 재발급 로직 구현
- Redis를 활용한 Refresh Token 및 이메일 인증 코드 관리

[기능 구현]
- 회원가입 (이메일 인증 포함)
- 로그인/로그아웃/토큰재발급 API 구현
- 공통 응답(ApiResponse) 및 전역 예외 처리(GlobalExceptionHandler) 적용
2025-12-26 13:07:14 +09:00
pwy3282040@msecure.co
49d435079f feat: JWT 기반 인증 시스템 및 이메일 가입 구현
[인프라]
- Docker Compose 구성 (DB, Redis, MinIO)
- Spring Boot 3.5.9 + Kotlin + Gradle 설정

[인증/보안]
- Spring Security 및 JWT 필터 설정
- RTR(Refresh Token Rotation) 방식의 토큰 재발급 로직 구현
- Redis를 활용한 Refresh Token 및 이메일 인증 코드 관리

[기능 구현]
- 회원가입 (이메일 인증 포함)
- 로그인/로그아웃/토큰재발급 API 구현
- 공통 응답(ApiResponse) 및 전역 예외 처리(GlobalExceptionHandler) 적용
2025-12-26 12:58:51 +09:00
44 changed files with 1698 additions and 28 deletions

3
.gitignore vendored
View File

@@ -1,5 +1,6 @@
HELP.md
.gradle
.env
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
@@ -38,4 +39,6 @@ out/
### Kotlin ###
.kotlin
### docker ###
postgres_data

View File

@@ -80,5 +80,5 @@ $ docker-compose logs -f blog-api
---
## 📝 관련 문서
- [**ENDPOINT**](https://affine.wypark.me/workspace/f85df0c4-a315-4166-94a8-6558cdafff1d/p13joh_beKNJ6R-I9KrFW)
- [**API 명세서**](https://affine.wypark.me/workspace/f85df0c4-a315-4166-94a8-6558cdafff1d/7fRNXK9utxG4mcekWivGK)
- [**ENDPOINT**](https://affine.wypark.me/workspace/f85df0c4-a315-4166-94a8-6558cdafff1d/_csY9ZzOnSbt4bTnIA_9w)
- [**API 명세서**](https://affine.wypark.me/workspace/f85df0c4-a315-4166-94a8-6558cdafff1d/9axPww7llEcSH3nOZzi-m)

View File

@@ -30,6 +30,7 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
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")
// 2. Kotlin Modules
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")

View File

@@ -9,16 +9,28 @@ services:
environment:
# Database
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/blog_db
- SPRING_DATASOURCE_USERNAME=wypark
- SPRING_DATASOURCE_PASSWORD=your_password
- SPRING_DATASOURCE_USERNAME=${DB_USER}
- SPRING_DATASOURCE_PASSWORD=${DB_PASS}
# Redis
- SPRING_DATA_REDIS_HOST=redis
- SPRING_DATA_REDIS_PORT=6379
# AWS S3 / MinIO (Docker 내부 통신용)
- CLOUD_AWS_CREDENTIALS_ACCESS_KEY=admin
- CLOUD_AWS_CREDENTIALS_SECRET_KEY=password
- CLOUD_AWS_REGION_STATIC=ap-northeast-2
- CLOUD_AWS_S3_ENDPOINT=http://minio:9000
- SPRING_CLOUD_AWS_CREDENTIALS_ACCESS_KEY=admin
- SPRING_CLOUD_AWS_CREDENTIALS_SECRET_KEY=password
- SPRING_CLOUD_AWS_REGION_STATIC=ap-northeast-2
- SPRING_CLOUD_AWS_S3_ENDPOINT=http://minio:9000
# SMTP 메일 설정
- SPRING_MAIL_HOST=smtp.gmail.com
- SPRING_MAIL_PORT=587
- SPRING_MAIL_USERNAME=${MAIL_USER}
- SPRING_MAIL_PASSWORD=${MAIL_PASS}
# SMTP 세부 설정
- SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=true
- SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE=true
- SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_REQUIRED=true
- SPRING_MAIL_PROPERTIES_MAIL_SMTP_CONNECTIONTIMEOUT=5000
- SPRING_MAIL_PROPERTIES_MAIL_SMTP_TIMEOUT=5000
- SPRING_MAIL_PROPERTIES_MAIL_SMTP_WRITETIMEOUT=5000
depends_on:
db:
condition: service_healthy
@@ -35,13 +47,13 @@ services:
ports:
- "5432:5432"
environment:
POSTGRES_USER: wypark
POSTGRES_PASSWORD: your_password
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
POSTGRES_DB: blog_db
volumes:
- ./postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U wypark -d blog_db"]
test: [ "CMD-SHELL", "pg_isready -U ${DB_USER} -d blog_db" ]
interval: 5s
retries: 5
networks:
@@ -53,7 +65,7 @@ services:
container_name: blog-redis
restart: always
healthcheck:
test: ["CMD", "redis-cli", "ping"]
test: [ "CMD", "redis-cli", "ping" ]
interval: 5s
retries: 5
networks:

View File

@@ -0,0 +1,17 @@
package me.wypark.blogbackend.api.common
data class ApiResponse<T>(
val code: String = "SUCCESS",
val message: String = "요청이 성공했습니다.",
val data: T? = null
) {
companion object {
fun <T> success(data: T? = null, message: String = "요청이 성공했습니다."): ApiResponse<T> {
return ApiResponse("SUCCESS", message, data)
}
fun error(message: String, code: String = "ERROR"): ApiResponse<Nothing> {
return ApiResponse(code, message, null)
}
}
}

View File

@@ -0,0 +1,49 @@
package me.wypark.blogbackend.api.controller
import jakarta.validation.Valid
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.*
import me.wypark.blogbackend.domain.auth.AuthService
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.*
@RestController
@RequestMapping("/api/auth")
class AuthController(
private val authService: AuthService
) {
@PostMapping("/signup")
fun signup(@RequestBody @Valid request: SignupRequest): ResponseEntity<ApiResponse<Nothing>> {
authService.signup(request)
return ResponseEntity.ok(ApiResponse.success(message = "회원가입에 성공했습니다. 이메일 인증을 완료해주세요."))
}
@PostMapping("/verify")
fun verifyEmail(@RequestBody @Valid request: VerifyEmailRequest): ResponseEntity<ApiResponse<Nothing>> {
authService.verifyEmail(request.email, request.code)
return ResponseEntity.ok(ApiResponse.success(message = "이메일 인증이 완료되었습니다."))
}
@PostMapping("/login")
fun login(@RequestBody @Valid request: LoginRequest): ResponseEntity<ApiResponse<TokenDto>> {
val tokenDto = authService.login(request)
return ResponseEntity.ok(ApiResponse.success(tokenDto))
}
@PostMapping("/reissue")
fun reissue(@RequestBody request: ReissueRequest): ResponseEntity<ApiResponse<TokenDto>> {
val tokenDto = authService.reissue(request.accessToken, request.refreshToken)
return ResponseEntity.ok(ApiResponse.success(tokenDto))
}
@PostMapping("/logout")
fun logout(@AuthenticationPrincipal user: User): ResponseEntity<ApiResponse<Nothing>> {
authService.logout(user.username) // user.username은 email입니다.
return ResponseEntity.ok(ApiResponse.success(message = "로그아웃 되었습니다."))
}
}
data class ReissueRequest(val accessToken: String, val refreshToken: String)

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.CategoryResponse
import me.wypark.blogbackend.domain.category.CategoryService
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/categories")
class CategoryController(
private val categoryService: CategoryService
) {
@GetMapping
fun getCategoryTree(): ResponseEntity<ApiResponse<List<CategoryResponse>>> {
return ResponseEntity.ok(ApiResponse.success(categoryService.getCategoryTree()))
}
}

View File

@@ -0,0 +1,49 @@
package me.wypark.blogbackend.api.controller
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.CommentDeleteRequest
import me.wypark.blogbackend.api.dto.CommentResponse
import me.wypark.blogbackend.api.dto.CommentSaveRequest
import me.wypark.blogbackend.domain.comment.CommentService
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.*
@RestController
@RequestMapping("/api/comments")
class CommentController(
private val commentService: CommentService
) {
// 댓글 목록 조회
@GetMapping
fun getComments(@RequestParam postSlug: String): ResponseEntity<ApiResponse<List<CommentResponse>>> {
return ResponseEntity.ok(ApiResponse.success(commentService.getComments(postSlug)))
}
// 댓글 작성 (회원 or 비회원)
@PostMapping
fun createComment(
@RequestBody request: CommentSaveRequest,
@AuthenticationPrincipal user: User? // 비회원이면 null이 들어옴
): ResponseEntity<ApiResponse<Long>> {
val email = user?.username // null이면 비회원
val commentId = commentService.createComment(request, email)
return ResponseEntity.ok(ApiResponse.success(commentId, "댓글이 등록되었습니다."))
}
// 댓글 삭제
@DeleteMapping("/{id}")
fun deleteComment(
@PathVariable id: Long,
@RequestBody(required = false) request: CommentDeleteRequest?, // 비회원용 비밀번호 바디
@AuthenticationPrincipal user: User?
): ResponseEntity<ApiResponse<Nothing>> {
val email = user?.username
val password = request?.guestPassword
commentService.deleteComment(id, email, password)
return ResponseEntity.ok(ApiResponse.success(message = "댓글이 삭제되었습니다."))
}
}

View File

@@ -0,0 +1,38 @@
package me.wypark.blogbackend.api.controller
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.PostResponse
import me.wypark.blogbackend.api.dto.PostSummaryResponse
import me.wypark.blogbackend.domain.post.PostService
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.data.web.PageableDefault
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/posts")
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
): ResponseEntity<ApiResponse<Page<PostSummaryResponse>>> {
val result = postService.searchPosts(keyword, category, tag, pageable)
return ResponseEntity.ok(ApiResponse.success(result))
}
// 상세 조회 (Slug)
@GetMapping("/{slug}")
fun getPost(@PathVariable slug: String): ResponseEntity<ApiResponse<PostResponse>> {
return ResponseEntity.ok(ApiResponse.success(postService.getPostBySlug(slug)))
}
}

View File

@@ -0,0 +1,26 @@
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.domain.category.CategoryService
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/admin/categories")
class AdminCategoryController(
private val categoryService: CategoryService
) {
@PostMapping
fun createCategory(@RequestBody request: CategoryCreateRequest): ResponseEntity<ApiResponse<Long>> {
val id = categoryService.createCategory(request)
return ResponseEntity.ok(ApiResponse.success(id, "카테고리가 생성되었습니다."))
}
@DeleteMapping("/{id}")
fun deleteCategory(@PathVariable id: Long): ResponseEntity<ApiResponse<Nothing>> {
categoryService.deleteCategory(id)
return ResponseEntity.ok(ApiResponse.success(message = "카테고리가 삭제되었습니다."))
}
}

View File

@@ -0,0 +1,33 @@
package me.wypark.blogbackend.api.controller.admin
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.AdminCommentResponse
import me.wypark.blogbackend.api.dto.CommentResponse
import me.wypark.blogbackend.domain.comment.CommentService
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.data.web.PageableDefault
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/admin/comments")
class AdminCommentController(
private val commentService: CommentService
) {
// 관리자 권한으로 댓글 삭제
@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
): ResponseEntity<ApiResponse<Page<AdminCommentResponse>>> {
return ResponseEntity.ok(ApiResponse.success(commentService.getAllComments(pageable)))
}
}

View File

@@ -0,0 +1,26 @@
package me.wypark.blogbackend.api.controller.admin
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.domain.image.ImageService
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
@RestController
@RequestMapping("/api/admin/images")
class AdminImageController(
private val imageService: ImageService
) {
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun uploadImage(
@RequestPart("image") image: MultipartFile
): ResponseEntity<ApiResponse<String>> {
val imageUrl = imageService.uploadImage(image)
return ResponseEntity.ok(ApiResponse.success(imageUrl, "이미지 업로드 성공"))
}
}

View File

@@ -0,0 +1,30 @@
package me.wypark.blogbackend.api.controller.admin
import jakarta.validation.Valid
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.PostSaveRequest
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
@RestController
@RequestMapping("/api/admin/posts")
class AdminPostController(
private val postService: PostService
) {
@PostMapping
fun createPost(
@RequestBody @Valid request: PostSaveRequest,
@AuthenticationPrincipal user: User
): ResponseEntity<ApiResponse<Long>> {
// user.username은 email입니다.
val postId = postService.createPost(request, user.username)
return ResponseEntity.ok(ApiResponse.success(postId, "게시글이 작성되었습니다."))
}
}

View File

@@ -0,0 +1,37 @@
package me.wypark.blogbackend.api.dto
import jakarta.validation.constraints.Email
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
// 회원가입 요청
data class SignupRequest(
@field:NotBlank(message = "이메일은 필수입니다.")
@field:Email(message = "올바른 이메일 형식이 아닙니다.")
val email: String,
@field:NotBlank(message = "비밀번호는 필수입니다.")
@field:Size(min = 8, max = 20, message = "비밀번호는 8자 이상 20자 이하로 입력해주세요.")
val password: String,
@field:NotBlank(message = "닉네임은 필수입니다.")
@field:Size(min = 2, max = 10, message = "닉네임은 2자 이상 10자 이하로 입력해주세요.")
val nickname: String
)
// 로그인 요청
data class LoginRequest(
@field:NotBlank(message = "이메일을 입력해주세요.")
val email: String,
@field:NotBlank(message = "비밀번호를 입력해주세요.")
val password: String
)
data class VerifyEmailRequest(
@field:NotBlank(message = "이메일을 입력해주세요")
val email: String,
@field:NotBlank(message = "인증 코드를 입력해주세요")
val code: String
)

View File

@@ -0,0 +1,51 @@
package me.wypark.blogbackend.api.dto
import me.wypark.blogbackend.domain.category.Category
// [요청] 카테고리 생성
data class CategoryCreateRequest(
val name: String,
val parentId: Long? = null // null이면 최상위(Root) 카테고리
)
// [응답] 카테고리 트리 구조 (재귀)
data class CategoryResponse(
val id: Long,
val name: String,
val children: List<CategoryResponse> // 자식들
) {
companion object {
// Entity -> DTO 변환 (재귀 호출)
fun from(category: Category): CategoryResponse {
return CategoryResponse(
id = category.id!!,
name = category.name,
// 자식들을 DTO로 변환하여 리스트에 담음
children = category.children.map { from(it) }
)
}
}
}
// [Admin용 응답] 관리자 대시보드 목록용
data class AdminCommentResponse(
val id: Long,
val content: String,
val author: String,
val postTitle: String, // 어떤 글인지 식별
val postSlug: String, // 클릭 시 해당 글로 이동용
val createdAt: java.time.LocalDateTime
) {
companion object {
fun from(comment: me.wypark.blogbackend.domain.comment.Comment): AdminCommentResponse {
return AdminCommentResponse(
id = comment.id!!,
content = comment.content,
author = comment.getAuthorName(),
postTitle = comment.post.title,
postSlug = comment.post.slug,
createdAt = comment.createdAt
)
}
}
}

View File

@@ -0,0 +1,46 @@
package me.wypark.blogbackend.api.dto
import me.wypark.blogbackend.domain.comment.Comment
import java.time.LocalDateTime
// [응답] 댓글 (계층형 구조)
data class CommentResponse(
val id: Long,
val content: String,
val author: String,
val isPostAuthor: Boolean, // 👈 [추가] 게시글 작성자 여부
val createdAt: LocalDateTime,
val children: List<CommentResponse>
) {
companion object {
fun from(comment: Comment): CommentResponse {
// 게시글 작성자 ID와 댓글 작성자(회원) ID가 같은지 비교
// comment.member는 비회원일 경우 null이므로 안전하게 처리됨
val isAuthor = comment.member?.id == comment.post.member.id
return CommentResponse(
id = comment.id!!,
content = comment.content,
author = comment.getAuthorName(),
isPostAuthor = isAuthor, // 👈 계산된 값 주입
createdAt = comment.createdAt,
children = comment.children.map { from(it) }
)
}
}
}
// [요청] 댓글 작성
data class CommentSaveRequest(
val postSlug: String,
val content: String,
val parentId: Long? = null, // 대댓글일 경우 부모 ID
// 비회원 전용 필드 (회원은 null 가능)
val guestNickname: String? = null,
val guestPassword: String? = null
)
// [요청] 댓글 삭제 (비회원용 비밀번호 전달)
data class CommentDeleteRequest(
val guestPassword: String? = null
)

View File

@@ -0,0 +1,62 @@
package me.wypark.blogbackend.api.dto
import me.wypark.blogbackend.domain.post.Post
import java.time.LocalDateTime
// [응답] 게시글 상세 정보
data class PostResponse(
val id: Long,
val title: String,
val content: String,
val slug: String,
val categoryName: String?,
val viewCount: Long,
val createdAt: LocalDateTime
) {
// Entity -> DTO 변환 편의 메서드
companion object {
fun from(post: Post): PostResponse {
return PostResponse(
id = post.id!!,
title = post.title,
content = post.content,
slug = post.slug,
categoryName = post.category?.name,
viewCount = post.viewCount,
createdAt = post.createdAt
)
}
}
}
// [응답] 게시글 목록용 (본문 제외, 가볍게)
data class PostSummaryResponse(
val id: Long,
val title: String,
val slug: String,
val categoryName: String?,
val viewCount: Long,
val createdAt: LocalDateTime
) {
companion object {
fun from(post: Post): PostSummaryResponse {
return PostSummaryResponse(
id = post.id!!,
title = post.title,
slug = post.slug,
categoryName = post.category?.name,
viewCount = post.viewCount,
createdAt = post.createdAt
)
}
}
}
// [요청] 게시글 작성/수정
data class PostSaveRequest(
val title: String,
val content: String, // 마크다운 원문
val slug: String? = null,
val categoryId: Long? = null,
val tags: List<String> = emptyList() // 태그는 나중에 구현
)

View File

@@ -0,0 +1,50 @@
package me.wypark.blogbackend.core.config
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.mail.javamail.JavaMailSender
import org.springframework.mail.javamail.JavaMailSenderImpl
import java.util.*
@Configuration
class MailConfig(
@Value("\${spring.mail.host}") private val host: String,
@Value("\${spring.mail.port}") private val port: Int,
@Value("\${spring.mail.username}") private val username: String,
@Value("\${spring.mail.password}") private val password: String,
@Value("\${spring.mail.properties.mail.smtp.auth}") private val auth: String,
@Value("\${spring.mail.properties.mail.smtp.starttls.enable}") private val starttlsEnable: String,
@Value("\${spring.mail.properties.mail.smtp.starttls.required}") private val starttlsRequired: String,
@Value("\${spring.mail.properties.mail.smtp.connectiontimeout}") private val connectionTimeout: Int,
@Value("\${spring.mail.properties.mail.smtp.timeout}") private val timeout: Int,
@Value("\${spring.mail.properties.mail.smtp.writetimeout}") private val writeTimeout: Int
) {
@Bean
fun javaMailSender(): JavaMailSender {
val mailSender = JavaMailSenderImpl()
// 기본 설정
mailSender.host = host
mailSender.port = port
mailSender.username = username
mailSender.password = password
mailSender.defaultEncoding = "UTF-8"
// 세부 프로퍼티 설정
val props: Properties = mailSender.javaMailProperties
props["mail.transport.protocol"] = "smtp"
props["mail.smtp.auth"] = auth
props["mail.smtp.starttls.enable"] = starttlsEnable
props["mail.smtp.starttls.required"] = starttlsRequired
props["mail.debug"] = "true" // 디버깅용 로그 출력 (배포 시 false로 변경 추천)
// 타임아웃 설정 (서버 응답 없을 때 무한 대기 방지)
props["mail.smtp.connectiontimeout"] = connectionTimeout
props["mail.smtp.timeout"] = timeout
props["mail.smtp.writetimeout"] = writeTimeout
return mailSender
}
}

View File

@@ -0,0 +1,18 @@
package me.wypark.blogbackend.core.config
import com.querydsl.jpa.impl.JPAQueryFactory
import jakarta.persistence.EntityManager
import jakarta.persistence.PersistenceContext
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class QueryDslConfig(
@PersistenceContext
private val entityManager: EntityManager
) {
@Bean
fun jpaQueryFactory(): JPAQueryFactory {
return JPAQueryFactory(entityManager)
}
}

View File

@@ -0,0 +1,31 @@
package me.wypark.blogbackend.core.config
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider
import software.amazon.awssdk.regions.Region
import software.amazon.awssdk.services.s3.S3Client
import java.net.URI
@Configuration
class S3Config(
@Value("\${spring.cloud.aws.credentials.access-key:admin}") private val accessKey: String,
@Value("\${spring.cloud.aws.credentials.secret-key:password}") private val secretKey: String,
@Value("\${spring.cloud.aws.region.static:ap-northeast-2}") private val regionStr: String, // 변수명 regionStr 확인
@Value("\${spring.cloud.aws.s3.endpoint:http://minio:9000}") private val endpoint: String
) {
@Bean
fun s3Client(): S3Client {
return S3Client.builder()
.region(Region.of(regionStr))
.credentialsProvider(
StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey))
)
.endpointOverride(URI.create(endpoint)) // MinIO 주소
.forcePathStyle(true) // MinIO 필수 설정
.build()
}
}

View File

@@ -32,7 +32,7 @@ class SecurityConfig(
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/admin/**").hasRole("ADMIN")
auth.requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN")
auth.anyRequest().authenticated()
}
// 필터 등록: UsernamePasswordAuthenticationFilter 앞에 JwtFilter를 실행

View File

@@ -0,0 +1,38 @@
package me.wypark.blogbackend.core.error
import me.wypark.blogbackend.api.common.ApiResponse
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdvice
class GlobalExceptionHandler {
// 1. 비즈니스 로직 에러 (의도적인 throw)
@ExceptionHandler(IllegalArgumentException::class, IllegalStateException::class)
fun handleBusinessException(e: RuntimeException): ResponseEntity<ApiResponse<Nothing>> {
return ResponseEntity
.badRequest()
.body(ApiResponse.error(e.message ?: "잘못된 요청입니다."))
}
// 2. @Valid 검증 실패 (DTO 유효성 체크)
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidationException(e: MethodArgumentNotValidException): ResponseEntity<ApiResponse<Nothing>> {
val message = e.bindingResult.fieldErrors.firstOrNull()?.defaultMessage ?: "입력값이 올바르지 않습니다."
return ResponseEntity
.badRequest()
.body(ApiResponse.error(message))
}
// 3. 나머지 알 수 없는 에러
@ExceptionHandler(Exception::class)
fun handleException(e: Exception): ResponseEntity<ApiResponse<Nothing>> {
e.printStackTrace() // 로그 남기기
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("서버 내부 오류가 발생했습니다."))
}
}

View File

@@ -0,0 +1,144 @@
package me.wypark.blogbackend.domain.auth
import me.wypark.blogbackend.api.dto.LoginRequest
import me.wypark.blogbackend.api.dto.SignupRequest
import me.wypark.blogbackend.api.dto.TokenDto
import me.wypark.blogbackend.core.config.jwt.JwtProvider
import me.wypark.blogbackend.domain.user.Member
import me.wypark.blogbackend.domain.user.MemberRepository
import me.wypark.blogbackend.domain.user.Role
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
@Transactional(readOnly = true)
class AuthService(
private val memberRepository: MemberRepository,
private val passwordEncoder: PasswordEncoder,
private val authenticationManagerBuilder: AuthenticationManagerBuilder,
private val jwtProvider: JwtProvider,
private val refreshTokenRepository: RefreshTokenRepository,
private val emailService: EmailService
) {
/**
* 회원가입
*/
@Transactional
fun signup(request: SignupRequest) {
if (memberRepository.existsByEmail(request.email)) {
throw IllegalArgumentException("이미 가입된 이메일입니다.")
}
if (memberRepository.existsByNickname(request.nickname)) {
throw IllegalArgumentException("이미 사용 중인 닉네임입니다.")
}
val member = Member(
email = request.email,
password = passwordEncoder.encode(request.password),
nickname = request.nickname,
role = Role.ROLE_USER,
isVerified = false
)
memberRepository.save(member)
emailService.sendVerificationCode(request.email)
}
/**
* 로그인
*/
@Transactional
fun login(request: LoginRequest): TokenDto {
val member = memberRepository.findByEmail(request.email)
?: throw IllegalArgumentException("가입되지 않은 이메일입니다.")
// 비밀번호 체크
if (!passwordEncoder.matches(request.password, member.password)) {
throw IllegalArgumentException("비밀번호가 일치하지 않습니다.")
}
// 이메일 인증 여부 체크
if (!member.isVerified) {
throw IllegalStateException("이메일 인증이 필요합니다.")
}
// 1. ID/PW 기반의 인증 토큰 생성
val authenticationToken = UsernamePasswordAuthenticationToken(request.email, request.password)
// 2. 실제 검증 (사용자 비밀번호 체크)
// authenticate() 실행 시 CustomUserDetailsService.loadUserByUsername 실행됨
val authentication = authenticationManagerBuilder.`object`.authenticate(authenticationToken)
// 3. 인증 정보를 기반으로 JWT 토큰 생성
val tokenDto = jwtProvider.generateTokenDto(authentication)
// 4. RefreshToken Redis 저장 (RTR: 기존 토큰 덮어쓰기)
refreshTokenRepository.save(authentication.name, tokenDto.refreshToken)
return tokenDto
}
/**
* 토큰 재발급 (RTR 적용)
*/
@Transactional
fun reissue(accessToken: String, refreshToken: String): TokenDto {
// 1. 리프레시 토큰 검증 (만료 여부, 위변조 여부)
if (!jwtProvider.validateToken(refreshToken)) {
throw IllegalArgumentException("유효하지 않은 Refresh Token입니다.")
}
// 2. 액세스 토큰에서 User ID(Email) 가져오기 (만료된 토큰이어도 파싱 가능하도록 JwtProvider가 설계됨)
val authentication = jwtProvider.getAuthentication(accessToken)
// 3. Redis에서 저장된 Refresh Token 가져오기
val savedRefreshToken = refreshTokenRepository.findByEmail(authentication.name)
?: throw IllegalArgumentException("로그아웃 된 사용자입니다.")
// 4. 토큰 일치 여부 확인 (재사용 방지)
if (savedRefreshToken != refreshToken) {
refreshTokenRepository.delete(authentication.name)
throw IllegalArgumentException("토큰 정보가 일치하지 않습니다.")
}
// 5. 새 토큰 생성 (Rotation)
val newTokenDto = jwtProvider.generateTokenDto(authentication)
// 6. Redis 업데이트 (한번 쓴 토큰 폐기 -> 새 토큰 저장)
refreshTokenRepository.save(authentication.name, newTokenDto.refreshToken)
return newTokenDto
}
/**
* 로그아웃
*/
@Transactional
fun logout(email: String) {
refreshTokenRepository.delete(email)
}
// 3. 이메일 인증 확인
@Transactional
fun verifyEmail(email: String, code: String) {
val member = memberRepository.findByEmail(email)
?: throw IllegalArgumentException("존재하지 않는 회원입니다.")
if (member.isVerified) {
throw IllegalArgumentException("이미 인증된 회원입니다.")
}
// 코드 검증
if (!emailService.verifyCode(email, code)) {
throw IllegalArgumentException("인증 코드가 올바르지 않거나 만료되었습니다.")
}
// 인증 상태 업데이트
member.verify()
}
}

View File

@@ -0,0 +1,30 @@
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
import org.springframework.stereotype.Service
@Service
class CustomUserDetailsService(
private val memberRepository: MemberRepository
) : UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails {
return memberRepository.findByEmail(username)
?.let { createUserDetails(it) }
?: throw UsernameNotFoundException("해당 유저를 찾을 수 없습니다: $username")
}
private fun createUserDetails(member: Member): UserDetails {
return User(
member.email,
member.password,
listOf(SimpleGrantedAuthority(member.role.name))
)
}
}

View File

@@ -0,0 +1,77 @@
package me.wypark.blogbackend.domain.auth
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.mail.javamail.JavaMailSender
import org.springframework.mail.javamail.MimeMessageHelper
import org.springframework.stereotype.Service
import java.util.concurrent.TimeUnit
import kotlin.random.Random
@Service
class EmailService(
private val javaMailSender: JavaMailSender,
private val redisTemplate: RedisTemplate<String, String>
) {
// 인증 코드 전송
fun sendVerificationCode(email: String) {
val code = createVerificationCode()
// 1. Redis에 저장 (Key: "Verify:이메일", Value: 코드, 유효시간: 5분)
redisTemplate.opsForValue().set(
"Verify:$email",
code,
5,
TimeUnit.MINUTES
)
// 2. 메일 발송
sendMail(email, code)
}
// 인증 코드 검증
fun verifyCode(email: String, code: String): Boolean {
val savedCode = redisTemplate.opsForValue().get("Verify:$email")
return savedCode != null && savedCode == code
}
private fun createVerificationCode(): String {
return Random.nextInt(100000, 999999).toString() // 6자리 난수
}
private fun sendMail(email: String, code: String) {
val mimeMessage = javaMailSender.createMimeMessage()
val helper = MimeMessageHelper(mimeMessage, "utf-8")
helper.setTo(email)
helper.setSubject("[Blog] 회원가입 인증 코드입니다.")
val htmlContent = """
<div style="font-family: 'Apple SD Gothic Neo', 'sans-serif' !important; width: 540px; height: 600px; border-top: 4px solid #00C73C; margin: 100px auto; padding: 30px 0; box-sizing: border-box;">
<h1 style="margin: 0; padding: 0 5px; font-size: 28px; font-weight: 400;">
<span style="font-size: 15px; margin: 0 0 10px 3px;">Wypark Blog</span><br />
<span style="color: #00C73C;">메일인증</span> 안내입니다.
</h1>
<p style="font-size: 16px; line-height: 26px; margin-top: 50px; padding: 0 5px;">
안녕하세요.<br />
Wypark Blog에 가입해 주셔서 진심으로 감사드립니다.<br />
아래 <b style="color: #00C73C;">'인증 코드'</b>를 입력하여 회원가입을 완료해 주세요.<br />
감사합니다.
</p>
<div style="margin-top: 50px; border-top: 1px solid #DDD; border-bottom: 1px solid #DDD; padding: 25px; background-color: #F9F9F9; text-align: center;">
<div style="font-size: 24px; font-weight: bold; letter-spacing: 5px; color: #333;">
$code
</div>
</div>
<div style="margin-top: 30px; text-align: center;">
<p style="font-size: 14px; color: #888;">이 코드는 5분간 유효합니다.</p>
</div>
</div>
""".trimIndent()
helper.setText(htmlContent, true) // true: HTML 모드 켜기
javaMailSender.send(mimeMessage)
}
}

View File

@@ -0,0 +1,25 @@
package me.wypark.blogbackend.domain.category
import jakarta.persistence.*
@Entity
class Category(
@Column(nullable = false)
var name: String,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
var parent: Category? = null, // 부모 카테고리 (없으면 최상위)
@OneToMany(mappedBy = "parent", cascade = [CascadeType.ALL])
val children: MutableList<Category> = mutableListOf() // 자식 카테고리들
) {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
// 연관관계 편의 메서드 (부모-자식 연결)
fun addChild(child: Category) {
this.children.add(child)
child.parent = this
}
}

View File

@@ -0,0 +1,17 @@
package me.wypark.blogbackend.domain.category
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
interface CategoryRepository : JpaRepository<Category, Long> {
// 부모가 없는 최상위 카테고리들만 조회 (이걸 가져오면 자식들은 줄줄이 딸려옵니다)
@Query("SELECT DISTINCT c FROM Category c LEFT JOIN FETCH c.children WHERE c.parent IS NULL")
fun findAllRoots(): List<Category>
// 카테고리 이름 중복 검사 (같은 레벨에서 중복 방지용)
fun existsByName(name: String): Boolean
// 이름으로 찾기 (게시글 작성 시 필요)
fun findByName(name: String): Category?
}

View File

@@ -0,0 +1,61 @@
package me.wypark.blogbackend.domain.category
import me.wypark.blogbackend.api.dto.CategoryCreateRequest
import me.wypark.blogbackend.api.dto.CategoryResponse
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
@Transactional(readOnly = true)
class CategoryService(
private val categoryRepository: CategoryRepository
) {
/**
* [Public] 카테고리 트리 조회
* 최상위(Root)만 조회하면, Entity 설정을 통해 자식들도 딸려옵니다.
*/
fun getCategoryTree(): List<CategoryResponse> {
val roots = categoryRepository.findAllRoots()
return roots.map { CategoryResponse.from(it) }
}
/**
* [Admin] 카테고리 생성
*/
@Transactional
fun createCategory(request: CategoryCreateRequest): Long {
// 이름 중복 체크 (선택 사항이지만 추천)
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 deleteCategory(id: Long) {
categoryRepository.deleteById(id)
}
}

View File

@@ -0,0 +1,50 @@
package me.wypark.blogbackend.domain.comment
import jakarta.persistence.*
import me.wypark.blogbackend.domain.common.BaseTimeEntity
import me.wypark.blogbackend.domain.post.Post
import me.wypark.blogbackend.domain.user.Member
@Entity
class Comment(
@Column(nullable = false, columnDefinition = "TEXT")
var content: String,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
val post: Post,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
var parent: Comment? = null, // 대댓글용 부모 댓글
@OneToMany(mappedBy = "parent", cascade = [CascadeType.ALL], orphanRemoval = true)
val children: MutableList<Comment> = mutableListOf(),
// --- 1. 회원일 경우 ---
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
val member: Member? = null,
// --- 2. 비회원일 경우 ---
@Column
var guestNickname: String? = null,
@Column
var guestPassword: String? = null // 암호화해서 저장 권장
) : BaseTimeEntity() {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
// 댓글 작성자 이름 가져오기 (회원이면 닉네임, 비회원이면 입력한 이름)
fun getAuthorName(): String {
return member?.nickname ?: guestNickname ?: "알 수 없음"
}
// 비회원 비밀번호 검증
fun matchGuestPassword(password: String): Boolean {
return this.guestPassword == password
}
}

View File

@@ -0,0 +1,14 @@
package me.wypark.blogbackend.domain.comment
import me.wypark.blogbackend.domain.post.Post
import org.springframework.data.jpa.repository.JpaRepository
interface CommentRepository : JpaRepository<Comment, Long> {
// 특정 게시글의 모든 댓글 조회 (최상위 부모 댓글 기준 + 작성순)
// 자식 댓글은 Entity의 children 필드를 통해 가져오거나, BatchSize로 최적화합니다.
fun findAllByPostAndParentIsNullOrderByCreatedAtAsc(post: Post): List<Comment>
// 게시글 삭제 시 관련 댓글 전체 삭제용
fun deleteAllByPost(post: Post)
}

View File

@@ -0,0 +1,120 @@
package me.wypark.blogbackend.domain.comment
import me.wypark.blogbackend.api.dto.AdminCommentResponse
import me.wypark.blogbackend.api.dto.CommentResponse
import me.wypark.blogbackend.api.dto.CommentSaveRequest
import me.wypark.blogbackend.domain.post.PostRepository
import me.wypark.blogbackend.domain.user.MemberRepository
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
@Transactional(readOnly = true)
class CommentService(
private val commentRepository: CommentRepository,
private val postRepository: PostRepository,
private val memberRepository: MemberRepository,
private val passwordEncoder: PasswordEncoder // 비밀번호 암호화용
) {
/**
* [Public] 특정 게시글의 댓글 목록 조회 (계층형)
*/
fun getComments(postSlug: String): List<CommentResponse> {
val post = postRepository.findBySlug(postSlug)
?: throw IllegalArgumentException("존재하지 않는 게시글입니다.")
// 최상위(부모가 null) 댓글만 가져오면, Entity 설정에 의해 자식들은 자동으로 딸려옴
val roots = commentRepository.findAllByPostAndParentIsNullOrderByCreatedAtAsc(post)
return roots.map { CommentResponse.from(it) }
}
/**
* [Hybrid] 댓글 작성 (회원/비회원 공용)
*/
@Transactional
fun createComment(request: CommentSaveRequest, userEmail: String?): Long {
// 1. 게시글 조회
val post = postRepository.findBySlug(request.postSlug)
?: throw IllegalArgumentException("존재하지 않는 게시글입니다.")
// 2. 부모 댓글 조회 (대댓글인 경우)
val parent = request.parentId?.let {
commentRepository.findByIdOrNull(it)
?: throw IllegalArgumentException("부모 댓글이 존재하지 않습니다.")
}
// 3. 회원/비회원 구분 로직
val comment = if (userEmail != null) {
// [회원] DB에서 회원 정보 조회 후 연결
val member = memberRepository.findByEmail(userEmail)
?: throw IllegalArgumentException("회원 정보를 찾을 수 없습니다.")
Comment(
content = request.content,
post = post,
parent = parent,
member = member // 회원 연결
)
} else {
// [비회원] 닉네임/비밀번호 필수 체크
if (request.guestNickname.isNullOrBlank() || request.guestPassword.isNullOrBlank()) {
throw IllegalArgumentException("비회원은 닉네임과 비밀번호가 필수입니다.")
}
Comment(
content = request.content,
post = post,
parent = parent,
guestNickname = request.guestNickname,
guestPassword = passwordEncoder.encode(request.guestPassword)
)
}
// 4. 부모가 있다면 연결 (양방향 편의)
parent?.children?.add(comment)
return commentRepository.save(comment).id!!
}
@Transactional
fun deleteComment(commentId: Long, userEmail: String?, guestPassword: String?) {
val comment = commentRepository.findByIdOrNull(commentId)
?: throw IllegalArgumentException("존재하지 않는 댓글입니다.")
// 권한 검증
if (userEmail != null) {
// [회원] 본인 댓글인지 확인 (이메일 비교)
if (comment.member?.email != userEmail) {
throw IllegalArgumentException("본인의 댓글만 삭제할 수 있습니다.")
}
} else {
// [비회원] 비밀번호 일치 확인
if (comment.guestPassword == null || guestPassword == null ||
!passwordEncoder.matches(guestPassword, comment.guestPassword)) {
throw IllegalArgumentException("비밀번호가 일치하지 않습니다.")
}
}
// 삭제 진행
commentRepository.delete(comment)
}
@Transactional
fun deleteCommentByAdmin(commentId: Long) {
val comment = commentRepository.findByIdOrNull(commentId)
?: throw IllegalArgumentException("존재하지 않는 댓글입니다.")
commentRepository.delete(comment)
}
fun getAllComments(pageable: Pageable): Page<AdminCommentResponse> {
return commentRepository.findAll(pageable)
.map { AdminCommentResponse.from(it) }
}
}

View File

@@ -0,0 +1,22 @@
package me.wypark.blogbackend.domain.common
import jakarta.persistence.Column
import jakarta.persistence.EntityListeners
import jakarta.persistence.MappedSuperclass
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime
@MappedSuperclass // 상속받은 엔티티가 이 클래스의 필드(컬럼)를 인식하도록 함
@EntityListeners(AuditingEntityListener::class) // JPA Auditing 기능 활성화
abstract class BaseTimeEntity {
@CreatedDate
@Column(nullable = false, updatable = false) // 생성일은 수정 불가
var createdAt: LocalDateTime = LocalDateTime.now()
@LastModifiedDate
@Column(nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now()
}

View File

@@ -0,0 +1,72 @@
package me.wypark.blogbackend.domain.image
import org.springframework.beans.factory.annotation.Value
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.PutObjectRequest
import java.util.*
@Service
class ImageService(
private val s3Client: S3Client,
@Value("\${spring.cloud.aws.s3.endpoint:http://minio:9000}") private val endpoint: String
) {
private val bucketName = "blog-images" // 버킷 이름
init {
createBucketIfNotExists()
}
fun uploadImage(file: MultipartFile): String {
// 1. 파일명 중복 방지 (UUID 사용)
val originalName = file.originalFilename ?: "image.jpg"
val ext = originalName.substringAfterLast(".", "jpg")
val fileName = "${UUID.randomUUID()}.$ext"
// 2. S3(MinIO)로 업로드
val putObjectRequest = PutObjectRequest.builder()
.bucket(bucketName)
.key(fileName)
.contentType(file.contentType)
.build()
s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(file.inputStream, file.size))
// 3. 접속 가능한 URL 반환
// 로컬 개발 환경에서는 localhost 주소를 직접 조합해서 줍니다.
// 배포 시에는 실제 도메인이나 CloudFront 주소로 변경해야 합니다.
return "$endpoint/$bucketName/$fileName"
}
private fun createBucketIfNotExists() {
try {
// 버킷 존재 여부 확인 (없으면 에러 발생하므로 try-catch)
s3Client.headBucket { it.bucket(bucketName) }
} catch (e: Exception) {
// 버킷 생성
s3Client.createBucket { it.bucket(bucketName) }
// ⭐ 버킷을 Public(공개)으로 설정 (이미지 조회를 위해 필수)
val policy = """
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "AWS": ["*"] },
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::$bucketName/*"]
}
]
}
""".trimIndent()
s3Client.putBucketPolicy {
it.bucket(bucketName).policy(policy)
}
}
}
}

View File

@@ -0,0 +1,57 @@
package me.wypark.blogbackend.domain.post
import jakarta.persistence.*
import me.wypark.blogbackend.domain.category.Category
import me.wypark.blogbackend.domain.common.BaseTimeEntity
import me.wypark.blogbackend.domain.tag.PostTag
import me.wypark.blogbackend.domain.user.Member
@Entity
class Post(
@Column(nullable = false)
var title: String,
// 마크다운 본문 (대용량 저장을 위해 TEXT 타입 지정)
@Column(nullable = false, columnDefinition = "TEXT")
var content: String,
@Column(nullable = false, unique = true)
var slug: String, // URL용 제목 (예: my-first-post)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
val member: Member, // 작성자 (관리자)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
var category: Category? = null // 카테고리 (없을 수도 있음)
) : BaseTimeEntity() { // 생성일, 수정일 자동 관리
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
@Column(nullable = false)
var viewCount: Long = 0
// 조회수 증가
fun increaseViewCount() {
this.viewCount++
}
// 게시글 수정
fun update(title: String, content: String, slug: String, category: Category?) {
this.title = title
this.content = content
this.slug = slug
this.category = category
}
@OneToMany(mappedBy = "post", cascade = [CascadeType.ALL], orphanRemoval = true)
var tags: MutableList<PostTag> = mutableListOf()
fun addTags(newTags: List<PostTag>) {
this.tags.clear()
this.tags.addAll(newTags)
}
}

View File

@@ -0,0 +1,21 @@
package me.wypark.blogbackend.domain.post
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
interface PostRepository : JpaRepository<Post, Long>, PostRepositoryCustom{
// 1. Slug로 상세 조회 (URL이 깔끔해짐)
fun findBySlug(slug: String): Post?
// 2. Slug 중복 검사 (글 작성/수정 시 필수)
fun existsBySlug(slug: String): Boolean
// 3. 페이징된 목록 조회 (최신순 등은 Pageable로 해결)
override fun findAll(pageable: Pageable): Page<Post>
// 4. 특정 카테고리의 글 목록 조회
fun findAllByCategory(category: Category, pageable: Pageable): Page<Post>
}

View File

@@ -0,0 +1,9 @@
package me.wypark.blogbackend.domain.post
import me.wypark.blogbackend.api.dto.PostSummaryResponse
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
interface PostRepositoryCustom {
fun search(keyword: String?, categoryName: String?, tagName: String?, pageable: Pageable): Page<PostSummaryResponse>
}

View File

@@ -0,0 +1,112 @@
package me.wypark.blogbackend.domain.post
import com.querydsl.core.BooleanBuilder
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 org.springframework.data.domain.Page
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import me.wypark.blogbackend.domain.tag.QPostTag.postTag
import me.wypark.blogbackend.domain.tag.QTag.tag
class PostRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : PostRepositoryCustom {
override fun search(keyword: String?, categoryName: String?, tagName: String?, pageable: Pageable): Page<PostSummaryResponse> {
// 1. 동적 필터링 조건
val builder = BooleanBuilder()
builder.and(containsKeyword(keyword))
builder.and(eqCategory(categoryName))
builder.and(eqTagName(tagName)) // 👈 태그 조건 추가
// 2. 쿼리 실행 (Join 추가)
val query = queryFactory
.select(
Projections.constructor(
PostSummaryResponse::class.java,
post.id,
post.title,
post.slug,
post.category.name,
post.viewCount,
post.createdAt
)
)
.from(post)
// 👇 태그 검색을 위해 테이블 Join
.leftJoin(post.tags, postTag)
.leftJoin(postTag.tag, tag)
.where(builder)
.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()) // ⭐ 개수 셀 때도 중복 제거
.from(post)
.leftJoin(post.tags, postTag)
.leftJoin(postTag.tag, tag)
.where(builder)
.fetchOne() ?: 0L
return PageImpl(content, pageable, total)
}
// --- 조건 메서드들 ---
// 검색어 (제목 or 본문)
private fun containsKeyword(keyword: String?): BooleanBuilder {
val builder = BooleanBuilder()
if (!keyword.isNullOrBlank()) {
builder.or(post.title.containsIgnoreCase(keyword))
builder.or(post.content.containsIgnoreCase(keyword))
}
return builder
}
// 카테고리 일치 (카테고리명이 없으면 무시)
private fun eqCategory(categoryName: String?): BooleanExpression? {
if (categoryName.isNullOrBlank()) return null
return post.category.name.eq(categoryName)
}
private fun eqTagName(tagName: String?): BooleanExpression? {
if (tagName.isNullOrBlank()) return null
return tag.name.eq(tagName) // 태그 이름은 정확히 일치해야 함
}
// Pageable Sort -> QueryDSL OrderSpecifier 변환
private fun getOrderSpecifiers(pageable: Pageable): List<OrderSpecifier<*>> {
val orders = mutableListOf<OrderSpecifier<*>>()
if (!pageable.sort.isEmpty) {
for (order in pageable.sort) {
val direction = if (order.direction.isAscending) Order.ASC else Order.DESC
// 들어온 정렬 기준값(property)에 따라 QClass 필드 매핑
when (order.property) {
"viewCount" -> orders.add(OrderSpecifier(direction, post.viewCount)) // 인기순
"createdAt" -> orders.add(OrderSpecifier(direction, post.createdAt)) // 최신순
"id" -> orders.add(OrderSpecifier(direction, post.id))
else -> orders.add(OrderSpecifier(Order.DESC, post.id)) // 기본
}
}
}
return orders
}
}

View File

@@ -0,0 +1,100 @@
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.CategoryRepository
import me.wypark.blogbackend.domain.tag.PostTag
import me.wypark.blogbackend.domain.tag.Tag
import me.wypark.blogbackend.domain.tag.TagRepository
import me.wypark.blogbackend.domain.user.MemberRepository
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
@Transactional(readOnly = true)
class PostService(
private val postRepository: PostRepository,
private val categoryRepository: CategoryRepository,
private val memberRepository: MemberRepository,
private val tagRepository: TagRepository
) {
/**
* [Public] 전체 게시글 목록 조회 (페이징)
*/
fun getPosts(pageable: Pageable): Page<PostSummaryResponse> {
return postRepository.findAll(pageable)
.map { PostSummaryResponse.from(it) }
}
/**
* [Public] 게시글 상세 조회 (Slug 기반) + 조회수 증가
*/
@Transactional
fun getPostBySlug(slug: String): PostResponse {
val post = postRepository.findBySlug(slug)
?: throw IllegalArgumentException("해당 게시글을 찾을 수 없습니다: $slug")
post.increaseViewCount() // 조회수 1 증가 (Dirty Checking)
return PostResponse.from(post)
}
/**
* [Admin] 게시글 작성
*/
@Transactional
fun createPost(request: PostSaveRequest, email: String): Long {
val member = memberRepository.findByEmail(email)
?: throw IllegalArgumentException("회원 없음")
val category = request.categoryId?.let { categoryRepository.findByIdOrNull(it) }
val rawSlug = if (!request.slug.isNullOrBlank()) {
request.slug
} 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(
title = request.title,
content = request.content,
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 내부에 구현되어 있다고 가정)
post.addTags(postTags)
return postRepository.save(post).id!!
}
fun searchPosts(keyword: String?, categoryName: String?, tagName: String?, pageable: Pageable): Page<PostSummaryResponse> {
return postRepository.search(keyword, categoryName, tagName, pageable)
}
}

View File

@@ -0,0 +1,19 @@
package me.wypark.blogbackend.domain.tag
import jakarta.persistence.*
import me.wypark.blogbackend.domain.post.Post
@Entity
@Table(name = "post_tag")
class PostTag(
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
val post: Post,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tag_id")
val tag: Tag
) {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
}

View File

@@ -0,0 +1,13 @@
package me.wypark.blogbackend.domain.tag
import jakarta.persistence.*
@Entity
@Table(name = "tag")
class Tag(
@Column(nullable = false, unique = true)
val name: String
) {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
}

View File

@@ -0,0 +1,7 @@
package me.wypark.blogbackend.domain.tag
import org.springframework.data.jpa.repository.JpaRepository
interface TagRepository : JpaRepository<Tag, Long> {
fun findByName(name: String): Tag?
}

View File

@@ -0,0 +1,37 @@
package me.wypark.blogbackend.domain.user
import jakarta.persistence.*
import me.wypark.blogbackend.domain.common.BaseTimeEntity
@Entity
@Table(name = "member")
class Member(
@Column(nullable = false, unique = true)
val email: String,
@Column(nullable = false)
var password: String,
@Column(nullable = false)
var nickname: String,
@Enumerated(EnumType.STRING)
@Column(nullable = false)
val role: Role,
@Column(nullable = false)
var isVerified: Boolean = false
) : BaseTimeEntity() {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
fun verify() {
this.isVerified = true
}
}
enum class Role {
ROLE_USER, ROLE_ADMIN
}

View File

@@ -0,0 +1,10 @@
package me.wypark.blogbackend.domain.user
import org.springframework.data.jpa.repository.JpaRepository
interface MemberRepository : JpaRepository<Member, Long> {
// 로그인 및 중복 가입 방지를 위한 핵심 메소드들입니다.
fun findByEmail(email: String): Member?
fun existsByEmail(email: String): Boolean
fun existsByNickname(nickname: String): Boolean
}

View File

@@ -21,18 +21,33 @@ spring:
highlight_sql: true # 쿼리 색상 강조 (가독성 UP)
open-in-view: false # OSIV 종료 (DB 커넥션 최적화)
mail:
host: smtp.gmail.com
port: 587
username: ${MAIL_USERNAME} # 환경변수 처리 추천
password: ${MAIL_PASSWORD} # 앱 비밀번호 (16자리)
properties:
mail:
smtp:
auth: true
starttls:
enable: true
required: true
connectiontimeout: 5000
timeout: 5000
writetimeout: 5000
# 3. Redis 설정
data:
redis:
host: redis # Docker 서비스명
port: 6379
# 4. AWS S3 / MinIO 설정 (아까 이야기한 부분)
cloud:
cloud:
aws:
s3:
bucket: my-blog-bucket # MinIO 콘솔에서 미리 생성해야 함
endpoint: http://minio:9000 # Docker 내부 통신용
bucket: my-blog-bucket
endpoint: http://minio:9000
credentials:
access-key: admin
secret-key: password
@@ -45,5 +60,5 @@ cloud:
jwt:
# 보안 경고: 실제 배포 시에는 절대 코드에 비밀키를 남기지 마세요. 환경변수로 주입받으세요.
secret: ${JWT_SECRET:v3ryS3cr3tK3yF0rMyB10gPr0j3ctM4k3ItL0ng3rTh4n32Byt3s!!}
access-token-validity: 1800000 # 30분
refresh-token-validity: 604800000 # 7일
access-token-validity: 600000
refresh-token-validity: 604800000