Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
package com.opensource.docgrid.domain.document.controller;

import java.nio.charset.StandardCharsets;

import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.InvalidMediaTypeException;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
Expand All @@ -9,9 +16,13 @@
import org.springframework.web.bind.annotation.RestController;

import com.opensource.docgrid.domain.auth.annotation.CurrentUser;
import com.opensource.docgrid.domain.document.dto.response.DocumentContentResponse;
import com.opensource.docgrid.domain.document.dto.response.DocumentDetailResponse;
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.DocumentFileDownload;
import com.opensource.docgrid.domain.document.service.DocumentFileService;
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;
Expand All @@ -22,6 +33,7 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Pattern;
import lombok.RequiredArgsConstructor;

@Tag(name = "Document", description = "문서 관련 API")
Expand All @@ -32,6 +44,7 @@
public class DocumentQueryController {

private final DocumentQueryService documentQueryService;
private final DocumentFileService documentFileService;

@Operation(
summary = "내 문서 목록 조회",
Expand All @@ -50,6 +63,59 @@ public ResponseEntity<ApiResponse<PageResponse<DocumentSummaryResponse>>> getMyD
return ResponseUtils.ok(documentQueryService.getMyDocuments(userId, status, page, size));
}

@Operation(
summary = "문서 상세 조회",
description = "문서 Metadata, 소유자와 현재 버전 정보를 조회합니다. 추출 본문과 원본 파일은 포함하지 않습니다. "
+ "문서 읽기 권한이 필요하며 삭제된 문서는 조회할 수 없습니다."
)
@GetMapping("/{documentId}")
public ResponseEntity<ApiResponse<DocumentDetailResponse>> getDocumentDetail(
@PathVariable Long documentId,
@Parameter(hidden = true) @CurrentUser Long userId
) {
return ResponseUtils.ok(documentQueryService.getDocumentDetail(userId, documentId));
}

@Operation(
summary = "문서 추출 본문 조회",
description = "현재 버전의 Chunk 중복을 제거하고 페이지·섹션 순서대로 복원한 정규화 Text 전체를 반환합니다. "
+ "원본 PDF·DOCX의 Layout, Image와 Font는 포함하지 않으며 문서 읽기 권한이 필요합니다."
)
@GetMapping("/{documentId}/content")
public ResponseEntity<ApiResponse<DocumentContentResponse>> getDocumentContent(
@PathVariable Long documentId,
@Parameter(hidden = true) @CurrentUser Long userId
) {
return ResponseUtils.ok(documentQueryService.getDocumentContent(userId, documentId));
}

@Operation(
summary = "문서 원본 파일 조회",
description = "현재 버전의 원본 PDF·DOCX·TXT 파일을 반환합니다. disposition은 inline 또는 attachment이며 "
+ "기본값 inline은 브라우저 표시, attachment는 다운로드에 사용합니다. 문서 읽기 권한이 필요합니다."
)
@GetMapping("/{documentId}/file")
public ResponseEntity<byte[]> getDocumentFile(
@PathVariable Long documentId,
@RequestParam(defaultValue = "inline")
@Pattern(regexp = "inline|attachment", message = "disposition은 inline 또는 attachment여야 합니다.")
String disposition,
@Parameter(hidden = true) @CurrentUser Long userId
) {
// 1. Service가 권한을 검증하고 현재 버전의 원본 Byte와 안전한 파일 Metadata를 반환한다.
DocumentFileDownload download = documentFileService.getDocumentFile(userId, documentId);

// 2. 저장소 내부 위치는 숨기고 브라우저 표시·다운로드에 필요한 표준 Header만 설정한다.
HttpHeaders headers = new HttpHeaders();
headers.setContentType(resolveMediaType(download.contentType()));
headers.setContentLength(download.fileSize());
headers.setContentDisposition(ContentDisposition.builder(disposition)
.filename(download.originalFilename(), StandardCharsets.UTF_8)
.build());
headers.setCacheControl("no-store");
return ResponseEntity.ok().headers(headers).body(download.content());
}

@Operation(
summary = "문서 인덱싱 상태 조회",
description = "현재 검색 가능한 INDEXED 버전과 처리 중인 버전 및 임베딩 작업 상태를 함께 조회합니다. "
Expand All @@ -62,4 +128,15 @@ public ResponseEntity<ApiResponse<DocumentStatusResponse>> getDocumentStatus(
) {
return ResponseUtils.ok(documentQueryService.getDocumentStatus(userId, documentId));
}

private MediaType resolveMediaType(String contentType) {
if (!StringUtils.hasText(contentType)) {
return MediaType.APPLICATION_OCTET_STREAM;
}
try {
return MediaType.parseMediaType(contentType);
} catch (InvalidMediaTypeException exception) {
return MediaType.APPLICATION_OCTET_STREAM;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.opensource.docgrid.domain.document.converter;

import org.springframework.stereotype.Component;

import com.opensource.docgrid.domain.document.dto.response.CurrentDocumentVersionResponse;
import com.opensource.docgrid.domain.document.dto.response.DocumentDetailResponse;
import com.opensource.docgrid.domain.document.entity.Document;
import com.opensource.docgrid.domain.document.entity.DocumentVersion;
import com.opensource.docgrid.domain.document.entity.FileObject;

/**
* 문서 Entity와 현재 버전 연관관계를 외부 상세 응답 DTO로 변환한다.
* 저장소 Bucket과 Object Key 같은 내부 파일 위치는 변환 경계 밖으로 노출하지 않는다.
*/
@Component
public class DocumentDetailConverter {

public DocumentDetailResponse toResponse(Document document, boolean contentAvailable) {
return new DocumentDetailResponse(
document.getId(),
document.getTitle(),
document.getDescription(),
document.getDocumentType(),
document.getSourceType(),
document.getStatus(),
document.getVisibility(),
document.getOwner().getId(),
document.getOwner().getName(),
toCurrentVersionResponse(document.getCurrentVersion()),
contentAvailable,
document.getCreatedAt(),
document.getUpdatedAt()
);
}

private CurrentDocumentVersionResponse toCurrentVersionResponse(DocumentVersion documentVersion) {
if (documentVersion == null) {
return null;
}

FileObject fileObject = documentVersion.getFileObject();
return new CurrentDocumentVersionResponse(
documentVersion.getId(),
documentVersion.getVersionNo(),
documentVersion.getStatus(),
documentVersion.getOriginalFilename() != null
? documentVersion.getOriginalFilename()
: fileObject != null ? fileObject.getOriginalFilename() : null,
documentVersion.getContentType() != null
? documentVersion.getContentType()
: fileObject != null ? fileObject.getContentType() : null,
fileObject != null ? fileObject.getFileSize() : null,
documentVersion.getIndexedAt(),
documentVersion.getCreatedAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.opensource.docgrid.domain.document.dto.response;

import java.time.LocalDateTime;

import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus;

import io.swagger.v3.oas.annotations.media.Schema;

/**
* 문서 상세 응답에서 현재 버전의 식별 정보와 원본 파일 Metadata만 노출한다.
* 처리 중인 별도 버전이나 저장소 내부 위치는 이 응답의 책임 범위에 포함하지 않는다.
*/
@Schema(description = "문서의 현재 버전 정보")
public record CurrentDocumentVersionResponse(
@Schema(description = "문서 버전 ID") Long documentVersionId,
@Schema(description = "버전 번호") int versionNo,
@Schema(description = "버전 상태") DocumentVersionStatus status,
@Schema(description = "원본 파일명") String originalFilename,
@Schema(description = "원본 파일 Content-Type") String contentType,
@Schema(description = "원본 파일 크기(Byte)") Long fileSize,
@Schema(description = "인덱싱 완료 시각") LocalDateTime indexedAt,
@Schema(description = "버전 생성 시각") LocalDateTime createdAt
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.opensource.docgrid.domain.document.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;

/**
* 현재 문서 버전의 Chunk 중복을 제거해 복원한 정규화 Text 전체를 반환한다.
* 원본 PDF·DOCX의 Binary, Layout, Image와 Font 정보는 이 응답에 포함하지 않는다.
*/
@Schema(description = "문서에서 추출한 전체 텍스트")
public record DocumentContentResponse(
@Schema(description = "문서 ID") Long documentId,
@Schema(description = "본문을 복원한 문서 버전 ID") Long documentVersionId,
@Schema(description = "본문을 복원한 버전 번호") int versionNo,
@Schema(description = "중복을 제거하고 원래 순서로 복원한 전체 텍스트") String content,
@Schema(description = "본문 복원에 사용한 Chunk 수") int chunkCount
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.opensource.docgrid.domain.document.dto.response;

import java.time.LocalDateTime;

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 io.swagger.v3.oas.annotations.media.Schema;

/**
* 문서 상세 화면에 필요한 Metadata와 현재 버전 요약을 반환한다.
* 추출 본문과 원본 파일 Byte는 별도 API 경계로 분리해 이 응답에 포함하지 않는다.
*/
@Schema(description = "문서 상세 정보")
public record DocumentDetailResponse(
@Schema(description = "문서 ID") Long documentId,
@Schema(description = "문서 제목") String title,
@Schema(description = "문서 설명") String description,
@Schema(description = "문서 형식") DocumentType documentType,
@Schema(description = "문서 생성 출처") DocumentSourceType sourceType,
@Schema(description = "문서 상태") DocumentStatus status,
@Schema(description = "공개 범위") VisibilityType visibility,
@Schema(description = "소유자 사용자 ID") Long ownerUserId,
@Schema(description = "소유자 이름") String ownerName,
@Schema(description = "현재 버전 정보, 현재 버전이 없으면 null") CurrentDocumentVersionResponse currentVersion,
@Schema(description = "현재 버전의 추출 본문 조회 가능 여부") boolean contentAvailable,
@Schema(description = "문서 생성 시각") LocalDateTime createdAt,
@Schema(description = "문서 마지막 수정 시각") LocalDateTime updatedAt
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,22 @@ public interface DocumentRepository extends JpaRepository<Document, Long> {
@Query("SELECT d FROM Document d LEFT JOIN FETCH d.currentVersion WHERE d.id = :documentId")
Optional<Document> findByIdWithCurrentVersion(@Param("documentId") Long documentId);

/**
* 문서 상세·본문·원본 파일 조회에 필요한 소유자, 현재 버전과 파일 정보를 한 번에 조회한다.
*
* @param documentId 조회할 문서 식별자
* @return 상세 조회에 필요한 연관관계가 초기화된 문서
*/
@Query("""
SELECT d
FROM Document d
JOIN FETCH d.owner
LEFT JOIN FETCH d.currentVersion currentVersion
LEFT JOIN FETCH currentVersion.fileObject
WHERE d.id = :documentId
""")
Optional<Document> findByIdWithDetail(@Param("documentId") Long documentId);

@Query("""
SELECT d.id AS documentId,
d.status AS documentStatus,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.opensource.docgrid.domain.document.service;

/**
* 권한 검증을 마친 문서 원본 Byte와 HTTP 응답에 필요한 파일 Metadata를 함께 전달한다.
* Content-Disposition 선택은 HTTP 계층의 책임이므로 이 값에 포함하지 않는다.
*/
public record DocumentFileDownload(
byte[] content,
String originalFilename,
String contentType,
long fileSize
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.opensource.docgrid.domain.document.service;

import org.springframework.stereotype.Service;

import com.opensource.docgrid.domain.document.service.query.DocumentFileSnapshot;
import com.opensource.docgrid.domain.document.service.query.DocumentQueryService;
import com.opensource.docgrid.domain.document.storage.FileStorageService;
import com.opensource.docgrid.global.exception.DocGridException;
import com.opensource.docgrid.global.exception.ErrorCode;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

/**
* 문서 원본 파일 조회 Snapshot과 Object Storage 읽기를 Transaction 밖에서 조정한다.
* 권한 및 현재 버전 선택은 Query Service에 위임하고 HTTP Header 조립은 Controller에 맡긴다.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class DocumentFileService {

private final DocumentQueryService documentQueryService;
private final FileStorageService fileStorageService;

public DocumentFileDownload getDocumentFile(Long userId, Long documentId) {
// 1. 짧은 DB Transaction에서 권한을 확인하고 현재 버전의 파일 위치를 Snapshot으로 고정한다.
DocumentFileSnapshot snapshot = documentQueryService.getDocumentFileSnapshot(userId, documentId);

// 2. DB Transaction이 끝난 뒤 원본 전체를 읽어 Controller가 소유할 수 있는 Byte 배열로 반환한다.
byte[] content = fileStorageService.read(snapshot.storedFile());
if (content.length != snapshot.fileSize()) {
log.error("원본 파일 크기가 Metadata와 일치하지 않습니다. expected={}, actual={}",
snapshot.fileSize(), content.length);
throw new DocGridException(ErrorCode.FILE_STORAGE_FAILED);
}
return new DocumentFileDownload(
content,
snapshot.originalFilename(),
snapshot.contentType(),
snapshot.fileSize()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.opensource.docgrid.domain.document.service.query;

import com.opensource.docgrid.domain.document.storage.StoredFile;

/**
* 원본 파일을 읽기 전에 짧은 DB Transaction에서 확정한 저장 위치와 응답 Metadata다.
* JPA Entity를 외부 저장소 I/O 구간으로 전달하지 않는 경계 역할만 담당한다.
*/
public record DocumentFileSnapshot(
StoredFile storedFile,
String originalFilename,
String contentType,
long fileSize
) {
}
Loading