diff --git a/docs/design/gimin-#153-document-list-api.md b/docs/design/gimin-#153-document-list-api.md new file mode 100644 index 0000000..cc3a3f7 --- /dev/null +++ b/docs/design/gimin-#153-document-list-api.md @@ -0,0 +1,152 @@ +# 문서 목록 조회 API + +closes #153 + +## 배경 + +문서 도메인에는 업로드(`POST /api/documents`)와 단건 인덱싱 상태 조회 +(`GET /api/documents/{documentId}/status`)만 있고, **사용자가 읽을 수 있는 문서 목록을 +반환하는 API가 없었다.** 문서를 한 번 업로드하고 나면 그 `documentId`를 따로 기억하지 않는 한 +다시 찾아갈 방법이 없어, 문서 화면을 구성할 수 없는 상태였다. + +목록에 필요한 권한 판정은 이미 존재한다. 검색 pre-filter가 쓰는 +`DocumentRepository.findReadableDocumentIds`가 5가지 접근 경로(OWNER / PUBLIC / +USER 캐시 / ROLE live / DEPARTMENT live)를 UNION으로 판정한다. 다만 이 쿼리는 +`d.status = 'INDEXED'`가 하드코딩되어 있어 그대로는 목록에 쓸 수 없다. 목록에서는 인덱싱 중 +(`INDEXING`)이거나 실패(`FAILED`)한 문서도 보여야 사용자가 진행 상황을 확인할 수 있기 +때문이다. + +## 설계 판단 + +### 쿼리를 복제하지 않고 상태 조건만 파라미터화했다 + +UNION 7개 브랜치짜리 네이티브 쿼리를 목록용으로 복사하면 권한 정책이 두 벌이 되어, 이후 접근 +경로가 추가될 때 한쪽만 고치는 사고가 나기 쉽다. `d.status = 'INDEXED'`를 +`d.status IN (:statuses)`로 바꾸고, 검색은 호출부에서 `INDEXED`만 넘겨 기존 동작을 그대로 +유지한다. + +| 호출자 | 넘기는 상태 | +|---|---| +| `AccessibleDocumentQueryService` (검색) | `INDEXED` | +| `DocumentQueryService` (목록) | `DELETED`를 제외한 전체, 또는 요청한 단일 상태 | + +`statuses`는 네이티브 쿼리라 `DocumentStatus.name()` 문자열 목록으로 넘긴다. + +### 권한 판정과 페이징을 분리했다 + +권한 pre-filter로 읽을 수 있는 문서 ID를 먼저 구하고, 그 ID 집합에 대해 JPQL 페이지 쿼리로 +정렬·페이징만 수행한다. 응답에 현재 버전 번호·상태를 담아야 하는데 `Document.currentVersion`이 +`LAZY`라 `LEFT JOIN FETCH`로 즉시 로딩한다(`countQuery`는 별도 지정). + +읽을 수 있는 문서가 하나도 없으면 페이지 쿼리를 아예 실행하지 않고 빈 응답을 반환한다. + +### 정렬은 고정이다 + +외부 `sort` 파라미터를 받지 않고 `createdAt DESC, id DESC`로 고정한다. 정렬 키를 열어두면 +인덱스 없는 컬럼 정렬 요청을 그대로 DB에 흘리게 되고, 응답 계약도 불안정해진다. + +## API 명세 + +### 요청 + +```http +GET /api/documents?status=INDEXED&page=0&size=20 +Authorization: Bearer {accessToken} +``` + +| 파라미터 | 타입 | 필수 | 기본값 | 설명 | +|---|---|---|---|---| +| `status` | `DocumentStatus` | X | 없음 | 지정 시 해당 상태만 조회. 미지정 시 `DELETED` 제외 전체 | +| `page` | int | X | `0` | 0부터 시작하는 페이지 번호 | +| `size` | int | X | `20` | 페이지 크기, 1~100 | + +`DocumentStatus`: `DRAFT` / `UPLOADED` / `INDEXING` / `INDEXED` / `FAILED` / `ARCHIVED` / `DELETED` + +### 응답 200 + +```json +{ + "success": true, + "status": 200, + "data": { + "content": [ + { + "documentId": 12, + "title": "2026 상반기 운영 가이드", + "description": "운영팀 공유용", + "documentType": "PDF", + "status": "INDEXED", + "visibility": "PRIVATE", + "ownerUserId": 3, + "currentVersionNo": 2, + "currentVersionStatus": "INDEXED", + "createdAt": "2026-08-10T09:12:33", + "updatedAt": "2026-08-11T14:02:10" + } + ], + "page": 0, + "size": 20, + "totalElements": 1, + "totalPages": 1, + "first": true, + "last": true + }, + "timestamp": "2026-08-12 19:30:00" +} +``` + +아직 인덱싱이 끝난 버전이 없으면 `currentVersionNo`와 `currentVersionStatus`는 `null`이다. + +### 에러 케이스 + +| 상황 | HTTP | 응답 | +|---|---|---| +| 인증 토큰 없음 또는 만료 | 401 | 인증 실패 | +| `page < 0`, `size < 1`, `size > 100` | 400 | 제약 조건 위반 | +| `status`에 정의되지 않은 값 | 400 | 타입 변환 실패 | +| 읽을 수 있는 문서 없음 | 200 | `content: []`, `totalElements: 0` (에러 아님) | + +읽을 수 있는 문서가 없는 것은 정상 상태이므로 404가 아니라 빈 페이지를 반환한다. 권한 없는 문서는 +목록에서 조용히 제외되며, 존재 여부를 응답으로 노출하지 않는다. + +## 변경 파일 + +| 파일 | 변경 | +|---|---| +| `DocumentRepository` | 권한 pre-filter 쿼리 2개 상태 파라미터화, 목록 페이지 쿼리 `findAllByIdIn` 추가 | +| `AccessibleDocumentQueryService` | 검색 호출부에서 `INDEXED` 상태를 명시적으로 전달 | +| `DocumentSummaryResponse` | 신규 응답 record | +| `DocumentSummaryConverter` | 신규 Converter | +| `DocumentQueryService` | `getMyDocuments` 추가 | +| `DocumentQueryController` | 목록 API 추가, `@Validated`로 page·size 범위 검증 | + +## 테스트 + +**단위 — `DocumentQueryServiceTest`** + +- 읽을 수 있는 문서를 페이지 응답으로 변환해 반환한다 +- 읽을 수 있는 문서가 없으면 문서를 조회하지 않고 빈 페이지를 반환한다 +- `status`를 지정하지 않으면 `DELETED`를 제외한 전체 상태로 조회한다 +- `status`를 지정하면 해당 상태만으로 조회한다 + +**Repository — `DocumentReadableIdsRepositoryTest` (`@DataJpaTest`)** + +- `INDEXED`만 요청하면 인덱싱 중인 문서는 제외한다 +- `INDEXING`을 함께 요청하면 인덱싱 중인 문서도 반환한다 +- soft delete된 문서는 `DELETED` 상태를 요청해도 제외한다 +- 컬렉션 범위 조회도 요청한 상태만 반환한다 + +seed 데이터에 PUBLIC·INDEXED 문서가 있어 모든 사용자 조회 결과에 포함되므로, 테스트가 생성한 +문서만 포함·제외로 검증한다. + +**검색 회귀** + +상태 조건을 파라미터화하면서 검색 경로가 바뀌지 않았는지 +`AccessibleDocumentQueryServiceTest`, `SearchFacadeTest`, +`DocumentIndexingCompletionIntegrationTest`로 확인했다. + +```bash +./gradlew build +``` + +760개 테스트 전부 통과. diff --git a/src/main/java/com/opensource/docgrid/domain/document/controller/DocumentQueryController.java b/src/main/java/com/opensource/docgrid/domain/document/controller/DocumentQueryController.java index 79bc8ae..1068157 100644 --- a/src/main/java/com/opensource/docgrid/domain/document/controller/DocumentQueryController.java +++ b/src/main/java/com/opensource/docgrid/domain/document/controller/DocumentQueryController.java @@ -1,23 +1,31 @@ package com.opensource.docgrid.domain.document.controller; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.opensource.docgrid.domain.auth.annotation.CurrentUser; import com.opensource.docgrid.domain.document.dto.response.DocumentStatusResponse; +import com.opensource.docgrid.domain.document.dto.response.DocumentSummaryResponse; +import com.opensource.docgrid.domain.document.enums.DocumentStatus; import com.opensource.docgrid.domain.document.service.query.DocumentQueryService; import com.opensource.docgrid.global.common.response.ApiResponse; +import com.opensource.docgrid.global.common.response.PageResponse; import com.opensource.docgrid.global.common.response.ResponseUtils; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; @Tag(name = "Document", description = "문서 관련 API") +@Validated @RestController @RequestMapping("/api/documents") @RequiredArgsConstructor @@ -25,6 +33,23 @@ public class DocumentQueryController { private final DocumentQueryService documentQueryService; + @Operation( + summary = "내 문서 목록 조회", + description = "로그인한 사용자가 읽을 수 있는 문서를 최신 등록순으로 조회합니다. " + + "소유한 문서, PUBLIC 문서, 문서·컬렉션 권한을 부여받은 문서가 모두 포함됩니다. " + + "status를 지정하면 해당 상태만 조회하며, 미지정 시 삭제된 문서를 제외한 전체를 반환합니다. " + + "size는 1~100까지 지정할 수 있습니다." + ) + @GetMapping + public ResponseEntity>> getMyDocuments( + @Parameter(hidden = true) @CurrentUser Long userId, + @RequestParam(required = false) DocumentStatus status, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ResponseUtils.ok(documentQueryService.getMyDocuments(userId, status, page, size)); + } + @Operation( summary = "문서 인덱싱 상태 조회", description = "현재 검색 가능한 INDEXED 버전과 처리 중인 버전 및 임베딩 작업 상태를 함께 조회합니다. " diff --git a/src/main/java/com/opensource/docgrid/domain/document/converter/DocumentSummaryConverter.java b/src/main/java/com/opensource/docgrid/domain/document/converter/DocumentSummaryConverter.java new file mode 100644 index 0000000..638b120 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/document/converter/DocumentSummaryConverter.java @@ -0,0 +1,29 @@ +package com.opensource.docgrid.domain.document.converter; + +import org.springframework.stereotype.Component; + +import com.opensource.docgrid.domain.document.dto.response.DocumentSummaryResponse; +import com.opensource.docgrid.domain.document.entity.Document; +import com.opensource.docgrid.domain.document.entity.DocumentVersion; + +@Component +public class DocumentSummaryConverter { + + public DocumentSummaryResponse toResponse(Document document) { + DocumentVersion currentVersion = document.getCurrentVersion(); + + return new DocumentSummaryResponse( + document.getId(), + document.getTitle(), + document.getDescription(), + document.getDocumentType(), + document.getStatus(), + document.getVisibility(), + document.getOwner().getId(), + currentVersion == null ? null : currentVersion.getVersionNo(), + currentVersion == null ? null : currentVersion.getStatus(), + document.getCreatedAt(), + document.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/document/dto/response/DocumentSummaryResponse.java b/src/main/java/com/opensource/docgrid/domain/document/dto/response/DocumentSummaryResponse.java new file mode 100644 index 0000000..9b4e87f --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/document/dto/response/DocumentSummaryResponse.java @@ -0,0 +1,25 @@ +package com.opensource.docgrid.domain.document.dto.response; + +import java.time.LocalDateTime; + +import com.opensource.docgrid.domain.document.enums.DocumentStatus; +import com.opensource.docgrid.domain.document.enums.DocumentType; +import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; +import com.opensource.docgrid.domain.document.enums.VisibilityType; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record DocumentSummaryResponse( + @Schema(description = "문서 ID") Long documentId, + @Schema(description = "문서 제목") String title, + @Schema(description = "문서 설명") String description, + @Schema(description = "문서 형식", example = "PDF") DocumentType documentType, + @Schema(description = "문서 상태", example = "INDEXED") DocumentStatus status, + @Schema(description = "공개 범위", example = "PRIVATE") VisibilityType visibility, + @Schema(description = "소유자 사용자 ID") Long ownerUserId, + @Schema(description = "현재 활성 버전 번호, 아직 활성 버전이 없으면 null") Integer currentVersionNo, + @Schema(description = "현재 활성 버전 상태, 아직 활성 버전이 없으면 null") DocumentVersionStatus currentVersionStatus, + @Schema(description = "등록 시각") LocalDateTime createdAt, + @Schema(description = "마지막 수정 시각") LocalDateTime updatedAt +) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java b/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java index 7fec04f..f9ef90f 100644 --- a/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java +++ b/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java @@ -4,6 +4,8 @@ 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.Lock; import org.springframework.data.jpa.repository.Query; @@ -67,34 +69,35 @@ List findDocumentStatus( @Param("activeJobStatuses") Collection activeJobStatuses ); - // 검색 pre-filter — 사용자가 읽을 수 있는 INDEXED 문서 ID 전체 (컬렉션 미지정) + // 검색 pre-filter — 사용자가 읽을 수 있는 문서 ID 전체 (컬렉션 미지정) // 5가지 접근 경로: OWNER / PUBLIC / USER캐시 / ROLE live / DEPT live (문서·컬렉션 권한 모두 포함) + // statuses는 DocumentStatus.name() 문자열 목록. 검색은 INDEXED만, 문서 목록은 처리 중 상태까지 넘긴다. @Query(value = """ SELECT d.id FROM documents d - WHERE d.owner_user_id = :userId AND d.deleted_at IS NULL AND d.status = 'INDEXED' + WHERE d.owner_user_id = :userId AND d.deleted_at IS NULL AND d.status IN (:statuses) UNION SELECT d.id FROM documents d - WHERE d.visibility = 'PUBLIC' AND d.deleted_at IS NULL AND d.status = 'INDEXED' + WHERE d.visibility = 'PUBLIC' AND d.deleted_at IS NULL AND d.status IN (:statuses) UNION SELECT d.id FROM documents d JOIN user_document_access_cache c ON c.document_id = d.id WHERE c.user_id = :userId AND c.can_read = true AND c.invalidated_at IS NULL AND (c.expires_at IS NULL OR c.expires_at > NOW()) - AND d.deleted_at IS NULL AND d.status = 'INDEXED' + AND d.deleted_at IS NULL AND d.status IN (:statuses) UNION SELECT d.id FROM documents d JOIN document_permissions dp ON dp.document_id = d.id JOIN user_roles ur ON ur.role_id = dp.role_id WHERE dp.target_type = 'ROLE' AND ur.user_id = :userId AND dp.can_read = true AND (dp.expires_at IS NULL OR dp.expires_at > NOW()) - AND d.deleted_at IS NULL AND d.status = 'INDEXED' + AND d.deleted_at IS NULL AND d.status IN (:statuses) UNION SELECT d.id FROM documents d JOIN document_permissions dp ON dp.document_id = d.id JOIN users u ON u.department_id = dp.department_id WHERE dp.target_type = 'DEPARTMENT' AND u.id = :userId AND dp.can_read = true AND (dp.expires_at IS NULL OR dp.expires_at > NOW()) - AND d.deleted_at IS NULL AND d.status = 'INDEXED' + AND d.deleted_at IS NULL AND d.status IN (:statuses) UNION SELECT d.id FROM documents d JOIN collection_documents cd ON cd.document_id = d.id @@ -102,7 +105,7 @@ List findDocumentStatus( JOIN user_roles ur ON ur.role_id = cp.role_id WHERE cp.target_type = 'ROLE' AND ur.user_id = :userId AND cp.can_read = true AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) - AND d.deleted_at IS NULL AND d.status = 'INDEXED' + AND d.deleted_at IS NULL AND d.status IN (:statuses) UNION SELECT d.id FROM documents d JOIN collection_documents cd ON cd.document_id = d.id @@ -110,38 +113,41 @@ List findDocumentStatus( JOIN users u ON u.department_id = cp.department_id WHERE cp.target_type = 'DEPARTMENT' AND u.id = :userId AND cp.can_read = true AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) - AND d.deleted_at IS NULL AND d.status = 'INDEXED' + AND d.deleted_at IS NULL AND d.status IN (:statuses) """, nativeQuery = true) - List findReadableDocumentIds(@Param("userId") Long userId); + List findReadableDocumentIds( + @Param("userId") Long userId, + @Param("statuses") Collection statuses + ); - // 검색 pre-filter — 특정 컬렉션 내에서 사용자가 읽을 수 있는 INDEXED 문서 ID + // 검색 pre-filter — 특정 컬렉션 내에서 사용자가 읽을 수 있는 문서 ID @Query(value = """ SELECT sub.id FROM ( SELECT d.id FROM documents d - WHERE d.owner_user_id = :userId AND d.deleted_at IS NULL AND d.status = 'INDEXED' + WHERE d.owner_user_id = :userId AND d.deleted_at IS NULL AND d.status IN (:statuses) UNION SELECT d.id FROM documents d - WHERE d.visibility = 'PUBLIC' AND d.deleted_at IS NULL AND d.status = 'INDEXED' + WHERE d.visibility = 'PUBLIC' AND d.deleted_at IS NULL AND d.status IN (:statuses) UNION SELECT d.id FROM documents d JOIN user_document_access_cache c ON c.document_id = d.id WHERE c.user_id = :userId AND c.can_read = true AND c.invalidated_at IS NULL AND (c.expires_at IS NULL OR c.expires_at > NOW()) - AND d.deleted_at IS NULL AND d.status = 'INDEXED' + AND d.deleted_at IS NULL AND d.status IN (:statuses) UNION SELECT d.id FROM documents d JOIN document_permissions dp ON dp.document_id = d.id JOIN user_roles ur ON ur.role_id = dp.role_id WHERE dp.target_type = 'ROLE' AND ur.user_id = :userId AND dp.can_read = true AND (dp.expires_at IS NULL OR dp.expires_at > NOW()) - AND d.deleted_at IS NULL AND d.status = 'INDEXED' + AND d.deleted_at IS NULL AND d.status IN (:statuses) UNION SELECT d.id FROM documents d JOIN document_permissions dp ON dp.document_id = d.id JOIN users u ON u.department_id = dp.department_id WHERE dp.target_type = 'DEPARTMENT' AND u.id = :userId AND dp.can_read = true AND (dp.expires_at IS NULL OR dp.expires_at > NOW()) - AND d.deleted_at IS NULL AND d.status = 'INDEXED' + AND d.deleted_at IS NULL AND d.status IN (:statuses) UNION SELECT d.id FROM documents d JOIN collection_documents cd ON cd.document_id = d.id @@ -149,7 +155,7 @@ SELECT sub.id FROM ( JOIN user_roles ur ON ur.role_id = cp.role_id WHERE cp.target_type = 'ROLE' AND ur.user_id = :userId AND cp.can_read = true AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) - AND d.deleted_at IS NULL AND d.status = 'INDEXED' + AND d.deleted_at IS NULL AND d.status IN (:statuses) UNION SELECT d.id FROM documents d JOIN collection_documents cd ON cd.document_id = d.id @@ -157,7 +163,7 @@ SELECT sub.id FROM ( JOIN users u ON u.department_id = cp.department_id WHERE cp.target_type = 'DEPARTMENT' AND u.id = :userId AND cp.can_read = true AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) - AND d.deleted_at IS NULL AND d.status = 'INDEXED' + AND d.deleted_at IS NULL AND d.status IN (:statuses) ) sub WHERE sub.id IN ( SELECT cd_filter.document_id FROM collection_documents cd_filter @@ -166,6 +172,24 @@ WHERE sub.id IN ( """, nativeQuery = true) List findReadableDocumentIdsInCollection( @Param("userId") Long userId, - @Param("collectionId") Long collectionId + @Param("collectionId") Long collectionId, + @Param("statuses") Collection statuses ); + + // 목록 화면용 — 권한 pre-filter로 걸러진 ID를 받아 정렬·페이징만 담당한다. + // currentVersion은 LAZY라 버전 번호·상태를 응답에 담으려면 JOIN FETCH가 필요하다. + @Query( + value = """ + SELECT d + FROM Document d + LEFT JOIN FETCH d.currentVersion + WHERE d.id IN :documentIds + """, + countQuery = """ + SELECT COUNT(d) + FROM Document d + WHERE d.id IN :documentIds + """ + ) + Page findAllByIdIn(@Param("documentIds") Collection documentIds, Pageable pageable); } diff --git a/src/main/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryService.java b/src/main/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryService.java index 3b66c53..42659fe 100644 --- a/src/main/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryService.java +++ b/src/main/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryService.java @@ -4,17 +4,25 @@ import java.util.List; import java.util.Set; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.opensource.docgrid.domain.document.converter.DocumentStatusConverter; +import com.opensource.docgrid.domain.document.converter.DocumentSummaryConverter; import com.opensource.docgrid.domain.document.dto.response.DocumentStatusResponse; +import com.opensource.docgrid.domain.document.dto.response.DocumentSummaryResponse; +import com.opensource.docgrid.domain.document.entity.Document; import com.opensource.docgrid.domain.document.enums.DocumentStatus; import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; import com.opensource.docgrid.domain.document.repository.DocumentRepository; import com.opensource.docgrid.domain.document.repository.DocumentStatusProjection; import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; import com.opensource.docgrid.domain.permission.service.query.PermissionQueryService; +import com.opensource.docgrid.global.common.response.PageResponse; import com.opensource.docgrid.global.exception.DocGridException; import com.opensource.docgrid.global.exception.ErrorCode; @@ -37,10 +45,41 @@ public class DocumentQueryService { EmbeddingJobStatus.PENDING, EmbeddingJobStatus.PROCESSING ); + // 목록에는 삭제된 문서만 빼고 모두 노출한다. 인덱싱 중·실패한 문서도 진행 상황 확인 대상이다. + private static final List LISTABLE_STATUSES = EnumSet.complementOf( + EnumSet.of(DocumentStatus.DELETED) + ).stream().map(DocumentStatus::name).toList(); + private static final Sort DOCUMENT_SORT = Sort.by( + Sort.Order.desc("createdAt"), + Sort.Order.desc("id") + ); private final DocumentRepository documentRepository; private final PermissionQueryService permissionQueryService; private final DocumentStatusConverter documentStatusConverter; + private final DocumentSummaryConverter documentSummaryConverter; + + public PageResponse getMyDocuments( + Long userId, + DocumentStatus status, + int page, + int size + ) { + // 1. 검색과 같은 권한 pre-filter로 읽을 수 있는 문서 ID를 먼저 좁힌다. + List statuses = status == null ? LISTABLE_STATUSES : List.of(status.name()); + List readableDocumentIds = documentRepository.findReadableDocumentIds(userId, statuses); + Pageable pageable = PageRequest.of(page, size, DOCUMENT_SORT); + if (readableDocumentIds.isEmpty()) { + return PageResponse.from(Page.empty(pageable), List.of()); + } + + // 2. Entity가 Transaction 밖으로 나가기 전에 DTO로 변환한다. + Page documents = documentRepository.findAllByIdIn(readableDocumentIds, pageable); + List content = documents.getContent().stream() + .map(documentSummaryConverter::toResponse) + .toList(); + return PageResponse.from(documents, content); + } public DocumentStatusResponse getDocumentStatus(Long userId, Long documentId) { if (!permissionQueryService.canReadDocument(userId, documentId)) { diff --git a/src/main/java/com/opensource/docgrid/domain/search/service/query/AccessibleDocumentQueryService.java b/src/main/java/com/opensource/docgrid/domain/search/service/query/AccessibleDocumentQueryService.java index 49647e6..3f21e38 100644 --- a/src/main/java/com/opensource/docgrid/domain/search/service/query/AccessibleDocumentQueryService.java +++ b/src/main/java/com/opensource/docgrid/domain/search/service/query/AccessibleDocumentQueryService.java @@ -5,6 +5,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.opensource.docgrid.domain.document.enums.DocumentStatus; import com.opensource.docgrid.domain.document.repository.DocumentRepository; import lombok.RequiredArgsConstructor; @@ -29,6 +30,9 @@ @RequiredArgsConstructor public class AccessibleDocumentQueryService { + // 검색은 인덱싱이 끝난 문서만 대상으로 한다. 처리 중인 버전은 벡터가 아직 없다. + private static final List SEARCHABLE_STATUSES = List.of(DocumentStatus.INDEXED.name()); + private final DocumentRepository documentRepository; /** @@ -40,8 +44,10 @@ public class AccessibleDocumentQueryService { */ public List findReadableDocumentIds(Long userId, Long collectionId) { if (collectionId != null) { - return documentRepository.findReadableDocumentIdsInCollection(userId, collectionId); + return documentRepository.findReadableDocumentIdsInCollection( + userId, collectionId, SEARCHABLE_STATUSES + ); } - return documentRepository.findReadableDocumentIds(userId); + return documentRepository.findReadableDocumentIds(userId, SEARCHABLE_STATUSES); } } diff --git a/src/test/java/com/opensource/docgrid/domain/document/repository/DocumentReadableIdsRepositoryTest.java b/src/test/java/com/opensource/docgrid/domain/document/repository/DocumentReadableIdsRepositoryTest.java new file mode 100644 index 0000000..797c9f2 --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/document/repository/DocumentReadableIdsRepositoryTest.java @@ -0,0 +1,162 @@ +package com.opensource.docgrid.domain.document.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; + +import com.opensource.docgrid.domain.collection.entity.CollectionDocument; +import com.opensource.docgrid.domain.collection.entity.DocumentCollection; +import com.opensource.docgrid.domain.collection.repository.CollectionDocumentRepository; +import com.opensource.docgrid.domain.collection.repository.CollectionRepository; +import com.opensource.docgrid.domain.document.entity.Document; +import com.opensource.docgrid.domain.document.enums.DocumentSourceType; +import com.opensource.docgrid.domain.document.enums.DocumentStatus; +import com.opensource.docgrid.domain.document.enums.DocumentType; +import com.opensource.docgrid.domain.document.enums.VisibilityType; +import com.opensource.docgrid.domain.user.entity.User; +import com.opensource.docgrid.domain.user.enums.UserStatus; +import com.opensource.docgrid.domain.user.repository.UserRepository; + +import jakarta.persistence.EntityManager; + +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@DisplayName("읽기 가능 문서 ID Repository 테스트") +class DocumentReadableIdsRepositoryTest { + + private static final List INDEXED_ONLY = List.of(DocumentStatus.INDEXED.name()); + private static final List INDEXED_AND_INDEXING = List.of( + DocumentStatus.INDEXED.name(), + DocumentStatus.INDEXING.name() + ); + + @Autowired private DocumentRepository documentRepository; + @Autowired private CollectionRepository collectionRepository; + @Autowired private CollectionDocumentRepository collectionDocumentRepository; + @Autowired private UserRepository userRepository; + @Autowired private EntityManager entityManager; + + // seed 데이터에 PUBLIC·INDEXED 문서가 있어 모든 사용자에게 조회되므로, 이 테스트가 만든 문서만 검증한다. + @Test + @DisplayName("INDEXED만 요청하면 인덱싱 중인 문서는 제외한다") + void findReadableDocumentIds_returnsIndexedOnly_when_indexedStatusIsRequested() { + User owner = saveOwner(); + Document indexed = saveDocument(owner, DocumentStatus.INDEXED); + Document indexing = saveDocument(owner, DocumentStatus.INDEXING); + flushAndClear(); + + List result = documentRepository.findReadableDocumentIds(owner.getId(), INDEXED_ONLY); + + assertThat(result).contains(indexed.getId()).doesNotContain(indexing.getId()); + } + + @Test + @DisplayName("INDEXING을 함께 요청하면 인덱싱 중인 문서도 반환한다") + void findReadableDocumentIds_returnsProcessingDocument_when_indexingStatusIsRequested() { + User owner = saveOwner(); + Document indexed = saveDocument(owner, DocumentStatus.INDEXED); + Document indexing = saveDocument(owner, DocumentStatus.INDEXING); + flushAndClear(); + + List result = documentRepository.findReadableDocumentIds(owner.getId(), INDEXED_AND_INDEXING); + + assertThat(result).contains(indexed.getId(), indexing.getId()); + } + + @Test + @DisplayName("soft delete된 문서는 DELETED 상태를 요청해도 제외한다") + void findReadableDocumentIds_excludesDeletedDocument() { + User owner = saveOwner(); + Document deleted = saveDocument(owner, DocumentStatus.INDEXED); + deleted.markDeleted(LocalDateTime.now()); + flushAndClear(); + + List result = documentRepository.findReadableDocumentIds( + owner.getId(), List.of(DocumentStatus.DELETED.name()) + ); + + assertThat(result).doesNotContain(deleted.getId()); + } + + @Test + @DisplayName("컬렉션 범위 조회도 요청한 상태만 반환한다") + void findReadableDocumentIdsInCollection_appliesStatusFilter() { + User owner = saveOwner(); + Document indexed = saveDocument(owner, DocumentStatus.INDEXED); + Document indexing = saveDocument(owner, DocumentStatus.INDEXING); + DocumentCollection collection = saveCollection(owner); + addToCollection(collection, indexed, owner); + addToCollection(collection, indexing, owner); + flushAndClear(); + + List indexedOnly = documentRepository.findReadableDocumentIdsInCollection( + owner.getId(), collection.getId(), INDEXED_ONLY + ); + List withIndexing = documentRepository.findReadableDocumentIdsInCollection( + owner.getId(), collection.getId(), INDEXED_AND_INDEXING + ); + + assertThat(indexedOnly).containsExactly(indexed.getId()); + assertThat(withIndexing).containsExactlyInAnyOrder(indexed.getId(), indexing.getId()); + } + + private User saveOwner() { + return userRepository.save( + User.builder() + .email("readable-ids-" + UUID.randomUUID() + "@test.com") + .passwordHash("hash") + .name("읽기 가능 문서 테스트 사용자") + .status(UserStatus.ACTIVE) + .build() + ); + } + + private Document saveDocument(User owner, DocumentStatus status) { + return documentRepository.save( + Document.builder() + .owner(owner) + .title("읽기 가능 문서 테스트") + .documentType(DocumentType.TXT) + .sourceType(DocumentSourceType.UPLOAD) + .status(status) + .visibility(VisibilityType.PRIVATE) + .build() + ); + } + + private DocumentCollection saveCollection(User owner) { + return collectionRepository.save( + DocumentCollection.builder() + .owner(owner) + .name("읽기 가능 문서 테스트 컬렉션") + .visibility(VisibilityType.PRIVATE) + .build() + ); + } + + private void addToCollection(DocumentCollection collection, Document document, User addedBy) { + collectionDocumentRepository.save( + CollectionDocument.builder() + .collection(collection) + .document(document) + .addedBy(addedBy) + .addedAt(LocalDateTime.now()) + .build() + ); + } + + private void flushAndClear() { + entityManager.flush(); + entityManager.clear(); + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryServiceTest.java b/src/test/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryServiceTest.java index 23767af..6a3ad0e 100644 --- a/src/test/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryServiceTest.java +++ b/src/test/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryServiceTest.java @@ -5,25 +5,37 @@ import static org.mockito.ArgumentMatchers.anyCollection; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import java.util.Collection; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import com.opensource.docgrid.domain.document.converter.DocumentStatusConverter; +import com.opensource.docgrid.domain.document.converter.DocumentSummaryConverter; import com.opensource.docgrid.domain.document.dto.response.DocumentStatusResponse; +import com.opensource.docgrid.domain.document.dto.response.DocumentSummaryResponse; +import com.opensource.docgrid.domain.document.entity.Document; import com.opensource.docgrid.domain.document.enums.DocumentStatus; +import com.opensource.docgrid.domain.document.enums.DocumentType; import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; +import com.opensource.docgrid.domain.document.enums.VisibilityType; import com.opensource.docgrid.domain.document.repository.DocumentRepository; import com.opensource.docgrid.domain.document.repository.DocumentStatusProjection; import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; import com.opensource.docgrid.domain.permission.service.query.PermissionQueryService; +import com.opensource.docgrid.global.common.response.PageResponse; import com.opensource.docgrid.global.exception.DocGridException; import com.opensource.docgrid.global.exception.ErrorCode; @@ -40,8 +52,78 @@ class DocumentQueryServiceTest { @Mock private DocumentRepository documentRepository; @Mock private PermissionQueryService permissionQueryService; @Mock private DocumentStatusConverter documentStatusConverter; + @Mock private DocumentSummaryConverter documentSummaryConverter; @Mock private DocumentStatusProjection projection; + @Test + @DisplayName("읽을 수 있는 문서를 페이지 응답으로 변환해 반환한다") + void getMyDocuments_returnsPage_when_readableDocumentsExist() { + Document document = mock(Document.class); + DocumentSummaryResponse expected = summaryResponse(); + given(documentRepository.findReadableDocumentIds( + org.mockito.ArgumentMatchers.eq(USER_ID), anyCollection() + )).willReturn(List.of(DOCUMENT_ID)); + given(documentRepository.findAllByIdIn( + org.mockito.ArgumentMatchers.eq(List.of(DOCUMENT_ID)), + org.mockito.ArgumentMatchers.any(Pageable.class) + )).willReturn(new PageImpl<>(List.of(document), PageRequest.of(0, 20), 1)); + given(documentSummaryConverter.toResponse(document)).willReturn(expected); + + PageResponse result = service.getMyDocuments(USER_ID, null, 0, 20); + + assertThat(result.content()).containsExactly(expected); + assertThat(result.totalElements()).isEqualTo(1); + assertThat(result.first()).isTrue(); + } + + @Test + @DisplayName("읽을 수 있는 문서가 없으면 문서를 조회하지 않고 빈 페이지를 반환한다") + void getMyDocuments_returnsEmptyPage_when_noReadableDocument() { + given(documentRepository.findReadableDocumentIds( + org.mockito.ArgumentMatchers.eq(USER_ID), anyCollection() + )).willReturn(List.of()); + + PageResponse result = service.getMyDocuments(USER_ID, null, 0, 20); + + assertThat(result.content()).isEmpty(); + assertThat(result.totalElements()).isZero(); + then(documentRepository).should(never()).findAllByIdIn(anyCollection(), org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("status를 지정하지 않으면 DELETED를 제외한 전체 상태로 조회한다") + void getMyDocuments_excludesDeletedStatus_when_statusIsNotGiven() { + ArgumentCaptor> captor = ArgumentCaptor.captor(); + given(documentRepository.findReadableDocumentIds( + org.mockito.ArgumentMatchers.eq(USER_ID), anyCollection() + )).willReturn(List.of()); + + service.getMyDocuments(USER_ID, null, 0, 20); + + then(documentRepository).should().findReadableDocumentIds( + org.mockito.ArgumentMatchers.eq(USER_ID), captor.capture() + ); + assertThat(captor.getValue()) + .contains(DocumentStatus.INDEXED.name(), DocumentStatus.INDEXING.name(), DocumentStatus.FAILED.name()) + .doesNotContain(DocumentStatus.DELETED.name()); + } + + @Test + @DisplayName("status를 지정하면 해당 상태만으로 조회한다") + void getMyDocuments_usesGivenStatus_when_statusIsGiven() { + ArgumentCaptor> captor = ArgumentCaptor.captor(); + given(documentRepository.findReadableDocumentIds( + org.mockito.ArgumentMatchers.eq(USER_ID), anyCollection() + )).willReturn(List.of()); + + service.getMyDocuments(USER_ID, DocumentStatus.FAILED, 0, 20); + + then(documentRepository).should().findReadableDocumentIds( + org.mockito.ArgumentMatchers.eq(USER_ID), captor.capture() + ); + assertThat(captor.getValue()).containsExactly(DocumentStatus.FAILED.name()); + } + @Test @DisplayName("읽기 권한이 있고 상태가 일관되면 문서 상태를 반환한다") void getDocumentStatus_returnsResponse_when_statusIsConsistent() { @@ -139,4 +221,20 @@ void getDocumentStatus_throws_when_documentIsDeleted() { .isInstanceOf(DocGridException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.DOCUMENT_NOT_FOUND); } + + private DocumentSummaryResponse summaryResponse() { + return new DocumentSummaryResponse( + DOCUMENT_ID, + "문서 목록 테스트", + null, + DocumentType.TXT, + DocumentStatus.INDEXED, + VisibilityType.PRIVATE, + USER_ID, + 1, + DocumentVersionStatus.INDEXED, + null, + null + ); + } } diff --git a/src/test/java/com/opensource/docgrid/domain/embedding/integration/DocumentIndexingCompletionIntegrationTest.java b/src/test/java/com/opensource/docgrid/domain/embedding/integration/DocumentIndexingCompletionIntegrationTest.java index a5f6acb..3dad7a8 100644 --- a/src/test/java/com/opensource/docgrid/domain/embedding/integration/DocumentIndexingCompletionIntegrationTest.java +++ b/src/test/java/com/opensource/docgrid/domain/embedding/integration/DocumentIndexingCompletionIntegrationTest.java @@ -94,7 +94,7 @@ void dropSchema() { void completeFirstVersion_makesDocumentSearchable() { ExecutionContext context = insertFirstVersionExecution(); - assertThat(documentRepository.findReadableDocumentIds(context.userId())).isEmpty(); + assertThat(documentRepository.findReadableDocumentIds(context.userId(), List.of("INDEXED"))).isEmpty(); assertThat(search(context)).isEmpty(); DocumentIndexingCompletionResponse response = completionService.complete( @@ -106,7 +106,7 @@ void completeFirstVersion_makesDocumentSearchable() { assertThat(response.jobStatus().name()).isEqualTo("INDEXED"); assertThat(response.attemptStatus().name()).isEqualTo("SUCCESS"); assertThat(response.versionStatus().name()).isEqualTo("INDEXED"); - assertThat(documentRepository.findReadableDocumentIds(context.userId())) + assertThat(documentRepository.findReadableDocumentIds(context.userId(), List.of("INDEXED"))) .containsExactly(context.documentId()); assertThat(search(context)) .extracting(VectorSearchCandidate::chunkText) diff --git a/src/test/java/com/opensource/docgrid/domain/search/service/query/AccessibleDocumentQueryServiceTest.java b/src/test/java/com/opensource/docgrid/domain/search/service/query/AccessibleDocumentQueryServiceTest.java index fd49224..4fdd9a1 100644 --- a/src/test/java/com/opensource/docgrid/domain/search/service/query/AccessibleDocumentQueryServiceTest.java +++ b/src/test/java/com/opensource/docgrid/domain/search/service/query/AccessibleDocumentQueryServiceTest.java @@ -28,17 +28,18 @@ class AccessibleDocumentQueryServiceTest { private static final Long USER_ID = 1L; private static final Long COLLECTION_ID = 10L; + private static final List INDEXED_ONLY = List.of("INDEXED"); @Test @DisplayName("collectionId가 null이면 전체 범위 쿼리를 호출하고 결과를 반환한다") void findReadableDocumentIds_withoutCollection_callsGlobalQuery() { List expected = List.of(1L, 2L, 3L); - given(documentRepository.findReadableDocumentIds(USER_ID)).willReturn(expected); + given(documentRepository.findReadableDocumentIds(USER_ID, INDEXED_ONLY)).willReturn(expected); List result = accessibleDocumentQueryService.findReadableDocumentIds(USER_ID, null); assertThat(result).isEqualTo(expected); - then(documentRepository).should(times(1)).findReadableDocumentIds(USER_ID); + then(documentRepository).should(times(1)).findReadableDocumentIds(USER_ID, INDEXED_ONLY); then(documentRepository).shouldHaveNoMoreInteractions(); } @@ -46,19 +47,19 @@ void findReadableDocumentIds_withoutCollection_callsGlobalQuery() { @DisplayName("collectionId가 있으면 컬렉션 범위 쿼리를 호출하고 결과를 반환한다") void findReadableDocumentIds_withCollection_callsCollectionQuery() { List expected = List.of(2L, 3L); - given(documentRepository.findReadableDocumentIdsInCollection(USER_ID, COLLECTION_ID)).willReturn(expected); + given(documentRepository.findReadableDocumentIdsInCollection(USER_ID, COLLECTION_ID, INDEXED_ONLY)).willReturn(expected); List result = accessibleDocumentQueryService.findReadableDocumentIds(USER_ID, COLLECTION_ID); assertThat(result).isEqualTo(expected); - then(documentRepository).should(times(1)).findReadableDocumentIdsInCollection(USER_ID, COLLECTION_ID); + then(documentRepository).should(times(1)).findReadableDocumentIdsInCollection(USER_ID, COLLECTION_ID, INDEXED_ONLY); then(documentRepository).shouldHaveNoMoreInteractions(); } @Test @DisplayName("접근 가능한 문서가 없으면 빈 목록을 반환한다") void findReadableDocumentIds_noAccessible_returnsEmptyList() { - given(documentRepository.findReadableDocumentIds(USER_ID)).willReturn(List.of()); + given(documentRepository.findReadableDocumentIds(USER_ID, INDEXED_ONLY)).willReturn(List.of()); List result = accessibleDocumentQueryService.findReadableDocumentIds(USER_ID, null); @@ -68,7 +69,7 @@ void findReadableDocumentIds_noAccessible_returnsEmptyList() { @Test @DisplayName("컬렉션 범위에서 접근 가능한 문서가 없으면 빈 목록을 반환한다") void findReadableDocumentIds_noAccessibleInCollection_returnsEmptyList() { - given(documentRepository.findReadableDocumentIdsInCollection(USER_ID, COLLECTION_ID)).willReturn(List.of()); + given(documentRepository.findReadableDocumentIdsInCollection(USER_ID, COLLECTION_ID, INDEXED_ONLY)).willReturn(List.of()); List result = accessibleDocumentQueryService.findReadableDocumentIds(USER_ID, COLLECTION_ID);