Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -182,27 +182,42 @@ public ApiResponse<MeetingResponse.HistoryListDTO> getHistory(
}

@GetMapping("/meetings/{meetingId}/analysis")
@Operation(summary = "회의 분석 결과 조회 API", description = "회의 분석 결과를 조회하는 API입니다.")
@Operation(summary = "회의 분석 결과 조회 API", description = """

회의 분석 결과를 조회하는 API입니다.<br>
회의가 존재하지 않으면 MEETING_NOT_FOUND(404)를 반환하고<br>
회의가 존재하지만 아직 분석 중이거나 분석 결과를 만들지 못 헀을 경우에는<br>
200응답으로 is_analyzed=false인 응답을 반환합니다.<br>

""")
@ApiErrorCodeExamples({
@ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"),
@ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"),
@ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND")
})
public ApiResponse<MeetingResponse.AnalysisResultDTO> getAnalysisResult(
@PathVariable Long meetingId) {
return ApiResponse.onSuccess(null);
return ApiResponse.onSuccess(meetingQueryService.getAnalysis(meetingId));
}

@GetMapping("/meetings/{meetingId}/audio")
@Operation(summary = "오디오 리플레이 API", description = "회의 오디오 파일을 리플레이하는 API입니다.")
@Operation(summary = "오디오 리플레이 API", description = """
회의 녹음본을 브라우저에서 바로 재생할 수 있는 정보를 조회하는 API입니다.

응답의 `audioUrl`은 10분짜리 presigned URL입니다.
프론트는 이 값을 `<audio src>` 또는 `new Audio(audioUrl)`로 바로 사용할 수 있습니다.

`audioDuration`은 초 단위 재생 시간이며, 녹음본이 없거나 길이를 확인할 수 없으면 `null`입니다.
""")
@ApiErrorCodeExamples({
@ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"),
@ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"),
@ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND")
@ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND"),
@ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_AUDIO_NOT_READY")
})
public ApiResponse<MeetingResponse.AudioDTO> getAudio(
@PathVariable Long meetingId) {
return ApiResponse.onSuccess(null);
return ApiResponse.onSuccess(meetingQueryService.getMeetingAudio(meetingId));
}

// @GetMapping("/meetings/{meetingId}/applications")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.whylog.server.domain.meeting.dto;

import com.whylog.server.domain.meeting.entity.MeetingAnalysis;
import com.whylog.server.domain.meeting.enums.MeetingStatus;
import com.whylog.server.domain.meeting.socket.MeetingParticipant;
import io.swagger.v3.oas.annotations.media.Schema;
Expand Down Expand Up @@ -153,6 +154,13 @@ public static class MeetingDetailDTO {

@Schema(description = "회의 참여자 목록", example = "[1, 2, 3]")
private List<MeetingParticipantInfo> members;

@Schema(
description = "녹음 파일의 재생 시간(초). 녹음본이 아직 없거나 길이를 확인할 수 없으면 null 입니다.",
example = "120",
nullable = true
)
private Integer audioDuration;
}

@Getter
Expand Down Expand Up @@ -216,25 +224,78 @@ public static class AnalysisResultDTO {
@Schema(description = "회의 ID", example = "1")
private Long meetingId;

@Schema(description = "분석 내용", example = "뭐 이건 나중에 논의 주제, 핵심맥락 등 들어갈 자리입니다.")
private String content;
@Schema(description = "분석 완료 여부", example = "true")
private Boolean isAnalyzed;

@Schema(description = "회의 제목", example = "Whylog 프로젝트 서버 저장 및 배포 관련 논의", nullable = true)
private String meetingTitle;

@Schema(description = "회의 목적", example = "서버 저장 시도 및 배포 DB 상태 점검", nullable = true)
private String meetingPurpose;

@Schema(description = "회의 재생 시간(초)", example = "43", nullable = true)
private Integer audioDuration;

@Schema(description = "논의 주제 목록", example = "[\"서버 저장 상태 확인\", \"배포 DB 환경\"]", nullable = true)
private List<String> topics;

@Schema(description = "핵심 맥락 목록", example = "[\"서버에 데이터가 저장되지 않은 상태로 추정됨\"]", nullable = true)
private List<String> coreContext;

@Schema(description = "적용사항 제목 목록", example = "[\"서버 저장 절차 정리\", \"배포 DB 점검\"]", nullable = true)
private List<String> applicationTitles;

@Schema(description = "적용사항 사유 목록", example = "[\"저장 실패 원인을 추적하기 위해\", \"배포 환경 차이를 확인하기 위해\"]", nullable = true)
private List<String> applicationReasons;

public static AnalysisResultDTO createFalse(Long meetingId) {
return AnalysisResultDTO.builder()
.meetingId(meetingId)
.isAnalyzed(false)
.build();
}

public static AnalysisResultDTO create(MeetingAnalysis ma, Integer audioDuration) {
return AnalysisResultDTO.builder()
.analysisId(ma.getId())
.meetingId(ma.getMeeting().getId())
.isAnalyzed(true)
.meetingTitle(ma.getMeetingTitle())
.meetingPurpose(ma.getMeetingPurpose())
.audioDuration(audioDuration)
.topics(ma.getTopics())
.coreContext(ma.getCoreContext())
.applicationTitles(ma.getApplicationTitles())
.applicationReasons(ma.getApplicationReasons())
.build();
}

}

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "회의 오디오 응답")
@Schema(description = "회의 오디오 응답. 프론트엔드는 audioUrl을 <audio src> 또는 Audio()로 바로 재생하면 됩니다.")
public static class AudioDTO {

@Schema(description = "오디오 ID", example = "1")
private Long audioId;

@Schema(description = "회의 ID", example = "1")
private Long meetingId;

@Schema(description = "오디오 URL", example = "https://example.com/audio/meeting-1.mp3")
@Schema(description = "실제 재생 가능한 오디오 저장 키(S3 object key). 예: recordings/meeting-1-audio.mp4", example = "recordings/meeting-1-audio.mp4")
private String audioKey;

@Schema(
description = "10분짜리 presigned URL. 브라우저는 이 URL로 오디오를 직접 받아 재생합니다.",
example = "https://example.com/presigned-audio-url"
)
private String audioUrl;

@Schema(
description = "녹음 파일의 재생 시간(초). 아직 파일이 없거나 길이를 알 수 없으면 null 입니다.",
example = "120",
nullable = true
)
private Integer audioDuration;
}

@Getter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,13 @@ public class Dialogue extends BaseEntity {

@Column(name = "speech_datetime", nullable = false)
private LocalDateTime speechDateTime;

public static Dialogue create(Meeting meeting, Member member, String content, LocalDateTime speechDateTime) {
Dialogue dialogue = new Dialogue();
dialogue.meeting = meeting;
dialogue.member = member;
dialogue.content = content;
dialogue.speechDateTime = speechDateTime;
return dialogue;
}
}
21 changes: 17 additions & 4 deletions src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,7 @@
import java.util.ArrayList;
import java.util.List;

import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.*;

@Entity
@Getter
Expand All @@ -42,6 +39,14 @@ public class Meeting extends BaseEntity {
@Column(name = "end_date_time")
private LocalDateTime endDateTime;

@Setter
@Column(name = "audio_key", length = 255)
private String audioKey;

@Setter
@Column(name = "audio_egress_id", length = 100)
private String audioEgressId;

@Builder
private Meeting(String name, Team team) {
this.name = name;
Expand Down Expand Up @@ -99,4 +104,12 @@ public String getElapse() {
//
// @OneToOne(mappedBy = "meeting", cascade = CascadeType.ALL, orphanRemoval = true)
// private Decision decision;

public void attachMeetingAnalysis(MeetingAnalysis meetingAnalysis) {
this.meetingAnalysis = meetingAnalysis;
}

public void addDialogue(Dialogue dialogue) {
this.dialogues.add(dialogue);
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package com.whylog.server.domain.meeting.entity;

import com.whylog.server.global.entity.BaseEntity;
import com.whylog.server.global.util.json.StringListJsonConverter;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;

@Entity
@Getter
Expand All @@ -20,4 +22,70 @@ public class MeetingAnalysis extends BaseEntity {
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "meeting_id", nullable = false, unique = true)
private Meeting meeting;

@Column(name = "meeting_title")
private String meetingTitle;

@Column(name = "meeting_purpose")
private String meetingPurpose;

@Column(name = "meeting_duration")
private String meetingDuration;

@Column(name = "analysis_content", columnDefinition = "LONGTEXT")
private String analysisContent;

@Convert(converter = StringListJsonConverter.class)
@Column(name = "topics", columnDefinition = "LONGTEXT")
private List<String> topics;

@Convert(converter = StringListJsonConverter.class)
@Column(name = "core_context", columnDefinition = "LONGTEXT")
private List<String> coreContext;

@Convert(converter = StringListJsonConverter.class)
@Column(name = "application_titles", columnDefinition = "LONGTEXT")
private List<String> applicationTitles;

@Convert(converter = StringListJsonConverter.class)
@Column(name = "application_reasons", columnDefinition = "LONGTEXT")
private List<String> applicationReasons;

public static MeetingAnalysis create(Meeting meeting, MeetingAnalysisPayload payload) {
MeetingAnalysis meetingAnalysis = new MeetingAnalysis();
meetingAnalysis.meeting = meeting;
meetingAnalysis.apply(payload);
return meetingAnalysis;
}

public void updateAnalysis(MeetingAnalysisPayload payload) {
apply(payload);
}

private void apply(MeetingAnalysisPayload payload) {
if (payload == null) {
return;
}

this.meetingTitle = payload.meetingTitle();
this.meetingPurpose = payload.meetingPurpose();
this.meetingDuration = payload.meetingDuration();
this.analysisContent = payload.analysisContent();
this.topics = payload.topics();
this.coreContext = payload.coreContext();
this.applicationTitles = payload.applicationTitles();
this.applicationReasons = payload.applicationReasons();
}

public record MeetingAnalysisPayload(
String meetingTitle,
String meetingPurpose,
String meetingDuration,
String analysisContent,
List<String> topics,
List<String> coreContext,
List<String> applicationTitles,
List<String> applicationReasons
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.whylog.server.domain.meeting.exception;

import com.whylog.server.global.apiPayload.exception.GeneralException;

public class MeetingAudioNotReadyException extends GeneralException {

public MeetingAudioNotReadyException() {
super(MeetingErrorCode.MEETING_AUDIO_NOT_READY);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public enum MeetingErrorCode implements BaseErrorCode {
MEETING_ALREADY_ENDED(HttpStatus.CONFLICT, "MEETING_409", "이미 종료된 회의입니다."),
MEETING_INVALID_MEMBER(HttpStatus.CONFLICT, "MEETING_410", "회의에 소속된 참여자가 아닙니다."),
MEETING_NOT_OWNER(HttpStatus.FORBIDDEN, "MEETING_403", "회의 삭제 권한이 없습니다."),
MEETING_AUDIO_NOT_READY(HttpStatus.CONFLICT, "MEETING_411", "회의 녹음본이 아직 생성되지 않았거나 업로드가 완료되지 않았습니다."),
;

private final HttpStatus httpStatus;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.Optional;

public interface MeetingAnalysisRepository extends JpaRepository<MeetingAnalysis, Long> {

@Modifying
Expand All @@ -15,4 +17,11 @@ public interface MeetingAnalysisRepository extends JpaRepository<MeetingAnalysis
@Modifying
@Query("DELETE FROM MeetingAnalysis ma WHERE ma.meeting.id = :meetingId")
void deleteByMeetingId(@Param("meetingId") Long meetingId);

@Query("""
SELECT ma FROM MeetingAnalysis ma
LEFT JOIN FETCH ma.meeting m
WHERE m.id = :meetingId
""")
Optional<MeetingAnalysis> findByMeetingId(@Param("meetingId") Long meetingId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ public interface MeetingRepository extends JpaRepository<Meeting, Long> {
@Query("""
SELECT DISTINCT m
FROM Meeting m
LEFT JOIN FETCH m.meetingMembers
LEFT JOIN FETCH m.meetingMembers mm
LEFT JOIN FETCH mm.member
WHERE m.id = :meetingId
""")
Optional<Meeting> findWithMembers(@Param("meetingId") Long meetingId);
Expand Down
Loading
Loading