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,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;
Expand All @@ -37,14 +38,19 @@
public class AdminAnnouncementController {

private final AdminAnnouncementService adminAnnouncementService;
private final ImageDomainService imageDomainService;

@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)))
@ApiResponse(
responseCode = "200",
description = "생성 성공",
content = @Content(schema = @Schema(implementation = AdminAnnouncementDetailResponse.class))
)
}
)
public AdminAnnouncementDetailResponse createAnnouncement(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -152,7 +184,11 @@ public AnnouncementImagePresignResponse generateImagePresignUrl(

private AdminAnnouncementDetailResponse toDetailResponse(Announcement announcement) {
List<AdminAnnouncementDetailResponse.Image> 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,31 +14,31 @@ public record AdminAnnouncementDetailResponse(
@Schema(description = "제목", example = "정기 점검 안내")
String title,

@Schema(description = "본문(HTML)", example = "<p>내용</p>")
@Schema(description = "내용(HTML)", example = "<p>점검 안내</p>")
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 = "본문 이미지 정보")
Expand All @@ -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
) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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<AdminAnnouncementImageRequest> images) {
Long announcementId,
String title,
String content,
LocalDateTime scheduledAt,
Boolean publishNow,
Boolean sendNotification,
String pushTitle,
String pushBody,
String inAppBody,
List<AdminAnnouncementImageRequest> images) {
Announcement announcement = announcementRepository.findById(announcementId)
.orElseThrow(() -> new NotFoundException("해당 ID의 공지사항이 존재하지 않아요."));

Expand Down Expand Up @@ -151,13 +153,14 @@ public void deleteAnnouncement(Long adminUserId, Long announcementId) {

List<String> 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)));
}
Expand All @@ -167,6 +170,11 @@ public List<Announcement> 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 누락입니다.");
Expand All @@ -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) {
Expand All @@ -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<AnnouncementImage> toImages(List<AdminAnnouncementImageRequest> 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<String> previousKeys, List<AnnouncementImage> currentImages) {
Set<String> currentKeys = currentImages.stream()
private void cleanupRemovedImages(List<String> previousKeys, List<AnnouncementImage> newImages) {
if (previousKeys == null || previousKeys.isEmpty()) {
return;
}

Set<String> currentKeys = newImages == null ? Set.of() : newImages.stream()
.map(AnnouncementImage::getObjectKey)
.filter(Objects::nonNull)
.collect(Collectors.toSet());

List<String> removedKeys = previousKeys.stream()
.filter(key -> !currentKeys.contains(key))
List<String> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ public Announcement save(Announcement announcement) {

public Optional<Announcement> findById(Long id) {
List<Announcement> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -15,7 +15,7 @@ public record AnnouncementDetailResponse(
@Schema(description = "공지사항 본문 HTML", example = "<p>내용</p>")
String content,

@Schema(description = "게시 시각", example = "2024-10-12T09:00:00+09:00")
OffsetDateTime publishedAt
@Schema(description = "게시 시각", example = "2024-10-12T09:00:00")
LocalDateTime publishedAt
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "게시된 공지사항 목록 응답")
Expand All @@ -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
) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
));
);
}
}
Loading