|
1 | 1 | package apu.saerok_admin.web; |
2 | 2 |
|
| 3 | +import apu.saerok_admin.infra.notification.AdminNotificationClient; |
| 4 | +import apu.saerok_admin.infra.notification.dto.AdminSendMessageRequest; |
| 5 | +import apu.saerok_admin.infra.user.AdminUserClient; |
| 6 | +import apu.saerok_admin.infra.user.dto.AdminUserListResponse; |
3 | 7 | import apu.saerok_admin.web.view.Breadcrumb; |
4 | | -import apu.saerok_admin.web.view.NotificationTargetOption; |
5 | | -import apu.saerok_admin.web.view.ToastMessage; |
| 8 | +import apu.saerok_admin.web.view.CurrentAdminProfile; |
| 9 | +import java.util.LinkedHashSet; |
6 | 10 | import java.util.List; |
| 11 | +import java.util.Map; |
| 12 | +import java.util.Set; |
| 13 | +import lombok.RequiredArgsConstructor; |
| 14 | +import lombok.extern.slf4j.Slf4j; |
| 15 | +import org.springframework.http.HttpStatus; |
| 16 | +import org.springframework.http.ResponseEntity; |
7 | 17 | import org.springframework.stereotype.Controller; |
8 | 18 | import org.springframework.ui.Model; |
| 19 | +import org.springframework.util.StringUtils; |
9 | 20 | import org.springframework.web.bind.annotation.GetMapping; |
| 21 | +import org.springframework.web.bind.annotation.ModelAttribute; |
| 22 | +import org.springframework.web.bind.annotation.PostMapping; |
10 | 23 | import org.springframework.web.bind.annotation.RequestMapping; |
| 24 | +import org.springframework.web.bind.annotation.RequestParam; |
| 25 | +import org.springframework.web.bind.annotation.ResponseBody; |
| 26 | +import org.springframework.web.client.RestClientException; |
| 27 | +import org.springframework.web.client.RestClientResponseException; |
| 28 | +import org.springframework.web.servlet.mvc.support.RedirectAttributes; |
11 | 29 |
|
| 30 | +@Slf4j |
12 | 31 | @Controller |
| 32 | +@RequiredArgsConstructor |
13 | 33 | @RequestMapping("/notifications") |
14 | 34 | public class NotificationController { |
15 | 35 |
|
| 36 | + private static final String PERMISSION_ADMIN_ANNOUNCEMENT_WRITE = "ADMIN_ANNOUNCEMENT_WRITE"; |
| 37 | + private static final int MAX_TITLE_LENGTH = 100; |
| 38 | + private static final int MAX_BODY_LENGTH = 500; |
| 39 | + |
| 40 | + private final AdminNotificationClient adminNotificationClient; |
| 41 | + private final AdminUserClient adminUserClient; |
| 42 | + |
16 | 43 | @GetMapping("/compose") |
17 | | - public String compose(Model model) { |
| 44 | + public String compose(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, |
| 45 | + Model model) { |
| 46 | + prepareComposeModel(currentAdminProfile, model); |
| 47 | + if (!model.containsAttribute("form")) { |
| 48 | + model.addAttribute("form", NotificationMessageForm.empty()); |
| 49 | + } |
| 50 | + return "notifications/compose"; |
| 51 | + } |
| 52 | + |
| 53 | + @GetMapping("/users/search") |
| 54 | + @ResponseBody |
| 55 | + public ResponseEntity<?> searchUsers(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, |
| 56 | + @RequestParam(name = "q", required = false) String query) { |
| 57 | + if (currentAdminProfile == null || !currentAdminProfile.hasPermission(PERMISSION_ADMIN_ANNOUNCEMENT_WRITE)) { |
| 58 | + return ResponseEntity.status(HttpStatus.FORBIDDEN) |
| 59 | + .body(Map.of("message", "사용자 목록을 조회할 권한이 없습니다.")); |
| 60 | + } |
| 61 | + |
| 62 | + try { |
| 63 | + AdminUserListResponse response = adminUserClient.listUsers(query, 1, 10); |
| 64 | + return ResponseEntity.ok(response); |
| 65 | + } catch (RestClientResponseException exception) { |
| 66 | + log.warn("Failed to search admin users. status={}, body={}", |
| 67 | + exception.getStatusCode(), exception.getResponseBodyAsString(), exception); |
| 68 | + return ResponseEntity.status(HttpStatus.BAD_GATEWAY) |
| 69 | + .body(Map.of("message", "사용자 목록을 불러오지 못했습니다.")); |
| 70 | + } catch (RestClientException | IllegalStateException exception) { |
| 71 | + log.warn("Failed to search admin users.", exception); |
| 72 | + return ResponseEntity.status(HttpStatus.BAD_GATEWAY) |
| 73 | + .body(Map.of("message", "사용자 목록을 불러오지 못했습니다.")); |
| 74 | + } |
| 75 | + } |
| 76 | + |
| 77 | + @PostMapping("/send") |
| 78 | + public String send(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, |
| 79 | + @RequestParam(name = "userIds", required = false) String userIdsRaw, |
| 80 | + @RequestParam(name = "title", required = false) String title, |
| 81 | + @RequestParam(name = "body", required = false) String body, |
| 82 | + RedirectAttributes redirectAttributes) { |
| 83 | + NotificationMessageForm form = new NotificationMessageForm( |
| 84 | + nullToEmpty(userIdsRaw), |
| 85 | + nullToEmpty(title), |
| 86 | + nullToEmpty(body) |
| 87 | + ); |
| 88 | + |
| 89 | + if (currentAdminProfile == null || !currentAdminProfile.hasPermission(PERMISSION_ADMIN_ANNOUNCEMENT_WRITE)) { |
| 90 | + return redirectWithError("대상 공지를 발송할 권한이 없습니다.", form, redirectAttributes); |
| 91 | + } |
| 92 | + |
| 93 | + String trimmedTitle = title != null ? title.trim() : ""; |
| 94 | + String trimmedBody = body != null ? body.trim() : ""; |
| 95 | + |
| 96 | + if (!StringUtils.hasText(trimmedTitle) || !StringUtils.hasText(trimmedBody)) { |
| 97 | + return redirectWithError("제목과 본문을 모두 입력해 주세요.", form, redirectAttributes); |
| 98 | + } |
| 99 | + if (trimmedTitle.length() > MAX_TITLE_LENGTH) { |
| 100 | + return redirectWithError("제목은 100자 이내로 입력해 주세요.", form, redirectAttributes); |
| 101 | + } |
| 102 | + if (trimmedBody.length() > MAX_BODY_LENGTH) { |
| 103 | + return redirectWithError("본문은 500자 이내로 입력해 주세요.", form, redirectAttributes); |
| 104 | + } |
| 105 | + |
| 106 | + List<Long> userIds; |
| 107 | + try { |
| 108 | + userIds = parseUserIds(userIdsRaw); |
| 109 | + } catch (IllegalArgumentException exception) { |
| 110 | + return redirectWithError(exception.getMessage(), form, redirectAttributes); |
| 111 | + } |
| 112 | + |
| 113 | + if (userIds.isEmpty()) { |
| 114 | + return redirectWithError("수신자 사용자 ID를 1개 이상 입력해 주세요.", form, redirectAttributes); |
| 115 | + } |
| 116 | + |
| 117 | + try { |
| 118 | + adminNotificationClient.sendMessage(new AdminSendMessageRequest(userIds, trimmedTitle, trimmedBody)); |
| 119 | + redirectAttributes.addFlashAttribute("flashStatus", "success"); |
| 120 | + redirectAttributes.addFlashAttribute("flashMessage", |
| 121 | + userIds.size() + "명에게 대상 공지 발송 요청이 접수되었습니다."); |
| 122 | + } catch (RestClientResponseException exception) { |
| 123 | + log.warn("Failed to send admin notification. status={}, body={}", |
| 124 | + exception.getStatusCode(), exception.getResponseBodyAsString(), exception); |
| 125 | + return redirectWithError(resolveFailureMessage(exception), form, redirectAttributes); |
| 126 | + } catch (RestClientException | IllegalStateException exception) { |
| 127 | + log.warn("Failed to send admin notification.", exception); |
| 128 | + return redirectWithError("대상 공지 발송에 실패했습니다. 잠시 후 다시 시도해주세요.", form, redirectAttributes); |
| 129 | + } |
| 130 | + |
| 131 | + return "redirect:/notifications/compose"; |
| 132 | + } |
| 133 | + |
| 134 | + private void prepareComposeModel(CurrentAdminProfile currentAdminProfile, Model model) { |
18 | 135 | model.addAttribute("pageTitle", "시스템 알림 발송"); |
19 | 136 | model.addAttribute("activeMenu", "notifications"); |
20 | 137 | model.addAttribute("breadcrumbs", List.of( |
21 | 138 | Breadcrumb.of("대시보드", "/"), |
22 | | - Breadcrumb.active("시스템 알림") |
| 139 | + Breadcrumb.active("대상 공지 발송") |
23 | 140 | )); |
24 | | - model.addAttribute("toastMessages", List.of( |
25 | | - ToastMessage.success("toastNotificationPreview", "미리보기 생성", "미리보기가 새 창에서 확인 가능합니다."), |
26 | | - ToastMessage.success("toastNotificationSent", "발송 완료", "알림 발송 요청이 접수되었습니다.") |
27 | | - )); |
28 | | - model.addAttribute("targetOptions", List.of( |
29 | | - new NotificationTargetOption("ALL", "전체 사용자", "모든 사용자에게 발송"), |
30 | | - new NotificationTargetOption("ACTIVE", "최근 30일 활동", "최근 30일 내 활동한 사용자"), |
31 | | - new NotificationTargetOption("PREMIUM", "프리미엄", "프리미엄 구독자 대상"), |
32 | | - new NotificationTargetOption("CUSTOM", "조건 직접 설정", "세그먼트 규칙으로 지정") |
33 | | - )); |
34 | | - return "notifications/compose"; |
| 141 | + model.addAttribute("toastMessages", List.of()); |
| 142 | + model.addAttribute("canSend", currentAdminProfile != null |
| 143 | + && currentAdminProfile.hasPermission(PERMISSION_ADMIN_ANNOUNCEMENT_WRITE)); |
| 144 | + } |
| 145 | + |
| 146 | + private String redirectWithError(String message, |
| 147 | + NotificationMessageForm form, |
| 148 | + RedirectAttributes redirectAttributes) { |
| 149 | + redirectAttributes.addFlashAttribute("flashStatus", "error"); |
| 150 | + redirectAttributes.addFlashAttribute("flashMessage", message); |
| 151 | + redirectAttributes.addFlashAttribute("form", form); |
| 152 | + return "redirect:/notifications/compose"; |
| 153 | + } |
| 154 | + |
| 155 | + private List<Long> parseUserIds(String raw) { |
| 156 | + if (!StringUtils.hasText(raw)) { |
| 157 | + return List.of(); |
| 158 | + } |
| 159 | + |
| 160 | + String[] tokens = raw.trim().split("[,\\s]+"); |
| 161 | + Set<Long> userIds = new LinkedHashSet<>(); |
| 162 | + for (String token : tokens) { |
| 163 | + if (!StringUtils.hasText(token)) { |
| 164 | + continue; |
| 165 | + } |
| 166 | + |
| 167 | + long userId; |
| 168 | + try { |
| 169 | + userId = Long.parseLong(token); |
| 170 | + } catch (NumberFormatException exception) { |
| 171 | + throw new IllegalArgumentException("사용자 ID는 숫자로만 입력해 주세요."); |
| 172 | + } |
| 173 | + |
| 174 | + if (userId <= 0) { |
| 175 | + throw new IllegalArgumentException("사용자 ID는 1 이상의 숫자로 입력해 주세요."); |
| 176 | + } |
| 177 | + userIds.add(userId); |
| 178 | + } |
| 179 | + return List.copyOf(userIds); |
| 180 | + } |
| 181 | + |
| 182 | + private String resolveFailureMessage(RestClientResponseException exception) { |
| 183 | + int statusCode = exception.getStatusCode().value(); |
| 184 | + if (statusCode == 400) { |
| 185 | + return "발송 요청이 거절되었습니다. 사용자 ID와 메시지 길이를 확인해 주세요."; |
| 186 | + } |
| 187 | + if (statusCode == 403) { |
| 188 | + return "대상 공지를 발송할 권한이 없습니다."; |
| 189 | + } |
| 190 | + return "대상 공지 발송에 실패했습니다. 잠시 후 다시 시도해주세요."; |
| 191 | + } |
| 192 | + |
| 193 | + private static String nullToEmpty(String value) { |
| 194 | + return value == null ? "" : value; |
| 195 | + } |
| 196 | + |
| 197 | + public record NotificationMessageForm( |
| 198 | + String userIdsRaw, |
| 199 | + String title, |
| 200 | + String body |
| 201 | + ) { |
| 202 | + static NotificationMessageForm empty() { |
| 203 | + return new NotificationMessageForm("", "", ""); |
| 204 | + } |
35 | 205 | } |
36 | 206 | } |
0 commit comments