diff --git a/src/main/java/com/haru/api/domain/lastOpened/service/UserDocumentLastOpenedService.java b/src/main/java/com/haru/api/domain/lastOpened/service/UserDocumentLastOpenedService.java index e3a5dba5..bcfa9074 100644 --- a/src/main/java/com/haru/api/domain/lastOpened/service/UserDocumentLastOpenedService.java +++ b/src/main/java/com/haru/api/domain/lastOpened/service/UserDocumentLastOpenedService.java @@ -1,18 +1,21 @@ package com.haru.api.domain.lastOpened.service; import com.haru.api.domain.lastOpened.entity.Documentable; -import com.haru.api.domain.lastOpened.entity.enums.DocumentType; +import com.haru.api.domain.lastOpened.entity.UserDocumentId; import com.haru.api.domain.user.entity.User; +import com.haru.api.global.common.entity.TitleHolder; import java.util.List; public interface UserDocumentLastOpenedService { - void updateLastOpened(Long userId, DocumentType documentType, Long documentId, Long workspaceId, String title); + void updateLastOpened(UserDocumentId userDocumentId, Long workspaceId, String title); void createInitialRecordsForWorkspaceUsers(List usersInWorkspace, Documentable document); void deleteRecordsForWorkspaceUsers(Documentable document); void updateRecordsForWorkspaceUsers(Documentable document); + + void updateRecordsForWorkspaceUsers(Documentable document, TitleHolder titleHolder); } diff --git a/src/main/java/com/haru/api/domain/lastOpened/service/UserDocumentLastOpenedServiceImpl.java b/src/main/java/com/haru/api/domain/lastOpened/service/UserDocumentLastOpenedServiceImpl.java index 3dbb75f5..55ee2866 100644 --- a/src/main/java/com/haru/api/domain/lastOpened/service/UserDocumentLastOpenedServiceImpl.java +++ b/src/main/java/com/haru/api/domain/lastOpened/service/UserDocumentLastOpenedServiceImpl.java @@ -3,12 +3,12 @@ import com.haru.api.domain.lastOpened.entity.Documentable; import com.haru.api.domain.lastOpened.entity.UserDocumentId; import com.haru.api.domain.lastOpened.entity.UserDocumentLastOpened; -import com.haru.api.domain.lastOpened.entity.enums.DocumentType; import com.haru.api.domain.lastOpened.repository.UserDocumentLastOpenedRepository; import com.haru.api.domain.user.entity.User; import com.haru.api.domain.user.repository.UserRepository; import com.haru.api.global.apiPayload.code.status.ErrorStatus; import com.haru.api.global.apiPayload.exception.handler.MemberHandler; +import com.haru.api.global.common.entity.TitleHolder; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -28,15 +28,14 @@ public class UserDocumentLastOpenedServiceImpl implements UserDocumentLastOpened @Override @Transactional - public void updateLastOpened(Long userId, DocumentType documentType, Long documentId, Long workspaceId, String title) { - UserDocumentId id = new UserDocumentId(userId, documentId, documentType); + public void updateLastOpened(UserDocumentId userDocumentId, Long workspaceId, String title) { - UserDocumentLastOpened record = userDocumentLastOpenedRepository.findById(id) + UserDocumentLastOpened record = userDocumentLastOpenedRepository.findById(userDocumentId) .orElseGet(() -> { - User foundUser = userRepository.findById(userId) + User foundUser = userRepository.findById(userDocumentId.getUserId()) .orElseThrow(() -> new MemberHandler(ErrorStatus.MEMBER_NOT_FOUND)); return UserDocumentLastOpened.builder() - .id(id) + .id(userDocumentId) .user(foundUser) .workspaceId(workspaceId) .title(title) @@ -86,6 +85,20 @@ public void updateRecordsForWorkspaceUsers(Documentable documentable) { } } + @Override + @Transactional + public void updateRecordsForWorkspaceUsers(Documentable documentable, TitleHolder titleHolder) { + + // 해당 문서 id, 문서 타입에 해당하는 last opened 튜플 검색 + List recordsToUpdate = userDocumentLastOpenedRepository.findByDocumentIdAndDocumentType(documentable.getId(), documentable.getDocumentType()); + + if (!recordsToUpdate.isEmpty()) { + for (UserDocumentLastOpened record : recordsToUpdate) { + record.updateTitle(titleHolder.getTitle()); + } + } + } + private List recordsToProcess(List usersInWorkspace, Documentable document) { // 처리할 엔티티들을 담을 리스트 생성 List recordsToProcess = new ArrayList<>(); diff --git a/src/main/java/com/haru/api/domain/meeting/controller/MeetingController.java b/src/main/java/com/haru/api/domain/meeting/controller/MeetingController.java index 4c4b63c4..8e1bfb16 100644 --- a/src/main/java/com/haru/api/domain/meeting/controller/MeetingController.java +++ b/src/main/java/com/haru/api/domain/meeting/controller/MeetingController.java @@ -2,13 +2,17 @@ import com.haru.api.domain.meeting.dto.MeetingRequestDTO; import com.haru.api.domain.meeting.dto.MeetingResponseDTO; +import com.haru.api.domain.meeting.entity.Meeting; import com.haru.api.domain.meeting.service.MeetingCommandService; import com.haru.api.domain.meeting.service.MeetingQueryService; -import com.haru.api.domain.user.security.jwt.SecurityUtil; +import com.haru.api.domain.user.entity.User; +import com.haru.api.global.annotation.AuthMeeting; +import com.haru.api.global.annotation.AuthUser; import com.haru.api.global.apiPayload.ApiResponse; import com.haru.api.global.apiPayload.code.status.ErrorStatus; import com.haru.api.global.apiPayload.exception.GeneralException; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Encoding; import lombok.RequiredArgsConstructor; @@ -45,15 +49,16 @@ public class MeetingController { ) public ApiResponse createMeeting( @RequestPart("agendaFile") MultipartFile agendaFile, - @RequestPart("request") MeetingRequestDTO.createMeetingRequest request) { + @RequestPart("request") MeetingRequestDTO.createMeetingRequest request, + @Parameter(hidden = true) @AuthUser User user + ) { // file업로드가 되지 않는 경우 controller단에서 요청 처리 if (agendaFile == null || agendaFile.isEmpty()) { throw new GeneralException(ErrorStatus.MEETING_AGENDAFILE_NOT_FOUND); } - Long userId = SecurityUtil.getCurrentUserId(); - MeetingResponseDTO.createMeetingResponse response = meetingCommandService.createMeeting(userId, agendaFile, request); + MeetingResponseDTO.createMeetingResponse response = meetingCommandService.createMeeting(user, agendaFile, request); return ApiResponse.onSuccess(response); } @@ -64,11 +69,12 @@ public ApiResponse createMeeting( ) @GetMapping("/workspaces/{workspaceId}") public ApiResponse> getMeetings( - @PathVariable("workspaceId") String workspaceId){ - - Long userId = SecurityUtil.getCurrentUserId(); + @PathVariable("workspaceId") String workspaceId, + @Parameter(hidden = true) @AuthUser User user, + @Parameter(hidden = true) @AuthMeeting Meeting meeting + ) { - List response = meetingQueryService.getMeetings(userId, Long.parseLong(workspaceId)); + List response = meetingQueryService.getMeetings(user, meeting); return ApiResponse.onSuccess(response); } @@ -79,11 +85,12 @@ public ApiResponse> getMeetings( @PatchMapping("/{meetingId}/title") public ApiResponse updateMeetingTitle( @PathVariable("meetingId")String meetingId, - @RequestBody MeetingRequestDTO.updateTitle request) { - - Long userId = SecurityUtil.getCurrentUserId(); + @RequestBody MeetingRequestDTO.updateTitle request, + @Parameter(hidden = true) @AuthUser User user, + @Parameter(hidden = true) @AuthMeeting Meeting meeting + ) { - meetingCommandService.updateMeetingTitle(userId, Long.parseLong(meetingId), request.getTitle()); + meetingCommandService.updateMeetingTitle(user, meeting, request); return ApiResponse.onSuccess("제목수정이 완료되었습니다."); } @@ -93,11 +100,12 @@ public ApiResponse updateMeetingTitle( ) @DeleteMapping("/{meetingId}") public ApiResponse deleteMeeting( - @PathVariable("meetingId") String meetingId) { - - Long userId = SecurityUtil.getCurrentUserId(); + @PathVariable("meetingId") String meetingId, + @Parameter(hidden = true) @AuthUser User user, + @Parameter(hidden = true) @AuthMeeting Meeting meeting + ) { - meetingCommandService.deleteMeeting(userId, Long.parseLong(meetingId)); + meetingCommandService.deleteMeeting(user, meeting); return ApiResponse.onSuccess("회의가 삭제되었습니다."); } @@ -107,12 +115,15 @@ public ApiResponse deleteMeeting( ) @GetMapping("/{meetingId}/ai-proceeding") public ApiResponse getMeetingProceeding( - @PathVariable("meetingId")String meetingId) { + @PathVariable("meetingId")String meetingId, + @Parameter(hidden = true) @AuthUser User user, + @Parameter(hidden = true) @AuthMeeting Meeting meeting + ) { - Long userId = SecurityUtil.getCurrentUserId(); - MeetingResponseDTO.getMeetingProceeding response = meetingQueryService.getMeetingProceeding(userId, Long.parseLong(meetingId)); + MeetingResponseDTO.getMeetingProceeding response = meetingQueryService.getMeetingProceeding(user, meeting); return ApiResponse.onSuccess(response); + } @@ -122,13 +133,15 @@ public ApiResponse getMeetingProceeding @PatchMapping("/{meetingId}") public ApiResponse adjustProceeding( @PathVariable("meetingId") String meetingId, - @RequestBody MeetingRequestDTO.meetingProceedingRequest request) { - - Long userId = SecurityUtil.getCurrentUserId(); + @RequestBody MeetingRequestDTO.meetingProceedingRequest request, + @Parameter(hidden = true) @AuthUser User user, + @Parameter(hidden = true) @AuthMeeting Meeting meeting + ) { - meetingCommandService.adjustProceeding(userId, Long.parseLong(meetingId), request); + meetingCommandService.adjustProceeding(user, meeting, request); return ApiResponse.onSuccess("회의가 수정되었습니다."); + } @Operation(summary = "회의 종료", description = @@ -137,13 +150,15 @@ public ApiResponse adjustProceeding( ) @PostMapping("/{meetingId}/end") public ApiResponse endMeeting( - @PathVariable("meetindId") String meetingId + @PathVariable("meetingId") String meetingId, + @Parameter(hidden = true) @AuthUser User user, + @Parameter(hidden = true) @AuthMeeting Meeting meeting ) { - Long userId = SecurityUtil.getCurrentUserId(); - meetingCommandService.endMeeting(userId, Long.parseLong(meetingId)); + meetingCommandService.endMeeting(user, meeting); return ApiResponse.onSuccess("회의가 종료되었습니다"); + } @Operation(summary = "회의록 다운로드", description = @@ -152,13 +167,15 @@ public ApiResponse endMeeting( ) @GetMapping("{meetingId}/ai-proceeding/download") public ApiResponse downloadMeeting( - @PathVariable("meetingId") String meetingId + @PathVariable("meetingId") String meetingId, + @Parameter(hidden = true) @AuthUser User user, + @Parameter(hidden = true) @AuthMeeting Meeting meeting ){ - Long userId = SecurityUtil.getCurrentUserId(); - MeetingResponseDTO.proceedingDownLoadLinkResponse response = meetingQueryService.downloadMeeting(userId, Long.parseLong(meetingId)); + MeetingResponseDTO.proceedingDownLoadLinkResponse response = meetingQueryService.downloadMeeting(user, meeting); return ApiResponse.onSuccess(response); + } @@ -168,15 +185,16 @@ public ApiResponse downloadMe ) @GetMapping("/{meetingId}/transcript") public ApiResponse getMeetingTranscript( - @PathVariable("meetingId") String meetingId + @PathVariable("meetingId") String meetingId, + @Parameter(hidden = true) @AuthUser User user, + @Parameter(hidden = true) @AuthMeeting Meeting meeting ) { - Long userId = SecurityUtil.getCurrentUserId(); - MeetingResponseDTO.TranscriptResponse transcriptResponse = meetingQueryService.getTranscript(userId, Long.parseLong(meetingId)); + MeetingResponseDTO.TranscriptResponse transcriptResponse = meetingQueryService.getTranscript(user, meeting); return ApiResponse.onSuccess(transcriptResponse); - } + } @Operation(summary = "회의록 음성파일 조회API", description = @@ -185,12 +203,14 @@ public ApiResponse getMeetingTranscript( ) @GetMapping("{meetingId}/ai-proceeding/voice") public ApiResponse MeetingvoiceFile( - @PathVariable("meetingId") String meetingId + @PathVariable("meetingId") String meetingId, + @Parameter(hidden = true) @AuthUser User user, + @Parameter(hidden = true) @AuthMeeting Meeting meeting ){ - Long userId = SecurityUtil.getCurrentUserId(); - MeetingResponseDTO.proceedingVoiceLinkResponse response = meetingQueryService.MeetingVoiceFile(userId, Long.parseLong(meetingId)); + MeetingResponseDTO.proceedingVoiceLinkResponse response = meetingQueryService.getMeetingVoiceFile(user, meeting); return ApiResponse.onSuccess(response); + } } diff --git a/src/main/java/com/haru/api/domain/meeting/dto/MeetingRequestDTO.java b/src/main/java/com/haru/api/domain/meeting/dto/MeetingRequestDTO.java index 6fb9e94b..9f6f0f87 100644 --- a/src/main/java/com/haru/api/domain/meeting/dto/MeetingRequestDTO.java +++ b/src/main/java/com/haru/api/domain/meeting/dto/MeetingRequestDTO.java @@ -1,6 +1,7 @@ package com.haru.api.domain.meeting.dto; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.haru.api.global.common.entity.TitleHolder; import com.haru.api.global.util.json.ToLongDeserializer; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; @@ -21,7 +22,7 @@ public static class createMeetingRequest{ private String title; } @Getter - public static class updateTitle { + public static class updateTitle implements TitleHolder { private String title; } diff --git a/src/main/java/com/haru/api/domain/meeting/entity/Meeting.java b/src/main/java/com/haru/api/domain/meeting/entity/Meeting.java index b73cab96..74304dc4 100644 --- a/src/main/java/com/haru/api/domain/meeting/entity/Meeting.java +++ b/src/main/java/com/haru/api/domain/meeting/entity/Meeting.java @@ -36,7 +36,7 @@ public class Meeting extends BaseEntity implements Documentable { @Column(columnDefinition="TEXT") private String proceeding; - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.EAGER) @JoinColumn(name = "user_id") private User creator; @@ -49,7 +49,7 @@ public class Meeting extends BaseEntity implements Documentable { private String proceedingKeyName; @Column(columnDefinition = "TEXT") - private String thumbnailKey; + private String thumbnailKeyName; @OneToMany(mappedBy = "meeting", cascade = CascadeType.ALL, orphanRemoval = true) private List meetingKeywords = new ArrayList<>(); @@ -61,9 +61,6 @@ public class Meeting extends BaseEntity implements Documentable { @Setter private String audioFileKey; - @Column(columnDefinition = "TEXT") - private String thumbnailKeyName; - private Meeting(String title, String agendaResult, User user, Workspace workspace) { this.title = title; this.agendaResult = agendaResult; @@ -80,8 +77,8 @@ public void updateTitle(String title) { public void updateProceeding(String proceeding) { this.proceeding = proceeding; } - public void initProceedingKeyName(String proceedingKeyName) {this.proceedingKeyName = proceedingKeyName;} - public void initThumbnailKey(String thumbnailKey) {this.thumbnailKey = thumbnailKey;} + public void initProceedingKeyName(String proceedingKeyName) { this.proceedingKeyName = proceedingKeyName; } + public void initThumbnailKeyName(String thumbnailKeyName) { this.thumbnailKeyName = thumbnailKeyName; } public void initStartTime(LocalDateTime startTime) { this.startTime = startTime; } diff --git a/src/main/java/com/haru/api/domain/meeting/repository/MeetingRepository.java b/src/main/java/com/haru/api/domain/meeting/repository/MeetingRepository.java index 18222ce1..48d025c3 100644 --- a/src/main/java/com/haru/api/domain/meeting/repository/MeetingRepository.java +++ b/src/main/java/com/haru/api/domain/meeting/repository/MeetingRepository.java @@ -26,4 +26,10 @@ public interface MeetingRepository extends JpaRepository { "AND mt.createdAt BETWEEN :startDate AND :endDate") List findAllDocumentForCalendars(Long workspaceId, LocalDateTime startDate, LocalDateTime endDate); + @Query("SELECT m FROM Meeting m " + + "WHERE m.id = :meetingId AND EXISTS (" + + " SELECT 1 FROM UserWorkspace uw " + + " WHERE uw.user.id = :userId AND uw.workspace.id = m.workspace.id" + + ")") + Optional findMeetingByIdIfUserHasAccess(Long userId, Long meetingId); } diff --git a/src/main/java/com/haru/api/domain/meeting/service/MeetingCommandService.java b/src/main/java/com/haru/api/domain/meeting/service/MeetingCommandService.java index 874f6d07..c1be77eb 100644 --- a/src/main/java/com/haru/api/domain/meeting/service/MeetingCommandService.java +++ b/src/main/java/com/haru/api/domain/meeting/service/MeetingCommandService.java @@ -2,16 +2,24 @@ import com.haru.api.domain.meeting.dto.MeetingRequestDTO; import com.haru.api.domain.meeting.dto.MeetingResponseDTO; +import com.haru.api.domain.meeting.entity.Meeting; +import com.haru.api.domain.user.entity.User; import com.haru.api.infra.websocket.AudioSessionBuffer; import org.springframework.web.multipart.MultipartFile; public interface MeetingCommandService { - MeetingResponseDTO.createMeetingResponse createMeeting(Long userId, MultipartFile agendaFile, MeetingRequestDTO.createMeetingRequest request); - void updateMeetingTitle(Long userId, Long meetingId, String newTitle); - void deleteMeeting(Long userId, Long meetingId); - void adjustProceeding(Long userId, Long meetingId, MeetingRequestDTO.meetingProceedingRequest newProceeding); - void endMeeting(Long userId, Long meetingId); + MeetingResponseDTO.createMeetingResponse createMeeting(User user, MultipartFile agendaFile, MeetingRequestDTO.createMeetingRequest request); + + void updateMeetingTitle(User user, Meeting meeting, MeetingRequestDTO.updateTitle request); + + void deleteMeeting(User user, Meeting meeting); + + void adjustProceeding(User user, Meeting meeting, MeetingRequestDTO.meetingProceedingRequest newProceeding); + + void endMeeting(User user, Meeting meeting); + void processAfterMeeting(AudioSessionBuffer audioSessionBuffer); + } diff --git a/src/main/java/com/haru/api/domain/meeting/service/MeetingCommandServiceImpl.java b/src/main/java/com/haru/api/domain/meeting/service/MeetingCommandServiceImpl.java index 83fc950a..14a29eef 100644 --- a/src/main/java/com/haru/api/domain/meeting/service/MeetingCommandServiceImpl.java +++ b/src/main/java/com/haru/api/domain/meeting/service/MeetingCommandServiceImpl.java @@ -1,6 +1,5 @@ package com.haru.api.domain.meeting.service; -import com.haru.api.domain.lastOpened.repository.UserDocumentLastOpenedRepository; import com.haru.api.domain.lastOpened.service.UserDocumentLastOpenedService; import com.haru.api.domain.meeting.converter.MeetingConverter; import com.haru.api.domain.meeting.dto.MeetingRequestDTO; @@ -10,12 +9,13 @@ import com.haru.api.domain.meeting.repository.MeetingRepository; import com.haru.api.domain.meeting.repository.KeywordRepository; import com.haru.api.domain.user.entity.User; -import com.haru.api.domain.user.repository.UserRepository; import com.haru.api.domain.userWorkspace.entity.UserWorkspace; import com.haru.api.domain.userWorkspace.entity.enums.Auth; import com.haru.api.domain.userWorkspace.repository.UserWorkspaceRepository; import com.haru.api.domain.workspace.entity.Workspace; import com.haru.api.domain.workspace.repository.WorkspaceRepository; +import com.haru.api.global.annotation.DeleteDocument; +import com.haru.api.global.annotation.UpdateDocumentTitle; import com.haru.api.global.apiPayload.code.status.ErrorStatus; import com.haru.api.global.apiPayload.exception.handler.*; import com.haru.api.infra.api.client.ChatGPTClient; @@ -24,15 +24,12 @@ import com.haru.api.infra.mp3encoder.Mp3EncoderService; import com.haru.api.infra.s3.AmazonS3Manager; import com.haru.api.infra.s3.MarkdownFileUploader; -import com.haru.api.infra.s3.MarkdownToPdfConverter; import com.haru.api.infra.websocket.AudioSessionBuffer; import com.haru.api.infra.websocket.WebSocketSessionRegistry; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.rendering.PDFRenderer; import org.apache.pdfbox.text.PDFTextStripper; -import org.docx4j.Docx4J; import org.docx4j.TextUtils; import org.docx4j.openpackaging.packages.WordprocessingMLPackage; import org.springframework.scheduling.annotation.Async; @@ -41,12 +38,7 @@ import org.springframework.web.multipart.MultipartFile; import org.springframework.web.socket.CloseStatus; -import javax.imageio.ImageIO; -import java.awt.image.BufferedImage; import java.io.*; -import java.util.ArrayList; -import java.util.Base64; -import java.util.Collections; import java.util.List; import java.util.stream.Collectors; @@ -56,17 +48,14 @@ @Transactional(readOnly = true) public class MeetingCommandServiceImpl implements MeetingCommandService { - private final UserRepository userRepository; private final UserWorkspaceRepository userWorkspaceRepository; private final WorkspaceRepository workspaceRepository; private final MeetingRepository meetingRepository; private final KeywordRepository keywordRepository; private final ChatGPTClient chatGPTClient; - private final UserDocumentLastOpenedRepository userDocumentLastOpenedRepository; private final UserDocumentLastOpenedService userDocumentLastOpenedService; private final WebSocketSessionRegistry webSocketSessionRegistry; private final SpeechSegmentRepository speechSegmentRepository; - private final MarkdownToPdfConverter markdownToPdfConverter; private final MarkdownFileUploader markdownFileUploader; private final AmazonS3Manager amazonS3Manager; @@ -75,23 +64,24 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { @Override @Transactional public MeetingResponseDTO.createMeetingResponse createMeeting( - Long userId, + User user, MultipartFile agendaFile, MeetingRequestDTO.createMeetingRequest request) { - User foundUser = userRepository.findById(userId) - .orElseThrow(() -> new MemberHandler(ErrorStatus.MEMBER_NOT_FOUND)); Workspace foundWorkspace = workspaceRepository.findById(request.getWorkspaceId()) .orElseThrow(() -> new WorkspaceHandler(ErrorStatus.WORKSPACE_NOT_FOUND)); + if (!userWorkspaceRepository.existsByUserIdAndWorkspaceId(user.getId(), foundWorkspace.getId())) + throw new UserWorkspaceHandler(ErrorStatus.USER_WORKSPACE_NOT_FOUND); + + String extractedText = extractTextFromFile(agendaFile); // agendaFile을 openAi 활용하여 요약 String agendaResult = chatGPTClient.summarizeDocument(extractedText) .block(); - String agendaKeywords = ""; String agendaSummary = "요약 생성에 실패했습니다."; @@ -108,7 +98,7 @@ public MeetingResponseDTO.createMeetingResponse createMeeting( Meeting newMeeting = Meeting.createInitialMeeting( request.getTitle(), agendaSummary, - foundUser, + user, foundWorkspace ); @@ -139,89 +129,54 @@ public MeetingResponseDTO.createMeetingResponse createMeeting( @Override @Transactional - public void updateMeetingTitle(Long userId, Long meetingId, String newTitle) { - - - Meeting foundMeeting = meetingRepository.findById(meetingId) - .orElseThrow(() -> new MeetingHandler(ErrorStatus.MEETING_NOT_FOUND)); - - User foundUser = userRepository.findById(userId) - .orElseThrow(() -> new MemberHandler(ErrorStatus.MEMBER_NOT_FOUND)); + @UpdateDocumentTitle + public void updateMeetingTitle(User user, Meeting meeting, MeetingRequestDTO.updateTitle request) { // 회의 생성자 권한 확인 - if (!foundMeeting.getCreator().getId().equals(userId)) { + if (!meeting.getCreator().getId().equals(user.getId())) { throw new MemberHandler(ErrorStatus.MEMBER_NO_AUTHORITY); } - foundMeeting.updateTitle(newTitle); - - // meeting 수정 시 워크스페이스에 속해있는 모든 유저에 대해 - // last opened 테이블에서 해당 문서 정보 업데이트 - userDocumentLastOpenedService.updateRecordsForWorkspaceUsers(foundMeeting); + meeting.updateTitle(request.getTitle()); + meetingRepository.save(meeting); } @Override @Transactional - public void deleteMeeting(Long userId, Long meetingId) { - User foundUser = userRepository.findById(userId) - .orElseThrow(() -> new MemberHandler(ErrorStatus.MEMBER_NOT_FOUND)); - - Meeting foundMeeting = meetingRepository.findById(meetingId) - .orElseThrow(() -> new MeetingHandler(ErrorStatus.MEETING_NOT_FOUND)); - - Workspace foundWorkspace = meetingRepository.findWorkspaceByMeetingId(meetingId) - .orElseThrow(() -> new WorkspaceHandler(ErrorStatus.WORKSPACE_NOT_FOUND)); + @DeleteDocument + public void deleteMeeting(User user, Meeting meeting) { - UserWorkspace foundUserWorkspace = userWorkspaceRepository.findByUserIdAndWorkspaceId(userId, foundWorkspace.getId()) + UserWorkspace foundUserWorkspace = userWorkspaceRepository.findByUserIdAndWorkspaceId(user.getId(), meeting.getWorkspace().getId()) .orElseThrow(() -> new UserWorkspaceHandler(ErrorStatus.USER_WORKSPACE_NOT_FOUND)); - if (!foundMeeting.getCreator().getId().equals(userId) && !foundUserWorkspace.getAuth().equals(Auth.ADMIN)) { + if (!meeting.getCreator().getId().equals(user.getId()) && !foundUserWorkspace.getAuth().equals(Auth.ADMIN)) { throw new MemberHandler(ErrorStatus.MEMBER_NO_AUTHORITY); } - meetingRepository.delete(foundMeeting); - - // meeting 삭제 시 워크스페이스에 속해있는 모든 유저에 대해 - // last opened 테이블에서 해당 문서 id를 가지고 있는 튜플 모두 삭제 - userDocumentLastOpenedService.deleteRecordsForWorkspaceUsers(foundMeeting); + meetingRepository.delete(meeting); } @Override @Transactional - public void adjustProceeding(Long userId, Long meetingId, MeetingRequestDTO.meetingProceedingRequest newProceeding){ - User foundUser = userRepository.findById(userId) - .orElseThrow(() -> new MemberHandler(ErrorStatus.MEMBER_NOT_FOUND)); - - Meeting foundMeeting = meetingRepository.findById(meetingId) - .orElseThrow(() -> new MeetingHandler(ErrorStatus.MEETING_NOT_FOUND)); + public void adjustProceeding(User user, Meeting meeting, MeetingRequestDTO.meetingProceedingRequest newProceeding){ - Workspace foundWorkspace = meetingRepository.findWorkspaceByMeetingId(meetingId) - .orElseThrow(() -> new WorkspaceHandler(ErrorStatus.WORKSPACE_NOT_FOUND)); - - UserWorkspace foundUserWorkspace = userWorkspaceRepository.findByUserIdAndWorkspaceId(userId, foundWorkspace.getId()) + UserWorkspace foundUserWorkspace = userWorkspaceRepository.findByUserIdAndWorkspaceId(user.getId(), meeting.getWorkspace().getId()) .orElseThrow(() -> new UserWorkspaceHandler(ErrorStatus.USER_WORKSPACE_NOT_FOUND)); - if (!foundMeeting.getCreator().getId().equals(userId) && !foundUserWorkspace.getAuth().equals(Auth.ADMIN)) { + if (!meeting.getCreator().getId().equals(user.getId()) && !foundUserWorkspace.getAuth().equals(Auth.ADMIN)) { throw new MemberHandler(ErrorStatus.MEMBER_NO_AUTHORITY); } - foundMeeting.updateProceeding(newProceeding.getProceeding()); + + meeting.updateProceeding(newProceeding.getProceeding()); + meetingRepository.save(meeting); } @Override @Transactional - public void endMeeting(Long userId, Long meetingId) { - userRepository.findById(userId) - .orElseThrow(() -> new MemberHandler(ErrorStatus.MEMBER_NOT_FOUND)); + public void endMeeting(User user, Meeting meeting) { - meetingRepository.findById(meetingId) - .orElseThrow(() -> new MeetingHandler(ErrorStatus.MEETING_NOT_FOUND)); - - Workspace foundWorkspace = meetingRepository.findWorkspaceByMeetingId(meetingId) - .orElseThrow(() -> new WorkspaceHandler(ErrorStatus.WORKSPACE_NOT_FOUND)); - - userWorkspaceRepository.findByUserIdAndWorkspaceId(userId, foundWorkspace.getId()) - .orElseThrow(() -> new UserWorkspaceHandler(ErrorStatus.USER_WORKSPACE_NOT_FOUND)); + Long meetingId = meeting.getId(); // 웹소켓 연결 종료 및 세션 삭제 try { @@ -248,10 +203,10 @@ public void processAfterMeeting(AudioSessionBuffer sessionBuffer) { // 파일 업로드 후, key name을 반환 String keyName = uploadAudioFile(audioBuffer); - // 3. 조회한 엔티티의 상태를 변경합니다. + // audio file key name 엔티티에 저장 currentMeeting.setAudioFileKey(keyName); - // 4. AI 회의록 생성 + // AI 회의록 생성 List segments = speechSegmentRepository.findByMeeting(currentMeeting); if (segments.isEmpty()) { @@ -259,7 +214,7 @@ public void processAfterMeeting(AudioSessionBuffer sessionBuffer) { return; } - // 2. 모든 대화 텍스트를 하나의 문자열로 조합 + // 모든 대화 텍스트를 하나의 문자열로 조합 String documentText = segments.stream() .map(SpeechSegment::getText) .collect(Collectors.joining("\n")); @@ -280,8 +235,8 @@ public void processAfterMeeting(AudioSessionBuffer sessionBuffer) { currentMeeting.initProceedingKeyName(pdfKey); // 썸네일 생성 및 업데이트 - String newThumbnailKey = markdownFileUploader.createOrUpdateThumbnail(pdfKey, "meetings/" + currentMeeting.getId(), currentMeeting.getThumbnailKey()); - currentMeeting.initThumbnailKey(newThumbnailKey); // Meeting 엔티티에 썸네일 키 저장 + String newThumbnailKey = markdownFileUploader.createOrUpdateThumbnail(pdfKey, "meetings/" + currentMeeting.getId(), currentMeeting.getThumbnailKeyName()); + currentMeeting.initThumbnailKeyName(newThumbnailKey); // Meeting 엔티티에 썸네일 키 저장 log.info("회의록 썸네일 생성/업데이트 완료. Key: {}", newThumbnailKey); } catch (Exception e) { @@ -299,55 +254,6 @@ public void processAfterMeeting(AudioSessionBuffer sessionBuffer) { } } - private List convertFileToImages(MultipartFile file) { - if (file == null || file.isEmpty()) { - return Collections.emptyList(); - } - - String filename = file.getOriginalFilename(); - try { - if (filename != null && filename.toLowerCase().endsWith(".pdf")) { - return convertPdfToImages(file.getInputStream()); - } else if (filename != null && filename.toLowerCase().endsWith(".docx")) { - return convertDocxToImages(file.getInputStream()); - } else { - return Collections.emptyList(); - } - } catch (Exception e) { - throw new RuntimeException("파일을 이미지로 변환하는 중 오류가 발생했습니다.", e); - } - } - - /** - * PDF 스트림을 이미지(Base64) 리스트로 변환 - */ - private List convertPdfToImages(InputStream inputStream) throws IOException { - List base64Images = new ArrayList<>(); - try (PDDocument document = PDDocument.load(inputStream)) { - PDFRenderer pdfRenderer = new PDFRenderer(document); - for (int i = 0; i < document.getNumberOfPages(); i++) { - BufferedImage bufferedImage = pdfRenderer.renderImageWithDPI(i, 300); - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - ImageIO.write(bufferedImage, "png", baos); - base64Images.add(Base64.getEncoder().encodeToString(baos.toByteArray())); - } - } - return base64Images; - } - - /** - * DOCX 스트림을 이미지(Base64) 리스트로 변환 (내부적으로 PDF로 변환 후 처리) - * docx의 폰트들을 서버에 다운로드해야지 사용가능 (CI) - 현재 불가능 - */ - private List convertDocxToImages(InputStream inputStream) throws Exception { - // docx -> pdf 변환 - WordprocessingMLPackage wordMLPackage = WordprocessingMLPackage.load(inputStream); - ByteArrayOutputStream pdfOutputStream = new ByteArrayOutputStream(); - Docx4J.toPDF(wordMLPackage, pdfOutputStream); - - return convertPdfToImages(new ByteArrayInputStream(pdfOutputStream.toByteArray())); - } - /** * MultipartFile을 받아 파일 형식에 따라 텍스트를 추출합니다. */ diff --git a/src/main/java/com/haru/api/domain/meeting/service/MeetingQueryService.java b/src/main/java/com/haru/api/domain/meeting/service/MeetingQueryService.java index 15c0e0f2..67ab47c0 100644 --- a/src/main/java/com/haru/api/domain/meeting/service/MeetingQueryService.java +++ b/src/main/java/com/haru/api/domain/meeting/service/MeetingQueryService.java @@ -1,18 +1,20 @@ package com.haru.api.domain.meeting.service; import com.haru.api.domain.meeting.dto.MeetingResponseDTO; +import com.haru.api.domain.meeting.entity.Meeting; +import com.haru.api.domain.user.entity.User; import java.util.List; public interface MeetingQueryService { - List getMeetings(Long userId, Long workspaceId); + List getMeetings(User user, Meeting meeting); - MeetingResponseDTO.getMeetingProceeding getMeetingProceeding(Long userId, Long meetingId); + MeetingResponseDTO.getMeetingProceeding getMeetingProceeding(User user, Meeting meeting); - MeetingResponseDTO.TranscriptResponse getTranscript(Long userId, Long meetingId); + MeetingResponseDTO.TranscriptResponse getTranscript(User user, Meeting meeting); - MeetingResponseDTO.proceedingDownLoadLinkResponse downloadMeeting(Long userId, Long meetingId); + MeetingResponseDTO.proceedingDownLoadLinkResponse downloadMeeting(User user, Meeting meeting); - MeetingResponseDTO.proceedingVoiceLinkResponse MeetingVoiceFile(Long userId, Long meetingId); + MeetingResponseDTO.proceedingVoiceLinkResponse getMeetingVoiceFile(User user, Meeting meeting); } diff --git a/src/main/java/com/haru/api/domain/meeting/service/MeetingQueryServiceImpl.java b/src/main/java/com/haru/api/domain/meeting/service/MeetingQueryServiceImpl.java index 9ff4ba09..e7fef13e 100644 --- a/src/main/java/com/haru/api/domain/meeting/service/MeetingQueryServiceImpl.java +++ b/src/main/java/com/haru/api/domain/meeting/service/MeetingQueryServiceImpl.java @@ -6,17 +6,9 @@ import com.haru.api.domain.meeting.entity.Meeting; import com.haru.api.domain.meeting.repository.MeetingRepository; import com.haru.api.domain.user.entity.User; -import com.haru.api.domain.user.repository.UserRepository; -import com.haru.api.domain.userWorkspace.entity.UserWorkspace; -import com.haru.api.domain.userWorkspace.repository.UserWorkspaceRepository; -import com.haru.api.domain.workspace.entity.Workspace; -import com.haru.api.domain.workspace.repository.WorkspaceRepository; import com.haru.api.global.annotation.TrackLastOpened; import com.haru.api.global.apiPayload.code.status.ErrorStatus; import com.haru.api.global.apiPayload.exception.handler.MeetingHandler; -import com.haru.api.global.apiPayload.exception.handler.MemberHandler; -import com.haru.api.global.apiPayload.exception.handler.UserWorkspaceHandler; -import com.haru.api.global.apiPayload.exception.handler.WorkspaceHandler; import com.haru.api.infra.s3.AmazonS3Manager; import com.haru.api.infra.api.entity.SpeechSegment; import com.haru.api.infra.api.repository.SpeechSegmentRepository; @@ -33,89 +25,48 @@ public class MeetingQueryServiceImpl implements MeetingQueryService{ private final MeetingRepository meetingRepository; - private final WorkspaceRepository workspaceRepository; - private final UserRepository userRepository; - private final UserWorkspaceRepository userWorkspaceRepository; private final AmazonS3Manager amazonS3Manager; private final SpeechSegmentRepository speechSegmentRepository; @Override - public List getMeetings(Long userId, Long workspaceId) { - User foundUser = userRepository.findById(userId) - .orElseThrow(() -> new MemberHandler(ErrorStatus.MEMBER_NOT_FOUND)); + public List getMeetings(User user, Meeting meeting) { - Workspace foundWorkspace = workspaceRepository.findById(workspaceId) - .orElseThrow(() -> new WorkspaceHandler(ErrorStatus.WORKSPACE_NOT_FOUND)); - - List foundMeetings = meetingRepository.findByWorkspaceOrderByUpdatedAtDesc(foundWorkspace); + List foundMeetings = meetingRepository.findByWorkspaceOrderByUpdatedAtDesc(meeting.getWorkspace()); return foundMeetings.stream() - .map(meeting -> MeetingConverter.toGetMeetingResponse(meeting, userId)) + .map(eachMeeting -> MeetingConverter.toGetMeetingResponse(eachMeeting, user.getId())) .collect(Collectors.toList()); } @Override @TrackLastOpened(type = DocumentType.AI_MEETING_MANAGER) - public MeetingResponseDTO.getMeetingProceeding getMeetingProceeding(Long userId, Long meetingId){ - User foundUser = userRepository.findById(userId) - .orElseThrow(() -> new MemberHandler(ErrorStatus.MEMBER_NOT_FOUND)); - - Meeting foundMeeting = meetingRepository.findById(meetingId) - .orElseThrow(() -> new MeetingHandler(ErrorStatus.MEETING_NOT_FOUND)); - - Workspace foundWorkspace = meetingRepository.findWorkspaceByMeetingId(meetingId) - .orElseThrow(() -> new WorkspaceHandler(ErrorStatus.WORKSPACE_NOT_FOUND)); - - UserWorkspace foundUserWorkspace = userWorkspaceRepository.findByUserIdAndWorkspaceId(userId, foundWorkspace.getId()) - .orElseThrow(() -> new UserWorkspaceHandler(ErrorStatus.USER_WORKSPACE_NOT_FOUND)); + public MeetingResponseDTO.getMeetingProceeding getMeetingProceeding(User user, Meeting meeting){ - User foundMeetingCreator = foundMeeting.getCreator(); + User foundMeetingCreator = meeting.getCreator(); - return MeetingConverter.toGetMeetingProceedingResponse(foundMeetingCreator, foundMeeting); + return MeetingConverter.toGetMeetingProceedingResponse(foundMeetingCreator, meeting); } @Override - public MeetingResponseDTO.TranscriptResponse getTranscript(Long userId, Long meetingId) { - User foundUser = userRepository.findById(userId) - .orElseThrow(() -> new MemberHandler(ErrorStatus.MEMBER_NOT_FOUND)); - - Meeting foundMeeting = meetingRepository.findById(meetingId) - .orElseThrow(() -> new MeetingHandler(ErrorStatus.MEETING_NOT_FOUND)); - - Workspace foundWorkspace = meetingRepository.findWorkspaceByMeetingId(meetingId) - .orElseThrow(() -> new WorkspaceHandler(ErrorStatus.WORKSPACE_NOT_FOUND)); - - UserWorkspace foundUserWorkspace = userWorkspaceRepository.findByUserIdAndWorkspaceId(userId, foundWorkspace.getId()) - .orElseThrow(() -> new UserWorkspaceHandler(ErrorStatus.USER_WORKSPACE_NOT_FOUND)); + public MeetingResponseDTO.TranscriptResponse getTranscript(User user, Meeting meeting) { // Repository를 통해 SpeechSegment와 연관된 AIQuestion을 함께 조회 (N+1 문제 해결) - List segments = speechSegmentRepository.findAllByMeetingIdWithAIQuestions(meetingId); + List segments = speechSegmentRepository.findAllByMeetingIdWithAIQuestions(meeting.getId()); List transcriptList = segments.stream() .map(MeetingResponseDTO.Transcript::from) .collect(Collectors.toList()); return MeetingResponseDTO.TranscriptResponse.builder() - .meetingStartTime(foundMeeting.getStartTime()) + .meetingStartTime(meeting.getStartTime()) .transcripts(transcriptList) .build(); } @Override - public MeetingResponseDTO.proceedingDownLoadLinkResponse downloadMeeting(Long userId, Long meetingId){ - User foundUser = userRepository.findById(userId) - .orElseThrow(() -> new MemberHandler(ErrorStatus.MEMBER_NOT_FOUND)); + public MeetingResponseDTO.proceedingDownLoadLinkResponse downloadMeeting(User user, Meeting meeting){ - Meeting foundMeeting = meetingRepository.findById(meetingId) - .orElseThrow(() -> new MeetingHandler(ErrorStatus.MEETING_NOT_FOUND)); - - Workspace foundWorkspace = meetingRepository.findWorkspaceByMeetingId(meetingId) - .orElseThrow(() -> new WorkspaceHandler(ErrorStatus.WORKSPACE_NOT_FOUND)); - - UserWorkspace foundUserWorkspace = userWorkspaceRepository.findByUserIdAndWorkspaceId(userId, foundWorkspace.getId()) - .orElseThrow(() -> new UserWorkspaceHandler(ErrorStatus.USER_WORKSPACE_NOT_FOUND)); - - String proceedingKeyName = foundMeeting.getProceedingKeyName(); + String proceedingKeyName = meeting.getProceedingKeyName(); if (proceedingKeyName == null || proceedingKeyName.isBlank()) { throw new MeetingHandler(ErrorStatus.MEETING_PROCEEDING_NOT_FOUND); @@ -129,20 +80,9 @@ public MeetingResponseDTO.proceedingDownLoadLinkResponse downloadMeeting(Long us } @Override - public MeetingResponseDTO.proceedingVoiceLinkResponse MeetingVoiceFile(Long userId, Long meetingId){ - User foundUser = userRepository.findById(userId) - .orElseThrow(() -> new MemberHandler(ErrorStatus.MEMBER_NOT_FOUND)); - - Meeting foundMeeting = meetingRepository.findById(meetingId) - .orElseThrow(() -> new MeetingHandler(ErrorStatus.MEETING_NOT_FOUND)); - - Workspace foundWorkspace = meetingRepository.findWorkspaceByMeetingId(meetingId) - .orElseThrow(() -> new WorkspaceHandler(ErrorStatus.WORKSPACE_NOT_FOUND)); - - UserWorkspace foundUserWorkspace = userWorkspaceRepository.findByUserIdAndWorkspaceId(userId, foundWorkspace.getId()) - .orElseThrow(() -> new UserWorkspaceHandler(ErrorStatus.USER_WORKSPACE_NOT_FOUND)); + public MeetingResponseDTO.proceedingVoiceLinkResponse getMeetingVoiceFile(User user, Meeting meeting){ - String audioFileKeyName = foundMeeting.getAudioFileKey(); + String audioFileKeyName = meeting.getAudioFileKey(); if (audioFileKeyName == null || audioFileKeyName.isBlank()) { throw new MeetingHandler(ErrorStatus.MEETING_PROCEEDING_NOT_FOUND); diff --git a/src/main/java/com/haru/api/domain/userWorkspace/entity/UserWorkspace.java b/src/main/java/com/haru/api/domain/userWorkspace/entity/UserWorkspace.java index 460559fd..e3372319 100644 --- a/src/main/java/com/haru/api/domain/userWorkspace/entity/UserWorkspace.java +++ b/src/main/java/com/haru/api/domain/userWorkspace/entity/UserWorkspace.java @@ -35,4 +35,11 @@ public class UserWorkspace extends BaseEntity { @Enumerated(EnumType.STRING) private Auth auth; + // existsBy에서 join이 발생하여 추가 + @Column(name = "user_id", insertable = false, updatable = false) + private Long userId; + + // existsBy에서 join이 발생하여 추가 + @Column(name = "workspace_id", insertable = false, updatable = false) + private Long workspaceId; } diff --git a/src/main/java/com/haru/api/global/annotation/AuthDocument.java b/src/main/java/com/haru/api/global/annotation/AuthDocument.java new file mode 100644 index 00000000..3df1edf6 --- /dev/null +++ b/src/main/java/com/haru/api/global/annotation/AuthDocument.java @@ -0,0 +1,15 @@ +package com.haru.api.global.annotation; + +import com.haru.api.domain.lastOpened.entity.enums.DocumentType; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.ANNOTATION_TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthDocument { + DocumentType documentType(); + String pathVariableName(); +} diff --git a/src/main/java/com/haru/api/global/annotation/AuthMeeting.java b/src/main/java/com/haru/api/global/annotation/AuthMeeting.java new file mode 100644 index 00000000..8bf98247 --- /dev/null +++ b/src/main/java/com/haru/api/global/annotation/AuthMeeting.java @@ -0,0 +1,14 @@ +package com.haru.api.global.annotation; + +import com.haru.api.domain.lastOpened.entity.enums.DocumentType; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@AuthDocument(documentType = DocumentType.AI_MEETING_MANAGER, pathVariableName = "meetingId") +public @interface AuthMeeting { +} diff --git a/src/main/java/com/haru/api/global/annotation/DeleteDocument.java b/src/main/java/com/haru/api/global/annotation/DeleteDocument.java new file mode 100644 index 00000000..85c11be4 --- /dev/null +++ b/src/main/java/com/haru/api/global/annotation/DeleteDocument.java @@ -0,0 +1,11 @@ +package com.haru.api.global.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface DeleteDocument { +} diff --git a/src/main/java/com/haru/api/global/annotation/LastOpenedAspect.java b/src/main/java/com/haru/api/global/annotation/LastOpenedAspect.java deleted file mode 100644 index 78315685..00000000 --- a/src/main/java/com/haru/api/global/annotation/LastOpenedAspect.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.haru.api.global.annotation; - -import com.haru.api.domain.lastOpened.entity.enums.DocumentType; -import com.haru.api.domain.lastOpened.service.UserDocumentLastOpenedService; -import com.haru.api.domain.meeting.dto.MeetingResponseDTO; -import com.haru.api.domain.moodTracker.dto.MoodTrackerResponseDTO; -import com.haru.api.domain.snsEvent.dto.SnsEventResponseDTO; -import lombok.RequiredArgsConstructor; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; -import org.springframework.core.annotation.Order; -import org.springframework.stereotype.Component; - -@Aspect -@Component -@RequiredArgsConstructor -@Order(1) -public class LastOpenedAspect { - - private final UserDocumentLastOpenedService userDocumentLastOpenedService; - - @Around("@annotation(trackLastOpened)") - public Object trackLastOpened(ProceedingJoinPoint joinPoint, TrackLastOpened trackLastOpened) throws Throwable { - // 실제 메서드 실행 - Object result = joinPoint.proceed(); - - DocumentType type = trackLastOpened.type(); - int userIdIndex = trackLastOpened.userIdIndex(); - int documentIdIndex = trackLastOpened.documentIdIndex(); - - // 메서드의 인자에서 userId와 documentId 추출 - Object[] args = joinPoint.getArgs(); - - // 인덱스를 사용하여 userId와 documentId 추출 - Long userId = (Long) args[userIdIndex]; - Long documentId = (Long) args[documentIdIndex]; - - if (userId != null && documentId != null) { - // document type에 따라 조회하는 repository 구분하여 workspaceId, title 추출 - Long workspaceId = null; - String title = null; - - if(result instanceof MeetingResponseDTO.getMeetingProceeding meetingResponseDTO) { - workspaceId = meetingResponseDTO.getWorkspaceId(); - title = meetingResponseDTO.getTitle(); - } else if(result instanceof SnsEventResponseDTO.GetSnsEventRequest snsEventResponseDTO) { - workspaceId = snsEventResponseDTO.getWorkspaceId(); - title = snsEventResponseDTO.getTitle(); - } else if(result instanceof MoodTrackerResponseDTO.QuestionResult moodTrackerResponseDTO) { - workspaceId = moodTrackerResponseDTO.getWorkspaceId(); - title = moodTrackerResponseDTO.getTitle(); - } else if(result instanceof MoodTrackerResponseDTO.ResponseResult moodTrackerResponseDTO) { - workspaceId = moodTrackerResponseDTO.getWorkspaceId(); - title = moodTrackerResponseDTO.getTitle(); - } else if(result instanceof MoodTrackerResponseDTO.ReportResult moodTrackerResponseDTO) { - workspaceId = moodTrackerResponseDTO.getWorkspaceId(); - title = moodTrackerResponseDTO.getTitle(); - } - - userDocumentLastOpenedService.updateLastOpened(userId, type, documentId, workspaceId, title); - } - - return result; - } - -} diff --git a/src/main/java/com/haru/api/global/annotation/UpdateDocumentTitle.java b/src/main/java/com/haru/api/global/annotation/UpdateDocumentTitle.java new file mode 100644 index 00000000..24d8939f --- /dev/null +++ b/src/main/java/com/haru/api/global/annotation/UpdateDocumentTitle.java @@ -0,0 +1,11 @@ +package com.haru.api.global.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface UpdateDocumentTitle { +} diff --git a/src/main/java/com/haru/api/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/haru/api/global/apiPayload/code/status/ErrorStatus.java index 8e31f9ff..607921c6 100644 --- a/src/main/java/com/haru/api/global/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/com/haru/api/global/apiPayload/code/status/ErrorStatus.java @@ -25,6 +25,7 @@ public enum ErrorStatus implements BaseErrorCode { MEMBER_USERNAME_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER4005", "해당 아이디를 가진 유저가 존재하지 않습니다."), // MEMBER_PASSWORD_NOT_MATCH(HttpStatus.BAD_REQUEST, "MEMBER4006", "비밀번호가 일치하지 않습니다."), SAME_WITH_OLD_PASSWORD(HttpStatus.BAD_REQUEST, "MEMBER4007", "변경하고자하는 비밀번호와 이전 비밀번호가 일치합니다."), + MEMBER_HAS_NO_ACCESS_TO_MEETING(HttpStatus.FORBIDDEN, "MEMBER4008", "유저가 해당 문서에 접근 권한이 없습니다."), // Workspace 관련 에러 WORKSPACE_NOT_FOUND(HttpStatus.BAD_REQUEST,"WORKSPACE4001", "워크스페이스가 없습니다."), diff --git a/src/main/java/com/haru/api/global/argumentResolver/AuthDocumentArgumentResolver.java b/src/main/java/com/haru/api/global/argumentResolver/AuthDocumentArgumentResolver.java new file mode 100644 index 00000000..a5b4bf67 --- /dev/null +++ b/src/main/java/com/haru/api/global/argumentResolver/AuthDocumentArgumentResolver.java @@ -0,0 +1,91 @@ +package com.haru.api.global.argumentResolver; + +import com.haru.api.domain.lastOpened.entity.Documentable; +import com.haru.api.domain.lastOpened.entity.enums.DocumentType; +import com.haru.api.global.annotation.AuthDocument; +import com.haru.api.global.documentFinder.DocumentFinder; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import org.springframework.web.servlet.HandlerMapping; + +import java.lang.annotation.Annotation; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Component +public class AuthDocumentArgumentResolver implements HandlerMethodArgumentResolver { + + private final Map finders; + + public AuthDocumentArgumentResolver(List finderList) { + this.finders = finderList.stream() + .collect(Collectors.toMap(DocumentFinder::getSupportType, Function.identity())); + } + + // 어떤 파라미터를 해당 Resolver가 처리할지 결정하는 메서드 + @Override + public boolean supportsParameter(MethodParameter parameter) { + + // 파라미터에 @AuthDocument 계열 어노테이션이 있는지 확인 + AuthDocument authDocumentInfo = findAuthDocumentAnnotation(parameter); + + if (authDocumentInfo != null) { + // 파라미터 타입이 Documentable 인터페이스를 구현했는지 확인 + Class parameterType = parameter.getParameterType(); + return Documentable.class.isAssignableFrom(parameterType); + } + + return false; + } + + // supportsParameter가 true일 때, 파라미터에 주입할 객체를 반환 + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + + Object isValidated = webRequest.getAttribute("isValidated", NativeWebRequest.SCOPE_REQUEST); + + if (isValidated instanceof Boolean) { + + // 이미 interceptor에서 검증된 document 경우, 넘겨받은 document 반환 + return webRequest.getAttribute("validatedDocument", NativeWebRequest.SCOPE_REQUEST); + + } else { + // 파라미터의 어노테이션에서 @AuthDocument 어노테이션에서 정보 추출 + AuthDocument authDocument = findAuthDocumentAnnotation(parameter); + DocumentType documentType = authDocument.documentType(); + String pathVariableName = authDocument.pathVariableName(); + + // 요청 URL에서 path variable 추출 + Map pathVariables = (Map) webRequest.getAttribute( + HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, NativeWebRequest.SCOPE_REQUEST); + + String idString = pathVariables.get(pathVariableName); + if (idString == null) { + throw new IllegalStateException("경로 변수 '" + pathVariableName + "'를 찾을 수 없습니다."); + } + Long id = Long.parseLong(idString); + + // DocumentType에 맞는 finder를 찾아 객체를 조회 + DocumentFinder finder = finders.get(documentType); + if (finder == null) + throw new IllegalStateException(documentType + " 타입을 처리할 Finder가 없습니다."); + + return finder.findById(id); + } + } + + private AuthDocument findAuthDocumentAnnotation(MethodParameter parameter) { + for (Annotation annotation : parameter.getParameterAnnotations()) { + if (annotation.annotationType().isAnnotationPresent(AuthDocument.class)) { + return annotation.annotationType().getAnnotation(AuthDocument.class); + } + } + throw new IllegalStateException("AuthDocument 어노테이션을 찾을 수 없습니다."); + } +} diff --git a/src/main/java/com/haru/api/global/annotation/AuthUserArgumentResolver.java b/src/main/java/com/haru/api/global/argumentResolver/AuthUserArgumentResolver.java similarity index 62% rename from src/main/java/com/haru/api/global/annotation/AuthUserArgumentResolver.java rename to src/main/java/com/haru/api/global/argumentResolver/AuthUserArgumentResolver.java index cf563e86..b204b81e 100644 --- a/src/main/java/com/haru/api/global/annotation/AuthUserArgumentResolver.java +++ b/src/main/java/com/haru/api/global/argumentResolver/AuthUserArgumentResolver.java @@ -1,8 +1,9 @@ -package com.haru.api.global.annotation; +package com.haru.api.global.argumentResolver; import com.haru.api.domain.user.entity.User; import com.haru.api.domain.user.repository.UserRepository; import com.haru.api.domain.user.security.jwt.SecurityUtil; +import com.haru.api.global.annotation.AuthUser; import com.haru.api.global.apiPayload.code.status.ErrorStatus; import com.haru.api.global.apiPayload.exception.handler.MemberHandler; import lombok.RequiredArgsConstructor; @@ -29,12 +30,19 @@ public boolean supportsParameter(MethodParameter parameter) { public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { - // 현재 로그인 해 있는 유저 ID 반환 - Long userId = SecurityUtil.getCurrentUserId(); + Object isValidated = webRequest.getAttribute("isValidated", NativeWebRequest.SCOPE_REQUEST); - // 해당 유저가 존재하는지 확인하고, 존재하면 해당 user 객체 반환 - return userRepository.findById(userId) - .orElseThrow(() -> new MemberHandler(ErrorStatus.MEMBER_NOT_FOUND)); + if (isValidated instanceof Boolean) { + // 이미 interceptor에서 검증된 유저인 경우, 넘겨받은 user 반환 + return webRequest.getAttribute("validatedUser", NativeWebRequest.SCOPE_REQUEST); + } else { + // 현재 로그인 해 있는 유저 ID 반환 + Long userId = SecurityUtil.getCurrentUserId(); + + // 해당 유저가 존재하는지 확인하고, 존재하면 해당 user 객체 반환 + return userRepository.findById(userId) + .orElseThrow(() -> new MemberHandler(ErrorStatus.MEMBER_NOT_FOUND)); + } } } diff --git a/src/main/java/com/haru/api/global/annotation/AuthWorkspaceArgumentResolver.java b/src/main/java/com/haru/api/global/argumentResolver/AuthWorkspaceArgumentResolver.java similarity index 54% rename from src/main/java/com/haru/api/global/annotation/AuthWorkspaceArgumentResolver.java rename to src/main/java/com/haru/api/global/argumentResolver/AuthWorkspaceArgumentResolver.java index 1de8bc92..ec884299 100644 --- a/src/main/java/com/haru/api/global/annotation/AuthWorkspaceArgumentResolver.java +++ b/src/main/java/com/haru/api/global/argumentResolver/AuthWorkspaceArgumentResolver.java @@ -1,7 +1,8 @@ -package com.haru.api.global.annotation; +package com.haru.api.global.argumentResolver; import com.haru.api.domain.workspace.entity.Workspace; import com.haru.api.domain.workspace.repository.WorkspaceRepository; +import com.haru.api.global.annotation.AuthWorkspace; import com.haru.api.global.apiPayload.code.status.ErrorStatus; import com.haru.api.global.apiPayload.exception.handler.WorkspaceHandler; import jakarta.servlet.http.HttpServletRequest; @@ -32,17 +33,28 @@ public boolean supportsParameter(MethodParameter parameter) { public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { - final HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + Object isValidated = webRequest.getAttribute("isValidated", NativeWebRequest.SCOPE_REQUEST); - // URL pathVariable에서 workspaceId 추출 - final Map pathVariables = (Map) request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE); + if (isValidated instanceof Boolean) { - if (pathVariables == null) throw new RuntimeException("empty path variables"); + // 이미 interceptor에서 검증된 workspace 경우, 넘겨받은 workspace 반환 + return webRequest.getAttribute("validatedWorkspace", NativeWebRequest.SCOPE_REQUEST); - final String workspaceId = pathVariables.get("workspaceId"); + } else { - // workspace 존재하는지 확인하고, 존재한다면 해당 workspace 객체 반환 - return workspaceRepository.findById(Long.parseLong(workspaceId)) - .orElseThrow(() -> new WorkspaceHandler(ErrorStatus.WORKSPACE_NOT_FOUND)); + final HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + + // URL pathVariable에서 workspaceId 추출 + final Map pathVariables = (Map) request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE); + + if (pathVariables == null) throw new RuntimeException("empty path variables"); + + final String workspaceId = pathVariables.get("workspaceId"); + + // workspace 존재하는지 확인하고, 존재한다면 해당 workspace 객체 반환 + return workspaceRepository.findById(Long.parseLong(workspaceId)) + .orElseThrow(() -> new WorkspaceHandler(ErrorStatus.WORKSPACE_NOT_FOUND)); + + } } } diff --git a/src/main/java/com/haru/api/global/aspect/DeleteDocumentAspect.java b/src/main/java/com/haru/api/global/aspect/DeleteDocumentAspect.java new file mode 100644 index 00000000..0d08206e --- /dev/null +++ b/src/main/java/com/haru/api/global/aspect/DeleteDocumentAspect.java @@ -0,0 +1,39 @@ +package com.haru.api.global.aspect; + +import com.haru.api.domain.lastOpened.entity.Documentable; +import com.haru.api.domain.lastOpened.service.UserDocumentLastOpenedService; +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.AfterReturning; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; + +@Aspect +@Component +@RequiredArgsConstructor +public class DeleteDocumentAspect { + + private final UserDocumentLastOpenedService userDocumentLastOpenedService; + + @AfterReturning("@annotation(com.haru.api.global.annotation.DeleteDocument)") + public void afterDeleteDocument(JoinPoint joinPoint) { + + // 실행된 메서드의 인자 추출 + Object[] args = joinPoint.getArgs(); + + Documentable document = null; + + // 인자들 중에서 Documentable 타입의 객체 추출 + for (Object arg : args) { + if (arg instanceof Documentable) { + document = (Documentable) arg; + break; + } + } + + // Documentable 객체를 토대로 서비스 로직 호출 + if (document != null) { + userDocumentLastOpenedService.deleteRecordsForWorkspaceUsers(document); + } + } +} diff --git a/src/main/java/com/haru/api/global/aspect/LastOpenedAspect.java b/src/main/java/com/haru/api/global/aspect/LastOpenedAspect.java new file mode 100644 index 00000000..2891b4d9 --- /dev/null +++ b/src/main/java/com/haru/api/global/aspect/LastOpenedAspect.java @@ -0,0 +1,53 @@ +package com.haru.api.global.aspect; + +import com.haru.api.domain.lastOpened.entity.Documentable; +import com.haru.api.domain.lastOpened.entity.UserDocumentId; +import com.haru.api.domain.lastOpened.entity.enums.DocumentType; +import com.haru.api.domain.lastOpened.service.UserDocumentLastOpenedService; +import com.haru.api.domain.user.entity.User; +import com.haru.api.global.annotation.TrackLastOpened; +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@Aspect +@Component +@RequiredArgsConstructor +@Order(1) +public class LastOpenedAspect { + + private final UserDocumentLastOpenedService userDocumentLastOpenedService; + + @Around("@annotation(trackLastOpened)") + public Object trackLastOpened(ProceedingJoinPoint joinPoint, TrackLastOpened trackLastOpened) throws Throwable { + // 실제 메서드 실행 + Object result = joinPoint.proceed(); + + DocumentType type = trackLastOpened.type(); + int userIdIndex = trackLastOpened.userIdIndex(); + int documentIdIndex = trackLastOpened.documentIdIndex(); + + // 메서드의 인자에서 userId와 documentId 추출 + Object[] args = joinPoint.getArgs(); + + // 인덱스를 사용하여 user와 document 추출 + User user = (User)args[userIdIndex]; + Documentable document = (Documentable)args[documentIdIndex]; + + if (user != null && document != null) { + + Long workspaceId = document.getWorkspaceId(); + String title = document.getTitle(); + + UserDocumentId userDocumentId = new UserDocumentId(user.getId(), document.getId(), document.getDocumentType()); + + userDocumentLastOpenedService.updateLastOpened(userDocumentId, workspaceId, title); + } + + return result; + } + +} diff --git a/src/main/java/com/haru/api/global/aspect/UpdateDocumentTitleAspect.java b/src/main/java/com/haru/api/global/aspect/UpdateDocumentTitleAspect.java new file mode 100644 index 00000000..76d23770 --- /dev/null +++ b/src/main/java/com/haru/api/global/aspect/UpdateDocumentTitleAspect.java @@ -0,0 +1,42 @@ +package com.haru.api.global.aspect; + +import com.haru.api.domain.lastOpened.entity.Documentable; +import com.haru.api.domain.lastOpened.service.UserDocumentLastOpenedService; +import com.haru.api.global.common.entity.TitleHolder; +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.AfterReturning; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; + +@Aspect +@Component +@RequiredArgsConstructor +public class UpdateDocumentTitleAspect { + + private final UserDocumentLastOpenedService userDocumentLastOpenedService; + + @AfterReturning("@annotation(com.haru.api.global.annotation.UpdateDocumentTitle)") + public void afterTitleUpdate(JoinPoint joinPoint) { + + Documentable document = null; + TitleHolder titleHolder = null; + + // 실행된 메서드의 인자 추출 + Object[] args = joinPoint.getArgs(); + + // 인자들 중에서 Documentable 타입의 객체 추출 + for (Object arg : args) { + if (arg instanceof Documentable) { + document = (Documentable) arg; + } else if (arg instanceof TitleHolder) { + titleHolder = (TitleHolder) arg; + } + } + + // document, titleHolder가 정상적으로 조회되면 last opened 테이블에서 해당 문서의 제목 수정 + if (document != null && titleHolder != null) { + userDocumentLastOpenedService.updateRecordsForWorkspaceUsers(document, titleHolder); + } + } +} diff --git a/src/main/java/com/haru/api/global/common/entity/TitleHolder.java b/src/main/java/com/haru/api/global/common/entity/TitleHolder.java new file mode 100644 index 00000000..614fd90b --- /dev/null +++ b/src/main/java/com/haru/api/global/common/entity/TitleHolder.java @@ -0,0 +1,5 @@ +package com.haru.api.global.common.entity; + +public interface TitleHolder { + String getTitle(); +} diff --git a/src/main/java/com/haru/api/global/config/WebConfig.java b/src/main/java/com/haru/api/global/config/WebConfig.java index b8a2473d..145656a6 100644 --- a/src/main/java/com/haru/api/global/config/WebConfig.java +++ b/src/main/java/com/haru/api/global/config/WebConfig.java @@ -1,7 +1,9 @@ package com.haru.api.global.config; -import com.haru.api.global.annotation.AuthUserArgumentResolver; -import com.haru.api.global.annotation.AuthWorkspaceArgumentResolver; +import com.haru.api.global.argumentResolver.AuthDocumentArgumentResolver; +import com.haru.api.global.argumentResolver.AuthUserArgumentResolver; +import com.haru.api.global.argumentResolver.AuthWorkspaceArgumentResolver; +import com.haru.api.global.interceptor.DocumentMemberAuthInterceptor; import com.haru.api.global.interceptor.WorkspaceMemberAuthInterceptor; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; @@ -18,7 +20,10 @@ public class WebConfig implements WebMvcConfigurer { private final AuthUserArgumentResolver authUserArgumentResolver; private final AuthWorkspaceArgumentResolver authWorkspaceArgumentResolver; + private final AuthDocumentArgumentResolver authDocumentArgumentResolver; + private final WorkspaceMemberAuthInterceptor workspaceMemberAuthInterceptor; + private final DocumentMemberAuthInterceptor documentMemberAuthInterceptor; @Override public void addCorsMappings(CorsRegistry registry) { @@ -33,10 +38,12 @@ public void addCorsMappings(CorsRegistry registry) { public void addArgumentResolvers(List argumentResolvers) { argumentResolvers.add(authUserArgumentResolver); argumentResolvers.add(authWorkspaceArgumentResolver); + argumentResolvers.add(authDocumentArgumentResolver); } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(workspaceMemberAuthInterceptor); + registry.addInterceptor(documentMemberAuthInterceptor); } } diff --git a/src/main/java/com/haru/api/global/documentFinder/DocumentFinder.java b/src/main/java/com/haru/api/global/documentFinder/DocumentFinder.java new file mode 100644 index 00000000..4aa3c435 --- /dev/null +++ b/src/main/java/com/haru/api/global/documentFinder/DocumentFinder.java @@ -0,0 +1,8 @@ +package com.haru.api.global.documentFinder; + +import com.haru.api.domain.lastOpened.entity.enums.DocumentType; + +public interface DocumentFinder { + DocumentType getSupportType(); + Object findById(Object id); +} diff --git a/src/main/java/com/haru/api/global/documentFinder/MeetingFinder.java b/src/main/java/com/haru/api/global/documentFinder/MeetingFinder.java new file mode 100644 index 00000000..8147b0ff --- /dev/null +++ b/src/main/java/com/haru/api/global/documentFinder/MeetingFinder.java @@ -0,0 +1,26 @@ +package com.haru.api.global.documentFinder; + +import com.haru.api.domain.lastOpened.entity.enums.DocumentType; +import com.haru.api.domain.meeting.repository.MeetingRepository; +import com.haru.api.global.apiPayload.code.status.ErrorStatus; +import com.haru.api.global.apiPayload.exception.handler.MeetingHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MeetingFinder implements DocumentFinder{ + + private final MeetingRepository meetingRepository; + + @Override + public DocumentType getSupportType() { + return DocumentType.AI_MEETING_MANAGER; + } + + @Override + public Object findById(Object id) { + return meetingRepository.findById((Long)id) + .orElseThrow(() -> new MeetingHandler(ErrorStatus.MEETING_NOT_FOUND)); + } +} diff --git a/src/main/java/com/haru/api/global/interceptor/DocumentMemberAuthInterceptor.java b/src/main/java/com/haru/api/global/interceptor/DocumentMemberAuthInterceptor.java new file mode 100644 index 00000000..0d386243 --- /dev/null +++ b/src/main/java/com/haru/api/global/interceptor/DocumentMemberAuthInterceptor.java @@ -0,0 +1,103 @@ +package com.haru.api.global.interceptor; + +import com.haru.api.domain.lastOpened.entity.enums.DocumentType; +import com.haru.api.domain.meeting.repository.MeetingRepository; +import com.haru.api.domain.user.entity.User; +import com.haru.api.domain.user.repository.UserRepository; +import com.haru.api.domain.user.security.jwt.SecurityUtil; +import com.haru.api.global.annotation.AuthDocument; +import com.haru.api.global.annotation.AuthUser; +import com.haru.api.global.apiPayload.code.status.ErrorStatus; +import com.haru.api.global.apiPayload.exception.handler.MemberHandler; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.apache.coyote.BadRequestException; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.HandlerMapping; + +import java.lang.annotation.Annotation; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class DocumentMemberAuthInterceptor implements HandlerInterceptor { + + private final UserRepository userRepository; + + private final MeetingRepository meetingRepository; + + @Override + public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response, final Object handler) throws Exception { + + // 컨트롤러 메서드인지 확인 + if (!(handler instanceof HandlerMethod)) { + return true; + } + + final HandlerMethod handlerMethod = (HandlerMethod) handler; + + // @AuthUser, @AuthDocument 어노테이션이 달린 인자가 있는지 확인 + boolean hasAuthUserParam = false; + AuthDocument authDocumentInfo = null; + + for (final var param : handlerMethod.getMethodParameters()) { + if (param.hasParameterAnnotation(AuthUser.class)) { + hasAuthUserParam = true; + } + + // 파라미터에 붙은 모든 어노테이션을 순회 + for (final Annotation annotation : param.getParameterAnnotations()) { + // 해당 어노테이션의 타입에 @AuthDocument 메타 어노테이션이 있는지 확인 + if (annotation.annotationType().isAnnotationPresent(AuthDocument.class)) { + // @AuthDocument 어노테이션의 실제 인스턴스를 가져와 저장 + authDocumentInfo = annotation.annotationType().getAnnotation(AuthDocument.class); + break; + } + } + } + + if (hasAuthUserParam && authDocumentInfo != null) { + + // AuthDocument에서 DocumentType, pathVariableName 추출 + DocumentType documentType = authDocumentInfo.documentType(); + String pathVariableName = authDocumentInfo.pathVariableName(); + + // URL pathvariable에서 documentId 추출 + final Map pathVariables = + (Map) request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE); + final String documentIdStr = pathVariables.get(pathVariableName); + + if (documentIdStr == null) { + throw new BadRequestException("경로 변수 " + pathVariableName + "가 없습니다."); + } + + // userId, documentId 추출 + final Long userId = SecurityUtil.getCurrentUserId(); + final Long documentId = Long.parseLong(documentIdStr); + + Object foundDocument = null; + + switch (documentType) { + case AI_MEETING_MANAGER: + foundDocument = meetingRepository.findMeetingByIdIfUserHasAccess(userId, documentId) + .orElseThrow(() -> new MemberHandler(ErrorStatus.MEMBER_HAS_NO_ACCESS_TO_MEETING)); + break; + } + + // 유저 조회 + User foundUser = userRepository.findById(userId) + .orElseThrow(() -> new MemberHandler(ErrorStatus.MEMBER_NOT_FOUND)); + + // request에 attribute 저장 + request.setAttribute("isValidated", true); + request.setAttribute("validatedUser", foundUser); + request.setAttribute("validatedDocument", foundDocument); + } + + return true; + } + +} diff --git a/src/main/java/com/haru/api/global/interceptor/WorkspaceMemberAuthInterceptor.java b/src/main/java/com/haru/api/global/interceptor/WorkspaceMemberAuthInterceptor.java index 83c9a626..63500d1b 100644 --- a/src/main/java/com/haru/api/global/interceptor/WorkspaceMemberAuthInterceptor.java +++ b/src/main/java/com/haru/api/global/interceptor/WorkspaceMemberAuthInterceptor.java @@ -1,11 +1,17 @@ package com.haru.api.global.interceptor; +import com.haru.api.domain.user.entity.User; +import com.haru.api.domain.user.repository.UserRepository; import com.haru.api.domain.user.security.jwt.SecurityUtil; import com.haru.api.domain.userWorkspace.repository.UserWorkspaceRepository; +import com.haru.api.domain.workspace.entity.Workspace; +import com.haru.api.domain.workspace.repository.WorkspaceRepository; import com.haru.api.global.annotation.AuthUser; import com.haru.api.global.annotation.AuthWorkspace; import com.haru.api.global.apiPayload.code.status.ErrorStatus; +import com.haru.api.global.apiPayload.exception.handler.MemberHandler; import com.haru.api.global.apiPayload.exception.handler.UserWorkspaceHandler; +import com.haru.api.global.apiPayload.exception.handler.WorkspaceHandler; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -20,8 +26,12 @@ @RequiredArgsConstructor public class WorkspaceMemberAuthInterceptor implements HandlerInterceptor { + private final UserRepository userRepository; + private final UserWorkspaceRepository userWorkspaceRepository; + private final WorkspaceRepository workspaceRepository; + @Override public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response, final Object handler) throws Exception { @@ -48,7 +58,7 @@ public boolean preHandle(final HttpServletRequest request, final HttpServletResp // 컨트롤러에 AuthUser, AuthWorkspace 어노테이션이 모두 달린 경우에 해당 if(hasAuthUserParam && hasAuthWorkspaceParam) { - // 1. URL pathVariable에서 workspaceId 추출 + // URL pathVariable에서 workspaceId 추출 final Map pathVariables = (Map) request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE); final String workspaceId = pathVariables.get("workspaceId"); @@ -58,14 +68,26 @@ public boolean preHandle(final HttpServletRequest request, final HttpServletResp return false; } - // 2. Security Context에서 현재 유저 ID 추출 + // Security Context에서 현재 유저 ID 추출 final Long userId = SecurityUtil.getCurrentUserId(); - // 3. 유저가 워크스페이스에 속하는지 확인 - final boolean isUserInWorkspace = userWorkspaceRepository.existsByUserIdAndWorkspaceId(userId, Long.parseLong(workspaceId)); + // 유저 조회 + User foundUser = userRepository.findById(userId) + .orElseThrow(() -> new MemberHandler(ErrorStatus.MEMBER_NOT_FOUND)); + + // 워크스페이스 조회 + Workspace foundWorkspace = workspaceRepository.findById(Long.parseLong(workspaceId)) + .orElseThrow(() -> new WorkspaceHandler(ErrorStatus.WORKSPACE_NOT_FOUND)); - // 4. 속하지 않는 경우 예외 발생 + // 유저가 워크스페이스에 속하는지 확인 + final boolean isUserInWorkspace = userWorkspaceRepository.existsByUserIdAndWorkspaceId(foundUser.getId(), foundWorkspace.getId()); + + // 속하지 않는 경우 예외 발생 if(!isUserInWorkspace) throw new UserWorkspaceHandler(ErrorStatus.USER_WORKSPACE_NOT_FOUND); + + request.setAttribute("isValidated", true); + request.setAttribute("validatedUser", foundUser); + request.setAttribute("validatedWorkspace", foundWorkspace); } return true;