From cecfb35f5ded503667519e66262e03a1595806a9 Mon Sep 17 00:00:00 2001 From: soonduck-dreams Date: Sun, 21 Dec 2025 23:37:36 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EA=B4=80=EB=A6=AC=20draft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../saerok_admin/web/NoticeController.java | 42 +++ .../web/NoticeImageMockController.java | 156 ++++++++ .../templates/fragments/_sidebar.html | 16 +- .../resources/templates/notices/compose.html | 337 ++++++++++++++++++ .../resources/templates/notices/index.html | 29 ++ 5 files changed, 572 insertions(+), 8 deletions(-) create mode 100644 src/main/java/apu/saerok_admin/web/NoticeController.java create mode 100644 src/main/java/apu/saerok_admin/web/NoticeImageMockController.java create mode 100644 src/main/resources/templates/notices/compose.html create mode 100644 src/main/resources/templates/notices/index.html 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..64373db --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/NoticeController.java @@ -0,0 +1,42 @@ +package apu.saerok_admin.web; + +import apu.saerok_admin.web.view.Breadcrumb; +import apu.saerok_admin.web.view.ToastMessage; +import java.util.List; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/notices") +public class NoticeController { + + @GetMapping + public String index(Model model) { + model.addAttribute("pageTitle", "공지사항 관리"); + model.addAttribute("activeMenu", "notices"); + model.addAttribute("breadcrumbs", List.of( + Breadcrumb.of("대시보드", "/"), + Breadcrumb.active("공지사항") + )); + model.addAttribute("toastMessages", List.of()); + return "notices/index"; + } + + @GetMapping("/new") + public String compose(Model model) { + model.addAttribute("pageTitle", "공지사항 작성"); + model.addAttribute("activeMenu", "notices"); + model.addAttribute("breadcrumbs", List.of( + Breadcrumb.of("대시보드", "/"), + Breadcrumb.of("공지사항", "/notices"), + Breadcrumb.active("작성") + )); + model.addAttribute("toastMessages", List.of( + ToastMessage.info("toastNoticePublishPending", "준비 중", "백엔드 연동이 완료되면 공지사항을 게시할 수 있어요."), + ToastMessage.info("toastNoticeSavedPending", "준비 중", "현재는 저장/게시 API 연동이 되어 있지 않습니다.") + )); + return "notices/compose"; + } +} 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/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..85f803b --- /dev/null +++ b/src/main/resources/templates/notices/compose.html @@ -0,0 +1,337 @@ + + + + + + + + +
+
+
+
+
+
+ + +
+ +
+ +
+
+
+
+ 굵게/기울임/밑줄/헤딩/리스트/링크/이미지만 지원합니다. (이미지 업로드는 현재 Mock) +
+ +
+ +
+
+
+
+ + +
+
+ 옵션을 켜면 공지 게시 시 푸시 알림인앱 알림 내용도 함께 작성합니다. +
+ + +
+
+
+ +
+
+
+
+
+
게시 방식
+
+ 지금 게시하거나, 특정 날짜/시각에 예약 게시할 수 있습니다. (현재는 버튼 동작만 Mock) +
+
+
+ +
+
+ + +
예약 게시 버튼을 누르면 위 시각으로 예약됩니다.
+
+
+ +
+ + + +
+
+
+
+
+ +
+
현재 상태
+
+ 이 화면은 UI + Quill 에디터까지만 구성되어 있고, + 게시/예약/저장 API는 아직 연동되지 않았습니다. + 이미지 업로드는 어드민 서버의 Mock 엔드포인트로 임시 저장됩니다. +
+
+
+
+
+ +
+
+
+

작성 가이드

+
    +
  • 제목은 한 줄로 간결하게 작성하세요.
  • +
  • 이미지는 중요한 지점에만 넣고, 여러 장을 넣을 때는 간격을 두세요.
  • +
  • 알림 옵션을 켠 경우, 푸시/인앱 문구는 공지 본문보다 더 짧게 작성하세요.
  • +
  • 예약 게시 시각은 서버 시간 기준으로 처리되도록(추후) 맞춰주세요.
  • +
+
+
+
+
+ + + +
+ diff --git a/src/main/resources/templates/notices/index.html b/src/main/resources/templates/notices/index.html new file mode 100644 index 0000000..ca067ed --- /dev/null +++ b/src/main/resources/templates/notices/index.html @@ -0,0 +1,29 @@ + + + +
+ +
+
+
+ 현재는 공지사항 작성 기능만 제공됩니다. +
+ 게시/예약/목록/수정/삭제 기능은 백엔드 연동이 완료되면 추가될 예정입니다. +
+
+
+
+ + From 6fcc9b4abdece7204c3eaf24d5e21fedcdc20c28 Mon Sep 17 00:00:00 2001 From: soonduck-dreams Date: Mon, 22 Dec 2025 16:42:30 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +- .../announcement/AdminAnnouncementClient.java | 110 +++++ .../dto/AdminAnnouncementDetailResponse.java | 30 ++ .../AdminAnnouncementImagePresignRequest.java | 6 + .../dto/AdminAnnouncementImageRequest.java | 7 + .../dto/AdminAnnouncementListResponse.java | 22 + .../dto/AdminCreateAnnouncementRequest.java | 19 + .../dto/AdminUpdateAnnouncementRequest.java | 19 + .../dto/AnnouncementImagePresignResponse.java | 11 + .../saerok_admin/web/NoticeController.java | 419 +++++++++++++++++- .../resources/templates/notices/compose.html | 310 ++++++++----- .../resources/templates/notices/index.html | 85 +++- 12 files changed, 917 insertions(+), 124 deletions(-) create mode 100644 src/main/java/apu/saerok_admin/infra/announcement/AdminAnnouncementClient.java create mode 100644 src/main/java/apu/saerok_admin/infra/announcement/dto/AdminAnnouncementDetailResponse.java create mode 100644 src/main/java/apu/saerok_admin/infra/announcement/dto/AdminAnnouncementImagePresignRequest.java create mode 100644 src/main/java/apu/saerok_admin/infra/announcement/dto/AdminAnnouncementImageRequest.java create mode 100644 src/main/java/apu/saerok_admin/infra/announcement/dto/AdminAnnouncementListResponse.java create mode 100644 src/main/java/apu/saerok_admin/infra/announcement/dto/AdminCreateAnnouncementRequest.java create mode 100644 src/main/java/apu/saerok_admin/infra/announcement/dto/AdminUpdateAnnouncementRequest.java create mode 100644 src/main/java/apu/saerok_admin/infra/announcement/dto/AnnouncementImagePresignResponse.java 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/web/NoticeController.java b/src/main/java/apu/saerok_admin/web/NoticeController.java index 64373db..d7371c8 100644 --- a/src/main/java/apu/saerok_admin/web/NoticeController.java +++ b/src/main/java/apu/saerok_admin/web/NoticeController.java @@ -1,42 +1,443 @@ 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.ToastMessage; +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.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.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 final AdminAnnouncementClient adminAnnouncementClient; + private final ObjectMapper objectMapper; + @GetMapping - public String index(Model model) { + 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("공지사항") + 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("/new") - public String compose(Model model) { + 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.of("공지사항 관리", "/notices"), Breadcrumb.active("작성") )); - model.addAttribute("toastMessages", List.of( - ToastMessage.info("toastNoticePublishPending", "준비 중", "백엔드 연동이 완료되면 공지사항을 게시할 수 있어요."), - ToastMessage.info("toastNoticeSavedPending", "준비 중", "현재는 저장/게시 API 연동이 되어 있지 않습니다.") - )); + 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 (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.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/resources/templates/notices/compose.html b/src/main/resources/templates/notices/compose.html index 85f803b..caf1d5a 100644 --- a/src/main/resources/templates/notices/compose.html +++ b/src/main/resources/templates/notices/compose.html @@ -37,10 +37,20 @@
-
+
+ +
작업이 완료되었습니다.
+
+ +
- +
@@ -49,16 +59,23 @@
- 굵게/기울임/밑줄/헤딩/리스트/링크/이미지만 지원합니다. (이미지 업로드는 현재 Mock) + 굵게/기울임/밑줄/헤딩/리스트/링크/이미지만 지원합니다. (이미지 업로드 연동 완료)
+ + + + +
- + @@ -71,15 +88,21 @@
- +
- +
- +
@@ -92,36 +115,28 @@
-
게시 방식
-
- 지금 게시하거나, 특정 날짜/시각에 예약 게시할 수 있습니다. (현재는 버튼 동작만 Mock) -
+
예약 게시
+
예약 게시 버튼을 누르면 아래 시각으로 예약됩니다.
-
- -
-
- - -
예약 게시 버튼을 누르면 위 시각으로 예약됩니다.
+
+
- -
@@ -129,12 +144,11 @@
-
-
현재 상태
+
+
안내
- 이 화면은 UI + Quill 에디터까지만 구성되어 있고, - 게시/예약/저장 API는 아직 연동되지 않았습니다. - 이미지 업로드는 어드민 서버의 Mock 엔드포인트로 임시 저장됩니다. + 즉시 게시, 예약 게시, 수정, 삭제가 가능합니다. + 에디터에서 이미지를 삽입하면 자동으로 업로드된 뒤 본문에 포함됩니다.
@@ -149,7 +163,7 @@

작성 가이드

  • 제목은 한 줄로 간결하게 작성하세요.
  • 이미지는 중요한 지점에만 넣고, 여러 장을 넣을 때는 간격을 두세요.
  • 알림 옵션을 켠 경우, 푸시/인앱 문구는 공지 본문보다 더 짧게 작성하세요.
  • -
  • 예약 게시 시각은 서버 시간 기준으로 처리되도록(추후) 맞춰주세요.
  • +
  • 예약 게시 시각은 KST 기준입니다.
  • @@ -161,6 +175,13 @@

    작성 가이드

    (function () { function $(id) { return document.getElementById(id); } + var form = $('noticeForm'); + var titleEl = $('noticeTitle'); + var scheduledAtEl = $('scheduledAt'); + var contentInput = $('noticeContentHtml'); + var publishNowInput = $('publishNow'); + var imagesJsonInput = $('noticeImagesJson'); + var sendToggle = $('sendNotificationToggle'); var notificationFields = $('notificationFields'); @@ -178,21 +199,52 @@

    작성 가이드

    return; } + var toolbarOptions = [ + [{ header: [1, 2, false] }], + ['bold', 'italic', 'underline'], + [{ list: 'ordered' }, { list: 'bullet' }], + ['link', 'image'], + ['clean'] + ]; + var quill = new window.Quill('#noticeEditor', { theme: 'snow', placeholder: '공지사항 본문을 작성하세요. 텍스트 중간에 이미지를 넣을 수 있습니다.', modules: { - toolbar: [ - [{ header: [1, 2, false] }], - ['bold', 'italic', 'underline'], - [{ list: 'ordered' }, { list: 'bullet' }], - ['link', 'image'], - ['clean'] - ] + toolbar: toolbarOptions } }); - var presignEndpoint = '/public/mock/notices/images/presign'; + // 초기 본문 + var initialContent = $('initialContentHtml'); + if (initialContent && initialContent.value) { + quill.root.innerHTML = initialContent.value; + } + + // 업로드된 이미지 메타(에디터 내 이미지 src 기준) + var imagesByUrl = {}; + + var initialImages = $('initialImagesJson'); + if (initialImages && initialImages.value) { + try { + var parsed = JSON.parse(initialImages.value || '[]'); + if (Array.isArray(parsed)) { + parsed.forEach(function (img) { + if (img && img.imageUrl && img.objectKey && img.contentType) { + imagesByUrl[img.imageUrl] = { + objectKey: img.objectKey, + contentType: img.contentType, + imageUrl: img.imageUrl + }; + } + }); + } + } catch (e) { + // ignore + } + } + + var presignEndpoint = '/notices/image/presign'; function selectLocalImage() { var input = document.createElement('input'); @@ -205,16 +257,16 @@

    작성 가이드

    if (!file) { return; } - uploadImage(file); + uploadAndInsertImage(file); }; } - async function uploadImage(file) { + async function uploadAndInsertImage(file) { try { var presignRes = await fetch(presignEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ filename: file.name, contentType: file.type }) + body: JSON.stringify({ contentType: file.type }) }); if (!presignRes.ok) { @@ -222,28 +274,33 @@

    작성 가이드

    } var presign = await presignRes.json(); - if (!presign || !presign.uploadUrl || !presign.imageUrl) { - throw new Error('presign_invalid'); + if (!presign || !presign.presignedUrl || !presign.imageUrl || !presign.objectKey) { + throw new Error('invalid_presign_response'); } - var uploadRes = await fetch(presign.uploadUrl, { + var putRes = await fetch(presign.presignedUrl, { method: 'PUT', - headers: { 'Content-Type': file.type || 'application/octet-stream' }, + headers: { 'Content-Type': file.type }, body: file }); - if (!uploadRes.ok) { + if (!putRes.ok) { throw new Error('upload_failed'); } var range = quill.getSelection(true); - var insertIndex = range ? range.index : quill.getLength(); - quill.insertEmbed(insertIndex, 'image', presign.imageUrl, 'user'); - quill.setSelection(insertIndex + 1); - + var index = range ? range.index : quill.getLength(); + quill.insertEmbed(index, 'image', presign.imageUrl, 'user'); + quill.setSelection(index + 1, 0); + + imagesByUrl[presign.imageUrl] = { + objectKey: presign.objectKey, + contentType: file.type, + imageUrl: presign.imageUrl + }; } catch (e) { - console.warn('Image upload failed (mock).', e); - alert('이미지 업로드에 실패했습니다. (Mock)\n- Presigned URL/업로드 API 연동 전 단계입니다.'); + console.warn('[NOTICE IMAGE UPLOAD ERROR]', e); + alert('이미지 업로드에 실패했습니다. 잠시 후 다시 시도해주세요.'); } } @@ -252,85 +309,124 @@

    작성 가이드

    toolbar.addHandler('image', selectLocalImage); } - function collectPayload() { - var html = quill.root ? quill.root.innerHTML : ''; - $('noticeContentHtml').value = html; - - return { - title: $('noticeTitle').value || '', - contentHtml: html || '', - sendNotification: !!($('sendNotificationToggle') && $('sendNotificationToggle').checked), - push: { - title: $('pushTitle') ? $('pushTitle').value : '', - body: $('pushBody') ? $('pushBody').value : '' - }, - inApp: { - body: $('inAppBody') ? $('inAppBody').value : '' - }, - scheduledAt: $('scheduledAt') ? $('scheduledAt').value : '' - }; + function stripHtml(html) { + return (html || '').replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim(); } - function validatePayload(payload) { - if (!payload.title.trim()) { + function collectImagesFromHtml(html) { + var doc = new DOMParser().parseFromString(html || '', 'text/html'); + var imgs = Array.prototype.slice.call(doc.querySelectorAll('img')); + var seen = {}; + var out = []; + imgs.forEach(function (img) { + var src = img.getAttribute('src'); + if (!src) { + return; + } + var meta = imagesByUrl[src]; + if (!meta || !meta.objectKey || seen[meta.objectKey]) { + return; + } + seen[meta.objectKey] = true; + out.push({ objectKey: meta.objectKey, contentType: meta.contentType }); + }); + return out; + } + + function validateForSubmit(publishNow, requireSchedule) { + if (!titleEl || !titleEl.value || !titleEl.value.trim()) { alert('공지 제목을 입력해 주세요.'); return false; } - if (!payload.contentHtml || payload.contentHtml.replace(/<[^>]*>/g, '').trim().length === 0) { - var hasImg = /]*src=/.test(payload.contentHtml || ''); - if (!hasImg) { - alert('공지 본문을 입력해 주세요.'); - return false; - } + var html = quill.root ? quill.root.innerHTML : ''; + var text = stripHtml(html); + var hasImg = /]*src=/.test(html || ''); + if (!text && !hasImg) { + alert('공지 본문을 입력해 주세요.'); + return false; } - if (payload.sendNotification) { - if (!payload.push.title.trim()) { - alert('푸시 알림 제목을 입력해 주세요.'); - return false; - } - if (!payload.push.body.trim()) { - alert('푸시 알림 본문을 입력해 주세요.'); + if (!publishNow && requireSchedule) { + if (!scheduledAtEl || !scheduledAtEl.value) { + alert('예약 게시 시각을 입력해 주세요.'); return false; } - if (!payload.inApp.body.trim()) { - alert('인앱 알림 본문을 입력해 주세요.'); + } + + if (sendToggle && sendToggle.checked) { + var pushTitleEl = $('pushTitle'); + var pushBodyEl = $('pushBody'); + var inAppBodyEl = $('inAppBody'); + + if (!pushTitleEl || !pushTitleEl.value.trim() || + !pushBodyEl || !pushBodyEl.value.trim() || + !inAppBodyEl || !inAppBodyEl.value.trim()) { + alert('알림을 보낼 경우 푸시 제목/본문과 인앱 알림 본문을 모두 입력해 주세요.'); return false; } } + if (contentInput) { + contentInput.value = html; + } + if (imagesJsonInput) { + imagesJsonInput.value = JSON.stringify(collectImagesFromHtml(html)); + } return true; } - function attachMockAction(buttonId, type) { - var btn = $(buttonId); - if (!btn) return; - - btn.addEventListener('click', function (event) { - var payload = collectPayload(); - payload.action = type; + function submitWithMode(mode) { + if (!form) { + return; + } - if (type === 'SCHEDULE_PUBLISH' && !payload.scheduledAt) { - alert('예약 게시 시각을 선택해 주세요.'); - event.preventDefault(); - event.stopPropagation(); - return; + var isEdit = form.getAttribute('data-mode') === 'edit'; + + if (mode === 'PUBLISH_NOW') { + if (publishNowInput) publishNowInput.value = 'true'; + if (scheduledAtEl) scheduledAtEl.value = ''; + if (!validateForSubmit(true, false)) return; + } else if (mode === 'SCHEDULE') { + if (publishNowInput) publishNowInput.value = 'false'; + if (!validateForSubmit(false, true)) return; + } else if (mode === 'SAVE') { + if (publishNowInput) publishNowInput.value = 'false'; + if (isEdit && scheduledAtEl) { + // 저장 버튼은 "현재 예약 시각 유지" (수정 본문만 저장) + scheduledAtEl.value = ''; } + if (!validateForSubmit(false, !isEdit)) return; + } else { + return; + } - if (type !== 'SAVE_DRAFT' && !validatePayload(payload)) { - event.preventDefault(); - event.stopPropagation(); - return; - } + form.submit(); + } + + var publishNowBtn = $('publishNowBtn'); + if (publishNowBtn) { + publishNowBtn.addEventListener('click', function (e) { + e.preventDefault(); + submitWithMode('PUBLISH_NOW'); + }); + } - console.log('[NOTICE MOCK SUBMIT]', payload); + var schedulePublishBtn = $('schedulePublishBtn'); + if (schedulePublishBtn) { + schedulePublishBtn.addEventListener('click', function (e) { + e.preventDefault(); + submitWithMode('SCHEDULE'); }); } - attachMockAction('publishNowBtn', 'PUBLISH_NOW'); - attachMockAction('schedulePublishBtn', 'SCHEDULE_PUBLISH'); - attachMockAction('saveDraftBtn', 'SAVE_DRAFT'); + var saveDraftBtn = $('saveDraftBtn'); + if (saveDraftBtn) { + saveDraftBtn.addEventListener('click', function (e) { + e.preventDefault(); + submitWithMode('SAVE'); + }); + } })(); diff --git a/src/main/resources/templates/notices/index.html b/src/main/resources/templates/notices/index.html index ca067ed..4f078fe 100644 --- a/src/main/resources/templates/notices/index.html +++ b/src/main/resources/templates/notices/index.html @@ -5,23 +5,94 @@

    공지사항 관리

    -

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

    +

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

    +
    + +
    작업이 완료되었습니다.
    +
    + +
    +
    공지사항을 불러올 수 없습니다.
    +
    잠시 후 다시 시도해주세요.
    +
    +
    -
    - 현재는 공지사항 작성 기능만 제공됩니다. -
    - 게시/예약/목록/수정/삭제 기능은 백엔드 연동이 완료되면 추가될 예정입니다. -
    +
    + 등록된 공지사항이 없습니다. +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + +
    ID제목상태예약 시각게시 시각작성자작업
    1 +
    공지사항 제목
    +
    + SCHEDULED +
    +
    + SCHEDULED + + - + + - + 운영자 +
    + + 수정 + + +
    + + +
    +
    +
    +
    + +
    + ※ 공지사항 작성/수정/삭제 권한이 없습니다.
    From 65a06fdc0c373e85009513fd7409e47ac40064e6 Mon Sep 17 00:00:00 2001 From: soonduck-dreams Date: Wed, 24 Dec 2025 16:46:59 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=20=EC=98=88=EC=95=BD=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20UI/UX=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../saerok_admin/web/NoticeController.java | 55 +++++++- .../resources/templates/notices/compose.html | 26 +--- .../resources/templates/notices/detail.html | 105 +++++++++++++++ .../resources/templates/notices/index.html | 123 ++++++++++-------- 4 files changed, 227 insertions(+), 82 deletions(-) create mode 100644 src/main/resources/templates/notices/detail.html diff --git a/src/main/java/apu/saerok_admin/web/NoticeController.java b/src/main/java/apu/saerok_admin/web/NoticeController.java index d7371c8..5e478d0 100644 --- a/src/main/java/apu/saerok_admin/web/NoticeController.java +++ b/src/main/java/apu/saerok_admin/web/NoticeController.java @@ -14,6 +14,7 @@ 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; @@ -27,6 +28,7 @@ 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; @@ -46,6 +48,7 @@ public class NoticeController { 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; @@ -91,6 +94,51 @@ public String index(@ModelAttribute("currentAdminProfile") CurrentAdminProfile c 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, @@ -268,6 +316,11 @@ public String updateAnnouncement(@ModelAttribute("currentAdminProfile") CurrentA } 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"); @@ -409,7 +462,7 @@ private static String formatForDatetimeLocal(OffsetDateTime offsetDateTime) { return ""; } try { - return offsetDateTime.toLocalDateTime().format(DATETIME_LOCAL_FORMAT); + return offsetDateTime.atZoneSameInstant(KST_ZONE).toLocalDateTime().format(DATETIME_LOCAL_FORMAT); } catch (Exception exception) { return ""; } diff --git a/src/main/resources/templates/notices/compose.html b/src/main/resources/templates/notices/compose.html index caf1d5a..7c101b5 100644 --- a/src/main/resources/templates/notices/compose.html +++ b/src/main/resources/templates/notices/compose.html @@ -127,16 +127,11 @@
    -
    @@ -381,8 +376,6 @@

    작성 가이드

    return; } - var isEdit = form.getAttribute('data-mode') === 'edit'; - if (mode === 'PUBLISH_NOW') { if (publishNowInput) publishNowInput.value = 'true'; if (scheduledAtEl) scheduledAtEl.value = ''; @@ -390,13 +383,6 @@

    작성 가이드

    } else if (mode === 'SCHEDULE') { if (publishNowInput) publishNowInput.value = 'false'; if (!validateForSubmit(false, true)) return; - } else if (mode === 'SAVE') { - if (publishNowInput) publishNowInput.value = 'false'; - if (isEdit && scheduledAtEl) { - // 저장 버튼은 "현재 예약 시각 유지" (수정 본문만 저장) - scheduledAtEl.value = ''; - } - if (!validateForSubmit(false, !isEdit)) return; } else { return; } @@ -419,14 +405,6 @@

    작성 가이드

    submitWithMode('SCHEDULE'); }); } - - var saveDraftBtn = $('saveDraftBtn'); - if (saveDraftBtn) { - saveDraftBtn.addEventListener('click', function (e) { - e.preventDefault(); - submitWithMode('SAVE'); - }); - } })(); 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 @@ + + + +
    + +
    작업이 완료되었습니다.
    +
    + +
    +
    +
    +
    +
    +
    + + 게시 예약됨 + + +

    공지사항 제목

    + +
    + - +
    +
    + +
    +
    작성자 운영자
    +
    ID #0
    +
    +
    +
    +
    + +
    +
    +

    공지 본문

    +
    +
    +
    본문이 없습니다.
    +
    +
    +
    + +
    +
    +

    알림 내용

    +
    +
    +
    +
    푸시 알림 제목
    +
    -
    +
    +
    +
    푸시 알림 본문
    +
    -
    +
    +
    +
    인앱 알림 본문
    +
    -
    +
    +
    +
    +
    + +
    +
    +
    +

    작업

    +
    + + + 목록 보기 + + + + 수정 + +
    + + +
    +
    +
    +
    +
    +
    +
    + diff --git a/src/main/resources/templates/notices/index.html b/src/main/resources/templates/notices/index.html index 4f078fe..58897c9 100644 --- a/src/main/resources/templates/notices/index.html +++ b/src/main/resources/templates/notices/index.html @@ -28,70 +28,79 @@

    공지사항 관리

    -
    -
    +
    +
    +

    공지사항 목록

    +

    + 총 0건 +

    +
    +
    + +
    +
    등록된 공지사항이 없습니다.
    -
    - - - - - - - - - - - - - - - - - - - - - - - -
    ID제목상태예약 시각게시 시각작성자작업
    1 -
    공지사항 제목
    -
    - SCHEDULED - - - - - - - 운영자 -
    - - 수정 - -
    - - -
    +
    공지사항 제목
    + +
    + -
    -
    -
    +
    + +
    +
    작성자
    +
    운영자
    +
    + + + + + +
    -
    +
    +
    ※ 공지사항 작성/수정/삭제 권한이 없습니다.
    From f0405082397cd0c360d04165148baf1a15bde9fd Mon Sep 17 00:00:00 2001 From: soonduck-dreams Date: Wed, 24 Dec 2025 17:06:42 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EC=9E=91=EC=84=B1=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=20=EB=B0=8F=20=EC=A6=89=EC=8B=9C=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=20UI/UX=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/templates/notices/compose.html | 86 ++++++++++--------- 1 file changed, 45 insertions(+), 41 deletions(-) diff --git a/src/main/resources/templates/notices/compose.html b/src/main/resources/templates/notices/compose.html index 7c101b5..ee67a88 100644 --- a/src/main/resources/templates/notices/compose.html +++ b/src/main/resources/templates/notices/compose.html @@ -34,7 +34,7 @@
    -
    +
    -
    -
    -
    -
    -
    예약 게시
    -
    예약 게시 버튼을 누르면 아래 시각으로 예약됩니다.
    -
    -
    - +
    +
    +
    +
    +
    + +
    지금 바로 게시
    +
    +
    + 즉시 게시로 설정하면 저장과 동시에 공지사항이 바로 노출됩니다. +
    +
    + +
    - -
    - - +
    +
    +
    +
    +
    + +
    예약해서 게시
    +
    +
    + 게시 시각을 선택한 뒤 예약해서 게시하기를 누르면 해당 시각에 자동으로 게시됩니다. +
    +
    + + +
    + PC에서는 오른쪽 입력창에서, 모바일에서는 아래 입력창에서 시각을 선택하세요. +
    +
    +
    + +
    +
    -
    -
    안내
    -
    - 즉시 게시, 예약 게시, 수정, 삭제가 가능합니다. - 에디터에서 이미지를 삽입하면 자동으로 업로드된 뒤 본문에 포함됩니다. -
    -
    -
    -
    -
    -

    작성 가이드

    -
      -
    • 제목은 한 줄로 간결하게 작성하세요.
    • -
    • 이미지는 중요한 지점에만 넣고, 여러 장을 넣을 때는 간격을 두세요.
    • -
    • 알림 옵션을 켠 경우, 푸시/인앱 문구는 공지 본문보다 더 짧게 작성하세요.
    • -
    • 예약 게시 시각은 KST 기준입니다.
    • -
    -
    -
    -
    From 07e2f6835b6da2c4b1309e8f364c063c415d906b Mon Sep 17 00:00:00 2001 From: soonduck-dreams Date: Wed, 24 Dec 2025 17:09:34 +0900 Subject: [PATCH 5/6] =?UTF-8?q?chore:=20=EA=B3=B5=EC=A7=80=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EC=9E=91=EC=84=B1=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=EB=8F=84=EC=9B=80?= =?UTF-8?q?=EB=A7=90=20=EB=AC=B8=EA=B5=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/templates/notices/compose.html | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/main/resources/templates/notices/compose.html b/src/main/resources/templates/notices/compose.html index ee67a88..38f3b97 100644 --- a/src/main/resources/templates/notices/compose.html +++ b/src/main/resources/templates/notices/compose.html @@ -58,9 +58,6 @@
    -
    - 굵게/기울임/밑줄/헤딩/리스트/링크/이미지만 지원합니다. (이미지 업로드 연동 완료) -
    @@ -145,9 +142,6 @@ -
    - PC에서는 오른쪽 입력창에서, 모바일에서는 아래 입력창에서 시각을 선택하세요. -