From 780b049b8f644e09ee95f7b681627f1b31c17ee0 Mon Sep 17 00:00:00 2001 From: Hyun-Seo Jeong <90139789+hynseoj@users.noreply.github.com> Date: Fri, 4 Oct 2024 23:13:39 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20Annotaion=20API,=20Recording=20API,=20D?= =?UTF-8?q?ocument=20API=20=EA=B5=AC=ED=98=84=20(#51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat: Annotaion API, Recording API, Document API 구현 (#50) * Style: formatting통일 (#30) * Feat: 요약 및 문제 생성 API 구현 (#32) * Rename: AI Task 도메인 이름 변경 - llm 으로 변경 * Refactor: LLM 도메인 애플리케이션 계층과 프레젠테이션 계층 응답 분리 * Feat: LLM 작업 진행 상태 확인 기능 구현 * Feat: 요약 및 문제 결과 조회 기능 구현 * Feat: 요약 및 문제 생성 기능 구현 - AI 서버와 통신하는 부분 제외하고 기능 구현 - 임시 UUID 를 통해 task 저장 * Feat: LLM 서버 콜백 기능 구현 - LLM 서버가 API 콜을 통해 페이지별 요약 및 문제 내용 전달 - task id 를 통해 조회하여 요약 내용과 문제 내용 업데이트 * Refactor: 변수 이름 변경 - 필드명 카멜케이스로 변경 * Refactor: 통일성 없는 부분 수정 - 필드명 변경 - 변수 추출 * Refactor: 예외 종류, 메서드 네이밍 변경 - LLMQueryService 예외 타입 변경 - SummaryAndProblemUpdateResponse 메서드 네이밍 변경 * Refactor: LLMQueryService 응답과 LLMController 응답 분리 * Style: 코드 포맷팅 통일 (#36) * Feat: 요약 및 문제 생성 API 구현 (#4) * Rename: AI Task 도메인 이름 변경 - llm 으로 변경 * Refactor: LLM 도메인 애플리케이션 계층과 프레젠테이션 계층 응답 분리 * Feat: LLM 작업 진행 상태 확인 기능 구현 * Feat: 요약 및 문제 결과 조회 기능 구현 * Feat: 요약 및 문제 생성 기능 구현 - AI 서버와 통신하는 부분 제외하고 기능 구현 - 임시 UUID 를 통해 task 저장 * Feat: LLM 서버 콜백 기능 구현 - LLM 서버가 API 콜을 통해 페이지별 요약 및 문제 내용 전달 - task id 를 통해 조회하여 요약 내용과 문제 내용 업데이트 * Refactor: 변수 이름 변경 - 필드명 카멜케이스로 변경 * Refactor: 통일성 없는 부분 수정 - 필드명 변경 - 변수 추출 * Refactor: 예외 종류, 메서드 네이밍 변경 - LLMQueryService 예외 타입 변경 - SummaryAndProblemUpdateResponse 메서드 네이밍 변경 * Refactor: LLMQueryService 응답과 LLMController 응답 분리 * Feat: 폴더 관련 기능 구현 (#6) * Init: 프로젝트 기본설정 세팅 - 프로젝트 생성 - .gitignore설정 - 프로젝트 의존성 추가 - application.yml 설정파일 구성 * Init: 프로젝트 기본 구조 및 공통 컴포넌트 설정 - 공통 설정 클래스 추가 (JPA, QueryDSL, Swagger, Web) - 공통 도메인 엔티티 (RootEntity) 정의 - 예외 처리 관련 클래스 및 타입 구현 - JSON 변환을 위한 AttributeConverter 추가 - 유틸리티 클래스 (Math) 추가 * Chore: Folder 도메인 폴더 구조 셋업 폴더 구조 셋업 작업 * Feat: Folder 도메인의 엔티티 생성 엔티티 생성자, 부모-자식간 연결로직 생성 * refactor: 자기 참조 관계 설정 수정 기존, 다대일 양방향 관계에서 다대일 단방향 관계로 설정하고, 삭제 등의 이슈 발생시 Service 계층에서 함수의 재귀사용을 통해 삭제할 예정 * Chore: Document 도메인 폴더 구조 셋업 폴더 구조 셋업 및 엔티티 생성 * Feat: Lombok 라이브러리 활용하여 기본 생성자 생성 기본 생성자 생성 lombok 라이브러리 활용하여 대체 * chore: name 필드의 length 50으로 설정 name 필드 (Document, Folder) 의 length = 50 으로 설정 * chore: Domain 계층의 Repository가 QueryRepository 상속받도록 함 상속 작업 수행 * chore: Member 도메인 매핑 작업 수행 Member 도메인 매핑 작업 수행 * chore: Member 도메인과 Folder 도메인 연결 작업 수행 Member 도메인과 Folder 도메인 연결 작업 수행 * feat: 루트 폴더 생성하는 기능 구현 루트 폴더 생성하는 기능 구현 * feat: 서브폴더 생성하는 기능 구현 서브 폴더 생성하는 기능 구현 * feat: 폴더를 루트로 이동시키는 기능 구현 폴더를 루트로 이동시키는 기능 구현 * feat: 새로운 폴더 내부로 이동시키는 기능 구현 새로운 폴더 내부로 이동시키는 기능 구현 * feat: 계층형 구조의 폴더 탐색 기능 구현 계층형 구조의 폴더 탐색 기능 구현 * test: 재귀적으로 폴더를 조회하는 테스트 코드 작성 재귀적으로 폴더 조회하는 테스트코드 작성 * remove: 사용하지 않는 QueryDSL 관련 파일 삭제 사용하지 않는 QueryDSL 관련 파일 삭제 * refactor: formatting 적용 formatting 적용 * feat: 폴더 재귀적으로 삭제하는 기능 구현 폴더 재귀적으로 삭제하는 기능 구현 * feat: @OnDelete 어노테이션을 사용하여 삭제 기능 구현 삭제 기능 구현 * feat: 폴더 구조의 조회를 간편하게 개선 폴더 구조의 조회 간편하게 개선 * feat: 루트에 폴더를 생성하는 API 구현 루트에 폴더를 생성하는 API 구현 * feat: 서브 폴더를 생성하는 API 구현 서브 폴더 생성하는 API 구현 * feat: 폴더 이동하는 API 구현 폴더 이동하는 API 구현 * refactor: 중복된 함수 기능 병합 작업 수행 중복된 함수 기능 병합 작업 수행 * feat: 폴더 조회 API 구현 폴더 조회 API 구현 * feat: 폴더 삭제 API 구현 폴더 삭제 API 구현 * rename: 함수명 변경 함수 명 변경 * refactor: 메서드 분리 작업 수행 메서드 분리 작업 수행 * refactor: Delete API 204 로 반환 204로 반환 * feat: 요청마다 DTO를 다르게 설정 요청마다 DTO 다르게 설정 * refactor: 타입추론방식에서 타입명시방식으로 변경 타입명시방식으로 코드 스타일 변경 * refactor: 도메인 값에 대한 검증은 도메인계층으로 옮김 도메인 계층으로 값에 대한 검증 이동 * refactor: Owner가 아닌 폴더에 접근하려고 하는 경우 NotFoundException 예외 발생 예외 발생 --------- Co-authored-by: rladbrua0207 <48901587+rladbrua0207@users.noreply.github.com> * Style: 코드 포맷팅 통일 * Refactor: 예외 종류 변경 --------- Co-authored-by: 윤정훈 <76200940+yunjunghun0116@users.noreply.github.com> Co-authored-by: rladbrua0207 <48901587+rladbrua0207@users.noreply.github.com> * Revert "Style: 코드 포맷팅 통일 (#36)" (#38) This reverts commit ad9062e737992caa2bbb3a07dda0072ec086c7ef. * Feat: 폴더 관련 기능 구현 (#39) * Feat: 요약 및 문제 생성 API 구현 (#4) * Rename: AI Task 도메인 이름 변경 - llm 으로 변경 * Refactor: LLM 도메인 애플리케이션 계층과 프레젠테이션 계층 응답 분리 * Feat: LLM 작업 진행 상태 확인 기능 구현 * Feat: 요약 및 문제 결과 조회 기능 구현 * Feat: 요약 및 문제 생성 기능 구현 - AI 서버와 통신하는 부분 제외하고 기능 구현 - 임시 UUID 를 통해 task 저장 * Feat: LLM 서버 콜백 기능 구현 - LLM 서버가 API 콜을 통해 페이지별 요약 및 문제 내용 전달 - task id 를 통해 조회하여 요약 내용과 문제 내용 업데이트 * Refactor: 변수 이름 변경 - 필드명 카멜케이스로 변경 * Refactor: 통일성 없는 부분 수정 - 필드명 변경 - 변수 추출 * Refactor: 예외 종류, 메서드 네이밍 변경 - LLMQueryService 예외 타입 변경 - SummaryAndProblemUpdateResponse 메서드 네이밍 변경 * Refactor: LLMQueryService 응답과 LLMController 응답 분리 * Feat: 폴더 관련 기능 구현 (#6) * Init: 프로젝트 기본설정 세팅 - 프로젝트 생성 - .gitignore설정 - 프로젝트 의존성 추가 - application.yml 설정파일 구성 * Init: 프로젝트 기본 구조 및 공통 컴포넌트 설정 - 공통 설정 클래스 추가 (JPA, QueryDSL, Swagger, Web) - 공통 도메인 엔티티 (RootEntity) 정의 - 예외 처리 관련 클래스 및 타입 구현 - JSON 변환을 위한 AttributeConverter 추가 - 유틸리티 클래스 (Math) 추가 * Chore: Folder 도메인 폴더 구조 셋업 폴더 구조 셋업 작업 * Feat: Folder 도메인의 엔티티 생성 엔티티 생성자, 부모-자식간 연결로직 생성 * refactor: 자기 참조 관계 설정 수정 기존, 다대일 양방향 관계에서 다대일 단방향 관계로 설정하고, 삭제 등의 이슈 발생시 Service 계층에서 함수의 재귀사용을 통해 삭제할 예정 * Chore: Document 도메인 폴더 구조 셋업 폴더 구조 셋업 및 엔티티 생성 * Feat: Lombok 라이브러리 활용하여 기본 생성자 생성 기본 생성자 생성 lombok 라이브러리 활용하여 대체 * chore: name 필드의 length 50으로 설정 name 필드 (Document, Folder) 의 length = 50 으로 설정 * chore: Domain 계층의 Repository가 QueryRepository 상속받도록 함 상속 작업 수행 * chore: Member 도메인 매핑 작업 수행 Member 도메인 매핑 작업 수행 * chore: Member 도메인과 Folder 도메인 연결 작업 수행 Member 도메인과 Folder 도메인 연결 작업 수행 * feat: 루트 폴더 생성하는 기능 구현 루트 폴더 생성하는 기능 구현 * feat: 서브폴더 생성하는 기능 구현 서브 폴더 생성하는 기능 구현 * feat: 폴더를 루트로 이동시키는 기능 구현 폴더를 루트로 이동시키는 기능 구현 * feat: 새로운 폴더 내부로 이동시키는 기능 구현 새로운 폴더 내부로 이동시키는 기능 구현 * feat: 계층형 구조의 폴더 탐색 기능 구현 계층형 구조의 폴더 탐색 기능 구현 * test: 재귀적으로 폴더를 조회하는 테스트 코드 작성 재귀적으로 폴더 조회하는 테스트코드 작성 * remove: 사용하지 않는 QueryDSL 관련 파일 삭제 사용하지 않는 QueryDSL 관련 파일 삭제 * refactor: formatting 적용 formatting 적용 * feat: 폴더 재귀적으로 삭제하는 기능 구현 폴더 재귀적으로 삭제하는 기능 구현 * feat: @OnDelete 어노테이션을 사용하여 삭제 기능 구현 삭제 기능 구현 * feat: 폴더 구조의 조회를 간편하게 개선 폴더 구조의 조회 간편하게 개선 * feat: 루트에 폴더를 생성하는 API 구현 루트에 폴더를 생성하는 API 구현 * feat: 서브 폴더를 생성하는 API 구현 서브 폴더 생성하는 API 구현 * feat: 폴더 이동하는 API 구현 폴더 이동하는 API 구현 * refactor: 중복된 함수 기능 병합 작업 수행 중복된 함수 기능 병합 작업 수행 * feat: 폴더 조회 API 구현 폴더 조회 API 구현 * feat: 폴더 삭제 API 구현 폴더 삭제 API 구현 * rename: 함수명 변경 함수 명 변경 * refactor: 메서드 분리 작업 수행 메서드 분리 작업 수행 * refactor: Delete API 204 로 반환 204로 반환 * feat: 요청마다 DTO를 다르게 설정 요청마다 DTO 다르게 설정 * refactor: 타입추론방식에서 타입명시방식으로 변경 타입명시방식으로 코드 스타일 변경 * refactor: 도메인 값에 대한 검증은 도메인계층으로 옮김 도메인 계층으로 값에 대한 검증 이동 * refactor: Owner가 아닌 폴더에 접근하려고 하는 경우 NotFoundException 예외 발생 예외 발생 --------- Co-authored-by: rladbrua0207 <48901587+rladbrua0207@users.noreply.github.com> --------- Co-authored-by: 윤정훈 <76200940+yunjunghun0116@users.noreply.github.com> Co-authored-by: rladbrua0207 <48901587+rladbrua0207@users.noreply.github.com> * Style: 코드 포맷팅 통일 (#40) * Style: 코드 포맷팅 통일 * Refactor: 예외 종류 변경 --------- Co-authored-by: 윤정훈 <76200940+yunjunghun0116@users.noreply.github.com> Co-authored-by: rladbrua0207 <48901587+rladbrua0207@users.noreply.github.com> * Remove: .idea 폴더 삭제 * Style: 코드 포맷팅 통일 (#41) * Feat: 녹음 파일 업로드 기능 구현 (#8) * Feat: 녹음 파일 업로드 기능 구현 - Recording 엔티티, 레포지토리, 컨트롤러 코드 작성 - 오디오 디코딩, 파일 저장 코드 작성 * Chore: Weekly5 로 rebase * Refactor: file base path @value 를 사용하도록 변경 --------- Co-authored-by: 윤정훈 <76200940+yunjunghun0116@users.noreply.github.com> Co-authored-by: rladbrua0207 <48901587+rladbrua0207@users.noreply.github.com> * Feat: 녹음-페이지 저장 기능 구현 (#10) * Refactor: 메서드, 파라미터 이름 변경 * Feat: 녹음-페이지 저장 기능 구현 - 페이지 넘김 이벤트에 따라 녹음-페이지 테이블에 타임스탬프 저장 * Refactor: 예외 메시지 수정 * Feature/annotation 구현 완료 (#12) * Feat : Annotation CRUD 구현 1. Controller : API 명세서 구현 2. Service : R-CUD를 QueryService, Serivce를 이용하여 구현 3. presentation : DTO 구현 * Refactor: @Positive를 이용한 양수 검증 * Refactor: @NoArgsConstructor의 접근 수준을 PROTECTED로 변경 * Refactor: CreateAnnotationRequest에서 좌표 및 크기 검증 추가 * Refactor: DTO를 record로 통일 * Refactor: getById로 변경 * Refactor: record로 인한 형식 변경 * Refactor: 정적 팩토리 from으로 변경 * Refactor: ManyToOne의 fetch 형식 LAZY로 설정 * Refactor : 정적팩토리 from으로 인한 코드 변경 * Refactor : createAnnotation에서 누락된 savedAnnotatio 추가 * Refactor : pageNumbers 누락 -> 해당 내용을 반영한 Read 구현 * Refactor: CRUD test code 작성 * Refactor : getById로 변경 * Revert "Feature/annotation 구현 완료 (#12)" (#14) This reverts commit 0dcf1d29f5897fd5db772dbf8035d877f84b19e9. * Feat: Annotation API 구현 (#15) * Feat : Annotation CRUD 구현 1. Controller : API 명세서 구현 2. Service : R-CUD를 QueryService, Serivce를 이용하여 구현 3. presentation : DTO 구현 * Refactor: @Positive를 이용한 양수 검증 * Refactor: @NoArgsConstructor의 접근 수준을 PROTECTED로 변경 * Refactor: CreateAnnotationRequest에서 좌표 및 크기 검증 추가 * Refactor: DTO를 record로 통일 * Refactor: getById로 변경 * Refactor: record로 인한 형식 변경 * Refactor: 정적 팩토리 from으로 변경 * Refactor: ManyToOne의 fetch 형식 LAZY로 설정 * Refactor : 정적팩토리 from으로 인한 코드 변경 * Refactor : createAnnotation에서 누락된 savedAnnotatio 추가 * Refactor : pageNumbers 누락 -> 해당 내용을 반영한 Read 구현 * Refactor: CRUD test code 작성 * Refactor : getById로 변경 --------- Co-authored-by: mingjuu Co-authored-by: Minju Song <101880766+mingjuu@users.noreply.github.com> * Feat: 문서 도메인 관련 기능 구현 (#13) * refactor: 폴더의 삭제 방법 재귀형태로 찾아 삭제하도록 개선 개선 * chore: API 매칭 URL 수정 작업 진행 API 매칭 URL 수정 작업 진행 * remove: 불필요한 문서 상태 삭제 불필요한 문서 상태 삭제 * feat: PDF 저장하는 기능 구현 * remove: 불필요한 라이브러리 삭제 불필요한 라이브러리 삭제 * feat: Document 저장하는 기능 구현 * test: PDF Service에서 PDF 저장 로직 테스트코드 작성 PDF 저장 로직 테스트코드 작성 * feat: Document 저장 하는 기능 구현 Document 저장하는 기능 구현 * chore: PDF 처리 및 OCR 라이브러리 라이브러리 import * feat: Document 생성 API 구현 Document 생성 API 구현 * feat: 자료 이름 수정 기능 구현 자료 이름 수정 기능 구현 * feat: 자료 조회 기능 구현 자료 조회 기능 구현 * feat: 자료 삭제 기능 구현 자료 삭제 기능 구현 * feat: 폴더 삭제시 자료도 함께 삭제되도록 기능 구현 삭제 기능 구현 * chore: PDF Setup pdf 세팅 * feat: 루트(메인)에 생성되는 Document 설정 루트 자료 추가 기능 구현 --------- Co-authored-by: rladbrua0207 <48901587+rladbrua0207@users.noreply.github.com> Co-authored-by: mingjuu Co-authored-by: Hyun-Seo Jeong <90139789+hynseoj@users.noreply.github.com> * Fix: 에러 충돌 해결 및 기능 추가 (#16) * Refactor: 코드 취합 후 수정 - document findById 를 getById 로 변경 등 --------- Co-authored-by: yugyeom <48901587+rladbrua0207@users.noreply.github.com> Co-authored-by: 윤정훈 <76200940+yunjunghun0116@users.noreply.github.com> Co-authored-by: Minju Song <101880766+mingjuu@users.noreply.github.com> Co-authored-by: mingjuu --------- Co-authored-by: yugyeom <48901587+rladbrua0207@users.noreply.github.com> Co-authored-by: 윤정훈 <76200940+yunjunghun0116@users.noreply.github.com> Co-authored-by: Cindy <93774025+Shsin9797@users.noreply.github.com> Co-authored-by: Minju Song <101880766+mingjuu@users.noreply.github.com> Co-authored-by: mingjuu --- build.gradle | 6 + .../application/AnnotationQueryService.java | 35 +++++ .../application/AnnotationService.java | 42 ++++++ .../notai/annotation/domain/Annotation.java | 66 +++++++++ .../domain/AnnotationRepository.java | 20 +++ .../presentation/AnnotationController.java | 81 +++++++++++ .../request/CreateAnnotationRequest.java | 29 ++++ .../response/AnnotationResponse.java | 32 +++++ src/main/java/notai/auth/TokenPair.java | 5 +- src/main/java/notai/auth/TokenService.java | 28 ++-- .../client/oauth/OauthClientComposite.java | 9 +- .../oauth/kakao/KakaoMemberResponse.java | 48 ++++--- .../java/notai/comment/domain/Comment.java | 7 +- .../notai/common/config/SwaggerConfig.java | 52 ++++--- .../java/notai/common/domain/vo/FilePath.java | 31 ++++ .../exception/type/FileProcessException.java | 11 ++ .../java/notai/common/utils/AudioDecoder.java | 18 +++ .../java/notai/common/utils/FileManager.java | 20 +++ .../application/DocumentQueryService.java | 16 +++ .../document/application/DocumentService.java | 62 ++++++++ .../document/application/PdfService.java | 47 +++++++ .../result/DocumentFindResult.java | 11 ++ .../result/DocumentSaveResult.java | 11 ++ .../result/DocumentUpdateResult.java | 11 ++ .../java/notai/document/domain/Document.java | 34 +++-- .../document/domain/DocumentRepository.java | 11 ++ .../notai/document/domain/DocumentStatus.java | 5 - .../presentation/DocumentController.java | 66 ++++++++- .../document/presentation/PdfController.java | 30 ++++ .../request/DocumentSaveRequest.java | 6 + .../request/DocumentUpdateRequest.java | 6 + .../response/DocumentFindResponse.java | 13 ++ .../response/DocumentSaveResponse.java | 13 ++ .../response/DocumentUpdateResponse.java | 17 +++ .../application/FolderQueryService.java | 25 ++++ .../folder/application/FolderService.java | 67 +++++++++ .../application/result/FolderFindResult.java | 11 ++ .../application/result/FolderMoveResult.java | 10 ++ .../application/result/FolderSaveResult.java | 11 ++ src/main/java/notai/folder/domain/Folder.java | 9 +- .../notai/folder/domain/FolderRepository.java | 15 +- .../folder/presentation/FolderController.java | 65 ++++++++- .../request/FolderMoveRequest.java | 6 + .../request/FolderSaveRequest.java | 7 + .../response/FolderFindResponse.java | 13 ++ .../response/FolderMoveResponse.java | 12 ++ .../response/FolderSaveResponse.java | 13 ++ .../folder/query/FolderQueryRepository.java | 4 - .../query/FolderQueryRepositoryImpl.java | 10 -- .../llm/application/LLMQueryService.java | 11 +- .../notai/llm/application/LLMService.java | 5 +- .../application/result/LLMStatusResult.java | 4 +- .../java/notai/llm/domain/TaskStatus.java | 4 +- .../notai/llm/presentation/LLMController.java | 2 +- .../request/LLMSubmitRequest.java | 3 +- .../response/LLMResultsResponse.java | 10 +- src/main/java/notai/member/domain/Member.java | 5 +- .../java/notai/member/domain/OauthId.java | 5 +- .../member/presentation/MemberController.java | 6 +- .../response/MemberOauthLoginResopnse.java | 11 -- .../response/MemberOauthLoginResponse.java | 12 ++ .../response/MemberTokenRefreshResponse.java | 3 +- .../application/PageRecordingService.java | 41 ++++++ .../command/PageRecordingSaveCommand.java | 16 +++ .../pageRecording/domain/PageRecording.java | 43 ++++++ .../domain/PageRecordingRepository.java | 7 + .../presentation/PageRecordingController.java | 25 ++++ .../request/PageRecordingSaveRequest.java | 32 +++++ .../query/PageRecordingQueryRepository.java | 12 ++ src/main/java/notai/post/domain/Post.java | 7 +- .../problem/query/ProblemQueryRepository.java | 3 +- .../application/RecordingService.java | 60 ++++++++ .../command/RecordingSaveCommand.java | 7 + .../result/RecordingSaveResult.java | 13 ++ .../notai/recording/domain/Recording.java | 43 ++++++ .../recording/domain/RecordingRepository.java | 10 ++ .../presentation/RecordingController.java | 28 ++++ .../request/RecordingSaveRequest.java | 15 ++ .../response/RecordingSaveResponse.java | 15 ++ .../summary/query/SummaryQueryRepository.java | 5 +- src/main/resources/application-local.yml | 3 + src/main/resources/application.yml | 4 + .../java/notai/BackendApplicationTests.java | 6 +- .../annotation/AnnotationServiceTest.java | 132 ++++++++++++++++++ .../oauth/kakao/KakaoOauthClientTest.java | 11 +- .../application/DocumentServiceTest.java | 13 ++ .../document/application/PdfServiceTest.java | 73 ++++++++++ .../application/FolderQueryServiceTest.java | 64 +++++++++ .../notai/llm/application/LLMServiceTest.java | 9 +- .../application/PageRecordingServiceTest.java | 54 +++++++ .../application/RecordingServiceTest.java | 103 ++++++++++++++ 91 files changed, 1945 insertions(+), 167 deletions(-) create mode 100644 src/main/java/notai/annotation/application/AnnotationQueryService.java create mode 100644 src/main/java/notai/annotation/application/AnnotationService.java create mode 100644 src/main/java/notai/annotation/domain/Annotation.java create mode 100644 src/main/java/notai/annotation/domain/AnnotationRepository.java create mode 100644 src/main/java/notai/annotation/presentation/AnnotationController.java create mode 100644 src/main/java/notai/annotation/presentation/request/CreateAnnotationRequest.java create mode 100644 src/main/java/notai/annotation/presentation/response/AnnotationResponse.java create mode 100644 src/main/java/notai/common/domain/vo/FilePath.java create mode 100644 src/main/java/notai/common/exception/type/FileProcessException.java create mode 100644 src/main/java/notai/common/utils/AudioDecoder.java create mode 100644 src/main/java/notai/common/utils/FileManager.java create mode 100644 src/main/java/notai/document/application/PdfService.java create mode 100644 src/main/java/notai/document/application/result/DocumentFindResult.java create mode 100644 src/main/java/notai/document/application/result/DocumentSaveResult.java create mode 100644 src/main/java/notai/document/application/result/DocumentUpdateResult.java delete mode 100644 src/main/java/notai/document/domain/DocumentStatus.java create mode 100644 src/main/java/notai/document/presentation/PdfController.java create mode 100644 src/main/java/notai/document/presentation/request/DocumentSaveRequest.java create mode 100644 src/main/java/notai/document/presentation/request/DocumentUpdateRequest.java create mode 100644 src/main/java/notai/document/presentation/response/DocumentFindResponse.java create mode 100644 src/main/java/notai/document/presentation/response/DocumentSaveResponse.java create mode 100644 src/main/java/notai/document/presentation/response/DocumentUpdateResponse.java create mode 100644 src/main/java/notai/folder/application/result/FolderFindResult.java create mode 100644 src/main/java/notai/folder/application/result/FolderMoveResult.java create mode 100644 src/main/java/notai/folder/application/result/FolderSaveResult.java create mode 100644 src/main/java/notai/folder/presentation/request/FolderMoveRequest.java create mode 100644 src/main/java/notai/folder/presentation/request/FolderSaveRequest.java create mode 100644 src/main/java/notai/folder/presentation/response/FolderFindResponse.java create mode 100644 src/main/java/notai/folder/presentation/response/FolderMoveResponse.java create mode 100644 src/main/java/notai/folder/presentation/response/FolderSaveResponse.java delete mode 100644 src/main/java/notai/folder/query/FolderQueryRepository.java delete mode 100644 src/main/java/notai/folder/query/FolderQueryRepositoryImpl.java delete mode 100644 src/main/java/notai/member/presentation/response/MemberOauthLoginResopnse.java create mode 100644 src/main/java/notai/member/presentation/response/MemberOauthLoginResponse.java create mode 100644 src/main/java/notai/pageRecording/application/PageRecordingService.java create mode 100644 src/main/java/notai/pageRecording/application/command/PageRecordingSaveCommand.java create mode 100644 src/main/java/notai/pageRecording/domain/PageRecording.java create mode 100644 src/main/java/notai/pageRecording/domain/PageRecordingRepository.java create mode 100644 src/main/java/notai/pageRecording/presentation/PageRecordingController.java create mode 100644 src/main/java/notai/pageRecording/presentation/request/PageRecordingSaveRequest.java create mode 100644 src/main/java/notai/pageRecording/query/PageRecordingQueryRepository.java create mode 100644 src/main/java/notai/recording/application/RecordingService.java create mode 100644 src/main/java/notai/recording/application/command/RecordingSaveCommand.java create mode 100644 src/main/java/notai/recording/application/result/RecordingSaveResult.java create mode 100644 src/main/java/notai/recording/domain/Recording.java create mode 100644 src/main/java/notai/recording/domain/RecordingRepository.java create mode 100644 src/main/java/notai/recording/presentation/RecordingController.java create mode 100644 src/main/java/notai/recording/presentation/request/RecordingSaveRequest.java create mode 100644 src/main/java/notai/recording/presentation/response/RecordingSaveResponse.java create mode 100644 src/test/java/notai/annotation/AnnotationServiceTest.java create mode 100644 src/test/java/notai/document/application/DocumentServiceTest.java create mode 100644 src/test/java/notai/document/application/PdfServiceTest.java create mode 100644 src/test/java/notai/folder/application/FolderQueryServiceTest.java create mode 100644 src/test/java/notai/pageRecording/application/PageRecordingServiceTest.java create mode 100644 src/test/java/notai/recording/application/RecordingServiceTest.java diff --git a/build.gradle b/build.gradle index 13d01b9..344ed4f 100644 --- a/build.gradle +++ b/build.gradle @@ -53,6 +53,12 @@ dependencies { // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // PDF + implementation 'org.apache.pdfbox:pdfbox:3.0.2' + + // OCR + implementation 'net.sourceforge.tess4j:tess4j:5.13.0' } tasks.named('test') { diff --git a/src/main/java/notai/annotation/application/AnnotationQueryService.java b/src/main/java/notai/annotation/application/AnnotationQueryService.java new file mode 100644 index 0000000..9d3a879 --- /dev/null +++ b/src/main/java/notai/annotation/application/AnnotationQueryService.java @@ -0,0 +1,35 @@ +package notai.annotation.application; + +import lombok.RequiredArgsConstructor; +import notai.annotation.domain.Annotation; +import notai.annotation.domain.AnnotationRepository; +import notai.annotation.presentation.response.AnnotationResponse; +import notai.common.exception.type.NotFoundException; +import notai.document.domain.DocumentRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class AnnotationQueryService { + + private final AnnotationRepository annotationRepository; + private final DocumentRepository documentRepository; + + @Transactional(readOnly = true) + public List getAnnotationsByDocumentAndPageNumbers(Long documentId, List pageNumbers) { + documentRepository.getById(documentId); + + List annotations = annotationRepository.findByDocumentIdAndPageNumberIn(documentId, pageNumbers); + if (annotations.isEmpty()) { + throw new NotFoundException("해당 문서에 해당 페이지 번호의 주석이 존재하지 않습니다."); + } + + return annotations.stream() + .map(AnnotationResponse::from) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/notai/annotation/application/AnnotationService.java b/src/main/java/notai/annotation/application/AnnotationService.java new file mode 100644 index 0000000..7aba5be --- /dev/null +++ b/src/main/java/notai/annotation/application/AnnotationService.java @@ -0,0 +1,42 @@ +package notai.annotation.application; + +import lombok.RequiredArgsConstructor; +import notai.annotation.domain.Annotation; +import notai.annotation.domain.AnnotationRepository; +import notai.annotation.presentation.response.AnnotationResponse; +import notai.document.domain.Document; +import notai.document.domain.DocumentRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AnnotationService { + + private final AnnotationRepository annotationRepository; + private final DocumentRepository documentRepository; + + @Transactional + public AnnotationResponse createAnnotation(Long documentId, int pageNumber, int x, int y, int width, int height, String content) { + Document document = documentRepository.getById(documentId); + + Annotation annotation = new Annotation(document, pageNumber, x, y, width, height, content); + Annotation savedAnnotation = annotationRepository.save(annotation); + return AnnotationResponse.from(savedAnnotation); + } + + @Transactional + public AnnotationResponse updateAnnotation(Long documentId, Long annotationId, int x, int y, int width, int height, String content) { + documentRepository.getById(documentId); + Annotation annotation = annotationRepository.getById(annotationId); + annotation.updateAnnotation(x, y, width, height, content); + return AnnotationResponse.from(annotation); + } + + @Transactional + public void deleteAnnotation(Long documentId, Long annotationId) { + documentRepository.getById(documentId); + Annotation annotation = annotationRepository.getById(annotationId); + annotationRepository.delete(annotation); + } +} diff --git a/src/main/java/notai/annotation/domain/Annotation.java b/src/main/java/notai/annotation/domain/Annotation.java new file mode 100644 index 0000000..66db390 --- /dev/null +++ b/src/main/java/notai/annotation/domain/Annotation.java @@ -0,0 +1,66 @@ +package notai.annotation.domain; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import notai.common.domain.RootEntity; +import notai.document.domain.Document; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "annotation") +public class Annotation extends RootEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "document_id") + @NotNull + private Document document; + + @NotNull + private int pageNumber; + + @NotNull + private int x; + + @NotNull + private int y; + + @NotNull + private int width; + + @NotNull + private int height; + + @Column(columnDefinition = "TEXT") + @NotNull + private String content; + + @Override + public Long getId() { + return this.id; + } + + public Annotation(Document document, int pageNumber, int x, int y, int width, int height, String content) { + this.document = document; + this.pageNumber = pageNumber; + this.x = x; + this.y = y; + this.width = width; + this.height = height; + this.content = content; + } + + public void updateAnnotation(int x, int y, int width, int height, String content) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + this.content = content; + } +} diff --git a/src/main/java/notai/annotation/domain/AnnotationRepository.java b/src/main/java/notai/annotation/domain/AnnotationRepository.java new file mode 100644 index 0000000..c05ab3c --- /dev/null +++ b/src/main/java/notai/annotation/domain/AnnotationRepository.java @@ -0,0 +1,20 @@ +package notai.annotation.domain; + +import notai.common.exception.type.NotFoundException; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AnnotationRepository extends JpaRepository { + + List findByDocumentIdAndPageNumberIn(Long documentId, List pageNumbers); + + + Optional findByIdAndDocumentId(Long id, Long documentId); + + default Annotation getById(Long annotationId) { + return findById(annotationId) + .orElseThrow(() -> new NotFoundException("주석을 찾을 수 없습니다. ID: " + annotationId)); + } +} diff --git a/src/main/java/notai/annotation/presentation/AnnotationController.java b/src/main/java/notai/annotation/presentation/AnnotationController.java new file mode 100644 index 0000000..a57414c --- /dev/null +++ b/src/main/java/notai/annotation/presentation/AnnotationController.java @@ -0,0 +1,81 @@ +package notai.annotation.presentation; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import notai.annotation.application.AnnotationQueryService; +import notai.annotation.application.AnnotationService; +import notai.annotation.presentation.request.CreateAnnotationRequest; +import notai.annotation.presentation.response.AnnotationResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/documents/{documentId}/annotations") +@RequiredArgsConstructor +public class AnnotationController { + + private final AnnotationService annotationService; + private final AnnotationQueryService annotationQueryService; + + @PostMapping + public ResponseEntity createAnnotation( + @PathVariable Long documentId, @RequestBody @Valid CreateAnnotationRequest request + ) { + + AnnotationResponse response = annotationService.createAnnotation(documentId, + request.pageNumber(), + request.x(), + request.y(), + request.width(), + request.height(), + request.content() + ); + + return new ResponseEntity<>(response, HttpStatus.CREATED); + } + + + @GetMapping + public ResponseEntity> getAnnotations( + @PathVariable Long documentId, @RequestParam List pageNumbers + ) { + + List response = annotationQueryService.getAnnotationsByDocumentAndPageNumbers( + documentId, + pageNumbers + ); + + return new ResponseEntity<>(response, HttpStatus.OK); + } + + @PutMapping("/{annotationId}") + public ResponseEntity updateAnnotation( + @PathVariable Long documentId, + @PathVariable Long annotationId, + @RequestBody @Valid CreateAnnotationRequest request + ) { + + AnnotationResponse response = annotationService.updateAnnotation(documentId, + annotationId, + request.x(), + request.y(), + request.width(), + request.height(), + request.content() + ); + + return new ResponseEntity<>(response, HttpStatus.OK); + } + + @DeleteMapping("/{annotationId}") + public ResponseEntity deleteAnnotation( + @PathVariable Long documentId, @PathVariable Long annotationId + ) { + + annotationService.deleteAnnotation(documentId, annotationId); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } +} diff --git a/src/main/java/notai/annotation/presentation/request/CreateAnnotationRequest.java b/src/main/java/notai/annotation/presentation/request/CreateAnnotationRequest.java new file mode 100644 index 0000000..8e05acc --- /dev/null +++ b/src/main/java/notai/annotation/presentation/request/CreateAnnotationRequest.java @@ -0,0 +1,29 @@ +package notai.annotation.presentation.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; + +public record CreateAnnotationRequest( + + @Positive(message = "페이지 번호는 양수여야 합니다.") + int pageNumber, + +// @Max(value = ?, message = "x 좌표는 최대 ? 이하여야 합니다.") + @PositiveOrZero(message = "x 좌표는 0 이상이어야 합니다.") + int x, + +// @Max(value = ?, message = "x 좌표는 최대 ? 이하여야 합니다.") + @PositiveOrZero(message = "y 좌표는 0 이상이어야 합니다.") + int y, + +// @Max(value = ?, message = "width는 최대 ? 이하여야 합니다.") + @Positive(message = "width는 양수여야 합니다.") + int width, + +// @Max(value = ?, message = "height는 최대 ? 이하여야 합니다.") + @Positive(message = "height는 양수여야 합니다.") + int height, + + String content +) {} diff --git a/src/main/java/notai/annotation/presentation/response/AnnotationResponse.java b/src/main/java/notai/annotation/presentation/response/AnnotationResponse.java new file mode 100644 index 0000000..2e2348c --- /dev/null +++ b/src/main/java/notai/annotation/presentation/response/AnnotationResponse.java @@ -0,0 +1,32 @@ +package notai.annotation.presentation.response; + +import notai.annotation.domain.Annotation; + +public record AnnotationResponse( + Long id, + Long documentId, + int pageNumber, + int x, + int y, + int width, + int height, + String content, + String createdAt, + String updatedAt +) { + + public static AnnotationResponse from(Annotation annotation) { + return new AnnotationResponse( + annotation.getId(), + annotation.getDocument().getId(), + annotation.getPageNumber(), + annotation.getX(), + annotation.getY(), + annotation.getWidth(), + annotation.getHeight(), + annotation.getContent(), + annotation.getCreatedAt().toString(), + annotation.getUpdatedAt().toString() + ); + } +} diff --git a/src/main/java/notai/auth/TokenPair.java b/src/main/java/notai/auth/TokenPair.java index 4e51456..0051082 100644 --- a/src/main/java/notai/auth/TokenPair.java +++ b/src/main/java/notai/auth/TokenPair.java @@ -1,4 +1,7 @@ package notai.auth; -public record TokenPair(String accessToken, String refreshToken) { +public record TokenPair( + String accessToken, + String refreshToken +) { } diff --git a/src/main/java/notai/auth/TokenService.java b/src/main/java/notai/auth/TokenService.java index b4b8510..9204e5a 100644 --- a/src/main/java/notai/auth/TokenService.java +++ b/src/main/java/notai/auth/TokenService.java @@ -29,20 +29,17 @@ public TokenService(TokenProperty tokenProperty, MemberRepository memberReposito } public String createAccessToken(Long memberId) { - return Jwts.builder() - .claim(MEMBER_ID_CLAIM, memberId) - .issuedAt(new Date()) - .expiration(new Date(System.currentTimeMillis() + accessTokenExpirationMillis)) - .signWith(secretKey, Jwts.SIG.HS512) - .compact(); + return Jwts.builder().claim(MEMBER_ID_CLAIM, + memberId + ).issuedAt(new Date()).expiration(new Date(System.currentTimeMillis() + accessTokenExpirationMillis)).signWith(secretKey, + Jwts.SIG.HS512 + ).compact(); } private String createRefreshToken() { - return Jwts.builder() - .issuedAt(new Date()) - .expiration(new Date(System.currentTimeMillis() + refreshTokenExpirationMillis)) - .signWith(secretKey, Jwts.SIG.HS512) - .compact(); + return Jwts.builder().issuedAt(new Date()).expiration(new Date(System.currentTimeMillis() + refreshTokenExpirationMillis)).signWith(secretKey, + Jwts.SIG.HS512 + ).compact(); } public TokenPair createTokenPair(Long memberId) { @@ -71,12 +68,9 @@ public TokenPair refreshTokenPair(String refreshToken) { public Long extractMemberId(String token) { try { - return Jwts.parser() - .verifyWith(secretKey) - .build() - .parseSignedClaims(token) - .getPayload() - .get(MEMBER_ID_CLAIM, Long.class); + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get(MEMBER_ID_CLAIM, + Long.class + ); } catch (Exception e) { throw new UnAuthorizedException("유효하지 않은 토큰입니다."); } diff --git a/src/main/java/notai/client/oauth/OauthClientComposite.java b/src/main/java/notai/client/oauth/OauthClientComposite.java index e4f349b..f65aca6 100644 --- a/src/main/java/notai/client/oauth/OauthClientComposite.java +++ b/src/main/java/notai/client/oauth/OauthClientComposite.java @@ -1,7 +1,5 @@ package notai.client.oauth; -import static java.util.function.Function.identity; -import static java.util.stream.Collectors.toMap; import notai.common.exception.type.BadRequestException; import notai.member.domain.Member; import notai.member.domain.OauthProvider; @@ -11,6 +9,9 @@ import java.util.Optional; import java.util.Set; +import static java.util.function.Function.identity; +import static java.util.stream.Collectors.toMap; + @Component public class OauthClientComposite { @@ -25,7 +26,7 @@ public Member fetchMember(OauthProvider oauthProvider, String accessToken) { } public OauthClient getOauthClient(OauthProvider oauthProvider) { - return Optional.ofNullable(oauthClients.get(oauthProvider)).orElseThrow(() -> new BadRequestException( - "지원하지 않는 소셜 로그인 타입입니다.")); + return Optional.ofNullable(oauthClients.get(oauthProvider)) + .orElseThrow(() -> new BadRequestException("지원하지 않는 소셜 로그인 타입입니다.")); } } diff --git a/src/main/java/notai/client/oauth/kakao/KakaoMemberResponse.java b/src/main/java/notai/client/oauth/kakao/KakaoMemberResponse.java index a6a473e..6e81ed8 100644 --- a/src/main/java/notai/client/oauth/kakao/KakaoMemberResponse.java +++ b/src/main/java/notai/client/oauth/kakao/KakaoMemberResponse.java @@ -10,29 +10,33 @@ @JsonNaming(value = SnakeCaseStrategy.class) public record KakaoMemberResponse( - Long id, - boolean hasSignedUp, - LocalDateTime connectedAt, - KakaoAccount kakaoAccount) { + Long id, + boolean hasSignedUp, + LocalDateTime connectedAt, + KakaoAccount kakaoAccount +) { - public Member toDomain() { - return new Member( - new OauthId(String.valueOf(id), OauthProvider.KAKAO), - kakaoAccount.email, - kakaoAccount.profile.nickname); - } + public Member toDomain() { + return new Member( + new OauthId(String.valueOf(id), OauthProvider.KAKAO), + kakaoAccount.email, + kakaoAccount.profile.nickname + ); + } - @JsonNaming(value = SnakeCaseStrategy.class) - public record KakaoAccount( - Profile profile, - boolean emailNeedsAgreement, - boolean isEmailValid, - boolean isEmailVerified, - String email) { - } + @JsonNaming(value = SnakeCaseStrategy.class) + public record KakaoAccount( + Profile profile, + boolean emailNeedsAgreement, + boolean isEmailValid, + boolean isEmailVerified, + String email + ) { + } - @JsonNaming(value = SnakeCaseStrategy.class) - public record Profile( - String nickname) { - } + @JsonNaming(value = SnakeCaseStrategy.class) + public record Profile( + String nickname + ) { + } } diff --git a/src/main/java/notai/comment/domain/Comment.java b/src/main/java/notai/comment/domain/Comment.java index 269cda6..d3bb860 100644 --- a/src/main/java/notai/comment/domain/Comment.java +++ b/src/main/java/notai/comment/domain/Comment.java @@ -1,10 +1,7 @@ package notai.comment.domain; import jakarta.persistence.*; -import static jakarta.persistence.FetchType.LAZY; -import static jakarta.persistence.GenerationType.IDENTITY; import jakarta.validation.constraints.NotNull; -import static lombok.AccessLevel.PROTECTED; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -12,6 +9,10 @@ import notai.member.domain.Member; import notai.post.domain.Post; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + @Entity @Table(name = "comment") @Getter diff --git a/src/main/java/notai/common/config/SwaggerConfig.java b/src/main/java/notai/common/config/SwaggerConfig.java index 3b79924..f3f77db 100644 --- a/src/main/java/notai/common/config/SwaggerConfig.java +++ b/src/main/java/notai/common/config/SwaggerConfig.java @@ -1,27 +1,43 @@ -package notai.client.oauth.kakao; +package notai.common.config; -import lombok.extern.slf4j.Slf4j; -import notai.common.exception.type.ExternalApiException; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpStatusCode; -import org.springframework.web.client.RestClient; -import static notai.client.HttpInterfaceUtil.createHttpInterface; - -@Slf4j @Configuration -public class KakaoClientConfig { +public class SwaggerConfig { + + private final String serverUrl; + + public SwaggerConfig(@Value("${server-url}") String serverUrl) { + this.serverUrl = serverUrl; + } @Bean - public KakaoClient kakaoClient() { - RestClient restClient = RestClient.builder().defaultStatusHandler(HttpStatusCode::isError, - (request, response) -> { - String responseData = new String(response.getBody().readAllBytes()); - log.error("카카오톡 API 오류 : {}", responseData); - throw new ExternalApiException(responseData, response.getStatusCode().value()); - } - ).build(); - return createHttpInterface(restClient, KakaoClient.class); + public OpenAPI openAPI() { + String jwt = "JWT"; + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwt); + Components components = new Components().addSecuritySchemes(jwt, new SecurityScheme().name(jwt) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .description("토큰값을 입력하여 인증을 활성화할 수 있습니다.") + .bearerFormat("JWT")); + Server server = new Server(); + server.setUrl(serverUrl); + return new OpenAPI().components(new Components()) + .info(apiInfo()) + .addSecurityItem(securityRequirement) + .components(components) + .addServersItem(server); + } + + private Info apiInfo() { + return new Info().title("notai API").description("notai API 문서입니다.").version("0.0.1"); } } diff --git a/src/main/java/notai/common/domain/vo/FilePath.java b/src/main/java/notai/common/domain/vo/FilePath.java new file mode 100644 index 0000000..d9c04af --- /dev/null +++ b/src/main/java/notai/common/domain/vo/FilePath.java @@ -0,0 +1,31 @@ +package notai.common.domain.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.Getter; +import lombok.NoArgsConstructor; +import notai.common.exception.type.BadRequestException; + +import static lombok.AccessLevel.PROTECTED; + +@Embeddable +@Getter +@NoArgsConstructor(access = PROTECTED) +public class FilePath { + + @Column(length = 50) + private String filePath; + + private FilePath(String filePath) { + this.filePath = filePath; + } + + public static FilePath from(String filePath) { + // 추후 확장자 추가 + if (!filePath.matches( + "[a-zA-Z0-9ㄱ-ㅎㅏ-ㅣ가-힣()\\[\\]+\\-&/_\\s]+(/[a-zA-Z0-9ㄱ-ㅎㅏ-ㅣ가-힣()\\[\\]+\\-&/_\\s]+)*/?[a-zA-Z0-9ㄱ-ㅎㅏ-ㅣ가-힣()\\[\\]+\\-&/_\\s]+\\.mp3")) { + throw new BadRequestException("지원하지 않는 파일 형식입니다."); + } + return new FilePath(filePath); + } +} diff --git a/src/main/java/notai/common/exception/type/FileProcessException.java b/src/main/java/notai/common/exception/type/FileProcessException.java new file mode 100644 index 0000000..640ff7e --- /dev/null +++ b/src/main/java/notai/common/exception/type/FileProcessException.java @@ -0,0 +1,11 @@ +package notai.common.exception.type; + + +import notai.common.exception.ApplicationException; + +public class FileProcessException extends ApplicationException { + + public FileProcessException(String message) { + super(message, 500); + } +} diff --git a/src/main/java/notai/common/utils/AudioDecoder.java b/src/main/java/notai/common/utils/AudioDecoder.java new file mode 100644 index 0000000..5f78bf5 --- /dev/null +++ b/src/main/java/notai/common/utils/AudioDecoder.java @@ -0,0 +1,18 @@ +package notai.common.utils; + +import org.springframework.stereotype.Component; + +import java.util.Base64; + +@Component +public class AudioDecoder { + + public byte[] decode(String audioData) throws IllegalArgumentException { + String base64AudioData = removeMetaData(audioData); + return Base64.getDecoder().decode(base64AudioData); + } + + private static String removeMetaData(String audioData) { + return audioData.split(",")[1]; + } +} diff --git a/src/main/java/notai/common/utils/FileManager.java b/src/main/java/notai/common/utils/FileManager.java new file mode 100644 index 0000000..1e6b2d7 --- /dev/null +++ b/src/main/java/notai/common/utils/FileManager.java @@ -0,0 +1,20 @@ +package notai.common.utils; + +import org.springframework.stereotype.Component; + +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +@Component +public class FileManager { + + public void save(byte[] binaryFile, Path path) throws IOException { + Files.createDirectories(path.getParent()); + + try (FileOutputStream fos = new FileOutputStream(path.toFile())) { + fos.write(binaryFile); + } + } +} diff --git a/src/main/java/notai/document/application/DocumentQueryService.java b/src/main/java/notai/document/application/DocumentQueryService.java index c055f04..4f2112f 100644 --- a/src/main/java/notai/document/application/DocumentQueryService.java +++ b/src/main/java/notai/document/application/DocumentQueryService.java @@ -1,9 +1,25 @@ package notai.document.application; import lombok.RequiredArgsConstructor; +import notai.document.application.result.DocumentFindResult; +import notai.document.domain.Document; +import notai.document.domain.DocumentRepository; import org.springframework.stereotype.Service; +import java.util.List; + @Service @RequiredArgsConstructor public class DocumentQueryService { + + private final DocumentRepository documentRepository; + + public List findDocuments(Long folderId) { + List documents = documentRepository.findAllByFolderId(folderId); + 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 b2674f1..1693b4f 100644 --- a/src/main/java/notai/document/application/DocumentService.java +++ b/src/main/java/notai/document/application/DocumentService.java @@ -1,9 +1,71 @@ package notai.document.application; import lombok.RequiredArgsConstructor; +import notai.document.application.result.DocumentSaveResult; +import notai.document.application.result.DocumentUpdateResult; +import notai.document.domain.Document; +import notai.document.domain.DocumentRepository; +import notai.document.presentation.request.DocumentSaveRequest; +import notai.document.presentation.request.DocumentUpdateRequest; +import notai.folder.domain.Folder; +import notai.folder.domain.FolderRepository; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; @Service @RequiredArgsConstructor public class DocumentService { + + private final PdfService pdfService; + private final DocumentRepository documentRepository; + private final FolderRepository folderRepository; + + public DocumentSaveResult saveDocument( + Long folderId, MultipartFile pdfFile, DocumentSaveRequest documentSaveRequest + ) { + String pdfName = pdfService.savePdf(pdfFile); + String pdfUrl = convertPdfUrl(pdfName); + Folder folder = folderRepository.getById(folderId); + Document document = new Document(folder, documentSaveRequest.name(), pdfUrl); + Document savedDocument = documentRepository.save(document); + return DocumentSaveResult.of(savedDocument.getId(), savedDocument.getName(), savedDocument.getUrl()); + } + + public DocumentSaveResult saveRootDocument( + MultipartFile pdfFile, DocumentSaveRequest documentSaveRequest + ) { + String pdfName = pdfService.savePdf(pdfFile); + String pdfUrl = convertPdfUrl(pdfName); + Document document = new Document(documentSaveRequest.name(), pdfUrl); + Document savedDocument = documentRepository.save(document); + return DocumentSaveResult.of(savedDocument.getId(), savedDocument.getName(), savedDocument.getUrl()); + } + + public DocumentUpdateResult updateDocument( + Long folderId, Long documentId, DocumentUpdateRequest documentUpdateRequest + ) { + Document document = documentRepository.getById(documentId); + 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 + ) { + Document document = documentRepository.getById(documentId); + document.validateDocument(folderId); + documentRepository.delete(document); + } + + public void deleteAllByFolder( + Folder folder + ) { + documentRepository.deleteAllByFolder(folder); + } + + private String convertPdfUrl(String pdfName) { + return String.format("pdf/%s", pdfName); + } } diff --git a/src/main/java/notai/document/application/PdfService.java b/src/main/java/notai/document/application/PdfService.java new file mode 100644 index 0000000..74f887a --- /dev/null +++ b/src/main/java/notai/document/application/PdfService.java @@ -0,0 +1,47 @@ +package notai.document.application; + +import lombok.RequiredArgsConstructor; +import notai.common.exception.type.FileProcessException; +import notai.common.exception.type.NotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class PdfService { + + private static final String STORAGE_DIR = "src/main/resources/pdf/"; + + public String savePdf(MultipartFile file) { + try { + Path directoryPath = Paths.get(STORAGE_DIR); + if (!Files.exists(directoryPath)) { + Files.createDirectories(directoryPath); + } + + String fileName = UUID.randomUUID() + ".pdf"; + Path filePath = directoryPath.resolve(fileName); + file.transferTo(filePath.toFile()); + + return fileName; + } catch (IOException exception) { + throw new FileProcessException("자료를 저장하는 과정에서 에러가 발생했습니다."); + } + } + + public File getPdf(String fileName) { + Path filePath = Paths.get(STORAGE_DIR, fileName); + + if (!Files.exists(filePath)) { + throw new NotFoundException("존재하지 않는 파일입니다."); + } + return filePath.toFile(); + } +} diff --git a/src/main/java/notai/document/application/result/DocumentFindResult.java b/src/main/java/notai/document/application/result/DocumentFindResult.java new file mode 100644 index 0000000..634e093 --- /dev/null +++ b/src/main/java/notai/document/application/result/DocumentFindResult.java @@ -0,0 +1,11 @@ +package notai.document.application.result; + +public record DocumentFindResult( + Long id, + String name, + String url +) { + public static DocumentFindResult of(Long id, String name, String url) { + return new DocumentFindResult(id, name, url); + } +} diff --git a/src/main/java/notai/document/application/result/DocumentSaveResult.java b/src/main/java/notai/document/application/result/DocumentSaveResult.java new file mode 100644 index 0000000..9337e0e --- /dev/null +++ b/src/main/java/notai/document/application/result/DocumentSaveResult.java @@ -0,0 +1,11 @@ +package notai.document.application.result; + +public record DocumentSaveResult( + Long id, + String name, + String url +) { + public static DocumentSaveResult of(Long id, String name, String url) { + return new DocumentSaveResult(id, name, url); + } +} diff --git a/src/main/java/notai/document/application/result/DocumentUpdateResult.java b/src/main/java/notai/document/application/result/DocumentUpdateResult.java new file mode 100644 index 0000000..cd1ff31 --- /dev/null +++ b/src/main/java/notai/document/application/result/DocumentUpdateResult.java @@ -0,0 +1,11 @@ +package notai.document.application.result; + +public record DocumentUpdateResult( + Long id, + String name, + String url +) { + public static DocumentUpdateResult of(Long id, String name, String url) { + return new DocumentUpdateResult(id, name, url); + } +} diff --git a/src/main/java/notai/document/domain/Document.java b/src/main/java/notai/document/domain/Document.java index 8135234..f2856f0 100644 --- a/src/main/java/notai/document/domain/Document.java +++ b/src/main/java/notai/document/domain/Document.java @@ -7,6 +7,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import notai.common.domain.RootEntity; +import notai.common.exception.type.NotFoundException; import notai.folder.domain.Folder; @Entity @@ -19,7 +20,6 @@ public class Document extends RootEntity { @GeneratedValue(strategy = IDENTITY) private Long id; - @NotNull @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "folder_id", referencedColumnName = "id") private Folder folder; @@ -29,23 +29,27 @@ public class Document extends RootEntity { private String name; @NotNull - @Column(name = "size") - private Integer size; + @Column(name = "url") + private String url; - @NotNull - @Column(name = "total_page") - private Integer totalPage; + public Document(Folder folder, String name, String url) { + this.folder = folder; + this.name = name; + this.url = url; + } - @NotNull - @Enumerated(value = EnumType.STRING) - @Column(name = "status") - private DocumentStatus status; + public Document(String name, String url) { + this.name = name; + this.url = url; + } - public Document(Folder folder, String name, Integer size, Integer totalPage, DocumentStatus status) { - this.folder = folder; + public void validateDocument(Long folderId) { + if (!this.folder.getId().equals(folderId)) { + throw new NotFoundException("해당 폴더 내에 존재하지 않는 자료입니다."); + } + } + + public void updateName(String name) { this.name = name; - this.size = size; - this.totalPage = totalPage; - this.status = status; } } diff --git a/src/main/java/notai/document/domain/DocumentRepository.java b/src/main/java/notai/document/domain/DocumentRepository.java index 5258454..803dd1b 100644 --- a/src/main/java/notai/document/domain/DocumentRepository.java +++ b/src/main/java/notai/document/domain/DocumentRepository.java @@ -1,7 +1,18 @@ package notai.document.domain; +import notai.common.exception.type.NotFoundException; import notai.document.query.DocumentQueryRepository; +import notai.folder.domain.Folder; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + public interface DocumentRepository extends JpaRepository, DocumentQueryRepository { + default Document getById(Long id) { + return findById(id).orElseThrow(() -> new NotFoundException("자료를 찾을 수 없습니다.")); + } + + List findAllByFolderId(Long folderId); + + void deleteAllByFolder(Folder folder); } diff --git a/src/main/java/notai/document/domain/DocumentStatus.java b/src/main/java/notai/document/domain/DocumentStatus.java deleted file mode 100644 index 3ae523e..0000000 --- a/src/main/java/notai/document/domain/DocumentStatus.java +++ /dev/null @@ -1,5 +0,0 @@ -package notai.document.domain; - -public enum DocumentStatus { - EXISTS, GARBAGE -} diff --git a/src/main/java/notai/document/presentation/DocumentController.java b/src/main/java/notai/document/presentation/DocumentController.java index 231a640..2356163 100644 --- a/src/main/java/notai/document/presentation/DocumentController.java +++ b/src/main/java/notai/document/presentation/DocumentController.java @@ -1,11 +1,73 @@ package notai.document.presentation; import lombok.RequiredArgsConstructor; +import notai.document.application.DocumentQueryService; +import notai.document.application.DocumentService; +import notai.document.application.result.DocumentFindResult; +import notai.document.application.result.DocumentSaveResult; +import notai.document.application.result.DocumentUpdateResult; +import notai.document.presentation.request.DocumentSaveRequest; +import notai.document.presentation.request.DocumentUpdateRequest; +import notai.document.presentation.response.DocumentFindResponse; +import notai.document.presentation.response.DocumentSaveResponse; +import notai.document.presentation.response.DocumentUpdateResponse; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.net.URI; +import java.util.List; @Controller -@RequestMapping("/api/documents") +@RequestMapping("/api/folders/{folderId}/documents") @RequiredArgsConstructor public class DocumentController { + + private final DocumentService documentService; + private final DocumentQueryService documentQueryService; + + @PostMapping(consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}) + public ResponseEntity saveDocument( + @PathVariable Long folderId, + @RequestPart MultipartFile pdfFile, + @RequestPart DocumentSaveRequest documentSaveRequest + ) { + DocumentSaveResult documentSaveResult; + if (folderId.equals(-1L)) { + documentSaveResult = documentService.saveRootDocument(pdfFile, documentSaveRequest); + } else { + documentSaveResult = documentService.saveDocument(folderId, pdfFile, documentSaveRequest); + } + DocumentSaveResponse response = DocumentSaveResponse.from(documentSaveResult); + String url = String.format("/api/folders/%s/documents/%s", folderId, response.id()); + return ResponseEntity.created(URI.create(url)).body(response); + } + + @PutMapping(value = "/{id}") + public ResponseEntity updateDocument( + @PathVariable Long folderId, @PathVariable Long id, @RequestBody DocumentUpdateRequest documentUpdateRequest + ) { + DocumentUpdateResult documentUpdateResult = documentService.updateDocument(folderId, id, documentUpdateRequest); + DocumentUpdateResponse response = DocumentUpdateResponse.from(documentUpdateResult); + return ResponseEntity.ok(response); + } + + @GetMapping + public ResponseEntity> getDocuments( + @PathVariable Long folderId + ) { + List 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 + ) { + documentService.deleteDocument(folderId, id); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/notai/document/presentation/PdfController.java b/src/main/java/notai/document/presentation/PdfController.java new file mode 100644 index 0000000..0b70edf --- /dev/null +++ b/src/main/java/notai/document/presentation/PdfController.java @@ -0,0 +1,30 @@ +package notai.document.presentation; + +import lombok.RequiredArgsConstructor; +import notai.document.application.PdfService; +import org.springframework.core.io.FileSystemResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.io.File; + +@Controller +@RequestMapping("/pdf") +@RequiredArgsConstructor +public class PdfController { + + private final PdfService pdfService; + + @GetMapping("/{fileName}") + public ResponseEntity getPdf(@PathVariable String fileName) { + File pdf = pdfService.getPdf(fileName); + FileSystemResource pdfResource = new FileSystemResource(pdf); + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=" + fileName).contentType( + MediaType.APPLICATION_PDF).body(pdfResource); + } +} diff --git a/src/main/java/notai/document/presentation/request/DocumentSaveRequest.java b/src/main/java/notai/document/presentation/request/DocumentSaveRequest.java new file mode 100644 index 0000000..0dd2bc0 --- /dev/null +++ b/src/main/java/notai/document/presentation/request/DocumentSaveRequest.java @@ -0,0 +1,6 @@ +package notai.document.presentation.request; + +public record DocumentSaveRequest( + String name +) { +} diff --git a/src/main/java/notai/document/presentation/request/DocumentUpdateRequest.java b/src/main/java/notai/document/presentation/request/DocumentUpdateRequest.java new file mode 100644 index 0000000..c4413fe --- /dev/null +++ b/src/main/java/notai/document/presentation/request/DocumentUpdateRequest.java @@ -0,0 +1,6 @@ +package notai.document.presentation.request; + +public record DocumentUpdateRequest( + String name +) { +} diff --git a/src/main/java/notai/document/presentation/response/DocumentFindResponse.java b/src/main/java/notai/document/presentation/response/DocumentFindResponse.java new file mode 100644 index 0000000..b588780 --- /dev/null +++ b/src/main/java/notai/document/presentation/response/DocumentFindResponse.java @@ -0,0 +1,13 @@ +package notai.document.presentation.response; + +import notai.document.application.result.DocumentFindResult; + +public record DocumentFindResponse( + Long id, + String name, + String url +) { + public static DocumentFindResponse from(DocumentFindResult documentFindResult) { + return new DocumentFindResponse(documentFindResult.id(), documentFindResult.name(), documentFindResult.url()); + } +} diff --git a/src/main/java/notai/document/presentation/response/DocumentSaveResponse.java b/src/main/java/notai/document/presentation/response/DocumentSaveResponse.java new file mode 100644 index 0000000..7158c65 --- /dev/null +++ b/src/main/java/notai/document/presentation/response/DocumentSaveResponse.java @@ -0,0 +1,13 @@ +package notai.document.presentation.response; + +import notai.document.application.result.DocumentSaveResult; + +public record DocumentSaveResponse( + Long id, + String name, + String url +) { + public static DocumentSaveResponse from(DocumentSaveResult documentSaveResult) { + return new DocumentSaveResponse(documentSaveResult.id(), documentSaveResult.name(), documentSaveResult.url()); + } +} diff --git a/src/main/java/notai/document/presentation/response/DocumentUpdateResponse.java b/src/main/java/notai/document/presentation/response/DocumentUpdateResponse.java new file mode 100644 index 0000000..0b16507 --- /dev/null +++ b/src/main/java/notai/document/presentation/response/DocumentUpdateResponse.java @@ -0,0 +1,17 @@ +package notai.document.presentation.response; + +import notai.document.application.result.DocumentUpdateResult; + +public record DocumentUpdateResponse( + Long id, + String name, + String url +) { + public static DocumentUpdateResponse from(DocumentUpdateResult documentUpdateResult) { + return new DocumentUpdateResponse( + documentUpdateResult.id(), + documentUpdateResult.name(), + documentUpdateResult.url() + ); + } +} diff --git a/src/main/java/notai/folder/application/FolderQueryService.java b/src/main/java/notai/folder/application/FolderQueryService.java index b51c863..1a3a830 100644 --- a/src/main/java/notai/folder/application/FolderQueryService.java +++ b/src/main/java/notai/folder/application/FolderQueryService.java @@ -1,9 +1,34 @@ package notai.folder.application; import lombok.RequiredArgsConstructor; +import notai.folder.application.result.FolderFindResult; +import notai.folder.domain.Folder; +import notai.folder.domain.FolderRepository; import org.springframework.stereotype.Service; +import java.util.List; + @Service @RequiredArgsConstructor public class FolderQueryService { + + private final FolderRepository folderRepository; + + public List getFolders(Long memberId, Long parentFolderId) { + List folders = getFoldersWithMemberAndParent(memberId, parentFolderId); + // document read + return folders.stream().map(this::getFolderResult).toList(); + } + + private List getFoldersWithMemberAndParent(Long memberId, Long parentFolderId) { + if (parentFolderId == null) { + return folderRepository.findAllByMemberIdAndParentFolderIsNull(memberId); + } + return folderRepository.findAllByMemberIdAndParentFolderId(memberId, parentFolderId); + } + + private FolderFindResult getFolderResult(Folder folder) { + Long parentFolderId = folder.getParentFolder() != null ? folder.getParentFolder().getId() : null; + return FolderFindResult.of(folder.getId(), parentFolderId, folder.getName()); + } } diff --git a/src/main/java/notai/folder/application/FolderService.java b/src/main/java/notai/folder/application/FolderService.java index 74ca5c6..c6fcf60 100644 --- a/src/main/java/notai/folder/application/FolderService.java +++ b/src/main/java/notai/folder/application/FolderService.java @@ -1,9 +1,76 @@ package notai.folder.application; import lombok.RequiredArgsConstructor; +import notai.document.application.DocumentService; +import notai.folder.application.result.FolderMoveResult; +import notai.folder.application.result.FolderSaveResult; +import notai.folder.domain.Folder; +import notai.folder.domain.FolderRepository; +import notai.folder.presentation.request.FolderMoveRequest; +import notai.folder.presentation.request.FolderSaveRequest; +import notai.member.domain.Member; +import notai.member.domain.MemberRepository; import org.springframework.stereotype.Service; +import java.util.List; + @Service @RequiredArgsConstructor public class FolderService { + + private final FolderRepository folderRepository; + private final MemberRepository memberRepository; + private final DocumentService documentService; + + public FolderSaveResult saveRootFolder(Long memberId, FolderSaveRequest folderSaveRequest) { + Member member = memberRepository.getById(memberId); + Folder folder = new Folder(member, folderSaveRequest.name()); + Folder savedFolder = folderRepository.save(folder); + return getFolderSaveResult(savedFolder); + } + + public FolderSaveResult saveSubFolder(Long memberId, FolderSaveRequest folderSaveRequest) { + Member member = memberRepository.getById(memberId); + Folder parentFolder = folderRepository.getById(folderSaveRequest.parentFolderId()); + Folder folder = new Folder(member, folderSaveRequest.name(), parentFolder); + Folder savedFolder = folderRepository.save(folder); + return getFolderSaveResult(savedFolder); + } + + public FolderMoveResult moveRootFolder(Long memberId, Long id) { + Folder folder = folderRepository.getById(id); + folder.validateOwner(memberId); + folder.moveRootFolder(); + folderRepository.save(folder); + return getFolderMoveResult(folder); + } + + public FolderMoveResult moveNewParentFolder(Long memberId, Long id, FolderMoveRequest folderMoveRequest) { + Folder folder = folderRepository.getById(id); + Folder parentFolder = folderRepository.getById(folderMoveRequest.targetFolderId()); + folder.validateOwner(memberId); + folder.moveNewParentFolder(parentFolder); + folderRepository.save(folder); + return getFolderMoveResult(folder); + } + + public void deleteFolder(Long memberId, Long id) { + Folder folder = folderRepository.getById(id); + folder.validateOwner(memberId); + List subFolders = folderRepository.findAllByParentFolder(folder); + for (Folder subFolder : subFolders) { + deleteFolder(memberId, subFolder.getId()); + } + documentService.deleteAllByFolder(folder); + folderRepository.delete(folder); + } + + private FolderSaveResult getFolderSaveResult(Folder folder) { + Long parentFolderId = folder.getParentFolder() != null ? folder.getParentFolder().getId() : null; + return FolderSaveResult.of(folder.getId(), parentFolderId, folder.getName()); + } + + private FolderMoveResult getFolderMoveResult(Folder folder) { + return FolderMoveResult.of(folder.getId(), folder.getName()); + } } diff --git a/src/main/java/notai/folder/application/result/FolderFindResult.java b/src/main/java/notai/folder/application/result/FolderFindResult.java new file mode 100644 index 0000000..3014ac0 --- /dev/null +++ b/src/main/java/notai/folder/application/result/FolderFindResult.java @@ -0,0 +1,11 @@ +package notai.folder.application.result; + +public record FolderFindResult( + Long id, + Long parentId, + String name +) { + public static FolderFindResult of(Long id, Long parentId, String name) { + return new FolderFindResult(id, parentId, name); + } +} diff --git a/src/main/java/notai/folder/application/result/FolderMoveResult.java b/src/main/java/notai/folder/application/result/FolderMoveResult.java new file mode 100644 index 0000000..4004836 --- /dev/null +++ b/src/main/java/notai/folder/application/result/FolderMoveResult.java @@ -0,0 +1,10 @@ +package notai.folder.application.result; + +public record FolderMoveResult( + Long id, + String name +) { + public static FolderMoveResult of(Long id, String name) { + return new FolderMoveResult(id, name); + } +} diff --git a/src/main/java/notai/folder/application/result/FolderSaveResult.java b/src/main/java/notai/folder/application/result/FolderSaveResult.java new file mode 100644 index 0000000..bb01f50 --- /dev/null +++ b/src/main/java/notai/folder/application/result/FolderSaveResult.java @@ -0,0 +1,11 @@ +package notai.folder.application.result; + +public record FolderSaveResult( + Long id, + Long parentId, + String name +) { + public static FolderSaveResult of(Long id, Long parentId, String name) { + return new FolderSaveResult(id, parentId, name); + } +} diff --git a/src/main/java/notai/folder/domain/Folder.java b/src/main/java/notai/folder/domain/Folder.java index f6367e8..84458bb 100644 --- a/src/main/java/notai/folder/domain/Folder.java +++ b/src/main/java/notai/folder/domain/Folder.java @@ -7,6 +7,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import notai.common.domain.RootEntity; +import notai.common.exception.type.NotFoundException; import notai.member.domain.Member; @Entity @@ -20,7 +21,7 @@ public class Folder extends RootEntity { private Long id; @NotNull - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) private Member member; @@ -50,4 +51,10 @@ public void moveRootFolder() { public void moveNewParentFolder(Folder parentFolder) { this.parentFolder = parentFolder; } + + public void validateOwner(Long memberId) { + if (!this.member.getId().equals(memberId)) { + throw new NotFoundException("해당 이용자가 보유한 폴더 중 이 폴더가 존재하지 않습니다."); + } + } } diff --git a/src/main/java/notai/folder/domain/FolderRepository.java b/src/main/java/notai/folder/domain/FolderRepository.java index 40a2231..d1fbd3c 100644 --- a/src/main/java/notai/folder/domain/FolderRepository.java +++ b/src/main/java/notai/folder/domain/FolderRepository.java @@ -1,7 +1,18 @@ package notai.folder.domain; -import notai.folder.query.FolderQueryRepository; +import notai.common.exception.type.NotFoundException; import org.springframework.data.jpa.repository.JpaRepository; -public interface FolderRepository extends JpaRepository, FolderQueryRepository { +import java.util.List; + +public interface FolderRepository extends JpaRepository { + default Folder getById(Long id) { + return findById(id).orElseThrow(() -> new NotFoundException("폴더 정보를 찾을 수 없습니다.")); + } + + List findAllByMemberIdAndParentFolderIsNull(Long memberId); + + List findAllByMemberIdAndParentFolderId(Long memberId, Long parentFolderId); + + List findAllByParentFolder(Folder parentFolder); } diff --git a/src/main/java/notai/folder/presentation/FolderController.java b/src/main/java/notai/folder/presentation/FolderController.java index 0c0383c..2f503dc 100644 --- a/src/main/java/notai/folder/presentation/FolderController.java +++ b/src/main/java/notai/folder/presentation/FolderController.java @@ -1,10 +1,24 @@ package notai.folder.presentation; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import notai.auth.Auth; import notai.folder.application.FolderQueryService; import notai.folder.application.FolderService; +import notai.folder.application.result.FolderFindResult; +import notai.folder.application.result.FolderMoveResult; +import notai.folder.application.result.FolderSaveResult; +import notai.folder.presentation.request.FolderMoveRequest; +import notai.folder.presentation.request.FolderSaveRequest; +import notai.folder.presentation.response.FolderFindResponse; +import notai.folder.presentation.response.FolderMoveResponse; +import notai.folder.presentation.response.FolderSaveResponse; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; +import java.util.List; @Controller @RequestMapping("/api/folders") @@ -13,4 +27,53 @@ public class FolderController { private final FolderService folderService; private final FolderQueryService folderQueryService; + + @PostMapping + public ResponseEntity saveFolder( + @Auth Long memberId, @Valid @RequestBody FolderSaveRequest folderSaveRequest + ) { + FolderSaveResult folderResult = saveFolderResult(memberId, folderSaveRequest); + FolderSaveResponse response = FolderSaveResponse.from(folderResult); + return ResponseEntity.created(URI.create("/api/folders/" + response.id())).body(response); + } + + @PostMapping("/{id}/move") + public ResponseEntity moveFolder( + @Auth Long memberId, @PathVariable Long id, @Valid @RequestBody FolderMoveRequest folderMoveRequest + ) { + FolderMoveResult folderResult = moveFolderWithRequest(memberId, id, folderMoveRequest); + FolderMoveResponse response = FolderMoveResponse.from(folderResult); + return ResponseEntity.ok(response); + } + + @GetMapping + public ResponseEntity> getFolders( + @Auth Long memberId, @RequestParam(required = false) Long parentFolderId + ) { + List folderResults = folderQueryService.getFolders(memberId, parentFolderId); + List response = folderResults.stream().map(FolderFindResponse::from).toList(); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteFolder( + @Auth Long memberId, @PathVariable Long id + ) { + folderService.deleteFolder(memberId, id); + return ResponseEntity.noContent().build(); + } + + private FolderSaveResult saveFolderResult(Long memberId, FolderSaveRequest folderSaveRequest) { + if (folderSaveRequest.parentFolderId() != null) { + return folderService.saveSubFolder(memberId, folderSaveRequest); + } + return folderService.saveRootFolder(memberId, folderSaveRequest); + } + + private FolderMoveResult moveFolderWithRequest(Long memberId, Long id, FolderMoveRequest folderMoveRequest) { + if (folderMoveRequest.targetFolderId() != null) { + return folderService.moveNewParentFolder(memberId, id, folderMoveRequest); + } + return folderService.moveRootFolder(memberId, id); + } } diff --git a/src/main/java/notai/folder/presentation/request/FolderMoveRequest.java b/src/main/java/notai/folder/presentation/request/FolderMoveRequest.java new file mode 100644 index 0000000..a1f6ff3 --- /dev/null +++ b/src/main/java/notai/folder/presentation/request/FolderMoveRequest.java @@ -0,0 +1,6 @@ +package notai.folder.presentation.request; + +public record FolderMoveRequest( + Long targetFolderId +) { +} diff --git a/src/main/java/notai/folder/presentation/request/FolderSaveRequest.java b/src/main/java/notai/folder/presentation/request/FolderSaveRequest.java new file mode 100644 index 0000000..b16f7f9 --- /dev/null +++ b/src/main/java/notai/folder/presentation/request/FolderSaveRequest.java @@ -0,0 +1,7 @@ +package notai.folder.presentation.request; + +public record FolderSaveRequest( + Long parentFolderId, + String name +) { +} diff --git a/src/main/java/notai/folder/presentation/response/FolderFindResponse.java b/src/main/java/notai/folder/presentation/response/FolderFindResponse.java new file mode 100644 index 0000000..8d0a687 --- /dev/null +++ b/src/main/java/notai/folder/presentation/response/FolderFindResponse.java @@ -0,0 +1,13 @@ +package notai.folder.presentation.response; + +import notai.folder.application.result.FolderFindResult; + +public record FolderFindResponse( + Long id, + Long parentId, + String name +) { + public static FolderFindResponse from(FolderFindResult folderFindResult) { + return new FolderFindResponse(folderFindResult.id(), folderFindResult.parentId(), folderFindResult.name()); + } +} diff --git a/src/main/java/notai/folder/presentation/response/FolderMoveResponse.java b/src/main/java/notai/folder/presentation/response/FolderMoveResponse.java new file mode 100644 index 0000000..0801a69 --- /dev/null +++ b/src/main/java/notai/folder/presentation/response/FolderMoveResponse.java @@ -0,0 +1,12 @@ +package notai.folder.presentation.response; + +import notai.folder.application.result.FolderMoveResult; + +public record FolderMoveResponse( + Long id, + String name +) { + public static FolderMoveResponse from(FolderMoveResult folderMoveResult) { + return new FolderMoveResponse(folderMoveResult.id(), folderMoveResult.name()); + } +} diff --git a/src/main/java/notai/folder/presentation/response/FolderSaveResponse.java b/src/main/java/notai/folder/presentation/response/FolderSaveResponse.java new file mode 100644 index 0000000..cfc552f --- /dev/null +++ b/src/main/java/notai/folder/presentation/response/FolderSaveResponse.java @@ -0,0 +1,13 @@ +package notai.folder.presentation.response; + +import notai.folder.application.result.FolderSaveResult; + +public record FolderSaveResponse( + Long id, + Long parentId, + String name +) { + public static FolderSaveResponse from(FolderSaveResult folderSaveResult) { + return new FolderSaveResponse(folderSaveResult.id(), folderSaveResult.parentId(), folderSaveResult.name()); + } +} diff --git a/src/main/java/notai/folder/query/FolderQueryRepository.java b/src/main/java/notai/folder/query/FolderQueryRepository.java deleted file mode 100644 index 93bdaee..0000000 --- a/src/main/java/notai/folder/query/FolderQueryRepository.java +++ /dev/null @@ -1,4 +0,0 @@ -package notai.folder.query; - -public interface FolderQueryRepository { -} diff --git a/src/main/java/notai/folder/query/FolderQueryRepositoryImpl.java b/src/main/java/notai/folder/query/FolderQueryRepositoryImpl.java deleted file mode 100644 index c7b7681..0000000 --- a/src/main/java/notai/folder/query/FolderQueryRepositoryImpl.java +++ /dev/null @@ -1,10 +0,0 @@ -package notai.folder.query; - -import com.querydsl.jpa.impl.JPAQueryFactory; -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -public class FolderQueryRepositoryImpl implements FolderQueryRepository { - - private final JPAQueryFactory jpaQueryFactory; -} diff --git a/src/main/java/notai/llm/application/LLMQueryService.java b/src/main/java/notai/llm/application/LLMQueryService.java index 2858c2a..74924e7 100644 --- a/src/main/java/notai/llm/application/LLMQueryService.java +++ b/src/main/java/notai/llm/application/LLMQueryService.java @@ -1,7 +1,6 @@ package notai.llm.application; import lombok.RequiredArgsConstructor; -import notai.common.exception.type.BadRequestException; import notai.common.exception.type.InternalServerErrorException; import notai.common.exception.type.NotFoundException; import notai.document.domain.DocumentRepository; @@ -80,7 +79,7 @@ private static void checkSummaryAndProblemCountsEqual( private List getSummaryIds(Long documentId) { List summaryIds = summaryQueryRepository.getSummaryIdsByDocumentId(documentId); if (summaryIds.isEmpty()) { - throw new BadRequestException("AI 기능을 요청한 기록이 없습니다."); + throw new NotFoundException("AI 기능을 요청한 기록이 없습니다."); } return summaryIds; } @@ -103,8 +102,10 @@ private List getProblemPageContentResults(Long documen } private String findProblemContentByPageNumber(List results, int pageNumber) { - return results.stream().filter(result -> result.pageNumber() == pageNumber).findFirst().map( - ProblemPageContentResult::content).orElseThrow(() -> new InternalServerErrorException( - "AI 요약 및 문제 생성 중에 문제가 발생했습니다.")); // 요약 페이지와 문제 페이지가 불일치 + return results.stream() + .filter(result -> result.pageNumber() == pageNumber) + .findFirst() + .map(ProblemPageContentResult::content) + .orElseThrow(() -> new InternalServerErrorException("AI 요약 및 문제 생성 중에 문제가 발생했습니다.")); // 요약 페이지와 문제 페이지가 불일치 } } diff --git a/src/main/java/notai/llm/application/LLMService.java b/src/main/java/notai/llm/application/LLMService.java index d6e529e..d8a395f 100644 --- a/src/main/java/notai/llm/application/LLMService.java +++ b/src/main/java/notai/llm/application/LLMService.java @@ -1,7 +1,6 @@ package notai.llm.application; import lombok.RequiredArgsConstructor; -import notai.common.exception.type.NotFoundException; import notai.document.domain.Document; import notai.document.domain.DocumentRepository; import notai.llm.application.command.LLMSubmitCommand; @@ -35,9 +34,7 @@ public class LLMService { private final ProblemRepository problemRepository; public LLMSubmitResult submitTask(LLMSubmitCommand command) { - // TODO: document 개발 코드 올려주시면, getById 로 수정 - Document foundDocument = - documentRepository.findById(command.documentId()).orElseThrow(() -> new NotFoundException("")); + Document foundDocument = documentRepository.getById(command.documentId()); command.pages().forEach(pageNumber -> { UUID taskId = sendRequestToAIServer(); diff --git a/src/main/java/notai/llm/application/result/LLMStatusResult.java b/src/main/java/notai/llm/application/result/LLMStatusResult.java index a9a3768..158e099 100644 --- a/src/main/java/notai/llm/application/result/LLMStatusResult.java +++ b/src/main/java/notai/llm/application/result/LLMStatusResult.java @@ -8,7 +8,9 @@ public record LLMStatusResult( Integer totalPages, Integer completedPages ) { - public static LLMStatusResult of(Long documentId, TaskStatus overallStatus, Integer totalPages, Integer completedPages) { + public static LLMStatusResult of( + Long documentId, TaskStatus overallStatus, Integer totalPages, Integer completedPages + ) { return new LLMStatusResult(documentId, overallStatus, totalPages, completedPages); } } diff --git a/src/main/java/notai/llm/domain/TaskStatus.java b/src/main/java/notai/llm/domain/TaskStatus.java index be44ed8..aa0b6dd 100644 --- a/src/main/java/notai/llm/domain/TaskStatus.java +++ b/src/main/java/notai/llm/domain/TaskStatus.java @@ -1,7 +1,5 @@ package notai.llm.domain; public enum TaskStatus { - PENDING, - IN_PROGRESS, - COMPLETED + PENDING, IN_PROGRESS, COMPLETED } diff --git a/src/main/java/notai/llm/presentation/LLMController.java b/src/main/java/notai/llm/presentation/LLMController.java index 69ad4ff..5d8a6e9 100644 --- a/src/main/java/notai/llm/presentation/LLMController.java +++ b/src/main/java/notai/llm/presentation/LLMController.java @@ -42,7 +42,7 @@ public ResponseEntity fetchTaskStatus(@PathVariable("document @GetMapping("/results/{documentId}") public ResponseEntity findTaskResult(@PathVariable("documentId") Long documentId) { LLMResultsResult result = llmQueryService.findTaskResult(documentId); - return ResponseEntity.ok(LLMResultsResponse.of(result)); + return ResponseEntity.ok(LLMResultsResponse.from(result)); } @PostMapping("/callback") diff --git a/src/main/java/notai/llm/presentation/request/LLMSubmitRequest.java b/src/main/java/notai/llm/presentation/request/LLMSubmitRequest.java index 8f78f1d..1226bb5 100644 --- a/src/main/java/notai/llm/presentation/request/LLMSubmitRequest.java +++ b/src/main/java/notai/llm/presentation/request/LLMSubmitRequest.java @@ -8,8 +8,7 @@ public record LLMSubmitRequest( - @NotNull(message = "문서 ID는 필수 입력 값입니다.") - Long documentId, + @NotNull(message = "문서 ID는 필수 입력 값입니다.") Long documentId, List<@Positive(message = "페이지 번호는 양수여야 합니다.") Integer> pages ) { diff --git a/src/main/java/notai/llm/presentation/response/LLMResultsResponse.java b/src/main/java/notai/llm/presentation/response/LLMResultsResponse.java index 535a1f2..4b7a688 100644 --- a/src/main/java/notai/llm/presentation/response/LLMResultsResponse.java +++ b/src/main/java/notai/llm/presentation/response/LLMResultsResponse.java @@ -11,11 +11,11 @@ public record LLMResultsResponse( Integer totalPages, List results ) { - public static LLMResultsResponse of(LLMResultsResult result) { + public static LLMResultsResponse from(LLMResultsResult result) { return new LLMResultsResponse( result.documentId(), result.results().size(), - result.results().stream().map(Result::of).toList() + result.results().stream().map(Result::from).toList() ); } @@ -23,8 +23,8 @@ public record Result( Integer pageNumber, Content content ) { - public static Result of(LLMResult result) { - return new Result(result.pageNumber(), Content.of(result.content())); + public static Result from(LLMResult result) { + return new Result(result.pageNumber(), Content.from(result.content())); } } @@ -32,7 +32,7 @@ public record Content( String summary, String problem ) { - public static Content of(LLMContent result) { + public static Content from(LLMContent result) { return new Content(result.summary(), result.problem()); } } diff --git a/src/main/java/notai/member/domain/Member.java b/src/main/java/notai/member/domain/Member.java index 2cbb807..90cb75a 100644 --- a/src/main/java/notai/member/domain/Member.java +++ b/src/main/java/notai/member/domain/Member.java @@ -1,14 +1,15 @@ package notai.member.domain; import jakarta.persistence.*; -import static jakarta.persistence.GenerationType.IDENTITY; import jakarta.validation.constraints.NotNull; -import static lombok.AccessLevel.PROTECTED; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import notai.common.domain.RootEntity; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + @Entity @Table(name = "member") @Getter diff --git a/src/main/java/notai/member/domain/OauthId.java b/src/main/java/notai/member/domain/OauthId.java index b1765fd..cb2bf87 100644 --- a/src/main/java/notai/member/domain/OauthId.java +++ b/src/main/java/notai/member/domain/OauthId.java @@ -2,14 +2,15 @@ import jakarta.persistence.Column; import jakarta.persistence.Embeddable; -import static jakarta.persistence.EnumType.STRING; import jakarta.persistence.Enumerated; import jakarta.validation.constraints.NotNull; -import static lombok.AccessLevel.PROTECTED; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; +import static jakarta.persistence.EnumType.STRING; +import static lombok.AccessLevel.PROTECTED; + @Getter @Embeddable @AllArgsConstructor diff --git a/src/main/java/notai/member/presentation/MemberController.java b/src/main/java/notai/member/presentation/MemberController.java index 2843b03..770b655 100644 --- a/src/main/java/notai/member/presentation/MemberController.java +++ b/src/main/java/notai/member/presentation/MemberController.java @@ -12,7 +12,7 @@ import notai.member.presentation.request.OauthLoginRequest; import notai.member.presentation.request.TokenRefreshRequest; import notai.member.presentation.response.MemberFindResponse; -import notai.member.presentation.response.MemberOauthLoginResopnse; +import notai.member.presentation.response.MemberOauthLoginResponse; import notai.member.presentation.response.MemberTokenRefreshResponse; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -28,13 +28,13 @@ public class MemberController { private final TokenService tokenService; @PostMapping("/oauth/login/{oauthProvider}") - public ResponseEntity loginWithOauth( + public ResponseEntity loginWithOauth( @PathVariable(value = "oauthProvider") OauthProvider oauthProvider, @RequestBody OauthLoginRequest request ) { Member member = oauthClient.fetchMember(oauthProvider, request.oauthAccessToken()); Long memberId = memberService.login(member); TokenPair tokenPair = tokenService.createTokenPair(memberId); - return ResponseEntity.ok(MemberOauthLoginResopnse.from(tokenPair)); + return ResponseEntity.ok(MemberOauthLoginResponse.from(tokenPair)); } @PostMapping("/token/refresh") diff --git a/src/main/java/notai/member/presentation/response/MemberOauthLoginResopnse.java b/src/main/java/notai/member/presentation/response/MemberOauthLoginResopnse.java deleted file mode 100644 index 7655d6d..0000000 --- a/src/main/java/notai/member/presentation/response/MemberOauthLoginResopnse.java +++ /dev/null @@ -1,11 +0,0 @@ -package notai.member.presentation.response; - -import notai.auth.TokenPair; - -public record MemberOauthLoginResopnse( - String accessToken, String refreshToken -) { - public static MemberOauthLoginResopnse from(TokenPair tokenPair) { - return new MemberOauthLoginResopnse(tokenPair.accessToken(), tokenPair.refreshToken()); - } -} diff --git a/src/main/java/notai/member/presentation/response/MemberOauthLoginResponse.java b/src/main/java/notai/member/presentation/response/MemberOauthLoginResponse.java new file mode 100644 index 0000000..0713d11 --- /dev/null +++ b/src/main/java/notai/member/presentation/response/MemberOauthLoginResponse.java @@ -0,0 +1,12 @@ +package notai.member.presentation.response; + +import notai.auth.TokenPair; + +public record MemberOauthLoginResponse( + String accessToken, + String refreshToken +) { + public static MemberOauthLoginResponse from(TokenPair tokenPair) { + return new MemberOauthLoginResponse(tokenPair.accessToken(), tokenPair.refreshToken()); + } +} diff --git a/src/main/java/notai/member/presentation/response/MemberTokenRefreshResponse.java b/src/main/java/notai/member/presentation/response/MemberTokenRefreshResponse.java index b10b135..6273edd 100644 --- a/src/main/java/notai/member/presentation/response/MemberTokenRefreshResponse.java +++ b/src/main/java/notai/member/presentation/response/MemberTokenRefreshResponse.java @@ -3,7 +3,8 @@ import notai.auth.TokenPair; public record MemberTokenRefreshResponse( - String accessToken, String refreshToken + String accessToken, + String refreshToken ) { public static MemberTokenRefreshResponse from(TokenPair tokenPair) { return new MemberTokenRefreshResponse(tokenPair.accessToken(), tokenPair.refreshToken()); diff --git a/src/main/java/notai/pageRecording/application/PageRecordingService.java b/src/main/java/notai/pageRecording/application/PageRecordingService.java new file mode 100644 index 0000000..4baae7a --- /dev/null +++ b/src/main/java/notai/pageRecording/application/PageRecordingService.java @@ -0,0 +1,41 @@ +package notai.pageRecording.application; + +import lombok.RequiredArgsConstructor; +import notai.common.exception.type.NotFoundException; +import notai.pageRecording.application.command.PageRecordingSaveCommand; +import notai.pageRecording.domain.PageRecording; +import notai.pageRecording.domain.PageRecordingRepository; +import notai.recording.domain.Recording; +import notai.recording.domain.RecordingRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class PageRecordingService { + + private final PageRecordingRepository pageRecordingRepository; + private final RecordingRepository recordingRepository; + + public void savePageRecording(PageRecordingSaveCommand command) { + Recording foundRecording = recordingRepository.getById(command.recordingId()); + checkDocumentOwnershipOfRecording(command, foundRecording); + + command.sessions().forEach(session -> { + PageRecording pageRecording = new PageRecording( + foundRecording, + session.pageNumber(), + session.startTime(), + session.endTime() + ); + pageRecordingRepository.save(pageRecording); + }); + } + + private static void checkDocumentOwnershipOfRecording(PageRecordingSaveCommand command, Recording foundRecording) { + if (!foundRecording.isRecordingOwnedByDocument(command.documentId())) { + throw new NotFoundException("해당 녹음 파일을 찾을 수 없습니다."); + } + } +} diff --git a/src/main/java/notai/pageRecording/application/command/PageRecordingSaveCommand.java b/src/main/java/notai/pageRecording/application/command/PageRecordingSaveCommand.java new file mode 100644 index 0000000..e8397e4 --- /dev/null +++ b/src/main/java/notai/pageRecording/application/command/PageRecordingSaveCommand.java @@ -0,0 +1,16 @@ +package notai.pageRecording.application.command; + +import java.util.List; + +public record PageRecordingSaveCommand( + Long documentId, + Long recordingId, + List sessions +) { + public record PageRecordingSession( + Integer pageNumber, + Double startTime, + Double endTime + ) { + } +} diff --git a/src/main/java/notai/pageRecording/domain/PageRecording.java b/src/main/java/notai/pageRecording/domain/PageRecording.java new file mode 100644 index 0000000..288ae1f --- /dev/null +++ b/src/main/java/notai/pageRecording/domain/PageRecording.java @@ -0,0 +1,43 @@ +package notai.pageRecording.domain; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; +import notai.recording.domain.Recording; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +@Table(name = "page_recording") +public class PageRecording { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @NotNull + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "recording_id") + private Recording recording; + + @NotNull + private Integer pageNumber; + + @NotNull + private Double startTime; + + @NotNull + private Double endTime; + + public PageRecording(Recording recording, Integer pageNumber, Double startTime, Double endTime) { + this.recording = recording; + this.pageNumber = pageNumber; + this.startTime = startTime; + this.endTime = endTime; + } +} diff --git a/src/main/java/notai/pageRecording/domain/PageRecordingRepository.java b/src/main/java/notai/pageRecording/domain/PageRecordingRepository.java new file mode 100644 index 0000000..01e84fe --- /dev/null +++ b/src/main/java/notai/pageRecording/domain/PageRecordingRepository.java @@ -0,0 +1,7 @@ +package notai.pageRecording.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PageRecordingRepository extends JpaRepository { + +} diff --git a/src/main/java/notai/pageRecording/presentation/PageRecordingController.java b/src/main/java/notai/pageRecording/presentation/PageRecordingController.java new file mode 100644 index 0000000..a5cfc4c --- /dev/null +++ b/src/main/java/notai/pageRecording/presentation/PageRecordingController.java @@ -0,0 +1,25 @@ +package notai.pageRecording.presentation; + +import lombok.RequiredArgsConstructor; +import notai.pageRecording.application.PageRecordingService; +import notai.pageRecording.application.command.PageRecordingSaveCommand; +import notai.pageRecording.presentation.request.PageRecordingSaveRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/documents/{documentId}/recordings/page-turns") +@RequiredArgsConstructor +public class PageRecordingController { + + private final PageRecordingService pageRecordingService; + + @PostMapping + public ResponseEntity savePageRecording( + @PathVariable("documentId") Long documentId, @RequestBody PageRecordingSaveRequest request + ) { + PageRecordingSaveCommand command = request.toCommand(documentId); + pageRecordingService.savePageRecording(command); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/notai/pageRecording/presentation/request/PageRecordingSaveRequest.java b/src/main/java/notai/pageRecording/presentation/request/PageRecordingSaveRequest.java new file mode 100644 index 0000000..1cb18c5 --- /dev/null +++ b/src/main/java/notai/pageRecording/presentation/request/PageRecordingSaveRequest.java @@ -0,0 +1,32 @@ +package notai.pageRecording.presentation.request; + +import notai.pageRecording.application.command.PageRecordingSaveCommand; + +import java.util.List; +import java.util.stream.IntStream; + +public record PageRecordingSaveRequest( + Long recordingId, + List events +) { + public PageRecordingSaveCommand toCommand(Long documentId) { + return new PageRecordingSaveCommand( + documentId, + recordingId, + IntStream.range(0, events.size()) + .mapToObj(i -> new PageRecordingSaveCommand.PageRecordingSession( + events.get(i).nextPage(), + events.get(i).timestamp(), // 페이지 넘김 이벤트가 발생했을 때의 시간을 페이지별 녹음의 시작 시간으로 둡니다. + (i < events.size() - 1) ? events.get(i + 1).timestamp() : null // 다음 페이지 넘김 이벤트가 발생했을 때의 시간을 끝 시간으로 둡니다. 마지막 페이지의 끝 시간은 null 입니다. + )) + .toList() + ); + } + + public record PageTurnEvent( + Integer prevPage, + Integer nextPage, + Double timestamp + ) { + } +} diff --git a/src/main/java/notai/pageRecording/query/PageRecordingQueryRepository.java b/src/main/java/notai/pageRecording/query/PageRecordingQueryRepository.java new file mode 100644 index 0000000..fa6e7c1 --- /dev/null +++ b/src/main/java/notai/pageRecording/query/PageRecordingQueryRepository.java @@ -0,0 +1,12 @@ +package notai.pageRecording.query; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class PageRecordingQueryRepository { + + private final JPAQueryFactory queryFactory; +} diff --git a/src/main/java/notai/post/domain/Post.java b/src/main/java/notai/post/domain/Post.java index 9202ab6..44e18b1 100644 --- a/src/main/java/notai/post/domain/Post.java +++ b/src/main/java/notai/post/domain/Post.java @@ -1,15 +1,16 @@ package notai.post.domain; import jakarta.persistence.*; -import static jakarta.persistence.FetchType.LAZY; -import static jakarta.persistence.GenerationType.IDENTITY; import jakarta.validation.constraints.NotNull; -import static lombok.AccessLevel.PROTECTED; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import notai.member.domain.Member; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + @Getter @NoArgsConstructor(access = PROTECTED) @AllArgsConstructor diff --git a/src/main/java/notai/problem/query/ProblemQueryRepository.java b/src/main/java/notai/problem/query/ProblemQueryRepository.java index 615c321..e1a51f0 100644 --- a/src/main/java/notai/problem/query/ProblemQueryRepository.java +++ b/src/main/java/notai/problem/query/ProblemQueryRepository.java @@ -35,8 +35,7 @@ public List getPageNumbersAndContentByDocumentId(Long problem.content )) .from(problem) - .where(problem.document.id.eq(documentId) - .and(problem.content.isNotNull())) + .where(problem.document.id.eq(documentId).and(problem.content.isNotNull())) .fetch(); } } diff --git a/src/main/java/notai/recording/application/RecordingService.java b/src/main/java/notai/recording/application/RecordingService.java new file mode 100644 index 0000000..3098466 --- /dev/null +++ b/src/main/java/notai/recording/application/RecordingService.java @@ -0,0 +1,60 @@ +package notai.recording.application; + +import lombok.RequiredArgsConstructor; +import notai.common.domain.vo.FilePath; +import notai.common.exception.type.BadRequestException; +import notai.common.exception.type.InternalServerErrorException; +import notai.common.utils.AudioDecoder; +import notai.common.utils.FileManager; +import notai.document.domain.Document; +import notai.document.domain.DocumentRepository; +import notai.recording.application.command.RecordingSaveCommand; +import notai.recording.application.result.RecordingSaveResult; +import notai.recording.domain.Recording; +import notai.recording.domain.RecordingRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; + +@Service +@Transactional +@RequiredArgsConstructor +public class RecordingService { + + private final RecordingRepository recordingRepository; + private final DocumentRepository documentRepository; + private final AudioDecoder audioDecoder; + private final FileManager fileManager; + + @Value("${file.audio.basePath}") + private String audioBasePath; + + public RecordingSaveResult saveRecording(RecordingSaveCommand command) { + Document foundDocument = documentRepository.getById(command.documentId()); + + Recording recording = new Recording(foundDocument); + Recording savedRecording = recordingRepository.save(recording); + + FilePath filePath = + FilePath.from(audioBasePath + foundDocument.getName() + "_" + savedRecording.getId() + ".mp3"); + + try { + byte[] binaryAudioData = audioDecoder.decode(command.audioData()); + Path outputPath = Paths.get(filePath.getFilePath()); + + fileManager.save(binaryAudioData, outputPath); + savedRecording.updateFilePath(filePath); + + return RecordingSaveResult.of(savedRecording.getId(), foundDocument.getId(), savedRecording.getCreatedAt()); + + } catch (IllegalArgumentException e) { + throw new BadRequestException("오디오 파일이 잘못되었습니다."); + } catch (IOException e) { + throw new InternalServerErrorException("녹음 파일 저장 중 오류가 발생했습니다."); // TODO: 재시도 로직 추가? + } + } +} diff --git a/src/main/java/notai/recording/application/command/RecordingSaveCommand.java b/src/main/java/notai/recording/application/command/RecordingSaveCommand.java new file mode 100644 index 0000000..e0270e4 --- /dev/null +++ b/src/main/java/notai/recording/application/command/RecordingSaveCommand.java @@ -0,0 +1,7 @@ +package notai.recording.application.command; + +public record RecordingSaveCommand( + Long documentId, + String audioData +) { +} diff --git a/src/main/java/notai/recording/application/result/RecordingSaveResult.java b/src/main/java/notai/recording/application/result/RecordingSaveResult.java new file mode 100644 index 0000000..29c2ece --- /dev/null +++ b/src/main/java/notai/recording/application/result/RecordingSaveResult.java @@ -0,0 +1,13 @@ +package notai.recording.application.result; + +import java.time.LocalDateTime; + +public record RecordingSaveResult( + Long recordingId, + Long documentId, + LocalDateTime createdAt +) { + public static RecordingSaveResult of(Long recordingId, Long documentId, LocalDateTime createdAt) { + return new RecordingSaveResult(recordingId, documentId, createdAt); + } +} diff --git a/src/main/java/notai/recording/domain/Recording.java b/src/main/java/notai/recording/domain/Recording.java new file mode 100644 index 0000000..64a8c3b --- /dev/null +++ b/src/main/java/notai/recording/domain/Recording.java @@ -0,0 +1,43 @@ +package notai.recording.domain; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; +import notai.common.domain.RootEntity; +import notai.common.domain.vo.FilePath; +import notai.document.domain.Document; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +public class Recording extends RootEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @NotNull + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "document_id") + private Document document; + + @Embedded + private FilePath filePath; + + public Recording(Document document) { + this.document = document; + } + + public void updateFilePath(FilePath filePath) { + this.filePath = filePath; + } + + public boolean isRecordingOwnedByDocument(Long documentId) { + return this.document.getId().equals(documentId); + } +} diff --git a/src/main/java/notai/recording/domain/RecordingRepository.java b/src/main/java/notai/recording/domain/RecordingRepository.java new file mode 100644 index 0000000..1c667aa --- /dev/null +++ b/src/main/java/notai/recording/domain/RecordingRepository.java @@ -0,0 +1,10 @@ +package notai.recording.domain; + +import notai.common.exception.type.NotFoundException; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RecordingRepository extends JpaRepository { + default Recording getById(Long id) { + return findById(id).orElseThrow(() -> new NotFoundException("해당 녹음 정보를 찾을 수 없습니다.")); + } +} diff --git a/src/main/java/notai/recording/presentation/RecordingController.java b/src/main/java/notai/recording/presentation/RecordingController.java new file mode 100644 index 0000000..92e5de5 --- /dev/null +++ b/src/main/java/notai/recording/presentation/RecordingController.java @@ -0,0 +1,28 @@ +package notai.recording.presentation; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import notai.recording.application.RecordingService; +import notai.recording.application.command.RecordingSaveCommand; +import notai.recording.application.result.RecordingSaveResult; +import notai.recording.presentation.request.RecordingSaveRequest; +import notai.recording.presentation.response.RecordingSaveResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/documents/{documentId}/recordings") +@RequiredArgsConstructor +public class RecordingController { + + private final RecordingService recordingService; + + @PostMapping + public ResponseEntity saveRecording( + @PathVariable("documentId") Long documentId, @RequestBody @Valid RecordingSaveRequest request + ) { + RecordingSaveCommand command = request.toCommand(documentId); + RecordingSaveResult result = recordingService.saveRecording(command); + return ResponseEntity.ok(RecordingSaveResponse.from(result)); + } +} diff --git a/src/main/java/notai/recording/presentation/request/RecordingSaveRequest.java b/src/main/java/notai/recording/presentation/request/RecordingSaveRequest.java new file mode 100644 index 0000000..3322eba --- /dev/null +++ b/src/main/java/notai/recording/presentation/request/RecordingSaveRequest.java @@ -0,0 +1,15 @@ +package notai.recording.presentation.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import notai.recording.application.command.RecordingSaveCommand; + +public record RecordingSaveRequest( + @NotBlank String documentName, + + @Pattern(regexp = "data:audio/mpeg;base64,[a-zA-Z0-9+/=]+", message = "지원하지 않는 오디오 형식입니다.") String audioData +) { + public RecordingSaveCommand toCommand(Long documentId) { + return new RecordingSaveCommand(documentId, audioData); + } +} diff --git a/src/main/java/notai/recording/presentation/response/RecordingSaveResponse.java b/src/main/java/notai/recording/presentation/response/RecordingSaveResponse.java new file mode 100644 index 0000000..07645a8 --- /dev/null +++ b/src/main/java/notai/recording/presentation/response/RecordingSaveResponse.java @@ -0,0 +1,15 @@ +package notai.recording.presentation.response; + +import notai.recording.application.result.RecordingSaveResult; + +import java.time.LocalDateTime; + +public record RecordingSaveResponse( + Long recordingId, + Long documentId, + LocalDateTime createdAt +) { + public static RecordingSaveResponse from(RecordingSaveResult result) { + return new RecordingSaveResponse(result.recordingId(), result.documentId(), result.createdAt()); + } +} diff --git a/src/main/java/notai/summary/query/SummaryQueryRepository.java b/src/main/java/notai/summary/query/SummaryQueryRepository.java index b767161..e522293 100644 --- a/src/main/java/notai/summary/query/SummaryQueryRepository.java +++ b/src/main/java/notai/summary/query/SummaryQueryRepository.java @@ -3,8 +3,8 @@ import com.querydsl.core.types.Projections; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; -import notai.summary.query.result.SummaryPageContentResult; import notai.summary.domain.QSummary; +import notai.summary.query.result.SummaryPageContentResult; import org.springframework.stereotype.Repository; import java.util.List; @@ -35,8 +35,7 @@ public List getPageNumbersAndContentByDocumentId(Long summary.content )) .from(summary) - .where(summary.document.id.eq(documentId) - .and(summary.content.isNotNull())) + .where(summary.document.id.eq(documentId).and(summary.content.isNotNull())) .fetch(); } } diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 9f6d882..ba990f8 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -26,6 +26,9 @@ spring: hibernate: ddl-auto: create defer-datasource-initialization: true + mvc: + converters: + preferred-json-mapper: gson server: servlet: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d74c444..79a97f3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,7 @@ spring: profiles: active: local + +file: + audio: + basePath: src/main/resources/audio/ \ No newline at end of file diff --git a/src/test/java/notai/BackendApplicationTests.java b/src/test/java/notai/BackendApplicationTests.java index b50683a..e34c1e0 100644 --- a/src/test/java/notai/BackendApplicationTests.java +++ b/src/test/java/notai/BackendApplicationTests.java @@ -6,8 +6,8 @@ @SpringBootTest class BackendApplicationTests { - @Test - void contextLoads() { - } + @Test + void contextLoads() { + } } diff --git a/src/test/java/notai/annotation/AnnotationServiceTest.java b/src/test/java/notai/annotation/AnnotationServiceTest.java new file mode 100644 index 0000000..36d21f0 --- /dev/null +++ b/src/test/java/notai/annotation/AnnotationServiceTest.java @@ -0,0 +1,132 @@ +package notai.annotation; + +import notai.annotation.application.AnnotationQueryService; +import notai.annotation.application.AnnotationService; +import notai.annotation.presentation.AnnotationController; +import notai.annotation.presentation.request.CreateAnnotationRequest; +import notai.annotation.presentation.response.AnnotationResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + + +class AnnotationControllerTest { + + @Mock + private AnnotationService annotationService; + + @Mock + private AnnotationQueryService annotationQueryService; + + @InjectMocks + private AnnotationController annotationController; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + MockitoAnnotations.initMocks(this); + mockMvc = MockMvcBuilders.standaloneSetup(annotationController).build(); + } + + @Test + void testCreateAnnotation_success() throws Exception { + CreateAnnotationRequest request = new CreateAnnotationRequest(1, 100, 200, 300, 100, "굵은글씨"); + LocalDateTime now = LocalDateTime.now(); + AnnotationResponse response = new AnnotationResponse(1L, 1L, 1, 100, 200, 300, 100, "굵은글씨", now.toString(), now.toString()); + + when(annotationService.createAnnotation(anyLong(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyString())) + .thenReturn(response); + + mockMvc.perform(post("/api/documents/1/annotations") + .contentType("application/json") + .content("{\"pageNumber\": 1, \"x\": 100, \"y\": 200, \"width\": 300, \"height\": 100, \"content\": \"굵은글씨\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(1L)) + .andExpect(jsonPath("$.pageNumber").value(1)) + .andExpect(jsonPath("$.x").value(100)) + .andExpect(jsonPath("$.y").value(200)) + .andExpect(jsonPath("$.width").value(300)) + .andExpect(jsonPath("$.height").value(100)) + .andExpect(jsonPath("$.content").value("굵은글씨")); + } + + @Test + void testGetAnnotations_success() throws Exception { + LocalDateTime now = LocalDateTime.now(); + + // Mock 데이터 설정 + List responses = List.of( + new AnnotationResponse(1L, 1L, 1, 100, 200, 300, 100, "굵은글씨 그냥 글씨 이탤릭체", now.toString(), now.toString()), + new AnnotationResponse(2L, 1L, 2, 150, 250, 350, 120, "", now.toString(), now.toString()) + ); + + when(annotationQueryService.getAnnotationsByDocumentAndPageNumbers(anyLong(), anyList())).thenReturn(responses); + + mockMvc.perform(get("/api/documents/1/annotations?pageNumbers=1,2") + .contentType("application/json")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(1L)) + .andExpect(jsonPath("$[0].pageNumber").value(1)) + .andExpect(jsonPath("$[0].x").value(100)) + .andExpect(jsonPath("$[0].y").value(200)) + .andExpect(jsonPath("$[0].width").value(300)) + .andExpect(jsonPath("$[0].height").value(100)) + .andExpect(jsonPath("$[0].content").value("굵은글씨 그냥 글씨 이탤릭체")) + .andExpect(jsonPath("$[0].createdAt").value(now.toString())) + .andExpect(jsonPath("$[0].updatedAt").value(now.toString())) + .andExpect(jsonPath("$[1].id").value(2L)) + .andExpect(jsonPath("$[1].pageNumber").value(2)) + .andExpect(jsonPath("$[1].x").value(150)) + .andExpect(jsonPath("$[1].y").value(250)) + .andExpect(jsonPath("$[1].width").value(350)) + .andExpect(jsonPath("$[1].height").value(120)); + } + + + @Test + void testUpdateAnnotation_success() throws Exception { + LocalDateTime now = LocalDateTime.now(); + AnnotationResponse response = new AnnotationResponse(1L, 1L, 1, 150, 250, 350, 120, "수정된 주석", now.toString(), now.toString()); + + when(annotationService.updateAnnotation(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), anyInt(), anyString())) + .thenReturn(response); + + mockMvc.perform(put("/api/documents/1/annotations/1") + .contentType("application/json") + .content("{\"pageNumber\": 1, \"x\": 150, \"y\": 250, \"width\": 350, \"height\": 120, \"content\": \"수정된 주석\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1L)) + .andExpect(jsonPath("$.pageNumber").value(1)) + .andExpect(jsonPath("$.content").value("수정된 주석")) + .andExpect(jsonPath("$.x").value(150)) + .andExpect(jsonPath("$.y").value(250)) + .andExpect(jsonPath("$.width").value(350)) + .andExpect(jsonPath("$.height").value(120)); + } + + + @Test + void testDeleteAnnotation_success() throws Exception { + doNothing().when(annotationService).deleteAnnotation(anyLong(), anyLong()); + + mockMvc.perform(delete("/api/documents/1/annotations/1") + .contentType("application/json")) + .andExpect(status().isNoContent()); + + verify(annotationService, times(1)).deleteAnnotation(1L, 1L); + } +} diff --git a/src/test/java/notai/client/oauth/kakao/KakaoOauthClientTest.java b/src/test/java/notai/client/oauth/kakao/KakaoOauthClientTest.java index 2dd7dcb..880eb1b 100644 --- a/src/test/java/notai/client/oauth/kakao/KakaoOauthClientTest.java +++ b/src/test/java/notai/client/oauth/kakao/KakaoOauthClientTest.java @@ -2,17 +2,16 @@ import notai.member.domain.Member; import notai.member.domain.OauthProvider; +import static org.junit.jupiter.api.Assertions.assertEquals; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; +import static org.mockito.Mockito.when; import org.mockito.MockitoAnnotations; import java.time.LocalDateTime; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.when; - public class KakaoOauthClientTest { @Mock @@ -39,12 +38,12 @@ public void testFetchMember() { String nickname = "nickname"; KakaoMemberResponse.Profile profile = new KakaoMemberResponse.Profile(nickname); - KakaoMemberResponse.KakaoAccount kakaoAccount = new KakaoMemberResponse.KakaoAccount( - profile, + KakaoMemberResponse.KakaoAccount kakaoAccount = new KakaoMemberResponse.KakaoAccount(profile, emailNeedsAgreement, isEmailValid, isEmailVerified, - email); + email + ); KakaoMemberResponse kakaoMemberResponse = new KakaoMemberResponse(id, hasSignedUp, connectedAt, kakaoAccount); diff --git a/src/test/java/notai/document/application/DocumentServiceTest.java b/src/test/java/notai/document/application/DocumentServiceTest.java new file mode 100644 index 0000000..6b82bfd --- /dev/null +++ b/src/test/java/notai/document/application/DocumentServiceTest.java @@ -0,0 +1,13 @@ +package notai.document.application; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DocumentServiceTest { + + @InjectMocks + PdfService pdfService; + +} diff --git a/src/test/java/notai/document/application/PdfServiceTest.java b/src/test/java/notai/document/application/PdfServiceTest.java new file mode 100644 index 0000000..70f2397 --- /dev/null +++ b/src/test/java/notai/document/application/PdfServiceTest.java @@ -0,0 +1,73 @@ +package notai.document.application; + +import net.sourceforge.tess4j.Tesseract; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.rendering.PDFRenderer; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.ClassPathResource; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +@ExtendWith(MockitoExtension.class) +class PdfServiceTest { + + @InjectMocks + PdfService pdfService; + + static final String STORAGE_DIR = "src/main/resources/pdf/"; + + @Test + void savePdf_success_existsTestPdf() throws IOException { + //given + ClassPathResource existsPdf = new ClassPathResource("pdf/test.pdf"); + MockMultipartFile mockFile = new MockMultipartFile("file", + existsPdf.getFilename(), + "application/pdf", + Files.readAllBytes(existsPdf.getFile().toPath()) + ); + //when + String savedFileName = pdfService.savePdf(mockFile); + //then + Path savedFilePath = Paths.get(STORAGE_DIR, savedFileName); + Assertions.assertThat(Files.exists(savedFilePath)).isTrue(); + + System.setProperty("jna.library.path", "/usr/local/opt/tesseract/lib/"); + //window, mac -> brew install tesseract, tesseract-lang + Tesseract tesseract = new Tesseract(); + + tesseract.setDatapath("/usr/local/share/tessdata"); + tesseract.setLanguage("kor+eng"); + + try { + PDDocument pdDocument = Loader.loadPDF(savedFilePath.toFile()); + PDFRenderer pdfRenderer = new PDFRenderer(pdDocument); + + var image = pdfRenderer.renderImage(9); + var start = System.currentTimeMillis(); + var ocrResult = tesseract.doOCR(image); + System.out.println("result : " + ocrResult); + var end = System.currentTimeMillis(); + System.out.println(end - start); + pdDocument.close(); + } catch (Exception e) { + e.printStackTrace(); + } + + deleteFile(savedFilePath); + } + + void deleteFile(Path filePath) throws IOException { + if (Files.exists(filePath)) { + Files.delete(filePath); + } + } +} diff --git a/src/test/java/notai/folder/application/FolderQueryServiceTest.java b/src/test/java/notai/folder/application/FolderQueryServiceTest.java new file mode 100644 index 0000000..a567e1d --- /dev/null +++ b/src/test/java/notai/folder/application/FolderQueryServiceTest.java @@ -0,0 +1,64 @@ +package notai.folder.application; + +import notai.folder.application.result.FolderFindResult; +import notai.folder.domain.Folder; +import notai.folder.domain.FolderRepository; +import notai.member.domain.Member; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import static org.mockito.ArgumentMatchers.any; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import static org.mockito.Mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +@ExtendWith(MockitoExtension.class) +class FolderQueryServiceTest { + + @Mock + private FolderRepository folderRepository; + @InjectMocks + private FolderQueryService folderQueryService; + + @Test + @DisplayName("루트 폴더 조회") + void getFolders_success_parentFolderIdIsNull() { + //given + Folder folder = getFolder(1L, null, "루트폴더"); + List expectedResults = List.of(folder); + + when(folderRepository.findAllByMemberIdAndParentFolderIsNull(any(Long.class))).thenReturn(expectedResults); + //when + List folders = folderQueryService.getFolders(1L, null); + + Assertions.assertThat(folders.size()).isEqualTo(1); + } + + @Test + @DisplayName("계층적 구조의 폴더 조회") + void getFolders_success_parentFolderId() { + //given + Folder folder1 = getFolder(1L, null, "루트폴더"); + Folder folder2 = getFolder(2L, folder1, "서브폴더"); + Folder folder3 = getFolder(3L, folder1, "서브폴더"); + List expectedResults = List.of(folder2, folder3); + + when(folderRepository.findAllByMemberIdAndParentFolderId(any(Long.class), any(Long.class))).thenReturn( + expectedResults); + //when + List folders = folderQueryService.getFolders(1L, 1L); + + Assertions.assertThat(folders.size()).isEqualTo(2); + } + + private Folder getFolder(Long id, Folder parentFolder, String name) { + Member member = mock(Member.class); + Folder folder = spy(new Folder(member, name, parentFolder)); + lenient().when(folder.getId()).thenReturn(id); + return folder; + } +} diff --git a/src/test/java/notai/llm/application/LLMServiceTest.java b/src/test/java/notai/llm/application/LLMServiceTest.java index 8d85bee..4919144 100644 --- a/src/test/java/notai/llm/application/LLMServiceTest.java +++ b/src/test/java/notai/llm/application/LLMServiceTest.java @@ -19,7 +19,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.util.List; -import java.util.Optional; import java.util.UUID; import static org.junit.jupiter.api.Assertions.*; @@ -53,11 +52,11 @@ class LLMServiceTest { List pages = List.of(1, 2, 3); LLMSubmitCommand command = new LLMSubmitCommand(documentId, pages); - given(documentRepository.findById(anyLong())).willReturn(Optional.empty()); + given(documentRepository.getById(anyLong())).willThrow(NotFoundException.class); // when & then assertAll(() -> assertThrows(NotFoundException.class, () -> llmService.submitTask(command)), - () -> verify(documentRepository, times(1)).findById(documentId), + () -> verify(documentRepository, times(1)).getById(documentId), () -> verify(llmRepository, never()).save(any(LLM.class)) ); } @@ -70,13 +69,13 @@ class LLMServiceTest { LLMSubmitCommand command = new LLMSubmitCommand(documentId, pages); Document document = mock(Document.class); - given(documentRepository.findById(anyLong())).willReturn(Optional.of(document)); + given(documentRepository.getById(anyLong())).willReturn(document); given(llmRepository.save(any(LLM.class))).willAnswer(invocation -> invocation.getArgument(0)); // when LLMSubmitResult result = llmService.submitTask(command); // then - assertAll(() -> verify(documentRepository, times(1)).findById(anyLong()), + assertAll(() -> verify(documentRepository, times(1)).getById(anyLong()), () -> verify(llmRepository, times(3)).save(any(LLM.class)) ); } diff --git a/src/test/java/notai/pageRecording/application/PageRecordingServiceTest.java b/src/test/java/notai/pageRecording/application/PageRecordingServiceTest.java new file mode 100644 index 0000000..3d89a01 --- /dev/null +++ b/src/test/java/notai/pageRecording/application/PageRecordingServiceTest.java @@ -0,0 +1,54 @@ +package notai.pageRecording.application; + +import notai.pageRecording.application.command.PageRecordingSaveCommand; +import notai.pageRecording.application.command.PageRecordingSaveCommand.PageRecordingSession; +import notai.pageRecording.domain.PageRecording; +import notai.pageRecording.domain.PageRecordingRepository; +import notai.recording.domain.Recording; +import notai.recording.domain.RecordingRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PageRecordingServiceTest { + + @InjectMocks + private PageRecordingService pageRecordingService; + + @Mock + private PageRecordingRepository pageRecordingRepository; + + @Mock + private RecordingRepository recordingRepository; + + @Test + void 페이지_넘김_이벤트에_따라_페이지별_녹음_시간을_저장() { + // given + Long recordingId = 1L; + Long documentId = 1L; + + PageRecordingSaveCommand command = new PageRecordingSaveCommand( + recordingId, + documentId, + List.of(new PageRecordingSession(1, 100.0, 185.5), new PageRecordingSession(5, 185.5, 290.3)) + ); + + Recording foundRecording = mock(Recording.class); + given(recordingRepository.getById(recordingId)).willReturn(foundRecording); + given(foundRecording.isRecordingOwnedByDocument(documentId)).willReturn(true); + + // when + pageRecordingService.savePageRecording(command); + + // then + verify(pageRecordingRepository, times(2)).save(any(PageRecording.class)); + } +} \ No newline at end of file diff --git a/src/test/java/notai/recording/application/RecordingServiceTest.java b/src/test/java/notai/recording/application/RecordingServiceTest.java new file mode 100644 index 0000000..c97dde0 --- /dev/null +++ b/src/test/java/notai/recording/application/RecordingServiceTest.java @@ -0,0 +1,103 @@ +package notai.recording.application; + +import notai.common.domain.vo.FilePath; +import notai.common.exception.type.BadRequestException; +import notai.common.utils.AudioDecoder; +import notai.common.utils.FileManager; +import notai.document.domain.Document; +import notai.document.domain.DocumentRepository; +import notai.recording.application.command.RecordingSaveCommand; +import notai.recording.application.result.RecordingSaveResult; +import notai.recording.domain.Recording; +import notai.recording.domain.RecordingRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +class RecordingServiceTest { + + @InjectMocks + private RecordingService recordingService; + + @Mock + private RecordingRepository recordingRepository; + + @Mock + private DocumentRepository documentRepository; + + @Spy + private final AudioDecoder audioDecoder = new AudioDecoder(); + + @Spy + private final FileManager fileManager = new FileManager(); + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(recordingService, "audioBasePath", "src/main/resources/audio/"); + } + + @Test + void 녹음_파일_업로드시_잘못된_데이터인_경우_예외발생() { + // given + Long documentId = 1L; + String invalidAudioData = "data:audio/mpeg;base64,!!!"; + + Document document = mock(Document.class); + RecordingSaveCommand command = new RecordingSaveCommand(documentId, invalidAudioData); + + Recording savedRecording = new Recording(document); + ReflectionTestUtils.setField(savedRecording, "id", 1L); + + given(documentRepository.getById(anyLong())).willReturn(document); + given(recordingRepository.save(any(Recording.class))).willReturn(savedRecording); + given(document.getName()).willReturn("안녕하세요백종원입니다"); + + // when & then + assertThrows(BadRequestException.class, () -> { + recordingService.saveRecording(command); + }); + } + + @Test + void 녹음_파일_업로드() { + // given + Long documentId = 1L; + String base64AudioData = + "data:audio/mpeg;base64,SUQzAwAAAAAfdlRJVDIAAAANAAAB//6BrdmzIAAzADMAVFBFMQAAAAEAAABUQUxCAAAAAQAAAFRZRVIAAAABAAAAVENPTgAAAAEAAABUUkNLAAAAAQAAAENPTU0AAAAfAAAAZW5nAG9ubGluZS1hdWRpby1jb252ZXJ0ZXIuY29tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA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VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVQAACBZVSfG6URvoYq1haZX0KWBemcSySQ6XIy+ZNyy2Oh/9n8stmRk1ks+yyVDL/vZ9lsvmoZVToqKDQ7Oz//7sYqKqHZ2MqIqLoqJ///+ir9FRF0sb8xQwOpVMQU1FMy4xMDBVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/+5Jkbg/y0me60eMUcgAADSAAAAEAAAGkAAAAIAAANIAAAARVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV"; + + Document document = mock(Document.class); + RecordingSaveCommand command = new RecordingSaveCommand(documentId, base64AudioData); + + Recording savedRecording = new Recording(document); + ReflectionTestUtils.setField(savedRecording, "id", 1L); + + given(documentRepository.getById(anyLong())).willReturn(document); + given(recordingRepository.save(any(Recording.class))).willReturn(savedRecording); + given(document.getName()).willReturn("안녕하세요백종원입니다"); + + // when + RecordingSaveResult result = recordingService.saveRecording(command); + + // then + FilePath filePath = FilePath.from("안녕하세요백종원입니다_1.mp3"); + Path expectedFilePath = Paths.get("src/main/resources/audio/" + filePath.getFilePath()); + + assertAll(() -> assertTrue(Files.exists(expectedFilePath)), () -> assertTrue(Files.size(expectedFilePath) > 0)); + } +} \ No newline at end of file