Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
49f5d27
[BOOK-480] refactor: apis - emotionTags는 빈 리스트도 허용이 되기 때문에 valid 패턴 제거
move-hoon Dec 24, 2025
b3c30f2
[BOOK-480] feat: infra - 감정 시스템 DB 스키마 마이그레이션 추가
move-hoon Dec 24, 2025
3a85de7
[BOOK-480] feat: domain - PrimaryEmotion 대분류 감정 enum 추가
move-hoon Dec 24, 2025
3b2914b
[BOOK-480] feat: domain - DetailTag 세부감정 도메인 및 Repository 인터페이스 추가
move-hoon Dec 24, 2025
84b94c8
[BOOK-480] feat: domain - ReadingRecordDetailTag 연결 도메인 및 Repository …
move-hoon Dec 24, 2025
4045ca8
[BOOK-480] feat: infra - DetailTag 엔티티 및 Repository 구현체 추가
move-hoon Dec 24, 2025
aa4cc4a
[BOOK-480] feat: infra - ReadingRecordDetailTag 엔티티 및 Repository 구현체 추가
move-hoon Dec 24, 2025
ee429a8
[BOOK-480] feat: domain, infra - ReadingRecord에 primaryEmotion 필드 추가
move-hoon Dec 24, 2025
1b0caa4
[BOOK-480] feat: domain - ReadingRecordInfoVO에 V2용 detailEmotions 필드 추가
move-hoon Dec 24, 2025
900feed
[BOOK-480] feat: domain - DetailTagDomainService 추가
move-hoon Dec 24, 2025
28c7d82
[BOOK-480] feat: domain - ReadingRecordDetailTagDomainService 추가
move-hoon Dec 24, 2025
ce821c6
[BOOK-480] refactor: domain - ReadingRecordDomainService V2 메서드 추가 및 …
move-hoon Dec 24, 2025
8af8806
[BOOK-480] feat: apis - V2 Request/Response DTO 추가
move-hoon Dec 24, 2025
ff5b0b6
[BOOK-480] feat: apis - ReadingRecordServiceV2 ApplicationService 추가
move-hoon Dec 24, 2025
0cdf8d9
[BOOK-480] feat: apis - ReadingRecordUseCaseV2 유스케이스 추가
move-hoon Dec 24, 2025
31d58a3
[BOOK-480] feat: apis - ReadingRecordControllerV2 컨트롤러 추가
move-hoon Dec 24, 2025
87b8b6c
[BOOK-480] feat: apis - EmotionController 감정 목록 조회 API 추가
move-hoon Dec 24, 2025
ad44b1d
[BOOK-480] fix: apis - V1 ReadingRecordResponse pageNumber nullable 호…
move-hoon Dec 24, 2025
92f2b5d
[BOOK-480] refactor: apis - private constructor 및 정적 팩토리 메서드로 패턴 변경
move-hoon Dec 30, 2025
29a8fb9
[BOOK-480] fix: apis - 독서 기록 목록 조회 (V2) 명세 오류 수정
move-hoon Dec 30, 2025
38498b2
[BOOK-480] fix: apis - 누락된 NotNull 어노테이션 추가
move-hoon Dec 30, 2025
1f39fa1
[BOOK-480] chore: apis - pageNumber가 선택 사항임을 명시
move-hoon Dec 30, 2025
71c5f90
[BOOK-480] fix: apis - primaryEmotion 변경 시 detailEmotion 데이터 일관성 보장
move-hoon Dec 30, 2025
ab30e00
[BOOK-480] refactor: apis - 독시 기록에 대한 소유권 검증 로직 추가
move-hoon Dec 30, 2025
7553515
[BOOK-480] refactor: apis - readingRecords를 불변 리스트로 변경
move-hoon Dec 30, 2025
45bdbbd
[BOOK-480] chore: apis - 403 에러 명세 추가
move-hoon Dec 30, 2025
598bfdb
[BOOK-480] refactor: apis - 기존 단언 패턴으로 valid 메서드 구현방식 변경
move-hoon Dec 30, 2025
ed4a5b7
[BOOK-480] chore: apis - Void 타입 대신 Kotlin의 Unit 타입으로 변경
move-hoon Dec 30, 2025
4a5e4ec
[BOOK-480] refactor: apis - 코드레빗 리뷰 반영
move-hoon Dec 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ enum class UserBookErrorCode(
private val code: String,
private val message: String
) : BaseErrorCode {

USER_BOOK_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_BOOK_404_01", "사용자의 책을 찾을 수 없습니다.");
USER_BOOK_ACCESS_DENIED(HttpStatus.FORBIDDEN, "USER_BOOK_403_01", "해당 책에 대한 접근 권한이 없습니다.");

override fun getHttpStatus(): HttpStatus = httpStatus
override fun getCode(): String = code
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ class UserBookService(
fun validateUserBookExists(userBookId: UUID, userId: UUID) {
if (!userBookDomainService.existsByUserBookIdAndUserId(userBookId, userId)) {
throw UserBookException(
UserBookErrorCode.USER_BOOK_NOT_FOUND,
"UserBook not found or access denied: $userBookId"
UserBookErrorCode.USER_BOOK_ACCESS_DENIED,
"UserBook access denied: $userBookId"
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.yapp.apis.emotion.controller

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
import org.yapp.apis.emotion.dto.response.EmotionListResponse
import org.yapp.apis.emotion.service.EmotionService

@RestController
@RequestMapping("/api/v2/emotions")
class EmotionController(
private val emotionService: EmotionService
) : EmotionControllerApi {

@GetMapping
override fun getEmotions(): ResponseEntity<EmotionListResponse> {
val response = emotionService.getEmotionList()
return ResponseEntity.ok(response)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package org.yapp.apis.emotion.controller

import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.yapp.apis.emotion.dto.response.EmotionListResponse

@Tag(name = "Emotions", description = "감정 관련 API")
@RequestMapping("/api/v2/emotions")
interface EmotionControllerApi {

@Operation(
summary = "감정 목록 조회",
description = "대분류 감정과 세부 감정 목록을 조회합니다."
)
@ApiResponses(
value = [
ApiResponse(
responseCode = "200",
description = "감정 목록 조회 성공",
content = [Content(schema = Schema(implementation = EmotionListResponse::class))]
)
]
)
@GetMapping
fun getEmotions(): ResponseEntity<EmotionListResponse>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package org.yapp.apis.emotion.dto.response

import io.swagger.v3.oas.annotations.media.Schema
import org.yapp.domain.detailtag.DetailTag
import org.yapp.domain.readingrecord.PrimaryEmotion
import java.util.UUID

@Schema(name = "EmotionListResponse", description = "감정 목록 응답")
data class EmotionListResponse private constructor(
@field:Schema(description = "감정 그룹 목록")
val emotions: List<EmotionGroupDto>
) {
companion object {
fun from(detailTags: List<DetailTag>): EmotionListResponse {
val grouped = detailTags.groupBy { it.primaryEmotion }

val emotions = PrimaryEmotion.entries.map { primary ->
EmotionGroupDto.of(
code = primary.name,
displayName = primary.displayName,
detailEmotions = grouped[primary]
?.sortedBy { it.displayOrder }
?.map { EmotionDetailDto.of(id = it.id.value, name = it.name) }
?: emptyList()
)
}

return EmotionListResponse(emotions = emotions)
}
}

@Schema(name = "EmotionGroupDto", description = "감정 그룹 (대분류 + 세부감정)")
data class EmotionGroupDto private constructor(
@field:Schema(description = "대분류 코드", example = "JOY")
val code: String,

@field:Schema(description = "대분류 표시 이름", example = "즐거움")
val displayName: String,

@field:Schema(description = "세부 감정 목록")
val detailEmotions: List<EmotionDetailDto>
) {
companion object {
fun of(
code: String,
displayName: String,
detailEmotions: List<EmotionDetailDto>
): EmotionGroupDto {
return EmotionGroupDto(
code = code,
displayName = displayName,
detailEmotions = detailEmotions
)
}
}
}

@Schema(name = "EmotionDetailDto", description = "세부 감정")
data class EmotionDetailDto private constructor(
@field:Schema(description = "세부 감정 ID", example = "123e4567-e89b-12d3-a456-426614174000")
val id: UUID,

@field:Schema(description = "세부 감정 이름", example = "설레는")
val name: String
) {
companion object {
fun of(id: UUID, name: String): EmotionDetailDto {
return EmotionDetailDto(id = id, name = name)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.yapp.apis.emotion.service

import org.yapp.apis.emotion.dto.response.EmotionListResponse
import org.yapp.domain.detailtag.DetailTagDomainService
import org.yapp.globalutils.annotation.ApplicationService

@ApplicationService
class EmotionService(
private val detailTagDomainService: DetailTagDomainService
) {
fun getEmotionList(): EmotionListResponse {
val detailTags = detailTagDomainService.findAll()
return EmotionListResponse.from(detailTags)
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package org.yapp.apis.readingrecord.controller

import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.Valid
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.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.*
import org.yapp.apis.readingrecord.dto.request.CreateReadingRecordRequestV2
import org.yapp.apis.readingrecord.dto.request.UpdateReadingRecordRequestV2
import org.yapp.apis.readingrecord.dto.response.ReadingRecordResponseV2
import org.yapp.domain.readingrecord.ReadingRecordSortType
import org.yapp.globalutils.exception.ErrorResponse
import java.util.*

@Tag(name = "Reading Records V2", description = "독서 기록 관련 API (V2)")
@RequestMapping("/api/v2/reading-records")
interface ReadingRecordControllerApiV2 {

@Operation(
summary = "독서 기록 생성 (V2)",
description = "사용자의 책에 대한 독서 기록을 생성합니다. 대분류 감정은 필수, 세부 감정은 선택입니다."
)
@ApiResponses(
value = [
ApiResponse(
responseCode = "201",
description = "독서 기록 생성 성공",
content = [Content(schema = Schema(implementation = ReadingRecordResponseV2::class))]
),
ApiResponse(
responseCode = "400",
description = "잘못된 요청",
content = [Content(schema = Schema(implementation = ErrorResponse::class))]
),
ApiResponse(
responseCode = "403",
description = "해당 책에 대한 접근 권한이 없음",
content = [Content(schema = Schema(implementation = ErrorResponse::class))]
),
ApiResponse(
responseCode = "404",
description = "사용자 또는 책을 찾을 수 없음",
content = [Content(schema = Schema(implementation = ErrorResponse::class))]
)
]
)
@PostMapping("/{userBookId}")
fun createReadingRecord(
@AuthenticationPrincipal @Parameter(description = "인증된 사용자 ID") userId: UUID,
@PathVariable @Parameter(description = "독서 기록을 생성할 사용자 책 ID") userBookId: UUID,
@Valid @RequestBody @Parameter(description = "독서 기록 생성 요청 객체") request: CreateReadingRecordRequestV2
): ResponseEntity<ReadingRecordResponseV2>

@Operation(
summary = "독서 기록 상세 조회 (V2)",
description = "독서 기록 ID로 독서 기록 상세 정보를 조회합니다."
)
@ApiResponses(
value = [
ApiResponse(
responseCode = "200",
description = "독서 기록 상세 조회 성공",
content = [Content(schema = Schema(implementation = ReadingRecordResponseV2::class))]
),
ApiResponse(
responseCode = "403",
description = "해당 독서 기록에 대한 접근 권한이 없음",
content = [Content(schema = Schema(implementation = ErrorResponse::class))]
),
ApiResponse(
responseCode = "404",
description = "사용자 또는 독서 기록을 찾을 수 없음",
content = [Content(schema = Schema(implementation = ErrorResponse::class))]
)
]
)
@GetMapping("/detail/{readingRecordId}")
fun getReadingRecordDetail(
@AuthenticationPrincipal @Parameter(description = "인증된 사용자 ID") userId: UUID,
@PathVariable @Parameter(description = "조회할 독서 기록 ID") readingRecordId: UUID
): ResponseEntity<ReadingRecordResponseV2>

@Operation(
summary = "독서 기록 목록 조회 (V2)",
description = "사용자의 책에 대한 독서 기록을 페이징하여 조회합니다. sort 파라미터가 지정된 경우 해당 정렬이 우선 적용되며, 지정하지 않으면 기본 정렬(updatedAt DESC)이 적용됩니다."
)
@ApiResponses(
value = [
ApiResponse(
responseCode = "200",
description = "독서 기록 목록 조회 성공"
),
ApiResponse(
responseCode = "404",
description = "사용자 또는 책을 찾을 수 없음",
content = [Content(schema = Schema(implementation = ErrorResponse::class))]
)
]
)
@GetMapping("/{userBookId}")
fun getReadingRecords(
@AuthenticationPrincipal @Parameter(description = "인증된 사용자 ID") userId: UUID,
@PathVariable @Parameter(description = "독서 기록을 조회할 사용자 책 ID") userBookId: UUID,
@RequestParam(required = false) @Parameter(
description = "정렬 타입 (PAGE_NUMBER_ASC, PAGE_NUMBER_DESC, CREATED_DATE_ASC, CREATED_DATE_DESC, UPDATED_DATE_ASC, UPDATED_DATE_DESC). 지정 시 Pageable의 sort보다 우선 적용됨"
) sort: ReadingRecordSortType?,
@PageableDefault(size = 10, sort = ["updatedAt"], direction = Sort.Direction.DESC)
@Parameter(description = "페이지네이션 정보 (기본값: 10개). 정렬은 sort 파라미터로 제어되며, Pageable의 sort는 무시됩니다.") pageable: Pageable
): ResponseEntity<Page<ReadingRecordResponseV2>>

@Operation(
summary = "독서 기록 수정 (V2)",
description = "독서 기록을 수정합니다. 대분류 감정과 세부 감정을 변경할 수 있습니다."
)
@ApiResponses(
value = [
ApiResponse(
responseCode = "200",
description = "독서 기록 수정 성공",
content = [Content(schema = Schema(implementation = ReadingRecordResponseV2::class))]
),
ApiResponse(
responseCode = "403",
description = "해당 독서 기록에 대한 접근 권한이 없음",
content = [Content(schema = Schema(implementation = ErrorResponse::class))]
),
ApiResponse(
responseCode = "404",
description = "독서 기록을 찾을 수 없음",
content = [Content(schema = Schema(implementation = ErrorResponse::class))]
)
]
)
@PutMapping("/{readingRecordId}")
fun updateReadingRecord(
@AuthenticationPrincipal @Parameter(description = "인증된 사용자 ID") userId: UUID,
@PathVariable @Parameter(description = "수정할 독서 기록 ID") readingRecordId: UUID,
@Valid @RequestBody @Parameter(description = "독서 기록 수정 요청 객체") request: UpdateReadingRecordRequestV2
): ResponseEntity<ReadingRecordResponseV2>

@Operation(
summary = "독서 기록 삭제",
description = "독서 기록을 삭제합니다."
)
@ApiResponses(
value = [
ApiResponse(responseCode = "204", description = "독서 기록 삭제 성공"),
ApiResponse(
responseCode = "403",
description = "해당 독서 기록에 대한 접근 권한이 없음",
content = [Content(schema = Schema(implementation = ErrorResponse::class))]
),
ApiResponse(
responseCode = "404",
description = "독서 기록을 찾을 수 없음",
content = [Content(schema = Schema(implementation = ErrorResponse::class))]
)
]
)
@DeleteMapping("/{readingRecordId}")
fun deleteReadingRecord(
@AuthenticationPrincipal @Parameter(description = "인증된 사용자 ID") userId: UUID,
@PathVariable @Parameter(description = "삭제할 독서 기록 ID") readingRecordId: UUID
): ResponseEntity<Unit>
}
Loading