Compare commits
6 Commits
main
...
feature/po
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
884853586d | ||
|
|
ef6ffa5670 | ||
|
|
2f6cb41764 | ||
|
|
60d645f47b | ||
|
|
6fbfcaf90b | ||
|
|
49d435079f |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
HELP.md
|
HELP.md
|
||||||
.gradle
|
.gradle
|
||||||
|
.env
|
||||||
build/
|
build/
|
||||||
!gradle/wrapper/gradle-wrapper.jar
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
!**/src/main/**/build/
|
!**/src/main/**/build/
|
||||||
@@ -38,4 +39,6 @@ out/
|
|||||||
|
|
||||||
### Kotlin ###
|
### Kotlin ###
|
||||||
.kotlin
|
.kotlin
|
||||||
|
|
||||||
|
### docker ###
|
||||||
postgres_data
|
postgres_data
|
||||||
@@ -80,5 +80,5 @@ $ docker-compose logs -f blog-api
|
|||||||
|
|
||||||
---
|
---
|
||||||
## 📝 관련 문서
|
## 📝 관련 문서
|
||||||
- [**ENDPOINT**](https://affine.wypark.me/workspace/f85df0c4-a315-4166-94a8-6558cdafff1d/p13joh_beKNJ6R-I9KrFW)
|
- [**ENDPOINT**](https://affine.wypark.me/workspace/f85df0c4-a315-4166-94a8-6558cdafff1d/_csY9ZzOnSbt4bTnIA_9w)
|
||||||
- [**API 명세서**](https://affine.wypark.me/workspace/f85df0c4-a315-4166-94a8-6558cdafff1d/7fRNXK9utxG4mcekWivGK)
|
- [**API 명세서**](https://affine.wypark.me/workspace/f85df0c4-a315-4166-94a8-6558cdafff1d/9axPww7llEcSH3nOZzi-m)
|
||||||
@@ -30,6 +30,7 @@ dependencies {
|
|||||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-data-redis")
|
implementation("org.springframework.boot:spring-boot-starter-data-redis")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-validation")
|
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||||
|
implementation("org.springframework.boot:spring-boot-starter-mail")
|
||||||
|
|
||||||
// 2. Kotlin Modules
|
// 2. Kotlin Modules
|
||||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||||
|
|||||||
@@ -9,16 +9,28 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
# Database
|
# Database
|
||||||
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/blog_db
|
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/blog_db
|
||||||
- SPRING_DATASOURCE_USERNAME=wypark
|
- SPRING_DATASOURCE_USERNAME=${DB_USER}
|
||||||
- SPRING_DATASOURCE_PASSWORD=your_password
|
- SPRING_DATASOURCE_PASSWORD=${DB_PASS}
|
||||||
# Redis
|
# Redis
|
||||||
- SPRING_DATA_REDIS_HOST=redis
|
- SPRING_DATA_REDIS_HOST=redis
|
||||||
- SPRING_DATA_REDIS_PORT=6379
|
- SPRING_DATA_REDIS_PORT=6379
|
||||||
# AWS S3 / MinIO (Docker 내부 통신용)
|
# AWS S3 / MinIO (Docker 내부 통신용)
|
||||||
- CLOUD_AWS_CREDENTIALS_ACCESS_KEY=admin
|
- SPRING_CLOUD_AWS_CREDENTIALS_ACCESS_KEY=admin
|
||||||
- CLOUD_AWS_CREDENTIALS_SECRET_KEY=password
|
- SPRING_CLOUD_AWS_CREDENTIALS_SECRET_KEY=password
|
||||||
- CLOUD_AWS_REGION_STATIC=ap-northeast-2
|
- SPRING_CLOUD_AWS_REGION_STATIC=ap-northeast-2
|
||||||
- CLOUD_AWS_S3_ENDPOINT=http://minio:9000
|
- 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:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -35,13 +47,13 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: wypark
|
POSTGRES_USER: ${DB_USER}
|
||||||
POSTGRES_PASSWORD: your_password
|
POSTGRES_PASSWORD: ${DB_PASS}
|
||||||
POSTGRES_DB: blog_db
|
POSTGRES_DB: blog_db
|
||||||
volumes:
|
volumes:
|
||||||
- ./postgres_data:/var/lib/postgresql/data
|
- ./postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U wypark -d blog_db"]
|
test: [ "CMD-SHELL", "pg_isready -U ${DB_USER} -d blog_db" ]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
networks:
|
networks:
|
||||||
@@ -53,7 +65,7 @@ services:
|
|||||||
container_name: blog-redis
|
container_name: blog-redis
|
||||||
restart: always
|
restart: always
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: [ "CMD", "redis-cli", "ping" ]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = "댓글이 삭제되었습니다."))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = "카테고리가 삭제되었습니다."))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, "이미지 업로드 성공"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, "게시글이 작성되었습니다."))
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/main/kotlin/me/wypark/blogbackend/api/dto/AuthDtos.kt
Normal file
37
src/main/kotlin/me/wypark/blogbackend/api/dto/AuthDtos.kt
Normal 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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/main/kotlin/me/wypark/blogbackend/api/dto/CommentDtos.kt
Normal file
46
src/main/kotlin/me/wypark/blogbackend/api/dto/CommentDtos.kt
Normal 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
|
||||||
|
)
|
||||||
62
src/main/kotlin/me/wypark/blogbackend/api/dto/PostDtos.kt
Normal file
62
src/main/kotlin/me/wypark/blogbackend/api/dto/PostDtos.kt
Normal 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() // 태그는 나중에 구현
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ class SecurityConfig(
|
|||||||
auth.requestMatchers("/api/auth/**").permitAll()
|
auth.requestMatchers("/api/auth/**").permitAll()
|
||||||
auth.requestMatchers(HttpMethod.GET, "/api/posts/**", "/api/categories/**", "/api/tags/**").permitAll()
|
auth.requestMatchers(HttpMethod.GET, "/api/posts/**", "/api/categories/**", "/api/tags/**").permitAll()
|
||||||
auth.requestMatchers(HttpMethod.POST, "/api/comments/**").permitAll() // 비회원 댓글 허용
|
auth.requestMatchers(HttpMethod.POST, "/api/comments/**").permitAll() // 비회원 댓글 허용
|
||||||
auth.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
auth.requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN")
|
||||||
auth.anyRequest().authenticated()
|
auth.anyRequest().authenticated()
|
||||||
}
|
}
|
||||||
// 필터 등록: UsernamePasswordAuthenticationFilter 앞에 JwtFilter를 실행
|
// 필터 등록: UsernamePasswordAuthenticationFilter 앞에 JwtFilter를 실행
|
||||||
|
|||||||
@@ -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("서버 내부 오류가 발생했습니다."))
|
||||||
|
}
|
||||||
|
}
|
||||||
144
src/main/kotlin/me/wypark/blogbackend/domain/auth/AuthService.kt
Normal file
144
src/main/kotlin/me/wypark/blogbackend/domain/auth/AuthService.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/main/kotlin/me/wypark/blogbackend/domain/post/Post.kt
Normal file
57
src/main/kotlin/me/wypark/blogbackend/domain/post/Post.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/main/kotlin/me/wypark/blogbackend/domain/post/PostService.kt
Normal file
100
src/main/kotlin/me/wypark/blogbackend/domain/post/PostService.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/main/kotlin/me/wypark/blogbackend/domain/tag/PostTag.kt
Normal file
19
src/main/kotlin/me/wypark/blogbackend/domain/tag/PostTag.kt
Normal 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
|
||||||
|
}
|
||||||
13
src/main/kotlin/me/wypark/blogbackend/domain/tag/Tag.kt
Normal file
13
src/main/kotlin/me/wypark/blogbackend/domain/tag/Tag.kt
Normal 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
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
|
}
|
||||||
37
src/main/kotlin/me/wypark/blogbackend/domain/user/Member.kt
Normal file
37
src/main/kotlin/me/wypark/blogbackend/domain/user/Member.kt
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -21,29 +21,44 @@ spring:
|
|||||||
highlight_sql: true # 쿼리 색상 강조 (가독성 UP)
|
highlight_sql: true # 쿼리 색상 강조 (가독성 UP)
|
||||||
open-in-view: false # OSIV 종료 (DB 커넥션 최적화)
|
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 설정
|
# 3. Redis 설정
|
||||||
data:
|
data:
|
||||||
redis:
|
redis:
|
||||||
host: redis # Docker 서비스명
|
host: redis # Docker 서비스명
|
||||||
port: 6379
|
port: 6379
|
||||||
|
|
||||||
# 4. AWS S3 / MinIO 설정 (아까 이야기한 부분)
|
cloud:
|
||||||
cloud:
|
aws:
|
||||||
aws:
|
s3:
|
||||||
s3:
|
bucket: my-blog-bucket
|
||||||
bucket: my-blog-bucket # MinIO 콘솔에서 미리 생성해야 함
|
endpoint: http://minio:9000
|
||||||
endpoint: http://minio:9000 # Docker 내부 통신용
|
credentials:
|
||||||
credentials:
|
access-key: admin
|
||||||
access-key: admin
|
secret-key: password
|
||||||
secret-key: password
|
region:
|
||||||
region:
|
static: ap-northeast-2
|
||||||
static: ap-northeast-2
|
stack:
|
||||||
stack:
|
auto: false
|
||||||
auto: false
|
|
||||||
|
|
||||||
# 5. JWT 설정
|
# 5. JWT 설정
|
||||||
jwt:
|
jwt:
|
||||||
# 보안 경고: 실제 배포 시에는 절대 코드에 비밀키를 남기지 마세요. 환경변수로 주입받으세요.
|
# 보안 경고: 실제 배포 시에는 절대 코드에 비밀키를 남기지 마세요. 환경변수로 주입받으세요.
|
||||||
secret: ${JWT_SECRET:v3ryS3cr3tK3yF0rMyB10gPr0j3ctM4k3ItL0ng3rTh4n32Byt3s!!}
|
secret: ${JWT_SECRET:v3ryS3cr3tK3yF0rMyB10gPr0j3ctM4k3ItL0ng3rTh4n32Byt3s!!}
|
||||||
access-token-validity: 1800000 # 30분
|
access-token-validity: 600000
|
||||||
refresh-token-validity: 604800000 # 7일
|
refresh-token-validity: 604800000
|
||||||
Reference in New Issue
Block a user