From 20d5562bed8b8b66cd90691a307e68dc0eff1095 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 11 Jul 2026 16:42:46 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8F=99=EC=A0=95=20=EB=8F=95=EA=B8=B0?= =?UTF-8?q?=20=EC=95=88=20=EB=B0=9B=EA=B8=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BirdIdRequestHistoryRecorder.java | 18 ++- .../dto/request/CreateCollectionRequest.java | 3 + .../dto/request/UpdateCollectionRequest.java | 3 + .../response/GetCollectionDetailResponse.java | 3 + .../GetCollectionEditDataResponse.java | 3 + .../response/UpdateCollectionResponse.java | 5 +- .../BirdIdSuggestionCommandService.java | 9 ++ .../application/CollectionCommandService.java | 28 ++-- .../dto/CreateCollectionCommand.java | 3 +- .../dto/UpdateCollectionCommand.java | 3 +- .../core/entity/UserBirdCollection.java | 17 ++- .../core/repository/CollectionRepository.java | 4 +- .../mapper/CollectionWebMapper.java | 1 + .../dto/common/CommunityCollectionInfo.java | 3 + .../application/CommunityDataAssembler.java | 4 +- .../core/repository/CommunityRepository.java | 4 +- .../community/mapper/CommunityWebMapper.java | 1 + ...rd_id_suggestion_enabled_to_collection.sql | 2 + .../BirdIdSuggestionCommandServiceTest.java | 18 +++ .../CollectionCommandServiceTest.java | 136 +++++++++++++++++- .../CommunityQueryServiceTest.java | 6 +- .../repository/CommunityRepositoryTest.java | 4 + 22 files changed, 243 insertions(+), 35 deletions(-) create mode 100644 src/main/resources/db/migration/V95__add_bird_id_suggestion_enabled_to_collection.sql diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/BirdIdRequestHistoryRecorder.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/BirdIdRequestHistoryRecorder.java index e346ee9f..d80803a3 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/BirdIdRequestHistoryRecorder.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/BirdIdRequestHistoryRecorder.java @@ -23,12 +23,22 @@ public class BirdIdRequestHistoryRecorder { /** 컬렉션 생성 직후, bird가 비어있고 PUBLIC인 경우 pending 시작을 기록 */ public void onCollectionCreatedIfPending(UserBirdCollection collection, OffsetDateTime startedAt) { - if (collection.getBird() != null) return; - if (collection.getAccessLevel() != PUBLIC) return; + if (!collection.canReceiveBirdIdSuggestions()) return; if (repo.findOpenByCollectionId(collection.getId()).isPresent()) return; repo.save(BirdIdRequestHistory.start(collection, startedAt)); } + /** 현재 컬렉션 상태를 기준으로 열린 동정 요청 이력을 생성하거나 취소 */ + public void syncOpenState(UserBirdCollection collection, OffsetDateTime now) { + if (collection.canReceiveBirdIdSuggestions()) { + if (repo.findOpenByCollectionId(collection.getId()).isEmpty()) { + repo.save(BirdIdRequestHistory.start(collection, now)); + } + return; + } + repo.deleteOpenByCollectionId(collection.getId()); + } + /** 채택(ADOPT)으로 해결된 순간 */ public void onResolvedByAdopt(UserBirdCollection collection, OffsetDateTime resolvedAt) { repo.findOpenByCollectionId(collection.getId()) @@ -42,7 +52,7 @@ public void onResolvedByEdit(UserBirdCollection collection) { /** not null -> null 로 바뀌는 순간: PUBLIC이면 새 pending 시작 */ public void onBirdSetToUnknown(UserBirdCollection collection, OffsetDateTime startedAt) { - if (collection.getAccessLevel() != PUBLIC) return; + if (!collection.canReceiveBirdIdSuggestions()) return; if (repo.findOpenByCollectionId(collection.getId()).isPresent()) return; repo.save(BirdIdRequestHistory.start(collection, startedAt)); } @@ -55,7 +65,7 @@ public void onAccessLevelChanged(UserBirdCollection collection, AccessLevelType repo.deleteOpenByCollectionId(collection.getId()); } else if (oldLevel == PRIVATE && newLevel == PUBLIC) { // 공개로 바뀌었고 아직 미식별이면 새로 오픈 - if (collection.getBird() == null && repo.findOpenByCollectionId(collection.getId()).isEmpty()) { + if (collection.canReceiveBirdIdSuggestions() && repo.findOpenByCollectionId(collection.getId()).isEmpty()) { repo.save(BirdIdRequestHistory.start(collection, now)); } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/CreateCollectionRequest.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/CreateCollectionRequest.java index 9b214b6a..9d27858c 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/CreateCollectionRequest.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/CreateCollectionRequest.java @@ -35,4 +35,7 @@ public class CreateCollectionRequest { @Schema(description = "공개/비공개 여부", example = "PUBLIC", nullable = true) private AccessLevelType accessLevel; + + @Schema(description = "birdId가 null일 때 동정 의견을 받을지 여부. 생략 시 true", example = "true", nullable = true) + private Boolean birdIdSuggestionEnabled; } \ No newline at end of file diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/UpdateCollectionRequest.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/UpdateCollectionRequest.java index f4759933..cdd407b8 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/UpdateCollectionRequest.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/UpdateCollectionRequest.java @@ -38,4 +38,7 @@ public class UpdateCollectionRequest { @Schema(description = "공개/비공개 여부", example = "PUBLIC", nullable = true) private AccessLevelType accessLevel; + + @Schema(description = "birdId가 null일 때 동정 의견을 받을지 여부. null이면 변경하지 않음", example = "false", nullable = true) + private Boolean birdIdSuggestionEnabled; } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionDetailResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionDetailResponse.java index aa03ac61..cfec44fb 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionDetailResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionDetailResponse.java @@ -53,6 +53,9 @@ public class GetCollectionDetailResponse { @Schema(description = "내 컬렉션인지 여부", example = "false") private Boolean isMine; + @Schema(description = "동정 의견을 받을 수 있는 상태인지 여부", example = "true") + private Boolean canSuggestBirdId; + @Schema(description = "새 정보") private BirdInfo bird; diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionEditDataResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionEditDataResponse.java index 4aee4c6a..8b567046 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionEditDataResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionEditDataResponse.java @@ -34,6 +34,9 @@ public class GetCollectionEditDataResponse { @Schema(description = "컬렉션 공개 범위 (공개/비공개)") private AccessLevelType accessLevel; + @Schema(description = "birdId가 null일 때 동정 의견을 받을지 여부", example = "true") + private Boolean birdIdSuggestionEnabled; + @Schema(description = "이미지 ID", example = "300") private Long imageId; diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/UpdateCollectionResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/UpdateCollectionResponse.java index d6c69fb6..16daacb3 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/UpdateCollectionResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/UpdateCollectionResponse.java @@ -34,5 +34,8 @@ public record UpdateCollectionResponse( String imageUrl, @Schema(description = "공개/비공개 여부", example = "PUBLIC", nullable = true) - AccessLevelType accessLevel + AccessLevelType accessLevel, + + @Schema(description = "birdId가 null일 때 동정 의견을 받을지 여부", example = "true") + Boolean birdIdSuggestionEnabled ) {} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandService.java index 0140728c..0e34e581 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandService.java @@ -41,6 +41,9 @@ public SuggestBirdIdResponse suggest(Long userId, Long collectionId, Long birdId if (collection.getBird() != null) throw new BadRequestException("이미 bird_id가 확정된 컬렉션이에요"); + if (!collection.canReceiveBirdIdSuggestions()) + throw new BadRequestException("동정 의견을 받지 않는 컬렉션이에요"); + if (collection.getUser().getId().equals(userId)) throw new BadRequestException("나 자신의 컬렉션에 동정 의견을 제안할 수 없어요"); @@ -106,6 +109,9 @@ public ToggleStatusResponse toggleAgree(Long userId, Long collectionId, Long bir if (collection.getBird() != null) throw new BadRequestException("이미 bird_id가 확정된 컬렉션이에요"); + if (!collection.canReceiveBirdIdSuggestions()) + throw new BadRequestException("동정 의견을 받지 않는 컬렉션이에요"); + if (collection.getUser().getId().equals(userId)) throw new BadRequestException("나 자신의 컬렉션에 동의할 수 없어요"); @@ -156,6 +162,9 @@ public ToggleStatusResponse toggleDisagree(Long userId, Long collectionId, Long if (collection.getBird() != null) throw new BadRequestException("이미 bird_id가 확정된 컬렉션이에요"); + if (!collection.canReceiveBirdIdSuggestions()) + throw new BadRequestException("동정 의견을 받지 않는 컬렉션이에요"); + if (collection.getUser().getId().equals(userId)) throw new BadRequestException("나 자신의 컬렉션에 비동의할 수 없어요"); diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommandService.java index 0124445f..3025d312 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommandService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommandService.java @@ -5,7 +5,6 @@ import org.devkor.apu.saerok_server.domain.collection.application.dto.CreateCollectionCommand; import org.devkor.apu.saerok_server.domain.collection.application.dto.DeleteCollectionCommand; import org.devkor.apu.saerok_server.domain.collection.application.dto.UpdateCollectionCommand; -import org.devkor.apu.saerok_server.domain.collection.core.entity.AccessLevelType; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; import org.devkor.apu.saerok_server.domain.collection.core.repository.CollectionImageRepository; import org.devkor.apu.saerok_server.domain.collection.core.repository.CollectionRepository; @@ -60,6 +59,7 @@ public Long createCollection(CreateCollectionCommand command) { throw new BadRequestException("한 줄 평 길이는 " + UserBirdCollection.NOTE_MAX_LENGTH + "자 이하여야 해요"); Point location = PointFactory.create(command.latitude(), command.longitude()); + boolean birdIdSuggestionEnabled = bird == null && !Boolean.FALSE.equals(command.birdIdSuggestionEnabled()); UserBirdCollection collection = UserBirdCollection.builder() .user(user) @@ -71,12 +71,12 @@ public Long createCollection(CreateCollectionCommand command) { .address(command.address()) .note(command.note()) .accessLevel(command.accessLevel()) + .birdIdSuggestionEnabled(birdIdSuggestionEnabled) .build(); Long id = collectionRepository.save(collection); - // 생성 직후 bird가 비어 있고 PUBLIC이면 '대기 시작' 기록 - birdReqHistory.onCollectionCreatedIfPending(collection, collection.getCreatedAt()); + birdReqHistory.syncOpenState(collection, collection.getCreatedAt()); return id; } @@ -110,8 +110,6 @@ public UpdateCollectionResponse updateCollection(UpdateCollectionCommand command } OffsetDateTime now = OffsetDateTime.now(); - // 변경 전 상태 스냅샷 - AccessLevelType oldLevel = collection.getAccessLevel(); // 새 ID 변경 if (Boolean.TRUE.equals(command.isBirdIdUpdated())) { @@ -119,16 +117,12 @@ public UpdateCollectionResponse updateCollection(UpdateCollectionCommand command Bird after = (command.birdId() != null) ? birdRepository.findById(command.birdId()).orElseThrow(() -> new NotFoundException("존재하지 않는 조류 id예요")) : null; - - if (before == null && after != null) { - // null -> not null : EDIT로 해결 → 열린 기록 삭제 - birdReqHistory.onResolvedByEdit(collection); - } else if (before != null && after == null) { - // not null -> null : PUBLIC이면 다시 대기 시작 - birdReqHistory.onBirdSetToUnknown(collection, now); - } + boolean becameUnknown = before != null && after == null; collection.changeBird(after); + if (becameUnknown && command.birdIdSuggestionEnabled() == null) { + collection.changeBirdIdSuggestionEnabled(true); + } } if (command.discoveredDate() != null) collection.setDiscoveredDate(command.discoveredDate()); @@ -150,12 +144,16 @@ public UpdateCollectionResponse updateCollection(UpdateCollectionCommand command collection.setNote(command.note()); } - // 액세스 레벨 변경 처리 (전/후 비교) if (command.accessLevel() != null) { collection.setAccessLevel(command.accessLevel()); - birdReqHistory.onAccessLevelChanged(collection, oldLevel, now); } + if (command.birdIdSuggestionEnabled() != null) { + collection.changeBirdIdSuggestionEnabled(command.birdIdSuggestionEnabled()); + } + + birdReqHistory.syncOpenState(collection, now); + String imageUrl = collectionImageRepository.findObjectKeysByCollectionId(command.collectionId()).stream() .map(imageDomainService::toUploadImageUrl) .findFirst() diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/CreateCollectionCommand.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/CreateCollectionCommand.java index 735771aa..16a9e05b 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/CreateCollectionCommand.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/CreateCollectionCommand.java @@ -13,6 +13,7 @@ public record CreateCollectionCommand ( String locationAlias, String address, String note, - AccessLevelType accessLevel + AccessLevelType accessLevel, + Boolean birdIdSuggestionEnabled ){ } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/UpdateCollectionCommand.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/UpdateCollectionCommand.java index d249b1ab..cce5cca4 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/UpdateCollectionCommand.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/UpdateCollectionCommand.java @@ -15,6 +15,7 @@ public record UpdateCollectionCommand ( String locationAlias, String address, String note, - AccessLevelType accessLevel + AccessLevelType accessLevel, + Boolean birdIdSuggestionEnabled ){ } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/entity/UserBirdCollection.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/entity/UserBirdCollection.java index 997a339d..cf97ca04 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/entity/UserBirdCollection.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/entity/UserBirdCollection.java @@ -62,8 +62,11 @@ public class UserBirdCollection extends Auditable { @Setter private AccessLevelType accessLevel; + @Column(name = "bird_id_suggestion_enabled", nullable = false) + private boolean birdIdSuggestionEnabled = true; + @Builder - public UserBirdCollection(User user, Bird bird, String tempBirdName, LocalDate discoveredDate, Point location, String locationAlias, String address, String note, boolean isPinned, AccessLevelType accessLevel) { + public UserBirdCollection(User user, Bird bird, String tempBirdName, LocalDate discoveredDate, Point location, String locationAlias, String address, String note, boolean isPinned, AccessLevelType accessLevel, Boolean birdIdSuggestionEnabled) { if (user == null) throw new IllegalArgumentException("user는 null일 수 없습니다."); if (discoveredDate == null) throw new IllegalArgumentException("discoveredDate는 null일 수 없습니다."); @@ -79,11 +82,23 @@ public UserBirdCollection(User user, Bird bird, String tempBirdName, LocalDate d this.note = note; this.isPinned = isPinned; this.accessLevel = accessLevel == null ? AccessLevelType.PUBLIC : accessLevel; + this.birdIdSuggestionEnabled = bird == null && (birdIdSuggestionEnabled == null || birdIdSuggestionEnabled); } /** 단순 변경: 동정 요청 기록 열고/닫기는 별도 Recorder가 처리 */ public void changeBird(Bird newBird) { this.bird = newBird; + if (newBird != null) { + this.birdIdSuggestionEnabled = false; + } + } + + public void changeBirdIdSuggestionEnabled(boolean enabled) { + this.birdIdSuggestionEnabled = getBird() == null && enabled; + } + + public boolean canReceiveBirdIdSuggestions() { + return bird == null && accessLevel == AccessLevelType.PUBLIC && birdIdSuggestionEnabled; } public double getLongitude() { return location.getX(); } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepository.java index 3af7c454..e5f1c80a 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepository.java @@ -274,7 +274,9 @@ public List findPublicPendingCollections() { SELECT c FROM UserBirdCollection c JOIN FETCH c.user u JOIN BirdIdRequestHistory h ON h.collection.id = c.id AND h.resolvedAt IS NULL - WHERE c.accessLevel = :public AND c.bird IS NULL + WHERE c.accessLevel = :public + AND c.bird IS NULL + AND c.birdIdSuggestionEnabled = true ORDER BY h.startedAt DESC """, UserBirdCollection.class) .setParameter("public", AccessLevelType.PUBLIC) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionWebMapper.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionWebMapper.java index b511dcbf..490ce138 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionWebMapper.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionWebMapper.java @@ -60,6 +60,7 @@ public interface CollectionWebMapper { @Mapping(target = "commentCount", source = "commentCount") @Mapping(target = "isLiked", source = "isLiked") @Mapping(target = "isMine", source = "isMine") + @Mapping(target = "canSuggestBirdId", expression = "java(collection.canReceiveBirdIdSuggestions())") @Mapping(target = "latitude", expression = "java(CollectionLocationMasker.latitude(collection, isMine))") @Mapping(target = "longitude", expression = "java(CollectionLocationMasker.longitude(collection, isMine))") @Mapping(target = "locationAlias", expression = "java(CollectionLocationMasker.locationAlias(collection, isMine))") diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/community/api/dto/common/CommunityCollectionInfo.java b/src/main/java/org/devkor/apu/saerok_server/domain/community/api/dto/common/CommunityCollectionInfo.java index f493d551..5027ab32 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/community/api/dto/common/CommunityCollectionInfo.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/community/api/dto/common/CommunityCollectionInfo.java @@ -51,6 +51,9 @@ public record CommunityCollectionInfo( @Schema(description = "동정 돕기에 참여한 유저 수 (동정 요청 컬렉션인 경우에만)", example = "5", nullable = true) Long suggestionUserCount, + + @Schema(description = "동정 의견을 받을 수 있는 상태인지 여부", example = "true") + Boolean canSuggestBirdId, @Schema(description = "새 정보") BirdInfo bird, diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityDataAssembler.java b/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityDataAssembler.java index 8b6cbecf..a7097935 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityDataAssembler.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityDataAssembler.java @@ -43,7 +43,7 @@ public List toCollectionInfos(List Map popularStatusMap = popularCollectionRepository.existsByCollectionIds(collectionIds); List pendingCollectionIds = collections.stream() - .filter(c -> c.getBird() == null) + .filter(UserBirdCollection::canReceiveBirdIdSuggestions) .map(UserBirdCollection::getId) .toList(); Map suggestionUserCounts = pendingCollectionIds.isEmpty() @@ -62,7 +62,7 @@ public List toCollectionInfos(List boolean isPopular = popularStatusMap.getOrDefault(collection.getId(), false); boolean isMine = userId != null && userId.equals(collection.getUser().getId()); - Long suggestionUserCount = collection.getBird() == null + Long suggestionUserCount = collection.canReceiveBirdIdSuggestions() ? suggestionUserCounts.getOrDefault(collection.getId(), 0L) : null; diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepository.java index f240c98f..41b05195 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepository.java @@ -54,7 +54,9 @@ public List findPendingBirdIdCollections(CommunityQueryComma SELECT c FROM UserBirdCollection c JOIN FETCH c.user u JOIN BirdIdRequestHistory h ON h.collection.id = c.id AND h.resolvedAt IS NULL - WHERE c.accessLevel = :public AND c.bird IS NULL + WHERE c.accessLevel = :public + AND c.bird IS NULL + AND c.birdIdSuggestionEnabled = true ORDER BY h.startedAt DESC """, UserBirdCollection.class) .setParameter("public", AccessLevelType.PUBLIC); diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/community/mapper/CommunityWebMapper.java b/src/main/java/org/devkor/apu/saerok_server/domain/community/mapper/CommunityWebMapper.java index c50669d1..f2272bea 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/community/mapper/CommunityWebMapper.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/community/mapper/CommunityWebMapper.java @@ -30,6 +30,7 @@ public interface CommunityWebMapper { @Mapping(target = "isLiked", source = "isLiked") @Mapping(target = "isPopular", source = "isPopular") @Mapping(target = "suggestionUserCount", source = "suggestionUserCount") + @Mapping(target = "canSuggestBirdId", expression = "java(collection.canReceiveBirdIdSuggestions())") @Mapping(target = "bird", expression = "java(mapBirdInfo(collection))") @Mapping(target = "user", expression = "java(mapUserInfo(collection, userProfileImageUrl, thumbnailProfileImageUrl))") CommunityCollectionInfo toCommunityCollectionInfo( diff --git a/src/main/resources/db/migration/V95__add_bird_id_suggestion_enabled_to_collection.sql b/src/main/resources/db/migration/V95__add_bird_id_suggestion_enabled_to_collection.sql new file mode 100644 index 00000000..a9b11a55 --- /dev/null +++ b/src/main/resources/db/migration/V95__add_bird_id_suggestion_enabled_to_collection.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_bird_collection + ADD COLUMN bird_id_suggestion_enabled BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandServiceTest.java index 0a84b6e5..c56cbf5e 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandServiceTest.java @@ -1,6 +1,7 @@ package org.devkor.apu.saerok_server.domain.collection.application; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.SuggestBirdIdResponse; +import org.devkor.apu.saerok_server.domain.collection.core.entity.AccessLevelType; import org.devkor.apu.saerok_server.domain.collection.core.entity.BirdIdSuggestion; import org.devkor.apu.saerok_server.domain.collection.core.entity.BirdIdSuggestion.SuggestionType; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; @@ -58,6 +59,7 @@ private UserBirdCollection collection(long id, User owner) { UserBirdCollection c = new UserBirdCollection(); ReflectionTestUtils.setField(c, "id", id); ReflectionTestUtils.setField(c, "user", owner); + c.setAccessLevel(AccessLevelType.PUBLIC); return c; } @@ -144,6 +146,22 @@ void userNotFound() { assertThatThrownBy(() -> sut.suggest(1L, 100L, 5L)).isInstanceOf(org.devkor.apu.saerok_server.global.shared.exception.NotFoundException.class); } + @Test @DisplayName("동정요청 비활성 컬렉션이면 제안 불가") + void suggestionDisabled() { + User u = user(1L); + UserBirdCollection col = collection(100L, user(2L)); + col.changeBirdIdSuggestionEnabled(false); + + when(userRepo.findById(1L)).thenReturn(Optional.of(u)); + when(collectionRepo.findById(100L)).thenReturn(Optional.of(col)); + + assertThatThrownBy(() -> sut.suggest(1L, 100L, 5L)) + .isInstanceOf(org.devkor.apu.saerok_server.global.shared.exception.BadRequestException.class) + .hasMessage("동정 의견을 받지 않는 컬렉션이에요"); + + verifyNoInteractions(birdRepo); + } + // … 이하 생략 (원본과 동일) } } diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommandServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommandServiceTest.java index 6f5a2236..f0ae911c 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommandServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommandServiceTest.java @@ -2,6 +2,7 @@ import org.devkor.apu.saerok_server.domain.collection.application.dto.CreateCollectionCommand; import org.devkor.apu.saerok_server.domain.collection.application.dto.DeleteCollectionCommand; +import org.devkor.apu.saerok_server.domain.collection.application.dto.UpdateCollectionCommand; import org.devkor.apu.saerok_server.domain.collection.core.entity.AccessLevelType; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; import org.devkor.apu.saerok_server.domain.collection.core.repository.CollectionImageRepository; @@ -93,7 +94,7 @@ void createCollection_success_withBird() { ReflectionTestUtils.setField(bird, "id", birdId); CreateCollectionCommand command = new CreateCollectionCommand( - userId, birdId, date, lat, lon, alias, address, note, accessLevel + userId, birdId, date, lat, lon, alias, address, note, accessLevel, null ); given(userRepository.findById(userId)).willReturn(Optional.of(user)); @@ -121,15 +122,74 @@ void createCollection_success_withBird() { assertThat(saved.getAddress()).isEqualTo(address); assertThat(saved.getNote()).isEqualTo(note); assertThat(saved.getAccessLevel()).isEqualTo(accessLevel); + assertThat(saved.isBirdIdSuggestionEnabled()).isFalse(); - then(birdReqHistory).should().onCollectionCreatedIfPending(same(saved), any()); + then(birdReqHistory).should().syncOpenState(same(saved), any()); + } + + @Test + @DisplayName("정상 생성 - birdId가 없고 동정요청 옵션 생략 시 활성화") + void createCollection_unknownBird_defaultSuggestionEnabled() { + Long userId = 1L; + LocalDate date = LocalDate.of(2025, 8, 7); + User user = User.createUser("email@example.com"); + ReflectionTestUtils.setField(user, "id", userId); + + CreateCollectionCommand command = new CreateCollectionCommand( + userId, null, date, 10.0, 20.0, null, null, null, AccessLevelType.PUBLIC, null + ); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + willAnswer(invocation -> { + UserBirdCollection c = invocation.getArgument(0); + ReflectionTestUtils.setField(c, "id", 3L); + return 3L; + }).given(collectionRepository).save(any(UserBirdCollection.class)); + + Long result = service.createCollection(command); + + assertThat(result).isEqualTo(3L); + ArgumentCaptor captor = ArgumentCaptor.forClass(UserBirdCollection.class); + then(collectionRepository).should().save(captor.capture()); + UserBirdCollection saved = captor.getValue(); + assertThat(saved.isBirdIdSuggestionEnabled()).isTrue(); + assertThat(saved.canReceiveBirdIdSuggestions()).isTrue(); + then(birdReqHistory).should().syncOpenState(same(saved), any()); + } + + @Test + @DisplayName("정상 생성 - birdId가 없고 동정요청 옵션 false면 비활성화") + void createCollection_unknownBird_suggestionDisabled() { + Long userId = 1L; + User user = User.createUser("email@example.com"); + ReflectionTestUtils.setField(user, "id", userId); + + CreateCollectionCommand command = new CreateCollectionCommand( + userId, null, LocalDate.now(), 10.0, 20.0, null, null, null, AccessLevelType.PUBLIC, false + ); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + willAnswer(invocation -> { + UserBirdCollection c = invocation.getArgument(0); + ReflectionTestUtils.setField(c, "id", 3L); + return 3L; + }).given(collectionRepository).save(any(UserBirdCollection.class)); + + service.createCollection(command); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserBirdCollection.class); + then(collectionRepository).should().save(captor.capture()); + UserBirdCollection saved = captor.getValue(); + assertThat(saved.isBirdIdSuggestionEnabled()).isFalse(); + assertThat(saved.canReceiveBirdIdSuggestions()).isFalse(); + then(birdReqHistory).should().syncOpenState(same(saved), any()); } @Test @DisplayName("발견 날짜 누락 시 BadRequestException") void createCollection_missingDate_throws() { CreateCollectionCommand cmd = new CreateCollectionCommand( - 1L, null, null, 10.0, 20.0, null, null, null, AccessLevelType.PUBLIC + 1L, null, null, 10.0, 20.0, null, null, null, AccessLevelType.PUBLIC, null ); given(userRepository.findById(1L)).willReturn(Optional.of(User.createUser("e@e"))); @@ -142,7 +202,7 @@ void createCollection_missingDate_throws() { @DisplayName("위치 정보 누락 시 BadRequestException") void createCollection_missingLocation_throws() { CreateCollectionCommand cmd = new CreateCollectionCommand( - 1L, null, LocalDate.now(), null, 20.0, null, null, null, AccessLevelType.PUBLIC + 1L, null, LocalDate.now(), null, 20.0, null, null, null, AccessLevelType.PUBLIC, null ); given(userRepository.findById(1L)).willReturn(Optional.of(User.createUser("e@e"))); @@ -156,7 +216,7 @@ void createCollection_missingLocation_throws() { void createCollection_noteTooLong_throws() { String longNote = "a".repeat(UserBirdCollection.NOTE_MAX_LENGTH + 1); CreateCollectionCommand cmd = new CreateCollectionCommand( - 1L, null, LocalDate.now(), 10.0, 20.0, null, null, longNote, AccessLevelType.PUBLIC + 1L, null, LocalDate.now(), 10.0, 20.0, null, null, longNote, AccessLevelType.PUBLIC, null ); given(userRepository.findById(1L)).willReturn(Optional.of(User.createUser("e@e"))); @@ -261,5 +321,69 @@ void deleteCollection_forbidden_throws() { } } - // updateCollection 관련 기존 테스트들은 이 변경과 무관하므로 그대로 유지합니다. + @Nested + @DisplayName("updateCollection 메서드 테스트") + class UpdateCollectionTests { + + @Test + @DisplayName("birdId가 없는 컬렉션에서 동정요청 옵션을 false로 변경") + void updateCollection_disableSuggestion() { + Long userId = 1L; + Long collId = 2L; + User user = User.createUser("e@e"); + ReflectionTestUtils.setField(user, "id", userId); + UserBirdCollection coll = UserBirdCollection.builder() + .user(user) + .bird(null) + .discoveredDate(LocalDate.now()) + .location(org.devkor.apu.saerok_server.domain.collection.core.util.PointFactory.create(0, 0)) + .accessLevel(AccessLevelType.PUBLIC) + .build(); + ReflectionTestUtils.setField(coll, "id", collId); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(collectionRepository.findById(collId)).willReturn(Optional.of(coll)); + given(collectionImageRepository.findObjectKeysByCollectionId(collId)).willReturn(List.of()); + + service.updateCollection(new UpdateCollectionCommand( + userId, collId, null, null, null, null, null, null, null, null, null, false + )); + + assertThat(coll.isBirdIdSuggestionEnabled()).isFalse(); + assertThat(coll.canReceiveBirdIdSuggestions()).isFalse(); + then(birdReqHistory).should().syncOpenState(same(coll), any()); + } + + @Test + @DisplayName("birdId를 확정 상태에서 null로 변경하고 옵션을 생략하면 동정요청 활성화") + void updateCollection_identifiedToUnknown_defaultsSuggestionEnabled() { + Long userId = 1L; + Long collId = 2L; + User user = User.createUser("e@e"); + ReflectionTestUtils.setField(user, "id", userId); + Bird bird = new Bird(); + ReflectionTestUtils.setField(bird, "id", 10L); + UserBirdCollection coll = UserBirdCollection.builder() + .user(user) + .bird(bird) + .discoveredDate(LocalDate.now()) + .location(org.devkor.apu.saerok_server.domain.collection.core.util.PointFactory.create(0, 0)) + .accessLevel(AccessLevelType.PUBLIC) + .build(); + ReflectionTestUtils.setField(coll, "id", collId); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(collectionRepository.findById(collId)).willReturn(Optional.of(coll)); + given(collectionImageRepository.findObjectKeysByCollectionId(collId)).willReturn(List.of()); + + service.updateCollection(new UpdateCollectionCommand( + userId, collId, true, null, null, null, null, null, null, null, null, null + )); + + assertThat(coll.getBird()).isNull(); + assertThat(coll.isBirdIdSuggestionEnabled()).isTrue(); + assertThat(coll.canReceiveBirdIdSuggestions()).isTrue(); + then(birdReqHistory).should().syncOpenState(same(coll), any()); + } + } } diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryServiceTest.java index 9f1a713b..8f7564be 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryServiceTest.java @@ -77,6 +77,7 @@ private static CommunityCollectionInfo collectionInfo( Long commentCount, Boolean isPopular, Long suggestionUserCount, + Boolean canSuggestBirdId, CommunityCollectionInfo.BirdInfo birdInfo, CommunityCollectionInfo.UserInfo userInfo ) { @@ -96,6 +97,7 @@ private static CommunityCollectionInfo collectionInfo( false, isPopular, suggestionUserCount, + canSuggestBirdId, birdInfo, userInfo ); @@ -189,13 +191,13 @@ void getRecentCollections_withMixedCollections_correctlySetsParticipantCount() { CommunityCollectionInfo pendingInfo = collectionInfo( 1L, "https://example.com/image1.jpg", "https://example.com/thumbnails/1", "이게 무슨 새일까요?", - 10L, 5L, false, 3L, null, userInfo + 10L, 5L, false, 3L, true, null, userInfo ); CommunityCollectionInfo.BirdInfo birdInfo = new CommunityCollectionInfo.BirdInfo(100L, "까치"); CommunityCollectionInfo normalInfo = collectionInfo( 2L, "https://example.com/image2.jpg", "https://example.com/thumbnails/2", "까치를 발견했어요!", - 15L, 7L, false, null, birdInfo, userInfo + 15L, 7L, false, null, false, birdInfo, userInfo ); given(dataAssembler.toCollectionInfos(collections, userId)) diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepositoryTest.java index 73ff8a43..cace7917 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepositoryTest.java @@ -218,10 +218,14 @@ void findPending_returnsWithoutBird() { newCollection(user, bird, AccessLevelType.PUBLIC, null); // resolved (bird!=null) UserBirdCollection withoutBirdPublic = newCollection(user, null, AccessLevelType.PUBLIC, null); // pending 대상 + UserBirdCollection suggestionDisabled = newCollection(user, null, AccessLevelType.PUBLIC, null); + suggestionDisabled.changeBirdIdSuggestionEnabled(false); + em.merge(suggestionDisabled); newCollection(user, null, AccessLevelType.PRIVATE, null); // PRIVATE → 제외 // ★ 핵심: pending 조회는 열린 BirdIdRequestHistory가 있어야 잡힌다 openPending(withoutBirdPublic, OffsetDateTime.now().minusMinutes(1)); + openPending(suggestionDisabled, OffsetDateTime.now().minusMinutes(2)); CommunityQueryCommand command = new CommunityQueryCommand(1, 10, null);