diff --git a/src/main/java/page/clab/api/domain/application/application/ApplicationService.java b/src/main/java/page/clab/api/domain/application/application/ApplicationService.java index 81fa9b55d..9fbabd6e2 100644 --- a/src/main/java/page/clab/api/domain/application/application/ApplicationService.java +++ b/src/main/java/page/clab/api/domain/application/application/ApplicationService.java @@ -1,6 +1,5 @@ package page.clab.api.domain.application.application; -import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -44,7 +43,7 @@ public String createApplication(ApplicationRequestDto requestDto) { notificationService.sendNotificationToAdmins(requestDto.getStudentId() + " " + requestDto.getName() + "님이 동아리에 지원하였습니다."); - slackService.sendApplicationNotification(requestDto); + slackService.sendNewApplicationNotification(requestDto); return applicationRepository.save(application).getStudentId(); } diff --git a/src/main/java/page/clab/api/domain/board/application/BoardService.java b/src/main/java/page/clab/api/domain/board/application/BoardService.java index 5462632f4..4c9427fda 100644 --- a/src/main/java/page/clab/api/domain/board/application/BoardService.java +++ b/src/main/java/page/clab/api/domain/board/application/BoardService.java @@ -24,6 +24,7 @@ import page.clab.api.global.common.dto.PagedResponseDto; 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.exception.NotFoundException; import page.clab.api.global.exception.PermissionDeniedException; import page.clab.api.global.validation.ValidationService; @@ -43,6 +44,8 @@ public class BoardService { private final ValidationService validationService; + private final SlackService slackService; + private final BoardRepository boardRepository; private final BoardLikeRepository boardLikeRepository; @@ -59,6 +62,7 @@ public String createBoard(BoardRequestDto requestDto) throws PermissionDeniedExc if (board.shouldNotifyForNewBoard()) { notificationService.sendNotificationToMember(currentMember, "[" + board.getTitle() + "] 새로운 공지사항이 등록되었습니다."); } + slackService.sendNewBoardNotification(board); return boardRepository.save(board).getCategory().getKey(); } diff --git a/src/main/java/page/clab/api/domain/board/domain/Board.java b/src/main/java/page/clab/api/domain/board/domain/Board.java index 99847c588..44db8437b 100644 --- a/src/main/java/page/clab/api/domain/board/domain/Board.java +++ b/src/main/java/page/clab/api/domain/board/domain/Board.java @@ -69,6 +69,7 @@ public class Board extends BaseEntity { private String imageUrl; + @Getter @Column(nullable = false) private boolean wantAnonymous; 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 new file mode 100644 index 000000000..e9757bb3d --- /dev/null +++ b/src/main/java/page/clab/api/global/common/slack/api/NotificationSettingController.java @@ -0,0 +1,46 @@ +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.annotation.Secured; +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 이상의 권한이 필요함") + @Secured({"ROLE_SUPER"}) + @GetMapping("") + public ApiResponse getNotificationSettings() { + List notificationSettings = notificationSettingService.getNotificationSettings(); + return ApiResponse.success(notificationSettings); + } + + @Operation(summary = "[S] 슬랙 알림 설정 변경", description = "ROLE_SUPER 이상의 권한이 필요함") + @Secured({"ROLE_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 new file mode 100644 index 000000000..16aad7c4d --- /dev/null +++ b/src/main/java/page/clab/api/global/common/slack/application/NotificationSettingService.java @@ -0,0 +1,48 @@ +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.response.NotificationSettingResponseDto; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class NotificationSettingService { + + private final AlertTypeResolver alertTypeResolver; + + private final NotificationSettingRepository settingRepository; + + @Transactional(readOnly = true) + public List getNotificationSettings() { + return settingRepository.findAll().stream() + .map(NotificationSettingResponseDto::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 index 48c76eeb3..8963b6f54 100644 --- 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 @@ -1,267 +1,49 @@ 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.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.jetbrains.annotations.NotNull; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.event.ContextRefreshedEvent; import org.springframework.context.event.EventListener; -import org.springframework.core.env.Environment; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import page.clab.api.domain.application.dto.request.ApplicationRequestDto; +import page.clab.api.domain.board.domain.Board; import page.clab.api.domain.member.domain.Member; +import page.clab.api.global.common.slack.domain.GeneralAlertType; import page.clab.api.global.common.slack.domain.SecurityAlertType; -import page.clab.api.global.config.SlackConfig; -import page.clab.api.global.util.HttpReqResUtil; +import page.clab.api.global.common.slack.event.NotificationEvent; -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; - -@Service @Slf4j +@Service +@RequiredArgsConstructor public class SlackService { - private final Slack slack; - - private final String webhookUrl; - - private final String webUrl; - - private final String apiUrl; - - private final String color; - - private final Environment environment; - - private final AttributeStrategy attributeStrategy; - - public SlackService(SlackConfig slackConfig, Environment environment, AttributeStrategy attributeStrategy) { - this.slack = slackConfig.slack(); - this.webhookUrl = slackConfig.getWebhookUrl(); - this.webUrl = slackConfig.getWebUrl(); - this.apiUrl = slackConfig.getApiUrl(); - this.color = slackConfig.getColor(); - this.environment = environment; - this.attributeStrategy = attributeStrategy; - } + private final ApplicationEventPublisher eventPublisher; public void sendServerErrorNotification(HttpServletRequest request, Exception e) { - List blocks = createErrorBlocks(request, e); - sendSlackMessageWithBlocks(blocks); + eventPublisher.publishEvent(new NotificationEvent(this, GeneralAlertType.SERVER_ERROR, request, e)); } public void sendSecurityAlertNotification(HttpServletRequest request, SecurityAlertType alertType, String additionalMessage) { - List blocks = createSecurityAlertBlocks(request, alertType, additionalMessage); - sendSlackMessageWithBlocks(blocks); + eventPublisher.publishEvent(new NotificationEvent(this, alertType, request, additionalMessage)); } public void sendAdminLoginNotification(HttpServletRequest request, Member loginMember) { - List blocks = createAdminLoginBlocks(request, loginMember); - sendSlackMessageWithBlocks(blocks); + eventPublisher.publishEvent(new NotificationEvent(this, GeneralAlertType.ADMIN_LOGIN, request, loginMember)); } - public void sendApplicationNotification(ApplicationRequestDto applicationRequestDto) { - List blocks = createApplicationBlocks(applicationRequestDto); - sendSlackMessageWithBlocks(blocks); + public void sendNewApplicationNotification(ApplicationRequestDto applicationRequestDto) { + eventPublisher.publishEvent(new NotificationEvent(this, GeneralAlertType.APPLICATION_CREATED, null, applicationRequestDto)); } - @EventListener(ContextRefreshedEvent.class) - public void sendServerStartNotification() { - List blocks = createServerStartBlocks(); - sendSlackMessageWithBlocks(blocks); - } - - private CompletableFuture sendSlackMessageWithBlocks(List blocks) { - 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; - } - }); - } - - private List createErrorBlocks(HttpServletRequest request, Exception e) { - String httpMethod = request.getMethod(); - String requestUrl = request.getRequestURI(); - String username = getUsername(); - - 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 + "] " + requestUrl) - ))), - section(section -> section.text(markdownText("*Error Message:*\n" + detailedMessage))), - section(section -> section.text(markdownText("*Stack Trace:*\n```" + getStackTraceSummary(e) + "```"))) - ); + public void sendNewBoardNotification(Board board) { + eventPublisher.publishEvent(new NotificationEvent(this, GeneralAlertType.BOARD_CREATED, null, board)); } - private List createSecurityAlertBlocks(HttpServletRequest request, SecurityAlertType alertType, String additionalMessage) { - String clientIpAddress = HttpReqResUtil.getClientIpAddressIfServletRequestExist(); - String requestUrl = request.getRequestURI(); - 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" + requestUrl) - ))), - section(section -> section.text(markdownText("*Details:*\n" + alertType.getDefaultMessage() + "\n" + additionalMessage))) - ); - } - - private List createAdminLoginBlocks(HttpServletRequest request, Member 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.getId() + " " + loginMember.getName()), - 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: *New Application*")))); - blocks.add(section(section -> section.fields(Arrays.asList( - markdownText("*Type:*\n" + requestDto.getApplicationType().getDescription()), - markdownText("*Student ID:*\n" + requestDto.getStudentId()), - markdownText("*Name:*\n" + requestDto.getName()), - markdownText("*Grade:*\n" + requestDto.getGrade() + "학년"), - markdownText("*Interests:*\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 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 static String getUsername() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - return (authentication == null || authentication.getName() == null) ? "anonymous" : authentication.getName(); - } - - 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(); + @EventListener(ContextRefreshedEvent.class) + public void sendServerStartNotification() { + eventPublisher.publishEvent(new NotificationEvent(this, 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 new file mode 100644 index 000000000..3bd9c0e6d --- /dev/null +++ b/src/main/java/page/clab/api/global/common/slack/application/SlackServiceHelper.java @@ -0,0 +1,293 @@ +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.application.dto.request.ApplicationRequestDto; +import page.clab.api.domain.board.domain.Board; +import page.clab.api.domain.member.domain.Member; +import page.clab.api.global.common.slack.domain.AlertType; +import page.clab.api.global.common.slack.domain.GeneralAlertType; +import page.clab.api.global.common.slack.domain.SecurityAlertType; +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; + +@Component +@Slf4j +public class SlackServiceHelper { + + private final Slack slack; + + private final String webhookUrl; + + 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.webhookUrl = slackConfig.getWebhookUrl(); + this.webUrl = slackConfig.getWebUrl(); + this.apiUrl = slackConfig.getApiUrl(); + this.color = slackConfig.getColor(); + this.environment = environment; + this.attributeStrategy = attributeStrategy; + } + + public CompletableFuture sendSlackMessage(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; + } + }); + } + + 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 Member) { + return createAdminLoginBlocks(request, (Member) additionalData); + } + break; + case APPLICATION_CREATED: + if (additionalData instanceof ApplicationRequestDto) { + return createApplicationBlocks((ApplicationRequestDto) additionalData); + } + break; + case BOARD_CREATED: + if (additionalData instanceof Board) { + return createBoardBlocks((Board) 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(); + } + } + return List.of(); + } + + private List createErrorBlocks(HttpServletRequest request, Exception e) { + String httpMethod = request.getMethod(); + String requestUrl = request.getRequestURI(); + String username = getUsername(); + + 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 + "] " + requestUrl) + ))), + 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 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" + requestUrl) + ))), + section(section -> section.text(markdownText("*Details:*\n" + alertType.getDefaultMessage() + "\n" + additionalMessage))) + ); + } + + private List createAdminLoginBlocks(HttpServletRequest request, Member 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.getId() + " " + loginMember.getName()), + 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: *New Application*")))); + blocks.add(section(section -> section.fields(Arrays.asList( + markdownText("*Type:*\n" + requestDto.getApplicationType().getDescription()), + markdownText("*Student ID:*\n" + requestDto.getStudentId()), + markdownText("*Name:*\n" + requestDto.getName()), + markdownText("*Grade:*\n" + requestDto.getGrade() + "학년"), + markdownText("*Interests:*\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(Board board) { + List blocks = new ArrayList<>(); + String username = board.isWantAnonymous() ? + board.getNickname() : board.getMember().getId() + " " + board.getMember().getName(); + + blocks.add(section(section -> section.text(markdownText(":writing_hand: *New Board*")))); + blocks.add(section(section -> section.fields(Arrays.asList( + markdownText("*Title:*\n" + board.getTitle()), + markdownText("*Category:*\n" + board.getCategory().getDescription()), + markdownText("*User:*\n" + username) + )))); + return blocks; + } + + 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 static String getUsername() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + return (authentication == null || authentication.getName() == null) ? "anonymous" : authentication.getName(); + } + + 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(); + } + +} \ No newline at end of file diff --git a/src/main/java/page/clab/api/global/common/slack/dao/NotificationSettingRepository.java b/src/main/java/page/clab/api/global/common/slack/dao/NotificationSettingRepository.java new file mode 100644 index 000000000..a66b8d5cd --- /dev/null +++ b/src/main/java/page/clab/api/global/common/slack/dao/NotificationSettingRepository.java @@ -0,0 +1,13 @@ +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; + +import java.util.Optional; + +public interface NotificationSettingRepository extends JpaRepository { + + Optional findByAlertType(AlertType alertType); + +} 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 new file mode 100644 index 000000000..971d10449 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/slack/domain/AlertType.java @@ -0,0 +1,9 @@ +package page.clab.api.global.common.slack.domain; + +public interface AlertType { + + String getTitle(); + + String getDefaultMessage(); + +} \ No newline at end of file diff --git a/src/main/java/page/clab/api/global/common/slack/domain/AlertTypeConverter.java b/src/main/java/page/clab/api/global/common/slack/domain/AlertTypeConverter.java new file mode 100644 index 000000000..c96092da1 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/slack/domain/AlertTypeConverter.java @@ -0,0 +1,43 @@ +package page.clab.api.global.common.slack.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; + +@Converter(autoApply = true) +public class AlertTypeConverter implements AttributeConverter { + + private static final Map CACHE = new HashMap<>(); + + static { + for (GeneralAlertType type : GeneralAlertType.values()) { + CACHE.put(type.getTitle(), type); + } + for (SecurityAlertType type : SecurityAlertType.values()) { + CACHE.put(type.getTitle(), type); + } + } + + @Override + public String convertToDatabaseColumn(AlertType alertType) { + if (alertType == null) { + return null; + } + return alertType.getTitle(); + } + + @Override + public AlertType convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isEmpty()) { + return null; + } + AlertType alertType = CACHE.get(dbData); + if (alertType == null) { + throw new AlertTypeNotFoundException(dbData); + } + return alertType; + } +} diff --git a/src/main/java/page/clab/api/global/common/slack/domain/AlertTypeResolver.java b/src/main/java/page/clab/api/global/common/slack/domain/AlertTypeResolver.java new file mode 100644 index 000000000..a0c8d101d --- /dev/null +++ b/src/main/java/page/clab/api/global/common/slack/domain/AlertTypeResolver.java @@ -0,0 +1,23 @@ +package page.clab.api.global.common.slack.domain; + +import org.springframework.stereotype.Service; +import page.clab.api.global.common.slack.exception.AlertTypeNotFoundException; + +@Service +public class AlertTypeResolver { + + public AlertType resolve(String alertTypeName) { + for (GeneralAlertType type : GeneralAlertType.values()) { + if (type.getTitle().equals(alertTypeName)) { + return type; + } + } + for (SecurityAlertType type : SecurityAlertType.values()) { + if (type.getTitle().equals(alertTypeName)) { + return type; + } + } + throw new AlertTypeNotFoundException(alertTypeName); + } + +} \ No newline at end of file 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 new file mode 100644 index 000000000..42444236e --- /dev/null +++ b/src/main/java/page/clab/api/global/common/slack/domain/GeneralAlertType.java @@ -0,0 +1,19 @@ +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."), + APPLICATION_CREATED("새 지원서", "New application has been submitted."), + BOARD_CREATED("새 게시글", "New board has been created."), + SERVER_START("서버 시작", "Server has been started."), + SERVER_ERROR("서버 에러", "Server error occurred."); + + private final String title; + private final String defaultMessage; + +} \ No newline at end of file diff --git a/src/main/java/page/clab/api/global/common/slack/domain/NotificationSetting.java b/src/main/java/page/clab/api/global/common/slack/domain/NotificationSetting.java new file mode 100644 index 000000000..a6b8ff8da --- /dev/null +++ b/src/main/java/page/clab/api/global/common/slack/domain/NotificationSetting.java @@ -0,0 +1,43 @@ +package page.clab.api.global.common.slack.domain; + +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Entity +public class NotificationSetting { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Convert(converter = AlertTypeConverter.class) + private AlertType alertType; + + private boolean enabled; + + public static NotificationSetting createDefault(AlertType alertType) { + return NotificationSetting.builder() + .alertType(alertType) + .enabled(true) + .build(); + } + + public void updateEnabled(boolean enabled) { + this.enabled = enabled; + } + +} \ No newline at end of file diff --git a/src/main/java/page/clab/api/global/common/slack/domain/SecurityAlertType.java b/src/main/java/page/clab/api/global/common/slack/domain/SecurityAlertType.java index 587de828d..a0921ba64 100644 --- a/src/main/java/page/clab/api/global/common/slack/domain/SecurityAlertType.java +++ b/src/main/java/page/clab/api/global/common/slack/domain/SecurityAlertType.java @@ -5,7 +5,7 @@ @Getter @AllArgsConstructor -public enum SecurityAlertType { +public enum SecurityAlertType implements AlertType { ABNORMAL_ACCESS("비정상적인 접근", "Unexpected access pattern detected."), REPEATED_LOGIN_FAILURES("지속된 로그인 실패", "Multiple consecutive failed login attempts."), 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/slack/dto/request/NotificationSettingUpdateRequestDto.java new file mode 100644 index 000000000..ed1343b02 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/slack/dto/request/NotificationSettingUpdateRequestDto.java @@ -0,0 +1,20 @@ +package page.clab.api.global.common.slack.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class NotificationSettingUpdateRequestDto { + + @NotNull(message = "{notNull.notificationSetting.alertType}") + @Schema(description = "알림 타입", example = "서버 시작") + private String alertType; + + @NotNull(message = "{notNull.notificationSetting.enabled}") + @Schema(description = "알림 활성화 여부", example = "true") + private boolean enabled; + +} 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/slack/dto/response/NotificationSettingResponseDto.java new file mode 100644 index 000000000..25bb27b3f --- /dev/null +++ b/src/main/java/page/clab/api/global/common/slack/dto/response/NotificationSettingResponseDto.java @@ -0,0 +1,22 @@ +package page.clab.api.global.common.slack.dto.response; + +import lombok.Builder; +import lombok.Getter; +import page.clab.api.global.common.slack.domain.NotificationSetting; + +@Getter +@Builder +public class NotificationSettingResponseDto { + + private String alertType; + + private boolean enabled; + + public static NotificationSettingResponseDto toDto(NotificationSetting setting) { + return NotificationSettingResponseDto.builder() + .alertType(setting.getAlertType().getTitle()) + .enabled(setting.isEnabled()) + .build(); + } + +} diff --git a/src/main/java/page/clab/api/global/common/slack/event/NotificationEvent.java b/src/main/java/page/clab/api/global/common/slack/event/NotificationEvent.java new file mode 100644 index 000000000..5ac93ee69 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/slack/event/NotificationEvent.java @@ -0,0 +1,24 @@ +package page.clab.api.global.common.slack.event; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; +import page.clab.api.global.common.slack.domain.AlertType; + +@Getter +public class NotificationEvent extends ApplicationEvent { + + private final AlertType alertType; + + private final HttpServletRequest request; + + private final Object additionalData; + + public NotificationEvent(Object source, AlertType alertType, HttpServletRequest request, Object additionalData) { + super(source); + this.alertType = alertType; + this.request = request; + this.additionalData = additionalData; + } + +} diff --git a/src/main/java/page/clab/api/global/common/slack/exception/AlertTypeNotFoundException.java b/src/main/java/page/clab/api/global/common/slack/exception/AlertTypeNotFoundException.java new file mode 100644 index 000000000..e7ea55bf6 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/slack/exception/AlertTypeNotFoundException.java @@ -0,0 +1,9 @@ +package page.clab.api.global.common.slack.exception; + +public class AlertTypeNotFoundException extends RuntimeException { + + public AlertTypeNotFoundException(String alertTypeName) { + super("Unknown alert type: " + alertTypeName); + } + +} \ No newline at end of file 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 new file mode 100644 index 000000000..9dac2e8fe --- /dev/null +++ b/src/main/java/page/clab/api/global/common/slack/listener/NotificationListener.java @@ -0,0 +1,30 @@ +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(alertType, event.getRequest(), event.getAdditionalData()); + } + } + +} \ No newline at end of file diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index d42cc8b92..6970588fd 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -157,6 +157,8 @@ notNull.news.source=출처는 필수 입력 항목입니다. notNull.news.date=날짜는 필수 입력 항목입니다. notNull.notification.memberId=학번은 필수 입력 항목입니다. notNull.notification.content=내용은 필수 입력 항목입니다. +notNull.notificationSetting.alertType=알림 타입은 필수 입력 항목입니다. +notNull.notificationSetting.enabled=알림 활성화 여부는 필수 입력 항목입니다. notNull.product.name=제품명은 필수 입력 항목입니다. notNull.product.description=설명은 필수 입력 항목입니다. notNull.recruitment.startDate=시작 날짜는 필수 입력 항목입니다.