diff --git a/build.gradle b/build.gradle index d641c37ad..1ee05e906 100644 --- a/build.gradle +++ b/build.gradle @@ -117,7 +117,7 @@ tasks.named('test') { def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile sourceSets { - main.java.srcDirs += [ querydslDir ] + main.java.srcDirs += [querydslDir] } tasks.withType(JavaCompile).configureEach { diff --git a/src/main/java/page/clab/api/domain/auth/accountLockInfo/application/service/MemberBanService.java b/src/main/java/page/clab/api/domain/auth/accountLockInfo/application/service/MemberBanService.java index d83793ba0..a7ca3a616 100644 --- a/src/main/java/page/clab/api/domain/auth/accountLockInfo/application/service/MemberBanService.java +++ b/src/main/java/page/clab/api/domain/auth/accountLockInfo/application/service/MemberBanService.java @@ -2,6 +2,7 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.auth.accountLockInfo.application.port.in.BanMemberUseCase; @@ -11,8 +12,8 @@ import page.clab.api.domain.memberManagement.member.application.dto.shared.MemberBasicInfoDto; import page.clab.api.external.auth.redisToken.application.port.ExternalManageRedisTokenUseCase; import page.clab.api.external.memberManagement.member.application.port.ExternalRetrieveMemberUseCase; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; @Service @RequiredArgsConstructor @@ -22,7 +23,7 @@ public class MemberBanService implements BanMemberUseCase { private final RegisterAccountLockInfoPort registerAccountLockInfoPort; private final ExternalRetrieveMemberUseCase externalRetrieveMemberUseCase; private final ExternalManageRedisTokenUseCase externalManageRedisTokenUseCase; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; /** * 멤버를 영구적으로 차단합니다. @@ -30,7 +31,7 @@ public class MemberBanService implements BanMemberUseCase { *

해당 멤버의 계정 잠금 정보를 조회하고, 없으면 새로 생성합니다. * Redis에 저장된 해당 멤버의 인증 토큰을 삭제하며, Slack에 밴 알림을 전송합니다.

* - * @param request 현재 요청 객체 + * @param request 현재 요청 객체 * @param memberId 차단할 멤버의 ID * @return 저장된 계정 잠금 정보의 ID */ @@ -58,6 +59,8 @@ private AccountLockInfo createAccountLockInfo(String memberId) { private void sendSlackBanNotification(HttpServletRequest request, String memberId) { String memberName = externalRetrieveMemberUseCase.getMemberBasicInfoById(memberId).getMemberName(); - slackService.sendSecurityAlertNotification(request, SecurityAlertType.MEMBER_BANNED, "ID: " + memberId + ", Name: " + memberName); + String memberBannedMessage = "ID: " + memberId + ", Name: " + memberName; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.MEMBER_BANNED, request, memberBannedMessage)); } } diff --git a/src/main/java/page/clab/api/domain/auth/accountLockInfo/application/service/MemberUnbanService.java b/src/main/java/page/clab/api/domain/auth/accountLockInfo/application/service/MemberUnbanService.java index 391628d8e..1aaf67e4d 100644 --- a/src/main/java/page/clab/api/domain/auth/accountLockInfo/application/service/MemberUnbanService.java +++ b/src/main/java/page/clab/api/domain/auth/accountLockInfo/application/service/MemberUnbanService.java @@ -2,6 +2,7 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.auth.accountLockInfo.application.port.in.UnbanMemberUseCase; @@ -10,8 +11,8 @@ import page.clab.api.domain.auth.accountLockInfo.domain.AccountLockInfo; import page.clab.api.domain.memberManagement.member.application.dto.shared.MemberBasicInfoDto; import page.clab.api.external.memberManagement.member.application.port.ExternalRetrieveMemberUseCase; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; @Service @RequiredArgsConstructor @@ -20,7 +21,7 @@ public class MemberUnbanService implements UnbanMemberUseCase { private final RetrieveAccountLockInfoPort retrieveAccountLockInfoPort; private final RegisterAccountLockInfoPort registerAccountLockInfoPort; private final ExternalRetrieveMemberUseCase externalRetrieveMemberUseCase; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; /** * 차단된 멤버를 해제합니다. @@ -28,7 +29,7 @@ public class MemberUnbanService implements UnbanMemberUseCase { *

해당 멤버의 계정 잠금 정보를 조회하고 해제합니다. * 해제된 정보는 저장되며, Slack에 해제 알림이 전송됩니다.

* - * @param request 현재 요청 객체 + * @param request 현재 요청 객체 * @param memberId 해제할 멤버의 ID * @return 업데이트된 계정 잠금 정보의 ID */ @@ -55,6 +56,8 @@ private AccountLockInfo createAccountLockInfo(String memberId) { private void sendSlackUnbanNotification(HttpServletRequest request, String memberId) { String memberName = externalRetrieveMemberUseCase.getMemberBasicInfoById(memberId).getMemberName(); - slackService.sendSecurityAlertNotification(request, SecurityAlertType.MEMBER_UNBANNED, "ID: " + memberId + ", Name: " + memberName); + String memberUnbannedMessage = "ID: " + memberId + ", Name: " + memberName; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.MEMBER_UNBANNED, request, memberUnbannedMessage)); } } diff --git a/src/main/java/page/clab/api/domain/auth/blacklistIp/application/service/BlacklistIpRegisterService.java b/src/main/java/page/clab/api/domain/auth/blacklistIp/application/service/BlacklistIpRegisterService.java index 6694afdec..42bc014d8 100644 --- a/src/main/java/page/clab/api/domain/auth/blacklistIp/application/service/BlacklistIpRegisterService.java +++ b/src/main/java/page/clab/api/domain/auth/blacklistIp/application/service/BlacklistIpRegisterService.java @@ -2,6 +2,7 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.auth.blacklistIp.application.dto.mapper.BlacklistIpDtoMapper; @@ -10,8 +11,8 @@ import page.clab.api.domain.auth.blacklistIp.application.port.out.RegisterBlacklistIpPort; import page.clab.api.domain.auth.blacklistIp.application.port.out.RetrieveBlacklistIpPort; import page.clab.api.domain.auth.blacklistIp.domain.BlacklistIp; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; @Service @RequiredArgsConstructor @@ -19,17 +20,16 @@ public class BlacklistIpRegisterService implements RegisterBlacklistIpUseCase { private final RegisterBlacklistIpPort registerBlacklistIpPort; private final RetrieveBlacklistIpPort retrieveBlacklistIpPort; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; private final BlacklistIpDtoMapper mapper; /** * 지정된 IP 주소를 블랙리스트에 등록합니다. * *

해당 IP 주소가 이미 블랙리스트에 존재하는지 확인하고, - * 존재하지 않을 경우 새롭게 등록합니다. - * 새로운 IP가 등록되면 Slack을 통해 보안 알림이 전송됩니다.

+ * 존재하지 않을 경우 새롭게 등록합니다. 새로운 IP가 등록되면 Slack을 통해 보안 알림이 전송됩니다.

* - * @param request 현재 요청 객체 + * @param request 현재 요청 객체 * @param requestDto 블랙리스트에 추가할 IP 주소 정보를 담은 DTO * @return 기존에 존재하거나 새로 추가된 블랙리스트 IP 주소 */ @@ -42,7 +42,12 @@ public String registerBlacklistIp(HttpServletRequest request, BlacklistIpRequest .orElseGet(() -> { BlacklistIp blacklistIp = mapper.fromDto(requestDto); registerBlacklistIpPort.save(blacklistIp); - slackService.sendSecurityAlertNotification(request, SecurityAlertType.BLACKLISTED_IP_ADDED, "Added IP: " + ipAddress); + + String blacklistAddedMessage = "Added IP: " + ipAddress; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.BLACKLISTED_IP_ADDED, request, + blacklistAddedMessage)); + return ipAddress; }); } diff --git a/src/main/java/page/clab/api/domain/auth/blacklistIp/application/service/BlacklistIpRemoveService.java b/src/main/java/page/clab/api/domain/auth/blacklistIp/application/service/BlacklistIpRemoveService.java index 5de09b640..26b558adc 100644 --- a/src/main/java/page/clab/api/domain/auth/blacklistIp/application/service/BlacklistIpRemoveService.java +++ b/src/main/java/page/clab/api/domain/auth/blacklistIp/application/service/BlacklistIpRemoveService.java @@ -2,14 +2,15 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.auth.blacklistIp.application.port.in.RemoveBlacklistIpUseCase; import page.clab.api.domain.auth.blacklistIp.application.port.out.RemoveBlacklistIpPort; import page.clab.api.domain.auth.blacklistIp.application.port.out.RetrieveBlacklistIpPort; import page.clab.api.domain.auth.blacklistIp.domain.BlacklistIp; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; @Service @RequiredArgsConstructor @@ -17,7 +18,7 @@ public class BlacklistIpRemoveService implements RemoveBlacklistIpUseCase { private final RetrieveBlacklistIpPort retrieveBlacklistIpPort; private final RemoveBlacklistIpPort removeBlacklistIpPort; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; /** * 지정된 IP 주소를 블랙리스트에서 제거합니다. @@ -25,7 +26,7 @@ public class BlacklistIpRemoveService implements RemoveBlacklistIpUseCase { *

블랙리스트에 등록된 IP 주소 정보를 조회하고 해당 정보를 삭제합니다. * 삭제가 완료되면 Slack을 통해 보안 알림이 전송됩니다.

* - * @param request 현재 요청 객체 + * @param request 현재 요청 객체 * @param ipAddress 제거할 블랙리스트 IP 주소 * @return 삭제된 블랙리스트 IP 주소 */ @@ -34,7 +35,12 @@ public class BlacklistIpRemoveService implements RemoveBlacklistIpUseCase { public String removeBlacklistIp(HttpServletRequest request, String ipAddress) { BlacklistIp blacklistIp = retrieveBlacklistIpPort.getByIpAddress(ipAddress); removeBlacklistIpPort.delete(blacklistIp); - slackService.sendSecurityAlertNotification(request, SecurityAlertType.BLACKLISTED_IP_REMOVED, "Deleted IP: " + ipAddress); + + String blacklistRemovedMessage = "Deleted IP: " + ipAddress; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.BLACKLISTED_IP_REMOVED, request, + blacklistRemovedMessage)); + return blacklistIp.getIpAddress(); } } diff --git a/src/main/java/page/clab/api/domain/auth/blacklistIp/application/service/BlacklistIpResetService.java b/src/main/java/page/clab/api/domain/auth/blacklistIp/application/service/BlacklistIpResetService.java index 342a2561f..cdcf60021 100644 --- a/src/main/java/page/clab/api/domain/auth/blacklistIp/application/service/BlacklistIpResetService.java +++ b/src/main/java/page/clab/api/domain/auth/blacklistIp/application/service/BlacklistIpResetService.java @@ -1,17 +1,17 @@ package page.clab.api.domain.auth.blacklistIp.application.service; import jakarta.servlet.http.HttpServletRequest; +import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.auth.blacklistIp.application.port.in.ResetBlacklistIpsUseCase; import page.clab.api.domain.auth.blacklistIp.application.port.out.RemoveBlacklistIpPort; import page.clab.api.domain.auth.blacklistIp.application.port.out.RetrieveBlacklistIpPort; import page.clab.api.domain.auth.blacklistIp.domain.BlacklistIp; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; - -import java.util.List; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; @Service @RequiredArgsConstructor @@ -19,7 +19,7 @@ public class BlacklistIpResetService implements ResetBlacklistIpsUseCase { private final RetrieveBlacklistIpPort retrieveBlacklistIpPort; private final RemoveBlacklistIpPort removeBlacklistIpPort; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; /** * 블랙리스트에 등록된 모든 IP 주소를 초기화합니다. @@ -38,7 +38,12 @@ public List resetBlacklistIps(HttpServletRequest request) { .map(BlacklistIp::getIpAddress) .toList(); removeBlacklistIpPort.deleteAll(); - slackService.sendSecurityAlertNotification(request, SecurityAlertType.BLACKLISTED_IP_REMOVED, "Deleted IP: ALL"); + + String blacklistRemovedMessage = "Deleted IP: ALL"; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.BLACKLISTED_IP_REMOVED, request, + blacklistRemovedMessage)); + return blacklistedIps; } } diff --git a/src/main/java/page/clab/api/domain/auth/login/application/service/TwoFactorAuthenticationService.java b/src/main/java/page/clab/api/domain/auth/login/application/service/TwoFactorAuthenticationService.java index 655a51a06..402cf4ee7 100644 --- a/src/main/java/page/clab/api/domain/auth/login/application/service/TwoFactorAuthenticationService.java +++ b/src/main/java/page/clab/api/domain/auth/login/application/service/TwoFactorAuthenticationService.java @@ -1,8 +1,10 @@ package page.clab.api.domain.auth.login.application.service; import jakarta.servlet.http.HttpServletRequest; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.auth.accountAccessLog.domain.AccountAccessResult; @@ -21,11 +23,10 @@ import page.clab.api.external.auth.redisToken.application.port.ExternalManageRedisTokenUseCase; import page.clab.api.external.memberManagement.member.application.port.ExternalRetrieveMemberUseCase; import page.clab.api.global.auth.jwt.JwtTokenProvider; -import page.clab.api.global.common.slack.application.SlackService; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.GeneralAlertType; import page.clab.api.global.util.HttpReqResUtil; -import java.util.List; - @Service @RequiredArgsConstructor @Qualifier("twoFactorAuthenticationService") @@ -36,12 +37,14 @@ public class TwoFactorAuthenticationService implements ManageLoginUseCase { private final ExternalRetrieveMemberUseCase externalRetrieveMemberUseCase; private final ExternalRegisterAccountAccessLogUseCase externalRegisterAccountAccessLogUseCase; private final ExternalManageRedisTokenUseCase externalManageRedisTokenUseCase; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; private final JwtTokenProvider jwtTokenProvider; @Transactional @Override - public LoginResult authenticate(HttpServletRequest request, TwoFactorAuthenticationRequestDto twoFactorAuthenticationRequestDto) throws LoginFailedException, MemberLockedException { + public LoginResult authenticate(HttpServletRequest request, + TwoFactorAuthenticationRequestDto twoFactorAuthenticationRequestDto) + throws LoginFailedException, MemberLockedException { String memberId = twoFactorAuthenticationRequestDto.getMemberId(); MemberLoginInfoDto loginMember = externalRetrieveMemberUseCase.getMemberLoginInfoById(memberId); String totp = twoFactorAuthenticationRequestDto.getTotp(); @@ -55,9 +58,11 @@ public LoginResult authenticate(HttpServletRequest request, TwoFactorAuthenticat return LoginResult.create(header, true); } - private void verifyTwoFactorAuthentication(String memberId, String totp, HttpServletRequest request) throws MemberLockedException, LoginFailedException { + private void verifyTwoFactorAuthentication(String memberId, String totp, HttpServletRequest request) + throws MemberLockedException, LoginFailedException { if (!manageAuthenticatorUseCase.isAuthenticatorValid(memberId, totp)) { - externalRegisterAccountAccessLogUseCase.registerAccountAccessLog(request, memberId, AccountAccessResult.FAILURE); + externalRegisterAccountAccessLogUseCase.registerAccountAccessLog(request, memberId, + AccountAccessResult.FAILURE); externalManageAccountLockUseCase.handleLoginFailure(request, memberId); throw new LoginFailedException("잘못된 인증번호입니다."); } @@ -67,18 +72,21 @@ private void verifyTwoFactorAuthentication(String memberId, String totp, HttpSer private TokenInfo generateAndSaveToken(MemberLoginInfoDto memberInfo) { TokenInfo tokenInfo = jwtTokenProvider.generateToken(memberInfo.getMemberId(), memberInfo.getRole()); String clientIpAddress = HttpReqResUtil.getClientIpAddressIfServletRequestExist(); - externalManageRedisTokenUseCase.saveToken(memberInfo.getMemberId(), memberInfo.getRole(), tokenInfo, clientIpAddress); + externalManageRedisTokenUseCase.saveToken(memberInfo.getMemberId(), memberInfo.getRole(), tokenInfo, + clientIpAddress); return tokenInfo; } private void sendAdminLoginNotification(HttpServletRequest request, MemberLoginInfoDto loginMember) { if (loginMember.isSuperAdminRole()) { - slackService.sendAdminLoginNotification(request, loginMember); + eventPublisher.publishEvent( + new NotificationEvent(this, GeneralAlertType.ADMIN_LOGIN, request, loginMember)); } } @Override - public LoginResult login(HttpServletRequest request, LoginRequestDto requestDto) throws LoginFailedException, MemberLockedException { + public LoginResult login(HttpServletRequest request, LoginRequestDto requestDto) + throws LoginFailedException, MemberLockedException { throw new UnsupportedOperationException("Method not implemented"); } diff --git a/src/main/java/page/clab/api/domain/auth/redisIpAccessMonitor/application/service/AbnormalAccessIpRemoveService.java b/src/main/java/page/clab/api/domain/auth/redisIpAccessMonitor/application/service/AbnormalAccessIpRemoveService.java index 7789853cb..a31e7ee8a 100644 --- a/src/main/java/page/clab/api/domain/auth/redisIpAccessMonitor/application/service/AbnormalAccessIpRemoveService.java +++ b/src/main/java/page/clab/api/domain/auth/redisIpAccessMonitor/application/service/AbnormalAccessIpRemoveService.java @@ -2,25 +2,29 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.auth.redisIpAccessMonitor.application.port.in.RemoveAbnormalAccessIpUseCase; import page.clab.api.domain.auth.redisIpAccessMonitor.application.port.out.RemoveIpAccessMonitorPort; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; @Service @RequiredArgsConstructor public class AbnormalAccessIpRemoveService implements RemoveAbnormalAccessIpUseCase { private final RemoveIpAccessMonitorPort removeIpAccessMonitorPort; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; @Override @Transactional public String removeAbnormalAccessIp(HttpServletRequest request, String ipAddress) { removeIpAccessMonitorPort.deleteById(ipAddress); - slackService.sendSecurityAlertNotification(request, SecurityAlertType.ABNORMAL_ACCESS_IP_DELETED, "Deleted IP: " + ipAddress); + String abnormalAccessIpDeletedMessage = "Deleted IP: " + ipAddress; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.ABNORMAL_ACCESS_IP_DELETED, request, + abnormalAccessIpDeletedMessage)); return ipAddress; } } diff --git a/src/main/java/page/clab/api/domain/auth/redisIpAccessMonitor/application/service/AbnormalAccessIpsClearService.java b/src/main/java/page/clab/api/domain/auth/redisIpAccessMonitor/application/service/AbnormalAccessIpsClearService.java index 7a5c7dbcb..f347f2644 100644 --- a/src/main/java/page/clab/api/domain/auth/redisIpAccessMonitor/application/service/AbnormalAccessIpsClearService.java +++ b/src/main/java/page/clab/api/domain/auth/redisIpAccessMonitor/application/service/AbnormalAccessIpsClearService.java @@ -1,17 +1,17 @@ package page.clab.api.domain.auth.redisIpAccessMonitor.application.service; import jakarta.servlet.http.HttpServletRequest; +import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.auth.redisIpAccessMonitor.application.port.in.ClearAbnormalAccessIpsUseCase; import page.clab.api.domain.auth.redisIpAccessMonitor.application.port.out.ClearIpAccessMonitorPort; import page.clab.api.domain.auth.redisIpAccessMonitor.application.port.out.RetrieveIpAccessMonitorPort; import page.clab.api.domain.auth.redisIpAccessMonitor.domain.RedisIpAccessMonitor; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; - -import java.util.List; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; @Service @RequiredArgsConstructor @@ -19,14 +19,19 @@ public class AbnormalAccessIpsClearService implements ClearAbnormalAccessIpsUseC private final ClearIpAccessMonitorPort clearIpAccessMonitorPort; private final RetrieveIpAccessMonitorPort retrieveIpAccessMonitorPort; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; @Override @Transactional public List clearAbnormalAccessIps(HttpServletRequest request) { List ipAccessMonitors = retrieveIpAccessMonitorPort.findAll(); clearIpAccessMonitorPort.deleteAll(); - slackService.sendSecurityAlertNotification(request, SecurityAlertType.ABNORMAL_ACCESS_IP_DELETED, "Deleted IP: ALL"); + + String abnormalAccessIpClearedMessage = "Deleted IP: ALL"; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.ABNORMAL_ACCESS_IP_DELETED, request, + abnormalAccessIpClearedMessage)); + return ipAccessMonitors; } } diff --git a/src/main/java/page/clab/api/domain/community/board/application/service/BoardRegisterService.java b/src/main/java/page/clab/api/domain/community/board/application/service/BoardRegisterService.java index f2e487749..cdcd2dee8 100644 --- a/src/main/java/page/clab/api/domain/community/board/application/service/BoardRegisterService.java +++ b/src/main/java/page/clab/api/domain/community/board/application/service/BoardRegisterService.java @@ -1,6 +1,8 @@ package page.clab.api.domain.community.board.application.service; +import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.community.board.application.dto.mapper.BoardDtoMapper; @@ -8,17 +10,16 @@ import page.clab.api.domain.community.board.application.port.in.RegisterBoardUseCase; import page.clab.api.domain.community.board.application.port.out.RegisterBoardPort; import page.clab.api.domain.community.board.domain.Board; -import page.clab.api.global.common.slack.domain.SlackBoardInfo; import page.clab.api.domain.memberManagement.member.application.dto.shared.MemberDetailedInfoDto; import page.clab.api.external.memberManagement.member.application.port.ExternalRetrieveMemberUseCase; import page.clab.api.external.memberManagement.notification.application.port.ExternalSendNotificationUseCase; import page.clab.api.global.common.file.application.UploadedFileService; import page.clab.api.global.common.file.domain.UploadedFile; -import page.clab.api.global.common.slack.application.SlackService; +import page.clab.api.global.common.notificationSetting.application.dto.notification.BoardNotificationInfo; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.ExecutivesAlertType; import page.clab.api.global.exception.PermissionDeniedException; -import java.util.List; - @Service @RequiredArgsConstructor public class BoardRegisterService implements RegisterBoardUseCase { @@ -27,7 +28,7 @@ public class BoardRegisterService implements RegisterBoardUseCase { private final ExternalRetrieveMemberUseCase externalRetrieveMemberUseCase; private final ExternalSendNotificationUseCase externalSendNotificationUseCase; private final UploadedFileService uploadedFileService; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; private final BoardDtoMapper mapper; /** @@ -48,10 +49,14 @@ public String registerBoard(BoardRequestDto requestDto) throws PermissionDeniedE Board board = mapper.fromDto(requestDto, currentMemberInfo.getMemberId(), uploadedFiles); board.validateAccessPermissionForCreation(currentMemberInfo); if (board.shouldNotifyForNewBoard(currentMemberInfo)) { - externalSendNotificationUseCase.sendNotificationToMember(currentMemberInfo.getMemberId(), "[" + board.getTitle() + "] 새로운 공지사항이 등록되었습니다."); + externalSendNotificationUseCase.sendNotificationToMember(currentMemberInfo.getMemberId(), + "[" + board.getTitle() + "] 새로운 공지사항이 등록되었습니다."); } - SlackBoardInfo boardInfo = SlackBoardInfo.create(board, currentMemberInfo); - slackService.sendNewBoardNotification(boardInfo); + + BoardNotificationInfo boardInfo = BoardNotificationInfo.create(board, currentMemberInfo); + eventPublisher.publishEvent(new NotificationEvent(this, ExecutivesAlertType.NEW_BOARD, null, + boardInfo)); + return registerBoardPort.save(board).getCategory().getKey(); } } diff --git a/src/main/java/page/clab/api/domain/hiring/application/application/service/ApplicationApplyService.java b/src/main/java/page/clab/api/domain/hiring/application/application/service/ApplicationApplyService.java index 11c69305c..357a59cde 100644 --- a/src/main/java/page/clab/api/domain/hiring/application/application/service/ApplicationApplyService.java +++ b/src/main/java/page/clab/api/domain/hiring/application/application/service/ApplicationApplyService.java @@ -1,6 +1,7 @@ package page.clab.api.domain.hiring.application.application.service; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.hiring.application.application.dto.mapper.ApplicationDtoMapper; @@ -10,7 +11,8 @@ import page.clab.api.domain.hiring.application.domain.Application; import page.clab.api.external.hiring.application.application.port.ExternalRetrieveRecruitmentUseCase; import page.clab.api.external.memberManagement.notification.application.port.ExternalSendNotificationUseCase; -import page.clab.api.global.common.slack.application.SlackService; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.ExecutivesAlertType; @Service @RequiredArgsConstructor @@ -19,9 +21,9 @@ public class ApplicationApplyService implements ApplyForApplicationUseCase { private final RegisterApplicationPort registerApplicationPort; private final ExternalRetrieveRecruitmentUseCase externalRetrieveRecruitmentUseCase; private final ExternalSendNotificationUseCase externalSendNotificationUseCase; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; private final ApplicationDtoMapper mapper; - + @Transactional @Override public String applyForClub(ApplicationRequestDto requestDto) { @@ -30,7 +32,10 @@ public String applyForClub(ApplicationRequestDto requestDto) { String applicationType = application.getApplicationTypeForNotificationPrefix(); externalSendNotificationUseCase.sendNotificationToAdmins(applicationType + requestDto.getStudentId() + " " + requestDto.getName() + "님이 지원하였습니다."); - slackService.sendNewApplicationNotification(requestDto); + + eventPublisher.publishEvent(new NotificationEvent(this, ExecutivesAlertType.NEW_APPLICATION, null, + requestDto)); + return registerApplicationPort.save(application).getStudentId(); } } diff --git a/src/main/java/page/clab/api/domain/library/bookLoanRecord/application/service/BookLoanRequestService.java b/src/main/java/page/clab/api/domain/library/bookLoanRecord/application/service/BookLoanRequestService.java index 1e759332e..9f32d5adb 100644 --- a/src/main/java/page/clab/api/domain/library/bookLoanRecord/application/service/BookLoanRequestService.java +++ b/src/main/java/page/clab/api/domain/library/bookLoanRecord/application/service/BookLoanRequestService.java @@ -1,6 +1,7 @@ package page.clab.api.domain.library.bookLoanRecord.application.service; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,8 +18,9 @@ import page.clab.api.external.library.book.application.port.ExternalRetrieveBookUseCase; import page.clab.api.external.memberManagement.member.application.port.ExternalRetrieveMemberUseCase; import page.clab.api.external.memberManagement.notification.application.port.ExternalSendNotificationUseCase; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SlackBookLoanRecordInfo; +import page.clab.api.global.common.notificationSetting.application.dto.notification.BookLoanRecordNotificationInfo; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.ExecutivesAlertType; import page.clab.api.global.exception.CustomOptimisticLockingFailureException; @Service @@ -30,21 +32,19 @@ public class BookLoanRequestService implements RequestBookLoanUseCase { private final ExternalRetrieveBookUseCase externalRetrieveBookUseCase; private final ExternalRetrieveMemberUseCase externalRetrieveMemberUseCase; private final ExternalSendNotificationUseCase externalSendNotificationUseCase; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; /** * 도서 대출 신청을 처리합니다. * *

현재 로그인한 멤버의 대출 상태와 한도를 검증한 후, - * 도서의 대출 신청이 이미 존재하는지 확인합니다. - * 대출 신청이 성공적으로 완료되면 멤버와 Slack에 알림을 전송하고, - * 대출 기록을 저장한 후 그 ID를 반환합니다.

+ * 도서의 대출 신청이 이미 존재하는지 확인합니다. 대출 신청이 성공적으로 완료되면 멤버와 Slack에 알림을 전송하고, 대출 기록을 저장한 후 그 ID를 반환합니다.

* * @param requestDto 도서 대출 신청 요청 정보 DTO * @return 저장된 대출 기록의 ID * @throws CustomOptimisticLockingFailureException 동시에 다른 사용자가 대출을 신청하여 충돌이 발생한 경우 예외 발생 - * @throws MaxBorrowLimitExceededException 대출 한도를 초과한 경우 예외 발생 - * @throws BookAlreadyAppliedForLoanException 이미 신청된 도서일 경우 예외 발생 + * @throws MaxBorrowLimitExceededException 대출 한도를 초과한 경우 예외 발생 + * @throws BookAlreadyAppliedForLoanException 이미 신청된 도서일 경우 예외 발생 */ @Transactional @Override @@ -60,10 +60,13 @@ public Long requestBookLoan(BookLoanRecordRequestDto requestDto) throws CustomOp BookLoanRecord bookLoanRecord = BookLoanRecord.create(book.getId(), borrowerInfo); - externalSendNotificationUseCase.sendNotificationToMember(borrowerInfo.getMemberId(), "[" + book.getTitle() + "] 도서 대출 신청이 완료되었습니다."); + externalSendNotificationUseCase.sendNotificationToMember(borrowerInfo.getMemberId(), + "[" + book.getTitle() + "] 도서 대출 신청이 완료되었습니다."); - SlackBookLoanRecordInfo bookLoanRecordInfo = SlackBookLoanRecordInfo.create(book, borrowerInfo); - slackService.sendNewBookLoanRequestNotification(bookLoanRecordInfo); + BookLoanRecordNotificationInfo bookLoanRecordInfo = BookLoanRecordNotificationInfo.create(book, + borrowerInfo); + eventPublisher.publishEvent(new NotificationEvent(this, ExecutivesAlertType.NEW_BOOK_LOAN_REQUEST, null, + bookLoanRecordInfo)); return registerBookLoanRecordPort.save(bookLoanRecord).getId(); } catch (ObjectOptimisticLockingFailureException e) { diff --git a/src/main/java/page/clab/api/domain/memberManagement/member/application/service/MemberRoleManagementService.java b/src/main/java/page/clab/api/domain/memberManagement/member/application/service/MemberRoleManagementService.java index db2ced3c0..ceed5fb73 100644 --- a/src/main/java/page/clab/api/domain/memberManagement/member/application/service/MemberRoleManagementService.java +++ b/src/main/java/page/clab/api/domain/memberManagement/member/application/service/MemberRoleManagementService.java @@ -2,6 +2,7 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.memberManagement.member.application.dto.request.ChangeMemberRoleRequest; @@ -11,8 +12,8 @@ import page.clab.api.domain.memberManagement.member.application.port.out.UpdateMemberPort; import page.clab.api.domain.memberManagement.member.domain.Member; import page.clab.api.domain.memberManagement.member.domain.Role; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; @Service @RequiredArgsConstructor @@ -20,11 +21,12 @@ public class MemberRoleManagementService implements ManageMemberRoleUseCase { private final RetrieveMemberPort retrieveMemberPort; private final UpdateMemberPort updateMemberPort; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; @Transactional @Override - public String changeMemberRole(HttpServletRequest httpServletRequest, String memberId, ChangeMemberRoleRequest request) { + public String changeMemberRole(HttpServletRequest httpServletRequest, String memberId, + ChangeMemberRoleRequest request) { Member member = retrieveMemberPort.getById(memberId); Role oldRole = member.getRole(); @@ -34,9 +36,13 @@ public String changeMemberRole(HttpServletRequest httpServletRequest, String mem member.changeRole(newRole); updateMemberPort.update(member); - slackService.sendSecurityAlertNotification(httpServletRequest, SecurityAlertType.MEMBER_ROLE_CHANGED, - String.format("[%s] %s: %s -> %s", - member.getId(), member.getName(), oldRole, newRole)); + + String memberRoleChangedMessage = String.format("[%s] %s: %s -> %s", member.getId(), member.getName(), oldRole, + newRole); + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.MEMBER_ROLE_CHANGED, httpServletRequest, + memberRoleChangedMessage)); + return memberId; } diff --git a/src/main/java/page/clab/api/domain/members/membershipFee/application/service/MembershipFeeRegisterService.java b/src/main/java/page/clab/api/domain/members/membershipFee/application/service/MembershipFeeRegisterService.java index 3d50a19e3..533b7d879 100644 --- a/src/main/java/page/clab/api/domain/members/membershipFee/application/service/MembershipFeeRegisterService.java +++ b/src/main/java/page/clab/api/domain/members/membershipFee/application/service/MembershipFeeRegisterService.java @@ -1,6 +1,7 @@ package page.clab.api.domain.members.membershipFee.application.service; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.memberManagement.member.application.dto.shared.MemberBasicInfoDto; @@ -11,8 +12,9 @@ import page.clab.api.domain.members.membershipFee.domain.MembershipFee; import page.clab.api.external.memberManagement.member.application.port.ExternalRetrieveMemberUseCase; import page.clab.api.external.memberManagement.notification.application.port.ExternalSendNotificationUseCase; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SlackMembershipFeeInfo; +import page.clab.api.global.common.notificationSetting.application.dto.notification.MembershipFeeNotificationInfo; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.ExecutivesAlertType; @Service @RequiredArgsConstructor @@ -21,7 +23,7 @@ public class MembershipFeeRegisterService implements RegisterMembershipFeeUseCas private final RegisterMembershipFeePort registerMembershipFeePort; private final ExternalRetrieveMemberUseCase externalRetrieveMemberUseCase; private final ExternalSendNotificationUseCase externalSendNotificationUseCase; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; private final MembershipFeeDtoMapper mapper; @Transactional @@ -30,8 +32,10 @@ public Long registerMembershipFee(MembershipFeeRequestDto requestDto) { MemberBasicInfoDto memberInfo = externalRetrieveMemberUseCase.getCurrentMemberBasicInfo(); MembershipFee membershipFee = mapper.fromDto(requestDto, memberInfo.getMemberId()); externalSendNotificationUseCase.sendNotificationToAdmins("새로운 회비 내역이 등록되었습니다."); - SlackMembershipFeeInfo membershipFeeInfo = SlackMembershipFeeInfo.create(membershipFee, memberInfo); - slackService.sendNewMembershipFeeNotification(membershipFeeInfo); + MembershipFeeNotificationInfo membershipFeeInfo = MembershipFeeNotificationInfo.create(membershipFee, + memberInfo); + eventPublisher.publishEvent(new NotificationEvent(this, ExecutivesAlertType.NEW_MEMBERSHIP_FEE, null, + membershipFeeInfo)); return registerMembershipFeePort.save(membershipFee).getId(); } } diff --git a/src/main/java/page/clab/api/external/auth/accountLockInfo/port/ExternalAccountLockManagementService.java b/src/main/java/page/clab/api/external/auth/accountLockInfo/port/ExternalAccountLockManagementService.java index 4a9eb6dde..436c68313 100644 --- a/src/main/java/page/clab/api/external/auth/accountLockInfo/port/ExternalAccountLockManagementService.java +++ b/src/main/java/page/clab/api/external/auth/accountLockInfo/port/ExternalAccountLockManagementService.java @@ -3,6 +3,7 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.auth.accountLockInfo.application.port.out.RegisterAccountLockInfoPort; @@ -13,8 +14,8 @@ import page.clab.api.domain.memberManagement.member.application.dto.shared.MemberDetailedInfoDto; import page.clab.api.external.auth.accountLockInfo.application.ExternalManageAccountLockUseCase; import page.clab.api.external.memberManagement.member.application.port.ExternalRetrieveMemberUseCase; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; @Service @RequiredArgsConstructor @@ -23,7 +24,7 @@ public class ExternalAccountLockManagementService implements ExternalManageAccou private final RetrieveAccountLockInfoPort retrieveAccountLockInfoPort; private final RegisterAccountLockInfoPort registerAccountLockInfoPort; private final ExternalRetrieveMemberUseCase externalRetrieveMemberUseCase; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; @Value("${security.login-attempt.max-failures}") private int maxLoginFailures; @@ -39,7 +40,7 @@ public class ExternalAccountLockManagementService implements ExternalManageAccou * * @param memberId 잠금 해제하려는 멤버의 ID * @throws MemberLockedException 계정이 현재 잠겨 있을 경우 예외 발생 - * @throws LoginFailedException 멤버가 존재하지 않을 경우 예외 발생 + * @throws LoginFailedException 멤버가 존재하지 않을 경우 예외 발생 */ @Transactional @Override @@ -55,17 +56,17 @@ public void handleAccountLockInfo(String memberId) throws MemberLockedException, * 로그인 실패를 처리하고 계정 잠금을 관리합니다. * *

로그인 실패 시 멤버의 존재 여부와 계정 잠금 상태를 확인합니다. - * 로그인 실패 횟수를 증가시키며, 설정된 최대 실패 횟수에 도달하면 계정을 잠그고 - * Slack에 보안 알림을 전송합니다.

+ * 로그인 실패 횟수를 증가시키며, 설정된 최대 실패 횟수에 도달하면 계정을 잠그고 Slack에 보안 알림을 전송합니다.

* - * @param request 현재 HTTP 요청 객체 + * @param request 현재 HTTP 요청 객체 * @param memberId 로그인 실패를 기록할 멤버의 ID * @throws MemberLockedException 계정이 현재 잠겨 있을 경우 예외 발생 - * @throws LoginFailedException 멤버가 존재하지 않을 경우 예외 발생 + * @throws LoginFailedException 멤버가 존재하지 않을 경우 예외 발생 */ @Transactional @Override - public void handleLoginFailure(HttpServletRequest request, String memberId) throws MemberLockedException, LoginFailedException { + public void handleLoginFailure(HttpServletRequest request, String memberId) + throws MemberLockedException, LoginFailedException { ensureMemberExists(memberId); AccountLockInfo accountLockInfo = ensureAccountLockInfo(memberId); validateAccountLockStatus(accountLockInfo); @@ -99,7 +100,10 @@ private void sendSlackLoginFailureNotification(HttpServletRequest request, Strin String memberName = memberInfo.getMemberName(); if (memberInfo.isAdminRole()) { request.setAttribute("member", memberId + " " + memberName); - slackService.sendSecurityAlertNotification(request, SecurityAlertType.REPEATED_LOGIN_FAILURES, "로그인 실패 횟수 초과로 계정이 잠겼습니다."); + String repeatedLoginFailuresMessage = "로그인 실패 횟수 초과로 계정이 잠겼습니다."; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.REPEATED_LOGIN_FAILURES, request, + repeatedLoginFailuresMessage)); } } } diff --git a/src/main/java/page/clab/api/external/auth/redisIpAccessMonitor/application/service/ExternalIpAccessMonitorRegisterService.java b/src/main/java/page/clab/api/external/auth/redisIpAccessMonitor/application/service/ExternalIpAccessMonitorRegisterService.java index a600c8b3d..61ac9a980 100644 --- a/src/main/java/page/clab/api/external/auth/redisIpAccessMonitor/application/service/ExternalIpAccessMonitorRegisterService.java +++ b/src/main/java/page/clab/api/external/auth/redisIpAccessMonitor/application/service/ExternalIpAccessMonitorRegisterService.java @@ -4,14 +4,15 @@ import lombok.RequiredArgsConstructor; import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.auth.redisIpAccessMonitor.application.port.out.RegisterIpAccessMonitorPort; import page.clab.api.domain.auth.redisIpAccessMonitor.application.port.out.RetrieveIpAccessMonitorPort; import page.clab.api.domain.auth.redisIpAccessMonitor.domain.RedisIpAccessMonitor; import page.clab.api.external.auth.redisIpAccessMonitor.application.port.ExternalRegisterIpAccessMonitorUseCase; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; @Service @RequiredArgsConstructor @@ -19,7 +20,7 @@ public class ExternalIpAccessMonitorRegisterService implements ExternalRegisterI private final RegisterIpAccessMonitorPort registerIpAccessMonitorPort; private final RetrieveIpAccessMonitorPort retrieveIpAccessMonitorPort; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; @Value("${security.ip-attempt.max-attempts}") private int maxAttempts; @@ -29,7 +30,10 @@ public class ExternalIpAccessMonitorRegisterService implements ExternalRegisterI public void registerIpAccessMonitor(HttpServletRequest request, String ipAddress) { RedisIpAccessMonitor redisIpAccessMonitor = getOrCreateRedisIpAccessMonitor(ipAddress); if (redisIpAccessMonitor.isBlocked()) { - slackService.sendSecurityAlertNotification(request, SecurityAlertType.ABNORMAL_ACCESS_IP_BLOCKED, "Blocked IP: " + ipAddress); + String abnormalAccessIpBlockedMessage = "Blocked IP: " + ipAddress; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.ABNORMAL_ACCESS_IP_BLOCKED, request, + abnormalAccessIpBlockedMessage)); } registerIpAccessMonitorPort.save(redisIpAccessMonitor); } diff --git a/src/main/java/page/clab/api/global/auth/filter/CustomBasicAuthenticationFilter.java b/src/main/java/page/clab/api/global/auth/filter/CustomBasicAuthenticationFilter.java index 954d80f94..ed2287c8e 100644 --- a/src/main/java/page/clab/api/global/auth/filter/CustomBasicAuthenticationFilter.java +++ b/src/main/java/page/clab/api/global/auth/filter/CustomBasicAuthenticationFilter.java @@ -4,8 +4,11 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Base64; import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.NotNull; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -15,21 +18,17 @@ import page.clab.api.external.auth.blacklistIp.application.port.ExternalRetrieveBlacklistIpUseCase; import page.clab.api.external.auth.redisIpAccessMonitor.application.port.ExternalCheckIpBlockedUseCase; import page.clab.api.global.auth.util.IpWhitelistValidator; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; import page.clab.api.global.util.HttpReqResUtil; import page.clab.api.global.util.ResponseUtil; import page.clab.api.global.util.WhitelistPathMatcher; -import java.io.IOException; -import java.util.Base64; - /** * {@code CustomBasicAuthenticationFilter}는 기본 인증 필터를 확장하여 추가적인 보안 기능을 제공합니다. * *

IP 주소 기반 접근 제한, 화이트리스트 경로 검증, 사용자 인증 정보를 바탕으로 - * Slack 보안 알림을 전송하는 기능을 포함합니다. 또한 Swagger 또는 Actuator에 대한 - * 접근이 성공하거나 실패할 경우 이를 Slack에 알립니다.

+ * Slack 보안 알림을 전송하는 기능을 포함합니다. 또한 Swagger 또는 Actuator에 대한 접근이 성공하거나 실패할 경우 이를 Slack에 알립니다.

* *

이 필터는 다음과 같은 추가 검증을 수행합니다:

*
    @@ -47,22 +46,29 @@ public class CustomBasicAuthenticationFilter extends BasicAuthenticationFilter { private final IpWhitelistValidator ipWhitelistValidator; - private final SlackService slackService; private final ExternalCheckIpBlockedUseCase externalCheckIpBlockedUseCase; private final ExternalRetrieveBlacklistIpUseCase externalRetrieveBlacklistIpUseCase; + private final ApplicationEventPublisher eventPublisher; public CustomBasicAuthenticationFilter( AuthenticationManager authenticationManager, IpWhitelistValidator ipWhitelistValidator, - SlackService slackService, ExternalCheckIpBlockedUseCase externalCheckIpBlockedUseCase, - ExternalRetrieveBlacklistIpUseCase externalRetrieveBlacklistIpUseCase + ExternalRetrieveBlacklistIpUseCase externalRetrieveBlacklistIpUseCase, + ApplicationEventPublisher eventPublisher ) { super(authenticationManager); this.externalCheckIpBlockedUseCase = externalCheckIpBlockedUseCase; this.externalRetrieveBlacklistIpUseCase = externalRetrieveBlacklistIpUseCase; this.ipWhitelistValidator = ipWhitelistValidator; - this.slackService = slackService; + this.eventPublisher = eventPublisher; + } + + @NotNull + private static String[] decodeCredentials(String authorizationHeader) { + String base64Credentials = authorizationHeader.substring("Basic ".length()); + String credentials = new String(Base64.getDecoder().decode(base64Credentials)); + return credentials.split(":", 2); } @Override @@ -82,7 +88,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse super.doFilterInternal(request, response, chain); } - private boolean authenticateUserCredentials(HttpServletRequest request, HttpServletResponse response) throws IOException { + private boolean authenticateUserCredentials(HttpServletRequest request, HttpServletResponse response) + throws IOException { String authorizationHeader = request.getHeader("Authorization"); if (authorizationHeader == null || !authorizationHeader.startsWith("Basic ")) { response.setHeader("WWW-Authenticate", "Basic realm=\"Please enter your username and password\""); @@ -122,28 +129,29 @@ private boolean verifyIpAddressAccess(HttpServletResponse response) throws IOExc return true; } - @NotNull - private static String[] decodeCredentials(String authorizationHeader) { - String base64Credentials = authorizationHeader.substring("Basic ".length()); - String credentials = new String(Base64.getDecoder().decode(base64Credentials)); - return credentials.split(":", 2); - } - private void sendAuthenticationSuccessAlertSlackMessage(HttpServletRequest request) { String path = request.getRequestURI(); if (WhitelistPathMatcher.isSwaggerIndexEndpoint(path)) { - slackService.sendSecurityAlertNotification(request, SecurityAlertType.API_DOCS_ACCESS,"API 문서에 대한 접근이 허가되었습니다."); + String apiDocsAccessMessage = "API 문서에 대한 접근이 허가되었습니다."; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.API_DOCS_ACCESS, request, apiDocsAccessMessage)); } else if (WhitelistPathMatcher.isActuatorRequest(path)) { - slackService.sendSecurityAlertNotification(request, SecurityAlertType.ACTUATOR_ACCESS,"Actuator에 대한 접근이 허가되었습니다."); + String actuatorAccessMessage = "Actuator에 대한 접근이 허가되었습니다."; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.ACTUATOR_ACCESS, request, actuatorAccessMessage)); } } private void sendAuthenticationFailureAlertSlackMessage(HttpServletRequest request) { String path = request.getRequestURI(); if (WhitelistPathMatcher.isSwaggerIndexEndpoint(path)) { - slackService.sendSecurityAlertNotification(request, SecurityAlertType.API_DOCS_ACCESS,"API 문서에 대한 접근이 거부되었습니다."); + String apiDocsAccessMessage = "API 문서에 대한 접근이 거부되었습니다."; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.API_DOCS_ACCESS, request, apiDocsAccessMessage)); } else if (WhitelistPathMatcher.isActuatorRequest(path)) { - slackService.sendSecurityAlertNotification(request, SecurityAlertType.ACTUATOR_ACCESS,"Actuator에 대한 접근이 거부되었습니다."); + String actuatorAccessMessage = "Actuator에 대한 접근이 거부되었습니다."; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.ACTUATOR_ACCESS, request, actuatorAccessMessage)); } } } diff --git a/src/main/java/page/clab/api/global/auth/filter/InvalidEndpointAccessFilter.java b/src/main/java/page/clab/api/global/auth/filter/InvalidEndpointAccessFilter.java index b8314f9bf..5f7548fd1 100644 --- a/src/main/java/page/clab/api/global/auth/filter/InvalidEndpointAccessFilter.java +++ b/src/main/java/page/clab/api/global/auth/filter/InvalidEndpointAccessFilter.java @@ -6,25 +6,24 @@ import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.GenericFilterBean; import page.clab.api.domain.auth.blacklistIp.domain.BlacklistIp; import page.clab.api.external.auth.blacklistIp.application.port.ExternalRegisterBlacklistIpUseCase; import page.clab.api.external.auth.blacklistIp.application.port.ExternalRetrieveBlacklistIpUseCase; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; import page.clab.api.global.util.HttpReqResUtil; import page.clab.api.global.util.ResponseUtil; import page.clab.api.global.util.SecurityPatternChecker; -import java.io.IOException; - /** - * {@code InvalidEndpointAccessFilter}는 서버 내부 파일 및 디렉토리에 대한 비정상적인 접근을 차단하고 - * 보안 경고를 전송하는 필터입니다. + * {@code InvalidEndpointAccessFilter}는 서버 내부 파일 및 디렉토리에 대한 비정상적인 접근을 차단하고 보안 경고를 전송하는 필터입니다. * *

    특정 패턴을 통해 비정상적인 접근 시도를 탐지하며, 비정상적인 경로로 접근을 시도한 IP를 * 블랙리스트에 등록하고, Slack을 통해 보안 경고 메시지를 전송합니다.

    @@ -44,13 +43,14 @@ @Slf4j public class InvalidEndpointAccessFilter extends GenericFilterBean { - private final SlackService slackService; private final String fileURL; private final ExternalRegisterBlacklistIpUseCase externalRegisterBlacklistIpUseCase; private final ExternalRetrieveBlacklistIpUseCase externalRetrieveBlacklistIpUseCase; + private final ApplicationEventPublisher eventPublisher; @Override - public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; String path = httpRequest.getRequestURI(); boolean isUploadedFileAccess = path.startsWith(fileURL); @@ -75,7 +75,8 @@ private void handleSuspiciousAccess(HttpServletRequest request, HttpServletRespo private void logSuspiciousAccess(HttpServletRequest request, String clientIpAddress) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - String id = (authentication == null || authentication.getName() == null) ? "anonymous" : authentication.getName(); + String id = + (authentication == null || authentication.getName() == null) ? "anonymous" : authentication.getName(); String requestUrl = request.getRequestURI(); String httpMethod = request.getMethod(); int statusCode = HttpServletResponse.SC_FORBIDDEN; @@ -97,7 +98,9 @@ private void sendSecurityAlerts(HttpServletRequest request, String clientIpAddre String abnormalAccessMessage = "서버 내부 파일 및 디렉토리에 대한 접근이 감지되었습니다."; String blacklistAddedMessage = "Added IP: " + clientIpAddress; - slackService.sendSecurityAlertNotification(request, SecurityAlertType.ABNORMAL_ACCESS, abnormalAccessMessage); - slackService.sendSecurityAlertNotification(request, SecurityAlertType.BLACKLISTED_IP_ADDED, blacklistAddedMessage); + eventPublisher.publishEvent(new NotificationEvent(this, SecurityAlertType.ABNORMAL_ACCESS, request, + abnormalAccessMessage)); + eventPublisher.publishEvent(new NotificationEvent(this, SecurityAlertType.BLACKLISTED_IP_ADDED, request, + blacklistAddedMessage)); } } diff --git a/src/main/java/page/clab/api/global/auth/filter/JwtAuthenticationFilter.java b/src/main/java/page/clab/api/global/auth/filter/JwtAuthenticationFilter.java index 12bda3af8..fba999b53 100644 --- a/src/main/java/page/clab/api/global/auth/filter/JwtAuthenticationFilter.java +++ b/src/main/java/page/clab/api/global/auth/filter/JwtAuthenticationFilter.java @@ -6,8 +6,10 @@ import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.GenericFilterBean; @@ -16,14 +18,12 @@ import page.clab.api.external.auth.redisIpAccessMonitor.application.port.ExternalCheckIpBlockedUseCase; import page.clab.api.external.auth.redisToken.application.port.ExternalManageRedisTokenUseCase; import page.clab.api.global.auth.jwt.JwtTokenProvider; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; import page.clab.api.global.util.HttpReqResUtil; import page.clab.api.global.util.ResponseUtil; import page.clab.api.global.util.WhitelistPathMatcher; -import java.io.IOException; - /** * {@code JwtAuthenticationFilter}는 JWT 토큰을 검증하고, IP 주소 기반 접근 제한을 수행하는 필터입니다. * @@ -45,14 +45,15 @@ @Slf4j public class JwtAuthenticationFilter extends GenericFilterBean { - private final SlackService slackService; private final JwtTokenProvider jwtTokenProvider; + private final ApplicationEventPublisher eventPublisher; private final ExternalManageRedisTokenUseCase externalManageRedisTokenUseCase; private final ExternalCheckIpBlockedUseCase externalCheckIpBlockedUseCase; private final ExternalRetrieveBlacklistIpUseCase externalRetrieveBlacklistIpUseCase; @Override - public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { HttpServletRequest httpServletRequest = (HttpServletRequest) request; HttpServletResponse httpServletResponse = (HttpServletResponse) response; String path = httpServletRequest.getRequestURI(); @@ -71,7 +72,8 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha } private boolean verifyIpAddressAccess(HttpServletResponse response, String clientIpAddress) throws IOException { - if (externalRetrieveBlacklistIpUseCase.existsByIpAddress(clientIpAddress) || externalCheckIpBlockedUseCase.isIpBlocked(clientIpAddress)) { + if (externalRetrieveBlacklistIpUseCase.existsByIpAddress(clientIpAddress) + || externalCheckIpBlockedUseCase.isIpBlocked(clientIpAddress)) { log.info("[{}] : 서비스 이용이 제한된 IP입니다.", clientIpAddress); ResponseUtil.sendErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED); return false; @@ -79,12 +81,15 @@ private boolean verifyIpAddressAccess(HttpServletResponse response, String clien return true; } - private boolean authenticateToken(HttpServletRequest request, HttpServletResponse response, String clientIpAddress) throws IOException { + private boolean authenticateToken(HttpServletRequest request, HttpServletResponse response, String clientIpAddress) + throws IOException { String token = jwtTokenProvider.resolveToken(request); // 토큰이 존재하고 유효한 경우 if (token != null && jwtTokenProvider.validateToken(token)) { - RedisToken redisToken = jwtTokenProvider.isRefreshToken(token) ? externalManageRedisTokenUseCase.findByRefreshToken(token) : externalManageRedisTokenUseCase.findByAccessToken(token); + RedisToken redisToken = + jwtTokenProvider.isRefreshToken(token) ? externalManageRedisTokenUseCase.findByRefreshToken(token) + : externalManageRedisTokenUseCase.findByAccessToken(token); if (redisToken == null) { log.warn("존재하지 않는 토큰입니다."); ResponseUtil.sendErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED); @@ -108,7 +113,9 @@ private boolean authenticateToken(HttpServletRequest request, HttpServletRespons private void sendSecurityAlertSlackMessage(HttpServletRequest request, RedisToken redisToken) { if (redisToken.isAdminToken()) { request.setAttribute("member", redisToken.getId()); - slackService.sendSecurityAlertNotification(request, SecurityAlertType.DUPLICATE_LOGIN, "토큰 발급 IP와 다른 IP에서 접속하여 토큰을 삭제하였습니다."); + String duplicateLoginMessage = "토큰 발급 IP와 다른 IP에서 접속하여 토큰을 삭제하였습니다."; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.DUPLICATE_LOGIN, request, duplicateLoginMessage)); } } } diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/adapter/in/web/NotificationSettingRetrieveController.java b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/in/web/NotificationSettingRetrieveController.java new file mode 100644 index 000000000..00117991b --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/in/web/NotificationSettingRetrieveController.java @@ -0,0 +1,30 @@ +package page.clab.api.global.common.notificationSetting.adapter.in.web; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import page.clab.api.global.common.dto.ApiResponse; +import page.clab.api.global.common.notificationSetting.application.dto.response.NotificationSettingResponseDto; +import page.clab.api.global.common.notificationSetting.application.port.in.RetrieveNotificationSettingUseCase; + +@RestController +@RequestMapping("/api/v1/notification-settings") +@RequiredArgsConstructor +@Tag(name = "Notification Setting", description = "웹훅 알림 설정") +public class NotificationSettingRetrieveController { + + private final RetrieveNotificationSettingUseCase retrieveNotificationSettingUseCase; + + @Operation(summary = "[S] 웹훅 알림 조회", description = "ROLE_SUPER 이상의 권한이 필요함") + @PreAuthorize("hasRole('SUPER')") + @GetMapping("") + public ApiResponse> getNotificationSettings() { + List notificationSettings = retrieveNotificationSettingUseCase.retrieveNotificationSettings(); + return ApiResponse.success(notificationSettings); + } +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/adapter/in/web/NotificationSettingToggleController.java b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/in/web/NotificationSettingToggleController.java new file mode 100644 index 000000000..74023a6bc --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/in/web/NotificationSettingToggleController.java @@ -0,0 +1,33 @@ +package page.clab.api.global.common.notificationSetting.adapter.in.web; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import page.clab.api.global.common.dto.ApiResponse; +import page.clab.api.global.common.notificationSetting.application.dto.request.NotificationSettingToggleRequestDto; +import page.clab.api.global.common.notificationSetting.application.port.in.ManageNotificationSettingUseCase; + +@RestController +@RequestMapping("/api/v1/notification-settings") +@RequiredArgsConstructor +@Tag(name = "Notification Setting", description = "웹훅 알림 설정") +public class NotificationSettingToggleController { + + private final ManageNotificationSettingUseCase manageNotificationSettingUseCase; + + @Operation(summary = "[S] 웹훅 알림 설정 변경", description = "ROLE_SUPER 이상의 권한이 필요함") + @PreAuthorize("hasRole('SUPER')") + @PutMapping("") + public ApiResponse toggleNotificationSetting( + @Valid @RequestBody NotificationSettingToggleRequestDto requestDto + ) { + manageNotificationSettingUseCase.toggleNotificationSetting(requestDto.getAlertType(), requestDto.isEnabled()); + return ApiResponse.success(); + } +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/persistence/NotificationSettingPersistenceAdapter.java b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/persistence/NotificationSettingPersistenceAdapter.java new file mode 100644 index 000000000..4bae839c3 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/persistence/NotificationSettingPersistenceAdapter.java @@ -0,0 +1,34 @@ +package page.clab.api.global.common.notificationSetting.adapter.out.persistence; + +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import page.clab.api.global.common.notificationSetting.application.port.out.RetrieveNotificationSettingPort; +import page.clab.api.global.common.notificationSetting.application.port.out.UpdateNotificationSettingPort; +import page.clab.api.global.common.notificationSetting.domain.AlertType; +import page.clab.api.global.common.notificationSetting.domain.NotificationSetting; + +@Component +@RequiredArgsConstructor +public class NotificationSettingPersistenceAdapter implements + RetrieveNotificationSettingPort, + UpdateNotificationSettingPort { + + private final NotificationSettingRepository repository; + + @Override + public List findAll() { + return repository.findAll(); + } + + @Override + public Optional findByAlertType(AlertType alertType) { + return repository.findByAlertType(alertType); + } + + @Override + public NotificationSetting save(NotificationSetting setting) { + return repository.save(setting); + } +} diff --git a/src/main/java/page/clab/api/global/common/slack/dao/NotificationSettingRepository.java b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/persistence/NotificationSettingRepository.java similarity index 52% rename from src/main/java/page/clab/api/global/common/slack/dao/NotificationSettingRepository.java rename to src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/persistence/NotificationSettingRepository.java index e4cab3c38..4be9efb7b 100644 --- a/src/main/java/page/clab/api/global/common/slack/dao/NotificationSettingRepository.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/persistence/NotificationSettingRepository.java @@ -1,10 +1,9 @@ -package page.clab.api.global.common.slack.dao; - -import org.springframework.data.jpa.repository.JpaRepository; -import page.clab.api.global.common.slack.domain.AlertType; -import page.clab.api.global.common.slack.domain.NotificationSetting; +package page.clab.api.global.common.notificationSetting.adapter.out.persistence; import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import page.clab.api.global.common.notificationSetting.domain.AlertType; +import page.clab.api.global.common.notificationSetting.domain.NotificationSetting; public interface NotificationSettingRepository extends JpaRepository { diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/AbstractWebhookClient.java b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/AbstractWebhookClient.java new file mode 100644 index 000000000..d67f0a17f --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/AbstractWebhookClient.java @@ -0,0 +1,17 @@ +package page.clab.api.global.common.notificationSetting.adapter.out.webhook; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.concurrent.CompletableFuture; +import page.clab.api.global.common.notificationSetting.application.port.out.WebhookClient; +import page.clab.api.global.common.notificationSetting.domain.AlertType; + +/** + * {@code AbstractWebhookClient}는 Discord 및 Slack Webhook 클라이언트의 공통 인터페이스를 정의하는 추상 클래스입니다. + */ +public abstract class AbstractWebhookClient implements WebhookClient { + + @Override + public abstract CompletableFuture sendMessage(String webhookUrl, AlertType alertType, + HttpServletRequest request, + Object additionalData); +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/DiscordNotificationSender.java b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/DiscordNotificationSender.java new file mode 100644 index 000000000..b6bfedf51 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/DiscordNotificationSender.java @@ -0,0 +1,25 @@ +package page.clab.api.global.common.notificationSetting.adapter.out.webhook; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.application.port.out.NotificationSender; +import page.clab.api.global.common.notificationSetting.domain.PlatformType; + +@Component +@RequiredArgsConstructor +public class DiscordNotificationSender implements NotificationSender { + + private final DiscordWebhookClient discordWebhookClient; + + @Override + public String getPlatformName() { + return PlatformType.DISCORD.getName(); + } + + @Override + public void sendNotification(NotificationEvent event, String webhookUrl) { + discordWebhookClient.sendMessage(webhookUrl, event.getAlertType(), event.getRequest(), + event.getAdditionalData()); + } +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/DiscordWebhookClient.java b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/DiscordWebhookClient.java new file mode 100644 index 000000000..fb1c443d3 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/DiscordWebhookClient.java @@ -0,0 +1,362 @@ +package page.clab.api.global.common.notificationSetting.adapter.out.webhook; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.env.Environment; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import page.clab.api.domain.hiring.application.application.dto.request.ApplicationRequestDto; +import page.clab.api.domain.memberManagement.member.application.dto.shared.MemberLoginInfoDto; +import page.clab.api.global.common.notificationSetting.application.dto.notification.BoardNotificationInfo; +import page.clab.api.global.common.notificationSetting.application.dto.notification.BookLoanRecordNotificationInfo; +import page.clab.api.global.common.notificationSetting.application.dto.notification.MembershipFeeNotificationInfo; +import page.clab.api.global.common.notificationSetting.application.service.WebhookCommonService; +import page.clab.api.global.common.notificationSetting.config.NotificationConfigProperties; +import page.clab.api.global.common.notificationSetting.domain.AlertType; +import page.clab.api.global.common.notificationSetting.domain.ExecutivesAlertType; +import page.clab.api.global.common.notificationSetting.domain.GeneralAlertType; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; +import page.clab.api.global.util.HttpReqResUtil; + +/** + * {@code DiscordWebhookClient}는 다양한 알림 유형에 따라 Discord 메시지를 구성하고 전송하는 클래스입니다. + * + *

    주요 기능:

    + *
      + *
    • {@link #sendMessage(String, AlertType, HttpServletRequest, Object)}: Discord에 알림 메시지를 비동기적으로 전송
    • + *
    • {@link #createEmbeds(AlertType, HttpServletRequest, Object)}: 알림 유형에 따라 Discord 메시지 임베드 생성
    • + *
    • 다양한 알림 유형에 맞는 메시지 형식을 생성하는 전용 메서드
    • + *
    + * + *

    Discord Webhook API를 사용하여 웹훅 URL을 통해 메시지를 전송하며, 메시지 전송 실패 시 로그에 오류를 기록합니다.

    + * + *

    AlertType을 기반으로 여러 도메인에서 발생하는 이벤트를 Discord를 통해 모니터링할 수 있도록 지원하며, + * Discord 알림은 주로 서버 이벤트, 보안 경고, 신규 신청, 관리자 로그인 등의 이벤트를 다룹니다.

    + * + * @see HttpClient + * @see HttpRequest + * @see HttpResponse + */ +@Component +@Slf4j +public class DiscordWebhookClient extends AbstractWebhookClient { + + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + private final NotificationConfigProperties.CommonProperties commonProperties; + private final Environment environment; + private final WebhookCommonService webhookCommonService; + + public DiscordWebhookClient( + NotificationConfigProperties notificationConfigProperties, + ObjectMapper objectMapper, + Environment environment, + WebhookCommonService webhookCommonService + ) { + this.httpClient = HttpClient.newHttpClient(); + this.objectMapper = objectMapper; + this.commonProperties = notificationConfigProperties.getCommon(); + this.environment = environment; + this.webhookCommonService = webhookCommonService; + } + + /** + * Discord에 알림 메시지를 비동기적으로 전송합니다. + * + * @param webhookUrl 메시지를 보낼 Discord 웹훅 URL + * @param alertType 알림 유형을 나타내는 {@link AlertType} + * @param request HttpServletRequest 객체, 클라이언트 요청 정보 + * @param additionalData 추가 데이터 + * @return 메시지 전송 성공 여부를 나타내는 CompletableFuture + */ + public CompletableFuture sendMessage(String webhookUrl, AlertType alertType, + HttpServletRequest request, Object additionalData) { + Map payload = createPayload(alertType, request, additionalData); + + return CompletableFuture.supplyAsync(() -> { + try { + String jsonPayload = objectMapper.writeValueAsString(payload); + + HttpRequest httpRequest = HttpRequest.newBuilder() + .uri(URI.create(webhookUrl)) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .POST(HttpRequest.BodyPublishers.ofString(jsonPayload)) + .build(); + + HttpResponse response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == HttpStatus.NO_CONTENT.value()) { + return true; + } else { + log.error("Discord notification failed: {}", response.body()); + return false; + } + } catch (IOException | InterruptedException e) { + log.error("Failed to send Discord message: {}", e.getMessage(), e); + return false; + } + }); + } + + /** + * 알림 유형과 요청 정보, 추가 데이터를 사용하여 Discord 메시지 페이로드를 생성합니다. + * + * @param alertType 알림 유형 + * @param request 클라이언트 요청 정보 + * @param additionalData 추가 데이터 + * @return 생성된 페이로드 맵 + */ + public Map createPayload(AlertType alertType, HttpServletRequest request, Object additionalData) { + List> embeds = createEmbeds(alertType, request, additionalData); + + Map payload = new HashMap<>(); + payload.put("embeds", embeds); + + return payload; + } + + /** + * 특정 알림 유형과 요청 정보 및 추가 데이터를 사용하여 Discord 메시지의 임베드를 생성합니다. + * + * @param alertType 알림 유형 + * @param request HttpServletRequest 객체 + * @param additionalData 추가 데이터 + * @return 생성된 임베드 목록 + */ + public List> createEmbeds(AlertType alertType, HttpServletRequest request, + Object additionalData) { + switch (alertType) { + case SecurityAlertType securityAlertType -> { + return createSecurityAlertEmbeds(request, alertType, additionalData.toString()); + } + case GeneralAlertType generalAlertType -> { + return createGeneralAlertEmbeds(generalAlertType, request, additionalData); + } + case ExecutivesAlertType executivesAlertType -> { + return createExecutivesAlertEmbeds(executivesAlertType, additionalData); + } + case null, default -> { + log.error("Unknown alert type: {}", alertType); + return Collections.emptyList(); + } + } + } + + private List> createGeneralAlertEmbeds(GeneralAlertType alertType, HttpServletRequest request, + Object additionalData) { + switch (alertType) { + case ADMIN_LOGIN: + if (additionalData instanceof MemberLoginInfoDto) { + return createAdminLoginEmbeds(request, (MemberLoginInfoDto) additionalData); + } + break; + case SERVER_START: + return createServerStartEmbeds(); + case SERVER_ERROR: + if (additionalData instanceof Exception) { + return createErrorEmbeds(request, (Exception) additionalData); + } + break; + default: + log.error("Unknown general alert type: {}", alertType); + } + return Collections.emptyList(); + } + + private List> createExecutivesAlertEmbeds(ExecutivesAlertType alertType, + Object additionalData) { + switch (alertType) { + case NEW_APPLICATION: + if (additionalData instanceof ApplicationRequestDto) { + return createApplicationEmbeds((ApplicationRequestDto) additionalData); + } + break; + case NEW_BOARD: + if (additionalData instanceof BoardNotificationInfo) { + return createBoardEmbeds((BoardNotificationInfo) additionalData); + } + break; + case NEW_MEMBERSHIP_FEE: + if (additionalData instanceof MembershipFeeNotificationInfo) { + return createMembershipFeeEmbeds((MembershipFeeNotificationInfo) additionalData); + } + break; + case NEW_BOOK_LOAN_REQUEST: + if (additionalData instanceof BookLoanRecordNotificationInfo) { + return createBookLoanRecordEmbeds((BookLoanRecordNotificationInfo) additionalData); + } + break; + default: + log.error("Unknown executives alert type: {}", alertType); + } + return Collections.emptyList(); + } + + private List> createErrorEmbeds(HttpServletRequest request, Exception e) { + String httpMethod = request.getMethod(); + String fullUrl = webhookCommonService.getFullUrl(request); + String username = webhookCommonService.getUsername(request); + String errorMessage = webhookCommonService.extractMessageAfterException(e); + String stackTrace = webhookCommonService.getStackTraceSummary(e); + + log.error("Server Error: {}", errorMessage); + + Map embed = new HashMap<>(); + embed.put("title", ":firecracker: Server Error"); + embed.put("color", commonProperties.getColorAsInt()); + embed.put("fields", Arrays.asList( + createField("User", username, true), + createField("Endpoint", "[" + httpMethod + "] " + fullUrl, true), + createField("Error Message", errorMessage, false), + createField("Stack Trace", "```" + stackTrace + "```", false) + )); + + return Collections.singletonList(embed); + } + + private List> createSecurityAlertEmbeds(HttpServletRequest request, AlertType alertType, + String additionalMessage) { + String clientIp = HttpReqResUtil.getClientIpAddressIfServletRequestExist(); + String fullUrl = webhookCommonService.getFullUrl(request); + String username = webhookCommonService.getUsername(request); + String location = webhookCommonService.getLocation(request); + + Map embed = new HashMap<>(); + embed.put("title", ":imp: " + alertType.getTitle()); + embed.put("color", commonProperties.getColorAsInt()); + embed.put("fields", Arrays.asList( + createField("User", username, true), + createField("IP Address", clientIp, true), + createField("Location", location, true), + createField("Endpoint", fullUrl, true), + createField("Details", alertType.getDefaultMessage() + "\n" + additionalMessage, false) + )); + + return Collections.singletonList(embed); + } + + private List> createAdminLoginEmbeds(HttpServletRequest request, + MemberLoginInfoDto loginMember) { + String clientIp = HttpReqResUtil.getClientIpAddressIfServletRequestExist(); + String location = webhookCommonService.getLocation(request); + + Map embed = new HashMap<>(); + embed.put("title", ":mechanic: " + loginMember.getRole().getDescription() + " Login"); + embed.put("color", commonProperties.getColorAsInt()); + embed.put("fields", Arrays.asList( + createField("User", loginMember.getMemberId() + " " + loginMember.getMemberName(), true), + createField("IP Address", clientIp, true), + createField("Location", location, true) + )); + + return Collections.singletonList(embed); + } + + private List> createApplicationEmbeds(ApplicationRequestDto requestDto) { + Map embed = new HashMap<>(); + embed.put("title", ":sparkles: 동아리 지원"); + embed.put("color", commonProperties.getColorAsInt()); + embed.put("fields", Arrays.asList( + createField("구분", requestDto.getApplicationType().getDescription(), true), + createField("학번", requestDto.getStudentId(), true), + createField("이름", requestDto.getName(), true), + createField("학년", requestDto.getGrade() + "학년", true), + createField("관심 분야", requestDto.getInterests(), false) + )); + + if (requestDto.getGithubUrl() != null && !requestDto.getGithubUrl().isEmpty()) { + embed.put("description", "[Github](" + requestDto.getGithubUrl() + ")"); + } + + return Collections.singletonList(embed); + } + + private List> createBoardEmbeds(BoardNotificationInfo board) { + Map embed = new HashMap<>(); + embed.put("title", ":writing_hand: 새 게시글"); + embed.put("color", commonProperties.getColorAsInt()); + embed.put("fields", Arrays.asList( + createField("제목", board.getTitle(), true), + createField("분류", board.getCategory(), true), + createField("작성자", board.getUsername(), true) + )); + + return Collections.singletonList(embed); + } + + private List> createMembershipFeeEmbeds(MembershipFeeNotificationInfo data) { + String username = data.getMemberId() + " " + data.getMemberName(); + + Map embed = new HashMap<>(); + embed.put("title", ":dollar: 회비 신청"); + embed.put("color", commonProperties.getColorAsInt()); + embed.put("fields", Arrays.asList( + createField("신청자", username, true), + createField("분류", data.getCategory(), true), + createField("금액", data.getAmount() + "원", true), + createField("Content", data.getContent(), false) + )); + + return Collections.singletonList(embed); + } + + private List> createBookLoanRecordEmbeds(BookLoanRecordNotificationInfo data) { + String username = data.getMemberId() + " " + data.getMemberName(); + + Map embed = new HashMap<>(); + embed.put("title", ":books: 도서 대여 신청"); + embed.put("color", commonProperties.getColorAsInt()); + embed.put("fields", Arrays.asList( + createField("도서명", data.getBookTitle(), true), + createField("분류", data.getCategory(), true), + createField("신청자", username, true), + createField("상태", data.isAvailable() ? "대여 가능" : "대여 중", true) + )); + + return Collections.singletonList(embed); + } + + private List> createServerStartEmbeds() { + String osInfo = webhookCommonService.getOperatingSystemInfo(); + String jdkVersion = webhookCommonService.getJavaRuntimeVersion(); + double cpuUsage = webhookCommonService.getCpuUsage(); + String memoryUsage = webhookCommonService.getMemoryUsage(); + + Map embed = new HashMap<>(); + embed.put("title", ":battery: Server Started"); + embed.put("color", commonProperties.getColorAsInt()); + embed.put("fields", Arrays.asList( + createField("Environment", environment.getProperty("spring.profiles.active"), true), + createField("OS", osInfo, true), + createField("JDK Version", jdkVersion, true), + createField("CPU Usage", String.format("%.2f%%", cpuUsage), true), + createField("Memory Usage", memoryUsage, true) + )); + + embed.put("description", + "[Web](" + commonProperties.getWebUrl() + ") | [API Docs](" + commonProperties.getApiUrl() + ")"); + + return Collections.singletonList(embed); + } + + private Map createField(String name, String value, boolean inline) { + Map field = new HashMap<>(); + field.put("name", name); + field.put("value", value); + field.put("inline", inline); + return field; + } +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/SlackNotificationSender.java b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/SlackNotificationSender.java new file mode 100644 index 000000000..3a993dc57 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/SlackNotificationSender.java @@ -0,0 +1,25 @@ +package page.clab.api.global.common.notificationSetting.adapter.out.webhook; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.application.port.out.NotificationSender; +import page.clab.api.global.common.notificationSetting.domain.PlatformType; + +@Component +@RequiredArgsConstructor +public class SlackNotificationSender implements NotificationSender { + + private final SlackWebhookClient slackWebhookClient; + + @Override + public String getPlatformName() { + return PlatformType.SLACK.getName(); + } + + @Override + public void sendNotification(NotificationEvent event, String webhookUrl) { + slackWebhookClient.sendMessage(webhookUrl, event.getAlertType(), event.getRequest(), + event.getAdditionalData()); + } +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/SlackWebhookClient.java b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/SlackWebhookClient.java new file mode 100644 index 000000000..291bfd047 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/SlackWebhookClient.java @@ -0,0 +1,338 @@ +package page.clab.api.global.common.notificationSetting.adapter.out.webhook; + +import static com.slack.api.model.block.Blocks.actions; +import static com.slack.api.model.block.Blocks.section; +import static com.slack.api.model.block.composition.BlockCompositions.markdownText; +import static com.slack.api.model.block.composition.BlockCompositions.plainText; +import static com.slack.api.model.block.element.BlockElements.asElements; +import static com.slack.api.model.block.element.BlockElements.button; + +import com.slack.api.Slack; +import com.slack.api.model.Attachment; +import com.slack.api.model.block.LayoutBlock; +import com.slack.api.webhook.Payload; +import com.slack.api.webhook.WebhookResponse; +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.env.Environment; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import page.clab.api.domain.hiring.application.application.dto.request.ApplicationRequestDto; +import page.clab.api.domain.memberManagement.member.application.dto.shared.MemberLoginInfoDto; +import page.clab.api.global.common.notificationSetting.application.dto.notification.BoardNotificationInfo; +import page.clab.api.global.common.notificationSetting.application.dto.notification.BookLoanRecordNotificationInfo; +import page.clab.api.global.common.notificationSetting.application.dto.notification.MembershipFeeNotificationInfo; +import page.clab.api.global.common.notificationSetting.application.service.WebhookCommonService; +import page.clab.api.global.common.notificationSetting.config.NotificationConfigProperties; +import page.clab.api.global.common.notificationSetting.domain.AlertType; +import page.clab.api.global.common.notificationSetting.domain.ExecutivesAlertType; +import page.clab.api.global.common.notificationSetting.domain.GeneralAlertType; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; +import page.clab.api.global.util.HttpReqResUtil; + +/** + * {@code SlackWebhookClient}는 다양한 알림 유형에 따라 Slack 메시지를 구성하고 전송하는 클래스입니다. + * + *

    주요 기능:

    + *
      + *
    • {@link #sendMessage(String, AlertType, HttpServletRequest, Object)}: Slack에 알림 메시지를 비동기적으로 전송
    • + *
    • {@link #createBlocks(AlertType, HttpServletRequest, Object)}: 알림 유형에 따라 Slack 메시지 블록 생성
    • + *
    • 다양한 알림 유형에 맞는 메시지 형식을 생성하는 전용 메서드
    • + *
    + * + *

    Slack API와 통합하여 웹훅 URL을 통해 메시지를 전송하며, 메시지 전송 실패 시 로그에 오류를 기록합니다.

    + * + *

    AlertType을 기반으로 여러 도메인에서 발생하는 이벤트를 Slack을 통해 모니터링할 수 있도록 지원하며, + * Slack 알림은 주로 서버 이벤트, 보안 경고, 신규 신청, 관리자 로그인 등의 이벤트를 다룹니다.

    + * + * @see Slack + * @see Payload + * @see LayoutBlock + */ +@Component +@Slf4j +public class SlackWebhookClient extends AbstractWebhookClient { + + private final Slack slack; + private final NotificationConfigProperties.CommonProperties commonProperties; + private final Environment environment; + private final WebhookCommonService webhookCommonService; + + public SlackWebhookClient( + NotificationConfigProperties notificationConfigProperties, + Environment environment, + WebhookCommonService webhookCommonService + ) { + this.slack = Slack.getInstance(); + this.commonProperties = notificationConfigProperties.getCommon(); + this.environment = environment; + this.webhookCommonService = webhookCommonService; + } + + /** + * Slack에 알림 메시지를 전송합니다. + * + *

    주어진 webhookUrl과 alertType, HttpServletRequest 및 추가 데이터(additionalData)를 사용하여 알림 메시지를 + * 비동기적으로 Slack에 전송합니다.

    + * + * @param webhookUrl 메시지를 보낼 Slack 웹훅 URL + * @param alertType 알림 유형을 나타내는 {@link AlertType} + * @param request HttpServletRequest 객체, 클라이언트 요청 정보 + * @param additionalData 추가 데이터 + * @return 메시지 전송 성공 여부를 나타내는 CompletableFuture + */ + public CompletableFuture sendMessage(String webhookUrl, AlertType alertType, + HttpServletRequest request, Object additionalData) { + List blocks = createBlocks(alertType, request, additionalData); + + return CompletableFuture.supplyAsync(() -> { + Payload payload = Payload.builder() + .blocks(Collections.singletonList(blocks.getFirst())) + .attachments(Collections.singletonList( + Attachment.builder() + .color(commonProperties.getColor()) + .blocks(blocks.subList(1, blocks.size())) + .build() + )) + .build(); + + try { + WebhookResponse response = slack.send(webhookUrl, payload); + if (response.getCode() == HttpStatus.OK.value()) { + return true; + } else { + log.error("Slack notification failed: {}", response.getMessage()); + return false; + } + } catch (IOException e) { + log.error("Failed to send Slack message: {}", e.getMessage(), e); + return false; + } + }); + } + + /** + * 특정 알림 유형과 요청 정보 및 추가 데이터를 사용하여 Slack 메시지의 블록을 생성합니다. + * + *

    AlertType에 따라 보안 경고, 일반 알림, 운영진 알림 등 다양한 형식의 메시지를 생성합니다.

    + * + * @param alertType 알림 유형 + * @param request HttpServletRequest 객체 + * @param additionalData 추가 데이터 + * @return 생성된 LayoutBlock 목록 + */ + public List createBlocks(AlertType alertType, HttpServletRequest request, Object additionalData) { + switch (alertType) { + case SecurityAlertType securityAlertType -> { + return createSecurityAlertBlocks(request, alertType, additionalData.toString()); + } + case GeneralAlertType generalAlertType -> { + return createGeneralAlertBlocks(generalAlertType, request, additionalData); + } + case ExecutivesAlertType executivesAlertType -> { + return createExecutivesAlertBlocks(executivesAlertType, additionalData); + } + case null, default -> { + log.error("Unknown alert type: {}", alertType); + return Collections.emptyList(); + } + } + } + + private List createGeneralAlertBlocks(GeneralAlertType alertType, HttpServletRequest request, + Object additionalData) { + switch (alertType) { + case ADMIN_LOGIN: + if (additionalData instanceof MemberLoginInfoDto) { + return createAdminLoginBlocks(request, (MemberLoginInfoDto) additionalData); + } + break; + case SERVER_START: + return createServerStartBlocks(); + case SERVER_ERROR: + if (additionalData instanceof Exception) { + return createErrorBlocks(request, (Exception) additionalData); + } + break; + default: + log.error("Unknown general alert type: {}", alertType); + } + return Collections.emptyList(); + } + + private List createExecutivesAlertBlocks(ExecutivesAlertType alertType, Object additionalData) { + switch (alertType) { + case NEW_APPLICATION: + if (additionalData instanceof ApplicationRequestDto) { + return createApplicationBlocks((ApplicationRequestDto) additionalData); + } + break; + case NEW_BOARD: + if (additionalData instanceof BoardNotificationInfo) { + return createBoardBlocks((BoardNotificationInfo) additionalData); + } + break; + case NEW_MEMBERSHIP_FEE: + if (additionalData instanceof MembershipFeeNotificationInfo) { + return createMembershipFeeBlocks((MembershipFeeNotificationInfo) additionalData); + } + break; + case NEW_BOOK_LOAN_REQUEST: + if (additionalData instanceof BookLoanRecordNotificationInfo) { + return createBookLoanRecordBlocks((BookLoanRecordNotificationInfo) additionalData); + } + break; + default: + log.error("Unknown executives alert type: {}", alertType); + } + return Collections.emptyList(); + } + + private List createErrorBlocks(HttpServletRequest request, Exception e) { + String httpMethod = request.getMethod(); + String fullUrl = webhookCommonService.getFullUrl(request); + String username = webhookCommonService.getUsername(request); + String errorMessage = webhookCommonService.extractMessageAfterException(e); + String stackTrace = webhookCommonService.getStackTraceSummary(e); + + log.error("Server Error: {}", errorMessage); + + return Arrays.asList( + section(s -> s.text(markdownText(":firecracker: *Server Error*"))), + section(s -> s.fields(Arrays.asList( + markdownText("*User:*\n" + username), + markdownText("*Endpoint:*\n[" + httpMethod + "] " + fullUrl) + ))), + section(s -> s.text(markdownText("*Error Message:*\n" + errorMessage))), + section(s -> s.text(markdownText("*Stack Trace:*\n```" + stackTrace + "```"))) + ); + } + + private List createSecurityAlertBlocks(HttpServletRequest request, AlertType alertType, + String additionalMessage) { + String clientIp = HttpReqResUtil.getClientIpAddressIfServletRequestExist(); + String fullUrl = webhookCommonService.getFullUrl(request); + String username = webhookCommonService.getUsername(request); + String location = webhookCommonService.getLocation(request); + + return Arrays.asList( + section(s -> s.text(markdownText(":imp: *" + alertType.getTitle() + "*"))), + section(s -> s.fields(Arrays.asList( + markdownText("*User:*\n" + username), + markdownText("*IP Address:*\n" + clientIp), + markdownText("*Location:*\n" + location), + markdownText("*Endpoint:*\n" + fullUrl) + ))), + section(s -> s.text( + markdownText("*Details:*\n" + alertType.getDefaultMessage() + "\n" + additionalMessage))) + ); + } + + private List createAdminLoginBlocks(HttpServletRequest request, MemberLoginInfoDto loginMember) { + String clientIp = HttpReqResUtil.getClientIpAddressIfServletRequestExist(); + String location = webhookCommonService.getLocation(request); + + return Arrays.asList( + section(s -> s.text(markdownText(":mechanic: *" + loginMember.getRole().getDescription() + " Login*"))), + section(s -> s.fields(Arrays.asList( + markdownText("*User:*\n" + loginMember.getMemberId() + " " + loginMember.getMemberName()), + markdownText("*IP Address:*\n" + clientIp), + markdownText("*Location:*\n" + location) + ))) + ); + } + + private List createApplicationBlocks(ApplicationRequestDto requestDto) { + List blocks = new ArrayList<>(); + + blocks.add(section(s -> s.text(markdownText(":sparkles: *동아리 지원*")))); + blocks.add(section(s -> s.fields(Arrays.asList( + markdownText("*구분:*\n" + requestDto.getApplicationType().getDescription()), + markdownText("*학번:*\n" + requestDto.getStudentId()), + markdownText("*이름:*\n" + requestDto.getName()), + markdownText("*학년:*\n" + requestDto.getGrade() + "학년"), + markdownText("*관심 분야:*\n" + requestDto.getInterests()) + )))); + + if (requestDto.getGithubUrl() != null && !requestDto.getGithubUrl().isEmpty()) { + blocks.add(actions(a -> a.elements(asElements( + button(b -> b.text(plainText(pt -> pt.emoji(true).text("Github"))) + .url(requestDto.getGithubUrl()) + .actionId("click_github")) + )))); + } + + return blocks; + } + + private List createBoardBlocks(BoardNotificationInfo board) { + return Arrays.asList( + section(s -> s.text(markdownText(":writing_hand: *새 게시글*"))), + section(s -> s.fields(Arrays.asList( + markdownText("*제목:*\n" + board.getTitle()), + markdownText("*분류:*\n" + board.getCategory()), + markdownText("*작성자:*\n" + board.getUsername()) + ))) + ); + } + + private List createMembershipFeeBlocks(MembershipFeeNotificationInfo data) { + String username = data.getMemberId() + " " + data.getMemberName(); + + return Arrays.asList( + section(s -> s.text(markdownText(":dollar: *회비 신청*"))), + section(s -> s.fields(Arrays.asList( + markdownText("*신청자:*\n" + username), + markdownText("*분류:*\n" + data.getCategory()), + markdownText("*금액:*\n" + data.getAmount() + "원") + ))), + section(s -> s.text(markdownText("*Content:*\n" + data.getContent()))) + ); + } + + private List createBookLoanRecordBlocks(BookLoanRecordNotificationInfo data) { + String username = data.getMemberId() + " " + data.getMemberName(); + + return Arrays.asList( + section(s -> s.text(markdownText(":books: *도서 대여 신청*"))), + section(s -> s.fields(Arrays.asList( + markdownText("*도서명:*\n" + data.getBookTitle()), + markdownText("*분류:*\n" + data.getCategory()), + markdownText("*신청자:*\n" + username), + markdownText("*상태:*\n" + (data.isAvailable() ? "대여 가능" : "대여 중")) + ))) + ); + } + + private List createServerStartBlocks() { + String osInfo = webhookCommonService.getOperatingSystemInfo(); + String jdkVersion = webhookCommonService.getJavaRuntimeVersion(); + double cpuUsage = webhookCommonService.getCpuUsage(); + String memoryUsage = webhookCommonService.getMemoryUsage(); + + return Arrays.asList( + section(s -> s.text(markdownText(":battery: *Server Started*"))), + section(s -> s.fields(Arrays.asList( + markdownText("*Environment:* \n" + environment.getProperty("spring.profiles.active")), + markdownText("*OS:* \n" + osInfo), + markdownText("*JDK Version:* \n" + jdkVersion), + markdownText("*CPU Usage:* \n" + String.format("%.2f%%", cpuUsage)), + markdownText("*Memory Usage:* \n" + memoryUsage) + ))), + actions(a -> a.elements(asElements( + button(b -> b.text(plainText(pt -> pt.emoji(true).text("Web"))) + .url(commonProperties.getWebUrl()) + .value("click_web")), + button(b -> b.text(plainText(pt -> pt.emoji(true).text("API Docs"))) + .url(commonProperties.getApiUrl()) + .value("click_apiDocs")) + ))) + ); + } +} diff --git a/src/main/java/page/clab/api/global/common/slack/dto/mapper/SlackDtoMapper.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/dto/mapper/NotificationSettingDtoMapper.java similarity index 51% rename from src/main/java/page/clab/api/global/common/slack/dto/mapper/SlackDtoMapper.java rename to src/main/java/page/clab/api/global/common/notificationSetting/application/dto/mapper/NotificationSettingDtoMapper.java index f2b75b6a3..4b092100a 100644 --- a/src/main/java/page/clab/api/global/common/slack/dto/mapper/SlackDtoMapper.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/dto/mapper/NotificationSettingDtoMapper.java @@ -1,11 +1,11 @@ -package page.clab.api.global.common.slack.dto.mapper; +package page.clab.api.global.common.notificationSetting.application.dto.mapper; import org.springframework.stereotype.Component; -import page.clab.api.global.common.slack.domain.NotificationSetting; -import page.clab.api.global.common.slack.dto.response.NotificationSettingResponseDto; +import page.clab.api.global.common.notificationSetting.application.dto.response.NotificationSettingResponseDto; +import page.clab.api.global.common.notificationSetting.domain.NotificationSetting; @Component -public class SlackDtoMapper { +public class NotificationSettingDtoMapper { public NotificationSettingResponseDto toDto(NotificationSetting setting) { return NotificationSettingResponseDto.builder() diff --git a/src/main/java/page/clab/api/global/common/slack/domain/SlackBoardInfo.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/dto/notification/BoardNotificationInfo.java similarity index 58% rename from src/main/java/page/clab/api/global/common/slack/domain/SlackBoardInfo.java rename to src/main/java/page/clab/api/global/common/notificationSetting/application/dto/notification/BoardNotificationInfo.java index a12be3cb8..4cf0a3442 100644 --- a/src/main/java/page/clab/api/global/common/slack/domain/SlackBoardInfo.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/dto/notification/BoardNotificationInfo.java @@ -1,4 +1,4 @@ -package page.clab.api.global.common.slack.domain; +package page.clab.api.global.common.notificationSetting.application.dto.notification; import lombok.Builder; import lombok.Getter; @@ -7,17 +7,18 @@ @Getter @Builder -public class SlackBoardInfo { +public class BoardNotificationInfo { private String title; private String category; private String username; - public static SlackBoardInfo create(Board board, MemberDetailedInfoDto memberInfo) { - return SlackBoardInfo.builder() + public static BoardNotificationInfo create(Board board, MemberDetailedInfoDto memberInfo) { + return BoardNotificationInfo.builder() .title(board.getTitle()) .category(board.getCategory().getDescription()) - .username(board.isWantAnonymous() ? board.getNickname() : memberInfo.getMemberId() + " " + memberInfo.getMemberName()) + .username(board.isWantAnonymous() ? board.getNickname() + : memberInfo.getMemberId() + " " + memberInfo.getMemberName()) .build(); } } diff --git a/src/main/java/page/clab/api/global/common/slack/domain/SlackBookLoanRecordInfo.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/dto/notification/BookLoanRecordNotificationInfo.java similarity index 69% rename from src/main/java/page/clab/api/global/common/slack/domain/SlackBookLoanRecordInfo.java rename to src/main/java/page/clab/api/global/common/notificationSetting/application/dto/notification/BookLoanRecordNotificationInfo.java index 1f961750f..036f1de32 100644 --- a/src/main/java/page/clab/api/global/common/slack/domain/SlackBookLoanRecordInfo.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/dto/notification/BookLoanRecordNotificationInfo.java @@ -1,4 +1,4 @@ -package page.clab.api.global.common.slack.domain; +package page.clab.api.global.common.notificationSetting.application.dto.notification; import lombok.Builder; import lombok.Getter; @@ -7,7 +7,7 @@ @Getter @Builder -public class SlackBookLoanRecordInfo { +public class BookLoanRecordNotificationInfo { private String memberId; private String memberName; @@ -15,8 +15,8 @@ public class SlackBookLoanRecordInfo { private String category; private boolean isAvailable; - public static SlackBookLoanRecordInfo create(Book book, MemberBorrowerInfoDto borrowerInfo) { - return SlackBookLoanRecordInfo.builder() + public static BookLoanRecordNotificationInfo create(Book book, MemberBorrowerInfoDto borrowerInfo) { + return BookLoanRecordNotificationInfo.builder() .memberId(borrowerInfo.getMemberId()) .memberName(borrowerInfo.getMemberName()) .bookTitle(book.getTitle()) diff --git a/src/main/java/page/clab/api/global/common/slack/domain/SlackMembershipFeeInfo.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/dto/notification/MembershipFeeNotificationInfo.java similarity index 69% rename from src/main/java/page/clab/api/global/common/slack/domain/SlackMembershipFeeInfo.java rename to src/main/java/page/clab/api/global/common/notificationSetting/application/dto/notification/MembershipFeeNotificationInfo.java index 02461c71d..65b6ed8f3 100644 --- a/src/main/java/page/clab/api/global/common/slack/domain/SlackMembershipFeeInfo.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/dto/notification/MembershipFeeNotificationInfo.java @@ -1,4 +1,4 @@ -package page.clab.api.global.common.slack.domain; +package page.clab.api.global.common.notificationSetting.application.dto.notification; import lombok.Builder; import lombok.Getter; @@ -7,7 +7,7 @@ @Getter @Builder -public class SlackMembershipFeeInfo { +public class MembershipFeeNotificationInfo { private String memberId; private String memberName; @@ -15,8 +15,8 @@ public class SlackMembershipFeeInfo { private Long amount; private String content; - public static SlackMembershipFeeInfo create(MembershipFee membershipFee, MemberBasicInfoDto memberInfo) { - return SlackMembershipFeeInfo.builder() + public static MembershipFeeNotificationInfo create(MembershipFee membershipFee, MemberBasicInfoDto memberInfo) { + return MembershipFeeNotificationInfo.builder() .memberId(memberInfo.getMemberId()) .memberName(memberInfo.getMemberName()) .category(membershipFee.getCategory()) diff --git a/src/main/java/page/clab/api/global/common/slack/dto/request/NotificationSettingUpdateRequestDto.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/dto/request/NotificationSettingToggleRequestDto.java similarity index 78% rename from src/main/java/page/clab/api/global/common/slack/dto/request/NotificationSettingUpdateRequestDto.java rename to src/main/java/page/clab/api/global/common/notificationSetting/application/dto/request/NotificationSettingToggleRequestDto.java index fcd941924..172cc146d 100644 --- a/src/main/java/page/clab/api/global/common/slack/dto/request/NotificationSettingUpdateRequestDto.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/dto/request/NotificationSettingToggleRequestDto.java @@ -1,4 +1,4 @@ -package page.clab.api.global.common.slack.dto.request; +package page.clab.api.global.common.notificationSetting.application.dto.request; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; @@ -7,7 +7,7 @@ @Getter @Setter -public class NotificationSettingUpdateRequestDto { +public class NotificationSettingToggleRequestDto { @NotNull(message = "{notNull.notificationSetting.alertType}") @Schema(description = "알림 타입", example = "서버 시작") diff --git a/src/main/java/page/clab/api/global/common/slack/dto/response/NotificationSettingResponseDto.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/dto/response/NotificationSettingResponseDto.java similarity index 67% rename from src/main/java/page/clab/api/global/common/slack/dto/response/NotificationSettingResponseDto.java rename to src/main/java/page/clab/api/global/common/notificationSetting/application/dto/response/NotificationSettingResponseDto.java index ffbbd87b9..8eeaf2256 100644 --- a/src/main/java/page/clab/api/global/common/slack/dto/response/NotificationSettingResponseDto.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/dto/response/NotificationSettingResponseDto.java @@ -1,4 +1,4 @@ -package page.clab.api.global.common.slack.dto.response; +package page.clab.api.global.common.notificationSetting.application.dto.response; import lombok.Builder; import lombok.Getter; diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/application/event/ApplicationStartupListener.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/event/ApplicationStartupListener.java new file mode 100644 index 000000000..0f9f08a6d --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/event/ApplicationStartupListener.java @@ -0,0 +1,23 @@ +package page.clab.api.global.common.notificationSetting.application.event; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import page.clab.api.global.common.notificationSetting.domain.GeneralAlertType; + +@Component +public class ApplicationStartupListener { + + private final ApplicationEventPublisher eventPublisher; + + public ApplicationStartupListener(ApplicationEventPublisher eventPublisher) { + this.eventPublisher = eventPublisher; + } + + @EventListener(ContextRefreshedEvent.class) + public void onApplicationEvent(ContextRefreshedEvent event) { + eventPublisher.publishEvent( + new NotificationEvent(this, GeneralAlertType.SERVER_START, null, null)); + } +} diff --git a/src/main/java/page/clab/api/global/common/slack/event/NotificationEvent.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/event/NotificationEvent.java similarity index 59% rename from src/main/java/page/clab/api/global/common/slack/event/NotificationEvent.java rename to src/main/java/page/clab/api/global/common/notificationSetting/application/event/NotificationEvent.java index 59bfb5158..4493c8fa6 100644 --- a/src/main/java/page/clab/api/global/common/slack/event/NotificationEvent.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/event/NotificationEvent.java @@ -1,21 +1,19 @@ -package page.clab.api.global.common.slack.event; +package page.clab.api.global.common.notificationSetting.application.event; import jakarta.servlet.http.HttpServletRequest; import lombok.Getter; import org.springframework.context.ApplicationEvent; -import page.clab.api.global.common.slack.domain.AlertType; +import page.clab.api.global.common.notificationSetting.domain.AlertType; @Getter public class NotificationEvent extends ApplicationEvent { - private final String webhookUrl; private final AlertType alertType; private final HttpServletRequest request; private final Object additionalData; - public NotificationEvent(Object source, String webhookUrl, AlertType alertType, HttpServletRequest request, Object additionalData) { + public NotificationEvent(Object source, AlertType alertType, HttpServletRequest request, Object additionalData) { super(source); - this.webhookUrl = webhookUrl; this.alertType = alertType; this.request = request; this.additionalData = additionalData; diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/application/event/NotificationListener.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/event/NotificationListener.java new file mode 100644 index 000000000..56c2eda22 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/event/NotificationListener.java @@ -0,0 +1,95 @@ +package page.clab.api.global.common.notificationSetting.application.event; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import page.clab.api.global.common.notificationSetting.application.port.in.ManageNotificationSettingUseCase; +import page.clab.api.global.common.notificationSetting.application.port.out.NotificationSender; +import page.clab.api.global.common.notificationSetting.config.NotificationConfigProperties; +import page.clab.api.global.common.notificationSetting.config.NotificationConfigProperties.PlatformConfig; +import page.clab.api.global.common.notificationSetting.config.NotificationConfigProperties.PlatformMapping; +import page.clab.api.global.common.notificationSetting.domain.AlertType; +import page.clab.api.global.common.notificationSetting.domain.NotificationSetting; + +@Component +@Slf4j +public class NotificationListener { + + private final ManageNotificationSettingUseCase manageNotificationSettingUseCase; + private final Map notificationSenders; + private final NotificationConfigProperties notificationConfigProperties; + + public NotificationListener( + ManageNotificationSettingUseCase manageNotificationSettingUseCase, + List notificationSenderList, + NotificationConfigProperties notificationConfigProperties) { + this.manageNotificationSettingUseCase = manageNotificationSettingUseCase; + this.notificationConfigProperties = notificationConfigProperties; + this.notificationSenders = notificationSenderList.stream() + .collect(Collectors.toMap(NotificationSender::getPlatformName, Function.identity())); + } + + @EventListener + public void handleNotificationEvent(NotificationEvent event) { + AlertType alertType = event.getAlertType(); + + NotificationSetting setting = manageNotificationSettingUseCase.getOrCreateDefaultSetting(alertType); + if (!setting.isEnabled()) { + return; + } + + List mappings = getMappingsForAlertType(alertType); + if (mappings.isEmpty()) { + return; + } + + mappings.forEach(mapping -> getWebhookUrl(mapping) + .ifPresent(webhookUrl -> sendNotification(mapping.getPlatform(), event, webhookUrl))); + } + + private List getMappingsForAlertType(AlertType alertType) { + String categoryName = alertType.getCategory().name(); + Map> categoryMappings = notificationConfigProperties.getCategoryMappings(); + + return Optional.ofNullable(categoryMappings.get(categoryName)) + .filter(list -> !list.isEmpty()) + .orElseGet(notificationConfigProperties::getDefaultMappings); + } + + private Optional getWebhookUrl(PlatformMapping mapping) { + String platform = mapping.getPlatform(); + String webhookKey = mapping.getWebhook(); + Map platforms = notificationConfigProperties.getPlatforms(); + + return Optional.ofNullable(platforms.get(platform)) + .map(platformConfig -> platformConfig.getWebhooks().get(webhookKey)) + .map(url -> { + log.debug("Found webhook URL for platform '{}', key '{}': {}", platform, webhookKey, url); + return url; + }) + .or(() -> { + log.warn("No webhook URL found for platform '{}', key '{}'", platform, webhookKey); + return Optional.empty(); + }); + } + + private void sendNotification(String platform, NotificationEvent event, String webhookUrl) { + NotificationSender sender = notificationSenders.get(platform); + if (sender == null) { + log.warn("No NotificationSender found for platform: {}", platform); + return; + } + + try { + sender.sendNotification(event, webhookUrl); + log.debug("Notification sent via platform: {}", platform); + } catch (Exception e) { + log.error("Failed to send notification via platform: {}", platform, e); + } + } +} diff --git a/src/main/java/page/clab/api/global/common/slack/exception/AlertTypeNotFoundException.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/exception/AlertTypeNotFoundException.java similarity index 71% rename from src/main/java/page/clab/api/global/common/slack/exception/AlertTypeNotFoundException.java rename to src/main/java/page/clab/api/global/common/notificationSetting/application/exception/AlertTypeNotFoundException.java index c52c39796..94de58360 100644 --- a/src/main/java/page/clab/api/global/common/slack/exception/AlertTypeNotFoundException.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/exception/AlertTypeNotFoundException.java @@ -1,4 +1,4 @@ -package page.clab.api.global.common.slack.exception; +package page.clab.api.global.common.notificationSetting.application.exception; public class AlertTypeNotFoundException extends RuntimeException { diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/application/port/in/ManageNotificationSettingUseCase.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/port/in/ManageNotificationSettingUseCase.java new file mode 100644 index 000000000..338de0f31 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/port/in/ManageNotificationSettingUseCase.java @@ -0,0 +1,11 @@ +package page.clab.api.global.common.notificationSetting.application.port.in; + +import page.clab.api.global.common.notificationSetting.domain.AlertType; +import page.clab.api.global.common.notificationSetting.domain.NotificationSetting; + +public interface ManageNotificationSettingUseCase { + + void toggleNotificationSetting(String alertTypeName, boolean enabled); + + NotificationSetting getOrCreateDefaultSetting(AlertType alertType); +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/application/port/in/RetrieveNotificationSettingUseCase.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/port/in/RetrieveNotificationSettingUseCase.java new file mode 100644 index 000000000..33bfde964 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/port/in/RetrieveNotificationSettingUseCase.java @@ -0,0 +1,9 @@ +package page.clab.api.global.common.notificationSetting.application.port.in; + +import java.util.List; +import page.clab.api.global.common.notificationSetting.application.dto.response.NotificationSettingResponseDto; + +public interface RetrieveNotificationSettingUseCase { + + List retrieveNotificationSettings(); +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/NotificationSender.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/NotificationSender.java new file mode 100644 index 000000000..23b9f55c3 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/NotificationSender.java @@ -0,0 +1,10 @@ +package page.clab.api.global.common.notificationSetting.application.port.out; + +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; + +public interface NotificationSender { + + String getPlatformName(); + + void sendNotification(NotificationEvent event, String webhookUrl); +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/RetrieveNotificationSettingPort.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/RetrieveNotificationSettingPort.java new file mode 100644 index 000000000..9b81d12b2 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/RetrieveNotificationSettingPort.java @@ -0,0 +1,13 @@ +package page.clab.api.global.common.notificationSetting.application.port.out; + +import java.util.List; +import java.util.Optional; +import page.clab.api.global.common.notificationSetting.domain.AlertType; +import page.clab.api.global.common.notificationSetting.domain.NotificationSetting; + +public interface RetrieveNotificationSettingPort { + + List findAll(); + + Optional findByAlertType(AlertType alertType); +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/UpdateNotificationSettingPort.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/UpdateNotificationSettingPort.java new file mode 100644 index 000000000..144205601 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/UpdateNotificationSettingPort.java @@ -0,0 +1,8 @@ +package page.clab.api.global.common.notificationSetting.application.port.out; + +import page.clab.api.global.common.notificationSetting.domain.NotificationSetting; + +public interface UpdateNotificationSettingPort { + + NotificationSetting save(NotificationSetting setting); +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/WebhookClient.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/WebhookClient.java new file mode 100644 index 000000000..c85e5776d --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/WebhookClient.java @@ -0,0 +1,23 @@ +package page.clab.api.global.common.notificationSetting.application.port.out; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.concurrent.CompletableFuture; +import page.clab.api.global.common.notificationSetting.domain.AlertType; + +/** + * WebhookClient는 외부 시스템(Discord, Slack 등)과 통신하기 위한 포트 인터페이스입니다. + */ +public interface WebhookClient { + + /** + * 특정 알림 유형과 요청 정보, 추가 데이터를 사용하여 메시지를 비동기적으로 전송합니다. + * + * @param webhookUrl 메시지를 보낼 Webhook URL + * @param alertType 알림 유형 + * @param request 클라이언트 요청 정보 + * @param additionalData 추가 데이터 + * @return 메시지 전송 성공 여부를 나타내는 CompletableFuture + */ + CompletableFuture sendMessage(String webhookUrl, AlertType alertType, HttpServletRequest request, + Object additionalData); +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/application/service/ManageNotificationSettingService.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/service/ManageNotificationSettingService.java new file mode 100644 index 000000000..d42337ddf --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/service/ManageNotificationSettingService.java @@ -0,0 +1,52 @@ +package page.clab.api.global.common.notificationSetting.application.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import page.clab.api.global.common.notificationSetting.application.port.in.ManageNotificationSettingUseCase; +import page.clab.api.global.common.notificationSetting.application.port.out.RetrieveNotificationSettingPort; +import page.clab.api.global.common.notificationSetting.application.port.out.UpdateNotificationSettingPort; +import page.clab.api.global.common.notificationSetting.domain.AlertType; +import page.clab.api.global.common.notificationSetting.domain.AlertTypeResolver; +import page.clab.api.global.common.notificationSetting.domain.NotificationSetting; + +/** + * {@code UpdateNotificationSettingService}는 알림 설정을 업데이트하는 서비스입니다. + * + *

    이 서비스는 주어진 알림 유형에 따라 활성화 또는 비활성화할 수 있는 설정을 업데이트할 수 있습니다. + * 또한, 기본 알림 설정이 존재하지 않으면 생성하여 제공합니다.

    + *

    + * 주요 기능: + *

      + *
    • {@link #toggleNotificationSetting(String, boolean)} - 주어진 알림 유형에 대해 알림 설정을 업데이트합니다.
    • + *
    • {@link #getOrCreateDefaultSetting(AlertType)} - 주어진 알림 유형에 대한 기본 알림 설정을 조회하거나, 존재하지 않으면 생성합니다.
    • + *
    + */ +@Service +@RequiredArgsConstructor +public class ManageNotificationSettingService implements ManageNotificationSettingUseCase { + + private final AlertTypeResolver alertTypeResolver; + private final RetrieveNotificationSettingPort retrieveNotificationSettingPort; + private final UpdateNotificationSettingPort updateNotificationSettingPort; + + @Transactional + @Override + public void toggleNotificationSetting(String alertTypeName, boolean enabled) { + AlertType alertType = alertTypeResolver.resolve(alertTypeName); + NotificationSetting setting = getOrCreateDefaultSetting(alertType); + setting.updateEnabled(enabled); + updateNotificationSettingPort.save(setting); + } + + @Transactional + public NotificationSetting getOrCreateDefaultSetting(AlertType alertType) { + return retrieveNotificationSettingPort.findByAlertType(alertType) + .orElseGet(() -> createAndSaveDefaultSetting(alertType)); + } + + private NotificationSetting createAndSaveDefaultSetting(AlertType alertType) { + NotificationSetting defaultSetting = NotificationSetting.createDefault(alertType); + return updateNotificationSettingPort.save(defaultSetting); + } +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/application/service/RetrieveNotificationSettingService.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/service/RetrieveNotificationSettingService.java new file mode 100644 index 000000000..3d4d6bc32 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/service/RetrieveNotificationSettingService.java @@ -0,0 +1,36 @@ +package page.clab.api.global.common.notificationSetting.application.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import page.clab.api.global.common.notificationSetting.application.dto.mapper.NotificationSettingDtoMapper; +import page.clab.api.global.common.notificationSetting.application.dto.response.NotificationSettingResponseDto; +import page.clab.api.global.common.notificationSetting.application.port.in.RetrieveNotificationSettingUseCase; +import page.clab.api.global.common.notificationSetting.application.port.out.RetrieveNotificationSettingPort; + +/** + * {@code RetrieveNotificationSettingService}는 알림 설정을 조회하는 서비스입니다. + * + *

    이 서비스는 알림 설정의 전체 목록을 조회할 수 있는 기능을 제공합니다.

    + *

    + * 주요 기능: + *

      + *
    • {@link #retrieveNotificationSettings()} - 모든 알림 설정을 조회합니다.
    • + *
    + */ +@Service +@RequiredArgsConstructor +public class RetrieveNotificationSettingService implements RetrieveNotificationSettingUseCase { + + private final RetrieveNotificationSettingPort retrieveNotificationSettingPort; + private final NotificationSettingDtoMapper mapper; + + @Transactional(readOnly = true) + @Override + public List retrieveNotificationSettings() { + return retrieveNotificationSettingPort.findAll().stream() + .map(mapper::toDto) + .toList(); + } +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/application/service/WebhookCommonService.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/service/WebhookCommonService.java new file mode 100644 index 000000000..0cb574aa0 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/service/WebhookCommonService.java @@ -0,0 +1,87 @@ +package page.clab.api.global.common.notificationSetting.application.service; + +import io.ipinfo.api.model.IPResponse; +import io.ipinfo.spring.strategies.attribute.AttributeStrategy; +import jakarta.servlet.http.HttpServletRequest; +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryMXBean; +import java.lang.management.MemoryUsage; +import java.lang.management.OperatingSystemMXBean; +import java.util.Arrays; +import java.util.Optional; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +/** + * {@code WebhookCommonService}는 Webhook 클라이언트에서 공통으로 사용되는 로직을 제공합니다. + */ +@Service +@RequiredArgsConstructor +public class WebhookCommonService { + + private final AttributeStrategy attributeStrategy; + + public String getFullUrl(HttpServletRequest request) { + String requestUrl = request.getRequestURI(); + String queryString = request.getQueryString(); + return queryString == null ? requestUrl : requestUrl + "?" + queryString; + } + + public String extractMessageAfterException(Exception e) { + String errorMessage = Optional.ofNullable(e.getMessage()).orElse("No error message provided"); + String exceptionIndicator = "Exception:"; + int index = errorMessage.indexOf(exceptionIndicator); + return index == -1 ? errorMessage : errorMessage.substring(index + exceptionIndicator.length()).trim(); + } + + public String getStackTraceSummary(Exception e) { + return Arrays.stream(e.getStackTrace()) + .limit(10) + .map(StackTraceElement::toString) + .collect(Collectors.joining("\n")); + } + + public String getOperatingSystemInfo() { + String osName = System.getProperty("os.name"); + String osVersion = System.getProperty("os.version"); + return osName + " " + osVersion; + } + + public String getJavaRuntimeVersion() { + return System.getProperty("java.version"); + } + + public double getCpuUsage() { + OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean(); + int processors = osBean.getAvailableProcessors(); + double systemLoadAverage = osBean.getSystemLoadAverage(); + return (systemLoadAverage / processors) * 100; + } + + public String getMemoryUsage() { + MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean(); + MemoryUsage memoryUsage = memoryMXBean.getHeapMemoryUsage(); + + long used = memoryUsage.getUsed() / (1024 * 1024); + long max = memoryUsage.getMax() / (1024 * 1024); + + return String.format("%dMB / %dMB (%.2f%%)", used, max, ((double) used / max) * 100); + } + + public String getUsername(HttpServletRequest request) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + return Optional.ofNullable(request.getAttribute("member")) + .map(Object::toString) + .orElseGet(() -> Optional.ofNullable(auth) + .map(Authentication::getName) + .orElse("anonymous")); + } + + public String getLocation(HttpServletRequest request) { + IPResponse ipResponse = attributeStrategy.getAttribute(request); + return ipResponse == null ? "Unknown" : ipResponse.getCountryName() + ", " + ipResponse.getCity(); + } +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/config/NotificationConfig.java b/src/main/java/page/clab/api/global/common/notificationSetting/config/NotificationConfig.java new file mode 100644 index 000000000..b398d8377 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/config/NotificationConfig.java @@ -0,0 +1,13 @@ +package page.clab.api.global.common.notificationSetting.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class NotificationConfig { + + @Bean + public NotificationConfigProperties notificationConfigProperties() { + return new NotificationConfigProperties(); + } +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/config/NotificationConfigProperties.java b/src/main/java/page/clab/api/global/common/notificationSetting/config/NotificationConfigProperties.java new file mode 100644 index 000000000..a052ac17e --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/config/NotificationConfigProperties.java @@ -0,0 +1,45 @@ +package page.clab.api.global.common.notificationSetting.config; + +import java.util.List; +import java.util.Map; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "notification") +@Getter +@Setter +public class NotificationConfigProperties { + + private CommonProperties common; + private Map platforms; + private Map> categoryMappings; + private List defaultMappings; + + @Getter + @Setter + public static class CommonProperties { + private String webUrl; + private String apiUrl; + private String color; + + public int getColorAsInt() { + return Integer.parseInt(color.replaceFirst("^#", ""), 16); + } + } + + @Getter + @Setter + public static class PlatformConfig { + private Map webhooks; + } + + @Getter + @Setter + public static class PlatformMapping { + private String platform; + private String webhook; + } +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertCategory.java b/src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertCategory.java new file mode 100644 index 000000000..1bbbb3a72 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertCategory.java @@ -0,0 +1,8 @@ +package page.clab.api.global.common.notificationSetting.domain; + +public enum AlertCategory { + + GENERAL, + SECURITY, + EXECUTIVES +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertType.java b/src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertType.java new file mode 100644 index 000000000..8cb5326ad --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertType.java @@ -0,0 +1,10 @@ +package page.clab.api.global.common.notificationSetting.domain; + +public interface AlertType { + + String getTitle(); + + String getDefaultMessage(); + + AlertCategory getCategory(); +} diff --git a/src/main/java/page/clab/api/global/common/slack/domain/AlertTypeConverter.java b/src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertTypeConverter.java similarity index 88% rename from src/main/java/page/clab/api/global/common/slack/domain/AlertTypeConverter.java rename to src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertTypeConverter.java index 1755b529b..dfa397238 100644 --- a/src/main/java/page/clab/api/global/common/slack/domain/AlertTypeConverter.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertTypeConverter.java @@ -1,11 +1,10 @@ -package page.clab.api.global.common.slack.domain; +package page.clab.api.global.common.notificationSetting.domain; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; -import page.clab.api.global.common.slack.exception.AlertTypeNotFoundException; - import java.util.HashMap; import java.util.Map; +import page.clab.api.global.common.notificationSetting.application.exception.AlertTypeNotFoundException; @Converter(autoApply = true) public class AlertTypeConverter implements AttributeConverter { diff --git a/src/main/java/page/clab/api/global/common/slack/domain/AlertTypeResolver.java b/src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertTypeResolver.java similarity index 77% rename from src/main/java/page/clab/api/global/common/slack/domain/AlertTypeResolver.java rename to src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertTypeResolver.java index f2d580962..7fe3cc2dd 100644 --- a/src/main/java/page/clab/api/global/common/slack/domain/AlertTypeResolver.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertTypeResolver.java @@ -1,7 +1,7 @@ -package page.clab.api.global.common.slack.domain; +package page.clab.api.global.common.notificationSetting.domain; import org.springframework.stereotype.Service; -import page.clab.api.global.common.slack.exception.AlertTypeNotFoundException; +import page.clab.api.global.common.notificationSetting.application.exception.AlertTypeNotFoundException; @Service public class AlertTypeResolver { diff --git a/src/main/java/page/clab/api/global/common/slack/domain/ExecutivesAlertType.java b/src/main/java/page/clab/api/global/common/notificationSetting/domain/ExecutivesAlertType.java similarity index 54% rename from src/main/java/page/clab/api/global/common/slack/domain/ExecutivesAlertType.java rename to src/main/java/page/clab/api/global/common/notificationSetting/domain/ExecutivesAlertType.java index 703b2974f..cba124e18 100644 --- a/src/main/java/page/clab/api/global/common/slack/domain/ExecutivesAlertType.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/domain/ExecutivesAlertType.java @@ -1,4 +1,4 @@ -package page.clab.api.global.common.slack.domain; +package page.clab.api.global.common.notificationSetting.domain; import lombok.AllArgsConstructor; import lombok.Getter; @@ -7,11 +7,12 @@ @AllArgsConstructor public enum ExecutivesAlertType implements AlertType { - NEW_APPLICATION("새 지원서", "New application has been submitted."), - NEW_BOARD("새 게시글", "New board has been posted."), - NEW_MEMBERSHIP_FEE("새 회비 신청", "New membership fee has been submitted."), - NEW_BOOK_LOAN_REQUEST("도서 대출 신청", "New book loan request has been submitted."); + NEW_APPLICATION("새 지원서", "New application has been submitted.", AlertCategory.EXECUTIVES), + NEW_BOARD("새 게시글", "New board has been posted.", AlertCategory.EXECUTIVES), + NEW_MEMBERSHIP_FEE("새 회비 신청", "New membership fee has been submitted.", AlertCategory.EXECUTIVES), + NEW_BOOK_LOAN_REQUEST("도서 대출 신청", "New book loan request has been submitted.", AlertCategory.EXECUTIVES); private final String title; private final String defaultMessage; + private final AlertCategory category; } diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/domain/GeneralAlertType.java b/src/main/java/page/clab/api/global/common/notificationSetting/domain/GeneralAlertType.java new file mode 100644 index 000000000..f8854c56e --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/domain/GeneralAlertType.java @@ -0,0 +1,17 @@ +package page.clab.api.global.common.notificationSetting.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum GeneralAlertType implements AlertType { + + ADMIN_LOGIN("관리자 로그인", "Admin login.", AlertCategory.GENERAL), + SERVER_START("서버 시작", "Server has been started.", AlertCategory.GENERAL), + SERVER_ERROR("서버 에러", "Server error occurred.", AlertCategory.GENERAL); + + private final String title; + private final String defaultMessage; + private final AlertCategory category; +} diff --git a/src/main/java/page/clab/api/global/common/slack/domain/NotificationSetting.java b/src/main/java/page/clab/api/global/common/notificationSetting/domain/NotificationSetting.java similarity index 94% rename from src/main/java/page/clab/api/global/common/slack/domain/NotificationSetting.java rename to src/main/java/page/clab/api/global/common/notificationSetting/domain/NotificationSetting.java index c352008a2..d9bd8f47f 100644 --- a/src/main/java/page/clab/api/global/common/slack/domain/NotificationSetting.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/domain/NotificationSetting.java @@ -1,4 +1,4 @@ -package page.clab.api.global.common.slack.domain; +package page.clab.api.global.common.notificationSetting.domain; import jakarta.persistence.Convert; import jakarta.persistence.Entity; diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/domain/PlatformType.java b/src/main/java/page/clab/api/global/common/notificationSetting/domain/PlatformType.java new file mode 100644 index 000000000..5ed4c6d32 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/domain/PlatformType.java @@ -0,0 +1,14 @@ +package page.clab.api.global.common.notificationSetting.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PlatformType { + + SLACK("slack"), + DISCORD("discord"); + + private final String name; +} diff --git a/src/main/java/page/clab/api/global/common/slack/domain/SecurityAlertType.java b/src/main/java/page/clab/api/global/common/notificationSetting/domain/SecurityAlertType.java similarity index 58% rename from src/main/java/page/clab/api/global/common/slack/domain/SecurityAlertType.java rename to src/main/java/page/clab/api/global/common/notificationSetting/domain/SecurityAlertType.java index 4aa4d1b59..5a6fb8800 100644 --- a/src/main/java/page/clab/api/global/common/slack/domain/SecurityAlertType.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/domain/SecurityAlertType.java @@ -1,4 +1,4 @@ -package page.clab.api.global.common.slack.domain; +package page.clab.api.global.common.notificationSetting.domain; import lombok.AllArgsConstructor; import lombok.Getter; @@ -7,20 +7,21 @@ @AllArgsConstructor public enum SecurityAlertType implements AlertType { - ABNORMAL_ACCESS("비정상적인 접근", "Unexpected access pattern detected."), - REPEATED_LOGIN_FAILURES("지속된 로그인 실패", "Multiple consecutive failed login attempts."), - DUPLICATE_LOGIN("중복 로그인", "Duplicate login attempt."), - API_DOCS_ACCESS("API 문서 접근", "API Documentation access attempt."), - ACTUATOR_ACCESS("Actuator 접근", "Actuator endpoint access attempt."), - UNAUTHORIZED_ACCESS("인가되지 않은 접근", "Unauthorized access attempt."), - BLACKLISTED_IP_ADDED("블랙리스트 IP 등록", "IP address has been added to the blacklist."), - BLACKLISTED_IP_REMOVED("블랙리스트 IP 해제", "IP address has been removed from the blacklist."), - ABNORMAL_ACCESS_IP_BLOCKED("비정상적인 접근 IP 차단", "Abnormal access IP has been blocked."), - ABNORMAL_ACCESS_IP_DELETED("비정상적인 접근 IP 삭제", "Abnormal access IP has been deleted."), - MEMBER_BANNED("멤버 밴 등록", "Member has been banned."), - MEMBER_UNBANNED("멤버 밴 해제", "Member has been unbanned."), - MEMBER_ROLE_CHANGED("멤버 권한 변경", "Member role has been changed."); + ABNORMAL_ACCESS("비정상적인 접근", "Unexpected access pattern detected.", AlertCategory.SECURITY), + REPEATED_LOGIN_FAILURES("지속된 로그인 실패", "Multiple consecutive failed login attempts.", AlertCategory.SECURITY), + DUPLICATE_LOGIN("중복 로그인", "Duplicate login attempt.", AlertCategory.SECURITY), + API_DOCS_ACCESS("API 문서 접근", "API Documentation access attempt.", AlertCategory.SECURITY), + ACTUATOR_ACCESS("Actuator 접근", "Actuator endpoint access attempt.", AlertCategory.SECURITY), + UNAUTHORIZED_ACCESS("인가되지 않은 접근", "Unauthorized access attempt.", AlertCategory.SECURITY), + BLACKLISTED_IP_ADDED("블랙리스트 IP 등록", "IP address has been added to the blacklist.", AlertCategory.SECURITY), + BLACKLISTED_IP_REMOVED("블랙리스트 IP 해제", "IP address has been removed from the blacklist.", AlertCategory.SECURITY), + ABNORMAL_ACCESS_IP_BLOCKED("비정상적인 접근 IP 차단", "Abnormal access IP has been blocked.", AlertCategory.SECURITY), + ABNORMAL_ACCESS_IP_DELETED("비정상적인 접근 IP 삭제", "Abnormal access IP has been deleted.", AlertCategory.SECURITY), + MEMBER_BANNED("멤버 밴 등록", "Member has been banned.", AlertCategory.SECURITY), + MEMBER_UNBANNED("멤버 밴 해제", "Member has been unbanned.", AlertCategory.SECURITY), + MEMBER_ROLE_CHANGED("멤버 권한 변경", "Member role has been changed.", AlertCategory.SECURITY); private final String title; private final String defaultMessage; + private final AlertCategory category; } diff --git a/src/main/java/page/clab/api/global/common/slack/api/NotificationSettingController.java b/src/main/java/page/clab/api/global/common/slack/api/NotificationSettingController.java deleted file mode 100644 index f242289d1..000000000 --- a/src/main/java/page/clab/api/global/common/slack/api/NotificationSettingController.java +++ /dev/null @@ -1,45 +0,0 @@ -package page.clab.api.global.common.slack.api; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import page.clab.api.global.common.dto.ApiResponse; -import page.clab.api.global.common.slack.application.NotificationSettingService; -import page.clab.api.global.common.slack.dto.request.NotificationSettingUpdateRequestDto; -import page.clab.api.global.common.slack.dto.response.NotificationSettingResponseDto; - -import java.util.List; - -@RestController -@RequestMapping("/api/v1/notification-settings") -@RequiredArgsConstructor -@Tag(name = "Notification Setting", description = "알림 설정") -public class NotificationSettingController { - - private final NotificationSettingService notificationSettingService; - - @Operation(summary = "[S] 슬랙 알림 조회", description = "ROLE_SUPER 이상의 권한이 필요함") - @PreAuthorize("hasRole('SUPER')") - @GetMapping("") - public ApiResponse> getNotificationSettings() { - List notificationSettings = notificationSettingService.getNotificationSettings(); - return ApiResponse.success(notificationSettings); - } - - @Operation(summary = "[S] 슬랙 알림 설정 변경", description = "ROLE_SUPER 이상의 권한이 필요함") - @PreAuthorize("hasRole('SUPER')") - @PutMapping("") - public ApiResponse updateNotificationSetting( - @Valid @RequestBody NotificationSettingUpdateRequestDto requestDto - ) { - notificationSettingService.updateNotificationSetting(requestDto.getAlertType(), requestDto.isEnabled()); - return ApiResponse.success(); - } -} diff --git a/src/main/java/page/clab/api/global/common/slack/application/NotificationSettingService.java b/src/main/java/page/clab/api/global/common/slack/application/NotificationSettingService.java deleted file mode 100644 index 4ce06b79b..000000000 --- a/src/main/java/page/clab/api/global/common/slack/application/NotificationSettingService.java +++ /dev/null @@ -1,61 +0,0 @@ -package page.clab.api.global.common.slack.application; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import page.clab.api.global.common.slack.dao.NotificationSettingRepository; -import page.clab.api.global.common.slack.domain.AlertType; -import page.clab.api.global.common.slack.domain.AlertTypeResolver; -import page.clab.api.global.common.slack.domain.NotificationSetting; -import page.clab.api.global.common.slack.dto.mapper.SlackDtoMapper; -import page.clab.api.global.common.slack.dto.response.NotificationSettingResponseDto; - -import java.util.List; - -/** - * {@code NotificationSettingService}는 알림 설정을 조회 및 업데이트하는 서비스입니다. - * - *

    이 서비스는 알림 유형에 따라 활성화 또는 비활성화할 수 있는 설정 기능을 제공하며, - * 기본 알림 설정을 생성하거나 조회할 수 있습니다.

    - * - * 주요 기능: - *
      - *
    • {@link #getNotificationSettings()} - 모든 알림 설정을 조회합니다.
    • - *
    • {@link #updateNotificationSetting(String, boolean)} - 주어진 알림 유형에 대해 알림 설정을 업데이트합니다.
    • - *
    • {@link #getOrCreateDefaultSetting(AlertType)} - 주어진 알림 유형에 대한 기본 알림 설정을 조회하거나, 존재하지 않으면 생성합니다.
    • - *
    - */ -@Service -@RequiredArgsConstructor -public class NotificationSettingService { - - private final AlertTypeResolver alertTypeResolver; - private final NotificationSettingRepository settingRepository; - private final SlackDtoMapper mapper; - - @Transactional(readOnly = true) - public List getNotificationSettings() { - return settingRepository.findAll().stream() - .map(mapper::toDto) - .toList(); - } - - @Transactional - public void updateNotificationSetting(String alertTypeName, boolean enabled) { - AlertType alertType = alertTypeResolver.resolve(alertTypeName); - NotificationSetting setting = getOrCreateDefaultSetting(alertType); - setting.updateEnabled(enabled); - settingRepository.save(setting); - } - - @Transactional - public NotificationSetting getOrCreateDefaultSetting(AlertType alertType) { - return settingRepository.findByAlertType(alertType) - .orElseGet(() -> createAndSaveDefaultSetting(alertType)); - } - - private NotificationSetting createAndSaveDefaultSetting(AlertType alertType) { - NotificationSetting defaultSetting = NotificationSetting.createDefault(alertType); - return settingRepository.save(defaultSetting); - } -} diff --git a/src/main/java/page/clab/api/global/common/slack/application/SlackService.java b/src/main/java/page/clab/api/global/common/slack/application/SlackService.java deleted file mode 100644 index dca15b3a9..000000000 --- a/src/main/java/page/clab/api/global/common/slack/application/SlackService.java +++ /dev/null @@ -1,82 +0,0 @@ -package page.clab.api.global.common.slack.application; - -import jakarta.servlet.http.HttpServletRequest; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.event.ContextRefreshedEvent; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; -import page.clab.api.domain.hiring.application.application.dto.request.ApplicationRequestDto; -import page.clab.api.domain.memberManagement.member.application.dto.shared.MemberLoginInfoDto; -import page.clab.api.global.common.slack.domain.ExecutivesAlertType; -import page.clab.api.global.common.slack.domain.GeneralAlertType; -import page.clab.api.global.common.slack.domain.SecurityAlertType; -import page.clab.api.global.common.slack.domain.SlackBoardInfo; -import page.clab.api.global.common.slack.domain.SlackBookLoanRecordInfo; -import page.clab.api.global.common.slack.domain.SlackMembershipFeeInfo; -import page.clab.api.global.common.slack.event.NotificationEvent; -import page.clab.api.global.config.SlackConfig; - -/** - * {@code SlackService}는 다양한 알림 유형에 따라 Slack 알림을 전송하는 서비스입니다. - * - *

    이 서비스는 `NotificationEvent`를 통해 Slack 알림을 발송하며, - * 서버 시작, 서버 오류, 보안 경고, 관리자 로그인 등의 알림 유형을 제공합니다.

    - * - * 주요 기능: - *
      - *
    • {@link #sendServerErrorNotification(HttpServletRequest, Exception)} - 서버 오류 발생 시 Slack으로 알림을 전송합니다.
    • - *
    • {@link #sendSecurityAlertNotification(HttpServletRequest, SecurityAlertType, String)} - 보안 경고 발생 시 알림을 전송합니다.
    • - *
    • {@link #sendAdminLoginNotification(HttpServletRequest, MemberLoginInfoDto)} - 관리자 로그인 시 Slack으로 알림을 전송합니다.
    • - *
    • {@link #sendNewApplicationNotification(ApplicationRequestDto)} - 신규 지원 정보가 있을 때 Slack으로 알림을 전송합니다.
    • - *
    • {@link #sendNewBoardNotification(SlackBoardInfo)} - 새 게시글이 등록되었을 때 알림을 전송합니다.
    • - *
    • {@link #sendNewMembershipFeeNotification(SlackMembershipFeeInfo)} - 신규 회비 신청 시 알림을 전송합니다.
    • - *
    • {@link #sendNewBookLoanRequestNotification(SlackBookLoanRecordInfo)} - 도서 대여 신청이 있을 때 알림을 전송합니다.
    • - *
    • {@link #sendServerStartNotification()} - 서버 시작 시 알림을 전송합니다.
    • - *
    - */ -@Service -public class SlackService { - - private final ApplicationEventPublisher eventPublisher; - private final String coreTeamWebhookUrl; - private final String executivesWebhookUrl; - - public SlackService(ApplicationEventPublisher eventPublisher, SlackConfig slackConfig) { - this.eventPublisher = eventPublisher; - this.coreTeamWebhookUrl = slackConfig.getCoreTeamWebhookUrl(); - this.executivesWebhookUrl = slackConfig.getExecutivesWebhookUrl(); - } - - public void sendServerErrorNotification(HttpServletRequest request, Exception e) { - eventPublisher.publishEvent(new NotificationEvent(this, coreTeamWebhookUrl, GeneralAlertType.SERVER_ERROR, request, e)); - } - - public void sendSecurityAlertNotification(HttpServletRequest request, SecurityAlertType alertType, String additionalMessage) { - eventPublisher.publishEvent(new NotificationEvent(this, coreTeamWebhookUrl, alertType, request, additionalMessage)); - } - - public void sendAdminLoginNotification(HttpServletRequest request, MemberLoginInfoDto loginMember) { - eventPublisher.publishEvent(new NotificationEvent(this, coreTeamWebhookUrl, GeneralAlertType.ADMIN_LOGIN, request, loginMember)); - } - - public void sendNewApplicationNotification(ApplicationRequestDto applicationRequestDto) { - eventPublisher.publishEvent(new NotificationEvent(this, executivesWebhookUrl, ExecutivesAlertType.NEW_APPLICATION, null, applicationRequestDto)); - } - - public void sendNewBoardNotification(SlackBoardInfo board) { - eventPublisher.publishEvent(new NotificationEvent(this, executivesWebhookUrl, ExecutivesAlertType.NEW_BOARD, null, board)); - } - - public void sendNewMembershipFeeNotification(SlackMembershipFeeInfo membershipFee) { - eventPublisher.publishEvent(new NotificationEvent(this, executivesWebhookUrl, ExecutivesAlertType.NEW_MEMBERSHIP_FEE, null, membershipFee)); - } - - public void sendNewBookLoanRequestNotification(SlackBookLoanRecordInfo bookLoanRecord) { - eventPublisher.publishEvent(new NotificationEvent(this, executivesWebhookUrl, ExecutivesAlertType.NEW_BOOK_LOAN_REQUEST, null, bookLoanRecord)); - } - - @EventListener(ContextRefreshedEvent.class) - public void sendServerStartNotification() { - eventPublisher.publishEvent(new NotificationEvent(this, coreTeamWebhookUrl, GeneralAlertType.SERVER_START, null, null)); - } -} diff --git a/src/main/java/page/clab/api/global/common/slack/application/SlackServiceHelper.java b/src/main/java/page/clab/api/global/common/slack/application/SlackServiceHelper.java deleted file mode 100644 index a3b2c3edf..000000000 --- a/src/main/java/page/clab/api/global/common/slack/application/SlackServiceHelper.java +++ /dev/null @@ -1,368 +0,0 @@ -package page.clab.api.global.common.slack.application; - -import com.slack.api.Slack; -import com.slack.api.model.Attachment; -import static com.slack.api.model.block.Blocks.actions; -import static com.slack.api.model.block.Blocks.section; -import com.slack.api.model.block.LayoutBlock; -import static com.slack.api.model.block.composition.BlockCompositions.markdownText; -import static com.slack.api.model.block.composition.BlockCompositions.plainText; -import static com.slack.api.model.block.element.BlockElements.asElements; -import static com.slack.api.model.block.element.BlockElements.button; -import com.slack.api.webhook.Payload; -import com.slack.api.webhook.WebhookResponse; -import io.ipinfo.api.model.IPResponse; -import io.ipinfo.spring.strategies.attribute.AttributeStrategy; -import jakarta.servlet.http.HttpServletRequest; -import lombok.extern.slf4j.Slf4j; -import org.jetbrains.annotations.NotNull; -import org.springframework.core.env.Environment; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import page.clab.api.domain.hiring.application.application.dto.request.ApplicationRequestDto; -import page.clab.api.domain.memberManagement.member.application.dto.shared.MemberLoginInfoDto; -import page.clab.api.global.common.slack.domain.AlertType; -import page.clab.api.global.common.slack.domain.ExecutivesAlertType; -import page.clab.api.global.common.slack.domain.GeneralAlertType; -import page.clab.api.global.common.slack.domain.SecurityAlertType; -import page.clab.api.global.common.slack.domain.SlackBoardInfo; -import page.clab.api.global.common.slack.domain.SlackBookLoanRecordInfo; -import page.clab.api.global.common.slack.domain.SlackMembershipFeeInfo; -import page.clab.api.global.config.SlackConfig; -import page.clab.api.global.util.HttpReqResUtil; - -import java.io.IOException; -import java.lang.management.ManagementFactory; -import java.lang.management.MemoryMXBean; -import java.lang.management.MemoryUsage; -import java.lang.management.OperatingSystemMXBean; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.stream.Collectors; - -/** - * {@code SlackServiceHelper}는 다양한 알림 유형에 따라 Slack 메시지를 구성하고 전송하는 클래스입니다. - * - *

    주요 기능:

    - *
      - *
    • {@link #sendSlackMessage(String, AlertType, HttpServletRequest, Object)}: Slack에 알림 메시지를 비동기적으로 전송
    • - *
    • {@link #createBlocks(AlertType, HttpServletRequest, Object)}: 알림 유형에 따라 Slack 메시지 블록 생성
    • - *
    • 다양한 알림 유형에 맞는 메시지 형식을 생성하는 전용 메서드
    • - *
    - * - *

    Slack API와 통합하여 웹훅 URL을 통해 메시지를 전송하며, 메시지 전송 실패 시 로그에 오류를 기록합니다.

    - * - *

    AlertType을 기반으로 여러 도메인에서 발생하는 이벤트를 Slack을 통해 모니터링할 수 있도록 지원하며, - * Slack 알림은 주로 서버 이벤트, 보안 경고, 신규 신청, 관리자 로그인 등의 이벤트를 다룹니다.

    - * - * @see Slack - * @see Payload - * @see LayoutBlock - */ -@Component -@Slf4j -public class SlackServiceHelper { - - private final Slack slack; - private final String webUrl; - private final String apiUrl; - private final String color; - private final Environment environment; - private final AttributeStrategy attributeStrategy; - - public SlackServiceHelper(SlackConfig slackConfig, Environment environment, AttributeStrategy attributeStrategy) { - this.slack = slackConfig.slack(); - this.webUrl = slackConfig.getWebUrl(); - this.apiUrl = slackConfig.getApiUrl(); - this.color = slackConfig.getColor(); - this.environment = environment; - this.attributeStrategy = attributeStrategy; - } - - /** - * Slack에 알림 메시지를 전송합니다. - * - *

    주어진 webhookUrl과 alertType, HttpServletRequest 및 추가 데이터(additionalData)를 사용하여 알림 메시지를 - * 비동기적으로 Slack에 전송합니다.

    - * - * @param webhookUrl 메시지를 보낼 Slack 웹훅 URL - * @param alertType 알림 유형을 나타내는 {@link AlertType} - * @param request HttpServletRequest 객체, 클라이언트 요청 정보 - * @param additionalData 추가 데이터 - * @return 메시지 전송 성공 여부를 나타내는 CompletableFuture - */ - public CompletableFuture sendSlackMessage(String webhookUrl, AlertType alertType, HttpServletRequest request, Object additionalData) { - List blocks = createBlocks(alertType, request, additionalData); - return CompletableFuture.supplyAsync(() -> { - Payload payload = Payload.builder() - .blocks(List.of(blocks.getFirst())) - .attachments(Collections.singletonList( - Attachment.builder() - .color(color) - .blocks(blocks.subList(1, blocks.size())) - .build() - )).build(); - try { - WebhookResponse response = slack.send(webhookUrl, payload); - if (response.getCode() == 200) { - return true; - } else { - log.error("Slack notification failed: {}", response.getMessage()); - return false; - } - } catch (IOException e) { - log.error("Failed to send Slack message: {}", e.getMessage(), e); - return false; - } - }); - } - - /** - * 특정 알림 유형과 요청 정보 및 추가 데이터를 사용하여 Slack 메시지의 블록을 생성합니다. - * - *

    AlertType에 따라 보안 경고, 일반 알림, 운영진 알림 등 다양한 형식의 메시지를 생성합니다.

    - * - * @param alertType 알림 유형 - * @param request HttpServletRequest 객체 - * @param additionalData 추가 데이터 - * @return 생성된 LayoutBlock 목록 - */ - public List createBlocks(AlertType alertType, HttpServletRequest request, Object additionalData) { - if (alertType instanceof SecurityAlertType) { - return createSecurityAlertBlocks(request, alertType, additionalData.toString()); - } else if (alertType instanceof GeneralAlertType) { - switch ((GeneralAlertType) alertType) { - case ADMIN_LOGIN: - if (additionalData instanceof MemberLoginInfoDto) { - return createAdminLoginBlocks(request, (MemberLoginInfoDto) additionalData); - } - break; - case SERVER_START: - return createServerStartBlocks(); - case SERVER_ERROR: - if (additionalData instanceof Exception) { - return createErrorBlocks(request, (Exception) additionalData); - } - break; - default: - log.error("Unknown alert type: {}", alertType); - return List.of(); - } - } else if (alertType instanceof ExecutivesAlertType) { - switch ((ExecutivesAlertType) alertType) { - case NEW_APPLICATION: - if (additionalData instanceof ApplicationRequestDto) { - return createApplicationBlocks((ApplicationRequestDto) additionalData); - } - break; - case NEW_BOARD: - if (additionalData instanceof SlackBoardInfo) { - return createBoardBlocks((SlackBoardInfo) additionalData); - } - break; - case NEW_MEMBERSHIP_FEE: - if (additionalData instanceof SlackMembershipFeeInfo) { - return createMembershipFeeBlocks((SlackMembershipFeeInfo) additionalData); - } - break; - case NEW_BOOK_LOAN_REQUEST: - if (additionalData instanceof SlackBookLoanRecordInfo) { - return createBookLoanRecordBlocks((SlackBookLoanRecordInfo) additionalData); - } - break; - default: - log.error("Unknown alert type: {}", alertType); - return List.of(); - } - } - return List.of(); - } - - private List createErrorBlocks(HttpServletRequest request, Exception e) { - String httpMethod = request.getMethod(); - String requestUrl = request.getRequestURI(); - String queryString = request.getQueryString(); - String fullUrl = queryString == null ? requestUrl : requestUrl + "?" + queryString; - String username = getUsername(request); - - String errorMessage = e.getMessage() == null ? "No error message provided" : e.getMessage(); - String detailedMessage = extractMessageAfterException(errorMessage); - log.error("Server Error: {}", detailedMessage); - return Arrays.asList( - section(section -> section.text(markdownText(":firecracker: *Server Error*"))), - section(section -> section.fields(Arrays.asList( - markdownText("*User:*\n" + username), - markdownText("*Endpoint:*\n[" + httpMethod + "] " + fullUrl) - ))), - section(section -> section.text(markdownText("*Error Message:*\n" + detailedMessage))), - section(section -> section.text(markdownText("*Stack Trace:*\n```" + getStackTraceSummary(e) + "```"))) - ); - } - - private List createSecurityAlertBlocks(HttpServletRequest request, AlertType alertType, String additionalMessage) { - String clientIpAddress = HttpReqResUtil.getClientIpAddressIfServletRequestExist(); - String requestUrl = request.getRequestURI(); - String queryString = request.getQueryString(); - String fullUrl = queryString == null ? requestUrl : requestUrl + "?" + queryString; - String username = getUsername(request); - String location = getLocation(request); - - return Arrays.asList( - section(section -> section.text(markdownText(String.format(":imp: *%s*", alertType.getTitle())))), - section(section -> section.fields(Arrays.asList( - markdownText("*User:*\n" + username), - markdownText("*IP Address:*\n" + clientIpAddress), - markdownText("*Location:*\n" + location), - markdownText("*Endpoint:*\n" + fullUrl) - ))), - section(section -> section.text(markdownText("*Details:*\n" + alertType.getDefaultMessage() + "\n" + additionalMessage))) - ); - } - - private List createAdminLoginBlocks(HttpServletRequest request, MemberLoginInfoDto loginMember) { - String clientIpAddress = HttpReqResUtil.getClientIpAddressIfServletRequestExist(); - String location = getLocation(request); - - return Arrays.asList( - section(section -> section.text(markdownText(String.format(":mechanic: *%s Login*", loginMember.getRole().getDescription())))), - section(section -> section.fields(Arrays.asList( - markdownText("*User:*\n" + loginMember.getMemberId() + " " + loginMember.getMemberName()), - markdownText("*IP Address:*\n" + clientIpAddress), - markdownText("*Location:*\n" + location) - ))) - ); - } - - private List createApplicationBlocks(ApplicationRequestDto requestDto) { - List blocks = new ArrayList<>(); - - blocks.add(section(section -> section.text(markdownText(":sparkles: *동아리 지원*")))); - blocks.add(section(section -> section.fields(Arrays.asList( - markdownText("*구분:*\n" + requestDto.getApplicationType().getDescription()), - markdownText("*학번:*\n" + requestDto.getStudentId()), - markdownText("*이름:*\n" + requestDto.getName()), - markdownText("*학년:*\n" + requestDto.getGrade() + "학년"), - markdownText("*관심 분야:*\n" + requestDto.getInterests()) - )))); - - if (requestDto.getGithubUrl() != null && !requestDto.getGithubUrl().isEmpty()) { - blocks.add(actions(actions -> actions.elements(asElements( - button(b -> b.text(plainText(pt -> pt.emoji(true).text("Github"))) - .url(requestDto.getGithubUrl()) - .actionId("click_github")) - )))); - } - return blocks; - } - - private List createBoardBlocks(SlackBoardInfo board) { - List blocks = new ArrayList<>(); - - blocks.add(section(section -> section.text(markdownText(":writing_hand: *새 게시글*")))); - blocks.add(section(section -> section.fields(Arrays.asList( - markdownText("*제목:*\n" + board.getTitle()), - markdownText("*분류:*\n" + board.getCategory()), - markdownText("*작성자:*\n" + board.getUsername()) - )))); - return blocks; - } - - private List createMembershipFeeBlocks(SlackMembershipFeeInfo additionalData) { - String username = additionalData.getMemberId() + " " + additionalData.getMemberName(); - - return Arrays.asList( - section(section -> section.text(markdownText(":dollar: *회비 신청*"))), - section(section -> section.fields(Arrays.asList( - markdownText("*신청자:*\n" + username), - markdownText("*분류:*\n" + additionalData.getCategory()), - markdownText("*금액:*\n" + additionalData.getAmount() + "원") - ))), - section(section -> section.text(markdownText("*Content:*\n" + additionalData.getContent()))) - ); - } - - private List createBookLoanRecordBlocks(SlackBookLoanRecordInfo additionalData) { - String username = additionalData.getMemberId() + " " + additionalData.getMemberName(); - - return Arrays.asList( - section(section -> section.text(markdownText(":books: *도서 대여 신청*"))), - section(section -> section.fields(Arrays.asList( - markdownText("*도서명:*\n" + additionalData.getBookTitle()), - markdownText("*분류:*\n" + additionalData.getCategory()), - markdownText("*신청자:*\n" + username), - markdownText("*상태:*\n" + (additionalData.isAvailable() ? "대여 가능" : "대여 중")) - ))) - ); - } - - private List createServerStartBlocks() { - String osInfo = System.getProperty("os.name") + " " + System.getProperty("os.version"); - String jdkVersion = System.getProperty("java.version"); - - OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean(); - int availableProcessors = osBean.getAvailableProcessors(); - double systemLoadAverage = osBean.getSystemLoadAverage(); - double cpuUsage = ((systemLoadAverage / availableProcessors) * 100); - - MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean(); - MemoryUsage heapMemoryUsage = memoryMXBean.getHeapMemoryUsage(); - String memoryInfo = formatMemoryUsage(heapMemoryUsage); - - return Arrays.asList( - section(section -> section.text(markdownText("*:battery: Server Started*"))), - section(section -> section.fields(Arrays.asList( - markdownText("*Environment:* \n" + environment.getProperty("spring.profiles.active")), - markdownText("*OS:* \n" + osInfo), - markdownText("*JDK Version:* \n" + jdkVersion), - markdownText("*CPU Usage:* \n" + String.format("%.2f%%", cpuUsage)), - markdownText("*Memory Usage:* \n" + memoryInfo) - ))), - actions(actions -> actions.elements(asElements( - button(b -> b.text(plainText(pt -> pt.emoji(true).text("Web"))) - .url(webUrl) - .value("click_web")), - button(b -> b.text(plainText(pt -> pt.emoji(true).text("Swagger"))) - .url(apiUrl) - .value("click_swagger")) - ))) - ); - } - - private String extractMessageAfterException(String message) { - String exceptionIndicator = "Exception:"; - int exceptionIndex = message.indexOf(exceptionIndicator); - return exceptionIndex == -1 ? message : message.substring(exceptionIndex + exceptionIndicator.length()).trim(); - } - - private String getStackTraceSummary(Exception e) { - return Arrays.stream(e.getStackTrace()) - .limit(10) - .map(StackTraceElement::toString) - .collect(Collectors.joining("\n")); - } - - private String formatMemoryUsage(MemoryUsage memoryUsage) { - long usedMemory = memoryUsage.getUsed() / (1024 * 1024); - long maxMemory = memoryUsage.getMax() / (1024 * 1024); - return String.format("%dMB / %dMB (%.2f%%)", usedMemory, maxMemory, ((double) usedMemory / maxMemory) * 100); - } - - private @NotNull String getUsername(HttpServletRequest request) { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - return Optional.ofNullable(request.getAttribute("member")) - .map(Object::toString) - .orElseGet(() -> Optional.ofNullable(authentication) - .map(Authentication::getName) - .orElse("anonymous")); - } - - private @NotNull String getLocation(HttpServletRequest request) { - IPResponse ipResponse = attributeStrategy.getAttribute(request); - return ipResponse == null ? "Unknown" : ipResponse.getCountryName() + ", " + ipResponse.getCity(); - } -} diff --git a/src/main/java/page/clab/api/global/common/slack/domain/AlertType.java b/src/main/java/page/clab/api/global/common/slack/domain/AlertType.java deleted file mode 100644 index 99c52fd8f..000000000 --- a/src/main/java/page/clab/api/global/common/slack/domain/AlertType.java +++ /dev/null @@ -1,8 +0,0 @@ -package page.clab.api.global.common.slack.domain; - -public interface AlertType { - - String getTitle(); - - String getDefaultMessage(); -} diff --git a/src/main/java/page/clab/api/global/common/slack/domain/GeneralAlertType.java b/src/main/java/page/clab/api/global/common/slack/domain/GeneralAlertType.java deleted file mode 100644 index 4b564bde7..000000000 --- a/src/main/java/page/clab/api/global/common/slack/domain/GeneralAlertType.java +++ /dev/null @@ -1,16 +0,0 @@ -package page.clab.api.global.common.slack.domain; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public enum GeneralAlertType implements AlertType { - - ADMIN_LOGIN("관리자 로그인", "Admin login."), - SERVER_START("서버 시작", "Server has been started."), - SERVER_ERROR("서버 에러", "Server error occurred."); - - private final String title; - private final String defaultMessage; -} diff --git a/src/main/java/page/clab/api/global/common/slack/listener/NotificationListener.java b/src/main/java/page/clab/api/global/common/slack/listener/NotificationListener.java deleted file mode 100644 index e8463a03a..000000000 --- a/src/main/java/page/clab/api/global/common/slack/listener/NotificationListener.java +++ /dev/null @@ -1,28 +0,0 @@ -package page.clab.api.global.common.slack.listener; - -import lombok.RequiredArgsConstructor; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Component; -import page.clab.api.global.common.slack.application.NotificationSettingService; -import page.clab.api.global.common.slack.application.SlackServiceHelper; -import page.clab.api.global.common.slack.domain.AlertType; -import page.clab.api.global.common.slack.domain.NotificationSetting; -import page.clab.api.global.common.slack.event.NotificationEvent; - -@Component -@RequiredArgsConstructor -public class NotificationListener { - - private final NotificationSettingService settingService; - private final SlackServiceHelper slackServiceHelper; - - @EventListener - public void handleNotificationEvent(NotificationEvent event) { - AlertType alertType = event.getAlertType(); - NotificationSetting setting = settingService.getOrCreateDefaultSetting(alertType); - - if (setting.isEnabled()) { - slackServiceHelper.sendSlackMessage(event.getWebhookUrl(), alertType, event.getRequest(), event.getAdditionalData()); - } - } -} diff --git a/src/main/java/page/clab/api/global/config/SecurityConfig.java b/src/main/java/page/clab/api/global/config/SecurityConfig.java index 0002f24be..d31e1ce21 100644 --- a/src/main/java/page/clab/api/global/config/SecurityConfig.java +++ b/src/main/java/page/clab/api/global/config/SecurityConfig.java @@ -2,10 +2,12 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; @@ -34,14 +36,11 @@ import page.clab.api.global.auth.jwt.JwtTokenProvider; import page.clab.api.global.auth.util.IpWhitelistValidator; import page.clab.api.global.common.file.application.FileService; -import page.clab.api.global.common.slack.application.SlackService; import page.clab.api.global.filter.IPinfoSpringFilter; import page.clab.api.global.util.ApiLogger; import page.clab.api.global.util.HttpReqResUtil; import page.clab.api.global.util.ResponseUtil; -import java.io.IOException; - @Configuration @EnableWebSecurity @EnableMethodSecurity @@ -54,7 +53,7 @@ public class SecurityConfig { private final ExternalCheckIpBlockedUseCase externalCheckIpBlockedUseCase; private final ExternalRegisterBlacklistIpUseCase externalRegisterBlacklistIpUseCase; private final ExternalRetrieveBlacklistIpUseCase externalRetrieveBlacklistIpUseCase; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; private final IpWhitelistValidator ipWhitelistValidator; private final WhitelistAccountProperties whitelistAccountProperties; private final WhitelistPatternsProperties whitelistPatternsProperties; @@ -92,15 +91,18 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { UsernamePasswordAuthenticationFilter.class ) .addFilterBefore( - new InvalidEndpointAccessFilter(slackService, fileURL, externalRegisterBlacklistIpUseCase, externalRetrieveBlacklistIpUseCase), + new InvalidEndpointAccessFilter(fileURL, externalRegisterBlacklistIpUseCase, + externalRetrieveBlacklistIpUseCase, eventPublisher), UsernamePasswordAuthenticationFilter.class ) .addFilterBefore( - new CustomBasicAuthenticationFilter(authenticationManager, ipWhitelistValidator, slackService, externalCheckIpBlockedUseCase, externalRetrieveBlacklistIpUseCase), + new CustomBasicAuthenticationFilter(authenticationManager, ipWhitelistValidator, + externalCheckIpBlockedUseCase, externalRetrieveBlacklistIpUseCase, eventPublisher), UsernamePasswordAuthenticationFilter.class ) .addFilterBefore( - new JwtAuthenticationFilter(slackService, jwtTokenProvider, externalManageRedisTokenUseCase, externalCheckIpBlockedUseCase, externalRetrieveBlacklistIpUseCase), + new JwtAuthenticationFilter(jwtTokenProvider, eventPublisher, externalManageRedisTokenUseCase, + externalCheckIpBlockedUseCase, externalRetrieveBlacklistIpUseCase), UsernamePasswordAuthenticationFilter.class ) // .addFilterBefore( @@ -120,11 +122,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(SecurityConstants.PERMIT_ALL).permitAll() .requestMatchers(HttpMethod.GET, SecurityConstants.PERMIT_ALL_API_ENDPOINTS_GET).permitAll() .requestMatchers(HttpMethod.POST, SecurityConstants.PERMIT_ALL_API_ENDPOINTS_POST).permitAll() - .requestMatchers(whitelistPatternsProperties.getWhitelistPatterns()).hasRole(whitelistAccountProperties.getRole()) + .requestMatchers(whitelistPatternsProperties.getWhitelistPatterns()) + .hasRole(whitelistAccountProperties.getRole()) .anyRequest().authenticated(); } - private void handleException(HttpServletRequest request, HttpServletResponse response, Exception exception) throws IOException { + private void handleException(HttpServletRequest request, HttpServletResponse response, Exception exception) + throws IOException { String clientIpAddress = HttpReqResUtil.getClientIpAddressIfServletRequestExist(); String message; int statusCode; diff --git a/src/main/java/page/clab/api/global/config/SlackConfig.java b/src/main/java/page/clab/api/global/config/SlackConfig.java deleted file mode 100644 index 31f1f6d63..000000000 --- a/src/main/java/page/clab/api/global/config/SlackConfig.java +++ /dev/null @@ -1,26 +0,0 @@ -package page.clab.api.global.config; - -import com.slack.api.Slack; -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Setter -@Getter -@Configuration -@ConfigurationProperties(prefix = "slack") -public class SlackConfig { - - private String coreTeamWebhookUrl; - private String executivesWebhookUrl; - private String webUrl; - private String apiUrl; - private String color; - - @Bean - public Slack slack() { - return Slack.getInstance(); - } -} diff --git a/src/main/java/page/clab/api/global/handler/GlobalExceptionHandler.java b/src/main/java/page/clab/api/global/handler/GlobalExceptionHandler.java index ac27a3774..ac269d054 100644 --- a/src/main/java/page/clab/api/global/handler/GlobalExceptionHandler.java +++ b/src/main/java/page/clab/api/global/handler/GlobalExceptionHandler.java @@ -7,9 +7,14 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.ConstraintViolationException; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.NoSuchElementException; +import java.util.concurrent.CompletionException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.hibernate.query.sqm.UnknownPathException; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.dao.InvalidDataAccessApiUsageException; @@ -75,7 +80,8 @@ import page.clab.api.global.common.file.exception.FileUploadFailException; import page.clab.api.global.common.file.exception.InvalidFileAttributeException; import page.clab.api.global.common.file.exception.InvalidPathVariableException; -import page.clab.api.global.common.slack.application.SlackService; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.GeneralAlertType; import page.clab.api.global.exception.CustomOptimisticLockingFailureException; import page.clab.api.global.exception.DecryptionException; import page.clab.api.global.exception.EncryptionException; @@ -87,17 +93,12 @@ import page.clab.api.global.exception.PermissionDeniedException; import page.clab.api.global.exception.SortingArgumentException; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.util.NoSuchElementException; -import java.util.concurrent.CompletionException; - @RestControllerAdvice(basePackages = "page.clab.api") @RequiredArgsConstructor @Slf4j public class GlobalExceptionHandler { - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; @ExceptionHandler({ InvalidInformationException.class, @@ -231,7 +232,8 @@ public ErrorResponse conflictException(HttpServletResponse response, Exception.class }) public ApiResponse serverException(HttpServletRequest request, HttpServletResponse response, Exception e) { - slackService.sendServerErrorNotification(request, e); + eventPublisher.publishEvent( + new NotificationEvent(this, GeneralAlertType.SERVER_ERROR, request, e)); log.warn(e.getMessage()); response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); return ApiResponse.failure(); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5ae527332..68c4e082c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -195,13 +195,49 @@ resource: ipinfo: access-token: ${IPINFO_ACCESS_TOKEN} # Register at https://ipinfo.io/ -# Slack webhook configuration -slack: - core-team-webhook-url: ${SLACK_WEBHOOK_URL} # Create a Slack channel and get a webhook URL - executives-webhook-url: ${SLACK_WEBHOOK_URL} # Create a Slack channel and get a webhook URL - web-url: ${WEB_URL} # Your web URL - api-url: ${API_URL} # Your API docs URL - color: "#FF968A" # Slack message color +# Messaging configuration +notification: + common: + web-url: "${WEB_URL}" # Your web URL + api-url: "${API_URL}" # Your API documentation URL + color: "#FF968A" # Message color used in notifications + platforms: + slack: + webhooks: + # Replace the placeholders with your actual Slack webhook URLs + core-team: "${SLACK_CORE_TEAM_WEBHOOK_URL}" # Slack webhook URL for core team notifications + executives: "${SLACK_EXECUTIVES_WEBHOOK_URL}" # Slack webhook URL for executive team notifications + discord: + webhooks: + # Replace the placeholders with your actual Discord webhook URLs + release: "${DISCORD_RELEASE_WEBHOOK_URL}" # Discord webhook URL for release notifications + notifications: "${DISCORD_NOTIFICATIONS_WEBHOOK_URL}" # Discord webhook URL for general notifications + executives: "${DISCORD_EXECUTIVES_WEBHOOK_URL}" # Discord webhook URL for executive team notifications + # The category-mappings section defines how notifications are routed based on their category. + # The category names should match those specified in "page.clab.api.global.common.notificationSetting.domain.AlertCategory". + # By specifying multiple platforms and webhooks under each category, you can configure messages to be sent to multiple platforms and multiple webhooks simultaneously. + category-mappings: + GENERAL: + - platform: slack + webhook: core-team + - platform: discord + webhook: notifications + SECURITY: + - platform: slack + webhook: core-team + - platform: discord + webhook: notifications + EXECUTIVES: + - platform: slack + webhook: executives + - platform: discord + webhook: executives + # If a notification category is not explicitly mapped in category-mappings, it will use the default-mappings to determine where to send messages. + default-mappings: + - platform: slack + webhook: core-team + - platform: discord + webhook: notifications # Configure Swagger UI and generate OpenAPI documentation springdoc: