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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ public enum AdminAuditAction {
ANNOUNCEMENT_UPDATED,
ANNOUNCEMENT_DELETED,
FREEBOARD_POST_DELETED,
FREEBOARD_COMMENT_DELETED
FREEBOARD_COMMENT_DELETED,
ADMIN_MESSAGE_SENT,
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ public enum AdminAuditTargetType {
REPORT_FREEBOARD_POST,
REPORT_FREEBOARD_COMMENT,
FREEBOARD_POST,
FREEBOARD_COMMENT
FREEBOARD_COMMENT,
ADMIN_MESSAGE,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package org.devkor.apu.saerok_server.domain.admin.notification.api;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
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.notification.api.dto.request.AdminSendMessageRequest;
import org.devkor.apu.saerok_server.domain.admin.notification.application.AdminNotificationCommandService;
import org.devkor.apu.saerok_server.global.security.principal.UserPrincipal;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "Admin Notification API", description = "관리자 알림 전송 API")
@RestController
@RequiredArgsConstructor
@RequestMapping("${api_prefix}/admin/notifications")
public class AdminNotificationController {

private final AdminNotificationCommandService commandService;

@PostMapping("/messages")
@ResponseStatus(HttpStatus.NO_CONTENT)
@PreAuthorize("@perm.has('ADMIN_ANNOUNCEMENT_WRITE')")
@Operation(
summary = "특정 사용자들에게 관리자 메시지 전송",
description = "지정한 사용자 목록에게 커스텀 알림을 발송합니다.",
security = @SecurityRequirement(name = "bearerAuth"),
responses = {
@ApiResponse(responseCode = "204", description = "전송 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
@ApiResponse(responseCode = "403", description = "관리자 권한 없음", content = @Content)
}
)
public void sendMessage(
@AuthenticationPrincipal UserPrincipal admin,
@Valid @RequestBody AdminSendMessageRequest request
) {
commandService.sendMessageToUsers(
admin.getId(),
request.getUserIds(),
request.getTitle(),
request.getBody()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package org.devkor.apu.saerok_server.domain.admin.notification.api.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

@Data
@NoArgsConstructor
@Schema(description = "관리자 메시지 전송 요청")
public class AdminSendMessageRequest {

@NotNull
@Size(min = 1)
@Schema(description = "수신자 사용자 ID 목록", example = "[1, 2, 3]")
private List<Long> userIds;

@NotBlank
@Size(max = 100)
@Schema(description = "알림 제목", example = "안내 사항")
private String title;

@NotBlank
@Size(max = 500)
@Schema(description = "알림 내용", example = "서비스 이용에 참고해 주세요.")
private String body;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package org.devkor.apu.saerok_server.domain.admin.notification.application;

import lombok.RequiredArgsConstructor;
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.admin.notification.application.event.AdminNotificationEvent;
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.NotFoundException;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

@Service
@Transactional
@RequiredArgsConstructor
public class AdminNotificationCommandService {

private final ApplicationEventPublisher eventPublisher;
private final AdminAuditLogRepository adminAuditLogRepository;
private final UserRepository userRepository;

public void sendMessageToUsers(Long adminUserId, List<Long> userIds, String title, String body) {
eventPublisher.publishEvent(
new AdminNotificationEvent.AdminMessageSent(userIds, title, body)
);

User admin = userRepository.findById(adminUserId)
.orElseThrow(() -> new NotFoundException("관리자 계정이 존재하지 않아요"));

Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("recipientCount", userIds.size());
metadata.put("recipientIds", userIds);
metadata.put("title", title);
metadata.put("body", body);

adminAuditLogRepository.save(AdminAuditLog.of(
admin,
AdminAuditAction.ADMIN_MESSAGE_SENT,
AdminAuditTargetType.ADMIN_MESSAGE,
null,
null,
metadata
));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package org.devkor.apu.saerok_server.domain.admin.notification.application.event;

import java.util.List;

public sealed interface AdminNotificationEvent {

record AdminMessageSent(
List<Long> recipientIds,
String title,
String body
) implements AdminNotificationEvent {}

record ContentDeletedByReport(
Long contentOwnerId,
String reason
) implements AdminNotificationEvent {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package org.devkor.apu.saerok_server.domain.admin.notification.application.event;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.devkor.apu.saerok_server.domain.notification.application.facade.NotifySystemService;
import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

import java.util.Map;

@Slf4j
@Component
@RequiredArgsConstructor
public class AdminNotificationWorker {

private final NotifySystemService notifySystemService;

@Async("pushNotificationExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(AdminNotificationEvent.AdminMessageSent event) {
try {
Map<String, Object> extras = Map.of(
"title", event.title(),
"body", event.body()
);
notifySystemService.notifyUsersDeduplicatedPush(
event.recipientIds(),
NotificationType.SYSTEM_ADMIN_MESSAGE,
null,
extras
);
} catch (Exception e) {
log.error("Failed to send admin message notification: recipientCount={}",
event.recipientIds().size(), e);
}
}

@Async("pushNotificationExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(AdminNotificationEvent.ContentDeletedByReport event) {
try {
Map<String, Object> extras = Map.of("reason", event.reason());
notifySystemService.notifyUser(
event.contentOwnerId(),
NotificationType.SYSTEM_CONTENT_DELETED,
null,
extras
);
} catch (Exception e) {
log.error("Failed to send content-deleted notification: ownerId={}",
event.contentOwnerId(), e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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.admin.notification.application.event.AdminNotificationEvent;
import org.devkor.apu.saerok_server.domain.freeboard.core.entity.FreeBoardPost;
import org.devkor.apu.saerok_server.domain.freeboard.core.entity.FreeBoardPostComment;
import org.devkor.apu.saerok_server.domain.freeboard.core.entity.FreeBoardPostCommentReport;
Expand All @@ -16,6 +17,7 @@
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.NotFoundException;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -35,6 +37,7 @@ public class AdminFreeBoardReportCommandService {

private final AdminAuditLogRepository adminAuditLogRepository;
private final UserRepository userRepository;
private final ApplicationEventPublisher eventPublisher;

/* ───────────── 신고 무시 ───────────── */

Expand Down Expand Up @@ -128,6 +131,10 @@ public void deletePostByReport(Long adminUserId, Long reportId, String reason) {
reportId,
metadata
));

// 콘텐츠 삭제 알림
eventPublisher.publishEvent(new AdminNotificationEvent.ContentDeletedByReport(
report.getReportedUser().getId(), reason));
}

public void deleteCommentByReport(Long adminUserId, Long reportId, String reason) {
Expand Down Expand Up @@ -168,5 +175,9 @@ public void deleteCommentByReport(Long adminUserId, Long reportId, String reason
reportId,
metadata
));

// 콘텐츠 삭제 알림
eventPublisher.publishEvent(new AdminNotificationEvent.ContentDeletedByReport(
report.getReportedUser().getId(), reason));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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.admin.notification.application.event.AdminNotificationEvent;
import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection;
import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollectionComment;
import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollectionCommentReport;
Expand All @@ -15,6 +16,7 @@
import org.devkor.apu.saerok_server.global.shared.exception.NotFoundException;
import org.devkor.apu.saerok_server.global.shared.infra.ImageService;
import org.devkor.apu.saerok_server.global.shared.util.TransactionUtils;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -39,6 +41,7 @@ public class AdminReportCommandService {
// 감사/행위자 조회
private final AdminAuditLogRepository adminAuditLogRepository;
private final UserRepository userRepository;
private final ApplicationEventPublisher eventPublisher;

/* ───────────── 신고 무시 ───────────── */

Expand Down Expand Up @@ -138,7 +141,11 @@ public void deleteCollectionByReport(Long adminUserId, Long reportId, String rea
metadata
));

// 5) 커밋 후 S3 삭제
// 5) 콘텐츠 삭제 알림
eventPublisher.publishEvent(new AdminNotificationEvent.ContentDeletedByReport(
report.getReportedUser().getId(), reason));

// 6) 커밋 후 S3 삭제
if (!objectKeys.isEmpty()) {
TransactionUtils.runAfterCommitOrNow(() -> imageService.deleteAll(objectKeys));
}
Expand Down Expand Up @@ -182,5 +189,9 @@ public void deleteCommentByReport(Long adminUserId, Long reportId, String reason
reportId,
metadata
));

// 콘텐츠 삭제 알림
eventPublisher.publishEvent(new AdminNotificationEvent.ContentDeletedByReport(
report.getReportedUser().getId(), reason));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,7 @@ public enum NotificationType {
SUGGESTED_BIRD_ID_ON_COLLECTION,

// ---- System Notification Types ----
SYSTEM_PUBLISHED_ANNOUNCEMENT
SYSTEM_PUBLISHED_ANNOUNCEMENT,
SYSTEM_ADMIN_MESSAGE,
SYSTEM_CONTENT_DELETED,
}
8 changes: 7 additions & 1 deletion src/main/resources/config/notification-messages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,10 @@ notification-messages:
batch-push-body: "{count}개의 새로운 의견이 공유되었어요. 확인해볼까요?"
SYSTEM_PUBLISHED_ANNOUNCEMENT:
push-title: "{title}"
push-body: "{body}"
push-body: "{body}"
SYSTEM_ADMIN_MESSAGE:
push-title: "{title}"
push-body: "{body}"
SYSTEM_CONTENT_DELETED:
push-title: "콘텐츠 삭제 안내"
push-body: "회원님의 콘텐츠가 운영정책 위반으로 삭제되었어요. 사유: {reason}"
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.test.util.ReflectionTestUtils;

import java.util.Map;
Expand All @@ -39,6 +40,7 @@ class AdminFreeBoardReportCommandServiceTest {
@Mock FreeBoardPostCommentRepository commentRepository;
@Mock AdminAuditLogRepository adminAuditLogRepository;
@Mock UserRepository userRepository;
@Mock ApplicationEventPublisher eventPublisher;

private static User user(long id) {
User u = new User();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.test.util.ReflectionTestUtils;

import java.util.List;
Expand Down Expand Up @@ -48,6 +49,7 @@ class AdminReportCommandServiceTest {

@Mock AdminAuditLogRepository adminAuditLogRepository;
@Mock UserRepository userRepository;
@Mock ApplicationEventPublisher eventPublisher;

private static User user(long id) {
User u = new User();
Expand Down
Loading
Loading