Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<String> 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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package apu.saerok_admin.infra.notification.dto;

import java.util.List;

public record AdminSendMessageRequest(
List<Long> userIds,
String title,
String body
) {
}
50 changes: 50 additions & 0 deletions src/main/java/apu/saerok_admin/infra/user/AdminUserClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package apu.saerok_admin.infra.user;

import apu.saerok_admin.infra.SaerokApiProps;
import apu.saerok_admin.infra.user.dto.AdminUserListResponse;
import java.net.URI;
import java.util.List;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClient;
import org.springframework.web.util.UriBuilder;

@Component
public class AdminUserClient {

private static final String[] ADMIN_USERS_SEGMENTS = {"admin", "users"};

private final RestClient saerokRestClient;
private final String[] missingPrefixSegments;

public AdminUserClient(RestClient saerokRestClient, SaerokApiProps saerokApiProps) {
this.saerokRestClient = saerokRestClient;
List<String> missing = saerokApiProps.missingPrefixSegments();
this.missingPrefixSegments = missing.toArray(new String[0]);
}

public AdminUserListResponse listUsers(String query, int page, int size) {
AdminUserListResponse response = saerokRestClient.get()
.uri(uriBuilder -> buildUri(uriBuilder, query, page, size))
.retrieve()
.body(AdminUserListResponse.class);

if (response == null) {
throw new IllegalStateException("Empty response from admin user API");
}
return response;
}

private URI buildUri(UriBuilder builder, String query, int page, int size) {
if (missingPrefixSegments.length > 0) {
builder.pathSegment(missingPrefixSegments);
}
builder.pathSegment(ADMIN_USERS_SEGMENTS);
if (StringUtils.hasText(query)) {
builder.queryParam("q", query.trim());
}
builder.queryParam("page", page);
builder.queryParam("size", size);
return builder.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package apu.saerok_admin.infra.user.dto;

import java.util.List;

public record AdminUserListResponse(
List<Item> users,
int page,
int size,
long totalElements,
int totalPages
) {

public record Item(
Long id,
String nickname
) {
}
}
12 changes: 10 additions & 2 deletions src/main/java/apu/saerok_admin/web/AdminAuditLogController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> 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 = "정의되지 않은 관리자 활동입니다.";
Expand Down
200 changes: 185 additions & 15 deletions src/main/java/apu/saerok_admin/web/NotificationController.java
Original file line number Diff line number Diff line change
@@ -1,36 +1,206 @@
package apu.saerok_admin.web;

import apu.saerok_admin.infra.notification.AdminNotificationClient;
import apu.saerok_admin.infra.notification.dto.AdminSendMessageRequest;
import apu.saerok_admin.infra.user.AdminUserClient;
import apu.saerok_admin.infra.user.dto.AdminUserListResponse;
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.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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.bind.annotation.ResponseBody;
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;
private final AdminUserClient adminUserClient;

@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";
}

@GetMapping("/users/search")
@ResponseBody
public ResponseEntity<?> searchUsers(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile,
@RequestParam(name = "q", required = false) String query) {
if (currentAdminProfile == null || !currentAdminProfile.hasPermission(PERMISSION_ADMIN_ANNOUNCEMENT_WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("message", "사용자 목록을 조회할 권한이 없습니다."));
}

try {
AdminUserListResponse response = adminUserClient.listUsers(query, 1, 10);
return ResponseEntity.ok(response);
} catch (RestClientResponseException exception) {
log.warn("Failed to search admin users. status={}, body={}",
exception.getStatusCode(), exception.getResponseBodyAsString(), exception);
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(Map.of("message", "사용자 목록을 불러오지 못했습니다."));
} catch (RestClientException | IllegalStateException exception) {
log.warn("Failed to search admin users.", exception);
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(Map.of("message", "사용자 목록을 불러오지 못했습니다."));
}
}

@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<Long> 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("시스템 알림")
Breadcrumb.active("대상 공지 발송")
));
model.addAttribute("toastMessages", List.of(
ToastMessage.success("toastNotificationPreview", "미리보기 생성", "미리보기가 새 창에서 확인 가능합니다."),
ToastMessage.success("toastNotificationSent", "발송 완료", "알림 발송 요청이 접수되었습니다.")
));
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<Long> parseUserIds(String raw) {
if (!StringUtils.hasText(raw)) {
return List.of();
}

String[] tokens = raw.trim().split("[,\\s]+");
Set<Long> 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("", "", "");
}
}
}
4 changes: 2 additions & 2 deletions src/main/java/apu/saerok_admin/web/ReportController.java
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ public String deleteCollectionByReport(@PathVariable long reportId,

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

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