From 7b963d1bca15ea0b83493dbf7913c69db132c486 Mon Sep 17 00:00:00 2001 From: jmj Date: Fri, 29 May 2026 11:29:49 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20Sprint=201=20=EC=9E=94=EC=97=AC=204?= =?UTF-8?q?=EA=B1=B4=20(US-02=20stats=20/=20US-03=20consent=20/=20US-04=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20/=20US-08=20repo=20sync)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## US-02 — GET /api/users/me/stats - 총/완료 세션 수 + 평균 점수(overall/technical/logic/communication) + 최근 10건 점수 추이 - session 슬라이스에 controller 위치 (user → session 의존 회피, URL 만 /api/users/me/stats) - 신규 사용자(데이터 없음)는 카운트 0 + 평균 null + recent 빈 배열 안전 반환 - InterviewSessionRepository: countByUser_Id 외 status별 count 추가 - SessionFeedbackRepository: 평균/최근 N건 JPQL 추가 ## US-03 — /api/users/me/consents - POST (제출, append-only), GET (이력 최신순), DELETE /{type} (최신 활성 row revoke) - UserConsent.agree() 정적 팩토리 + revoke() 도메인 메서드 - IP 는 X-Forwarded-For 우선 (Nginx 프록시 대응) - 동의 ConsentType: TOS|PRIVACY|MARKETING ## US-04 — DELETE /api/users/me - User.markDeleted() + UserDeletedEvent 발행 - auth 슬라이스 UserDeletionRevokeListener 가 받아 모든 refresh token revoke - user → auth 직접 의존 회피 (event 패턴, auth → user 단방향) - RefreshTokenRepository.revokeAllByUserId @Modifying 쿼리 - 이미 탈퇴한 사용자는 USER_ALREADY_DELETED (410) - GitHub access token 무효화는 사용자 본인이 GitHub Settings 에서 별도 수행 안내 ## US-08 — POST /api/repositories/{id}/sync - 등록된 레포의 GitHub 메타(name/full_name/url/default_branch) 재fetch → 갱신 - GithubRepository.updateMetadata() 도메인 메서드 + last_synced_at 갱신 - 분석 재트리거는 분리(/reanalyze 후속 PR) - 404/403/502 분기 기존 등록 API 와 동일 ## 빌드 - ArchUnit 도메인 순환 없음 (user ↔ auth 이벤트 분리 / session 슬라이스 위치) - UserServiceTest 갱신: deleteAccount 케이스 추가 - StackupApplicationTests: 신규 repository 8개 @MockitoBean 추가 --- .../UserDeletionRevokeListener.java | 28 ++++++ .../auth/domain/RefreshTokenRepository.java | 12 +++ .../application/GithubRepositoryService.java | 11 +++ .../github/domain/GithubRepository.java | 16 ++++ .../GithubRepositoryController.java | 20 +++++ .../session/application/UserStatsService.java | 58 ++++++++++++ .../application/dto/UserStatsResult.java | 28 ++++++ .../domain/InterviewSessionRepository.java | 7 ++ .../domain/SessionFeedbackRepository.java | 35 ++++++++ .../presentation/UserStatsController.java | 37 ++++++++ .../presentation/dto/UserStatsResponse.java | 48 ++++++++++ .../user/application/UserConsentService.java | 58 ++++++++++++ .../stackup/user/application/UserService.java | 20 ++++- .../user/application/dto/ConsentResult.java | 27 ++++++ .../application/dto/ConsentSubmitCommand.java | 10 +++ .../application/event/UserDeletedEvent.java | 8 ++ .../com/stackup/stackup/user/domain/User.java | 4 + .../user/domain/consent/UserConsent.java | 31 +++++++ .../domain/consent/UserConsentRepository.java | 8 +- .../presentation/UserConsentController.java | 89 +++++++++++++++++++ .../user/presentation/UserController.java | 20 +++++ .../presentation/dto/ConsentResponse.java | 27 ++++++ .../dto/ConsentSubmitRequest.java | 15 ++++ .../stackup/StackupApplicationTests.java | 24 +++++ .../user/application/UserServiceTest.java | 51 ++++++++++- 25 files changed, 687 insertions(+), 5 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/auth/application/UserDeletionRevokeListener.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/UserStatsService.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/dto/UserStatsResult.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/presentation/UserStatsController.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/presentation/dto/UserStatsResponse.java create mode 100644 backend/src/main/java/com/stackup/stackup/user/application/UserConsentService.java create mode 100644 backend/src/main/java/com/stackup/stackup/user/application/dto/ConsentResult.java create mode 100644 backend/src/main/java/com/stackup/stackup/user/application/dto/ConsentSubmitCommand.java create mode 100644 backend/src/main/java/com/stackup/stackup/user/application/event/UserDeletedEvent.java create mode 100644 backend/src/main/java/com/stackup/stackup/user/presentation/UserConsentController.java create mode 100644 backend/src/main/java/com/stackup/stackup/user/presentation/dto/ConsentResponse.java create mode 100644 backend/src/main/java/com/stackup/stackup/user/presentation/dto/ConsentSubmitRequest.java diff --git a/backend/src/main/java/com/stackup/stackup/auth/application/UserDeletionRevokeListener.java b/backend/src/main/java/com/stackup/stackup/auth/application/UserDeletionRevokeListener.java new file mode 100644 index 00000000..8a12bacd --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/application/UserDeletionRevokeListener.java @@ -0,0 +1,28 @@ +package com.stackup.stackup.auth.application; + +import com.stackup.stackup.auth.domain.RefreshTokenRepository; +import com.stackup.stackup.user.application.event.UserDeletedEvent; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +// user 도메인 UserDeletedEvent 수신 → 해당 사용자의 모든 refresh token revoke. +// user → auth 직접 의존을 피하기 위한 분리. auth → user 단방향만 유지. +@Component +@RequiredArgsConstructor +public class UserDeletionRevokeListener { + + private static final Logger log = LoggerFactory.getLogger(UserDeletionRevokeListener.class); + + private final RefreshTokenRepository refreshTokenRepository; + + @EventListener + @Transactional + public void on(UserDeletedEvent event) { + int revoked = refreshTokenRepository.revokeAllByUserId(event.userId()); + log.info("refresh tokens revoked on user delete. userId={}, revoked={}", event.userId(), revoked); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/domain/RefreshTokenRepository.java b/backend/src/main/java/com/stackup/stackup/auth/domain/RefreshTokenRepository.java index afae25aa..fb5f6c85 100644 --- a/backend/src/main/java/com/stackup/stackup/auth/domain/RefreshTokenRepository.java +++ b/backend/src/main/java/com/stackup/stackup/auth/domain/RefreshTokenRepository.java @@ -2,10 +2,22 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface RefreshTokenRepository extends JpaRepository { Optional findByTokenHash(String tokenHash); void deleteByUser_Id(Long userId); + + @Modifying(clearAutomatically = true) + @Query(""" + UPDATE RefreshToken rt + SET rt.revoked = true + WHERE rt.user.id = :userId + AND rt.revoked = false + """) + int revokeAllByUserId(@Param("userId") Long userId); } diff --git a/backend/src/main/java/com/stackup/stackup/github/application/GithubRepositoryService.java b/backend/src/main/java/com/stackup/stackup/github/application/GithubRepositoryService.java index 2ee2d624..599eaf79 100644 --- a/backend/src/main/java/com/stackup/stackup/github/application/GithubRepositoryService.java +++ b/backend/src/main/java/com/stackup/stackup/github/application/GithubRepositoryService.java @@ -83,6 +83,17 @@ public void delete(Long userId, Long repositoryId) { repositoryRepository.delete(repo); } + // US-08: GitHub 메타 재동기화. 등록된 레포의 default_branch / name / url 등을 최신화. + // 분석 재트리거는 별도 endpoint (/reanalyze) — 본 메서드는 메타만. + @Transactional + public GithubRepositoryResult sync(Long userId, Long repositoryId) { + GithubRepository repo = loadOwned(userId, repositoryId); + String accessToken = tokenService.fetchPlainAccessToken(userId); + GithubRepoResponse meta = githubApiClient.getRepository(accessToken, repo.getRepoFullName()); + repo.updateMetadata(meta.name(), meta.fullName(), meta.htmlUrl(), meta.defaultBranch()); + return GithubRepositoryResult.of(repo); + } + public List listCandidates(Long userId, int page, int perPage) { int actualPerPage = (perPage <= 0) ? CANDIDATE_DEFAULT_PER_PAGE : Math.min(perPage, CANDIDATE_MAX_PER_PAGE); int actualPage = Math.max(page, 1); diff --git a/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java b/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java index 3fdeb687..4a6b7f45 100644 --- a/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java +++ b/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java @@ -85,6 +85,22 @@ public static GithubRepository create(User user, Long githubRepoId, String repoN return new GithubRepository(user, githubRepoId, repoName, repoFullName, repoUrl, defaultBranch); } + public void updateMetadata(String repoName, String repoFullName, String repoUrl, String defaultBranch) { + if (repoName != null && !repoName.isBlank()) { + this.repoName = repoName; + } + if (repoFullName != null && !repoFullName.isBlank()) { + this.repoFullName = repoFullName; + } + if (repoUrl != null && !repoUrl.isBlank()) { + this.repoUrl = repoUrl; + } + if (defaultBranch != null && !defaultBranch.isBlank()) { + this.defaultBranch = defaultBranch; + } + this.lastSyncedAt = Instant.now(); + } + public void markAnalyzing() { this.status = RepositoryStatus.ANALYZING; } diff --git a/backend/src/main/java/com/stackup/stackup/github/presentation/GithubRepositoryController.java b/backend/src/main/java/com/stackup/stackup/github/presentation/GithubRepositoryController.java index cb52aabb..212188d1 100644 --- a/backend/src/main/java/com/stackup/stackup/github/presentation/GithubRepositoryController.java +++ b/backend/src/main/java/com/stackup/stackup/github/presentation/GithubRepositoryController.java @@ -101,6 +101,26 @@ public RegisteredRepositoryResponse get( return RegisteredRepositoryResponse.from(service.get(principal.userId(), repositoryId)); } + @Operation( + operationId = "syncRepository", + summary = "GitHub 메타 재동기화 (US-08)", + description = "GitHub API 로 default_branch/name/url 등을 다시 fetch 해 DB 갱신. last_synced_at 갱신. 분석은 재트리거하지 않음." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "메타 갱신 완료"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "등록된 레포 또는 GitHub 상 레포 없음"), + @ApiResponse(responseCode = "403", description = "비공개 레포 접근 불가"), + @ApiResponse(responseCode = "502", description = "GitHub API 호출 실패") + }) + @PostMapping("/{repositoryId}/sync") + public RegisteredRepositoryResponse sync( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long repositoryId + ) { + return RegisteredRepositoryResponse.from(service.sync(principal.userId(), repositoryId)); + } + @Operation(operationId = "deleteRepository", summary = "등록 레포 soft delete") @ApiResponses({ @ApiResponse(responseCode = "204", description = "삭제 완료"), diff --git a/backend/src/main/java/com/stackup/stackup/session/application/UserStatsService.java b/backend/src/main/java/com/stackup/stackup/session/application/UserStatsService.java new file mode 100644 index 00000000..27489ff4 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/UserStatsService.java @@ -0,0 +1,58 @@ +package com.stackup.stackup.session.application; + +import com.stackup.stackup.session.application.dto.UserStatsResult; +import com.stackup.stackup.session.application.dto.UserStatsResult.AverageScores; +import com.stackup.stackup.session.application.dto.UserStatsResult.RecentScore; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.SessionFeedback; +import com.stackup.stackup.session.domain.SessionFeedbackRepository; +import com.stackup.stackup.session.domain.SessionStatus; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +// US-02: 사용자별 면접 통계 / 점수 추이. +// 데이터 없을 때 (신규 사용자) 모든 score 가 null / 0 으로 안전 반환. +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserStatsService { + + private static final int RECENT_LIMIT = 10; + + private final InterviewSessionRepository sessionRepository; + private final SessionFeedbackRepository feedbackRepository; + + public UserStatsResult forUser(Long userId) { + long total = sessionRepository.countByUser_Id(userId); + long completed = sessionRepository.countByUser_IdAndStatus(userId, SessionStatus.COMPLETED); + + AverageScores avg = new AverageScores( + feedbackRepository.averageOverallScore(userId), + feedbackRepository.averageTechnicalAccuracy(userId), + feedbackRepository.averageLogicScore(userId), + feedbackRepository.averageCommunicationScore(userId) + ); + + List recent = feedbackRepository + .findRecentByOwner(userId, PageRequest.of(0, RECENT_LIMIT)) + .stream() + .map(this::toRecent) + .toList(); + + return new UserStatsResult(total, completed, avg, recent); + } + + private RecentScore toRecent(SessionFeedback f) { + return new RecentScore( + f.getSession().getId(), + f.getOverallScore(), + f.getTechnicalAccuracy(), + f.getLogicScore(), + f.getCommunicationScore(), + f.getSession().getEndedAt() + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/UserStatsResult.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/UserStatsResult.java new file mode 100644 index 00000000..a8687d4c --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/UserStatsResult.java @@ -0,0 +1,28 @@ +package com.stackup.stackup.session.application.dto; + +import java.util.List; + +public record UserStatsResult( + long totalSessionCount, + long completedSessionCount, + AverageScores averages, + List recent +) { + public record AverageScores( + Double overall, + Double technical, + Double logic, + Double communication + ) { + } + + public record RecentScore( + Long sessionId, + Double overall, + Double technical, + Double logic, + Double communication, + java.time.Instant endedAt + ) { + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java index b098f659..9a20bfec 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface InterviewSessionRepository extends JpaRepository { @@ -9,4 +10,10 @@ public interface InterviewSessionRepository extends JpaRepository findByUser_Id(Long userId); Optional findByIdAndUser_Id(Long id, Long userId); + + long countByUser_Id(Long userId); + + long countByUser_IdAndStatus(Long userId, SessionStatus status); + + List findByUser_IdAndStatusOrderByEndedAtDesc(Long userId, SessionStatus status, Pageable pageable); } diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/SessionFeedbackRepository.java b/backend/src/main/java/com/stackup/stackup/session/domain/SessionFeedbackRepository.java index b1f34d20..516b8289 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/SessionFeedbackRepository.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/SessionFeedbackRepository.java @@ -1,9 +1,44 @@ package com.stackup.stackup.session.domain; +import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface SessionFeedbackRepository extends JpaRepository { Optional findBySession_Id(Long sessionId); + + @Query(""" + SELECT f FROM SessionFeedback f + WHERE f.session.user.id = :userId + ORDER BY f.session.endedAt DESC NULLS LAST, f.id DESC + """) + List findRecentByOwner(@Param("userId") Long userId, Pageable pageable); + + @Query(""" + SELECT AVG(f.overallScore) FROM SessionFeedback f + WHERE f.session.user.id = :userId + """) + Double averageOverallScore(@Param("userId") Long userId); + + @Query(""" + SELECT AVG(f.technicalAccuracy) FROM SessionFeedback f + WHERE f.session.user.id = :userId + """) + Double averageTechnicalAccuracy(@Param("userId") Long userId); + + @Query(""" + SELECT AVG(f.logicScore) FROM SessionFeedback f + WHERE f.session.user.id = :userId + """) + Double averageLogicScore(@Param("userId") Long userId); + + @Query(""" + SELECT AVG(f.communicationScore) FROM SessionFeedback f + WHERE f.session.user.id = :userId + """) + Double averageCommunicationScore(@Param("userId") Long userId); } diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/UserStatsController.java b/backend/src/main/java/com/stackup/stackup/session/presentation/UserStatsController.java new file mode 100644 index 00000000..7c2dc295 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/UserStatsController.java @@ -0,0 +1,37 @@ +package com.stackup.stackup.session.presentation; + +import com.stackup.stackup.common.security.UserPrincipal; +import com.stackup.stackup.session.application.UserStatsService; +import com.stackup.stackup.session.presentation.dto.UserStatsResponse; +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 lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +// URL 은 /api/users/me/stats 이지만 데이터 출처가 session 도메인이라 controller 는 session 슬라이스에 둔다. +// (user → session 직접 의존 회피) +@Tag(name = "Users (Stats)", description = "사용자 면접 통계 — 총/완료 세션 수, 평균 점수, 최근 점수 추이.") +@RestController +@RequiredArgsConstructor +public class UserStatsController { + + private final UserStatsService statsService; + + @Operation( + operationId = "getCurrentUserStats", + summary = "내 면접 통계 (US-02)", + description = "신규 사용자(데이터 없음)는 카운트 0, 평균 null, recent 빈 배열." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "통계 반환"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + @GetMapping("/api/users/me/stats") + public UserStatsResponse getStats(@AuthenticationPrincipal UserPrincipal principal) { + return UserStatsResponse.from(statsService.forUser(principal.userId())); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/UserStatsResponse.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/UserStatsResponse.java new file mode 100644 index 00000000..78e53963 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/UserStatsResponse.java @@ -0,0 +1,48 @@ +package com.stackup.stackup.session.presentation.dto; + +import com.stackup.stackup.session.application.dto.UserStatsResult; +import java.time.Instant; +import java.util.List; + +public record UserStatsResponse( + long totalSessionCount, + long completedSessionCount, + AverageScores averages, + List recent +) { + public record AverageScores(Double overall, Double technical, Double logic, Double communication) { + } + + public record RecentScore( + Long sessionId, + Double overall, + Double technical, + Double logic, + Double communication, + Instant endedAt + ) { + } + + public static UserStatsResponse from(UserStatsResult r) { + return new UserStatsResponse( + r.totalSessionCount(), + r.completedSessionCount(), + new AverageScores( + r.averages().overall(), + r.averages().technical(), + r.averages().logic(), + r.averages().communication() + ), + r.recent().stream() + .map(s -> new RecentScore( + s.sessionId(), + s.overall(), + s.technical(), + s.logic(), + s.communication(), + s.endedAt() + )) + .toList() + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/user/application/UserConsentService.java b/backend/src/main/java/com/stackup/stackup/user/application/UserConsentService.java new file mode 100644 index 00000000..b1ff31df --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/user/application/UserConsentService.java @@ -0,0 +1,58 @@ +package com.stackup.stackup.user.application; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.user.application.dto.ConsentResult; +import com.stackup.stackup.user.application.dto.ConsentSubmitCommand; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import com.stackup.stackup.user.domain.consent.ConsentType; +import com.stackup.stackup.user.domain.consent.UserConsent; +import com.stackup.stackup.user.domain.consent.UserConsentRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +// 동의 이력은 append-only. 철회 시 가장 최근 활성 row 의 revoked_at 갱신. +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserConsentService { + + private final UserRepository userRepository; + private final UserConsentRepository consentRepository; + + @Transactional + public ConsentResult submit(Long userId, ConsentSubmitCommand command) { + User user = loadUser(userId); + UserConsent consent = consentRepository.save(UserConsent.agree( + user, + command.consentType(), + command.consentVersion(), + command.ipAddress() + )); + return ConsentResult.of(consent); + } + + public List history(Long userId) { + loadUser(userId); + return consentRepository.findByUser_IdOrderByIdDesc(userId).stream() + .map(ConsentResult::of) + .toList(); + } + + @Transactional + public void revoke(Long userId, ConsentType consentType) { + loadUser(userId); + UserConsent latest = consentRepository + .findFirstByUser_IdAndConsentTypeAndAgreedTrueAndRevokedAtIsNullOrderByIdDesc(userId, consentType) + .orElseThrow(() -> new DomainException(ApiErrorCode.VALIDATION_ERROR)); + latest.revoke(); + } + + private User loadUser(Long userId) { + return userRepository.findByIdAndDeletedFalse(userId) + .orElseThrow(() -> new DomainException(ApiErrorCode.USER_NOT_FOUND)); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/user/application/UserService.java b/backend/src/main/java/com/stackup/stackup/user/application/UserService.java index 78d80ba5..6da09a19 100644 --- a/backend/src/main/java/com/stackup/stackup/user/application/UserService.java +++ b/backend/src/main/java/com/stackup/stackup/user/application/UserService.java @@ -3,8 +3,10 @@ import com.stackup.stackup.common.exception.ApiErrorCode; import com.stackup.stackup.common.exception.DomainException; import com.stackup.stackup.user.application.dto.UserProfileResult; +import com.stackup.stackup.user.application.event.UserDeletedEvent; import com.stackup.stackup.user.domain.User; import com.stackup.stackup.user.domain.UserRepository; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -13,9 +15,11 @@ public class UserService { private final UserRepository userRepository; + private final ApplicationEventPublisher events; - public UserService(UserRepository userRepository) { + public UserService(UserRepository userRepository, ApplicationEventPublisher events) { this.userRepository = userRepository; + this.events = events; } @Transactional(readOnly = true) @@ -35,4 +39,18 @@ public UserProfileResult getCurrentUser(Long userId) { user.getAvatarUrl() ); } + + // 회원 탈퇴: User soft delete + UserDeletedEvent 발행. + // auth 슬라이스 listener 가 모든 refresh token 을 revoke 한다 (도메인 분리). + // GitHub 측 access_token 무효화는 사용자가 GitHub Settings 에서 별도 수행. + @Transactional + public void deleteAccount(Long userId) { + if (userId == null) { + throw new DomainException(ApiErrorCode.AUTH_INVALID_TOKEN); + } + User user = userRepository.findByIdAndDeletedFalse(userId) + .orElseThrow(() -> new DomainException(ApiErrorCode.USER_ALREADY_DELETED)); + user.markDeleted(); + events.publishEvent(new UserDeletedEvent(userId)); + } } diff --git a/backend/src/main/java/com/stackup/stackup/user/application/dto/ConsentResult.java b/backend/src/main/java/com/stackup/stackup/user/application/dto/ConsentResult.java new file mode 100644 index 00000000..61ba0974 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/user/application/dto/ConsentResult.java @@ -0,0 +1,27 @@ +package com.stackup.stackup.user.application.dto; + +import com.stackup.stackup.user.domain.consent.ConsentType; +import com.stackup.stackup.user.domain.consent.UserConsent; +import java.time.Instant; + +public record ConsentResult( + Long id, + ConsentType consentType, + String consentVersion, + boolean agreed, + Instant agreedAt, + Instant revokedAt, + Instant createdAt +) { + public static ConsentResult of(UserConsent consent) { + return new ConsentResult( + consent.getId(), + consent.getConsentType(), + consent.getConsentVersion(), + consent.isAgreed(), + consent.getAgreedAt(), + consent.getRevokedAt(), + consent.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/user/application/dto/ConsentSubmitCommand.java b/backend/src/main/java/com/stackup/stackup/user/application/dto/ConsentSubmitCommand.java new file mode 100644 index 00000000..60f74309 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/user/application/dto/ConsentSubmitCommand.java @@ -0,0 +1,10 @@ +package com.stackup.stackup.user.application.dto; + +import com.stackup.stackup.user.domain.consent.ConsentType; + +public record ConsentSubmitCommand( + ConsentType consentType, + String consentVersion, + String ipAddress +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/user/application/event/UserDeletedEvent.java b/backend/src/main/java/com/stackup/stackup/user/application/event/UserDeletedEvent.java new file mode 100644 index 00000000..df28c83b --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/user/application/event/UserDeletedEvent.java @@ -0,0 +1,8 @@ +package com.stackup.stackup.user.application.event; + +// 회원 탈퇴 시 발행. auth 슬라이스 listener 가 받아 refresh token revoke. +// user → auth 직접 의존을 피하기 위한 매개체. auth → user 단방향만 유지. +public record UserDeletedEvent( + Long userId +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/user/domain/User.java b/backend/src/main/java/com/stackup/stackup/user/domain/User.java index 6e59fa76..2f8ac47d 100644 --- a/backend/src/main/java/com/stackup/stackup/user/domain/User.java +++ b/backend/src/main/java/com/stackup/stackup/user/domain/User.java @@ -71,4 +71,8 @@ public void updateGithubProfile( this.avatarUrl = avatarUrl; this.encryptedGithubAccessToken = encryptedGithubAccessToken; } + + public void markDeleted() { + this.deleted = true; + } } diff --git a/backend/src/main/java/com/stackup/stackup/user/domain/consent/UserConsent.java b/backend/src/main/java/com/stackup/stackup/user/domain/consent/UserConsent.java index 19036541..e073a583 100644 --- a/backend/src/main/java/com/stackup/stackup/user/domain/consent/UserConsent.java +++ b/backend/src/main/java/com/stackup/stackup/user/domain/consent/UserConsent.java @@ -58,4 +58,35 @@ public class UserConsent extends BaseTimeEntity { @Column(name = "ip_address", length = 45) private String ipAddress; + + private UserConsent(User user, ConsentType consentType, String consentVersion, String ipAddress) { + this.user = user; + this.consentType = consentType; + this.consentVersion = consentVersion; + this.agreed = true; + this.agreedAt = Instant.now(); + this.ipAddress = ipAddress; + } + + public static UserConsent agree(User user, ConsentType consentType, String consentVersion, String ipAddress) { + if (user == null) { + throw new IllegalArgumentException("user must not be null"); + } + if (consentType == null) { + throw new IllegalArgumentException("consentType must not be null"); + } + if (consentVersion == null || consentVersion.isBlank()) { + throw new IllegalArgumentException("consentVersion must not be blank"); + } + return new UserConsent(user, consentType, consentVersion, ipAddress); + } + + public void revoke() { + this.agreed = false; + this.revokedAt = Instant.now(); + } + + public boolean isActive() { + return agreed && revokedAt == null; + } } diff --git a/backend/src/main/java/com/stackup/stackup/user/domain/consent/UserConsentRepository.java b/backend/src/main/java/com/stackup/stackup/user/domain/consent/UserConsentRepository.java index e3ea3908..a70f39ce 100644 --- a/backend/src/main/java/com/stackup/stackup/user/domain/consent/UserConsentRepository.java +++ b/backend/src/main/java/com/stackup/stackup/user/domain/consent/UserConsentRepository.java @@ -1,9 +1,15 @@ package com.stackup.stackup.user.domain.consent; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface UserConsentRepository extends JpaRepository { - List findByUser_Id(Long userId); + List findByUser_IdOrderByIdDesc(Long userId); + + Optional findFirstByUser_IdAndConsentTypeAndAgreedTrueAndRevokedAtIsNullOrderByIdDesc( + Long userId, + ConsentType consentType + ); } diff --git a/backend/src/main/java/com/stackup/stackup/user/presentation/UserConsentController.java b/backend/src/main/java/com/stackup/stackup/user/presentation/UserConsentController.java new file mode 100644 index 00000000..45300120 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/user/presentation/UserConsentController.java @@ -0,0 +1,89 @@ +package com.stackup.stackup.user.presentation; + +import com.stackup.stackup.common.security.UserPrincipal; +import com.stackup.stackup.user.application.UserConsentService; +import com.stackup.stackup.user.domain.consent.ConsentType; +import com.stackup.stackup.user.presentation.dto.ConsentResponse; +import com.stackup.stackup.user.presentation.dto.ConsentSubmitRequest; +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 jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "User Consents", description = "개인정보처리/이용약관/마케팅 동의 제출·이력·철회 (append-only audit).") +@RestController +@RequestMapping("/api/users/me/consents") +@RequiredArgsConstructor +public class UserConsentController { + + private final UserConsentService consentService; + + @Operation(operationId = "submitConsent", summary = "동의 제출 (새 row 생성)") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "동의 row 생성"), + @ApiResponse(responseCode = "400", description = "요청 검증 실패"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ConsentResponse submit( + @AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody ConsentSubmitRequest request, + HttpServletRequest http + ) { + return ConsentResponse.from( + consentService.submit(principal.userId(), request.toCommand(clientIp(http))) + ); + } + + @Operation(operationId = "listConsents", summary = "내 동의 이력 (최신순)") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "이력 목록"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + @GetMapping + public List history(@AuthenticationPrincipal UserPrincipal principal) { + return consentService.history(principal.userId()).stream() + .map(ConsentResponse::from) + .toList(); + } + + @Operation(operationId = "revokeConsent", summary = "동의 철회 (최신 활성 row 의 revoked_at 갱신)") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "철회 처리됨"), + @ApiResponse(responseCode = "400", description = "철회할 활성 동의 없음"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + @DeleteMapping("/{type}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void revoke( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable ConsentType type + ) { + consentService.revoke(principal.userId(), type); + } + + private String clientIp(HttpServletRequest http) { + // X-Forwarded-For (Nginx 등 프록시 뒤) 우선, 없으면 remote addr + String xff = http.getHeader("X-Forwarded-For"); + if (xff != null && !xff.isBlank()) { + int comma = xff.indexOf(','); + return (comma > 0 ? xff.substring(0, comma) : xff).trim(); + } + return http.getRemoteAddr(); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/user/presentation/UserController.java b/backend/src/main/java/com/stackup/stackup/user/presentation/UserController.java index ab50937d..f362ad02 100644 --- a/backend/src/main/java/com/stackup/stackup/user/presentation/UserController.java +++ b/backend/src/main/java/com/stackup/stackup/user/presentation/UserController.java @@ -8,10 +8,13 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.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.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @RestController @@ -39,4 +42,21 @@ public ResponseEntity getCurrentUser( result.avatarUrl() )); } + + @Operation( + operationId = "deleteCurrentUser", + summary = "회원 탈퇴 (soft delete + 모든 refresh token revoke)", + description = "User row 의 is_deleted=true. 모든 refresh_token revoke. GitHub access token 무효화는 사용자가 GitHub Settings 에서 별도 수행." + ) + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "탈퇴 처리됨"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "410", description = "이미 탈퇴한 사용자") + }) + @DeleteMapping("/me") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteCurrentUser(@AuthenticationPrincipal UserPrincipal principal) { + Long userId = principal == null ? null : principal.userId(); + userService.deleteAccount(userId); + } } diff --git a/backend/src/main/java/com/stackup/stackup/user/presentation/dto/ConsentResponse.java b/backend/src/main/java/com/stackup/stackup/user/presentation/dto/ConsentResponse.java new file mode 100644 index 00000000..7f2d87b8 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/user/presentation/dto/ConsentResponse.java @@ -0,0 +1,27 @@ +package com.stackup.stackup.user.presentation.dto; + +import com.stackup.stackup.user.application.dto.ConsentResult; +import com.stackup.stackup.user.domain.consent.ConsentType; +import java.time.Instant; + +public record ConsentResponse( + Long id, + ConsentType consentType, + String consentVersion, + boolean agreed, + Instant agreedAt, + Instant revokedAt, + Instant createdAt +) { + public static ConsentResponse from(ConsentResult result) { + return new ConsentResponse( + result.id(), + result.consentType(), + result.consentVersion(), + result.agreed(), + result.agreedAt(), + result.revokedAt(), + result.createdAt() + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/user/presentation/dto/ConsentSubmitRequest.java b/backend/src/main/java/com/stackup/stackup/user/presentation/dto/ConsentSubmitRequest.java new file mode 100644 index 00000000..10569c42 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/user/presentation/dto/ConsentSubmitRequest.java @@ -0,0 +1,15 @@ +package com.stackup.stackup.user.presentation.dto; + +import com.stackup.stackup.user.application.dto.ConsentSubmitCommand; +import com.stackup.stackup.user.domain.consent.ConsentType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record ConsentSubmitRequest( + @NotNull ConsentType consentType, + @NotBlank String consentVersion +) { + public ConsentSubmitCommand toCommand(String ipAddress) { + return new ConsentSubmitCommand(consentType, consentVersion, ipAddress); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java b/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java index bbe8606e..b3d69ffd 100644 --- a/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java +++ b/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java @@ -7,7 +7,13 @@ import com.stackup.stackup.document.domain.DocumentEmbeddingRepository; import com.stackup.stackup.github.domain.GithubRepositoryRepository; import com.stackup.stackup.resume.domain.ResumeRepository; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.MessageVoiceAnalysisRepository; +import com.stackup.stackup.session.domain.SessionContextRepository; +import com.stackup.stackup.session.domain.SessionFeedbackRepository; import com.stackup.stackup.user.domain.UserRepository; +import com.stackup.stackup.user.domain.consent.UserConsentRepository; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -39,6 +45,24 @@ class StackupApplicationTests { @MockitoBean private DocumentEmbeddingRepository documentEmbeddingRepository; + @MockitoBean + private UserConsentRepository userConsentRepository; + + @MockitoBean + private InterviewSessionRepository interviewSessionRepository; + + @MockitoBean + private InterviewMessageRepository interviewMessageRepository; + + @MockitoBean + private SessionContextRepository sessionContextRepository; + + @MockitoBean + private SessionFeedbackRepository sessionFeedbackRepository; + + @MockitoBean + private MessageVoiceAnalysisRepository messageVoiceAnalysisRepository; + @Test void contextLoads() { } diff --git a/backend/src/test/java/com/stackup/stackup/user/application/UserServiceTest.java b/backend/src/test/java/com/stackup/stackup/user/application/UserServiceTest.java index 3527604c..c778c74d 100644 --- a/backend/src/test/java/com/stackup/stackup/user/application/UserServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/user/application/UserServiceTest.java @@ -2,18 +2,24 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +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.user.application.dto.UserProfileResult; +import com.stackup.stackup.user.application.event.UserDeletedEvent; import com.stackup.stackup.user.domain.User; import com.stackup.stackup.user.domain.UserRepository; import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) @@ -22,6 +28,9 @@ class UserServiceTest { @Mock private UserRepository userRepository; + @Mock + private ApplicationEventPublisher events; + @Test void getCurrentUser_returnsAuthenticatedUserProfile() { User user = User.createGithubUser( @@ -33,7 +42,7 @@ void getCurrentUser_returnsAuthenticatedUserProfile() { ); ReflectionTestUtils.setField(user, "id", 1L); when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.of(user)); - UserService service = new UserService(userRepository); + UserService service = new UserService(userRepository, events); UserProfileResult result = service.getCurrentUser(1L); @@ -46,7 +55,7 @@ void getCurrentUser_returnsAuthenticatedUserProfile() { @Test void getCurrentUser_rejectsMissingPrincipal() { - UserService service = new UserService(userRepository); + UserService service = new UserService(userRepository, events); assertThatThrownBy(() -> service.getCurrentUser(null)) .isInstanceOf(DomainException.class) @@ -57,11 +66,47 @@ void getCurrentUser_rejectsMissingPrincipal() { @Test void getCurrentUser_rejectsMissingUser() { when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.empty()); - UserService service = new UserService(userRepository); + UserService service = new UserService(userRepository, events); assertThatThrownBy(() -> service.getCurrentUser(1L)) .isInstanceOf(DomainException.class) .extracting("errorCode") .isEqualTo(ApiErrorCode.USER_NOT_FOUND); } + + @Test + void deleteAccount_marksUserDeletedAndPublishesEvent() { + User user = User.createGithubUser(123L, "octocat", null, null, "tok"); + ReflectionTestUtils.setField(user, "id", 1L); + when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.of(user)); + UserService service = new UserService(userRepository, events); + + service.deleteAccount(1L); + + assertThat(user.isDeleted()).isTrue(); + ArgumentCaptor captor = ArgumentCaptor.forClass(UserDeletedEvent.class); + verify(events, times(1)).publishEvent(captor.capture()); + assertThat(captor.getValue().userId()).isEqualTo(1L); + } + + @Test + void deleteAccount_rejectsAlreadyDeletedUser() { + when(userRepository.findByIdAndDeletedFalse(2L)).thenReturn(Optional.empty()); + UserService service = new UserService(userRepository, events); + + assertThatThrownBy(() -> service.deleteAccount(2L)) + .isInstanceOf(DomainException.class) + .extracting("errorCode") + .isEqualTo(ApiErrorCode.USER_ALREADY_DELETED); + verify(events, never()).publishEvent(org.mockito.ArgumentMatchers.any()); + } + + @Test + void deleteAccount_rejectsMissingPrincipal() { + UserService service = new UserService(userRepository, events); + assertThatThrownBy(() -> service.deleteAccount(null)) + .isInstanceOf(DomainException.class) + .extracting("errorCode") + .isEqualTo(ApiErrorCode.AUTH_INVALID_TOKEN); + } } From 5176da06d95beba3365e70e0eb58b8cbac3e64c0 Mon Sep 17 00:00:00 2001 From: jmj Date: Fri, 29 May 2026 12:24:53 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat(session):=20US-13~17=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EB=8F=84=EB=A9=94=EC=9D=B8=20application/presentat?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InterviewSession 도메인 메서드: create/start/end/interrupt/cancel/incrementQuestionCount/updateTitleAndMemo - SessionContext 정적 팩토리 link() - SessionContextRepository: 단건→List 로 변경 (N:M) - InterviewSessionRepository: deleted=false 필터 + 페이지 + 통계 메서드 - SessionService: create(context 연결 포함)/list/get/start/end/interrupt/cancel/update/delete - SessionController: POST /api/sessions, GET (목록/단건), PATCH (메타/start/end/interrupt/cancel), DELETE - 상태 전환 위반은 SESSION_INVALID_STATE (422) - 분석 미완료 문서를 context 로 지정 시 DOC_NOT_ANALYZED (422) --- .../session/application/SessionService.java | 156 ++++++++++++++++ .../application/dto/SessionCreateCommand.java | 18 ++ .../application/dto/SessionResult.java | 47 +++++ .../session/domain/InterviewSession.java | 76 ++++++++ .../domain/InterviewSessionRepository.java | 4 + .../session/domain/SessionContext.java | 15 ++ .../domain/SessionContextRepository.java | 4 +- .../presentation/SessionController.java | 173 ++++++++++++++++++ .../dto/SessionCreateRequest.java | 35 ++++ .../presentation/dto/SessionResponse.java | 47 +++++ .../dto/SessionUpdateRequest.java | 9 + 11 files changed, 582 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/SessionService.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/dto/SessionCreateCommand.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/dto/SessionResult.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionResponse.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionUpdateRequest.java diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java new file mode 100644 index 00000000..5de2bff6 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java @@ -0,0 +1,156 @@ +package com.stackup.stackup.session.application; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.document.domain.AnalysisStatus; +import com.stackup.stackup.document.domain.AnalyzedDocument; +import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; +import com.stackup.stackup.session.application.dto.SessionCreateCommand; +import com.stackup.stackup.session.application.dto.SessionResult; +import com.stackup.stackup.session.domain.InterviewSession; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.SessionContext; +import com.stackup.stackup.session.domain.SessionContextRepository; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +// US-13~17: 면접 세션 생성/목록/단건/상태 전환/삭제. +// session_contexts 는 N:M (이력서/레포 분석 문서를 세션의 컨텍스트로 연결). +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SessionService { + + private final InterviewSessionRepository sessionRepository; + private final SessionContextRepository contextRepository; + private final AnalyzedDocumentRepository documentRepository; + private final UserRepository userRepository; + + @Transactional + public SessionResult create(Long userId, SessionCreateCommand command) { + User user = loadUser(userId); + InterviewSession session = sessionRepository.save(InterviewSession.create( + user, + command.title(), + command.memo(), + command.mode(), + command.interviewType(), + command.jobCategory(), + command.maxQuestions(), + command.maxDurationMinutes() + )); + + List linkedIds = linkContexts(session, userId, command.contextDocumentIds()); + return SessionResult.of(session, linkedIds); + } + + public List list(Long userId) { + loadUser(userId); + return sessionRepository.findByUser_IdAndDeletedFalseOrderByIdDesc(userId).stream() + .map(s -> SessionResult.of(s, contextDocumentIds(s.getId()))) + .toList(); + } + + public SessionResult get(Long userId, Long sessionId) { + InterviewSession session = loadOwned(userId, sessionId); + return SessionResult.of(session, contextDocumentIds(sessionId)); + } + + @Transactional + public SessionResult start(Long userId, Long sessionId) { + InterviewSession session = loadOwned(userId, sessionId); + try { + session.start(); + } catch (IllegalStateException e) { + throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE); + } + return SessionResult.of(session, contextDocumentIds(sessionId)); + } + + @Transactional + public SessionResult end(Long userId, Long sessionId) { + InterviewSession session = loadOwned(userId, sessionId); + try { + session.end(); + } catch (IllegalStateException e) { + throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE); + } + return SessionResult.of(session, contextDocumentIds(sessionId)); + } + + @Transactional + public SessionResult interrupt(Long userId, Long sessionId) { + InterviewSession session = loadOwned(userId, sessionId); + try { + session.interrupt(); + } catch (IllegalStateException e) { + throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE); + } + return SessionResult.of(session, contextDocumentIds(sessionId)); + } + + @Transactional + public SessionResult cancel(Long userId, Long sessionId) { + InterviewSession session = loadOwned(userId, sessionId); + try { + session.cancel(); + } catch (IllegalStateException e) { + throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE); + } + return SessionResult.of(session, contextDocumentIds(sessionId)); + } + + @Transactional + public void delete(Long userId, Long sessionId) { + InterviewSession session = loadOwned(userId, sessionId); + sessionRepository.delete(session); + } + + @Transactional + public SessionResult updateMeta(Long userId, Long sessionId, String title, String memo) { + InterviewSession session = loadOwned(userId, sessionId); + session.updateTitleAndMemo(title, memo); + return SessionResult.of(session, contextDocumentIds(sessionId)); + } + + private User loadUser(Long userId) { + return userRepository.findByIdAndDeletedFalse(userId) + .orElseThrow(() -> new DomainException(ApiErrorCode.USER_NOT_FOUND)); + } + + private InterviewSession loadOwned(Long userId, Long sessionId) { + return sessionRepository.findByIdAndUser_IdAndDeletedFalse(sessionId, userId) + .orElseThrow(() -> new DomainException(ApiErrorCode.SESSION_NOT_FOUND)); + } + + private List linkContexts(InterviewSession session, Long userId, List documentIds) { + if (documentIds == null || documentIds.isEmpty()) { + return List.of(); + } + // 중복 제거 + 순서 보존 + LinkedHashSet unique = new LinkedHashSet<>(documentIds); + List linked = new ArrayList<>(unique.size()); + for (Long documentId : unique) { + AnalyzedDocument doc = documentRepository.findActiveByIdAndOwner(documentId, userId) + .orElseThrow(() -> new DomainException(ApiErrorCode.DOC_NOT_FOUND)); + if (doc.getAnalysisStatus() != AnalysisStatus.ANALYZED) { + throw new DomainException(ApiErrorCode.DOC_NOT_ANALYZED); + } + contextRepository.save(SessionContext.link(session, doc)); + linked.add(documentId); + } + return linked; + } + + private List contextDocumentIds(Long sessionId) { + return contextRepository.findBySession_Id(sessionId).stream() + .map(c -> c.getDocument().getId()) + .toList(); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionCreateCommand.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionCreateCommand.java new file mode 100644 index 00000000..a009c58f --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionCreateCommand.java @@ -0,0 +1,18 @@ +package com.stackup.stackup.session.application.dto; + +import com.stackup.stackup.session.domain.InterviewType; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.SessionMode; +import java.util.List; + +public record SessionCreateCommand( + String title, + String memo, + SessionMode mode, + InterviewType interviewType, + JobCategory jobCategory, + Integer maxQuestions, + Integer maxDurationMinutes, + List contextDocumentIds +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionResult.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionResult.java new file mode 100644 index 00000000..52a2da0e --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionResult.java @@ -0,0 +1,47 @@ +package com.stackup.stackup.session.application.dto; + +import com.stackup.stackup.session.domain.InterviewSession; +import com.stackup.stackup.session.domain.InterviewType; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.SessionMode; +import com.stackup.stackup.session.domain.SessionStatus; +import java.time.Instant; +import java.util.List; + +public record SessionResult( + Long id, + String title, + String memo, + SessionMode mode, + InterviewType interviewType, + JobCategory jobCategory, + Integer maxQuestions, + Integer maxDurationMinutes, + SessionStatus status, + Integer totalQuestionCount, + Instant startedAt, + Instant endedAt, + List contextDocumentIds, + Instant createdAt, + Instant updatedAt +) { + public static SessionResult of(InterviewSession session, List documentIds) { + return new SessionResult( + session.getId(), + session.getTitle(), + session.getMemo(), + session.getMode(), + session.getInterviewType(), + session.getJobCategory(), + session.getMaxQuestions(), + session.getMaxDurationMinutes(), + session.getStatus(), + session.getTotalQuestionCount(), + session.getStartedAt(), + session.getEndedAt(), + documentIds, + session.getCreatedAt(), + session.getUpdatedAt() + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java index 6f2dfdac..30274701 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java @@ -76,4 +76,80 @@ public class InterviewSession extends BaseSoftDeleteEntity { @Column(name = "ended_at") private Instant endedAt; + + private InterviewSession(User user, String title, String memo, SessionMode mode, + InterviewType interviewType, JobCategory jobCategory, + Integer maxQuestions, Integer maxDurationMinutes) { + this.user = user; + this.title = title; + this.memo = memo; + this.mode = mode; + this.interviewType = interviewType; + this.jobCategory = jobCategory; + if (maxQuestions != null) { + this.maxQuestions = maxQuestions; + } + if (maxDurationMinutes != null) { + this.maxDurationMinutes = maxDurationMinutes; + } + } + + public static InterviewSession create(User user, String title, String memo, SessionMode mode, + InterviewType interviewType, JobCategory jobCategory, + Integer maxQuestions, Integer maxDurationMinutes) { + if (user == null) { + throw new IllegalArgumentException("user must not be null"); + } + if (mode == null || interviewType == null || jobCategory == null) { + throw new IllegalArgumentException("mode/interviewType/jobCategory must not be null"); + } + return new InterviewSession(user, title, memo, mode, interviewType, jobCategory, maxQuestions, maxDurationMinutes); + } + + public void start() { + if (status != SessionStatus.READY) { + throw new IllegalStateException("session is not READY to start (current=" + status + ")"); + } + this.status = SessionStatus.IN_PROGRESS; + this.startedAt = Instant.now(); + } + + public void end() { + if (status != SessionStatus.IN_PROGRESS) { + throw new IllegalStateException("session is not IN_PROGRESS to end (current=" + status + ")"); + } + this.status = SessionStatus.COMPLETED; + this.endedAt = Instant.now(); + } + + public void interrupt() { + if (status != SessionStatus.IN_PROGRESS) { + throw new IllegalStateException("session is not IN_PROGRESS to interrupt (current=" + status + ")"); + } + this.status = SessionStatus.INTERRUPTED; + this.endedAt = Instant.now(); + } + + public void cancel() { + if (status != SessionStatus.READY) { + throw new IllegalStateException("only READY session can be cancelled (current=" + status + ")"); + } + this.status = SessionStatus.CANCELLED; + } + + public void incrementQuestionCount() { + if (totalQuestionCount == null) { + totalQuestionCount = 0; + } + totalQuestionCount++; + } + + public void updateTitleAndMemo(String title, String memo) { + if (title != null && !title.isBlank()) { + this.title = title; + } + if (memo != null) { + this.memo = memo; + } + } } diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java index 9a20bfec..ac58a777 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java @@ -11,6 +11,10 @@ public interface InterviewSessionRepository extends JpaRepository findByIdAndUser_Id(Long id, Long userId); + Optional findByIdAndUser_IdAndDeletedFalse(Long id, Long userId); + + List findByUser_IdAndDeletedFalseOrderByIdDesc(Long userId); + long countByUser_Id(Long userId); long countByUser_IdAndStatus(Long userId, SessionStatus status); diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/SessionContext.java b/backend/src/main/java/com/stackup/stackup/session/domain/SessionContext.java index 09e759b9..480e6e60 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/SessionContext.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/SessionContext.java @@ -41,4 +41,19 @@ public class SessionContext extends BaseTimeEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "document_id", nullable = false) private AnalyzedDocument document; + + private SessionContext(InterviewSession session, AnalyzedDocument document) { + this.session = session; + this.document = document; + } + + public static SessionContext link(InterviewSession session, AnalyzedDocument document) { + if (session == null) { + throw new IllegalArgumentException("session must not be null"); + } + if (document == null) { + throw new IllegalArgumentException("document must not be null"); + } + return new SessionContext(session, document); + } } diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/SessionContextRepository.java b/backend/src/main/java/com/stackup/stackup/session/domain/SessionContextRepository.java index 6334bbec..8abcf2fe 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/SessionContextRepository.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/SessionContextRepository.java @@ -1,9 +1,9 @@ package com.stackup.stackup.session.domain; -import java.util.Optional; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; public interface SessionContextRepository extends JpaRepository { - Optional findBySession_Id(Long sessionId); + List findBySession_Id(Long sessionId); } diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java b/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java new file mode 100644 index 00000000..b2c7814a --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java @@ -0,0 +1,173 @@ +package com.stackup.stackup.session.presentation; + +import com.stackup.stackup.common.security.UserPrincipal; +import com.stackup.stackup.session.application.SessionService; +import com.stackup.stackup.session.presentation.dto.SessionCreateRequest; +import com.stackup.stackup.session.presentation.dto.SessionResponse; +import com.stackup.stackup.session.presentation.dto.SessionUpdateRequest; +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 jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Sessions", description = "면접 세션 생성·조회·상태 전환. 컨텍스트(이력서/레포 분석 문서) 연결 포함.") +@RestController +@RequestMapping("/api/sessions") +@RequiredArgsConstructor +public class SessionController { + + private final SessionService sessionService; + + @Operation( + operationId = "createSession", + summary = "세션 생성 (US-13/14)", + description = "mode/interviewType/jobCategory 필수. contextDocumentIds 로 분석 문서 N개 연결 (US-14). 분석 미완료 문서는 422." + ) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "세션 생성 완료"), + @ApiResponse(responseCode = "400", description = "요청 검증 실패"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "지정된 문서 없음"), + @ApiResponse(responseCode = "422", description = "분석 미완료 문서를 컨텍스트로 지정") + }) + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public SessionResponse create( + @AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody SessionCreateRequest request + ) { + return SessionResponse.from(sessionService.create(principal.userId(), request.toCommand())); + } + + @Operation(operationId = "listSessions", summary = "내 세션 히스토리 (US-15)") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "세션 목록"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + @GetMapping + public List list(@AuthenticationPrincipal UserPrincipal principal) { + return sessionService.list(principal.userId()).stream() + .map(SessionResponse::from) + .toList(); + } + + @Operation(operationId = "getSession", summary = "세션 상세 (US-16)") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "세션 상세"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "세션 없음") + }) + @GetMapping("/{sessionId}") + public SessionResponse get( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long sessionId + ) { + return SessionResponse.from(sessionService.get(principal.userId(), sessionId)); + } + + @Operation(operationId = "updateSessionMeta", summary = "세션 제목/메모 수정") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "갱신 완료"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "세션 없음") + }) + @PatchMapping("/{sessionId}") + public SessionResponse update( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long sessionId, + @Valid @RequestBody SessionUpdateRequest request + ) { + return SessionResponse.from(sessionService.updateMeta( + principal.userId(), sessionId, request.title(), request.memo() + )); + } + + @Operation(operationId = "startSession", summary = "세션 시작 (READY→IN_PROGRESS) (US-17)") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "시작됨"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "세션 없음"), + @ApiResponse(responseCode = "422", description = "READY 아님") + }) + @PatchMapping("/{sessionId}/start") + public SessionResponse start( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long sessionId + ) { + return SessionResponse.from(sessionService.start(principal.userId(), sessionId)); + } + + @Operation(operationId = "endSession", summary = "세션 종료 (IN_PROGRESS→COMPLETED) (US-17)") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "종료됨"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "세션 없음"), + @ApiResponse(responseCode = "422", description = "IN_PROGRESS 아님") + }) + @PatchMapping("/{sessionId}/end") + public SessionResponse end( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long sessionId + ) { + return SessionResponse.from(sessionService.end(principal.userId(), sessionId)); + } + + @Operation(operationId = "interruptSession", summary = "세션 중단 (IN_PROGRESS→INTERRUPTED) (US-17)") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "중단됨"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "세션 없음"), + @ApiResponse(responseCode = "422", description = "IN_PROGRESS 아님") + }) + @PatchMapping("/{sessionId}/interrupt") + public SessionResponse interrupt( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long sessionId + ) { + return SessionResponse.from(sessionService.interrupt(principal.userId(), sessionId)); + } + + @Operation(operationId = "cancelSession", summary = "세션 취소 (READY→CANCELLED)") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "취소됨"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "세션 없음"), + @ApiResponse(responseCode = "422", description = "READY 아님") + }) + @PatchMapping("/{sessionId}/cancel") + public SessionResponse cancel( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long sessionId + ) { + return SessionResponse.from(sessionService.cancel(principal.userId(), sessionId)); + } + + @Operation(operationId = "deleteSession", summary = "세션 soft delete") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "삭제됨"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "세션 없음") + }) + @DeleteMapping("/{sessionId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long sessionId + ) { + sessionService.delete(principal.userId(), sessionId); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java new file mode 100644 index 00000000..d50a705b --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java @@ -0,0 +1,35 @@ +package com.stackup.stackup.session.presentation.dto; + +import com.stackup.stackup.session.application.dto.SessionCreateCommand; +import com.stackup.stackup.session.domain.InterviewType; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.SessionMode; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; + +public record SessionCreateRequest( + @Size(max = 200) String title, + @Size(max = 4000) String memo, + @NotNull SessionMode mode, + @NotNull InterviewType interviewType, + @NotNull JobCategory jobCategory, + @Min(1) @Max(30) Integer maxQuestions, + @Min(5) @Max(180) Integer maxDurationMinutes, + List contextDocumentIds +) { + public SessionCreateCommand toCommand() { + return new SessionCreateCommand( + title, + memo, + mode, + interviewType, + jobCategory, + maxQuestions, + maxDurationMinutes, + contextDocumentIds + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionResponse.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionResponse.java new file mode 100644 index 00000000..a7907d3f --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionResponse.java @@ -0,0 +1,47 @@ +package com.stackup.stackup.session.presentation.dto; + +import com.stackup.stackup.session.application.dto.SessionResult; +import com.stackup.stackup.session.domain.InterviewType; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.SessionMode; +import com.stackup.stackup.session.domain.SessionStatus; +import java.time.Instant; +import java.util.List; + +public record SessionResponse( + Long id, + String title, + String memo, + SessionMode mode, + InterviewType interviewType, + JobCategory jobCategory, + Integer maxQuestions, + Integer maxDurationMinutes, + SessionStatus status, + Integer totalQuestionCount, + Instant startedAt, + Instant endedAt, + List contextDocumentIds, + Instant createdAt, + Instant updatedAt +) { + public static SessionResponse from(SessionResult r) { + return new SessionResponse( + r.id(), + r.title(), + r.memo(), + r.mode(), + r.interviewType(), + r.jobCategory(), + r.maxQuestions(), + r.maxDurationMinutes(), + r.status(), + r.totalQuestionCount(), + r.startedAt(), + r.endedAt(), + r.contextDocumentIds(), + r.createdAt(), + r.updatedAt() + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionUpdateRequest.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionUpdateRequest.java new file mode 100644 index 00000000..67cba02d --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionUpdateRequest.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.session.presentation.dto; + +import jakarta.validation.constraints.Size; + +public record SessionUpdateRequest( + @Size(max = 200) String title, + @Size(max = 4000) String memo +) { +} From cacdea0cec9c6b0411725bab1b59b6855ed84be0 Mon Sep 17 00:00:00 2001 From: jmj Date: Fri, 29 May 2026 13:43:25 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat(B2):=20AI=20generate.questions=20consu?= =?UTF-8?q?mer=20+=20Core=20=EB=B0=9C=ED=96=89=20=EC=9D=B8=ED=94=84?= =?UTF-8?q?=EB=9D=BC=20(US-18=201/2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPRINT2_PLAN 정합 — generate.questions 는 세션 CREATE commit 이후 자동 발행 (start 가 아니라 create, plan §A-2). ## AI 측 - ai/src/ai_server/model/messages/questions.py - DocumentContext / GenerateQuestionsRequest / GeneratedQuestion / QuestionPoolCallbackPayload - QuestionCategory: CS_FUNDAMENTAL | PROJECT_DEEP_DIVE | TECH_CHOICE | BEHAVIORAL - ai/src/ai_server/chain/prompts/question_generation.py - 직군·면접유형별 카테고리 분배 가이드 포함 - ai/src/ai_server/chain/question_generation_chain.py - GeneratedQuestionPool (Pydantic 출력 파서) + LlmQuestionGenerator + Pro 모델 - ai/src/ai_server/messaging/consumers/questions_consumer.py - parse → idempotency check → LLM 호출 → callback.questions(kind=POOL) publish - runner.py: questions consumer 등록, settings 에 ai_queue_questions/ai_callback_routing_questions - tests/test_questions_consumer.py — 4 케이스 (publish, idempotency skip, context build x2) — pass ## Core 측 - session.application.event.SessionCreatedEvent - SessionService.create() 가 commit 직전 publishEvent - SessionQuestionsRequester (application) - @TransactionalEventListener(AFTER_COMMIT) + Propagation.NOT_SUPPORTED - documentIds → AnalyzedDocument fetch → MinIO 에서 MD 본문 read (≤200KB cap) → envelope 구성 - generate.questions 발행 (envelope.context.sessionId 포함) - application/dto/GenerateQuestionsPayload — DocumentContext 포함 (RAG 정교화는 후속 — 현재는 doc summary + MD 전체를 컨텍스트로 주입) (US-24 피드백은 SPRINT2_PLAN §3 에 따라 Sprint 3 분리) --- SPRINT2_PLAN.md | 200 ++++++++++++++++++ .../chain/prompts/question_generation.py | 34 +++ .../chain/question_generation_chain.py | 74 +++++++ ai/src/ai_server/config/settings.py | 3 + .../messaging/consumers/questions_consumer.py | 108 ++++++++++ ai/src/ai_server/messaging/runner.py | 21 ++ ai/src/ai_server/model/messages/questions.py | 52 +++++ ai/tests/test_questions_consumer.py | 166 +++++++++++++++ .../SessionQuestionsRequester.java | 118 +++++++++++ .../session/application/SessionService.java | 13 ++ .../dto/GenerateQuestionsPayload.java | 22 ++ .../event/SessionCreatedEvent.java | 16 ++ 12 files changed, 827 insertions(+) create mode 100644 SPRINT2_PLAN.md create mode 100644 ai/src/ai_server/chain/prompts/question_generation.py create mode 100644 ai/src/ai_server/chain/question_generation_chain.py create mode 100644 ai/src/ai_server/messaging/consumers/questions_consumer.py create mode 100644 ai/src/ai_server/model/messages/questions.py create mode 100644 ai/tests/test_questions_consumer.py create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/SessionQuestionsRequester.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateQuestionsPayload.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/event/SessionCreatedEvent.java diff --git a/SPRINT2_PLAN.md b/SPRINT2_PLAN.md new file mode 100644 index 00000000..d97be526 --- /dev/null +++ b/SPRINT2_PLAN.md @@ -0,0 +1,200 @@ +# Sprint 2 — 텍스트 면접 E2E 작업 계획 + +> 범위: **US-13 ~ US-20**. 세션 생성 → 첫 질문 → 텍스트 답변 → 꼬리질문 → 세션 종료까지의 골든 패스 완성. 피드백 리포트(US-24)는 Sprint 3로 분리. + +작성일: 2026-05-29 / 기준 브랜치: `main` (Sprint 1 #30까지 머지 완료) + +--- + +## 1. Sprint 1 정리 + +- 열린 PR 없음. Sprint 1 마지막 PR(#30, Sprint1 백엔드 및 AI서버 API 연동)이 5/28에 머지됨. +- 머지 완료: auth(GitHub OAuth + JWT), resume 업로드/분석, GitHub repo 등록/분석, document 메타·요약·임베딩 upsert, SSE stream-token + 분석 상태 푸시, RabbitMQ DLX·DLQ + 재시도 정책(US-28), `.env` 자동 sync 워크플로. +- **Sprint 2 진입 준비 완료 상태.** + +--- + +## 2. 현재 면접 도메인 진척도 + +| 레이어 | 상태 | +|---|---| +| **DB** | 7개 테이블(`interview_sessions`, `interview_messages`, `session_contexts`, `message_voice_analyses`, `session_feedbacks` 등) + 모든 ENUM 이미 마이그레이션됨 | +| **Backend `session/`** | [`InterviewSession.java`](backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java), [`InterviewMessage.java`](backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java), Repository, Enum 까지만. `application/` · `presentation/` · `infrastructure/` 패키지 **부재** | +| **AI 서버** | `analyze.resume / .repository / .web` 컨슈머만 본 구현. `generate.questions` · `generate.followup` 큐는 정의만, **코드 없음** ([`ai/CLAUDE.md §16`](ai/CLAUDE.md)) | +| **Frontend** | [`features/interview/index.ts`](frontend/src/features/interview/index.ts), [`pages/Interview/index.ts`](frontend/src/pages/Interview/index.ts), [`features/feedback/index.ts`](frontend/src/features/feedback/index.ts), [`pages/History/index.ts`](frontend/src/pages/History/index.ts) 모두 1줄 빈 export 스캐폴딩 | +| **RealTime** | SSE + RabbitMQ `realtime.session.*` bridge 동작 중. Core 가 publish 만 하면 그대로 fan-out | +| **메시징 스펙** | [`docs/messaging.md §5.6~5.9`](docs/messaging.md), envelope · 큐 · DLQ 모두 확정 | + +데이터·흐름·SSE 이벤트 카탈로그 모두 사양화 완료 ([`docs/data-flow.md §3`](docs/data-flow.md), [`docs/event-stream.md §3.3~3.4`](docs/event-stream.md)) — **사양은 다 있고 코드만 채우면 됨.** + +--- + +## 3. 작업 분해 — 트랙별 + +### 트랙 A — Backend session 도메인 + +세션 생성부터 종료까지의 트랜잭션 · 메시지 발행 · SSE push 전부. + +#### A-1. Application / Presentation 계층 신설 +- `session/application/SessionService` + - `create(cmd)` — `interview_sessions` INSERT(`status=READY`) + `session_contexts` INSERT + - `start(id)` — `READY → IN_PROGRESS`, `started_at` 세팅 + - `submitAnswer(id, content)` — INTERVIEWEE 메시지 INSERT + `generate.followup` 발행 + - `end(id, reason)` — `IN_PROGRESS → COMPLETED`, `ended_at` + - `get(id)`, `list(userId, pageable)`, `getMessages(id)` +- `session/application/dto/` — `SessionCreateCommand`, `SessionResult`, `MessageSubmitCommand`, `MessageResult` +- `session/presentation/SessionController` + - `POST /api/sessions` + - `POST /api/sessions/{id}/start` + - `POST /api/sessions/{id}/messages` + - `POST /api/sessions/{id}/end` + - `GET /api/sessions/{id}` + - `GET /api/sessions` + - `GET /api/sessions/{id}/messages` + +#### A-2. RabbitMQ 발행 (`session/infrastructure/SessionEventPublisher`) +- 세션 생성 commit 후 `generate.questions` 발행 (`@TransactionalEventListener(AFTER_COMMIT)`, [`docs/messaging.md §3`](docs/messaging.md)) +- 답변 INSERT commit 후 `generate.followup` 발행 + +#### A-3. RabbitMQ 컨슈머 (`session/infrastructure/SessionCallbackHandler`) +`core.callback.questions` 구독, `payload.kind`로 분기. +- `POOL` — 질문 풀 저장 + 첫 질문 `interview_messages` INSERT + SSE `session.message` push + (옵션) `realtime.session.notify` 발행 +- `FOLLOWUP` — INTERVIEWER 메시지 INSERT(`parent_message_id` 매핑) + SSE push + `total_question_count >= max_questions` 시 자동 종료 트리거 + +#### A-4. 세션 종료 정책 +- 백엔드 컷: `total_question_count >= max_questions` 또는 timeout +- `interview_sessions.status=COMPLETED`, `ended_at`, SSE `session.state` push + +#### A-5. ArchUnit 룰 위반 점검 ([`backend/CLAUDE.md §16`](backend/CLAUDE.md)) + +#### A-6. 테스트 +- `SessionServiceTest` (Mockito) +- `SessionControllerTest` (`@WebMvcTest`) +- 컨슈머 통합 테스트 (Testcontainer RabbitMQ + PG) + +--- + +### 트랙 B — AI 서버 질문 / 꼬리질문 + +[`ai/CLAUDE.md §14`](ai/CLAUDE.md) 절차 따라. + +#### B-1. 메시지 모델 +- `model/messages/generate.py` — `GenerateQuestionsPayload`, `GenerateFollowupPayload`, `CallbackQuestionsPayload` + +#### B-2. 컨슈머 2종 +- `messaging/consumers/questions_consumer.py` +- `messaging/consumers/followup_consumer.py` +- 멱등(messageId) 패턴은 기존 컨슈머 동일 + +#### B-3. 질문 풀 생성 체인 (`chain/question_pool_chain.py`) +- 입력: `interviewType`, `jobCategory`, `documentIds`, `maxQuestions` +- RAG: Core 내부 API `POST /api/internal/embeddings/search` 로 컨텍스트 chunk fetch → 프롬프트 주입 +- LLM: **Pro 모델** (Gemini 3.1 Pro), Pydantic 출력 파서로 `[{ category, question }]` 강제 +- 프롬프트: `chain/prompts/question_pool.py` 신규 + +#### B-4. 꼬리질문 체인 (`chain/followup_chain.py`) +- 입력: 직전 질문 / 답변, sessionId +- **Flash 모델** + 답변 평가 스키마(`specificity`, `logic`, `structure`) +- SLA < 3 초 — 응답 토큰 cap, streaming 미사용 + +#### B-5. 임베딩 provider +- **운영/개발: [`GeminiEmbeddingProvider`](ai/src/ai_server/rag/embedder.py) 사용** (이미 구현되어 있음). 설정에서 default 로 스위치 +- **테스트: `MockEmbeddingProvider`** 유지 (단위/통합 테스트 결정론 확보) +- [`ai/CLAUDE.md §16`](ai/CLAUDE.md) 의 "MockEmbeddingProvider default" 기술은 본 스프린트에서 갱신 + +#### B-6. 콜백 발행 +- `callback.questions` (`kind=POOL` / `FOLLOWUP`) + +#### B-7. `ai_request_logs` +- Core API 또는 자체 publish 로 input/output 토큰 · latency 기록 + +#### B-8. 테스트 +- `FakeListLLM` mock 으로 chain 단위 +- Testcontainer 로 컨슈머 통합 + +--- + +### 트랙 C — Frontend Interview UX + +[`frontend/CLAUDE.md §5`](frontend/CLAUDE.md) 라우트 가이드 기준. + +#### C-1. 라우터 등록 +- `/sessions/new`, `/sessions/:id`, `/history`, `/history/:id` +- `RequireAuth` wrap + +#### C-2. `features/interview/` +- `api/interview.ts` — create / start / submitAnswer / end / get / list / getMessages +- `model/types.ts` — `Session`, `InterviewMessage`, ENUM 미러 ([`docs/glossary.md`](docs/glossary.md)) +- `model/useSession.ts`, `useSessionMessages.ts`, `useSubmitAnswer.ts` +- `ui/SessionConfigForm.tsx` — mode / type / jobCategory / maxQuestions / maxDuration + `analyzed_documents` 멀티셀렉트(컨텍스트 문서) +- `ui/MessageList.tsx`, `ui/AnswerInput.tsx`, `ui/SessionControls.tsx` (start, end, 진행률) +- `ui/QuestionPendingState.tsx` — AI 풀 생성 대기용 4-state pending + +#### C-3. SSE 연동 +- 기존 [`useEventStream.ts`](frontend/src/shared/hooks/useEventStream.ts) 에 `session.message` · `session.state` 핸들러 추가 +- `GET /api/stream/sessions/{sessionId}` — Sprint 1 의 stream-token API 재사용 + +#### C-4. `pages/Interview/` +- `NewSessionPage` (config form 만) +- `InterviewPage` (`:id`, AsyncBoundary 로 감싸고 진행 중 / 종료 분기) + +#### C-5. `pages/History/` +- 세션 리스트(상태 뱃지) · 세션 상세(메시지 시퀀스) + +#### C-6. 테스트 +- Vitest 로 hook +- Playwright 로 "세션 생성 → 답변 3회 → 종료" 골든 패스 + +--- + +### 트랙 D — RealTime 서버 + +현재 `q.realtime.session.notify` 컨슈머가 SSE 로 fan-out 됨. **Core 가 publish 만 시작하면 별도 작업 없음.** 다만 확인할 것: + +- Core 가 `q.realtime.session.notify` 로 publish vs 자기 인메모리 SSE 둘 중 어디로 보낼지 정책 통일 ([`docs/event-stream.md §6`](docs/event-stream.md)) — Phase 1 은 Core 인메모리로도 충분. RealTime 경유로 가면 멀티 인스턴스 대비 가능. **Sprint 2 에선 인메모리 + 별도 publish 둘 다 가능** — 결정 필요 +- session 권한 검증(`/api/stream/sessions/:id` 본인 세션 검사) 이 이미 들어가 있는지 확인 + +--- + +## 4. 결정 사항 / 미결정 사항 + +### 4.1 확정된 결정 (2026-05-29) + +| # | 항목 | 결정 | 근거 | +|---|---|---|---| +| 1 | **임베딩 provider** | 운영 / 개발은 [`GeminiEmbeddingProvider`](ai/src/ai_server/rag/embedder.py) 사용 (이미 구현됨), 테스트는 `MockEmbeddingProvider` 유지 | Mock 만 쓰면 RAG 검색이 noise, 신규 구현체 도입 불필요 | +| 2 | **첫 질문 push 방식** | Core 가 `callback.questions (POOL)` 수신 즉시 첫 질문을 `interview_messages` INSERT + SSE `session.message` push | round-trip · race 회피, SSE 인프라 재사용 | +| 3 | **SSE 전송 경로** | Phase 1 은 Core 인메모리 단일화 | RT2 (분석 상태) 와 경로 통일, 디버깅 비용 ↓. RealTime 서버는 멀티 인스턴스 / 음성 단계에서 본격 활용 | +| 4 | **답변 멱등키** | `POST /api/sessions/{id}/messages` 한정으로 `Idempotency-Key` 헤더 도입 (Frontend UUID 발급 → Core 가 `processed_messages` 테이블에 24h 캐시) | `(session_id, sequence_number) UNIQUE` 깨짐 방지, SSE 재연결 + 자동 재시도 시 중복 INSERT 차단 | + +### 4.2 미결정 사항 (Sprint 2 진입 전 픽스 필요) + +| # | 항목 | 책임 | 메모 | +|---|---|---|---| +| 5 | 세션 종료 후 피드백 (US-24) Sprint 3 로 미루는지 확정 | PO | 본 계획은 분리를 가정. Sprint 2 종료 시점에 `/sessions/:id/feedback` placeholder 만 두는 안 검토 | +| 6 | Flyway 추가 마이그레이션 필요 여부 | Core | 핵심 결정: **질문 풀 저장 위치** — `interview_sessions.question_pool JSONB` 컬럼 추가 vs 별도 테이블 vs Core 인메모리. 컬럼 추가 시 마이그레이션 1 개 필요 | + +--- + +## 5. 권장 진행 순서 + +1. **미결정 사항 5·6 회의로 픽스** (10 분) +2. **메시지 스키마 정합성 PR** — envelope 모델만 Core / AI 양쪽에 추가 → 트랙 A · B 의 unblock +3. **트랙 A · B · C 병렬 진행** (각각 PR 1~2 개) +4. **통합 시나리오 테스트** — 세션 생성 → 첫 질문 → 답변 → 꼬리질문 → 종료 골든 패스 E2E +5. **트랙 D 정책 통일** + 멀티 인스턴스 대비 (선택) + +Sprint 1 PR 들이 평균 1~2 개 도메인씩 끊어 머지된 패턴을 보면, Sprint 2 도 트랙별 PR 2~3 개씩(총 8~10 PR)으로 끊는 게 자연스러움. + +--- + +## 6. Definition of Done + +- [ ] `/sessions/new` 에서 컨텍스트 문서를 골라 세션 생성 가능 +- [ ] 세션 생성 후 첫 질문이 SSE 로 도착 +- [ ] 답변 제출 → < 3 초 내 꼬리질문 push +- [ ] `maxQuestions` 도달 시 자동 종료, `/history` 에 COMPLETED 표시 +- [ ] `/history/:id` 에서 전체 메시지 트리 조회 가능 +- [ ] DLQ 격리 정책 ([`docs/messaging.md §6`](docs/messaging.md)) 가 generate.* 큐에도 적용 확인 +- [ ] `ai_request_logs` 에 질문 풀 / 꼬리질문 호출 기록 +- [ ] 골든 패스 E2E 테스트 통과 diff --git a/ai/src/ai_server/chain/prompts/question_generation.py b/ai/src/ai_server/chain/prompts/question_generation.py new file mode 100644 index 00000000..e455403a --- /dev/null +++ b/ai/src/ai_server/chain/prompts/question_generation.py @@ -0,0 +1,34 @@ +# 질문 풀 생성 프롬프트 (US-18) +# 분석된 이력서/레포 컨텍스트 + 직군 + 면접 유형 → 질문 N개. + +SYSTEM_PROMPT = ( + "당신은 IT 직군 채용을 진행하는 시니어 면접관입니다. " + "지원자 컨텍스트(이력서, GitHub 레포 분석)와 면접 유형에 맞춰 면접 질문 풀을 " + "생성하세요.\n" + "- 사실에 근거한 질문만. 자료에 없는 내용은 추측하지 않습니다.\n" + "- 자료에 명시된 기술 스택과 프로젝트 경험을 적극 인용하세요.\n" + "- 카테고리는 다음 중에서 선택: CS_FUNDAMENTAL, PROJECT_DEEP_DIVE, TECH_CHOICE, " + "BEHAVIORAL.\n" + "- 한국어로 작성하되 기술 용어는 영문 원어를 그대로 둡니다.\n" + "- 응답은 반드시 지정된 JSON 스키마를 따릅니다." +) + +HUMAN_PROMPT = ( + "직군: {job_category}\n" + "면접 유형: {interview_type}\n" + "최대 질문 수: {max_questions}\n\n" + "지원자 컨텍스트 (이력서/레포 분석):\n" + "---\n" + "{context}\n" + "---\n\n" + "요구 사항:\n" + "1. 정확히 {max_questions}개의 질문을 생성합니다.\n" + "2. 각 질문에 적절한 category 를 부여합니다.\n" + "3. 직군({job_category})·유형({interview_type}) 에 맞는 비중으로 카테고리 분배:\n" + " - TECHNICAL: CS_FUNDAMENTAL + TECH_CHOICE + PROJECT_DEEP_DIVE 중심.\n" + " - PERSONALITY: BEHAVIORAL 중심.\n" + " - INTEGRATED: 모두 균형.\n" + "4. 지원자 컨텍스트에 명시된 기술/프로젝트를 구체적으로 언급하는 PROJECT_DEEP_DIVE / " + "TECH_CHOICE 질문을 최소 절반 이상 포함하세요.\n\n" + "{format_instructions}" +) diff --git a/ai/src/ai_server/chain/question_generation_chain.py b/ai/src/ai_server/chain/question_generation_chain.py new file mode 100644 index 00000000..2678459c --- /dev/null +++ b/ai/src/ai_server/chain/question_generation_chain.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from typing import Protocol + +from langchain_core.output_parsers import PydanticOutputParser +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.runnables import Runnable +from pydantic import BaseModel, Field + +from ai_server.chain.prompts.question_generation import HUMAN_PROMPT, SYSTEM_PROMPT +from ai_server.config.settings import Settings +from ai_server.model.messages.questions import GeneratedQuestion + + +class GeneratedQuestionPool(BaseModel): + questions: list[GeneratedQuestion] = Field(default_factory=list) + + +class QuestionGenerator(Protocol): + async def generate( + self, + *, + job_category: str, + interview_type: str, + max_questions: int, + context: str, + ) -> GeneratedQuestionPool: ... + + +class LlmQuestionGenerator: + def __init__(self, chain: Runnable) -> None: + self._chain = chain + + async def generate( + self, + *, + job_category: str, + interview_type: str, + max_questions: int, + context: str, + ) -> GeneratedQuestionPool: + result = await self._chain.ainvoke( + { + "job_category": job_category, + "interview_type": interview_type, + "max_questions": max_questions, + "context": context, + } + ) + if not isinstance(result, GeneratedQuestionPool): + raise TypeError( + f"chain returned {type(result).__name__}, expected GeneratedQuestionPool" + ) + return result + + +def build_question_generation_chain(settings: Settings) -> Runnable: + from langchain_openai import ChatOpenAI + + parser = PydanticOutputParser(pydantic_object=GeneratedQuestionPool) + prompt = ChatPromptTemplate.from_messages( + [ + ("system", SYSTEM_PROMPT), + ("human", HUMAN_PROMPT), + ] + ).partial(format_instructions=parser.get_format_instructions()) + + llm = ChatOpenAI( + model=settings.llm_pro_model, + temperature=settings.llm_pro_temperature, + api_key=settings.llm_api_key or None, + base_url=settings.llm_base_url, + ) + return prompt | llm | parser diff --git a/ai/src/ai_server/config/settings.py b/ai/src/ai_server/config/settings.py index 3d1a90f0..a086d7c5 100644 --- a/ai/src/ai_server/config/settings.py +++ b/ai/src/ai_server/config/settings.py @@ -22,9 +22,12 @@ class Settings(BaseSettings): ai_queue_resume: str = "ai.analyze.resume" ai_queue_repository: str = "ai.analyze.repository" ai_queue_web: str = "ai.analyze.web" + ai_queue_questions: str = "ai.generate.questions" + ai_queue_followup: str = "ai.generate.followup" ai_queue_prefetch: int = 10 ai_callback_exchange: str = "stackup.ai-to-core" ai_callback_routing_analysis: str = "callback.analysis" + ai_callback_routing_questions: str = "callback.questions" ai_publisher_name: str = "ai-server" ai_idempotency_lru_size: int = 1024 diff --git a/ai/src/ai_server/messaging/consumers/questions_consumer.py b/ai/src/ai_server/messaging/consumers/questions_consumer.py new file mode 100644 index 00000000..9b5fe451 --- /dev/null +++ b/ai/src/ai_server/messaging/consumers/questions_consumer.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import structlog +from aio_pika.abc import AbstractIncomingMessage + +from ai_server.chain.question_generation_chain import QuestionGenerator +from ai_server.messaging.idempotency import LruIdempotencyStore +from ai_server.messaging.publisher import CallbackPublisher +from ai_server.model.envelope import Envelope +from ai_server.model.messages.questions import ( + DocumentContext, + GenerateQuestionsRequest, + QuestionPoolCallbackPayload, +) + +log = structlog.get_logger(__name__) + + +class QuestionsConsumer: + def __init__( + self, + *, + generator: QuestionGenerator, + publisher: CallbackPublisher, + idempotency: LruIdempotencyStore, + callback_routing_key: str, + ) -> None: + self._generator = generator + self._publisher = publisher + self._idempotency = idempotency + self._callback_routing_key = callback_routing_key + + async def handle(self, message: AbstractIncomingMessage) -> None: + async with message.process(requeue=False): + try: + envelope = Envelope[GenerateQuestionsRequest].model_validate_json( + message.body + ) + except Exception as exc: + log.error( + "questions.parse.failed", + error=str(exc), + delivery_tag=message.delivery_tag, + ) + raise + + if self._idempotency.is_seen_then_mark(envelope.message_id): + log.info( + "questions.idempotent.skip", + message_id=envelope.message_id, + trace_id=envelope.trace_id, + ) + return + + req = envelope.payload + log.info( + "questions.generate.start", + message_id=envelope.message_id, + session_id=req.session_id, + doc_count=len(req.documents), + max_questions=req.max_questions, + trace_id=envelope.trace_id, + ) + + context_text = _build_context(req.documents) + pool = await self._generator.generate( + job_category=req.job_category, + interview_type=req.interview_type, + max_questions=req.max_questions, + context=context_text, + ) + + payload = QuestionPoolCallbackPayload( + session_id=req.session_id, + kind="POOL", + questions=pool.questions, + ) + + await self._publisher.publish( + routing_key=self._callback_routing_key, + message_type="callback.questions", + payload=payload, + trace_id=envelope.trace_id, + correlation_id=envelope.message_id, + context=envelope.context, + ) + log.info( + "questions.generate.done", + message_id=envelope.message_id, + session_id=req.session_id, + question_count=len(pool.questions), + trace_id=envelope.trace_id, + ) + + +def _build_context(documents: list[DocumentContext]) -> str: + parts: list[str] = [] + for d in documents: + block = [f"## 문서 #{d.document_id} ({d.source_type})"] + if d.summary: + block.append(f"요약: {d.summary}") + if d.tech_stack: + block.append("기술 스택: " + ", ".join(d.tech_stack)) + if d.markdown: + block.append("") + block.append(d.markdown) + parts.append("\n".join(block)) + return "\n\n".join(parts) if parts else "(no documents)" diff --git a/ai/src/ai_server/messaging/runner.py b/ai/src/ai_server/messaging/runner.py index 3b437b94..032325b0 100644 --- a/ai/src/ai_server/messaging/runner.py +++ b/ai/src/ai_server/messaging/runner.py @@ -13,11 +13,16 @@ LlmDocumentAnalyzer, build_document_analysis_chain, ) +from ai_server.chain.question_generation_chain import ( + LlmQuestionGenerator, + build_question_generation_chain, +) from ai_server.config.settings import Settings from ai_server.core.client import HttpCoreClient from ai_server.messaging.connection import RabbitConnection from ai_server.rag.chunker import MarkdownChunker from ai_server.rag.embedder import build_embedding_provider +from ai_server.messaging.consumers.questions_consumer import QuestionsConsumer from ai_server.messaging.consumers.repository_consumer import RepositoryConsumer from ai_server.messaging.consumers.resume_consumer import ResumeConsumer from ai_server.messaging.consumers.web_consumer import WebResumeConsumer @@ -128,6 +133,17 @@ def __init__(self, settings: Settings) -> None: callback_routing_key=settings.ai_callback_routing_analysis, ) + # 질문 풀 생성 (US-18) + question_generator = LlmQuestionGenerator( + build_question_generation_chain(settings) + ) + self._questions_consumer = QuestionsConsumer( + generator=question_generator, + publisher=self._publisher, + idempotency=self._idempotency, + callback_routing_key=settings.ai_callback_routing_questions, + ) + self._consumers: list[tuple[AbstractRobustQueue, str]] = [] async def start(self) -> None: @@ -151,6 +167,11 @@ async def start(self) -> None: queue_name=self._settings.ai_queue_web, handler=self._web_consumer.handle, ) + await self._start_consumer( + channel, + queue_name=self._settings.ai_queue_questions, + handler=self._questions_consumer.handle, + ) async def _start_consumer(self, channel, *, queue_name, handler) -> None: queue = await channel.declare_queue( diff --git a/ai/src/ai_server/model/messages/questions.py b/ai/src/ai_server/model/messages/questions.py new file mode 100644 index 00000000..718f9c5c --- /dev/null +++ b/ai/src/ai_server/model/messages/questions.py @@ -0,0 +1,52 @@ +from typing import Literal + +from pydantic import BaseModel + +from ai_server.model._config import camel_config + +InterviewType = Literal["PERSONALITY", "TECHNICAL", "LIVE_CODING", "INTEGRATED"] +JobCategory = Literal["FRONTEND", "BACKEND", "INFRA", "DBA"] +QuestionCategory = Literal[ + "CS_FUNDAMENTAL", + "PROJECT_DEEP_DIVE", + "TECH_CHOICE", + "BEHAVIORAL", +] +CallbackKind = Literal["POOL", "FOLLOWUP"] + + +class DocumentContext(BaseModel): + """Core 가 generate.questions envelope 에 담아 보내는 문서 컨텍스트.""" + model_config = camel_config() + + document_id: int + source_type: str # RESUME | REPOSITORY | WEB + summary: str | None = None + tech_stack: list[str] = [] + markdown: str | None = None + + +class GenerateQuestionsRequest(BaseModel): + model_config = camel_config() + + session_id: int + interview_type: InterviewType + job_category: JobCategory + documents: list[DocumentContext] = [] + max_questions: int = 10 + + +class GeneratedQuestion(BaseModel): + """LLM 응답 단위. category 는 Spring 측 enum 과 동기.""" + model_config = camel_config() + + category: QuestionCategory + question: str + + +class QuestionPoolCallbackPayload(BaseModel): + model_config = camel_config() + + session_id: int + kind: CallbackKind = "POOL" + questions: list[GeneratedQuestion] = [] diff --git a/ai/tests/test_questions_consumer.py b/ai/tests/test_questions_consumer.py new file mode 100644 index 00000000..64b2e2a1 --- /dev/null +++ b/ai/tests/test_questions_consumer.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from ai_server.chain.question_generation_chain import GeneratedQuestionPool +from ai_server.messaging.consumers.questions_consumer import ( + QuestionsConsumer, + _build_context, +) +from ai_server.messaging.idempotency import LruIdempotencyStore +from ai_server.model.messages.questions import ( + DocumentContext, + GeneratedQuestion, + QuestionPoolCallbackPayload, +) + + +class _StubMessage: + def __init__(self, body: bytes): + self.body = body + self.delivery_tag = 1 + + def process(self, requeue: bool = False): + return _NoopCtx() + + +class _NoopCtx: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + +def _envelope(payload: dict) -> bytes: + env = { + "messageId": "m-1", + "messageType": "generate.questions", + "version": "v1", + "traceId": "t-1", + "publishedAt": "2026-05-29T00:00:00Z", + "publisher": "core-server", + "payload": payload, + "context": {"userId": 42, "sessionId": 99}, + } + return json.dumps(env).encode() + + +@pytest.mark.asyncio +async def test_consumer_generates_questions_and_publishes_callback(): + generator = MagicMock() + generator.generate = AsyncMock( + return_value=GeneratedQuestionPool( + questions=[ + GeneratedQuestion(category="CS_FUNDAMENTAL", question="DB 트랜잭션 격리수준?"), + GeneratedQuestion(category="PROJECT_DEEP_DIVE", question="결제 outbox 어떻게 보장?"), + ] + ) + ) + publisher = MagicMock() + publisher.publish = AsyncMock() + + consumer = QuestionsConsumer( + generator=generator, + publisher=publisher, + idempotency=LruIdempotencyStore(max_size=10), + callback_routing_key="callback.questions", + ) + + body = _envelope( + { + "sessionId": 99, + "interviewType": "TECHNICAL", + "jobCategory": "BACKEND", + "documents": [ + { + "documentId": 1, + "sourceType": "RESUME", + "summary": "Java/Spring 백엔드 3년차.", + "techStack": ["Java", "Spring Boot"], + "markdown": "## 경력\n토스페이먼츠.", + } + ], + "maxQuestions": 5, + } + ) + await consumer.handle(_StubMessage(body)) + + generator.generate.assert_awaited_once() + call = generator.generate.await_args + assert call.kwargs["job_category"] == "BACKEND" + assert call.kwargs["interview_type"] == "TECHNICAL" + assert call.kwargs["max_questions"] == 5 + assert "Java" in call.kwargs["context"] + + publisher.publish.assert_awaited_once() + pub_call = publisher.publish.await_args + payload: QuestionPoolCallbackPayload = pub_call.kwargs["payload"] + assert payload.session_id == 99 + assert payload.kind == "POOL" + assert len(payload.questions) == 2 + assert pub_call.kwargs["routing_key"] == "callback.questions" + assert pub_call.kwargs["message_type"] == "callback.questions" + assert pub_call.kwargs["correlation_id"] == "m-1" + + +@pytest.mark.asyncio +async def test_consumer_skips_when_message_id_already_seen(): + generator = MagicMock() + generator.generate = AsyncMock() + publisher = MagicMock() + publisher.publish = AsyncMock() + idempotency = LruIdempotencyStore(max_size=10) + idempotency.is_seen_then_mark("m-1") # 이미 본 것 + + consumer = QuestionsConsumer( + generator=generator, + publisher=publisher, + idempotency=idempotency, + callback_routing_key="callback.questions", + ) + body = _envelope( + { + "sessionId": 99, + "interviewType": "TECHNICAL", + "jobCategory": "BACKEND", + "documents": [], + "maxQuestions": 3, + } + ) + await consumer.handle(_StubMessage(body)) + generator.generate.assert_not_awaited() + publisher.publish.assert_not_awaited() + + +def test_build_context_handles_empty_documents(): + assert _build_context([]) == "(no documents)" + + +def test_build_context_joins_doc_blocks(): + docs = [ + DocumentContext( + document_id=1, + source_type="RESUME", + summary="요약1", + tech_stack=["Java", "Spring"], + markdown="본문1", + ), + DocumentContext( + document_id=2, + source_type="REPOSITORY", + summary=None, + tech_stack=[], + markdown="readme", + ), + ] + text = _build_context(docs) + assert "문서 #1 (RESUME)" in text + assert "요약1" in text + assert "Java, Spring" in text + assert "본문1" in text + assert "문서 #2 (REPOSITORY)" in text + assert "readme" in text diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionQuestionsRequester.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionQuestionsRequester.java new file mode 100644 index 00000000..1f2bd69a --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionQuestionsRequester.java @@ -0,0 +1,118 @@ +package com.stackup.stackup.session.application; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.stackup.stackup.common.config.properties.RabbitMqProperties; +import com.stackup.stackup.common.messaging.MessageContext; +import com.stackup.stackup.common.messaging.RabbitMessagePublisher; +import com.stackup.stackup.common.storage.ObjectStorageClient; +import com.stackup.stackup.common.storage.StorageException; +import com.stackup.stackup.document.domain.AnalysisStatus; +import com.stackup.stackup.document.domain.AnalyzedDocument; +import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; +import com.stackup.stackup.session.application.dto.GenerateQuestionsPayload; +import com.stackup.stackup.session.application.dto.GenerateQuestionsPayload.DocumentContext; +import com.stackup.stackup.session.application.event.SessionCreatedEvent; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +// 세션 생성 commit 후 발화 → generate.questions envelope 발행. +// AI 가 documentIds 만으로 다시 fetch 하지 않도록, MD 본문까지 envelope 에 담아 보낸다 (RAG 정교화는 후속). +@Component +@RequiredArgsConstructor +public class SessionQuestionsRequester { + + private static final Logger log = LoggerFactory.getLogger(SessionQuestionsRequester.class); + private static final JsonMapper JSON = JsonMapper.builder().build(); + private static final TypeReference> TECH_STACK_TYPE = new TypeReference<>() {}; + private static final long MAX_MARKDOWN_BYTES = 200_000L; // envelope 비대화 방지 + + private final RabbitMessagePublisher publisher; + private final RabbitMqProperties properties; + private final AnalyzedDocumentRepository documentRepository; + private final ObjectStorageClient storage; + + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onSessionCreated(SessionCreatedEvent event) { + List documents = buildDocumentContexts(event.contextDocumentIds()); + GenerateQuestionsPayload payload = new GenerateQuestionsPayload( + event.sessionId(), + event.interviewType(), + event.jobCategory(), + documents, + event.maxQuestions() + ); + publisher.publishToAi( + properties.routingKeys().generateQuestions(), + payload, + new MessageContext(event.userId(), event.sessionId(), null, null) + ); + log.info("generate.questions published. sessionId={}, doc_count={}, max={}", + event.sessionId(), documents.size(), event.maxQuestions()); + } + + private List buildDocumentContexts(List documentIds) { + if (documentIds == null || documentIds.isEmpty()) { + return List.of(); + } + List result = new ArrayList<>(documentIds.size()); + for (Long id : documentIds) { + AnalyzedDocument doc = documentRepository.findById(id).orElse(null); + if (doc == null || doc.getAnalysisStatus() != AnalysisStatus.ANALYZED) { + continue; // 미완료 문서는 컨텍스트에서 skip (SessionService 가 이미 검증 — 방어) + } + result.add(new DocumentContext( + doc.getId(), + resolveSourceType(doc), + doc.getSummary(), + parseTechStack(doc.getTechStack()), + fetchMarkdown(doc.getDocumentPath()) + )); + } + return result; + } + + private String resolveSourceType(AnalyzedDocument doc) { + if (doc.getResume() != null) return "RESUME"; + if (doc.getRepository() != null) return "REPOSITORY"; + return "UNKNOWN"; + } + + private List parseTechStack(String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + try { + return JSON.readValue(json, TECH_STACK_TYPE); + } catch (JsonProcessingException e) { + log.warn("techStack parse failed during generate.questions publish", e); + return List.of(); + } + } + + private String fetchMarkdown(String documentPath) { + if (documentPath == null || documentPath.isBlank()) { + return null; + } + try (InputStream in = storage.get(documentPath)) { + byte[] bytes = in.readNBytes((int) MAX_MARKDOWN_BYTES); + return new String(bytes, StandardCharsets.UTF_8); + } catch (StorageException | IOException e) { + log.warn("markdown fetch failed for generate.questions. key={}", documentPath, e); + return null; + } + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java index 5de2bff6..ef893d21 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java @@ -7,6 +7,7 @@ import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; import com.stackup.stackup.session.application.dto.SessionCreateCommand; import com.stackup.stackup.session.application.dto.SessionResult; +import com.stackup.stackup.session.application.event.SessionCreatedEvent; import com.stackup.stackup.session.domain.InterviewSession; import com.stackup.stackup.session.domain.InterviewSessionRepository; import com.stackup.stackup.session.domain.SessionContext; @@ -17,6 +18,7 @@ import java.util.LinkedHashSet; import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -31,6 +33,7 @@ public class SessionService { private final SessionContextRepository contextRepository; private final AnalyzedDocumentRepository documentRepository; private final UserRepository userRepository; + private final ApplicationEventPublisher events; @Transactional public SessionResult create(Long userId, SessionCreateCommand command) { @@ -47,6 +50,16 @@ public SessionResult create(Long userId, SessionCreateCommand command) { )); List linkedIds = linkContexts(session, userId, command.contextDocumentIds()); + + // 세션 생성 commit 후 AI 질문 풀 생성 트리거 (US-18). 인프라스트럭처가 listener 에서 발행. + events.publishEvent(new SessionCreatedEvent( + userId, + session.getId(), + session.getInterviewType(), + session.getJobCategory(), + session.getMaxQuestions(), + linkedIds + )); return SessionResult.of(session, linkedIds); } diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateQuestionsPayload.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateQuestionsPayload.java new file mode 100644 index 00000000..e1335627 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateQuestionsPayload.java @@ -0,0 +1,22 @@ +package com.stackup.stackup.session.application.dto; + +import com.stackup.stackup.session.domain.InterviewType; +import com.stackup.stackup.session.domain.JobCategory; +import java.util.List; + +public record GenerateQuestionsPayload( + Long sessionId, + InterviewType interviewType, + JobCategory jobCategory, + List documents, + Integer maxQuestions +) { + public record DocumentContext( + Long documentId, + String sourceType, // RESUME | REPOSITORY | WEB + String summary, + List techStack, + String markdown + ) { + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/event/SessionCreatedEvent.java b/backend/src/main/java/com/stackup/stackup/session/application/event/SessionCreatedEvent.java new file mode 100644 index 00000000..49cc4519 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/event/SessionCreatedEvent.java @@ -0,0 +1,16 @@ +package com.stackup.stackup.session.application.event; + +import com.stackup.stackup.session.domain.InterviewType; +import com.stackup.stackup.session.domain.JobCategory; +import java.util.List; + +// 세션 생성 commit 후 발행. 인프라스트럭처가 받아 generate.questions 메시지를 RabbitMQ 에 publish. +public record SessionCreatedEvent( + Long userId, + Long sessionId, + InterviewType interviewType, + JobCategory jobCategory, + Integer maxQuestions, + List contextDocumentIds +) { +} From b03ede41c52b4420d9663356a5f24acd85a255f9 Mon Sep 17 00:00:00 2001 From: jmj Date: Fri, 29 May 2026 13:46:52 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat(B3):=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EC=8B=9C=ED=80=80=EC=8A=A4=20+=20callback.questions(POOL)=20?= =?UTF-8?q?=EC=88=98=EC=8B=A0=20+=20=EC=B2=AB=20=EC=A7=88=EB=AC=B8=20INSER?= =?UTF-8?q?T/SSE=20(US-20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 도메인 - InterviewMessage: interviewer/followup/interviewee 정적 팩토리 + markStatus - InterviewMessageRepository: findFirst...DESC, countBySession_Id ## callback.questions 처리 (Core) - QuestionsCallbackPayload (POOL/FOLLOWUP 양쪽 필드) - QuestionsCallbackEnvelope (구체 타입, JacksonJsonMessageConverter 호환) - QuestionsCallbackHandler @RabbitListener - QuestionsCallbackService - POOL: 첫 질문을 sequenceNumber=1 INSERT + totalQuestionCount++ + SSE session.message + user 채널에도 알림(SessionMessageNotice) — 프론트가 session 단위 사전 인지 없이 수신 가능 - FOLLOWUP: B5 진행 시 활성 (지금은 로깅만) - 멱등: processed_messages ## 답변 제출 + 꼬리질문 트리거 - InterviewMessageService.submitAnswer - 직전 메시지가 INTERVIEWER 인지 검증, IN_PROGRESS 검증 - INTERVIEWEE 메시지 INSERT - AnswerSubmittedEvent 발행 (B5 가 받아 generate.followup publish) - GET /api/sessions/{id}/messages, POST /api/sessions/{id}/messages --- .../application/InterviewMessageService.java | 64 +++++++++ .../application/QuestionsCallbackService.java | 122 ++++++++++++++++++ .../application/dto/MessageResult.java | 30 +++++ .../dto/QuestionsCallbackEnvelope.java | 17 +++ .../dto/QuestionsCallbackPayload.java | 35 +++++ .../event/AnswerSubmittedEvent.java | 10 ++ .../session/domain/InterviewMessage.java | 29 +++++ .../domain/InterviewMessageRepository.java | 5 + .../QuestionsCallbackHandler.java | 30 +++++ .../InterviewMessageController.java | 71 ++++++++++ .../presentation/dto/MessageResponse.java | 30 +++++ .../dto/MessageSubmitRequest.java | 9 ++ 12 files changed, 452 insertions(+) create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/dto/QuestionsCallbackEnvelope.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/dto/QuestionsCallbackPayload.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/event/AnswerSubmittedEvent.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/infrastructure/QuestionsCallbackHandler.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/presentation/InterviewMessageController.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageResponse.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageSubmitRequest.java diff --git a/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java b/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java new file mode 100644 index 00000000..7c1d2016 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java @@ -0,0 +1,64 @@ +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.MessageResult; +import com.stackup.stackup.session.application.event.AnswerSubmittedEvent; +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.SessionStatus; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +// 메시지 조회 + 사용자 답변 INSERT. +// 답변 commit 후 AnswerSubmittedEvent 발행 → 인프라가 generate.followup 발행. +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class InterviewMessageService { + + private final InterviewSessionRepository sessionRepository; + private final InterviewMessageRepository messageRepository; + private final ApplicationEventPublisher events; + + public List list(Long userId, Long sessionId) { + ownedSession(userId, sessionId); + return messageRepository.findBySession_IdOrderBySequenceNumberAsc(sessionId).stream() + .map(MessageResult::of) + .toList(); + } + + @Transactional + public MessageResult submitAnswer(Long userId, Long sessionId, String content) { + InterviewSession session = ownedSession(userId, sessionId); + if (session.getStatus() != SessionStatus.IN_PROGRESS) { + throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE); + } + InterviewMessage latest = messageRepository + .findFirstBySession_IdOrderBySequenceNumberDesc(sessionId) + .orElseThrow(() -> new DomainException(ApiErrorCode.SESSION_INVALID_STATE)); + if (latest.getRole() != com.stackup.stackup.session.domain.MessageRole.INTERVIEWER) { + // 직전 메시지가 질문이 아니면 답변 불가 + throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE); + } + int nextSeq = latest.getSequenceNumber() + 1; + InterviewMessage answer = messageRepository.save( + InterviewMessage.interviewee(session, nextSeq, content, latest) + ); + + events.publishEvent(new AnswerSubmittedEvent( + userId, sessionId, latest.getId(), answer.getId() + )); + return MessageResult.of(answer); + } + + private InterviewSession ownedSession(Long userId, Long sessionId) { + return sessionRepository.findByIdAndUser_IdAndDeletedFalse(sessionId, userId) + .orElseThrow(() -> new DomainException(ApiErrorCode.SESSION_NOT_FOUND)); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java b/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java new file mode 100644 index 00000000..9fdb928a --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java @@ -0,0 +1,122 @@ +package com.stackup.stackup.session.application; + +import com.stackup.stackup.common.messaging.domain.ProcessedMessage; +import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; +import com.stackup.stackup.common.sse.SseEventPublisher; +import com.stackup.stackup.common.sse.SseEventType; +import com.stackup.stackup.session.application.dto.QuestionsCallbackEnvelope; +import com.stackup.stackup.session.application.dto.QuestionsCallbackPayload; +import com.stackup.stackup.session.application.dto.QuestionsCallbackPayload.GeneratedQuestion; +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 java.util.List; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +// callback.questions 처리. +// POOL: 첫 질문을 interview_messages 에 INSERT + SSE session.message push. +// (전체 풀은 first cut 에선 메모리상 처리 — 후속 PR 에서 별도 테이블 또는 컬럼) +// FOLLOWUP: parent_message_id 매핑하여 INSERT + SSE push (B5 진행 시 활성) +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class QuestionsCallbackService { + + private static final Logger log = LoggerFactory.getLogger(QuestionsCallbackService.class); + private static final String CONSUMER_NAME = "core.callback.questions"; + + private final InterviewSessionRepository sessionRepository; + private final InterviewMessageRepository messageRepository; + private final ProcessedMessageRepository processedMessageRepository; + private final SseEventPublisher sseEventPublisher; + + @Transactional + public void apply(QuestionsCallbackEnvelope envelope) { + if (envelope == null || envelope.payload() == null) { + log.warn("callback.questions envelope or payload is null — skip"); + return; + } + QuestionsCallbackPayload payload = envelope.payload(); + if (isProcessed(envelope.messageId())) { + log.info("callback.questions duplicate, skip. messageId={}", envelope.messageId()); + return; + } + Long sessionId = payload.sessionId(); + if (sessionId == null) { + log.warn("callback.questions missing sessionId. messageId={}", envelope.messageId()); + markProcessed(envelope.messageId()); + return; + } + InterviewSession session = sessionRepository.findById(sessionId).orElse(null); + if (session == null || session.isDeleted()) { + log.warn("callback.questions session not found or deleted. id={}, messageId={}", + sessionId, envelope.messageId()); + markProcessed(envelope.messageId()); + return; + } + + if (payload.isPool()) { + applyPool(session, payload); + } else if (payload.isFollowup()) { + applyFollowup(session, payload); + } else { + log.warn("callback.questions unknown kind={}. messageId={}", payload.kind(), envelope.messageId()); + } + markProcessed(envelope.messageId()); + } + + private void applyPool(InterviewSession session, QuestionsCallbackPayload payload) { + List questions = payload.questions(); + if (questions == null || questions.isEmpty()) { + log.warn("callback.questions POOL with no questions. sessionId={}", session.getId()); + return; + } + GeneratedQuestion first = questions.get(0); + InterviewMessage message = messageRepository.save( + InterviewMessage.interviewer(session, 1, first.question()) + ); + session.incrementQuestionCount(); + sseEventPublisher.publishToSession(session.getId(), SseEventType.SESSION_MESSAGE, message.getId()); + // 사용자 user 채널에도 알림 — frontend 가 documentId/sessionId 사전 인지 없이도 받을 수 있게 + sseEventPublisher.publishToUser( + session.getUser().getId(), + SseEventType.SESSION_MESSAGE, + new SessionMessageNotice(session.getId(), message.getId(), "QUESTION_POOL_READY") + ); + log.info("callback.questions POOL processed. sessionId={}, total={}", + session.getId(), questions.size()); + } + + private void applyFollowup(InterviewSession session, QuestionsCallbackPayload payload) { + // B5 진행 시 활성화. 지금은 envelope 만 로깅 (메시지/답변 시퀀스가 들어와야 의미 있음) + log.info("callback.questions FOLLOWUP received. sessionId={}, parent={}, willStoreInB5", + session.getId(), payload.parentMessageId()); + } + + private boolean isProcessed(String messageId) { + if (messageId == null || messageId.isBlank()) { + return false; + } + return processedMessageRepository.existsById(messageId); + } + + private void markProcessed(String messageId) { + if (messageId == null || messageId.isBlank()) { + return; + } + try { + processedMessageRepository.save(ProcessedMessage.of(messageId, CONSUMER_NAME)); + } catch (DataIntegrityViolationException ignored) { + // race + } + } + + public record SessionMessageNotice(Long sessionId, Long messageId, String reason) { + } +} 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 new file mode 100644 index 00000000..2da1258f --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java @@ -0,0 +1,30 @@ +package com.stackup.stackup.session.application.dto; + +import com.stackup.stackup.session.domain.InterviewMessage; +import com.stackup.stackup.session.domain.MessageRole; +import com.stackup.stackup.session.domain.MessageStatus; +import java.time.Instant; + +public record MessageResult( + Long id, + Long sessionId, + Integer sequenceNumber, + MessageRole role, + String content, + Long parentMessageId, + MessageStatus status, + Instant createdAt +) { + public static MessageResult of(InterviewMessage m) { + return new MessageResult( + m.getId(), + m.getSession().getId(), + m.getSequenceNumber(), + m.getRole(), + m.getContent(), + m.getParentMessage() == null ? null : m.getParentMessage().getId(), + m.getStatus(), + m.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/QuestionsCallbackEnvelope.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/QuestionsCallbackEnvelope.java new file mode 100644 index 00000000..891b3aac --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/QuestionsCallbackEnvelope.java @@ -0,0 +1,17 @@ +package com.stackup.stackup.session.application.dto; + +import com.stackup.stackup.common.messaging.MessageContext; +import java.time.Instant; + +// MessageEnvelope 의 구체 타입 (JacksonJsonMessageConverter generic 추론용). +public record QuestionsCallbackEnvelope( + String messageId, + String messageType, + String version, + String traceId, + Instant publishedAt, + String publisher, + QuestionsCallbackPayload payload, + MessageContext context +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/QuestionsCallbackPayload.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/QuestionsCallbackPayload.java new file mode 100644 index 00000000..9041e286 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/QuestionsCallbackPayload.java @@ -0,0 +1,35 @@ +package com.stackup.stackup.session.application.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record QuestionsCallbackPayload( + Long sessionId, + String kind, // POOL | FOLLOWUP + List questions, // POOL 시 사용 + Long parentMessageId, // FOLLOWUP 시 사용 + String followupQuestion, // FOLLOWUP 시 사용 + AnswerEvaluation answerEvaluation // FOLLOWUP 시 옵션 +) { + public boolean isPool() { + return "POOL".equals(kind); + } + + public boolean isFollowup() { + return "FOLLOWUP".equals(kind); + } + + public record GeneratedQuestion( + String category, + String question + ) { + } + + public record AnswerEvaluation( + Double specificity, + Double logic, + String structure + ) { + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/event/AnswerSubmittedEvent.java b/backend/src/main/java/com/stackup/stackup/session/application/event/AnswerSubmittedEvent.java new file mode 100644 index 00000000..ecb63226 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/event/AnswerSubmittedEvent.java @@ -0,0 +1,10 @@ +package com.stackup.stackup.session.application.event; + +// 답변 INSERT commit 후 발행. 인프라스트럭처가 받아 generate.followup 발행. +public record AnswerSubmittedEvent( + Long userId, + Long sessionId, + Long parentQuestionMessageId, + Long answerMessageId +) { +} 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 c175add6..c7aca58b 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 @@ -61,4 +61,33 @@ public class InterviewMessage extends BaseTimeEntity { @Column(nullable = false, length = 20) @Enumerated(EnumType.STRING) private MessageStatus status = MessageStatus.CREATED; + + private InterviewMessage(InterviewSession session, Integer sequenceNumber, MessageRole role, + String content, InterviewMessage parentMessage) { + this.session = session; + this.sequenceNumber = sequenceNumber; + this.role = role; + this.content = content; + this.parentMessage = parentMessage; + } + + public static InterviewMessage interviewer(InterviewSession session, int seq, String content) { + return new InterviewMessage(session, seq, MessageRole.INTERVIEWER, content, null); + } + + public static InterviewMessage followup(InterviewSession session, int seq, String content, + InterviewMessage parent) { + return new InterviewMessage(session, seq, MessageRole.INTERVIEWER, content, parent); + } + + public static InterviewMessage interviewee(InterviewSession session, int seq, String content, + InterviewMessage parent) { + return new InterviewMessage(session, seq, MessageRole.INTERVIEWEE, content, parent); + } + + 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 075483db..37ba1589 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 @@ -1,9 +1,14 @@ package com.stackup.stackup.session.domain; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface InterviewMessageRepository extends JpaRepository { List findBySession_IdOrderBySequenceNumberAsc(Long sessionId); + + Optional findFirstBySession_IdOrderBySequenceNumberDesc(Long sessionId); + + long countBySession_Id(Long sessionId); } diff --git a/backend/src/main/java/com/stackup/stackup/session/infrastructure/QuestionsCallbackHandler.java b/backend/src/main/java/com/stackup/stackup/session/infrastructure/QuestionsCallbackHandler.java new file mode 100644 index 00000000..7d4cd6f7 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/infrastructure/QuestionsCallbackHandler.java @@ -0,0 +1,30 @@ +package com.stackup.stackup.session.infrastructure; + +import com.stackup.stackup.session.application.QuestionsCallbackService; +import com.stackup.stackup.session.application.dto.QuestionsCallbackEnvelope; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Component; + +// stackup.ai-to-core / callback.questions 큐 consumer. 도메인 갱신은 application 서비스에 위임. +@Component +@RequiredArgsConstructor +public class QuestionsCallbackHandler { + + private static final Logger log = LoggerFactory.getLogger(QuestionsCallbackHandler.class); + + private final QuestionsCallbackService callbackService; + + @RabbitListener(queues = "${app.messaging.rabbitmq.queues.names.core-callback-questions}") + public void handle(QuestionsCallbackEnvelope envelope) { + try { + callbackService.apply(envelope); + } catch (RuntimeException e) { + log.error("callback.questions processing failed. messageId={}", + envelope == null ? null : envelope.messageId(), e); + throw e; + } + } +} 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 new file mode 100644 index 00000000..84a34b72 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/InterviewMessageController.java @@ -0,0 +1,71 @@ +package com.stackup.stackup.session.presentation; + +import com.stackup.stackup.common.security.UserPrincipal; +import com.stackup.stackup.session.application.InterviewMessageService; +import com.stackup.stackup.session.presentation.dto.MessageResponse; +import com.stackup.stackup.session.presentation.dto.MessageSubmitRequest; +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 jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Session Messages", description = "면접 메시지 시퀀스 — 질문/답변 트리.") +@RestController +@RequestMapping("/api/sessions/{sessionId}/messages") +@RequiredArgsConstructor +public class InterviewMessageController { + + private final InterviewMessageService messageService; + + @Operation(operationId = "listSessionMessages", summary = "세션 메시지 시퀀스 (US-20)") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "메시지 목록"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "세션 없음") + }) + @GetMapping + public List list( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long sessionId + ) { + return messageService.list(principal.userId(), sessionId).stream() + .map(MessageResponse::from) + .toList(); + } + + @Operation( + operationId = "submitAnswer", + summary = "답변 제출 (US-19) + 꼬리질문 자동 트리거", + description = "직전 INTERVIEWER 메시지에 대한 답변을 INTERVIEWEE 로 INSERT. commit 후 generate.followup 발행." + ) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "답변 저장 + 꼬리질문 발행"), + @ApiResponse(responseCode = "400", description = "검증 실패"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "세션 없음"), + @ApiResponse(responseCode = "422", description = "세션이 IN_PROGRESS 아니거나 직전 메시지가 질문 아님") + }) + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public MessageResponse submit( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long sessionId, + @Valid @RequestBody MessageSubmitRequest request + ) { + return MessageResponse.from( + messageService.submitAnswer(principal.userId(), sessionId, request.content()) + ); + } +} 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 new file mode 100644 index 00000000..0dc1b4e2 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageResponse.java @@ -0,0 +1,30 @@ +package com.stackup.stackup.session.presentation.dto; + +import com.stackup.stackup.session.application.dto.MessageResult; +import com.stackup.stackup.session.domain.MessageRole; +import com.stackup.stackup.session.domain.MessageStatus; +import java.time.Instant; + +public record MessageResponse( + Long id, + Long sessionId, + Integer sequenceNumber, + MessageRole role, + String content, + Long parentMessageId, + MessageStatus status, + Instant createdAt +) { + public static MessageResponse from(MessageResult r) { + return new MessageResponse( + r.id(), + r.sessionId(), + r.sequenceNumber(), + r.role(), + r.content(), + r.parentMessageId(), + r.status(), + r.createdAt() + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageSubmitRequest.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageSubmitRequest.java new file mode 100644 index 00000000..e37832cc --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageSubmitRequest.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.session.presentation.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record MessageSubmitRequest( + @NotBlank @Size(max = 8000) String content +) { +} From 50a08705dc3c7c2859b7d13fbfe40ac7a2fd7dd2 Mon Sep 17 00:00:00 2001 From: jmj Date: Fri, 29 May 2026 14:01:45 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat(B5,B7):=20=EA=BC=AC=EB=A6=AC=EC=A7=88?= =?UTF-8?q?=EB=AC=B8=20=EC=83=9D=EC=84=B1=20+=20AI=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0/=EC=A7=80=EC=97=B0=20=EB=A1=9C=EA=B9=85=20(U?= =?UTF-8?q?S-19,=20US-30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B5 (US-19 꼬리질문): - AI: generate.followup consumer + LangChain chain (Flash 모델) - 답변 평가 (specificity 0~5, logic 0~5, structure: FULL_STAR|PARTIAL_STAR|NONE) - FollowupCallbackPayload 발행 (kind=FOLLOWUP, parent_message_id) - Core: AnswerSubmittedEvent → SessionFollowupRequester @TransactionalEventListener(AFTER_COMMIT) 로 generate.followup 발행 - callback.questions FOLLOWUP 처리 활성화 — INSERT followup 메시지 + SSE push - maxQuestions 도달 시 자동 세션 종료 (SESSION_STATE: MAX_QUESTIONS_REACHED) B7 (US-30 AI 호출 로깅): - AI: CoreAiLogCallback (LangChain AsyncCallbackHandler) — on_llm_start/end/error 에서 토큰/latency 추출, fire-and-forget POST /api/internal/ai-logs - 3개 chain builder (document_analysis, question_generation, followup_generation)에 core_client 주입 + 콜백 부착 - Core: InternalAiLogController + AiRequestLogService.record() — userId/sessionId 로딩 후 ai_request_logs INSERT (202 Accepted) - Settings: llm_flash_model/temperature/max_tokens 추가 테스트: 백엔드 contextLoads 통과 (@MockitoBean AiRequestLogRepository 추가), AI 129 테스트 통과 (followup consumer 포함) --- .../chain/document_analysis_chain.py | 13 ++- .../chain/followup_generation_chain.py | 87 ++++++++++++++ .../chain/prompts/followup_generation.py | 24 ++++ .../chain/question_generation_chain.py | 13 ++- ai/src/ai_server/config/settings.py | 5 + ai/src/ai_server/core/client.py | 49 ++++++++ .../messaging/consumers/followup_consumer.py | 91 +++++++++++++++ ai/src/ai_server/messaging/runner.py | 36 ++++-- ai/src/ai_server/model/messages/followup.py | 37 ++++++ ai/src/ai_server/observability/__init__.py | 0 .../observability/llm_logging_callback.py | 97 ++++++++++++++++ ai/tests/test_followup_consumer.py | 106 ++++++++++++++++++ .../ai/application/AiRequestLogService.java | 47 ++++++++ .../application/dto/AiRequestLogCommand.java | 14 +++ .../stackup/log/ai/domain/AiRequestLog.java | 21 ++++ .../presentation/InternalAiLogController.java | 61 ++++++++++ .../application/QuestionsCallbackService.java | 49 +++++++- .../application/SessionFollowupRequester.java | 59 ++++++++++ .../dto/GenerateFollowupPayload.java | 15 +++ .../stackup/StackupApplicationTests.java | 4 + 20 files changed, 815 insertions(+), 13 deletions(-) create mode 100644 ai/src/ai_server/chain/followup_generation_chain.py create mode 100644 ai/src/ai_server/chain/prompts/followup_generation.py create mode 100644 ai/src/ai_server/messaging/consumers/followup_consumer.py create mode 100644 ai/src/ai_server/model/messages/followup.py create mode 100644 ai/src/ai_server/observability/__init__.py create mode 100644 ai/src/ai_server/observability/llm_logging_callback.py create mode 100644 ai/tests/test_followup_consumer.py create mode 100644 backend/src/main/java/com/stackup/stackup/log/ai/application/AiRequestLogService.java create mode 100644 backend/src/main/java/com/stackup/stackup/log/ai/application/dto/AiRequestLogCommand.java create mode 100644 backend/src/main/java/com/stackup/stackup/log/ai/presentation/InternalAiLogController.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/SessionFollowupRequester.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFollowupPayload.java diff --git a/ai/src/ai_server/chain/document_analysis_chain.py b/ai/src/ai_server/chain/document_analysis_chain.py index b212e9c3..ac949df2 100644 --- a/ai/src/ai_server/chain/document_analysis_chain.py +++ b/ai/src/ai_server/chain/document_analysis_chain.py @@ -10,6 +10,8 @@ from ai_server.analyzer.sources.base import SourceType from ai_server.chain.prompts.document_analysis import HUMAN_PROMPT, SYSTEM_PROMPT from ai_server.config.settings import Settings +from ai_server.core.client import CoreClient +from ai_server.observability.llm_logging_callback import CoreAiLogCallback class DocumentAnalysisResult(BaseModel): @@ -42,7 +44,7 @@ async def analyze( # 프롬프트 -> LLM -> 파서 하나로 묶어서 처리함 -def build_document_analysis_chain(settings: Settings) -> Runnable: +def build_document_analysis_chain(settings: Settings, core_client: CoreClient | None = None) -> Runnable: from langchain_openai import ChatOpenAI parser = PydanticOutputParser(pydantic_object=DocumentAnalysisResult) @@ -53,10 +55,19 @@ def build_document_analysis_chain(settings: Settings) -> Runnable: ] ).partial(format_instructions=parser.get_format_instructions()) + callbacks = [] + if core_client is not None: + callbacks.append(CoreAiLogCallback( + core_client=core_client, + request_type="analyze.document", + default_model=settings.llm_pro_model, + )) + llm = ChatOpenAI( model=settings.llm_pro_model, temperature=settings.llm_pro_temperature, api_key=settings.llm_api_key or None, base_url=settings.llm_base_url, + callbacks=callbacks, ) return prompt | llm | parser diff --git a/ai/src/ai_server/chain/followup_generation_chain.py b/ai/src/ai_server/chain/followup_generation_chain.py new file mode 100644 index 00000000..2c26ce80 --- /dev/null +++ b/ai/src/ai_server/chain/followup_generation_chain.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from typing import Protocol + +from langchain_core.output_parsers import PydanticOutputParser +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.runnables import Runnable +from pydantic import BaseModel, Field + +from ai_server.chain.prompts.followup_generation import HUMAN_PROMPT, SYSTEM_PROMPT +from ai_server.config.settings import Settings +from ai_server.core.client import CoreClient +from ai_server.model.messages.followup import AnswerEvaluation +from ai_server.observability.llm_logging_callback import CoreAiLogCallback + + +class FollowupResult(BaseModel): + followup_question: str = Field(..., description="한국어 꼬리질문 1개") + answer_evaluation: AnswerEvaluation + + +class FollowupGenerator(Protocol): + async def generate( + self, + *, + job_category: str, + interview_type: str, + previous_question: str, + answer_text: str, + ) -> FollowupResult: ... + + +class LlmFollowupGenerator: + def __init__(self, chain: Runnable) -> None: + self._chain = chain + + async def generate( + self, + *, + job_category: str, + interview_type: str, + previous_question: str, + answer_text: str, + ) -> FollowupResult: + result = await self._chain.ainvoke( + { + "job_category": job_category, + "interview_type": interview_type, + "previous_question": previous_question, + "answer_text": answer_text, + } + ) + if not isinstance(result, FollowupResult): + raise TypeError( + f"chain returned {type(result).__name__}, expected FollowupResult" + ) + return result + + +def build_followup_generation_chain(settings: Settings, core_client: CoreClient | None = None) -> Runnable: + from langchain_openai import ChatOpenAI + + parser = PydanticOutputParser(pydantic_object=FollowupResult) + prompt = ChatPromptTemplate.from_messages( + [ + ("system", SYSTEM_PROMPT), + ("human", HUMAN_PROMPT), + ] + ).partial(format_instructions=parser.get_format_instructions()) + + callbacks = [] + if core_client is not None: + callbacks.append(CoreAiLogCallback( + core_client=core_client, + request_type="generate.followup", + default_model=settings.llm_flash_model, + )) + + llm = ChatOpenAI( + model=settings.llm_flash_model, + temperature=settings.llm_flash_temperature, + api_key=settings.llm_api_key or None, + base_url=settings.llm_base_url, + max_tokens=settings.llm_flash_max_tokens, + callbacks=callbacks, + ) + return prompt | llm | parser diff --git a/ai/src/ai_server/chain/prompts/followup_generation.py b/ai/src/ai_server/chain/prompts/followup_generation.py new file mode 100644 index 00000000..01049b24 --- /dev/null +++ b/ai/src/ai_server/chain/prompts/followup_generation.py @@ -0,0 +1,24 @@ +# 꼬리질문 생성 + 답변 평가 (US-19) +# Flash 모델 + 저지연 (< 3s). 사용자 답변의 specificity·logic·structure 채점 + 부족 부분 파고드는 꼬리질문 1개. + +SYSTEM_PROMPT = ( + "당신은 IT 직군 면접관입니다. 직전 질문에 대한 지원자의 답변을 평가하고, " + "부족한 부분(구체성·논리·구조)을 파고드는 꼬리질문 1개를 한국어로 만드세요.\n" + "- 평가 항목:\n" + " - specificity (0~5): 답변에 구체적 수치/사례/기술 선택 근거가 있는가.\n" + " - logic (0~5): 인과관계와 trade-off 가 명확한가.\n" + " - structure: STAR (Situation-Task-Action-Result) 구조 측면.\n" + " - FULL_STAR: 네 요소 모두 명확.\n" + " - PARTIAL_STAR: 일부 요소 누락.\n" + " - NONE: 구조 부재.\n" + "- 꼬리질문은 답변에서 가장 약한 축 (예: 구체성 낮음 → 수치/사례 요구) 을 겨냥합니다.\n" + "- 응답은 반드시 지정된 JSON 스키마를 따릅니다." +) + +HUMAN_PROMPT = ( + "직군: {job_category}\n" + "면접 유형: {interview_type}\n\n" + "직전 질문:\n{previous_question}\n\n" + "지원자 답변:\n{answer_text}\n\n" + "{format_instructions}" +) diff --git a/ai/src/ai_server/chain/question_generation_chain.py b/ai/src/ai_server/chain/question_generation_chain.py index 2678459c..0b6ecd5f 100644 --- a/ai/src/ai_server/chain/question_generation_chain.py +++ b/ai/src/ai_server/chain/question_generation_chain.py @@ -9,7 +9,9 @@ from ai_server.chain.prompts.question_generation import HUMAN_PROMPT, SYSTEM_PROMPT from ai_server.config.settings import Settings +from ai_server.core.client import CoreClient from ai_server.model.messages.questions import GeneratedQuestion +from ai_server.observability.llm_logging_callback import CoreAiLogCallback class GeneratedQuestionPool(BaseModel): @@ -54,7 +56,7 @@ async def generate( return result -def build_question_generation_chain(settings: Settings) -> Runnable: +def build_question_generation_chain(settings: Settings, core_client: CoreClient | None = None) -> Runnable: from langchain_openai import ChatOpenAI parser = PydanticOutputParser(pydantic_object=GeneratedQuestionPool) @@ -65,10 +67,19 @@ def build_question_generation_chain(settings: Settings) -> Runnable: ] ).partial(format_instructions=parser.get_format_instructions()) + callbacks = [] + if core_client is not None: + callbacks.append(CoreAiLogCallback( + core_client=core_client, + request_type="generate.questions", + default_model=settings.llm_pro_model, + )) + llm = ChatOpenAI( model=settings.llm_pro_model, temperature=settings.llm_pro_temperature, api_key=settings.llm_api_key or None, base_url=settings.llm_base_url, + callbacks=callbacks, ) return prompt | llm | parser diff --git a/ai/src/ai_server/config/settings.py b/ai/src/ai_server/config/settings.py index a086d7c5..b4a0fc2c 100644 --- a/ai/src/ai_server/config/settings.py +++ b/ai/src/ai_server/config/settings.py @@ -46,6 +46,11 @@ class Settings(BaseSettings): llm_pro_model: str = "gemini-3.1-pro-preview" llm_pro_temperature: float = 0.2 + # 꼬리질문용 Flash 모델 (저지연 < 3s) + llm_flash_model: str = "gemini-3.1-flash-lite-preview" + llm_flash_temperature: float = 0.4 + llm_flash_max_tokens: int = 512 + analyzed_resume_md_key_template: str = "analyzed/resume/{resume_id}/summary.md" analyzed_repository_md_key_template: str = ( "analyzed/repository/{repository_id}/summary.md" diff --git a/ai/src/ai_server/core/client.py b/ai/src/ai_server/core/client.py index bfc3028e..9242ecab 100644 --- a/ai/src/ai_server/core/client.py +++ b/ai/src/ai_server/core/client.py @@ -45,6 +45,20 @@ async def upsert_embeddings( chunks: list[EmbeddingChunkPayload], ) -> int: ... + async def record_ai_log( + self, + *, + request_type: str, + model_name: str | None, + input_tokens: int | None, + output_tokens: int | None, + latency_ms: int | None, + status: str, + user_id: int | None = None, + session_id: int | None = None, + error_message: str | None = None, + ) -> None: ... + class HttpCoreClient: def __init__( @@ -219,3 +233,38 @@ async def _do_upsert( if not isinstance(count, int): return len(body["chunks"]) return count + + async def record_ai_log( + self, + *, + request_type: str, + model_name: str | None, + input_tokens: int | None, + output_tokens: int | None, + latency_ms: int | None, + status: str, + user_id: int | None = None, + session_id: int | None = None, + error_message: str | None = None, + ) -> None: + """`ai_request_logs` 에 fire-and-forget INSERT. 실패해도 raise 하지 않음 — 관측용.""" + path = "/api/internal/ai-logs" + body = { + "userId": user_id, + "sessionId": session_id, + "requestType": request_type, + "modelName": model_name, + "inputTokens": input_tokens, + "outputTokens": output_tokens, + "latencyMs": latency_ms, + "status": status, + "errorMessage": error_message, + } + try: + if self._client is not None: + await self._client.post(path, json=body) + return + async with self._build_client() as client: + await client.post(path, json=body) + except Exception as exc: + log.warn("core.ai_log.failed", error=str(exc), request_type=request_type) diff --git a/ai/src/ai_server/messaging/consumers/followup_consumer.py b/ai/src/ai_server/messaging/consumers/followup_consumer.py new file mode 100644 index 00000000..fe72079d --- /dev/null +++ b/ai/src/ai_server/messaging/consumers/followup_consumer.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import structlog +from aio_pika.abc import AbstractIncomingMessage + +from ai_server.chain.followup_generation_chain import FollowupGenerator +from ai_server.messaging.idempotency import LruIdempotencyStore +from ai_server.messaging.publisher import CallbackPublisher +from ai_server.model.envelope import Envelope +from ai_server.model.messages.followup import ( + FollowupCallbackPayload, + GenerateFollowupRequest, +) + +log = structlog.get_logger(__name__) + + +class FollowupConsumer: + def __init__( + self, + *, + generator: FollowupGenerator, + publisher: CallbackPublisher, + idempotency: LruIdempotencyStore, + callback_routing_key: str, + ) -> None: + self._generator = generator + self._publisher = publisher + self._idempotency = idempotency + self._callback_routing_key = callback_routing_key + + async def handle(self, message: AbstractIncomingMessage) -> None: + async with message.process(requeue=False): + try: + envelope = Envelope[GenerateFollowupRequest].model_validate_json( + message.body + ) + except Exception as exc: + log.error( + "followup.parse.failed", + error=str(exc), + delivery_tag=message.delivery_tag, + ) + raise + + if self._idempotency.is_seen_then_mark(envelope.message_id): + log.info( + "followup.idempotent.skip", + message_id=envelope.message_id, + trace_id=envelope.trace_id, + ) + return + + req = envelope.payload + log.info( + "followup.generate.start", + message_id=envelope.message_id, + session_id=req.session_id, + parent=req.parent_message_id, + trace_id=envelope.trace_id, + ) + + result = await self._generator.generate( + job_category=req.job_category, + interview_type=req.interview_type, + previous_question=req.previous_question, + answer_text=req.answer_text, + ) + + payload = FollowupCallbackPayload( + session_id=req.session_id, + kind="FOLLOWUP", + parent_message_id=req.parent_message_id, + followup_question=result.followup_question, + answer_evaluation=result.answer_evaluation, + ) + + await self._publisher.publish( + routing_key=self._callback_routing_key, + message_type="callback.questions", + payload=payload, + trace_id=envelope.trace_id, + correlation_id=envelope.message_id, + context=envelope.context, + ) + log.info( + "followup.generate.done", + message_id=envelope.message_id, + session_id=req.session_id, + trace_id=envelope.trace_id, + ) diff --git a/ai/src/ai_server/messaging/runner.py b/ai/src/ai_server/messaging/runner.py index 032325b0..e18005e2 100644 --- a/ai/src/ai_server/messaging/runner.py +++ b/ai/src/ai_server/messaging/runner.py @@ -13,6 +13,10 @@ LlmDocumentAnalyzer, build_document_analysis_chain, ) +from ai_server.chain.followup_generation_chain import ( + LlmFollowupGenerator, + build_followup_generation_chain, +) from ai_server.chain.question_generation_chain import ( LlmQuestionGenerator, build_question_generation_chain, @@ -22,6 +26,7 @@ from ai_server.messaging.connection import RabbitConnection from ai_server.rag.chunker import MarkdownChunker from ai_server.rag.embedder import build_embedding_provider +from ai_server.messaging.consumers.followup_consumer import FollowupConsumer from ai_server.messaging.consumers.questions_consumer import QuestionsConsumer from ai_server.messaging.consumers.repository_consumer import RepositoryConsumer from ai_server.messaging.consumers.resume_consumer import ResumeConsumer @@ -52,7 +57,12 @@ def __init__(self, settings: Settings) -> None: ) storage = build_storage(settings) - chain = build_document_analysis_chain(settings) + core_client = HttpCoreClient( + base_url=settings.core_internal_base_url, + api_key=settings.core_internal_api_key, + timeout_sec=settings.core_internal_timeout_sec, + ) + chain = build_document_analysis_chain(settings, core_client=core_client) chain_analyzer = LlmDocumentAnalyzer(chain) chunker = MarkdownChunker( @@ -66,12 +76,6 @@ def __init__(self, settings: Settings) -> None: gemini_api_key=settings.gemini_api_key, ) - core_client = HttpCoreClient( - base_url=settings.core_internal_base_url, - api_key=settings.core_internal_api_key, - timeout_sec=settings.core_internal_timeout_sec, - ) - # 이력서 PDF resume_analyzer = ResumeAnalyzer( extractor=PdfSourceExtractor(storage=storage), @@ -135,7 +139,7 @@ def __init__(self, settings: Settings) -> None: # 질문 풀 생성 (US-18) question_generator = LlmQuestionGenerator( - build_question_generation_chain(settings) + build_question_generation_chain(settings, core_client=core_client) ) self._questions_consumer = QuestionsConsumer( generator=question_generator, @@ -144,6 +148,17 @@ def __init__(self, settings: Settings) -> None: callback_routing_key=settings.ai_callback_routing_questions, ) + # 꼬리질문 생성 (US-19) + followup_generator = LlmFollowupGenerator( + build_followup_generation_chain(settings, core_client=core_client) + ) + self._followup_consumer = FollowupConsumer( + generator=followup_generator, + publisher=self._publisher, + idempotency=self._idempotency, + callback_routing_key=settings.ai_callback_routing_questions, + ) + self._consumers: list[tuple[AbstractRobustQueue, str]] = [] async def start(self) -> None: @@ -172,6 +187,11 @@ async def start(self) -> None: queue_name=self._settings.ai_queue_questions, handler=self._questions_consumer.handle, ) + await self._start_consumer( + channel, + queue_name=self._settings.ai_queue_followup, + handler=self._followup_consumer.handle, + ) async def _start_consumer(self, channel, *, queue_name, handler) -> None: queue = await channel.declare_queue( diff --git a/ai/src/ai_server/model/messages/followup.py b/ai/src/ai_server/model/messages/followup.py new file mode 100644 index 00000000..7ce9111f --- /dev/null +++ b/ai/src/ai_server/model/messages/followup.py @@ -0,0 +1,37 @@ +from typing import Literal + +from pydantic import BaseModel + +from ai_server.model._config import camel_config + + +class GenerateFollowupRequest(BaseModel): + """Core 가 답변 commit 후 발행.""" + model_config = camel_config() + + session_id: int + parent_message_id: int # 직전 질문 메시지 ID + answer_message_id: int # 답변 메시지 ID + previous_question: str + answer_text: str + interview_type: Literal["PERSONALITY", "TECHNICAL", "LIVE_CODING", "INTEGRATED"] + job_category: Literal["FRONTEND", "BACKEND", "INFRA", "DBA"] + + +class AnswerEvaluation(BaseModel): + """답변 평가 (US-19). LLM 이 specificity/logic/structure 채움.""" + model_config = camel_config() + + specificity: float # 0~5 + logic: float # 0~5 + structure: Literal["FULL_STAR", "PARTIAL_STAR", "NONE"] + + +class FollowupCallbackPayload(BaseModel): + model_config = camel_config() + + session_id: int + kind: Literal["FOLLOWUP"] = "FOLLOWUP" + parent_message_id: int + followup_question: str + answer_evaluation: AnswerEvaluation | None = None diff --git a/ai/src/ai_server/observability/__init__.py b/ai/src/ai_server/observability/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ai/src/ai_server/observability/llm_logging_callback.py b/ai/src/ai_server/observability/llm_logging_callback.py new file mode 100644 index 00000000..7ccefdbf --- /dev/null +++ b/ai/src/ai_server/observability/llm_logging_callback.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import asyncio +import time +from typing import Any +from uuid import UUID + +import structlog +from langchain_core.callbacks import AsyncCallbackHandler +from langchain_core.outputs import LLMResult + +from ai_server.core.client import CoreClient + +log = structlog.get_logger(__name__) + + +class CoreAiLogCallback(AsyncCallbackHandler): + """LangChain LLM 호출별로 input/output 토큰 + latency 를 측정해 Core 에 fire-and-forget POST. + + request_type 은 chain 별로 미리 정해 (예: 'analyze.document', 'generate.questions', + 'generate.followup'). 핸들러는 LangChain async 콜백을 받아 start/end timestamp 와 응답 + metadata 에서 토큰 수를 추출한다. + """ + + def __init__(self, *, core_client: CoreClient, request_type: str, default_model: str) -> None: + self._core = core_client + self._request_type = request_type + self._default_model = default_model + self._start_at: dict[UUID, float] = {} + + async def on_llm_start(self, serialized, prompts, *, run_id: UUID, **kwargs: Any) -> None: + self._start_at[run_id] = time.perf_counter() + + async def on_llm_end(self, response: LLMResult, *, run_id: UUID, **kwargs: Any) -> None: + latency_ms = self._latency_ms(run_id) + in_tok, out_tok, model = _extract_usage(response, self._default_model) + await self._fire( + request_type=self._request_type, + model_name=model, + input_tokens=in_tok, + output_tokens=out_tok, + latency_ms=latency_ms, + status="SUCCESS", + error_message=None, + ) + + async def on_llm_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None: + latency_ms = self._latency_ms(run_id) + await self._fire( + request_type=self._request_type, + model_name=self._default_model, + input_tokens=None, + output_tokens=None, + latency_ms=latency_ms, + status="FAILED", + error_message=str(error)[:1000], + ) + + def _latency_ms(self, run_id: UUID) -> int | None: + started = self._start_at.pop(run_id, None) + if started is None: + return None + return int((time.perf_counter() - started) * 1000) + + async def _fire(self, **kwargs: Any) -> None: + # fire-and-forget: 본 핸들러 실패가 LLM 응답 흐름을 막지 않도록. + async def _do() -> None: + try: + await self._core.record_ai_log(**kwargs) + except Exception as exc: + log.warn("core.ai_log.background_failed", error=str(exc), **kwargs) + asyncio.create_task(_do()) + + +def _extract_usage(response: LLMResult, default_model: str) -> tuple[int | None, int | None, str]: + in_tok = out_tok = None + model = default_model + try: + llm_output = response.llm_output or {} + if isinstance(llm_output, dict): + model = llm_output.get("model_name") or llm_output.get("model") or model + usage = llm_output.get("token_usage") or llm_output.get("usage") or {} + if isinstance(usage, dict): + in_tok = usage.get("prompt_tokens") or usage.get("input_tokens") + out_tok = usage.get("completion_tokens") or usage.get("output_tokens") + + if (in_tok is None or out_tok is None) and response.generations: + for gen_list in response.generations: + for gen in gen_list: + info = getattr(gen, "generation_info", None) or {} + usage = info.get("usage_metadata") or info.get("token_usage") or {} + if isinstance(usage, dict): + in_tok = in_tok or usage.get("input_tokens") or usage.get("prompt_tokens") + out_tok = out_tok or usage.get("output_tokens") or usage.get("completion_tokens") + except Exception as exc: + log.debug("ai_log.usage_extract_failed", error=str(exc)) + return in_tok, out_tok, model diff --git a/ai/tests/test_followup_consumer.py b/ai/tests/test_followup_consumer.py new file mode 100644 index 00000000..b5e69494 --- /dev/null +++ b/ai/tests/test_followup_consumer.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from ai_server.chain.followup_generation_chain import FollowupResult +from ai_server.messaging.consumers.followup_consumer import FollowupConsumer +from ai_server.messaging.idempotency import LruIdempotencyStore +from ai_server.model.messages.followup import ( + AnswerEvaluation, + FollowupCallbackPayload, +) + + +class _StubMessage: + def __init__(self, body: bytes): + self.body = body + self.delivery_tag = 1 + + def process(self, requeue: bool = False): + return _NoopCtx() + + +class _NoopCtx: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + +def _envelope() -> bytes: + env = { + "messageId": "m-1", + "messageType": "generate.followup", + "version": "v1", + "traceId": "t-1", + "publishedAt": "2026-05-29T00:00:00Z", + "publisher": "core-server", + "payload": { + "sessionId": 99, + "parentMessageId": 501, + "answerMessageId": 502, + "previousQuestion": "결제 outbox 어떻게 구현?", + "answerText": "RabbitMQ로 보냈습니다.", + "interviewType": "TECHNICAL", + "jobCategory": "BACKEND", + }, + "context": {"userId": 42, "sessionId": 99}, + } + return json.dumps(env).encode() + + +@pytest.mark.asyncio +async def test_consumer_generates_followup_and_publishes_callback(): + generator = MagicMock() + generator.generate = AsyncMock( + return_value=FollowupResult( + followup_question="구체적으로 outbox 테이블 스키마와 polling 주기는?", + answer_evaluation=AnswerEvaluation( + specificity=2.0, logic=3.0, structure="PARTIAL_STAR" + ), + ) + ) + publisher = MagicMock() + publisher.publish = AsyncMock() + + consumer = FollowupConsumer( + generator=generator, + publisher=publisher, + idempotency=LruIdempotencyStore(max_size=10), + callback_routing_key="callback.questions", + ) + await consumer.handle(_StubMessage(_envelope())) + + generator.generate.assert_awaited_once() + publisher.publish.assert_awaited_once() + payload: FollowupCallbackPayload = publisher.publish.await_args.kwargs["payload"] + assert payload.session_id == 99 + assert payload.kind == "FOLLOWUP" + assert payload.parent_message_id == 501 + assert payload.followup_question.startswith("구체적으로") + assert payload.answer_evaluation.structure == "PARTIAL_STAR" + assert publisher.publish.await_args.kwargs["message_type"] == "callback.questions" + + +@pytest.mark.asyncio +async def test_consumer_idempotent_skip(): + generator = MagicMock() + generator.generate = AsyncMock() + publisher = MagicMock() + publisher.publish = AsyncMock() + idempotency = LruIdempotencyStore(max_size=10) + idempotency.is_seen_then_mark("m-1") + + consumer = FollowupConsumer( + generator=generator, + publisher=publisher, + idempotency=idempotency, + callback_routing_key="callback.questions", + ) + await consumer.handle(_StubMessage(_envelope())) + generator.generate.assert_not_awaited() + publisher.publish.assert_not_awaited() diff --git a/backend/src/main/java/com/stackup/stackup/log/ai/application/AiRequestLogService.java b/backend/src/main/java/com/stackup/stackup/log/ai/application/AiRequestLogService.java new file mode 100644 index 00000000..c5f3c52d --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/log/ai/application/AiRequestLogService.java @@ -0,0 +1,47 @@ +package com.stackup.stackup.log.ai.application; + +import com.stackup.stackup.log.ai.application.dto.AiRequestLogCommand; +import com.stackup.stackup.log.ai.domain.AiRequestLog; +import com.stackup.stackup.log.ai.domain.AiRequestLogRepository; +import com.stackup.stackup.log.ai.domain.AiRequestStatus; +import com.stackup.stackup.session.domain.InterviewSession; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class AiRequestLogService { + + private static final Logger log = LoggerFactory.getLogger(AiRequestLogService.class); + + private final AiRequestLogRepository logRepository; + private final UserRepository userRepository; + private final InterviewSessionRepository sessionRepository; + + public void record(AiRequestLogCommand cmd) { + User user = cmd.userId() == null ? null : userRepository.findById(cmd.userId()).orElse(null); + InterviewSession session = cmd.sessionId() == null + ? null + : sessionRepository.findById(cmd.sessionId()).orElse(null); + AiRequestStatus status; + try { + status = AiRequestStatus.valueOf(cmd.status()); + } catch (IllegalArgumentException e) { + status = AiRequestStatus.FAILED; + log.warn("invalid ai_request_logs.status={}, fallback to FAILED", cmd.status()); + } + logRepository.save(AiRequestLog.of( + user, session, + cmd.requestType(), cmd.modelName(), + cmd.inputTokens(), cmd.outputTokens(), cmd.latencyMs(), + status, cmd.errorMessage() + )); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/log/ai/application/dto/AiRequestLogCommand.java b/backend/src/main/java/com/stackup/stackup/log/ai/application/dto/AiRequestLogCommand.java new file mode 100644 index 00000000..8bb57da2 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/log/ai/application/dto/AiRequestLogCommand.java @@ -0,0 +1,14 @@ +package com.stackup.stackup.log.ai.application.dto; + +public record AiRequestLogCommand( + Long userId, + Long sessionId, + String requestType, + String modelName, + Integer inputTokens, + Integer outputTokens, + Integer latencyMs, + String status, // SUCCESS | FAILED | TIMEOUT + String errorMessage +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/log/ai/domain/AiRequestLog.java b/backend/src/main/java/com/stackup/stackup/log/ai/domain/AiRequestLog.java index 31694cd1..ccd1a581 100644 --- a/backend/src/main/java/com/stackup/stackup/log/ai/domain/AiRequestLog.java +++ b/backend/src/main/java/com/stackup/stackup/log/ai/domain/AiRequestLog.java @@ -66,4 +66,25 @@ public class AiRequestLog extends BaseTimeEntity { @Column(name = "error_message", columnDefinition = "text") private String errorMessage; + + private AiRequestLog(User user, InterviewSession session, String requestType, String modelName, + Integer inputTokens, Integer outputTokens, Integer latencyMs, + AiRequestStatus status, String errorMessage) { + this.user = user; + this.session = session; + this.requestType = requestType; + this.modelName = modelName; + this.inputTokens = inputTokens; + this.outputTokens = outputTokens; + this.latencyMs = latencyMs; + this.status = status; + this.errorMessage = errorMessage; + } + + public static AiRequestLog of(User user, InterviewSession session, String requestType, String modelName, + Integer inputTokens, Integer outputTokens, Integer latencyMs, + AiRequestStatus status, String errorMessage) { + return new AiRequestLog(user, session, requestType, modelName, + inputTokens, outputTokens, latencyMs, status, errorMessage); + } } diff --git a/backend/src/main/java/com/stackup/stackup/log/ai/presentation/InternalAiLogController.java b/backend/src/main/java/com/stackup/stackup/log/ai/presentation/InternalAiLogController.java new file mode 100644 index 00000000..fcf5413f --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/log/ai/presentation/InternalAiLogController.java @@ -0,0 +1,61 @@ +package com.stackup.stackup.log.ai.presentation; + +import com.stackup.stackup.log.ai.application.AiRequestLogService; +import com.stackup.stackup.log.ai.application.dto.AiRequestLogCommand; +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 jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Internal: AI Request Logs", description = "X-Internal-API-Key 필요. AI 가 LLM 호출별 토큰/지연시간을 기록.") +@RestController +@RequestMapping("/api/internal/ai-logs") +@RequiredArgsConstructor +public class InternalAiLogController { + + private final AiRequestLogService service; + + @Operation(operationId = "internalRecordAiRequestLog", summary = "AI 요청 로그 기록") + @ApiResponses({ + @ApiResponse(responseCode = "202", description = "기록 큐잉 (fire-and-forget)"), + @ApiResponse(responseCode = "401", description = "X-Internal-API-Key 인증 실패") + }) + @PostMapping + @ResponseStatus(HttpStatus.ACCEPTED) + public void record(@Valid @RequestBody Request request) { + service.record(new AiRequestLogCommand( + request.userId(), + request.sessionId(), + request.requestType(), + request.modelName(), + request.inputTokens(), + request.outputTokens(), + request.latencyMs(), + request.status(), + request.errorMessage() + )); + } + + public record Request( + Long userId, + Long sessionId, + @NotBlank String requestType, + String modelName, + Integer inputTokens, + Integer outputTokens, + Integer latencyMs, + @NotNull String status, + String errorMessage + ) { + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java b/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java index 9fdb928a..2f496d65 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java @@ -94,9 +94,52 @@ private void applyPool(InterviewSession session, QuestionsCallbackPayload payloa } private void applyFollowup(InterviewSession session, QuestionsCallbackPayload payload) { - // B5 진행 시 활성화. 지금은 envelope 만 로깅 (메시지/답변 시퀀스가 들어와야 의미 있음) - log.info("callback.questions FOLLOWUP received. sessionId={}, parent={}, willStoreInB5", - session.getId(), payload.parentMessageId()); + if (payload.followupQuestion() == null || payload.followupQuestion().isBlank()) { + log.warn("callback.questions FOLLOWUP empty question. sessionId={}", session.getId()); + return; + } + InterviewMessage parent = payload.parentMessageId() == null + ? null + : messageRepository.findById(payload.parentMessageId()).orElse(null); + + long currentMsgs = messageRepository.countBySession_Id(session.getId()); + int nextSeq = (int) currentMsgs + 1; + + InterviewMessage message = messageRepository.save( + InterviewMessage.followup(session, nextSeq, payload.followupQuestion(), parent) + ); + session.incrementQuestionCount(); + + sseEventPublisher.publishToSession(session.getId(), SseEventType.SESSION_MESSAGE, message.getId()); + sseEventPublisher.publishToUser( + session.getUser().getId(), + SseEventType.SESSION_MESSAGE, + new SessionMessageNotice(session.getId(), message.getId(), "FOLLOWUP_READY") + ); + + // maxQuestions 도달 시 자동 종료 (plan §A-4) + Integer max = session.getMaxQuestions(); + if (max != null && session.getTotalQuestionCount() != null + && session.getTotalQuestionCount() >= max) { + try { + session.end(); + sseEventPublisher.publishToSession(session.getId(), SseEventType.SESSION_STATE, + new SessionStateNotice(session.getId(), session.getStatus().name(), "MAX_QUESTIONS_REACHED")); + sseEventPublisher.publishToUser(session.getUser().getId(), SseEventType.SESSION_STATE, + new SessionStateNotice(session.getId(), session.getStatus().name(), "MAX_QUESTIONS_REACHED")); + log.info("session auto-completed on max questions. sessionId={}, max={}", + session.getId(), max); + } catch (IllegalStateException e) { + log.warn("auto-end skipped — session not IN_PROGRESS. sessionId={}, status={}", + session.getId(), session.getStatus()); + } + } + + log.info("callback.questions FOLLOWUP processed. sessionId={}, msg={}, totalQ={}", + session.getId(), message.getId(), session.getTotalQuestionCount()); + } + + public record SessionStateNotice(Long sessionId, String status, String reason) { } private boolean isProcessed(String messageId) { diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionFollowupRequester.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionFollowupRequester.java new file mode 100644 index 00000000..37064190 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionFollowupRequester.java @@ -0,0 +1,59 @@ +package com.stackup.stackup.session.application; + +import com.stackup.stackup.common.config.properties.RabbitMqProperties; +import com.stackup.stackup.common.messaging.MessageContext; +import com.stackup.stackup.common.messaging.RabbitMessagePublisher; +import com.stackup.stackup.session.application.dto.GenerateFollowupPayload; +import com.stackup.stackup.session.application.event.AnswerSubmittedEvent; +import com.stackup.stackup.session.domain.InterviewMessage; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.InterviewSession; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +// 답변 commit 후 발화 → generate.followup envelope 발행 (US-19). +@Component +@RequiredArgsConstructor +public class SessionFollowupRequester { + + private static final Logger log = LoggerFactory.getLogger(SessionFollowupRequester.class); + + private final RabbitMessagePublisher publisher; + private final RabbitMqProperties properties; + private final InterviewMessageRepository messageRepository; + + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onAnswerSubmitted(AnswerSubmittedEvent event) { + InterviewMessage parent = messageRepository.findById(event.parentQuestionMessageId()).orElse(null); + InterviewMessage answer = messageRepository.findById(event.answerMessageId()).orElse(null); + if (parent == null || answer == null) { + log.warn("generate.followup skipped — message not found. parent={}, answer={}", + event.parentQuestionMessageId(), event.answerMessageId()); + return; + } + InterviewSession session = parent.getSession(); + GenerateFollowupPayload payload = new GenerateFollowupPayload( + session.getId(), + parent.getId(), + answer.getId(), + parent.getContent(), + answer.getContent(), + session.getInterviewType(), + session.getJobCategory() + ); + publisher.publishToAi( + properties.routingKeys().generateFollowup(), + payload, + new MessageContext(event.userId(), session.getId(), null, null) + ); + log.info("generate.followup published. sessionId={}, parent={}, answer={}", + session.getId(), parent.getId(), answer.getId()); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFollowupPayload.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFollowupPayload.java new file mode 100644 index 00000000..8a6f7fa5 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFollowupPayload.java @@ -0,0 +1,15 @@ +package com.stackup.stackup.session.application.dto; + +import com.stackup.stackup.session.domain.InterviewType; +import com.stackup.stackup.session.domain.JobCategory; + +public record GenerateFollowupPayload( + Long sessionId, + Long parentMessageId, + Long answerMessageId, + String previousQuestion, + String answerText, + InterviewType interviewType, + JobCategory jobCategory +) { +} diff --git a/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java b/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java index b3d69ffd..d6a17090 100644 --- a/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java +++ b/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java @@ -6,6 +6,7 @@ import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; import com.stackup.stackup.document.domain.DocumentEmbeddingRepository; import com.stackup.stackup.github.domain.GithubRepositoryRepository; +import com.stackup.stackup.log.ai.domain.AiRequestLogRepository; import com.stackup.stackup.resume.domain.ResumeRepository; import com.stackup.stackup.session.domain.InterviewMessageRepository; import com.stackup.stackup.session.domain.InterviewSessionRepository; @@ -63,6 +64,9 @@ class StackupApplicationTests { @MockitoBean private MessageVoiceAnalysisRepository messageVoiceAnalysisRepository; + @MockitoBean + private AiRequestLogRepository aiRequestLogRepository; + @Test void contextLoads() { } From e5226afe18c8152304e259aed9cf5464d1817d56 Mon Sep 17 00:00:00 2001 From: jmj Date: Fri, 29 May 2026 14:06:13 +0900 Subject: [PATCH 6/8] =?UTF-8?q?docs:=20Sprint=202=20=EB=B3=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=98=EC=98=81=20+=20LLM=5FFLASH=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=B9=B4=ED=83=88=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ai/.env.example, docs/environment.md: LLM_FLASH_MODEL/TEMPERATURE/MAX_TOKENS 추가 - ai/CLAUDE.md §5/§16: generate.questions·generate.followup·llm 호출 로깅 본 구현 반영 - backend/CLAUDE.md §19: 현재 상태 갱신 (면접 도메인, ai_request_logs, ArchUnit, Flyway 본 구현) --- ai/.env.example | 3 +++ ai/CLAUDE.md | 25 +++++++++++++------------ backend/CLAUDE.md | 16 ++++++++++------ docs/environment.md | 3 +++ 4 files changed, 29 insertions(+), 18 deletions(-) diff --git a/ai/.env.example b/ai/.env.example index 708db850..50a4b619 100644 --- a/ai/.env.example +++ b/ai/.env.example @@ -18,6 +18,9 @@ LLM_API_KEY= LLM_BASE_URL=https://factchat-cloud.mindlogic.ai/v1/gateway LLM_PRO_MODEL=gemini-3.1-pro-preview LLM_PRO_TEMPERATURE=0.2 +LLM_FLASH_MODEL=gemini-3.1-flash-lite-preview +LLM_FLASH_TEMPERATURE=0.4 +LLM_FLASH_MAX_TOKENS=512 # Github 관련 CORE_INTERNAL_BASE_URL=http://localhost:38010 diff --git a/ai/CLAUDE.md b/ai/CLAUDE.md index 68c09f19..e900901d 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -93,8 +93,8 @@ ai/ | `ai.analyze.resume` | `analyze.resume` | 본 구현 (PDF → MD) | | `ai.analyze.repository` | `analyze.repository` | 본 구현 (GitHub README + tree + 소스 sampling) | | `ai.analyze.web` | `analyze.web` | 본 구현 (URL → trafilatura) | -| `ai.generate.questions` | `generate.questions` | 큐만, 코드 미구현 | -| `ai.generate.followup` | `generate.followup` | 큐만, 코드 미구현 | +| `ai.generate.questions` | `generate.questions` | 본 구현 (Pro 모델, 질문 풀 생성, US-18) | +| `ai.generate.followup` | `generate.followup` | 본 구현 (Flash 모델, 답변 평가+꼬리질문, US-19) | 콜백 발행: `ai.callback.{type}` 익스체인지. 상세 envelope/스키마/재시도: [`/docs/messaging.md`](../docs/messaging.md). @@ -310,19 +310,20 @@ docker run --env-file .env -p 8000:8000 stackup-ai ## 16. 현재 상태 (2026-05 기준) - FastAPI 부트스트랩 + 헬스체크 -- RabbitMQ consumer `ai.analyze.resume` 본 구현: - - PDF 텍스트 추출 (`analyzer/sources/pdf.py`, pypdf) +- 분석 consumer 본 구현 — `analyze.resume` / `analyze.repository` / `analyze.web`: + - PDF·GitHub Repo·웹 URL 소스 추출 추상화 (`analyzer/sources/`) - LLM 분석 (`chain/document_analysis_chain.py`, Gemini Pro + Pydantic 출력 파서) - 분석 MD를 스토리지에 저장 - `callback.analysis` 발행 (status `ANALYZED` / `FAILED`, retriable 플래그 포함) -- **스토리지 추상화 도입** (`storage/`): `ObjectStorage` 인터페이스 + 구현체 두 개. - - `S3Storage` (기본) — boto3 + `asyncio.to_thread`. MinIO·AWS S3 모두 호환. `S3_ENDPOINT_URL`만 바꿔 swap. - - `LocalFilesystemStorage` (dev/test 전용) — aiofiles + traversal 방어. - - `STORAGE_BACKEND=s3|local` 환경변수 한 줄로 전환. -- **소스 추출 추상화 도입** (`analyzer/sources/`): `SourceExtractor` 인터페이스 + `PdfSourceExtractor`. - GitHub repo / 웹 이력서 추출기는 동일 인터페이스로 후속 PR에서 추가 예정. -- 임베딩·청킹·pgvector upsert는 미구현 → 후속 PR. `embedding_chunk_count`는 0으로 발행. -- `analyze.repository` / `generate.questions` / `generate.followup` consumer는 큐 정의만, 코드 없음 +- 면접 consumer 본 구현 — `generate.questions` (US-18) / `generate.followup` (US-19): + - 질문 풀 생성 (Pro 모델, `chain/question_generation_chain.py`) + - 꼬리질문 + 답변 평가 (Flash 모델, `chain/followup_generation_chain.py`) + - 콜백: `callback.questions` (`kind=POOL|FOLLOWUP`) +- **임베딩 본 구현** (`rag/`): `MarkdownChunker` + `GeminiEmbeddingProvider` (1536d, `gemini-embedding-001`). + 운영/개발 default 는 gemini, 테스트는 `MockEmbeddingProvider`. +- **스토리지 추상화** (`storage/`): `S3Storage`(기본) / `LocalFilesystemStorage`. `STORAGE_BACKEND` 토글. +- **LLM 호출 로깅 본 구현** (`observability/llm_logging_callback.py`, US-30): + LangChain `AsyncCallbackHandler` 가 토큰/latency 측정 → Core `/api/internal/ai-logs` POST. - 음성 모듈은 Phase 2 각 도입 시 본 문서 갱신. diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index f89f21bb..f70561d6 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -351,12 +351,16 @@ docker compose up -d --- -## 19. 현재 상태 (2026-04 기준) - -- 도메인 패키지 골격만 존재, 실제 구현 거의 없음 -- Spring Security 미도입 → US-01 작업 시 도입 -- RabbitMQ starter 미도입 → US-09 작업 시 도입 -- Flyway 미도입 → 첫 entity 작성 PR에서 도입 +## 19. 현재 상태 (2026-05 기준) + +- 도메인 본 구현 진행 — auth · user(consent 포함) · github · resume · document · session · log.ai +- Spring Security + JWT + GitHub OAuth (US-01) 본 구현 +- RabbitMQ starter 본 구현 — Core ↔ AI envelope · DLX · DLQ · 멱등(`processed_messages`) 완비 +- Flyway 본 구현 — 모든 테이블 / pgvector index / ENUM CHECK +- ArchUnit 룰 적용 (의존 방향 · 순환 차단 · `@Transactional` application 한정 · entity는 domain 패키지) +- 면접 도메인 (US-13~20) 본 구현: 세션 CRUD/start/end/interrupt, generate.questions 발행, + callback.questions(POOL/FOLLOWUP) 수신, 자동 종료 +- AI 호출 로깅 (US-30) 본 구현: `/api/internal/ai-logs` + `ai_request_logs` INSERT - **Spring AI 미사용** — LLM·임베딩 호출은 모두 AI 서버 위임. Core는 RabbitMQ 발행만 담당. - **Redis 미사용** — 휘발성 데이터는 DB short-lived 레코드 또는 인메모리로. diff --git a/docs/environment.md b/docs/environment.md index 6c5966c0..2aa8e85f 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -97,6 +97,9 @@ LLM_API_KEY= # 학교 발급 키 LLM_BASE_URL=https://factchat-cloud.mindlogic.ai/v1/gateway LLM_PRO_MODEL=gemini-3.1-pro-preview LLM_PRO_TEMPERATURE=0.2 +LLM_FLASH_MODEL=gemini-3.1-flash-lite-preview # 꼬리질문(US-19) 저지연 모델 +LLM_FLASH_TEMPERATURE=0.4 +LLM_FLASH_MAX_TOKENS=512 # (외부 직접 호출용, fallback) GEMINI_API_KEY= From 38f2e54a624ced4389bcfcbd59dd6aa7ff1e57a3 Mon Sep 17 00:00:00 2001 From: jmj Date: Fri, 29 May 2026 14:16:44 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20=EB=8B=B5=EB=B3=80=20=EB=A9=B1?= =?UTF-8?q?=EB=93=B1=ED=82=A4=20+=20Sprint=202=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SPRINT2_PLAN decision #4: POST /api/sessions/{id}/messages 에 Idempotency-Key 헤더 처리 (interview_messages.idempotency_key 컬럼 + Flyway V5 partial unique index). SSE 재연결 자동 재시도 시 동일 키면 기존 답변 반환. - 단위 테스트 (Mockito): - SessionServiceTest: create/start/end + analyzed 컨텍스트 링크 + non-analyzed 거부 - InterviewMessageServiceTest: submitAnswer + 멱등키 중복 차단 + 직전 메시지 검증 - QuestionsCallbackServiceTest: POOL 첫 질문 INSERT/SSE, FOLLOWUP 자동종료, 중복 messageId skip - AiRequestLogServiceTest: record + invalid status fallback --- .../application/InterviewMessageService.java | 14 +- .../session/domain/InterviewMessage.java | 15 +- .../domain/InterviewMessageRepository.java | 2 + .../InterviewMessageController.java | 4 +- .../V5__interview_messages_idempotency.sql | 10 ++ .../application/AiRequestLogServiceTest.java | 63 +++++++ .../InterviewMessageServiceTest.java | 99 +++++++++++ .../QuestionsCallbackServiceTest.java | 134 +++++++++++++++ .../application/SessionServiceTest.java | 157 ++++++++++++++++++ 9 files changed, 490 insertions(+), 8 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V5__interview_messages_idempotency.sql create mode 100644 backend/src/test/java/com/stackup/stackup/log/ai/application/AiRequestLogServiceTest.java create mode 100644 backend/src/test/java/com/stackup/stackup/session/application/InterviewMessageServiceTest.java create mode 100644 backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java create mode 100644 backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java diff --git a/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java b/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java index 7c1d2016..44b06662 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java @@ -34,8 +34,17 @@ public List list(Long userId, Long sessionId) { } @Transactional - public MessageResult submitAnswer(Long userId, Long sessionId, String content) { + public MessageResult submitAnswer(Long userId, Long sessionId, String content, String idempotencyKey) { InterviewSession session = ownedSession(userId, sessionId); + + // SPRINT2_PLAN decision #4: SSE 재연결 자동 재시도 중복 차단 + if (idempotencyKey != null && !idempotencyKey.isBlank()) { + var existing = messageRepository.findBySession_IdAndIdempotencyKey(sessionId, idempotencyKey); + if (existing.isPresent()) { + return MessageResult.of(existing.get()); + } + } + if (session.getStatus() != SessionStatus.IN_PROGRESS) { throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE); } @@ -48,7 +57,8 @@ public MessageResult submitAnswer(Long userId, Long sessionId, String content) { } int nextSeq = latest.getSequenceNumber() + 1; InterviewMessage answer = messageRepository.save( - InterviewMessage.interviewee(session, nextSeq, content, latest) + InterviewMessage.interviewee(session, nextSeq, content, latest, + idempotencyKey != null && !idempotencyKey.isBlank() ? idempotencyKey : null) ); events.publishEvent(new AnswerSubmittedEvent( 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 c7aca58b..7bcfd0f6 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 @@ -62,27 +62,32 @@ public class InterviewMessage extends BaseTimeEntity { @Enumerated(EnumType.STRING) private MessageStatus status = MessageStatus.CREATED; + @Column(name = "idempotency_key", length = 64) + private String idempotencyKey; + private InterviewMessage(InterviewSession session, Integer sequenceNumber, MessageRole role, - String content, InterviewMessage parentMessage) { + String content, InterviewMessage parentMessage, + String idempotencyKey) { this.session = session; this.sequenceNumber = sequenceNumber; this.role = role; this.content = content; this.parentMessage = parentMessage; + this.idempotencyKey = idempotencyKey; } public static InterviewMessage interviewer(InterviewSession session, int seq, String content) { - return new InterviewMessage(session, seq, MessageRole.INTERVIEWER, content, null); + return new InterviewMessage(session, seq, MessageRole.INTERVIEWER, content, null, null); } public static InterviewMessage followup(InterviewSession session, int seq, String content, InterviewMessage parent) { - return new InterviewMessage(session, seq, MessageRole.INTERVIEWER, content, parent); + return new InterviewMessage(session, seq, MessageRole.INTERVIEWER, content, parent, null); } public static InterviewMessage interviewee(InterviewSession session, int seq, String content, - InterviewMessage parent) { - return new InterviewMessage(session, seq, MessageRole.INTERVIEWEE, content, parent); + InterviewMessage parent, String idempotencyKey) { + return new InterviewMessage(session, seq, MessageRole.INTERVIEWEE, content, parent, idempotencyKey); } public void markStatus(MessageStatus 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 37ba1589..a791d4ff 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 @@ -11,4 +11,6 @@ public interface InterviewMessageRepository extends JpaRepository findFirstBySession_IdOrderBySequenceNumberDesc(Long sessionId); long countBySession_Id(Long sessionId); + + Optional findBySession_IdAndIdempotencyKey(Long sessionId, String idempotencyKey); } 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 84a34b72..04233f93 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 @@ -17,6 +17,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -62,10 +63,11 @@ public List list( public MessageResponse submit( @AuthenticationPrincipal UserPrincipal principal, @PathVariable Long sessionId, + @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey, @Valid @RequestBody MessageSubmitRequest request ) { return MessageResponse.from( - messageService.submitAnswer(principal.userId(), sessionId, request.content()) + messageService.submitAnswer(principal.userId(), sessionId, request.content(), idempotencyKey) ); } } diff --git a/backend/src/main/resources/db/migration/V5__interview_messages_idempotency.sql b/backend/src/main/resources/db/migration/V5__interview_messages_idempotency.sql new file mode 100644 index 00000000..2307ae99 --- /dev/null +++ b/backend/src/main/resources/db/migration/V5__interview_messages_idempotency.sql @@ -0,0 +1,10 @@ +-- US-19 답변 제출 멱등 처리 (SPRINT2_PLAN decision #4) +-- POST /api/sessions/{id}/messages 에서 SSE 재연결 후 자동 재시도 시 중복 INSERT 차단. +-- session 단위로 unique — 동일 키여도 다른 세션이면 별도 답변. + +ALTER TABLE interview_messages + ADD COLUMN idempotency_key VARCHAR(64); + +CREATE UNIQUE INDEX uq_interview_messages_session_idem_key + ON interview_messages (session_id, idempotency_key) + WHERE idempotency_key IS NOT NULL; diff --git a/backend/src/test/java/com/stackup/stackup/log/ai/application/AiRequestLogServiceTest.java b/backend/src/test/java/com/stackup/stackup/log/ai/application/AiRequestLogServiceTest.java new file mode 100644 index 00000000..eb0a7de3 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/log/ai/application/AiRequestLogServiceTest.java @@ -0,0 +1,63 @@ +package com.stackup.stackup.log.ai.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.log.ai.application.dto.AiRequestLogCommand; +import com.stackup.stackup.log.ai.domain.AiRequestLog; +import com.stackup.stackup.log.ai.domain.AiRequestLogRepository; +import com.stackup.stackup.log.ai.domain.AiRequestStatus; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class AiRequestLogServiceTest { + + @Mock AiRequestLogRepository logRepository; + @Mock UserRepository userRepository; + @Mock InterviewSessionRepository sessionRepository; + @InjectMocks AiRequestLogService service; + + @Test + void record_savesLogWithResolvedUserAndSession() { + User user = User.createGithubUser(1L, "u", null, null, "t"); + ReflectionTestUtils.setField(user, "id", 1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(sessionRepository.findById(10L)).thenReturn(Optional.empty()); + + service.record(new AiRequestLogCommand( + 1L, 10L, "analyze.document", "gemini-3.1-pro", + 123, 456, 1500, "SUCCESS", null + )); + + ArgumentCaptor cap = ArgumentCaptor.forClass(AiRequestLog.class); + verify(logRepository).save(cap.capture()); + AiRequestLog saved = cap.getValue(); + assertThat(saved.getStatus()).isEqualTo(AiRequestStatus.SUCCESS); + assertThat(saved.getRequestType()).isEqualTo("analyze.document"); + assertThat(saved.getInputTokens()).isEqualTo(123); + } + + @Test + void record_fallsBackToFailedOnUnknownStatus() { + service.record(new AiRequestLogCommand( + null, null, "generate.followup", "gemini-flash", + 10, 20, 800, "BOGUS", null + )); + + ArgumentCaptor cap = ArgumentCaptor.forClass(AiRequestLog.class); + verify(logRepository).save(cap.capture()); + assertThat(cap.getValue().getStatus()).isEqualTo(AiRequestStatus.FAILED); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/session/application/InterviewMessageServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/InterviewMessageServiceTest.java new file mode 100644 index 00000000..99d93205 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/application/InterviewMessageServiceTest.java @@ -0,0 +1,99 @@ +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.DomainException; +import com.stackup.stackup.session.application.dto.MessageResult; +import com.stackup.stackup.session.application.event.AnswerSubmittedEvent; +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.InterviewType; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.SessionMode; +import com.stackup.stackup.user.domain.User; +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.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class InterviewMessageServiceTest { + + @Mock InterviewSessionRepository sessionRepository; + @Mock InterviewMessageRepository messageRepository; + @Mock ApplicationEventPublisher events; + @InjectMocks InterviewMessageService service; + + @Test + void submitAnswer_insertsIntervieweeAndPublishesEvent() { + InterviewSession session = sessionInProgress(10L); + InterviewMessage question = InterviewMessage.interviewer(session, 1, "Q1?"); + ReflectionTestUtils.setField(question, "id", 100L); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session)); + when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)).thenReturn(Optional.of(question)); + when(messageRepository.save(any(InterviewMessage.class))).thenAnswer(inv -> { + InterviewMessage m = inv.getArgument(0); + ReflectionTestUtils.setField(m, "id", 200L); + return m; + }); + + MessageResult result = service.submitAnswer(1L, 10L, "answer text", null); + + assertThat(result.id()).isEqualTo(200L); + assertThat(result.sequenceNumber()).isEqualTo(2); + verify(events).publishEvent(any(AnswerSubmittedEvent.class)); + } + + @Test + void submitAnswer_returnsExistingWhenIdempotencyKeyHit() { + InterviewSession session = sessionInProgress(10L); + InterviewMessage prior = InterviewMessage.interviewee(session, 2, "prior", null, "key-abc"); + ReflectionTestUtils.setField(prior, "id", 200L); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session)); + when(messageRepository.findBySession_IdAndIdempotencyKey(10L, "key-abc")) + .thenReturn(Optional.of(prior)); + + MessageResult result = service.submitAnswer(1L, 10L, "retry", "key-abc"); + + assertThat(result.id()).isEqualTo(200L); + verify(messageRepository, never()).save(any(InterviewMessage.class)); + verify(events, never()).publishEvent(any(AnswerSubmittedEvent.class)); + } + + @Test + void submitAnswer_rejectsWhenPreviousMessageNotInterviewer() { + InterviewSession session = sessionInProgress(10L); + InterviewMessage prior = InterviewMessage.interviewee(session, 1, "prev answer", null, null); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session)); + when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)).thenReturn(Optional.of(prior)); + + assertThatThrownBy(() -> service.submitAnswer(1L, 10L, "x", null)) + .isInstanceOf(DomainException.class); + } + + private InterviewSession sessionInProgress(Long id) { + User user = User.createGithubUser(1L, "u", null, null, "t"); + ReflectionTestUtils.setField(user, "id", 1L); + InterviewSession s = InterviewSession.create( + user, "t", null, SessionMode.ONLINE, + InterviewType.TECHNICAL, JobCategory.BACKEND, 5, 30 + ); + ReflectionTestUtils.setField(s, "id", id); + s.start(); + return s; + } +} diff --git a/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java new file mode 100644 index 00000000..95e3ecfb --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java @@ -0,0 +1,134 @@ +package com.stackup.stackup.session.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.common.messaging.domain.ProcessedMessage; +import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; +import com.stackup.stackup.common.sse.SseEventPublisher; +import com.stackup.stackup.common.sse.SseEventType; +import com.stackup.stackup.session.application.dto.QuestionsCallbackEnvelope; +import com.stackup.stackup.session.application.dto.QuestionsCallbackPayload; +import com.stackup.stackup.session.application.dto.QuestionsCallbackPayload.GeneratedQuestion; +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.InterviewType; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.SessionMode; +import com.stackup.stackup.session.domain.SessionStatus; +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 QuestionsCallbackServiceTest { + + @Mock InterviewSessionRepository sessionRepository; + @Mock InterviewMessageRepository messageRepository; + @Mock ProcessedMessageRepository processedMessageRepository; + @Mock SseEventPublisher sseEventPublisher; + @InjectMocks QuestionsCallbackService service; + + @Test + void apply_poolInsertsFirstQuestionAndPushesSse() { + InterviewSession session = sessionFixture(11L, SessionStatus.READY); + QuestionsCallbackEnvelope env = poolEnvelope(11L, List.of( + new GeneratedQuestion("INTRO", "자기소개"), + new GeneratedQuestion("TECH", "JPA?") + )); + when(processedMessageRepository.existsById("m-1")).thenReturn(false); + when(sessionRepository.findById(11L)).thenReturn(Optional.of(session)); + when(messageRepository.save(any(InterviewMessage.class))).thenAnswer(inv -> { + InterviewMessage m = inv.getArgument(0); + ReflectionTestUtils.setField(m, "id", 500L); + return m; + }); + + service.apply(env); + + verify(messageRepository).save(any(InterviewMessage.class)); + verify(sseEventPublisher).publishToSession(eq(11L), eq(SseEventType.SESSION_MESSAGE), any()); + verify(processedMessageRepository).save(any(ProcessedMessage.class)); + assertThat(session.getTotalQuestionCount()).isEqualTo(1); + } + + @Test + void apply_followupAutoEndsSessionAtMaxQuestions() { + InterviewSession session = sessionFixture(11L, SessionStatus.IN_PROGRESS); + // maxQuestions=5 ; total=4 → followup INSERT 시 5 도달 → 자동 종료 + ReflectionTestUtils.setField(session, "totalQuestionCount", 4); + + QuestionsCallbackEnvelope env = followupEnvelope(11L, 200L, "꼬리?"); + when(processedMessageRepository.existsById("m-2")).thenReturn(false); + when(sessionRepository.findById(11L)).thenReturn(Optional.of(session)); + when(messageRepository.findById(200L)).thenReturn(Optional.of(parentMessageFixture(session))); + when(messageRepository.countBySession_Id(11L)).thenReturn(8L); + when(messageRepository.save(any(InterviewMessage.class))).thenAnswer(inv -> { + InterviewMessage m = inv.getArgument(0); + ReflectionTestUtils.setField(m, "id", 700L); + return m; + }); + + service.apply(env); + + assertThat(session.getStatus()).isEqualTo(SessionStatus.COMPLETED); + assertThat(session.getTotalQuestionCount()).isEqualTo(5); + } + + @Test + void apply_skipsDuplicateMessageId() { + QuestionsCallbackEnvelope env = poolEnvelope(11L, List.of(new GeneratedQuestion("X", "Q"))); + when(processedMessageRepository.existsById("m-1")).thenReturn(true); + + service.apply(env); + + verify(sessionRepository, never()).findById(any()); + verify(messageRepository, never()).save(any(InterviewMessage.class)); + } + + private QuestionsCallbackEnvelope poolEnvelope(Long sessionId, List questions) { + QuestionsCallbackPayload payload = new QuestionsCallbackPayload( + sessionId, "POOL", questions, null, null, null + ); + return new QuestionsCallbackEnvelope("m-1", "callback.questions", "1", "t", null, "ai", payload, null); + } + + private QuestionsCallbackEnvelope followupEnvelope(Long sessionId, Long parentId, String followup) { + QuestionsCallbackPayload payload = new QuestionsCallbackPayload( + sessionId, "FOLLOWUP", null, parentId, followup, null + ); + return new QuestionsCallbackEnvelope("m-2", "callback.questions", "1", "t", null, "ai", payload, null); + } + + private InterviewSession sessionFixture(Long id, SessionStatus status) { + User user = User.createGithubUser(1L, "u", null, null, "t"); + ReflectionTestUtils.setField(user, "id", 1L); + InterviewSession s = InterviewSession.create( + user, "t", null, SessionMode.ONLINE, + InterviewType.TECHNICAL, JobCategory.BACKEND, 5, 30 + ); + ReflectionTestUtils.setField(s, "id", id); + if (status == SessionStatus.IN_PROGRESS || status == SessionStatus.COMPLETED) { + s.start(); + } + return s; + } + + private InterviewMessage parentMessageFixture(InterviewSession session) { + InterviewMessage m = InterviewMessage.interviewer(session, 1, "Q?"); + ReflectionTestUtils.setField(m, "id", 200L); + return m; + } +} diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java new file mode 100644 index 00000000..1da89ad5 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java @@ -0,0 +1,157 @@ +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.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.document.domain.AnalysisStatus; +import com.stackup.stackup.document.domain.AnalyzedDocument; +import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; +import com.stackup.stackup.session.application.dto.SessionCreateCommand; +import com.stackup.stackup.session.application.dto.SessionResult; +import com.stackup.stackup.session.application.event.SessionCreatedEvent; +import com.stackup.stackup.session.domain.InterviewSession; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.InterviewType; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.SessionContext; +import com.stackup.stackup.session.domain.SessionContextRepository; +import com.stackup.stackup.session.domain.SessionMode; +import com.stackup.stackup.session.domain.SessionStatus; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +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.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class SessionServiceTest { + + @Mock InterviewSessionRepository sessionRepository; + @Mock SessionContextRepository contextRepository; + @Mock AnalyzedDocumentRepository documentRepository; + @Mock UserRepository userRepository; + @Mock ApplicationEventPublisher events; + @InjectMocks SessionService service; + + @Test + void create_savesSessionAndPublishesEvent() { + User user = userFixture(1L); + when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.of(user)); + when(sessionRepository.save(any(InterviewSession.class))).thenAnswer(inv -> { + InterviewSession s = inv.getArgument(0); + ReflectionTestUtils.setField(s, "id", 100L); + return s; + }); + + SessionResult result = service.create(1L, new SessionCreateCommand( + "title", "memo", SessionMode.ONLINE, InterviewType.TECHNICAL, JobCategory.BACKEND, + 5, 30, List.of() + )); + + assertThat(result.id()).isEqualTo(100L); + assertThat(result.status()).isEqualTo(SessionStatus.READY); + verify(events).publishEvent(any(SessionCreatedEvent.class)); + } + + @Test + void create_linksAnalyzedContextDocuments() { + User user = userFixture(1L); + when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.of(user)); + when(sessionRepository.save(any(InterviewSession.class))).thenAnswer(inv -> { + InterviewSession s = inv.getArgument(0); + ReflectionTestUtils.setField(s, "id", 100L); + return s; + }); + AnalyzedDocument doc = analyzedDocFixture(7L, AnalysisStatus.ANALYZED); + when(documentRepository.findActiveByIdAndOwner(7L, 1L)).thenReturn(Optional.of(doc)); + + SessionResult result = service.create(1L, new SessionCreateCommand( + "t", null, SessionMode.ONLINE, InterviewType.TECHNICAL, JobCategory.BACKEND, + 5, 30, List.of(7L, 7L) + )); + + assertThat(result.contextDocumentIds()).containsExactly(7L); + verify(contextRepository).save(any(SessionContext.class)); + } + + @Test + void create_rejectsNonAnalyzedDocument() { + User user = userFixture(1L); + when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.of(user)); + when(sessionRepository.save(any(InterviewSession.class))).thenAnswer(inv -> inv.getArgument(0)); + AnalyzedDocument pending = analyzedDocFixture(8L, AnalysisStatus.PROCESSING); + when(documentRepository.findActiveByIdAndOwner(8L, 1L)).thenReturn(Optional.of(pending)); + + assertThatThrownBy(() -> service.create(1L, new SessionCreateCommand( + "t", null, SessionMode.ONLINE, InterviewType.TECHNICAL, JobCategory.BACKEND, + 5, 30, List.of(8L) + ))).isInstanceOf(DomainException.class); + } + + @Test + void start_transitionsReadyToInProgress() { + InterviewSession session = sessionFixture(50L); + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(50L, 1L)) + .thenReturn(Optional.of(session)); + + SessionResult result = service.start(1L, 50L); + + assertThat(result.status()).isEqualTo(SessionStatus.IN_PROGRESS); + } + + @Test + void start_throwsWhenAlreadyInProgress() { + InterviewSession session = sessionFixture(50L); + session.start(); + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(50L, 1L)) + .thenReturn(Optional.of(session)); + + assertThatThrownBy(() -> service.start(1L, 50L)) + .isInstanceOf(DomainException.class); + } + + @Test + void end_transitionsInProgressToCompleted() { + InterviewSession session = sessionFixture(50L); + session.start(); + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(50L, 1L)) + .thenReturn(Optional.of(session)); + + SessionResult result = service.end(1L, 50L); + + assertThat(result.status()).isEqualTo(SessionStatus.COMPLETED); + } + + private User userFixture(Long id) { + User user = User.createGithubUser(123L, "octocat", null, null, "tok"); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private InterviewSession sessionFixture(Long id) { + InterviewSession s = InterviewSession.create( + userFixture(1L), "t", null, SessionMode.ONLINE, + InterviewType.TECHNICAL, JobCategory.BACKEND, 5, 30 + ); + ReflectionTestUtils.setField(s, "id", id); + return s; + } + + private AnalyzedDocument analyzedDocFixture(Long id, AnalysisStatus status) { + AnalyzedDocument doc = mock(AnalyzedDocument.class); + org.mockito.Mockito.lenient().when(doc.getId()).thenReturn(id); + org.mockito.Mockito.lenient().when(doc.getAnalysisStatus()).thenReturn(status); + return doc; + } +} From beee46f8277ed7b10b834947f6960c4b665afa82 Mon Sep 17 00:00:00 2001 From: jmj Date: Fri, 29 May 2026 14:19:36 +0900 Subject: [PATCH 8/8] =?UTF-8?q?chore(env):=20docker-compose=20+=20?= =?UTF-8?q?=EB=A3=A8=ED=8A=B8=20.env.example=20=EC=97=90=20LLM=5FFLASH=5F*?= =?UTF-8?q?=20=EC=A0=84=ED=8C=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deploy-app.yml 의 .env append 로직이 루트 .env.example 만 읽기 때문에 루트 .env.example 동기화 누락 시 stack-up.shop 의 ai 컨테이너에 환경변수 주입 누락 발생. docker-compose 의 ai 서비스 env 매핑도 함께 추가. --- .env.example | 3 +++ docker-compose.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.env.example b/.env.example index 080aebd8..00e909bc 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,9 @@ S3_REGION=us-east-1 LLM_API_KEY= LLM_BASE_URL=https://factchat-cloud.mindlogic.ai/v1/gateway LLM_PRO_MODEL=gemini-3.1-pro-preview +LLM_FLASH_MODEL=gemini-3.1-flash-lite-preview +LLM_FLASH_TEMPERATURE=0.4 +LLM_FLASH_MAX_TOKENS=512 # RealTime server REALTIME_PORT=38020 diff --git a/docker-compose.yml b/docker-compose.yml index b08c4ce1..c28e95f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -87,6 +87,9 @@ services: LLM_API_KEY: ${LLM_API_KEY:-} LLM_BASE_URL: ${LLM_BASE_URL:-https://factchat-cloud.mindlogic.ai/v1/gateway} LLM_PRO_MODEL: ${LLM_PRO_MODEL:-gemini-3.1-pro-preview} + LLM_FLASH_MODEL: ${LLM_FLASH_MODEL:-gemini-3.1-flash-lite-preview} + LLM_FLASH_TEMPERATURE: ${LLM_FLASH_TEMPERATURE:-0.4} + LLM_FLASH_MAX_TOKENS: ${LLM_FLASH_MAX_TOKENS:-512} # AI → Core 내부 API. 같은 compose 네트워크의 backend 서비스로 직접 라우팅. CORE_INTERNAL_BASE_URL: ${CORE_INTERNAL_BASE_URL:-http://backend:38010} CORE_INTERNAL_API_KEY: ${CORE_INTERNAL_API_KEY:-local-development-internal-api-key}