From 0bcdf8a618e6aa9ae496eae8ce35c133543f02a8 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 18 Apr 2026 17:52:44 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=ED=8A=B9=EC=A0=95=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=EC=97=90=EA=B2=8C=20=EC=95=8C=EB=A6=BC=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/AdminNotificationClient.java | 45 +++ .../dto/AdminSendMessageRequest.java | 10 + .../web/AdminAuditLogController.java | 12 +- .../web/NotificationController.java | 169 ++++++++++- .../saerok_admin/web/ReportController.java | 4 +- .../web/view/NotificationTargetOption.java | 4 - .../templates/fragments/_sidebar.html | 11 +- .../resources/templates/notices/index.html | 4 + .../templates/notifications/compose.html | 264 ++++++++++-------- .../templates/reports/delete-modal.html | 10 +- .../AdminNotificationClientTest.java | 62 ++++ 11 files changed, 452 insertions(+), 143 deletions(-) create mode 100644 src/main/java/apu/saerok_admin/infra/notification/AdminNotificationClient.java create mode 100644 src/main/java/apu/saerok_admin/infra/notification/dto/AdminSendMessageRequest.java delete mode 100644 src/main/java/apu/saerok_admin/web/view/NotificationTargetOption.java create mode 100644 src/test/java/apu/saerok_admin/infra/notification/AdminNotificationClientTest.java diff --git a/src/main/java/apu/saerok_admin/infra/notification/AdminNotificationClient.java b/src/main/java/apu/saerok_admin/infra/notification/AdminNotificationClient.java new file mode 100644 index 0000000..a455e37 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/notification/AdminNotificationClient.java @@ -0,0 +1,45 @@ +package apu.saerok_admin.infra.notification; + +import apu.saerok_admin.infra.SaerokApiProps; +import apu.saerok_admin.infra.notification.dto.AdminSendMessageRequest; +import java.net.URI; +import java.util.List; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriBuilder; + +@Component +public class AdminNotificationClient { + + private static final String[] ADMIN_NOTIFICATIONS_SEGMENTS = {"admin", "notifications"}; + + private final RestClient saerokRestClient; + private final String[] missingPrefixSegments; + + public AdminNotificationClient(RestClient saerokRestClient, SaerokApiProps saerokApiProps) { + this.saerokRestClient = saerokRestClient; + List missing = saerokApiProps.missingPrefixSegments(); + this.missingPrefixSegments = missing.toArray(new String[0]); + } + + public void sendMessage(AdminSendMessageRequest request) { + saerokRestClient.post() + .uri(uriBuilder -> buildUri(uriBuilder, "messages")) + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .toBodilessEntity(); + } + + private URI buildUri(UriBuilder builder, String... segments) { + if (missingPrefixSegments.length > 0) { + builder.pathSegment(missingPrefixSegments); + } + builder.pathSegment(ADMIN_NOTIFICATIONS_SEGMENTS); + if (segments != null && segments.length > 0) { + builder.pathSegment(segments); + } + return builder.build(); + } +} diff --git a/src/main/java/apu/saerok_admin/infra/notification/dto/AdminSendMessageRequest.java b/src/main/java/apu/saerok_admin/infra/notification/dto/AdminSendMessageRequest.java new file mode 100644 index 0000000..b0daf0c --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/notification/dto/AdminSendMessageRequest.java @@ -0,0 +1,10 @@ +package apu.saerok_admin.infra.notification.dto; + +import java.util.List; + +public record AdminSendMessageRequest( + List userIds, + String title, + String body +) { +} diff --git a/src/main/java/apu/saerok_admin/web/AdminAuditLogController.java b/src/main/java/apu/saerok_admin/web/AdminAuditLogController.java index 659f815..7b87c27 100644 --- a/src/main/java/apu/saerok_admin/web/AdminAuditLogController.java +++ b/src/main/java/apu/saerok_admin/web/AdminAuditLogController.java @@ -45,17 +45,25 @@ public class AdminAuditLogController { Map.entry("AD_PLACEMENT_DELETED", new ActionPresentation("광고 스케줄 삭제", "광고 스케줄을 삭제했습니다.", "text-bg-danger")), Map.entry("ANNOUNCEMENT_CREATED", new ActionPresentation("공지 등록", "새 공지를 등록했습니다.", "text-bg-success")), Map.entry("ANNOUNCEMENT_UPDATED", new ActionPresentation("공지 수정", "공지 내용을 수정했습니다.", "text-bg-info")), - Map.entry("ANNOUNCEMENT_DELETED", new ActionPresentation("공지 삭제", "공지를 삭제했습니다.", "text-bg-danger")) + Map.entry("ANNOUNCEMENT_DELETED", new ActionPresentation("공지 삭제", "공지를 삭제했습니다.", "text-bg-danger")), + Map.entry("FREEBOARD_POST_DELETED", new ActionPresentation("게시글 삭제", "신고된 자유게시판 게시글을 삭제했습니다.", "text-bg-danger")), + Map.entry("FREEBOARD_COMMENT_DELETED", new ActionPresentation("게시판 댓글 삭제", "신고된 자유게시판 댓글을 삭제했습니다.", "text-bg-danger")), + Map.entry("ADMIN_MESSAGE_SENT", new ActionPresentation("대상 공지 발송", "특정 사용자에게 관리자 메시지를 발송했습니다.", "text-bg-primary")) ); private static final Map TARGET_LABELS = Map.ofEntries( Map.entry("REPORT_COLLECTION", "새록 신고"), Map.entry("REPORT_COMMENT", "댓글 신고"), + Map.entry("REPORT_FREEBOARD_POST", "자유게시판 게시글 신고"), + Map.entry("REPORT_FREEBOARD_COMMENT", "자유게시판 댓글 신고"), Map.entry("COLLECTION", "새록"), Map.entry("COMMENT", "댓글"), + Map.entry("FREEBOARD_POST", "자유게시판 게시글"), + Map.entry("FREEBOARD_COMMENT", "자유게시판 댓글"), Map.entry("AD", "광고"), Map.entry("SLOT", "광고 위치"), Map.entry("AD_PLACEMENT", "광고 스케줄"), - Map.entry("ANNOUNCEMENT", "공지") + Map.entry("ANNOUNCEMENT", "공지"), + Map.entry("ADMIN_MESSAGE", "관리자 메시지") ); private static final String UNKNOWN_ACTION_LABEL = "기록되지 않은 작업"; private static final String UNKNOWN_ACTION_DESCRIPTION = "정의되지 않은 관리자 활동입니다."; diff --git a/src/main/java/apu/saerok_admin/web/NotificationController.java b/src/main/java/apu/saerok_admin/web/NotificationController.java index 8dbd574..968c464 100644 --- a/src/main/java/apu/saerok_admin/web/NotificationController.java +++ b/src/main/java/apu/saerok_admin/web/NotificationController.java @@ -1,36 +1,175 @@ package apu.saerok_admin.web; +import apu.saerok_admin.infra.notification.AdminNotificationClient; +import apu.saerok_admin.infra.notification.dto.AdminSendMessageRequest; import apu.saerok_admin.web.view.Breadcrumb; -import apu.saerok_admin.web.view.NotificationTargetOption; -import apu.saerok_admin.web.view.ToastMessage; +import apu.saerok_admin.web.view.CurrentAdminProfile; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; +import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; +@Slf4j @Controller +@RequiredArgsConstructor @RequestMapping("/notifications") public class NotificationController { + private static final String PERMISSION_ADMIN_ANNOUNCEMENT_WRITE = "ADMIN_ANNOUNCEMENT_WRITE"; + private static final int MAX_TITLE_LENGTH = 100; + private static final int MAX_BODY_LENGTH = 500; + + private final AdminNotificationClient adminNotificationClient; + @GetMapping("/compose") - public String compose(Model model) { + public String compose(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + Model model) { + prepareComposeModel(currentAdminProfile, model); + if (!model.containsAttribute("form")) { + model.addAttribute("form", NotificationMessageForm.empty()); + } + return "notifications/compose"; + } + + @PostMapping("/send") + public String send(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @RequestParam(name = "userIds", required = false) String userIdsRaw, + @RequestParam(name = "title", required = false) String title, + @RequestParam(name = "body", required = false) String body, + RedirectAttributes redirectAttributes) { + NotificationMessageForm form = new NotificationMessageForm( + nullToEmpty(userIdsRaw), + nullToEmpty(title), + nullToEmpty(body) + ); + + if (currentAdminProfile == null || !currentAdminProfile.hasPermission(PERMISSION_ADMIN_ANNOUNCEMENT_WRITE)) { + return redirectWithError("대상 공지를 발송할 권한이 없습니다.", form, redirectAttributes); + } + + String trimmedTitle = title != null ? title.trim() : ""; + String trimmedBody = body != null ? body.trim() : ""; + + if (!StringUtils.hasText(trimmedTitle) || !StringUtils.hasText(trimmedBody)) { + return redirectWithError("제목과 본문을 모두 입력해 주세요.", form, redirectAttributes); + } + if (trimmedTitle.length() > MAX_TITLE_LENGTH) { + return redirectWithError("제목은 100자 이내로 입력해 주세요.", form, redirectAttributes); + } + if (trimmedBody.length() > MAX_BODY_LENGTH) { + return redirectWithError("본문은 500자 이내로 입력해 주세요.", form, redirectAttributes); + } + + List userIds; + try { + userIds = parseUserIds(userIdsRaw); + } catch (IllegalArgumentException exception) { + return redirectWithError(exception.getMessage(), form, redirectAttributes); + } + + if (userIds.isEmpty()) { + return redirectWithError("수신자 사용자 ID를 1개 이상 입력해 주세요.", form, redirectAttributes); + } + + try { + adminNotificationClient.sendMessage(new AdminSendMessageRequest(userIds, trimmedTitle, trimmedBody)); + redirectAttributes.addFlashAttribute("flashStatus", "success"); + redirectAttributes.addFlashAttribute("flashMessage", + userIds.size() + "명에게 대상 공지 발송 요청이 접수되었습니다."); + } catch (RestClientResponseException exception) { + log.warn("Failed to send admin notification. status={}, body={}", + exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + return redirectWithError(resolveFailureMessage(exception), form, redirectAttributes); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to send admin notification.", exception); + return redirectWithError("대상 공지 발송에 실패했습니다. 잠시 후 다시 시도해주세요.", form, redirectAttributes); + } + + return "redirect:/notifications/compose"; + } + + private void prepareComposeModel(CurrentAdminProfile currentAdminProfile, Model model) { model.addAttribute("pageTitle", "시스템 알림 발송"); model.addAttribute("activeMenu", "notifications"); model.addAttribute("breadcrumbs", List.of( Breadcrumb.of("대시보드", "/"), - Breadcrumb.active("시스템 알림") - )); - model.addAttribute("toastMessages", List.of( - ToastMessage.success("toastNotificationPreview", "미리보기 생성", "미리보기가 새 창에서 확인 가능합니다."), - ToastMessage.success("toastNotificationSent", "발송 완료", "알림 발송 요청이 접수되었습니다.") + Breadcrumb.active("대상 공지 발송") )); - model.addAttribute("targetOptions", List.of( - new NotificationTargetOption("ALL", "전체 사용자", "모든 사용자에게 발송"), - new NotificationTargetOption("ACTIVE", "최근 30일 활동", "최근 30일 내 활동한 사용자"), - new NotificationTargetOption("PREMIUM", "프리미엄", "프리미엄 구독자 대상"), - new NotificationTargetOption("CUSTOM", "조건 직접 설정", "세그먼트 규칙으로 지정") - )); - return "notifications/compose"; + model.addAttribute("toastMessages", List.of()); + model.addAttribute("canSend", currentAdminProfile != null + && currentAdminProfile.hasPermission(PERMISSION_ADMIN_ANNOUNCEMENT_WRITE)); + } + + private String redirectWithError(String message, + NotificationMessageForm form, + RedirectAttributes redirectAttributes) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", message); + redirectAttributes.addFlashAttribute("form", form); + return "redirect:/notifications/compose"; + } + + private List parseUserIds(String raw) { + if (!StringUtils.hasText(raw)) { + return List.of(); + } + + String[] tokens = raw.trim().split("[,\\s]+"); + Set userIds = new LinkedHashSet<>(); + for (String token : tokens) { + if (!StringUtils.hasText(token)) { + continue; + } + + long userId; + try { + userId = Long.parseLong(token); + } catch (NumberFormatException exception) { + throw new IllegalArgumentException("사용자 ID는 숫자로만 입력해 주세요."); + } + + if (userId <= 0) { + throw new IllegalArgumentException("사용자 ID는 1 이상의 숫자로 입력해 주세요."); + } + userIds.add(userId); + } + return List.copyOf(userIds); + } + + private String resolveFailureMessage(RestClientResponseException exception) { + int statusCode = exception.getStatusCode().value(); + if (statusCode == 400) { + return "발송 요청이 거절되었습니다. 사용자 ID와 메시지 길이를 확인해 주세요."; + } + if (statusCode == 403) { + return "대상 공지를 발송할 권한이 없습니다."; + } + return "대상 공지 발송에 실패했습니다. 잠시 후 다시 시도해주세요."; + } + + private static String nullToEmpty(String value) { + return value == null ? "" : value; + } + + public record NotificationMessageForm( + String userIdsRaw, + String title, + String body + ) { + static NotificationMessageForm empty() { + return new NotificationMessageForm("", "", ""); + } } } diff --git a/src/main/java/apu/saerok_admin/web/ReportController.java b/src/main/java/apu/saerok_admin/web/ReportController.java index c748e76..8bc78db 100644 --- a/src/main/java/apu/saerok_admin/web/ReportController.java +++ b/src/main/java/apu/saerok_admin/web/ReportController.java @@ -223,7 +223,7 @@ public String deleteCollectionByReport(@PathVariable long reportId, return performAction(() -> adminReportClient.deleteCollectionByReport(reportId, trimmedReason), reportId, - "신고 대상 새록을 삭제했습니다.", + "신고 대상 새록을 삭제하고 작성자에게 사유 알림을 요청했습니다.", "신고 대상 새록 삭제에 실패했습니다.", redirect, returnTypes, @@ -261,7 +261,7 @@ public String deleteCommentByReport(@PathVariable long reportId, return performAction(() -> adminReportClient.deleteCommentByReport(reportId, trimmedReason), reportId, - "신고 대상 댓글을 삭제했습니다.", + "신고 대상 댓글을 삭제하고 작성자에게 사유 알림을 요청했습니다.", "신고 대상 댓글 삭제에 실패했습니다.", redirect, returnTypes, diff --git a/src/main/java/apu/saerok_admin/web/view/NotificationTargetOption.java b/src/main/java/apu/saerok_admin/web/view/NotificationTargetOption.java deleted file mode 100644 index 1515f7f..0000000 --- a/src/main/java/apu/saerok_admin/web/view/NotificationTargetOption.java +++ /dev/null @@ -1,4 +0,0 @@ -package apu.saerok_admin.web.view; - -public record NotificationTargetOption(String value, String label, String description) { -} diff --git a/src/main/resources/templates/fragments/_sidebar.html b/src/main/resources/templates/fragments/_sidebar.html index e90fd8a..945dd4d 100644 --- a/src/main/resources/templates/fragments/_sidebar.html +++ b/src/main/resources/templates/fragments/_sidebar.html @@ -41,6 +41,11 @@ 공지사항 관리 + + + 대상 공지 발송 + @@ -99,6 +104,11 @@
새록 어드민
공지사항 관리
+ + + 대상 공지 발송 + @@ -116,4 +126,3 @@
새록 어드민
- diff --git a/src/main/resources/templates/notices/index.html b/src/main/resources/templates/notices/index.html index 58897c9..49beef4 100644 --- a/src/main/resources/templates/notices/index.html +++ b/src/main/resources/templates/notices/index.html @@ -8,6 +8,10 @@

공지사항 관리

앱 공지사항을 작성하고 게시/예약 게시를 관리합니다.

+ + + 대상 공지 발송 + 공지사항 작성 diff --git a/src/main/resources/templates/notifications/compose.html b/src/main/resources/templates/notifications/compose.html index 3438eeb..b1d0ac4 100644 --- a/src/main/resources/templates/notifications/compose.html +++ b/src/main/resources/templates/notifications/compose.html @@ -2,138 +2,170 @@ -
-
-
-
-
-
- - -
-
- - -
마크다운 문법을 지원합니다.
+
+ +
+ + 처리가 완료되었습니다. +
+ +
+
발송 권한이 없습니다.
+
대상 공지 발송에는 공지사항 작성 권한이 필요합니다.
+
+ +
+
+
+
+ +
+ + +
+ 쉼표, 공백, 줄바꿈으로 구분할 수 있습니다. 중복 ID는 한 번만 발송됩니다.
-
- -
- -
+
+ +
+ + +
+ 0/100
-
- -
-
-
- - -
-
-
- -
-
- -
-
-
예약 발송을 사용하지 않으면 즉시 발송됩니다.
+
+ +
+ + +
+ 0/500
- -
- -
-
-
-
-
-

발송 가이드

-
    -
  • 하루 최대 3회 발송 권장
  • -
  • 예약 발송은 최대 30일 이내
  • -
  • 민감 정보 포함 여부 재검토
  • -
-
-
-
-
-

최근 발송 로그

-
    -
  • 2024.05.30 20:00 · 주간 업데이트 · 1,026명
  • -
  • 2024.05.28 09:00 · 시스템 점검 안내 · 982명
  • -
  • 2024.05.25 18:30 · 신규 기능 소개 · 1,201명
  • -
-
+ +
+ +
+
-