-
Notifications
You must be signed in to change notification settings - Fork 1
[Feat] 컬렉션 트리(하위 컬렉션) 지원 + 관련 권한 개선 #236
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 10 commits
975d0c8
40dc5ac
5e93f6f
36056a9
29a9aae
a715b9f
644380f
7c934e8
164e163
b0833c2
d4c5fbd
a14d8cb
9861e4d
ac5e034
f88be13
3799f39
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -53,7 +53,11 @@ public CollectionResponse createCollection(Long userId, CreateCollectionRequest | |
| DocumentCollection parentCollection = null; // 상위 폴더 지정은 선택 사항이라 null로 초기화 | ||
| if (request.parentCollectionId() != null) { | ||
| parentCollection = collectionRepository.findById(request.parentCollectionId()) | ||
| .filter(c -> c.getStatus() != CollectionStatus.DELETED) | ||
| .orElseThrow(() -> new DocGridException(ErrorCode.COLLECTION_NOT_FOUND)); | ||
| if (!permissionQueryService.canWriteCollection(userId, parentCollection)) { | ||
| throw new DocGridException(ErrorCode.PERMISSION_DENIED); | ||
| } | ||
| } | ||
|
|
||
| VisibilityType visibility = request.visibility() != null ? request.visibility() : VisibilityType.PRIVATE; | ||
|
|
@@ -102,25 +106,34 @@ public CollectionDocumentResponse addDocument(Long collectionId, Long userId, Ad | |
| return collectionConverter.toDocumentResponse(collectionDocument); | ||
| } | ||
|
|
||
| // 컬렉션 soft delete — 소유자만 가능 | ||
| // 컬렉션 soft delete — 소유자만 가능. 하위 컬렉션 전체와 그 안의 문서 매핑까지 cascade로 함께 삭제한다. | ||
| // owner 체크는 삭제 대상 최상위(root)에서만 하고 하위 각각은 재확인하지 않는다 | ||
| // (구글드라이브 공유폴더 삭제와 동일한 멘탈모델 — root에 대한 권한으로 하위 전체가 지워짐). | ||
| public void deleteCollection(Long collectionId, Long userId) { | ||
| DocumentCollection collection = collectionRepository.findById(collectionId) | ||
| DocumentCollection root = collectionRepository.findById(collectionId) | ||
| .filter(c -> c.getStatus() != CollectionStatus.DELETED) | ||
| .orElseThrow(() -> new DocGridException(ErrorCode.COLLECTION_NOT_FOUND)); | ||
|
|
||
| if (!collection.getOwner().getId().equals(userId)) { | ||
| if (!root.getOwner().getId().equals(userId)) { | ||
| throw new DocGridException(ErrorCode.PERMISSION_DENIED); | ||
| } | ||
|
|
||
| // 폴더에 속한 모든 권한 삭제 및 캐시 무효화 | ||
| List<CollectionPermission> permissions = collectionPermissionRepository.findAllByCollectionId(collectionId); | ||
| List<Long> targetIds = collectionRepository.findDescendantIdsInclusive(collectionId); // 자기 자신 포함 | ||
|
|
||
| // 대상 전체(자기 자신+하위)에 속한 권한 삭제 및 캐시 무효화 | ||
| List<CollectionPermission> permissions = collectionPermissionRepository.findAllByCollectionIdIn(targetIds); | ||
| permissions.stream() | ||
| .filter(p -> p.getTargetType() == PermissionTargetType.USER) | ||
| // 컬렉션 권한이 USER 대상인 경우에만 캐시 무효화 | ||
| .forEach(p -> cacheService.bulkRevokeBySource(AccessSourceType.DIRECT_COLLECTION_PERMISSION, p.getId())); | ||
| collectionPermissionRepository.deleteAll(permissions); // 컬렉션 권한 삭제 | ||
|
|
||
| collection.markDeleted(LocalDateTime.now()); // 폴더 상태를 DELETED로 변경 | ||
| // 대상 전체(자기 자신+하위)의 문서 매핑 삭제 | ||
| List<CollectionDocument> mappings = collectionDocumentRepository.findAllByCollectionIdIn(targetIds); | ||
| collectionDocumentRepository.deleteAll(mappings); | ||
|
|
||
| LocalDateTime now = LocalDateTime.now(); | ||
| collectionRepository.findAllById(targetIds).forEach(c -> c.markDeleted(now)); // 대상 전체 상태를 DELETED로 변경 | ||
|
Comment on lines
+121
to
+136
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift 삭제와 하위 생성 요청을 직렬화하세요. Line 121은 삭제 시작 시점의 후손 ID만 조회합니다. 이 조회 뒤에 다른 사용자가 기존 하위 컬렉션 아래에 새 컬렉션을 생성하면, 새 컬렉션은 생성과 삭제가 같은 계층 잠금 규약을 사용하게 하세요. 생성 시 부모와 조상 체인을 잠근 뒤 상태와 쓰기 권한을 확인하세요. 삭제 시 root를 잠근 뒤 후손을 조회하고 삭제하세요. 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| // 컬렉션에서 문서 제거 — 소유자만 가능 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,6 +39,10 @@ public class CollectionQueryService { | |
| Sort.Order.desc("addedAt"), | ||
| Sort.Order.desc("id") | ||
| ); | ||
| private static final Sort COLLECTION_SORT = Sort.by( | ||
| Sort.Order.desc("createdAt"), | ||
| Sort.Order.desc("id") | ||
| ); | ||
|
|
||
| private final CollectionRepository collectionRepository; | ||
| private final CollectionDocumentRepository collectionDocumentRepository; | ||
|
|
@@ -57,10 +61,37 @@ public CollectionResponse getCollection(Long userId, Long collectionId) { | |
| return collectionConverter.toResponse(collection); | ||
| } | ||
|
|
||
| // 내 컬렉션 목록 조회 (ACTIVE 상태만) | ||
| public List<CollectionResponse> getMyCollections(Long userId) { | ||
| return collectionRepository.findAllByOwnerIdAndStatus(userId, CollectionStatus.ACTIVE) | ||
| /** | ||
| * 사용자가 읽을 수 있는 컬렉션 목록 페이지 조회 (owner + PUBLIC + 권한부여 + 부모 상속, ACTIVE만). | ||
| * keyword가 있으면 이름·설명 부분일치로도 필터링한다. | ||
| */ | ||
| public PageResponse<CollectionResponse> getCollections(Long userId, String keyword, int page, int size) { | ||
| Pageable pageable = PageRequest.of(page, size, COLLECTION_SORT); | ||
| List<Long> readableIds = collectionRepository.findReadableCollectionIds(userId, keyword); | ||
| if (readableIds.isEmpty()) { | ||
| return PageResponse.from(Page.empty(pageable), List.of()); | ||
| } | ||
|
|
||
| Page<DocumentCollection> collections = collectionRepository.findAllByIdIn(readableIds, pageable); | ||
| List<CollectionResponse> content = collections.getContent().stream() | ||
| .map(collectionConverter::toResponse) | ||
| .toList(); | ||
| return PageResponse.from(collections, content); | ||
| } | ||
|
|
||
| // 직계 자식 컬렉션 목록 조회 — 부모 읽기 권한 확인 후, 자식 각각의 읽기 권한도 확인 | ||
| // (자식 owner/visibility가 부모와 다를 수 있으므로 부모 권한만으로 자식을 노출하면 안 됨) | ||
| public List<CollectionResponse> getChildren(Long userId, Long collectionId) { | ||
| DocumentCollection parent = collectionRepository.findById(collectionId) | ||
| .filter(c -> c.getStatus() != CollectionStatus.DELETED) | ||
| .orElseThrow(() -> new DocGridException(ErrorCode.COLLECTION_NOT_FOUND)); | ||
| if (!permissionQueryService.canReadCollection(userId, parent)) { | ||
| throw new DocGridException(ErrorCode.PERMISSION_DENIED); | ||
| } | ||
|
|
||
| return collectionRepository.findAllByParentCollectionIdAndStatus(collectionId, CollectionStatus.ACTIVE) | ||
| .stream() | ||
| .filter(child -> permissionQueryService.canReadCollection(userId, child)) | ||
| .map(collectionConverter::toResponse) | ||
|
Comment on lines
+92
to
95
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift 직계 자식 권한 필터를 페이지 단위 SQL 조회로 변경하십시오. 현재 구현은 모든 ACTIVE 직계 자식을 가져온 뒤 자식마다 권한 조건을 저장소 조회에 포함하고 페이지네이션을 적용하십시오. 응답은 현재처럼 직계 자식만 반환해야 합니다. 🤖 Prompt for AI Agents |
||
| .toList(); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift
권한 ID 전체를 먼저 물질화하지 마십시오.
findReadableCollectionIds는 페이지와 무관하게 읽기 가능한 모든 ID를 반환합니다. 이후CollectionQueryService.getCollections가 이 전체 목록을IN :ids로 다시 조회합니다. 컬렉션 수가 증가하면 메모리 사용량, SQL 바인드 수, 재귀 CTE 비용이 페이지 크기와 무관하게 증가합니다.권한 필터, 정렬, 페이지네이션, count 쿼리를 데이터베이스에서 한 번에 수행하는
Page조회로 변경하십시오.🤖 Prompt for AI Agents