From 08a0b1dd983ebaaed838c333a38f9a40107c400f Mon Sep 17 00:00:00 2001 From: Junhee Han <115782193+soonduck-dreams@users.noreply.github.com> Date: Thu, 18 Dec 2025 14:35:38 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=B6=94=EA=B0=80=20(#277)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../render/ActionNotificationRenderer.java | 40 ++------------- .../DelegatingNotificationRenderer.java | 28 +++++++++++ .../render/NotificationTemplateRenderer.java | 48 ++++++++++++++++++ .../render/SystemNotificationRenderer.java | 27 ++++++++++ .../store/InAppNotificationWriter.java | 20 ++++---- .../facade/NotificationPublisher.java | 31 +++++++----- .../application/facade/NotifyActionDsl.java | 4 +- .../facade/NotifySystemService.java | 49 +++++++++++++++++++ .../application/gateway/PushGateway.java | 14 ++++-- .../payload/ActionNotificationPayload.java | 18 ++++++- .../model/payload/NotificationPayload.java | 45 ++++++++++++++--- .../payload/SystemNotificationPayload.java | 26 ++++++++++ .../core/entity/NotificationType.java | 15 +++++- .../infra/fcm/FcmPushGateway.java | 7 +-- .../infra/local/LocalPushGateway.java | 25 ++++------ .../config/notification-messages.yml | 3 ++ .../BirdIdSuggestionCommandServiceTest.java | 9 ++-- .../CollectionCommentCommandServiceTest.java | 7 +-- .../CollectionLikeCommandServiceTest.java | 7 +-- 19 files changed, 314 insertions(+), 109 deletions(-) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/DelegatingNotificationRenderer.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/NotificationTemplateRenderer.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/SystemNotificationRenderer.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotifySystemService.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/SystemNotificationPayload.java diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/ActionNotificationRenderer.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/ActionNotificationRenderer.java index ce3a87c5..d05d8ee9 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/ActionNotificationRenderer.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/ActionNotificationRenderer.java @@ -3,14 +3,9 @@ import lombok.RequiredArgsConstructor; import org.devkor.apu.saerok_server.domain.notification.application.model.payload.ActionNotificationPayload; import org.devkor.apu.saerok_server.domain.notification.application.model.payload.NotificationPayload; -import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; -import org.devkor.apu.saerok_server.domain.notification.core.service.NotificationTypeResolver; import org.devkor.apu.saerok_server.global.core.config.feature.NotificationMessagesConfig; import org.springframework.stereotype.Component; -import java.util.HashMap; -import java.util.Map; - @Component @RequiredArgsConstructor public class ActionNotificationRenderer implements NotificationRenderer { @@ -23,38 +18,11 @@ public RenderedMessage render(NotificationPayload p) { throw new IllegalArgumentException("Unsupported payload: " + p.getClass()); } - NotificationType type = NotificationTypeResolver.from(a.subject(), a.action()); - NotificationMessagesConfig.Template t = messages.forType(type); - - Map vars = new HashMap<>(); - vars.put("actorName", nullToEmpty(a.actorName())); - a.extras().forEach((k, v) -> vars.put(k, v == null ? "" : String.valueOf(v))); + NotificationMessagesConfig.Template t = messages.forType(a.type()); - String title = renderTemplate(t.getPushTitle(), vars); - String body = renderTemplate(t.getPushBody(), vars); - - return new RenderedMessage(title, body); - } + var vars = NotificationTemplateRenderer.toVars(a.extras()); + vars.put("actorName", NotificationTemplateRenderer.nullToEmpty(a.actorName())); - private static String nullToEmpty(String s) { - return s == null ? "" : s; - } - - /** - * 템플릿 렌더링 - * - */ - private String renderTemplate(String template, Map vars) { - if (template == null) return ""; - String out = template.replace("{{", "\u0000").replace("}}", "\u0001"); // 임시 마커 - for (Map.Entry e : vars.entrySet()) { - String key = "{" + e.getKey() + "}"; - out = out.replace(key, e.getValue() == null ? "" : e.getValue()); - } - // 이스케이프 복원 - return out.replace("\u0000", "{").replace("\u0001", "}"); + return NotificationTemplateRenderer.render(t, vars); } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/DelegatingNotificationRenderer.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/DelegatingNotificationRenderer.java new file mode 100644 index 00000000..bcedfa6f --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/DelegatingNotificationRenderer.java @@ -0,0 +1,28 @@ +package org.devkor.apu.saerok_server.domain.notification.application.assembly.render; + +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.ActionNotificationPayload; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.NotificationPayload; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.SystemNotificationPayload; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; + +@Primary +@Component +@RequiredArgsConstructor +public class DelegatingNotificationRenderer implements NotificationRenderer { + + private final ActionNotificationRenderer actionRenderer; + private final SystemNotificationRenderer systemRenderer; + + @Override + public RenderedMessage render(NotificationPayload payload) { + if (payload instanceof ActionNotificationPayload) { + return actionRenderer.render(payload); + } + if (payload instanceof SystemNotificationPayload) { + return systemRenderer.render(payload); + } + throw new IllegalArgumentException("Unsupported payload: " + payload.getClass()); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/NotificationTemplateRenderer.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/NotificationTemplateRenderer.java new file mode 100644 index 00000000..1b629d76 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/NotificationTemplateRenderer.java @@ -0,0 +1,48 @@ +package org.devkor.apu.saerok_server.domain.notification.application.assembly.render; + +import org.devkor.apu.saerok_server.domain.notification.application.assembly.render.NotificationRenderer.RenderedMessage; +import org.devkor.apu.saerok_server.global.core.config.feature.NotificationMessagesConfig; + +import java.util.HashMap; +import java.util.Map; + +final class NotificationTemplateRenderer { + + private NotificationTemplateRenderer() { + } + + static RenderedMessage render(NotificationMessagesConfig.Template template, Map vars) { + String title = renderTemplate(template.getPushTitle(), vars); + String body = renderTemplate(template.getPushBody(), vars); + return new RenderedMessage(title, body); + } + + static Map toVars(Map extras) { + Map vars = new HashMap<>(); + extras.forEach((k, v) -> vars.put(k, v == null ? "" : String.valueOf(v))); + return vars; + } + + static String nullToEmpty(String s) { + return s == null ? "" : s; + } + + /** + *

간단한 템플릿 렌더링

+ * + *
    + *
  • 치환 규칙: {key} → vars.get(key)
  • + *
  • 이스케이프: {{{, }}}
  • + *
+ */ + private static String renderTemplate(String template, Map vars) { + if (template == null) return ""; + String out = template.replace("{{", "\u0000").replace("}}", "\u0001"); // 임시 마커 + for (Map.Entry e : vars.entrySet()) { + String key = "{" + e.getKey() + "}"; + out = out.replace(key, e.getValue() == null ? "" : e.getValue()); + } + // 이스케이프 복원 + return out.replace("\u0000", "{").replace("\u0001", "}"); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/SystemNotificationRenderer.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/SystemNotificationRenderer.java new file mode 100644 index 00000000..6a48e55a --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/SystemNotificationRenderer.java @@ -0,0 +1,27 @@ +package org.devkor.apu.saerok_server.domain.notification.application.assembly.render; + +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.NotificationPayload; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.SystemNotificationPayload; +import org.devkor.apu.saerok_server.global.core.config.feature.NotificationMessagesConfig; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SystemNotificationRenderer implements NotificationRenderer { + + private final NotificationMessagesConfig messages; + + @Override + public RenderedMessage render(NotificationPayload p) { + if (!(p instanceof SystemNotificationPayload s)) { + throw new IllegalArgumentException("Unsupported payload: " + p.getClass()); + } + + NotificationMessagesConfig.Template t = messages.forType(s.type()); + + var vars = NotificationTemplateRenderer.toVars(s.extras()); + + return NotificationTemplateRenderer.render(t, vars); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/store/InAppNotificationWriter.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/store/InAppNotificationWriter.java index 0c93a4ed..bc09ac7e 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/store/InAppNotificationWriter.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/store/InAppNotificationWriter.java @@ -6,7 +6,6 @@ import org.devkor.apu.saerok_server.domain.notification.core.entity.Notification; import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; import org.devkor.apu.saerok_server.domain.notification.core.repository.NotificationRepository; -import org.devkor.apu.saerok_server.domain.notification.core.service.NotificationTypeResolver; import org.devkor.apu.saerok_server.domain.user.core.entity.User; import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; import org.springframework.stereotype.Component; @@ -22,20 +21,19 @@ public class InAppNotificationWriter { private final UserRepository userRepository; public Long save(NotificationPayload payload) { - if (!(payload instanceof ActionNotificationPayload a)) { - throw new IllegalArgumentException("Unsupported payload: " + payload.getClass()); - } - - User recipient = userRepository.findById(a.recipientId()) - .orElseThrow(() -> new IllegalArgumentException("Recipient not found: " + a.recipientId())); + User recipient = userRepository.findById(payload.recipientId()) + .orElseThrow(() -> new IllegalArgumentException("Recipient not found: " + payload.recipientId())); - User actor = userRepository.findById(a.actorId()) - .orElseThrow(() -> new IllegalArgumentException("Actor not found: " + a.actorId())); + NotificationType type = payload.type(); - NotificationType type = NotificationTypeResolver.from(a.subject(), a.action()); + User actor = null; + if (payload instanceof ActionNotificationPayload a) { + actor = userRepository.findById(a.actorId()) + .orElseThrow(() -> new IllegalArgumentException("Actor not found: " + a.actorId())); + } Map payloadMap = new HashMap<>(); - if (a.extras() != null) payloadMap.putAll(a.extras()); + if (payload.extras() != null) payloadMap.putAll(payload.extras()); Notification entity = Notification.builder() .user(recipient) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotificationPublisher.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotificationPublisher.java index db9758ca..10207dbe 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotificationPublisher.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotificationPublisher.java @@ -6,11 +6,8 @@ import org.devkor.apu.saerok_server.domain.notification.application.assembly.store.InAppNotificationWriter; import org.devkor.apu.saerok_server.domain.notification.application.dto.PushMessageCommand; import org.devkor.apu.saerok_server.domain.notification.application.gateway.PushGateway; -import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.Target; -import org.devkor.apu.saerok_server.domain.notification.application.model.payload.ActionNotificationPayload; import org.devkor.apu.saerok_server.domain.notification.application.model.payload.NotificationPayload; import org.devkor.apu.saerok_server.domain.notification.core.repository.NotificationRepository; -import org.devkor.apu.saerok_server.domain.notification.core.service.NotificationTypeResolver; import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,9 +22,20 @@ public class NotificationPublisher { private final PushGateway pushGateway; private final UserRepository userRepository; + /** + *

모든 알림의 공통 파이프라인

+ * + *
    + *
  1. 렌더링
  2. + *
  3. 인앱 저장
  4. + *
  5. 배지 카운트 계산
  6. + *
  7. 푸시 발송
  8. + *
+ */ @Transactional - public void push(NotificationPayload payload, Target target) { + public void push(NotificationPayload payload) { + // recipient가 삭제/미존재면 조용히 무시 if (userRepository.findById(payload.recipientId()).isEmpty()) { return; } @@ -37,16 +45,15 @@ public void push(NotificationPayload payload, Target target) { int unread = notificationRepository.countUnreadByUserId(payload.recipientId()).intValue(); - if (!(payload instanceof ActionNotificationPayload a)) { - throw new IllegalArgumentException("Unsupported payload: " + payload.getClass()); - } - - String typeString = NotificationTypeResolver.from(a.subject(), a.action()).name(); - PushMessageCommand cmd = PushMessageCommand.createPushMessageCommand( - renderedMessage.pushTitle(), renderedMessage.pushBody(), typeString, target.id(), unread, notificationId + renderedMessage.pushTitle(), + renderedMessage.pushBody(), + payload.type().name(), + payload.relatedId(), + unread, + notificationId ); - pushGateway.sendToUser(a.recipientId(), a.subject(), a.action(), cmd); + pushGateway.sendToUser(payload.recipientId(), payload.type(), cmd); } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotifyActionDsl.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotifyActionDsl.java index 2ff31d33..df7dbf03 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotifyActionDsl.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotifyActionDsl.java @@ -116,9 +116,9 @@ public void to(Long recipientId) { actor.name(), notificationSubject, notificationAction, + target.id(), extras - ), - target + ) ); } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotifySystemService.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotifySystemService.java new file mode 100644 index 00000000..df74d093 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotifySystemService.java @@ -0,0 +1,49 @@ +package org.devkor.apu.saerok_server.domain.notification.application.facade; + +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.SystemNotificationPayload; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; + +/** + * 시스템 알림 발송을 위한 간단한 파사드. + * + *

+ * 행동 알림은 NotifyActionDsl이 담당하고, + * 시스템 알림은 (Subject/Action 없이) 이 서비스로 발송합니다. + *

+ */ +@Service +@RequiredArgsConstructor +public class NotifySystemService { + + private final NotificationPublisher publisher; + + /** + * 특정 사용자에게 시스템 알림을 전송합니다. + * + * @param recipientId 알림 수신자 + * @param type SYSTEM_* 타입 권장 (현재는 SYSTEM_NOTICE) + * @param relatedId 관련 엔티티 id (없으면 null) + * @param extras 인앱 payload에 저장될 추가 데이터 (nullable) + */ + public void notifyUser( + Long recipientId, + NotificationType type, + Long relatedId, + Map extras + ) { + Map merged = new HashMap<>(); + if (extras != null) merged.putAll(extras); + + publisher.push(new SystemNotificationPayload( + recipientId, + type, + relatedId, + merged + )); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/gateway/PushGateway.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/gateway/PushGateway.java index 1fa156c6..32101c40 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/gateway/PushGateway.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/gateway/PushGateway.java @@ -1,11 +1,19 @@ package org.devkor.apu.saerok_server.domain.notification.application.gateway; import org.devkor.apu.saerok_server.domain.notification.application.dto.PushMessageCommand; -import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationAction; -import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationSubject; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; public interface PushGateway { - void sendToUser(Long userId, NotificationSubject subject, NotificationAction action, PushMessageCommand cmd); + /** + * 특정 사용자에게 푸시 메시지를 전송합니다. + * + *

디바이스/설정 필터링(알림 타입별 on/off)은 구현체에서 처리합니다.

+ */ + void sendToUser(Long userId, NotificationType type, PushMessageCommand cmd); + + /** + * iOS 배지 업데이트용 silent push를 전송합니다. + */ void sendSilentBadgeUpdate(Long userId, int unreadCount); } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/ActionNotificationPayload.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/ActionNotificationPayload.java index 1c80a8d1..4f54e398 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/ActionNotificationPayload.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/ActionNotificationPayload.java @@ -2,14 +2,30 @@ import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationAction; import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationSubject; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; +import org.devkor.apu.saerok_server.domain.notification.core.service.NotificationTypeResolver; import java.util.Map; +/** + * 다른 사용자(actor)의 특정 행동이 대상 사용자(recipient)에게 전달되는 행동 알림 payload. + */ public record ActionNotificationPayload( Long recipientId, Long actorId, String actorName, NotificationSubject subject, NotificationAction action, + Long relatedId, Map extras -) implements NotificationPayload { } +) implements NotificationPayload { + + public ActionNotificationPayload { + extras = (extras == null) ? Map.of() : Map.copyOf(extras); + } + + @Override + public NotificationType type() { + return NotificationTypeResolver.from(subject, action); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/NotificationPayload.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/NotificationPayload.java index 06f94352..723d7a8b 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/NotificationPayload.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/NotificationPayload.java @@ -1,15 +1,46 @@ package org.devkor.apu.saerok_server.domain.notification.application.model.payload; -import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationAction; -import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationSubject; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; import java.util.Map; +/** + *

알림 발송 파이프라인에서 사용하는 공통 Payload

+ * + *
    + *
  • ActionNotificationPayload: 다른 사용자(actor)의 행동에 의해 발생하는 알림
  • + *
  • SystemNotificationPayload: 공지/점검 등 시스템 차원에서 발생하는 알림
  • + *
+ * + *

+ * 공통 파이프라인은 type, relatedId, recipientId, extras만 알면 되고, + * Subject/Action 같은 행동 알림 전용 속성은 Action payload에만 존재합니다. + *

+ */ public sealed interface NotificationPayload - permits ActionNotificationPayload /*, SystemNotificationPayload 등 확장 여지 */ { + permits ActionNotificationPayload, SystemNotificationPayload { - NotificationSubject subject(); - NotificationAction action(); - Long recipientId(); // 알림 받을 사람 - Map extras(); // 메타데이터 (ex. comment, collectionId, collectionImageUrl ...) + /** + * 클라이언트에서 식별 가능한 최종 알림 타입. + */ + NotificationType type(); + + /** + * 알림이 "무엇"과 관련된 것인지 표현하는 id. + *
    + *
  • 예) COLLECTION 알림이면 collectionId
  • + *
+ * 없을 경우 null 허용. + */ + Long relatedId(); + + /** + * 알림을 받을 사용자 id. + */ + Long recipientId(); + + /** + * 인앱 알림 payload(JSON)로 저장되는 추가 메타데이터. + */ + Map extras(); } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/SystemNotificationPayload.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/SystemNotificationPayload.java new file mode 100644 index 00000000..6dbed226 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/SystemNotificationPayload.java @@ -0,0 +1,26 @@ +package org.devkor.apu.saerok_server.domain.notification.application.model.payload; + +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; + +import java.util.Map; + +/** + * 시스템 차원에서 발생하는 알림 payload. + * + *

예) 공지사항, 점검 안내, 신규 기능 안내 등

+ * + *

+ * 시스템 알림은 행동 알림과 달리 NotificationSubject/NotificationAction을 사용하지 않습니다. + *

+ */ +public record SystemNotificationPayload( + Long recipientId, + NotificationType type, + Long relatedId, + Map extras +) implements NotificationPayload { + + public SystemNotificationPayload { + extras = (extras == null) ? Map.of() : Map.copyOf(extras); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java index 761f79ed..d0e29506 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java @@ -1,7 +1,20 @@ package org.devkor.apu.saerok_server.domain.notification.core.entity; +/** + * 클라이언트에서 최종 식별자로 사용하는 알림 타입. + * + *

+ * Action 알림은 Subject/Action을 조합해 Type으로 해석됩니다. + * System 알림은 Type을 직접 지정합니다. + *

+ */ public enum NotificationType { + + // ---- Action Notification Types ---- LIKED_ON_COLLECTION, COMMENTED_ON_COLLECTION, - SUGGESTED_BIRD_ID_ON_COLLECTION + SUGGESTED_BIRD_ID_ON_COLLECTION, + + // ---- System Notification Types ---- + SYSTEM_PUBLISHED_ANNOUNCEMENT } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmPushGateway.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmPushGateway.java index 7d5e30db..af7960f7 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmPushGateway.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmPushGateway.java @@ -4,12 +4,9 @@ import lombok.extern.slf4j.Slf4j; import org.devkor.apu.saerok_server.domain.notification.application.dto.PushMessageCommand; import org.devkor.apu.saerok_server.domain.notification.application.gateway.PushGateway; -import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationAction; -import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationSubject; import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; import org.devkor.apu.saerok_server.domain.notification.core.repository.NotificationSettingRepository; import org.devkor.apu.saerok_server.domain.notification.core.repository.UserDeviceRepository; -import org.devkor.apu.saerok_server.domain.notification.core.service.NotificationTypeResolver; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; @@ -26,8 +23,7 @@ public class FcmPushGateway implements PushGateway { private final FcmMessageClient fcmMessageClient; @Override - public void sendToUser(Long userId, NotificationSubject subject, NotificationAction action, PushMessageCommand cmd) { - NotificationType type = NotificationTypeResolver.from(subject, action); + public void sendToUser(Long userId, NotificationType type, PushMessageCommand cmd) { List deviceIds = settingRepository.findEnabledDeviceIdsByUserAndType(userId, type); if (deviceIds.isEmpty()) { @@ -37,6 +33,7 @@ public void sendToUser(Long userId, NotificationSubject subject, NotificationAct List tokens = userDeviceRepository.findTokensByUserDeviceIds(deviceIds); if (tokens.isEmpty()) return; + fcmMessageClient.sendToDevices(tokens, cmd); } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/local/LocalPushGateway.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/local/LocalPushGateway.java index 6779ab47..681c054c 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/local/LocalPushGateway.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/local/LocalPushGateway.java @@ -3,9 +3,7 @@ import lombok.extern.slf4j.Slf4j; import org.devkor.apu.saerok_server.domain.notification.application.dto.PushMessageCommand; import org.devkor.apu.saerok_server.domain.notification.application.gateway.PushGateway; -import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationAction; -import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationSubject; -import org.devkor.apu.saerok_server.domain.notification.core.service.NotificationTypeResolver; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; @@ -15,29 +13,26 @@ public class LocalPushGateway implements PushGateway { @Override - public void sendToUser(Long userId, NotificationSubject subject, NotificationAction action, PushMessageCommand cmd) { - String type = NotificationTypeResolver.from(subject, action).name(); + public void sendToUser(Long userId, NotificationType type, PushMessageCommand cmd) { log.info(""" ┌───────────────── LOCAL PUSH (SIMULATED) ─────────────────┐ - │ userId : {} - │ subject : {} - │ action : {} - │ type : {} - │ title : {} - │ body : {} - │ relatedId : {} - │ unreadCount : {} + │ userId : {} + │ type : {} + │ title : {} + │ body : {} + │ relatedId : {} + │ notificationId : {} + │ unreadCount : {} └──────────────────────────────────────────────────────────┘ """, userId, - subject, - action, type, safe(cmd.title()), safe(cmd.body()), cmd.relatedId(), + cmd.notificationId(), cmd.unreadCount() ); } diff --git a/src/main/resources/config/notification-messages.yml b/src/main/resources/config/notification-messages.yml index 46b8c7ee..4b51e952 100644 --- a/src/main/resources/config/notification-messages.yml +++ b/src/main/resources/config/notification-messages.yml @@ -9,3 +9,6 @@ notification-messages: SUGGESTED_BIRD_ID_ON_COLLECTION: push-title: "동정 의견 공유" push-body: "두근두근! 새로운 의견이 공유되었어요. 확인해볼까요?" + SYSTEM_PUBLISHED_ANNOUNCEMENT: + push-title: "{title}" + push-body: "{body}" \ No newline at end of file diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandServiceTest.java index 4b9ed9df..a24d5004 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandServiceTest.java @@ -10,7 +10,6 @@ import org.devkor.apu.saerok_server.domain.dex.bird.core.repository.BirdRepository; import org.devkor.apu.saerok_server.domain.notification.application.facade.NotificationPublisher; import org.devkor.apu.saerok_server.domain.notification.application.facade.NotifyActionDsl; -import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.Target; import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.TargetType; import org.devkor.apu.saerok_server.domain.notification.application.model.payload.ActionNotificationPayload; import org.devkor.apu.saerok_server.domain.notification.application.model.payload.NotificationPayload; @@ -57,7 +56,7 @@ void setUp() { Map extras = base == null ? new HashMap<>() : new HashMap<>(base); if (target.type() == TargetType.COLLECTION) { extras.put("collectionId", target.id()); - extras.put("collectionImageUrl", null); + extras.put("collectionImageUrl", "dummy"); } return extras; } @@ -129,8 +128,7 @@ void firstTime() { verify(suggestionRepo, times(2)).save(any(BirdIdSuggestion.class)); ArgumentCaptor payloadCap = ArgumentCaptor.forClass(NotificationPayload.class); - ArgumentCaptor targetCap = ArgumentCaptor.forClass(Target.class); - verify(publisher).push(payloadCap.capture(), targetCap.capture()); + verify(publisher).push(payloadCap.capture()); ActionNotificationPayload p = (ActionNotificationPayload) payloadCap.getValue(); assertThat(p.subject()).isEqualTo(NotificationSubject.COLLECTION); @@ -140,7 +138,6 @@ void firstTime() { Map extras = p.extras(); assertThat(extras.get("collectionId")).isEqualTo(100L); assertThat(extras).containsKey("collectionImageUrl"); - assertThat(targetCap.getValue()).isEqualTo(Target.collection(100L)); } // 이하 기존 테스트 동일 … @@ -158,7 +155,7 @@ void alreadySuggested() { when(suggestionRepo.existsByCollectionIdAndBirdIdAndType(100L, 5L, SuggestionType.SUGGEST)).thenReturn(true); sut.suggest(1L, 100L, 5L); - verify(publisher, never()).push(any(), any()); + verify(publisher, never()).push(any()); } // 나머지 예외 케이스 테스트들 그대로… diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandServiceTest.java index a185b01c..a0937e0c 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandServiceTest.java @@ -11,7 +11,6 @@ import org.devkor.apu.saerok_server.domain.collection.mapper.CollectionCommentWebMapper; import org.devkor.apu.saerok_server.domain.notification.application.facade.NotificationPublisher; import org.devkor.apu.saerok_server.domain.notification.application.facade.NotifyActionDsl; -import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.Target; import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.TargetType; import org.devkor.apu.saerok_server.domain.notification.application.model.payload.ActionNotificationPayload; import org.devkor.apu.saerok_server.domain.notification.application.model.payload.NotificationPayload; @@ -84,7 +83,7 @@ void init() { Map extras = base == null ? new HashMap<>() : new HashMap<>(base); if (target.type() == TargetType.COLLECTION) { extras.put("collectionId", target.id()); - extras.put("collectionImageUrl", null); + extras.put("collectionImageUrl", "dummy"); } return extras; } @@ -119,8 +118,7 @@ void success() { verify(commentRepo).save(any()); ArgumentCaptor payloadCap = ArgumentCaptor.forClass(NotificationPayload.class); - ArgumentCaptor targetCap = ArgumentCaptor.forClass(Target.class); - verify(publisher).push(payloadCap.capture(), targetCap.capture()); + verify(publisher).push(payloadCap.capture()); ActionNotificationPayload p = (ActionNotificationPayload) payloadCap.getValue(); assertThat(p.subject()).isEqualTo(NotificationSubject.COLLECTION); @@ -131,7 +129,6 @@ void success() { assertThat(extras.get("collectionId")).isEqualTo(COLL_ID); assertThat(extras.get("comment")).isEqualTo("Nice"); assertThat(extras).containsKey("collectionImageUrl"); - assertThat(targetCap.getValue()).isEqualTo(Target.collection(COLL_ID)); } @Test @DisplayName("사용자 없음 → NotFoundException") diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionLikeCommandServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionLikeCommandServiceTest.java index 39f46a74..0539d8fd 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionLikeCommandServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionLikeCommandServiceTest.java @@ -7,7 +7,6 @@ import org.devkor.apu.saerok_server.domain.collection.core.repository.CollectionRepository; import org.devkor.apu.saerok_server.domain.notification.application.facade.NotificationPublisher; import org.devkor.apu.saerok_server.domain.notification.application.facade.NotifyActionDsl; -import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.Target; import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.TargetType; import org.devkor.apu.saerok_server.domain.notification.application.model.payload.ActionNotificationPayload; import org.devkor.apu.saerok_server.domain.notification.application.model.payload.NotificationPayload; @@ -49,7 +48,7 @@ void setUp() { Map extras = base == null ? new HashMap<>() : new HashMap<>(base); if (target.type() == TargetType.COLLECTION) { extras.put("collectionId", target.id()); - extras.put("collectionImageUrl", null); + extras.put("collectionImageUrl", "dummy"); } return extras; } @@ -83,8 +82,7 @@ void toggleLike_addLike_success() { verify(collectionLikeRepository).existsByUserIdAndCollectionId(userId, collectionId); ArgumentCaptor payloadCap = ArgumentCaptor.forClass(NotificationPayload.class); - ArgumentCaptor targetCap = ArgumentCaptor.forClass(Target.class); - verify(publisher).push(payloadCap.capture(), targetCap.capture()); + verify(publisher).push(payloadCap.capture()); ActionNotificationPayload p = (ActionNotificationPayload) payloadCap.getValue(); assertEquals(NotificationSubject.COLLECTION, p.subject()); @@ -94,7 +92,6 @@ void toggleLike_addLike_success() { Map extras = p.extras(); assertEquals(collectionId, extras.get("collectionId")); assertTrue(extras.containsKey("collectionImageUrl")); - assertEquals(Target.collection(collectionId), targetCap.getValue()); } @Test From 4d0dc2e45618bb42048031a4bfc483c4d85960b3 Mon Sep 17 00:00:00 2001 From: Junhee Han <115782193+soonduck-dreams@users.noreply.github.com> Date: Sun, 21 Dec 2025 23:32:19 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#278)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 공지사항 기능 추가 관리자용 공지사항 관리 엔드포인트 + 유저용 공지사항 조회 엔드포인트 * feat: 공지사항 게시될 때 푸시 알림/인앱 알림 * feat: 동일 디바이스에 푸시 알림 중복 발송 방지 로직 폰 1개에 계정 2개일 때 공지사항 알림이 2번 오지 않도록 PushGateway에서 전용 푸시 메서드를 추가하고 적용했음 --- .../api/AdminAnnouncementController.java | 173 ++++++++++++ .../AdminAnnouncementImagePresignRequest.java | 13 + .../AdminAnnouncementImageRequest.java | 16 ++ .../AdminCreateAnnouncementRequest.java | 47 ++++ .../AdminUpdateAnnouncementRequest.java | 47 ++++ .../AdminAnnouncementDetailResponse.java | 54 ++++ .../AdminAnnouncementListResponse.java | 32 +++ .../AnnouncementImagePresignResponse.java | 13 + .../application/AdminAnnouncementService.java | 251 ++++++++++++++++++ .../core/entity/Announcement.java | 158 +++++++++++ .../core/entity/AnnouncementImage.java | 41 +++ .../core/entity/AnnouncementStatus.java | 6 + .../repository/AnnouncementRepository.java | 68 +++++ .../audit/core/entity/AdminAuditAction.java | 5 +- .../core/entity/AdminAuditTargetType.java | 3 +- .../api/AnnouncementController.java | 51 ++++ .../response/AnnouncementDetailResponse.java | 21 ++ .../response/AnnouncementListResponse.java | 22 ++ .../AnnouncementPublicationScheduler.java | 17 ++ .../AnnouncementPublicationService.java | 81 ++++++ .../application/AnnouncementQueryService.java | 50 ++++ .../application/dto/PushTarget.java | 10 + .../facade/NotificationPublisher.java | 44 +++ .../facade/NotifySystemService.java | 29 ++ .../application/gateway/PushGateway.java | 6 + .../core/repository/UserDeviceRepository.java | 8 + .../infra/fcm/FcmPushGateway.java | 55 ++++ .../infra/local/LocalPushGateway.java | 15 ++ .../user/core/repository/UserRepository.java | 11 + .../security/permission/PermissionKey.java | 4 +- .../global/security/permission/permissions.md | 19 +- .../global/shared/image/ImageKind.java | 4 +- .../shared/infra/ImageDomainRouter.java | 2 +- .../V83__add_announcement_domain.sql | 50 ++++ ...__add_announcement_notification_fields.sql | 13 + 35 files changed, 1433 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/AdminAnnouncementController.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/request/AdminAnnouncementImagePresignRequest.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/request/AdminAnnouncementImageRequest.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/request/AdminCreateAnnouncementRequest.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/request/AdminUpdateAnnouncementRequest.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/response/AdminAnnouncementDetailResponse.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/response/AdminAnnouncementListResponse.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/response/AnnouncementImagePresignResponse.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/application/AdminAnnouncementService.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/core/entity/Announcement.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/core/entity/AnnouncementImage.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/core/entity/AnnouncementStatus.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/core/repository/AnnouncementRepository.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/AnnouncementController.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/dto/response/AnnouncementDetailResponse.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/dto/response/AnnouncementListResponse.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/announcement/application/AnnouncementPublicationScheduler.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/announcement/application/AnnouncementPublicationService.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/announcement/application/AnnouncementQueryService.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/dto/PushTarget.java create mode 100644 src/main/resources/db/migration/V83__add_announcement_domain.sql create mode 100644 src/main/resources/db/migration/V84__add_announcement_notification_fields.sql diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/AdminAnnouncementController.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/AdminAnnouncementController.java new file mode 100644 index 00000000..b55bfcf6 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/AdminAnnouncementController.java @@ -0,0 +1,173 @@ +package org.devkor.apu.saerok_server.domain.admin.announcement.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.admin.announcement.api.dto.request.AdminAnnouncementImagePresignRequest; +import org.devkor.apu.saerok_server.domain.admin.announcement.api.dto.request.AdminCreateAnnouncementRequest; +import org.devkor.apu.saerok_server.domain.admin.announcement.api.dto.request.AdminUpdateAnnouncementRequest; +import org.devkor.apu.saerok_server.domain.admin.announcement.api.dto.response.AdminAnnouncementDetailResponse; +import org.devkor.apu.saerok_server.domain.admin.announcement.api.dto.response.AdminAnnouncementListResponse; +import org.devkor.apu.saerok_server.domain.admin.announcement.api.dto.response.AnnouncementImagePresignResponse; +import org.devkor.apu.saerok_server.domain.admin.announcement.application.AdminAnnouncementService; +import org.devkor.apu.saerok_server.domain.admin.announcement.core.entity.Announcement; +import org.devkor.apu.saerok_server.global.security.principal.UserPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "Admin Announcement API", description = "공지사항 관리용 관리자 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("${api_prefix}/admin/announcement") +public class AdminAnnouncementController { + + private final AdminAnnouncementService adminAnnouncementService; + + @PostMapping + @PreAuthorize("@perm.has('ADMIN_ANNOUNCEMENT_WRITE')") + @Operation( + summary = "공지사항 생성", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "생성 성공", content = @Content(schema = @Schema(implementation = AdminAnnouncementDetailResponse.class))) + } + ) + public AdminAnnouncementDetailResponse createAnnouncement( + @Valid @RequestBody AdminCreateAnnouncementRequest request, + @AuthenticationPrincipal UserPrincipal admin + ) { + Announcement announcement = adminAnnouncementService.createAnnouncement( + admin.getId(), + request.title(), + request.content(), + request.scheduledAt(), + request.publishNow(), + request.sendNotification(), + request.pushTitle(), + request.pushBody(), + request.inAppBody(), + request.images() + ); + + return toDetailResponse(announcement); + } + + @PutMapping("/{id}") + @PreAuthorize("@perm.has('ADMIN_ANNOUNCEMENT_WRITE')") + @Operation( + summary = "공지사항 수정", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "수정 성공", content = @Content(schema = @Schema(implementation = AdminAnnouncementDetailResponse.class))) + } + ) + public AdminAnnouncementDetailResponse updateAnnouncement( + @PathVariable Long id, + @Valid @RequestBody AdminUpdateAnnouncementRequest request, + @AuthenticationPrincipal UserPrincipal admin + ) { + Announcement announcement = adminAnnouncementService.updateScheduledAnnouncement( + admin.getId(), + id, + request.title(), + request.content(), + request.scheduledAt(), + request.publishNow(), + request.sendNotification(), + request.pushTitle(), + request.pushBody(), + request.inAppBody(), + request.images() + ); + + return toDetailResponse(announcement); + } + + @DeleteMapping("/{id}") + @PreAuthorize("@perm.has('ADMIN_ANNOUNCEMENT_WRITE')") + @Operation( + summary = "공지사항 삭제", + security = @SecurityRequirement(name = "bearerAuth") + ) + public void deleteAnnouncement( + @PathVariable Long id, + @AuthenticationPrincipal UserPrincipal admin + ) { + adminAnnouncementService.deleteAnnouncement(admin.getId(), id); + } + + @GetMapping + @PreAuthorize("@perm.has('ADMIN_ANNOUNCEMENT_READ')") + @Operation( + summary = "공지사항 목록 조회", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = AdminAnnouncementListResponse.class))) + } + ) + public AdminAnnouncementListResponse listAnnouncements() { + List announcements = adminAnnouncementService.listAnnouncements(); + + List items = announcements.stream() + .map(a -> new AdminAnnouncementListResponse.Item( + a.getId(), + a.getTitle(), + a.getStatus(), + a.getScheduledAt(), + a.getPublishedAt(), + a.getAdmin().getNickname() + )) + .toList(); + + return new AdminAnnouncementListResponse(items); + } + + @PostMapping("/image/presign") + @PreAuthorize("@perm.has('ADMIN_ANNOUNCEMENT_WRITE')") + @Operation( + summary = "공지사항 이미지 Presigned URL 발급", + security = @SecurityRequirement(name = "bearerAuth"), + description = "공지사항 본문 이미지 업로드용 Presigned URL을 발급합니다." + ) + public AnnouncementImagePresignResponse generateImagePresignUrl( + @Valid @RequestBody AdminAnnouncementImagePresignRequest request + ) { + return adminAnnouncementService.generateImagePresignUrl(request.contentType()); + } + + private AdminAnnouncementDetailResponse toDetailResponse(Announcement announcement) { + List images = announcement.getImages().stream() + .map(img -> new AdminAnnouncementDetailResponse.Image(img.getObjectKey(), img.getContentType())) + .toList(); + + return new AdminAnnouncementDetailResponse( + announcement.getId(), + announcement.getTitle(), + announcement.getContent(), + announcement.getStatus(), + announcement.getScheduledAt(), + announcement.getPublishedAt(), + announcement.getSendNotification(), + announcement.getPushTitle(), + announcement.getPushBody(), + announcement.getInAppBody(), + announcement.getAdmin().getNickname(), + images + ); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/request/AdminAnnouncementImagePresignRequest.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/request/AdminAnnouncementImagePresignRequest.java new file mode 100644 index 00000000..2a984cbc --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/request/AdminAnnouncementImagePresignRequest.java @@ -0,0 +1,13 @@ +package org.devkor.apu.saerok_server.domain.admin.announcement.api.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "공지사항 이미지 Presigned URL 발급 요청") +public record AdminAnnouncementImagePresignRequest( + + @Schema(description = "업로드할 이미지 MIME 타입", example = "image/png", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + String contentType +) { +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/request/AdminAnnouncementImageRequest.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/request/AdminAnnouncementImageRequest.java new file mode 100644 index 00000000..f7dd5323 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/request/AdminAnnouncementImageRequest.java @@ -0,0 +1,16 @@ +package org.devkor.apu.saerok_server.domain.admin.announcement.api.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "공지사항 이미지 정보") +public record AdminAnnouncementImageRequest( + @Schema(description = "S3 object key", example = "announcements/2024/notice-1.png", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + String objectKey, + + @Schema(description = "이미지 MIME 타입", example = "image/png", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + String contentType +) { +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/request/AdminCreateAnnouncementRequest.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/request/AdminCreateAnnouncementRequest.java new file mode 100644 index 00000000..4a9ed563 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/request/AdminCreateAnnouncementRequest.java @@ -0,0 +1,47 @@ +package org.devkor.apu.saerok_server.domain.admin.announcement.api.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "공지사항 생성 요청") +public record AdminCreateAnnouncementRequest( + + @Schema(description = "공지사항 제목", example = "정기 점검 안내", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + @Size(max = 255) + String title, + + @Schema(description = "공지사항 본문 (HTML)", example = "

공지 내용

", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + String content, + + @Schema(description = "KST 기준 게시 예정 시각 (즉시 게시 시 null)", example = "2024-11-01T09:00:00") + LocalDateTime scheduledAt, + + @Schema(description = "즉시 게시 여부", example = "false") + Boolean publishNow, + + @Schema(description = "공지사항 게시 시 알림 발송 여부", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull + Boolean sendNotification, + + @Schema(description = "푸시 알림 제목 (알림 발송 시 필수)", example = "새 공지사항이 게시되었습니다.") + String pushTitle, + + @Schema(description = "푸시 알림 본문 (알림 발송 시 필수)", example = "공지사항을 확인해 주세요.") + String pushBody, + + @Schema(description = "인앱 알림 본문 (알림 발송 시 필수)", example = "새 공지사항이 올라왔어요.") + String inAppBody, + + @Schema(description = "본문에 포함될 이미지 정보 목록") + @Valid + List images +) { +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/request/AdminUpdateAnnouncementRequest.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/request/AdminUpdateAnnouncementRequest.java new file mode 100644 index 00000000..3f6a1cac --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/request/AdminUpdateAnnouncementRequest.java @@ -0,0 +1,47 @@ +package org.devkor.apu.saerok_server.domain.admin.announcement.api.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "공지사항 수정 요청") +public record AdminUpdateAnnouncementRequest( + + @Schema(description = "공지사항 제목", example = "정기 점검 일정 변경", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + @Size(max = 255) + String title, + + @Schema(description = "공지사항 본문 (HTML)", example = "

변경된 공지 내용

", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + String content, + + @Schema(description = "KST 기준 게시 예정 시각 (즉시 게시 시 null)", example = "2024-11-01T10:00:00") + LocalDateTime scheduledAt, + + @Schema(description = "즉시 게시 여부", example = "false") + Boolean publishNow, + + @Schema(description = "공지사항 게시 시 알림 발송 여부", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull + Boolean sendNotification, + + @Schema(description = "푸시 알림 제목 (알림 발송 시 필수)", example = "공지사항이 업데이트되었습니다.") + String pushTitle, + + @Schema(description = "푸시 알림 본문 (알림 발송 시 필수)", example = "변경 내용을 확인해 주세요.") + String pushBody, + + @Schema(description = "인앱 알림 본문 (알림 발송 시 필수)", example = "공지사항이 수정되었어요.") + String inAppBody, + + @Schema(description = "본문에 포함될 이미지 정보 목록") + @Valid + List images +) { +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/response/AdminAnnouncementDetailResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/response/AdminAnnouncementDetailResponse.java new file mode 100644 index 00000000..ae164994 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/response/AdminAnnouncementDetailResponse.java @@ -0,0 +1,54 @@ +package org.devkor.apu.saerok_server.domain.admin.announcement.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.devkor.apu.saerok_server.domain.admin.announcement.core.entity.AnnouncementStatus; + +import java.time.OffsetDateTime; +import java.util.List; + +@Schema(description = "공지사항 단일 응답") +public record AdminAnnouncementDetailResponse( + @Schema(description = "공지사항 ID", example = "1") + Long id, + + @Schema(description = "제목", example = "정기 점검 안내") + String title, + + @Schema(description = "본문(HTML)", example = "

내용

") + String content, + + @Schema(description = "상태", example = "SCHEDULED") + AnnouncementStatus status, + + @Schema(description = "게시 예정 시각(KST)", example = "2024-11-01T09:00:00+09:00") + OffsetDateTime scheduledAt, + + @Schema(description = "게시 시각", example = "2024-11-01T09:00:00+09:00") + OffsetDateTime publishedAt, + + @Schema(description = "알림 발송 여부", example = "true") + Boolean sendNotification, + + @Schema(description = "푸시 알림 제목", example = "새 공지사항 안내") + String pushTitle, + + @Schema(description = "푸시 알림 본문", example = "공지사항을 확인해 주세요.") + String pushBody, + + @Schema(description = "인앱 알림 본문", example = "새 공지사항이 게시되었습니다.") + String inAppBody, + + @Schema(description = "작성 관리자 닉네임", example = "운영자A") + String adminName, + + @Schema(description = "본문 이미지 정보") + List images +) { + public record Image( + @Schema(description = "이미지 object key", example = "announcements/uuid.png") + String objectKey, + + @Schema(description = "이미지 MIME 타입", example = "image/png") + String contentType + ) {} +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/response/AdminAnnouncementListResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/response/AdminAnnouncementListResponse.java new file mode 100644 index 00000000..2910341b --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/response/AdminAnnouncementListResponse.java @@ -0,0 +1,32 @@ +package org.devkor.apu.saerok_server.domain.admin.announcement.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.devkor.apu.saerok_server.domain.admin.announcement.core.entity.AnnouncementStatus; + +import java.time.OffsetDateTime; +import java.util.List; + +@Schema(description = "공지사항 목록 응답") +public record AdminAnnouncementListResponse( + List announcements +) { + public record Item( + @Schema(description = "공지사항 ID", example = "1") + Long id, + + @Schema(description = "제목", example = "정기 점검 안내") + String title, + + @Schema(description = "상태", example = "SCHEDULED") + AnnouncementStatus status, + + @Schema(description = "게시 예정 시각(KST)", example = "2024-11-01T09:00:00+09:00") + OffsetDateTime scheduledAt, + + @Schema(description = "게시 시각", example = "2024-11-01T09:00:00+09:00") + OffsetDateTime publishedAt, + + @Schema(description = "작성 관리자 닉네임", example = "운영자A") + String adminName + ) {} +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/response/AnnouncementImagePresignResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/response/AnnouncementImagePresignResponse.java new file mode 100644 index 00000000..b2e1cd94 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/response/AnnouncementImagePresignResponse.java @@ -0,0 +1,13 @@ +package org.devkor.apu.saerok_server.domain.admin.announcement.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "공지사항 이미지 업로드용 Presigned URL 응답") +public record AnnouncementImagePresignResponse( + @Schema(description = "이미지 업로드용 Presigned URL", example = "https://s3...signed-url") + String presignedUrl, + + @Schema(description = "업로드할 object key", example = "announcements/uuid.png") + String objectKey +) { +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/application/AdminAnnouncementService.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/application/AdminAnnouncementService.java new file mode 100644 index 00000000..ae082925 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/application/AdminAnnouncementService.java @@ -0,0 +1,251 @@ +package org.devkor.apu.saerok_server.domain.admin.announcement.application; + +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.admin.announcement.api.dto.request.AdminAnnouncementImageRequest; +import org.devkor.apu.saerok_server.domain.admin.announcement.api.dto.response.AnnouncementImagePresignResponse; +import org.devkor.apu.saerok_server.domain.admin.announcement.core.entity.Announcement; +import org.devkor.apu.saerok_server.domain.admin.announcement.core.entity.AnnouncementImage; +import org.devkor.apu.saerok_server.domain.admin.announcement.core.entity.AnnouncementStatus; +import org.devkor.apu.saerok_server.domain.admin.announcement.core.repository.AnnouncementRepository; +import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditAction; +import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditLog; +import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditTargetType; +import org.devkor.apu.saerok_server.domain.admin.audit.core.repository.AdminAuditLogRepository; +import org.devkor.apu.saerok_server.domain.announcement.application.AnnouncementPublicationService; +import org.devkor.apu.saerok_server.domain.user.core.entity.User; +import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; +import org.devkor.apu.saerok_server.global.shared.exception.BadRequestException; +import org.devkor.apu.saerok_server.global.shared.exception.NotFoundException; +import org.devkor.apu.saerok_server.global.shared.image.ImageKind; +import org.devkor.apu.saerok_server.global.shared.image.ImageVariantService; +import org.devkor.apu.saerok_server.global.shared.infra.ImageService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.devkor.apu.saerok_server.global.shared.util.TransactionUtils.runAfterCommitOrNow; + +@Service +@Transactional +@RequiredArgsConstructor +public class AdminAnnouncementService { + + private final AnnouncementRepository announcementRepository; + private final UserRepository userRepository; + private final ImageService imageService; + private final ImageVariantService imageVariantService; + private final AdminAuditLogRepository adminAuditLogRepository; + private final AnnouncementPublicationService publicationService; + + public Announcement createAnnouncement(Long adminUserId, + String title, + String content, + LocalDateTime scheduledAt, + Boolean publishNow, + Boolean sendNotification, + String pushTitle, + String pushBody, + String inAppBody, + List images) { + validateScheduleRequest(scheduledAt, publishNow); + validateNotificationOptions(sendNotification, pushTitle, pushBody, inAppBody); + + User admin = loadAdmin(adminUserId); + OffsetDateTime now = publicationService.nowKst(); + OffsetDateTime scheduled = publicationService.toKstOffset(scheduledAt); + + boolean shouldSendNotification = sendNotification; + if (!shouldSendNotification) { + pushTitle = null; + pushBody = null; + inAppBody = null; + } + + Announcement announcement; + if (Boolean.TRUE.equals(publishNow)) { + announcement = Announcement.createPublished(admin, title, content, now, + shouldSendNotification, pushTitle, pushBody, inAppBody); + } else { + announcement = Announcement.createScheduled(admin, title, content, scheduled, + shouldSendNotification, pushTitle, pushBody, inAppBody); + } + + announcement.replaceImages(toImages(images)); + Announcement saved = announcementRepository.save(announcement); + + if (Boolean.TRUE.equals(publishNow)) { + publicationService.notifyPublishedAnnouncement(saved); + } else if (scheduled != null && !scheduled.isAfter(now)) { + publicationService.publishAnnouncement(saved, scheduled); + } + + recordAudit(admin, AdminAuditAction.ANNOUNCEMENT_CREATED, saved); + + return saved; + } + + public Announcement updateScheduledAnnouncement(Long adminUserId, + Long announcementId, + String title, + String content, + LocalDateTime scheduledAt, + Boolean publishNow, + Boolean sendNotification, + String pushTitle, + String pushBody, + String inAppBody, + List images) { + Announcement announcement = announcementRepository.findById(announcementId) + .orElseThrow(() -> new NotFoundException("해당 ID의 공지사항이 존재하지 않아요.")); + + if (announcement.getStatus() == AnnouncementStatus.PUBLISHED) { + throw new BadRequestException("이미 게시된 공지사항은 수정할 수 없어요."); + } + + if (!Boolean.TRUE.equals(publishNow) && scheduledAt == null && announcement.getScheduledAt() == null) { + throw new BadRequestException("게시 예정 시각을 입력하거나 즉시 게시 옵션을 선택해 주세요."); + } + validateNotificationOptions(sendNotification, pushTitle, pushBody, inAppBody); + + OffsetDateTime now = publicationService.nowKst(); + OffsetDateTime newSchedule = publicationService.toKstOffset(scheduledAt); + + List previousKeys = announcement.getImages().stream() + .map(AnnouncementImage::getObjectKey) + .toList(); + + announcement.updateContent(title, content); + announcement.updateNotificationOptions(sendNotification, pushTitle, pushBody, inAppBody); + + if (Boolean.TRUE.equals(publishNow)) { + publicationService.publishAnnouncement(announcement, now); + } else if (newSchedule != null) { + announcement.reschedule(newSchedule); + if (!newSchedule.isAfter(now)) { + publicationService.publishAnnouncement(announcement, newSchedule); + } + } + + announcement.replaceImages(toImages(images)); + + cleanupRemovedImages(previousKeys, announcement.getImages()); + + User admin = loadAdmin(adminUserId); + recordAudit(admin, AdminAuditAction.ANNOUNCEMENT_UPDATED, announcement); + + return announcement; + } + + public void deleteAnnouncement(Long adminUserId, Long announcementId) { + Announcement announcement = announcementRepository.findById(announcementId) + .orElseThrow(() -> new NotFoundException("해당 ID의 공지사항이 존재하지 않아요.")); + + List imageKeys = announcement.getImages().stream() + .map(AnnouncementImage::getObjectKey) + .toList(); + + announcementRepository.delete(announcement); + + User admin = loadAdmin(adminUserId); + recordAudit(admin, AdminAuditAction.ANNOUNCEMENT_DELETED, announcement); + + if (!imageKeys.isEmpty()) { + runAfterCommitOrNow(() -> imageService.deleteAll(imageVariantService.associatedKeys(ImageKind.ANNOUNCEMENT_IMAGE, imageKeys))); + } + } + + public List listAnnouncements() { + return announcementRepository.findAllOrderByLatest(); + } + + public AnnouncementImagePresignResponse generateImagePresignUrl(String contentType) { + if (contentType == null || contentType.isBlank()) { + throw new BadRequestException("contentType 누락입니다."); + } + + String fileName = UUID.randomUUID().toString(); + String objectKey = "announcements/" + fileName; + String uploadUrl = imageService.generateUploadUrl(objectKey, contentType, 10); + + return new AnnouncementImagePresignResponse(uploadUrl, objectKey); + } + + private void validateScheduleRequest(LocalDateTime scheduledAt, Boolean publishNow) { + if (!Boolean.TRUE.equals(publishNow) && scheduledAt == null) { + throw new BadRequestException("게시 예정 시각을 입력하거나 즉시 게시 옵션을 선택해 주세요."); + } + } + + private void validateNotificationOptions(Boolean sendNotification, + String pushTitle, + String pushBody, + String inAppBody) { + if (sendNotification == null) { + throw new BadRequestException("알림 발송 여부를 입력해 주세요."); + } + if (sendNotification) { + if (pushTitle == null || pushTitle.isBlank()) { + throw new BadRequestException("푸시 알림 제목을 입력해 주세요."); + } + if (pushBody == null || pushBody.isBlank()) { + throw new BadRequestException("푸시 알림 본문을 입력해 주세요."); + } + if (inAppBody == null || inAppBody.isBlank()) { + throw new BadRequestException("인앱 알림 본문을 입력해 주세요."); + } + } + } + + private List toImages(List images) { + if (images == null || images.isEmpty()) return List.of(); + return images.stream() + .filter(Objects::nonNull) + .map(image -> AnnouncementImage.of(image.objectKey(), image.contentType())) + .toList(); + } + + private void cleanupRemovedImages(List previousKeys, List currentImages) { + Set currentKeys = currentImages.stream() + .map(AnnouncementImage::getObjectKey) + .collect(Collectors.toSet()); + + List removedKeys = previousKeys.stream() + .filter(key -> !currentKeys.contains(key)) + .toList(); + + if (!removedKeys.isEmpty()) { + runAfterCommitOrNow(() -> imageService.deleteAll(imageVariantService.associatedKeys(ImageKind.ANNOUNCEMENT_IMAGE, removedKeys))); + } + } + + private User loadAdmin(Long adminUserId) { + return userRepository.findById(adminUserId) + .orElseThrow(() -> new NotFoundException("관리자 계정이 존재하지 않아요")); + } + + private void recordAudit(User admin, AdminAuditAction action, Announcement announcement) { + Map metadata = new LinkedHashMap<>(); + metadata.put("title", announcement.getTitle()); + metadata.put("status", announcement.getStatus()); + metadata.put("scheduledAt", announcement.getScheduledAt()); + metadata.put("publishedAt", announcement.getPublishedAt()); + + adminAuditLogRepository.save(AdminAuditLog.of( + admin, + action, + AdminAuditTargetType.ANNOUNCEMENT, + announcement.getId(), + null, + metadata + )); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/core/entity/Announcement.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/core/entity/Announcement.java new file mode 100644 index 00000000..b91cf690 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/core/entity/Announcement.java @@ -0,0 +1,158 @@ +package org.devkor.apu.saerok_server.domain.admin.announcement.core.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.devkor.apu.saerok_server.domain.user.core.entity.User; +import org.devkor.apu.saerok_server.global.shared.entity.Auditable; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "announcement") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Announcement extends Auditable { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "admin_user_id", nullable = false) + private User admin; + + @Column(name = "title", nullable = false, length = 255) + private String title; + + @Column(name = "content", nullable = false, columnDefinition = "TEXT") + private String content; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 32) + private AnnouncementStatus status; + + @Column(name = "scheduled_at") + private OffsetDateTime scheduledAt; + + @Column(name = "published_at") + private OffsetDateTime publishedAt; + + @Column(name = "send_notification", nullable = false) + private Boolean sendNotification; + + @Column(name = "push_title", length = 255) + private String pushTitle; + + @Column(name = "push_body", columnDefinition = "TEXT") + private String pushBody; + + @Column(name = "in_app_body", columnDefinition = "TEXT") + private String inAppBody; + + @OneToMany(mappedBy = "announcement", cascade = CascadeType.ALL, orphanRemoval = true) + private List images = new ArrayList<>(); + + private Announcement(User admin, + String title, + String content, + AnnouncementStatus status, + OffsetDateTime scheduledAt, + OffsetDateTime publishedAt, + boolean sendNotification, + String pushTitle, + String pushBody, + String inAppBody) { + this.admin = admin; + this.title = title; + this.content = content; + this.status = status; + this.scheduledAt = scheduledAt; + this.publishedAt = publishedAt; + this.sendNotification = sendNotification; + this.pushTitle = pushTitle; + this.pushBody = pushBody; + this.inAppBody = inAppBody; + } + + public static Announcement createScheduled(User admin, + String title, + String content, + OffsetDateTime scheduledAt, + boolean sendNotification, + String pushTitle, + String pushBody, + String inAppBody) { + return new Announcement(admin, title, content, AnnouncementStatus.SCHEDULED, scheduledAt, null, + sendNotification, pushTitle, pushBody, inAppBody); + } + + public static Announcement createPublished(User admin, + String title, + String content, + OffsetDateTime publishedAt, + boolean sendNotification, + String pushTitle, + String pushBody, + String inAppBody) { + return new Announcement(admin, title, content, AnnouncementStatus.PUBLISHED, null, publishedAt, + sendNotification, pushTitle, pushBody, inAppBody); + } + + public void publish(OffsetDateTime publishedAt) { + if (this.status == AnnouncementStatus.PUBLISHED) { + return; + } + this.status = AnnouncementStatus.PUBLISHED; + this.publishedAt = publishedAt; + this.scheduledAt = null; + } + + public void reschedule(OffsetDateTime newSchedule) { + if (this.status == AnnouncementStatus.PUBLISHED) { + throw new IllegalStateException("이미 게시된 공지사항은 수정할 수 없어요."); + } + this.scheduledAt = newSchedule; + } + + public void updateContent(String title, String content) { + if (title != null) { + this.title = title; + } + if (content != null) { + this.content = content; + } + } + + public void updateNotificationOptions(boolean sendNotification, + String pushTitle, + String pushBody, + String inAppBody) { + this.sendNotification = sendNotification; + if (sendNotification) { + this.pushTitle = pushTitle; + this.pushBody = pushBody; + this.inAppBody = inAppBody; + } else { + this.pushTitle = null; + this.pushBody = null; + this.inAppBody = null; + } + } + + public void replaceImages(List newImages) { + this.images.clear(); + if (newImages == null) return; + for (AnnouncementImage image : newImages) { + image.assignAnnouncement(this); + this.images.add(image); + } + } + + public boolean isPublished() { + return this.status == AnnouncementStatus.PUBLISHED; + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/core/entity/AnnouncementImage.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/core/entity/AnnouncementImage.java new file mode 100644 index 00000000..e03300b7 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/core/entity/AnnouncementImage.java @@ -0,0 +1,41 @@ +package org.devkor.apu.saerok_server.domain.admin.announcement.core.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.devkor.apu.saerok_server.global.shared.entity.CreatedAtOnly; + +@Entity +@Table(name = "announcement_image") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AnnouncementImage extends CreatedAtOnly { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "announcement_id", nullable = false) + private Announcement announcement; + + @Column(name = "object_key", nullable = false) + private String objectKey; + + @Column(name = "content_type", nullable = false, length = 100) + private String contentType; + + private AnnouncementImage(String objectKey, String contentType) { + this.objectKey = objectKey; + this.contentType = contentType; + } + + public static AnnouncementImage of(String objectKey, String contentType) { + return new AnnouncementImage(objectKey, contentType); + } + + void assignAnnouncement(Announcement announcement) { + this.announcement = announcement; + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/core/entity/AnnouncementStatus.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/core/entity/AnnouncementStatus.java new file mode 100644 index 00000000..461c17c9 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/core/entity/AnnouncementStatus.java @@ -0,0 +1,6 @@ +package org.devkor.apu.saerok_server.domain.admin.announcement.core.entity; + +public enum AnnouncementStatus { + SCHEDULED, + PUBLISHED +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/core/repository/AnnouncementRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/core/repository/AnnouncementRepository.java new file mode 100644 index 00000000..1f701e4e --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/core/repository/AnnouncementRepository.java @@ -0,0 +1,68 @@ +package org.devkor.apu.saerok_server.domain.admin.announcement.core.repository; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.admin.announcement.core.entity.Announcement; +import org.devkor.apu.saerok_server.domain.admin.announcement.core.entity.AnnouncementStatus; +import org.springframework.stereotype.Repository; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class AnnouncementRepository { + + private final EntityManager em; + + public Announcement save(Announcement announcement) { + em.persist(announcement); + return announcement; + } + + public Optional findById(Long id) { + List results = em.createQuery( + "SELECT a FROM Announcement a " + + "JOIN FETCH a.admin " + + "WHERE a.id = :id", + Announcement.class) + .setParameter("id", id) + .getResultList(); + + return results.stream().findFirst(); + } + + public List findAllOrderByLatest() { + return em.createQuery( + "SELECT a FROM Announcement a " + + "JOIN FETCH a.admin " + + "ORDER BY COALESCE(a.publishedAt, a.scheduledAt) DESC, a.id DESC", + Announcement.class) + .getResultList(); + } + + public List findPublishedOrderByPublishedAtDesc() { + return em.createQuery( + "SELECT a FROM Announcement a " + + "WHERE a.status = :status " + + "ORDER BY a.publishedAt DESC, a.id DESC", + Announcement.class) + .setParameter("status", AnnouncementStatus.PUBLISHED) + .getResultList(); + } + + public List findDueAnnouncements(OffsetDateTime now) { + return em.createQuery( + "SELECT a FROM Announcement a " + + "WHERE a.status = :status AND a.scheduledAt <= :now", + Announcement.class) + .setParameter("status", AnnouncementStatus.SCHEDULED) + .setParameter("now", now) + .getResultList(); + } + + public void delete(Announcement announcement) { + em.remove(announcement); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditAction.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditAction.java index b89ac7e3..3ed73d1f 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditAction.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditAction.java @@ -12,5 +12,8 @@ public enum AdminAuditAction { SLOT_DELETED, AD_PLACEMENT_CREATED, AD_PLACEMENT_UPDATED, - AD_PLACEMENT_DELETED + AD_PLACEMENT_DELETED, + ANNOUNCEMENT_CREATED, + ANNOUNCEMENT_UPDATED, + ANNOUNCEMENT_DELETED } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditTargetType.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditTargetType.java index caa5c05f..8dbe870c 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditTargetType.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditTargetType.java @@ -7,5 +7,6 @@ public enum AdminAuditTargetType { COMMENT, AD, SLOT, - AD_PLACEMENT + AD_PLACEMENT, + ANNOUNCEMENT } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/AnnouncementController.java b/src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/AnnouncementController.java new file mode 100644 index 00000000..72d83a80 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/AnnouncementController.java @@ -0,0 +1,51 @@ +package org.devkor.apu.saerok_server.domain.announcement.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.announcement.api.dto.response.AnnouncementDetailResponse; +import org.devkor.apu.saerok_server.domain.announcement.api.dto.response.AnnouncementListResponse; +import org.devkor.apu.saerok_server.domain.announcement.application.AnnouncementQueryService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Announcement API", description = "서비스 공지사항 조회 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("${api_prefix}/announcements") +public class AnnouncementController { + + private final AnnouncementQueryService announcementQueryService; + + @GetMapping + @Operation( + summary = "게시된 공지사항 목록 조회", + responses = { + @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = AnnouncementListResponse.class))) + } + ) + public AnnouncementListResponse listAnnouncements() { + return announcementQueryService.listPublished(); + } + + @GetMapping("/{id}") + @Operation( + summary = "게시된 공지사항 상세 조회", + responses = { + @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = AnnouncementDetailResponse.class))), + @ApiResponse(responseCode = "404", description = "존재하지 않는 공지사항", content = @Content) + } + ) + public AnnouncementDetailResponse getAnnouncement( + @Parameter(description = "공지사항 ID", example = "1") + @PathVariable Long id + ) { + return announcementQueryService.getPublishedAnnouncement(id); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/dto/response/AnnouncementDetailResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/dto/response/AnnouncementDetailResponse.java new file mode 100644 index 00000000..95509cb4 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/dto/response/AnnouncementDetailResponse.java @@ -0,0 +1,21 @@ +package org.devkor.apu.saerok_server.domain.announcement.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.OffsetDateTime; + +@Schema(description = "공지사항 상세 응답") +public record AnnouncementDetailResponse( + @Schema(description = "공지사항 ID", example = "1") + Long id, + + @Schema(description = "공지사항 제목", example = "새 기능 안내") + String title, + + @Schema(description = "공지사항 본문 HTML", example = "

내용

") + String content, + + @Schema(description = "게시 시각", example = "2024-10-12T09:00:00+09:00") + OffsetDateTime publishedAt +) { +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/dto/response/AnnouncementListResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/dto/response/AnnouncementListResponse.java new file mode 100644 index 00000000..00bde611 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/dto/response/AnnouncementListResponse.java @@ -0,0 +1,22 @@ +package org.devkor.apu.saerok_server.domain.announcement.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.OffsetDateTime; +import java.util.List; + +@Schema(description = "게시된 공지사항 목록 응답") +public record AnnouncementListResponse( + List announcements +) { + public record Item( + @Schema(description = "공지사항 ID", example = "1") + Long id, + + @Schema(description = "공지사항 제목", example = "새 기능 안내") + String title, + + @Schema(description = "게시 시각", example = "2024-10-12T09:00:00+09:00") + OffsetDateTime publishedAt + ) {} +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/announcement/application/AnnouncementPublicationScheduler.java b/src/main/java/org/devkor/apu/saerok_server/domain/announcement/application/AnnouncementPublicationScheduler.java new file mode 100644 index 00000000..f59868fd --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/announcement/application/AnnouncementPublicationScheduler.java @@ -0,0 +1,17 @@ +package org.devkor.apu.saerok_server.domain.announcement.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AnnouncementPublicationScheduler { + + private final AnnouncementPublicationService publicationService; + + @Scheduled(fixedDelayString = "10000", initialDelayString = "10000") + public void publishDueAnnouncements() { + publicationService.publishDueAnnouncements(); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/announcement/application/AnnouncementPublicationService.java b/src/main/java/org/devkor/apu/saerok_server/domain/announcement/application/AnnouncementPublicationService.java new file mode 100644 index 00000000..a4116472 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/announcement/application/AnnouncementPublicationService.java @@ -0,0 +1,81 @@ +package org.devkor.apu.saerok_server.domain.announcement.application; + +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.admin.announcement.core.entity.Announcement; +import org.devkor.apu.saerok_server.domain.admin.announcement.core.repository.AnnouncementRepository; +import org.devkor.apu.saerok_server.domain.notification.application.facade.NotifySystemService; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; +import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Map; + +import static org.devkor.apu.saerok_server.global.shared.util.TransactionUtils.runAfterCommitOrNow; + +@Service +@Transactional +@RequiredArgsConstructor +public class AnnouncementPublicationService { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private final AnnouncementRepository announcementRepository; + private final UserRepository userRepository; + private final NotifySystemService notifySystemService; + + public OffsetDateTime toKstOffset(LocalDateTime localDateTime) { + if (localDateTime == null) return null; + return localDateTime.atZone(KST).toOffsetDateTime(); + } + + public OffsetDateTime nowKst() { + return OffsetDateTime.now(KST); + } + + public void publishDueAnnouncements() { + OffsetDateTime now = nowKst(); + List dueAnnouncements = announcementRepository.findDueAnnouncements(now); + + for (Announcement announcement : dueAnnouncements) { + OffsetDateTime publishedAt = announcement.getScheduledAt() != null + ? announcement.getScheduledAt() + : now; + publishAnnouncement(announcement, publishedAt); + } + } + + public void publishAnnouncement(Announcement announcement, OffsetDateTime publishedAt) { + if (announcement.isPublished()) { + return; + } + announcement.publish(publishedAt); + notifyPublishedAnnouncement(announcement); + } + + public void notifyPublishedAnnouncement(Announcement announcement) { + if (!announcement.isPublished() || !Boolean.TRUE.equals(announcement.getSendNotification())) { + return; + } + List userIds = userRepository.findActiveUserIds(); + if (userIds.isEmpty()) { + return; + } + Map extras = Map.of( + "title", announcement.getPushTitle(), + "body", announcement.getPushBody(), + "inAppBody", announcement.getInAppBody() + ); + + runAfterCommitOrNow(() -> notifySystemService.notifyUsersDeduplicatedPush( + userIds, + NotificationType.SYSTEM_PUBLISHED_ANNOUNCEMENT, + announcement.getId(), + extras + )); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/announcement/application/AnnouncementQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/announcement/application/AnnouncementQueryService.java new file mode 100644 index 00000000..49c3ab4e --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/announcement/application/AnnouncementQueryService.java @@ -0,0 +1,50 @@ +package org.devkor.apu.saerok_server.domain.announcement.application; + +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.admin.announcement.core.entity.Announcement; +import org.devkor.apu.saerok_server.domain.admin.announcement.core.entity.AnnouncementStatus; +import org.devkor.apu.saerok_server.domain.admin.announcement.core.repository.AnnouncementRepository; +import org.devkor.apu.saerok_server.domain.announcement.api.dto.response.AnnouncementDetailResponse; +import org.devkor.apu.saerok_server.domain.announcement.api.dto.response.AnnouncementListResponse; +import org.devkor.apu.saerok_server.global.shared.exception.NotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional +@RequiredArgsConstructor +public class AnnouncementQueryService { + + private final AnnouncementRepository announcementRepository; + + public AnnouncementListResponse listPublished() { + List published = announcementRepository.findPublishedOrderByPublishedAtDesc(); + List items = published.stream() + .map(a -> new AnnouncementListResponse.Item( + a.getId(), + a.getTitle(), + a.getPublishedAt() + )) + .toList(); + + return new AnnouncementListResponse(items); + } + + public AnnouncementDetailResponse getPublishedAnnouncement(Long id) { + Announcement announcement = announcementRepository.findById(id) + .orElseThrow(() -> new NotFoundException("존재하지 않는 공지사항이에요.")); + + if (announcement.getStatus() != AnnouncementStatus.PUBLISHED) { + throw new NotFoundException("존재하지 않는 공지사항이에요."); + } + + return new AnnouncementDetailResponse( + announcement.getId(), + announcement.getTitle(), + announcement.getContent(), + announcement.getPublishedAt() + ); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/dto/PushTarget.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/dto/PushTarget.java new file mode 100644 index 00000000..bba92a51 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/dto/PushTarget.java @@ -0,0 +1,10 @@ +package org.devkor.apu.saerok_server.domain.notification.application.dto; + +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; + +public record PushTarget( + Long userId, + NotificationType type, + PushMessageCommand command +) { +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotificationPublisher.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotificationPublisher.java index 10207dbe..27ca17b0 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotificationPublisher.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotificationPublisher.java @@ -5,6 +5,7 @@ import org.devkor.apu.saerok_server.domain.notification.application.assembly.render.NotificationRenderer.RenderedMessage; import org.devkor.apu.saerok_server.domain.notification.application.assembly.store.InAppNotificationWriter; import org.devkor.apu.saerok_server.domain.notification.application.dto.PushMessageCommand; +import org.devkor.apu.saerok_server.domain.notification.application.dto.PushTarget; import org.devkor.apu.saerok_server.domain.notification.application.gateway.PushGateway; import org.devkor.apu.saerok_server.domain.notification.application.model.payload.NotificationPayload; import org.devkor.apu.saerok_server.domain.notification.core.repository.NotificationRepository; @@ -56,4 +57,47 @@ public void push(NotificationPayload payload) { pushGateway.sendToUser(payload.recipientId(), payload.type(), cmd); } + + /** + * 여러 사용자에게 알림을 발송하되, 푸시는 디바이스 기준으로 중복 제거합니다. + */ + @Transactional + public void pushDeduplicatedByDevice(Iterable payloads) { + if (payloads == null) { + return; + } + + java.util.List targets = new java.util.ArrayList<>(); + + for (NotificationPayload payload : payloads) { + if (payload == null) { + continue; + } + + if (userRepository.findById(payload.recipientId()).isEmpty()) { + continue; + } + + RenderedMessage renderedMessage = renderer.render(payload); + Long notificationId = inAppWriter.save(payload); + int unread = notificationRepository.countUnreadByUserId(payload.recipientId()).intValue(); + + PushMessageCommand cmd = PushMessageCommand.createPushMessageCommand( + renderedMessage.pushTitle(), + renderedMessage.pushBody(), + payload.type().name(), + payload.relatedId(), + unread, + notificationId + ); + + targets.add(new PushTarget(payload.recipientId(), payload.type(), cmd)); + } + + if (targets.isEmpty()) { + return; + } + + pushGateway.sendToUsersDeduplicated(targets); + } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotifySystemService.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotifySystemService.java index df74d093..1b545814 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotifySystemService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotifySystemService.java @@ -6,6 +6,7 @@ import org.springframework.stereotype.Service; import java.util.HashMap; +import java.util.List; import java.util.Map; /** @@ -46,4 +47,32 @@ public void notifyUser( merged )); } + + /** + * 여러 사용자에게 시스템 알림을 전송하되, 푸시는 디바이스 기준으로 중복 제거합니다. + */ + public void notifyUsersDeduplicatedPush( + List recipientIds, + NotificationType type, + Long relatedId, + Map extras + ) { + if (recipientIds == null || recipientIds.isEmpty()) { + return; + } + + Map merged = new HashMap<>(); + if (extras != null) merged.putAll(extras); + + List payloads = recipientIds.stream() + .map(recipientId -> new SystemNotificationPayload( + recipientId, + type, + relatedId, + merged + )) + .toList(); + + publisher.pushDeduplicatedByDevice(payloads); + } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/gateway/PushGateway.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/gateway/PushGateway.java index 32101c40..d571c0c4 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/gateway/PushGateway.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/gateway/PushGateway.java @@ -1,6 +1,7 @@ package org.devkor.apu.saerok_server.domain.notification.application.gateway; import org.devkor.apu.saerok_server.domain.notification.application.dto.PushMessageCommand; +import org.devkor.apu.saerok_server.domain.notification.application.dto.PushTarget; import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; public interface PushGateway { @@ -12,6 +13,11 @@ public interface PushGateway { */ void sendToUser(Long userId, NotificationType type, PushMessageCommand cmd); + /** + * 여러 사용자에게 푸시를 발송하되, 동일 디바이스로의 중복 발송은 제거합니다. + */ + void sendToUsersDeduplicated(java.util.List targets); + /** * iOS 배지 업데이트용 silent push를 전송합니다. */ diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepository.java index 1754347d..26cc1380 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepository.java @@ -62,6 +62,14 @@ public Optional findByUserIdAndDeviceId(Long userId, String deviceId return results.isEmpty() ? Optional.empty() : Optional.of(results.getFirst()); } + + public List findAllByUserId(Long userId) { + return em.createQuery( + "SELECT ud FROM UserDevice ud WHERE ud.user.id = :userId", + UserDevice.class) + .setParameter("userId", userId) + .getResultList(); + } // UserDevice ID 목록으로 FCM 토큰 목록 조회 public List findTokensByUserDeviceIds(List userDeviceIds) { diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmPushGateway.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmPushGateway.java index af7960f7..d73d783f 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmPushGateway.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmPushGateway.java @@ -3,14 +3,18 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.devkor.apu.saerok_server.domain.notification.application.dto.PushMessageCommand; +import org.devkor.apu.saerok_server.domain.notification.application.dto.PushTarget; import org.devkor.apu.saerok_server.domain.notification.application.gateway.PushGateway; import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; import org.devkor.apu.saerok_server.domain.notification.core.repository.NotificationSettingRepository; import org.devkor.apu.saerok_server.domain.notification.core.repository.UserDeviceRepository; +import org.devkor.apu.saerok_server.domain.notification.core.service.NotificationSettingBackfillService; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; +import java.util.HashSet; import java.util.List; +import java.util.Set; @Slf4j @Component @@ -21,10 +25,14 @@ public class FcmPushGateway implements PushGateway { private final NotificationSettingRepository settingRepository; private final UserDeviceRepository userDeviceRepository; private final FcmMessageClient fcmMessageClient; + private final NotificationSettingBackfillService backfillService; @Override public void sendToUser(Long userId, NotificationType type, PushMessageCommand cmd) { + userDeviceRepository.findAllByUserId(userId) + .forEach(backfillService::ensureDefaults); + List deviceIds = settingRepository.findEnabledDeviceIdsByUserAndType(userId, type); if (deviceIds.isEmpty()) { log.debug("No enabled devices for user={}, type={}", userId, type); @@ -37,6 +45,53 @@ public void sendToUser(Long userId, NotificationType type, PushMessageCommand cm fcmMessageClient.sendToDevices(tokens, cmd); } + @Override + public void sendToUsersDeduplicated(List targets) { + if (targets == null || targets.isEmpty()) { + return; + } + + Set sentTokens = new HashSet<>(); + + for (PushTarget target : targets) { + if (target == null) { + continue; + } + + Long userId = target.userId(); + NotificationType type = target.type(); + PushMessageCommand cmd = target.command(); + + if (userId == null || type == null || cmd == null) { + continue; + } + + userDeviceRepository.findAllByUserId(userId) + .forEach(backfillService::ensureDefaults); + + List deviceIds = settingRepository.findEnabledDeviceIdsByUserAndType(userId, type); + if (deviceIds.isEmpty()) { + log.debug("No enabled devices for user={}, type={}", userId, type); + continue; + } + + List tokens = userDeviceRepository.findTokensByUserDeviceIds(deviceIds); + if (tokens.isEmpty()) { + continue; + } + + List deduped = tokens.stream() + .filter(sentTokens::add) + .toList(); + + if (deduped.isEmpty()) { + continue; + } + + fcmMessageClient.sendToDevices(deduped, cmd); + } + } + @Override public void sendSilentBadgeUpdate(Long userId, int unreadCount) { List tokens = userDeviceRepository.findTokensByUserId(userId); diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/local/LocalPushGateway.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/local/LocalPushGateway.java index 681c054c..e5b5c280 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/local/LocalPushGateway.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/local/LocalPushGateway.java @@ -2,6 +2,7 @@ import lombok.extern.slf4j.Slf4j; import org.devkor.apu.saerok_server.domain.notification.application.dto.PushMessageCommand; +import org.devkor.apu.saerok_server.domain.notification.application.dto.PushTarget; import org.devkor.apu.saerok_server.domain.notification.application.gateway.PushGateway; import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; import org.springframework.context.annotation.Profile; @@ -37,6 +38,20 @@ public void sendToUser(Long userId, NotificationType type, PushMessageCommand cm ); } + @Override + public void sendToUsersDeduplicated(java.util.List targets) { + if (targets == null || targets.isEmpty()) { + return; + } + + for (PushTarget target : targets) { + if (target == null) { + continue; + } + sendToUser(target.userId(), target.type(), target.command()); + } + } + @Override public void sendSilentBadgeUpdate(Long userId, int unreadCount) { log.info(""" diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepository.java index 63ed5ce6..474f54f2 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepository.java @@ -2,9 +2,11 @@ import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.user.core.entity.SignupStatusType; import org.devkor.apu.saerok_server.domain.user.core.entity.User; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -38,4 +40,13 @@ public Optional findByNickname(String nickname) { .getResultStream() .findFirst(); } + + public List findActiveUserIds() { + return em.createQuery( + "SELECT u.id FROM User u " + + "WHERE u.deletedAt IS NULL AND u.signupStatus <> :withdrawn", + Long.class) + .setParameter("withdrawn", SignupStatusType.WITHDRAWN) + .getResultList(); + } } diff --git a/src/main/java/org/devkor/apu/saerok_server/global/security/permission/PermissionKey.java b/src/main/java/org/devkor/apu/saerok_server/global/security/permission/PermissionKey.java index 6ffdc7d5..0edf4480 100644 --- a/src/main/java/org/devkor/apu/saerok_server/global/security/permission/PermissionKey.java +++ b/src/main/java/org/devkor/apu/saerok_server/global/security/permission/PermissionKey.java @@ -19,5 +19,7 @@ public enum PermissionKey { ADMIN_SLOT_DELETE, ADMIN_ROLE_MY_READ, ADMIN_ROLE_READ, - ADMIN_ROLE_WRITE + ADMIN_ROLE_WRITE, + ADMIN_ANNOUNCEMENT_READ, + ADMIN_ANNOUNCEMENT_WRITE } diff --git a/src/main/java/org/devkor/apu/saerok_server/global/security/permission/permissions.md b/src/main/java/org/devkor/apu/saerok_server/global/security/permission/permissions.md index 7c0ff986..1bfc48fa 100644 --- a/src/main/java/org/devkor/apu/saerok_server/global/security/permission/permissions.md +++ b/src/main/java/org/devkor/apu/saerok_server/global/security/permission/permissions.md @@ -81,6 +81,23 @@ - 관련 메서드: - AdminAdController.deleteSlot() +## 공지사항 (Announcement) + +- KEY: ADMIN_ANNOUNCEMENT_READ + - 설명: 관리자 공지사항 조회 + - 현재 허용된 Role: TEAM_MEMBER, ADMIN_EDITOR + - 관련 메서드: + - AdminAnnouncementController.listAnnouncements() + +- KEY: ADMIN_ANNOUNCEMENT_WRITE + - 설명: 관리자 공지사항 생성/수정/삭제 + - 현재 허용된 Role: ADMIN_EDITOR + - 관련 메서드: + - AdminAnnouncementController.createAnnouncement() + - AdminAnnouncementController.updateAnnouncement() + - AdminAnnouncementController.deleteAnnouncement() + - AdminAnnouncementController.generateImagePresignUrl() + ## 관리자 역할 관리 (Role Management) - KEY: ADMIN_ROLE_MY_READ @@ -104,4 +121,4 @@ - AdminRoleController.deleteRole() - AdminRoleController.updateRolePermissions() - AdminRoleController.grantRoleToUser() - - AdminRoleController.revokeRoleFromUser() \ No newline at end of file + - AdminRoleController.revokeRoleFromUser() diff --git a/src/main/java/org/devkor/apu/saerok_server/global/shared/image/ImageKind.java b/src/main/java/org/devkor/apu/saerok_server/global/shared/image/ImageKind.java index 22d67b05..7601a60c 100644 --- a/src/main/java/org/devkor/apu/saerok_server/global/shared/image/ImageKind.java +++ b/src/main/java/org/devkor/apu/saerok_server/global/shared/image/ImageKind.java @@ -6,10 +6,12 @@ * - USER_PROFILE_IMAGE: 사용자 프로필 이미지 (UserProfileImage) * - AD_IMAGE: 광고 배너 이미지 (Ad.objectKey) * - DEX_BIRD_IMAGE: 도감 이미지 (BirdImage) + * - ANNOUNCEMENT_IMAGE: 공지사항 본문 이미지 */ public enum ImageKind { USER_COLLECTION_IMAGE, USER_PROFILE_IMAGE, AD_IMAGE, - DEX_BIRD_IMAGE + DEX_BIRD_IMAGE, + ANNOUNCEMENT_IMAGE } diff --git a/src/main/java/org/devkor/apu/saerok_server/global/shared/infra/ImageDomainRouter.java b/src/main/java/org/devkor/apu/saerok_server/global/shared/infra/ImageDomainRouter.java index cbf1c066..9c395900 100644 --- a/src/main/java/org/devkor/apu/saerok_server/global/shared/infra/ImageDomainRouter.java +++ b/src/main/java/org/devkor/apu/saerok_server/global/shared/infra/ImageDomainRouter.java @@ -20,7 +20,7 @@ public class ImageDomainRouter { */ public String toUrlFor(ImageKind kind, String objectKey) { return switch (kind) { - case USER_COLLECTION_IMAGE, USER_PROFILE_IMAGE, AD_IMAGE -> imageDomainService.toUploadImageUrl(objectKey); + case USER_COLLECTION_IMAGE, USER_PROFILE_IMAGE, AD_IMAGE, ANNOUNCEMENT_IMAGE -> imageDomainService.toUploadImageUrl(objectKey); case DEX_BIRD_IMAGE -> imageDomainService.toDexImageUrl(objectKey); }; } diff --git a/src/main/resources/db/migration/V83__add_announcement_domain.sql b/src/main/resources/db/migration/V83__add_announcement_domain.sql new file mode 100644 index 00000000..f1ffead7 --- /dev/null +++ b/src/main/resources/db/migration/V83__add_announcement_domain.sql @@ -0,0 +1,50 @@ +-- 공지사항 도메인 테이블 및 권한 추가 + +-- 시퀀스 +CREATE SEQUENCE announcement_seq START WITH 1 INCREMENT BY 50; +CREATE SEQUENCE announcement_image_seq START WITH 1 INCREMENT BY 50; + +-- 공지사항 본문 +CREATE TABLE announcement ( + id BIGINT NOT NULL PRIMARY KEY, + admin_user_id BIGINT NOT NULL, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + status VARCHAR(32) NOT NULL, + scheduled_at TIMESTAMPTZ, + published_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +ALTER TABLE announcement + ADD CONSTRAINT fk_announcement_admin_user + FOREIGN KEY (admin_user_id) REFERENCES users(id); + +CREATE INDEX idx_announcement_status_scheduled_at + ON announcement (status, scheduled_at); + +CREATE INDEX idx_announcement_published_at + ON announcement (published_at); + +-- 공지사항 이미지 +CREATE TABLE announcement_image ( + id BIGINT NOT NULL PRIMARY KEY, + announcement_id BIGINT NOT NULL, + object_key TEXT NOT NULL, + content_type VARCHAR(100) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +ALTER TABLE announcement_image + ADD CONSTRAINT fk_announcement_image_announcement + FOREIGN KEY (announcement_id) REFERENCES announcement(id) ON DELETE CASCADE; + +-- 관리자 권한 추가 +INSERT INTO permission (id, key, description, created_at, updated_at) +SELECT nextval('permission_seq'), 'ADMIN_ANNOUNCEMENT_READ', '공지사항 관리 내역 조회', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE key = 'ADMIN_ANNOUNCEMENT_READ'); + +INSERT INTO permission (id, key, description, created_at, updated_at) +SELECT nextval('permission_seq'), 'ADMIN_ANNOUNCEMENT_WRITE', '공지사항 생성/수정/삭제', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE key = 'ADMIN_ANNOUNCEMENT_WRITE'); \ No newline at end of file diff --git a/src/main/resources/db/migration/V84__add_announcement_notification_fields.sql b/src/main/resources/db/migration/V84__add_announcement_notification_fields.sql new file mode 100644 index 00000000..3a2c10b7 --- /dev/null +++ b/src/main/resources/db/migration/V84__add_announcement_notification_fields.sql @@ -0,0 +1,13 @@ +-- 공지사항 알림 옵션 추가 + +ALTER TABLE announcement + ADD COLUMN send_notification BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE announcement + ADD COLUMN push_title VARCHAR(255); + +ALTER TABLE announcement + ADD COLUMN push_body TEXT; + +ALTER TABLE announcement + ADD COLUMN in_app_body TEXT; From f4489c984ef97a28157d6c874d97d69cb699712c Mon Sep 17 00:00:00 2001 From: Junhee Han <115782193+soonduck-dreams@users.noreply.github.com> Date: Sun, 21 Dec 2025 23:45:09 +0900 Subject: [PATCH 3/8] =?UTF-8?q?chore:=20Swagger=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=9D=91=EB=8B=B5=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=ED=8B=80=EB=A6=AC=EA=B2=8C=20=EB=82=98=ED=83=80?= =?UTF-8?q?=EB=82=98=EB=8A=94=20=EA=B2=83=20=EC=88=98=EC=A0=95=20(#279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/dto/response/AdminAnnouncementListResponse.java | 2 ++ .../announcement/api/dto/response/AnnouncementListResponse.java | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/response/AdminAnnouncementListResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/response/AdminAnnouncementListResponse.java index 2910341b..55524381 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/response/AdminAnnouncementListResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/response/AdminAnnouncementListResponse.java @@ -10,6 +10,8 @@ public record AdminAnnouncementListResponse( List announcements ) { + + @Schema(name = "AdminAnnouncementListResponse.Item") public record Item( @Schema(description = "공지사항 ID", example = "1") Long id, diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/dto/response/AnnouncementListResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/dto/response/AnnouncementListResponse.java index 00bde611..d5f3ef47 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/dto/response/AnnouncementListResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/dto/response/AnnouncementListResponse.java @@ -9,6 +9,8 @@ public record AnnouncementListResponse( List announcements ) { + + @Schema(name = "AnnouncementListResponse.Item") public record Item( @Schema(description = "공지사항 ID", example = "1") Long id, From 8641c34c2692d8a9af1150fd3d38c5e2cdd8a5e1 Mon Sep 17 00:00:00 2001 From: Junhee Han <115782193+soonduck-dreams@users.noreply.github.com> Date: Mon, 22 Dec 2025 17:03:27 +0900 Subject: [PATCH 4/8] =?UTF-8?q?chore:=20=EC=96=B4=EB=93=9C=EB=AF=BC?= =?UTF-8?q?=EC=9A=A9=20=EA=B3=B5=EC=A7=80=EC=82=AC=ED=95=AD=20=EB=8B=A8?= =?UTF-8?q?=EA=B1=B4=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80=20+?= =?UTF-8?q?=20=EA=B3=B5=EC=A7=80=EC=82=AC=ED=95=AD=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20(#280)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 어드민 공지사항 단건 조회 추가 * fix: 공지사항 알림 버그 수정 --- .../api/AdminAnnouncementController.java | 44 ++++++++- .../AdminAnnouncementDetailResponse.java | 17 ++-- .../AnnouncementImagePresignResponse.java | 5 +- .../application/AdminAnnouncementService.java | 90 +++++++++++-------- .../repository/AnnouncementRepository.java | 3 +- .../AnnouncementPublicationService.java | 6 +- 6 files changed, 111 insertions(+), 54 deletions(-) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/AdminAnnouncementController.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/AdminAnnouncementController.java index b55bfcf6..ee759a51 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/AdminAnnouncementController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/AdminAnnouncementController.java @@ -17,6 +17,7 @@ import org.devkor.apu.saerok_server.domain.admin.announcement.application.AdminAnnouncementService; import org.devkor.apu.saerok_server.domain.admin.announcement.core.entity.Announcement; import org.devkor.apu.saerok_server.global.security.principal.UserPrincipal; +import org.devkor.apu.saerok_server.global.shared.infra.ImageDomainService; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; @@ -37,6 +38,7 @@ public class AdminAnnouncementController { private final AdminAnnouncementService adminAnnouncementService; + private final ImageDomainService imageDomainService; @PostMapping @PreAuthorize("@perm.has('ADMIN_ANNOUNCEMENT_WRITE')") @@ -44,7 +46,11 @@ public class AdminAnnouncementController { summary = "공지사항 생성", security = @SecurityRequirement(name = "bearerAuth"), responses = { - @ApiResponse(responseCode = "200", description = "생성 성공", content = @Content(schema = @Schema(implementation = AdminAnnouncementDetailResponse.class))) + @ApiResponse( + responseCode = "200", + description = "생성 성공", + content = @Content(schema = @Schema(implementation = AdminAnnouncementDetailResponse.class)) + ) } ) public AdminAnnouncementDetailResponse createAnnouncement( @@ -73,7 +79,11 @@ public AdminAnnouncementDetailResponse createAnnouncement( summary = "공지사항 수정", security = @SecurityRequirement(name = "bearerAuth"), responses = { - @ApiResponse(responseCode = "200", description = "수정 성공", content = @Content(schema = @Schema(implementation = AdminAnnouncementDetailResponse.class))) + @ApiResponse( + responseCode = "200", + description = "수정 성공", + content = @Content(schema = @Schema(implementation = AdminAnnouncementDetailResponse.class)) + ) } ) public AdminAnnouncementDetailResponse updateAnnouncement( @@ -111,13 +121,35 @@ public void deleteAnnouncement( adminAnnouncementService.deleteAnnouncement(admin.getId(), id); } + @GetMapping("/{id}") + @PreAuthorize("@perm.has('ADMIN_ANNOUNCEMENT_READ')") + @Operation( + summary = "공지사항 단건 조회", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = AdminAnnouncementDetailResponse.class)) + ) + } + ) + public AdminAnnouncementDetailResponse getAnnouncement(@PathVariable Long id) { + Announcement announcement = adminAnnouncementService.getAnnouncement(id); + return toDetailResponse(announcement); + } + @GetMapping @PreAuthorize("@perm.has('ADMIN_ANNOUNCEMENT_READ')") @Operation( summary = "공지사항 목록 조회", security = @SecurityRequirement(name = "bearerAuth"), responses = { - @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = AdminAnnouncementListResponse.class))) + @ApiResponse( + responseCode = "200", + description = "목록 조회 성공", + content = @Content(schema = @Schema(implementation = AdminAnnouncementListResponse.class)) + ) } ) public AdminAnnouncementListResponse listAnnouncements() { @@ -152,7 +184,11 @@ public AnnouncementImagePresignResponse generateImagePresignUrl( private AdminAnnouncementDetailResponse toDetailResponse(Announcement announcement) { List images = announcement.getImages().stream() - .map(img -> new AdminAnnouncementDetailResponse.Image(img.getObjectKey(), img.getContentType())) + .map(img -> new AdminAnnouncementDetailResponse.Image( + img.getObjectKey(), + img.getContentType(), + img.getObjectKey() != null ? imageDomainService.toUploadImageUrl(img.getObjectKey()) : null + )) .toList(); return new AdminAnnouncementDetailResponse( diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/response/AdminAnnouncementDetailResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/response/AdminAnnouncementDetailResponse.java index ae164994..1b219788 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/response/AdminAnnouncementDetailResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/response/AdminAnnouncementDetailResponse.java @@ -14,31 +14,31 @@ public record AdminAnnouncementDetailResponse( @Schema(description = "제목", example = "정기 점검 안내") String title, - @Schema(description = "본문(HTML)", example = "

내용

") + @Schema(description = "내용(HTML)", example = "

점검 안내

") String content, @Schema(description = "상태", example = "SCHEDULED") AnnouncementStatus status, - @Schema(description = "게시 예정 시각(KST)", example = "2024-11-01T09:00:00+09:00") + @Schema(description = "예약 게시 시각(KST)", example = "2025-01-01T12:00:00+09:00") OffsetDateTime scheduledAt, - @Schema(description = "게시 시각", example = "2024-11-01T09:00:00+09:00") + @Schema(description = "게시 시각(KST)", example = "2025-01-01T12:00:00+09:00") OffsetDateTime publishedAt, @Schema(description = "알림 발송 여부", example = "true") Boolean sendNotification, - @Schema(description = "푸시 알림 제목", example = "새 공지사항 안내") + @Schema(description = "푸시 알림 제목", example = "새 공지사항이 등록되었어요") String pushTitle, @Schema(description = "푸시 알림 본문", example = "공지사항을 확인해 주세요.") String pushBody, - @Schema(description = "인앱 알림 본문", example = "새 공지사항이 게시되었습니다.") + @Schema(description = "인앱 알림 본문", example = "공지사항이 게시되었습니다.") String inAppBody, - @Schema(description = "작성 관리자 닉네임", example = "운영자A") + @Schema(description = "작성자(관리자) 닉네임", example = "admin") String adminName, @Schema(description = "본문 이미지 정보") @@ -49,6 +49,9 @@ public record Image( String objectKey, @Schema(description = "이미지 MIME 타입", example = "image/png") - String contentType + String contentType, + + @Schema(description = "이미지 접근 URL (CDN 도메인 기반)", example = "https://cdn.../announcements/uuid.png") + String imageUrl ) {} } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/response/AnnouncementImagePresignResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/response/AnnouncementImagePresignResponse.java index b2e1cd94..b385cf3a 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/response/AnnouncementImagePresignResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/api/dto/response/AnnouncementImagePresignResponse.java @@ -8,6 +8,9 @@ public record AnnouncementImagePresignResponse( String presignedUrl, @Schema(description = "업로드할 object key", example = "announcements/uuid.png") - String objectKey + String objectKey, + + @Schema(description = "업로드된 이미지의 최종 접근 URL (CDN 도메인 기반)", example = "https://cdn.../announcements/uuid.png") + String imageUrl ) { } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/application/AdminAnnouncementService.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/application/AdminAnnouncementService.java index ae082925..2cb4c13c 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/application/AdminAnnouncementService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/application/AdminAnnouncementService.java @@ -14,6 +14,7 @@ import org.devkor.apu.saerok_server.domain.announcement.application.AnnouncementPublicationService; import org.devkor.apu.saerok_server.domain.user.core.entity.User; import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; +import org.devkor.apu.saerok_server.global.shared.infra.ImageDomainService; import org.devkor.apu.saerok_server.global.shared.exception.BadRequestException; import org.devkor.apu.saerok_server.global.shared.exception.NotFoundException; import org.devkor.apu.saerok_server.global.shared.image.ImageKind; @@ -43,6 +44,7 @@ public class AdminAnnouncementService { private final UserRepository userRepository; private final ImageService imageService; private final ImageVariantService imageVariantService; + private final ImageDomainService imageDomainService; private final AdminAuditLogRepository adminAuditLogRepository; private final AnnouncementPublicationService publicationService; @@ -94,16 +96,16 @@ public Announcement createAnnouncement(Long adminUserId, } public Announcement updateScheduledAnnouncement(Long adminUserId, - Long announcementId, - String title, - String content, - LocalDateTime scheduledAt, - Boolean publishNow, - Boolean sendNotification, - String pushTitle, - String pushBody, - String inAppBody, - List images) { + Long announcementId, + String title, + String content, + LocalDateTime scheduledAt, + Boolean publishNow, + Boolean sendNotification, + String pushTitle, + String pushBody, + String inAppBody, + List images) { Announcement announcement = announcementRepository.findById(announcementId) .orElseThrow(() -> new NotFoundException("해당 ID의 공지사항이 존재하지 않아요.")); @@ -151,13 +153,14 @@ public void deleteAnnouncement(Long adminUserId, Long announcementId) { List imageKeys = announcement.getImages().stream() .map(AnnouncementImage::getObjectKey) + .filter(Objects::nonNull) .toList(); - announcementRepository.delete(announcement); - User admin = loadAdmin(adminUserId); recordAudit(admin, AdminAuditAction.ANNOUNCEMENT_DELETED, announcement); + announcementRepository.delete(announcement); + if (!imageKeys.isEmpty()) { runAfterCommitOrNow(() -> imageService.deleteAll(imageVariantService.associatedKeys(ImageKind.ANNOUNCEMENT_IMAGE, imageKeys))); } @@ -167,6 +170,11 @@ public List listAnnouncements() { return announcementRepository.findAllOrderByLatest(); } + public Announcement getAnnouncement(Long announcementId) { + return announcementRepository.findById(announcementId) + .orElseThrow(() -> new NotFoundException("해당 ID의 공지사항이 존재하지 않아요.")); + } + public AnnouncementImagePresignResponse generateImagePresignUrl(String contentType) { if (contentType == null || contentType.isBlank()) { throw new BadRequestException("contentType 누락입니다."); @@ -176,7 +184,9 @@ public AnnouncementImagePresignResponse generateImagePresignUrl(String contentTy String objectKey = "announcements/" + fileName; String uploadUrl = imageService.generateUploadUrl(objectKey, contentType, 10); - return new AnnouncementImagePresignResponse(uploadUrl, objectKey); + String imageUrl = imageDomainService.toUploadImageUrl(objectKey); + + return new AnnouncementImagePresignResponse(uploadUrl, objectKey, imageUrl); } private void validateScheduleRequest(LocalDateTime scheduledAt, Boolean publishNow) { @@ -189,47 +199,53 @@ private void validateNotificationOptions(Boolean sendNotification, String pushTitle, String pushBody, String inAppBody) { - if (sendNotification == null) { - throw new BadRequestException("알림 발송 여부를 입력해 주세요."); + if (!Boolean.TRUE.equals(sendNotification)) { + return; } - if (sendNotification) { - if (pushTitle == null || pushTitle.isBlank()) { - throw new BadRequestException("푸시 알림 제목을 입력해 주세요."); - } - if (pushBody == null || pushBody.isBlank()) { - throw new BadRequestException("푸시 알림 본문을 입력해 주세요."); - } - if (inAppBody == null || inAppBody.isBlank()) { - throw new BadRequestException("인앱 알림 본문을 입력해 주세요."); - } + if (pushTitle == null || pushTitle.isBlank() + || pushBody == null || pushBody.isBlank() + || inAppBody == null || inAppBody.isBlank()) { + throw new BadRequestException("알림을 보낼 경우 푸시 제목/본문과 인앱 알림 본문을 모두 입력해 주세요."); } } + private User loadAdmin(Long adminUserId) { + return userRepository.findById(adminUserId) + .orElseThrow(() -> new NotFoundException("관리자 정보를 찾을 수 없어요.")); + } + private List toImages(List images) { - if (images == null || images.isEmpty()) return List.of(); + if (images == null || images.isEmpty()) { + return List.of(); + } + return images.stream() .filter(Objects::nonNull) - .map(image -> AnnouncementImage.of(image.objectKey(), image.contentType())) + .filter(i -> i.objectKey() != null && !i.objectKey().isBlank()) + .map(i -> AnnouncementImage.of(i.objectKey(), i.contentType())) .toList(); } - private void cleanupRemovedImages(List previousKeys, List currentImages) { - Set currentKeys = currentImages.stream() + private void cleanupRemovedImages(List previousKeys, List newImages) { + if (previousKeys == null || previousKeys.isEmpty()) { + return; + } + + Set currentKeys = newImages == null ? Set.of() : newImages.stream() .map(AnnouncementImage::getObjectKey) + .filter(Objects::nonNull) .collect(Collectors.toSet()); - List removedKeys = previousKeys.stream() - .filter(key -> !currentKeys.contains(key)) + List removed = previousKeys.stream() + .filter(Objects::nonNull) + .filter(k -> !currentKeys.contains(k)) .toList(); - if (!removedKeys.isEmpty()) { - runAfterCommitOrNow(() -> imageService.deleteAll(imageVariantService.associatedKeys(ImageKind.ANNOUNCEMENT_IMAGE, removedKeys))); + if (removed.isEmpty()) { + return; } - } - private User loadAdmin(Long adminUserId) { - return userRepository.findById(adminUserId) - .orElseThrow(() -> new NotFoundException("관리자 계정이 존재하지 않아요")); + runAfterCommitOrNow(() -> imageService.deleteAll(imageVariantService.associatedKeys(ImageKind.ANNOUNCEMENT_IMAGE, removed))); } private void recordAudit(User admin, AdminAuditAction action, Announcement announcement) { diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/core/repository/AnnouncementRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/core/repository/AnnouncementRepository.java index 1f701e4e..71716f09 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/core/repository/AnnouncementRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/core/repository/AnnouncementRepository.java @@ -23,8 +23,9 @@ public Announcement save(Announcement announcement) { public Optional findById(Long id) { List results = em.createQuery( - "SELECT a FROM Announcement a " + + "SELECT DISTINCT a FROM Announcement a " + "JOIN FETCH a.admin " + + "LEFT JOIN FETCH a.images " + "WHERE a.id = :id", Announcement.class) .setParameter("id", id) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/announcement/application/AnnouncementPublicationService.java b/src/main/java/org/devkor/apu/saerok_server/domain/announcement/application/AnnouncementPublicationService.java index a4116472..03a36f3d 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/announcement/application/AnnouncementPublicationService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/announcement/application/AnnouncementPublicationService.java @@ -15,8 +15,6 @@ import java.util.List; import java.util.Map; -import static org.devkor.apu.saerok_server.global.shared.util.TransactionUtils.runAfterCommitOrNow; - @Service @Transactional @RequiredArgsConstructor @@ -71,11 +69,11 @@ public void notifyPublishedAnnouncement(Announcement announcement) { "inAppBody", announcement.getInAppBody() ); - runAfterCommitOrNow(() -> notifySystemService.notifyUsersDeduplicatedPush( + notifySystemService.notifyUsersDeduplicatedPush( userIds, NotificationType.SYSTEM_PUBLISHED_ANNOUNCEMENT, announcement.getId(), extras - )); + ); } } From 73e0ac20ccc34617b5a6389822967f3629eee37c Mon Sep 17 00:00:00 2001 From: Park hanjun <119517079+pizzazoa@users.noreply.github.com> Date: Wed, 24 Dec 2025 03:57:51 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat():=20=EC=95=8C=EB=A6=BC=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=ED=99=94=20=EA=B5=AC=ED=98=84=20(#281)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(infra): 배치 알림 템플릿 추가 * feat(infra): 배치 알림을 위한 설정 파일 추가 * refactor(infra): redis의 ttl을 90으로 늘림 조금 더 안전하게 하기 위해 * feat: batch 모델 생성 Batch 클래스, BatchKey, 그리고 actor 리스트에 쓰일 BatchActor를 생성함 * feat: 배치의 '상태'가 아닌, '결과'를 담는 모델 추가 이를 통해 배치가 불필요한 작업은 배치를 만들지 않고도 작업이 가능한 관심사의 분리 및 배치의 처리 결과를 확인 가능 * feat: 배치 인터페이스 및 구현체 추가 * refactor: 배치 dto를 레코드로 변경 * feat: 배치 알림 페이로드 추가 * feat: 배치 서비스 구현 * feat: 배치 알림 렌더러 추가 * feat: 인앱 writer에 배치 알림 페이로드 분기 추가 * feat: 퍼블리셔에 배치 알림 전송 로직 추가 배치화된 알림 전송을 위한 분기를 만들었고, 스케줄러에서 배치화된 알림 최종 push를 담당하도록 했습니다. 중복되는 로직은 sendNotification으로 묶었습니다. * refactor: 퍼블리셔에서 코드 중복 묶기 --- .../NotificationBatchScheduler.java | 54 +++++++++ .../application/NotificationBatchService.java | 86 +++++++++++++ .../render/BatchedNotificationRenderer.java | 53 ++++++++ .../DelegatingNotificationRenderer.java | 5 + .../render/NotificationTemplateRenderer.java | 6 + .../store/InAppNotificationWriter.java | 22 +++- .../facade/NotificationPublisher.java | 72 +++++++---- .../application/model/batch/BatchActor.java | 13 ++ .../application/model/batch/BatchKey.java | 28 +++++ .../application/model/batch/BatchResult.java | 30 +++++ .../model/batch/NotificationBatch.java | 108 +++++++++++++++++ .../payload/BatchedNotificationPayload.java | 58 +++++++++ .../model/payload/NotificationPayload.java | 3 +- .../store/NotificationBatchStore.java | 25 ++++ .../infra/redis/NotificationBatchDto.java | 70 +++++++++++ .../redis/RedisNotificationBatchStore.java | 114 ++++++++++++++++++ .../feature/NotificationBatchConfig.java | 44 +++++++ .../feature/NotificationMessagesConfig.java | 4 + src/main/resources/application.yml | 8 +- .../config/notification-messages.yml | 6 + 20 files changed, 784 insertions(+), 25 deletions(-) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationBatchScheduler.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationBatchService.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/BatchedNotificationRenderer.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/BatchActor.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/BatchKey.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/BatchResult.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/NotificationBatch.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/BatchedNotificationPayload.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/store/NotificationBatchStore.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/NotificationBatchDto.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/RedisNotificationBatchStore.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/NotificationBatchConfig.java diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationBatchScheduler.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationBatchScheduler.java new file mode 100644 index 00000000..d0a9a6f7 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationBatchScheduler.java @@ -0,0 +1,54 @@ +package org.devkor.apu.saerok_server.domain.notification.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.devkor.apu.saerok_server.domain.notification.application.facade.NotificationPublisher; +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.NotificationBatch; +import org.devkor.apu.saerok_server.global.core.config.feature.NotificationBatchConfig; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 알림 배치 스케줄러. + * 주기적으로 만료된 배치를 조회하여 전송한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationBatchScheduler { + + private final NotificationBatchService batchService; + private final NotificationPublisher publisher; + private final NotificationBatchConfig batchConfig; + + /** + * 10초마다 만료된 배치를 조회하여 전송. + */ + @Scheduled(fixedDelay = 10000, initialDelay = 10000) + public void processExpiredBatches() { + if (!batchConfig.isEnabled()) { + return; + } + + try { + List expiredBatches = batchService.findExpiredBatches(); + + if (expiredBatches.isEmpty()) { + return; + } + + for (NotificationBatch batch : expiredBatches) { + try { + publisher.pushBatch(batch); + } catch (Exception e) { + log.error("만료된 배치 처리에 실패했습니다: key={}", batch.getKey(), e); + } + } + + } catch (Exception e) { + log.error("Error in batch scheduler", e); + } + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationBatchService.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationBatchService.java new file mode 100644 index 00000000..355eda93 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationBatchService.java @@ -0,0 +1,86 @@ +package org.devkor.apu.saerok_server.domain.notification.application; + +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.*; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.ActionNotificationPayload; +import org.devkor.apu.saerok_server.domain.notification.application.store.NotificationBatchStore; +import org.devkor.apu.saerok_server.global.core.config.feature.NotificationBatchConfig; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +/** + * 알림 배치 관리 서비스. + * 배치 생성, 추가, 조회 등의 작업을 담당한다. + */ +@Service +@RequiredArgsConstructor +public class NotificationBatchService { + + private final NotificationBatchStore batchStore; + private final NotificationBatchConfig batchConfig; + + /** + * 배치에 알림 추가. + * 기존 배치가 있으면 추가하고 최대 시간까지 연장하며, 없으면 새로 생성한다. + */ + public BatchResult addToBatch(ActionNotificationPayload payload) { + // 배치 처리가 비활성화되어 있으면 즉시 전송 + if (!batchConfig.isEnabled()) { + return BatchResult.sendImmediately(); + } + + BatchKey key = new BatchKey( + payload.recipientId(), + payload.subject(), + payload.action(), + payload.relatedId() + ); + + BatchActor actor = BatchActor.of(payload.actorId(), payload.actorName()); + + synchronized (this.getLockKey(key)) { + Optional existingBatch = batchStore.findBatch(key); + + if (existingBatch.isPresent()) { + // 기존 배치에 추가하고 최대 시간까지 연장 + NotificationBatch updatedBatch = existingBatch.get() + .addActor(actor, payload.extras(), batchConfig.getMaxWindowSeconds()); + + batchStore.saveBatch(updatedBatch); + + return BatchResult.added(updatedBatch); + + } else { + // 새 배치 생성 + NotificationBatch newBatch = NotificationBatch.create( + key, + actor, + batchConfig.getInitialWindowSeconds(), + payload.extras() + ); + + batchStore.saveBatch(newBatch); + + return BatchResult.created(newBatch); + } + } + } + + public List findExpiredBatches() { + return batchStore.findExpiredBatches(); + } + + public void deleteBatch(BatchKey key) { + batchStore.deleteBatch(key); + } + + /** + * 동시성 제어를 위한 락 키 생성. + * 같은 배치 키에 대한 동시 접근을 막기 위해 String 인터닝 활용. + */ + private String getLockKey(BatchKey key) { + return key.toRedisKey().intern(); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/BatchedNotificationRenderer.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/BatchedNotificationRenderer.java new file mode 100644 index 00000000..94a0886f --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/BatchedNotificationRenderer.java @@ -0,0 +1,53 @@ +package org.devkor.apu.saerok_server.domain.notification.application.assembly.render; + +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.BatchedNotificationPayload; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.NotificationPayload; +import org.devkor.apu.saerok_server.global.core.config.feature.NotificationMessagesConfig; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * 배치 처리된 알림 렌더러. + * 액터 수에 따라 단일/집계 메시지를 렌더링한다. + */ +@Component +@RequiredArgsConstructor +public class BatchedNotificationRenderer implements NotificationRenderer { + + private final NotificationMessagesConfig messages; + + @Override + public RenderedMessage render(NotificationPayload p) { + if (!(p instanceof BatchedNotificationPayload b)) { + throw new IllegalArgumentException("Unsupported payload: " + p.getClass()); + } + + NotificationMessagesConfig.Template t = messages.forType(b.type()); + + // 단일 액터인 경우 기존 메시지 형식 사용 + if (b.isSingleActor()) { + return renderSingle(t, b); + } + + return renderBatched(t, b); + } + + private RenderedMessage renderSingle(NotificationMessagesConfig.Template t, BatchedNotificationPayload b) { + var vars = NotificationTemplateRenderer.toVars(b.extras()); + vars.put("actorName", NotificationTemplateRenderer.nullToEmpty(b.getFirstActor().name())); + + return NotificationTemplateRenderer.render(t, vars); + } + + private RenderedMessage renderBatched(NotificationMessagesConfig.Template t, BatchedNotificationPayload b) { + var vars = NotificationTemplateRenderer.toVars(b.extras()); + vars.put("actorName", NotificationTemplateRenderer.nullToEmpty(b.getFirstActor().name())); + vars.put("count", String.valueOf(b.actorCount())); + vars.put("othersCount", String.valueOf(b.actorCount() - 1)); + + return NotificationTemplateRenderer.renderBatch(t, vars); + } + +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/DelegatingNotificationRenderer.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/DelegatingNotificationRenderer.java index bcedfa6f..fd3e38d9 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/DelegatingNotificationRenderer.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/DelegatingNotificationRenderer.java @@ -2,6 +2,7 @@ import lombok.RequiredArgsConstructor; import org.devkor.apu.saerok_server.domain.notification.application.model.payload.ActionNotificationPayload; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.BatchedNotificationPayload; import org.devkor.apu.saerok_server.domain.notification.application.model.payload.NotificationPayload; import org.devkor.apu.saerok_server.domain.notification.application.model.payload.SystemNotificationPayload; import org.springframework.context.annotation.Primary; @@ -14,6 +15,7 @@ public class DelegatingNotificationRenderer implements NotificationRenderer { private final ActionNotificationRenderer actionRenderer; private final SystemNotificationRenderer systemRenderer; + private final BatchedNotificationRenderer batchedRenderer; @Override public RenderedMessage render(NotificationPayload payload) { @@ -23,6 +25,9 @@ public RenderedMessage render(NotificationPayload payload) { if (payload instanceof SystemNotificationPayload) { return systemRenderer.render(payload); } + if (payload instanceof BatchedNotificationPayload) { + return batchedRenderer.render(payload); + } throw new IllegalArgumentException("Unsupported payload: " + payload.getClass()); } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/NotificationTemplateRenderer.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/NotificationTemplateRenderer.java index 1b629d76..9fe25924 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/NotificationTemplateRenderer.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/NotificationTemplateRenderer.java @@ -17,6 +17,12 @@ static RenderedMessage render(NotificationMessagesConfig.Template template, Map< return new RenderedMessage(title, body); } + static RenderedMessage renderBatch(NotificationMessagesConfig.Template template, Map vars) { + String title = renderTemplate(template.getBatchPushTitle(), vars); + String body = renderTemplate(template.getBatchPushBody(), vars); + return new RenderedMessage(title, body); + } + static Map toVars(Map extras) { Map vars = new HashMap<>(); extras.forEach((k, v) -> vars.put(k, v == null ? "" : String.valueOf(v))); diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/store/InAppNotificationWriter.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/store/InAppNotificationWriter.java index bc09ac7e..44167794 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/store/InAppNotificationWriter.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/store/InAppNotificationWriter.java @@ -1,7 +1,9 @@ package org.devkor.apu.saerok_server.domain.notification.application.assembly.store; import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.BatchActor; import org.devkor.apu.saerok_server.domain.notification.application.model.payload.ActionNotificationPayload; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.BatchedNotificationPayload; import org.devkor.apu.saerok_server.domain.notification.application.model.payload.NotificationPayload; import org.devkor.apu.saerok_server.domain.notification.core.entity.Notification; import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; @@ -27,13 +29,29 @@ public Long save(NotificationPayload payload) { NotificationType type = payload.type(); User actor = null; + Map payloadMap = new HashMap<>(); + + // 공통 extras 복사 + if (payload.extras() != null) { + payloadMap.putAll(payload.extras()); + } + if (payload instanceof ActionNotificationPayload a) { actor = userRepository.findById(a.actorId()) .orElseThrow(() -> new IllegalArgumentException("Actor not found: " + a.actorId())); } - Map payloadMap = new HashMap<>(); - if (payload.extras() != null) payloadMap.putAll(payload.extras()); + if (payload instanceof BatchedNotificationPayload b) { + // 첫 번째 액터를 대표 액터로 저장 + BatchActor firstActor = b.getFirstActor(); + actor = userRepository.findById(firstActor.id()) + .orElse(null); // 배치 알림의 경우 액터가 없을 수도 있음 (삭제된 사용자) + + payloadMap.put("actorCount", b.actorCount()); + payloadMap.put("actors", b.actors().stream() + .map(a -> Map.of("id", a.id(), "name", a.name())) + .toList()); + } Notification entity = Notification.builder() .user(recipient) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotificationPublisher.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotificationPublisher.java index 27ca17b0..25679f0b 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotificationPublisher.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotificationPublisher.java @@ -1,18 +1,26 @@ package org.devkor.apu.saerok_server.domain.notification.application.facade; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.devkor.apu.saerok_server.domain.notification.application.NotificationBatchService; import org.devkor.apu.saerok_server.domain.notification.application.assembly.render.NotificationRenderer; import org.devkor.apu.saerok_server.domain.notification.application.assembly.render.NotificationRenderer.RenderedMessage; import org.devkor.apu.saerok_server.domain.notification.application.assembly.store.InAppNotificationWriter; import org.devkor.apu.saerok_server.domain.notification.application.dto.PushMessageCommand; import org.devkor.apu.saerok_server.domain.notification.application.dto.PushTarget; import org.devkor.apu.saerok_server.domain.notification.application.gateway.PushGateway; +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.NotificationBatch; +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.BatchResult; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.ActionNotificationPayload; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.BatchedNotificationPayload; import org.devkor.apu.saerok_server.domain.notification.application.model.payload.NotificationPayload; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.SystemNotificationPayload; import org.devkor.apu.saerok_server.domain.notification.core.repository.NotificationRepository; import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor public class NotificationPublisher { @@ -22,6 +30,7 @@ public class NotificationPublisher { private final NotificationRepository notificationRepository; private final PushGateway pushGateway; private final UserRepository userRepository; + private final NotificationBatchService batchService; /** *

모든 알림의 공통 파이프라인

@@ -35,15 +44,53 @@ public class NotificationPublisher { */ @Transactional public void push(NotificationPayload payload) { + // 시스템 알림은 즉시 전송 + if (payload instanceof SystemNotificationPayload) { + prepareNotificationTarget(payload).ifPresent(target -> + pushGateway.sendToUser(target.userId(), target.type(), target.command()) + ); + return; + } + + if (payload instanceof ActionNotificationPayload actionPayload) { + BatchResult result = batchService.addToBatch(actionPayload); + + if (result.shouldSendImmediately()) { + prepareNotificationTarget(payload).ifPresent(target -> + pushGateway.sendToUser(target.userId(), target.type(), target.command()) + ); + } + // 배치에 추가되었으면 스케줄러가 나중에 전송 + } + } + /** + * 배치 알림 전송 (스케줄러에서 호출). + */ + @Transactional + public void pushBatch(NotificationBatch batch) { + try { + BatchedNotificationPayload payload = BatchedNotificationPayload.fromBatch(batch); + prepareNotificationTarget(payload).ifPresent(target -> + pushGateway.sendToUser(target.userId(), target.type(), target.command()) + ); + + } catch (Exception e) { + log.error("Failed to send batch notification: key={}", batch.getKey(), e); + } finally { + // 성공/실패 여부와 관계없이 배치 삭제 (재시도 방지) + batchService.deleteBatch(batch.getKey()); + } + } + + private java.util.Optional prepareNotificationTarget(NotificationPayload payload) { // recipient가 삭제/미존재면 조용히 무시 if (userRepository.findById(payload.recipientId()).isEmpty()) { - return; + return java.util.Optional.empty(); } RenderedMessage renderedMessage = renderer.render(payload); Long notificationId = inAppWriter.save(payload); - int unread = notificationRepository.countUnreadByUserId(payload.recipientId()).intValue(); PushMessageCommand cmd = PushMessageCommand.createPushMessageCommand( @@ -55,7 +102,7 @@ public void push(NotificationPayload payload) { notificationId ); - pushGateway.sendToUser(payload.recipientId(), payload.type(), cmd); + return java.util.Optional.of(new PushTarget(payload.recipientId(), payload.type(), cmd)); } /** @@ -74,24 +121,7 @@ public void pushDeduplicatedByDevice(Iterable pay continue; } - if (userRepository.findById(payload.recipientId()).isEmpty()) { - continue; - } - - RenderedMessage renderedMessage = renderer.render(payload); - Long notificationId = inAppWriter.save(payload); - int unread = notificationRepository.countUnreadByUserId(payload.recipientId()).intValue(); - - PushMessageCommand cmd = PushMessageCommand.createPushMessageCommand( - renderedMessage.pushTitle(), - renderedMessage.pushBody(), - payload.type().name(), - payload.relatedId(), - unread, - notificationId - ); - - targets.add(new PushTarget(payload.recipientId(), payload.type(), cmd)); + prepareNotificationTarget(payload).ifPresent(targets::add); } if (targets.isEmpty()) { diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/BatchActor.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/BatchActor.java new file mode 100644 index 00000000..d9bc429a --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/BatchActor.java @@ -0,0 +1,13 @@ +package org.devkor.apu.saerok_server.domain.notification.application.model.batch; + +/** + * 배치 내 행동 주체(actor) 정보. + */ +public record BatchActor( + Long id, + String name +) { + public static BatchActor of(Long id, String name) { + return new BatchActor(id, name); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/BatchKey.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/BatchKey.java new file mode 100644 index 00000000..31e4d616 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/BatchKey.java @@ -0,0 +1,28 @@ +package org.devkor.apu.saerok_server.domain.notification.application.model.batch; + +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationAction; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationSubject; + +/** + * 알림 배치를 식별하기 위한 키. + */ +public record BatchKey( + Long recipientId, + NotificationSubject subject, + NotificationAction action, + Long relatedId +) { + /** + * Redis 키 형식으로 변환. + * 형식: notification:batch:{recipientId}:{subject}:{action}:{relatedId} + */ + public String toRedisKey() { + return String.format( + "notification:batch:%d:%s:%s:%d", + recipientId, + subject.name(), + action.name(), + relatedId + ); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/BatchResult.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/BatchResult.java new file mode 100644 index 00000000..b1957513 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/BatchResult.java @@ -0,0 +1,30 @@ +package org.devkor.apu.saerok_server.domain.notification.application.model.batch; + +/** + * 배치 추가 작업의 결과. + */ +public record BatchResult( + BatchAction action, + NotificationBatch batch +) { + + public static BatchResult created(NotificationBatch batch) { return new BatchResult(BatchAction.CREATED, batch); } + + public static BatchResult added(NotificationBatch batch) { + return new BatchResult(BatchAction.ADDED, batch); + } + + public static BatchResult sendImmediately() { + return new BatchResult(BatchAction.SEND_IMMEDIATELY, null); + } + + public boolean shouldSendImmediately() { + return action == BatchAction.SEND_IMMEDIATELY; + } + + public enum BatchAction { + CREATED, // 새 배치 생성됨 + ADDED, // 기존 배치에 추가됨 + SEND_IMMEDIATELY // 즉시 전송 (배치 처리 안 함) + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/NotificationBatch.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/NotificationBatch.java new file mode 100644 index 00000000..a110de46 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/NotificationBatch.java @@ -0,0 +1,108 @@ +package org.devkor.apu.saerok_server.domain.notification.application.model.batch; + +import lombok.Getter; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationAction; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationSubject; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.HashMap; + +/** + * 알림 배치 도메인 모델. + * 일정 시간 동안 같은 대상(수신자, 주제, 행동, 관련 ID)에 대한 알림을 모아서 처리한다. + */ +@Getter +public class NotificationBatch { + private final BatchKey key; + private final List actors; + private final LocalDateTime createdAt; + private final LocalDateTime expiresAt; + private final Map extras; + + public NotificationBatch( + BatchKey key, + List actors, + LocalDateTime createdAt, + LocalDateTime expiresAt, + Map extras + ) { + this.key = key; + this.actors = new ArrayList<>(actors == null ? List.of() : actors); + this.createdAt = createdAt; + this.expiresAt = expiresAt; + this.extras = new HashMap<>(extras == null ? Map.of() : extras); + } + + /** + * 새 배치 생성. + */ + public static NotificationBatch create( + BatchKey key, + BatchActor initialActor, + int initialWindowSeconds, + Map extras + ) { + LocalDateTime now = LocalDateTime.now(); + return new NotificationBatch( + key, + List.of(initialActor), + now, + now.plusSeconds(initialWindowSeconds), + extras != null ? extras : Map.of() + ); + } + + /** + * 배치에 새 액터 추가하고 만료 시간 연장 (최대 시간까지만). + * 중복된 액터는 추가하지 않는다. + * + * @param maxWindowSeconds 배치 생성 시점부터의 최대 대기 시간 + */ + public NotificationBatch addActor(BatchActor actor, Map newExtras, int maxWindowSeconds) { + List updatedActors = new ArrayList<>(this.actors); + + // 중복 체크 + boolean exists = updatedActors.stream() + .anyMatch(a -> a.id().equals(actor.id())); + + if (!exists) { + updatedActors.add(actor); + } + + // extras 병합 (새로운 extras로 기존 것을 덮어씀 - 최신 정보 유지) + Map mergedExtras = new HashMap<>(this.extras); + if (newExtras != null) { + mergedExtras.putAll(newExtras); + } + + // 만료 시간 연장 + LocalDateTime maxExpiresAt = this.createdAt.plusSeconds(maxWindowSeconds); + + // 기존 만료 시간보다 더 늦은 경우에만 연장 + LocalDateTime finalExpiresAt = maxExpiresAt.isAfter(this.expiresAt) ? maxExpiresAt : this.expiresAt; + + return new NotificationBatch( + this.key, + updatedActors, + this.createdAt, + finalExpiresAt, + mergedExtras + ); + } + + public boolean isExpired() {return LocalDateTime.now().isAfter(expiresAt);} + + public int getActorCount() {return actors.size();} + + // BatchKey 위임 편의 메서드 + public Long getRecipientId() {return key.recipientId();} + + public NotificationSubject getSubject() {return key.subject();} + + public NotificationAction getAction() {return key.action();} + + public Long getRelatedId() {return key.relatedId();} +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/BatchedNotificationPayload.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/BatchedNotificationPayload.java new file mode 100644 index 00000000..592c6528 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/BatchedNotificationPayload.java @@ -0,0 +1,58 @@ +package org.devkor.apu.saerok_server.domain.notification.application.model.payload; + +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.NotificationBatch; +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.BatchActor; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationAction; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationSubject; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; +import org.devkor.apu.saerok_server.domain.notification.core.service.NotificationTypeResolver; + +import java.util.List; +import java.util.Map; + +/** + * 배치 처리된 알림 payload. + * 여러 액터의 행동을 하나로 묶어서 전달한다. + */ +public record BatchedNotificationPayload( + Long recipientId, + NotificationSubject subject, + NotificationAction action, + Long relatedId, + List actors, + int actorCount, + Map extras +) implements NotificationPayload { + + public BatchedNotificationPayload { + extras = (extras == null) ? Map.of() : Map.copyOf(extras); + } + + public static BatchedNotificationPayload fromBatch(NotificationBatch batch) { + return new BatchedNotificationPayload( + batch.getRecipientId(), + batch.getSubject(), + batch.getAction(), + batch.getRelatedId(), + batch.getActors(), + batch.getActorCount(), + batch.getExtras() + ); + } + + @Override + public NotificationType type() { + return NotificationTypeResolver.from(subject, action); + } + + public BatchActor getFirstActor() { + if (actors.isEmpty()) { + throw new IllegalStateException("Batch has no actors"); + } + return actors.getFirst(); + } + + public boolean isSingleActor() { + return actorCount == 1; + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/NotificationPayload.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/NotificationPayload.java index 723d7a8b..6012bd83 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/NotificationPayload.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/NotificationPayload.java @@ -10,6 +10,7 @@ *
    *
  • ActionNotificationPayload: 다른 사용자(actor)의 행동에 의해 발생하는 알림
  • *
  • SystemNotificationPayload: 공지/점검 등 시스템 차원에서 발생하는 알림
  • + *
  • BatchedNotificationPayload: 여러 사용자의 행동을 모은 배치 알림
  • *
* *

@@ -18,7 +19,7 @@ *

*/ public sealed interface NotificationPayload - permits ActionNotificationPayload, SystemNotificationPayload { + permits ActionNotificationPayload, SystemNotificationPayload, BatchedNotificationPayload { /** * 클라이언트에서 식별 가능한 최종 알림 타입. diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/store/NotificationBatchStore.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/store/NotificationBatchStore.java new file mode 100644 index 00000000..2fefd9dd --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/store/NotificationBatchStore.java @@ -0,0 +1,25 @@ +package org.devkor.apu.saerok_server.domain.notification.application.store; + +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.BatchKey; +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.NotificationBatch; + +import java.util.List; +import java.util.Optional; + +/** + * 알림 배치 저장소 인터페이스. + */ +public interface NotificationBatchStore { + + Optional findBatch(BatchKey key); + + void saveBatch(NotificationBatch batch); + + void deleteBatch(BatchKey key); + + /** + * 만료된 배치 목록 조회. + * redis의 key 만료를 감안한 메서드 + */ + List findExpiredBatches(); +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/NotificationBatchDto.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/NotificationBatchDto.java new file mode 100644 index 00000000..9cd824e0 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/NotificationBatchDto.java @@ -0,0 +1,70 @@ +package org.devkor.apu.saerok_server.domain.notification.infra.redis; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.BatchActor; +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.BatchKey; +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.NotificationBatch; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationAction; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationSubject; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * Redis 직렬화를 위한 NotificationBatch DTO. + */ +public record NotificationBatchDto( + @JsonProperty("recipientId") Long recipientId, + @JsonProperty("subject") String subject, + @JsonProperty("action") String action, + @JsonProperty("relatedId") Long relatedId, + @JsonProperty("actors") List actors, + @JsonProperty("createdAt") LocalDateTime createdAt, + @JsonProperty("expiresAt") LocalDateTime expiresAt, + @JsonProperty("extras") Map extras +) { + + public static NotificationBatchDto fromBatch(NotificationBatch batch) { + List actorDtos = batch.getActors().stream() + .map(actor -> new ActorDto(actor.id(), actor.name())) + .toList(); + + return new NotificationBatchDto( + batch.getRecipientId(), + batch.getSubject().name(), + batch.getAction().name(), + batch.getRelatedId(), + actorDtos, + batch.getCreatedAt(), + batch.getExpiresAt(), + batch.getExtras() + ); + } + + public NotificationBatch toBatch() { + BatchKey key = new BatchKey( + recipientId, + NotificationSubject.valueOf(subject), + NotificationAction.valueOf(action), + relatedId + ); + + List batchActors = actors.stream() + .map(dto -> BatchActor.of(dto.id(), dto.name())) + .toList(); + + return new NotificationBatch( + key, + batchActors, + createdAt, + expiresAt, + extras == null ? Map.of() : extras + ); + } + + public record ActorDto( + @JsonProperty("id") Long id, + @JsonProperty("name") String name + ) {} +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/RedisNotificationBatchStore.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/RedisNotificationBatchStore.java new file mode 100644 index 00000000..70fec9bb --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/RedisNotificationBatchStore.java @@ -0,0 +1,114 @@ +package org.devkor.apu.saerok_server.domain.notification.infra.redis; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.BatchKey; +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.NotificationBatch; +import org.devkor.apu.saerok_server.domain.notification.application.store.NotificationBatchStore; +import org.devkor.apu.saerok_server.global.core.config.feature.NotificationBatchConfig; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Redis 기반 알림 배치 저장소 구현체. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RedisNotificationBatchStore implements NotificationBatchStore { + + private static final String KEY_PREFIX = "notification:batch:"; + private static final String KEY_PATTERN = KEY_PREFIX + "*"; + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + private final NotificationBatchConfig batchConfig; + + @Override + public Optional findBatch(BatchKey key) { + try { + String redisKey = key.toRedisKey(); + String json = redisTemplate.opsForValue().get(redisKey); + + if (json == null) { + return Optional.empty(); + } + + NotificationBatchDto dto = objectMapper.readValue(json, NotificationBatchDto.class); + NotificationBatch batch = dto.toBatch(); + return Optional.of(batch); + + } catch (JsonProcessingException e) { + log.error("Redis에서 배치 데이터 역직렬화에 실패했습니다: {}", key, e); + return Optional.empty(); + } + } + + @Override + public void saveBatch(NotificationBatch batch) { + try { + String redisKey = batch.getKey().toRedisKey(); + NotificationBatchDto dto = NotificationBatchDto.fromBatch(batch); + String json = objectMapper.writeValueAsString(dto); + + redisTemplate.opsForValue().set(redisKey, json, Duration.ofSeconds(batchConfig.getTtlSeconds())); + + } catch (JsonProcessingException e) { + log.error("Redis에서 배치 데이터 직렬화에 실패했습니다: {}", batch.getKey(), e); + throw new IllegalStateException("Redis에 배치 저장하는 것에 실패했습니다", e); + } + } + + @Override + public void deleteBatch(BatchKey key) { + String redisKey = key.toRedisKey(); + redisTemplate.delete(redisKey); + } + + @Override + public List findExpiredBatches() { + List expiredBatches = new ArrayList<>(); + + ScanOptions scanOptions = ScanOptions.scanOptions() + .match(KEY_PATTERN) + .count(100) + .build(); + + try (Cursor cursor = redisTemplate.scan(scanOptions)) { + while (cursor.hasNext()) { + String redisKey = cursor.next(); + String json = redisTemplate.opsForValue().get(redisKey); + + if (json == null) { + // 키가 스캔 후 만료되었을 수 있음 + continue; + } + + try { + NotificationBatchDto dto = objectMapper.readValue(json, NotificationBatchDto.class); + NotificationBatch batch = dto.toBatch(); + + if (batch.isExpired()) { + expiredBatches.add(batch); + } + + } catch (JsonProcessingException e) { + log.error("Redis 키 역직렬화에 실패했습니다: {}", redisKey, e); + } + } + } catch (Exception e) { + log.error("만료된 배치 스캔에 실패했습니다.", e); + } + + return expiredBatches; + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/NotificationBatchConfig.java b/src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/NotificationBatchConfig.java new file mode 100644 index 00000000..ea27a96a --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/NotificationBatchConfig.java @@ -0,0 +1,44 @@ +package org.devkor.apu.saerok_server.global.core.config.feature; + +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * 알림 배치 처리 설정. + */ +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = "notification-batch") +public class NotificationBatchConfig { + + private boolean enabled = true; // 배치 처리 활성화 여부. + private int initialWindowSeconds = 30; + private int maxWindowSeconds = 60; + private int ttlSeconds = 90; + + @PostConstruct + void validateConfig() { + if (initialWindowSeconds <= 0) { + throw new IllegalStateException("notification-batch.initial-window-seconds는 양수여야합니다"); + } + if (maxWindowSeconds <= 0) { + throw new IllegalStateException("notification-batch.max-window-seconds는 양수여야합니다"); + } + if (maxWindowSeconds < initialWindowSeconds) { + throw new IllegalStateException( + String.format("notification-batch.max-window-seconds (%d) >= initial-window-seconds (%d) 이어야 합니다", + maxWindowSeconds, initialWindowSeconds) + ); + } + if (ttlSeconds <= maxWindowSeconds) { + throw new IllegalStateException( + String.format("notification-batch.ttl-seconds (%d) > max-window-seconds (%d) 이어야 합니다", + ttlSeconds, maxWindowSeconds) + ); + } + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/NotificationMessagesConfig.java b/src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/NotificationMessagesConfig.java index aa930ea9..3d5dd1bd 100644 --- a/src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/NotificationMessagesConfig.java +++ b/src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/NotificationMessagesConfig.java @@ -43,5 +43,9 @@ public static class Template { private String pushTitle; private String pushBody; private String inAppBody; + + // 배치 알림용 템플릿 + private String batchPushTitle; + private String batchPushBody; } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 20c7ca18..278d68f6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -38,4 +38,10 @@ firebase: app: cookie: - secure: false \ No newline at end of file + secure: false + +notification-batch: + enabled: true + initial-window-seconds: 30 + max-window-seconds: 60 + ttl-seconds: 90 \ No newline at end of file diff --git a/src/main/resources/config/notification-messages.yml b/src/main/resources/config/notification-messages.yml index 4b51e952..67f871c1 100644 --- a/src/main/resources/config/notification-messages.yml +++ b/src/main/resources/config/notification-messages.yml @@ -3,12 +3,18 @@ notification-messages: LIKED_ON_COLLECTION: push-title: "{actorName}" push-body: "나의 새록을 좋아해요." + batch-push-title: "새록 좋아요" + batch-push-body: "{count}개의 좋아요가 달렸어요." COMMENTED_ON_COLLECTION: push-title: "{actorName}" push-body: "나의 새록에 댓글을 남겼어요. \"{comment}\"" + batch-push-title: "{actorName} 외 {othersCount}명" + batch-push-body: "나의 새록에 댓글을 남겼어요. \"{comment}\"" SUGGESTED_BIRD_ID_ON_COLLECTION: push-title: "동정 의견 공유" push-body: "두근두근! 새로운 의견이 공유되었어요. 확인해볼까요?" + batch-push-title: "동정 의견 공유" + batch-push-body: "{count}개의 새로운 의견이 공유되었어요. 확인해볼까요?" SYSTEM_PUBLISHED_ANNOUNCEMENT: push-title: "{title}" push-body: "{body}" \ No newline at end of file From 4e3a70891a7337fd849d0e50352385a676065c18 Mon Sep 17 00:00:00 2001 From: Park hanjun <119517079+pizzazoa@users.noreply.github.com> Date: Wed, 24 Dec 2025 16:02:29 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor(notification):=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20lock=20=EA=B0=9C=EC=84=A0=EA=B3=BC=20Redis=20SCAN?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0=20(#282)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 배치 처리에서 기존 intern 락 대신 Guava Striped 락으로 변경 OOM 에러 위험 감소 * refactor: Redis 조회를 SCAN 방식에서 ZSET 방식으로 변경 명시적으로 만료된 키들만 조회하여 성능 개선 * refactor: 타임존 Asia/Seoul로 변경과 스케줄러 최대 처리 배치 제한 --- build.gradle | 3 ++ .../application/NotificationBatchService.java | 17 +++---- .../redis/RedisNotificationBatchStore.java | 49 ++++++++++++++----- .../feature/NotificationBatchConfig.java | 4 ++ src/main/resources/application.yml | 3 +- 5 files changed, 53 insertions(+), 23 deletions(-) diff --git a/build.gradle b/build.gradle index b6fe6761..83107669 100644 --- a/build.gradle +++ b/build.gradle @@ -81,6 +81,9 @@ dependencies { // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // Guava (동시성 제어용) + implementation 'com.google.guava:guava:33.4.0-jre' } tasks.named('test') { diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationBatchService.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationBatchService.java index 355eda93..103d120a 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationBatchService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationBatchService.java @@ -1,5 +1,6 @@ package org.devkor.apu.saerok_server.domain.notification.application; +import com.google.common.util.concurrent.Striped; import lombok.RequiredArgsConstructor; import org.devkor.apu.saerok_server.domain.notification.application.model.batch.*; import org.devkor.apu.saerok_server.domain.notification.application.model.payload.ActionNotificationPayload; @@ -9,6 +10,7 @@ import java.util.List; import java.util.Optional; +import java.util.concurrent.locks.Lock; /** * 알림 배치 관리 서비스. @@ -20,6 +22,7 @@ public class NotificationBatchService { private final NotificationBatchStore batchStore; private final NotificationBatchConfig batchConfig; + private final Striped stripedLocks = Striped.lock(256); /** * 배치에 알림 추가. @@ -40,7 +43,9 @@ public BatchResult addToBatch(ActionNotificationPayload payload) { BatchActor actor = BatchActor.of(payload.actorId(), payload.actorName()); - synchronized (this.getLockKey(key)) { + Lock lock = stripedLocks.get(key.toRedisKey()); + lock.lock(); + try { Optional existingBatch = batchStore.findBatch(key); if (existingBatch.isPresent()) { @@ -65,6 +70,8 @@ public BatchResult addToBatch(ActionNotificationPayload payload) { return BatchResult.created(newBatch); } + } finally { + lock.unlock(); } } @@ -75,12 +82,4 @@ public List findExpiredBatches() { public void deleteBatch(BatchKey key) { batchStore.deleteBatch(key); } - - /** - * 동시성 제어를 위한 락 키 생성. - * 같은 배치 키에 대한 동시 접근을 막기 위해 String 인터닝 활용. - */ - private String getLockKey(BatchKey key) { - return key.toRedisKey().intern(); - } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/RedisNotificationBatchStore.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/RedisNotificationBatchStore.java index 70fec9bb..2f2c6450 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/RedisNotificationBatchStore.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/RedisNotificationBatchStore.java @@ -8,15 +8,15 @@ import org.devkor.apu.saerok_server.domain.notification.application.model.batch.NotificationBatch; import org.devkor.apu.saerok_server.domain.notification.application.store.NotificationBatchStore; import org.devkor.apu.saerok_server.global.core.config.feature.NotificationBatchConfig; -import org.springframework.data.redis.core.Cursor; -import org.springframework.data.redis.core.ScanOptions; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import java.time.Duration; +import java.time.ZoneId; import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.Set; /** * Redis 기반 알림 배치 저장소 구현체. @@ -26,8 +26,13 @@ @RequiredArgsConstructor public class RedisNotificationBatchStore implements NotificationBatchStore { - private static final String KEY_PREFIX = "notification:batch:"; - private static final String KEY_PATTERN = KEY_PREFIX + "*"; + /** + * 만료 시간 인덱스용 Sorted Set. + * score: 만료 시간 타임스탬프 (밀리초) + * member: 배치 데이터 Redis 키 + */ + private static final String EXPIRY_INDEX = "notification:batch:expiry_index"; + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; @@ -62,6 +67,13 @@ public void saveBatch(NotificationBatch batch) { redisTemplate.opsForValue().set(redisKey, json, Duration.ofSeconds(batchConfig.getTtlSeconds())); + long expiryTimestamp = batch.getExpiresAt() + .atZone(KST) + .toInstant() + .toEpochMilli(); + + redisTemplate.opsForZSet().add(EXPIRY_INDEX, redisKey, expiryTimestamp); + } catch (JsonProcessingException e) { log.error("Redis에서 배치 데이터 직렬화에 실패했습니다: {}", batch.getKey(), e); throw new IllegalStateException("Redis에 배치 저장하는 것에 실패했습니다", e); @@ -72,24 +84,33 @@ public void saveBatch(NotificationBatch batch) { public void deleteBatch(BatchKey key) { String redisKey = key.toRedisKey(); redisTemplate.delete(redisKey); + redisTemplate.opsForZSet().remove(EXPIRY_INDEX, redisKey); } + /** + * Sorted Set을 사용한 만료 배치 조회. + */ @Override public List findExpiredBatches() { List expiredBatches = new ArrayList<>(); - ScanOptions scanOptions = ScanOptions.scanOptions() - .match(KEY_PATTERN) - .count(100) - .build(); + try { + long now = System.currentTimeMillis(); + + // Sorted Set에서 score가 현재 시간 이하인 키들만 조회 + Set expiredKeys = redisTemplate.opsForZSet() + .rangeByScore(EXPIRY_INDEX, 0, now, 0, batchConfig.getMaxBatchesPerTick()); + + if (expiredKeys == null || expiredKeys.isEmpty()) { + return expiredBatches; + } - try (Cursor cursor = redisTemplate.scan(scanOptions)) { - while (cursor.hasNext()) { - String redisKey = cursor.next(); + // 만료된 키들의 데이터 조회 + for (String redisKey : expiredKeys) { String json = redisTemplate.opsForValue().get(redisKey); if (json == null) { - // 키가 스캔 후 만료되었을 수 있음 + redisTemplate.opsForZSet().remove(EXPIRY_INDEX, redisKey); continue; } @@ -99,14 +120,16 @@ public List findExpiredBatches() { if (batch.isExpired()) { expiredBatches.add(batch); + redisTemplate.opsForZSet().remove(EXPIRY_INDEX, redisKey); } } catch (JsonProcessingException e) { log.error("Redis 키 역직렬화에 실패했습니다: {}", redisKey, e); + redisTemplate.opsForZSet().remove(EXPIRY_INDEX, redisKey); } } } catch (Exception e) { - log.error("만료된 배치 스캔에 실패했습니다.", e); + log.error("만료된 배치 조회에 실패했습니다.", e); } return expiredBatches; diff --git a/src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/NotificationBatchConfig.java b/src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/NotificationBatchConfig.java index ea27a96a..4fdc012c 100644 --- a/src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/NotificationBatchConfig.java +++ b/src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/NotificationBatchConfig.java @@ -19,6 +19,7 @@ public class NotificationBatchConfig { private int initialWindowSeconds = 30; private int maxWindowSeconds = 60; private int ttlSeconds = 90; + private int maxBatchesPerTick = 300; // 한 번의 스케줄러 틱에서 처리할 최대 배치 수 @PostConstruct void validateConfig() { @@ -40,5 +41,8 @@ void validateConfig() { ttlSeconds, maxWindowSeconds) ); } + if (maxBatchesPerTick <= 0) { + throw new IllegalStateException("notification-batch.max-batches-per-tick은 양수여야합니다"); + } } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 278d68f6..1af059ae 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -44,4 +44,5 @@ notification-batch: enabled: true initial-window-seconds: 30 max-window-seconds: 60 - ttl-seconds: 90 \ No newline at end of file + ttl-seconds: 90 + max-batches-per-tick: 300 \ No newline at end of file From 3847ff52a3abf44c80a13ad00b9f5028879083a5 Mon Sep 17 00:00:00 2001 From: Junhee Han <115782193+soonduck-dreams@users.noreply.github.com> Date: Wed, 24 Dec 2025 16:57:52 +0900 Subject: [PATCH 7/8] =?UTF-8?q?chore:=20=EA=B3=B5=EC=A7=80=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EA=B8=B0=EB=8A=A5=20=EB=A7=88=EC=9D=B4=EB=84=88=20?= =?UTF-8?q?=EC=9D=B4=EC=8A=88=20=EC=88=98=EC=A0=95=20(#283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 어드민 공지사항 단건 조회 추가 * fix: 공지사항 알림 버그 수정 * chore: 공지사항 게시 시각 형식 변경 --- .../api/dto/response/AnnouncementDetailResponse.java | 6 +++--- .../api/dto/response/AnnouncementListResponse.java | 6 +++--- .../announcement/application/AnnouncementQueryService.java | 5 +++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/dto/response/AnnouncementDetailResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/dto/response/AnnouncementDetailResponse.java index 95509cb4..85bd448b 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/dto/response/AnnouncementDetailResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/dto/response/AnnouncementDetailResponse.java @@ -2,7 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; -import java.time.OffsetDateTime; +import java.time.LocalDateTime; @Schema(description = "공지사항 상세 응답") public record AnnouncementDetailResponse( @@ -15,7 +15,7 @@ public record AnnouncementDetailResponse( @Schema(description = "공지사항 본문 HTML", example = "

내용

") String content, - @Schema(description = "게시 시각", example = "2024-10-12T09:00:00+09:00") - OffsetDateTime publishedAt + @Schema(description = "게시 시각", example = "2024-10-12T09:00:00") + LocalDateTime publishedAt ) { } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/dto/response/AnnouncementListResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/dto/response/AnnouncementListResponse.java index d5f3ef47..b6694ce4 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/dto/response/AnnouncementListResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/announcement/api/dto/response/AnnouncementListResponse.java @@ -2,7 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; -import java.time.OffsetDateTime; +import java.time.LocalDateTime; import java.util.List; @Schema(description = "게시된 공지사항 목록 응답") @@ -18,7 +18,7 @@ public record Item( @Schema(description = "공지사항 제목", example = "새 기능 안내") String title, - @Schema(description = "게시 시각", example = "2024-10-12T09:00:00+09:00") - OffsetDateTime publishedAt + @Schema(description = "게시 시각", example = "2024-10-12T09:00:00") + LocalDateTime publishedAt ) {} } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/announcement/application/AnnouncementQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/announcement/application/AnnouncementQueryService.java index 49c3ab4e..09a745fb 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/announcement/application/AnnouncementQueryService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/announcement/application/AnnouncementQueryService.java @@ -7,6 +7,7 @@ import org.devkor.apu.saerok_server.domain.announcement.api.dto.response.AnnouncementDetailResponse; import org.devkor.apu.saerok_server.domain.announcement.api.dto.response.AnnouncementListResponse; import org.devkor.apu.saerok_server.global.shared.exception.NotFoundException; +import org.devkor.apu.saerok_server.global.shared.util.OffsetDateTimeLocalizer; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,7 +26,7 @@ public AnnouncementListResponse listPublished() { .map(a -> new AnnouncementListResponse.Item( a.getId(), a.getTitle(), - a.getPublishedAt() + OffsetDateTimeLocalizer.toSeoulLocalDateTime(a.getPublishedAt()) )) .toList(); @@ -44,7 +45,7 @@ public AnnouncementDetailResponse getPublishedAnnouncement(Long id) { announcement.getId(), announcement.getTitle(), announcement.getContent(), - announcement.getPublishedAt() + OffsetDateTimeLocalizer.toSeoulLocalDateTime(announcement.getPublishedAt()) ); } } From 46c9d934b700fec402ae0114a5b560516be4ef5c Mon Sep 17 00:00:00 2001 From: Junhee Han <115782193+soonduck-dreams@users.noreply.github.com> Date: Wed, 24 Dec 2025 17:21:41 +0900 Subject: [PATCH 8/8] =?UTF-8?q?chore:=20=EC=96=B4=EB=93=9C=EB=AF=BC?= =?UTF-8?q?=EC=9A=A9=20=EA=B6=8C=ED=95=9C=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80=20(#284)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 공지사항 이미지 object key prefix 수정 이미지 파일 저장 경로를 `announcements`에서 `announcement-images`로 변경 * feat: 관리자 권한 목록 조회 API 추가 권한 관리 개선을 위해 권한 목록을 조회하는 엔드포인트를 추가 --- .../application/AdminAnnouncementService.java | 2 +- .../domain/admin/role/api/AdminRoleController.java | 13 +++++++++++++ .../dto/response/AdminPermissionListResponse.java | 8 ++++++++ .../role/application/AdminRoleQueryService.java | 4 ++++ 4 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/role/api/dto/response/AdminPermissionListResponse.java diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/application/AdminAnnouncementService.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/application/AdminAnnouncementService.java index 2cb4c13c..b0217a20 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/application/AdminAnnouncementService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/announcement/application/AdminAnnouncementService.java @@ -181,7 +181,7 @@ public AnnouncementImagePresignResponse generateImagePresignUrl(String contentTy } String fileName = UUID.randomUUID().toString(); - String objectKey = "announcements/" + fileName; + String objectKey = "announcement-images/" + fileName; String uploadUrl = imageService.generateUploadUrl(objectKey, contentType, 10); String imageUrl = imageDomainService.toUploadImageUrl(objectKey); diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/role/api/AdminRoleController.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/role/api/AdminRoleController.java index bb187a0a..f5c0c538 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/role/api/AdminRoleController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/role/api/AdminRoleController.java @@ -14,6 +14,7 @@ import org.devkor.apu.saerok_server.domain.admin.role.api.dto.request.CreateRoleRequest; import org.devkor.apu.saerok_server.domain.admin.role.api.dto.request.UpdateRolePermissionsRequest; import org.devkor.apu.saerok_server.domain.admin.role.api.dto.response.AdminMyRoleResponse; +import org.devkor.apu.saerok_server.domain.admin.role.api.dto.response.AdminPermissionListResponse; import org.devkor.apu.saerok_server.domain.admin.role.api.dto.response.AdminRoleListResponse; import org.devkor.apu.saerok_server.domain.admin.role.api.dto.response.AdminRoleUserListResponse; import org.devkor.apu.saerok_server.domain.admin.role.api.dto.response.AdminUserRoleResponse; @@ -86,6 +87,18 @@ public AdminRoleListResponse listRoles() { return new AdminRoleListResponse(roles); } + @GetMapping("/permissions") + @PreAuthorize("@perm.has('ADMIN_ROLE_WRITE')") + @Operation( + summary = "권한 목록 조회", + security = @SecurityRequirement(name = "bearerAuth"), + responses = @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = AdminPermissionListResponse.class))) + ) + public AdminPermissionListResponse listPermissions() { + List permissions = mapPermissions(queryService.listPermissions()); + return new AdminPermissionListResponse(permissions); + } + @PostMapping @PreAuthorize("@perm.has('ADMIN_ROLE_WRITE')") @Operation( diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/role/api/dto/response/AdminPermissionListResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/role/api/dto/response/AdminPermissionListResponse.java new file mode 100644 index 00000000..bdbbc4f5 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/role/api/dto/response/AdminPermissionListResponse.java @@ -0,0 +1,8 @@ +package org.devkor.apu.saerok_server.domain.admin.role.api.dto.response; + +import java.util.List; + +public record AdminPermissionListResponse( + List permissions +) { +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/role/application/AdminRoleQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/role/application/AdminRoleQueryService.java index 68f82ad1..af7f291a 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/role/application/AdminRoleQueryService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/role/application/AdminRoleQueryService.java @@ -88,6 +88,10 @@ public List listRoles() { .toList(); } + public List listPermissions() { + return permissionRepository.findAll(); + } + public record MyRoleInfo(List roles, List permissions) { }