diff --git a/build.gradle b/build.gradle index 6e1c3e1..0ca7c0d 100644 --- a/build.gradle +++ b/build.gradle @@ -29,7 +29,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' - compileOnly 'org.projectlombok:lombok' + implementation 'org.springframework.boot:spring-boot-starter-validation' + compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/apu/saerok_admin/infra/announcement/AdminAnnouncementClient.java b/src/main/java/apu/saerok_admin/infra/announcement/AdminAnnouncementClient.java new file mode 100644 index 0000000..b1bc01f --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/announcement/AdminAnnouncementClient.java @@ -0,0 +1,110 @@ +package apu.saerok_admin.infra.announcement; + +import apu.saerok_admin.infra.SaerokApiProps; +import apu.saerok_admin.infra.announcement.dto.AdminAnnouncementDetailResponse; +import apu.saerok_admin.infra.announcement.dto.AdminAnnouncementImagePresignRequest; +import apu.saerok_admin.infra.announcement.dto.AdminAnnouncementListResponse; +import apu.saerok_admin.infra.announcement.dto.AdminCreateAnnouncementRequest; +import apu.saerok_admin.infra.announcement.dto.AdminUpdateAnnouncementRequest; +import apu.saerok_admin.infra.announcement.dto.AnnouncementImagePresignResponse; +import java.net.URI; +import java.util.List; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriBuilder; + +@Component +public class AdminAnnouncementClient { + + private static final String[] ADMIN_ANNOUNCEMENT_SEGMENTS = {"admin", "announcement"}; + + private final RestClient saerokRestClient; + private final String[] missingPrefixSegments; + + public AdminAnnouncementClient(RestClient saerokRestClient, SaerokApiProps saerokApiProps) { + this.saerokRestClient = saerokRestClient; + List missing = saerokApiProps.missingPrefixSegments(); + this.missingPrefixSegments = missing.toArray(new String[0]); + } + + public AdminAnnouncementListResponse listAnnouncements() { + return get(AdminAnnouncementListResponse.class); + } + + public AdminAnnouncementDetailResponse getAnnouncement(Long announcementId) { + return get(AdminAnnouncementDetailResponse.class, announcementId.toString()); + } + + public AdminAnnouncementDetailResponse createAnnouncement(AdminCreateAnnouncementRequest request) { + return post(AdminAnnouncementDetailResponse.class, request); + } + + public AdminAnnouncementDetailResponse updateAnnouncement(Long announcementId, AdminUpdateAnnouncementRequest request) { + return put(AdminAnnouncementDetailResponse.class, request, announcementId.toString()); + } + + public void deleteAnnouncement(Long announcementId) { + delete(announcementId.toString()); + } + + public AnnouncementImagePresignResponse generateImagePresignUrl(AdminAnnouncementImagePresignRequest request) { + return post(AnnouncementImagePresignResponse.class, request, "image", "presign"); + } + + private T get(Class responseType, String... segments) { + T response = saerokRestClient.get() + .uri(uriBuilder -> buildUri(uriBuilder, segments)) + .retrieve() + .body(responseType); + + if (response == null) { + throw new IllegalStateException("Empty response from admin announcement API"); + } + return response; + } + + private T post(Class responseType, Object body, String... segments) { + T response = saerokRestClient.post() + .uri(uriBuilder -> buildUri(uriBuilder, segments)) + .body(body) + .retrieve() + .body(responseType); + + if (response == null) { + throw new IllegalStateException("Empty response from admin announcement API"); + } + return response; + } + + private T put(Class responseType, Object body, String... segments) { + T response = saerokRestClient.method(HttpMethod.PUT) + .uri(uriBuilder -> buildUri(uriBuilder, segments)) + .body(body) + .retrieve() + .body(responseType); + + if (response == null) { + throw new IllegalStateException("Empty response from admin announcement API"); + } + return response; + } + + private void delete(String... segments) { + saerokRestClient.delete() + .uri(uriBuilder -> buildUri(uriBuilder, segments)) + .retrieve() + .toBodilessEntity(); + } + + private URI buildUri(UriBuilder builder, String... segments) { + if (missingPrefixSegments.length > 0) { + builder.pathSegment(missingPrefixSegments); + } + builder.pathSegment(ADMIN_ANNOUNCEMENT_SEGMENTS); + if (segments != null && segments.length > 0) { + builder.pathSegment(segments); + } + return builder.build(); + } +} diff --git a/src/main/java/apu/saerok_admin/infra/announcement/dto/AdminAnnouncementDetailResponse.java b/src/main/java/apu/saerok_admin/infra/announcement/dto/AdminAnnouncementDetailResponse.java new file mode 100644 index 0000000..8021402 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/announcement/dto/AdminAnnouncementDetailResponse.java @@ -0,0 +1,30 @@ +package apu.saerok_admin.infra.announcement.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.time.OffsetDateTime; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record AdminAnnouncementDetailResponse( + Long id, + String title, + String content, + String status, + OffsetDateTime scheduledAt, + OffsetDateTime publishedAt, + Boolean sendNotification, + String pushTitle, + String pushBody, + String inAppBody, + String adminName, + List images +) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Image( + String objectKey, + String contentType, + String imageUrl + ) { + } +} diff --git a/src/main/java/apu/saerok_admin/infra/announcement/dto/AdminAnnouncementImagePresignRequest.java b/src/main/java/apu/saerok_admin/infra/announcement/dto/AdminAnnouncementImagePresignRequest.java new file mode 100644 index 0000000..ef0ed3a --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/announcement/dto/AdminAnnouncementImagePresignRequest.java @@ -0,0 +1,6 @@ +package apu.saerok_admin.infra.announcement.dto; + +public record AdminAnnouncementImagePresignRequest( + String contentType +) { +} diff --git a/src/main/java/apu/saerok_admin/infra/announcement/dto/AdminAnnouncementImageRequest.java b/src/main/java/apu/saerok_admin/infra/announcement/dto/AdminAnnouncementImageRequest.java new file mode 100644 index 0000000..e3fb608 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/announcement/dto/AdminAnnouncementImageRequest.java @@ -0,0 +1,7 @@ +package apu.saerok_admin.infra.announcement.dto; + +public record AdminAnnouncementImageRequest( + String objectKey, + String contentType +) { +} diff --git a/src/main/java/apu/saerok_admin/infra/announcement/dto/AdminAnnouncementListResponse.java b/src/main/java/apu/saerok_admin/infra/announcement/dto/AdminAnnouncementListResponse.java new file mode 100644 index 0000000..8fa0fbc --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/announcement/dto/AdminAnnouncementListResponse.java @@ -0,0 +1,22 @@ +package apu.saerok_admin.infra.announcement.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.time.OffsetDateTime; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record AdminAnnouncementListResponse( + List announcements +) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Item( + Long id, + String title, + String status, + OffsetDateTime scheduledAt, + OffsetDateTime publishedAt, + String adminName + ) { + } +} diff --git a/src/main/java/apu/saerok_admin/infra/announcement/dto/AdminCreateAnnouncementRequest.java b/src/main/java/apu/saerok_admin/infra/announcement/dto/AdminCreateAnnouncementRequest.java new file mode 100644 index 0000000..991fbab --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/announcement/dto/AdminCreateAnnouncementRequest.java @@ -0,0 +1,19 @@ +package apu.saerok_admin.infra.announcement.dto; + +import jakarta.validation.Valid; +import java.time.LocalDateTime; +import java.util.List; + +public record AdminCreateAnnouncementRequest( + String title, + String content, + LocalDateTime scheduledAt, + Boolean publishNow, + Boolean sendNotification, + String pushTitle, + String pushBody, + String inAppBody, + @Valid + List images +) { +} diff --git a/src/main/java/apu/saerok_admin/infra/announcement/dto/AdminUpdateAnnouncementRequest.java b/src/main/java/apu/saerok_admin/infra/announcement/dto/AdminUpdateAnnouncementRequest.java new file mode 100644 index 0000000..b86c081 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/announcement/dto/AdminUpdateAnnouncementRequest.java @@ -0,0 +1,19 @@ +package apu.saerok_admin.infra.announcement.dto; + +import jakarta.validation.Valid; +import java.time.LocalDateTime; +import java.util.List; + +public record AdminUpdateAnnouncementRequest( + String title, + String content, + LocalDateTime scheduledAt, + Boolean publishNow, + Boolean sendNotification, + String pushTitle, + String pushBody, + String inAppBody, + @Valid + List images +) { +} diff --git a/src/main/java/apu/saerok_admin/infra/announcement/dto/AnnouncementImagePresignResponse.java b/src/main/java/apu/saerok_admin/infra/announcement/dto/AnnouncementImagePresignResponse.java new file mode 100644 index 0000000..50cb6b3 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/announcement/dto/AnnouncementImagePresignResponse.java @@ -0,0 +1,11 @@ +package apu.saerok_admin.infra.announcement.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record AnnouncementImagePresignResponse( + String presignedUrl, + String objectKey, + String imageUrl +) { +} diff --git a/src/main/java/apu/saerok_admin/infra/role/AdminRoleClient.java b/src/main/java/apu/saerok_admin/infra/role/AdminRoleClient.java index 9e53351..9f2e285 100644 --- a/src/main/java/apu/saerok_admin/infra/role/AdminRoleClient.java +++ b/src/main/java/apu/saerok_admin/infra/role/AdminRoleClient.java @@ -1,6 +1,7 @@ package apu.saerok_admin.infra.role; import apu.saerok_admin.infra.SaerokApiProps; +import apu.saerok_admin.infra.role.dto.AdminPermissionListResponse; import apu.saerok_admin.infra.role.dto.AdminMyRoleResponse; import apu.saerok_admin.infra.role.dto.AdminRoleListResponse; import apu.saerok_admin.infra.role.dto.AdminRoleUserListResponse; @@ -42,6 +43,10 @@ public AdminRoleListResponse listRoles() { return get(AdminRoleListResponse.class); } + public AdminPermissionListResponse listPermissions() { + return get(AdminPermissionListResponse.class, "permissions"); + } + public RoleDetailResponse createRole(CreateRoleRequest request) { return post(RoleDetailResponse.class, request); } diff --git a/src/main/java/apu/saerok_admin/infra/role/dto/AdminPermissionListResponse.java b/src/main/java/apu/saerok_admin/infra/role/dto/AdminPermissionListResponse.java new file mode 100644 index 0000000..ec517c8 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/role/dto/AdminPermissionListResponse.java @@ -0,0 +1,8 @@ +package apu.saerok_admin.infra.role.dto; + +import java.util.List; + +public record AdminPermissionListResponse( + List permissions +) { +} diff --git a/src/main/java/apu/saerok_admin/web/NoticeController.java b/src/main/java/apu/saerok_admin/web/NoticeController.java new file mode 100644 index 0000000..5e478d0 --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/NoticeController.java @@ -0,0 +1,496 @@ +package apu.saerok_admin.web; + +import apu.saerok_admin.infra.announcement.AdminAnnouncementClient; +import apu.saerok_admin.infra.announcement.dto.AdminAnnouncementDetailResponse; +import apu.saerok_admin.infra.announcement.dto.AdminAnnouncementImagePresignRequest; +import apu.saerok_admin.infra.announcement.dto.AdminAnnouncementImageRequest; +import apu.saerok_admin.infra.announcement.dto.AdminAnnouncementListResponse; +import apu.saerok_admin.infra.announcement.dto.AdminCreateAnnouncementRequest; +import apu.saerok_admin.infra.announcement.dto.AdminUpdateAnnouncementRequest; +import apu.saerok_admin.infra.announcement.dto.AnnouncementImagePresignResponse; +import apu.saerok_admin.web.view.Breadcrumb; +import apu.saerok_admin.web.view.CurrentAdminProfile; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +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.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +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("/notices") +public class NoticeController { + + private static final String PERMISSION_ADMIN_ANNOUNCEMENT_READ = "ADMIN_ANNOUNCEMENT_READ"; + private static final String PERMISSION_ADMIN_ANNOUNCEMENT_WRITE = "ADMIN_ANNOUNCEMENT_WRITE"; + + private static final DateTimeFormatter DATETIME_LOCAL_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm"); + private static final ZoneId KST_ZONE = ZoneId.of("Asia/Seoul"); + + private final AdminAnnouncementClient adminAnnouncementClient; + private final ObjectMapper objectMapper; + + @GetMapping + public String index(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + Model model) { + model.addAttribute("pageTitle", "공지사항 관리"); + model.addAttribute("activeMenu", "notices"); + model.addAttribute("breadcrumbs", List.of( + Breadcrumb.of("대시보드", "/"), + Breadcrumb.active("공지사항 관리") + )); + model.addAttribute("toastMessages", List.of()); + + boolean canRead = currentAdminProfile != null && currentAdminProfile.hasPermission(PERMISSION_ADMIN_ANNOUNCEMENT_READ); + boolean canWrite = currentAdminProfile != null && currentAdminProfile.hasPermission(PERMISSION_ADMIN_ANNOUNCEMENT_WRITE); + model.addAttribute("canWrite", canWrite); + + if (!canRead) { + model.addAttribute("loadError", "공지사항을 조회할 권한이 없습니다."); + model.addAttribute("announcements", List.of()); + return "notices/index"; + } + + try { + AdminAnnouncementListResponse response = adminAnnouncementClient.listAnnouncements(); + List items = response != null && response.announcements() != null + ? response.announcements() + : List.of(); + model.addAttribute("announcements", items); + model.addAttribute("loadError", null); + } catch (RestClientResponseException exception) { + log.warn("Failed to load announcements. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + model.addAttribute("loadError", "공지사항 목록을 불러오지 못했습니다. 잠시 후 다시 시도해주세요."); + model.addAttribute("announcements", List.of()); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to load announcements.", exception); + model.addAttribute("loadError", "공지사항 목록을 불러오지 못했습니다. 잠시 후 다시 시도해주세요."); + model.addAttribute("announcements", List.of()); + } + + return "notices/index"; + } + + @GetMapping("/{id:\\d+}") + public String detail(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @PathVariable Long id, + Model model, + RedirectAttributes redirectAttributes) { + boolean canRead = currentAdminProfile != null && currentAdminProfile.hasPermission(PERMISSION_ADMIN_ANNOUNCEMENT_READ); + boolean canWrite = currentAdminProfile != null && currentAdminProfile.hasPermission(PERMISSION_ADMIN_ANNOUNCEMENT_WRITE); + + if (!canRead) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "공지사항을 조회할 권한이 없습니다."); + return "redirect:/notices"; + } + + try { + AdminAnnouncementDetailResponse response = adminAnnouncementClient.getAnnouncement(id); + if (response == null) { + throw new IllegalStateException("Empty announcement detail response"); + } + + model.addAttribute("pageTitle", "공지사항 상세"); + model.addAttribute("activeMenu", "notices"); + model.addAttribute("breadcrumbs", List.of( + Breadcrumb.of("대시보드", "/"), + Breadcrumb.of("공지사항 관리", "/notices"), + Breadcrumb.active("상세") + )); + model.addAttribute("toastMessages", List.of()); + model.addAttribute("detail", response); + model.addAttribute("canWrite", canWrite); + model.addAttribute("canEdit", canWrite && !"PUBLISHED".equalsIgnoreCase(response.status())); + return "notices/detail"; + } catch (RestClientResponseException exception) { + log.warn("Failed to load announcement detail. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "공지사항을 불러오지 못했습니다. 잠시 후 다시 시도해주세요."); + return "redirect:/notices"; + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to load announcement detail.", exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "공지사항을 불러오지 못했습니다. 잠시 후 다시 시도해주세요."); + return "redirect:/notices"; + } + } + + @GetMapping("/new") + public String createForm(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + Model model, + RedirectAttributes redirectAttributes) { + if (currentAdminProfile == null || !currentAdminProfile.hasPermission(PERMISSION_ADMIN_ANNOUNCEMENT_WRITE)) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "공지사항을 작성할 권한이 없습니다."); + return "redirect:/notices"; + } + + model.addAttribute("pageTitle", "공지사항 작성"); + model.addAttribute("activeMenu", "notices"); + model.addAttribute("breadcrumbs", List.of( + Breadcrumb.of("대시보드", "/"), + Breadcrumb.of("공지사항 관리", "/notices"), + Breadcrumb.active("작성") + )); + model.addAttribute("toastMessages", List.of()); + model.addAttribute("isEdit", false); + model.addAttribute("form", NoticeForm.empty()); + model.addAttribute("initialImagesJson", "[]"); + return "notices/compose"; + } + + @GetMapping("/edit") + public String editForm(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @RequestParam Long id, + Model model, + RedirectAttributes redirectAttributes) { + if (currentAdminProfile == null || !currentAdminProfile.hasPermission(PERMISSION_ADMIN_ANNOUNCEMENT_WRITE)) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "공지사항을 수정할 권한이 없습니다."); + return "redirect:/notices"; + } + + try { + AdminAnnouncementDetailResponse response = adminAnnouncementClient.getAnnouncement(id); + if (response == null) { + throw new IllegalStateException("Empty announcement detail response"); + } + + if ("PUBLISHED".equalsIgnoreCase(response.status())) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "이미 게시된 공지사항은 수정할 수 없습니다."); + return "redirect:/notices"; + } + + String scheduledAt = formatForDatetimeLocal(response.scheduledAt()); + NoticeForm form = new NoticeForm( + response.id(), + nullToEmpty(response.title()), + nullToEmpty(response.content()), + Boolean.TRUE.equals(response.sendNotification()), + nullToEmpty(response.pushTitle()), + nullToEmpty(response.pushBody()), + nullToEmpty(response.inAppBody()), + scheduledAt + ); + + model.addAttribute("pageTitle", "공지사항 수정"); + model.addAttribute("activeMenu", "notices"); + model.addAttribute("breadcrumbs", List.of( + Breadcrumb.of("대시보드", "/"), + Breadcrumb.of("공지사항 관리", "/notices"), + Breadcrumb.active("수정") + )); + model.addAttribute("toastMessages", List.of()); + model.addAttribute("isEdit", true); + model.addAttribute("form", form); + model.addAttribute("initialImagesJson", toImagesJson(response.images())); + return "notices/compose"; + } catch (RestClientResponseException exception) { + log.warn("Failed to load announcement detail. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "공지사항을 불러오지 못했습니다. 잠시 후 다시 시도해주세요."); + return "redirect:/notices"; + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to load announcement detail.", exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "공지사항을 불러오지 못했습니다. 잠시 후 다시 시도해주세요."); + return "redirect:/notices"; + } + } + + @PostMapping("/create") + public String createAnnouncement(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @RequestParam String title, + @RequestParam(name = "contentHtml") String contentHtml, + @RequestParam(name = "publishNow", defaultValue = "false") boolean publishNow, + @RequestParam(name = "scheduledAt", required = false) String scheduledAtRaw, + @RequestParam(name = "sendNotification", defaultValue = "false") boolean sendNotification, + @RequestParam(name = "pushTitle", required = false) String pushTitle, + @RequestParam(name = "pushBody", required = false) String pushBody, + @RequestParam(name = "inAppBody", required = false) String inAppBody, + @RequestParam(name = "imagesJson", required = false) String imagesJson, + RedirectAttributes redirectAttributes) { + if (currentAdminProfile == null || !currentAdminProfile.hasPermission(PERMISSION_ADMIN_ANNOUNCEMENT_WRITE)) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "공지사항을 작성할 권한이 없습니다."); + return "redirect:/notices"; + } + + if (!StringUtils.hasText(title) || !StringUtils.hasText(contentHtml)) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "필수 입력값을 모두 채워주세요."); + return "redirect:/notices/new"; + } + + LocalDateTime scheduledAt = parseDatetimeLocal(scheduledAtRaw); + if (!publishNow && scheduledAt == null) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "예약 게시 시각을 입력해 주세요."); + return "redirect:/notices/new"; + } + + if (sendNotification && (!StringUtils.hasText(pushTitle) || !StringUtils.hasText(pushBody) || !StringUtils.hasText(inAppBody))) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "알림을 보낼 경우 푸시 제목/본문과 인앱 알림 본문을 모두 입력해 주세요."); + return "redirect:/notices/new"; + } + + List images = parseImages(imagesJson); + + try { + adminAnnouncementClient.createAnnouncement(new AdminCreateAnnouncementRequest( + title.trim(), + contentHtml, + scheduledAt, + publishNow, + sendNotification, + trimToNull(pushTitle), + trimToNull(pushBody), + trimToNull(inAppBody), + images + )); + redirectAttributes.addFlashAttribute("flashStatus", "success"); + redirectAttributes.addFlashAttribute("flashMessage", publishNow ? "공지사항이 게시되었습니다." : "공지사항이 예약되었습니다."); + return "redirect:/notices"; + } catch (RestClientResponseException exception) { + log.warn("Failed to create announcement. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "공지사항 저장에 실패했습니다. 입력값을 확인해 주세요."); + return "redirect:/notices/new"; + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to create announcement.", exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "공지사항 저장에 실패했습니다. 잠시 후 다시 시도해주세요."); + return "redirect:/notices/new"; + } + } + + @PostMapping("/edit") + public String updateAnnouncement(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @RequestParam Long id, + @RequestParam String title, + @RequestParam(name = "contentHtml") String contentHtml, + @RequestParam(name = "publishNow", defaultValue = "false") boolean publishNow, + @RequestParam(name = "scheduledAt", required = false) String scheduledAtRaw, + @RequestParam(name = "sendNotification", defaultValue = "false") boolean sendNotification, + @RequestParam(name = "pushTitle", required = false) String pushTitle, + @RequestParam(name = "pushBody", required = false) String pushBody, + @RequestParam(name = "inAppBody", required = false) String inAppBody, + @RequestParam(name = "imagesJson", required = false) String imagesJson, + RedirectAttributes redirectAttributes) { + if (currentAdminProfile == null || !currentAdminProfile.hasPermission(PERMISSION_ADMIN_ANNOUNCEMENT_WRITE)) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "공지사항을 수정할 권한이 없습니다."); + return "redirect:/notices"; + } + + if (!StringUtils.hasText(title) || !StringUtils.hasText(contentHtml)) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "필수 입력값을 모두 채워주세요."); + return "redirect:/notices/edit?id=" + id; + } + + LocalDateTime scheduledAt = parseDatetimeLocal(scheduledAtRaw); + if (!publishNow && scheduledAt == null) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "예약 게시 시각을 입력해 주세요."); + return "redirect:/notices/edit?id=" + id; + } + + if (sendNotification && (!StringUtils.hasText(pushTitle) || !StringUtils.hasText(pushBody) || !StringUtils.hasText(inAppBody))) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "알림을 보낼 경우 푸시 제목/본문과 인앱 알림 본문을 모두 입력해 주세요."); + return "redirect:/notices/edit?id=" + id; + } + + List images = parseImages(imagesJson); + + try { + adminAnnouncementClient.updateAnnouncement(id, new AdminUpdateAnnouncementRequest( + title.trim(), + contentHtml, + scheduledAt, + publishNow, + sendNotification, + trimToNull(pushTitle), + trimToNull(pushBody), + trimToNull(inAppBody), + images + )); + redirectAttributes.addFlashAttribute("flashStatus", "success"); + redirectAttributes.addFlashAttribute("flashMessage", publishNow ? "공지사항이 게시되었습니다." : "공지사항이 저장되었습니다."); + return "redirect:/notices"; + } catch (RestClientResponseException exception) { + log.warn("Failed to update announcement. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "공지사항 저장에 실패했습니다. 입력값을 확인해 주세요."); + return "redirect:/notices/edit?id=" + id; + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to update announcement.", exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "공지사항 저장에 실패했습니다. 잠시 후 다시 시도해주세요."); + return "redirect:/notices/edit?id=" + id; + } + } + + @PostMapping("/delete") + public String deleteAnnouncement(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @RequestParam Long id, + RedirectAttributes redirectAttributes) { + if (currentAdminProfile == null || !currentAdminProfile.hasPermission(PERMISSION_ADMIN_ANNOUNCEMENT_WRITE)) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "공지사항을 삭제할 권한이 없습니다."); + return "redirect:/notices"; + } + + try { + adminAnnouncementClient.deleteAnnouncement(id); + redirectAttributes.addFlashAttribute("flashStatus", "success"); + redirectAttributes.addFlashAttribute("flashMessage", "공지사항이 삭제되었습니다."); + } catch (RestClientResponseException exception) { + log.warn("Failed to delete announcement. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "공지사항 삭제에 실패했습니다."); + } catch (RestClientException exception) { + log.warn("Failed to delete announcement.", exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "공지사항 삭제에 실패했습니다. 잠시 후 다시 시도해주세요."); + } + + return "redirect:/notices"; + } + + @PostMapping("/image/presign") + @ResponseBody + public ResponseEntity presignImage(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @RequestBody Map payload) { + if (currentAdminProfile == null || !currentAdminProfile.hasPermission(PERMISSION_ADMIN_ANNOUNCEMENT_WRITE)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(Map.of("message", "이미지 업로드 권한이 없습니다.")); + } + + String contentType = payload != null ? payload.get("contentType") : null; + if (!StringUtils.hasText(contentType)) { + return ResponseEntity.badRequest().body(Map.of("message", "contentType이 필요합니다.")); + } + + try { + AnnouncementImagePresignResponse presign = adminAnnouncementClient.generateImagePresignUrl( + new AdminAnnouncementImagePresignRequest(contentType) + ); + + Map result = new LinkedHashMap<>(); + result.put("presignedUrl", presign.presignedUrl()); + result.put("objectKey", presign.objectKey()); + result.put("imageUrl", presign.imageUrl()); + return ResponseEntity.ok(result); + } catch (RestClientResponseException exception) { + log.warn("Failed to request announcement image presign. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + return ResponseEntity.status(exception.getStatusCode()).body(Map.of("message", "Presigned URL 발급에 실패했습니다.")); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to request announcement image presign.", exception); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("message", "Presigned URL 발급에 실패했습니다.")); + } + } + + private List parseImages(String imagesJson) { + if (!StringUtils.hasText(imagesJson)) { + return List.of(); + } + try { + return objectMapper.readValue(imagesJson, new TypeReference>() {}); + } catch (Exception exception) { + log.warn("Failed to parse imagesJson.", exception); + return List.of(); + } + } + + private String toImagesJson(List images) { + if (images == null || images.isEmpty()) { + return "[]"; + } + try { + return objectMapper.writeValueAsString(images); + } catch (Exception exception) { + log.warn("Failed to serialize images.", exception); + return "[]"; + } + } + + private static LocalDateTime parseDatetimeLocal(String raw) { + if (!StringUtils.hasText(raw)) { + return null; + } + try { + return LocalDateTime.parse(raw); + } catch (Exception exception) { + try { + return LocalDateTime.parse(raw, DATETIME_LOCAL_FORMAT); + } catch (Exception ignore) { + return null; + } + } + } + + private static String formatForDatetimeLocal(OffsetDateTime offsetDateTime) { + if (offsetDateTime == null) { + return ""; + } + try { + return offsetDateTime.atZoneSameInstant(KST_ZONE).toLocalDateTime().format(DATETIME_LOCAL_FORMAT); + } catch (Exception exception) { + return ""; + } + } + + private static String trimToNull(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + return value.trim(); + } + + private static String nullToEmpty(String value) { + return value == null ? "" : value; + } + + public record NoticeForm( + Long id, + String title, + String contentHtml, + boolean sendNotification, + String pushTitle, + String pushBody, + String inAppBody, + String scheduledAt + ) { + static NoticeForm empty() { + return new NoticeForm(null, "", "", false, "", "", "", ""); + } + } +} diff --git a/src/main/java/apu/saerok_admin/web/NoticeImageMockController.java b/src/main/java/apu/saerok_admin/web/NoticeImageMockController.java new file mode 100644 index 0000000..a0e5974 --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/NoticeImageMockController.java @@ -0,0 +1,156 @@ +package apu.saerok_admin.web; + +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +/** + * 공지사항 에디터(Quill) 이미지 업로드를 위한 Mock 엔드포인트. + * + * - 실제 구현에서는 "백엔드 서비스 서버"에서 Presigned URL을 발급하고(S3 PUT URL), + * 클라이언트는 해당 URL로 업로드한 뒤, 반환받은 public URL을 본문에 삽입한다. + * - 현재는 어드민 서버 로컬 임시 폴더에 저장하고, /public/mock 경로로 서빙한다. + */ +@RestController +public class NoticeImageMockController { + + private final Path storageDir; + private final Map contentTypes = new ConcurrentHashMap<>(); + + public NoticeImageMockController() { + try { + this.storageDir = Paths.get(System.getProperty("java.io.tmpdir"), "saerok-admin-mock-notice-images"); + Files.createDirectories(storageDir); + } catch (IOException exception) { + throw new IllegalStateException("Failed to initialize mock notice image storage directory.", exception); + } + } + + @PostMapping(path = "/public/mock/notices/images/presign", produces = MediaType.APPLICATION_JSON_VALUE) + public PresignResponse presign(@RequestBody(required = false) PresignRequest request) { + String contentType = request != null ? request.contentType() : null; + String filename = request != null ? request.filename() : null; + + String extension = resolveExtension(contentType, filename); + String objectKey = UUID.randomUUID() + (StringUtils.hasText(extension) ? "." + extension : ""); + + String uploadUrl = ServletUriComponentsBuilder.fromCurrentContextPath() + .path("/public/mock/notices/images/upload/") + .path(objectKey) + .toUriString(); + + String imageUrl = ServletUriComponentsBuilder.fromCurrentContextPath() + .path("/public/mock/notices/images/") + .path(objectKey) + .toUriString(); + + return new PresignResponse(uploadUrl, imageUrl, objectKey); + } + + @PutMapping(path = "/public/mock/notices/images/upload/{objectKey:.+}", consumes = MediaType.ALL_VALUE) + public ResponseEntity upload(@PathVariable String objectKey, HttpServletRequest request) throws IOException { + if (!StringUtils.hasText(objectKey)) { + return ResponseEntity.badRequest().build(); + } + + Path target = storageDir.resolve(objectKey); + try (InputStream inputStream = request.getInputStream()) { + Files.copy(inputStream, target, StandardCopyOption.REPLACE_EXISTING); + } + + String contentType = request.getContentType(); + if (StringUtils.hasText(contentType)) { + contentTypes.put(objectKey, contentType); + } + + return ResponseEntity.noContent().build(); + } + + @GetMapping(path = "/public/mock/notices/images/{objectKey:.+}") + public ResponseEntity view(@PathVariable String objectKey) throws IOException { + if (!StringUtils.hasText(objectKey)) { + return ResponseEntity.notFound().build(); + } + + Path target = storageDir.resolve(objectKey); + if (!Files.exists(target)) { + return ResponseEntity.notFound().build(); + } + + String contentType = contentTypes.get(objectKey); + if (!StringUtils.hasText(contentType)) { + contentType = Files.probeContentType(target); + } + MediaType mediaType = parseMediaTypeOrFallback(contentType); + + return ResponseEntity.ok() + .contentType(mediaType) + .cacheControl(CacheControl.noCache()) + .body(new InputStreamResource(Files.newInputStream(target))); + } + + private static MediaType parseMediaTypeOrFallback(String contentType) { + if (!StringUtils.hasText(contentType)) { + return MediaType.APPLICATION_OCTET_STREAM; + } + try { + return MediaType.parseMediaType(contentType); + } catch (IllegalArgumentException ignored) { + return MediaType.APPLICATION_OCTET_STREAM; + } + } + + private static String resolveExtension(String contentType, String filename) { + String normalizedContentType = StringUtils.hasText(contentType) ? contentType.toLowerCase(Locale.ROOT) : ""; + + if (normalizedContentType.startsWith("image/")) { + String subtype = normalizedContentType.substring("image/".length()); + if ("jpeg".equals(subtype) || "jpg".equals(subtype)) { + return "jpg"; + } + if ("png".equals(subtype) || "webp".equals(subtype) || "gif".equals(subtype)) { + return subtype; + } + if ("svg+xml".equals(subtype)) { + return "svg"; + } + } + + if (StringUtils.hasText(filename) && filename.contains(".")) { + String ext = filename.substring(filename.lastIndexOf('.') + 1); + ext = ext.trim().toLowerCase(Locale.ROOT); + if (ext.length() <= 8 && ext.matches("[a-z0-9]+")) { + return ext; + } + } + + return "bin"; + } + + public record PresignRequest(String filename, String contentType) { + } + + public record PresignResponse(String uploadUrl, String imageUrl, String objectKey) { + } +} diff --git a/src/main/java/apu/saerok_admin/web/RoleManagementController.java b/src/main/java/apu/saerok_admin/web/RoleManagementController.java index c0e1b59..1f5ace0 100644 --- a/src/main/java/apu/saerok_admin/web/RoleManagementController.java +++ b/src/main/java/apu/saerok_admin/web/RoleManagementController.java @@ -2,6 +2,7 @@ import apu.saerok_admin.infra.role.AdminRoleClient; import apu.saerok_admin.infra.role.dto.AdminMyRoleResponse; +import apu.saerok_admin.infra.role.dto.AdminPermissionListResponse; import apu.saerok_admin.infra.role.dto.AdminRoleListResponse; import apu.saerok_admin.infra.role.dto.AdminRoleUserListResponse; import apu.saerok_admin.infra.role.dto.AdminUserRoleResponse; @@ -13,7 +14,6 @@ import apu.saerok_admin.infra.role.dto.UpdateRolePermissionsRequest; import apu.saerok_admin.web.view.Breadcrumb; import apu.saerok_admin.web.view.CurrentAdminProfile; -import apu.saerok_admin.web.view.role.PermissionCatalog; import apu.saerok_admin.web.view.role.PermissionOptionView; import apu.saerok_admin.web.view.role.PermissionView; import apu.saerok_admin.web.view.role.RoleDisplay; @@ -104,7 +104,6 @@ public String index(@ModelAttribute("currentAdminProfile") CurrentAdminProfile c (left, right) -> left, LinkedHashMap::new )); - permissionOptions = buildPermissionOptions(roleTemplates); } catch (RestClientResponseException exception) { log.warn("Failed to load role templates. status={}, body={}", exception.getStatusCode(), exception.getResponseBodyAsString(), exception); @@ -115,6 +114,32 @@ public String index(@ModelAttribute("currentAdminProfile") CurrentAdminProfile c } } + if (canManageRoles) { + try { + AdminPermissionListResponse response = adminRoleClient.listPermissions(); + permissionOptions = Optional.ofNullable(response) + .map(AdminPermissionListResponse::permissions) + .orElseGet(List::of) + .stream() + .map(permission -> new PermissionOptionView(permission.key(), permission.description())) + .collect(Collectors.toMap( + PermissionOptionView::key, + option -> option, + (left, right) -> left, + LinkedHashMap::new + )) + .values() + .stream() + .sorted(permissionOptionComparator()) + .toList(); + } catch (RestClientResponseException exception) { + log.warn("Failed to load permissions. status={}, body={}", + exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to load permissions.", exception); + } + } + String normalizedRoleCode = normalizeRoleCode(selectedRoleCode); RoleTemplateView selectedRoleTemplate = roleTemplatesByCode.get(normalizedRoleCode); if (selectedRoleTemplate == null && !roleTemplates.isEmpty()) { @@ -566,20 +591,10 @@ private TeamMemberView toTeamMemberView(AdminUserRoleResponse response) { ); } - private List buildPermissionOptions(List templates) { - Map deduplicated = new LinkedHashMap<>(); - for (RoleTemplateView template : templates) { - for (PermissionView permission : template.permissions()) { - deduplicated.put(permission.key(), new PermissionOptionView(permission.key(), permission.description())); - } - } - for (PermissionOptionView builtin : PermissionCatalog.builtinPermissions()) { - deduplicated.putIfAbsent(builtin.key(), builtin); - } - Comparator comparator = Comparator + private Comparator permissionOptionComparator() { + return Comparator .comparing(PermissionOptionView::label, String.CASE_INSENSITIVE_ORDER) .thenComparing(PermissionOptionView::key, String.CASE_INSENSITIVE_ORDER); - return deduplicated.values().stream().sorted(comparator).toList(); } private String normalizeTab(String requestedTab, boolean canViewTeamRoles, boolean canManageRoles) { diff --git a/src/main/resources/templates/fragments/_sidebar.html b/src/main/resources/templates/fragments/_sidebar.html index 1649ec3..e90fd8a 100644 --- a/src/main/resources/templates/fragments/_sidebar.html +++ b/src/main/resources/templates/fragments/_sidebar.html @@ -36,11 +36,11 @@ 광고 관리 - + - 시스템 알림 발송 - + 공지사항 관리 + @@ -94,11 +94,11 @@
새록 어드민
광고 관리
- + - 시스템 알림 발송 - + 공지사항 관리 + diff --git a/src/main/resources/templates/notices/compose.html b/src/main/resources/templates/notices/compose.html new file mode 100644 index 0000000..38f3b97 --- /dev/null +++ b/src/main/resources/templates/notices/compose.html @@ -0,0 +1,409 @@ + + + + + + + + +
+
+
+
+
+ +
작업이 완료되었습니다.
+
+ +
+
+ + +
+ +
+ +
+
+
+ + + + + + +
+ +
+
+
+
+ + +
+
+ 옵션을 켜면 공지 게시 시 푸시 알림인앱 알림 내용도 함께 작성합니다. +
+ + +
+
+
+ +
+
+
+
+
+
+ +
지금 바로 게시
+
+
+ 즉시 게시로 설정하면 저장과 동시에 공지사항이 바로 노출됩니다. +
+
+ +
+
+
+
+
+
+
+
+ +
예약해서 게시
+
+
+ 게시 시각을 선택한 뒤 예약해서 게시하기를 누르면 해당 시각에 자동으로 게시됩니다. +
+
+ + +
+
+ +
+
+
+
+
+
+
+ +
+
+
+ +
+ + + +
+ diff --git a/src/main/resources/templates/notices/detail.html b/src/main/resources/templates/notices/detail.html new file mode 100644 index 0000000..8ae7bc3 --- /dev/null +++ b/src/main/resources/templates/notices/detail.html @@ -0,0 +1,105 @@ + + + +
+ +
작업이 완료되었습니다.
+
+ +
+ + diff --git a/src/main/resources/templates/notices/index.html b/src/main/resources/templates/notices/index.html new file mode 100644 index 0000000..58897c9 --- /dev/null +++ b/src/main/resources/templates/notices/index.html @@ -0,0 +1,109 @@ + + + +
+
+

공지사항 관리

+

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

+
+ +
+ +
+ +
작업이 완료되었습니다.
+
+ +
+
공지사항을 불러올 수 없습니다.
+
잠시 후 다시 시도해주세요.
+
+ +
+
+
+

공지사항 목록

+

+ 총 0건 +

+
+
+ +
+
+ 등록된 공지사항이 없습니다. +
+ + +
+ +
+
+ ※ 공지사항 작성/수정/삭제 권한이 없습니다. +
+
+
+
+