Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
69 changes: 69 additions & 0 deletions src/main/java/com/dokdok/book/api/PersonalBookRecordApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import com.dokdok.book.dto.request.PersonalReadingRecordCreateRequest;
import com.dokdok.book.dto.request.PersonalReadingRecordUpdateRequest;
import com.dokdok.book.dto.request.PreOpinionTimeType;
import com.dokdok.book.dto.response.*;
import com.dokdok.global.response.ApiResponse;
import com.dokdok.global.response.CursorResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
Expand All @@ -17,6 +19,7 @@
import org.springframework.web.bind.annotation.*;

import java.time.OffsetDateTime;
import java.time.LocalDateTime;

@Tag(name = "독서 기록", description = "책별 독서 기록 관련 API")
@RequestMapping("/api/book")
Expand Down Expand Up @@ -606,4 +609,70 @@ ResponseEntity<ApiResponse<PersonalReadingTopicAnswerResponse>> getMyTopicAnswer
@Parameter(description = "개인 책장 ID (personal_book 테이블 PK)", required = true, example = "10")
@PathVariable Long personalBookId
);

@Operation(
summary = "독서 타임라인 조회 (developer: 권우희)",
description = """
독서 기록/사전 의견/개인 회고를 하나의 타임라인으로 커서 기반 조회합니다.
- personalBook의 gatheringId가 null이면 사전 의견/회고는 제외됩니다.
- 사전 의견(PRE_OPINION)은 **내 답변이 있는 미팅만** 포함합니다.
- 정렬: eventAt DESC, typeOrder DESC, sourceId DESC
- preOpinionTime: 사전 의견 정렬 기준 (MEETING_START | ANSWER_CREATED, 기본값 ANSWER_CREATED)

**사용 방법**
- 첫 페이지: `?size=10&preOpinionTime=ANSWER_CREATED`
- 다음 페이지: `?size=10&cursorEventAt={nextCursor.eventAt}&cursorType={nextCursor.type}&cursorSourceId={nextCursor.sourceId}`
"""
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "독서 타임라인 조회 성공",
content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ReadingTimelineCursorResponse.class),
examples = @ExampleObject(value = """
{
"code": "SUCCESS",
"message": "독서 타임라인 조회 성공",
"data": {
"items": [
{
"type": "READING_RECORD",
"eventAt": "2026-01-25T22:39:57.899858",
"sourceId": 1,
"readingRecord": {
"recordId": 1,
"recordType": "QUOTE",
"recordContent": "기억에 남는 구절",
"meta": {"page": "12", "excerpt": "..."},
"createdAt": "2026-01-25T22:39:57.899858",
"bookId": 1
}
}
],
"pageSize": 10,
"hasNext": false,
"nextCursor": null
}
}
"""))
)
})
@GetMapping("/{personalBookId}/records/timeline")
ResponseEntity<ApiResponse<CursorResponse<ReadingTimelineItem, ReadingTimelineCursor>>> getMyReadingTimeline(
@Parameter(description = "개인 책장 ID (personal_book 테이블 PK)", required = true, example = "10")
@PathVariable Long personalBookId,
@Parameter(description = "커서 - 마지막 이벤트 시간 (ISO 8601)", example = "")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime cursorEventAt,
@Parameter(description = "커서 - 마지막 이벤트 타입 (READING_RECORD | PERSONAL_RETROSPECTIVE | PRE_OPINION)")
@RequestParam(required = false) ReadingTimelineType cursorType,
@Parameter(description = "커서 - 마지막 이벤트 원본 ID")
@RequestParam(required = false) Long cursorSourceId,
@Parameter(description = "한 페이지당 아이템 수", example = "10")
@RequestParam(required = false) Integer size,
@Parameter(description = "사전 의견 정렬 기준 (MEETING_START | ANSWER_CREATED)", example = "ANSWER_CREATED")
@RequestParam(required = false, defaultValue = "ANSWER_CREATED") PreOpinionTimeType preOpinionTime
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@
import com.dokdok.book.api.PersonalBookRecordApi;
import com.dokdok.book.dto.request.PersonalReadingRecordCreateRequest;
import com.dokdok.book.dto.request.PersonalReadingRecordUpdateRequest;
import com.dokdok.book.dto.request.PreOpinionTimeType;
import com.dokdok.book.dto.response.*;
import com.dokdok.book.service.PersonalReadingRecordService;
import com.dokdok.book.service.ReadingTimelineService;
import com.dokdok.global.response.CursorResponse;
import com.dokdok.global.response.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDateTime;
import java.time.OffsetDateTime;

@RestController
Expand All @@ -19,6 +23,7 @@
public class PersonalBookRecordController implements PersonalBookRecordApi {

private final PersonalReadingRecordService personalReadingRecordService;
private final ReadingTimelineService readingTimelineService;

@Override
@PostMapping("/{personalBookId}/records")
Expand Down Expand Up @@ -65,4 +70,28 @@ public ResponseEntity<ApiResponse<PersonalReadingTopicAnswerResponse>> getMyTopi
PersonalReadingTopicAnswerResponse response = personalReadingRecordService.getTopicAnswers(personalBookId);
return ApiResponse.success(response, "사전 의견 조회 성공");
}

@Override
@GetMapping("/{personalBookId}/records/timeline")
public ResponseEntity<ApiResponse<CursorResponse<ReadingTimelineItem, ReadingTimelineCursor>>> getMyReadingTimeline(
@PathVariable Long personalBookId,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime cursorEventAt,
@RequestParam(required = false) ReadingTimelineType cursorType,
@RequestParam(required = false) Long cursorSourceId,
@RequestParam(required = false) Integer size,
@RequestParam(required = false, defaultValue = "ANSWER_CREATED") PreOpinionTimeType preOpinionTime
) {
CursorResponse<ReadingTimelineItem, ReadingTimelineCursor> response =
readingTimelineService.getTimeline(
personalBookId,
cursorEventAt,
cursorType,
cursorSourceId,
size,
preOpinionTime
);
return ApiResponse.success(response, "독서 타임라인 조회 성공");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.dokdok.book.dto.request;

public enum PreOpinionTimeType {
MEETING_START,
ANSWER_CREATED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.dokdok.book.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;

import java.time.LocalDateTime;

@Schema(description = "독서 타임라인 커서")
public record ReadingTimelineCursor(
@Schema(description = "마지막 이벤트 시간", example = "2026-01-05T21:38:00")
LocalDateTime eventAt,
@Schema(description = "마지막 이벤트 타입", example = "READING_RECORD")
ReadingTimelineType type,
@Schema(description = "마지막 이벤트 원본 ID", example = "10")
Long sourceId
) {
public static ReadingTimelineCursor from(LocalDateTime eventAt, ReadingTimelineType type, Long sourceId) {
return new ReadingTimelineCursor(eventAt, type, sourceId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.dokdok.book.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;

import java.util.List;

@Schema(description = "독서 타임라인 커서 응답(문서용)")
public record ReadingTimelineCursorResponse(
@Schema(description = "아이템 목록")
List<ReadingTimelineItem> items,

@Schema(description = "페이지 크기", example = "10")
int pageSize,

@Schema(description = "다음 페이지 존재 여부", example = "true")
boolean hasNext,

@Schema(description = "다음 커서")
ReadingTimelineCursor nextCursor
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.dokdok.book.dto.response;

import com.dokdok.retrospective.dto.response.RetrospectiveRecordResponse;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;

import java.time.LocalDateTime;

@Schema(description = "독서 타임라인 아이템")
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ReadingTimelineItem(
@Schema(description = "타임라인 타입", example = "READING_RECORD")
ReadingTimelineType type,
@Schema(description = "정렬 기준 시간", example = "2026-01-05T21:38:00")
LocalDateTime eventAt,
@Schema(description = "원본 ID", example = "1")
Long sourceId,
@Schema(description = "독서 기록 데이터 (type=READING_RECORD)")
PersonalReadingRecordListResponse readingRecord,
@Schema(description = "개인 회고 데이터 (type=PERSONAL_RETROSPECTIVE)")
RetrospectiveRecordResponse retrospective,
@Schema(description = "사전 의견 데이터 (type=PRE_OPINION)")
PersonalReadingTopicAnswerResponse preOpinion
) {
public static ReadingTimelineItem readingRecord(
LocalDateTime eventAt,
Long sourceId,
PersonalReadingRecordListResponse readingRecord
) {
return new ReadingTimelineItem(
ReadingTimelineType.READING_RECORD,
eventAt,
sourceId,
readingRecord,
null,
null
);
}

public static ReadingTimelineItem retrospective(
LocalDateTime eventAt,
Long sourceId,
RetrospectiveRecordResponse retrospective
) {
return new ReadingTimelineItem(
ReadingTimelineType.PERSONAL_RETROSPECTIVE,
eventAt,
sourceId,
null,
retrospective,
null
);
}

public static ReadingTimelineItem preOpinion(
LocalDateTime eventAt,
Long sourceId,
PersonalReadingTopicAnswerResponse preOpinion
) {
return new ReadingTimelineItem(
ReadingTimelineType.PRE_OPINION,
eventAt,
sourceId,
null,
null,
preOpinion
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.dokdok.book.dto.response;

public enum ReadingTimelineType {
READING_RECORD(3),
PERSONAL_RETROSPECTIVE(2),
PRE_OPINION(1);

private final int order;

ReadingTimelineType(int order) {
this.order = order;
}

public int getOrder() {
return order;
}

public static ReadingTimelineType from(String value) {
return ReadingTimelineType.valueOf(value);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public interface PersonalReadingRecordRepository extends JpaRepository<PersonalR
Optional<PersonalReadingRecord> findByIdAndPersonalBook_IdAndUserId(Long id, Long personalBookId, Long userId);
Page<PersonalReadingRecord> findAllByPersonalBook_IdAndUserId(Long personalBookId, Long userId, Pageable pageable);
long countByPersonalBook_IdAndUserId(Long personalBookId, Long userId);
List<PersonalReadingRecord> findByIdInAndPersonalBook_IdAndUserId(List<Long> ids, Long personalBookId, Long userId);

@Query("""
select record
Expand Down
Loading