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/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/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 - )); + ); } } 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()) ); } }