diff --git a/src/main/java/com/whylog/server/domain/decision/controller/ApplicationController.java b/src/main/java/com/whylog/server/domain/decision/controller/ApplicationController.java index a1cc92a..622c406 100644 --- a/src/main/java/com/whylog/server/domain/decision/controller/ApplicationController.java +++ b/src/main/java/com/whylog/server/domain/decision/controller/ApplicationController.java @@ -2,7 +2,12 @@ import com.whylog.server.domain.decision.dto.ApplicationResponse; import com.whylog.server.domain.decision.dto.DecisionRequest; +import com.whylog.server.domain.decision.exception.DecisionErrorCode; +import com.whylog.server.domain.decision.service.ApplicationQueryService; import com.whylog.server.global.apiPayload.ApiResponse; +import com.whylog.server.global.apiPayload.annotation.ApiErrorCodeExample; +import com.whylog.server.global.apiPayload.annotation.ApiErrorCodeExamples; +import com.whylog.server.global.apiPayload.code.status.ErrorStatus; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -22,12 +27,18 @@ @Tag(name = "Application", description = "적용사항 관련 API") public class ApplicationController { + private final ApplicationQueryService applicationQueryService; + @GetMapping("/{applicationId}") - @Operation(summary = "적용사항 상세 조회 API", description = "특정 적용사항의 상세 정보를 조회하는 API입니다.") + @Operation(summary = "적용사항 상세 조회 API", description = "특정 적용사항의 상세 정보를 조회하는 API입니다. 적용사항 상세 조회 화면에서 적용사항 제목, 타임라인, 결정원문 맥락, 결정근거를 조회합니다.") + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = DecisionErrorCode.class, name = "APPLICATION_NOT_FOUND") + }) public ApiResponse getApplication( @PathVariable Long applicationId) { - return ApiResponse.onSuccess(null); + return ApiResponse.onSuccess(applicationQueryService.getApplicationDetail(applicationId)); } @GetMapping("/{applicationId}/recommended-commits") @@ -60,4 +71,5 @@ public ApiResponse saveRecommendation( return ApiResponse.onSuccess(null); } + // TODO: 적용현황 조회 api 추가 } diff --git a/src/main/java/com/whylog/server/domain/decision/controller/DecisionController.java b/src/main/java/com/whylog/server/domain/decision/controller/DecisionController.java index 088ff51..cb64fef 100644 --- a/src/main/java/com/whylog/server/domain/decision/controller/DecisionController.java +++ b/src/main/java/com/whylog/server/domain/decision/controller/DecisionController.java @@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; + import java.util.List; @RestController diff --git a/src/main/java/com/whylog/server/domain/decision/dto/ApplicationResponse.java b/src/main/java/com/whylog/server/domain/decision/dto/ApplicationResponse.java index 07b87f5..97b8089 100644 --- a/src/main/java/com/whylog/server/domain/decision/dto/ApplicationResponse.java +++ b/src/main/java/com/whylog/server/domain/decision/dto/ApplicationResponse.java @@ -42,12 +42,14 @@ public static class ApplicationDetailDTO { @Schema(description = "결정 타임라인 목록") private List decisionTimelines; - @Schema(description = "결정근거 목록") - private List decisionReasons; + @Schema(description = "결정 원문 맥락 목록") + private List decisionContexts; - @Schema(description = "적용현황 (커밋 정보) 목록") - private List applicationBases; + @Schema(description = "결정근거 개수", example = "3") + private Integer decisionReasonCount; + @Schema(description = "결정근거 목록") + private List decisionReasons; } @Getter @@ -60,12 +62,35 @@ public static class DecisionTimelineItemDTO { @Schema(description = "타임라인 시간", example = "2026-03-24T12:28:00") private String time; + @Schema(description = "타임라인 단계", example = "이슈제기") + private String step; + @Schema(description = "타임라인 내용", example = "장애 이슈 제기") private String content; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "결정 원문 맥락 항목") + public static class DecisionContextItemDTO { + + @Schema(description = "타임라인 시간", example = "2026-03-24T12:28:00") + private String time; + + @Schema(description = "발화자 ID", example = "1", nullable = true) + private Long memberId; + + @Schema(description = "발화자 이름", example = "김주뇽", nullable = true) + private String memberName; + + @Schema(description = "발화자 프로필 사진", example = "https://example.com/profile.jpg", nullable = true) + private String profileImage; + @Schema(description = "대화 내용", example = "아니 우리 이거 버그난다니까?!?@??@") private String dialogueContent; - } diff --git a/src/main/java/com/whylog/server/domain/decision/dto/DecisionResponse.java b/src/main/java/com/whylog/server/domain/decision/dto/DecisionResponse.java index 386b671..68e5f95 100644 --- a/src/main/java/com/whylog/server/domain/decision/dto/DecisionResponse.java +++ b/src/main/java/com/whylog/server/domain/decision/dto/DecisionResponse.java @@ -35,15 +35,6 @@ public static class ReliabilityDTO { @Schema(description = "신뢰도 점수", example = "85") private Integer score; - - @Schema(description = "근거발언 개수", example = "42") - private Integer reasonSpeechCount; - - @Schema(description = "참여자 합의도", example = "HIGH") - private String participantConsensus; - - @Schema(description = "결정 구현 일치율", example = "92") - private Integer matchRatio; } } diff --git a/src/main/java/com/whylog/server/domain/decision/entity/ApplicationBase.java b/src/main/java/com/whylog/server/domain/decision/entity/ApplicationBase.java index 4ac69b4..9f63395 100644 --- a/src/main/java/com/whylog/server/domain/decision/entity/ApplicationBase.java +++ b/src/main/java/com/whylog/server/domain/decision/entity/ApplicationBase.java @@ -24,4 +24,12 @@ public class ApplicationBase extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "decision_base_pk", nullable = false) private DecisionBase decisionBase; + + public static ApplicationBase create(Application application, DecisionBase decisionBase) { + ApplicationBase applicationBase = new ApplicationBase(); + applicationBase.id = new ApplicationBaseId(application.getId(), decisionBase.getId()); + applicationBase.application = application; + applicationBase.decisionBase = decisionBase; + return applicationBase; + } } diff --git a/src/main/java/com/whylog/server/domain/decision/entity/ApplicationBaseId.java b/src/main/java/com/whylog/server/domain/decision/entity/ApplicationBaseId.java index c6c6cc2..7ebd42e 100644 --- a/src/main/java/com/whylog/server/domain/decision/entity/ApplicationBaseId.java +++ b/src/main/java/com/whylog/server/domain/decision/entity/ApplicationBaseId.java @@ -3,6 +3,7 @@ import jakarta.persistence.Column; import jakarta.persistence.Embeddable; import java.io.Serializable; +import lombok.AllArgsConstructor; import lombok.AccessLevel; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -11,6 +12,7 @@ @Getter @Embeddable @EqualsAndHashCode +@AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ApplicationBaseId implements Serializable { diff --git a/src/main/java/com/whylog/server/domain/decision/entity/ApplicationTimeline.java b/src/main/java/com/whylog/server/domain/decision/entity/ApplicationTimeline.java index 67b1abb..c315695 100644 --- a/src/main/java/com/whylog/server/domain/decision/entity/ApplicationTimeline.java +++ b/src/main/java/com/whylog/server/domain/decision/entity/ApplicationTimeline.java @@ -30,4 +30,12 @@ public class ApplicationTimeline extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "decision_timeline_pk", nullable = false) private DecisionTimeline decisionTimeline; + + public static ApplicationTimeline create(Application application, DecisionTimeline decisionTimeline) { + ApplicationTimeline applicationTimeline = new ApplicationTimeline(); + applicationTimeline.id = new ApplicationTimelineId(application.getId(), decisionTimeline.getId()); + applicationTimeline.application = application; + applicationTimeline.decisionTimeline = decisionTimeline; + return applicationTimeline; + } } diff --git a/src/main/java/com/whylog/server/domain/decision/entity/ApplicationTimelineId.java b/src/main/java/com/whylog/server/domain/decision/entity/ApplicationTimelineId.java index de00599..01dc439 100644 --- a/src/main/java/com/whylog/server/domain/decision/entity/ApplicationTimelineId.java +++ b/src/main/java/com/whylog/server/domain/decision/entity/ApplicationTimelineId.java @@ -3,6 +3,7 @@ import jakarta.persistence.Column; import jakarta.persistence.Embeddable; import java.io.Serializable; +import lombok.AllArgsConstructor; import lombok.AccessLevel; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -11,6 +12,7 @@ @Getter @Embeddable @EqualsAndHashCode +@AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ApplicationTimelineId implements Serializable { diff --git a/src/main/java/com/whylog/server/domain/decision/entity/DecisionBase.java b/src/main/java/com/whylog/server/domain/decision/entity/DecisionBase.java index 1c5f4f0..7e21d74 100644 --- a/src/main/java/com/whylog/server/domain/decision/entity/DecisionBase.java +++ b/src/main/java/com/whylog/server/domain/decision/entity/DecisionBase.java @@ -14,7 +14,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; -// 적용현황 +// 결정근거 @Entity @Getter @Table(name = "Decision_Base") @@ -29,4 +29,14 @@ public class DecisionBase extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "decision_id", nullable = false) private Decision decision; + + @Column(name = "content", columnDefinition = "TEXT") + private String content; + + public static DecisionBase create(Decision decision, String content) { + DecisionBase decisionBase = new DecisionBase(); + decisionBase.decision = decision; + decisionBase.content = content; + return decisionBase; + } } diff --git a/src/main/java/com/whylog/server/domain/decision/entity/DecisionTimeline.java b/src/main/java/com/whylog/server/domain/decision/entity/DecisionTimeline.java index d71a5f5..aa42c8b 100644 --- a/src/main/java/com/whylog/server/domain/decision/entity/DecisionTimeline.java +++ b/src/main/java/com/whylog/server/domain/decision/entity/DecisionTimeline.java @@ -28,4 +28,39 @@ public class DecisionTimeline extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "decision_id", nullable = false) private Decision decision; + + @Column(name = "timestamp", length = 30) + private String timestamp; + + // 타임라인 단계 + @Column(name = "step", length = 10) + private String step; + + // 타임라인 내용 요약 + @Column(name = "content", columnDefinition = "TEXT") + private String content; + + // 발화자 + @Column(name = "member_id") + private Long memberId; + + // 발화 원문 + @Column(name = "utterance", columnDefinition = "TEXT") + private String utterance; + + public static DecisionTimeline create(Decision decision, + String timestamp, + String step, + String content, + Long memberId, + String utterance) { + DecisionTimeline decisionTimeline = new DecisionTimeline(); + decisionTimeline.decision = decision; + decisionTimeline.timestamp = timestamp; + decisionTimeline.step = step; + decisionTimeline.content = content; + decisionTimeline.memberId = memberId; + decisionTimeline.utterance = utterance; + return decisionTimeline; + } } diff --git a/src/main/java/com/whylog/server/domain/decision/exception/ApplicationNotFoundException.java b/src/main/java/com/whylog/server/domain/decision/exception/ApplicationNotFoundException.java new file mode 100644 index 0000000..102259f --- /dev/null +++ b/src/main/java/com/whylog/server/domain/decision/exception/ApplicationNotFoundException.java @@ -0,0 +1,10 @@ +package com.whylog.server.domain.decision.exception; + +import com.whylog.server.global.apiPayload.exception.GeneralException; + +public class ApplicationNotFoundException extends GeneralException { + + public ApplicationNotFoundException() { + super(DecisionErrorCode.APPLICATION_NOT_FOUND); + } +} diff --git a/src/main/java/com/whylog/server/domain/decision/exception/DecisionErrorCode.java b/src/main/java/com/whylog/server/domain/decision/exception/DecisionErrorCode.java new file mode 100644 index 0000000..3787764 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/decision/exception/DecisionErrorCode.java @@ -0,0 +1,39 @@ +package com.whylog.server.domain.decision.exception; + +import com.whylog.server.global.apiPayload.code.BaseErrorCode; +import com.whylog.server.global.apiPayload.code.ErrorReasonDTO; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum DecisionErrorCode implements BaseErrorCode { + + DECISION_NOT_FOUND(HttpStatus.NOT_FOUND, "DECISION_404", "존재하지 않는 결정사항입니다."), + APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "APPLICATION_404", "존재하지 않는 적용사항입니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDTO getReason() { + return ErrorReasonDTO.builder() + .isSuccess(false) + .code(code) + .message(message) + .build(); + } + + @Override + public ErrorReasonDTO getReasonHttpStatus() { + return ErrorReasonDTO.builder() + .httpStatus(httpStatus) + .isSuccess(false) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/whylog/server/domain/decision/exception/DecisionNotFoundException.java b/src/main/java/com/whylog/server/domain/decision/exception/DecisionNotFoundException.java new file mode 100644 index 0000000..5491af8 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/decision/exception/DecisionNotFoundException.java @@ -0,0 +1,10 @@ +package com.whylog.server.domain.decision.exception; + +import com.whylog.server.global.apiPayload.exception.GeneralException; + +public class DecisionNotFoundException extends GeneralException { + + public DecisionNotFoundException() { + super(DecisionErrorCode.DECISION_NOT_FOUND); + } +} diff --git a/src/main/java/com/whylog/server/domain/decision/repository/ApplicationBaseRepository.java b/src/main/java/com/whylog/server/domain/decision/repository/ApplicationBaseRepository.java new file mode 100644 index 0000000..c9309d6 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/decision/repository/ApplicationBaseRepository.java @@ -0,0 +1,22 @@ +package com.whylog.server.domain.decision.repository; + +import com.whylog.server.domain.decision.entity.ApplicationBase; +import com.whylog.server.domain.decision.entity.ApplicationBaseId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface ApplicationBaseRepository extends JpaRepository { + + // 적용사항에 연결된 결정근거 목록 조회 + @Query(""" + SELECT ab + FROM ApplicationBase ab + JOIN FETCH ab.decisionBase db + WHERE ab.application.id = :applicationId + ORDER BY db.id ASC + """) + List findByApplicationId(@Param("applicationId") Long applicationId); +} diff --git a/src/main/java/com/whylog/server/domain/decision/repository/ApplicationTimelineRepository.java b/src/main/java/com/whylog/server/domain/decision/repository/ApplicationTimelineRepository.java new file mode 100644 index 0000000..7cb8950 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/decision/repository/ApplicationTimelineRepository.java @@ -0,0 +1,22 @@ +package com.whylog.server.domain.decision.repository; + +import com.whylog.server.domain.decision.entity.ApplicationTimeline; +import com.whylog.server.domain.decision.entity.ApplicationTimelineId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface ApplicationTimelineRepository extends JpaRepository { + + // 적용사항에 연결된 결정 타임라인 목록을 조회한다. + @Query(""" + SELECT at + FROM ApplicationTimeline at + JOIN FETCH at.decisionTimeline dt + WHERE at.application.id = :applicationId + ORDER BY dt.timestamp ASC, dt.id ASC + """) + List findByApplicationId(@Param("applicationId") Long applicationId); +} diff --git a/src/main/java/com/whylog/server/domain/decision/repository/DecisionBaseRepository.java b/src/main/java/com/whylog/server/domain/decision/repository/DecisionBaseRepository.java new file mode 100644 index 0000000..4b5c554 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/decision/repository/DecisionBaseRepository.java @@ -0,0 +1,7 @@ +package com.whylog.server.domain.decision.repository; + +import com.whylog.server.domain.decision.entity.DecisionBase; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DecisionBaseRepository extends JpaRepository { +} diff --git a/src/main/java/com/whylog/server/domain/decision/repository/DecisionTimelineRepository.java b/src/main/java/com/whylog/server/domain/decision/repository/DecisionTimelineRepository.java new file mode 100644 index 0000000..488ed0d --- /dev/null +++ b/src/main/java/com/whylog/server/domain/decision/repository/DecisionTimelineRepository.java @@ -0,0 +1,7 @@ +package com.whylog.server.domain.decision.repository; + +import com.whylog.server.domain.decision.entity.DecisionTimeline; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DecisionTimelineRepository extends JpaRepository { +} diff --git a/src/main/java/com/whylog/server/domain/decision/service/ApplicationQueryService.java b/src/main/java/com/whylog/server/domain/decision/service/ApplicationQueryService.java new file mode 100644 index 0000000..8ac93a0 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/decision/service/ApplicationQueryService.java @@ -0,0 +1,102 @@ +package com.whylog.server.domain.decision.service; + +import com.whylog.server.domain.decision.dto.ApplicationResponse; +import com.whylog.server.domain.decision.entity.Application; +import com.whylog.server.domain.decision.entity.ApplicationBase; +import com.whylog.server.domain.decision.entity.ApplicationTimeline; +import com.whylog.server.domain.decision.exception.ApplicationNotFoundException; +import com.whylog.server.domain.decision.repository.ApplicationBaseRepository; +import com.whylog.server.domain.decision.repository.ApplicationRepository; +import com.whylog.server.domain.decision.repository.ApplicationTimelineRepository; +import com.whylog.server.domain.user.entity.Member; +import com.whylog.server.domain.user.service.MemberUseCase; +import java.util.Map; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ApplicationQueryService { + + private final ApplicationRepository applicationRepository; + private final ApplicationBaseRepository applicationBaseRepository; + private final ApplicationTimelineRepository applicationTimelineRepository; + private final MemberUseCase memberUseCase; + + // 적용사항 상세 조회에 필요한 제목, 타임라인, 원문 맥락, 결정근거를 조회 + @Transactional(readOnly = true) + public ApplicationResponse.ApplicationDetailDTO getApplicationDetail(Long applicationId) { + Application application = applicationRepository.findById(applicationId) + .orElseThrow(ApplicationNotFoundException::new); + + // 적용사항에 연결된 근거/타임라인 원본 엔티티를 각각 조회 + List applicationBases = applicationBaseRepository.findByApplicationId(applicationId); + List applicationTimelines = applicationTimelineRepository.findByApplicationId(applicationId); + Map membersById = findMembersById(applicationTimelines); + + return ApplicationResponse.ApplicationDetailDTO.builder() + .applicationId(application.getId()) + .name(application.getName()) + .decisionReasons(toDecisionReasonItems(applicationBases)) + .decisionTimelines(toDecisionTimelineItems(applicationTimelines)) + .decisionContexts(toDecisionContextItems(applicationTimelines, membersById)) + .decisionReasonCount(applicationBases.size()) + .build(); + } + + // 연결 테이블을 따라 적용사항에 속한 결정근거 목록을 응답 DTO로 변환 + private List toDecisionReasonItems(List applicationBases) { + return applicationBases.stream() + .map(applicationBase -> ApplicationResponse.DecisionReasonItemDTO.builder() + .reasonId(String.valueOf(applicationBase.getDecisionBase().getId())) + .title(applicationBase.getDecisionBase().getContent()) + .build()) + .toList(); + } + + // 타임라인 요약 정보 + private List toDecisionTimelineItems(List applicationTimelines) { + return applicationTimelines.stream() + .map(applicationTimeline -> ApplicationResponse.DecisionTimelineItemDTO.builder() + .time(applicationTimeline.getDecisionTimeline().getTimestamp()) + .step(applicationTimeline.getDecisionTimeline().getStep()) + .content(applicationTimeline.getDecisionTimeline().getContent()) + .build()) + .toList(); + } + + // 원문 맥락은 발화자 정보와 원문 발화를 함께 내려줌 + private List toDecisionContextItems(List applicationTimelines, + Map membersById) { + return applicationTimelines.stream() + .map(applicationTimeline -> { + Long memberId = applicationTimeline.getDecisionTimeline().getMemberId(); + Member member = memberId != null ? membersById.get(memberId) : null; + + return ApplicationResponse.DecisionContextItemDTO.builder() + .time(applicationTimeline.getDecisionTimeline().getTimestamp()) + .memberId(memberId) + .memberName(member != null ? member.getName() : null) + .profileImage(member != null ? member.getProfileImage() : null) + .dialogueContent(applicationTimeline.getDecisionTimeline().getUtterance()) + .build(); + }) + .toList(); + } + + // 타임라인에 포함된 발화자들을 한 번에 조회해 memberId 기준 맵으로 구성한다. + private Map findMembersById(List applicationTimelines) { + List memberIds = applicationTimelines.stream() + .map(applicationTimeline -> applicationTimeline.getDecisionTimeline().getMemberId()) + .filter(memberId -> memberId != null) + .distinct() + .toList(); + + return memberUseCase.findMembersByIds(memberIds).stream() + .collect(Collectors.toMap(Member::getId, Function.identity())); + } +} diff --git a/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java b/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java index 421a469..45c1504 100644 --- a/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java +++ b/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java @@ -5,6 +5,7 @@ import com.whylog.server.domain.meeting.enums.MeetingStatus; import com.whylog.server.domain.meeting.exception.MeetingErrorCode; import com.whylog.server.domain.meeting.service.MeetingCommandService; +import com.whylog.server.domain.meeting.service.MeetingAnalysisService; import com.whylog.server.domain.meeting.service.MeetingQueryService; import com.whylog.server.domain.meeting.service.MeetingRtcService; import com.whylog.server.domain.team.exception.TeamErrorCode; @@ -39,6 +40,7 @@ public class MeetingController { private final MeetingCommandService meetingCommandService; private final MeetingQueryService meetingQueryService; private final MeetingRtcService meetingRtcService; + private final MeetingAnalysisService meetingAnalysisService; @GetMapping("/teams/{teamId}/meetings") @Operation(summary = "회의 목록 조회 API", description = """ @@ -134,6 +136,20 @@ public ApiResponse endMeeting( return ApiResponse.onSuccess(meetingCommandService.endMeeting(memberId, meetingId)); } + @PostMapping("/meetings/{meetingId}/analysis-test") + @Operation(summary = "회의 분석 저장 테스트 API", description = """ + FastAPI 상태 조회 응답 JSON을 그대로 전달해 결정사항/적용사항 저장 로직만 테스트하는 API입니다. + 외부 분석 호출 없이 저장 흐름만 검증할 때 사용합니다. + """) + public ApiResponse analyzeTestResponse( + @Parameter(hidden = true) @CurrentMember Long memberId, + @PathVariable Long meetingId, + @RequestBody MeetingRequest.MeetingAnalysisTestDTO request + ) { + meetingAnalysisService.persistTestMeetingAnalysis(memberId, meetingId, request); + return ApiResponse.onSuccess(null); + } + @DeleteMapping("/meetings/{meetingId}") @Operation(summary = "회의 삭제 API", description = """ 특정 회의를 삭제하는 API입니다. diff --git a/src/main/java/com/whylog/server/domain/meeting/dto/MeetingRequest.java b/src/main/java/com/whylog/server/domain/meeting/dto/MeetingRequest.java index 2fb09eb..63f655b 100644 --- a/src/main/java/com/whylog/server/domain/meeting/dto/MeetingRequest.java +++ b/src/main/java/com/whylog/server/domain/meeting/dto/MeetingRequest.java @@ -1,5 +1,6 @@ package com.whylog.server.domain.meeting.dto; +import com.whylog.server.global.external.fast.dto.response.TranscribeApplicationRunResponse; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import lombok.*; @@ -23,4 +24,24 @@ public static class MeetingCreateDTO { private LocalDateTime startDateTime; } + + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + @Builder + @Schema(description = "회의 분석 저장 테스트 요청") + public static class MeetingAnalysisTestDTO { + + @Schema(description = "요청 성공 여부", example = "true") + private Boolean isSuccess; + + @Schema(description = "응답 코드", example = "TRANSCRIBE_200") + private String code; + + @Schema(description = "응답 메시지", example = "비동기 실행 상태 조회에 성공했습니다.") + private String message; + + @Schema(description = "FastAPI 비동기 실행 상태 조회 결과") + private TranscribeApplicationRunResponse result; + } } diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java index 0369e75..c69984a 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java @@ -3,17 +3,27 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.whylog.server.domain.decision.entity.Application; +import com.whylog.server.domain.decision.entity.ApplicationBase; +import com.whylog.server.domain.decision.entity.ApplicationTimeline; import com.whylog.server.domain.decision.entity.Decision; +import com.whylog.server.domain.decision.entity.DecisionBase; +import com.whylog.server.domain.decision.entity.DecisionTimeline; +import com.whylog.server.domain.decision.repository.ApplicationBaseRepository; +import com.whylog.server.domain.decision.repository.ApplicationTimelineRepository; import com.whylog.server.domain.decision.repository.ApplicationRepository; +import com.whylog.server.domain.decision.repository.DecisionBaseRepository; +import com.whylog.server.domain.decision.repository.DecisionTimelineRepository; import com.whylog.server.domain.decision.repository.DecisionRepository; import com.whylog.server.domain.meeting.dto.MeetingResponse; import com.whylog.server.domain.meeting.entity.Dialogue; import com.whylog.server.domain.meeting.entity.Meeting; import com.whylog.server.domain.meeting.entity.MeetingAnalysis; import com.whylog.server.domain.meeting.entity.MeetingMember; +import com.whylog.server.domain.meeting.exception.MeetingInvalidMemberException; import com.whylog.server.domain.meeting.exception.MeetingAudioNotReadyException; import com.whylog.server.domain.meeting.repository.DialogueRepository; import com.whylog.server.domain.meeting.repository.MeetingAnalysisRepository; +import com.whylog.server.domain.meeting.repository.MeetingMemberRepository; import com.whylog.server.domain.user.entity.Member; import com.whylog.server.global.external.fast.client.FastApiTranscribeClient; import com.whylog.server.global.external.fast.dto.FastApiResponse; @@ -56,9 +66,14 @@ public class MeetingAnalysisService { private final MeetingAudioFileService meetingAudioFileService; private final FastApiTranscribeClient fastApiTranscribeClient; private final ApplicationRepository applicationRepository; + private final ApplicationBaseRepository applicationBaseRepository; + private final ApplicationTimelineRepository applicationTimelineRepository; + private final DecisionBaseRepository decisionBaseRepository; + private final DecisionTimelineRepository decisionTimelineRepository; private final DecisionRepository decisionRepository; private final MeetingAnalysisRepository meetingAnalysisRepository; private final DialogueRepository dialogueRepository; + private final MeetingMemberRepository meetingMemberRepository; private final TransactionTemplate transactionTemplate; private final ObjectMapper objectMapper; @@ -72,6 +87,23 @@ public void analyzeMeetingAudio(Long meetingId) { persistMeetingAnalysis(meeting, finalResponse); } + // FastAPI 응답 JSON을 받아 저장 로직을 테스트한다. + public void persistTestMeetingAnalysis(Long memberId, + Long meetingId, + com.whylog.server.domain.meeting.dto.MeetingRequest.MeetingAnalysisTestDTO request) { + if (!meetingMemberRepository.existsByMemberIdAndMeetingId(memberId, meetingId)) { + throw new MeetingInvalidMemberException(); + } + + Meeting meeting = meetingUseCase.findMeetingWithMembersById(meetingId); + TranscribeApplicationRunResponse response = request.getResult(); + if (response == null) { + throw new FastApiException(FastApiErrorCode.FAST_API_RESPONSE_EMPTY); + } + + persistMeetingAnalysis(meeting, response); + } + // 회의 오디오가 준비될 때까지 재시도하며 오디오 응답을 확보한다. private MeetingResponse.AudioDTO resolveAudioWithRetry(Meeting meeting) { MeetingAudioNotReadyException lastException = null; @@ -191,8 +223,6 @@ private void persistMeetingAnalysis(Meeting meeting, TranscribeApplicationRunRes transactionTemplate.executeWithoutResult(status -> { Meeting managedMeeting = meetingUseCase.findMeetingWithMembersById(meeting.getId()); - meetingAnalysisRepository.deleteByMeetingId(managedMeeting.getId()); - dialogueRepository.deleteByMeetingId(managedMeeting.getId()); MeetingAnalysis meetingAnalysis = MeetingAnalysis.create(managedMeeting, payload); meetingAnalysisRepository.save(meetingAnalysis); @@ -226,19 +256,97 @@ private Decision createDecisionIfAbsent(Meeting meeting) { private void replaceApplications(Long meetingId, Decision decision, List applications) { - List newApplications = applications.stream() - .map(TranscribeApplicationRunResponse.ApplicationResponse::applicationTitle) - .filter(title -> title != null && !title.isBlank()) - .map(title -> Application.create(decision, title.trim())) + List validApplications = applications.stream() + .filter(application -> application != null + && application.applicationTitle() != null + && !application.applicationTitle().isBlank()) + .toList(); + + List newApplications = validApplications.stream() + .map(application -> Application.create( + decision, + application.applicationTitle().trim() + )) .toList(); if (!newApplications.isEmpty()) { - applicationRepository.saveAll(newApplications); + List savedApplications = applicationRepository.saveAllAndFlush(newApplications); + persistApplicationDetails(savedApplications, validApplications); log.info("적용사항 저장 완료: meetingId={}, decisionId={}, applicationCount={}", meetingId, decision.getId(), newApplications.size()); } } + // 저장된 적용사항 엔티티에 reason/timeline 세부 정보를 순서대로 연결 저장한다. + private void persistApplicationDetails(List applications, + List applicationResponses) { + for (int index = 0; index < applications.size(); index++) { + Application application = applications.get(index); + TranscribeApplicationRunResponse.ApplicationResponse response = applicationResponses.get(index); + persistApplicationReasons(application, response.applicationReasons()); + persistApplicationTimelines(application, response.timeline()); + } + } + + // 적용사항 reason 목록을 DecisionBase/ApplicationBase로 분리 저장한다. + private void persistApplicationReasons(Application application, List reasons) { + List validReasons = safeStrings(reasons); + if (validReasons.isEmpty()) { + return; + } + + List decisionBases = validReasons.stream() + .map(reason -> DecisionBase.create(application.getDecision(), reason.trim())) + .toList(); + List savedDecisionBases = decisionBaseRepository.saveAllAndFlush(decisionBases); + + List applicationBases = savedDecisionBases.stream() + .map(decisionBase -> ApplicationBase.create(application, decisionBase)) + .toList(); + applicationBaseRepository.saveAllAndFlush(applicationBases); + } + + // 적용사항 timeline 목록을 DecisionTimeline/ApplicationTimeline으로 분리 저장한다. + private void persistApplicationTimelines(Application application, + List timelines) { + if (timelines == null || timelines.isEmpty()) { + return; + } + + List decisionTimelines = timelines.stream() + .filter(timeline -> timeline != null) + .map(timeline -> DecisionTimeline.create( + application.getDecision(), + timeline.timestamp(), + timeline.step(), + timeline.content(), + meetingUseCase.resolveMemberIdBySpeakerId(application.getDecision().getMeeting().getId(), timeline.speakerId()), + timeline.utterance() + )) + .toList(); + + if (decisionTimelines.isEmpty()) { + return; + } + + List savedDecisionTimelines = decisionTimelineRepository.saveAllAndFlush(decisionTimelines); + List applicationTimelines = savedDecisionTimelines.stream() + .map(decisionTimeline -> ApplicationTimeline.create(application, decisionTimeline)) + .toList(); + applicationTimelineRepository.saveAllAndFlush(applicationTimelines); + } + + // null 이거나 비어 있는 문자열을 제외한 값만 반환한다. + private List safeStrings(List values) { + if (values == null || values.isEmpty()) { + return List.of(); + } + + return values.stream() + .filter(value -> value != null && !value.isBlank()) + .toList(); + } + // OverallAnalysis를 MeetingAnalysis 저장용 payload로 변환한다. private MeetingAnalysis.MeetingAnalysisPayload buildMeetingAnalysisPayload( diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingSpeakerResolver.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingSpeakerResolver.java new file mode 100644 index 0000000..14b9fb5 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingSpeakerResolver.java @@ -0,0 +1,6 @@ +package com.whylog.server.domain.meeting.service; + +public interface MeetingSpeakerResolver { + + Long resolveMemberIdBySpeakerId(Long meetingId, String speakerId); +} diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java index 5a972fa..b97cc47 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java @@ -3,7 +3,6 @@ import com.whylog.server.domain.meeting.entity.Meeting; import com.whylog.server.domain.meeting.entity.MeetingMember; import com.whylog.server.domain.meeting.exception.MeetingNotFoundException; -import com.whylog.server.domain.meeting.repository.MeetingAnalysisRepository; import com.whylog.server.domain.meeting.repository.MeetingRepository; import com.whylog.server.domain.user.entity.Member; import lombok.RequiredArgsConstructor; @@ -13,10 +12,9 @@ @Service @RequiredArgsConstructor -public class MeetingUseCase { +public class MeetingUseCase implements MeetingSpeakerResolver { private final MeetingRepository meetingRepository; - private final MeetingAnalysisRepository meetingAnalysisRepository; public Meeting findMeetingById(Long id){ return meetingRepository.findById(id) @@ -48,5 +46,9 @@ public Meeting findWithAnalysisByMeetingId(Long meetingId) { return meetingRepository.findByMeetingId(meetingId) .orElseThrow(MeetingNotFoundException::new); } - + @Override + public Long resolveMemberIdBySpeakerId(Long meetingId, String speakerId) { + // TODO: 대화 내역을 기준으로 speakerId와 실제 memberId를 매칭한다. + return 1L; + } } diff --git a/src/main/java/com/whylog/server/domain/user/service/MemberUseCase.java b/src/main/java/com/whylog/server/domain/user/service/MemberUseCase.java index bb2aa84..09cbed9 100644 --- a/src/main/java/com/whylog/server/domain/user/service/MemberUseCase.java +++ b/src/main/java/com/whylog/server/domain/user/service/MemberUseCase.java @@ -3,6 +3,7 @@ import com.whylog.server.domain.user.entity.Member; import com.whylog.server.domain.user.exception.MemberNotFoundException; import com.whylog.server.domain.user.repository.MemberRepository; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -23,4 +24,8 @@ public Member findMemberByEmail(String email) { .orElseThrow(MemberNotFoundException::new); } + public List findMembersByIds(List memberIds) { + return memberRepository.findAllById(memberIds); + } + }