diff --git a/src/main/java/com/haru/api/domain/lastOpened/converter/UserDocumentLastOpenedConverter.java b/src/main/java/com/haru/api/domain/lastOpened/converter/UserDocumentLastOpenedConverter.java index ea5571c7..aadcbc12 100644 --- a/src/main/java/com/haru/api/domain/lastOpened/converter/UserDocumentLastOpenedConverter.java +++ b/src/main/java/com/haru/api/domain/lastOpened/converter/UserDocumentLastOpenedConverter.java @@ -1,44 +1,22 @@ package com.haru.api.domain.lastOpened.converter; +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.meeting.entity.Meeting; -import com.haru.api.domain.moodTracker.entity.MoodTracker; -import com.haru.api.domain.snsEvent.entity.SnsEvent; import com.haru.api.domain.user.entity.User; public class UserDocumentLastOpenedConverter { - public static UserDocumentLastOpened toUserDocumentLastOpened(Meeting meeting, User user) { + public static UserDocumentLastOpened toUserDocumentLastOpened(Documentable document, User user) { - UserDocumentId userDocumentId = new UserDocumentId(user.getId(), meeting.getId(), DocumentType.AI_MEETING_MANAGER); - - return UserDocumentLastOpened.builder() - .id(userDocumentId) - .user(user) - .lastOpened(null) - .build(); - } - - public static UserDocumentLastOpened toUserDocumentLastOpened(SnsEvent snsEvent, User user) { - - UserDocumentId userDocumentId = new UserDocumentId(user.getId(), snsEvent.getId(), DocumentType.SNS_EVENT_ASSISTANT); - - return UserDocumentLastOpened.builder() - .id(userDocumentId) - .user(user) - .lastOpened(null) - .build(); - } - - public static UserDocumentLastOpened toUserDocumentLastOpened(MoodTracker moodTracker, User user) { - - UserDocumentId userDocumentId = new UserDocumentId(user.getId(), moodTracker.getId(), DocumentType.TEAM_MOOD_TRACKER); + UserDocumentId userDocumentId = new UserDocumentId(user.getId(), document.getId(), document.getDocumentType()); return UserDocumentLastOpened.builder() .id(userDocumentId) .user(user) + .workspaceId(document.getWorkspaceId()) .lastOpened(null) + .thumbnailKeyName(document.getThumbnailKeyName()) + .title(document.getTitle()) .build(); } } 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..68997067 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) @@ -50,6 +49,7 @@ public void updateLastOpened(Long userId, DocumentType documentType, Long docume } @Override + @Transactional public void createInitialRecordsForWorkspaceUsers(List usersInWorkspace, Documentable document) { // 저장할 엔티티 리스트 생성 @@ -86,6 +86,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/snsEvent/controller/SnsEventController.java b/src/main/java/com/haru/api/domain/snsEvent/controller/SnsEventController.java index 6cf6f77f..03ff0436 100644 --- a/src/main/java/com/haru/api/domain/snsEvent/controller/SnsEventController.java +++ b/src/main/java/com/haru/api/domain/snsEvent/controller/SnsEventController.java @@ -2,17 +2,20 @@ import com.haru.api.domain.snsEvent.dto.SnsEventRequestDTO; import com.haru.api.domain.snsEvent.dto.SnsEventResponseDTO; +import com.haru.api.domain.snsEvent.entity.SnsEvent; import com.haru.api.domain.snsEvent.entity.enums.Format; import com.haru.api.domain.snsEvent.entity.enums.ListType; import com.haru.api.domain.snsEvent.service.SnsEventCommandService; import com.haru.api.domain.snsEvent.service.SnsEventQueryService; -import com.haru.api.domain.user.security.jwt.SecurityUtil; +import com.haru.api.domain.user.entity.User; +import com.haru.api.domain.workspace.entity.Workspace; +import com.haru.api.global.annotation.AuthSnsEvent; +import com.haru.api.global.annotation.AuthUser; +import com.haru.api.global.annotation.AuthWorkspace; import com.haru.api.global.apiPayload.ApiResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController @@ -31,10 +34,12 @@ public class SnsEventController { @PostMapping("/{workspaceId}") public ApiResponse instagramOauthRedirectUri( @PathVariable String workspaceId, - @RequestBody SnsEventRequestDTO.CreateSnsRequest request + @RequestBody SnsEventRequestDTO.CreateSnsRequest request, + @Parameter(hidden = true) @AuthUser User user, + @Parameter(hidden = true) @AuthWorkspace Workspace workspace ) { return ApiResponse.onSuccess( - snsEventCommandService.createSnsEvent(Long.parseLong(workspaceId), request) + snsEventCommandService.createSnsEvent(user, workspace, request) ); } @@ -58,11 +63,12 @@ public ApiResponse instagramRedirectUri( @PostMapping("/{workspaceId}/link-instagram") public ApiResponse linkInstagramAccount( @RequestHeader("code") String code, - @PathVariable String workspaceId + @PathVariable String workspaceId, + @Parameter(hidden = true) @AuthWorkspace Workspace workspace ) { System.out.println("Received accessToken: " + code); return ApiResponse.onSuccess( - snsEventCommandService.getInstagramAccessTokenAndAccount(code, Long.parseLong(workspaceId)) + snsEventCommandService.getInstagramAccessTokenAndAccount(code, workspace) ); } @@ -73,11 +79,13 @@ public ApiResponse linkInstagr ) @GetMapping("/{workspaceId}/list") public ApiResponse getSnsEventList( - @PathVariable String workspaceId + @PathVariable String workspaceId, + @Parameter(hidden = true) @AuthUser User user, + @Parameter(hidden = true) @AuthWorkspace Workspace workspace ) { - Long userId = SecurityUtil.getCurrentUserId(); + return ApiResponse.onSuccess( - snsEventQueryService.getSnsEventList(userId, Long.parseLong(workspaceId)) + snsEventQueryService.getSnsEventList(user, workspace) ); } @@ -86,14 +94,18 @@ public ApiResponse getSnsEventList( description = "# [v1.0 (2025-08-05)](https://www.notion.so/2265da7802c580e8b883e3e4481fd61d?v=2265da7802c5816ab095000cc1ddadca&p=22a5da7802c580d3bed7c57de0b88492&pm=s)" + " SNS 이벤트명 수정 API입니다. Header에 access token을 넣고 Path Variable에는 snsEvnetId를 Request Body에 SNS 이벤트 수정 정보(title)를 담아 요청해주세요." ) - @PatchMapping("/{snsEvnetId}") + @PatchMapping("/{snsEventId}") public ApiResponse updateSnsEventTitle( - @PathVariable String snsEvnetId, - @RequestBody SnsEventRequestDTO.UpdateSnsEventRequest request + @PathVariable String snsEventId, + @RequestBody SnsEventRequestDTO.UpdateSnsEventRequest request, + @Parameter(hidden = true) @AuthUser User user, + @Parameter(hidden = true) @AuthSnsEvent SnsEvent snsEvent ) { - Long userId = SecurityUtil.getCurrentUserId(); - snsEventCommandService.updateSnsEventTitle(userId, Long.parseLong(snsEvnetId), request); + + snsEventCommandService.updateSnsEventTitle(user, snsEvent, request); + return ApiResponse.onSuccess(""); + } @Operation( @@ -101,13 +113,18 @@ public ApiResponse updateSnsEventTitle( description = "# [v1.0 (2025-08-05)](https://www.notion.so/2265da7802c580e8b883e3e4481fd61d?v=2265da7802c5816ab095000cc1ddadca&p=2265da7802c5809b84d3d8c09f95c36b&pm=s)" + " SNS 이벤트 삭제 API입니다. Header에 access token을 넣고 Path Variable에는 삭제할 SNS Event의 snsEvnetId를 담아 요청해주세요." ) - @DeleteMapping("/{snsEvnetId}") + @DeleteMapping("/{snsEventId}") public ApiResponse deleteSnsEvent( - @PathVariable String snsEvnetId + @PathVariable String snsEventId, + @Parameter(hidden = true) @AuthUser User user, + @Parameter(hidden = true) @AuthSnsEvent SnsEvent snsEvent + ) { - Long userId = SecurityUtil.getCurrentUserId(); - snsEventCommandService.deleteSnsEvent(userId, Long.parseLong(snsEvnetId)); + + snsEventCommandService.deleteSnsEvent(user, snsEvent); + return ApiResponse.onSuccess(""); + } @Operation( @@ -117,11 +134,12 @@ public ApiResponse deleteSnsEvent( ) @GetMapping("/{snsEventId}") public ApiResponse getSnsEvent( - @PathVariable String snsEventId + @PathVariable String snsEventId, + @Parameter(hidden = true) @AuthUser User user, + @Parameter(hidden = true) @AuthSnsEvent SnsEvent snsEvent ) { - Long userId = SecurityUtil.getCurrentUserId(); return ApiResponse.onSuccess( - snsEventQueryService.getSnsEvent(userId, Long.parseLong(snsEventId)) + snsEventQueryService.getSnsEvent(user, snsEvent) ); } @@ -134,27 +152,18 @@ public ApiResponse getSnsEvent( public ApiResponse downloadList( @PathVariable String snsEventId, @RequestParam ListType listType, - @RequestParam Format format + @RequestParam Format format, + @Parameter(hidden = true) @AuthUser User user, + @Parameter(hidden = true) @AuthSnsEvent SnsEvent snsEvent ) { - Long userId = SecurityUtil.getCurrentUserId(); return ApiResponse.onSuccess( snsEventCommandService.downloadList( - userId, - Long.parseLong(snsEventId), + user, + snsEvent, listType, format ) ); - -// String listTypefileName = listType == ListType.PARTICIPANT ? "참여자" : "당첨자"; -// String filename = format == Format.PDF ? listTypefileName + ".pdf" : listTypefileName + ".docx"; -// String contentType = format == Format.PDF -// ? MediaType.APPLICATION_PDF_VALUE -// : "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; -// -// return ResponseEntity.ok() -// .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") -// .contentType(MediaType.parseMediaType(contentType)) -// .body(fileBytes); } + } diff --git a/src/main/java/com/haru/api/domain/snsEvent/dto/SnsEventRequestDTO.java b/src/main/java/com/haru/api/domain/snsEvent/dto/SnsEventRequestDTO.java index 127bced6..11ffc926 100644 --- a/src/main/java/com/haru/api/domain/snsEvent/dto/SnsEventRequestDTO.java +++ b/src/main/java/com/haru/api/domain/snsEvent/dto/SnsEventRequestDTO.java @@ -1,5 +1,6 @@ package com.haru.api.domain.snsEvent.dto; +import com.haru.api.global.common.entity.TitleHolder; import lombok.*; import java.time.LocalDateTime; @@ -33,7 +34,7 @@ public static class SnsCondition { @Builder @NoArgsConstructor @AllArgsConstructor - public static class UpdateSnsEventRequest { + public static class UpdateSnsEventRequest implements TitleHolder { private String title; } } diff --git a/src/main/java/com/haru/api/domain/snsEvent/entity/SnsEvent.java b/src/main/java/com/haru/api/domain/snsEvent/entity/SnsEvent.java index 69406f33..9fa17e19 100644 --- a/src/main/java/com/haru/api/domain/snsEvent/entity/SnsEvent.java +++ b/src/main/java/com/haru/api/domain/snsEvent/entity/SnsEvent.java @@ -30,13 +30,12 @@ public class SnsEvent extends BaseEntity implements Documentable { @Column(nullable = false, length = 100) private String title; - @Column(length = 255) private String snsLink; @Column(length = 200) private String snsLinkTitle; - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.EAGER) @JoinColumn(name = "user_id") private User creator; diff --git a/src/main/java/com/haru/api/domain/snsEvent/repository/SnsEventRepository.java b/src/main/java/com/haru/api/domain/snsEvent/repository/SnsEventRepository.java index 54984d8c..1b6b827e 100644 --- a/src/main/java/com/haru/api/domain/snsEvent/repository/SnsEventRepository.java +++ b/src/main/java/com/haru/api/domain/snsEvent/repository/SnsEventRepository.java @@ -8,6 +8,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; @Repository public interface SnsEventRepository extends JpaRepository { @@ -22,4 +23,11 @@ public interface SnsEventRepository extends JpaRepository { "WHERE mt.workspace.id = :workspaceId " + "AND mt.createdAt BETWEEN :startDate AND :endDate") List findAllDocumentForCalendars(Long workspaceId, LocalDateTime startDate, LocalDateTime endDate); + + @Query("SELECT se FROM SnsEvent se " + + "WHERE se.id = :snsEventId AND EXISTS (" + + " SELECT 1 FROM UserWorkspace uw " + + " WHERE uw.user.id = :userId AND uw.workspace.id = se.workspace.id" + + ")") + Optional findSnsEventByIdIfUserHasAccess(Long userId, Long snsEventId); } diff --git a/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventCommandService.java b/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventCommandService.java index c7ab30ad..cda950e2 100644 --- a/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventCommandService.java +++ b/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventCommandService.java @@ -2,17 +2,20 @@ import com.haru.api.domain.snsEvent.dto.SnsEventRequestDTO; import com.haru.api.domain.snsEvent.dto.SnsEventResponseDTO; +import com.haru.api.domain.snsEvent.entity.SnsEvent; import com.haru.api.domain.snsEvent.entity.enums.Format; import com.haru.api.domain.snsEvent.entity.enums.ListType; +import com.haru.api.domain.user.entity.User; +import com.haru.api.domain.workspace.entity.Workspace; public interface SnsEventCommandService { - SnsEventResponseDTO.CreateSnsEventResponse createSnsEvent(Long workspaceId, SnsEventRequestDTO.CreateSnsRequest request); + SnsEventResponseDTO.CreateSnsEventResponse createSnsEvent(User user, Workspace workspace, SnsEventRequestDTO.CreateSnsRequest request); - SnsEventResponseDTO.LinkInstagramAccountResponse getInstagramAccessTokenAndAccount(String code, Long workspaceId); + SnsEventResponseDTO.LinkInstagramAccountResponse getInstagramAccessTokenAndAccount(String code, Workspace workspace); - void updateSnsEventTitle(Long userId, Long snsEventId, SnsEventRequestDTO.UpdateSnsEventRequest request); + void updateSnsEventTitle(User user, SnsEvent snsEvent, SnsEventRequestDTO.UpdateSnsEventRequest request); - void deleteSnsEvent(Long userId, Long snsEventId); + void deleteSnsEvent(User user, SnsEvent snsEvent); - SnsEventResponseDTO.ListDownLoadLinkResponse downloadList(Long userId, Long snsEventId, ListType listType, Format format); + SnsEventResponseDTO.ListDownLoadLinkResponse downloadList(User user, SnsEvent snsEvent, ListType listType, Format format); } diff --git a/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventCommandServiceImpl.java b/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventCommandServiceImpl.java index 5b08770e..0496e02c 100644 --- a/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventCommandServiceImpl.java +++ b/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventCommandServiceImpl.java @@ -1,6 +1,5 @@ package com.haru.api.domain.snsEvent.service; -import com.haru.api.domain.lastOpened.repository.UserDocumentLastOpenedRepository; import com.haru.api.domain.lastOpened.service.UserDocumentLastOpenedService; import com.haru.api.domain.snsEvent.converter.SnsEventConverter; import com.haru.api.domain.snsEvent.dto.SnsEventRequestDTO; @@ -14,13 +13,13 @@ import com.haru.api.domain.snsEvent.repository.SnsEventRepository; import com.haru.api.domain.snsEvent.repository.WinnerRepository; 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.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.exception.handler.MemberHandler; import com.haru.api.global.apiPayload.exception.handler.SnsEventHandler; import com.haru.api.global.apiPayload.exception.handler.WorkspaceHandler; @@ -34,7 +33,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.poi.xwpf.usermodel.*; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClientException; @@ -56,20 +54,13 @@ public class SnsEventCommandServiceImpl implements SnsEventCommandService{ private final SpringTemplateEngine templateEngine; - @Value("${instagram.client.id}") - private String instagramClientId; - @Value("${instagram.client.secret}") - private String instagramClientSecret; - @Value("${instagram.redirect.uri}") - private String instagramRedirectUri; + private final SnsEventRepository snsEventRepository; - private final UserRepository userRepository; private final WorkspaceRepository workspaceRepository; private final UserWorkspaceRepository userWorkspaceRepository; private final ParticipantRepository participantRepository; private final WinnerRepository winnerRepository; private final RestTemplate restTemplate; - private final UserDocumentLastOpenedRepository userDocumentLastOpenedRepository; private final UserDocumentLastOpenedService userDocumentLastOpenedService; private final InstagramOauth2RestTemplate instagramOauth2RestTemplate; private final int WORD_TABLE_SIZE = 40; // 페이지당 총 아이디 수 @@ -81,22 +72,16 @@ public class SnsEventCommandServiceImpl implements SnsEventCommandService{ @Override @Transactional public SnsEventResponseDTO.CreateSnsEventResponse createSnsEvent( - Long workspaceId, + User user, + Workspace workspace, SnsEventRequestDTO.CreateSnsRequest request ) { // SNS 이벤트 생성 및 저장 - Long userId = SecurityUtil.getCurrentUserId(); - User foundUser = userRepository.findById(userId) - .orElseThrow(() -> new MemberHandler(MEMBER_NOT_FOUND)); - Workspace foundWorkspace = workspaceRepository.findById(workspaceId) - .orElseThrow(() -> new WorkspaceHandler(WORKSPACE_NOT_FOUND)); - UserWorkspace foundUserWorkSapce = userWorkspaceRepository.findByUserAndWorkspace(foundUser, foundWorkspace) - .orElseThrow(() -> new MemberHandler(NOT_BELONG_TO_WORKSPACE)); - SnsEvent createdSnsEvent = SnsEventConverter.toSnsEvent(request, foundUser); - createdSnsEvent.setWorkspace(foundWorkspace); + SnsEvent createdSnsEvent = SnsEventConverter.toSnsEvent(request, user); + createdSnsEvent.setWorkspace(workspace); - // Instagarm API 호출 후 참여라 리스트, 당첨자 리스트 생성 및 저장 - String accessToken = foundWorkspace.getInstagramAccessToken(); + // Instagram API 호출 후 참여자 리스트, 당첨자 리스트 생성 및 저장 + String accessToken = workspace.getInstagramAccessToken(); if (accessToken == null || accessToken.isEmpty()) { throw new SnsEventHandler(SNS_EVENT_NO_ACCESS_TOKEN); } @@ -171,7 +156,7 @@ public SnsEventResponseDTO.CreateSnsEventResponse createSnsEvent( // sns event 생성 시 워크스페이스에 속해있는 모든 유저에 대해 // last opened 테이블에 마지막으로 연 시간은 null로하여 추가 - List usersInWorkspace = userWorkspaceRepository.findUsersByWorkspaceId(savedSnsEvent.getId()); + List usersInWorkspace = userWorkspaceRepository.findUsersByWorkspaceId(workspace.getId()); userDocumentLastOpenedService.createInitialRecordsForWorkspaceUsers(usersInWorkspace, savedSnsEvent); return SnsEventResponseDTO.CreateSnsEventResponse.builder() @@ -179,6 +164,131 @@ public SnsEventResponseDTO.CreateSnsEventResponse createSnsEvent( .build(); } + + @Override + @Transactional + public SnsEventResponseDTO.LinkInstagramAccountResponse getInstagramAccessTokenAndAccount( + String code, + Workspace workspace + ) { + String shortLivedAccessToken; + String longLivedAccessToken; + Map userInfo; + try { + // 1. Access Token 요청 + shortLivedAccessToken = instagramOauth2RestTemplate.getShortLivedAccessTokenUrl(code); + // 2. 단기 토큰을 장기(Long-Lived) 토큰으로 교환 + longLivedAccessToken = instagramOauth2RestTemplate.getLongLivedAccessToken(shortLivedAccessToken); + // 3. 장기 토큰으로 사용자 계정 정보 요청 + userInfo = instagramOauth2RestTemplate.getInstagramAccountInfo(longLivedAccessToken); + } catch (Exception e) { + log.error("Instagram OAuth2 처리 중 오류 발생: {}", e.getMessage()); + throw new SnsEventHandler(SNS_EVENT_INSTAGRAM_API_ERROR); + } + // 4. 워크스페이스에 인스타그램 계정 정보 저장 + String instagramId = (String) userInfo.get("user_id"); + Workspace foundWorkspace = workspaceRepository.findById(workspace.getId()) + .orElseThrow(() -> new WorkspaceHandler(WORKSPACE_NOT_FOUND)); + if (foundWorkspace.getInstagramId() != null && foundWorkspace.getInstagramId().equals(instagramId)) { + throw new SnsEventHandler(SNS_EVENT_INSTAGRAM_ALREADY_LINKED); + } + foundWorkspace.saveInstagramId(instagramId); + foundWorkspace.saveInstagramAccessToken(longLivedAccessToken); + foundWorkspace.saveInstagramAccountName((String) userInfo.get("username")); + return SnsEventConverter.toLinkInstagramAccountResponse((String) userInfo.get("username")); + } + + @Override + @Transactional + @UpdateDocumentTitle + public void updateSnsEventTitle( + User user, + SnsEvent snsEvent, + SnsEventRequestDTO.UpdateSnsEventRequest request + ) { + + UserWorkspace foundUserWorkspace = userWorkspaceRepository.findByWorkspaceAndAuth(snsEvent.getWorkspace(), Auth.ADMIN) + .orElseThrow(() -> new MemberHandler(WORKSPACE_CREATOR_NOT_FOUND)); + + // 수정 권한 확인 (워크스페이스 생성자 혹은 SNS 이벤트의 생성자만 수정 가능) + if (!foundUserWorkspace.getUser().getId().equals(user.getId()) || !snsEvent.getCreator().getId().equals(user.getId())) { + throw new SnsEventHandler(SNS_EVENT_NO_AUTHORITY); + } + + snsEvent.updateTitle(request.getTitle()); + snsEventRepository.save(snsEvent); + + } + + @Override + @Transactional + @DeleteDocument + public void deleteSnsEvent( + User user, + SnsEvent snsEvent + ) { + + UserWorkspace foundUserWorkspace = userWorkspaceRepository.findByWorkspaceAndAuth(snsEvent.getWorkspace(), Auth.ADMIN) + .orElseThrow(() -> new MemberHandler(WORKSPACE_CREATOR_NOT_FOUND)); + + // 수정 권한 확인 (워크스페이스 생성자 혹은 SNS 이벤트의 생성자만 삭제 가능) + if (!foundUserWorkspace.getUser().getId().equals(user.getId()) || !snsEvent.getCreator().getId().equals(user.getId())) { + throw new SnsEventHandler(SNS_EVENT_NO_AUTHORITY); + } + snsEventRepository.delete(snsEvent); + + } + + @Override + public SnsEventResponseDTO.ListDownLoadLinkResponse downloadList( + User user, + SnsEvent snsEvent, + ListType listType, + Format format + ) { + String downloadLink = ""; + + String snsEventTitle = snsEvent.getTitle(); + if (listType == ListType.PARTICIPANT) { + if (format == Format.PDF) { + String keyName = snsEvent.getKeyNameParticipantPdf(); + if (keyName == null || keyName.isEmpty()) { + throw new SnsEventHandler(SNS_EVENT_LIST_KEYNAME_NOT_FOUND); + } + downloadLink = amazonS3Manager.generatePresignedUrlForDownloadPdfAndWord(keyName, snsEventTitle + "_participnat_list.pdf"); + } else if (format == Format.DOCX) { + String keyName = snsEvent.getKeyNameParticipantWord(); + if (keyName == null || keyName.isEmpty()) { + throw new SnsEventHandler(SNS_EVENT_LIST_KEYNAME_NOT_FOUND); + } + downloadLink = amazonS3Manager.generatePresignedUrlForDownloadPdfAndWord(keyName, snsEventTitle + "_participnat_list.docx"); + } else { + throw new SnsEventHandler(SNS_EVENT_WRONG_FORMAT); + } + } else if (listType == ListType.WINNER) { + if (format == Format.PDF) { + String keyName = snsEvent.getKeyNameWinnerPdf(); + if (keyName == null || keyName.isEmpty()) { + throw new SnsEventHandler(SNS_EVENT_LIST_KEYNAME_NOT_FOUND); + } + downloadLink = amazonS3Manager.generatePresignedUrlForDownloadPdfAndWord(keyName, snsEventTitle + "_winner_list.pdf"); + } else if (format == Format.DOCX) { + String keyName = snsEvent.getKeyNameWinnerWord(); + if (keyName == null || keyName.isEmpty()) { + throw new SnsEventHandler(SNS_EVENT_LIST_KEYNAME_NOT_FOUND); + } + downloadLink = amazonS3Manager.generatePresignedUrlForDownloadPdfAndWord(keyName, snsEventTitle + "_winner_list.docx"); + } else { + throw new SnsEventHandler(SNS_EVENT_WRONG_FORMAT); + } + } else { + throw new SnsEventHandler(SNS_EVENT_WRONG_LIST_TYPE); + } + return SnsEventResponseDTO.ListDownLoadLinkResponse.builder() + .downloadLink(downloadLink) + .build(); + } + private String createListHtml( SnsEvent snsEvent, ListType listType @@ -305,7 +415,7 @@ private SnsEventResponseDTO.InstagramMediaResponse fetchInstagramMedia( } - public List getComments( + private List getComments( String mediaId, String accessToken ) { @@ -347,7 +457,7 @@ private int countOccurrences( return count; } - public List pickWinners(Set participants, int n) { + private List pickWinners(Set participants, int n) { List list = new ArrayList<>(participants); // Set → List로 변환 Collections.shuffle(list); // 무작위 섞기 @@ -358,157 +468,6 @@ public List pickWinners(Set participants, int n) { return list.subList(0, n); // 앞에서 n개만 추출 } - @Override - @Transactional - public SnsEventResponseDTO.LinkInstagramAccountResponse getInstagramAccessTokenAndAccount( - String code, - Long workspaceId - ) { - String shortLivedAccessToken; - String longLivedAccessToken; - Map userInfo; - try { - // 1. Access Token 요청 - shortLivedAccessToken = instagramOauth2RestTemplate.getShortLivedAccessTokenUrl(code); - // 2. 단기 토큰을 장기(Long-Lived) 토큰으로 교환 - longLivedAccessToken = instagramOauth2RestTemplate.getLongLivedAccessToken(shortLivedAccessToken); - // 3. 장기 토큰으로 사용자 계정 정보 요청 - userInfo = instagramOauth2RestTemplate.getInstagramAccountInfo(longLivedAccessToken); - } catch (Exception e) { - log.error("Instagram OAuth2 처리 중 오류 발생: {}", e.getMessage()); - throw new SnsEventHandler(SNS_EVENT_INSTAGRAM_API_ERROR); - } - // 4. 워크스페이스에 인스타그램 계정 정보 저장 - String instagramId = (String) userInfo.get("user_id"); - Workspace foundWorkspace = workspaceRepository.findById(workspaceId) - .orElseThrow(() -> new WorkspaceHandler(WORKSPACE_NOT_FOUND)); - if (foundWorkspace.getInstagramId() != null && foundWorkspace.getInstagramId().equals(instagramId)) { - throw new SnsEventHandler(SNS_EVENT_INSTAGRAM_ALREADY_LINKED); - } - foundWorkspace.saveInstagramId(instagramId); - foundWorkspace.saveInstagramAccessToken(longLivedAccessToken); - foundWorkspace.saveInstagramAccountName((String) userInfo.get("username")); - return SnsEventConverter.toLinkInstagramAccountResponse((String) userInfo.get("username")); - } - - @Override - @Transactional - public void updateSnsEventTitle( - Long userId, - Long snsEventId, - SnsEventRequestDTO.UpdateSnsEventRequest request - ) { - User foundUser = userRepository.findById(userId) - .orElseThrow(() -> new MemberHandler(MEMBER_NOT_FOUND)); - SnsEvent foundSnsEvent = snsEventRepository.findById(snsEventId) - .orElseThrow(() -> new SnsEventHandler(SNS_EVENT_NOT_FOUND)); - UserWorkspace foundUserWorkspace = userWorkspaceRepository.findByWorkspaceAndAuth(foundSnsEvent.getWorkspace(), Auth.ADMIN) - .orElseThrow(() -> new MemberHandler(WORKSPACE_CREATOR_NOT_FOUND)); - // 수정 권한 확인 (워크스페이스 생성자 혹은 SNS 이벤트의 생성자만 수정 가능) - if (!foundUserWorkspace.getUser().getId().equals(foundUser.getId()) || !foundSnsEvent.getCreator().getId().equals(foundUser.getId())) { - throw new SnsEventHandler(SNS_EVENT_NO_AUTHORITY); - } - foundSnsEvent.updateTitle(request.getTitle()); - snsEventRepository.save(foundSnsEvent); - - // sns event 수정 시 워크스페이스에 속해있는 모든 유저에 대해 - // last opened 테이블에서 해당 문서 정보 업데이트 - userDocumentLastOpenedService.updateRecordsForWorkspaceUsers(foundSnsEvent); - } - - @Override - @Transactional - public void deleteSnsEvent( - Long userId, - Long snsEventId - ) { - User foundUser = userRepository.findById(userId) - .orElseThrow(() -> new MemberHandler(MEMBER_NOT_FOUND)); - SnsEvent foundSnsEvent = snsEventRepository.findById(snsEventId) - .orElseThrow(() -> new SnsEventHandler(SNS_EVENT_NOT_FOUND)); - UserWorkspace foundUserWorkspace = userWorkspaceRepository.findByWorkspaceAndAuth(foundSnsEvent.getWorkspace(), Auth.ADMIN) - .orElseThrow(() -> new MemberHandler(WORKSPACE_CREATOR_NOT_FOUND)); - // 수정 권한 확인 (워크스페이스 생성자 혹은 SNS 이벤트의 생성자만 삭제 가능) - if (!foundUserWorkspace.getUser().getId().equals(foundUser.getId()) || !foundSnsEvent.getCreator().getId().equals(foundUser.getId())) { - throw new SnsEventHandler(SNS_EVENT_NO_AUTHORITY); - } - snsEventRepository.delete(foundSnsEvent); - - // sns event 삭제 시 워크스페이스에 속해있는 모든 유저에 대해 - // last opened 테이블에서 해당 문서 id를 가지고 있는 튜플 모두 삭제 - userDocumentLastOpenedService.deleteRecordsForWorkspaceUsers(foundSnsEvent); - } - - @Override - public SnsEventResponseDTO.ListDownLoadLinkResponse downloadList( - Long userId, - Long snsEventId, - ListType listType, - Format format - ) { - String downloadLink = ""; - User foundUser = userRepository.findById(userId) - .orElseThrow(() -> new MemberHandler(MEMBER_NOT_FOUND)); - SnsEvent foundSnsEvent = snsEventRepository.findById(snsEventId) - .orElseThrow(() -> new SnsEventHandler(SNS_EVENT_NOT_FOUND)); - String snsEventTitle = foundSnsEvent.getTitle(); - if (listType == ListType.PARTICIPANT) { - if (format == Format.PDF) { - String keyName = foundSnsEvent.getKeyNameParticipantPdf(); - if (keyName == null || keyName.isEmpty()) { - throw new SnsEventHandler(SNS_EVENT_LIST_KEYNAME_NOT_FOUND); - } - downloadLink = amazonS3Manager.generatePresignedUrlForDownloadPdfAndWord(keyName, snsEventTitle + "_participnat_list.pdf"); - } else if (format == Format.DOCX) { - String keyName = foundSnsEvent.getKeyNameParticipantWord(); - if (keyName == null || keyName.isEmpty()) { - throw new SnsEventHandler(SNS_EVENT_LIST_KEYNAME_NOT_FOUND); - } - downloadLink = amazonS3Manager.generatePresignedUrlForDownloadPdfAndWord(keyName, snsEventTitle + "_participnat_list.docx"); - } else { - throw new SnsEventHandler(SNS_EVENT_WRONG_FORMAT); - } - } else if (listType == ListType.WINNER) { - if (format == Format.PDF) { - String keyName = foundSnsEvent.getKeyNameWinnerPdf(); - if (keyName == null || keyName.isEmpty()) { - throw new SnsEventHandler(SNS_EVENT_LIST_KEYNAME_NOT_FOUND); - } - downloadLink = amazonS3Manager.generatePresignedUrlForDownloadPdfAndWord(keyName, snsEventTitle + "_winner_list.pdf"); - } else if (format == Format.DOCX) { - String keyName = foundSnsEvent.getKeyNameWinnerWord(); - if (keyName == null || keyName.isEmpty()) { - throw new SnsEventHandler(SNS_EVENT_LIST_KEYNAME_NOT_FOUND); - } - downloadLink = amazonS3Manager.generatePresignedUrlForDownloadPdfAndWord(keyName, snsEventTitle + "_winner_list.docx"); - } else { - throw new SnsEventHandler(SNS_EVENT_WRONG_FORMAT); - } - } else { - throw new SnsEventHandler(SNS_EVENT_WRONG_LIST_TYPE); - } - return SnsEventResponseDTO.ListDownLoadLinkResponse.builder() - .downloadLink(downloadLink) - .build(); - } - - private String injectHead(String html) { - String fontCss = """ - - """; - if (html.toLowerCase().contains("")) { - // 바로 뒤에 스타일 삽입 - return html.replaceFirst("(?i)", "" + fontCss); - } else { - // head가 없으면 생성 - return html.replaceFirst("(?i)", "" + fontCss + ""); - } - } - private byte[] addPdfTitle(byte[] pdfBytes, String text, byte[] fontBytes) throws Exception { ByteArrayOutputStream out = new ByteArrayOutputStream(); PdfReader reader = new PdfReader(new ByteArrayInputStream(pdfBytes)); diff --git a/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventQueryService.java b/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventQueryService.java index e73ef735..3c1f0427 100644 --- a/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventQueryService.java +++ b/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventQueryService.java @@ -1,10 +1,13 @@ package com.haru.api.domain.snsEvent.service; import com.haru.api.domain.snsEvent.dto.SnsEventResponseDTO; +import com.haru.api.domain.snsEvent.entity.SnsEvent; +import com.haru.api.domain.user.entity.User; +import com.haru.api.domain.workspace.entity.Workspace; public interface SnsEventQueryService { - SnsEventResponseDTO.GetSnsEventListRequest getSnsEventList(Long userId, Long workspaceId); + SnsEventResponseDTO.GetSnsEventListRequest getSnsEventList(User user, Workspace workspace); - SnsEventResponseDTO.GetSnsEventRequest getSnsEvent(Long userId, Long snsEventId); + SnsEventResponseDTO.GetSnsEventRequest getSnsEvent(User user, SnsEvent snsEvent); } diff --git a/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventQueryServiceImpl.java b/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventQueryServiceImpl.java index a4d817b1..cbe8128a 100644 --- a/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventQueryServiceImpl.java +++ b/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventQueryServiceImpl.java @@ -10,58 +10,43 @@ import com.haru.api.domain.snsEvent.repository.SnsEventRepository; import com.haru.api.domain.snsEvent.repository.WinnerRepository; 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.exception.handler.MemberHandler; -import com.haru.api.global.apiPayload.exception.handler.SnsEventHandler; -import com.haru.api.global.apiPayload.exception.handler.WorkspaceHandler; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import java.util.List; -import static com.haru.api.global.apiPayload.code.status.ErrorStatus.*; - @Service @RequiredArgsConstructor public class SnsEventQueryServiceImpl implements SnsEventQueryService { - private final UserRepository userRepository; - private final WorkspaceRepository workspaceRepository; - private final UserWorkspaceRepository userWorkspaceRepository; private final SnsEventRepository snsEventRepository; private final ParticipantRepository participantRepository; private final WinnerRepository winnerRepository; @Override - public SnsEventResponseDTO.GetSnsEventListRequest getSnsEventList(Long userId, Long workspaceId) { - User foundUser = userRepository.findById(userId) - .orElseThrow(() -> new MemberHandler(MEMBER_NOT_FOUND)); - Workspace foundWorkspace = workspaceRepository.findById(workspaceId) - .orElseThrow(() -> new WorkspaceHandler(WORKSPACE_NOT_FOUND)); - UserWorkspace foundUserWorkSpace = userWorkspaceRepository.findByUserAndWorkspace(foundUser, foundWorkspace) - .orElseThrow(() -> new MemberHandler(NOT_BELONG_TO_WORKSPACE)); - List snsEventList = snsEventRepository.findAllByWorkspace(foundWorkspace); + public SnsEventResponseDTO.GetSnsEventListRequest getSnsEventList(User user, Workspace workspace) { + + List snsEventList = snsEventRepository.findAllByWorkspace(workspace); + return SnsEventConverter.toGetSnsEventListRequest(snsEventList); + } @Override @TrackLastOpened(type = DocumentType.SNS_EVENT_ASSISTANT) - public SnsEventResponseDTO.GetSnsEventRequest getSnsEvent(Long userId, Long snsEventId) { - User foundUser = userRepository.findById(userId) - .orElseThrow(() -> new MemberHandler(MEMBER_NOT_FOUND)); - SnsEvent foundSnsEvent = snsEventRepository.findById(snsEventId) - .orElseThrow(() -> new SnsEventHandler(SNS_EVENT_NOT_FOUND)); - List participantList = participantRepository.findAllBySnsEvent(foundSnsEvent); - List winnerList = winnerRepository.findAllBySnsEvent(foundSnsEvent); + public SnsEventResponseDTO.GetSnsEventRequest getSnsEvent(User user, SnsEvent snsEvent) { + + List participantList = participantRepository.findAllBySnsEvent(snsEvent); + + List winnerList = winnerRepository.findAllBySnsEvent(snsEvent); + return SnsEventConverter.toGetSnsEventRequest( - foundSnsEvent, + snsEvent, participantList, winnerList ); + } } diff --git a/src/main/java/com/haru/api/domain/user/controller/UserController.java b/src/main/java/com/haru/api/domain/user/controller/UserController.java index d634388a..7c5acec9 100644 --- a/src/main/java/com/haru/api/domain/user/controller/UserController.java +++ b/src/main/java/com/haru/api/domain/user/controller/UserController.java @@ -154,14 +154,15 @@ public ApiResponse checkOriginalP } @Operation(summary = "회원가입 후 로그인", description = - "# [v1.0 (2025-08-15)](https://www.notion.so/2505da7802c5808583b9d0b08087b8e5)" + - " 회원가입 후 로그인까지 진행하는 API입니다." + "# [v1.1 (2025-08-18)](https://www.notion.so/2505da7802c5808583b9d0b08087b8e5)" + + " 회원가입 후 로그인까지 진행하는 API입니다. query string으로 token을 넘기면 워크스페이스에 초대됩니다." ) @PostMapping("/signup-and-login") public ApiResponse signUpAndLogin( - @RequestBody @Valid UserRequestDTO.SignUpRequest request + @RequestBody @Valid UserRequestDTO.SignUpRequest request, + @RequestParam(required = false) String token ) { - UserResponseDTO.LoginResponse response = userCommandService.signupAndLogin(request); + UserResponseDTO.LoginResponse response = userCommandService.signupAndLoginAndInviteAccept(request, token); return ApiResponse.onSuccess(response); } diff --git a/src/main/java/com/haru/api/domain/user/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/haru/api/domain/user/security/jwt/JwtAuthenticationFilter.java index 0e14d8eb..9efa6f29 100644 --- a/src/main/java/com/haru/api/domain/user/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/haru/api/domain/user/security/jwt/JwtAuthenticationFilter.java @@ -37,6 +37,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { "/v3/**", "/users/admin/**", "/ws/audio/**", + "/ws/test", "/api/v1/workspaces/invite-accept", "/api/v1/terms", "/api/v1/sns/oauth/callback", diff --git a/src/main/java/com/haru/api/domain/user/service/UserCommandService.java b/src/main/java/com/haru/api/domain/user/service/UserCommandService.java index 30c6d905..cdd80b84 100644 --- a/src/main/java/com/haru/api/domain/user/service/UserCommandService.java +++ b/src/main/java/com/haru/api/domain/user/service/UserCommandService.java @@ -22,5 +22,5 @@ public interface UserCommandService { UserResponseDTO.CheckOriginalPasswordResponse checkOriginalPassword(UserRequestDTO.CheckOriginalPasswordRequest request, User user); - UserResponseDTO.LoginResponse signupAndLogin(UserRequestDTO.SignUpRequest request); + UserResponseDTO.LoginResponse signupAndLoginAndInviteAccept(UserRequestDTO.SignUpRequest request, String token); } diff --git a/src/main/java/com/haru/api/domain/user/service/UserCommandServiceImpl.java b/src/main/java/com/haru/api/domain/user/service/UserCommandServiceImpl.java index c3dd87e1..b281e09c 100644 --- a/src/main/java/com/haru/api/domain/user/service/UserCommandServiceImpl.java +++ b/src/main/java/com/haru/api/domain/user/service/UserCommandServiceImpl.java @@ -8,6 +8,7 @@ import com.haru.api.domain.user.repository.UserRepository; import com.haru.api.domain.user.security.jwt.JwtUtils; import com.haru.api.domain.user.security.jwt.SecurityUtil; +import com.haru.api.domain.workspace.service.WorkspaceCommandService; import com.haru.api.global.apiPayload.code.status.ErrorStatus; import com.haru.api.global.apiPayload.exception.handler.MemberHandler; import jakarta.transaction.Transactional; @@ -34,7 +35,9 @@ public class UserCommandServiceImpl implements UserCommandService{ private int accessExpTime; @Value("${jwt.refresh-expiration}") private int refreshExpTime; + private final UserRepository userRepository; + private final WorkspaceCommandService workspaceCommandService; private final PasswordEncoder passwordEncoder; private final AuthenticationManagerBuilder authenticationManagerBuilder; private final JwtUtils jwtUtils; @@ -163,7 +166,8 @@ public UserResponseDTO.CheckOriginalPasswordResponse checkOriginalPassword(UserR } @Override - public UserResponseDTO.LoginResponse signupAndLogin(UserRequestDTO.SignUpRequest request) { + @Transactional + public UserResponseDTO.LoginResponse signupAndLoginAndInviteAccept(UserRequestDTO.SignUpRequest request, String token) { String password = passwordEncoder.encode(request.getPassword()); @@ -175,6 +179,8 @@ public UserResponseDTO.LoginResponse signupAndLogin(UserRequestDTO.SignUpRequest User user = UserConverter.toUsers(request, password); userRepository.save(user); + workspaceCommandService.acceptInvite(token); + return login(UserRequestDTO.LoginRequest.builder() .email(request.getEmail()) .password(request.getPassword()) 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/domain/workspace/entity/Workspace.java b/src/main/java/com/haru/api/domain/workspace/entity/Workspace.java index eb0f9416..04ff62cf 100644 --- a/src/main/java/com/haru/api/domain/workspace/entity/Workspace.java +++ b/src/main/java/com/haru/api/domain/workspace/entity/Workspace.java @@ -34,7 +34,7 @@ public class Workspace extends BaseEntity { private String instagramId; - @Column(length = 200) + @Column(columnDefinition="TEXT") private String instagramAccessToken; @Column(length = 50) diff --git a/src/main/java/com/haru/api/domain/workspace/service/WorkspaceCommandServiceImpl.java b/src/main/java/com/haru/api/domain/workspace/service/WorkspaceCommandServiceImpl.java index bcc47e17..5921bee0 100644 --- a/src/main/java/com/haru/api/domain/workspace/service/WorkspaceCommandServiceImpl.java +++ b/src/main/java/com/haru/api/domain/workspace/service/WorkspaceCommandServiceImpl.java @@ -1,13 +1,11 @@ package com.haru.api.domain.workspace.service; import com.haru.api.domain.lastOpened.converter.UserDocumentLastOpenedConverter; +import com.haru.api.domain.lastOpened.entity.Documentable; import com.haru.api.domain.lastOpened.entity.UserDocumentLastOpened; import com.haru.api.domain.lastOpened.repository.UserDocumentLastOpenedRepository; -import com.haru.api.domain.meeting.entity.Meeting; import com.haru.api.domain.meeting.repository.MeetingRepository; -import com.haru.api.domain.moodTracker.entity.MoodTracker; import com.haru.api.domain.moodTracker.repository.MoodTrackerRepository; -import com.haru.api.domain.snsEvent.entity.SnsEvent; import com.haru.api.domain.snsEvent.repository.SnsEventRepository; import com.haru.api.domain.user.entity.User; import com.haru.api.domain.user.repository.UserRepository; @@ -107,7 +105,6 @@ public WorkspaceResponseDTO.Workspace updateWorkspace(User user, Workspace works return WorkspaceConverter.toWorkspaceDTO(workspace); } - @Transactional @Override public WorkspaceResponseDTO.InvitationAcceptResult acceptInvite(String token) { @@ -175,8 +172,7 @@ public WorkspaceResponseDTO.InvitationAcceptResult acceptInvite(String token, U .auth(Auth.MEMBER) .build()); - // 각 문서 조회 - // 각 문서 UserDocumentLastOpened로 변환 + // 각 문서 조회 후, UserDocumentLastOpened로 변환 List userDocumentLastOpenedList = addDocumentsToUserLastOpened(foundWorkspace, signedUser); // 워크스페이스에 속해있는 모든 문서를 user_document_last_opened에 추가 @@ -223,17 +219,16 @@ public void sendInviteEmail(User user, WorkspaceRequestDTO.WorkspaceInviteEmailR } private List addDocumentsToUserLastOpened(Workspace workspace, User user) { - List meetingList = meetingRepository.findAllByWorkspaceId(workspace.getId()); - List snsEventList = snsEventRepository.findAllByWorkspaceId(workspace.getId()); - List moodTrackerList = moodTrackerRepository.findAllByWorkspaceId(workspace.getId()); + + List documentList = new ArrayList<>(); + + documentList.addAll(meetingRepository.findAllByWorkspaceId(workspace.getId())); + documentList.addAll(snsEventRepository.findAllByWorkspaceId(workspace.getId())); + documentList.addAll(moodTrackerRepository.findAllByWorkspaceId(workspace.getId())); List userDocumentLastOpenedList = new ArrayList<>(); - for(Meeting meeting : meetingList) - userDocumentLastOpenedList.add(UserDocumentLastOpenedConverter.toUserDocumentLastOpened(meeting, user)); - for(SnsEvent snsEvent : snsEventList) - userDocumentLastOpenedList.add(UserDocumentLastOpenedConverter.toUserDocumentLastOpened(snsEvent, user)); - for(MoodTracker moodTracker : moodTrackerList) - userDocumentLastOpenedList.add(UserDocumentLastOpenedConverter.toUserDocumentLastOpened(moodTracker, user)); + for(Documentable documentable : documentList) + userDocumentLastOpenedList.add(UserDocumentLastOpenedConverter.toUserDocumentLastOpened(documentable, user)); userDocumentLastOpenedRepository.saveAll(userDocumentLastOpenedList); 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/AuthSnsEvent.java b/src/main/java/com/haru/api/global/annotation/AuthSnsEvent.java new file mode 100644 index 00000000..ca8fca63 --- /dev/null +++ b/src/main/java/com/haru/api/global/annotation/AuthSnsEvent.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.SNS_EVENT_ASSISTANT, pathVariableName = "snsEventId") +public @interface AuthSnsEvent { +} 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..b6a40caf --- /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); + } + } + return null; + } +} 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/config/WebSocketConfig.java b/src/main/java/com/haru/api/global/config/WebSocketConfig.java index 7f2706f2..c174a627 100644 --- a/src/main/java/com/haru/api/global/config/WebSocketConfig.java +++ b/src/main/java/com/haru/api/global/config/WebSocketConfig.java @@ -1,6 +1,7 @@ package com.haru.api.global.config; import com.haru.api.infra.websocket.AudioWebSocketHandler; +import com.haru.api.infra.websocket.TestWebSocketHandler; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; @@ -13,10 +14,14 @@ public class WebSocketConfig implements WebSocketConfigurer { private final AudioWebSocketHandler audioWebSocketHandler; + private final TestWebSocketHandler testWebSocketHandler; @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(audioWebSocketHandler, "/ws/audio/{meetingId}") .setAllowedOrigins("*"); // Cross-Origin 허용 + + registry.addHandler(testWebSocketHandler, "/ws/test") + .setAllowedOrigins("*"); } } 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/documentFinder/SnsEventFinder.java b/src/main/java/com/haru/api/global/documentFinder/SnsEventFinder.java new file mode 100644 index 00000000..c02f8df3 --- /dev/null +++ b/src/main/java/com/haru/api/global/documentFinder/SnsEventFinder.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.snsEvent.repository.SnsEventRepository; +import com.haru.api.global.apiPayload.code.status.ErrorStatus; +import com.haru.api.global.apiPayload.exception.handler.SnsEventHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SnsEventFinder implements DocumentFinder { + + private final SnsEventRepository snsEventRepository; + + @Override + public DocumentType getSupportType() { + return DocumentType.SNS_EVENT_ASSISTANT; + } + + @Override + public Object findById(Object id) { + return snsEventRepository.findById((Long) id) + .orElseThrow(() -> new SnsEventHandler(ErrorStatus.SNS_EVENT_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..1bbea75e --- /dev/null +++ b/src/main/java/com/haru/api/global/interceptor/DocumentMemberAuthInterceptor.java @@ -0,0 +1,113 @@ +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.snsEvent.repository.SnsEventRepository; +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 com.haru.api.global.apiPayload.exception.handler.SnsEventHandler; +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; + private final SnsEventRepository snsEventRepository; + + @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; + } + } + } + + // AuthUser, AuthDocument가 모두 존재하는 경우 + 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; + + case SNS_EVENT_ASSISTANT: + foundDocument = snsEventRepository.findSnsEventByIdIfUserHasAccess(userId, documentId) + .orElseThrow(() -> new SnsEventHandler(ErrorStatus.SNS_EVENT_NOT_FOUND)); + 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; diff --git a/src/main/java/com/haru/api/infra/websocket/TestWebSocketHandler.java b/src/main/java/com/haru/api/infra/websocket/TestWebSocketHandler.java new file mode 100644 index 00000000..a0e6a02a --- /dev/null +++ b/src/main/java/com/haru/api/infra/websocket/TestWebSocketHandler.java @@ -0,0 +1,83 @@ +package com.haru.api.infra.websocket; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.haru.api.infra.api.dto.AIQuestionResponse; +import com.haru.api.infra.api.dto.SpeechSegmentResponseDTO; +import com.haru.api.infra.api.dto.WebSocketMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +@Component +@RequiredArgsConstructor +public class TestWebSocketHandler extends TextWebSocketHandler { + + private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); + private final ObjectMapper objectMapper; + private final AtomicLong speechIdCounter = new AtomicLong(1); + + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + // 클라이언트가 연결되면 세션 목록에 추가 + sessions.put(session.getId(), session); + System.out.println("Test WS - 클라이언트 연결됨: " + session.getId()); + } + + // 2초마다 실행되는 스케줄링 메서드 + @Scheduled(fixedRate = 2000) + public void sendPeriodicMessages() { + if (sessions.isEmpty()) { + return; // 연결된 클라이언트가 없으면 실행하지 않음 + } + + // 보낼 테스트 데이터 생성 + long currentSpeechId = speechIdCounter.getAndIncrement(); + + WebSocketMessage messageToSend; + + // 50% 확률로 다른 타입의 메시지 전송 + if (Math.random() > 0.5) { + SpeechSegmentResponseDTO.SpeechSegmentResponse speechData = SpeechSegmentResponseDTO.SpeechSegmentResponse.builder() + .speechId(currentSpeechId) + .speakerId("SPK_01") + .text("이것은 2초마다 전송되는 테스트 STT 데이터입니다. ID: " + currentSpeechId) + .startTime(LocalDateTime.now()) + .build(); + messageToSend = new WebSocketMessage<>("SPEECH_SEGMENT", speechData); + } else { + AIQuestionResponse questionData = new AIQuestionResponse(); + questionData.setSpeechId(currentSpeechId); + questionData.setQuestions(List.of("이 내용의 핵심 질문은 무엇인가요?", "다음 예상 질문은 무엇일까요?")); + messageToSend = new WebSocketMessage<>("AI_QUESTION", questionData); + } + + // 모든 연결된 클라이언트에게 메시지 전송 + sessions.values().forEach(session -> { + try { + if (session.isOpen()) { + String jsonMessage = objectMapper.writeValueAsString(messageToSend); + session.sendMessage(new TextMessage(jsonMessage)); + } + } catch (IOException e) { + System.err.println("메시지 전송 중 오류 발생: " + e.getMessage()); + } + }); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { + // 클라이언트 연결이 끊어지면 세션 목록에서 제거 + sessions.remove(session.getId()); + System.out.println("Test WS - 클라이언트 연결 끊김: " + session.getId()); + } +}