diff --git a/api/admin-api/build.gradle.kts b/api/admin-api/build.gradle.kts index 983679fe..3d452814 100644 --- a/api/admin-api/build.gradle.kts +++ b/api/admin-api/build.gradle.kts @@ -7,6 +7,7 @@ description = "운영진 콘솔 — ADMIN, /v1/admin/**" dependencies { implementation(project(":api:common-api")) implementation(project(":core:common")) + implementation(project(":core:domain:event")) implementation(project(":core:domain:file")) implementation(project(":core:domain:internal")) implementation(project(":core:domain:member")) 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 new file mode 100644 index 00000000..fa1d0092 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/AdminArchiveApi.java @@ -0,0 +1,45 @@ +package kr.ac.kookmin.stream.api.admin.event.archive; + +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.ArchiveSearchParams; +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; +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.event.domain.archive.domain.ArchiveErrorCode; +import org.springdoc.core.annotations.ParameterObject; + +/** + * 운영진 아카이빙 API의 문서 명세. 구현은 {@link AdminArchiveController}가 맡는다. + *

+ * 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다. + */ +@Tag(name = "아카이빙 관리", description = "운영진 아카이빙 목록·상세 조회") +public interface AdminArchiveApi { + + /** 아카이빙 목록. 제목으로 검색하고 슬랑제 노출 여부로 거르며, 등록일 최신순으로 페이지 단위 조회한다. */ + @Operation(summary = "아카이빙 목록 조회", + description = "삭제되지 않은 아카이빙을 등록일 최신순으로 페이지 단위로 조회한다. " + + "keyword를 주면 제목에 그 글자가 들어 있는 것만, isExposed를 주면 슬랑제 페이지 노출 여부가 " + + "그 값인 것만 조회하며 둘을 함께 쓸 수 있다. 생략하면 좁히지 않는다. page는 0부터 시작한다. " + + "학생 앱 목록이 활동 시작일 내림차순인 것과 달리 등록일 기준이다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + ApiResponse> getArchives( + AdminApiUser apiUser, + @ParameterObject ArchiveSearchParams searchParams, + @ParameterObject PageParams pageParams + ); + + /** 아카이빙 상세. 게시물 상세 화면과 수정 화면의 기존 값 채우기에 함께 쓴다. */ + @Operation(summary = "아카이빙 상세 조회", + description = "아카이빙 한 건을 조회한다. 당일 활동은 endDate가 null이다. " + + "활동 사진은 노출 순서대로 담고 첫 장이 대표 이미지다 — 대표를 따로 내려주지 않는다. " + + "삭제된 파일은 사진 목록에서 조용히 빠진다.") + @ApiErrorCode(type = ArchiveErrorCode.class, codes = {"ARCHIVE_NOT_FOUND"}) + ApiResponse getArchive(AdminApiUser apiUser, Long archiveId); +} 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 new file mode 100644 index 00000000..cdef0275 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/AdminArchiveController.java @@ -0,0 +1,63 @@ +package kr.ac.kookmin.stream.api.admin.event.archive; + +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.ArchiveSearchParams; +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; +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.event.domain.archive.domain.Archive; +import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveDetail; +import kr.ac.kookmin.stream.event.domain.archive.service.AdminArchiveService; +import kr.ac.kookmin.stream.event.domain.archive.service.ArchiveService; +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.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 목록은 정렬 기준이 달라 {@link AdminArchiveService}가, 상세는 학생 앱과 내용이 같아 {@link ArchiveService}가 맡는다 + * ({@code coding-style.md} 2-7절). + */ +@RestController +@RequestMapping("/v1/admin/archives") +@RequiredArgsConstructor +public class AdminArchiveController implements AdminArchiveApi { + + private final AdminArchiveService adminArchiveService; + private final ArchiveService archiveService; + private final FileService fileService; + + @Override + @GetMapping + public ApiResponse> getArchives( + AdminApiUser apiUser, + @Valid @ModelAttribute ArchiveSearchParams searchParams, + @Valid @ModelAttribute PageParams pageParams + ) { + PageResult result = adminArchiveService.getArchives( + searchParams.keyword(), searchParams.isExposed(), pageParams.toOffset()); + + return ApiResponse.success(PageResponse.from(result, AdminArchiveListItemResponse::from)); + } + + @Override + @GetMapping("/{archiveId}") + public ApiResponse getArchive( + AdminApiUser apiUser, + @PathVariable Long archiveId + ) { + ArchiveDetail detail = archiveService.getArchive(archiveId); + Map imagesById = fileService.findAllByIdIn(detail.imageIds()); + + return ApiResponse.success(AdminArchiveDetailResponse.of(detail, imagesById)); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/request/ArchiveSearchParams.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/request/ArchiveSearchParams.java new file mode 100644 index 00000000..c1b09364 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/request/ArchiveSearchParams.java @@ -0,0 +1,19 @@ +package kr.ac.kookmin.stream.api.admin.event.archive.request; + +/** + * 아카이빙 목록을 좁히는 조건. 둘 다 없으면 전체를 조회한다. + *

+ * {@code Boolean}을 {@code @RequestParam}으로 직접 받지 않고 record로 감싼다. 타입이 맞지 않는 값 + * ({@code ?isExposed=abc})이 오면 {@code @RequestParam}은 {@code MethodArgumentTypeMismatchException}을 + * 내는데, {@code GlobalExceptionHandler}가 그 예외를 다루지 않아 catch-all에 걸려 500이 된다. + * {@code @ModelAttribute}로 받으면 바인딩 실패가 {@code MethodArgumentNotValidException}으로 나와 + * 400 {@code INVALID_INPUT}이 된다. {@code LockerPeriodParams}·{@code ArchiveListParams}와 같은 이유다. + * + * @param keyword 제목 일부. 비어 있으면 제목으로 좁히지 않는다 + * @param isExposed 슬랑제 페이지 노출 여부. {@code null}이면 노출 여부로 좁히지 않는다 + */ +public record ArchiveSearchParams( + String keyword, + Boolean isExposed +) { +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/response/AdminArchiveDetailResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/response/AdminArchiveDetailResponse.java new file mode 100644 index 00000000..9b705f6b --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/response/AdminArchiveDetailResponse.java @@ -0,0 +1,56 @@ +package kr.ac.kookmin.stream.api.admin.event.archive.response; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import kr.ac.kookmin.stream.api.common.StorageUrlBuilder; +import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveDetail; +import kr.ac.kookmin.stream.file.domain.File; + +/** + * 대표 이미지는 따로 담지 않는다. 사진 배열의 첫 장이 대표라 화면이 그대로 떼어 쓰면 되고, 학생 앱 상세 + * ({@code ArchiveDetailResponse})도 같은 모양이다. + * + * @param endDate 당일 활동이면 {@code null} + * @param images 활동 사진. 노출 순서대로 담고, 없으면 빈 목록이다. 첫 장이 대표 이미지다 + */ +public record AdminArchiveDetailResponse( + Long archiveId, + String title, + LocalDate startDate, + LocalDate endDate, + String location, + String departmentName, + String content, + boolean isExposed, + List images, + LocalDateTime createdAt +) { + + /** @param imagesById 활동 사진 파일. 삭제된 파일은 키가 없어 목록에서 조용히 빠진다 */ + public static AdminArchiveDetailResponse of(ArchiveDetail detail, Map imagesById) { + return new AdminArchiveDetailResponse( + detail.archiveId(), + detail.title(), + detail.startDate(), + detail.endDate(), + detail.location(), + detail.departmentName(), + detail.content(), + detail.exposed(), + detail.imageIds().stream() + .filter(imagesById::containsKey) + .map(fileId -> Image.of(fileId, imagesById.get(fileId))) + .toList(), + detail.createdAt() + ); + } + + public record Image(Long fileId, String fileUrl) { + + public static Image of(Long fileId, File file) { + return new Image(fileId, StorageUrlBuilder.build(file.getFileKey())); + } + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/response/AdminArchiveListItemResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/response/AdminArchiveListItemResponse.java new file mode 100644 index 00000000..affc6ee5 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/response/AdminArchiveListItemResponse.java @@ -0,0 +1,33 @@ +package kr.ac.kookmin.stream.api.admin.event.archive.response; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.event.domain.archive.domain.Archive; + +/** + * @param endDate 당일 활동이면 {@code null} + */ +public record AdminArchiveListItemResponse( + Long archiveId, + String title, + LocalDate startDate, + LocalDate endDate, + String location, + String departmentName, + boolean isExposed, + LocalDateTime createdAt +) { + + public static AdminArchiveListItemResponse from(Archive archive) { + return new AdminArchiveListItemResponse( + archive.getId(), + archive.getTitle(), + archive.getStartDate(), + archive.getEndDate(), + archive.getLocation(), + archive.getDepartmentName(), + archive.isExposed(), + archive.getCreatedAt() + ); + } +} 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 6af7f7a1..1a31352a 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 @@ -1,6 +1,7 @@ package kr.ac.kookmin.stream.event.domain.archive.domain; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -20,6 +21,10 @@ public class Archive { private String location; private String departmentName; private List imageIds; + /** 슬랑제(연말 행사) 전용 페이지에 띄울지. 삭제 여부와 달리 운영진이 목록에서 보고 켜고 끄는 값이라 도메인이 들고 있다. */ + private boolean exposed; + /** 등록 일시. 운영진 목록이 이 순서로 정렬하고 화면에도 보여준다. */ + private LocalDateTime createdAt; public static Archive of( Long id, @@ -29,9 +34,12 @@ public static Archive of( LocalDate endDate, String location, String departmentName, - List imageIds + List imageIds, + boolean exposed, + LocalDateTime createdAt ) { - return new Archive(id, title, content, startDate, endDate, location, departmentName, imageIds); + return new Archive( + id, title, content, startDate, endDate, location, departmentName, imageIds, exposed, createdAt); } /** diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/ArchiveDetail.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/ArchiveDetail.java index 3aeb6134..91df9126 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/ArchiveDetail.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/ArchiveDetail.java @@ -1,10 +1,14 @@ package kr.ac.kookmin.stream.event.domain.archive.domain; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; /** * 아카이빙 상세 한 건. 현장 사진은 저장된 배열 순서가 곧 표시 순서다. + *

+ * 학생 앱과 운영진이 같이 쓴다. 담는 값이 거의 같고 운영진만 보는 것은 두 개(슬랑제 노출 여부·등록 일시)뿐이라 + * 하나로 두고, 학생 앱 응답이 그 둘을 담지 않는다. */ public record ArchiveDetail( Long archiveId, @@ -15,7 +19,9 @@ public record ArchiveDetail( String departmentName, String content, List imageIds, - List relatedLinks + List relatedLinks, + boolean exposed, + LocalDateTime createdAt ) { public static ArchiveDetail of(Archive archive, List relatedLinks) { @@ -28,7 +34,9 @@ public static ArchiveDetail of(Archive archive, List related archive.getDepartmentName(), archive.getContent(), archive.getImageIds() == null ? List.of() : archive.getImageIds(), - relatedLinks + relatedLinks, + archive.isExposed(), + archive.getCreatedAt() ); } } 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 0b5a5c79..66f7e5c9 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 @@ -2,6 +2,8 @@ import java.util.List; import java.util.Optional; +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.ArchiveRelatedLink; @@ -19,6 +21,14 @@ public interface ArchiveRepository { */ List findAllYearsDesc(); + /** + * 운영진 목록용. 등록일 최신순(같으면 식별자 내림차순)으로 페이징한다. 학생 앱 목록이 활동 시작일 기준인 것과 다르다. + * + * @param keyword 제목 일부. null이면 제목으로 좁히지 않는다 + * @param exposed 슬랑제 노출 여부. null이면 노출 여부로 좁히지 않는다 + */ + PageResult search(String keyword, Boolean exposed, PageOffset pageOffset); + Optional findById(Long id); List findRelatedLinksByArchiveId(Long archiveId); 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 new file mode 100644 index 00000000..cd46badb --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/service/AdminArchiveService.java @@ -0,0 +1,22 @@ +package kr.ac.kookmin.stream.event.domain.archive.service; + +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.event.domain.archive.domain.Archive; + +/** + * 운영진 아카이빙 API가 쓰는 서비스. + *

+ * 목록만 여기 둔다. 운영진 목록은 등록일 최신순이고 학생 앱 목록은 활동 시작일 내림차순이라 조회 자체가 다르다. + * 상세 조회는 내용이 같아 운영진도 {@link ArchiveService}를 그대로 쓴다({@code coding-style.md} 2-7절의 공지와 같다). + */ +public interface AdminArchiveService { + + /** + * 아카이빙을 등록일 최신순으로 조회한다. 학생 앱 목록이 활동 시작일 기준인 것과 달리, 운영진은 방금 올린 글을 먼저 본다. + * + * @param keyword 제목 일부. 비어 있으면 제목으로 좁히지 않는다 + * @param exposed 슬랑제 노출 여부. {@code null}이면 노출 여부로 좁히지 않는다 + */ + PageResult getArchives(String keyword, Boolean exposed, PageOffset pageOffset); +} 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 new file mode 100644 index 00000000..455a1a92 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/service/impl/AdminArchiveServiceImpl.java @@ -0,0 +1,21 @@ +package kr.ac.kookmin.stream.event.domain.archive.service.impl; + +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.repository.ArchiveRepository; +import kr.ac.kookmin.stream.event.domain.archive.service.AdminArchiveService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +class AdminArchiveServiceImpl implements AdminArchiveService { + + private final ArchiveRepository archiveRepository; + + @Override + public PageResult getArchives(String keyword, Boolean exposed, PageOffset pageOffset) { + return archiveRepository.search(keyword, exposed, pageOffset); + } +} 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 824215fc..abc421cc 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 @@ -8,7 +8,7 @@ import jakarta.persistence.Table; import java.time.LocalDate; import java.util.List; -import kr.ac.kookmin.stream.db.common.BaseTimeEntity; +import kr.ac.kookmin.stream.db.common.BaseSoftDeleteEntity; import kr.ac.kookmin.stream.event.domain.archive.domain.Archive; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -18,7 +18,7 @@ @Entity @Table(name = "archives") @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class ArchiveJpaEntity extends BaseTimeEntity { +public class ArchiveJpaEntity extends BaseSoftDeleteEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -46,6 +46,9 @@ public class ArchiveJpaEntity extends BaseTimeEntity { @Column(name = "image_ids", columnDefinition = "json") private List imageIds; + @Column(name = "is_exposed", nullable = false) + private boolean exposed; + private ArchiveJpaEntity(Archive archive) { this.id = archive.getId(); this.title = archive.getTitle(); @@ -55,6 +58,7 @@ private ArchiveJpaEntity(Archive archive) { this.location = archive.getLocation(); this.departmentName = archive.getDepartmentName(); this.imageIds = archive.getImageIds(); + this.exposed = archive.isExposed(); } public static ArchiveJpaEntity from(Archive archive) { @@ -62,6 +66,8 @@ public static ArchiveJpaEntity from(Archive archive) { } public Archive toDomain() { - return Archive.of(id, title, content, startDate, endDate, location, departmentName, imageIds); + return Archive.of( + id, title, content, startDate, endDate, location, departmentName, imageIds, exposed, + getCreatedAt()); } } 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 d3ba36c1..8ba7984a 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 @@ -2,6 +2,9 @@ import java.time.LocalDate; import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -17,8 +20,9 @@ public interface ArchiveJpaRepository extends JpaRepository= :startInclusive AND a.startDate < :endExclusive)) + WHERE a.isDeleted = false + AND (:startInclusive IS NULL + OR (a.startDate >= :startInclusive AND a.startDate < :endExclusive)) ORDER BY a.startDate DESC, a.id DESC """) List findAllInPeriod( @@ -26,9 +30,36 @@ List findAllInPeriod( @Param("endExclusive") LocalDate endExclusive ); + /** 연도 버튼은 목록과 별도로 조회하므로 삭제 필터를 여기에도 둔다. 빠뜨리면 지운 아카이빙의 연도가 남는다. */ @Query(""" SELECT DISTINCT YEAR(a.startDate) FROM ArchiveJpaEntity a + WHERE a.isDeleted = false ORDER BY YEAR(a.startDate) DESC """) List findAllYearsDesc(); + + Optional findByIdAndIsDeletedFalse(Long id); + + /** + * 운영진 목록. 제목 검색은 {@code LIKE '%...%'}라 인덱스를 타지 못하고 테이블을 훑는다 — 아카이빙이 연간 + * 수십 건 규모라 그대로 둔다. 검색어 이스케이프는 {@code LikePatterns}가 한다. + */ + @Query(value = """ + SELECT a FROM ArchiveJpaEntity a + WHERE a.isDeleted = false + AND (:keyword IS NULL OR a.title LIKE CONCAT('%', :keyword, '%') ESCAPE '!') + AND (:exposed IS NULL OR a.exposed = :exposed) + ORDER BY a.createdAt DESC, a.id DESC + """, + countQuery = """ + SELECT COUNT(a) FROM ArchiveJpaEntity a + WHERE a.isDeleted = false + AND (:keyword IS NULL OR a.title LIKE CONCAT('%', :keyword, '%') ESCAPE '!') + AND (:exposed IS NULL OR a.exposed = :exposed) + """) + Page search( + @Param("keyword") String keyword, + @Param("exposed") Boolean exposed, + Pageable pageable + ); } 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 177ad48d..06f9bec2 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 @@ -3,10 +3,15 @@ import java.util.List; import java.util.Optional; import kr.ac.kookmin.stream.common.DateUtil; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.db.common.LikePatterns; import kr.ac.kookmin.stream.event.domain.archive.domain.Archive; import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveRelatedLink; import kr.ac.kookmin.stream.event.domain.archive.repository.ArchiveRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Repository; @Repository @@ -33,9 +38,25 @@ public List findAllYearsDesc() { return archiveJpaRepository.findAllYearsDesc(); } + @Override + public PageResult search(String keyword, Boolean exposed, PageOffset pageOffset) { + Page result = archiveJpaRepository.search( + LikePatterns.escape(keyword), + exposed, + PageRequest.of(pageOffset.page(), pageOffset.size()) + ); + + return PageResult.of( + result.getContent().stream().map(ArchiveJpaEntity::toDomain).toList(), + result.getNumber(), + result.getSize(), + result.getTotalElements() + ); + } + @Override public Optional findById(Long id) { - return archiveJpaRepository.findById(id).map(ArchiveJpaEntity::toDomain); + return archiveJpaRepository.findByIdAndIsDeletedFalse(id).map(ArchiveJpaEntity::toDomain); } @Override diff --git a/infrastructure/db/src/main/resources/db/migration/V30__add_exposed_and_is_deleted_to_archives.sql b/infrastructure/db/src/main/resources/db/migration/V30__add_exposed_and_is_deleted_to_archives.sql new file mode 100644 index 00000000..f0e9dc46 --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V30__add_exposed_and_is_deleted_to_archives.sql @@ -0,0 +1,12 @@ +-- 관리자 API가 아카이빙을 목록에서 걸러내고 삭제할 수 있게 한다. +-- +-- 노출 여부: 슬랑제(연말 행사) 전용 페이지에 이 아카이빙을 띄울지. 운영진이 목록에서 켜고 끈다. +-- 아카이빙이 노출될 곳은 그 페이지뿐이라 컬럼 이름에는 슬랑제를 넣지 않는다. +-- 삭제: 실수로 지운 아카이빙을 되살릴 수 있도록 행은 남긴다 (flyway-migration.md 3-4절). +-- 운영 중인 행이 있어 둘 다 DEFAULT를 준다(3-3절). 기존 아카이빙은 "노출 안 함 · 살아 있음"으로 시작한다. +-- +-- 인덱스는 두지 않는다. 아카이빙은 연간 수십 건 규모라 목록이 테이블을 통째로 읽어도 되고, +-- 제목 검색이 LIKE '%...%'라 어차피 인덱스를 타지 못한다. +ALTER TABLE archives + ADD COLUMN is_exposed TINYINT(1) NOT NULL DEFAULT 0, + ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0;