Skip to content

Commit e23cb84

Browse files
authored
Merge pull request #316 from DevKor-github/develop
#313~#315 반영해 배포
2 parents e68cc20 + b37593c commit e23cb84

43 files changed

Lines changed: 1119 additions & 223 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditAction.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,6 @@ public enum AdminAuditAction {
1717
ANNOUNCEMENT_UPDATED,
1818
ANNOUNCEMENT_DELETED,
1919
FREEBOARD_POST_DELETED,
20-
FREEBOARD_COMMENT_DELETED
20+
FREEBOARD_COMMENT_DELETED,
21+
ADMIN_MESSAGE_SENT,
2122
}

src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditTargetType.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,6 @@ public enum AdminAuditTargetType {
1212
REPORT_FREEBOARD_POST,
1313
REPORT_FREEBOARD_COMMENT,
1414
FREEBOARD_POST,
15-
FREEBOARD_COMMENT
15+
FREEBOARD_COMMENT,
16+
ADMIN_MESSAGE,
1617
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package org.devkor.apu.saerok_server.domain.admin.notification.api;
2+
3+
import io.swagger.v3.oas.annotations.Operation;
4+
import io.swagger.v3.oas.annotations.media.Content;
5+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
6+
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
7+
import io.swagger.v3.oas.annotations.tags.Tag;
8+
import jakarta.validation.Valid;
9+
import lombok.RequiredArgsConstructor;
10+
import org.devkor.apu.saerok_server.domain.admin.notification.api.dto.request.AdminSendMessageRequest;
11+
import org.devkor.apu.saerok_server.domain.admin.notification.application.AdminNotificationCommandService;
12+
import org.devkor.apu.saerok_server.global.security.principal.UserPrincipal;
13+
import org.springframework.http.HttpStatus;
14+
import org.springframework.security.access.prepost.PreAuthorize;
15+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
16+
import org.springframework.web.bind.annotation.PostMapping;
17+
import org.springframework.web.bind.annotation.RequestBody;
18+
import org.springframework.web.bind.annotation.RequestMapping;
19+
import org.springframework.web.bind.annotation.ResponseStatus;
20+
import org.springframework.web.bind.annotation.RestController;
21+
22+
@Tag(name = "Admin Notification API", description = "관리자 알림 전송 API")
23+
@RestController
24+
@RequiredArgsConstructor
25+
@RequestMapping("${api_prefix}/admin/notifications")
26+
public class AdminNotificationController {
27+
28+
private final AdminNotificationCommandService commandService;
29+
30+
@PostMapping("/messages")
31+
@ResponseStatus(HttpStatus.NO_CONTENT)
32+
@PreAuthorize("@perm.has('ADMIN_ANNOUNCEMENT_WRITE')")
33+
@Operation(
34+
summary = "특정 사용자들에게 관리자 메시지 전송",
35+
description = "지정한 사용자 목록에게 커스텀 알림을 발송합니다.",
36+
security = @SecurityRequirement(name = "bearerAuth"),
37+
responses = {
38+
@ApiResponse(responseCode = "204", description = "전송 성공"),
39+
@ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content),
40+
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
41+
@ApiResponse(responseCode = "403", description = "관리자 권한 없음", content = @Content)
42+
}
43+
)
44+
public void sendMessage(
45+
@AuthenticationPrincipal UserPrincipal admin,
46+
@Valid @RequestBody AdminSendMessageRequest request
47+
) {
48+
commandService.sendMessageToUsers(
49+
admin.getId(),
50+
request.getUserIds(),
51+
request.getTitle(),
52+
request.getBody()
53+
);
54+
}
55+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package org.devkor.apu.saerok_server.domain.admin.notification.api.dto.request;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.NotNull;
6+
import jakarta.validation.constraints.Size;
7+
import lombok.Data;
8+
import lombok.NoArgsConstructor;
9+
10+
import java.util.List;
11+
12+
@Data
13+
@NoArgsConstructor
14+
@Schema(description = "관리자 메시지 전송 요청")
15+
public class AdminSendMessageRequest {
16+
17+
@NotNull
18+
@Size(min = 1)
19+
@Schema(description = "수신자 사용자 ID 목록", example = "[1, 2, 3]")
20+
private List<Long> userIds;
21+
22+
@NotBlank
23+
@Size(max = 100)
24+
@Schema(description = "알림 제목", example = "안내 사항")
25+
private String title;
26+
27+
@NotBlank
28+
@Size(max = 500)
29+
@Schema(description = "알림 내용", example = "서비스 이용에 참고해 주세요.")
30+
private String body;
31+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package org.devkor.apu.saerok_server.domain.admin.notification.application;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditAction;
5+
import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditLog;
6+
import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditTargetType;
7+
import org.devkor.apu.saerok_server.domain.admin.audit.core.repository.AdminAuditLogRepository;
8+
import org.devkor.apu.saerok_server.domain.admin.notification.application.event.AdminNotificationEvent;
9+
import org.devkor.apu.saerok_server.domain.user.core.entity.User;
10+
import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository;
11+
import org.devkor.apu.saerok_server.global.shared.exception.NotFoundException;
12+
import org.springframework.context.ApplicationEventPublisher;
13+
import org.springframework.stereotype.Service;
14+
import org.springframework.transaction.annotation.Transactional;
15+
16+
import java.util.LinkedHashMap;
17+
import java.util.List;
18+
import java.util.Map;
19+
20+
@Service
21+
@Transactional
22+
@RequiredArgsConstructor
23+
public class AdminNotificationCommandService {
24+
25+
private final ApplicationEventPublisher eventPublisher;
26+
private final AdminAuditLogRepository adminAuditLogRepository;
27+
private final UserRepository userRepository;
28+
29+
public void sendMessageToUsers(Long adminUserId, List<Long> userIds, String title, String body) {
30+
eventPublisher.publishEvent(
31+
new AdminNotificationEvent.AdminMessageSent(userIds, title, body)
32+
);
33+
34+
User admin = userRepository.findById(adminUserId)
35+
.orElseThrow(() -> new NotFoundException("관리자 계정이 존재하지 않아요"));
36+
37+
Map<String, Object> metadata = new LinkedHashMap<>();
38+
metadata.put("recipientCount", userIds.size());
39+
metadata.put("recipientIds", userIds);
40+
metadata.put("title", title);
41+
metadata.put("body", body);
42+
43+
adminAuditLogRepository.save(AdminAuditLog.of(
44+
admin,
45+
AdminAuditAction.ADMIN_MESSAGE_SENT,
46+
AdminAuditTargetType.ADMIN_MESSAGE,
47+
null,
48+
null,
49+
metadata
50+
));
51+
}
52+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package org.devkor.apu.saerok_server.domain.admin.notification.application.event;
2+
3+
import java.util.List;
4+
5+
public sealed interface AdminNotificationEvent {
6+
7+
record AdminMessageSent(
8+
List<Long> recipientIds,
9+
String title,
10+
String body
11+
) implements AdminNotificationEvent {}
12+
13+
record ContentDeletedByReport(
14+
Long contentOwnerId,
15+
String reason
16+
) implements AdminNotificationEvent {}
17+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package org.devkor.apu.saerok_server.domain.admin.notification.application.event;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.devkor.apu.saerok_server.domain.notification.application.facade.NotifySystemService;
6+
import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType;
7+
import org.springframework.scheduling.annotation.Async;
8+
import org.springframework.stereotype.Component;
9+
import org.springframework.transaction.event.TransactionPhase;
10+
import org.springframework.transaction.event.TransactionalEventListener;
11+
12+
import java.util.Map;
13+
14+
@Slf4j
15+
@Component
16+
@RequiredArgsConstructor
17+
public class AdminNotificationWorker {
18+
19+
private final NotifySystemService notifySystemService;
20+
21+
@Async("pushNotificationExecutor")
22+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
23+
public void handle(AdminNotificationEvent.AdminMessageSent event) {
24+
try {
25+
Map<String, Object> extras = Map.of(
26+
"title", event.title(),
27+
"body", event.body()
28+
);
29+
notifySystemService.notifyUsersDeduplicatedPush(
30+
event.recipientIds(),
31+
NotificationType.SYSTEM_ADMIN_MESSAGE,
32+
null,
33+
extras
34+
);
35+
} catch (Exception e) {
36+
log.error("Failed to send admin message notification: recipientCount={}",
37+
event.recipientIds().size(), e);
38+
}
39+
}
40+
41+
@Async("pushNotificationExecutor")
42+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
43+
public void handle(AdminNotificationEvent.ContentDeletedByReport event) {
44+
try {
45+
Map<String, Object> extras = Map.of("reason", event.reason());
46+
notifySystemService.notifyUser(
47+
event.contentOwnerId(),
48+
NotificationType.SYSTEM_CONTENT_DELETED,
49+
null,
50+
extras
51+
);
52+
} catch (Exception e) {
53+
log.error("Failed to send content-deleted notification: ownerId={}",
54+
event.contentOwnerId(), e);
55+
}
56+
}
57+
}

src/main/java/org/devkor/apu/saerok_server/domain/admin/report/application/AdminFreeBoardReportCommandService.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditLog;
66
import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditTargetType;
77
import org.devkor.apu.saerok_server.domain.admin.audit.core.repository.AdminAuditLogRepository;
8+
import org.devkor.apu.saerok_server.domain.admin.notification.application.event.AdminNotificationEvent;
89
import org.devkor.apu.saerok_server.domain.freeboard.core.entity.FreeBoardPost;
910
import org.devkor.apu.saerok_server.domain.freeboard.core.entity.FreeBoardPostComment;
1011
import org.devkor.apu.saerok_server.domain.freeboard.core.entity.FreeBoardPostCommentReport;
@@ -16,6 +17,7 @@
1617
import org.devkor.apu.saerok_server.domain.user.core.entity.User;
1718
import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository;
1819
import org.devkor.apu.saerok_server.global.shared.exception.NotFoundException;
20+
import org.springframework.context.ApplicationEventPublisher;
1921
import org.springframework.stereotype.Service;
2022
import org.springframework.transaction.annotation.Transactional;
2123

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

3638
private final AdminAuditLogRepository adminAuditLogRepository;
3739
private final UserRepository userRepository;
40+
private final ApplicationEventPublisher eventPublisher;
3841

3942
/* ───────────── 신고 무시 ───────────── */
4043

@@ -128,6 +131,10 @@ public void deletePostByReport(Long adminUserId, Long reportId, String reason) {
128131
reportId,
129132
metadata
130133
));
134+
135+
// 콘텐츠 삭제 알림
136+
eventPublisher.publishEvent(new AdminNotificationEvent.ContentDeletedByReport(
137+
report.getReportedUser().getId(), reason));
131138
}
132139

133140
public void deleteCommentByReport(Long adminUserId, Long reportId, String reason) {
@@ -168,5 +175,9 @@ public void deleteCommentByReport(Long adminUserId, Long reportId, String reason
168175
reportId,
169176
metadata
170177
));
178+
179+
// 콘텐츠 삭제 알림
180+
eventPublisher.publishEvent(new AdminNotificationEvent.ContentDeletedByReport(
181+
report.getReportedUser().getId(), reason));
171182
}
172183
}

src/main/java/org/devkor/apu/saerok_server/domain/admin/report/application/AdminReportCommandService.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditLog;
66
import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditTargetType;
77
import org.devkor.apu.saerok_server.domain.admin.audit.core.repository.AdminAuditLogRepository;
8+
import org.devkor.apu.saerok_server.domain.admin.notification.application.event.AdminNotificationEvent;
89
import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection;
910
import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollectionComment;
1011
import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollectionCommentReport;
@@ -15,6 +16,7 @@
1516
import org.devkor.apu.saerok_server.global.shared.exception.NotFoundException;
1617
import org.devkor.apu.saerok_server.global.shared.infra.ImageService;
1718
import org.devkor.apu.saerok_server.global.shared.util.TransactionUtils;
19+
import org.springframework.context.ApplicationEventPublisher;
1820
import org.springframework.stereotype.Service;
1921
import org.springframework.transaction.annotation.Transactional;
2022

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

4346
/* ───────────── 신고 무시 ───────────── */
4447

@@ -138,7 +141,11 @@ public void deleteCollectionByReport(Long adminUserId, Long reportId, String rea
138141
metadata
139142
));
140143

141-
// 5) 커밋 후 S3 삭제
144+
// 5) 콘텐츠 삭제 알림
145+
eventPublisher.publishEvent(new AdminNotificationEvent.ContentDeletedByReport(
146+
report.getReportedUser().getId(), reason));
147+
148+
// 6) 커밋 후 S3 삭제
142149
if (!objectKeys.isEmpty()) {
143150
TransactionUtils.runAfterCommitOrNow(() -> imageService.deleteAll(objectKeys));
144151
}
@@ -182,5 +189,9 @@ public void deleteCommentByReport(Long adminUserId, Long reportId, String reason
182189
reportId,
183190
metadata
184191
));
192+
193+
// 콘텐츠 삭제 알림
194+
eventPublisher.publishEvent(new AdminNotificationEvent.ContentDeletedByReport(
195+
report.getReportedUser().getId(), reason));
185196
}
186197
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package org.devkor.apu.saerok_server.domain.admin.user.api;
2+
3+
import io.swagger.v3.oas.annotations.Operation;
4+
import io.swagger.v3.oas.annotations.media.Content;
5+
import io.swagger.v3.oas.annotations.media.Schema;
6+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
7+
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
8+
import io.swagger.v3.oas.annotations.tags.Tag;
9+
import lombok.RequiredArgsConstructor;
10+
import org.devkor.apu.saerok_server.domain.admin.user.api.dto.response.AdminUserListResponse;
11+
import org.devkor.apu.saerok_server.domain.admin.user.application.AdminUserQueryService;
12+
import org.devkor.apu.saerok_server.global.shared.exception.BadRequestException;
13+
import org.springframework.security.access.prepost.PreAuthorize;
14+
import org.springframework.web.bind.annotation.GetMapping;
15+
import org.springframework.web.bind.annotation.RequestMapping;
16+
import org.springframework.web.bind.annotation.RequestParam;
17+
import org.springframework.web.bind.annotation.RestController;
18+
19+
@Tag(name = "Admin User API", description = "관리자 사용자 조회 API")
20+
@RestController
21+
@RequiredArgsConstructor
22+
@RequestMapping("${api_prefix}/admin/users")
23+
public class AdminUserController {
24+
25+
private static final int MAX_PAGE_SIZE = 50;
26+
27+
private final AdminUserQueryService queryService;
28+
29+
@GetMapping
30+
@PreAuthorize("@perm.has('ADMIN_ANNOUNCEMENT_WRITE')")
31+
@Operation(
32+
summary = "사용자 ID/닉네임 목록 조회",
33+
description = "대상 공지 발송용 활성 사용자 ID와 닉네임 목록을 조회합니다.",
34+
security = @SecurityRequirement(name = "bearerAuth"),
35+
responses = {
36+
@ApiResponse(
37+
responseCode = "200",
38+
description = "조회 성공",
39+
content = @Content(schema = @Schema(implementation = AdminUserListResponse.class))
40+
)
41+
}
42+
)
43+
public AdminUserListResponse listUsers(
44+
@RequestParam(required = false) String q,
45+
@RequestParam(defaultValue = "1") int page,
46+
@RequestParam(defaultValue = "20") int size
47+
) {
48+
validatePagination(page, size);
49+
return queryService.listUsers(q, page, size);
50+
}
51+
52+
private void validatePagination(int page, int size) {
53+
if (page < 1) {
54+
throw new BadRequestException("page는 1 이상의 숫자로 입력해 주세요.");
55+
}
56+
if (size < 1 || size > MAX_PAGE_SIZE) {
57+
throw new BadRequestException("size는 1 이상 50 이하의 숫자로 입력해 주세요.");
58+
}
59+
}
60+
}

0 commit comments

Comments
 (0)