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/infra/user/AdminUserClient.java b/src/main/java/apu/saerok_admin/infra/user/AdminUserClient.java new file mode 100644 index 0000000..f615453 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/user/AdminUserClient.java @@ -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 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(); + } +} diff --git a/src/main/java/apu/saerok_admin/infra/user/dto/AdminUserListResponse.java b/src/main/java/apu/saerok_admin/infra/user/dto/AdminUserListResponse.java new file mode 100644 index 0000000..414a356 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/user/dto/AdminUserListResponse.java @@ -0,0 +1,18 @@ +package apu.saerok_admin.infra.user.dto; + +import java.util.List; + +public record AdminUserListResponse( + List users, + int page, + int size, + long totalElements, + int totalPages +) { + + public record Item( + Long id, + String nickname + ) { + } +} 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..1d9e127 100644 --- a/src/main/java/apu/saerok_admin/web/NotificationController.java +++ b/src/main/java/apu/saerok_admin/web/NotificationController.java @@ -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 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 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/UserController.java b/src/main/java/apu/saerok_admin/web/UserController.java index fa7874f..3965faa 100644 --- a/src/main/java/apu/saerok_admin/web/UserController.java +++ b/src/main/java/apu/saerok_admin/web/UserController.java @@ -1,74 +1,113 @@ package apu.saerok_admin.web; +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.UserActivity; -import apu.saerok_admin.web.view.UserDetail; +import apu.saerok_admin.web.view.CurrentAdminProfile; import apu.saerok_admin.web.view.UserListItem; -import apu.saerok_admin.web.view.ToastMessage; -import java.time.LocalDateTime; import java.util.List; +import java.util.stream.IntStream; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.ModelAttribute; 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; +@Slf4j @Controller +@RequiredArgsConstructor @RequestMapping("/users") public class UserController { + private static final String PERMISSION_ADMIN_ANNOUNCEMENT_WRITE = "ADMIN_ANNOUNCEMENT_WRITE"; + private static final int DEFAULT_PAGE = 1; + private static final int DEFAULT_SIZE = 20; + private static final int MAX_SIZE = 50; + + private final AdminUserClient adminUserClient; + @GetMapping - public String list(@RequestParam(required = false) String q, - @RequestParam(required = false) String status, + public String list(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @RequestParam(required = false) String q, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, Model model) { + int normalizedPage = normalizePage(page); + int normalizedSize = normalizeSize(size); + boolean canViewUsers = currentAdminProfile != null + && currentAdminProfile.hasPermission(PERMISSION_ADMIN_ANNOUNCEMENT_WRITE); + model.addAttribute("pageTitle", "사용자 관리"); model.addAttribute("activeMenu", "users"); model.addAttribute("breadcrumbs", List.of(Breadcrumb.of("대시보드", "/"), Breadcrumb.active("사용자"))); - model.addAttribute("toastMessages", List.of( - ToastMessage.success("toastUserAction", "조치 완료", "사용자 상태를 업데이트했습니다."), - ToastMessage.success("toastUserNotify", "알림 발송", "사용자에게 알림을 전송했습니다.") - )); + model.addAttribute("toastMessages", List.of()); model.addAttribute("query", q); - model.addAttribute("statusFilter", status); - model.addAttribute("page", page); - model.addAttribute("size", size); - model.addAttribute("totalPages", 14); - model.addAttribute("totalElements", 512); - model.addAttribute("statusOptions", List.of("전체", "정상", "휴면", "차단")); - model.addAttribute("users", List.of( - new UserListItem(501, "솔바람", "sol@saerok.app", LocalDateTime.now().minusMonths(5), "정상", 48, 2), - new UserListItem(502, "별빛여행", "star@saerok.app", LocalDateTime.now().minusMonths(3), "정상", 36, 0), - new UserListItem(503, "느린풍경", "slow@saerok.app", LocalDateTime.now().minusMonths(8), "차단", 12, 7), - new UserListItem(504, "새벽지기", "dawn@saerok.app", LocalDateTime.now().minusMonths(1), "정상", 18, 1), - new UserListItem(505, "이끼정원", "moss@saerok.app", LocalDateTime.now().minusMonths(10), "휴면", 5, 0) - )); + model.addAttribute("page", normalizedPage); + model.addAttribute("size", normalizedSize); + model.addAttribute("canViewUsers", canViewUsers); + + if (!canViewUsers) { + model.addAttribute("loadError", "사용자 목록을 조회할 권한이 없습니다."); + addEmptyUserListModel(model); + return "users/list"; + } + + try { + AdminUserListResponse response = adminUserClient.listUsers(q, normalizedPage, normalizedSize); + List users = response.users() == null + ? List.of() + : response.users().stream() + .map(item -> new UserListItem(item.id(), item.nickname())) + .toList(); + + model.addAttribute("users", users); + model.addAttribute("totalPages", response.totalPages()); + model.addAttribute("totalElements", response.totalElements()); + model.addAttribute("pageNumbers", pageNumbers(response.totalPages())); + model.addAttribute("loadError", null); + } catch (RestClientResponseException exception) { + log.warn("Failed to load admin users. status={}, body={}", + exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + model.addAttribute("loadError", "사용자 목록을 불러오지 못했습니다. 잠시 후 다시 시도해주세요."); + addEmptyUserListModel(model); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to load admin users.", exception); + model.addAttribute("loadError", "사용자 목록을 불러오지 못했습니다. 잠시 후 다시 시도해주세요."); + addEmptyUserListModel(model); + } + return "users/list"; } - @GetMapping("/{id}") - public String detail(@PathVariable long id, Model model) { - UserDetail detail = new UserDetail(id, "솔바람", "sol@saerok.app", "010-1234-5678", "정상", - LocalDateTime.now().minusMonths(5), LocalDateTime.now().minusDays(1), 48, 2, - List.of( - new UserActivity("새록", "초여름 갈대밭 일지", LocalDateTime.now().minusDays(1), "공개"), - new UserActivity("댓글", "강변 산책 중 만난 물새들", LocalDateTime.now().minusDays(2), "공개"), - new UserActivity("신고", "댓글 신고 #2041", LocalDateTime.now().minusDays(3), "검토중"), - new UserActivity("새록", "가을철 맹금류 관찰 노트", LocalDateTime.now().minusDays(7), "공개") - )); - model.addAttribute("pageTitle", "사용자 상세"); - model.addAttribute("activeMenu", "users"); - model.addAttribute("breadcrumbs", List.of( - Breadcrumb.of("대시보드", "/"), - Breadcrumb.of("사용자", "/users"), - Breadcrumb.active("#" + id))); - model.addAttribute("toastMessages", List.of( - ToastMessage.success("toastUserAction", "조치 완료", "사용자 상태를 업데이트했습니다."), - ToastMessage.success("toastUserNotify", "알림 발송", "사용자에게 알림을 전송했습니다.") - )); - model.addAttribute("detail", detail); - return "users/detail"; + private int normalizePage(int page) { + return page < 1 ? DEFAULT_PAGE : page; + } + + private int normalizeSize(int size) { + if (size < 1 || size > MAX_SIZE) { + return DEFAULT_SIZE; + } + return size; + } + + private List pageNumbers(int totalPages) { + if (totalPages <= 0) { + return List.of(); + } + return IntStream.rangeClosed(1, totalPages) + .boxed() + .toList(); + } + + private void addEmptyUserListModel(Model model) { + model.addAttribute("users", List.of()); + model.addAttribute("totalPages", 0); + model.addAttribute("totalElements", 0L); + model.addAttribute("pageNumbers", List.of()); } } 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/java/apu/saerok_admin/web/view/UserListItem.java b/src/main/java/apu/saerok_admin/web/view/UserListItem.java index 3976470..81130b0 100644 --- a/src/main/java/apu/saerok_admin/web/view/UserListItem.java +++ b/src/main/java/apu/saerok_admin/web/view/UserListItem.java @@ -1,7 +1,4 @@ package apu.saerok_admin.web.view; -import java.time.LocalDateTime; - -public record UserListItem(long id, String nickname, String email, LocalDateTime joinedAt, - String status, int postCount, int reportCount) { +public record UserListItem(long id, String nickname) { } diff --git a/src/main/resources/templates/fragments/_sidebar.html b/src/main/resources/templates/fragments/_sidebar.html index e90fd8a..95a6428 100644 --- a/src/main/resources/templates/fragments/_sidebar.html +++ b/src/main/resources/templates/fragments/_sidebar.html @@ -23,6 +23,10 @@ 서비스 인사이트 + + + 사용자 목록 + @@ -41,6 +45,11 @@ 공지사항 관리 + + + 대상 공지 발송 + @@ -81,6 +90,11 @@
새록 어드민
서비스 인사이트
+ + + 사용자 목록 + @@ -99,6 +113,11 @@
새록 어드민
공지사항 관리 + + + 대상 공지 발송 + @@ -115,5 +134,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..0a87858 100644 --- a/src/main/resources/templates/notifications/compose.html +++ b/src/main/resources/templates/notifications/compose.html @@ -2,138 +2,366 @@ -
-
-
-
-
-
- - + + +
+ + 처리가 완료되었습니다. +
+ +
+
발송 권한이 없습니다.
+
대상 공지 발송에는 공지사항 작성 권한이 필요합니다.
+
+ +
+
+
+
+ +
+ + +
+ +
-
- - -
마크다운 문법을 지원합니다.
+
+
+ 닉네임 검색 결과에서 사용자를 선택하세요. 중복 선택은 자동으로 제외됩니다.
-
- -
- -
+
+ +
+ + +
+ 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명
  • -
-
+ +
+ +
+
-