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
@@ -0,0 +1,60 @@
package org.devkor.apu.saerok_server.domain.admin.user.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 lombok.RequiredArgsConstructor;
import org.devkor.apu.saerok_server.domain.admin.user.api.dto.response.AdminUserListResponse;
import org.devkor.apu.saerok_server.domain.admin.user.application.AdminUserQueryService;
import org.devkor.apu.saerok_server.global.shared.exception.BadRequestException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "Admin User API", description = "관리자 사용자 조회 API")
@RestController
@RequiredArgsConstructor
@RequestMapping("${api_prefix}/admin/users")
public class AdminUserController {

private static final int MAX_PAGE_SIZE = 50;

private final AdminUserQueryService queryService;

@GetMapping
@PreAuthorize("@perm.has('ADMIN_ANNOUNCEMENT_WRITE')")
@Operation(
summary = "사용자 ID/닉네임 목록 조회",
description = "대상 공지 발송용 활성 사용자 ID와 닉네임 목록을 조회합니다.",
security = @SecurityRequirement(name = "bearerAuth"),
responses = {
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content = @Content(schema = @Schema(implementation = AdminUserListResponse.class))
)
}
)
public AdminUserListResponse listUsers(
@RequestParam(required = false) String q,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size
) {
validatePagination(page, size);
return queryService.listUsers(q, page, size);
}

private void validatePagination(int page, int size) {
if (page < 1) {
throw new BadRequestException("page는 1 이상의 숫자로 입력해 주세요.");
}
if (size < 1 || size > MAX_PAGE_SIZE) {
throw new BadRequestException("size는 1 이상 50 이하의 숫자로 입력해 주세요.");
}
}
}
Loading
Loading