diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..7262a56 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,48 @@ +name: Deploy to Production + +on: + push: + branches: + - 'release/**' + workflow_dispatch: + +jobs: + deploy: + name: Production Deploy + runs-on: self-hosted + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + with: + submodules: true + token: ${{ secrets.ACTION_TOKEN }} + + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'corretto' + + - name: Cache Gradle packages + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Build with Gradle + run: | + pwd + ls -la + chmod +x ./gradlew + ./gradlew clean bootJar + + - name: Copy files and Deploy + run: | + cp -r ./build/libs/*.jar /home/yugyeom/notai/ + cd /home/yugyeom/notai + docker-compose down + docker-compose up -d --build diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..fc0f695 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "Team29_BE_Submodule"] + path = Team29_BE_Submodule + url = git@github.com:29ana-notai/Team29_BE_Submodule.git + branch = release/0.0.2 diff --git a/Team29_BE_Submodule b/Team29_BE_Submodule new file mode 160000 index 0000000..9786d71 --- /dev/null +++ b/Team29_BE_Submodule @@ -0,0 +1 @@ +Subproject commit 9786d71108d3333313244c73fb01e56b6993916f diff --git a/build.gradle b/build.gradle index 79dab35..60edd53 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'notai' -version = '0.0.1-SNAPSHOT' +version = '0.0.2' java { toolchain { @@ -59,6 +59,9 @@ dependencies { // OCR implementation 'net.sourceforge.tess4j:tess4j:5.13.0' + + // Slack + implementation("com.slack.api:slack-api-client:1.44.1") } tasks.named('test') { @@ -70,3 +73,11 @@ test { excludeTags 'exclude-test' } } + +processResources.dependsOn('copySecret') + +tasks.register('copySecret', Copy) { + from './Team29_BE_Submodule' + include "**" + into './src/main/resources' +} diff --git a/settings.gradle b/settings.gradle index 0f5036d..21eb53a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -rootProject.name = 'backend' +rootProject.name = 'notai' diff --git a/src/main/java/notai/client/ai/AiClient.java b/src/main/java/notai/client/ai/AiClient.java index 296787a..c03507b 100644 --- a/src/main/java/notai/client/ai/AiClient.java +++ b/src/main/java/notai/client/ai/AiClient.java @@ -2,17 +2,17 @@ import notai.client.ai.request.LlmTaskRequest; import notai.client.ai.response.TaskResponse; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestPart; -import org.springframework.web.multipart.MultipartFile; import org.springframework.web.service.annotation.PostExchange; +import java.io.InputStream; + public interface AiClient { @PostExchange(url = "/api/ai/llm") TaskResponse submitLlmTask(@RequestBody LlmTaskRequest request); - @PostExchange(url = "/api/ai/stt") - TaskResponse submitSttTask(@RequestPart("audio") MultipartFile audioFile); + @PostExchange(url = "/api/ai/stt", contentType = MediaType.MULTIPART_FORM_DATA_VALUE) + TaskResponse submitSttTask(@RequestBody InputStream audioFileStream); } - diff --git a/src/main/java/notai/client/slack/SlackWebHookClient.java b/src/main/java/notai/client/slack/SlackWebHookClient.java new file mode 100644 index 0000000..58f8947 --- /dev/null +++ b/src/main/java/notai/client/slack/SlackWebHookClient.java @@ -0,0 +1,44 @@ +package notai.client.slack; + +import com.slack.api.Slack; +import com.slack.api.webhook.Payload; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import notai.common.exception.ErrorMessages; +import notai.common.exception.type.ExternalApiException; +import org.springframework.http.HttpStatus; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +@Async +@Slf4j +public class SlackWebHookClient { + + private final SlackWebHookProperty slackWebHookProperty; + + public void sendToInfoChannel(String message) { + Slack slack = Slack.getInstance(); + Payload payload = Payload.builder().text(message).build(); + + try { + slack.send(slackWebHookProperty.infoChannel().webhookUrl(), payload); + } catch (IOException e) { + throw new ExternalApiException(ErrorMessages.SLACK_API_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.value()); + } + } + + public void sendToErrorChannel(String message) { + Slack slack = Slack.getInstance(); + Payload payload = Payload.builder().text(message).build(); + + try { + slack.send(slackWebHookProperty.errorChannel().webhookUrl(), payload); + } catch (IOException e) { + throw new ExternalApiException(ErrorMessages.SLACK_API_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.value()); + } + } +} diff --git a/src/main/java/notai/client/slack/SlackWebHookProperty.java b/src/main/java/notai/client/slack/SlackWebHookProperty.java new file mode 100644 index 0000000..01a64f1 --- /dev/null +++ b/src/main/java/notai/client/slack/SlackWebHookProperty.java @@ -0,0 +1,14 @@ +package notai.client.slack; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "slack") +public record SlackWebHookProperty( + WebhookProperty infoChannel, + WebhookProperty errorChannel +) { + public record WebhookProperty( + String webhookUrl + ) { + } +} diff --git a/src/main/java/notai/common/config/AuthConfig.java b/src/main/java/notai/common/config/AuthConfig.java index 6e4417f..c0e8bc9 100644 --- a/src/main/java/notai/common/config/AuthConfig.java +++ b/src/main/java/notai/common/config/AuthConfig.java @@ -17,10 +17,8 @@ public class AuthConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(authInterceptor) - .addPathPatterns("/api/**") - .excludePathPatterns("/api/members/oauth/login/**") - .excludePathPatterns("/api/members/token/refresh"); + registry.addInterceptor(authInterceptor).addPathPatterns("/api/**").excludePathPatterns( + "/api/members/oauth/login/**").excludePathPatterns("/api/members/token/refresh"); } @Override diff --git a/src/main/java/notai/common/config/SwaggerConfig.java b/src/main/java/notai/common/config/SwaggerConfig.java index f3f77db..bcf6279 100644 --- a/src/main/java/notai/common/config/SwaggerConfig.java +++ b/src/main/java/notai/common/config/SwaggerConfig.java @@ -38,6 +38,6 @@ public OpenAPI openAPI() { } private Info apiInfo() { - return new Info().title("notai API").description("notai API 문서입니다.").version("0.0.1"); + return new Info().title("notai API").description("notai API 문서입니다.").version("0.0.2"); } } diff --git a/src/main/java/notai/common/exception/ApplicationException.java b/src/main/java/notai/common/exception/ApplicationException.java index c6bc1b3..10552ca 100644 --- a/src/main/java/notai/common/exception/ApplicationException.java +++ b/src/main/java/notai/common/exception/ApplicationException.java @@ -11,4 +11,9 @@ public ApplicationException(ErrorMessages message, int code) { super(message.getMessage()); this.code = code; } + + public ApplicationException(String message, int code) { + super(message); + this.code = code; + } } diff --git a/src/main/java/notai/common/exception/ErrorMessages.java b/src/main/java/notai/common/exception/ErrorMessages.java index ba0884a..0fd77c8 100644 --- a/src/main/java/notai/common/exception/ErrorMessages.java +++ b/src/main/java/notai/common/exception/ErrorMessages.java @@ -9,16 +9,20 @@ public enum ErrorMessages { ANNOTATION_NOT_FOUND("주석을 찾을 수 없습니다."), // document - DOCUMENT_NOT_FOUND("자료를 찾을 수 없습니다."), INVALID_DOCUMENT_PAGE("존재하지 않는 페이지 입니다."), + DOCUMENT_NOT_FOUND("자료를 찾을 수 없습니다."), + INVALID_DOCUMENT_PAGE("존재하지 않는 페이지 입니다."), + UNAUTHORIZED_DOCUMENT_ACCESS("자료에 대한 권한이 없습니다."), // ocr - OCR_RESULT_NOT_FOUND("OCR 데이터를 찾을 수 없습니다."), OCR_TASK_ERROR("PDF 파일을 통해 OCR 작업을 수행하는데 실패했습니다."), + OCR_RESULT_NOT_FOUND("OCR 데이터를 찾을 수 없습니다."), + OCR_TASK_ERROR("PDF 파일을 통해 OCR 작업을 수행하는데 실패했습니다."), // folder FOLDER_NOT_FOUND("폴더를 찾을 수 없습니다."), // llm task - LLM_TASK_LOG_NOT_FOUND("AI 작업 기록을 찾을 수 없습니다."), LLM_TASK_RESULT_ERROR("AI 요약 및 문제 생성 중에 문제가 발생했습니다."), + LLM_TASK_LOG_NOT_FOUND("AI 작업 기록을 찾을 수 없습니다."), + LLM_TASK_RESULT_ERROR("AI 요약 및 문제 생성 중에 문제가 발생했습니다."), // problem PROBLEM_NOT_FOUND("문제 정보를 찾을 수 없습니다."), @@ -33,19 +37,30 @@ public enum ErrorMessages { RECORDING_NOT_FOUND("녹음 파일을 찾을 수 없습니다."), // external api call - KAKAO_API_ERROR("카카오 API 호출에 예외가 발생했습니다."), AI_SERVER_ERROR("AI 서버 API 호출에 예외가 발생했습니다."), + KAKAO_API_ERROR("카카오 API 호출에 예외가 발생했습니다."), + AI_SERVER_ERROR("AI 서버 API 호출에 예외가 발생했습니다."), + SLACK_API_ERROR("슬랙 API 호출에 예외가 발생했습니다."), // auth - INVALID_ACCESS_TOKEN("유효하지 않은 토큰입니다."), INVALID_REFRESH_TOKEN("유요하지 않은 Refresh Token입니다."), EXPIRED_REFRESH_TOKEN( - "만료된 Refresh Token입니다."), INVALID_LOGIN_TYPE("지원하지 않는 소셜 로그인 타입입니다."), NOTFOUND_ACCESS_TOKEN( - "토큰 정보가 존재하지 않습니다."), + INVALID_ACCESS_TOKEN("유효하지 않은 토큰입니다."), + INVALID_REFRESH_TOKEN("유요하지 않은 Refresh Token입니다."), + EXPIRED_REFRESH_TOKEN("만료된 Refresh Token입니다."), + INVALID_LOGIN_TYPE("지원하지 않는 소셜 로그인 타입입니다."), + NOTFOUND_ACCESS_TOKEN("토큰 정보가 존재하지 않습니다."), + + // stt + STT_TASK_NOT_FOUND("음성 인식 작업을 찾을 수 없습니다."), + STT_TASK_ERROR("음성 인식 작업 중에 오류가 발생했습니다."), // json conversion JSON_CONVERSION_ERROR("JSON-객체 변환 중에 오류가 발생했습니다."), // etc - INVALID_FILE_TYPE("지원하지 않는 파일 형식입니다."), FILE_NOT_FOUND("존재하지 않는 파일입니다."), FILE_SAVE_ERROR( - "파일을 저장하는 과정에서 오류가 발생했습니다."), INVALID_AUDIO_ENCODING("오디오 파일이 잘못되었습니다."); + INVALID_FILE_TYPE("지원하지 않는 파일 형식입니다."), + FILE_NOT_FOUND("존재하지 않는 파일입니다."), + FILE_SAVE_ERROR("파일을 저장하는 과정에서 오류가 발생했습니다."), + INVALID_AUDIO_ENCODING("오디오 파일이 잘못되었습니다."), + FILE_READ_ERROR("파일을 읽는 과정에서 오류가 발생했습니다."); private final String message; diff --git a/src/main/java/notai/common/exception/ExceptionControllerAdvice.java b/src/main/java/notai/common/exception/ExceptionControllerAdvice.java index c01f2d4..e62be1b 100644 --- a/src/main/java/notai/common/exception/ExceptionControllerAdvice.java +++ b/src/main/java/notai/common/exception/ExceptionControllerAdvice.java @@ -4,6 +4,7 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import notai.client.slack.SlackWebHookClient; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; @@ -25,11 +26,19 @@ @RestControllerAdvice public class ExceptionControllerAdvice extends ResponseEntityExceptionHandler { + private final SlackWebHookClient slackWebHookClient; + @ExceptionHandler(ApplicationException.class) ResponseEntity handleException(HttpServletRequest request, ApplicationException e) { log.info("잘못된 요청이 들어왔습니다. uri: {} {}, 내용: {}", request.getMethod(), request.getRequestURI(), e.getMessage()); + slackWebHookClient.sendToInfoChannel( + "잘못된 요청이 들어왔습니다.\n" + + "uri: " + request.getMethod() + " " + request.getRequestURI() + "\n" + + "내용: " + e.getMessage() + ); + requestLogging(request); return ResponseEntity.status(e.getCode()).body(new ExceptionResponse(e.getMessage())); @@ -39,6 +48,12 @@ ResponseEntity handleException(HttpServletRequest request, Ap ResponseEntity handleException(HttpServletRequest request, Exception e) { log.error("예상하지 못한 예외가 발생했습니다. uri: {} {}, ", request.getMethod(), request.getRequestURI(), e); + slackWebHookClient.sendToErrorChannel( + "예상하지 못한 예외가 발생했습니다.\n" + + "uri: " + request.getMethod() + " " + request.getRequestURI() + "\n" + + "내용: " + e.getMessage() + ); + requestLogging(request); return ResponseEntity.internalServerError().body(new ExceptionResponse(e.getMessage())); } @@ -57,6 +72,12 @@ protected ResponseEntity handleExceptionInternal( HttpServletRequest request = ((ServletWebRequest) webRequest).getRequest(); log.error("예외가 발생했습니다. uri: {} {}, ", request.getMethod(), request.getRequestURI(), e); + slackWebHookClient.sendToErrorChannel( + "예외가 발생했습니다.\n" + + "uri: " + request.getMethod() + " " + request.getRequestURI() + "\n" + + " 내용: " + e.getMessage() + ); + requestLogging(request); return ResponseEntity.status(statusCode).body(new ExceptionResponse(e.getMessage())); } diff --git a/src/main/java/notai/document/application/DocumentQueryService.java b/src/main/java/notai/document/application/DocumentQueryService.java index 4f2112f..0e45f85 100644 --- a/src/main/java/notai/document/application/DocumentQueryService.java +++ b/src/main/java/notai/document/application/DocumentQueryService.java @@ -19,6 +19,11 @@ public List findDocuments(Long folderId) { return documents.stream().map(this::getDocumentFindResult).toList(); } + public List findRootDocuments(Long memberId) { + List documents = documentRepository.findAllByMemberIdAndFolderIdIsNull(memberId); + return documents.stream().map(this::getDocumentFindResult).toList(); + } + private DocumentFindResult getDocumentFindResult(Document document) { return DocumentFindResult.of(document.getId(), document.getName(), document.getUrl()); } diff --git a/src/main/java/notai/document/application/DocumentService.java b/src/main/java/notai/document/application/DocumentService.java index d6b1f5e..6a04c5e 100644 --- a/src/main/java/notai/document/application/DocumentService.java +++ b/src/main/java/notai/document/application/DocumentService.java @@ -9,6 +9,8 @@ import notai.document.presentation.request.DocumentUpdateRequest; import notai.folder.domain.Folder; import notai.folder.domain.FolderRepository; +import notai.member.domain.Member; +import notai.member.domain.MemberRepository; import notai.ocr.application.OCRService; import notai.pdf.PdfService; import notai.pdf.result.PdfSaveResult; @@ -25,58 +27,76 @@ public class DocumentService { private final OCRService ocrService; private final DocumentRepository documentRepository; private final FolderRepository folderRepository; + private final MemberRepository memberRepository; + + private static final Long ROOT_FOLDER_ID = -1L; + public DocumentSaveResult saveDocument( - Long folderId, MultipartFile pdfFile, DocumentSaveRequest documentSaveRequest + Long memberId, Long folderId, MultipartFile pdfFile, DocumentSaveRequest documentSaveRequest ) { PdfSaveResult pdfSaveResult = pdfService.savePdf(pdfFile); - Document document = saveAndReturnDocument(folderId, documentSaveRequest, pdfSaveResult); + Document document = saveAndReturnDocument(memberId, folderId, documentSaveRequest, pdfSaveResult); ocrService.saveOCR(document, pdfSaveResult.pdf()); return DocumentSaveResult.of(document.getId(), document.getName(), document.getUrl()); } public DocumentSaveResult saveRootDocument( - MultipartFile pdfFile, DocumentSaveRequest documentSaveRequest + Long memberId, MultipartFile pdfFile, DocumentSaveRequest documentSaveRequest ) { PdfSaveResult pdfSaveResult = pdfService.savePdf(pdfFile); - Document document = saveAndReturnRootDocument(documentSaveRequest, pdfSaveResult); + Document document = saveAndReturnRootDocument(memberId, documentSaveRequest, pdfSaveResult); ocrService.saveOCR(document, pdfSaveResult.pdf()); return DocumentSaveResult.of(document.getId(), document.getName(), document.getUrl()); } public DocumentUpdateResult updateDocument( - Long folderId, Long documentId, DocumentUpdateRequest documentUpdateRequest + Long memberId, Long folderId, Long documentId, DocumentUpdateRequest documentUpdateRequest ) { Document document = documentRepository.getById(documentId); - document.validateDocument(folderId); + Member member = memberRepository.getById(memberId); + + document.validateOwner(member); + + if (!folderId.equals(ROOT_FOLDER_ID)) { + document.validateDocument(folderId); + } document.updateName(documentUpdateRequest.name()); Document savedDocument = documentRepository.save(document); return DocumentUpdateResult.of(savedDocument.getId(), savedDocument.getName(), savedDocument.getUrl()); } public void deleteDocument( - Long folderId, Long documentId + Long memberId, Long folderId, Long documentId ) { Document document = documentRepository.getById(documentId); - document.validateDocument(folderId); + Member member = memberRepository.getById(memberId); + + document.validateOwner(member); + + if (!folderId.equals(ROOT_FOLDER_ID)) { + document.validateDocument(folderId); + } ocrService.deleteAllByDocument(document); documentRepository.delete(document); } public void deleteAllByFolder( - Folder folder + Long memberId, Folder folder ) { List documents = documentRepository.findAllByFolderId(folder.getId()); for (Document document : documents) { - deleteDocument(folder.getId(), document.getId()); + deleteDocument(memberId, folder.getId(), document.getId()); } } private Document saveAndReturnDocument( - Long folderId, DocumentSaveRequest documentSaveRequest, PdfSaveResult pdfSaveResult + Long memberId, Long folderId, DocumentSaveRequest documentSaveRequest, PdfSaveResult pdfSaveResult ) { + Member member = memberRepository.getById(memberId); Folder folder = folderRepository.getById(folderId); Document document = new Document(folder, + member, documentSaveRequest.name(), pdfSaveResult.pdfUrl(), pdfSaveResult.totalPages() @@ -84,8 +104,12 @@ private Document saveAndReturnDocument( return documentRepository.save(document); } - private Document saveAndReturnRootDocument(DocumentSaveRequest documentSaveRequest, PdfSaveResult pdfSaveResult) { - Document document = new Document(documentSaveRequest.name(), + private Document saveAndReturnRootDocument( + Long memberId, DocumentSaveRequest documentSaveRequest, PdfSaveResult pdfSaveResult + ) { + Member member = memberRepository.getById(memberId); + Document document = new Document(member, + documentSaveRequest.name(), pdfSaveResult.pdfUrl(), pdfSaveResult.totalPages() ); diff --git a/src/main/java/notai/document/domain/Document.java b/src/main/java/notai/document/domain/Document.java index 7149790..04a5a84 100644 --- a/src/main/java/notai/document/domain/Document.java +++ b/src/main/java/notai/document/domain/Document.java @@ -8,10 +8,12 @@ import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; import notai.common.domain.RootEntity; -import static notai.common.exception.ErrorMessages.DOCUMENT_NOT_FOUND; -import static notai.common.exception.ErrorMessages.INVALID_DOCUMENT_PAGE; +import notai.common.exception.ErrorMessages; +import static notai.common.exception.ErrorMessages.*; import notai.common.exception.type.NotFoundException; +import notai.common.exception.type.UnAuthorizedException; import notai.folder.domain.Folder; +import notai.member.domain.Member; @Slf4j @Entity @@ -28,6 +30,10 @@ public class Document extends RootEntity { @JoinColumn(name = "folder_id", referencedColumnName = "id") private Folder folder; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", referencedColumnName = "id") + private Member member; + @NotNull @Column(name = "name", length = 50) private String name; @@ -40,14 +46,16 @@ public class Document extends RootEntity { @Column(name = "total_pages") private Integer totalPages; - public Document(Folder folder, String name, String url, Integer totalPages) { + public Document(Folder folder, Member member, String name, String url, Integer totalPages) { + this.member = member; this.folder = folder; this.name = name; this.url = url; this.totalPages = totalPages; } - public Document(String name, String url, Integer totalPages) { + public Document(Member member, String name, String url, Integer totalPages) { + this.member = member; this.name = name; this.url = url; this.totalPages = totalPages; @@ -68,4 +76,10 @@ public void validatePageNumber(Integer pageNumber) { public void updateName(String name) { this.name = name; } + + public void validateOwner(Member member) { + if (!this.member.equals(member)) { + throw new UnAuthorizedException(UNAUTHORIZED_DOCUMENT_ACCESS); + } + } } diff --git a/src/main/java/notai/document/domain/DocumentRepository.java b/src/main/java/notai/document/domain/DocumentRepository.java index ddb0a36..2af91ca 100644 --- a/src/main/java/notai/document/domain/DocumentRepository.java +++ b/src/main/java/notai/document/domain/DocumentRepository.java @@ -13,4 +13,6 @@ default Document getById(Long id) { } List findAllByFolderId(Long folderId); + + List findAllByMemberIdAndFolderIdIsNull(Long memberId); } diff --git a/src/main/java/notai/document/presentation/DocumentController.java b/src/main/java/notai/document/presentation/DocumentController.java index c343ddd..12423c5 100644 --- a/src/main/java/notai/document/presentation/DocumentController.java +++ b/src/main/java/notai/document/presentation/DocumentController.java @@ -1,6 +1,7 @@ package notai.document.presentation; import lombok.RequiredArgsConstructor; +import notai.auth.Auth; import notai.document.application.DocumentQueryService; import notai.document.application.DocumentService; import notai.document.application.result.DocumentFindResult; @@ -31,15 +32,17 @@ public class DocumentController { @PostMapping public ResponseEntity saveDocument( + @Auth Long memberId, @PathVariable Long folderId, @RequestPart MultipartFile pdfFile, @RequestPart DocumentSaveRequest documentSaveRequest ) { + DocumentSaveResult documentSaveResult; if (folderId.equals(ROOT_FOLDER_ID)) { - documentSaveResult = documentService.saveRootDocument(pdfFile, documentSaveRequest); + documentSaveResult = documentService.saveRootDocument(memberId, pdfFile, documentSaveRequest); } else { - documentSaveResult = documentService.saveDocument(folderId, pdfFile, documentSaveRequest); + documentSaveResult = documentService.saveDocument(memberId, folderId, pdfFile, documentSaveRequest); } DocumentSaveResponse response = DocumentSaveResponse.from(documentSaveResult); String url = String.format(FOLDER_URL_FORMAT, folderId, response.id()); @@ -48,27 +51,41 @@ public ResponseEntity saveDocument( @PutMapping(value = "/{id}") public ResponseEntity updateDocument( - @PathVariable Long folderId, @PathVariable Long id, @RequestBody DocumentUpdateRequest documentUpdateRequest + @Auth Long memberId, + @PathVariable Long folderId, + @PathVariable Long id, + @RequestBody DocumentUpdateRequest documentUpdateRequest ) { - DocumentUpdateResult documentUpdateResult = documentService.updateDocument(folderId, id, documentUpdateRequest); + DocumentUpdateResult documentUpdateResult = documentService.updateDocument( + memberId, + folderId, + id, + documentUpdateRequest + ); DocumentUpdateResponse response = DocumentUpdateResponse.from(documentUpdateResult); return ResponseEntity.ok(response); } @GetMapping public ResponseEntity> getDocuments( - @PathVariable Long folderId + @Auth Long memberId, @PathVariable Long folderId ) { - List documentResults = documentQueryService.findDocuments(folderId); + List documentResults; + if (folderId.equals(ROOT_FOLDER_ID)) { + documentResults = documentQueryService.findRootDocuments(memberId); + } else { + documentResults = documentQueryService.findDocuments(folderId); + } + List responses = documentResults.stream().map(DocumentFindResponse::from).toList(); return ResponseEntity.ok(responses); } @DeleteMapping("/{id}") - public ResponseEntity getDocuments( - @PathVariable Long folderId, @PathVariable Long id + public ResponseEntity deleteDocument( + @Auth Long memberId, @PathVariable Long folderId, @PathVariable Long id ) { - documentService.deleteDocument(folderId, id); + documentService.deleteDocument(memberId, folderId, id); return ResponseEntity.noContent().build(); } } diff --git a/src/main/java/notai/folder/application/FolderService.java b/src/main/java/notai/folder/application/FolderService.java index ea338fa..11abcae 100644 --- a/src/main/java/notai/folder/application/FolderService.java +++ b/src/main/java/notai/folder/application/FolderService.java @@ -71,7 +71,7 @@ public void deleteFolder(Long memberId, Long id) { for (Folder subFolder : subFolders) { deleteFolder(memberId, subFolder.getId()); } - documentService.deleteAllByFolder(folder); + documentService.deleteAllByFolder(memberId, folder); folderRepository.delete(folder); } diff --git a/src/main/java/notai/ocr/application/OCRService.java b/src/main/java/notai/ocr/application/OCRService.java index 55353ba..f5c9a21 100644 --- a/src/main/java/notai/ocr/application/OCRService.java +++ b/src/main/java/notai/ocr/application/OCRService.java @@ -10,6 +10,7 @@ import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.rendering.PDFRenderer; +import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; @@ -22,27 +23,32 @@ public class OCRService { private final OCRRepository ocrRepository; + @Value("${tesseract.library.path}") + private String libraryPath; + + @Value("${tesseract.data.path}") + private String dataPath; + + @Value("${tesseract.language}") + private String language; + @Async public void saveOCR( Document document, File pdfFile ) { try { - // System.setProperty("jna.library.path", "/usr/local/opt/tesseract/lib/"); - System.setProperty("jna.library.path", "C:\\Program Files\\Tesseract-OCR"); + System.setProperty("jna.library.path", libraryPath); - //window, mac -> brew install tesseract, tesseract-lang Tesseract tesseract = new Tesseract(); - - // tesseract.setDatapath("/usr/local/share/tessdata"); - tesseract.setDatapath("C:\\Program Files\\Tesseract-OCR\\tessdata"); - tesseract.setLanguage("kor+eng"); + tesseract.setDatapath(dataPath); + tesseract.setLanguage(language); PDDocument pdDocument = Loader.loadPDF(pdfFile); PDFRenderer pdfRenderer = new PDFRenderer(pdDocument); for (int i = 0; i < pdDocument.getNumberOfPages(); i++) { BufferedImage image = pdfRenderer.renderImage(i); String ocrResult = tesseract.doOCR(image); - OCR ocr = new OCR(document, i + 1, ocrResult); + OCR ocr = new OCR(document, i, ocrResult); ocrRepository.save(ocr); } diff --git a/src/main/java/notai/ocr/domain/OCR.java b/src/main/java/notai/ocr/domain/OCR.java index cad27e8..061bcd7 100644 --- a/src/main/java/notai/ocr/domain/OCR.java +++ b/src/main/java/notai/ocr/domain/OCR.java @@ -28,7 +28,7 @@ public class OCR extends RootEntity { private Integer pageNumber; @NotNull - @Column(name = "content", length = 255) + @Column(name = "content", columnDefinition = "TEXT") private String content; public OCR(Document document, Integer pageNumber, String content) { diff --git a/src/main/java/notai/pageRecording/domain/PageRecordingRepository.java b/src/main/java/notai/pageRecording/domain/PageRecordingRepository.java index 01e84fe..2a7fe96 100644 --- a/src/main/java/notai/pageRecording/domain/PageRecordingRepository.java +++ b/src/main/java/notai/pageRecording/domain/PageRecordingRepository.java @@ -1,7 +1,13 @@ package notai.pageRecording.domain; +import notai.pageRecording.query.PageRecordingQueryRepository; +import notai.recording.domain.Recording; import org.springframework.data.jpa.repository.JpaRepository; -public interface PageRecordingRepository extends JpaRepository { +import java.util.List; +public interface PageRecordingRepository extends + JpaRepository, PageRecordingQueryRepository { + + List findAllByRecording(Recording recording); } diff --git a/src/main/java/notai/pageRecording/query/PageRecordingQueryRepository.java b/src/main/java/notai/pageRecording/query/PageRecordingQueryRepository.java index fa6e7c1..b49195b 100644 --- a/src/main/java/notai/pageRecording/query/PageRecordingQueryRepository.java +++ b/src/main/java/notai/pageRecording/query/PageRecordingQueryRepository.java @@ -1,12 +1,10 @@ package notai.pageRecording.query; -import com.querydsl.jpa.impl.JPAQueryFactory; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; +import notai.pageRecording.domain.PageRecording; -@Repository -@RequiredArgsConstructor -public class PageRecordingQueryRepository { +import java.util.List; - private final JPAQueryFactory queryFactory; +public interface PageRecordingQueryRepository { + + List findAllByRecordingIdOrderByStartTime(Long recordingId); } diff --git a/src/main/java/notai/pageRecording/query/PageRecordingQueryRepositoryImpl.java b/src/main/java/notai/pageRecording/query/PageRecordingQueryRepositoryImpl.java new file mode 100644 index 0000000..c2a297b --- /dev/null +++ b/src/main/java/notai/pageRecording/query/PageRecordingQueryRepositoryImpl.java @@ -0,0 +1,23 @@ +package notai.pageRecording.query; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import notai.pageRecording.domain.PageRecording; +import static notai.pageRecording.domain.QPageRecording.pageRecording; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class PageRecordingQueryRepositoryImpl implements PageRecordingQueryRepository{ + + private final JPAQueryFactory queryFactory; + + public List findAllByRecordingIdOrderByStartTime(Long recordingId) { + return queryFactory.selectFrom(pageRecording) + .where(pageRecording.recording.id.eq(recordingId)) + .orderBy(pageRecording.startTime.asc()) + .fetch(); + } +} diff --git a/src/main/java/notai/pdf/PdfService.java b/src/main/java/notai/pdf/PdfService.java index f427ec7..5b403bb 100644 --- a/src/main/java/notai/pdf/PdfService.java +++ b/src/main/java/notai/pdf/PdfService.java @@ -9,6 +9,7 @@ import notai.pdf.result.PdfSaveResult; import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -23,7 +24,8 @@ @RequiredArgsConstructor public class PdfService { - private static final String STORAGE_DIR = "src/main/resources/pdf/"; + @Value("${file.pdf.basePath}") + private String STORAGE_DIR; public PdfSaveResult savePdf(MultipartFile file) { try { diff --git a/src/main/java/notai/recording/application/RecordingService.java b/src/main/java/notai/recording/application/RecordingService.java index 7f65bfb..d09e819 100644 --- a/src/main/java/notai/recording/application/RecordingService.java +++ b/src/main/java/notai/recording/application/RecordingService.java @@ -2,6 +2,8 @@ import lombok.RequiredArgsConstructor; import notai.common.domain.vo.FilePath; +import static notai.common.exception.ErrorMessages.FILE_SAVE_ERROR; +import static notai.common.exception.ErrorMessages.INVALID_AUDIO_ENCODING; import notai.common.exception.type.BadRequestException; import notai.common.exception.type.InternalServerErrorException; import notai.common.utils.AudioDecoder; @@ -12,6 +14,8 @@ import notai.recording.application.result.RecordingSaveResult; import notai.recording.domain.Recording; import notai.recording.domain.RecordingRepository; +import notai.stt.application.SttTaskService; +import notai.stt.application.command.SttRequestCommand; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,9 +24,6 @@ import java.nio.file.Path; import java.nio.file.Paths; -import static notai.common.exception.ErrorMessages.FILE_SAVE_ERROR; -import static notai.common.exception.ErrorMessages.INVALID_AUDIO_ENCODING; - @Service @Transactional @RequiredArgsConstructor @@ -32,6 +33,7 @@ public class RecordingService { private final DocumentRepository documentRepository; private final AudioDecoder audioDecoder; private final FileManager fileManager; + private final SttTaskService sttTaskService; @Value("${file.audio.basePath}") private String audioBasePath; @@ -52,6 +54,9 @@ public RecordingSaveResult saveRecording(RecordingSaveCommand command) { fileManager.save(binaryAudioData, outputPath); savedRecording.updateFilePath(filePath); + SttRequestCommand sttCommand = new SttRequestCommand(savedRecording.getId(), filePath.getFilePath()); + sttTaskService.submitSttTask(sttCommand); + return RecordingSaveResult.of(savedRecording.getId(), foundDocument.getId(), savedRecording.getCreatedAt()); } catch (IllegalArgumentException e) { diff --git a/src/main/java/notai/stt/application/SttService.java b/src/main/java/notai/stt/application/SttService.java new file mode 100644 index 0000000..f48545d --- /dev/null +++ b/src/main/java/notai/stt/application/SttService.java @@ -0,0 +1,45 @@ +package notai.stt.application; + +import lombok.RequiredArgsConstructor; +import notai.pageRecording.domain.PageRecording; +import notai.pageRecording.domain.PageRecordingRepository; +import notai.recording.domain.Recording; +import notai.stt.application.command.UpdateSttResultCommand; +import notai.stt.application.dto.SttPageMatchedDto; +import notai.stt.domain.Stt; +import notai.stt.domain.SttRepository; +import notai.sttTask.domain.SttTask; +import notai.sttTask.domain.SttTaskRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional +@RequiredArgsConstructor +public class SttService { + private final SttRepository sttRepository; + private final SttTaskRepository sttTaskRepository; + private final PageRecordingRepository pageRecordingRepository; + + /** + * AI 서버로부터 받은 STT 결과를 처리하여 페이지별 STT 데이터를 생성하고 저장합니다. + * 1. STT 테스크와 관련 엔티티들을 조회 + * 2. 음성 인식된 단어들을 페이지와 매칭 + * 3. 매칭 결과를 저장하고 테스크를 완료 처리 + */ + public void updateSttResult(UpdateSttResultCommand command) { + SttTask sttTask = sttTaskRepository.getById(command.taskId()); + Stt stt = sttTask.getStt(); + Recording recording = stt.getRecording(); + List pageRecordings = pageRecordingRepository.findAllByRecordingIdOrderByStartTime(recording.getId()); + + SttPageMatchedDto matchedResult = stt.matchWordsWithPages(command.words(), pageRecordings); + List pageMatchedSttResults = Stt.createFromMatchedResult(recording, matchedResult); + sttRepository.saveAll(pageMatchedSttResults); + + sttTask.complete(); + sttTaskRepository.save(sttTask); + } +} diff --git a/src/main/java/notai/stt/application/SttTaskService.java b/src/main/java/notai/stt/application/SttTaskService.java new file mode 100644 index 0000000..af2cfe9 --- /dev/null +++ b/src/main/java/notai/stt/application/SttTaskService.java @@ -0,0 +1,61 @@ +package notai.stt.application; + +import lombok.RequiredArgsConstructor; +import notai.client.ai.AiClient; +import notai.client.ai.response.TaskResponse; +import static notai.common.exception.ErrorMessages.FILE_NOT_FOUND; +import static notai.common.exception.ErrorMessages.FILE_READ_ERROR; +import notai.common.exception.type.FileProcessException; +import notai.common.exception.type.NotFoundException; +import notai.llm.domain.TaskStatus; +import notai.recording.domain.Recording; +import notai.recording.domain.RecordingRepository; +import notai.stt.application.command.SttRequestCommand; +import notai.stt.domain.Stt; +import notai.stt.domain.SttRepository; +import notai.sttTask.domain.SttTask; +import notai.sttTask.domain.SttTaskRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; + +@Service +@Transactional +@RequiredArgsConstructor +public class SttTaskService { + private final AiClient aiClient; + private final SttRepository sttRepository; + private final SttTaskRepository sttTaskRepository; + private final RecordingRepository recordingRepository; + + public void submitSttTask(SttRequestCommand command) { + Recording recording = recordingRepository.getById(command.recordingId()); + File audioFile = validateAudioFile(command.audioFilePath()); + + try (FileInputStream fileInputStream = new FileInputStream(audioFile)) { + TaskResponse response = aiClient.submitSttTask(fileInputStream); + createAndSaveSttTask(recording, response); + } catch (IOException e) { + throw new FileProcessException(FILE_READ_ERROR); + } + } + + private File validateAudioFile(String audioFilePath) { + File audioFile = new File(audioFilePath); + if (!audioFile.exists()) { + throw new NotFoundException(FILE_NOT_FOUND); + } + return audioFile; + } + + private void createAndSaveSttTask(Recording recording, TaskResponse response) { + Stt stt = new Stt(recording); + sttRepository.save(stt); + + SttTask sttTask = new SttTask(response.taskId(), stt, TaskStatus.PENDING); + sttTaskRepository.save(sttTask); + } +} diff --git a/src/main/java/notai/stt/application/command/SttRequestCommand.java b/src/main/java/notai/stt/application/command/SttRequestCommand.java new file mode 100644 index 0000000..540529b --- /dev/null +++ b/src/main/java/notai/stt/application/command/SttRequestCommand.java @@ -0,0 +1,7 @@ +package notai.stt.application.command; + +public record SttRequestCommand( + Long recordingId, + String audioFilePath +) { +} diff --git a/src/main/java/notai/stt/application/command/UpdateSttResultCommand.java b/src/main/java/notai/stt/application/command/UpdateSttResultCommand.java new file mode 100644 index 0000000..c476790 --- /dev/null +++ b/src/main/java/notai/stt/application/command/UpdateSttResultCommand.java @@ -0,0 +1,16 @@ +package notai.stt.application.command; + +import java.util.List; +import java.util.UUID; + +public record UpdateSttResultCommand( + UUID taskId, + List words +) { + public record Word( + String word, + double start, + double end + ) { + } +} diff --git a/src/main/java/notai/stt/application/dto/SttPageMatchedDto.java b/src/main/java/notai/stt/application/dto/SttPageMatchedDto.java new file mode 100644 index 0000000..7be4a98 --- /dev/null +++ b/src/main/java/notai/stt/application/dto/SttPageMatchedDto.java @@ -0,0 +1,19 @@ +package notai.stt.application.dto; + +import java.util.List; + +public record SttPageMatchedDto( + List pageContents +) { + public record PageMatchedContent( + Integer pageNumber, + String content, + List words + ) {} + + public record PageMatchedWord( + String word, + Integer startTime, + Integer endTime + ) {} +} diff --git a/src/main/java/notai/stt/domain/Stt.java b/src/main/java/notai/stt/domain/Stt.java new file mode 100644 index 0000000..46794d6 --- /dev/null +++ b/src/main/java/notai/stt/domain/Stt.java @@ -0,0 +1,150 @@ +package notai.stt.domain; + +import jakarta.persistence.*; +import static jakarta.persistence.FetchType.LAZY; +import jakarta.validation.constraints.NotNull; +import static lombok.AccessLevel.PROTECTED; +import lombok.Getter; +import lombok.NoArgsConstructor; +import notai.common.domain.RootEntity; +import notai.pageRecording.domain.PageRecording; +import notai.recording.domain.Recording; +import notai.stt.application.command.UpdateSttResultCommand; +import notai.stt.application.dto.SttPageMatchedDto; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +@Table(name = "stt") +public class Stt extends RootEntity { + + // Todo: 실제 테스트해보며 오차 시간 조정 + // 페이지 매칭 시 허용되는 시간 오차 (초) + private static final double TIME_THRESHOLD = 0.0; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "recording_id") + private Recording recording; + + private Integer pageNumber; + + @Column(columnDefinition = "TEXT") + private String content; + + private Integer startTime; + + private Integer endTime; + + public Stt(Recording recording) { + this.recording = recording; + } + + public Stt(Recording recording, Integer pageNumber, String content, Integer startTime, Integer endTime) { + this.recording = recording; + this.pageNumber = pageNumber; + this.content = content; + this.startTime = startTime; + this.endTime = endTime; + } + + /** + * 페이지별 STT 결과로부터 새로운 STT 엔티티를 생성합니다. + * 시작/종료 시간은 페이지 내 첫/마지막 단어의 시간으로 설정합니다. + */ + public static Stt createFromPageContent(Recording recording, SttPageMatchedDto.PageMatchedContent content) { + return new Stt( + recording, + content.pageNumber(), + content.content(), + content.words().get(0).startTime(), + content.words().get(content.words().size() - 1).endTime() + ); + } + + /** + * 음성 인식된 단어들을 페이지 기록과 매칭하여 페이지별 STT 결과를 생성합니다. + */ + public SttPageMatchedDto matchWordsWithPages( + List words, + List pageRecordings + ) { + if (pageRecordings.isEmpty()) { + return new SttPageMatchedDto(List.of()); + } + + // 페이지 번호 순으로 자동 정렬됨 + Map> pageWordMap = new TreeMap<>(); + int wordIndex = 0; + PageRecording lastPage = pageRecordings.get(pageRecordings.size() - 1); + + // 각 페이지별로 매칭되는 단어들을 찾아 처리 + for (PageRecording page : pageRecordings) { + List pageWords = new ArrayList<>(); + double pageStart = page.getStartTime(); + double pageEnd = page.getEndTime(); + + // 현재 페이지의 시간 범위에 속하는 단어들을 찾아 매칭 + while (wordIndex < words.size()) { + UpdateSttResultCommand.Word word = words.get(wordIndex); + + // 페이지 시작 시간보다 이른 단어는 건너뛰기 + if (word.start() + TIME_THRESHOLD < pageStart) { + wordIndex++; + continue; + } + + // 마지막 페이지가 아닐 경우, 페이지 종료 시간을 벗어난 단어가 나오면 다음 페이지로 + if (page != lastPage && word.start() - TIME_THRESHOLD >= pageEnd) { + break; + } + + // 현재 페이지에 단어 매칭하여 추가 + pageWords.add(new SttPageMatchedDto.PageMatchedWord( + word.word(), + (int) word.start(), + (int) word.end() + )); + wordIndex++; + } + + // 매칭된 단어가 있는 경우만 맵에 추가 + if (!pageWords.isEmpty()) { + pageWordMap.put(page.getPageNumber(), pageWords); + } + } + + // 페이지별로 단어들을 하나의 텍스트로 합치는 과정 + List pageContents = pageWordMap.entrySet().stream() + .map(entry -> { + Integer pageNumber = entry.getKey(); + List pageWords = entry.getValue(); + String combinedContent = pageWords.stream() + .map(SttPageMatchedDto.PageMatchedWord::word) + .collect(Collectors.joining(" ")); + return new SttPageMatchedDto.PageMatchedContent(pageNumber, combinedContent, pageWords); + }) + .toList(); + + return new SttPageMatchedDto(pageContents); + } + + /** + * 페이지 매칭 결과로부터 STT 엔티티들을 생성하고 저장합니다. + */ + public static List createFromMatchedResult(Recording recording, SttPageMatchedDto matchedResult) { + return matchedResult.pageContents().stream() + .map(content -> createFromPageContent(recording, content)) + .toList(); + } +} diff --git a/src/main/java/notai/stt/domain/SttRepository.java b/src/main/java/notai/stt/domain/SttRepository.java new file mode 100644 index 0000000..88f2064 --- /dev/null +++ b/src/main/java/notai/stt/domain/SttRepository.java @@ -0,0 +1,6 @@ +package notai.stt.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SttRepository extends JpaRepository { +} diff --git a/src/main/java/notai/stt/presentation/SttCallbackController.java b/src/main/java/notai/stt/presentation/SttCallbackController.java new file mode 100644 index 0000000..d048dae --- /dev/null +++ b/src/main/java/notai/stt/presentation/SttCallbackController.java @@ -0,0 +1,22 @@ +package notai.stt.presentation; + +import lombok.RequiredArgsConstructor; +import notai.stt.application.SttService; +import notai.stt.presentation.request.SttCallbackRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class SttCallbackController { + + private final SttService sttService; + + @PostMapping("/api/ai/stt/callback") + public ResponseEntity sttCallback(@RequestBody SttCallbackRequest request) { + sttService.updateSttResult(request.toCommand()); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/notai/stt/presentation/request/SttCallbackRequest.java b/src/main/java/notai/stt/presentation/request/SttCallbackRequest.java new file mode 100644 index 0000000..1e58bc7 --- /dev/null +++ b/src/main/java/notai/stt/presentation/request/SttCallbackRequest.java @@ -0,0 +1,38 @@ +package notai.stt.presentation.request; + +import notai.stt.application.command.UpdateSttResultCommand; +import notai.stt.application.command.UpdateSttResultCommand.Word; + +import java.util.List; +import java.util.UUID; + +public record SttCallbackRequest( + String taskId, + String state, + SttResult result +) { + public UpdateSttResultCommand toCommand() { + List words = result.words().stream() + .map(word -> new Word( + word.word(), + word.start(), + word.end() + )) + .toList(); + return new UpdateSttResultCommand(UUID.fromString(taskId), words); + } + + public record SttResult( + double audioLength, + String language, + double languageProbability, + String text, + List words + ) { + public record Word( + double start, + double end, + String word + ) {} + } +} diff --git a/src/main/java/notai/sttTask/domain/SttTask.java b/src/main/java/notai/sttTask/domain/SttTask.java new file mode 100644 index 0000000..4897c13 --- /dev/null +++ b/src/main/java/notai/sttTask/domain/SttTask.java @@ -0,0 +1,43 @@ +package notai.sttTask.domain; + +import static jakarta.persistence.CascadeType.PERSIST; +import jakarta.persistence.*; +import static jakarta.persistence.FetchType.LAZY; +import jakarta.validation.constraints.NotNull; +import static lombok.AccessLevel.PROTECTED; +import lombok.Getter; +import lombok.NoArgsConstructor; +import notai.common.domain.RootEntity; +import notai.llm.domain.TaskStatus; +import notai.stt.domain.Stt; + +import java.util.UUID; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +@Table(name = "stt_task") +public class SttTask extends RootEntity { + + @Id + private UUID id; + + @OneToOne(fetch = LAZY, cascade = PERSIST) + @JoinColumn(name = "stt_id") + private Stt stt; + + @NotNull + @Enumerated(EnumType.STRING) + @Column(length = 20) + private TaskStatus status; + + public SttTask(UUID id, Stt stt, TaskStatus status) { + this.id = id; + this.stt = stt; + this.status = status; + } + + public void complete() { + this.status = TaskStatus.COMPLETED; + } +} diff --git a/src/main/java/notai/sttTask/domain/SttTaskRepository.java b/src/main/java/notai/sttTask/domain/SttTaskRepository.java new file mode 100644 index 0000000..08956f1 --- /dev/null +++ b/src/main/java/notai/sttTask/domain/SttTaskRepository.java @@ -0,0 +1,14 @@ +package notai.sttTask.domain; + +import static notai.common.exception.ErrorMessages.AI_SERVER_ERROR; +import notai.common.exception.type.NotFoundException; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface SttTaskRepository extends JpaRepository { + + default SttTask getById(UUID id) { + return findById(id).orElseThrow(() -> new NotFoundException(AI_SERVER_ERROR)); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index da70f6b..277fd4f 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -29,6 +29,11 @@ spring: mvc: converters: preferred-json-mapper: gson + servlet: + multipart: + max-file-size: 100MB + max-request-size: 100MB + enabled: true server: servlet: @@ -43,3 +48,16 @@ token: # todo production에서 secretKey 변경 secretKey: "ZGQrT0tuZHZkRWRxeXJCamRYMDFKMnBaR2w5WXlyQm9HU2RqZHNha1gycFlkMWpLc0dObw==" accessTokenExpirationMillis: 10000000000 refreshTokenExpirationMillis: 10000000000 + +file: + audio: + basePath: audio/ + pdf: + basePath: pdf/ + +tesseract: + library: + path: C:\\Program Files\\Tesseract-OCR + data: + path: C:\\Program Files\\Tesseract-OCR\\tessdata + language: kor+eng diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3baccb8..fadf7d2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,7 +1,4 @@ spring: profiles: - active: local + active: prod -file: - audio: - basePath: /app/audio/ # Docker 볼륨에 맞게 경로 수정 \ No newline at end of file diff --git a/src/test/java/notai/client/ai/AiClientIntegrationTest.java b/src/test/java/notai/client/ai/AiClientIntegrationTest.java index ebd27d3..782f4b8 100644 --- a/src/test/java/notai/client/ai/AiClientIntegrationTest.java +++ b/src/test/java/notai/client/ai/AiClientIntegrationTest.java @@ -8,7 +8,8 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.mock.web.MockMultipartFile; +import java.io.ByteArrayInputStream; +import java.io.InputStream; @SpringBootTest @Tag("exclude-test") // 테스트 필요할때 주석 @@ -34,16 +35,15 @@ class AiClientIntegrationTest { @Test void STT_태스크_제출_통합_테스트() { // Given - MockMultipartFile audioFile = new MockMultipartFile( - "audio", "test.mp3", "audio/mpeg", "test audio content".getBytes() - ); + byte[] audioBytes = "test audio content".getBytes(); + InputStream audioInputStream = new ByteArrayInputStream(audioBytes); // When - TaskResponse response = aiClient.submitSttTask(audioFile); + TaskResponse response = aiClient.submitSttTask(audioInputStream); // Then assertNotNull(response); assertNotNull(response.taskId()); - assertEquals("llm", response.taskType()); + assertEquals("stt", response.taskType()); } } diff --git a/src/test/java/notai/client/ai/AiClientTest.java b/src/test/java/notai/client/ai/AiClientTest.java index 2fab457..b447e0a 100644 --- a/src/test/java/notai/client/ai/AiClientTest.java +++ b/src/test/java/notai/client/ai/AiClientTest.java @@ -8,8 +8,8 @@ import org.mockito.Mock; import static org.mockito.Mockito.*; import org.mockito.MockitoAnnotations; -import org.springframework.web.multipart.MultipartFile; +import java.io.InputStream; import java.util.UUID; class AiClientTest { @@ -41,16 +41,16 @@ void setUp() { @Test void STT_테스크_전달_테스트() { // Given - MultipartFile mockAudioFile = mock(MultipartFile.class); + InputStream inputStream = mock(InputStream.class); UUID expectedTaskId = UUID.randomUUID(); TaskResponse expectedResponse = new TaskResponse(expectedTaskId, "stt"); - when(aiClient.submitSttTask(mockAudioFile)).thenReturn(expectedResponse); + when(aiClient.submitSttTask(inputStream)).thenReturn(expectedResponse); // When - TaskResponse response = aiClient.submitSttTask(mockAudioFile); + TaskResponse response = aiClient.submitSttTask(inputStream); // Then assertEquals(expectedResponse, response); - verify(aiClient, times(1)).submitSttTask(mockAudioFile); + verify(aiClient, times(1)).submitSttTask(inputStream); } } diff --git a/src/test/java/notai/pageRecording/application/PageRecordingServiceTest.java b/src/test/java/notai/pageRecording/application/PageRecordingServiceTest.java index 3d89a01..085a4ca 100644 --- a/src/test/java/notai/pageRecording/application/PageRecordingServiceTest.java +++ b/src/test/java/notai/pageRecording/application/PageRecordingServiceTest.java @@ -8,15 +8,14 @@ import notai.recording.domain.RecordingRepository; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import static org.mockito.BDDMockito.given; import org.mockito.InjectMocks; import org.mockito.Mock; +import static org.mockito.Mockito.*; import org.mockito.junit.jupiter.MockitoExtension; import java.util.List; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.*; - @ExtendWith(MockitoExtension.class) class PageRecordingServiceTest { @@ -35,8 +34,7 @@ class PageRecordingServiceTest { Long recordingId = 1L; Long documentId = 1L; - PageRecordingSaveCommand command = new PageRecordingSaveCommand( - recordingId, + PageRecordingSaveCommand command = new PageRecordingSaveCommand(recordingId, documentId, List.of(new PageRecordingSession(1, 100.0, 185.5), new PageRecordingSession(5, 185.5, 290.3)) ); @@ -51,4 +49,4 @@ class PageRecordingServiceTest { // then verify(pageRecordingRepository, times(2)).save(any(PageRecording.class)); } -} \ No newline at end of file +} diff --git a/src/test/java/notai/stt/application/SttServiceTest.java b/src/test/java/notai/stt/application/SttServiceTest.java new file mode 100644 index 0000000..f80d455 --- /dev/null +++ b/src/test/java/notai/stt/application/SttServiceTest.java @@ -0,0 +1,124 @@ +package notai.stt.application; + +import static notai.common.exception.ErrorMessages.AI_SERVER_ERROR; +import notai.common.exception.type.NotFoundException; +import notai.pageRecording.domain.PageRecording; +import notai.pageRecording.domain.PageRecordingRepository; +import notai.recording.domain.Recording; +import notai.stt.application.command.UpdateSttResultCommand; +import notai.stt.application.dto.SttPageMatchedDto; +import notai.stt.domain.Stt; +import notai.stt.domain.SttRepository; +import notai.sttTask.domain.SttTask; +import notai.sttTask.domain.SttTaskRepository; +import static org.junit.jupiter.api.Assertions.assertThrows; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import static org.mockito.Mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.UUID; + +@ExtendWith(MockitoExtension.class) +class SttServiceTest { + + @InjectMocks + private SttService sttService; + + @Mock + private SttRepository sttRepository; + + @Mock + private SttTaskRepository sttTaskRepository; + + @Mock + private PageRecordingRepository pageRecordingRepository; + + @Test + void STT_결과_업데이트_성공() { + // given + UUID taskId = UUID.randomUUID(); + List words = List.of( + new UpdateSttResultCommand.Word("테스트", 1.0, 2.0), + new UpdateSttResultCommand.Word("음성인식", 3.1, 4) + ); + UpdateSttResultCommand command = new UpdateSttResultCommand(taskId, words); + + SttTask sttTask = mock(SttTask.class); + Stt stt = mock(Stt.class); + Recording recording = mock(Recording.class); + List pageRecordings = List.of(mock(PageRecording.class)); + + when(sttTaskRepository.getById(taskId)).thenReturn(sttTask); + when(sttTask.getStt()).thenReturn(stt); + when(stt.getRecording()).thenReturn(recording); + when(recording.getId()).thenReturn(1L); + when(pageRecordingRepository.findAllByRecordingIdOrderByStartTime(1L)).thenReturn(pageRecordings); + + List matchedWords = List.of( + new SttPageMatchedDto.PageMatchedWord("테스트", 1, 2), + new SttPageMatchedDto.PageMatchedWord("음성인식", 3, 4) + ); + SttPageMatchedDto matchedResult = new SttPageMatchedDto(List.of( + new SttPageMatchedDto.PageMatchedContent(1, "테스트 음성인식", matchedWords))); + when(stt.matchWordsWithPages(words, pageRecordings)).thenReturn(matchedResult); + + // when + sttService.updateSttResult(command); + + // then + verify(sttTask).complete(); + verify(sttTaskRepository).save(sttTask); + verify(sttRepository).saveAll(argThat(sttList -> { + List results = (List) sttList; + return results.size() == 1; // 예상되는 STT 엔티티 개수 확인 + })); + } + + @Test + void STT_결과_업데이트_실패_태스크_없음() { + // given + UUID taskId = UUID.randomUUID(); + List words = List.of(new UpdateSttResultCommand.Word("테스트", 1.0, 2.0)); + UpdateSttResultCommand command = new UpdateSttResultCommand(taskId, words); + + when(sttTaskRepository.getById(taskId)).thenThrow(new NotFoundException(AI_SERVER_ERROR)); + + // when & then + assertThrows(NotFoundException.class, () -> sttService.updateSttResult(command)); + verify(sttRepository, never()).saveAll(any()); + } + + @Test + void STT_결과_업데이트_빈_페이지_리스트() { + // given + UUID taskId = UUID.randomUUID(); + List words = List.of(new UpdateSttResultCommand.Word("테스트", 1.0, 2.0)); + + UpdateSttResultCommand command = new UpdateSttResultCommand(taskId, words); + + SttTask sttTask = mock(SttTask.class); + Stt stt = mock(Stt.class); + Recording recording = mock(Recording.class); + + when(sttTaskRepository.getById(taskId)).thenReturn(sttTask); + when(sttTask.getStt()).thenReturn(stt); + when(stt.getRecording()).thenReturn(recording); + when(recording.getId()).thenReturn(1L); + when(pageRecordingRepository.findAllByRecordingIdOrderByStartTime(1L)).thenReturn(List.of()); + + SttPageMatchedDto matchedResult = new SttPageMatchedDto(List.of()); + when(stt.matchWordsWithPages(words, List.of())).thenReturn(matchedResult); + + // when + sttService.updateSttResult(command); + + // then + verify(sttTask).complete(); + verify(sttTaskRepository).save(sttTask); + verify(sttRepository).saveAll(argThat(sttList -> ((List) sttList).isEmpty())); + } +} diff --git a/src/test/java/notai/stt/domain/SttTest.java b/src/test/java/notai/stt/domain/SttTest.java new file mode 100644 index 0000000..5a6df06 --- /dev/null +++ b/src/test/java/notai/stt/domain/SttTest.java @@ -0,0 +1,287 @@ +package notai.stt.domain; + +import notai.pageRecording.domain.PageRecording; +import notai.recording.domain.Recording; +import notai.stt.application.command.UpdateSttResultCommand; +import notai.stt.application.dto.SttPageMatchedDto; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +@ExtendWith(MockitoExtension.class) +class SttTest { + + @Test + void 페이지_매칭_빈_페이지_리스트() { + // given + Recording recording = mock(Recording.class); + Stt stt = new Stt(recording); + List words = List.of( + new UpdateSttResultCommand.Word("테스트", 1.0, 2.0) + ); + + // when + SttPageMatchedDto result = stt.matchWordsWithPages(words, List.of()); + + // then + assertThat(result.pageContents()).isEmpty(); + } + + @Test + void 페이지_매칭_정상_케이스() { + // given + Recording recording = mock(Recording.class); + Stt stt = new Stt(recording); + + List words = List.of( + new UpdateSttResultCommand.Word("첫번째", 1.0, 2.0), + new UpdateSttResultCommand.Word("두번째", 2.5, 3.5), + new UpdateSttResultCommand.Word("세번째", 4.0, 5.0) + ); + + + List pages = List.of( + createPageRecording(1, 0.0, 3.0), + createPageRecording(2, 3.0, 6.0) + ); + + // when + SttPageMatchedDto result = stt.matchWordsWithPages(words, pages); + + // then + assertAll( + () -> assertThat(result.pageContents()).hasSize(2), + () -> assertThat(result.pageContents().get(0).pageNumber()).isEqualTo(1), + () -> assertThat(result.pageContents().get(0).content()).isEqualTo("첫번째 두번째"), + () -> assertThat(result.pageContents().get(1).pageNumber()).isEqualTo(2), + () -> assertThat(result.pageContents().get(1).content()).isEqualTo("세번째") + ); + } + + @Test + void 페이지_컨텐츠로부터_STT_엔티티_생성() { + // given + Recording recording = mock(Recording.class); + List words = List.of( + new SttPageMatchedDto.PageMatchedWord("테스트", 100, 200), + new SttPageMatchedDto.PageMatchedWord("단어", 300, 400) + ); + SttPageMatchedDto.PageMatchedContent content = new SttPageMatchedDto.PageMatchedContent( + 1, + "테스트 단어", + words + ); + + // when + Stt result = Stt.createFromPageContent(recording, content); + + // then + assertAll( + () -> assertThat(result.getPageNumber()).isEqualTo(1), + () -> assertThat(result.getContent()).isEqualTo("테스트 단어"), + () -> assertThat(result.getStartTime()).isEqualTo(100), + () -> assertThat(result.getEndTime()).isEqualTo(400) + ); + } + + @Test + void 페이지_매칭_비순차적_페이지_번호() { + // given + Recording recording = mock(Recording.class); + Stt stt = new Stt(recording); + + List words = List.of( + new UpdateSttResultCommand.Word("word1", 1.0, 2.0), + new UpdateSttResultCommand.Word("word2", 6.0, 7.0), + new UpdateSttResultCommand.Word("word3", 8.0, 9.0), + new UpdateSttResultCommand.Word("word4", 10.0, 11.0) + ); + + List pages = List.of( + createPageRecording(1, 0.0, 3.0), + createPageRecording(5, 5.0, 7.0), + createPageRecording(3, 7.0, 9.0), + createPageRecording(4, 9.0, 12.0) + ); + + // when + SttPageMatchedDto result = stt.matchWordsWithPages(words, pages); + + // then + assertAll( + () -> assertThat(result.pageContents()).hasSize(4), + () -> assertThat(result.pageContents().get(0).pageNumber()).isEqualTo(1), + () -> assertThat(result.pageContents().get(0).content()).isEqualTo("word1"), + () -> assertThat(result.pageContents().get(1).pageNumber()).isEqualTo(3), + () -> assertThat(result.pageContents().get(1).content()).isEqualTo("word3"), + () -> assertThat(result.pageContents().get(2).pageNumber()).isEqualTo(4), + () -> assertThat(result.pageContents().get(2).content()).isEqualTo("word4"), + () -> assertThat(result.pageContents().get(3).pageNumber()).isEqualTo(5), + () -> assertThat(result.pageContents().get(3).content()).isEqualTo("word2") + ); + } + + @Test + void 페이지_매칭_시간_경계값_테스트() { + // given + Recording recording = mock(Recording.class); + Stt stt = new Stt(recording); + + List words = List.of( + new UpdateSttResultCommand.Word("경계단어1", 2.99, 3.5), // 첫 페이지 끝에 걸침 + new UpdateSttResultCommand.Word("경계단어2", 3.0, 3.8), // 정확히 두번째 페이지 시작 + new UpdateSttResultCommand.Word("경계단어3", 5.01, 6.0), // 두번째 페이지 끝에 걸침 + new UpdateSttResultCommand.Word("경계단어4", 5.51, 6.2) // 벗어나 세번째 페이지로 분류 + ); + + List pages = List.of( + createPageRecording(1, 0.0, 3.0), + createPageRecording(2, 3.0, 5.5), + createPageRecording(3, 5.5, 8.0) + ); + + // when + SttPageMatchedDto result = stt.matchWordsWithPages(words, pages); + + // then + assertAll( + () -> assertThat(result.pageContents()).hasSize(3), + () -> assertThat(result.pageContents().get(0).pageNumber()).isEqualTo(1), + () -> assertThat(result.pageContents().get(0).content()).isEqualTo("경계단어1"), + () -> assertThat(result.pageContents().get(1).pageNumber()).isEqualTo(2), + () -> assertThat(result.pageContents().get(1).content()).isEqualTo("경계단어2 경계단어3"), + () -> assertThat(result.pageContents().get(2).pageNumber()).isEqualTo(3), + () -> assertThat(result.pageContents().get(2).content()).isEqualTo("경계단어4") + ); + } + + @Test + void 페이지_매칭_마지막_페이지_특수처리() { + // given + Recording recording = mock(Recording.class); + Stt stt = new Stt(recording); + + List words = List.of( + new UpdateSttResultCommand.Word("정상단어", 1.0, 2.0), + new UpdateSttResultCommand.Word("늦은단어1", 7.0, 8.0), + // 마지막 페이지 종료 시간 이후 + new UpdateSttResultCommand.Word("늦은단어2", 8.0, 9.0) + // 마지막 페이지에 포함되어야 함 + ); + + List pages = List.of(createPageRecording(1, 0.0, 3.0), createPageRecording(2, 3.0, 6.0)); + + // when + SttPageMatchedDto result = stt.matchWordsWithPages(words, pages); + + // then + assertAll( + () -> assertThat(result.pageContents()).hasSize(2), + () -> assertThat(result.pageContents().get(0).pageNumber()).isEqualTo(1), + () -> assertThat(result.pageContents().get(0).content()).isEqualTo("정상단어"), + () -> assertThat(result.pageContents().get(1).pageNumber()).isEqualTo(2), + () -> assertThat(result.pageContents().get(1).content()).isEqualTo("늦은단어1 늦은단어2") + // 마지막 페이지는 시간 제한 없이 모든 단어 포함 + ); + } + + @Test + void 매칭_결과로부터_여러_STT_엔티티_생성() { + // given + Recording recording = mock(Recording.class); + List contents = List.of( + new SttPageMatchedDto.PageMatchedContent( + 1, + "첫번째 페이지", + List.of( + new SttPageMatchedDto.PageMatchedWord("첫번째", 1, 2), + new SttPageMatchedDto.PageMatchedWord("페이지", 2, 3) + ) + ), + new SttPageMatchedDto.PageMatchedContent( + 2, + "두번째 페이지", + List.of( + new SttPageMatchedDto.PageMatchedWord("두번째", 4, 5), + new SttPageMatchedDto.PageMatchedWord("페이지", 5, 6) + ) + ) + ); + SttPageMatchedDto matchedResult = new SttPageMatchedDto(contents); + + // when + List results = Stt.createFromMatchedResult(recording, matchedResult); + + // then + assertAll(() -> assertThat(results).hasSize(2), () -> { + Stt firstStt = results.get(0); + assertThat(firstStt.getRecording()).isEqualTo(recording); + assertThat(firstStt.getPageNumber()).isEqualTo(1); + assertThat(firstStt.getContent()).isEqualTo("첫번째 페이지"); + assertThat(firstStt.getStartTime()).isEqualTo(1); + assertThat(firstStt.getEndTime()).isEqualTo(3); + }, () -> { + Stt secondStt = results.get(1); + assertThat(secondStt.getRecording()).isEqualTo(recording); + assertThat(secondStt.getPageNumber()).isEqualTo(2); + assertThat(secondStt.getContent()).isEqualTo("두번째 페이지"); + assertThat(secondStt.getStartTime()).isEqualTo(4); + assertThat(secondStt.getEndTime()).isEqualTo(6); + }); + } + + @Test + void 매칭_결과가_비어있을때_빈_리스트_반환() { + // given + Recording recording = mock(Recording.class); + SttPageMatchedDto matchedResult = new SttPageMatchedDto(List.of()); + + // when + List results = Stt.createFromMatchedResult(recording, matchedResult); + + // then + assertThat(results).isEmpty(); + } + + @Test + void 페이지_매칭_결과_순서_보장() { + // given + Recording recording = mock(Recording.class); + Stt stt = new Stt(recording); + + // startTime 기준으로 정렬된 words + List words = List.of( + new UpdateSttResultCommand.Word("1번", 1.0, 2.0), + new UpdateSttResultCommand.Word("3번", 6.0, 7.0), + new UpdateSttResultCommand.Word("5번", 10.0, 11.0) + ); + + // startTime 기준으로 정렬된 pages + List pages = List.of( + createPageRecording(1, 0.0, 3.0), + createPageRecording(5, 5.0, 8.0), + createPageRecording(3, 9.0, 12.0) + ); + + // when + SttPageMatchedDto result = stt.matchWordsWithPages(words, pages); + + // then + assertThat(result.pageContents()).extracting(SttPageMatchedDto.PageMatchedContent::pageNumber) + .containsExactly(1, 3, 5); // 페이지 번호 순서 검증 + } + + private PageRecording createPageRecording(int pageNumber, double startTime, double endTime) { + PageRecording pageRecording = mock(PageRecording.class); + when(pageRecording.getPageNumber()).thenReturn(pageNumber); + when(pageRecording.getStartTime()).thenReturn(startTime); + when(pageRecording.getEndTime()).thenReturn(endTime); + return pageRecording; + } +}