Skip to content
Open
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 @@ -6,34 +6,57 @@
import kr.ac.kookmin.stream.api.admin.welfare.notice.request.NoticeCreateRequest;
import kr.ac.kookmin.stream.api.admin.welfare.notice.request.NoticePinRequest;
import kr.ac.kookmin.stream.api.admin.welfare.notice.request.NoticeUpdateRequest;
import kr.ac.kookmin.stream.api.admin.welfare.notice.response.AdminNoticeDetailResponse;
import kr.ac.kookmin.stream.api.admin.welfare.notice.response.AdminNoticeListItemResponse;
import kr.ac.kookmin.stream.api.admin.welfare.notice.response.NoticeCreateResponse;
import kr.ac.kookmin.stream.api.admin.welfare.notice.response.NoticeUpdateResponse;
import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
import kr.ac.kookmin.stream.api.common.dto.PageParams;
import kr.ac.kookmin.stream.api.common.dto.PageResponse;
import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode;
import kr.ac.kookmin.stream.common.CommonErrorCode;
import kr.ac.kookmin.stream.file.domain.FileErrorCode;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeErrorCode;
import org.springdoc.core.annotations.ParameterObject;

/**
* 운영진 공지 API의 문서 명세. 구현은 {@link AdminNoticeController}가 맡는다.
* <p>
* 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다.
*/
@Tag(name = "공지 관리", description = "운영진 공지 작성·수정·삭제·고정")
@Tag(name = "공지 관리", description = "운영진 공지 조회·작성·수정·삭제·고정")
public interface AdminNoticeApi {

/** 공지 목록. 제목으로 검색하고 고정 공지를 먼저, 그다음 최신순으로 페이지 단위 조회한다. */
@Operation(summary = "공지 목록 조회",
description = "삭제되지 않은 공지를 고정 공지 우선, 최신순으로 페이지 단위로 조회한다. keyword를 주면 제목에 그 글자가 "
+ "들어 있는 공지만 조회한다. page는 0부터 시작한다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
ApiResponse<PageResponse<AdminNoticeListItemResponse>> getNotices(
AdminApiUser apiUser,
String keyword,
@ParameterObject PageParams pageParams
);

/** 공지 상세. 수정 화면이 이 값을 불러와 채우고, 수정할 때 updatedAt을 그대로 돌려 보낸다. */
@Operation(summary = "공지 상세 조회",
description = "공지 한 건을 조회한다. 수정 화면에 필요한 고정 여부, 이미지·첨부 파일의 ID와 URL, 수정 시각(updatedAt)을 "
+ "함께 내려준다. 삭제된 파일은 목록에서 빠진다.")
@ApiErrorCode(type = NoticeErrorCode.class, codes = {"NOTICE_NOT_FOUND"})
ApiResponse<AdminNoticeDetailResponse> getNotice(AdminApiUser apiUser, Long noticeId);

/** 공지 작성. 작성자는 토큰의 관리자로 정해진다. */
@Operation(summary = "공지 작성",
description = "공지를 등록한다. 이미지·첨부 파일은 업로드를 마친 파일 ID로 넘기며, 존재하지 않는 파일이 하나라도 있으면 "
+ "등록하지 않는다. 새 공지는 고정되지 않은 상태로 만들어진다.")
+ "등록하지 않는다. 고정 여부(pinned)도 함께 정한다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = FileErrorCode.class, codes = {"FILE_NOT_FOUND"})
ApiResponse<NoticeCreateResponse> createNotice(AdminApiUser apiUser, NoticeCreateRequest request);

/** 공지 수정. 요청의 수정 시각이 저장된 값과 다르면 거부한다. */
@Operation(summary = "공지 수정",
description = "공지의 제목·본문·종류·이미지·첨부 파일을 통째로 바꾼다. 수정 화면이 공지를 읽은 뒤 다른 관리자가 먼저 "
+ "고쳤다면(수정 시각 불일치) 409로 거부한다.")
description = "공지의 제목·본문·종류·이미지·첨부 파일·고정 여부를 통째로 바꾼다. 수정 화면이 공지를 읽은 뒤 다른 관리자가 "
+ "먼저 고쳤다면(수정 시각 불일치) 409로 거부한다. 상세 조회에서 받은 updatedAt을 그대로 보낸다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = FileErrorCode.class, codes = {"FILE_NOT_FOUND"})
@ApiErrorCode(type = NoticeErrorCode.class, codes = {"NOTICE_NOT_FOUND", "NOTICE_UPDATE_CONFLICT"})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,29 @@
import kr.ac.kookmin.stream.api.admin.welfare.notice.request.NoticeCreateRequest;
import kr.ac.kookmin.stream.api.admin.welfare.notice.request.NoticePinRequest;
import kr.ac.kookmin.stream.api.admin.welfare.notice.request.NoticeUpdateRequest;
import kr.ac.kookmin.stream.api.admin.welfare.notice.response.AdminNoticeDetailResponse;
import kr.ac.kookmin.stream.api.admin.welfare.notice.response.AdminNoticeListItemResponse;
import kr.ac.kookmin.stream.api.admin.welfare.notice.response.NoticeCreateResponse;
import kr.ac.kookmin.stream.api.admin.welfare.notice.response.NoticeUpdateResponse;
import kr.ac.kookmin.stream.api.admin.welfare.notice.usecase.AdminNoticeReadUseCase;
import kr.ac.kookmin.stream.api.admin.welfare.notice.usecase.AdminNoticeWriteUseCase;
import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
import kr.ac.kookmin.stream.api.common.dto.PageParams;
import kr.ac.kookmin.stream.api.common.dto.PageResponse;
import kr.ac.kookmin.stream.common.PageResult;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice;
import kr.ac.kookmin.stream.welfare.domain.notice.service.NoticeService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PatchMapping;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
Expand All @@ -27,8 +36,26 @@
public class AdminNoticeController implements AdminNoticeApi {

private final NoticeService noticeService;
private final AdminNoticeReadUseCase adminNoticeReadUseCase;
private final AdminNoticeWriteUseCase adminNoticeWriteUseCase;

@Override
@GetMapping
public ApiResponse<PageResponse<AdminNoticeListItemResponse>> getNotices(
AdminApiUser apiUser,
@RequestParam(required = false) String keyword,
@Valid @ModelAttribute PageParams pageParams
) {
PageResult<Notice> result = noticeService.search(keyword, pageParams.toOffset());
return ApiResponse.success(PageResponse.from(result, AdminNoticeListItemResponse::from));
}

@Override
@GetMapping("/{noticeId}")
public ApiResponse<AdminNoticeDetailResponse> getNotice(AdminApiUser apiUser, @PathVariable Long noticeId) {
return ApiResponse.success(AdminNoticeDetailResponse.from(adminNoticeReadUseCase.getDetail(noticeId)));
}

@Override
@PostMapping
public ApiResponse<NoticeCreateResponse> createNotice(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ public record NoticeCreateRequest(

@Schema(description = "첨부 파일 ID 목록", example = "[201]")
@NotNull(message = "첨부 파일 목록을 입력해 주세요.")
List<@NotNull(message = "첨부 파일 ID는 비워 둘 수 없습니다.") Long> attachmentFileIds
List<@NotNull(message = "첨부 파일 ID는 비워 둘 수 없습니다.") Long> attachmentFileIds,

@Schema(description = "상단 고정 여부", example = "false")
@NotNull(message = "고정 여부를 입력해 주세요.")
Boolean pinned
) {

public NoticeCreateCommand toCommand() {
Expand All @@ -42,7 +46,8 @@ public NoticeCreateCommand toCommand() {
content,
category,
imageFileIds.stream().distinct().toList(),
attachmentFileIds.stream().distinct().toList()
attachmentFileIds.stream().distinct().toList(),
pinned
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
import kr.ac.kookmin.stream.common.DateUtil;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory;
Expand Down Expand Up @@ -35,6 +33,10 @@ public record NoticeUpdateRequest(
@NotNull(message = "첨부 파일 목록을 입력해 주세요.")
List<@NotNull(message = "첨부 파일 ID는 비워 둘 수 없습니다.") Long> attachmentFileIds,

@Schema(description = "상단 고정 여부", example = "false")
@NotNull(message = "고정 여부를 입력해 주세요.")
Boolean pinned,

@Schema(description = "수정 화면이 공지를 읽었을 때의 수정 시각(KST 기준 epoch 밀리초). 그 사이 다른 관리자가 고쳤다면 409",
example = "1756339200000")
@NotNull(message = "수정 시각을 입력해 주세요.")
Expand All @@ -48,7 +50,8 @@ public NoticeUpdateCommand toCommand() {
category,
imageFileIds.stream().distinct().toList(),
attachmentFileIds.stream().distinct().toList(),
LocalDateTime.ofInstant(Instant.ofEpochMilli(updatedAt), DateUtil.KST)
pinned,
DateUtil.fromEpochMillis(updatedAt)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package kr.ac.kookmin.stream.api.admin.welfare.notice.response;

import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import kr.ac.kookmin.stream.api.admin.welfare.notice.usecase.AdminNoticeDetail;
import kr.ac.kookmin.stream.api.common.StorageUrlBuilder;
import kr.ac.kookmin.stream.common.DateUtil;
import kr.ac.kookmin.stream.file.domain.File;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory;

/**
* @param images 수정할 때 {@code imageFileIds}로 되돌려 보낼 수 있도록 파일 ID를 함께 내려준다. 저장된 순서 그대로이고 첫 번째가 썸네일이다
* @param updatedAt 수정할 때 그대로 되돌려 보내는 수정 시각(KST 기준 epoch 밀리초). 그 사이 다른 관리자가 고쳤는지 가려내는 데 쓴다
*/
public record AdminNoticeDetailResponse(
Long noticeId,
String title,
String content,
NoticeCategory category,
boolean pinned,
List<Image> images,
List<Attachment> attachments,
LocalDateTime createdAt,
@Schema(description = "수정 시각(KST 기준 epoch 밀리초). 수정 요청의 updatedAt에 그대로 보낸다", example = "1756339200000")
long updatedAt
) {

/**
* 존재하지 않는(삭제된) 파일은 조용히 뺀다. 학생 앱 상세와 같은 정책이다.
*/
public static AdminNoticeDetailResponse from(AdminNoticeDetail detail) {
Notice notice = detail.notice();

return new AdminNoticeDetailResponse(
notice.getId(),
notice.getTitle(),
notice.getContent(),
notice.getCategory(),
notice.isPinned(),
toImages(notice.getImageIds(), detail.imagesById()),
toAttachments(notice.getAttachmentIds(), detail.attachmentsById()),
notice.getCreatedAt(),
DateUtil.toEpochMillis(notice.getUpdatedAt())
);
}

private static List<Image> toImages(List<Long> fileIds, Map<Long, File> filesById) {
if (fileIds == null) {
return List.of();
}
return fileIds.stream()
.filter(filesById::containsKey)
.map(fileId -> Image.of(fileId, filesById.get(fileId)))
.toList();
}

private static List<Attachment> toAttachments(List<Long> fileIds, Map<Long, File> filesById) {
if (fileIds == null) {
return List.of();
}
return fileIds.stream()
.filter(filesById::containsKey)
.map(fileId -> Attachment.of(fileId, filesById.get(fileId)))
.toList();
}

public record Image(Long fileId, String fileUrl) {

public static Image of(Long fileId, File file) {
return new Image(fileId, StorageUrlBuilder.build(file.getFileKey()));
}
}

public record Attachment(Long fileId, String fileName, String fileUrl) {

public static Attachment of(Long fileId, File file) {
return new Attachment(fileId, file.getOriginalName(), StorageUrlBuilder.build(file.getFileKey()));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package kr.ac.kookmin.stream.api.admin.welfare.notice.response;

import java.time.LocalDateTime;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory;

public record AdminNoticeListItemResponse(
Long noticeId,
String title,
NoticeCategory category,
boolean pinned,
LocalDateTime createdAt
) {

public static AdminNoticeListItemResponse from(Notice notice) {
return new AdminNoticeListItemResponse(
notice.getId(),
notice.getTitle(),
notice.getCategory(),
notice.isPinned(),
notice.getCreatedAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package kr.ac.kookmin.stream.api.admin.welfare.notice.usecase;

import java.util.Map;
import kr.ac.kookmin.stream.file.domain.File;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice;

/**
* 운영진용 공지 상세. 공지에 그 공지가 연결한 파일 정보를 붙인 조회용 조합 객체다.
* 존재하지 않는(삭제된) 파일은 맵에 키가 없다.
*/
public record AdminNoticeDetail(
Notice notice,
Map<Long, File> imagesById,
Map<Long, File> attachmentsById
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package kr.ac.kookmin.stream.api.admin.welfare.notice.usecase;

import java.util.List;
import kr.ac.kookmin.stream.file.service.FileService;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice;
import kr.ac.kookmin.stream.welfare.domain.notice.service.NoticeService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

/**
* 운영진 공지 상세를 조회한다. 공지(welfare)와 그 공지가 연결한 파일(file)은 서로 다른 도메인이라 이 UseCase가 두 Service를 조합한다.
*/
@Component
@RequiredArgsConstructor
public class AdminNoticeReadUseCase {

private final NoticeService noticeService;
private final FileService fileService;

public AdminNoticeDetail getDetail(Long noticeId) {
Notice notice = noticeService.getNotice(noticeId);

return new AdminNoticeDetail(
notice,
fileService.findAllByIdIn(orEmpty(notice.getImageIds())),
fileService.findAllByIdIn(orEmpty(notice.getAttachmentIds()))
);
}

private List<Long> orEmpty(List<Long> ids) {
return ids == null ? List.of() : ids;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package kr.ac.kookmin.stream.common;

import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
Expand Down Expand Up @@ -32,4 +34,19 @@ public static LocalDate startOfYear(Integer year) {
public static LocalDate startOfNextYear(Integer year) {
return year == null ? null : LocalDate.of(year + 1, FIRST_MONTH, FIRST_DAY);
}

/**
* DB의 시각(한국 시간 기준 {@code LocalDateTime})을 epoch 밀리초로 바꾼다. 시각을 클라이언트에 내려줄 때 쓰고,
* 돌려받는 쪽은 {@link #fromEpochMillis(long)}로 되돌린다. 두 방향이 같은 기준(KST)이어야 값이 어긋나지 않는다.
*/
public static long toEpochMillis(LocalDateTime dateTime) {
return dateTime.atZone(KST).toInstant().toEpochMilli();
}

/**
* {@link #toEpochMillis(LocalDateTime)}의 반대 방향. epoch 밀리초를 한국 시간 기준 {@code LocalDateTime}으로 바꾼다.
*/
public static LocalDateTime fromEpochMillis(long epochMillis) {
return LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), KST);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,14 @@ public static Notice of(
return new Notice(id, title, content, category, pinned, createdBy, attachmentIds, imageIds, createdAt, updatedAt);
}

// 새 공지는 고정되지 않은 채로 등록한다. 생성·수정 시각은 DB가 채운다
// 생성·수정 시각은 DB가 채운다
public static Notice create(Long createdBy, NoticeCreateCommand command) {
return new Notice(
null,
command.title(),
command.content(),
command.category(),
false,
command.pinned(),
createdBy,
command.attachmentIds(),
command.imageIds(),
Expand All @@ -60,6 +60,7 @@ public void update(NoticeUpdateCommand command) {
this.category = command.category();
this.attachmentIds = command.attachmentIds();
this.imageIds = command.imageIds();
this.pinned = command.pinned();
}

public void changePinned(boolean pinned) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public record NoticeCreateCommand(
String content,
NoticeCategory category,
List<Long> imageIds,
List<Long> attachmentIds
List<Long> attachmentIds,
boolean pinned
) {
}
Loading
Loading