Skip to content
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ dependencies {

// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

// Guava (동시성 제어용)
implementation 'com.google.guava:guava:33.4.0-jre'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package org.devkor.apu.saerok_server.domain.admin.announcement.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 jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.devkor.apu.saerok_server.domain.admin.announcement.api.dto.request.AdminAnnouncementImagePresignRequest;
import org.devkor.apu.saerok_server.domain.admin.announcement.api.dto.request.AdminCreateAnnouncementRequest;
import org.devkor.apu.saerok_server.domain.admin.announcement.api.dto.request.AdminUpdateAnnouncementRequest;
import org.devkor.apu.saerok_server.domain.admin.announcement.api.dto.response.AdminAnnouncementDetailResponse;
import org.devkor.apu.saerok_server.domain.admin.announcement.api.dto.response.AdminAnnouncementListResponse;
import org.devkor.apu.saerok_server.domain.admin.announcement.api.dto.response.AnnouncementImagePresignResponse;
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;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@Tag(name = "Admin Announcement API", description = "공지사항 관리용 관리자 API")
@RestController
@RequiredArgsConstructor
@RequestMapping("${api_prefix}/admin/announcement")
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))
)
}
)
public AdminAnnouncementDetailResponse createAnnouncement(
@Valid @RequestBody AdminCreateAnnouncementRequest request,
@AuthenticationPrincipal UserPrincipal admin
) {
Announcement announcement = adminAnnouncementService.createAnnouncement(
admin.getId(),
request.title(),
request.content(),
request.scheduledAt(),
request.publishNow(),
request.sendNotification(),
request.pushTitle(),
request.pushBody(),
request.inAppBody(),
request.images()
);

return toDetailResponse(announcement);
}

@PutMapping("/{id}")
@PreAuthorize("@perm.has('ADMIN_ANNOUNCEMENT_WRITE')")
@Operation(
summary = "공지사항 수정",
security = @SecurityRequirement(name = "bearerAuth"),
responses = {
@ApiResponse(
responseCode = "200",
description = "수정 성공",
content = @Content(schema = @Schema(implementation = AdminAnnouncementDetailResponse.class))
)
}
)
public AdminAnnouncementDetailResponse updateAnnouncement(
@PathVariable Long id,
@Valid @RequestBody AdminUpdateAnnouncementRequest request,
@AuthenticationPrincipal UserPrincipal admin
) {
Announcement announcement = adminAnnouncementService.updateScheduledAnnouncement(
admin.getId(),
id,
request.title(),
request.content(),
request.scheduledAt(),
request.publishNow(),
request.sendNotification(),
request.pushTitle(),
request.pushBody(),
request.inAppBody(),
request.images()
);

return toDetailResponse(announcement);
}

@DeleteMapping("/{id}")
@PreAuthorize("@perm.has('ADMIN_ANNOUNCEMENT_WRITE')")
@Operation(
summary = "공지사항 삭제",
security = @SecurityRequirement(name = "bearerAuth")
)
public void deleteAnnouncement(
@PathVariable Long id,
@AuthenticationPrincipal UserPrincipal admin
) {
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))
)
}
)
public AdminAnnouncementListResponse listAnnouncements() {
List<Announcement> announcements = adminAnnouncementService.listAnnouncements();

List<AdminAnnouncementListResponse.Item> items = announcements.stream()
.map(a -> new AdminAnnouncementListResponse.Item(
a.getId(),
a.getTitle(),
a.getStatus(),
a.getScheduledAt(),
a.getPublishedAt(),
a.getAdmin().getNickname()
))
.toList();

return new AdminAnnouncementListResponse(items);
}

@PostMapping("/image/presign")
@PreAuthorize("@perm.has('ADMIN_ANNOUNCEMENT_WRITE')")
@Operation(
summary = "공지사항 이미지 Presigned URL 발급",
security = @SecurityRequirement(name = "bearerAuth"),
description = "공지사항 본문 이미지 업로드용 Presigned URL을 발급합니다."
)
public AnnouncementImagePresignResponse generateImagePresignUrl(
@Valid @RequestBody AdminAnnouncementImagePresignRequest request
) {
return adminAnnouncementService.generateImagePresignUrl(request.contentType());
}

private AdminAnnouncementDetailResponse toDetailResponse(Announcement announcement) {
List<AdminAnnouncementDetailResponse.Image> images = announcement.getImages().stream()
.map(img -> new AdminAnnouncementDetailResponse.Image(
img.getObjectKey(),
img.getContentType(),
img.getObjectKey() != null ? imageDomainService.toUploadImageUrl(img.getObjectKey()) : null
))
.toList();

return new AdminAnnouncementDetailResponse(
announcement.getId(),
announcement.getTitle(),
announcement.getContent(),
announcement.getStatus(),
announcement.getScheduledAt(),
announcement.getPublishedAt(),
announcement.getSendNotification(),
announcement.getPushTitle(),
announcement.getPushBody(),
announcement.getInAppBody(),
announcement.getAdmin().getNickname(),
images
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package org.devkor.apu.saerok_server.domain.admin.announcement.api.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;

@Schema(description = "공지사항 이미지 Presigned URL 발급 요청")
public record AdminAnnouncementImagePresignRequest(

@Schema(description = "업로드할 이미지 MIME 타입", example = "image/png", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank
String contentType
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.devkor.apu.saerok_server.domain.admin.announcement.api.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;

@Schema(description = "공지사항 이미지 정보")
public record AdminAnnouncementImageRequest(
@Schema(description = "S3 object key", example = "announcements/2024/notice-1.png", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank
String objectKey,

@Schema(description = "이미지 MIME 타입", example = "image/png", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank
String contentType
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package org.devkor.apu.saerok_server.domain.admin.announcement.api.dto.request;

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

import java.time.LocalDateTime;
import java.util.List;

@Schema(description = "공지사항 생성 요청")
public record AdminCreateAnnouncementRequest(

@Schema(description = "공지사항 제목", example = "정기 점검 안내", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank
@Size(max = 255)
String title,

@Schema(description = "공지사항 본문 (HTML)", example = "<p>공지 내용</p>", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank
String content,

@Schema(description = "KST 기준 게시 예정 시각 (즉시 게시 시 null)", example = "2024-11-01T09:00:00")
LocalDateTime scheduledAt,

@Schema(description = "즉시 게시 여부", example = "false")
Boolean publishNow,

@Schema(description = "공지사항 게시 시 알림 발송 여부", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull
Boolean sendNotification,

@Schema(description = "푸시 알림 제목 (알림 발송 시 필수)", example = "새 공지사항이 게시되었습니다.")
String pushTitle,

@Schema(description = "푸시 알림 본문 (알림 발송 시 필수)", example = "공지사항을 확인해 주세요.")
String pushBody,

@Schema(description = "인앱 알림 본문 (알림 발송 시 필수)", example = "새 공지사항이 올라왔어요.")
String inAppBody,

@Schema(description = "본문에 포함될 이미지 정보 목록")
@Valid
List<AdminAnnouncementImageRequest> images
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package org.devkor.apu.saerok_server.domain.admin.announcement.api.dto.request;

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

import java.time.LocalDateTime;
import java.util.List;

@Schema(description = "공지사항 수정 요청")
public record AdminUpdateAnnouncementRequest(

@Schema(description = "공지사항 제목", example = "정기 점검 일정 변경", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank
@Size(max = 255)
String title,

@Schema(description = "공지사항 본문 (HTML)", example = "<p>변경된 공지 내용</p>", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank
String content,

@Schema(description = "KST 기준 게시 예정 시각 (즉시 게시 시 null)", example = "2024-11-01T10:00:00")
LocalDateTime scheduledAt,

@Schema(description = "즉시 게시 여부", example = "false")
Boolean publishNow,

@Schema(description = "공지사항 게시 시 알림 발송 여부", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull
Boolean sendNotification,

@Schema(description = "푸시 알림 제목 (알림 발송 시 필수)", example = "공지사항이 업데이트되었습니다.")
String pushTitle,

@Schema(description = "푸시 알림 본문 (알림 발송 시 필수)", example = "변경 내용을 확인해 주세요.")
String pushBody,

@Schema(description = "인앱 알림 본문 (알림 발송 시 필수)", example = "공지사항이 수정되었어요.")
String inAppBody,

@Schema(description = "본문에 포함될 이미지 정보 목록")
@Valid
List<AdminAnnouncementImageRequest> images
) {
}
Loading