From e974ecb9117ae7b2279d8c448ef633bd17e31241 Mon Sep 17 00:00:00 2001 From: JWoong-01 Date: Tue, 27 Jan 2026 20:28:43 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20stt=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/dokdok/ai/client/AiSttClient.java | 27 +++ .../java/com/dokdok/ai/dto/SttRequest.java | 8 + .../java/com/dokdok/ai/dto/SttResponse.java | 9 + src/main/java/com/dokdok/stt/api/SttApi.java | 75 ++++++++ .../dokdok/stt/controller/SttController.java | 29 ++++ .../com/dokdok/stt/dto/SttJobResponse.java | 28 +++ .../java/com/dokdok/stt/entity/SttJob.java | 72 ++++++++ .../com/dokdok/stt/entity/SttJobStatus.java | 8 + .../stt/repository/SttJobRepository.java | 10 ++ .../com/dokdok/stt/service/SttJobService.java | 163 ++++++++++++++++++ src/main/resources/application-dev.yaml | 4 + src/main/resources/application.yaml | 4 + 12 files changed, 437 insertions(+) create mode 100644 src/main/java/com/dokdok/ai/client/AiSttClient.java create mode 100644 src/main/java/com/dokdok/ai/dto/SttRequest.java create mode 100644 src/main/java/com/dokdok/ai/dto/SttResponse.java create mode 100644 src/main/java/com/dokdok/stt/api/SttApi.java create mode 100644 src/main/java/com/dokdok/stt/controller/SttController.java create mode 100644 src/main/java/com/dokdok/stt/dto/SttJobResponse.java create mode 100644 src/main/java/com/dokdok/stt/entity/SttJob.java create mode 100644 src/main/java/com/dokdok/stt/entity/SttJobStatus.java create mode 100644 src/main/java/com/dokdok/stt/repository/SttJobRepository.java create mode 100644 src/main/java/com/dokdok/stt/service/SttJobService.java diff --git a/src/main/java/com/dokdok/ai/client/AiSttClient.java b/src/main/java/com/dokdok/ai/client/AiSttClient.java new file mode 100644 index 00000000..82cdbb01 --- /dev/null +++ b/src/main/java/com/dokdok/ai/client/AiSttClient.java @@ -0,0 +1,27 @@ +package com.dokdok.ai.client; + +import com.dokdok.ai.dto.SttRequest; +import com.dokdok.ai.dto.SttResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +@Component +@RequiredArgsConstructor +public class AiSttClient { + + private final WebClient aiWebClient; + + @Value("${ai.api.stt-path}") + private String sttPath; + + public SttResponse requestStt(SttRequest request) { + return aiWebClient.post() + .uri(sttPath) + .bodyValue(request) + .retrieve() + .bodyToMono(SttResponse.class) + .block(); + } +} diff --git a/src/main/java/com/dokdok/ai/dto/SttRequest.java b/src/main/java/com/dokdok/ai/dto/SttRequest.java new file mode 100644 index 00000000..2cd118c6 --- /dev/null +++ b/src/main/java/com/dokdok/ai/dto/SttRequest.java @@ -0,0 +1,8 @@ +package com.dokdok.ai.dto; + +public record SttRequest( + Long jobId, + String filePath, + String language +) { +} diff --git a/src/main/java/com/dokdok/ai/dto/SttResponse.java b/src/main/java/com/dokdok/ai/dto/SttResponse.java new file mode 100644 index 00000000..af43f958 --- /dev/null +++ b/src/main/java/com/dokdok/ai/dto/SttResponse.java @@ -0,0 +1,9 @@ +package com.dokdok.ai.dto; + +public record SttResponse( + Long jobId, + String status, + String text, + String errorMessage +) { +} diff --git a/src/main/java/com/dokdok/stt/api/SttApi.java b/src/main/java/com/dokdok/stt/api/SttApi.java new file mode 100644 index 00000000..1e80cbaa --- /dev/null +++ b/src/main/java/com/dokdok/stt/api/SttApi.java @@ -0,0 +1,75 @@ +package com.dokdok.stt.api; + +import com.dokdok.global.response.ApiResponse; +import com.dokdok.stt.dto.SttJobResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "STT", description = "STT 작업 관련 API") +@RequestMapping("/api/meetings/{meetingId}/stt/jobs") +public interface SttApi { + + @Operation( + summary = "STT 작업 생성", + description = "오디오 파일을 업로드하여 STT 작업을 생성합니다.", + parameters = { + @Parameter(name = "meetingId", description = "약속 식별자", in = ParameterIn.PATH, required = true) + } + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "201", + description = "STT 작업 생성 성공", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = SttJobResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "약속 멤버가 아님"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "약속을 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류") + }) + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + ResponseEntity> createJob( + @PathVariable Long meetingId, + @RequestPart("file") MultipartFile file + ); + + @Operation( + summary = "STT 작업 조회", + description = "STT 작업 상태/결과를 조회합니다.", + parameters = { + @Parameter(name = "meetingId", description = "약속 식별자", in = ParameterIn.PATH, required = true), + @Parameter(name = "jobId", description = "STT 작업 식별자", in = ParameterIn.PATH, required = true) + } + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "STT 작업 조회 성공", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = SttJobResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "약속 멤버가 아님"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "작업을 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류") + }) + @GetMapping(value = "/{jobId}", produces = MediaType.APPLICATION_JSON_VALUE) + ResponseEntity> getJob( + @PathVariable Long meetingId, + @PathVariable Long jobId + ); +} diff --git a/src/main/java/com/dokdok/stt/controller/SttController.java b/src/main/java/com/dokdok/stt/controller/SttController.java new file mode 100644 index 00000000..4c8238fd --- /dev/null +++ b/src/main/java/com/dokdok/stt/controller/SttController.java @@ -0,0 +1,29 @@ +package com.dokdok.stt.controller; + +import com.dokdok.global.response.ApiResponse; +import com.dokdok.stt.api.SttApi; +import com.dokdok.stt.dto.SttJobResponse; +import com.dokdok.stt.service.SttJobService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequiredArgsConstructor +public class SttController implements SttApi { + + private final SttJobService sttJobService; + + @Override + public ResponseEntity> createJob(Long meetingId, MultipartFile file) { + SttJobResponse response = sttJobService.createJob(meetingId, file); + return ApiResponse.created(response, "STT 작업이 생성되었습니다."); + } + + @Override + public ResponseEntity> getJob(Long meetingId, Long jobId) { + SttJobResponse response = sttJobService.getJob(meetingId, jobId); + return ApiResponse.success(response, "STT 작업 조회를 완료했습니다."); + } +} diff --git a/src/main/java/com/dokdok/stt/dto/SttJobResponse.java b/src/main/java/com/dokdok/stt/dto/SttJobResponse.java new file mode 100644 index 00000000..9699751b --- /dev/null +++ b/src/main/java/com/dokdok/stt/dto/SttJobResponse.java @@ -0,0 +1,28 @@ +package com.dokdok.stt.dto; + +import com.dokdok.stt.entity.SttJob; +import com.dokdok.stt.entity.SttJobStatus; + +import java.time.LocalDateTime; + +public record SttJobResponse( + Long jobId, + Long meetingId, + Long userId, + SttJobStatus status, + String resultText, + String errorMessage, + LocalDateTime createdAt +) { + public static SttJobResponse from(SttJob job) { + return new SttJobResponse( + job.getId(), + job.getMeeting().getId(), + job.getUser().getId(), + job.getStatus(), + job.getResultText(), + job.getErrorMessage(), + job.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/dokdok/stt/entity/SttJob.java b/src/main/java/com/dokdok/stt/entity/SttJob.java new file mode 100644 index 00000000..380942e8 --- /dev/null +++ b/src/main/java/com/dokdok/stt/entity/SttJob.java @@ -0,0 +1,72 @@ +package com.dokdok.stt.entity; + +import com.dokdok.global.BaseTimeEntity; +import com.dokdok.meeting.entity.Meeting; +import com.dokdok.user.entity.User; +import jakarta.persistence.*; +import lombok.*; +import lombok.experimental.SuperBuilder; + +@Entity +@Table(name = "stt_job") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@SuperBuilder +public class SttJob extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "stt_job_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "meeting_id", nullable = false) + private Meeting meeting; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + @Builder.Default + private SttJobStatus status = SttJobStatus.PENDING; + + @Column(name = "result_text", columnDefinition = "TEXT") + private String resultText; + + @Column(name = "error_message", columnDefinition = "TEXT") + private String errorMessage; + + @Column(name = "original_filename", length = 255) + private String originalFilename; + + @Column(name = "content_type", length = 100) + private String contentType; + + @Column(name = "file_size") + private Long fileSize; + + @Column(name = "temp_file_path", length = 500) + private String tempFilePath; + + @Column(name = "language", length = 20) + @Builder.Default + private String language = "ko-KR"; + + public void markProcessing() { + this.status = SttJobStatus.PROCESSING; + } + + public void markDone(String resultText) { + this.status = SttJobStatus.DONE; + this.resultText = resultText; + this.errorMessage = null; + } + + public void markFailed(String errorMessage) { + this.status = SttJobStatus.FAILED; + this.errorMessage = errorMessage; + } +} diff --git a/src/main/java/com/dokdok/stt/entity/SttJobStatus.java b/src/main/java/com/dokdok/stt/entity/SttJobStatus.java new file mode 100644 index 00000000..259ccfce --- /dev/null +++ b/src/main/java/com/dokdok/stt/entity/SttJobStatus.java @@ -0,0 +1,8 @@ +package com.dokdok.stt.entity; + +public enum SttJobStatus { + PENDING, + PROCESSING, + DONE, + FAILED +} diff --git a/src/main/java/com/dokdok/stt/repository/SttJobRepository.java b/src/main/java/com/dokdok/stt/repository/SttJobRepository.java new file mode 100644 index 00000000..57e10379 --- /dev/null +++ b/src/main/java/com/dokdok/stt/repository/SttJobRepository.java @@ -0,0 +1,10 @@ +package com.dokdok.stt.repository; + +import com.dokdok.stt.entity.SttJob; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface SttJobRepository extends JpaRepository { + Optional findByIdAndMeetingId(Long id, Long meetingId); +} diff --git a/src/main/java/com/dokdok/stt/service/SttJobService.java b/src/main/java/com/dokdok/stt/service/SttJobService.java new file mode 100644 index 00000000..248abdc1 --- /dev/null +++ b/src/main/java/com/dokdok/stt/service/SttJobService.java @@ -0,0 +1,163 @@ +package com.dokdok.stt.service; + +import com.dokdok.ai.client.AiSttClient; +import com.dokdok.ai.dto.SttRequest; +import com.dokdok.ai.dto.SttResponse; +import com.dokdok.global.exception.GlobalErrorCode; +import com.dokdok.global.exception.GlobalException; +import com.dokdok.global.util.SecurityUtil; +import com.dokdok.meeting.entity.Meeting; +import com.dokdok.meeting.service.MeetingValidator; +import com.dokdok.stt.dto.SttJobResponse; +import com.dokdok.stt.entity.SttJob; +import com.dokdok.stt.entity.SttJobStatus; +import com.dokdok.stt.repository.SttJobRepository; +import com.dokdok.user.entity.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Set; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SttJobService { + + private static final long MAX_FILE_SIZE = 50L * 1024L * 1024L; + private static final Set ALLOWED_EXTENSIONS = Set.of( + "mp3", "aac", "ac3", "ogg", "flac", "wav", "m4a" + ); + + private final MeetingValidator meetingValidator; + private final SttJobRepository sttJobRepository; + private final AiSttClient aiSttClient; + + @Value("${stt.temp-dir:}") + private String tempDirProperty; + + @Transactional + public SttJobResponse createJob(Long meetingId, MultipartFile file) { + Long userId = SecurityUtil.getCurrentUserId(); + meetingValidator.validateMeeting(meetingId); + meetingValidator.validateMeetingMember(meetingId, userId); + + validateFile(file); + + Meeting meeting = meetingValidator.findMeetingOrThrow(meetingId); + User user = SecurityUtil.getCurrentUserEntity(); + + Path tempFilePath = saveToTemp(file); + SttJob job = SttJob.builder() + .meeting(meeting) + .user(user) + .originalFilename(file.getOriginalFilename()) + .contentType(file.getContentType()) + .fileSize(file.getSize()) + .tempFilePath(tempFilePath.toString()) + .status(SttJobStatus.PROCESSING) + .build(); + sttJobRepository.save(job); + + try { + SttResponse response = aiSttClient.requestStt( + new SttRequest(job.getId(), tempFilePath.toString(), "ko-KR") + ); + if (response == null || response.text() == null) { + job.markFailed("STT response is empty"); + } else { + job.markDone(response.text()); + } + } catch (WebClientResponseException e) { + job.markFailed("AI STT error: " + e.getStatusCode()); + log.error("AI STT request failed: {}", e.getMessage(), e); + } catch (Exception e) { + job.markFailed("AI STT error"); + log.error("AI STT request failed", e); + } finally { + deleteTempFile(tempFilePath); + } + + return SttJobResponse.from(job); + } + + @Transactional(readOnly = true) + public SttJobResponse getJob(Long meetingId, Long jobId) { + Long userId = SecurityUtil.getCurrentUserId(); + meetingValidator.validateMeeting(meetingId); + meetingValidator.validateMeetingMember(meetingId, userId); + + SttJob job = sttJobRepository.findByIdAndMeetingId(jobId, meetingId) + .orElseThrow(() -> new GlobalException(GlobalErrorCode.INVALID_INPUT_VALUE)); + + return SttJobResponse.from(job); + } + + private void validateFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new GlobalException(GlobalErrorCode.INVALID_INPUT_VALUE); + } + if (file.getSize() > MAX_FILE_SIZE) { + throw new GlobalException(GlobalErrorCode.FILE_SIZE_EXCEEDED); + } + String originalFilename = file.getOriginalFilename(); + String extension = extractExtension(originalFilename); + if (extension == null || !ALLOWED_EXTENSIONS.contains(extension)) { + throw new GlobalException(GlobalErrorCode.INVALID_FILE_TYPE); + } + } + + private String extractExtension(String filename) { + if (filename == null) { + return null; + } + int lastDot = filename.lastIndexOf('.'); + if (lastDot < 0 || lastDot == filename.length() - 1) { + return null; + } + return filename.substring(lastDot + 1).toLowerCase(); + } + + private Path saveToTemp(MultipartFile file) { + String tempRoot = tempDirProperty == null || tempDirProperty.isBlank() + ? System.getProperty("java.io.tmpdir") + : tempDirProperty; + Path tempDir = Paths.get(tempRoot, "dokdok-stt"); + try { + Files.createDirectories(tempDir); + String safeName = UUID.randomUUID() + "_" + sanitizeFilename(file.getOriginalFilename()); + Path tempFilePath = tempDir.resolve(safeName); + file.transferTo(tempFilePath.toFile()); + return tempFilePath; + } catch (IOException e) { + throw new GlobalException(GlobalErrorCode.FILE_UPLOAD_FAILED, e); + } + } + + private String sanitizeFilename(String filename) { + if (filename == null) { + return "audio"; + } + return filename.replaceAll("[^a-zA-Z0-9._-]", "_"); + } + + private void deleteTempFile(Path path) { + if (path == null) { + return; + } + try { + Files.deleteIfExists(path); + } catch (IOException e) { + log.warn("Failed to delete temp STT file: {}", path, e); + } + } +} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 4c102276..da25229a 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -99,3 +99,7 @@ ai: api: base-url: http://localhost:8000 summary-path: /summaries/topics + stt-path: /stt + +stt: + temp-dir: ${STT_TEMP_DIR:} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 806ed29f..6401c2f9 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -21,3 +21,7 @@ ai: api: base-url: ${AI_API_BASE_URL:http://localhost:8000} summary-path: ${AI_API_SUMMARY_PATH:/summaries/topics} + stt-path: ${AI_API_STT_PATH:/stt} + +stt: + temp-dir: ${STT_TEMP_DIR:} From d9a78f45bee3f80ee8b7936f10d4a15eb4eca1f1 Mon Sep 17 00:00:00 2001 From: JWoong-01 Date: Mon, 2 Feb 2026 22:50:47 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20stt=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/dokdok/ai/dto/SttResponse.java | 6 +++ .../com/dokdok/stt/dto/SttJobResponse.java | 10 +++- .../com/dokdok/stt/entity/SttSummary.java | 50 +++++++++++++++++++ .../stt/repository/SttSummaryRepository.java | 10 ++++ .../com/dokdok/stt/service/SttJobService.java | 43 ++++++++++++++-- 5 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/dokdok/stt/entity/SttSummary.java create mode 100644 src/main/java/com/dokdok/stt/repository/SttSummaryRepository.java diff --git a/src/main/java/com/dokdok/ai/dto/SttResponse.java b/src/main/java/com/dokdok/ai/dto/SttResponse.java index af43f958..d74376ea 100644 --- a/src/main/java/com/dokdok/ai/dto/SttResponse.java +++ b/src/main/java/com/dokdok/ai/dto/SttResponse.java @@ -1,9 +1,15 @@ package com.dokdok.ai.dto; +import java.util.List; + public record SttResponse( Long jobId, String status, String text, + String summary, + List mainPoints, + List highlights, + List keywords, String errorMessage ) { } diff --git a/src/main/java/com/dokdok/stt/dto/SttJobResponse.java b/src/main/java/com/dokdok/stt/dto/SttJobResponse.java index 9699751b..d8d08ade 100644 --- a/src/main/java/com/dokdok/stt/dto/SttJobResponse.java +++ b/src/main/java/com/dokdok/stt/dto/SttJobResponse.java @@ -2,8 +2,10 @@ import com.dokdok.stt.entity.SttJob; import com.dokdok.stt.entity.SttJobStatus; +import com.dokdok.stt.entity.SttSummary; import java.time.LocalDateTime; +import java.util.List; public record SttJobResponse( Long jobId, @@ -11,16 +13,22 @@ public record SttJobResponse( Long userId, SttJobStatus status, String resultText, + String summary, + List highlights, + List keywords, String errorMessage, LocalDateTime createdAt ) { - public static SttJobResponse from(SttJob job) { + public static SttJobResponse from(SttJob job, SttSummary summary) { return new SttJobResponse( job.getId(), job.getMeeting().getId(), job.getUser().getId(), job.getStatus(), job.getResultText(), + summary != null ? summary.getSummary() : null, + summary != null ? summary.getHighlights() : null, + summary != null ? summary.getKeywords() : null, job.getErrorMessage(), job.getCreatedAt() ); diff --git a/src/main/java/com/dokdok/stt/entity/SttSummary.java b/src/main/java/com/dokdok/stt/entity/SttSummary.java new file mode 100644 index 00000000..d0bddeb7 --- /dev/null +++ b/src/main/java/com/dokdok/stt/entity/SttSummary.java @@ -0,0 +1,50 @@ +package com.dokdok.stt.entity; + +import com.dokdok.global.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.util.List; + +@Entity +@Table(name = "stt_summary") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@SuperBuilder +public class SttSummary extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "stt_summary_id") + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "stt_job_id", nullable = false) + private SttJob sttJob; + + @Column(name = "summary", columnDefinition = "TEXT") + private String summary; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "highlights", columnDefinition = "jsonb") + private List highlights; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "keywords", columnDefinition = "jsonb") + private List keywords; +} diff --git a/src/main/java/com/dokdok/stt/repository/SttSummaryRepository.java b/src/main/java/com/dokdok/stt/repository/SttSummaryRepository.java new file mode 100644 index 00000000..94fe24c5 --- /dev/null +++ b/src/main/java/com/dokdok/stt/repository/SttSummaryRepository.java @@ -0,0 +1,10 @@ +package com.dokdok.stt.repository; + +import com.dokdok.stt.entity.SttSummary; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface SttSummaryRepository extends JpaRepository { + Optional findBySttJobId(Long sttJobId); +} diff --git a/src/main/java/com/dokdok/stt/service/SttJobService.java b/src/main/java/com/dokdok/stt/service/SttJobService.java index 248abdc1..b831afc8 100644 --- a/src/main/java/com/dokdok/stt/service/SttJobService.java +++ b/src/main/java/com/dokdok/stt/service/SttJobService.java @@ -11,7 +11,9 @@ import com.dokdok.stt.dto.SttJobResponse; import com.dokdok.stt.entity.SttJob; import com.dokdok.stt.entity.SttJobStatus; +import com.dokdok.stt.entity.SttSummary; import com.dokdok.stt.repository.SttJobRepository; +import com.dokdok.stt.repository.SttSummaryRepository; import com.dokdok.user.entity.User; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -25,6 +27,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.List; import java.util.Set; import java.util.UUID; @@ -40,6 +43,7 @@ public class SttJobService { private final MeetingValidator meetingValidator; private final SttJobRepository sttJobRepository; + private final SttSummaryRepository sttSummaryRepository; private final AiSttClient aiSttClient; @Value("${stt.temp-dir:}") @@ -68,14 +72,20 @@ public SttJobResponse createJob(Long meetingId, MultipartFile file) { .build(); sttJobRepository.save(job); + SttSummary summary = null; try { SttResponse response = aiSttClient.requestStt( new SttRequest(job.getId(), tempFilePath.toString(), "ko-KR") ); - if (response == null || response.text() == null) { + if (response == null) { + job.markFailed("STT response is empty"); + } else if ("FAILED".equalsIgnoreCase(response.status())) { + job.markFailed(response.errorMessage() == null ? "STT failed" : response.errorMessage()); + } else if (isEmptyResponse(response)) { job.markFailed("STT response is empty"); } else { job.markDone(response.text()); + summary = saveSummary(job, response); } } catch (WebClientResponseException e) { job.markFailed("AI STT error: " + e.getStatusCode()); @@ -87,7 +97,7 @@ public SttJobResponse createJob(Long meetingId, MultipartFile file) { deleteTempFile(tempFilePath); } - return SttJobResponse.from(job); + return SttJobResponse.from(job, summary); } @Transactional(readOnly = true) @@ -99,7 +109,8 @@ public SttJobResponse getJob(Long meetingId, Long jobId) { SttJob job = sttJobRepository.findByIdAndMeetingId(jobId, meetingId) .orElseThrow(() -> new GlobalException(GlobalErrorCode.INVALID_INPUT_VALUE)); - return SttJobResponse.from(job); + SttSummary summary = sttSummaryRepository.findBySttJobId(jobId).orElse(null); + return SttJobResponse.from(job, summary); } private void validateFile(MultipartFile file) { @@ -160,4 +171,30 @@ private void deleteTempFile(Path path) { log.warn("Failed to delete temp STT file: {}", path, e); } } + + private SttSummary saveSummary(SttJob job, SttResponse response) { + List highlights = response.mainPoints() != null ? response.mainPoints() : response.highlights(); + if (response.summary() == null + && (highlights == null || highlights.isEmpty()) + && (response.keywords() == null || response.keywords().isEmpty()) + && response.text() == null) { + return null; + } + SttSummary summary = SttSummary.builder() + .sttJob(job) + .summary(response.summary()) + .highlights(highlights) + .keywords(response.keywords()) + .build(); + return sttSummaryRepository.save(summary); + } + + private boolean isEmptyResponse(SttResponse response) { + boolean noText = response.text() == null || response.text().isBlank(); + boolean noSummary = response.summary() == null || response.summary().isBlank(); + boolean noHighlights = (response.mainPoints() == null || response.mainPoints().isEmpty()) + && (response.highlights() == null || response.highlights().isEmpty()); + boolean noKeywords = response.keywords() == null || response.keywords().isEmpty(); + return noText && noSummary && noHighlights && noKeywords; + } } From 6e7a15140017f48fa4750ffff8d7859bb9689e50 Mon Sep 17 00:00:00 2001 From: JWoong-01 Date: Fri, 20 Feb 2026 21:25:48 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20ai=20=EC=82=AC=EC=A0=84=EC=9A=94?= =?UTF-8?q?=EC=95=BD=20+=20stt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/dokdok/ai/client/AiSttClient.java | 8 +- .../com/dokdok/ai/client/AiSummaryClient.java | 26 --- .../java/com/dokdok/ai/dto/SttRequest.java | 13 +- .../java/com/dokdok/ai/dto/SttResponse.java | 15 -- .../dokdok/ai/dto/TopicSummaryRequest.java | 15 -- src/main/java/com/dokdok/stt/api/SttApi.java | 4 +- .../com/dokdok/stt/dto/SttJobResponse.java | 4 - .../java/com/dokdok/stt/entity/SttJob.java | 6 +- .../com/dokdok/stt/entity/SttSummary.java | 4 - .../com/dokdok/stt/service/SttJobService.java | 175 +++++++++++--- .../com/dokdok/topic/api/TopicSummaryApi.java | 84 ------- .../controller/TopicSummaryController.java | 27 --- .../dto/response/TopicSummaryResponse.java | 13 -- .../topic/service/TopicSummaryService.java | 53 ----- src/main/resources/application-dev.yaml | 2 +- .../resources/application-local.yaml.example | 8 +- src/main/resources/application.yaml | 2 +- .../service/TopicSummaryServiceTest.java | 217 ------------------ 18 files changed, 173 insertions(+), 503 deletions(-) delete mode 100644 src/main/java/com/dokdok/ai/client/AiSummaryClient.java delete mode 100644 src/main/java/com/dokdok/ai/dto/SttResponse.java delete mode 100644 src/main/java/com/dokdok/ai/dto/TopicSummaryRequest.java delete mode 100644 src/main/java/com/dokdok/topic/api/TopicSummaryApi.java delete mode 100644 src/main/java/com/dokdok/topic/controller/TopicSummaryController.java delete mode 100644 src/main/java/com/dokdok/topic/dto/response/TopicSummaryResponse.java delete mode 100644 src/main/java/com/dokdok/topic/service/TopicSummaryService.java delete mode 100644 src/test/java/com/dokdok/topic/service/TopicSummaryServiceTest.java diff --git a/src/main/java/com/dokdok/ai/client/AiSttClient.java b/src/main/java/com/dokdok/ai/client/AiSttClient.java index 82cdbb01..e6c0e5c4 100644 --- a/src/main/java/com/dokdok/ai/client/AiSttClient.java +++ b/src/main/java/com/dokdok/ai/client/AiSttClient.java @@ -1,9 +1,11 @@ package com.dokdok.ai.client; +import com.dokdok.global.response.ApiResponse; +import com.dokdok.retrospective.dto.response.RetrospectiveSummaryResponse; import com.dokdok.ai.dto.SttRequest; -import com.dokdok.ai.dto.SttResponse; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; @@ -16,12 +18,12 @@ public class AiSttClient { @Value("${ai.api.stt-path}") private String sttPath; - public SttResponse requestStt(SttRequest request) { + public ApiResponse requestStt(SttRequest request) { return aiWebClient.post() .uri(sttPath) .bodyValue(request) .retrieve() - .bodyToMono(SttResponse.class) + .bodyToMono(new ParameterizedTypeReference>() {}) .block(); } } diff --git a/src/main/java/com/dokdok/ai/client/AiSummaryClient.java b/src/main/java/com/dokdok/ai/client/AiSummaryClient.java deleted file mode 100644 index e12a9e12..00000000 --- a/src/main/java/com/dokdok/ai/client/AiSummaryClient.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.dokdok.ai.client; - -import com.dokdok.ai.dto.TopicSummaryRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.springframework.web.reactive.function.client.WebClient; - -@Component -@RequiredArgsConstructor -public class AiSummaryClient { - - private final WebClient aiWebClient; - - @Value("${ai.api.summary-path}") - private String summaryPath; - - public String requestTopicSummary(TopicSummaryRequest request) { - return aiWebClient.post() - .uri(summaryPath) - .bodyValue(request) - .retrieve() - .bodyToMono(String.class) - .block(); - } -} diff --git a/src/main/java/com/dokdok/ai/dto/SttRequest.java b/src/main/java/com/dokdok/ai/dto/SttRequest.java index 2cd118c6..fe21e86e 100644 --- a/src/main/java/com/dokdok/ai/dto/SttRequest.java +++ b/src/main/java/com/dokdok/ai/dto/SttRequest.java @@ -1,8 +1,19 @@ package com.dokdok.ai.dto; +import java.util.List; + public record SttRequest( Long jobId, + Long meetingId, String filePath, - String language + String language, + List preAnswers ) { + public record PreAnswer( + Long topicId, + String topicTitle, + Long userId, + String content + ) { + } } diff --git a/src/main/java/com/dokdok/ai/dto/SttResponse.java b/src/main/java/com/dokdok/ai/dto/SttResponse.java deleted file mode 100644 index d74376ea..00000000 --- a/src/main/java/com/dokdok/ai/dto/SttResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.dokdok.ai.dto; - -import java.util.List; - -public record SttResponse( - Long jobId, - String status, - String text, - String summary, - List mainPoints, - List highlights, - List keywords, - String errorMessage -) { -} diff --git a/src/main/java/com/dokdok/ai/dto/TopicSummaryRequest.java b/src/main/java/com/dokdok/ai/dto/TopicSummaryRequest.java deleted file mode 100644 index 86e3f46e..00000000 --- a/src/main/java/com/dokdok/ai/dto/TopicSummaryRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.dokdok.ai.dto; - -import java.util.List; - -public record TopicSummaryRequest( - Long topicId, - String topicTitle, - List answers -) { - public record Answer( - Long userId, - String content - ) { - } -} diff --git a/src/main/java/com/dokdok/stt/api/SttApi.java b/src/main/java/com/dokdok/stt/api/SttApi.java index 1e80cbaa..c4e67419 100644 --- a/src/main/java/com/dokdok/stt/api/SttApi.java +++ b/src/main/java/com/dokdok/stt/api/SttApi.java @@ -24,7 +24,7 @@ public interface SttApi { @Operation( summary = "STT 작업 생성", - description = "오디오 파일을 업로드하여 STT 작업을 생성합니다.", + description = "오디오 파일 업로드 또는 사전의견만으로 STT 요약 작업을 생성합니다.", parameters = { @Parameter(name = "meetingId", description = "약속 식별자", in = ParameterIn.PATH, required = true) } @@ -44,7 +44,7 @@ public interface SttApi { @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) ResponseEntity> createJob( @PathVariable Long meetingId, - @RequestPart("file") MultipartFile file + @RequestPart(value = "file", required = false) MultipartFile file ); @Operation( diff --git a/src/main/java/com/dokdok/stt/dto/SttJobResponse.java b/src/main/java/com/dokdok/stt/dto/SttJobResponse.java index d8d08ade..d37b00ec 100644 --- a/src/main/java/com/dokdok/stt/dto/SttJobResponse.java +++ b/src/main/java/com/dokdok/stt/dto/SttJobResponse.java @@ -12,10 +12,8 @@ public record SttJobResponse( Long meetingId, Long userId, SttJobStatus status, - String resultText, String summary, List highlights, - List keywords, String errorMessage, LocalDateTime createdAt ) { @@ -25,10 +23,8 @@ public static SttJobResponse from(SttJob job, SttSummary summary) { job.getMeeting().getId(), job.getUser().getId(), job.getStatus(), - job.getResultText(), summary != null ? summary.getSummary() : null, summary != null ? summary.getHighlights() : null, - summary != null ? summary.getKeywords() : null, job.getErrorMessage(), job.getCreatedAt() ); diff --git a/src/main/java/com/dokdok/stt/entity/SttJob.java b/src/main/java/com/dokdok/stt/entity/SttJob.java index 380942e8..4a0bb74e 100644 --- a/src/main/java/com/dokdok/stt/entity/SttJob.java +++ b/src/main/java/com/dokdok/stt/entity/SttJob.java @@ -33,9 +33,6 @@ public class SttJob extends BaseTimeEntity { @Builder.Default private SttJobStatus status = SttJobStatus.PENDING; - @Column(name = "result_text", columnDefinition = "TEXT") - private String resultText; - @Column(name = "error_message", columnDefinition = "TEXT") private String errorMessage; @@ -59,9 +56,8 @@ public void markProcessing() { this.status = SttJobStatus.PROCESSING; } - public void markDone(String resultText) { + public void markDone() { this.status = SttJobStatus.DONE; - this.resultText = resultText; this.errorMessage = null; } diff --git a/src/main/java/com/dokdok/stt/entity/SttSummary.java b/src/main/java/com/dokdok/stt/entity/SttSummary.java index d0bddeb7..3a00aba9 100644 --- a/src/main/java/com/dokdok/stt/entity/SttSummary.java +++ b/src/main/java/com/dokdok/stt/entity/SttSummary.java @@ -43,8 +43,4 @@ public class SttSummary extends BaseTimeEntity { @JdbcTypeCode(SqlTypes.JSON) @Column(name = "highlights", columnDefinition = "jsonb") private List highlights; - - @JdbcTypeCode(SqlTypes.JSON) - @Column(name = "keywords", columnDefinition = "jsonb") - private List keywords; } diff --git a/src/main/java/com/dokdok/stt/service/SttJobService.java b/src/main/java/com/dokdok/stt/service/SttJobService.java index b831afc8..d131819f 100644 --- a/src/main/java/com/dokdok/stt/service/SttJobService.java +++ b/src/main/java/com/dokdok/stt/service/SttJobService.java @@ -2,18 +2,25 @@ import com.dokdok.ai.client.AiSttClient; import com.dokdok.ai.dto.SttRequest; -import com.dokdok.ai.dto.SttResponse; +import com.dokdok.global.response.ApiResponse; import com.dokdok.global.exception.GlobalErrorCode; import com.dokdok.global.exception.GlobalException; import com.dokdok.global.util.SecurityUtil; import com.dokdok.meeting.entity.Meeting; import com.dokdok.meeting.service.MeetingValidator; +import com.dokdok.retrospective.dto.response.RetrospectiveSummaryResponse; +import com.dokdok.retrospective.entity.TopicRetrospectiveSummary; +import com.dokdok.retrospective.repository.TopicRetrospectiveSummaryRepository; import com.dokdok.stt.dto.SttJobResponse; import com.dokdok.stt.entity.SttJob; import com.dokdok.stt.entity.SttJobStatus; import com.dokdok.stt.entity.SttSummary; import com.dokdok.stt.repository.SttJobRepository; import com.dokdok.stt.repository.SttSummaryRepository; +import com.dokdok.topic.entity.Topic; +import com.dokdok.topic.entity.TopicAnswer; +import com.dokdok.topic.repository.TopicRepository; +import com.dokdok.topic.repository.TopicAnswerRepository; import com.dokdok.user.entity.User; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -44,6 +51,9 @@ public class SttJobService { private final MeetingValidator meetingValidator; private final SttJobRepository sttJobRepository; private final SttSummaryRepository sttSummaryRepository; + private final TopicRepository topicRepository; + private final TopicRetrospectiveSummaryRepository topicRetrospectiveSummaryRepository; + private final TopicAnswerRepository topicAnswerRepository; private final AiSttClient aiSttClient; @Value("${stt.temp-dir:}") @@ -55,41 +65,72 @@ public SttJobResponse createJob(Long meetingId, MultipartFile file) { meetingValidator.validateMeeting(meetingId); meetingValidator.validateMeetingMember(meetingId, userId); - validateFile(file); - Meeting meeting = meetingValidator.findMeetingOrThrow(meetingId); User user = SecurityUtil.getCurrentUserEntity(); - Path tempFilePath = saveToTemp(file); + if (file != null && file.isEmpty()) { + throw new GlobalException(GlobalErrorCode.INVALID_INPUT_VALUE); + } + + List preAnswers = buildPreAnswers(meetingId); + if (file == null && preAnswers.isEmpty()) { + throw new GlobalException(GlobalErrorCode.INVALID_INPUT_VALUE); + } + + Path tempFilePath = null; + if (file != null) { + validateFile(file); + tempFilePath = saveToTemp(file); + } SttJob job = SttJob.builder() .meeting(meeting) .user(user) - .originalFilename(file.getOriginalFilename()) - .contentType(file.getContentType()) - .fileSize(file.getSize()) - .tempFilePath(tempFilePath.toString()) + .originalFilename(file != null ? file.getOriginalFilename() : null) + .contentType(file != null ? file.getContentType() : null) + .fileSize(file != null ? file.getSize() : null) + .tempFilePath(tempFilePath != null ? tempFilePath.toString() : null) .status(SttJobStatus.PROCESSING) .build(); sttJobRepository.save(job); SttSummary summary = null; try { - SttResponse response = aiSttClient.requestStt( - new SttRequest(job.getId(), tempFilePath.toString(), "ko-KR") + log.info("STT pre-answers count: {}", preAnswers.size()); + ApiResponse apiResponse = aiSttClient.requestStt( + new SttRequest( + job.getId(), + meetingId, + tempFilePath != null ? tempFilePath.toString() : null, + "ko-KR", + preAnswers + ) ); - if (response == null) { + RetrospectiveSummaryResponse response = apiResponse != null ? apiResponse.data() : null; + if (apiResponse == null) { job.markFailed("STT response is empty"); - } else if ("FAILED".equalsIgnoreCase(response.status())) { - job.markFailed(response.errorMessage() == null ? "STT failed" : response.errorMessage()); - } else if (isEmptyResponse(response)) { + } else if (!"SUCCESS".equalsIgnoreCase(apiResponse.code())) { + String message = apiResponse.message() == null ? "STT failed" : apiResponse.message(); + job.markFailed(message); + } else if (response == null) { job.markFailed("STT response is empty"); } else { - job.markDone(response.text()); + int topicCount = response.topics() == null ? 0 : response.topics().size(); + List topicIds = response.topics() == null + ? List.of() + : response.topics().stream().map(RetrospectiveSummaryResponse.TopicSummaryResponse::topicId).toList(); + log.info("STT summary topics count: {}, topicIds={}", topicCount, topicIds); + job.markDone(); summary = saveSummary(job, response); + saveRetrospectiveSummaries(meetingId, response); } } catch (WebClientResponseException e) { job.markFailed("AI STT error: " + e.getStatusCode()); - log.error("AI STT request failed: {}", e.getMessage(), e); + log.error( + "AI STT request failed: status={}, body={}", + e.getStatusCode(), + e.getResponseBodyAsString(), + e + ); } catch (Exception e) { job.markFailed("AI STT error"); log.error("AI STT request failed", e); @@ -172,29 +213,101 @@ private void deleteTempFile(Path path) { } } - private SttSummary saveSummary(SttJob job, SttResponse response) { - List highlights = response.mainPoints() != null ? response.mainPoints() : response.highlights(); - if (response.summary() == null - && (highlights == null || highlights.isEmpty()) - && (response.keywords() == null || response.keywords().isEmpty()) - && response.text() == null) { + private SttSummary saveSummary(SttJob job, RetrospectiveSummaryResponse response) { + RetrospectiveSummaryResponse.TopicSummaryResponse topicSummary = extractTopicSummary(response); + if (topicSummary == null) { + return null; + } + String summaryText = topicSummary.summary(); + List highlights = topicSummary.keyPoints() == null + ? null + : topicSummary.keyPoints().stream() + .map(RetrospectiveSummaryResponse.KeyPointResponse::title) + .toList(); + + if ((summaryText == null || summaryText.isBlank()) + && (highlights == null || highlights.isEmpty())) { return null; } + SttSummary summary = SttSummary.builder() .sttJob(job) - .summary(response.summary()) + .summary(summaryText) .highlights(highlights) - .keywords(response.keywords()) .build(); return sttSummaryRepository.save(summary); } - private boolean isEmptyResponse(SttResponse response) { - boolean noText = response.text() == null || response.text().isBlank(); - boolean noSummary = response.summary() == null || response.summary().isBlank(); - boolean noHighlights = (response.mainPoints() == null || response.mainPoints().isEmpty()) - && (response.highlights() == null || response.highlights().isEmpty()); - boolean noKeywords = response.keywords() == null || response.keywords().isEmpty(); - return noText && noSummary && noHighlights && noKeywords; + private RetrospectiveSummaryResponse.TopicSummaryResponse extractTopicSummary( + RetrospectiveSummaryResponse response + ) { + if (response.topics() == null || response.topics().isEmpty()) { + return null; + } + return response.topics().get(0); + } + + private void saveRetrospectiveSummaries( + Long meetingId, + RetrospectiveSummaryResponse response + ) { + if (response.topics() == null || response.topics().isEmpty()) { + return; + } + + RetrospectiveSummaryResponse.TopicSummaryResponse baseSummary = response.topics().stream() + .filter(topic -> topic.topicId() == null) + .findFirst() + .orElse(null); + if (baseSummary != null) { + List topics = topicRepository.findConfirmedTopics(meetingId); + for (Topic topic : topics) { + upsertTopicSummary(topic, baseSummary); + } + return; + } + + for (RetrospectiveSummaryResponse.TopicSummaryResponse topicResponse : response.topics()) { + Long topicId = topicResponse.topicId(); + if (topicId == null) { + continue; + } + Topic topic = topicRepository.findById(topicId).orElse(null); + if (topic == null || !meetingId.equals(topic.getMeeting().getId())) { + continue; + } + upsertTopicSummary(topic, topicResponse); + } + } + + private void upsertTopicSummary( + Topic topic, + RetrospectiveSummaryResponse.TopicSummaryResponse topicResponse + ) { + List keyPoints = topicResponse.keyPoints() == null + ? List.of() + : topicResponse.keyPoints().stream() + .map(kp -> new TopicRetrospectiveSummary.KeyPoint(kp.title(), kp.details())) + .toList(); + TopicRetrospectiveSummary summary = topicRetrospectiveSummaryRepository + .findByTopicId(topic.getId()) + .orElseGet(() -> TopicRetrospectiveSummary.builder() + .topic(topic) + .build()); + summary.update(topicResponse.summary(), keyPoints); + topicRetrospectiveSummaryRepository.save(summary); + } + + private List buildPreAnswers(Long meetingId) { + List answers = topicAnswerRepository.findByMeetingId(meetingId); + return answers.stream() + .filter(answer -> answer.getContent() != null && !answer.getContent().isBlank()) + .map(answer -> new SttRequest.PreAnswer( + answer.getTopic() != null ? answer.getTopic().getId() : null, + answer.getTopic() != null ? answer.getTopic().getTitle() : null, + answer.getUser() != null ? answer.getUser().getId() : null, + answer.getContent() + )) + .toList(); } } diff --git a/src/main/java/com/dokdok/topic/api/TopicSummaryApi.java b/src/main/java/com/dokdok/topic/api/TopicSummaryApi.java deleted file mode 100644 index 7663c1a5..00000000 --- a/src/main/java/com/dokdok/topic/api/TopicSummaryApi.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.dokdok.topic.api; - -import com.dokdok.global.response.ApiResponse; -import com.dokdok.topic.dto.response.TopicSummaryResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.enums.ParameterIn; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.ExampleObject; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; - -@Tag(name = "토픽 요약", description = "토픽 요약 관련 API") -@RequestMapping("/api/gatherings/{gathering_id}/meetings/{meeting_id}/topics/{topic_id}/summaries") -public interface TopicSummaryApi { - - @Operation( - summary = "토픽 요약 요청 (AI 테스트) (developer: 양재웅)", - description = "AI 서버로 토픽 답변 요약 요청을 전달합니다.", - parameters = { - @Parameter(name = "gathering_id", description = "모임 식별자", in = ParameterIn.PATH, required = true), - @Parameter(name = "meeting_id", description = "약속 식별자", in = ParameterIn.PATH, required = true), - @Parameter(name = "topic_id", description = "토픽 식별자", in = ParameterIn.PATH, required = true) - } - ) - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "토픽 요약 요청 성공", - content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = TopicSummaryResponse.class)) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청", - content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, - examples = @ExampleObject(value = """ - {"code": "G002", "message": "입력값이 올바르지 않습니다.", "data": null} - """))), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "모임 또는 약속의 멤버가 아님", - content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, - examples = @ExampleObject(value = """ - {"code": "M004", "message": "약속의 멤버가 아닙니다.", "data": null} - """))), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "모임, 약속 또는 토픽을 찾을 수 없음", - content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, - examples = { - @ExampleObject( - name = "모임 없음", - value = """ - {"code": "GA001", "message": "모임을 찾을 수 없습니다.", "data": null} - """ - ), - @ExampleObject( - name = "약속 없음", - value = """ - {"code": "M001", "message": "약속을 찾을 수 없습니다.", "data": null} - """ - ), - @ExampleObject( - name = "토픽 없음", - value = """ - {"code": "E101", "message": "주제를 찾을 수 없습니다.", "data": null} - """ - ) - })), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류", - content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, - examples = @ExampleObject(value = """ - {"code": "E000", "message": "서버 에러가 발생했습니다. 담당자에게 문의 바랍니다.", "data": null} - """))) - }) - @PostMapping(value = "/ai-test", produces = MediaType.APPLICATION_JSON_VALUE) - ResponseEntity> requestTopicSummary( - @PathVariable("gathering_id") Long gatheringId, - @PathVariable("meeting_id") Long meetingId, - @PathVariable("topic_id") Long topicId - ); -} diff --git a/src/main/java/com/dokdok/topic/controller/TopicSummaryController.java b/src/main/java/com/dokdok/topic/controller/TopicSummaryController.java deleted file mode 100644 index 388f52cb..00000000 --- a/src/main/java/com/dokdok/topic/controller/TopicSummaryController.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.dokdok.topic.controller; - -import com.dokdok.global.response.ApiResponse; -import com.dokdok.topic.api.TopicSummaryApi; -import com.dokdok.topic.dto.response.TopicSummaryResponse; -import com.dokdok.topic.service.TopicSummaryService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -public class TopicSummaryController implements TopicSummaryApi { - - private final TopicSummaryService topicSummaryService; - - @Override - public ResponseEntity> requestTopicSummary( - Long gatheringId, - Long meetingId, - Long topicId - ) { - String response = topicSummaryService.requestTopicSummary(gatheringId, meetingId, topicId); - - return ApiResponse.success(TopicSummaryResponse.from(response), "AI 요약 요청이 전달되었습니다."); - } -} diff --git a/src/main/java/com/dokdok/topic/dto/response/TopicSummaryResponse.java b/src/main/java/com/dokdok/topic/dto/response/TopicSummaryResponse.java deleted file mode 100644 index 0c7cdde0..00000000 --- a/src/main/java/com/dokdok/topic/dto/response/TopicSummaryResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.dokdok.topic.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "토픽 요약 응답") -public record TopicSummaryResponse( - @Schema(description = "AI 요약 결과 원문(JSON 문자열)", example = "{\"summary\":\"...\",\"highlights\":[\"...\"],\"keywords\":[\"...\"]}") - String result -) { - public static TopicSummaryResponse from(String result) { - return new TopicSummaryResponse(result); - } -} diff --git a/src/main/java/com/dokdok/topic/service/TopicSummaryService.java b/src/main/java/com/dokdok/topic/service/TopicSummaryService.java deleted file mode 100644 index 601ba357..00000000 --- a/src/main/java/com/dokdok/topic/service/TopicSummaryService.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.dokdok.topic.service; - -import com.dokdok.ai.client.AiSummaryClient; -import com.dokdok.ai.dto.TopicSummaryRequest; -import com.dokdok.gathering.service.GatheringValidator; -import com.dokdok.global.util.SecurityUtil; -import com.dokdok.meeting.service.MeetingValidator; -import com.dokdok.topic.entity.Topic; -import com.dokdok.topic.entity.TopicAnswer; -import com.dokdok.topic.repository.TopicAnswerRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class TopicSummaryService { - - private final GatheringValidator gatheringValidator; - private final MeetingValidator meetingValidator; - private final TopicValidator topicValidator; - private final TopicAnswerRepository topicAnswerRepository; - private final AiSummaryClient aiSummaryClient; - - public String requestTopicSummary(Long gatheringId, Long meetingId, Long topicId) { - Long userId = SecurityUtil.getCurrentUserId(); - gatheringValidator.validateGathering(gatheringId); - meetingValidator.validateMeetingInGathering(meetingId, gatheringId); - meetingValidator.validateMeetingMember(meetingId, userId); - - Topic topic = topicValidator.getTopicInMeeting(topicId, meetingId); - List answers = topicAnswerRepository.findSubmittedByTopicId(topicId); - - List answerDtos = answers.stream() - .map(answer -> new TopicSummaryRequest.Answer( - answer.getUser().getId(), - answer.getContent() - )) - .filter(answer -> answer.content() != null && !answer.content().isBlank()) - .toList(); - - TopicSummaryRequest request = new TopicSummaryRequest( - topic.getId(), - topic.getTitle(), - answerDtos - ); - - return aiSummaryClient.requestTopicSummary(request); - } -} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index f7be70cf..cb823e48 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -99,7 +99,7 @@ ai: api: base-url: http://localhost:8000 summary-path: /summaries/topics - stt-path: /stt + stt-path: /stt/summary stt: temp-dir: ${STT_TEMP_DIR:} diff --git a/src/main/resources/application-local.yaml.example b/src/main/resources/application-local.yaml.example index 15b550b1..f4c49eec 100644 --- a/src/main/resources/application-local.yaml.example +++ b/src/main/resources/application-local.yaml.example @@ -43,9 +43,15 @@ server: session: timeout: 30m # 세션 타임아웃 30분 +ai: + api: + base-url: http://localhost:8000 + summary-path: /summaries/topics + stt-path: /stt/summary + # Swagger/OpenAPI Configuration springdoc: swagger-ui: servers: - url: http://localhost:8080 - description: 로컬 서버 \ No newline at end of file + description: 로컬 서버 diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 358da90b..16791a2d 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -27,7 +27,7 @@ ai: api: base-url: ${AI_API_BASE_URL:http://localhost:8000} summary-path: ${AI_API_SUMMARY_PATH:/summaries/topics} - stt-path: ${AI_API_STT_PATH:/stt} + stt-path: ${AI_API_STT_PATH:/stt/summary} stt: temp-dir: ${STT_TEMP_DIR:} diff --git a/src/test/java/com/dokdok/topic/service/TopicSummaryServiceTest.java b/src/test/java/com/dokdok/topic/service/TopicSummaryServiceTest.java deleted file mode 100644 index ce2f9f15..00000000 --- a/src/test/java/com/dokdok/topic/service/TopicSummaryServiceTest.java +++ /dev/null @@ -1,217 +0,0 @@ -package com.dokdok.topic.service; - -import com.dokdok.ai.client.AiSummaryClient; -import com.dokdok.ai.dto.TopicSummaryRequest; -import com.dokdok.gathering.service.GatheringValidator; -import com.dokdok.global.exception.GlobalException; -import com.dokdok.meeting.service.MeetingValidator; -import com.dokdok.topic.entity.Topic; -import com.dokdok.topic.entity.TopicAnswer; -import com.dokdok.topic.repository.TopicAnswerRepository; -import com.dokdok.user.entity.User; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -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.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class TopicSummaryServiceTest { - - @Mock - private GatheringValidator gatheringValidator; - - @Mock - private MeetingValidator meetingValidator; - - @Mock - private TopicValidator topicValidator; - - @Mock - private TopicAnswerRepository topicAnswerRepository; - - @Mock - private AiSummaryClient aiSummaryClient; - - @InjectMocks - private TopicSummaryService topicSummaryService; - - @BeforeEach - void setUpSecurityContext() { - User user = User.builder() - .id(1L) - .nickname("tester") - .kakaoId(1L) - .build(); - com.dokdok.oauth2.CustomOAuth2User principal = com.dokdok.oauth2.CustomOAuth2User.builder() - .user(user) - .attributes(java.util.Collections.emptyMap()) - .build(); - UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( - principal, - null, - principal.getAuthorities() - ); - SecurityContextHolder.getContext().setAuthentication(authentication); - } - - @AfterEach - void clearSecurityContext() { - SecurityContextHolder.clearContext(); - } - - @Test - @DisplayName("토픽 요약 요청 시 제출된 답변만 모아 AI에 전달한다") - void requestTopicSummary_buildsPayloadAndDelegatesToClient() { - Long gatheringId = 1L; - Long meetingId = 2L; - Long topicId = 5L; - Topic topic = Topic.builder().id(topicId).title("데미안 토론").build(); - - User user3 = User.builder().id(3L).kakaoId(3L).build(); - User user4 = User.builder().id(4L).kakaoId(4L).build(); - User user5 = User.builder().id(5L).kakaoId(5L).build(); - - TopicAnswer answer1 = TopicAnswer.builder() - .topic(topic) - .user(user3) - .content("답변 1") - .isSubmitted(true) - .build(); - TopicAnswer answer2 = TopicAnswer.builder() - .topic(topic) - .user(user4) - .content(" ") - .isSubmitted(true) - .build(); - TopicAnswer answer3 = TopicAnswer.builder() - .topic(topic) - .user(user5) - .content("답변 3") - .isSubmitted(true) - .build(); - - doNothing().when(gatheringValidator).validateGathering(gatheringId); - doNothing().when(meetingValidator).validateMeetingInGathering(meetingId, gatheringId); - doNothing().when(meetingValidator).validateMeetingMember(meetingId, 1L); - when(topicValidator.getTopicInMeeting(topicId, meetingId)).thenReturn(topic); - when(topicAnswerRepository.findSubmittedByTopicId(topicId)) - .thenReturn(List.of(answer1, answer2, answer3)); - when(aiSummaryClient.requestTopicSummary(org.mockito.ArgumentMatchers.any())) - .thenReturn("ok"); - - String result = topicSummaryService.requestTopicSummary(gatheringId, meetingId, topicId); - - ArgumentCaptor captor = ArgumentCaptor.forClass(TopicSummaryRequest.class); - verify(aiSummaryClient).requestTopicSummary(captor.capture()); - - TopicSummaryRequest request = captor.getValue(); - assertThat(request.topicId()).isEqualTo(topicId); - assertThat(request.topicTitle()).isEqualTo("데미안 토론"); - assertThat(request.answers()).containsExactly( - new TopicSummaryRequest.Answer(3L, "답변 1"), - new TopicSummaryRequest.Answer(5L, "답변 3") - ); - assertThat(result).isEqualTo("ok"); - } - - @Test - @DisplayName("제출된 답변이 비어있으면 빈 answers로 AI 요청한다") - void requestTopicSummary_sendsEmptyAnswersWhenNoContent() { - Long gatheringId = 1L; - Long meetingId = 2L; - Long topicId = 5L; - Topic topic = Topic.builder().id(topicId).title("빈 답변 토픽").build(); - - User user3 = User.builder().id(3L).kakaoId(3L).build(); - TopicAnswer answer1 = TopicAnswer.builder() - .topic(topic) - .user(user3) - .content(" ") - .isSubmitted(true) - .build(); - - doNothing().when(gatheringValidator).validateGathering(gatheringId); - doNothing().when(meetingValidator).validateMeetingInGathering(meetingId, gatheringId); - doNothing().when(meetingValidator).validateMeetingMember(meetingId, 1L); - when(topicValidator.getTopicInMeeting(topicId, meetingId)).thenReturn(topic); - when(topicAnswerRepository.findSubmittedByTopicId(topicId)) - .thenReturn(List.of(answer1)); - when(aiSummaryClient.requestTopicSummary(org.mockito.ArgumentMatchers.any())) - .thenReturn("ok"); - - topicSummaryService.requestTopicSummary(gatheringId, meetingId, topicId); - - ArgumentCaptor captor = ArgumentCaptor.forClass(TopicSummaryRequest.class); - verify(aiSummaryClient).requestTopicSummary(captor.capture()); - assertThat(captor.getValue().answers()).isEmpty(); - } - - @Test - @DisplayName("제출된 답변이 없으면 빈 answers로 AI 요청한다") - void requestTopicSummary_sendsEmptyAnswersWhenNoSubmittedAnswer() { - Long gatheringId = 1L; - Long meetingId = 2L; - Long topicId = 5L; - Topic topic = Topic.builder().id(topicId).title("빈 답변 토픽").build(); - - doNothing().when(gatheringValidator).validateGathering(gatheringId); - doNothing().when(meetingValidator).validateMeetingInGathering(meetingId, gatheringId); - doNothing().when(meetingValidator).validateMeetingMember(meetingId, 1L); - when(topicValidator.getTopicInMeeting(topicId, meetingId)).thenReturn(topic); - when(topicAnswerRepository.findSubmittedByTopicId(topicId)) - .thenReturn(List.of()); - when(aiSummaryClient.requestTopicSummary(org.mockito.ArgumentMatchers.any())) - .thenReturn("ok"); - - topicSummaryService.requestTopicSummary(gatheringId, meetingId, topicId); - - ArgumentCaptor captor = ArgumentCaptor.forClass(TopicSummaryRequest.class); - verify(aiSummaryClient).requestTopicSummary(captor.capture()); - assertThat(captor.getValue().answers()).isEmpty(); - } - - @Test - @DisplayName("인증 정보가 없으면 예외가 발생한다") - void requestTopicSummary_throwsWhenUnauthenticated() { - SecurityContextHolder.clearContext(); - - assertThatThrownBy(() -> topicSummaryService.requestTopicSummary(1L, 2L, 3L)) - .isInstanceOf(GlobalException.class); - } - - @Test - @DisplayName("AI 호출 실패 시 예외가 그대로 전파된다") - void requestTopicSummary_throwsWhenAiClientFails() { - Long gatheringId = 1L; - Long meetingId = 2L; - Long topicId = 5L; - Topic topic = Topic.builder().id(topicId).title("데미안 토론").build(); - - doNothing().when(gatheringValidator).validateGathering(gatheringId); - doNothing().when(meetingValidator).validateMeetingInGathering(meetingId, gatheringId); - doNothing().when(meetingValidator).validateMeetingMember(meetingId, 1L); - when(topicValidator.getTopicInMeeting(topicId, meetingId)).thenReturn(topic); - when(topicAnswerRepository.findSubmittedByTopicId(topicId)) - .thenReturn(List.of()); - when(aiSummaryClient.requestTopicSummary(org.mockito.ArgumentMatchers.any())) - .thenThrow(new IllegalStateException("ai fail")); - - assertThatThrownBy(() -> topicSummaryService.requestTopicSummary(gatheringId, meetingId, topicId)) - .isInstanceOf(IllegalStateException.class); - } -} From f176628728abf83e798eafbfed31faf00761f8cb Mon Sep 17 00:00:00 2001 From: JWoong-01 Date: Fri, 20 Feb 2026 22:05:13 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20validate=20=EB=B0=8F=20api=EB=AA=85?= =?UTF-8?q?=EC=84=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/dokdok/stt/api/SttApi.java | 54 +++++++++++++++---- .../dokdok/stt/controller/SttController.java | 12 +++-- .../com/dokdok/stt/service/SttJobService.java | 12 +++-- 3 files changed, 62 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/dokdok/stt/api/SttApi.java b/src/main/java/com/dokdok/stt/api/SttApi.java index c4e67419..5f22a1ea 100644 --- a/src/main/java/com/dokdok/stt/api/SttApi.java +++ b/src/main/java/com/dokdok/stt/api/SttApi.java @@ -19,13 +19,14 @@ import org.springframework.web.multipart.MultipartFile; @Tag(name = "STT", description = "STT 작업 관련 API") -@RequestMapping("/api/meetings/{meetingId}/stt/jobs") +@RequestMapping("/api/gatherings/{gatheringId}/meetings/{meetingId}/stt/jobs") public interface SttApi { @Operation( summary = "STT 작업 생성", description = "오디오 파일 업로드 또는 사전의견만으로 STT 요약 작업을 생성합니다.", parameters = { + @Parameter(name = "gatheringId", description = "모임 식별자", in = ParameterIn.PATH, required = true), @Parameter(name = "meetingId", description = "약속 식별자", in = ParameterIn.PATH, required = true) } ) @@ -36,13 +37,30 @@ public interface SttApi { content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = SttJobResponse.class)) ), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "약속 멤버가 아님"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "약속을 찾을 수 없음"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @io.swagger.v3.oas.annotations.media.ExampleObject(value = """ + {"code":"E000","message":"잘못된 요청입니다.","data":null} + """))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "약속 멤버가 아님", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @io.swagger.v3.oas.annotations.media.ExampleObject(value = """ + {"code":"G002","message":"모임의 멤버가 아닙니다.","data":null} + """))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "약속을 찾을 수 없음", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @io.swagger.v3.oas.annotations.media.ExampleObject(value = """ + {"code":"M001","message":"약속을 찾을 수 없습니다.","data":null} + """))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @io.swagger.v3.oas.annotations.media.ExampleObject(value = """ + {"code":"E000","message":"서버 에러가 발생했습니다. 담당자에게 문의 바랍니다.","data":null} + """))) }) @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) ResponseEntity> createJob( + @PathVariable Long gatheringId, @PathVariable Long meetingId, @RequestPart(value = "file", required = false) MultipartFile file ); @@ -51,6 +69,7 @@ ResponseEntity> createJob( summary = "STT 작업 조회", description = "STT 작업 상태/결과를 조회합니다.", parameters = { + @Parameter(name = "gatheringId", description = "모임 식별자", in = ParameterIn.PATH, required = true), @Parameter(name = "meetingId", description = "약속 식별자", in = ParameterIn.PATH, required = true), @Parameter(name = "jobId", description = "STT 작업 식별자", in = ParameterIn.PATH, required = true) } @@ -62,13 +81,30 @@ ResponseEntity> createJob( content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = SttJobResponse.class)) ), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "약속 멤버가 아님"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "작업을 찾을 수 없음"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @io.swagger.v3.oas.annotations.media.ExampleObject(value = """ + {"code":"E000","message":"잘못된 요청입니다.","data":null} + """))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "약속 멤버가 아님", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @io.swagger.v3.oas.annotations.media.ExampleObject(value = """ + {"code":"G002","message":"모임의 멤버가 아닙니다.","data":null} + """))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "작업을 찾을 수 없음", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @io.swagger.v3.oas.annotations.media.ExampleObject(value = """ + {"code":"E101","message":"작업을 찾을 수 없습니다.","data":null} + """))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @io.swagger.v3.oas.annotations.media.ExampleObject(value = """ + {"code":"E000","message":"서버 에러가 발생했습니다. 담당자에게 문의 바랍니다.","data":null} + """))) }) @GetMapping(value = "/{jobId}", produces = MediaType.APPLICATION_JSON_VALUE) ResponseEntity> getJob( + @PathVariable Long gatheringId, @PathVariable Long meetingId, @PathVariable Long jobId ); diff --git a/src/main/java/com/dokdok/stt/controller/SttController.java b/src/main/java/com/dokdok/stt/controller/SttController.java index 4c8238fd..df665aff 100644 --- a/src/main/java/com/dokdok/stt/controller/SttController.java +++ b/src/main/java/com/dokdok/stt/controller/SttController.java @@ -16,14 +16,18 @@ public class SttController implements SttApi { private final SttJobService sttJobService; @Override - public ResponseEntity> createJob(Long meetingId, MultipartFile file) { - SttJobResponse response = sttJobService.createJob(meetingId, file); + public ResponseEntity> createJob( + Long gatheringId, + Long meetingId, + MultipartFile file + ) { + SttJobResponse response = sttJobService.createJob(gatheringId, meetingId, file); return ApiResponse.created(response, "STT 작업이 생성되었습니다."); } @Override - public ResponseEntity> getJob(Long meetingId, Long jobId) { - SttJobResponse response = sttJobService.getJob(meetingId, jobId); + public ResponseEntity> getJob(Long gatheringId, Long meetingId, Long jobId) { + SttJobResponse response = sttJobService.getJob(gatheringId, meetingId, jobId); return ApiResponse.success(response, "STT 작업 조회를 완료했습니다."); } } diff --git a/src/main/java/com/dokdok/stt/service/SttJobService.java b/src/main/java/com/dokdok/stt/service/SttJobService.java index d131819f..061fb3c0 100644 --- a/src/main/java/com/dokdok/stt/service/SttJobService.java +++ b/src/main/java/com/dokdok/stt/service/SttJobService.java @@ -2,9 +2,10 @@ import com.dokdok.ai.client.AiSttClient; import com.dokdok.ai.dto.SttRequest; -import com.dokdok.global.response.ApiResponse; import com.dokdok.global.exception.GlobalErrorCode; import com.dokdok.global.exception.GlobalException; +import com.dokdok.gathering.service.GatheringValidator; +import com.dokdok.global.response.ApiResponse; import com.dokdok.global.util.SecurityUtil; import com.dokdok.meeting.entity.Meeting; import com.dokdok.meeting.service.MeetingValidator; @@ -49,6 +50,7 @@ public class SttJobService { ); private final MeetingValidator meetingValidator; + private final GatheringValidator gatheringValidator; private final SttJobRepository sttJobRepository; private final SttSummaryRepository sttSummaryRepository; private final TopicRepository topicRepository; @@ -60,8 +62,10 @@ public class SttJobService { private String tempDirProperty; @Transactional - public SttJobResponse createJob(Long meetingId, MultipartFile file) { + public SttJobResponse createJob(Long gatheringId, Long meetingId, MultipartFile file) { Long userId = SecurityUtil.getCurrentUserId(); + gatheringValidator.validateMembership(gatheringId, userId); + meetingValidator.validateMeetingInGathering(meetingId, gatheringId); meetingValidator.validateMeeting(meetingId); meetingValidator.validateMeetingMember(meetingId, userId); @@ -142,8 +146,10 @@ public SttJobResponse createJob(Long meetingId, MultipartFile file) { } @Transactional(readOnly = true) - public SttJobResponse getJob(Long meetingId, Long jobId) { + public SttJobResponse getJob(Long gatheringId, Long meetingId, Long jobId) { Long userId = SecurityUtil.getCurrentUserId(); + gatheringValidator.validateMembership(gatheringId, userId); + meetingValidator.validateMeetingInGathering(meetingId, gatheringId); meetingValidator.validateMeeting(meetingId); meetingValidator.validateMeetingMember(meetingId, userId);