diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/AdminArchiveApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/AdminArchiveApi.java index 6718116bb..ec9efd972 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/AdminArchiveApi.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/AdminArchiveApi.java @@ -3,7 +3,9 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import kr.ac.kookmin.stream.api.admin.AdminApiUser; +import kr.ac.kookmin.stream.api.admin.event.archive.request.ArchiveExposureChangeRequest; import kr.ac.kookmin.stream.api.admin.event.archive.request.ArchiveSearchParams; +import kr.ac.kookmin.stream.api.admin.event.archive.request.ArchiveWriteRequest; import kr.ac.kookmin.stream.api.admin.event.archive.response.AdminArchiveDetailResponse; import kr.ac.kookmin.stream.api.admin.event.archive.response.AdminArchiveListItemResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; @@ -12,6 +14,8 @@ import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; import kr.ac.kookmin.stream.common.CommonErrorCode; import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveErrorCode; +import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; +import kr.ac.kookmin.stream.file.domain.FileErrorCode; import org.springdoc.core.annotations.ParameterObject; /** @@ -19,7 +23,7 @@ *

* 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다. */ -@Tag(name = "아카이빙 관리", description = "운영진 아카이빙 목록·상세 조회") +@Tag(name = "아카이빙 관리", description = "운영진 아카이빙 목록·상세 조회와 등록·수정·삭제") public interface AdminArchiveApi { /** 아카이빙 목록. 제목으로 검색하고 슬랑제 노출 여부로 거르며, 등록일 최신순으로 페이지 단위 조회한다. */ @@ -44,4 +48,40 @@ ApiResponse> getArchives( + "연결하지 않았거나 행사가 삭제됐으면 null이다 — 운영진 화면이므로 아직 게시하지 않은 행사도 보여준다.") @ApiErrorCode(type = ArchiveErrorCode.class, codes = {"ARCHIVE_NOT_FOUND"}) ApiResponse getArchive(AdminApiUser apiUser, Long archiveId); + + @Operation(summary = "아카이빙 등록", + description = "아카이빙을 새로 등록한다. 활동 사진은 한 장 이상이어야 하고 첫 장이 대표 이미지다. " + + "당일 활동은 endDate를 생략한다 — 종료일을 주면 시작일보다 앞설 수 없다. " + + "relatedEventId로 행사를 연결하며, 아직 게시하지 않은 행사도 연결할 수 있다. " + + "응답 본문이 없으므로 등록 결과는 목록을 다시 조회해 받는다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = ArchiveErrorCode.class, codes = {"INVALID_ARCHIVE_PERIOD"}) + @ApiErrorCode(type = FileErrorCode.class, codes = {"FILE_NOT_FOUND"}) + @ApiErrorCode(type = EventErrorCode.class, codes = {"EVENT_NOT_FOUND"}) + ApiResponse createArchive(AdminApiUser apiUser, ArchiveWriteRequest request); + + @Operation(summary = "아카이빙 수정", + description = "아카이빙을 통째로 바꾼다. 보낸 값이 그대로 저장되므로 고치지 않을 필드도 함께 보내야 한다. " + + "imageIds는 최종 사진 목록이고, relatedEventId를 null로 주면 행사 연결이 끊긴다. " + + "응답 본문이 없으므로 바뀐 결과는 상세를 다시 조회해 받는다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = ArchiveErrorCode.class, codes = {"ARCHIVE_NOT_FOUND", "INVALID_ARCHIVE_PERIOD"}) + @ApiErrorCode(type = FileErrorCode.class, codes = {"FILE_NOT_FOUND"}) + @ApiErrorCode(type = EventErrorCode.class, codes = {"EVENT_NOT_FOUND"}) + ApiResponse updateArchive(AdminApiUser apiUser, Long archiveId, ArchiveWriteRequest request); + + @Operation(summary = "아카이빙 삭제", + description = "아카이빙을 삭제한다. 행은 남기고 표시만 바꿔 되살릴 수 있게 하며, 학생 앱 조회에서는 바로 빠진다. " + + "이미 삭제된 아카이빙은 없는 것으로 본다.") + @ApiErrorCode(type = ArchiveErrorCode.class, codes = {"ARCHIVE_NOT_FOUND"}) + ApiResponse deleteArchive(AdminApiUser apiUser, Long archiveId); + + @Operation(summary = "아카이빙 슬랑제 노출 여부 변경", + description = "여러 아카이빙의 슬랑제 페이지 노출 여부를 한 번에 바꾼다. 목록의 체크박스 일괄 변경과 " + + "행별 드롭다운 변경에 함께 쓰며, 행별 변경은 archiveIds에 하나만 담아 보낸다. " + + "한 번에 1개 이상 100개 이하로 보내고, 하나라도 없거나 삭제된 아카이빙이 섞이면 아무것도 바뀌지 않는다. " + + "응답 본문이 없으므로 바뀐 결과는 목록을 다시 조회해 받는다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = ArchiveErrorCode.class, codes = {"ARCHIVE_NOT_FOUND"}) + ApiResponse changeExposure(AdminApiUser apiUser, ArchiveExposureChangeRequest request); } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/AdminArchiveController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/AdminArchiveController.java index c14438290..f60e70dd3 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/AdminArchiveController.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/AdminArchiveController.java @@ -3,10 +3,13 @@ import jakarta.validation.Valid; import java.util.Map; import kr.ac.kookmin.stream.api.admin.AdminApiUser; +import kr.ac.kookmin.stream.api.admin.event.archive.request.ArchiveExposureChangeRequest; import kr.ac.kookmin.stream.api.admin.event.archive.request.ArchiveSearchParams; +import kr.ac.kookmin.stream.api.admin.event.archive.request.ArchiveWriteRequest; import kr.ac.kookmin.stream.api.admin.event.archive.response.AdminArchiveDetailResponse; import kr.ac.kookmin.stream.api.admin.event.archive.response.AdminArchiveListItemResponse; import kr.ac.kookmin.stream.api.admin.event.archive.usecase.AdminArchiveDetailUseCase; +import kr.ac.kookmin.stream.api.admin.event.archive.usecase.AdminArchiveWriteUseCase; import kr.ac.kookmin.stream.api.admin.event.archive.usecase.ArchiveWithRelatedEvent; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.api.common.dto.PageParams; @@ -17,9 +20,14 @@ import kr.ac.kookmin.stream.file.domain.File; import kr.ac.kookmin.stream.file.service.FileService; 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.RestController; @@ -34,6 +42,7 @@ public class AdminArchiveController implements AdminArchiveApi { private final AdminArchiveService adminArchiveService; private final AdminArchiveDetailUseCase adminArchiveDetailUseCase; + private final AdminArchiveWriteUseCase adminArchiveWriteUseCase; private final FileService fileService; @Override @@ -61,4 +70,42 @@ public ApiResponse getArchive( return ApiResponse.success( AdminArchiveDetailResponse.of(archive.detail(), archive.relatedEventTitle(), imagesById)); } + + @Override + @PostMapping + public ApiResponse createArchive( + AdminApiUser apiUser, + @Valid @RequestBody ArchiveWriteRequest request + ) { + adminArchiveWriteUseCase.create(request.toCommand()); + return ApiResponse.success(); + } + + @Override + @PutMapping("/{archiveId}") + public ApiResponse updateArchive( + AdminApiUser apiUser, + @PathVariable Long archiveId, + @Valid @RequestBody ArchiveWriteRequest request + ) { + adminArchiveWriteUseCase.update(archiveId, request.toCommand()); + return ApiResponse.success(); + } + + @Override + @DeleteMapping("/{archiveId}") + public ApiResponse deleteArchive(AdminApiUser apiUser, @PathVariable Long archiveId) { + adminArchiveService.delete(archiveId); + return ApiResponse.success(); + } + + @Override + @PatchMapping("/exposure") + public ApiResponse changeExposure( + AdminApiUser apiUser, + @Valid @RequestBody ArchiveExposureChangeRequest request + ) { + adminArchiveService.changeExposure(request.archiveIds(), request.isExposed()); + return ApiResponse.success(); + } } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/request/ArchiveExposureChangeRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/request/ArchiveExposureChangeRequest.java new file mode 100644 index 000000000..e15dc6281 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/request/ArchiveExposureChangeRequest.java @@ -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; + +/** + * 여러 아카이빙의 슬랑제 페이지 노출 여부를 한 번에 바꾼다. + *

+ * 목록의 체크박스 일괄 변경과 행별 드롭다운 변경이 같은 동작이라 하나로 받는다 — 행별 변경은 식별자가 하나인 요청이다. + */ +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 +) { +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/request/ArchiveWriteRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/request/ArchiveWriteRequest.java new file mode 100644 index 000000000..828d43361 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/request/ArchiveWriteRequest.java @@ -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로 나누면 같은 선언이 두 벌 생긴다. + *

+ * 종료일이 시작일보다 앞서는지는 여기서 보지 않는다. 값 하나의 형식이 아니라 두 값 사이의 규칙이라 + * {@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 = "활동 내용을 입력해 주세요.") + @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 = "활동 사진을 한 장 이상 등록해 주세요.") + 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 distinctImageIds() { + return imageIds.stream().distinct().toList(); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/usecase/AdminArchiveWriteUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/usecase/AdminArchiveWriteUseCase.java new file mode 100644 index 000000000..e9685d027 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/usecase/AdminArchiveWriteUseCase.java @@ -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절). + *

+ * 확인과 저장을 한 트랜잭션으로 묶지 않는다. 확인 직후 사진이 지워져도 상세 조회가 목록에서 조용히 빼고, 행사가 + * 지워지면 연결만 비어 보인다 — 둘 다 아카이빙을 못 쓰게 만들지 않는다. 공지와 같은 판단이다 + * ({@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 imageIds) { + Set 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); + } + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/Archive.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/Archive.java index 8c718288a..8ecc22007 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/Archive.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/Archive.java @@ -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; + } + + /** + * 당일 활동은 종료일이 없다. 종료일을 줬다면 시작일보다 앞설 수 없다. + *

+ * 값 하나의 형식이 아니라 두 값 사이의 규칙이라 요청 어노테이션으로는 표현할 수 없어 도메인에서 본다. + */ + private static void validatePeriod(LocalDate startDate, LocalDate endDate) { + if (endDate != null && endDate.isBefore(startDate)) { + throw new BusinessException(ArchiveErrorCode.INVALID_ARCHIVE_PERIOD); + } + } + /** * 대표 이미지 파일 id. 사진 배열의 첫 장을 대표로 쓴다. *

- * 사진 없는 아카이빙은 등록되지 않는다는 전제지만 최소 1장 제약이 없어, 비어 있으면 예외 대신 null을 돌린다. + * 등록·수정은 사진을 한 장 이상 받지만 그 전에 DB로 직접 넣은 행은 비어 있을 수 있어, 예외 대신 null을 돌린다. */ public Long thumbnailFileId() { return imageIds == null || imageIds.isEmpty() ? null : imageIds.getFirst(); diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/ArchiveErrorCode.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/ArchiveErrorCode.java index 9561f9bdc..65c5288fb 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/ArchiveErrorCode.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/ArchiveErrorCode.java @@ -11,7 +11,8 @@ @AllArgsConstructor public enum ArchiveErrorCode implements ErrorCode { - ARCHIVE_NOT_FOUND(ErrorStatus.NOT_FOUND, "아카이빙을 찾을 수 없습니다."); + ARCHIVE_NOT_FOUND(ErrorStatus.NOT_FOUND, "아카이빙을 찾을 수 없습니다."), + INVALID_ARCHIVE_PERIOD(ErrorStatus.BAD_REQUEST, "활동 종료일이 시작일보다 앞설 수 없습니다."); private final int status; private final String message; diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/ArchiveWriteCommand.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/ArchiveWriteCommand.java new file mode 100644 index 000000000..025db7497 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/ArchiveWriteCommand.java @@ -0,0 +1,24 @@ +package kr.ac.kookmin.stream.event.domain.archive.domain; + +import java.time.LocalDate; +import java.util.List; + +/** + * 아카이빙 등록·수정 입력. 수정이 전체 필드를 통째로 바꾸는 방식이라 등록과 담는 값이 같아 하나로 둔다. + * + * @param endDate 당일 활동이면 {@code null} + * @param imageIds 활동 사진. 배열 순서가 노출 순서이고 첫 장이 대표 이미지다 + * @param relatedEventId 연결할 행사. 연결하지 않으면 {@code null}이고, 수정에서 {@code null}이면 연결을 끊는다 + */ +public record ArchiveWriteCommand( + String title, + String content, + LocalDate startDate, + LocalDate endDate, + String location, + String departmentName, + List imageIds, + boolean exposed, + Long relatedEventId +) { +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/repository/ArchiveRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/repository/ArchiveRepository.java index 638c21354..0ec1808e9 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/repository/ArchiveRepository.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/repository/ArchiveRepository.java @@ -29,4 +29,19 @@ public interface ArchiveRepository { PageResult search(String keyword, Boolean exposed, PageOffset pageOffset); Optional findById(Long id); + + /** + * 식별자 목록에 드는 아카이빙. 삭제된 것은 담지 않는다. 노출 여부 일괄 변경이 요청한 전부가 살아 있는지 + * 확인하려고 쓰므로, 결과 수가 요청 수와 다르면 부르는 쪽이 끊는다. + */ + List findAllByIds(List archiveIds); + + Archive save(Archive archive); + + void saveAll(List archives); + + /** + * 소프트 삭제한다. 이미 없거나 지워진 아카이빙이면 {@code false}다. + */ + boolean delete(Long id); } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/service/AdminArchiveService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/service/AdminArchiveService.java index cd46badb1..ceae56b28 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/service/AdminArchiveService.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/service/AdminArchiveService.java @@ -1,8 +1,10 @@ package kr.ac.kookmin.stream.event.domain.archive.service; +import java.util.List; import kr.ac.kookmin.stream.common.PageOffset; import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.event.domain.archive.domain.Archive; +import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveWriteCommand; /** * 운영진 아카이빙 API가 쓰는 서비스. @@ -19,4 +21,24 @@ public interface AdminArchiveService { * @param exposed 슬랑제 노출 여부. {@code null}이면 노출 여부로 좁히지 않는다 */ PageResult getArchives(String keyword, Boolean exposed, PageOffset pageOffset); + + /** + * 사진 파일과 연결할 행사가 실제로 있는지는 이 서비스가 보지 않는다. 교차 도메인이라 + * {@code api:admin-api}의 UseCase가 확인한다. + */ + void create(ArchiveWriteCommand command); + + /** 사진과 연결 행사도 받은 값으로 교체한다. 삭제된 아카이빙은 없는 것으로 본다. */ + void update(Long archiveId, ArchiveWriteCommand command); + + /** 행은 남기고 표시만 바꾼다. 학생 앱 조회에서는 바로 빠진다. */ + void delete(Long archiveId); + + /** + * 여러 아카이빙의 슬랑제 페이지 노출 여부를 한 번에 바꾼다. 목록의 체크박스 일괄 변경과 행별 드롭다운 변경이 + * 같은 동작이라 하나로 받는다 — 행별 변경은 식별자가 하나인 요청이다. + *

+ * 하나라도 없거나 지워진 아카이빙이 섞이면 아무것도 바꾸지 않는다. + */ + void changeExposure(List archiveIds, boolean exposed); } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/service/impl/AdminArchiveServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/service/impl/AdminArchiveServiceImpl.java index 455a1a92f..cc268faef 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/service/impl/AdminArchiveServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/service/impl/AdminArchiveServiceImpl.java @@ -1,12 +1,17 @@ package kr.ac.kookmin.stream.event.domain.archive.service.impl; +import java.util.List; +import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.common.PageOffset; import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.event.domain.archive.domain.Archive; +import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveErrorCode; +import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveWriteCommand; import kr.ac.kookmin.stream.event.domain.archive.repository.ArchiveRepository; import kr.ac.kookmin.stream.event.domain.archive.service.AdminArchiveService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -18,4 +23,47 @@ class AdminArchiveServiceImpl implements AdminArchiveService { public PageResult getArchives(String keyword, Boolean exposed, PageOffset pageOffset) { return archiveRepository.search(keyword, exposed, pageOffset); } + + @Override + @Transactional + public void create(ArchiveWriteCommand command) { + archiveRepository.save(Archive.create(command)); + } + + @Override + @Transactional + public void update(Long archiveId, ArchiveWriteCommand command) { + Archive archive = getArchive(archiveId); + archive.update(command); + + archiveRepository.save(archive); + } + + @Override + @Transactional + public void delete(Long archiveId) { + if (!archiveRepository.delete(archiveId)) { + throw new BusinessException(ArchiveErrorCode.ARCHIVE_NOT_FOUND); + } + } + + @Override + @Transactional + public void changeExposure(List archiveIds, boolean exposed) { + // 같은 식별자가 두 번 오면 아래 개수 비교가 어긋나므로, 호출부에 맡기지 않고 여기서 걷어낸다 + List distinctIds = archiveIds.stream().distinct().toList(); + List archives = archiveRepository.findAllByIds(distinctIds); + // 하나라도 없으면 아무것도 바꾸지 않는다. 삭제된 아카이빙은 findAllByIds가 빼므로 수가 모자란다 + if (archives.size() != distinctIds.size()) { + throw new BusinessException(ArchiveErrorCode.ARCHIVE_NOT_FOUND); + } + + archives.forEach(archive -> archive.changeExposure(exposed)); + archiveRepository.saveAll(archives); + } + + private Archive getArchive(Long archiveId) { + return archiveRepository.findById(archiveId) + .orElseThrow(() -> new BusinessException(ArchiveErrorCode.ARCHIVE_NOT_FOUND)); + } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/ArchiveJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/ArchiveJpaEntity.java index 0d19036f4..eeebc014a 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/ArchiveJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/ArchiveJpaEntity.java @@ -12,10 +12,16 @@ import kr.ac.kookmin.stream.event.domain.archive.domain.Archive; import lombok.AccessLevel; import lombok.NoArgsConstructor; +import org.hibernate.annotations.DynamicUpdate; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; +// 바뀐 컬럼만 UPDATE한다(@DynamicUpdate). 수정은 같은 트랜잭션에서 findById로 엔티티를 영속성 컨텍스트에 올린 뒤 +// merge하므로, 도메인이 들고 있지 않은 is_deleted는 읽어 둔 값 그대로여서 변경으로 잡히지 않고 UPDATE에서 빠진다. +// 덕분에 운영진 A가 수정하는 사이 B가 삭제를 커밋해도 A의 UPDATE가 삭제를 되돌리지 않는다. +// (NoticeJpaEntity·LockerJpaEntity가 같은 이유로 붙이고 있다) @Entity +@DynamicUpdate @Table(name = "archives") @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ArchiveJpaEntity extends BaseSoftDeleteEntity { diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/ArchiveJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/ArchiveJpaRepository.java index 8ba7984ad..6a702d652 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/ArchiveJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/ArchiveJpaRepository.java @@ -40,6 +40,8 @@ ORDER BY YEAR(a.startDate) DESC Optional findByIdAndIsDeletedFalse(Long id); + List findAllByIdInAndIsDeletedFalse(List ids); + /** * 운영진 목록. 제목 검색은 {@code LIKE '%...%'}라 인덱스를 타지 못하고 테이블을 훑는다 — 아카이빙이 연간 * 수십 건 규모라 그대로 둔다. 검색어 이스케이프는 {@code LikePatterns}가 한다. diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/ArchiveRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/ArchiveRepositoryImpl.java index 0b463851e..bfd37943d 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/ArchiveRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/ArchiveRepositoryImpl.java @@ -56,4 +56,38 @@ public PageResult search(String keyword, Boolean exposed, PageOffset pa public Optional findById(Long id) { return archiveJpaRepository.findByIdAndIsDeletedFalse(id).map(ArchiveJpaEntity::toDomain); } + + @Override + public List findAllByIds(List archiveIds) { + if (archiveIds.isEmpty()) { + return List.of(); + } + return archiveJpaRepository.findAllByIdInAndIsDeletedFalse(archiveIds).stream() + .map(ArchiveJpaEntity::toDomain) + .toList(); + } + + @Override + public Archive save(Archive archive) { + return archiveJpaRepository.save(ArchiveJpaEntity.from(archive)).toDomain(); + } + + @Override + public void saveAll(List archives) { + archiveJpaRepository.saveAll(archives.stream().map(ArchiveJpaEntity::from).toList()); + } + + /** + * 관리 상태 엔티티를 읽어 표시만 바꾼다. 더티 체킹이 is_deleted 한 컬럼만 UPDATE하므로, 도메인이 들고 있지 않은 + * 삭제 표시를 쓰기 경로로 되돌릴 일이 없다 (NoticeRepositoryImpl.delete와 같은 방식). + */ + @Override + public boolean delete(Long id) { + return archiveJpaRepository.findByIdAndIsDeletedFalse(id) + .map(entity -> { + entity.delete(); + return true; + }) + .orElse(false); + } }