From cea666447d3eee092bc3e93feec63d678d6d1eaa Mon Sep 17 00:00:00 2001 From: junyong Date: Tue, 28 Apr 2026 01:20:09 +0900 Subject: [PATCH 1/3] =?UTF-8?q?Fix:=20=EB=A9=A4=EB=B2=84=EC=9D=98=20?= =?UTF-8?q?=ED=9A=8C=EC=9D=98=20=EC=B0=B8=EC=97=AC=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/meeting/service/MeetingRtcService.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingRtcService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingRtcService.java index 6618df1..fa24e8c 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingRtcService.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingRtcService.java @@ -50,7 +50,7 @@ public MeetingRtcService( this.liveKitTokenExpireTime = liveKitTokenExpireTime; } - @Transactional(readOnly = true) + @Transactional public MeetingResponse.MeetingRtcTokenDTO issueRtcToken(Long memberId, Long meetingId) { Meeting meeting = meetingRepository.findById(meetingId) .orElseThrow(MeetingNotFoundException::new); @@ -73,7 +73,6 @@ private void ensureMeetingParticipant(Meeting meeting, Member member) { if (meetingMemberRepository.existsByMemberIdAndMeetingId(member.getId(), meeting.getId())) { return; } - meetingMemberRepository.save(MeetingMember.create(meeting, member, MeetingRole.GENERAL)); } From a735d05838e60bca2ec9625fcb103dd56cf293bf Mon Sep 17 00:00:00 2001 From: junyong Date: Tue, 28 Apr 2026 01:23:12 +0900 Subject: [PATCH 2/3] =?UTF-8?q?Refactor:=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=ED=9A=8C=EC=9D=98=20=EB=B0=9C=ED=99=94=EB=82=B4=EC=9A=A9=20?= =?UTF-8?q?=EC=86=A1=EC=8B=A0=20=EB=B3=91=EB=AA=A9=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 0 -> 6148 bytes .../meeting/socket/MeetingSocketHandler.java | 87 ++++++++++++++--- .../socket/MeetingSocketRoomService.java | 90 ++++++++++++------ .../repository/MeetingRoomRepository.java | 41 +++++++- .../config/MeetingSocketExecutorConfig.java | 23 +++++ 5 files changed, 196 insertions(+), 45 deletions(-) create mode 100644 .DS_Store create mode 100644 src/main/java/com/whylog/server/global/config/MeetingSocketExecutorConfig.java diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5008ddfcf53c02e82d7eee2e57c38e5672ef89f6 GIT binary patch literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0 meetingSocketRoomService.broadcastText( - participant.meetingId(), - JsonConverter.toJson(MeetingTextMessage.createTextMessage( - participant, - type, - null, - Optional.ofNullable(incoming.text()).orElse(""), - incoming.payload() - )) - ); + case CHAT -> { + logIncomingText(participant, type, incoming); + meetingSocketRoomService.broadcastChatText( + participant.meetingId(), + JsonConverter.toJson(MeetingTextMessage.createTextMessage( + participant, + type, + null, + Optional.ofNullable(incoming.text()).orElse(""), + incoming.payload() + )) + ); + } + case SPEECH -> { + logIncomingText(participant, type, incoming); + meetingSocketRoomService.broadcastSpeechText( + participant.meetingId(), + JsonConverter.toJson(MeetingTextMessage.createTextMessage( + participant, + type, + null, + Optional.ofNullable(incoming.text()).orElse(""), + incoming.payload() + )) + ); + } + case AUDIO_TEXT -> { + logIncomingText(participant, type, incoming); + meetingSocketRoomService.broadcastAudioText( + participant.meetingId(), + JsonConverter.toJson(MeetingTextMessage.createTextMessage( + participant, + type, + null, + Optional.ofNullable(incoming.text()).orElse(""), + incoming.payload() + )) + ); + } case OFFER, ANSWER, ICE -> { if (incoming.targetMemberId() == null) { sendError(session, "targetMemberId is required for " + type.value()); @@ -150,6 +185,10 @@ private void broadcastRoster(Long meetingId) { // 핸드셰이크에서 저장한 속성으로 참가자 정보를 복원합니다. private MeetingParticipant createParticipant(WebSocketSession session) { + return createParticipant(session, session); + } + + private MeetingParticipant createParticipant(WebSocketSession session, WebSocketSession outboundSession) { Long meetingId = getAttribute(session, MeetingSocketAuthInterceptor.MEETING_ID_ATTRIBUTE, Long.class); Long memberId = getAttribute(session, MeetingSocketAuthInterceptor.MEMBER_ID_ATTRIBUTE, Long.class); String name = getAttribute(session, MeetingSocketAuthInterceptor.MEMBER_NAME_ATTRIBUTE, String.class); @@ -158,7 +197,7 @@ private MeetingParticipant createParticipant(WebSocketSession session) { throw new IllegalStateException("WebSocket participant attributes are missing"); } - return new MeetingParticipant(session.getId(), memberId, name, meetingId, session); + return new MeetingParticipant(session.getId(), memberId, name, meetingId, outboundSession); } // 잘못된 요청이나 지원하지 않는 타입에 대한 에러 메시지를 클라이언트에 보냅니다. @@ -189,8 +228,30 @@ private List participantSummaries(Long meetingId) { } // 웹소켓 메시지에 사용할 현재 시각 문자열을 생성합니다. - private String now() { + private String now() { return Instant.now().toString(); } + private String logIncomingText(MeetingParticipant participant, MeetingMessageType type, MeetingSocketMessage incoming) { + String text = Optional.ofNullable(incoming.text()).orElse(""); + log.info( + "meeting text received: meetingId={}, memberId={}, name={}, type={}, targetMemberId={}, text={}", + participant.meetingId(), + participant.memberId(), + participant.name(), + type.value(), + incoming.targetMemberId(), + text + ); + return text; + } + + private WebSocketSession decorate(WebSocketSession session) { + return new ConcurrentWebSocketSessionDecorator( + session, + SESSION_SEND_TIME_LIMIT_MS, + SESSION_BUFFER_SIZE_LIMIT_BYTES + ); + } + } diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketRoomService.java b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketRoomService.java index 683af8a..cf5cd52 100644 --- a/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketRoomService.java +++ b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketRoomService.java @@ -7,26 +7,36 @@ import com.whylog.server.domain.meeting.socket.repository.MeetingRoomRepository; import com.whylog.server.domain.meeting.socket.repository.MeetingSocketRoomRepository; import com.whylog.server.global.util.json.JsonConverter; -import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketMessage; -import java.io.IOException; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.concurrent.Executor; import java.util.function.Function; // 회의별 참가자 세션 저장소 역할을 하며 텍스트/오디오 메시지 전달을 담당합니다. @Service -@RequiredArgsConstructor public class MeetingSocketRoomService { private final MeetingRepository meetingRepository; private final MeetingSocketRoomRepository meetingSocketRoomRepository; + private final Executor meetingSocketDispatchExecutor; + + public MeetingSocketRoomService( + MeetingRepository meetingRepository, + MeetingSocketRoomRepository meetingSocketRoomRepository, + @Qualifier("meetingSocketDispatchExecutor") Executor meetingSocketDispatchExecutor + ) { + this.meetingRepository = meetingRepository; + this.meetingSocketRoomRepository = meetingSocketRoomRepository; + this.meetingSocketDispatchExecutor = meetingSocketDispatchExecutor; + } // 웹소켓으로 연결하려는 회의가 DB에 실제로 존재하는지 확인합니다. @Transactional(readOnly = true) @@ -79,34 +89,39 @@ public List listParticipants(Long meetingId) { // 텍스트 메시지를 회의방의 모든 참가자에게 브로드캐스트합니다. public void broadcastText(Long meetingId, String payload) { - broadcast( + dispatch(() -> broadcast( meetingId, participant -> new TextMessage(payload), - participant -> false); + participant -> false)); } - // 특정 대상 참가자 한 명에게만 시그널링 메시지를 전달합니다. - public void sendToMember(Long meetingId, Long targetMemberId, String payload) { - MeetingRoomRepository room = getRoom(meetingId); - if (room == null) { - return; - } + // 채팅 메시지를 별도 경로로 브로드캐스트합니다. + public void broadcastChatText(Long meetingId, String payload) { + dispatch(() -> broadcast( + meetingId, + participant -> new TextMessage(payload), + participant -> false)); + } - room.participants().stream() - .filter(participant -> participant.memberId().equals(targetMemberId)) - .findFirst() - .ifPresent(participant -> { - if (!participant.socketSession().isOpen()) { - leave(meetingId, participant.sessionId()); - return; - } + // 실시간 자막/STT 메시지를 채팅과 분리해 브로드캐스트합니다. + public void broadcastSpeechText(Long meetingId, String payload) { + dispatch(() -> broadcast( + meetingId, + participant -> new TextMessage(payload), + participant -> false)); + } - try { - participant.socketSession().sendMessage(new TextMessage(payload)); - } catch (IOException exception) { - leave(meetingId, participant.sessionId()); - } - }); + // 오디오 텍스트 변환 결과를 별도 경로로 브로드캐스트합니다. + public void broadcastAudioText(Long meetingId, String payload) { + dispatch(() -> broadcast( + meetingId, + participant -> new TextMessage(payload), + participant -> false)); + } + + // 특정 대상 참가자 한 명에게만 시그널링 메시지를 전달합니다. + public void sendToMember(Long meetingId, Long targetMemberId, String payload) { + dispatch(() -> sendToMemberInternal(meetingId, targetMemberId, payload)); } // 회의 종료 메시지를 현재 회의방 참가자 전체에게 전송합니다. @@ -131,6 +146,27 @@ private MeetingRoomRepository getRoom(Long meetingId) { return meetingSocketRoomRepository.findByMeetingId(meetingId); } + private void dispatch(Runnable task) { + try { + meetingSocketDispatchExecutor.execute(task); + } catch (RuntimeException exception) { + task.run(); + } + } + + private void sendToMemberInternal(Long meetingId, Long targetMemberId, String payload) { + MeetingRoomRepository room = getRoom(meetingId); + if (room == null) { + return; + } + + room.participantsByMemberId(targetMemberId).forEach(participant -> { + if (!sendMessage(participant, new TextMessage(payload))) { + leave(meetingId, participant.sessionId()); + } + }); + } + // 회의방 참가자 전체를 순회하면서 메시지를 보내고 끊어진 세션은 정리합니다. private void broadcast( Long meetingId, @@ -144,7 +180,7 @@ private void broadcast( } List disconnectedParticipants = new ArrayList<>(); - for (MeetingParticipant participant : room.participants()) { + for (MeetingParticipant participant : new ArrayList<>(room.participants())) { if (skipCondition.apply(participant)) { continue; } @@ -166,7 +202,7 @@ private boolean sendMessage(MeetingParticipant participant, WebSocketMessage try { participant.socketSession().sendMessage(message); return true; - } catch (IOException exception) { + } catch (Exception exception) { return false; } } diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingRoomRepository.java b/src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingRoomRepository.java index 462dc2b..b781d90 100644 --- a/src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingRoomRepository.java +++ b/src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingRoomRepository.java @@ -3,32 +3,63 @@ import com.whylog.server.domain.meeting.socket.MeetingParticipant; import java.util.Collection; +import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; // 하나의 회의방에 연결된 웹소켓 참가자 세션들을 메모리에서 관리합니다. public class MeetingRoomRepository { // sessionId -> MeetingParticipantSession - private final Map participants = new ConcurrentHashMap<>(); + private final Map participantsBySessionId = new ConcurrentHashMap<>(); + private final Map> sessionIdsByMemberId = new ConcurrentHashMap<>(); // 새로 연결된 참가자 세션을 회의방에 추가합니다. public void addParticipant(MeetingParticipant participant) { - participants.put(participant.sessionId(), participant); + participantsBySessionId.put(participant.sessionId(), participant); + sessionIdsByMemberId.compute(participant.memberId(), (memberId, sessionIds) -> { + Set targetSessionIds = sessionIds != null ? sessionIds : ConcurrentHashMap.newKeySet(); + targetSessionIds.add(participant.sessionId()); + return targetSessionIds; + }); } // 연결이 종료된 참가자 세션을 회의방에서 제거합니다. public MeetingParticipant removeParticipant(String sessionId) { - return participants.remove(sessionId); + MeetingParticipant removed = participantsBySessionId.remove(sessionId); + if (removed == null) { + return null; + } + + sessionIdsByMemberId.computeIfPresent(removed.memberId(), (memberId, sessionIds) -> { + sessionIds.remove(sessionId); + return sessionIds.isEmpty() ? null : sessionIds; + }); + return removed; } // 현재 회의방에 연결된 전체 참가자 세션 목록을 반환합니다. public Collection participants() { - return participants.values(); + return participantsBySessionId.values(); + } + + // 특정 멤버의 모든 연결 세션을 반환합니다. + public List participantsByMemberId(Long memberId) { + Set sessionIds = sessionIdsByMemberId.get(memberId); + if (sessionIds == null || sessionIds.isEmpty()) { + return List.of(); + } + + return sessionIds.stream() + .map(participantsBySessionId::get) + .filter(Objects::nonNull) + .toList(); } // 회의방에 남아 있는 참가자가 없는지 확인합니다. public boolean isEmpty() { - return participants.isEmpty(); + return participantsBySessionId.isEmpty(); } } diff --git a/src/main/java/com/whylog/server/global/config/MeetingSocketExecutorConfig.java b/src/main/java/com/whylog/server/global/config/MeetingSocketExecutorConfig.java new file mode 100644 index 0000000..ad4dce2 --- /dev/null +++ b/src/main/java/com/whylog/server/global/config/MeetingSocketExecutorConfig.java @@ -0,0 +1,23 @@ +package com.whylog.server.global.config; + +import java.util.concurrent.Executor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +public class MeetingSocketExecutorConfig { + + @Bean(name = "meetingSocketDispatchExecutor") + public Executor meetingSocketDispatchExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(8); + executor.setQueueCapacity(1_000); + executor.setThreadNamePrefix("meeting-socket-"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(5); + executor.initialize(); + return executor; + } +} From 9de1a7185043ccd94f073d0d9eb1b21d8643e4ae Mon Sep 17 00:00:00 2001 From: junyong Date: Tue, 28 Apr 2026 02:02:26 +0900 Subject: [PATCH 3/3] =?UTF-8?q?Feat:=20=ED=8C=80=20=EC=A0=9C=EA=B1=B0,=20?= =?UTF-8?q?=EB=AF=B8=ED=8C=85=20=EC=A0=9C=EA=B1=B0=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/ApplicationRepository.java | 18 +++++++ .../repository/DecisionRepository.java | 26 +++++++++ .../meeting/controller/MeetingController.java | 18 +++++++ .../domain/meeting/dto/MeetingResponse.java | 14 +++++ .../server/domain/meeting/entity/Meeting.java | 11 ++-- .../meeting/exception/MeetingErrorCode.java | 1 + .../repository/DialogueRepository.java | 18 +++++++ .../repository/MeetingAnalysisRepository.java | 18 +++++++ .../repository/MeetingMemberRepository.java | 33 ++++++++++++ .../meeting/repository/MeetingRepository.java | 29 +++++++--- .../service/MeetingCleanupService.java | 54 +++++++++++++++++++ .../service/MeetingCommandService.java | 38 +++++++++++++ .../team/controller/TeamController.java | 28 +++++++--- .../server/domain/team/dto/TeamResponse.java | 11 ++++ .../server/domain/team/entity/Team.java | 14 ++--- .../domain/team/exception/TeamErrorCode.java | 1 + .../team/repository/TeamMemberRepository.java | 18 +++++++ .../team/service/TeamCommandService.java | 52 +++++++++++++++++- .../server/global/external/s3/S3Client.java | 23 ++++++++ .../global/external/s3/S3ErrorCode.java | 1 + 20 files changed, 398 insertions(+), 28 deletions(-) create mode 100644 src/main/java/com/whylog/server/domain/decision/repository/ApplicationRepository.java create mode 100644 src/main/java/com/whylog/server/domain/decision/repository/DecisionRepository.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/repository/DialogueRepository.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/repository/MeetingAnalysisRepository.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/service/MeetingCleanupService.java diff --git a/src/main/java/com/whylog/server/domain/decision/repository/ApplicationRepository.java b/src/main/java/com/whylog/server/domain/decision/repository/ApplicationRepository.java new file mode 100644 index 0000000..5c186b3 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/decision/repository/ApplicationRepository.java @@ -0,0 +1,18 @@ +package com.whylog.server.domain.decision.repository; + +import com.whylog.server.domain.decision.entity.Application; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ApplicationRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM Application a WHERE a.decision.meeting.team.id = :teamId") + void deleteByTeamId(@Param("teamId") Long teamId); + + @Modifying + @Query("DELETE FROM Application a WHERE a.decision.meeting.id = :meetingId") + void deleteByMeetingId(@Param("meetingId") Long meetingId); +} diff --git a/src/main/java/com/whylog/server/domain/decision/repository/DecisionRepository.java b/src/main/java/com/whylog/server/domain/decision/repository/DecisionRepository.java new file mode 100644 index 0000000..9e81ab3 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/decision/repository/DecisionRepository.java @@ -0,0 +1,26 @@ +package com.whylog.server.domain.decision.repository; + +import com.whylog.server.domain.decision.entity.Decision; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface DecisionRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM Application a WHERE a.decision.meeting.team.id = :teamId") + void deleteApplicationsByTeamId(@Param("teamId") Long teamId); + + @Modifying + @Query("DELETE FROM Application a WHERE a.decision.meeting.id = :meetingId") + void deleteApplicationsByMeetingId(@Param("meetingId") Long meetingId); + + @Modifying + @Query("DELETE FROM Decision d WHERE d.meeting.team.id = :teamId") + void deleteByTeamId(@Param("teamId") Long teamId); + + @Modifying + @Query("DELETE FROM Decision d WHERE d.meeting.id = :meetingId") + void deleteByMeetingId(@Param("meetingId") Long meetingId); +} diff --git a/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java b/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java index b791f7d..293c0cf 100644 --- a/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java +++ b/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java @@ -134,6 +134,24 @@ public ApiResponse endMeeting( return ApiResponse.onSuccess(meetingCommandService.endMeeting(memberId, meetingId)); } + @DeleteMapping("/meetings/{meetingId}") + @Operation(summary = "회의 삭제 API", description = """ + 특정 회의를 삭제하는 API입니다. + 회의 삭제 시 회의 참여자, 대화 기록, 결정사항, 분석 데이터가 함께 삭제됩니다. + """) + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_OWNER") + }) + public ApiResponse deleteMeeting( + @Parameter(hidden = true) @CurrentMember Long memberId, + @PathVariable Long meetingId + ) { + return ApiResponse.onSuccess(meetingCommandService.deleteMeeting(memberId, meetingId)); + } + @GetMapping("/meetings/{meetingId}") @Operation(summary = "회의 기본 정보 조회 API", description = """ 특정 회의의 기본 정보를 조회하는 API입니다. diff --git a/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java b/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java index 6e8fe23..4b7f528 100644 --- a/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java +++ b/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java @@ -92,6 +92,20 @@ public static class MeetingEndResponseDTO { private LocalDateTime endDateTime; } + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "회의 삭제 응답") + public static class MeetingDeleteResponseDTO { + + @Schema(description = "회의 ID", example = "1") + private Long meetingId; + + @Schema(description = "삭제 성공 여부", example = "true") + private Boolean isRemoved; + } + @Getter @NoArgsConstructor @AllArgsConstructor diff --git a/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java b/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java index dd660ce..c0ab419 100644 --- a/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java +++ b/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java @@ -1,5 +1,6 @@ package com.whylog.server.domain.meeting.entity; +import com.whylog.server.domain.decision.entity.Decision; import com.whylog.server.domain.meeting.dto.MeetingRequest; import com.whylog.server.domain.meeting.enums.MeetingStatus; import com.whylog.server.domain.team.entity.Team; @@ -90,12 +91,12 @@ public String getElapse() { @OneToMany(mappedBy = "meeting", cascade = CascadeType.ALL, orphanRemoval = true) private final List meetingMembers = new ArrayList<>(); // -// @OneToOne(mappedBy = "meeting", cascade = CascadeType.ALL, orphanRemoval = true) -// private final MeetingAnalysis meetingAnalyses; + @OneToOne(mappedBy = "meeting", cascade = CascadeType.ALL, orphanRemoval = true) + private final MeetingAnalysis meetingAnalyses = new MeetingAnalysis(); // -// @OneToMany(mappedBy = "meeting", cascade = CascadeType.ALL, orphanRemoval = true) -// private final List dialogues = new ArrayList<>(); + @OneToMany(mappedBy = "meeting", cascade = CascadeType.ALL, orphanRemoval = true) + private final List dialogues = new ArrayList<>(); // // @OneToOne(mappedBy = "meeting", cascade = CascadeType.ALL, orphanRemoval = true) // private Decision decision; -} +} \ No newline at end of file diff --git a/src/main/java/com/whylog/server/domain/meeting/exception/MeetingErrorCode.java b/src/main/java/com/whylog/server/domain/meeting/exception/MeetingErrorCode.java index c91d187..77f2d32 100644 --- a/src/main/java/com/whylog/server/domain/meeting/exception/MeetingErrorCode.java +++ b/src/main/java/com/whylog/server/domain/meeting/exception/MeetingErrorCode.java @@ -13,6 +13,7 @@ public enum MeetingErrorCode implements BaseErrorCode { MEETING_NOT_FOUND(HttpStatus.NOT_FOUND, "MEETING_404", "존재하지 않는 회의입니다."), MEETING_ALREADY_ENDED(HttpStatus.CONFLICT, "MEETING_409", "이미 종료된 회의입니다."), MEETING_INVALID_MEMBER(HttpStatus.CONFLICT, "MEETING_410", "회의에 소속된 참여자가 아닙니다."), + MEETING_NOT_OWNER(HttpStatus.FORBIDDEN, "MEETING_403", "회의 삭제 권한이 없습니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/whylog/server/domain/meeting/repository/DialogueRepository.java b/src/main/java/com/whylog/server/domain/meeting/repository/DialogueRepository.java new file mode 100644 index 0000000..e5af2f9 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/repository/DialogueRepository.java @@ -0,0 +1,18 @@ +package com.whylog.server.domain.meeting.repository; + +import com.whylog.server.domain.meeting.entity.Dialogue; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface DialogueRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM Dialogue d WHERE d.meeting.team.id = :teamId") + void deleteByTeamId(@Param("teamId") Long teamId); + + @Modifying + @Query("DELETE FROM Dialogue d WHERE d.meeting.id = :meetingId") + void deleteByMeetingId(@Param("meetingId") Long meetingId); +} diff --git a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingAnalysisRepository.java b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingAnalysisRepository.java new file mode 100644 index 0000000..3b3d6fc --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingAnalysisRepository.java @@ -0,0 +1,18 @@ +package com.whylog.server.domain.meeting.repository; + +import com.whylog.server.domain.meeting.entity.MeetingAnalysis; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface MeetingAnalysisRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM MeetingAnalysis ma WHERE ma.meeting.team.id = :teamId") + void deleteByTeamId(@Param("teamId") Long teamId); + + @Modifying + @Query("DELETE FROM MeetingAnalysis ma WHERE ma.meeting.id = :meetingId") + void deleteByMeetingId(@Param("meetingId") Long meetingId); +} diff --git a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingMemberRepository.java b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingMemberRepository.java index 4e10518..dc1d888 100644 --- a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingMemberRepository.java +++ b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingMemberRepository.java @@ -2,8 +2,41 @@ import com.whylog.server.domain.meeting.entity.MeetingMember; import com.whylog.server.domain.meeting.entity.MeetingMemberId; +import com.whylog.server.domain.meeting.enums.MeetingRole; +import java.util.Optional; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface MeetingMemberRepository extends JpaRepository { boolean existsByMemberIdAndMeetingId(Long memberId, Long meetingId); + + @Query(""" + SELECT mm + FROM MeetingMember mm + JOIN FETCH mm.meeting + WHERE mm.member.id = :memberId + AND mm.meeting.id = :meetingId + AND mm.role = :role + """) + Optional findOwnerMeetingMember( + @Param("memberId") Long memberId, + @Param("meetingId") Long meetingId, + @Param("role") MeetingRole role + ); + + @Modifying + @Query(""" + DELETE FROM MeetingMember mm + WHERE mm.meeting.team.id = :teamId + """) + void deleteByTeamId(@Param("teamId") Long teamId); + + @Modifying + @Query(""" + DELETE FROM MeetingMember mm + WHERE mm.meeting.id = :meetingId + """) + void deleteByMeetingId(@Param("meetingId") Long meetingId); } diff --git a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java index d60cf9c..abf553d 100644 --- a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java +++ b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java @@ -1,9 +1,8 @@ package com.whylog.server.domain.meeting.repository; import com.whylog.server.domain.meeting.entity.Meeting; -import com.whylog.server.domain.meeting.enums.MeetingStatus; -import io.swagger.v3.oas.annotations.Parameter; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -13,19 +12,33 @@ public interface MeetingRepository extends JpaRepository { @Query(""" - SELECT m FROM Meeting m - LEFT JOIN FETCH Team t - ON t.id = :teamId + SELECT m + FROM Meeting m + WHERE m.team.id = :teamId """) List findByTeamId(@Param("teamId") Long teamId); @Query(""" - SELECT m FROM Meeting m - LEFT JOIN FETCH MeetingMember mm - ON mm.meeting.id = m.id + SELECT DISTINCT m + FROM Meeting m + LEFT JOIN FETCH m.meetingMembers WHERE m.id = :meetingId """) Optional findWithMembers(@Param("meetingId") Long meetingId); + @Query(""" + SELECT m.id + FROM Meeting m + WHERE m.team.id = :teamId + """) + List findIdsByTeamId(@Param("teamId") Long teamId); + + @Modifying + @Query(""" + DELETE FROM Meeting m + WHERE m.team.id = :teamId + """) + void deleteByTeamId(@Param("teamId") Long teamId); + } diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingCleanupService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingCleanupService.java new file mode 100644 index 0000000..e3a2117 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingCleanupService.java @@ -0,0 +1,54 @@ +package com.whylog.server.domain.meeting.service; + +import com.whylog.server.domain.decision.repository.ApplicationRepository; +import com.whylog.server.domain.decision.repository.DecisionRepository; +import com.whylog.server.domain.meeting.repository.DialogueRepository; +import com.whylog.server.domain.meeting.repository.MeetingAnalysisRepository; +import com.whylog.server.domain.meeting.repository.MeetingMemberRepository; +import com.whylog.server.domain.meeting.repository.MeetingRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MeetingCleanupService { + + private final ApplicationRepository applicationRepository; + private final DecisionRepository decisionRepository; + private final MeetingAnalysisRepository meetingAnalysisRepository; + private final DialogueRepository dialogueRepository; + private final MeetingMemberRepository meetingMemberRepository; + private final MeetingRepository meetingRepository; + + @Transactional + public List deleteByTeamId(Long teamId) { + List meetingIds = meetingRepository.findIdsByTeamId(teamId); + deleteChildrenByTeamId(teamId); + meetingRepository.deleteByTeamId(teamId); + return meetingIds; + } + + @Transactional + public void deleteByMeetingId(Long meetingId) { + deleteChildrenByMeetingId(meetingId); + meetingRepository.deleteById(meetingId); + } + + private void deleteChildrenByTeamId(Long teamId) { + applicationRepository.deleteByTeamId(teamId); + decisionRepository.deleteByTeamId(teamId); + meetingAnalysisRepository.deleteByTeamId(teamId); + dialogueRepository.deleteByTeamId(teamId); + meetingMemberRepository.deleteByTeamId(teamId); + } + + private void deleteChildrenByMeetingId(Long meetingId) { + applicationRepository.deleteByMeetingId(meetingId); + decisionRepository.deleteByMeetingId(meetingId); + meetingAnalysisRepository.deleteByMeetingId(meetingId); + dialogueRepository.deleteByMeetingId(meetingId); + meetingMemberRepository.deleteByMeetingId(meetingId); + } +} diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java index 1b47318..fd6fb00 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java @@ -5,20 +5,25 @@ import com.whylog.server.domain.meeting.entity.Meeting; import com.whylog.server.domain.meeting.entity.MeetingMember; import com.whylog.server.domain.meeting.enums.MeetingRole; +import com.whylog.server.domain.meeting.exception.MeetingErrorCode; import com.whylog.server.domain.meeting.exception.MeetingAlreadyEndedException; import com.whylog.server.domain.meeting.exception.MeetingInvalidMemberException; import com.whylog.server.domain.meeting.exception.MeetingNotFoundException; import com.whylog.server.domain.meeting.repository.MeetingMemberRepository; import com.whylog.server.domain.meeting.repository.MeetingRepository; import com.whylog.server.domain.meeting.socket.MeetingSocketRoomService; +import com.whylog.server.domain.meeting.service.MeetingCleanupService; import com.whylog.server.domain.team.entity.Team; import com.whylog.server.domain.team.service.TeamUseCase; import com.whylog.server.domain.user.entity.Member; import com.whylog.server.domain.user.service.MemberUseCase; +import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; import com.whylog.server.global.apiPayload.exception.ParameterRequiredException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.time.LocalDateTime; @@ -28,6 +33,7 @@ public class MeetingCommandService { private final MeetingMemberRepository meetingMemberRepository; private final MeetingRepository meetingRepository; + private final MeetingCleanupService meetingCleanupService; private final MemberUseCase memberUseCase; private final TeamUseCase teamUseCase; @@ -106,4 +112,36 @@ public MeetingResponse.MeetingEndResponseDTO endMeeting(Long memberId, Long meet .build(); } + @Transactional + public MeetingResponse.MeetingDeleteResponseDTO deleteMeeting(Long memberId, Long meetingId) { + + meetingRepository.findById(meetingId) + .orElseThrow(MeetingNotFoundException::new); + + meetingMemberRepository.findOwnerMeetingMember(memberId, meetingId, MeetingRole.OWNER) + .orElseThrow(() -> new ErrorHandler(MeetingErrorCode.MEETING_NOT_OWNER)); + + meetingCleanupService.deleteByMeetingId(meetingId); + scheduleAfterCommit(() -> meetingSocketRoomService.closeRoom(meetingId)); + + return MeetingResponse.MeetingDeleteResponseDTO.builder() + .meetingId(meetingId) + .isRemoved(true) + .build(); + } + + private void scheduleAfterCommit(Runnable task) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + task.run(); + } + }); + return; + } + + task.run(); + } + } diff --git a/src/main/java/com/whylog/server/domain/team/controller/TeamController.java b/src/main/java/com/whylog/server/domain/team/controller/TeamController.java index 04d4bf2..eb942b7 100644 --- a/src/main/java/com/whylog/server/domain/team/controller/TeamController.java +++ b/src/main/java/com/whylog/server/domain/team/controller/TeamController.java @@ -22,13 +22,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestPart; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -126,4 +120,24 @@ public ApiResponse createTeam( return ApiResponse.onSuccess(teamCommandService.createTeam(memberId, request, image)); } + @DeleteMapping("/{teamId}") + @Operation(summary = "팀 제거 api", description = """ + + 팀 제거 API입니다.
+ 팀을 제거하면 관련된 데이터( 팀원, 회의, 저장소 ) 정보도 함께 제거됩니다. + + """) + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = TeamErrorCode.class, name = "TEAM_NOT_FOUND"), + @ApiErrorCodeExample(value = TeamErrorCode.class, name = "TEAM_NOT_OWNER") + }) + public ApiResponse removeTeam( + @CurrentMember Long memberId, + @PathVariable Long teamId + ) { + return ApiResponse.onSuccess(teamCommandService.removeTeam(memberId, teamId)); + } + } diff --git a/src/main/java/com/whylog/server/domain/team/dto/TeamResponse.java b/src/main/java/com/whylog/server/domain/team/dto/TeamResponse.java index f44da01..454c5f3 100644 --- a/src/main/java/com/whylog/server/domain/team/dto/TeamResponse.java +++ b/src/main/java/com/whylog/server/domain/team/dto/TeamResponse.java @@ -40,4 +40,15 @@ public static class TeamCreateResponseDTO { } + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "팀 제거 응답") + public static class TeamRemoveResponseDTO { + + @Schema(description = "제거 성공 여부", example = "true") + private Boolean isRemoved; + } + } diff --git a/src/main/java/com/whylog/server/domain/team/entity/Team.java b/src/main/java/com/whylog/server/domain/team/entity/Team.java index e226dc8..5c8a7e3 100644 --- a/src/main/java/com/whylog/server/domain/team/entity/Team.java +++ b/src/main/java/com/whylog/server/domain/team/entity/Team.java @@ -1,5 +1,7 @@ package com.whylog.server.domain.team.entity; +import com.whylog.server.domain.git.entity.Repository; +import com.whylog.server.domain.meeting.entity.Meeting; import com.whylog.server.domain.team.dto.TeamRequest; import com.whylog.server.global.entity.BaseEntity; import jakarta.persistence.CascadeType; @@ -47,12 +49,12 @@ public static Team create(TeamRequest.TeamCreateDTO dto, String image) { .build(); } -// @OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true) -// private final List teamMembers = new ArrayList<>(); + @OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true) + private final List teamMembers = new ArrayList<>(); // -// @OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true) -// private final List meetings = new ArrayList<>(); + @OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true) + private final List meetings = new ArrayList<>(); // -// @OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true) -// private final List repositories = new ArrayList<>(); + @OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true) + private final List repositories = new ArrayList<>(); } diff --git a/src/main/java/com/whylog/server/domain/team/exception/TeamErrorCode.java b/src/main/java/com/whylog/server/domain/team/exception/TeamErrorCode.java index bbec6e2..9d5bcc3 100644 --- a/src/main/java/com/whylog/server/domain/team/exception/TeamErrorCode.java +++ b/src/main/java/com/whylog/server/domain/team/exception/TeamErrorCode.java @@ -11,6 +11,7 @@ public enum TeamErrorCode implements BaseErrorCode { TEAM_NOT_FOUND(HttpStatus.NOT_FOUND, "TEAM_404", "존재하지 않는 팀입니다."), + TEAM_NOT_OWNER(HttpStatus.FORBIDDEN, "TEAM_403", "팀 삭제 권한이 없습니다."), // 400 Bad Request TEAM_NAME_LENGTH(HttpStatus.BAD_REQUEST, "TEAM_400", "팀명 길이는 50글자 미만이어야 합니다."), diff --git a/src/main/java/com/whylog/server/domain/team/repository/TeamMemberRepository.java b/src/main/java/com/whylog/server/domain/team/repository/TeamMemberRepository.java index 4f4b88a..5706bb9 100644 --- a/src/main/java/com/whylog/server/domain/team/repository/TeamMemberRepository.java +++ b/src/main/java/com/whylog/server/domain/team/repository/TeamMemberRepository.java @@ -3,6 +3,9 @@ import com.whylog.server.domain.team.entity.TeamMember; import com.whylog.server.domain.team.entity.TeamMemberId; import java.util.List; +import java.util.Optional; + +import com.whylog.server.domain.team.enums.TeamRole; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -20,4 +23,19 @@ public interface TeamMemberRepository extends JpaRepository findActiveTeamsByMemberId(@Param("memberId") Long memberId); + + @Query(""" + SELECT tm + FROM TeamMember tm + JOIN FETCH tm.team + WHERE tm.member.id = :memberId + AND tm.team.id = :teamId + AND tm.role = :role +""") + Optional findOwnerTeamMember( + @Param("memberId") Long memberId, + @Param("teamId") Long teamId, + @Param("role") TeamRole role + ); + } diff --git a/src/main/java/com/whylog/server/domain/team/service/TeamCommandService.java b/src/main/java/com/whylog/server/domain/team/service/TeamCommandService.java index 0c51a88..bed7bd5 100644 --- a/src/main/java/com/whylog/server/domain/team/service/TeamCommandService.java +++ b/src/main/java/com/whylog/server/domain/team/service/TeamCommandService.java @@ -8,27 +8,37 @@ import com.whylog.server.domain.team.exception.TeamErrorCode; import com.whylog.server.domain.team.repository.TeamMemberRepository; import com.whylog.server.domain.team.repository.TeamRepository; +import com.whylog.server.domain.meeting.service.MeetingCleanupService; +import com.whylog.server.domain.meeting.socket.MeetingSocketRoomService; import com.whylog.server.domain.user.entity.Member; import com.whylog.server.domain.user.service.MemberUseCase; import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; import com.whylog.server.global.external.s3.ImageType; import com.whylog.server.global.external.s3.S3Client; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.multipart.MultipartFile; +import java.util.List; + @Service +@Slf4j @RequiredArgsConstructor +@Transactional public class TeamCommandService { private final TeamRepository teamRepository; private final TeamMemberRepository teamMemberRepository; + private final MeetingCleanupService meetingCleanupService; + private final MeetingSocketRoomService meetingSocketRoomService; private final MemberUseCase memberUseCase; private final TeamUseCase teamUseCase; private final S3Client s3Client; - @Transactional public TeamResponse.TeamCreateResponseDTO createTeam(Long memberId, TeamRequest.TeamCreateDTO request, MultipartFile image){ // 팀명 이미 존재하면 예외 발생 @@ -61,7 +71,6 @@ public TeamResponse.TeamCreateResponseDTO createTeam(Long memberId, TeamRequest. .build(); } - @Transactional public TeamResponse.InvitationResponseDTO invite(Long teamId, TeamRequest.InvitationDTO request){ // 데이터 조회 @@ -82,6 +91,45 @@ public TeamResponse.InvitationResponseDTO invite(Long teamId, TeamRequest.Invita .build(); } + public TeamResponse.TeamRemoveResponseDTO removeTeam(Long memberId, Long teamId) { + Team team = teamUseCase.findTeamById(teamId); + + teamMemberRepository.findOwnerTeamMember(memberId, teamId, TeamRole.OWNER) + .orElseThrow(() -> new ErrorHandler(TeamErrorCode.TEAM_NOT_OWNER)); + + List meetingIds = meetingCleanupService.deleteByTeamId(teamId); + teamRepository.delete(team); + + // 팀 제거로 인한 미팅 종료 + scheduleAfterCommit(() -> meetingIds.forEach(meetingSocketRoomService::closeRoom)); + scheduleAfterCommit(() -> { + try { + s3Client.deleteFile(team.getImage()); + } catch (RuntimeException exception) { + log.warn("Failed to delete team image from S3: teamId={}, imageKey={}", teamId, team.getImage(), exception); + } + }); + + return TeamResponse.TeamRemoveResponseDTO.builder() + .isRemoved(true) + .build(); + + } + + private void scheduleAfterCommit(Runnable task) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + task.run(); + } + }); + return; + } + + task.run(); + } + private TeamMember addMember(Team team, Member member, TeamRole role){ TeamMember teamMember = TeamMember.create(team, member, role); return teamMemberRepository.save(teamMember); diff --git a/src/main/java/com/whylog/server/global/external/s3/S3Client.java b/src/main/java/com/whylog/server/global/external/s3/S3Client.java index 7c123af..d1d5a32 100644 --- a/src/main/java/com/whylog/server/global/external/s3/S3Client.java +++ b/src/main/java/com/whylog/server/global/external/s3/S3Client.java @@ -11,6 +11,7 @@ import org.springframework.web.multipart.MultipartFile; import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest; @Slf4j @@ -74,6 +75,28 @@ public String getFileUrl(String fileName) { return "https://" + bucket + ".s3." + region + ".amazonaws.com/" + encodedFileName; } + public void deleteFile(String fileName) { + if (!StringUtils.hasText(fileName)) { + return; + } + + if (!StringUtils.hasText(bucket)) { + throw new S3Exception(S3ErrorCode.S3_BUCKET_NOT_CONFIGURED); + } + + DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder() + .bucket(bucket) + .key(fileName) + .build(); + + try { + s3Client.deleteObject(deleteObjectRequest); + } catch (software.amazon.awssdk.services.s3.model.S3Exception | SdkClientException e) { + log.error("S3 삭제 에러 발생: {}", e.getMessage()); + throw new S3Exception(S3ErrorCode.S3_DELETE_FAILED); + } + } + // ------------------------------------------------------------------------------------------------------------------------------ // 파일 이름 생성 diff --git a/src/main/java/com/whylog/server/global/external/s3/S3ErrorCode.java b/src/main/java/com/whylog/server/global/external/s3/S3ErrorCode.java index a52e385..2cd3a49 100644 --- a/src/main/java/com/whylog/server/global/external/s3/S3ErrorCode.java +++ b/src/main/java/com/whylog/server/global/external/s3/S3ErrorCode.java @@ -14,6 +14,7 @@ public enum S3ErrorCode implements BaseErrorCode { S3_FILE_NAME_EMPTY(HttpStatus.BAD_REQUEST, "S3_400_2", "S3 파일명이 비어있습니다."), S3_BUCKET_NOT_CONFIGURED(HttpStatus.INTERNAL_SERVER_ERROR, "S3_500_1", "S3 버킷 설정이 필요합니다."), S3_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "S3_500_2", "S3 파일 업로드에 실패했습니다."), + S3_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "S3_500_3", "S3 파일 삭제에 실패했습니다."), ; private final HttpStatus httpStatus;