From b22a0b38cd5c5e7579165e871debe1c323252d3b Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 24 May 2026 15:53:30 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EB=8F=84=EA=B0=90=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=EC=97=90=20=EB=B3=B4=ED=98=B8=EB=93=B1?= =?UTF-8?q?=EA=B8=89=20=EC=B9=BC=EB=9F=BC=20=EC=B6=94=EA=B0=80&=EB=B7=B0?= =?UTF-8?q?=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V91__add_bird_conservation_grade.sql | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 src/main/resources/db/migration/V91__add_bird_conservation_grade.sql diff --git a/src/main/resources/db/migration/V91__add_bird_conservation_grade.sql b/src/main/resources/db/migration/V91__add_bird_conservation_grade.sql new file mode 100644 index 00000000..7a2e1e9b --- /dev/null +++ b/src/main/resources/db/migration/V91__add_bird_conservation_grade.sql @@ -0,0 +1,140 @@ +ALTER TABLE bird + ADD COLUMN conservation_grade VARCHAR(16) NOT NULL DEFAULT 'NONE'; + +ALTER TABLE bird + ADD CONSTRAINT chk_bird_conservation_grade + CHECK (conservation_grade IN ('NONE', 'GRADE_I', 'GRADE_II')); + +UPDATE bird +SET conservation_grade = 'GRADE_I' +WHERE id IN ( + 58, 59, 86, 90, 100, 128, 161, 214, 215, 219, + 235, 257, 258, 275, 473, 518, 600 +); + +UPDATE bird +SET conservation_grade = 'GRADE_II' +WHERE id IN ( + 27, 31, 33, 49, 50, 53, 57, 60, 73, 93, + 97, 98, 99, 102, 123, 135, 150, 162, 164, 179, + 201, 217, 218, 222, 224, 250, 253, 254, 255, 256, + 261, 264, 265, 266, 267, 268, 269, 271, 274, 287, + 395, 399, 403, 410, 411, 421, 425, 433, 444, 445, + 452, 459, 529, 530, 537, 538, 553, 577, 583, 592, + 593, 595, 602 +); + +DROP MATERIALIZED VIEW IF EXISTS bird_profile_mv; + +CREATE MATERIALIZED VIEW bird_profile_mv AS +WITH month_season AS ( + SELECT m AS month, + CASE WHEN m IN (3,4,5) THEN 'SPRING' + WHEN m IN (6,7,8) THEN 'SUMMER' + WHEN m IN (9,10,11) THEN 'AUTUMN' + ELSE 'WINTER' + END AS season + FROM generate_series(1,12) AS g(m) +), + bird_month_priority AS ( + SELECT br.bird_id, + ms.month, + MAX(rt.priority) AS priority + FROM bird_residency br + JOIN rarity_type rt ON rt.id = br.rarity_type_id + JOIN residency_type rty ON rty.id = br.residency_type_id + JOIN month_season ms ON ((COALESCE(br.month_bitmask, rty.month_bitmask) + >> (ms.month-1)) & 1) = 1 + GROUP BY br.bird_id, ms.month + ), + bird_season_priority AS ( + SELECT bmp.bird_id, + ms.season, + MAX(bmp.priority) AS priority + FROM bird_month_priority bmp + JOIN month_season ms ON ms.month = bmp.month + GROUP BY bmp.bird_id, ms.season + ), + bird_season_rarity AS ( + SELECT bsp.bird_id, + bsp.season, + bsp.priority, + rt.code AS rarity_code + FROM bird_season_priority bsp + JOIN rarity_type rt ON rt.priority = bsp.priority + ), + seasons_json AS ( + SELECT bird_id, + jsonb_agg( + jsonb_build_object( + 'season', season, + 'rarity', rarity_code, + 'priority', priority + ) + ORDER BY array_position( + ARRAY['SPRING','SUMMER','AUTUMN','WINTER'], season + ) + ) AS seasons_with_rarity + FROM bird_season_rarity + GROUP BY bird_id + ), + habitats_array AS ( + SELECT bird_id, + array_agg(DISTINCT habitat_type) AS habitats + FROM bird_habitat + GROUP BY bird_id + ), + images_json AS ( + SELECT bird_id, + jsonb_agg( + jsonb_build_object( + 'object_key', object_key, + 'original_url', original_url, + 'order_index', order_index, + 'is_thumb', is_thumb + ) + ORDER BY order_index + ) AS images + FROM bird_image + GROUP BY bird_id + ) +SELECT + b.id, + b.korean_name, + b.scientific_name, + b.scientific_year, + b.description_is_ai_generated, + b.class_eng, + b.class_kor, + b."order_eng", + b."order_kor", + b.family_eng, + b.family_kor, + b.genus_eng, + b.genus_kor, + b.species_eng, + b.species_kor, + b.scientific_author, + b.phylum_eng, + b.phylum_kor, + b.nibr_url, + b.conservation_grade, + b.description, + b.description_source, + ha.habitats, + b.body_length_cm, + COALESCE(sj.seasons_with_rarity, '[]'::jsonb) AS seasons_with_rarity, + COALESCE(ij.images, '[]'::jsonb) AS images, + b.created_at, + b.updated_at, + b.deleted_at +FROM bird b + LEFT JOIN habitats_array ha ON ha.bird_id = b.id + LEFT JOIN seasons_json sj ON sj.bird_id = b.id + LEFT JOIN images_json ij ON ij.bird_id = b.id +WHERE b.deleted_at IS NULL +WITH NO DATA; + +CREATE UNIQUE INDEX idx_bird_profile_mv_id ON bird_profile_mv(id); + +REFRESH MATERIALIZED VIEW bird_profile_mv; From 4c4d1156db319c42bed25e6c07e1ee3caf4050a0 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 24 May 2026 16:02:43 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EB=B3=B4=ED=98=B8=20=EB=93=B1?= =?UTF-8?q?=EA=B8=89=20=EC=B6=94=EA=B0=80=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?=EB=8F=84=EA=B0=90=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dex/bird/api/dto/response/BirdDetailResponse.java | 4 ++++ .../bird/api/dto/response/BirdFullSyncResponse.java | 4 ++++ .../dex/bird/api/dto/response/BirdSearchResponse.java | 4 ++++ .../domain/dex/bird/core/entity/Bird.java | 5 +++++ .../domain/dex/bird/core/enums/ConservationGrade.java | 11 +++++++++++ .../dex/bird/query/mapper/BirdProfileViewMapper.java | 3 +++ .../domain/dex/bird/query/view/BirdProfileView.java | 5 +++++ 7 files changed, 36 insertions(+) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/enums/ConservationGrade.java diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdDetailResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdDetailResponse.java index 7d60a39c..1a0503c5 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdDetailResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdDetailResponse.java @@ -1,6 +1,7 @@ package org.devkor.apu.saerok_server.domain.dex.bird.api.dto.response; import io.swagger.v3.oas.annotations.media.Schema; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; import java.util.List; @@ -15,6 +16,9 @@ public class BirdDetailResponse { @Schema(description = "학명", example = "Pica pica") public String scientificName; + @Schema(description = "보호등급", example = "NONE", allowableValues = {"NONE", "GRADE_I", "GRADE_II"}) + public ConservationGrade conservationGrade; + @Schema(description = "분류학적 정보") public BirdTaxonomy taxonomy; diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdFullSyncResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdFullSyncResponse.java index a050e235..bd10b64b 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdFullSyncResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdFullSyncResponse.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.HabitatType; import java.time.OffsetDateTime; @@ -36,6 +37,9 @@ public static class BirdProfileItem { @Schema(description = "NIBR URL", example = "http://nibr...") private String nibrUrl; + @Schema(description = "보호등급", example = "NONE", allowableValues = {"NONE", "GRADE_I", "GRADE_II"}) + private ConservationGrade conservationGrade; + @Schema(description = "서식지 목록") private List habitats; diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdSearchResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdSearchResponse.java index bdc4ba3e..f1df8fe8 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdSearchResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdSearchResponse.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; import java.util.List; @@ -24,6 +25,9 @@ public static class BirdSearchItem { @Schema(description = "학명", example = "Pica pica") public String scientificName; + @Schema(description = "보호등급", example = "NONE", allowableValues = {"NONE", "GRADE_I", "GRADE_II"}) + public ConservationGrade conservationGrade; + @Schema(description = "썸네일 이미지 URL", example = "https://example.com/images/bird-thumb.jpg") public String thumbImageUrl; } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/entity/Bird.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/entity/Bird.java index d04bf8be..fc254471 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/entity/Bird.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/entity/Bird.java @@ -3,6 +3,7 @@ import jakarta.persistence.*; import lombok.Getter; import org.devkor.apu.saerok_server.domain.dex.bird.core.contract.HasBodyLength; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; import org.devkor.apu.saerok_server.global.shared.entity.SoftDeletableAuditable; import java.util.List; @@ -31,6 +32,10 @@ public class Bird extends SoftDeletableAuditable implements HasBodyLength { @Column(name = "nibr_url") private String nibrUrl; + @Enumerated(EnumType.STRING) + @Column(name = "conservation_grade", nullable = false) + private ConservationGrade conservationGrade = ConservationGrade.NONE; + @OneToMany(mappedBy = "bird", cascade = CascadeType.ALL, orphanRemoval = true) private List images; diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/enums/ConservationGrade.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/enums/ConservationGrade.java new file mode 100644 index 00000000..e0cd6813 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/enums/ConservationGrade.java @@ -0,0 +1,11 @@ +package org.devkor.apu.saerok_server.domain.dex.bird.core.enums; + +public enum ConservationGrade { + NONE, + GRADE_I, + GRADE_II; + + public boolean shouldHideLocation() { + return this != NONE; + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/mapper/BirdProfileViewMapper.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/mapper/BirdProfileViewMapper.java index 594bcd05..f748e7cc 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/mapper/BirdProfileViewMapper.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/mapper/BirdProfileViewMapper.java @@ -22,6 +22,9 @@ public abstract class BirdProfileViewMapper { // TODO: 프론트에서 nibrUrl null 처리가 되면, 이 nibrUrl 임시 처리(null 대신 빈 문자열)를 지운다. public abstract BirdFullSyncResponse.BirdProfileItem toDto(BirdProfileView birdProfileView); + @Mapping(target = "s3Url", ignore = true) + protected abstract BirdFullSyncResponse.BirdProfileItem.Image toDtoImage(BirdProfileView.Image image); + @AfterMapping protected void fillS3Urls( BirdProfileView source, diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/view/BirdProfileView.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/view/BirdProfileView.java index c615408f..0ff52f1d 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/view/BirdProfileView.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/view/BirdProfileView.java @@ -8,6 +8,7 @@ import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdDescription; import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdName; import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdTaxonomy; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.HabitatType; import org.hibernate.annotations.Immutable; import org.hibernate.annotations.JdbcTypeCode; @@ -40,6 +41,10 @@ public class BirdProfileView implements HasBodyLength { @Column(name = "nibr_url") private String nibrUrl; + @Enumerated(EnumType.STRING) + @Column(name = "conservation_grade") + private ConservationGrade conservationGrade; + @JdbcTypeCode(SqlTypes.ARRAY) @Column(name = "habitats") private List habitats; From 2b7b3290f60920d845d23a93c0337e77ae50cbd3 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 24 May 2026 16:04:12 +0900 Subject: [PATCH 3/6] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B9=8C=EB=8D=94=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../saerok_server/testsupport/builder/BirdBuilder.java | 8 ++++++++ .../testsupport/builder/CollectionBuilder.java | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/src/test/java/org/devkor/apu/saerok_server/testsupport/builder/BirdBuilder.java b/src/test/java/org/devkor/apu/saerok_server/testsupport/builder/BirdBuilder.java index f1df3539..07ea8535 100644 --- a/src/test/java/org/devkor/apu/saerok_server/testsupport/builder/BirdBuilder.java +++ b/src/test/java/org/devkor/apu/saerok_server/testsupport/builder/BirdBuilder.java @@ -4,6 +4,7 @@ import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdDescription; import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdName; import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdTaxonomy; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; @@ -17,6 +18,7 @@ public class BirdBuilder { private BirdTaxonomy taxonomy; private Double bodyLengthCm = 25.0; private String nibrUrl = null; + private ConservationGrade conservationGrade = ConservationGrade.NONE; public BirdBuilder(TestEntityManager em) { this.em = em; @@ -56,6 +58,11 @@ public BirdBuilder thumbnailUrl(String url) { return this; } + public BirdBuilder conservationGrade(ConservationGrade conservationGrade) { + this.conservationGrade = conservationGrade; + return this; + } + /** * Builds and persists the Bird. */ @@ -78,6 +85,7 @@ public Bird build() { // additional fields ReflectionTestUtils.setField(bird, "bodyLengthCm", bodyLengthCm); ReflectionTestUtils.setField(bird, "nibrUrl", nibrUrl); + ReflectionTestUtils.setField(bird, "conservationGrade", conservationGrade); em.persist(bird); em.flush(); diff --git a/src/test/java/org/devkor/apu/saerok_server/testsupport/builder/CollectionBuilder.java b/src/test/java/org/devkor/apu/saerok_server/testsupport/builder/CollectionBuilder.java index 4e716fa9..06e6f18f 100644 --- a/src/test/java/org/devkor/apu/saerok_server/testsupport/builder/CollectionBuilder.java +++ b/src/test/java/org/devkor/apu/saerok_server/testsupport/builder/CollectionBuilder.java @@ -2,6 +2,7 @@ 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.dex.bird.core.entity.Bird; import org.devkor.apu.saerok_server.domain.user.core.entity.User; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.GeometryFactory; @@ -17,6 +18,7 @@ public class CollectionBuilder { private final TestEntityManager em; private User owner; + private Bird bird; private AccessLevelType accessLevel = AccessLevelType.PUBLIC; private LocalDate discoveredDate = LocalDate.now(); private Point location = new GeometryFactory() @@ -31,6 +33,11 @@ public CollectionBuilder owner(User owner) { return this; } + public CollectionBuilder bird(Bird bird) { + this.bird = bird; + return this; + } + public CollectionBuilder accessLevel(AccessLevelType level) { this.accessLevel = level; return this; @@ -53,6 +60,7 @@ public UserBirdCollection build() { UserBirdCollection coll = new UserBirdCollection(); // inject owner ReflectionTestUtils.setField(coll, "user", owner); + ReflectionTestUtils.setField(coll, "bird", bird); coll.setAccessLevel(accessLevel); coll.setDiscoveredDate(discoveredDate); coll.setLocation(location); From f8a7496f114316c18df7970f46130c9a1594677a Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 24 May 2026 16:06:41 +0900 Subject: [PATCH 4/6] =?UTF-8?q?test:=20=EB=8F=84=EA=B0=90=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BirdConservationGradeMigrationTest.java | 35 +++++++++++++++++++ .../core/repository/BirdRepositoryTest.java | 9 +++++ .../BirdProfileViewRepositoryTest.java | 11 ++++++ .../builder/BirdProfileViewTestBuilder.java | 3 ++ 4 files changed, 58 insertions(+) create mode 100644 src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/BirdConservationGradeMigrationTest.java diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/BirdConservationGradeMigrationTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/BirdConservationGradeMigrationTest.java new file mode 100644 index 00000000..8525b010 --- /dev/null +++ b/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/BirdConservationGradeMigrationTest.java @@ -0,0 +1,35 @@ +package org.devkor.apu.saerok_server.domain.dex.bird; + +import jakarta.persistence.EntityManager; +import org.devkor.apu.saerok_server.testsupport.AbstractPostgresContainerTest; +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.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +class BirdConservationGradeMigrationTest extends AbstractPostgresContainerTest { + + @Autowired + EntityManager em; + + @Test + @DisplayName("Flyway 보호등급 데이터가 현재 도감 seed 기준 카운트와 일치한다") + void conservationGradeCounts_matchSpreadsheet() { + Object[] row = (Object[]) em.createNativeQuery(""" + SELECT + COUNT(*) FILTER (WHERE conservation_grade = 'GRADE_I'), + COUNT(*) FILTER (WHERE conservation_grade = 'GRADE_II'), + COUNT(*) FILTER (WHERE conservation_grade = 'NONE') + FROM bird + """).getSingleResult(); + + assertThat(((Number) row[0]).longValue()).isEqualTo(16L); + assertThat(((Number) row[1]).longValue()).isEqualTo(58L); + assertThat(((Number) row[2]).longValue()).isEqualTo(512L); + } +} diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/core/repository/BirdRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/core/repository/BirdRepositoryTest.java index 6ec49a1c..1552a6ae 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/core/repository/BirdRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/core/repository/BirdRepositoryTest.java @@ -1,6 +1,7 @@ package org.devkor.apu.saerok_server.domain.dex.bird.core.repository; import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.Bird; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; import org.devkor.apu.saerok_server.domain.dex.bird.query.dto.BirdSearchDto; import org.devkor.apu.saerok_server.domain.dex.bird.query.dto.CmRangeDto; import org.devkor.apu.saerok_server.domain.dex.bird.query.enums.BirdSearchSortDirType; @@ -46,6 +47,14 @@ void findById_returnsActiveBird() { assertThat(found.get().getId()).isEqualTo(bird.getId()); } + @Test @DisplayName("findById returns conservation grade") + void findById_returnsConservationGrade() { + Optional found = repo.findById(600L); + + assertThat(found).isPresent(); + assertThat(found.get().getConservationGrade()).isEqualTo(ConservationGrade.GRADE_I); + } + @Test @DisplayName("findById - soft delete 된 새 제외") void findById_excludesSoftDeleted() { Bird bird = newBird("deleted-bird-" + System.nanoTime(), 10.0); diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/query/repository/BirdProfileViewRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/query/repository/BirdProfileViewRepositoryTest.java index 03ce3cc8..29acb471 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/query/repository/BirdProfileViewRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/query/repository/BirdProfileViewRepositoryTest.java @@ -1,6 +1,7 @@ package org.devkor.apu.saerok_server.domain.dex.bird.query.repository; import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.Bird; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; import org.devkor.apu.saerok_server.domain.dex.bird.core.repository.BirdRepository; import org.devkor.apu.saerok_server.domain.dex.bird.query.view.BirdProfileView; import org.devkor.apu.saerok_server.testsupport.AbstractPostgresContainerTest; @@ -47,6 +48,16 @@ public class BirdProfileViewRepositoryTest extends AbstractPostgresContainerTest System.out.println(birdProfile.get().toSummaryString()); } + @Test + void 새프로필에_보호등급이_포함된다() { + // when + Optional birdProfile = birdProfileRepository.findById(600L); + + // then + assertTrue(birdProfile.isPresent()); + assertEquals(ConservationGrade.GRADE_I, birdProfile.get().getConservationGrade()); + } + @Test void 새프로필_전부_조회하기() { // when diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/query/view/builder/BirdProfileViewTestBuilder.java b/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/query/view/builder/BirdProfileViewTestBuilder.java index 712c8bdd..beb0b094 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/query/view/builder/BirdProfileViewTestBuilder.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/query/view/builder/BirdProfileViewTestBuilder.java @@ -3,6 +3,7 @@ import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdDescription; import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdName; import org.devkor.apu.saerok_server.domain.dex.bird.query.view.BirdProfileView; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdTaxonomy; import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.HabitatType; import org.springframework.test.util.ReflectionTestUtils; @@ -17,6 +18,7 @@ public class BirdProfileViewTestBuilder { private BirdDescription description = new BirdDescription(); private Double bodyLengthCm = 25.0; private String nibrUrl = null; + private ConservationGrade conservationGrade = ConservationGrade.NONE; private List habitats = List.of(); private List seasonsWithRarity = List.of(); private List images = List.of(); @@ -30,6 +32,7 @@ public BirdProfileView build() { ReflectionTestUtils.setField(birdProfileView, "description", description); ReflectionTestUtils.setField(birdProfileView, "bodyLengthCm", bodyLengthCm); ReflectionTestUtils.setField(birdProfileView, "nibrUrl", nibrUrl); + ReflectionTestUtils.setField(birdProfileView, "conservationGrade", conservationGrade); ReflectionTestUtils.setField(birdProfileView, "habitats", habitats); ReflectionTestUtils.setField(birdProfileView, "seasonsWithRarity", seasonsWithRarity); ReflectionTestUtils.setField(birdProfileView, "images", images); From 3affe29b5e8b636367ab07e5eb877d8d8bf2e55f Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 24 May 2026 16:12:55 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=EC=BB=AC=EB=A0=89=EC=85=98?= =?UTF-8?q?=EC=97=90=20=EC=9C=84=EC=B9=98=20=EB=A7=88=EC=8A=A4=EC=BB=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20=EC=BB=AC=EB=A0=89=EC=85=98=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=9D=B4=20=EA=B0=80=EB=8A=94=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=EB=93=A4=EC=97=90=20isMine=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=A7=88=EC=8A=A4=EC=BB=A4=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9,=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/CollectionQueryService.java | 4 +- .../core/repository/CollectionRepository.java | 16 ++- .../service/CollectionLocationMasker.java | 53 ++++++++ .../mapper/CollectionWebMapper.java | 14 ++- .../application/CommunityDataAssembler.java | 3 +- .../community/mapper/CommunityWebMapper.java | 14 ++- .../repository/CollectionRepositoryTest.java | 117 ++++++++++++++++++ .../service/CollectionLocationMaskerTest.java | 78 ++++++++++++ 8 files changed, 287 insertions(+), 12 deletions(-) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/collection/core/service/CollectionLocationMasker.java create mode 100644 src/test/java/org/devkor/apu/saerok_server/domain/collection/core/service/CollectionLocationMaskerTest.java diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java index de913e9e..caee9867 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java @@ -214,6 +214,7 @@ public GetNearbyCollectionsResponse getNearbyCollections(GetNearbyCollectionsCom boolean isLikedByMe = command.userId() != null && myLikeMap.getOrDefault(c.getId(), false); String userProfileImageUrl = profileImageMap.get(c.getUser().getId()); String thumbnailProfileImageUrl = thumbnailProfileImageMap.get(c.getUser().getId()); + boolean isMine = command.userId() != null && command.userId().equals(c.getUser().getId()); return collectionWebMapper.toGetNearbyCollectionsResponseItem( c, @@ -223,7 +224,8 @@ public GetNearbyCollectionsResponse getNearbyCollections(GetNearbyCollectionsCom thumbnailProfileImageUrl, likeCount, commentCount, - isLikedByMe + isLikedByMe, + isMine ); }) .toList(); 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 714d3c2b..0262c49b 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 @@ -55,14 +55,16 @@ public List findNearby(Point ref, double radiusMeters, Long if (isMineOnly && userId != null) { String sqlMineOnly = """ - SELECT * + SELECT c.* FROM user_bird_collection c + LEFT JOIN bird b ON b.id = c.bird_id WHERE ST_DWithin( c.location::geography, CAST(:refPoint AS geography), :radius ) AND c.user_id = :userId + AND (c.bird_id IS NULL OR b.conservation_grade = 'NONE') ORDER BY ST_Distance( c.location::geography, CAST(:refPoint AS geography) @@ -79,8 +81,9 @@ ORDER BY ST_Distance( } String sqlAll = """ - SELECT * + SELECT c.* FROM user_bird_collection c + LEFT JOIN bird b ON b.id = c.bird_id WHERE ST_DWithin( c.location::geography, CAST(:refPoint AS geography), @@ -90,6 +93,7 @@ WHERE ST_DWithin( c.access_level = 'PUBLIC' OR (CAST(:userId AS bigint) IS NOT NULL AND c.user_id = :userId) ) + AND (c.bird_id IS NULL OR b.conservation_grade = 'NONE') ORDER BY ST_Distance( c.location::geography, CAST(:refPoint AS geography) @@ -111,12 +115,14 @@ public long countNearbyCandidates(Point ref, double radiusMeters, Long userId, b String sql = """ SELECT COUNT(*) FROM user_bird_collection c + LEFT JOIN bird b ON b.id = c.bird_id WHERE ST_DWithin( c.location::geography, CAST(:refPoint AS geography), :radius ) AND c.user_id = :userId + AND (c.bird_id IS NULL OR b.conservation_grade = 'NONE') """; var query = em.createNativeQuery(sql) @@ -130,6 +136,7 @@ WHERE ST_DWithin( String sql = """ SELECT COUNT(*) FROM user_bird_collection c + LEFT JOIN bird b ON b.id = c.bird_id WHERE ST_DWithin( c.location::geography, CAST(:refPoint AS geography), @@ -139,6 +146,7 @@ WHERE ST_DWithin( c.access_level = 'PUBLIC' OR (CAST(:userId AS bigint) IS NOT NULL AND c.user_id = :userId) ) + AND (c.bird_id IS NULL OR b.conservation_grade = 'NONE') """; var query = em.createNativeQuery(sql) @@ -169,12 +177,14 @@ WITH candidates AS ( ST_Distance(c.location::geography, CAST(:refPoint AS geography)) AS dist, ST_SnapToGrid(ST_Transform(c.location, 3857), :gridSize, :gridSize) AS cell_id FROM user_bird_collection c + LEFT JOIN bird b ON b.id = c.bird_id WHERE ST_DWithin( c.location::geography, CAST(:refPoint AS geography), :radius ) AND c.user_id = :userId + AND (c.bird_id IS NULL OR b.conservation_grade = 'NONE') ), ranked AS ( SELECT id, dist, @@ -203,6 +213,7 @@ WITH candidates AS ( ST_Distance(c.location::geography, CAST(:refPoint AS geography)) AS dist, ST_SnapToGrid(ST_Transform(c.location, 3857), :gridSize, :gridSize) AS cell_id FROM user_bird_collection c + LEFT JOIN bird b ON b.id = c.bird_id WHERE ST_DWithin( c.location::geography, CAST(:refPoint AS geography), @@ -212,6 +223,7 @@ WHERE ST_DWithin( c.access_level = 'PUBLIC' OR (CAST(:userId AS bigint) IS NOT NULL AND c.user_id = :userId) ) + AND (c.bird_id IS NULL OR b.conservation_grade = 'NONE') ), ranked AS ( SELECT id, dist, diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/service/CollectionLocationMasker.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/service/CollectionLocationMasker.java new file mode 100644 index 00000000..86ad1183 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/service/CollectionLocationMasker.java @@ -0,0 +1,53 @@ +package org.devkor.apu.saerok_server.domain.collection.core.service; + +import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; +import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.Bird; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; + +public final class CollectionLocationMasker { + + private CollectionLocationMasker() { + } + + public static Double latitude(UserBirdCollection collection, boolean isOwner) { + if (shouldMaskLocation(collection, isOwner) || collection == null || collection.getLocation() == null) { + return null; + } + return collection.getLatitude(); + } + + public static Double longitude(UserBirdCollection collection, boolean isOwner) { + if (shouldMaskLocation(collection, isOwner) || collection == null || collection.getLocation() == null) { + return null; + } + return collection.getLongitude(); + } + + public static String locationAlias(UserBirdCollection collection, boolean isOwner) { + if (shouldMaskLocation(collection, isOwner) || collection == null) { + return null; + } + return collection.getLocationAlias(); + } + + public static String address(UserBirdCollection collection, boolean isOwner) { + if (shouldMaskLocation(collection, isOwner) || collection == null) { + return null; + } + return collection.getAddress(); + } + + public static boolean shouldMaskLocation(UserBirdCollection collection, boolean isOwner) { + if (isOwner || collection == null) { + return false; + } + + Bird bird = collection.getBird(); + if (bird == null) { + return false; + } + + ConservationGrade grade = bird.getConservationGrade(); + return grade != null && grade.shouldHideLocation(); + } +} 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 45b74c34..b511dcbf 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 @@ -9,13 +9,15 @@ import org.devkor.apu.saerok_server.domain.collection.application.dto.DeleteCollectionCommand; import org.devkor.apu.saerok_server.domain.collection.application.dto.*; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; +import org.devkor.apu.saerok_server.domain.collection.core.service.CollectionLocationMasker; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.MappingConstants; import org.mapstruct.Named; @Mapper( - componentModel = MappingConstants.ComponentModel.SPRING + componentModel = MappingConstants.ComponentModel.SPRING, + imports = CollectionLocationMasker.class ) public interface CollectionWebMapper { @@ -58,6 +60,10 @@ public interface CollectionWebMapper { @Mapping(target = "commentCount", source = "commentCount") @Mapping(target = "isLiked", source = "isLiked") @Mapping(target = "isMine", source = "isMine") + @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))") + @Mapping(target = "address", expression = "java(CollectionLocationMasker.address(collection, isMine))") GetCollectionDetailResponse toGetCollectionDetailResponse(UserBirdCollection collection, String imageUrl, String userProfileImageUrl, String thumbnailProfileImageUrl, long likeCount, long commentCount, boolean isLiked, boolean isMine); @Mapping(target = "collectionId", source = "collection.id") @@ -71,7 +77,11 @@ public interface CollectionWebMapper { @Mapping(target = "user.nickname", source = "collection.user.nickname") @Mapping(target = "user.profileImageUrl", source = "userProfileImageUrl") @Mapping(target = "user.thumbnailProfileImageUrl", source = "thumbnailProfileImageUrl") - GetNearbyCollectionsResponse.Item toGetNearbyCollectionsResponseItem(UserBirdCollection collection, String imageUrl, String thumbnailUrl, String userProfileImageUrl, String thumbnailProfileImageUrl, long likeCount, long commentCount, boolean isLiked); + @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))") + @Mapping(target = "address", expression = "java(CollectionLocationMasker.address(collection, isMine))") + GetNearbyCollectionsResponse.Item toGetNearbyCollectionsResponseItem(UserBirdCollection collection, String imageUrl, String thumbnailUrl, String userProfileImageUrl, String thumbnailProfileImageUrl, long likeCount, long commentCount, boolean isLiked, boolean isMine); @Named("getBirdId") default Long getBirdId(UserBirdCollection collection) { 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 ee60186d..8b6cbecf 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 @@ -60,13 +60,14 @@ public List toCollectionInfos(List long commentCount = collectionCommentRepository.countByCollectionId(collection.getId()); boolean isLiked = userId != null && collectionLikeRepository.existsByUserIdAndCollectionId(userId, collection.getId()); boolean isPopular = popularStatusMap.getOrDefault(collection.getId(), false); + boolean isMine = userId != null && userId.equals(collection.getUser().getId()); Long suggestionUserCount = collection.getBird() == null ? suggestionUserCounts.getOrDefault(collection.getId(), 0L) : null; return communityWebMapper.toCommunityCollectionInfo( - collection, imageUrl, thumbnailImageUrl, userProfileImageUrl, thumbnailProfileImageUrl, likeCount, commentCount, isLiked, isPopular, suggestionUserCount + collection, imageUrl, thumbnailImageUrl, userProfileImageUrl, thumbnailProfileImageUrl, likeCount, commentCount, isLiked, isPopular, suggestionUserCount, isMine ); }) .toList(); 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 aa101213..c50669d1 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 @@ -1,6 +1,7 @@ package org.devkor.apu.saerok_server.domain.community.mapper; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; +import org.devkor.apu.saerok_server.domain.collection.core.service.CollectionLocationMasker; import org.devkor.apu.saerok_server.domain.community.api.dto.common.CommunityCollectionInfo; import org.devkor.apu.saerok_server.domain.community.api.dto.common.CommunityFreeBoardPostInfo; import org.devkor.apu.saerok_server.domain.community.api.dto.common.CommunityUserInfo; @@ -11,7 +12,7 @@ import org.mapstruct.Mapping; import org.mapstruct.MappingConstants; -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, imports = OffsetDateTimeLocalizer.class) +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, imports = {OffsetDateTimeLocalizer.class, CollectionLocationMasker.class}) public interface CommunityWebMapper { @Mapping(target = "collectionId", source = "collection.id") @@ -19,10 +20,10 @@ public interface CommunityWebMapper { @Mapping(target = "thumbnailImageUrl", source = "thumbnailImageUrl") @Mapping(target = "discoveredDate", source = "collection.discoveredDate") @Mapping(target = "createdAt", expression = "java(OffsetDateTimeLocalizer.toSeoulLocalDateTime(collection.getCreatedAt()))") - @Mapping(target = "latitude", source = "collection.latitude") - @Mapping(target = "longitude", source = "collection.longitude") - @Mapping(target = "locationAlias", source = "collection.locationAlias") - @Mapping(target = "address", source = "collection.address") + @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))") + @Mapping(target = "address", expression = "java(CollectionLocationMasker.address(collection, isMine))") @Mapping(target = "note", source = "collection.note") @Mapping(target = "likeCount", source = "likeCount") @Mapping(target = "commentCount", source = "commentCount") @@ -41,7 +42,8 @@ CommunityCollectionInfo toCommunityCollectionInfo( Long commentCount, Boolean isLiked, Boolean isPopular, - Long suggestionUserCount + Long suggestionUserCount, + boolean isMine ); @Mapping(target = "userId", source = "user.id") diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepositoryTest.java index e1cfcccf..952e5b1c 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepositoryTest.java @@ -2,8 +2,11 @@ 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.dex.bird.core.entity.Bird; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; import org.devkor.apu.saerok_server.domain.user.core.entity.User; import org.devkor.apu.saerok_server.testsupport.AbstractPostgresContainerTest; +import org.devkor.apu.saerok_server.testsupport.builder.BirdBuilder; import org.devkor.apu.saerok_server.testsupport.builder.CollectionBuilder; import org.devkor.apu.saerok_server.testsupport.builder.UserBuilder; import org.junit.jupiter.api.DisplayName; @@ -45,9 +48,19 @@ private UserBirdCollection newCollection( User owner, Point point, AccessLevelType accessLevel + ) { + return newCollection(owner, null, point, accessLevel); + } + + private UserBirdCollection newCollection( + User owner, + Bird bird, + Point point, + AccessLevelType accessLevel ) { return new CollectionBuilder(em) .owner(owner) + .bird(bird) .location(point) .accessLevel(accessLevel) .build(); @@ -163,6 +176,72 @@ void findNearby_withLimit_appliesMaxResults() throws Exception { assertEquals(2, result.size()); } + @Test + @DisplayName("주변 조회는 보호등급 새 컬렉션을 결과에서 제외한다") + void findNearby_excludesProtectedBirdCollections() throws Exception { + // given + User owner = newUser(); + Point ref = gf.createPoint(new Coordinate(126.9780, 37.5665)); + Point near = gf.createPoint(new Coordinate(126.9781, 37.5664)); + + Bird normalBird = new BirdBuilder(em) + .korName("normal-nearby-" + System.nanoTime()) + .build(); + Bird protectedBird = new BirdBuilder(em) + .korName("protected-nearby-" + System.nanoTime()) + .conservationGrade(ConservationGrade.GRADE_I) + .build(); + + UserBirdCollection normalCollection = newCollection(owner, normalBird, near, AccessLevelType.PUBLIC); + UserBirdCollection protectedCollection = newCollection(owner, protectedBird, near, AccessLevelType.PUBLIC); + + em.flush(); + em.clear(); + + // when + List result = collectionRepository.findNearby(ref, 1_000, null, false, null); + long candidateCount = collectionRepository.countNearbyCandidates(ref, 1_000, null, false); + + // then + assertEquals(1, result.size()); + assertEquals(1L, candidateCount); + assertTrue(result.stream().anyMatch(c -> c.getId().equals(normalCollection.getId()))); + assertFalse(result.stream().anyMatch(c -> c.getId().equals(protectedCollection.getId()))); + } + + @Test + @DisplayName("mineOnly 주변 조회도 보호등급 새 컬렉션을 결과에서 제외한다") + void findNearby_mineOnly_excludesProtectedBirdCollections() throws Exception { + // given + User owner = newUser(); + Point ref = gf.createPoint(new Coordinate(126.9780, 37.5665)); + Point near = gf.createPoint(new Coordinate(126.9781, 37.5664)); + + Bird normalBird = new BirdBuilder(em) + .korName("normal-mine-nearby-" + System.nanoTime()) + .build(); + Bird protectedBird = new BirdBuilder(em) + .korName("protected-mine-nearby-" + System.nanoTime()) + .conservationGrade(ConservationGrade.GRADE_II) + .build(); + + UserBirdCollection normalCollection = newCollection(owner, normalBird, near, AccessLevelType.PRIVATE); + UserBirdCollection protectedCollection = newCollection(owner, protectedBird, near, AccessLevelType.PRIVATE); + + em.flush(); + em.clear(); + + // when + List result = collectionRepository.findNearby(ref, 1_000, owner.getId(), true, null); + long candidateCount = collectionRepository.countNearbyCandidates(ref, 1_000, owner.getId(), true); + + // then + assertEquals(1, result.size()); + assertEquals(1L, candidateCount); + assertTrue(result.stream().anyMatch(c -> c.getId().equals(normalCollection.getId()))); + assertFalse(result.stream().anyMatch(c -> c.getId().equals(protectedCollection.getId()))); + } + @Test @DisplayName("findNearbyEven은 셀 단위 라운드로빈으로 균등 샘플링한다") void findNearbyEven_returnsBalancedSamplesAcrossCells() throws Exception { @@ -200,4 +279,42 @@ void findNearbyEven_returnsBalancedSamplesAcrossCells() throws Exception { assertFalse(result.stream().anyMatch(c -> c.getId().equals(cellASecond.getId())), "같은 셀의 두 번째 후보는 limit보다 뒤 순위로 밀려야 한다"); } + + @Test + @DisplayName("findNearbyEven도 보호등급 새 컬렉션을 결과에서 제외한다") + void findNearbyEven_excludesProtectedBirdCollections() throws Exception { + // given + User owner = newUser(); + Point ref = gf.createPoint(new Coordinate(126.9780, 37.5665)); + Point near = gf.createPoint(new Coordinate(126.9781, 37.5664)); + + Bird normalBird = new BirdBuilder(em) + .korName("normal-even-nearby-" + System.nanoTime()) + .build(); + Bird protectedBird = new BirdBuilder(em) + .korName("protected-even-nearby-" + System.nanoTime()) + .conservationGrade(ConservationGrade.GRADE_I) + .build(); + + UserBirdCollection normalCollection = newCollection(owner, normalBird, near, AccessLevelType.PUBLIC); + UserBirdCollection protectedCollection = newCollection(owner, protectedBird, near, AccessLevelType.PUBLIC); + + em.flush(); + em.clear(); + + // when + List result = collectionRepository.findNearbyEven( + ref, + 1_000, + null, + false, + 10, + 80 + ); + + // then + assertEquals(1, result.size()); + assertTrue(result.stream().anyMatch(c -> c.getId().equals(normalCollection.getId()))); + assertFalse(result.stream().anyMatch(c -> c.getId().equals(protectedCollection.getId()))); + } } diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/service/CollectionLocationMaskerTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/service/CollectionLocationMaskerTest.java new file mode 100644 index 00000000..4cb510b0 --- /dev/null +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/service/CollectionLocationMaskerTest.java @@ -0,0 +1,78 @@ +package org.devkor.apu.saerok_server.domain.collection.core.service; + +import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; +import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.Bird; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +class CollectionLocationMaskerTest { + + private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory(); + + @Test + @DisplayName("보호등급 새 컬렉션은 소유자가 아닌 사용자에게 위치를 숨긴다") + void protectedBird_nonOwner_masksLocation() { + UserBirdCollection collection = collectionWithBird(ConservationGrade.GRADE_I); + + assertThat(CollectionLocationMasker.shouldMaskLocation(collection, false)).isTrue(); + assertThat(CollectionLocationMasker.latitude(collection, false)).isNull(); + assertThat(CollectionLocationMasker.longitude(collection, false)).isNull(); + assertThat(CollectionLocationMasker.locationAlias(collection, false)).isNull(); + assertThat(CollectionLocationMasker.address(collection, false)).isNull(); + } + + @Test + @DisplayName("보호등급 새 컬렉션도 소유자에게는 원본 위치를 보여준다") + void protectedBird_owner_keepsLocation() { + UserBirdCollection collection = collectionWithBird(ConservationGrade.GRADE_II); + + assertThat(CollectionLocationMasker.shouldMaskLocation(collection, true)).isFalse(); + assertThat(CollectionLocationMasker.latitude(collection, true)).isEqualTo(37.5665); + assertThat(CollectionLocationMasker.longitude(collection, true)).isEqualTo(126.9780); + assertThat(CollectionLocationMasker.locationAlias(collection, true)).isEqualTo("서울광장"); + assertThat(CollectionLocationMasker.address(collection, true)).isEqualTo("서울 중구"); + } + + @Test + @DisplayName("보호등급이 없는 새 컬렉션은 소유자가 아니어도 위치를 보여준다") + void unprotectedBird_nonOwner_keepsLocation() { + UserBirdCollection collection = collectionWithBird(ConservationGrade.NONE); + + assertThat(CollectionLocationMasker.shouldMaskLocation(collection, false)).isFalse(); + assertThat(CollectionLocationMasker.latitude(collection, false)).isEqualTo(37.5665); + assertThat(CollectionLocationMasker.longitude(collection, false)).isEqualTo(126.9780); + assertThat(CollectionLocationMasker.locationAlias(collection, false)).isEqualTo("서울광장"); + assertThat(CollectionLocationMasker.address(collection, false)).isEqualTo("서울 중구"); + } + + @Test + @DisplayName("bird가 아직 없는 동정 요청 컬렉션은 위치를 숨기지 않는다") + void pendingCollection_keepsLocation() { + UserBirdCollection collection = collectionWithBird(null); + + assertThat(CollectionLocationMasker.shouldMaskLocation(collection, false)).isFalse(); + assertThat(CollectionLocationMasker.latitude(collection, false)).isEqualTo(37.5665); + assertThat(CollectionLocationMasker.longitude(collection, false)).isEqualTo(126.9780); + } + + private static UserBirdCollection collectionWithBird(ConservationGrade grade) { + UserBirdCollection collection = new UserBirdCollection(); + collection.setLocation(GEOMETRY_FACTORY.createPoint(new Coordinate(126.9780, 37.5665))); + collection.setLocationAlias("서울광장"); + collection.setAddress("서울 중구"); + + if (grade != null) { + Bird bird = new Bird(); + ReflectionTestUtils.setField(bird, "conservationGrade", grade); + ReflectionTestUtils.setField(collection, "bird", bird); + } + + return collection; + } +} From 01e92aa162ee532f78d3c4b3e1094d49d6134a71 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 24 May 2026 16:33:33 +0900 Subject: [PATCH 6/6] =?UTF-8?q?refactor:=20=EC=9E=90=EA=B8=B0=20=EC=9E=90?= =?UTF-8?q?=EC=8B=A0=EC=97=90=EA=B2=90=20=EC=A7=80=EB=8F=84=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B3=B4=EC=9D=B4=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/repository/CollectionRepository.java | 24 +++--- .../repository/CollectionRepositoryTest.java | 76 ++++++++++++++++++- 2 files changed, 87 insertions(+), 13 deletions(-) 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 0262c49b..3af7c454 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 @@ -57,14 +57,12 @@ public List findNearby(Point ref, double radiusMeters, Long String sqlMineOnly = """ SELECT c.* FROM user_bird_collection c - LEFT JOIN bird b ON b.id = c.bird_id WHERE ST_DWithin( c.location::geography, CAST(:refPoint AS geography), :radius ) AND c.user_id = :userId - AND (c.bird_id IS NULL OR b.conservation_grade = 'NONE') ORDER BY ST_Distance( c.location::geography, CAST(:refPoint AS geography) @@ -93,7 +91,11 @@ WHERE ST_DWithin( c.access_level = 'PUBLIC' OR (CAST(:userId AS bigint) IS NOT NULL AND c.user_id = :userId) ) - AND (c.bird_id IS NULL OR b.conservation_grade = 'NONE') + AND ( + c.bird_id IS NULL + OR b.conservation_grade = 'NONE' + OR (CAST(:userId AS bigint) IS NOT NULL AND c.user_id = :userId) + ) ORDER BY ST_Distance( c.location::geography, CAST(:refPoint AS geography) @@ -115,14 +117,12 @@ public long countNearbyCandidates(Point ref, double radiusMeters, Long userId, b String sql = """ SELECT COUNT(*) FROM user_bird_collection c - LEFT JOIN bird b ON b.id = c.bird_id WHERE ST_DWithin( c.location::geography, CAST(:refPoint AS geography), :radius ) AND c.user_id = :userId - AND (c.bird_id IS NULL OR b.conservation_grade = 'NONE') """; var query = em.createNativeQuery(sql) @@ -146,7 +146,11 @@ WHERE ST_DWithin( c.access_level = 'PUBLIC' OR (CAST(:userId AS bigint) IS NOT NULL AND c.user_id = :userId) ) - AND (c.bird_id IS NULL OR b.conservation_grade = 'NONE') + AND ( + c.bird_id IS NULL + OR b.conservation_grade = 'NONE' + OR (CAST(:userId AS bigint) IS NOT NULL AND c.user_id = :userId) + ) """; var query = em.createNativeQuery(sql) @@ -177,14 +181,12 @@ WITH candidates AS ( ST_Distance(c.location::geography, CAST(:refPoint AS geography)) AS dist, ST_SnapToGrid(ST_Transform(c.location, 3857), :gridSize, :gridSize) AS cell_id FROM user_bird_collection c - LEFT JOIN bird b ON b.id = c.bird_id WHERE ST_DWithin( c.location::geography, CAST(:refPoint AS geography), :radius ) AND c.user_id = :userId - AND (c.bird_id IS NULL OR b.conservation_grade = 'NONE') ), ranked AS ( SELECT id, dist, @@ -223,7 +225,11 @@ WHERE ST_DWithin( c.access_level = 'PUBLIC' OR (CAST(:userId AS bigint) IS NOT NULL AND c.user_id = :userId) ) - AND (c.bird_id IS NULL OR b.conservation_grade = 'NONE') + AND ( + c.bird_id IS NULL + OR b.conservation_grade = 'NONE' + OR (CAST(:userId AS bigint) IS NOT NULL AND c.user_id = :userId) + ) ), ranked AS ( SELECT id, dist, diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepositoryTest.java index 952e5b1c..c0d23ce5 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepositoryTest.java @@ -210,8 +210,8 @@ void findNearby_excludesProtectedBirdCollections() throws Exception { } @Test - @DisplayName("mineOnly 주변 조회도 보호등급 새 컬렉션을 결과에서 제외한다") - void findNearby_mineOnly_excludesProtectedBirdCollections() throws Exception { + @DisplayName("mineOnly 주변 조회는 내 보호등급 새 컬렉션도 결과에 포함한다") + void findNearby_mineOnly_includesMyProtectedBirdCollections() throws Exception { // given User owner = newUser(); Point ref = gf.createPoint(new Coordinate(126.9780, 37.5665)); @@ -235,11 +235,46 @@ void findNearby_mineOnly_excludesProtectedBirdCollections() throws Exception { List result = collectionRepository.findNearby(ref, 1_000, owner.getId(), true, null); long candidateCount = collectionRepository.countNearbyCandidates(ref, 1_000, owner.getId(), true); + // then + assertEquals(2, result.size()); + assertEquals(2L, candidateCount); + assertTrue(result.stream().anyMatch(c -> c.getId().equals(normalCollection.getId()))); + assertTrue(result.stream().anyMatch(c -> c.getId().equals(protectedCollection.getId()))); + } + + @Test + @DisplayName("로그인 주변 조회는 내 보호등급 새 컬렉션만 포함하고 타인의 보호등급 새 컬렉션은 제외한다") + void findNearby_all_includesMyProtectedBirdCollectionsOnly() throws Exception { + // given + User me = newUser(); + User other = newUser(); + Point ref = gf.createPoint(new Coordinate(126.9780, 37.5665)); + Point near = gf.createPoint(new Coordinate(126.9781, 37.5664)); + + Bird myProtectedBird = new BirdBuilder(em) + .korName("my-protected-nearby-" + System.nanoTime()) + .conservationGrade(ConservationGrade.GRADE_I) + .build(); + Bird otherProtectedBird = new BirdBuilder(em) + .korName("other-protected-nearby-" + System.nanoTime()) + .conservationGrade(ConservationGrade.GRADE_II) + .build(); + + UserBirdCollection myProtectedCollection = newCollection(me, myProtectedBird, near, AccessLevelType.PRIVATE); + UserBirdCollection otherProtectedCollection = newCollection(other, otherProtectedBird, near, AccessLevelType.PUBLIC); + + em.flush(); + em.clear(); + + // when + List result = collectionRepository.findNearby(ref, 1_000, me.getId(), false, null); + long candidateCount = collectionRepository.countNearbyCandidates(ref, 1_000, me.getId(), false); + // then assertEquals(1, result.size()); assertEquals(1L, candidateCount); - assertTrue(result.stream().anyMatch(c -> c.getId().equals(normalCollection.getId()))); - assertFalse(result.stream().anyMatch(c -> c.getId().equals(protectedCollection.getId()))); + assertTrue(result.stream().anyMatch(c -> c.getId().equals(myProtectedCollection.getId()))); + assertFalse(result.stream().anyMatch(c -> c.getId().equals(otherProtectedCollection.getId()))); } @Test @@ -317,4 +352,37 @@ void findNearbyEven_excludesProtectedBirdCollections() throws Exception { assertTrue(result.stream().anyMatch(c -> c.getId().equals(normalCollection.getId()))); assertFalse(result.stream().anyMatch(c -> c.getId().equals(protectedCollection.getId()))); } + + @Test + @DisplayName("findNearbyEven mineOnly는 내 보호등급 새 컬렉션도 결과에 포함한다") + void findNearbyEven_mineOnly_includesMyProtectedBirdCollections() throws Exception { + // given + User owner = newUser(); + Point ref = gf.createPoint(new Coordinate(126.9780, 37.5665)); + Point near = gf.createPoint(new Coordinate(126.9781, 37.5664)); + + Bird protectedBird = new BirdBuilder(em) + .korName("protected-even-mine-nearby-" + System.nanoTime()) + .conservationGrade(ConservationGrade.GRADE_I) + .build(); + + UserBirdCollection protectedCollection = newCollection(owner, protectedBird, near, AccessLevelType.PRIVATE); + + em.flush(); + em.clear(); + + // when + List result = collectionRepository.findNearbyEven( + ref, + 1_000, + owner.getId(), + true, + 10, + 80 + ); + + // then + assertEquals(1, result.size()); + assertTrue(result.stream().anyMatch(c -> c.getId().equals(protectedCollection.getId()))); + } }