Skip to content

Commit 7a7b567

Browse files
authored
Merge pull request #67 from DevKor-github/develop
운영 서버 배포(특정 대상 알림 발송)
2 parents 2bc30b6 + d745ffb commit 7a7b567

17 files changed

Lines changed: 987 additions & 336 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package apu.saerok_admin.infra.notification;
2+
3+
import apu.saerok_admin.infra.SaerokApiProps;
4+
import apu.saerok_admin.infra.notification.dto.AdminSendMessageRequest;
5+
import java.net.URI;
6+
import java.util.List;
7+
import org.springframework.http.MediaType;
8+
import org.springframework.stereotype.Component;
9+
import org.springframework.web.client.RestClient;
10+
import org.springframework.web.util.UriBuilder;
11+
12+
@Component
13+
public class AdminNotificationClient {
14+
15+
private static final String[] ADMIN_NOTIFICATIONS_SEGMENTS = {"admin", "notifications"};
16+
17+
private final RestClient saerokRestClient;
18+
private final String[] missingPrefixSegments;
19+
20+
public AdminNotificationClient(RestClient saerokRestClient, SaerokApiProps saerokApiProps) {
21+
this.saerokRestClient = saerokRestClient;
22+
List<String> missing = saerokApiProps.missingPrefixSegments();
23+
this.missingPrefixSegments = missing.toArray(new String[0]);
24+
}
25+
26+
public void sendMessage(AdminSendMessageRequest request) {
27+
saerokRestClient.post()
28+
.uri(uriBuilder -> buildUri(uriBuilder, "messages"))
29+
.contentType(MediaType.APPLICATION_JSON)
30+
.body(request)
31+
.retrieve()
32+
.toBodilessEntity();
33+
}
34+
35+
private URI buildUri(UriBuilder builder, String... segments) {
36+
if (missingPrefixSegments.length > 0) {
37+
builder.pathSegment(missingPrefixSegments);
38+
}
39+
builder.pathSegment(ADMIN_NOTIFICATIONS_SEGMENTS);
40+
if (segments != null && segments.length > 0) {
41+
builder.pathSegment(segments);
42+
}
43+
return builder.build();
44+
}
45+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package apu.saerok_admin.infra.notification.dto;
2+
3+
import java.util.List;
4+
5+
public record AdminSendMessageRequest(
6+
List<Long> userIds,
7+
String title,
8+
String body
9+
) {
10+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package apu.saerok_admin.infra.user;
2+
3+
import apu.saerok_admin.infra.SaerokApiProps;
4+
import apu.saerok_admin.infra.user.dto.AdminUserListResponse;
5+
import java.net.URI;
6+
import java.util.List;
7+
import org.springframework.stereotype.Component;
8+
import org.springframework.util.StringUtils;
9+
import org.springframework.web.client.RestClient;
10+
import org.springframework.web.util.UriBuilder;
11+
12+
@Component
13+
public class AdminUserClient {
14+
15+
private static final String[] ADMIN_USERS_SEGMENTS = {"admin", "users"};
16+
17+
private final RestClient saerokRestClient;
18+
private final String[] missingPrefixSegments;
19+
20+
public AdminUserClient(RestClient saerokRestClient, SaerokApiProps saerokApiProps) {
21+
this.saerokRestClient = saerokRestClient;
22+
List<String> missing = saerokApiProps.missingPrefixSegments();
23+
this.missingPrefixSegments = missing.toArray(new String[0]);
24+
}
25+
26+
public AdminUserListResponse listUsers(String query, int page, int size) {
27+
AdminUserListResponse response = saerokRestClient.get()
28+
.uri(uriBuilder -> buildUri(uriBuilder, query, page, size))
29+
.retrieve()
30+
.body(AdminUserListResponse.class);
31+
32+
if (response == null) {
33+
throw new IllegalStateException("Empty response from admin user API");
34+
}
35+
return response;
36+
}
37+
38+
private URI buildUri(UriBuilder builder, String query, int page, int size) {
39+
if (missingPrefixSegments.length > 0) {
40+
builder.pathSegment(missingPrefixSegments);
41+
}
42+
builder.pathSegment(ADMIN_USERS_SEGMENTS);
43+
if (StringUtils.hasText(query)) {
44+
builder.queryParam("q", query.trim());
45+
}
46+
builder.queryParam("page", page);
47+
builder.queryParam("size", size);
48+
return builder.build();
49+
}
50+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package apu.saerok_admin.infra.user.dto;
2+
3+
import java.util.List;
4+
5+
public record AdminUserListResponse(
6+
List<Item> users,
7+
int page,
8+
int size,
9+
long totalElements,
10+
int totalPages
11+
) {
12+
13+
public record Item(
14+
Long id,
15+
String nickname
16+
) {
17+
}
18+
}

src/main/java/apu/saerok_admin/web/AdminAuditLogController.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,17 +45,25 @@ public class AdminAuditLogController {
4545
Map.entry("AD_PLACEMENT_DELETED", new ActionPresentation("광고 스케줄 삭제", "광고 스케줄을 삭제했습니다.", "text-bg-danger")),
4646
Map.entry("ANNOUNCEMENT_CREATED", new ActionPresentation("공지 등록", "새 공지를 등록했습니다.", "text-bg-success")),
4747
Map.entry("ANNOUNCEMENT_UPDATED", new ActionPresentation("공지 수정", "공지 내용을 수정했습니다.", "text-bg-info")),
48-
Map.entry("ANNOUNCEMENT_DELETED", new ActionPresentation("공지 삭제", "공지를 삭제했습니다.", "text-bg-danger"))
48+
Map.entry("ANNOUNCEMENT_DELETED", new ActionPresentation("공지 삭제", "공지를 삭제했습니다.", "text-bg-danger")),
49+
Map.entry("FREEBOARD_POST_DELETED", new ActionPresentation("게시글 삭제", "신고된 자유게시판 게시글을 삭제했습니다.", "text-bg-danger")),
50+
Map.entry("FREEBOARD_COMMENT_DELETED", new ActionPresentation("게시판 댓글 삭제", "신고된 자유게시판 댓글을 삭제했습니다.", "text-bg-danger")),
51+
Map.entry("ADMIN_MESSAGE_SENT", new ActionPresentation("대상 공지 발송", "특정 사용자에게 관리자 메시지를 발송했습니다.", "text-bg-primary"))
4952
);
5053
private static final Map<String, String> TARGET_LABELS = Map.ofEntries(
5154
Map.entry("REPORT_COLLECTION", "새록 신고"),
5255
Map.entry("REPORT_COMMENT", "댓글 신고"),
56+
Map.entry("REPORT_FREEBOARD_POST", "자유게시판 게시글 신고"),
57+
Map.entry("REPORT_FREEBOARD_COMMENT", "자유게시판 댓글 신고"),
5358
Map.entry("COLLECTION", "새록"),
5459
Map.entry("COMMENT", "댓글"),
60+
Map.entry("FREEBOARD_POST", "자유게시판 게시글"),
61+
Map.entry("FREEBOARD_COMMENT", "자유게시판 댓글"),
5562
Map.entry("AD", "광고"),
5663
Map.entry("SLOT", "광고 위치"),
5764
Map.entry("AD_PLACEMENT", "광고 스케줄"),
58-
Map.entry("ANNOUNCEMENT", "공지")
65+
Map.entry("ANNOUNCEMENT", "공지"),
66+
Map.entry("ADMIN_MESSAGE", "관리자 메시지")
5967
);
6068
private static final String UNKNOWN_ACTION_LABEL = "기록되지 않은 작업";
6169
private static final String UNKNOWN_ACTION_DESCRIPTION = "정의되지 않은 관리자 활동입니다.";
Lines changed: 185 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,206 @@
11
package apu.saerok_admin.web;
22

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;
37
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;
610
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;
717
import org.springframework.stereotype.Controller;
818
import org.springframework.ui.Model;
19+
import org.springframework.util.StringUtils;
920
import org.springframework.web.bind.annotation.GetMapping;
21+
import org.springframework.web.bind.annotation.ModelAttribute;
22+
import org.springframework.web.bind.annotation.PostMapping;
1023
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;
1129

30+
@Slf4j
1231
@Controller
32+
@RequiredArgsConstructor
1333
@RequestMapping("/notifications")
1434
public class NotificationController {
1535

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+
1643
@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) {
18135
model.addAttribute("pageTitle", "시스템 알림 발송");
19136
model.addAttribute("activeMenu", "notifications");
20137
model.addAttribute("breadcrumbs", List.of(
21138
Breadcrumb.of("대시보드", "/"),
22-
Breadcrumb.active("시스템 알림")
139+
Breadcrumb.active("대상 공지 발송")
23140
));
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+
}
35205
}
36206
}

src/main/java/apu/saerok_admin/web/ReportController.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ public String deleteCollectionByReport(@PathVariable long reportId,
223223

224224
return performAction(() -> adminReportClient.deleteCollectionByReport(reportId, trimmedReason),
225225
reportId,
226-
"신고 대상 새록을 삭제했습니다.",
226+
"신고 대상 새록을 삭제하고 작성자에게 사유 알림을 요청했습니다.",
227227
"신고 대상 새록 삭제에 실패했습니다.",
228228
redirect,
229229
returnTypes,
@@ -261,7 +261,7 @@ public String deleteCommentByReport(@PathVariable long reportId,
261261

262262
return performAction(() -> adminReportClient.deleteCommentByReport(reportId, trimmedReason),
263263
reportId,
264-
"신고 대상 댓글을 삭제했습니다.",
264+
"신고 대상 댓글을 삭제하고 작성자에게 사유 알림을 요청했습니다.",
265265
"신고 대상 댓글 삭제에 실패했습니다.",
266266
redirect,
267267
returnTypes,

0 commit comments

Comments
 (0)