diff --git a/build.gradle b/build.gradle index 8b278865..a207b72c 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.retry:spring-retry' // db implementation 'com.mysql:mysql-connector-j' @@ -46,6 +47,7 @@ dependencies { // notification implementation 'com.github.maricn:logback-slack-appender:1.4.0' implementation 'net.logstash.logback:logstash-logback-encoder:8.0' + implementation 'com.slack.api:slack-api-client:1.44.2' // querydsl implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' diff --git a/src/main/java/gg/agit/konect/KonectApplication.java b/src/main/java/gg/agit/konect/KonectApplication.java index e171797e..e5f3a2f7 100644 --- a/src/main/java/gg/agit/konect/KonectApplication.java +++ b/src/main/java/gg/agit/konect/KonectApplication.java @@ -3,7 +3,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.retry.annotation.EnableRetry; +@EnableRetry @SpringBootApplication @ConfigurationPropertiesScan public class KonectApplication { diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java index b8116ccb..3f6039da 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java @@ -4,7 +4,6 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; import org.springframework.data.repository.query.Param; @@ -52,11 +51,4 @@ List findUnreadMessagesByChatRoomIdAndUserId( @Param("chatRoomId") Integer chatRoomId, @Param("receiverId") Integer receiverId ); - - @Modifying - @Query(""" - DELETE FROM ChatMessage cm - WHERE cm.sender.id = :userId OR cm.receiver.id = :userId - """) - void deleteByUserId(@Param("userId") Integer userId); } diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java index 5bec2da4..92b86323 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java @@ -3,7 +3,6 @@ import java.util.List; import java.util.Optional; -import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; import org.springframework.data.repository.query.Param; @@ -40,11 +39,4 @@ public interface ChatRoomRepository extends Repository { OR (cr.sender.id = :userId2 AND cr.receiver.id = :userId1) """) Optional findByTwoUsers(@Param("userId1") Integer userId1, @Param("userId2") Integer userId2); - - @Modifying - @Query(""" - DELETE FROM ChatRoom cr - WHERE cr.sender.id = :userId OR cr.receiver.id = :userId - """) - void deleteByUserId(@Param("userId") Integer userId); } diff --git a/src/main/java/gg/agit/konect/domain/club/model/ClubMember.java b/src/main/java/gg/agit/konect/domain/club/model/ClubMember.java index 1c12ce7c..cd748285 100644 --- a/src/main/java/gg/agit/konect/domain/club/model/ClubMember.java +++ b/src/main/java/gg/agit/konect/domain/club/model/ClubMember.java @@ -58,4 +58,8 @@ public boolean isPresident() { public boolean isSameUser(Integer userId) { return this.user.getId().equals(userId); } + + public boolean hasUnpaidFee() { + return Boolean.FALSE.equals(this.isFeePaid); + } } diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubApplyRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubApplyRepository.java index 11cba5c1..87debe1f 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubApplyRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubApplyRepository.java @@ -9,6 +9,4 @@ public interface ClubApplyRepository extends Repository { boolean existsByClubIdAndUserId(Integer clubId, Integer userId); ClubApply save(ClubApply clubApply); - - void deleteByUserId(Integer userId); } diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java index 10a46254..5c1a78b2 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java @@ -43,6 +43,4 @@ public interface ClubMemberRepository extends Repository findByUserId(Integer userId); - - void deleteByUserId(Integer userId); } diff --git a/src/main/java/gg/agit/konect/domain/notice/repository/CouncilNoticeReadRepository.java b/src/main/java/gg/agit/konect/domain/notice/repository/CouncilNoticeReadRepository.java index 2ff8e81c..4554903c 100644 --- a/src/main/java/gg/agit/konect/domain/notice/repository/CouncilNoticeReadRepository.java +++ b/src/main/java/gg/agit/konect/domain/notice/repository/CouncilNoticeReadRepository.java @@ -26,6 +26,4 @@ WHERE NOT EXISTS ( ) """) Long countUnreadNoticesByUserId(@Param("userId") Integer userId); - - void deleteByUserId(Integer userId); } diff --git a/src/main/java/gg/agit/konect/domain/user/event/UserRegisteredEvent.java b/src/main/java/gg/agit/konect/domain/user/event/UserRegisteredEvent.java new file mode 100644 index 00000000..7f6f584c --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/user/event/UserRegisteredEvent.java @@ -0,0 +1,9 @@ +package gg.agit.konect.domain.user.event; + +public record UserRegisteredEvent( + String email +) { + public static UserRegisteredEvent from(String email) { + return new UserRegisteredEvent(email); + } +} diff --git a/src/main/java/gg/agit/konect/domain/user/event/UserWithdrawnEvent.java b/src/main/java/gg/agit/konect/domain/user/event/UserWithdrawnEvent.java new file mode 100644 index 00000000..847f7f20 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/user/event/UserWithdrawnEvent.java @@ -0,0 +1,9 @@ +package gg.agit.konect.domain.user.event; + +public record UserWithdrawnEvent( + String email +) { + public static UserWithdrawnEvent from(String email) { + return new UserWithdrawnEvent(email); + } +} diff --git a/src/main/java/gg/agit/konect/domain/user/service/UserService.java b/src/main/java/gg/agit/konect/domain/user/service/UserService.java index 50316352..693acd17 100644 --- a/src/main/java/gg/agit/konect/domain/user/service/UserService.java +++ b/src/main/java/gg/agit/konect/domain/user/service/UserService.java @@ -1,17 +1,16 @@ package gg.agit.konect.domain.user.service; import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_DELETE_CLUB_PRESIDENT; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_DELETE_USER_WITH_UNPAID_FEE; import java.util.List; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; -import gg.agit.konect.domain.chat.repository.ChatMessageRepository; -import gg.agit.konect.domain.chat.repository.ChatRoomRepository; import gg.agit.konect.domain.club.model.ClubMember; -import gg.agit.konect.domain.club.repository.ClubApplyRepository; import gg.agit.konect.domain.club.repository.ClubMemberRepository; import gg.agit.konect.domain.notice.repository.CouncilNoticeReadRepository; import gg.agit.konect.domain.studytime.service.StudyTimeQueryService; @@ -21,6 +20,8 @@ import gg.agit.konect.domain.user.dto.UserInfoResponse; import gg.agit.konect.domain.user.dto.UserUpdateRequest; import gg.agit.konect.domain.user.enums.Provider; +import gg.agit.konect.domain.user.event.UserRegisteredEvent; +import gg.agit.konect.domain.user.event.UserWithdrawnEvent; import gg.agit.konect.domain.user.model.UnRegisteredUser; import gg.agit.konect.domain.user.model.User; import gg.agit.konect.domain.user.repository.UnRegisteredUserRepository; @@ -39,10 +40,8 @@ public class UserService { private final UniversityRepository universityRepository; private final ClubMemberRepository clubMemberRepository; private final CouncilNoticeReadRepository councilNoticeReadRepository; - private final ClubApplyRepository clubApplyRepository; - private final ChatMessageRepository chatMessageRepository; - private final ChatRoomRepository chatRoomRepository; private final StudyTimeQueryService studyTimeQueryService; + private final ApplicationEventPublisher applicationEventPublisher; @Transactional public Integer signup(String email, Provider provider, SignupRequest request) { @@ -74,6 +73,7 @@ public Integer signup(String email, Provider provider, SignupRequest request) { unRegisteredUserRepository.delete(tempUser); + applicationEventPublisher.publishEvent(UserRegisteredEvent.from(savedUser.getEmail())); return savedUser.getId(); } @@ -136,18 +136,28 @@ private void validatePhoneNumberDuplication(User user, UserUpdateRequest request public void deleteUser(Integer userId) { User user = userRepository.getById(userId); + validateNotClubPresident(userId); + validatePaidFees(userId); + userRepository.delete(user); + + applicationEventPublisher.publishEvent(UserWithdrawnEvent.from(user.getEmail())); + } + + private void validateNotClubPresident(Integer userId) { List clubMembers = clubMemberRepository.findByUserId(userId); boolean isPresident = clubMembers.stream().anyMatch(ClubMember::isPresident); if (isPresident) { throw CustomException.of(CANNOT_DELETE_CLUB_PRESIDENT); } + } - // TODO. 메시지 데이터 히스토리 테이블로 이관 로직 추가 - chatMessageRepository.deleteByUserId(userId); - chatRoomRepository.deleteByUserId(userId); - councilNoticeReadRepository.deleteByUserId(userId); - clubApplyRepository.deleteByUserId(userId); - clubMemberRepository.deleteByUserId(userId); - userRepository.delete(user); + private void validatePaidFees(Integer userId) { + List clubMembers = clubMemberRepository.findByUserId(userId); + boolean hasUnpaidFee = clubMembers.stream() + .anyMatch(ClubMember::hasUnpaidFee); + + if (hasUnpaidFee) { + throw CustomException.of(CANNOT_DELETE_USER_WITH_UNPAID_FEE); + } } } diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index 74f4b096..919edbfb 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -22,6 +22,7 @@ public enum ApiResponseCode { CANNOT_CREATE_CHAT_ROOM_WITH_SELF(HttpStatus.BAD_REQUEST, "자기 자신과는 채팅방을 만들 수 없습니다."), REQUIRED_CLUB_APPLY_ANSWER_MISSING(HttpStatus.BAD_REQUEST, "필수 가입 답변이 누락되었습니다."), CANNOT_DELETE_CLUB_PRESIDENT(HttpStatus.BAD_REQUEST, "동아리 회장인 경우 회장을 양도하고 탈퇴해야 합니다."), + CANNOT_DELETE_USER_WITH_UNPAID_FEE(HttpStatus.BAD_REQUEST, "미납 회비가 있는 경우 탈퇴할 수 없습니다."), STUDY_TIMER_NOT_RUNNING(HttpStatus.BAD_REQUEST, "실행 중인 스터디 타이머가 없습니다."), STUDY_TIMER_TIME_MISMATCH(HttpStatus.BAD_REQUEST, "스터디 타이머 시간이 유효하지 않습니다."), diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/client/SlackClient.java b/src/main/java/gg/agit/konect/infrastructure/slack/client/SlackClient.java new file mode 100644 index 00000000..25fc4c85 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/slack/client/SlackClient.java @@ -0,0 +1,45 @@ +package gg.agit.konect.infrastructure.slack.client; + +import static org.springframework.http.MediaType.APPLICATION_JSON; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SlackClient { + + private final RestTemplate restTemplate; + + @Retryable + public void sendMessage(String message, String url) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(APPLICATION_JSON); + + Map payload = new HashMap<>(); + payload.put("text", message); + + HttpEntity> request = new HttpEntity<>(payload, headers); + restTemplate.postForEntity( + url, + request, + String.class + ); + } + + @Recover + public void sendMessageRecover(Exception e, String message, String url) { + log.error("Slack 메시지 전송 실패 : message={}, url={}", message, url, e); + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/config/SlackProperties.java b/src/main/java/gg/agit/konect/infrastructure/slack/config/SlackProperties.java new file mode 100644 index 00000000..e66b97cc --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/slack/config/SlackProperties.java @@ -0,0 +1,15 @@ +package gg.agit.konect.infrastructure.slack.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "slack") +public record SlackProperties( + Webhooks webhooks +) { + public record Webhooks( + String error, + String event + ) { + + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java b/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java new file mode 100644 index 00000000..4e0167f5 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java @@ -0,0 +1,27 @@ +package gg.agit.konect.infrastructure.slack.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SlackMessageTemplate { + + USER_REGISTER( + """ + `%s님이 가입하셨습니다.` + """ + ), + USER_WITHDRAWAL( + """ + `%s님이 탈퇴하셨습니다.` + """ + ), + ; + + private final String template; + + public String format(Object... args) { + return String.format(template, args); + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/listener/UserSlackListener.java b/src/main/java/gg/agit/konect/infrastructure/slack/listener/UserSlackListener.java new file mode 100644 index 00000000..d5bd5f77 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/slack/listener/UserSlackListener.java @@ -0,0 +1,31 @@ +package gg.agit.konect.infrastructure.slack.listener; + +import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +import gg.agit.konect.domain.user.event.UserRegisteredEvent; +import gg.agit.konect.domain.user.event.UserWithdrawnEvent; +import gg.agit.konect.infrastructure.slack.service.SlackNotificationService; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class UserSlackListener { + + private final SlackNotificationService slackNotificationService; + + @Async + @TransactionalEventListener(phase = AFTER_COMMIT) + public void handleUserWithdrawn(UserWithdrawnEvent event) { + slackNotificationService.notifyUserWithdraw(event.email()); + } + + @Async + @TransactionalEventListener(phase = AFTER_COMMIT) + public void handleUserRegistered(UserRegisteredEvent event) { + slackNotificationService.notifyUserRegister(event.email()); + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/service/SlackNotificationService.java b/src/main/java/gg/agit/konect/infrastructure/slack/service/SlackNotificationService.java new file mode 100644 index 00000000..af0b1d6d --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/slack/service/SlackNotificationService.java @@ -0,0 +1,28 @@ +package gg.agit.konect.infrastructure.slack.service; + +import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.USER_REGISTER; +import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.USER_WITHDRAWAL; + +import org.springframework.stereotype.Service; + +import gg.agit.konect.infrastructure.slack.client.SlackClient; +import gg.agit.konect.infrastructure.slack.config.SlackProperties; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class SlackNotificationService { + + private final SlackProperties slackProperties; + private final SlackClient slackClient; + + public void notifyUserWithdraw(String email) { + String message = USER_WITHDRAWAL.format(email); + slackClient.sendMessage(message, slackProperties.webhooks().event()); + } + + public void notifyUserRegister(String email) { + String message = USER_REGISTER.format(email); + slackClient.sendMessage(message, slackProperties.webhooks().event()); + } +}