From 1744867075f3379f93e995f6d57729618ea0e565 Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 19 Aug 2026 11:20:53 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(backend):=20=EC=98=A4=EB=8B=B5?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=E2=80=94=20=EC=A7=88=EB=AC=B8=20=EB=B6=81?= =?UTF-8?q?=EB=A7=88=ED=81=AC=20+=20=EB=AA=A8=EC=95=84=EB=B3=B4=EA=B8=B0?= =?UTF-8?q?=20(B-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 다시 볼 질문을 표시해 두고 나중에 모아 복습한다. - `PUT /api/sessions/{sid}/messages/{mid}/bookmark` — 표시/해제 - `GET /api/users/me/bookmarks` — 모아보기 ## 설계 판단 **질문(INTERVIEWER)에만 걸 수 있다** (`MESSAGE_NOT_BOOKMARKABLE`, 422). 답변을 표시해 봐야 복습할 게 없다. **토글이 아니라 명시적 상태를 받는다.** 토글이면 재전송·더블클릭이 상태를 뒤집어 놓는다. 같은 요청이 두 번 와도 결과가 같다. **목록은 질문 + 그때 내 답변 + 모범답안/코칭을 한 묶음으로** 반환한다. 질문만 모아 봐야 "내가 뭘 틀렸는지"를 알 수 없다. 답변은 `findByParentMessage_IdIn` 으로 한 번에 받아 매핑한다 — 질문마다 조회하면 N+1 이다. **별도 테이블을 만들지 않았다.** V26 으로 `interview_messages.bookmarked` + 부분 인덱스 (`WHERE bookmarked = TRUE`). 북마크의 수명은 질문의 수명과 같다 — 세션을 지우면 그 질문도 사라지는 게 맞다(목록 쿼리가 `session.deleted = false` 로 거른다). 엔티티 변경 메서드는 `applyBookmark` 다. `setBookmarked` 로 두면 ArchUnit 의 "엔티티 public setter 금지" 룰에 걸린다. `QuestionBookmarkController` 는 URL 이 `/api/users/me/*` 지만 `UserStatsController` 와 같은 이유로 session 슬라이스에 둔다(user → session 직접 의존 회피). ## 테스트 `QuestionBookmarkServiceTest` 신설 (8) — 표시, 멱등, 답변 표시 거부, 타인 세션 거부, **다른 세션의 메시지 id 끼워넣기 거부**, 답변·코칭 결합, 답변 없는 질문도 목록에 노출, 북마크 0건일 때 답변 조회 스킵. --- backend/CLAUDE.md | 8 + backend/openapi.json | 177 ++++++++++++++++++ .../common/exception/ApiErrorCode.java | 2 + .../application/QuestionBookmarkService.java | 78 ++++++++ .../dto/BookmarkedQuestionResult.java | 21 +++ .../application/dto/MessageResult.java | 7 +- .../session/domain/InterviewMessage.java | 10 + .../domain/InterviewMessageRepository.java | 13 ++ .../InterviewMessageController.java | 31 +++ .../QuestionBookmarkController.java | 41 ++++ .../dto/BookmarkedQuestionResponse.java | 32 ++++ .../presentation/dto/MessageResponse.java | 7 +- .../dto/QuestionBookmarkRequest.java | 11 ++ .../dto/QuestionBookmarkResponse.java | 7 + .../migration/V26__add_message_bookmark.sql | 7 + .../QuestionBookmarkServiceTest.java | 168 +++++++++++++++++ 16 files changed, 616 insertions(+), 4 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/QuestionBookmarkService.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/dto/BookmarkedQuestionResult.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/presentation/QuestionBookmarkController.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/presentation/dto/BookmarkedQuestionResponse.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/presentation/dto/QuestionBookmarkRequest.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/presentation/dto/QuestionBookmarkResponse.java create mode 100644 backend/src/main/resources/db/migration/V26__add_message_bookmark.sql create mode 100644 backend/src/test/java/com/stackup/stackup/session/application/QuestionBookmarkServiceTest.java diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 734aa2cf..24890220 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -457,6 +457,14 @@ docker compose up -d target_evidence 필수 조건은 면제되지 않는다). - `@Value` 필드에 **자바 초기값도 함께** 둔다(`= 70`). Spring 밖(단위 테스트)에서는 주입이 안 돼 0.0 이 되고, 그러면 모든 축이 '기준 이상'으로 판정돼 조용히 다른 동작을 한다. +- **오답노트 본 구현 (B-4)**: `PUT /api/sessions/{sid}/messages/{mid}/bookmark` (표시/해제) + + `GET /api/users/me/bookmarks` (모아보기). V26 으로 `interview_messages.bookmarked` + 부분 인덱스. + **질문(INTERVIEWER) 메시지에만** 걸 수 있다(`MESSAGE_NOT_BOOKMARKABLE`) — 답변을 표시해도 복습할 게 없다. + 요청은 토글이 아니라 **명시적 상태**를 받는다: 토글이면 재전송·더블클릭이 상태를 뒤집는다. + 목록은 질문 + 그때 내 답변 + 모범답안/코칭을 한 묶음으로 반환하며, 답변은 + `findByParentMessage_IdIn` 으로 한 번에 받아 매핑한다(질문마다 조회하면 N+1). + `QuestionBookmarkController` 는 URL 이 `/api/users/me/*` 지만 `UserStatsController` 와 같은 이유로 + session 슬라이스에 둔다(user → session 직접 의존 회피). - **Spring AI 미사용** — LLM·임베딩 호출은 모두 AI 서버 위임. Core는 RabbitMQ 발행만 담당. - **Redis 미사용** — 휘발성 데이터는 DB short-lived 레코드 또는 인메모리로. diff --git a/backend/openapi.json b/backend/openapi.json index 6499fffe..a95769c7 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -26,6 +26,9 @@ }, { "name" : "Documents", "description" : "분석 문서(이력서/레포 공통) 조회. 상세 조회 시 분석 마크다운의 presigned S3 URL 포함." + }, { + "name" : "Users (Bookmarks)", + "description" : "오답노트 — 다시 볼 질문 모아보기." }, { "name" : "Users (Stats)", "description" : "사용자 면접 통계 — 총/완료 세션 수, 평균 점수, 최근 점수 추이." @@ -67,6 +70,83 @@ "description" : "X-Internal-API-Key 필요. AI 서버가 질문/피드백 생성 시 컨텍스트 청크 검색 (pgvector cosine)." } ], "paths" : { + "/api/sessions/{sessionId}/messages/{messageId}/bookmark" : { + "put" : { + "tags" : [ "Session Messages" ], + "summary" : "질문 오답노트 표시/해제", + "description" : "다시 볼 질문을 표시한다. 질문(INTERVIEWER) 메시지에만 걸 수 있다. 모아보기는 GET /api/users/me/bookmarks.", + "operationId" : "setQuestionBookmark", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + }, { + "name" : "messageId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/QuestionBookmarkRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "표시 상태", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/QuestionBookmarkResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/QuestionBookmarkResponse" + } + } + } + }, + "404" : { + "description" : "세션 또는 메시지 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/QuestionBookmarkResponse" + } + } + } + }, + "422" : { + "description" : "질문이 아닌 메시지", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/QuestionBookmarkResponse" + } + } + } + } + } + } + }, "/api/internal/documents/{documentId}/embeddings" : { "put" : { "tags" : [ "Internal: Document Embeddings" ], @@ -2028,6 +2108,42 @@ } } }, + "/api/users/me/bookmarks" : { + "get" : { + "tags" : [ "Users (Bookmarks)" ], + "summary" : "오답노트 목록", + "description" : "표시해 둔 질문과 그때 내 답변·모범 답안·코칭을 함께 반환한다. 표시/해제는 PUT /api/sessions/{sessionId}/messages/{messageId}/bookmark.", + "operationId" : "listBookmarkedQuestions", + "responses" : { + "200" : { + "description" : "최근 표시 순 목록", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/BookmarkedQuestionResponse" + } + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/BookmarkedQuestionResponse" + } + } + } + } + } + } + } + }, "/api/system/ready" : { "get" : { "tags" : [ "system-controller" ], @@ -2832,6 +2948,26 @@ }, "components" : { "schemas" : { + "QuestionBookmarkRequest" : { + "type" : "object", + "properties" : { + "bookmarked" : { + "type" : "boolean" + } + } + }, + "QuestionBookmarkResponse" : { + "type" : "object", + "properties" : { + "messageId" : { + "type" : "integer", + "format" : "int64" + }, + "bookmarked" : { + "type" : "boolean" + } + } + }, "ChunkRequest" : { "type" : "object", "properties" : { @@ -3219,6 +3355,9 @@ }, "deliveryComment" : { "type" : "string" + }, + "bookmarked" : { + "type" : "boolean" } } }, @@ -3722,6 +3861,44 @@ } } }, + "BookmarkedQuestionResponse" : { + "type" : "object", + "properties" : { + "messageId" : { + "type" : "integer", + "format" : "int64" + }, + "sessionId" : { + "type" : "integer", + "format" : "int64" + }, + "sessionTitle" : { + "type" : "string" + }, + "category" : { + "type" : "string" + }, + "question" : { + "type" : "string" + }, + "expectedSignal" : { + "type" : "string" + }, + "myAnswer" : { + "type" : "string" + }, + "modelAnswer" : { + "type" : "string" + }, + "coachingComment" : { + "type" : "string" + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, "ComponentHealthResponse" : { "type" : "object", "properties" : { diff --git a/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java index d89ea2dd..228c1d69 100644 --- a/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java +++ b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java @@ -51,6 +51,8 @@ public enum ApiErrorCode { VOICE_INVALID_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 음성 형식입니다."), VOICE_MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "음성 메시지를 찾을 수 없습니다."), VOICE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "음성 파일 업로드에 실패했습니다."), + MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "메시지를 찾을 수 없습니다."), + MESSAGE_NOT_BOOKMARKABLE(HttpStatus.UNPROCESSABLE_ENTITY, "질문만 오답노트에 담을 수 있습니다."), VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "요청 값이 올바르지 않습니다."), ACCESS_DENIED(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."), diff --git a/backend/src/main/java/com/stackup/stackup/session/application/QuestionBookmarkService.java b/backend/src/main/java/com/stackup/stackup/session/application/QuestionBookmarkService.java new file mode 100644 index 00000000..6160ca1e --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/QuestionBookmarkService.java @@ -0,0 +1,78 @@ +package com.stackup.stackup.session.application; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.session.application.dto.BookmarkedQuestionResult; +import com.stackup.stackup.session.domain.InterviewMessage; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.InterviewSession; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.MessageRole; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +// 오답노트 — 다시 볼 질문 표시 + 모아보기. +// 표시는 질문(INTERVIEWER) 메시지에만 건다. 답변·부연 메시지를 표시해도 복습할 게 없다. +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class QuestionBookmarkService { + + private final InterviewSessionRepository sessionRepository; + private final InterviewMessageRepository messageRepository; + + @Transactional + public boolean setBookmark(Long userId, Long sessionId, Long messageId, boolean bookmarked) { + // 소유권은 세션으로 확인한다(메시지에는 user 가 없다). + sessionRepository.findByIdAndUser_IdAndDeletedFalse(sessionId, userId) + .orElseThrow(() -> new DomainException(ApiErrorCode.SESSION_NOT_FOUND)); + + InterviewMessage message = messageRepository.findById(messageId) + .filter(m -> m.getSession().getId().equals(sessionId)) + .orElseThrow(() -> new DomainException(ApiErrorCode.MESSAGE_NOT_FOUND)); + if (message.getRole() != MessageRole.INTERVIEWER) { + throw new DomainException(ApiErrorCode.MESSAGE_NOT_BOOKMARKABLE); + } + message.applyBookmark(bookmarked); + return bookmarked; + } + + public List list(Long userId) { + List questions = messageRepository.findBookmarkedByOwner(userId); + if (questions.isEmpty()) { + return List.of(); + } + // 질문마다 답변을 따로 조회하면 N+1 이 된다 — 한 번에 받아 매핑한다. + Map answerByQuestionId = messageRepository + .findByParentMessage_IdIn(questions.stream().map(InterviewMessage::getId).toList()) + .stream() + .filter(m -> m.getRole() == MessageRole.INTERVIEWEE) + .collect(Collectors.toMap( + m -> m.getParentMessage().getId(), Function.identity(), (a, b) -> a)); + + return questions.stream() + .map(q -> toResult(q, answerByQuestionId.get(q.getId()))) + .toList(); + } + + private BookmarkedQuestionResult toResult(InterviewMessage question, InterviewMessage answer) { + InterviewSession session = question.getSession(); + return new BookmarkedQuestionResult( + question.getId(), + session.getId(), + session.getTitle(), + question.getCategory(), + question.getContent(), + question.getExpectedSignal(), + answer == null ? null : answer.getContent(), + answer == null ? null : answer.getModelAnswer(), + answer == null ? null : answer.getCoachingComment(), + question.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/BookmarkedQuestionResult.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/BookmarkedQuestionResult.java new file mode 100644 index 00000000..26291fe5 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/BookmarkedQuestionResult.java @@ -0,0 +1,21 @@ +package com.stackup.stackup.session.application.dto; + +import java.time.Instant; + +// 오답노트 항목 — 표시한 질문 + 그때 내 답변 + 복기 재료. +public record BookmarkedQuestionResult( + Long messageId, + Long sessionId, + String sessionTitle, + String category, + String question, + // 좋은 답변이 드러내야 할 핵심(질문에 기록됨). + String expectedSignal, + // 그때 내가 한 답변. 답변 전에 표시했거나 실패한 턴이면 null. + String myAnswer, + // 답변에 붙은 복기 재료(피드백 생성 시 기록). 피드백 전이면 null. + String modelAnswer, + String coachingComment, + Instant createdAt +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java index c700ed96..778c5240 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java @@ -43,7 +43,9 @@ public record MessageResult( Double pronunciationAccuracy, // 위 메트릭에서 결정론적으로 산정한 전달력 평가(배지 GOOD/FAIR/POOR + 한 줄 코칭). 음성 답변에만. String deliveryRating, - String deliveryComment + String deliveryComment, + // 오답노트 표시 여부(질문 메시지에만 의미 있음). + boolean bookmarked ) { public static MessageResult of(InterviewMessage m) { return of(m, null, null, false); @@ -98,7 +100,8 @@ public static MessageResult of( revealInsights ? fillerWordCounts : null, revealInsights ? pronunciationAccuracy : null, delivery == null ? null : delivery.rating(), - delivery == null ? null : delivery.comment() + delivery == null ? null : delivery.comment(), + m.isBookmarked() ); } } diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java index 125f446d..c2f7cf58 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java @@ -125,6 +125,10 @@ public class InterviewMessage extends BaseTimeEntity { @Column(nullable = false) private boolean clarification = false; + // 오답노트 — 다시 볼 질문으로 표시됐는지. 질문(INTERVIEWER) 메시지에만 쓴다. + @Column(nullable = false) + private boolean bookmarked = false; + private InterviewMessage(InterviewSession session, Integer sequenceNumber, MessageRole role, String content, InterviewMessage parentMessage, MessageStatus initialStatus, String idempotencyKey) { @@ -215,6 +219,12 @@ public static InterviewMessage voiceInterviewee(InterviewSession session, int se return m; } + // 오답노트 토글. 질문이 아닌 메시지를 표시하려는 시도는 호출부에서 막는다. + // (setter 네이밍은 ArchUnit 이 막는다 — 엔티티는 의미 있는 도메인 메서드로만 바뀐다.) + public void applyBookmark(boolean value) { + this.bookmarked = value; + } + public void markStatus(MessageStatus newStatus) { if (newStatus != null) { this.status = newStatus; diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessageRepository.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessageRepository.java index 9145f9c2..bac3e08b 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessageRepository.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessageRepository.java @@ -20,4 +20,17 @@ public interface InterviewMessageRepository extends JpaRepository findBookmarkedByOwner(@Param("userId") Long userId); + + // 질문에 달린 답변(있으면 1개). 오답노트에 '내 답변 + 코칭'을 함께 보여주기 위해. + List findByParentMessage_IdIn(List parentMessageIds); } diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/InterviewMessageController.java b/backend/src/main/java/com/stackup/stackup/session/presentation/InterviewMessageController.java index ebc88caf..9812e503 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/InterviewMessageController.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/InterviewMessageController.java @@ -2,7 +2,10 @@ import com.stackup.stackup.common.security.UserPrincipal; import com.stackup.stackup.session.application.InterviewMessageService; +import com.stackup.stackup.session.application.QuestionBookmarkService; import com.stackup.stackup.session.presentation.dto.MessageResponse; +import com.stackup.stackup.session.presentation.dto.QuestionBookmarkRequest; +import com.stackup.stackup.session.presentation.dto.QuestionBookmarkResponse; import com.stackup.stackup.session.presentation.dto.MessageSubmitRequest; import com.stackup.stackup.session.application.InterviewMessageService.AudioStream; import io.swagger.v3.oas.annotations.Operation; @@ -23,6 +26,7 @@ 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.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; @@ -37,6 +41,33 @@ public class InterviewMessageController { private final InterviewMessageService messageService; + private final QuestionBookmarkService bookmarkService; + + @Operation( + operationId = "setQuestionBookmark", + summary = "질문 오답노트 표시/해제", + description = "다시 볼 질문을 표시한다. 질문(INTERVIEWER) 메시지에만 걸 수 있다. " + + "모아보기는 GET /api/users/me/bookmarks." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "표시 상태"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "세션 또는 메시지 없음"), + @ApiResponse(responseCode = "422", description = "질문이 아닌 메시지") + }) + @PutMapping("/{messageId}/bookmark") + public QuestionBookmarkResponse setBookmark( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long sessionId, + @PathVariable Long messageId, + @Valid @RequestBody QuestionBookmarkRequest request + ) { + return new QuestionBookmarkResponse( + messageId, + bookmarkService.setBookmark( + principal.userId(), sessionId, messageId, request.bookmarkedOrDefault()) + ); + } @Operation(operationId = "listSessionMessages", summary = "세션 메시지 시퀀스 (US-20)") @ApiResponses({ diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/QuestionBookmarkController.java b/backend/src/main/java/com/stackup/stackup/session/presentation/QuestionBookmarkController.java new file mode 100644 index 00000000..b0923f2c --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/QuestionBookmarkController.java @@ -0,0 +1,41 @@ +package com.stackup.stackup.session.presentation; + +import com.stackup.stackup.common.security.UserPrincipal; +import com.stackup.stackup.session.application.QuestionBookmarkService; +import com.stackup.stackup.session.presentation.dto.BookmarkedQuestionResponse; +import io.swagger.v3.oas.annotations.Operation; +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 java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +// UserStatsController 와 같은 이유로 session 슬라이스에 둔다 — URL 은 /api/users/me/* 지만 +// 데이터 출처가 session 도메인이다(user → session 직접 의존 회피). +@Tag(name = "Users (Bookmarks)", description = "오답노트 — 다시 볼 질문 모아보기.") +@RestController +@RequiredArgsConstructor +public class QuestionBookmarkController { + + private final QuestionBookmarkService bookmarkService; + + @Operation( + operationId = "listBookmarkedQuestions", + summary = "오답노트 목록", + description = "표시해 둔 질문과 그때 내 답변·모범 답안·코칭을 함께 반환한다. " + + "표시/해제는 PUT /api/sessions/{sessionId}/messages/{messageId}/bookmark." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "최근 표시 순 목록"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + @GetMapping("/api/users/me/bookmarks") + public List list(@AuthenticationPrincipal UserPrincipal principal) { + return bookmarkService.list(principal.userId()).stream() + .map(BookmarkedQuestionResponse::from) + .toList(); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/BookmarkedQuestionResponse.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/BookmarkedQuestionResponse.java new file mode 100644 index 00000000..9e825a30 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/BookmarkedQuestionResponse.java @@ -0,0 +1,32 @@ +package com.stackup.stackup.session.presentation.dto; + +import com.stackup.stackup.session.application.dto.BookmarkedQuestionResult; +import java.time.Instant; + +public record BookmarkedQuestionResponse( + Long messageId, + Long sessionId, + String sessionTitle, + String category, + String question, + String expectedSignal, + String myAnswer, + String modelAnswer, + String coachingComment, + Instant createdAt +) { + public static BookmarkedQuestionResponse from(BookmarkedQuestionResult r) { + return new BookmarkedQuestionResponse( + r.messageId(), + r.sessionId(), + r.sessionTitle(), + r.category(), + r.question(), + r.expectedSignal(), + r.myAnswer(), + r.modelAnswer(), + r.coachingComment(), + r.createdAt() + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageResponse.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageResponse.java index 890f35de..d27aad52 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageResponse.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageResponse.java @@ -41,7 +41,9 @@ public record MessageResponse( Double pronunciationAccuracy, // 전달력 메트릭에서 산정한 배지(GOOD/FAIR/POOR)와 한 줄 코칭. String deliveryRating, - String deliveryComment + String deliveryComment, + // 오답노트 표시 여부(질문 메시지에만 의미 있음). + boolean bookmarked ) { public static MessageResponse from(MessageResult r) { return new MessageResponse( @@ -74,7 +76,8 @@ public static MessageResponse from(MessageResult r) { r.fillerWordCounts(), r.pronunciationAccuracy(), r.deliveryRating(), - r.deliveryComment() + r.deliveryComment(), + r.bookmarked() ); } } diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/QuestionBookmarkRequest.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/QuestionBookmarkRequest.java new file mode 100644 index 00000000..b1a45c81 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/QuestionBookmarkRequest.java @@ -0,0 +1,11 @@ +package com.stackup.stackup.session.presentation.dto; + +// 오답노트 표시 상태. 토글이 아니라 명시적 상태를 받는다 — +// 토글이면 중복 요청(더블클릭·재전송)이 상태를 뒤집어 놓는다. +public record QuestionBookmarkRequest( + Boolean bookmarked +) { + public boolean bookmarkedOrDefault() { + return Boolean.TRUE.equals(bookmarked); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/QuestionBookmarkResponse.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/QuestionBookmarkResponse.java new file mode 100644 index 00000000..5720b33c --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/QuestionBookmarkResponse.java @@ -0,0 +1,7 @@ +package com.stackup.stackup.session.presentation.dto; + +public record QuestionBookmarkResponse( + Long messageId, + boolean bookmarked +) { +} diff --git a/backend/src/main/resources/db/migration/V26__add_message_bookmark.sql b/backend/src/main/resources/db/migration/V26__add_message_bookmark.sql new file mode 100644 index 00000000..f0024f33 --- /dev/null +++ b/backend/src/main/resources/db/migration/V26__add_message_bookmark.sql @@ -0,0 +1,7 @@ +-- B-4 오답노트. 다시 볼 질문을 표시해 두고 나중에 모아 복습한다. +-- 질문(INTERVIEWER) 메시지에만 의미가 있다. 세션이 삭제되면 함께 사라진다(질문의 출처가 세션이므로). +ALTER TABLE interview_messages ADD COLUMN bookmarked BOOLEAN NOT NULL DEFAULT FALSE; + +-- 북마크 목록 조회는 "내 것 중 표시된 것"이라 부분 인덱스로 충분하다. +CREATE INDEX idx_interview_messages_bookmarked + ON interview_messages (session_id) WHERE bookmarked = TRUE; diff --git a/backend/src/test/java/com/stackup/stackup/session/application/QuestionBookmarkServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/QuestionBookmarkServiceTest.java new file mode 100644 index 00000000..5de1dd5d --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/application/QuestionBookmarkServiceTest.java @@ -0,0 +1,168 @@ +package com.stackup.stackup.session.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.session.application.dto.BookmarkedQuestionResult; +import com.stackup.stackup.session.domain.InterviewMessage; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.InterviewSession; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.SessionMode; +import com.stackup.stackup.user.domain.User; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class QuestionBookmarkServiceTest { + + @Mock InterviewSessionRepository sessionRepository; + @Mock InterviewMessageRepository messageRepository; + @InjectMocks QuestionBookmarkService service; + + @Test + void setBookmark_marksQuestion() { + InterviewSession session = sessionFixture(10L); + InterviewMessage question = questionFixture(session, 100L); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session)); + when(messageRepository.findById(100L)).thenReturn(Optional.of(question)); + + assertThat(service.setBookmark(1L, 10L, 100L, true)).isTrue(); + assertThat(question.isBookmarked()).isTrue(); + } + + // 토글이 아니라 명시적 상태를 받으므로 같은 요청이 두 번 와도 결과가 같다. + @Test + void setBookmark_isIdempotent() { + InterviewSession session = sessionFixture(10L); + InterviewMessage question = questionFixture(session, 100L); + question.applyBookmark(true); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session)); + when(messageRepository.findById(100L)).thenReturn(Optional.of(question)); + + service.setBookmark(1L, 10L, 100L, true); + + assertThat(question.isBookmarked()).isTrue(); + } + + // 답변을 표시해 봐야 복습할 게 없다. + @Test + void setBookmark_rejectsNonQuestionMessage() { + InterviewSession session = sessionFixture(10L); + InterviewMessage answer = InterviewMessage.interviewee(session, 2, "제 답변입니다", null, null); + ReflectionTestUtils.setField(answer, "id", 200L); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session)); + when(messageRepository.findById(200L)).thenReturn(Optional.of(answer)); + + assertThatThrownBy(() -> service.setBookmark(1L, 10L, 200L, true)) + .isInstanceOfSatisfying(DomainException.class, e -> + assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.MESSAGE_NOT_BOOKMARKABLE)); + } + + @Test + void setBookmark_rejectsSessionOfAnotherUser() { + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.setBookmark(1L, 10L, 100L, true)) + .isInstanceOfSatisfying(DomainException.class, e -> + assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.SESSION_NOT_FOUND)); + + verify(messageRepository, never()).findById(any()); + } + + // 다른 세션의 메시지 id 를 끼워 넣어도 통과하면 안 된다. + @Test + void setBookmark_rejectsMessageOfAnotherSession() { + InterviewSession session = sessionFixture(10L); + InterviewMessage foreign = questionFixture(sessionFixture(99L), 100L); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session)); + when(messageRepository.findById(100L)).thenReturn(Optional.of(foreign)); + + assertThatThrownBy(() -> service.setBookmark(1L, 10L, 100L, true)) + .isInstanceOfSatisfying(DomainException.class, e -> + assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.MESSAGE_NOT_FOUND)); + } + + // 오답노트는 '질문 + 그때 내 답변 + 복기 재료'가 한 묶음이어야 쓸모가 있다. + @Test + void list_joinsAnswerAndCoachingToEachQuestion() { + InterviewSession session = sessionFixture(10L); + InterviewMessage question = questionFixture(session, 100L); + InterviewMessage answer = InterviewMessage.interviewee(session, 2, "제 답변", question, null); + ReflectionTestUtils.setField(answer, "id", 200L); + answer.recordCoaching("모범 답안", "리라이트", "한 줄 코칭"); + + when(messageRepository.findBookmarkedByOwner(1L)).thenReturn(List.of(question)); + when(messageRepository.findByParentMessage_IdIn(List.of(100L))).thenReturn(List.of(answer)); + + List results = service.list(1L); + + assertThat(results).hasSize(1); + BookmarkedQuestionResult r = results.get(0); + assertThat(r.messageId()).isEqualTo(100L); + assertThat(r.sessionId()).isEqualTo(10L); + assertThat(r.question()).isEqualTo("ACID 를 설명해 주세요."); + assertThat(r.myAnswer()).isEqualTo("제 답변"); + assertThat(r.modelAnswer()).isEqualTo("모범 답안"); + assertThat(r.coachingComment()).isEqualTo("한 줄 코칭"); + } + + // 답변 전에 표시했거나 피드백이 아직 없으면 복기 재료가 비어 있을 뿐, 목록은 나와야 한다. + @Test + void list_returnsQuestionEvenWithoutAnswer() { + InterviewSession session = sessionFixture(10L); + InterviewMessage question = questionFixture(session, 100L); + + when(messageRepository.findBookmarkedByOwner(1L)).thenReturn(List.of(question)); + when(messageRepository.findByParentMessage_IdIn(List.of(100L))).thenReturn(List.of()); + + List results = service.list(1L); + + assertThat(results).hasSize(1); + assertThat(results.get(0).myAnswer()).isNull(); + assertThat(results.get(0).modelAnswer()).isNull(); + } + + @Test + void list_skipsAnswerLookupWhenNoBookmarks() { + when(messageRepository.findBookmarkedByOwner(1L)).thenReturn(List.of()); + + assertThat(service.list(1L)).isEmpty(); + verify(messageRepository, never()).findByParentMessage_IdIn(any()); + } + + private InterviewMessage questionFixture(InterviewSession session, Long id) { + InterviewMessage question = + InterviewMessage.interviewer(session, 1, "ACID 를 설명해 주세요."); + ReflectionTestUtils.setField(question, "id", id); + return question; + } + + private InterviewSession sessionFixture(Long id) { + User user = User.createGithubUser(1L, "u", null, null, "t"); + ReflectionTestUtils.setField(user, "id", 1L); + InterviewSession s = InterviewSession.create( + user, "백엔드 모의면접", null, SessionMode.TECHNICAL, + List.of(JobCategory.BACKEND), 5, 30, null, null + ); + ReflectionTestUtils.setField(s, "id", id); + return s; + } +} From df1566e8835de6cd83e7de194d1bece4ba1018f3 Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 19 Aug 2026 11:21:08 +0900 Subject: [PATCH 2/4] =?UTF-8?q?chore:=20=ED=94=84=EB=A1=A0=ED=8A=B8=20API?= =?UTF-8?q?=20=ED=83=80=EC=9E=85=20=EC=9E=AC=EC=83=9D=EC=84=B1=20(?= =?UTF-8?q?=EC=98=A4=EB=8B=B5=EB=85=B8=ED=8A=B8=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/shared/api/generated.ts | 147 +++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts index 22300ce5..a145a5d4 100644 --- a/frontend/src/shared/api/generated.ts +++ b/frontend/src/shared/api/generated.ts @@ -4,6 +4,26 @@ */ export interface paths { + "/api/sessions/{sessionId}/messages/{messageId}/bookmark": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * 질문 오답노트 표시/해제 + * @description 다시 볼 질문을 표시한다. 질문(INTERVIEWER) 메시지에만 걸 수 있다. 모아보기는 GET /api/users/me/bookmarks. + */ + put: operations["setQuestionBookmark"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/internal/documents/{documentId}/embeddings": { parameters: { query?: never; @@ -583,6 +603,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/users/me/bookmarks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 오답노트 목록 + * @description 표시해 둔 질문과 그때 내 답변·모범 답안·코칭을 함께 반환한다. 표시/해제는 PUT /api/sessions/{sessionId}/messages/{messageId}/bookmark. + */ + get: operations["listBookmarkedQuestions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/system/ready": { parameters: { query?: never; @@ -910,6 +950,14 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + QuestionBookmarkRequest: { + bookmarked?: boolean; + }; + QuestionBookmarkResponse: { + /** Format: int64 */ + messageId?: number; + bookmarked?: boolean; + }; ChunkRequest: { /** Format: int32 */ chunkIndex?: number; @@ -1055,6 +1103,7 @@ export interface components { pronunciationAccuracy?: number; deliveryRating?: string; deliveryComment?: string; + bookmarked?: boolean; }; VoiceStreamBeginResponse: { /** Format: int64 */ @@ -1289,6 +1338,21 @@ export interface components { averages?: components["schemas"]["AverageScores"]; recent?: components["schemas"]["RecentScore"][]; }; + BookmarkedQuestionResponse: { + /** Format: int64 */ + messageId?: number; + /** Format: int64 */ + sessionId?: number; + sessionTitle?: string; + category?: string; + question?: string; + expectedSignal?: string; + myAnswer?: string; + modelAnswer?: string; + coachingComment?: string; + /** Format: date-time */ + createdAt?: string; + }; ComponentHealthResponse: { name?: string; status?: string; @@ -1480,6 +1544,60 @@ export interface components { } export type $defs = Record; export interface operations { + setQuestionBookmark: { + parameters: { + query?: never; + header?: never; + path: { + sessionId: number; + messageId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["QuestionBookmarkRequest"]; + }; + }; + responses: { + /** @description 표시 상태 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["QuestionBookmarkResponse"]; + }; + }; + /** @description 인증 실패 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["QuestionBookmarkResponse"]; + }; + }; + /** @description 세션 또는 메시지 없음 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["QuestionBookmarkResponse"]; + }; + }; + /** @description 질문이 아닌 메시지 */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["QuestionBookmarkResponse"]; + }; + }; + }; + }; internalUpsertDocumentEmbeddings: { parameters: { query?: never; @@ -3173,6 +3291,35 @@ export interface operations { }; }; }; + listBookmarkedQuestions: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 최근 표시 순 목록 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["BookmarkedQuestionResponse"][]; + }; + }; + /** @description 인증 실패 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["BookmarkedQuestionResponse"][]; + }; + }; + }; + }; ready: { parameters: { query?: never; From e7ae5b20cc0527bd049b7c25acb45510915fd7e2 Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 19 Aug 2026 11:21:08 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(frontend):=20=EC=98=A4=EB=8B=B5?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=E2=80=94=20=EC=A7=88=EB=AC=B8=20=EB=8B=B4?= =?UTF-8?q?=EA=B8=B0=20+=20=EC=9B=8C=ED=81=AC=EC=8A=A4=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EB=AA=A8=EC=95=84=EB=B3=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 종료 세션 기록(`InterviewTranscript`)의 질문 버블에 별 버튼. **라이브 중엔 안 띄운다** — 답변에 집중해야 할 때 끼어드는 조작이다(`bookmarkable` prop 으로 게이팅) - 워크스페이스에 '오답노트' 메뉴 + `/workspace/bookmarks` - **모범 답안은 접혀 있다.** 먼저 스스로 떠올려 보고 펼치는 게 복습의 핵심이라, 펼치기 전에는 질문과 평가 관점만 보인다 - 답변·피드백 기록이 없는 질문(답변 전에 담았거나 피드백 전)은 빈 아코디언 대신 이유를 적어 준다 - 담기/빼기 모두 명시적 상태를 보낸다(서버 계약과 동일 — 재전송이 상태를 뒤집지 않게) 북마크는 면접 질문이라 별도 feature 슬라이스로 쪼개지 않고 `features/interview` 에 뒀다. 쪼갰다면 질문 버블의 토글이 feature 간 import 가 돼 FSD 규칙에 걸린다. 테스트: `BookmarkList.test.tsx`(5) — 빈 상태, 모범답안 접힘→펼침, 복습 재료 없을 때 안내, 출처 면접 링크, 빼기 인자. `QuestionBubble.test.tsx` 신설(3) — 기본은 별 미노출, 담기/빼기 인자. vitest 98/98 · eslint 0 · tsc · build 통과. --- frontend/src/app/router/index.tsx | 8 + frontend/src/features/CLAUDE.md | 2 +- .../src/features/interview/api/bookmarkApi.ts | 23 +++ frontend/src/features/interview/index.ts | 2 + .../features/interview/model/useBookmarks.ts | 33 ++++ .../interview/ui/BookmarkList.test.tsx | 100 +++++++++++ .../features/interview/ui/BookmarkList.tsx | 157 ++++++++++++++++++ .../interview/ui/InterviewTranscript.tsx | 2 +- .../interview/ui/live/QuestionBubble.test.tsx | 55 ++++++ .../interview/ui/live/QuestionBubble.tsx | 49 +++++- .../src/pages/Workspace/ui/BookmarksView.tsx | 14 ++ .../src/pages/Workspace/ui/WorkspacePage.tsx | 10 +- .../workspace-sidebar/ui/WorkspaceSidebar.tsx | 18 ++ 13 files changed, 469 insertions(+), 4 deletions(-) create mode 100644 frontend/src/features/interview/api/bookmarkApi.ts create mode 100644 frontend/src/features/interview/model/useBookmarks.ts create mode 100644 frontend/src/features/interview/ui/BookmarkList.test.tsx create mode 100644 frontend/src/features/interview/ui/BookmarkList.tsx create mode 100644 frontend/src/features/interview/ui/live/QuestionBubble.test.tsx create mode 100644 frontend/src/pages/Workspace/ui/BookmarksView.tsx diff --git a/frontend/src/app/router/index.tsx b/frontend/src/app/router/index.tsx index 35107e97..6d3b0334 100644 --- a/frontend/src/app/router/index.tsx +++ b/frontend/src/app/router/index.tsx @@ -89,6 +89,14 @@ export const router = createBrowserRouter([ ), }, + { + path: '/workspace/bookmarks', + element: ( + + + + ), + }, { path: '/history', element: }, { path: '/design-system/*', diff --git a/frontend/src/features/CLAUDE.md b/frontend/src/features/CLAUDE.md index f3dae5f1..35269fab 100644 --- a/frontend/src/features/CLAUDE.md +++ b/frontend/src/features/CLAUDE.md @@ -80,7 +80,7 @@ features/{X} → pages/*, app/* ✗ | `resume` | 이력서 업로드, 목록, 삭제 | US-05, US-06 | | `repo` | GitHub 후보 조회/등록/목록/삭제 | US-07, US-08 | | `analysis` | 분석 문서 목록·요약·기술스택·원문(presigned) | US-11, US-12 | -| `interview` | 세션 생성·진행·종료, 메시지, 음성 | US-13~22 | +| `interview` | 세션 생성·진행·종료, 메시지, 음성, 재도전, **오답노트**(질문 북마크 — 북마크는 면접 질문이라 별도 슬라이스로 쪼개지 않았다) | US-13~22 | | `feedback` | 피드백 리포트, 점수, 키워드 | US-24, US-25 | | `history` (계획) | 세션 히스토리 목록·상세, 통계 | US-15, US-16, US-26, US-27 | diff --git a/frontend/src/features/interview/api/bookmarkApi.ts b/frontend/src/features/interview/api/bookmarkApi.ts new file mode 100644 index 00000000..923c638f --- /dev/null +++ b/frontend/src/features/interview/api/bookmarkApi.ts @@ -0,0 +1,23 @@ +import { apiClient } from '@/shared/api' +import type { components } from '@/shared/api/generated' + +type S = components['schemas'] +export type BookmarkedQuestion = S['BookmarkedQuestionResponse'] + +export async function listBookmarks(): Promise { + return (await apiClient.get('/api/users/me/bookmarks')).data +} + +// 토글이 아니라 명시적 상태를 보낸다 — 토글이면 재전송·더블클릭이 상태를 뒤집는다. +export async function setQuestionBookmark( + sessionId: number, + messageId: number, + bookmarked: boolean, +): Promise { + return ( + await apiClient.put( + `/api/sessions/${sessionId}/messages/${messageId}/bookmark`, + { bookmarked }, + ) + ).data +} diff --git a/frontend/src/features/interview/index.ts b/frontend/src/features/interview/index.ts index be884065..30192cb7 100644 --- a/frontend/src/features/interview/index.ts +++ b/frontend/src/features/interview/index.ts @@ -1,8 +1,10 @@ export { LiveInterview } from './ui/live/LiveInterview' export { InterviewTranscript } from './ui/InterviewTranscript' +export { BookmarkList } from './ui/BookmarkList' export { InterviewSetupForm } from './ui/setup/InterviewSetupForm' export type { DocOption } from './ui/setup/ContextDocumentPicker' export { useCreateSession } from './model/useCreateSession' export { useRetrySession } from './model/useRetrySession' export { useSession } from './model/useSession' +export { useBookmarks, useSetQuestionBookmark, bookmarkKeys } from './model/useBookmarks' export { useLiveInterview } from './model/useLiveInterview' diff --git a/frontend/src/features/interview/model/useBookmarks.ts b/frontend/src/features/interview/model/useBookmarks.ts new file mode 100644 index 00000000..07c63ebe --- /dev/null +++ b/frontend/src/features/interview/model/useBookmarks.ts @@ -0,0 +1,33 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { toast } from '@/shared/ui' +import { listBookmarks, setQuestionBookmark } from '../api/bookmarkApi' +import type { BookmarkedQuestion } from '../api/bookmarkApi' +import { messageKeys } from './useSessionMessages' + +export const bookmarkKeys = { + all: ['bookmarks'] as const, +} + +export function useBookmarks() { + return useQuery({ + queryKey: bookmarkKeys.all, + queryFn: listBookmarks, + }) +} + +export function useSetQuestionBookmark(sessionId: number) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ messageId, bookmarked }: { messageId: number; bookmarked: boolean }) => + setQuestionBookmark(sessionId, messageId, bookmarked), + onSuccess: (_data, variables) => { + // 전사(별 표시 상태)와 오답노트 목록 양쪽이 바뀐다. + void queryClient.invalidateQueries({ queryKey: messageKeys.list(sessionId) }) + void queryClient.invalidateQueries({ queryKey: bookmarkKeys.all }) + toast.success( + variables.bookmarked ? '오답노트에 담았어요' : '오답노트에서 뺐어요', + ) + }, + onError: () => toast.error('오답노트를 바꾸지 못했어요. 다시 시도해 주세요.'), + }) +} diff --git a/frontend/src/features/interview/ui/BookmarkList.test.tsx b/frontend/src/features/interview/ui/BookmarkList.test.tsx new file mode 100644 index 00000000..e73bd0b1 --- /dev/null +++ b/frontend/src/features/interview/ui/BookmarkList.test.tsx @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import { BookmarkList } from './BookmarkList' +import type { BookmarkedQuestion } from '../api/bookmarkApi' + +const useBookmarks = vi.fn() +const removeMutate = vi.fn() +vi.mock('../model/useBookmarks', () => ({ + useBookmarks: () => useBookmarks(), + useSetQuestionBookmark: () => ({ mutate: removeMutate, isPending: false }), +})) + +const item: BookmarkedQuestion = { + messageId: 100, + sessionId: 7, + sessionTitle: '백엔드 모의면접', + category: 'CS_FUNDAMENTAL', + question: 'ACID 를 설명해 주세요.', + expectedSignal: '트랜잭션 4대 속성을 예시와 함께', + myAnswer: '원자성만 말했습니다', + modelAnswer: '원자성·일관성·격리성·지속성을…', + coachingComment: '나머지 3가지도 짚어보세요', + createdAt: '2026-08-18T00:00:00Z', +} + +function renderList(data: BookmarkedQuestion[]) { + useBookmarks.mockReturnValue({ + data, + isPending: false, + isError: false, + refetch: vi.fn(), + }) + return render( + + + , + ) +} + +beforeEach(() => removeMutate.mockClear()) + +describe('BookmarkList', () => { + it('담아둔 게 없으면 담는 방법을 안내한다', () => { + renderList([]) + + expect(screen.getByText('아직 담아둔 질문이 없어요')).toBeInTheDocument() + }) + + // 복습의 핵심은 먼저 스스로 떠올려 보는 것 — 모범 답안이 처음부터 보이면 의미가 없다. + it('모범 답안은 접혀 있고 펼쳐야 보인다', async () => { + renderList([item]) + + expect(screen.getByText('ACID 를 설명해 주세요.')).toBeInTheDocument() + expect(screen.getByText(/평가 관점/)).toBeInTheDocument() + expect(screen.queryByText('원자성·일관성·격리성·지속성을…')).not.toBeInTheDocument() + + await userEvent.click( + screen.getByRole('button', { name: '내 답변 · 모범 답안 보기' }), + ) + + expect(screen.getByText('원자성·일관성·격리성·지속성을…')).toBeInTheDocument() + expect(screen.getByText('원자성만 말했습니다')).toBeInTheDocument() + expect(screen.getByText('나머지 3가지도 짚어보세요')).toBeInTheDocument() + }) + + // 답변 전에 담았거나 피드백이 아직 없는 경우 — 빈 아코디언 대신 이유를 보여준다. + it('복습 재료가 없으면 이유를 알려준다', () => { + // 서버는 값이 없으면 필드를 비운다(생성 타입상 optional). + renderList([ + { ...item, myAnswer: undefined, modelAnswer: undefined, coachingComment: undefined }, + ]) + + expect( + screen.getByText('이 질문에는 아직 답변·피드백 기록이 없어요.'), + ).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: '내 답변 · 모범 답안 보기' }), + ).not.toBeInTheDocument() + }) + + it('출처 면접으로 돌아갈 수 있다', () => { + renderList([item]) + + expect(screen.getByRole('link', { name: '백엔드 모의면접' })).toHaveAttribute( + 'href', + '/sessions/7', + ) + }) + + // 해제는 토글이 아니라 bookmarked=false 를 명시해 보낸다(재전송이 상태를 뒤집지 않게). + it('빼기는 명시적으로 false 를 보낸다', async () => { + renderList([item]) + + await userEvent.click(screen.getByRole('button', { name: '오답노트에서 빼기' })) + + expect(removeMutate).toHaveBeenCalledWith({ messageId: 100, bookmarked: false }) + }) +}) diff --git a/frontend/src/features/interview/ui/BookmarkList.tsx b/frontend/src/features/interview/ui/BookmarkList.tsx new file mode 100644 index 00000000..ac4be2c0 --- /dev/null +++ b/frontend/src/features/interview/ui/BookmarkList.tsx @@ -0,0 +1,157 @@ +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { EmptyState, ListSkeleton, QueryError, StatusBadge } from '@/shared/ui' +import { categoryLabel } from '../lib/categoryLabel' +import { useBookmarks, useSetQuestionBookmark } from '../model/useBookmarks' +import type { BookmarkedQuestion } from '../api/bookmarkApi' + +export function BookmarkList() { + const { data = [], isPending, isError, refetch } = useBookmarks() + + if (isPending) { + return + } + if (isError) { + return ( + refetch()} /> + ) + } + if (data.length === 0) { + return ( + + ) + } + + return ( +
    + {data.map((item) => ( + + ))} +
+ ) +} + +function BookmarkCard({ item }: { item: BookmarkedQuestion }) { + // 모범 답안은 접어 둔다 — 먼저 스스로 떠올려 보고 펼치는 게 복습의 핵심이다. + const [open, setOpen] = useState(false) + const remove = useSetQuestionBookmark(item.sessionId ?? 0) + const label = categoryLabel(item.category) + const hasReview = Boolean(item.myAnswer || item.modelAnswer || item.coachingComment) + + return ( +
  • +
    +
    +
    + {label && {label}} + {item.sessionId != null && ( + + {item.sessionTitle || `면접 #${item.sessionId}`} + + )} +
    +

    + {item.question} +

    + {item.expectedSignal && ( +

    + 평가 관점: {item.expectedSignal} +

    + )} +
    + +
    + + {hasReview ? ( +
    + + {open && ( +
    + {item.myAnswer && ( + + )} + {item.modelAnswer && } + {item.coachingComment && ( + + )} +
    + )} +
    + ) : ( + // 답변 전에 담았거나 피드백이 아직 없으면 복습 재료가 없다 — 이유를 알려준다. +

    + 이 질문에는 아직 답변·피드백 기록이 없어요. +

    + )} +
  • + ) +} + +function Block({ + title, + body, + tone = 'strong', +}: { + title: string + body: string + tone?: 'strong' | 'muted' +}) { + return ( +
    +

    {title}

    +

    + {body} +

    +
    + ) +} + +function TrashIcon() { + return ( + + + + ) +} diff --git a/frontend/src/features/interview/ui/InterviewTranscript.tsx b/frontend/src/features/interview/ui/InterviewTranscript.tsx index 61b7cc89..17df00e6 100644 --- a/frontend/src/features/interview/ui/InterviewTranscript.tsx +++ b/frontend/src/features/interview/ui/InterviewTranscript.tsx @@ -40,7 +40,7 @@ export function InterviewTranscript({ sessionId }: { sessionId: number }) {
    {items.map((m) => isQuestion(m) ? ( - + ) : (
    diff --git a/frontend/src/features/interview/ui/live/QuestionBubble.test.tsx b/frontend/src/features/interview/ui/live/QuestionBubble.test.tsx new file mode 100644 index 00000000..4eab02d0 --- /dev/null +++ b/frontend/src/features/interview/ui/live/QuestionBubble.test.tsx @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QuestionBubble } from './QuestionBubble' +import type { Message } from '@/domain/session' + +const bookmarkMutate = vi.fn() +vi.mock('../../model/useBookmarks', () => ({ + useSetQuestionBookmark: () => ({ mutate: bookmarkMutate, isPending: false }), +})) +vi.mock('../../lib/media/useTtsPlayback', () => ({ + useTtsPlayback: () => ({ playing: false, toggle: vi.fn(), audioNode: null }), +})) + +const question: Message = { + id: 100, + sessionId: 7, + role: 'INTERVIEWER', + content: 'ACID 를 설명해 주세요.', + category: 'CS_FUNDAMENTAL', + sequenceNumber: 1, +} + +beforeEach(() => bookmarkMutate.mockClear()) + +describe('QuestionBubble', () => { + // 라이브 중에는 별을 숨긴다 — 답변에 집중해야 할 때 끼어드는 조작이다. + it('기본적으로는 오답노트 버튼을 노출하지 않는다', () => { + render() + + expect(screen.queryByRole('button', { name: /오답노트/ })).not.toBeInTheDocument() + }) + + it('담기를 누르면 bookmarked=true 를 보낸다', async () => { + render() + + const button = screen.getByRole('button', { name: '오답노트에 담기' }) + expect(button).toHaveAttribute('aria-pressed', 'false') + + await userEvent.click(button) + + expect(bookmarkMutate).toHaveBeenCalledWith({ messageId: 100, bookmarked: true }) + }) + + it('이미 담긴 질문은 빼기로 동작한다', async () => { + render() + + const button = screen.getByRole('button', { name: '오답노트에서 빼기' }) + expect(button).toHaveAttribute('aria-pressed', 'true') + + await userEvent.click(button) + + expect(bookmarkMutate).toHaveBeenCalledWith({ messageId: 100, bookmarked: false }) + }) +}) diff --git a/frontend/src/features/interview/ui/live/QuestionBubble.tsx b/frontend/src/features/interview/ui/live/QuestionBubble.tsx index b347f0b6..2ce7a995 100644 --- a/frontend/src/features/interview/ui/live/QuestionBubble.tsx +++ b/frontend/src/features/interview/ui/live/QuestionBubble.tsx @@ -4,6 +4,24 @@ import { categoryLabel } from '../../lib/categoryLabel' import { useTtsPlayback } from '../../lib/media/useTtsPlayback' import { FOLLOWUP_GENERATING_TEXT } from '../../model/streamingBuffer' import { useTypewriter } from '../../lib/useTypewriter' +import { useSetQuestionBookmark } from '../../model/useBookmarks' + +function StarIcon({ filled }: { filled: boolean }) { + return ( + + + + ) +} function PlayIcon({ playing }: { playing: boolean }) { return ( @@ -17,12 +35,16 @@ export function QuestionBubble({ message, autoPlay = false, streaming = false, + bookmarkable = false, }: { message: Message autoPlay?: boolean streaming?: boolean + /** 오답노트 표시 버튼 노출. 라이브 중엔 끄고(집중 방해) 종료 세션 기록에서만 켠다. */ + bookmarkable?: boolean }) { const label = categoryLabel(message.category) + const bookmark = useSetQuestionBookmark(message.sessionId ?? 0) const hasMeta = Boolean(label || message.targetEvidence) const ttsReady = message.ttsStatus === 'SUCCEEDED' const isSentinel = message.content === FOLLOWUP_GENERATING_TEXT @@ -38,12 +60,37 @@ export function QuestionBubble({ return (
    - {hasMeta && ( + {(hasMeta || bookmarkable) && (
    {label && {label}} {message.targetEvidence && ( 근거: {message.targetEvidence} )} + {bookmarkable && message.id != null && ( + + )}
    )}
    diff --git a/frontend/src/pages/Workspace/ui/BookmarksView.tsx b/frontend/src/pages/Workspace/ui/BookmarksView.tsx new file mode 100644 index 00000000..13b89e9b --- /dev/null +++ b/frontend/src/pages/Workspace/ui/BookmarksView.tsx @@ -0,0 +1,14 @@ +import { BookmarkList } from '@/features/interview' +import { WorkspaceSection } from '@/widgets/workspace-section' + +export function BookmarksView() { + return ( + + + + ) +} diff --git a/frontend/src/pages/Workspace/ui/WorkspacePage.tsx b/frontend/src/pages/Workspace/ui/WorkspacePage.tsx index 575b8827..aae3b2c4 100644 --- a/frontend/src/pages/Workspace/ui/WorkspacePage.tsx +++ b/frontend/src/pages/Workspace/ui/WorkspacePage.tsx @@ -8,11 +8,13 @@ import { ResumesView } from './ResumesView' import { ReposView } from './ReposView' import { CoverLettersView } from './CoverLettersView' import { HistoryView } from './HistoryView' +import { BookmarksView } from './BookmarksView' -type View = 'home' | 'resumes' | 'repos' | 'cover-letters' | 'history' +type View = 'home' | 'resumes' | 'repos' | 'cover-letters' | 'history' | 'bookmarks' function resolveView(pathname: string): View { if (pathname.startsWith('/workspace/history')) return 'history' + if (pathname.startsWith('/workspace/bookmarks')) return 'bookmarks' if (pathname.startsWith('/workspace/resumes')) return 'resumes' if (pathname.startsWith('/workspace/repos')) return 'repos' if (pathname.startsWith('/workspace/cover-letters')) return 'cover-letters' @@ -53,6 +55,11 @@ export default function WorkspacePage() { title: '면접 히스토리', description: '지난 면접 기록과 점수 추이를 확인하세요.', }, + bookmarks: { + eyebrow: '워크스페이스', + title: '오답노트', + description: '다시 볼 질문을 모아 복습하세요.', + }, }[view] return ( @@ -72,6 +79,7 @@ export default function WorkspacePage() { {view === 'repos' && } {view === 'cover-letters' && } {view === 'history' && } + {view === 'bookmarks' && }
    diff --git a/frontend/src/widgets/workspace-sidebar/ui/WorkspaceSidebar.tsx b/frontend/src/widgets/workspace-sidebar/ui/WorkspaceSidebar.tsx index 337ba68b..f65414d8 100644 --- a/frontend/src/widgets/workspace-sidebar/ui/WorkspaceSidebar.tsx +++ b/frontend/src/widgets/workspace-sidebar/ui/WorkspaceSidebar.tsx @@ -16,6 +16,7 @@ const navItems: NavItem[] = [ { to: '/workspace/repos', label: '레포지토리', icon: }, { to: '/workspace/cover-letters', label: '자소서', icon: }, { to: '/workspace/history', label: '히스토리', icon: }, + { to: '/workspace/bookmarks', label: '오답노트', icon: }, ] export function WorkspaceSidebar() { @@ -212,6 +213,23 @@ function RepoIcon() { ) } +function BookmarkIcon() { + return ( + + + + ) +} + function HistoryIcon() { return ( Date: Wed, 19 Aug 2026 11:21:08 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=EC=98=A4=EB=8B=B5=EB=85=B8?= =?UTF-8?q?=ED=8A=B8=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `docs/database.md` — interview_messages.bookmarked(V26) + 부분 인덱스 - `backend/CLAUDE.md` — 엔드포인트·설계 판단(질문 전용, 명시적 상태, N+1 회피, 컨트롤러 위치) - `frontend/src/features/CLAUDE.md` — interview 슬라이스 책임에 오답노트 추가와 그 이유 --- docs/database.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/database.md b/docs/database.md index ed161663..10c67c73 100644 --- a/docs/database.md +++ b/docs/database.md @@ -217,6 +217,8 @@ CREATE TABLE interview_messages ( -- tts_* (V7), category/target_evidence/expected_signal (V9), answer_* 평가 4종 (V10), -- clarification (V13), 그리고 질문별 복기 3종 (V19): -- model_answer TEXT, answer_rewrite TEXT, coaching_comment TEXT -- 답변(INTERVIEWEE)에만, 종료 세션 조회에서만 노출 +-- bookmarked BOOLEAN NOT NULL DEFAULT FALSE (V26) -- 오답노트. 질문(INTERVIEWER)에만 의미. +-- 부분 인덱스 idx_interview_messages_bookmarked ON (session_id) WHERE bookmarked = TRUE -- 10. message_voice_analyses CREATE TABLE message_voice_analyses (