Repository navigation
[Feat/#186] 아카이빙 등록·수정·삭제·노출 변경 관리자 API 추가 #196
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e8a9844
755c613
b87f959
10163ee
292fe5e
76a70c3
3598400
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| package kr.ac.kookmin.stream.api.admin.event.archive.request; | ||
|
|
||
| import jakarta.validation.constraints.NotEmpty; | ||
| import jakarta.validation.constraints.NotNull; | ||
| import jakarta.validation.constraints.Size; | ||
| import java.util.List; | ||
|
|
||
| /** | ||
| * 여러 아카이빙의 슬랑제 페이지 노출 여부를 한 번에 바꾼다. | ||
| * <p> | ||
| * 목록의 체크박스 일괄 변경과 행별 드롭다운 변경이 같은 동작이라 하나로 받는다 — 행별 변경은 식별자가 하나인 요청이다. | ||
| */ | ||
| public record ArchiveExposureChangeRequest( | ||
|
|
||
| // @Size는 null을 통과시키므로 @NotEmpty가 같이 있어야 목록을 빠뜨린 요청이 400으로 나간다. | ||
| // 상한은 목록에서 체크박스로 고르는 동작이라 한 페이지 최대와 같게 둔다(PageParams의 size 상한) | ||
| @NotEmpty(message = "아카이빙을 하나 이상 선택해 주세요.") | ||
| @Size(max = 100, message = "아카이빙은 한 번에 100개까지 바꿀 수 있습니다.") | ||
| List<@NotNull(message = "아카이빙 ID는 비워 둘 수 없습니다.") Long> archiveIds, | ||
|
|
||
| @NotNull(message = "슬랑제 페이지 노출 여부를 입력해 주세요.") | ||
| Boolean isExposed | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,68 @@ | ||||||||||
| package kr.ac.kookmin.stream.api.admin.event.archive.request; | ||||||||||
|
|
||||||||||
| import jakarta.validation.constraints.NotBlank; | ||||||||||
| import jakarta.validation.constraints.NotEmpty; | ||||||||||
| import jakarta.validation.constraints.NotNull; | ||||||||||
| import jakarta.validation.constraints.Size; | ||||||||||
| import java.time.LocalDate; | ||||||||||
| import java.util.List; | ||||||||||
| import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveWriteCommand; | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * 아카이빙 등록과 수정이 같은 본문을 받는다. 수정이 전체 필드를 통째로 바꾸는 방식이라 등록과 모양이 같아, | ||||||||||
| * 두 record로 나누면 같은 선언이 두 벌 생긴다. | ||||||||||
| * <p> | ||||||||||
| * 종료일이 시작일보다 앞서는지는 여기서 보지 않는다. 값 하나의 형식이 아니라 두 값 사이의 규칙이라 | ||||||||||
| * {@code Archive}가 본다. | ||||||||||
| * | ||||||||||
| * @param endDate 당일 활동이면 생략하거나 {@code null} | ||||||||||
| * @param imageIds 활동 사진. 배열 순서가 노출 순서이고 첫 장이 대표 이미지다 | ||||||||||
| * @param relatedEventId 연결할 행사. 연결하지 않으면 {@code null}이고, 수정에서 {@code null}이면 연결을 끊는다 | ||||||||||
| */ | ||||||||||
| public record ArchiveWriteRequest( | ||||||||||
|
|
||||||||||
| @NotBlank(message = "활동 제목을 입력해 주세요.") | ||||||||||
| @Size(max = 200, message = "활동 제목은 200자 이하여야 합니다.") | ||||||||||
| String title, | ||||||||||
|
|
||||||||||
| // content는 TEXT 컬럼(최대 65,535바이트)이다. 글자당 최대 4바이트로 잡아도 넘치지 않는 길이로 막는다 | ||||||||||
| @NotBlank(message = "활동 내용을 입력해 주세요.") | ||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
예를 들어 운영진이 한글 2만 2천 자쯤(약 66,000바이트) 되는 본문을 붙여 넣으면
등록과 수정이 같은 record를 써서 두 API 모두 해당돼요. 공지·행사·피드백 답변과 같은 기준으로 막아 두면 좋겠어요.
Suggested change
선례:
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 3598400 |
||||||||||
| @Size(max = 16000, message = "활동 내용은 16000자 이하여야 합니다.") | ||||||||||
| String content, | ||||||||||
|
|
||||||||||
| @NotNull(message = "활동 시작일을 입력해 주세요.") | ||||||||||
| LocalDate startDate, | ||||||||||
|
|
||||||||||
| LocalDate endDate, | ||||||||||
|
|
||||||||||
| @Size(max = 255, message = "활동 장소는 255자 이하여야 합니다.") | ||||||||||
| String location, | ||||||||||
|
|
||||||||||
| @NotBlank(message = "담당 부서를 입력해 주세요.") | ||||||||||
| @Size(max = 100, message = "담당 부서는 100자 이하여야 합니다.") | ||||||||||
| String departmentName, | ||||||||||
|
|
||||||||||
| // 사진 없는 아카이빙은 목록에서 썸네일이 빈 칸으로 보인다. 첫 장이 대표 이미지라 한 장은 있어야 한다 | ||||||||||
| @NotEmpty(message = "활동 사진을 한 장 이상 등록해 주세요.") | ||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
덧붙여 최대 장수도 명세에 있다면 |
||||||||||
| List<@NotNull(message = "파일 ID는 비워 둘 수 없습니다.") Long> imageIds, | ||||||||||
|
|
||||||||||
| @NotNull(message = "슬랑제 페이지 노출 여부를 입력해 주세요.") | ||||||||||
| Boolean isExposed, | ||||||||||
|
|
||||||||||
| Long relatedEventId | ||||||||||
| ) { | ||||||||||
|
|
||||||||||
| public ArchiveWriteCommand toCommand() { | ||||||||||
| return new ArchiveWriteCommand( | ||||||||||
| title, content, startDate, endDate, location, departmentName, distinctImageIds(), isExposed, | ||||||||||
| relatedEventId); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * 중복을 걷어낸 사진 식별자. 같은 사진을 두 번 보여줄 일이 없어 저장 전에 뺀다. 처음 나온 자리를 지켜 노출 | ||||||||||
| * 순서는 그대로 둔다. | ||||||||||
| */ | ||||||||||
| private List<Long> distinctImageIds() { | ||||||||||
| return imageIds.stream().distinct().toList(); | ||||||||||
| } | ||||||||||
| } | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| package kr.ac.kookmin.stream.api.admin.event.archive.usecase; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Set; | ||
| import kr.ac.kookmin.stream.common.BusinessException; | ||
| import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveWriteCommand; | ||
| import kr.ac.kookmin.stream.event.domain.archive.service.AdminArchiveService; | ||
| import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; | ||
| import kr.ac.kookmin.stream.event.domain.event.service.EventService; | ||
| import kr.ac.kookmin.stream.file.domain.FileErrorCode; | ||
| import kr.ac.kookmin.stream.file.service.FileService; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| /** | ||
| * 아카이빙을 쓰기 전에 연결할 사진과 행사가 실제로 있는지 확인한다. 사진(file)과 행사는 아카이빙과 다른 도메인이라 | ||
| * 도메인끼리 직접 의존하지 않고 이 UseCase가 조합한다({@code architecture.md} 6-1절). | ||
| * <p> | ||
| * 확인과 저장을 한 트랜잭션으로 묶지 않는다. 확인 직후 사진이 지워져도 상세 조회가 목록에서 조용히 빼고, 행사가 | ||
| * 지워지면 연결만 비어 보인다 — 둘 다 아카이빙을 못 쓰게 만들지 않는다. 공지와 같은 판단이다 | ||
| * ({@code AdminNoticeWriteUseCase}). | ||
| */ | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class AdminArchiveWriteUseCase { | ||
|
|
||
| private final FileService fileService; | ||
| private final EventService eventService; | ||
| private final AdminArchiveService adminArchiveService; | ||
|
|
||
| public void create(ArchiveWriteCommand command) { | ||
| validateImagesExist(command.imageIds()); | ||
| validateRelatedEventExists(command.relatedEventId()); | ||
|
|
||
| adminArchiveService.create(command); | ||
| } | ||
|
|
||
| public void update(Long archiveId, ArchiveWriteCommand command) { | ||
| validateImagesExist(command.imageIds()); | ||
| validateRelatedEventExists(command.relatedEventId()); | ||
|
|
||
| adminArchiveService.update(archiveId, command); | ||
| } | ||
|
|
||
| /** | ||
| * 조회된 개수를 견줘 전부 있는지 본다. 중복이 남아 있으면 있는 파일도 없다고 오판하므로 호출부에 맡기지 않고 | ||
| * 여기서 묶는다. | ||
| */ | ||
| private void validateImagesExist(List<Long> imageIds) { | ||
| Set<Long> fileIds = Set.copyOf(imageIds); | ||
|
|
||
| if (fileService.findAllByIdIn(List.copyOf(fileIds)).size() != fileIds.size()) { | ||
| throw new BusinessException(FileErrorCode.FILE_NOT_FOUND); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 없는 행사를 연결하면 상세에서 조용히 비어 보여 운영진이 눈치채지 못한다. 쓰는 자리에서 막는다. | ||
| * 아직 게시하지 않은 행사는 연결할 수 있다 — 운영진 화면은 미게시 행사도 고를 수 있어야 한다. | ||
| */ | ||
| private void validateRelatedEventExists(Long relatedEventId) { | ||
| if (relatedEventId != null && eventService.getByIdOrNull(relatedEventId) == null) { | ||
| throw new BusinessException(EventErrorCode.EVENT_NOT_FOUND); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ | |
| import java.time.LocalDate; | ||
| import java.time.LocalDateTime; | ||
| import java.util.List; | ||
| import kr.ac.kookmin.stream.common.BusinessException; | ||
| import lombok.AccessLevel; | ||
| import lombok.AllArgsConstructor; | ||
| import lombok.EqualsAndHashCode; | ||
|
|
@@ -45,10 +46,65 @@ public static Archive of( | |
| exposed, relatedEventId, createdAt); | ||
| } | ||
|
|
||
| /** 등록 일시는 DB 기본값으로 채워져 저장 전에는 비어 있다. */ | ||
| public static Archive create(ArchiveWriteCommand command) { | ||
| validatePeriod(command.startDate(), command.endDate()); | ||
|
|
||
| return new Archive( | ||
| null, | ||
| command.title(), | ||
| command.content(), | ||
| command.startDate(), | ||
| command.endDate(), | ||
| command.location(), | ||
| command.departmentName(), | ||
| command.imageIds(), | ||
| command.exposed(), | ||
| command.relatedEventId(), | ||
| null | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * 전체 필드를 통째로 바꾼다. 사진과 연결 행사도 받은 값으로 교체하므로, {@code relatedEventId}가 | ||
| * {@code null}이면 연결을 끊는다. | ||
| */ | ||
| public void update(ArchiveWriteCommand command) { | ||
| validatePeriod(command.startDate(), command.endDate()); | ||
|
|
||
| this.title = command.title(); | ||
| this.content = command.content(); | ||
| this.startDate = command.startDate(); | ||
| this.endDate = command.endDate(); | ||
| this.location = command.location(); | ||
| this.departmentName = command.departmentName(); | ||
| this.imageIds = command.imageIds(); | ||
| this.exposed = command.exposed(); | ||
| this.relatedEventId = command.relatedEventId(); | ||
| } | ||
|
|
||
| /** | ||
| * 노출 여부를 바꾼다. 이미 그 값이어도 그대로 둔다 — 영속성 컨텍스트가 값이 바뀐 행만 UPDATE한다. | ||
| */ | ||
| public void changeExposure(boolean exposed) { | ||
| this.exposed = exposed; | ||
| } | ||
|
|
||
| /** | ||
| * 당일 활동은 종료일이 없다. 종료일을 줬다면 시작일보다 앞설 수 없다. | ||
| * <p> | ||
| * 값 하나의 형식이 아니라 두 값 사이의 규칙이라 요청 어노테이션으로는 표현할 수 없어 도메인에서 본다. | ||
| */ | ||
| private static void validatePeriod(LocalDate startDate, LocalDate endDate) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
// core:domain:event — archive/domain
/**
* 활동 기간. 당일 활동은 종료일이 없다.
* 종료일이 시작일보다 앞서지 않는다는 규칙은 이 값의 불변식이라 of에서 검증한다.
*/
public record ArchivePeriod(LocalDate startDate, LocalDate endDate) {
public static ArchivePeriod of(LocalDate startDate, LocalDate endDate) {
validateOrder(startDate, endDate);
return new ArchivePeriod(startDate, endDate);
}
private static void validateOrder(LocalDate startDate, LocalDate endDate) {
if (endDate != null && endDate.isBefore(startDate)) {
throw new BusinessException(ArchiveErrorCode.INVALID_ARCHIVE_PERIOD);
}
}
}
근거는 |
||
| if (endDate != null && endDate.isBefore(startDate)) { | ||
| throw new BusinessException(ArchiveErrorCode.INVALID_ARCHIVE_PERIOD); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 대표 이미지 파일 id. 사진 배열의 첫 장을 대표로 쓴다. | ||
| * <p> | ||
| * 사진 없는 아카이빙은 등록되지 않는다는 전제지만 최소 1장 제약이 없어, 비어 있으면 예외 대신 null을 돌린다. | ||
| * 등록·수정은 사진을 한 장 이상 받지만 그 전에 DB로 직접 넣은 행은 비어 있을 수 있어, 예외 대신 null을 돌린다. | ||
| */ | ||
| public Long thumbnailFileId() { | ||
| return imageIds == null || imageIds.isEmpty() ? null : imageIds.getFirst(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[a]리뷰 포인트 주신 경로·필드 이름에 대한 의견이에요. 바꾸신 이름이 좋아 보여요.ArchiveSearchParams.isExposed, 응답AdminArchiveListItemResponse·AdminArchiveDetailResponse의isExposed, 이번 두 요청이 모두isExposed로 맞아 있고, 행사 공개 전환의EventPublishRequest.isPublished와도 모양이 같아요PATCH /v1/admin/lockers/sections/{sectionId}/lockers/status처럼 바꿀 속성 이름을 경로 끝에 두는 형태라/exposure도 자연스러워요관리자 프론트가 명세 이름으로 이미 붙였는지만 확인해 주시고, 붙였다면 바꾸는 시점을 프론트와 맞춰 주시면 될 것 같아요.