Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,9 @@ class BookController(

@GetMapping("/search")
override fun searchBooks(
@AuthenticationPrincipal userId: UUID,
@Valid @ModelAttribute request: BookSearchRequest
): ResponseEntity<BookSearchResponse> {
val response = bookUseCase.searchBooks(request, userId)
val response = bookUseCase.searchBooks(request)
return ResponseEntity.ok(response)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ interface BookControllerApi {
)
@GetMapping("/search")
fun searchBooks(
@AuthenticationPrincipal userId: UUID,
@Valid @Parameter(description = "도서 검색 요청 객체") request: BookSearchRequest
): ResponseEntity<BookSearchResponse>

Expand Down
16 changes: 7 additions & 9 deletions apis/src/main/kotlin/org/yapp/apis/book/usecase/BookUseCase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,10 @@ class BookUseCase(
private val bookManagementService: BookManagementService
) {
fun searchBooks(
request: BookSearchRequest,
userId: UUID
request: BookSearchRequest
): BookSearchResponse {
userService.validateUserExists(userId)

val searchResponse = bookQueryService.searchBooks(request)
val booksWithUserStatus = mergeWithUserBookStatus(searchResponse.books, userId)
val booksWithUserStatus = mergeWithUserBookStatus(searchResponse.books, null)

Comment on lines 31 to 36
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

검색 결과 개인화 유지 여부 확인 및 선택적 사용자 컨텍스트 허용 제안

검색을 공개로 전환한 의도는 타당합니다. 다만 사용자가 인증된 경우에 한해 개인화(내 서재 상태 표기)를 유지하고 싶다면, 파라미터를 선택적(기본 null)로 열어두는 방식이 가장 간단합니다. 시그니처 호환성도 그대로 유지됩니다.

해당 범위 내 적용 diff:

 fun searchBooks(
-        request: BookSearchRequest
+        request: BookSearchRequest,
+        userId: UUID? = null
     ): BookSearchResponse {
     val searchResponse = bookQueryService.searchBooks(request)
-    val booksWithUserStatus = mergeWithUserBookStatus(searchResponse.books, null)
+    val booksWithUserStatus = mergeWithUserBookStatus(searchResponse.books, userId)

컨트롤러는 인증이 있으면 userId를 전달하고(없으면 null), 게이트웨이에서는 permitAll 유지로 비인증 접근도 허용하는 구조가 됩니다. 제품 요구사항이 “항상 비개인화 응답”이라면 현 상태로도 무방합니다. 요구사항을 한 번만 확인해 주세요.


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "------ Show BookUseCase.kt (first 300 lines) ------"
sed -n '1,300p' apis/src/main/kotlin/org/yapp/apis/book/usecase/BookUseCase.kt || true

echo
echo "------ Search for mergeWithUserBookStatus occurrences ------"
rg "mergeWithUserBookStatus" -n || true

echo
echo "------ Search for function definition of mergeWithUserBookStatus ------"
rg "fun mergeWithUserBookStatus" -n || true

echo
echo "------ Search for any searchBooks function definitions/usages ------"
rg "fun searchBooks\(" -n || true

echo
echo "------ Search for BookUseCase references ------"
rg "BookUseCase" -n || true

echo
echo "------ Search for controllers referencing searchBooks or BookUseCase ------"
rg "searchBooks\(|BookUseCase" -n || true

Length of output: 8225


searchBooks에 선택적 userId 파라미터 추가 제안 — 인증 시 개인화 유지, 비인증 시 비개인화 유지

검증 결과: 현재 searchBooks는 mergeWithUserBookStatus(..., null)를 호출해 항상 비개인화 응답을 반환합니다. 인증된 사용자에 한해 개인화(내 서재 상태 표기)를 유지하려면 userId를 선택적 파라미터로 열어두고 컨트롤러에서 인증 시 전달하도록 변경하는 것이 안전합니다.

수정이 필요한 위치:

  • apis/src/main/kotlin/org/yapp/apis/book/usecase/BookUseCase.kt — searchBooks 시그니처 및 호출부 변경
  • apis/src/main/kotlin/org/yapp/apis/book/controller/BookController.kt — 인증 정보가 있으면 userId를 추출해 bookUseCase.searchBooks에 전달(없으면 null)
  • apis/src/main/kotlin/org/yapp/apis/book/controller/BookControllerApi.kt — 필요한 경우 시그니처/핸들러 확인 및 업데이트

적용 예시(diff):

 fun searchBooks(
-        request: BookSearchRequest
+        request: BookSearchRequest,
+        userId: UUID? = null
     ): BookSearchResponse {
     val searchResponse = bookQueryService.searchBooks(request)
-    val booksWithUserStatus = mergeWithUserBookStatus(searchResponse.books, null)
+    val booksWithUserStatus = mergeWithUserBookStatus(searchResponse.books, userId)

운영 요구가 “항상 비개인화”라면 현재 상태 유지 가능하지만, 인증 시 개인화 허용이 필요하면 위 변경을 적용해 주세요.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fun searchBooks(
request: BookSearchRequest,
userId: UUID
request: BookSearchRequest
): BookSearchResponse {
userService.validateUserExists(userId)
val searchResponse = bookQueryService.searchBooks(request)
val booksWithUserStatus = mergeWithUserBookStatus(searchResponse.books, userId)
val booksWithUserStatus = mergeWithUserBookStatus(searchResponse.books, null)
fun searchBooks(
request: BookSearchRequest,
userId: UUID? = null
): BookSearchResponse {
val searchResponse = bookQueryService.searchBooks(request)
val booksWithUserStatus = mergeWithUserBookStatus(searchResponse.books, userId)
🤖 Prompt for AI Agents
In apis/src/main/kotlin/org/yapp/apis/book/usecase/BookUseCase.kt around lines
31–36, searchBooks currently always calls mergeWithUserBookStatus(..., null)
causing non-personalized results; change the searchBooks signature to accept an
optional userId (e.g. userId: Long? or String?), forward that userId into
mergeWithUserBookStatus instead of null, and update all internal calls
accordingly. Then, in
apis/src/main/kotlin/org/yapp/apis/book/controller/BookController.kt extract the
authenticated user id when present (from the security principal or request
context) and pass it to bookUseCase.searchBooks(userId = ...) or null if
unauthenticated. Finally, verify and update
apis/src/main/kotlin/org/yapp/apis/book/controller/BookControllerApi.kt handler
signatures/types to accept/propagate the optional userId so controller and use
case signatures remain consistent.

return searchResponse.withUpdatedBooks(booksWithUserStatus)
}
Expand Down Expand Up @@ -89,10 +86,10 @@ class BookUseCase(

private fun mergeWithUserBookStatus(
searchedBooks: List<BookSummary>,
userId: UUID
userId: UUID?
): List<BookSummary> {
if (searchedBooks.isEmpty()) {
return emptyList()
if (userId == null || searchedBooks.isEmpty()) {
return searchedBooks
}

val isbn13s = searchedBooks.map { it.isbn13 }
Expand All @@ -107,8 +104,9 @@ class BookUseCase(

private fun getUserBookStatusMap(
isbn13s: List<String>,
userId: UUID
userId: UUID?
): Map<String, BookStatus> {
if (userId == null) return emptyMap()
val userBooksResponse = userBookService.findAllByUserIdAndBookIsbn13In(
UserBooksByIsbn13sRequest.of(userId, isbn13s)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@ import org.springframework.data.web.PageableDefault
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PatchMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.yapp.apis.readingrecord.dto.request.CreateReadingRecordRequest
import org.yapp.apis.readingrecord.dto.request.UpdateReadingRecordRequest
import org.yapp.apis.readingrecord.dto.response.ReadingRecordPageResponse // Added import
import org.yapp.apis.readingrecord.dto.response.ReadingRecordResponse
import org.yapp.apis.readingrecord.usecase.ReadingRecordUseCase
Expand Down Expand Up @@ -80,4 +83,27 @@ class ReadingRecordController(
val stats = readingRecordUseCase.getSeedStats(userId, userBookId)
return ResponseEntity.ok(stats)
}

@PatchMapping("/{readingRecordId}")
override fun updateReadingRecord(
@AuthenticationPrincipal userId: UUID,
@PathVariable readingRecordId: UUID,
@Valid @RequestBody request: UpdateReadingRecordRequest
): ResponseEntity<ReadingRecordResponse> {
val response = readingRecordUseCase.updateReadingRecord(
userId = userId,
readingRecordId = readingRecordId,
request = request
)
return ResponseEntity.ok(response)
}

@DeleteMapping("/{readingRecordId}")
override fun deleteReadingRecord(
@AuthenticationPrincipal userId: UUID,
@PathVariable readingRecordId: UUID
): ResponseEntity<Unit> {
readingRecordUseCase.deleteReadingRecord(userId, readingRecordId)
return ResponseEntity.noContent().build()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ 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.CreateReadingRecordRequest
import org.yapp.apis.readingrecord.dto.request.UpdateReadingRecordRequest
import org.yapp.apis.readingrecord.dto.response.ReadingRecordPageResponse
import org.yapp.apis.readingrecord.dto.response.ReadingRecordResponse
import org.yapp.apis.readingrecord.dto.response.SeedStatsResponse
Expand Down Expand Up @@ -131,4 +132,58 @@ interface ReadingRecordControllerApi {
@AuthenticationPrincipal userId: UUID,
@PathVariable userBookId: UUID
): ResponseEntity<SeedStatsResponse>

@Operation(
summary = "독서 기록 수정",
description = "독서 기록 ID로 독서 기록을 수정합니다."
)
@ApiResponses(
value = [
ApiResponse(
responseCode = "200",
description = "독서 기록 수정 성공",
content = [Content(schema = Schema(implementation = ReadingRecordResponse::class))]
),
ApiResponse(
responseCode = "400",
description = "잘못된 요청",
content = [Content(schema = Schema(implementation = ErrorResponse::class))]
),

ApiResponse(
responseCode = "404",
description = "독서 기록을 찾을 수 없음",
content = [Content(schema = Schema(implementation = ErrorResponse::class))]
)
]
)
@PatchMapping("/{readingRecordId}")
fun updateReadingRecord(
@AuthenticationPrincipal @Parameter(description = "인증된 사용자 ID") userId: UUID,
@PathVariable @Parameter(description = "수정할 독서 기록 ID") readingRecordId: UUID,
@Valid @RequestBody @Parameter(description = "독서 기록 수정 요청 객체") request: UpdateReadingRecordRequest
): ResponseEntity<ReadingRecordResponse>

Comment on lines +160 to +166
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

API 문서에 401 Unauthorized 응답을 추가하세요

인증이 필요한 엔드포인트이므로 401 응답을 명시하는 것이 좋습니다.

     @ApiResponses(
         value = [
             ApiResponse(
                 responseCode = "200",
                 description = "독서 기록 수정 성공",
                 content = [Content(schema = Schema(implementation = ReadingRecordResponse::class))]
             ),
+            ApiResponse(
+                responseCode = "401",
+                description = "인증 필요",
+                content = [Content(schema = Schema(implementation = ErrorResponse::class))]
+            ),
             ApiResponse(
                 responseCode = "400",
                 description = "잘못된 요청",
                 content = [Content(schema = Schema(implementation = ErrorResponse::class))]
             ),

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerApi.kt
around lines 160 to 166, the PATCH endpoint lacks an explicit 401 Unauthorized
response in the API documentation; add an OpenAPI/Swagger annotation (e.g.,
@ApiResponses with an @ApiResponse(responseCode = "401", description =
"Unauthorized") or include responseCode "401" in an existing
@Operation/@ApiResponses block) directly above the updateReadingRecord method so
the generated API docs clearly show authentication failures; ensure the
annotation imports are added if missing and that the 401 description matches
project i18n/style.

@Operation(
summary = "독서 기록 삭제",
description = "독서 기록 ID로 독서 기록을 삭제합니다."
)
@ApiResponses(
value = [
ApiResponse(
responseCode = "204",
description = "독서 기록 삭제 성공"
),
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>
}
Comment on lines +184 to 189
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

삭제 응답 타입은 ResponseEntity가 관례적입니다

삭제는 본문이 없으므로 ResponseEntity<Void>가 더 명확하고, Java/Spring과의 상호운용성도 좋습니다. 인터페이스/구현 모두 동일하게 맞춰 주세요.

-    ): ResponseEntity<Unit>
+    ): ResponseEntity<Void>

구현 클래스 변경 예시(다른 파일):

// apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordController.kt
override fun deleteReadingRecord(
    @AuthenticationPrincipal userId: UUID,
    @PathVariable readingRecordId: UUID
): ResponseEntity<Void> {
    readingRecordUseCase.deleteReadingRecord(userId, readingRecordId)
    return ResponseEntity.noContent().build()
}
🤖 Prompt for AI Agents
In
apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerApi.kt
around lines 184 to 189, change the delete endpoint return type from
ResponseEntity<Unit> to ResponseEntity<Void> to follow convention for no-body
DELETE responses; update the interface signature accordingly and then update the
implementing class (e.g., ReadingRecordController.kt) to return
ResponseEntity.noContent().build() after calling
readingRecordUseCase.deleteReadingRecord(userId, readingRecordId), and adjust
any imports/types to compile with ResponseEntity<Void>.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 request에서는 Validation이 없는데 의도하신건 지 궁금합니다!!

만약 없다면 @Valid 어노테이션 + valid function 패턴을 하는게 좋을 것 같아서요!

Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package org.yapp.apis.readingrecord.dto.request

import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.Size

@Schema(
name = "UpdateReadingRecordRequest",
description = "독서 기록 수정 요청",
example = """
{
"pageNumber": 50,
"quote": "수정된 기억에 남는 문장입니다.",
"review": "수정된 감상평입니다.",
"emotionTags": ["놀라움"]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저희 예시에 놀라움 대신, 진짜 있는 태그로 하면 좋을 것 같아요!
(따뜻함, 즐거움, 슬픔, 깨달음)

}
Comment on lines +12 to +17
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

OpenAPI 예시를 최대 3개 제약에 맞게 보완하세요

스키마 설명은 “최대 3개”이나 예시는 1개만 포함되어 있어 오해 소지가 있습니다. 2~3개의 예시를 넣어 제약을 명확히 전달하는 것을 권장합니다.

예시를 다음과 같이 조정:

-          "emotionTags": ["놀라움"]
+          "emotionTags": ["놀라움", "영감"]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{
"pageNumber": 50,
"quote": "수정된 기억에 남는 문장입니다.",
"review": "수정된 감상평입니다.",
"emotionTags": ["놀라움"]
}
{
"pageNumber": 50,
"quote": "수정된 기억에 남는 문장입니다.",
"review": "수정된 감상평입니다.",
"emotionTags": ["놀라움", "영감"]
}

"""
)
data class UpdateReadingRecordRequest private constructor(
@field:Min(1, message = "페이지 번호는 1 이상이어야 합니다.")
@field:Max(9999, message = "페이지 번호는 9999 이하여야 합니다.")
@field:Schema(description = "수정할 페이지 번호", example = "50")
val pageNumber: Int?,

@field:Size(max = 1000, message = "기억에 남는 문장은 1000자를 초과할 수 없습니다.")
@field:Schema(description = "수정할 기억에 남는 문장", example = "수정된 기억에 남는 문장입니다.")
val quote: String?,

@field:Size(max = 1000, message = "감상평은 1000자를 초과할 수 없습니다.")
@field:Schema(description = "수정할 감상평", example = "수정된 감상평입니다.")
val review: String?,

Comment on lines +26 to +33
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

quote/review의 공백 문자열 차단 필요 (도메인 규칙과 정합성 확보)

도메인에서 공백을 허용하지 않는다면 DTO 단계에서 @notblank로 조기에 검증하는 것이 좋습니다. 필드는 nullable이라 전달된 경우에만 제약이 적용됩니다.

적용 diff:

@@
-    @field:Size(max = 1000, message = "기억에 남는 문장은 1000자를 초과할 수 없습니다.")
+    @field:Size(max = 1000, message = "기억에 남는 문장은 1000자를 초과할 수 없습니다.")
+    @field:NotBlank(message = "기억에 남는 문장은 비어 있을 수 없습니다.")
@@
-    @field:Size(max = 1000, message = "감상평은 1000자를 초과할 수 없습니다.")
+    @field:Size(max = 1000, message = "감상평은 1000자를 초과할 수 없습니다.")
+    @field:NotBlank(message = "감상평은 비어 있을 수 없습니다.")

추가로 import가 필요합니다(아래 별도 코멘트 참조).
만약 "빈 문자열 전달 시 해당 값 초기화"가 의도라면, @notblank 대신 서비스 계층에서 "" → null로 정규화(map)하는 전략으로 일관되게 처리하도록 논의가 필요합니다.


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- Searching for validation annotations usage ----"
rg -n "@field:NotBlank|@NotBlank|@field:Size|@Size|jakarta.validation|javax.validation|@field:Pattern|@Pattern" || true

echo
echo "---- Showing target file ----"
sed -n '1,240p' apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/UpdateReadingRecordRequest.kt || true

Length of output: 15245


Update DTO는 nullable이므로 @notblank 대신 "전달된 경우만 공백 차단"하는 검증 사용 권고

UpdateReadingRecordRequest의 quote/review는 nullable(선택) 필드이므로 @notblank를 붙이면 null(미전달)도 거부되어 업데이트 용도와 맞지 않습니다. 전달된 경우에만 공백(공백 문자만 있는 값)을 차단하려면 @field:Pattern(regexp = ".\S.") 처럼 패턴 검증을 사용하세요.

수정 대상

  • apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/UpdateReadingRecordRequest.kt — quote, review 필드
  • 참고: 생성 DTO(apIs/src/.../CreateReadingRecordRequest.kt)는 생성 시 필수라 @notblank 사용됨(용도 차이 확인 필요)

권장 적용 diff:

@@
-import jakarta.validation.constraints.Size
+import jakarta.validation.constraints.Size
+import jakarta.validation.constraints.Pattern
@@
-    @field:Size(max = 1000, message = "기억에 남는 문장은 1000자를 초과할 수 없습니다.")
-    @field:Schema(description = "수정할 기억에 남는 문장", example = "수정된 기억에 남는 문장입니다.")
-    val quote: String?,
+    @field:Size(max = 1000, message = "기억에 남는 문장은 1000자를 초과할 수 없습니다.")
+    @field:Pattern(regexp = ".*\\S.*", message = "기억에 남는 문장은 비어 있을 수 없습니다.")
+    @field:Schema(description = "수정할 기억에 남는 문장", example = "수정된 기억에 남는 문장입니다.")
+    val quote: String?,
@@
-    @field:Size(max = 1000, message = "감상평은 1000자를 초과할 수 없습니다.")
-    @field:Schema(description = "수정할 감상평", example = "수정된 감상평입니다.")
-    val review: String?,
+    @field:Size(max = 1000, message = "감상평은 1000자를 초과할 수 없습니다.")
+    @field:Pattern(regexp = ".*\\S.*", message = "감상평은 비어 있을 수 없습니다.")
+    @field:Schema(description = "수정할 감상평", example = "수정된 감상평입니다.")
+    val review: String?,

추가 유의사항: 만약 빈 문자열("")을 수신 시 자동으로 null로 정규화하려는 의도라면(@pattern 대신) 서비스 계층에서 "" → null로 변환하는 전략을 택하세요. 또한 현재 DTO의 validX() 메서드들이 !!를 사용하므로 null 가능성을 고려해 호출 위치를 확인하세요.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@field:Size(max = 1000, message = "기억에 남는 문장은 1000자를 초과할 수 없습니다.")
@field:Schema(description = "수정할 기억에 남는 문장", example = "수정된 기억에 남는 문장입니다.")
val quote: String?,
@field:Size(max = 1000, message = "감상평은 1000자를 초과할 수 없습니다.")
@field:Schema(description = "수정할 감상평", example = "수정된 감상평입니다.")
val review: String?,
@field:Size(max = 1000, message = "기억에 남는 문장은 1000자를 초과할 수 없습니다.")
@field:Pattern(regexp = ".*\\S.*", message = "기억에 남는 문장은 비어 있을 수 없습니다.")
@field:Schema(description = "수정할 기억에 남는 문장", example = "수정된 기억에 남는 문장입니다.")
val quote: String?,
@field:Size(max = 1000, message = "감상평은 1000자를 초과할 수 없습니다.")
@field:Pattern(regexp = ".*\\S.*", message = "감상평은 비어 있을 수 없습니다.")
@field:Schema(description = "수정할 감상평", example = "수정된 감상평입니다.")
val review: String?,
🤖 Prompt for AI Agents
In
apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/UpdateReadingRecordRequest.kt
around lines 26–33, quote and review are nullable update fields but currently
use @NotBlank semantics (per review) — change their validation so null is
allowed but whitespace-only strings are rejected: keep @field:Size(max = 1000)
and replace any @NotBlank with a pattern validator such as @field:Pattern(regexp
= ".*\\S.*") to enforce "if provided, must contain a non-space character"; do
not reject nulls. Also ensure callers of the DTO’s validX() methods handle
nullable values (or normalize "" → null in service layer) to avoid unsafe !!
usages.

@field:Size(max = 3, message = "감정 태그는 최대 3개까지 가능합니다.")
@field:Schema(description = "수정할 감정 태그 목록", example = """["따뜻함","즐거움","슬픔","깨달음"]""")
val emotionTags: List<@Size(max = 10, message = "감정 태그는 10자를 초과할 수 없습니다.") String>?
) {
fun validPageNumber(): Int = pageNumber!!
fun validQuote(): String = quote!!
fun validReview(): String = review!!
fun validEmotionTags(): List<String> = emotionTags!!
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package org.yapp.apis.readingrecord.service
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.yapp.apis.readingrecord.dto.request.CreateReadingRecordRequest
import org.yapp.apis.readingrecord.dto.request.UpdateReadingRecordRequest
import org.yapp.apis.readingrecord.dto.response.ReadingRecordResponse
import org.yapp.domain.readingrecord.ReadingRecordDomainService
import org.yapp.domain.readingrecord.ReadingRecordSortType
Expand Down Expand Up @@ -45,4 +46,24 @@ class ReadingRecordService(
val page = readingRecordDomainService.findReadingRecordsByDynamicCondition(userBookId, sort, pageable)
return page.map { ReadingRecordResponse.from(it) }
}

fun updateReadingRecord(
readingRecordId: UUID,
request: UpdateReadingRecordRequest
): ReadingRecordResponse {
val readingRecordInfoVO = readingRecordDomainService.modifyReadingRecord(
readingRecordId = readingRecordId,
pageNumber = request.validPageNumber(),
quote = request.validQuote(),
review = request.validReview(),
emotionTags = request.validEmotionTags()
)
return ReadingRecordResponse.from(readingRecordInfoVO)
}
Comment on lines +50 to +62
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

PATCH 부분 업데이트가 NPE로 실패할 수 있음: DTO 프로퍼티를 그대로 전달

UpdateReadingRecordRequestvalid*()!!로 강제 언랩하므로, 선택적 필드를 전달하지 않으면 NPE가 발생합니다. PATCH 의미(미전달=미변경)를 살리려면 nullable 프로퍼티를 그대로 도메인 서비스로 전달하세요. DTO의 불필요한 valid* 메서드도 제거하세요.

적용 diff:

         val readingRecordInfoVO = readingRecordDomainService.modifyReadingRecord(
             readingRecordId = readingRecordId,
-            pageNumber = request.validPageNumber(),
-            quote = request.validQuote(),
-            review = request.validReview(),
-            emotionTags = request.validEmotionTags()
+            pageNumber = request.pageNumber,
+            quote = request.quote,
+            review = request.review,
+            emotionTags = request.emotionTags
         )

참고: createReadingRecord 쪽도 동일 패턴을 사용 중이면, 동일하게 프로퍼티 접근으로 교체해 주세요(변경 범위 밖이라 예시만 첨부).

예시:

readingRecordDomainService.createReadingRecord(
    userBookId = userBookId,
    pageNumber = request.pageNumber,
    quote = request.quote,
    review = request.review,
    emotionTags = request.emotionTags
)
🤖 Prompt for AI Agents
In
apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordService.kt
around lines 50-62, the updateReadingRecord call uses request.valid*() methods
that force-unwrap optional fields and cause NPE for PATCH semantics; change the
call to pass the DTO nullable properties directly (pageNumber, quote, review,
emotionTags) so omitted fields remain null and are treated as "no change" by the
domain service, and remove the unnecessary valid*() methods from the DTO; also
scan createReadingRecord for the same pattern and apply the same nullable
property passing if present.


fun deleteReadingRecord(
readingRecordId: UUID
) {
readingRecordDomainService.deleteReadingRecord(readingRecordId)
}
Comment on lines +64 to +68
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

삭제 정책 점검: 멱등성, soft-delete 일관성, 권한 검증 경계

  • 멱등성: 이미 삭제된 리소스에 대한 재삭제 요청 시 응답(404 vs 204/200) 정책을 명확히 하고, 카운트 감소가 중복 반영되지 않도록 해주세요.
  • soft-delete: PR 설명대로 deletedAt 기반 soft-delete를 사용하는지, 하드 삭제와의 혼용 여부를 확인해 주세요.
  • 권한/소유 검증: 본 메서드는 userId 인자를 받지 않습니다(사용자 경계 검증을 상위 usecase/controller에서 처리한다면 OK). 체계적으로 어디에서 소유권 확인이 수행되는지 문서화/확인 바랍니다.

아래 스크립트로 관련 구현을 빠르게 확인할 수 있습니다(레포 루트에서 실행).


🏁 Script executed:

#!/bin/bash
# deleteReadingRecord 구현 및 삭제/soft-delete 흔적 탐색
rg -n -A 12 -B 6 'deleteReadingRecord\(' --type kotlin
rg -n -A 4 -B 4 'deletedAt' --type kotlin
rg -n -A 6 -B 2 'decreaseReadingRecordCount\(|readingRecordCount' --type kotlin

# 하드 삭제 호출 여부
rg -n -A 2 -B 2 'deleteById\(' --type kotlin

Length of output: 49912


🏁 Script executed:

#!/bin/bash
set -euo pipefail

files=(
  "domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt"
  "domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecord.kt"
  "domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordRepository.kt"
  "infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/ReadingRecordRepositoryImpl.kt"
  "infra/src/main/kotlin/org/yapp/infra/readingrecord/entity/ReadingRecordEntity.kt"
  "domain/src/main/kotlin/org/yapp/domain/userbook/UserBook.kt"
  "domain/src/main/kotlin/org/yapp/domain/userbook/UserBookRepository.kt"
  "domain/src/main/kotlin/org/yapp/domain/userbook/UserBookDomainService.kt"
)

for f in "${files[@]}"; do
  if [ -f "$f" ]; then
    printf "\n==== FILE: %s ====\n\n" "$f"
    sed -n '1,240p' "$f" || true
  else
    printf "\n==== FILE NOT FOUND: %s ====\n\n" "$f"
  fi
done

Length of output: 29026


조치 필요: 삭제(soft-delete) 흐름은 확인됐으나 — 멱등성·권한 검증 보강 필요

요약:

  • soft-delete는 infra entity의 @SQLDelete/@SQLRestriction + BaseTimeEntity.deletedAt로 적용되어 있습니다.
  • 현재 삭제 흐름은 findById → deleteById → userBook.decreaseReadingRecordCount() 로 동작하며, 이미 삭제된 리소트에 대한 재요청은 findById가 null을 반환해 ReadingRecordNotFoundException(→404)을 발생시킵니다. 멱등성(404 vs 204) 정책이 불명확합니다.
  • 사용자 소유권 검증이 빠져 있습니다. 컨트롤러/유스케이스는 userId 존재만 확인하고 실제 reading record의 소유자(userBook.userId) 검증을 수행하지 않습니다.

주의가 필요한 위치:

  • domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt
    • fun deleteReadingRecord(readingRecordId: UUID) { ... readingRecordRepository.deleteById(...) / userBookRepository.save(userBook.decreaseReadingRecordCount()) }
  • infra/src/main/kotlin/org/yapp/infra/readingrecord/entity/ReadingRecordEntity.kt
    • @SQLDelete, @SQLRestriction 및 deletedAt 필드 (soft-delete 구현)
  • apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCase.kt
    • fun deleteReadingRecord(userId: UUID, readingRecordId: UUID) — userService.validateUserExists(userId)만 호출, 소유권 검증 미실행
  • apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordService.kt
    • deleteReadingRecord(readingRecordId: UUID) — 도메인 서비스 위임

권장 조치(간단히)

  1. 멱등성 정책 결정·문서화
    • 재요청에 204를 허용하려면 유스케이스/컨트롤러에서 NotFound를 무시하고 noContent 반환하거나, 도메인에 deleteIfExists 같은 반환값 있는 API 도입.
    • 404로 유지할 경우 문서에 명시.
  2. 소유권(권한) 검증 추가
    • 유스케이스(또는 도메인)에서 readingRecord → userBook을 조회해 userBook.userId == 요청자 userId 검증 또는 userBookRepository.existsByIdAndUserId(...) 호출 후 미일치 시 Forbidden 처리.
  3. 일관성/동시성 검토
    • 동시 삭제 시 readingRecordCount가 중복 감소될 수 있음(경합 상황). 필요시 낙관적 잠금/DB에서의 조건부 감소 또는 트랜잭션/검증 로직 보강 검토.

간단한 참고 코드 위치(검토/수정 권장):

  • domain/.../readingrecord/ReadingRecordDomainService.kt (delete flow)
  • infra/.../readingrecord/entity/ReadingRecordEntity.kt (@SQLDelete/@SQLRestriction)
  • apis/.../usecase/ReadingRecordUseCase.kt (권한 검사 추가 지점)

수정이 필요한 상태입니다.

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import org.springframework.data.domain.Pageable
import org.springframework.transaction.annotation.Transactional
import org.yapp.apis.book.service.UserBookService
import org.yapp.apis.readingrecord.dto.request.CreateReadingRecordRequest
import org.yapp.apis.readingrecord.dto.request.UpdateReadingRecordRequest
import org.yapp.apis.readingrecord.dto.response.ReadingRecordPageResponse // Added import
import org.yapp.apis.readingrecord.dto.response.ReadingRecordResponse
import org.yapp.apis.readingrecord.dto.response.SeedStatsResponse
Expand Down Expand Up @@ -76,4 +77,27 @@ class ReadingRecordUseCase(
userBookService.validateUserBookExists(userBookId, userId)
return readingRecordTagService.getSeedStatsByUserIdAndUserBookId(userId, userBookId)
}

@Transactional
fun updateReadingRecord(
userId: UUID,
readingRecordId: UUID,
request: UpdateReadingRecordRequest
): ReadingRecordResponse {
userService.validateUserExists(userId)

return readingRecordService.updateReadingRecord(
readingRecordId = readingRecordId,
request = request
)
}

@Transactional
fun deleteReadingRecord(
userId: UUID,
readingRecordId: UUID
) {
userService.validateUserExists(userId)
readingRecordService.deleteReadingRecord(readingRecordId)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,21 @@ data class ReadingRecord private constructor(
}
}

fun update(
pageNumber: Int?,
quote: String?,
review: String?,
emotionTags: List<String>?
): ReadingRecord {
return this.copy(
pageNumber = pageNumber?.let { PageNumber.newInstance(it) } ?: this.pageNumber,
quote = quote?.let { Quote.newInstance(it) } ?: this.quote,
review = review?.let { Review.newInstance(it) } ?: this.review,
emotionTags = emotionTags?.map { EmotionTag.newInstance(it) } ?: this.emotionTags,
updatedAt = LocalDateTime.now()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

시간 의존성 주입 고려 (테스트 용이성/일관성)

LocalDateTime.now() 직접 호출 대신 java.time.Clock을 주입받아 LocalDateTime.now(clock) 형태로 사용하면 테스트 용이성과 시간대 일관성이 좋아집니다.

🤖 Prompt for AI Agents
In domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecord.kt around
line 72, replace the direct call to LocalDateTime.now() with a Clock-based
approach: add a Clock dependency (inject via constructor or method parameter,
defaulting to Clock.systemDefaultZone() if needed) and use
LocalDateTime.now(clock) to set updatedAt; update any constructors/factory
methods and unit tests to supply a fixed Clock in tests for deterministic
timestamps.

)
}
Comment on lines +61 to +74
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

업데이트 입력값 정규화(트림/중복 제거) 및 no-op 최적화 제안

  • 모든 인자가 null이면 변경사항이 없음에도 updatedAt만 변경됩니다. 무의미한 업데이트를 피하기 위해 early return을 권장합니다.
  • quote/review는 trim 없이 그대로 검증하므로 앞뒤 공백이 의도치 않게 저장될 수 있습니다. trim 후 검증/적용을 권장합니다.
  • emotionTags는 중복/공백 태그가 그대로 반영될 수 있습니다. trim → 공백 제거 → 중복 제거 → 래핑 순으로 정규화하는 편이 안전합니다.

아래처럼 개선을 제안합니다.

     fun update(
         pageNumber: Int?,
         quote: String?,
         review: String?,
         emotionTags: List<String>?
     ): ReadingRecord {
-        return this.copy(
-            pageNumber = pageNumber?.let { PageNumber.newInstance(it) } ?: this.pageNumber,
-            quote = quote?.let { Quote.newInstance(it) } ?: this.quote,
-            review = review?.let { Review.newInstance(it) } ?: this.review,
-            emotionTags = emotionTags?.map { EmotionTag.newInstance(it) } ?: this.emotionTags,
-            updatedAt = LocalDateTime.now()
-        )
+        if (pageNumber == null && quote == null && review == null && emotionTags == null) {
+            return this
+        }
+        return this.copy(
+            pageNumber = pageNumber?.let { PageNumber.newInstance(it) } ?: this.pageNumber,
+            quote = quote?.trim()?.let { Quote.newInstance(it) } ?: this.quote,
+            review = review?.trim()?.let { Review.newInstance(it) } ?: this.review,
+            emotionTags = emotionTags
+                ?.map { it.trim() }
+                ?.filter { it.isNotBlank() }
+                ?.distinct()
+                ?.map { EmotionTag.newInstance(it) }
+                ?: this.emotionTags,
+            updatedAt = LocalDateTime.now()
+        )
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fun update(
pageNumber: Int?,
quote: String?,
review: String?,
emotionTags: List<String>?
): ReadingRecord {
return this.copy(
pageNumber = pageNumber?.let { PageNumber.newInstance(it) } ?: this.pageNumber,
quote = quote?.let { Quote.newInstance(it) } ?: this.quote,
review = review?.let { Review.newInstance(it) } ?: this.review,
emotionTags = emotionTags?.map { EmotionTag.newInstance(it) } ?: this.emotionTags,
updatedAt = LocalDateTime.now()
)
}
fun update(
pageNumber: Int?,
quote: String?,
review: String?,
emotionTags: List<String>?
): ReadingRecord {
if (pageNumber == null && quote == null && review == null && emotionTags == null) {
return this
}
return this.copy(
pageNumber = pageNumber?.let { PageNumber.newInstance(it) } ?: this.pageNumber,
quote = quote?.trim()?.let { Quote.newInstance(it) } ?: this.quote,
review = review?.trim()?.let { Review.newInstance(it) } ?: this.review,
emotionTags = emotionTags
?.map { it.trim() }
?.filter { it.isNotBlank() }
?.distinct()
?.map { EmotionTag.newInstance(it) }
?: this.emotionTags,
updatedAt = LocalDateTime.now()
)
}
🤖 Prompt for AI Agents
In domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecord.kt around
lines 61 to 74, the update(...) method always sets updatedAt even when no actual
field changes and does not normalize inputs; change it to detect a no-op (all
incoming values either null or equal to current values) and return this early
without modifying updatedAt, trim quote and review before validation/wrapping
(use .trim() and treat empty as null if appropriate), and normalize emotionTags
by trimming each tag, filtering out blank entries, removing duplicates, then
wrapping to EmotionTag instances; only construct a new copy and set updatedAt
when at least one real change exists.


@JvmInline
value class Id(val value: UUID) {
companion object {
Expand Down Expand Up @@ -114,4 +129,8 @@ data class ReadingRecord private constructor(
}
}
}

fun delete(): ReadingRecord {
return this.copy(deletedAt = LocalDateTime.now())
}
Comment on lines +133 to +135
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

soft-delete 가드 및 updatedAt 동기화 권장 + 실제 삭제 로직과의 정합성 확인

  • 이미 삭제된 엔티티에 대한 중복 삭제를 방지하는 가드가 없습니다.
  • 삭제 시점에 updatedAt도 함께 갱신해주면 감사 관점에서 유용합니다.
  • 만약 인프라 레이어에서 실제 삭제(deleteById)를 수행한다면, 도메인에서 설정한 deletedAt이 영속 전에 사라질 수 있습니다. soft-delete와 hard-delete가 동시에 쓰이지 않는지 확인이 필요합니다.

제안 변경:

-    fun delete(): ReadingRecord {
-        return this.copy(deletedAt = LocalDateTime.now())
-    }
+    fun delete(): ReadingRecord {
+        require(deletedAt == null) { "이미 삭제된 독서 기록입니다." }
+        val now = LocalDateTime.now()
+        return this.copy(
+            deletedAt = now,
+            updatedAt = now
+        )
+    }

다음 스크립트로 인프라/서비스 레이어에서 실제 삭제가 수행되는지, soft-delete와의 혼용 여부를 확인해 주세요.


🏁 Script executed:

#!/bin/bash
# 삭제 관련 흐름 및 deletedAt 사용 검증

echo "== delete use-case/service 진입점 탐색 =="
rg -n -A 5 -B 3 "deleteReadingRecord\(" --type kotlin

echo
echo "== ReadingRecordRepository.deleteById 사용 여부 =="
rg -n -A 2 -B 2 "deleteById\(" --type kotlin

echo
echo "== QueryDSL에서 deletedAt.isNull() 필터 확인 =="
rg -n -A 3 -B 3 "deletedAt\.isNull\(\)" --type kotlin

echo
echo "== 삭제 시 태그 삭제 흐름 확인(태그 레포) =="
rg -n -A 3 -B 3 "deleteAllByReadingRecordId" --type kotlin

Length of output: 14099


🏁 Script executed:

#!/bin/bash
set -euo pipefail

files=(
  "domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecord.kt"
  "domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt"
  "domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordRepository.kt"
  "infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/ReadingRecordRepositoryImpl.kt"
  "infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/JpaReadingRecordQuerydslRepositoryImpl.kt"
  "infra/src/main/kotlin/org/yapp/infra/readingrecordtag/repository/JpaReadingRecordTagRepository.kt"
  "infra/src/main/kotlin/org/yapp/infra/readingrecordtag/repository/impl/ReadingRecordTagRepositoryImpl.kt"
)

for f in "${files[@]}"; do
  echo "== FILE: $f =="
  if [ -f "$f" ]; then
    nl -ba -w3 -s': ' "$f"
  else
    echo "MISSING: $f"
  fi
  echo
done

Length of output: 1150


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== show ReadingRecord.kt =="
sed -n '1,240p' domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecord.kt || true
echo
echo "== show ReadingRecordDomainService.kt =="
sed -n '1,260p' domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt || true
echo
echo "== show ReadingRecordRepository.kt =="
sed -n '1,200p' domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordRepository.kt || true
echo
echo "== show infra ReadingRecordRepositoryImpl.kt =="
sed -n '1,200p' infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/ReadingRecordRepositoryImpl.kt || true
echo
echo "== show JpaReadingRecordQuerydslRepositoryImpl.kt =="
sed -n '1,240p' infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/JpaReadingRecordQuerydslRepositoryImpl.kt || true
echo
echo "== show ReadingRecordTagRepositoryImpl.kt =="
sed -n '1,200p' infra/src/main/kotlin/org/yapp/infra/readingrecordtag/repository/impl/ReadingRecordTagRepositoryImpl.kt || true
echo
echo "== search for ReadingRecordTagEntity file(s) =="
rg -n "ReadingRecordTagEntity" || true
echo
echo "== show any ReadingRecordTagEntity file if found =="
for f in $(rg -n --files-with-matches "class .*ReadingRecordTagEntity" || true); do
  echo "---- $f ----"
  sed -n '1,240p' "$f" || true
done

Length of output: 23587


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate ReadingRecordEntity files =="
rg -n --files-with-matches "class .*ReadingRecordEntity" || true
echo

echo "== show ReadingRecordEntity (if found) =="
for f in $(rg --files-with-matches "class .*ReadingRecordEntity" || true); do
  echo "---- $f ----"
  sed -n '1,320p' "$f" || true
done

echo
echo "== show JpaReadingRecordRepository files =="
rg -n --files-with-matches "interface .*JpaReadingRecordRepository" || true
for f in $(rg --files-with-matches "interface .*JpaReadingRecordRepository" || true); do
  echo "---- $f ----"
  sed -n '1,240p' "$f" || true
done

echo
echo "== search for @SQLDelete usages =="
rg -n "@SQLDelete" || true

echo
echo "== search for deleted_at / deletedAt column in infra entities =="
rg -n "deletedAt|deleted_at" infra || true

Length of output: 8925


soft-delete 흐름 정합성 확인 — infra(@SQLDelete) 기반 soft-delete 사용 중, 도메인 delete()만 수정하면 반영되지 않습니다

검증 요약: ReadingRecordDomainService.deleteReadingRecord는 readingRecordRepository.deleteById(...)를 호출하고, 엔티티(ReadingRecordEntity / ReadingRecordTagEntity 등)에 @SQLDelete가 있어 JPA 레벨에서 DB의 deleted_at을 업데이트(soft-delete)합니다. 따라서 도메인 내 delete() 변경만으로는 실제 DB의 updated_at 동기화가 되지 않으며, 연관 태그 처리가 누락되어 있을 수 있습니다.

조치 제안(우선순위 순):

  • ReadingRecord 삭제 시 관련 태그도 soft-delete 처리하도록 호출 추가

    • 파일: domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt
    • 제안 diff:
    -        readingRecordRepository.deleteById(readingRecordId)
    +        // 연관 태그도 soft-delete 처리
    +        readingRecordTagRepository.deleteAllByReadingRecordId(readingRecordId)
    +        readingRecordRepository.deleteById(readingRecordId)
  • DB 레벨 soft-delete 시 updated_at도 갱신해 감사 일관성 유지

    • 파일: infra/src/main/kotlin/org/yapp/infra/readingrecord/entity/ReadingRecordEntity.kt
    • 제안 diff:
    -@SQLDelete(sql = "UPDATE reading_records SET deleted_at = NOW() WHERE id = ?")
    +@SQLDelete(sql = "UPDATE reading_records SET deleted_at = NOW(), updated_at = NOW() WHERE id = ?")
    • 동일 변경을 ReadingRecordTagEntity 및 프로젝트 내 다른 @SQLDelete 사용 엔티티들(예: user_books, users, tags, books 등)에도 적용 권장:
      • infra/src/main/kotlin/org/yapp/infra/readingrecordtag/entity/ReadingRecordTagEntity.kt
      • infra/src/main/kotlin/org/yapp/infra/userbook/entity/UserBookEntity.kt
      • infra/src/main/kotlin/org/yapp/infra/user/entity/UserEntity.kt
      • infra/src/main/kotlin/org/yapp/infra/tag/entity/TagEntity.kt
      • infra/src/main/kotlin/org/yapp/infra/book/entity/BookEntity.kt
  • 도메인 delete()의 가드 및 updatedAt 동기화(선택)

    • 파일: domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecord.kt
    • 제안 diff (도메인 레벨로 삭제 로직을 사용하려면 적용; 현재는 서비스에서 deleteById를 사용하므로 선택적):
    -    fun delete(): ReadingRecord {
    -        return this.copy(deletedAt = LocalDateTime.now())
    -    }
    +    fun delete(): ReadingRecord {
  •    require(deletedAt == null) { "이미 삭제된 독서 기록입니다." }
    
  •    val now = LocalDateTime.now()
    
  •    return this.copy(deletedAt = now, updatedAt = now)
    
  • }

권고: 삭제 흐름을 하나로 통일하세요.
- 도메인 주도 방식으로 통일하려면 DomainService에서 읽어온 엔티티에 대해 readingRecord.delete() 호출 후 readingRecordRepository.save(...)로 영속화.
- 현재 구조(서비스에서 deleteById → @SQLDelete로 DB 업데이트)를 유지하려면 위의 @SQLDelete 변경(및 태그 삭제 호출)을 적용해 감사/연관 데이터 정합성 보장.

참고: QueryDSL 필터(readingRecord.deletedAt.isNull())는 이미 적용되어 있어 변경 불필요합니다.

<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
  fun delete(): ReadingRecord {
      require(deletedAt == null) { "이미 삭제된 독서 기록입니다." }
      val now = LocalDateTime.now()
      return this.copy(deletedAt = now, updatedAt = now)
  }
🤖 Prompt for AI Agents
In domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecord.kt (around
lines 133-135), the domain delete() only sets deletedAt on the in-memory object
but the app uses repository.deleteById(...) with @SQLDelete at the infra layer,
so the domain-only change won't persist or handle related tags; update
ReadingRecordDomainService.deleteReadingRecord to explicitly soft-delete
associated ReadingRecordTag entities (either by loading related tag entities and
setting their deletedAt and saving, or by calling a repository method that
executes the soft-delete for tags) so tag relations are consistent, and modify
infra/src/main/kotlin/org/yapp/infra/readingrecord/entity/ReadingRecordEntity.kt
(and other entities using @SQLDelete such as ReadingRecordTagEntity,
UserBookEntity, UserEntity, TagEntity, BookEntity) to include updated_at = now()
in the @SQLDelete SQL so DB-level soft-deletes update updated_at for auditing;
optionally, if switching to domain-driven deletion, change DomainService to load
the aggregate, call readingRecord.delete() and save via repository to persist
domain-side updatedAt too.

}
Original file line number Diff line number Diff line change
Expand Up @@ -102,24 +102,64 @@ class ReadingRecordDomainService(
sort: ReadingRecordSortType?,
pageable: Pageable
): Page<ReadingRecordInfoVO> {
val page = readingRecordRepository.findReadingRecordsByDynamicCondition(userBookId, sort, pageable)

// Get the UserBook entity to get the book thumbnail, title, and publisher
val userBook = userBookRepository.findById(userBookId)
return readingRecordRepository.findReadingRecordsByDynamicCondition(userBookId, sort, pageable)
.map { buildReadingRecordInfoVO(it) }
}

return page.map { readingRecord ->
val readingRecordTags = readingRecordTagRepository.findByReadingRecordId(readingRecord.id.value)
val tagIds = readingRecordTags.map { it.tagId.value }
val tags = tagRepository.findByIds(tagIds)
ReadingRecordInfoVO.newInstance(
readingRecord = readingRecord,
emotionTags = tags.map { it.name },
bookTitle = userBook?.title,
bookPublisher = userBook?.publisher,
bookCoverImageUrl = userBook?.coverImageUrl,
author = userBook?.author
fun modifyReadingRecord(
readingRecordId: UUID,
pageNumber: Int?,
quote: String?,
review: String?,
emotionTags: List<String>?
): ReadingRecordInfoVO {
val readingRecord = readingRecordRepository.findById(readingRecordId)
?: throw ReadingRecordNotFoundException(
ReadingRecordErrorCode.READING_RECORD_NOT_FOUND,
"Reading record not found with id: $readingRecordId"
)

val updatedReadingRecord = readingRecord.update(
pageNumber = pageNumber,
quote = quote,
review = review,
emotionTags = emotionTags
)

val savedReadingRecord = readingRecordRepository.save(updatedReadingRecord)

// Update emotion tags
if (emotionTags != null) {
readingRecordTagRepository.deleteAllByReadingRecordId(readingRecordId)
val tags = emotionTags.map { tagName ->
tagRepository.findByName(tagName) ?: tagRepository.save(Tag.create(tagName))
}
val newReadingRecordTags = tags.map {
ReadingRecordTag.create(
readingRecordId = savedReadingRecord.id.value,
tagId = it.id.value
)
}
readingRecordTagRepository.saveAll(newReadingRecordTags)
}

return buildReadingRecordInfoVO(savedReadingRecord)
}

fun deleteReadingRecord(readingRecordId: UUID) {
val readingRecord = readingRecordRepository.findById(readingRecordId)
?: throw ReadingRecordNotFoundException(
ReadingRecordErrorCode.READING_RECORD_NOT_FOUND,
"Reading record not found with id: $readingRecordId"
)

val userBook = userBookRepository.findById(readingRecord.userBookId.value)
?: throw UserBookNotFoundException(
UserBookErrorCode.USER_BOOK_NOT_FOUND,
"User book not found with id: ${readingRecord.userBookId.value}"
)

readingRecordRepository.deleteById(readingRecordId)
userBookRepository.save(userBook.decreaseReadingRecordCount())
}
}
Loading