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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ repositories {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'org.postgresql:postgresql'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.security.PermitAll;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.devkor.apu.saerok_server.domain.collection.api.dto.request.*;
import org.devkor.apu.saerok_server.domain.collection.api.dto.response.CreateCollectionImageResponse;
Expand All @@ -17,11 +18,13 @@
import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetNearbyCollectionsResponse;
import org.devkor.apu.saerok_server.domain.collection.api.dto.response.MyCollectionsResponse;
import org.devkor.apu.saerok_server.domain.collection.api.dto.response.PresignResponse;
import org.devkor.apu.saerok_server.domain.collection.api.dto.response.SearchNearbyCollectionsResponse;
import org.devkor.apu.saerok_server.domain.collection.api.dto.response.UpdateCollectionResponse;
import org.devkor.apu.saerok_server.domain.collection.application.CollectionCommandService;
import org.devkor.apu.saerok_server.domain.collection.application.CollectionImageCommandService;
import org.devkor.apu.saerok_server.domain.collection.application.CollectionQueryService;
import org.devkor.apu.saerok_server.domain.collection.application.dto.GetNearbyCollectionsCommand;
import org.devkor.apu.saerok_server.domain.collection.application.dto.SearchNearbyCollectionsCommand;
import org.devkor.apu.saerok_server.domain.collection.application.NearbyCollectionsMode;
import org.devkor.apu.saerok_server.domain.collection.mapper.CollectionWebMapper;
import org.devkor.apu.saerok_server.global.security.principal.UserPrincipal;
Expand Down Expand Up @@ -370,6 +373,54 @@ public void deleteCollectionImage(
collectionImageCommandService.deleteCollectionImage(userId, collectionId, imageId);
}

@PostMapping("/nearby/search")
@PermitAll
@Operation(
summary = "새 이름으로 주위 컬렉션 검색 (인증: optional)",
security = @SecurityRequirement(name = "bearerAuth"),
description = """
현재 위치를 기준으로 새 한국어 이름에 검색어가 포함된 컬렉션을 조회합니다.

- `radiusMeters`는 필수이며, 프론트 지도 반경을 미터 단위로 전달합니다.
- `radiusMeters`가 1.25km 미만이면 해당 반경을 한 번 검색한 뒤 1.25km로 확장합니다.
- 이후 검색 결과가 없으면 반경을 두 배씩 확장하고, 최대 150km까지 조회합니다.
- 최대 반경에도 결과가 없으면 `noResults`가 true로 반환됩니다.
- 검색 결과는 기본 최대 60건이며, 반경 확장마다 최대 개수가 절반으로 줄고 기본 요청은 최소 10건으로 유지됩니다.
클라이언트가 10건보다 작은 limit을 지정하면 그 값을 넘지 않습니다.
- 현재는 동정 완료된 새의 한국어 이름만 검색합니다. `tempBirdName`은 아직 검색에 포함하지 않습니다.
""",
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
required = true,
content = @Content(schema = @Schema(implementation = SearchNearbyCollectionsRequest.class))
),
responses = {
@ApiResponse(
responseCode = "200", description = "검색 성공",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = SearchNearbyCollectionsResponse.class)
)
),
@ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content)
}
)
public SearchNearbyCollectionsResponse searchNearbyCollectionsByBirdName(
@AuthenticationPrincipal UserPrincipal userPrincipal,
@Valid @RequestBody SearchNearbyCollectionsRequest request
) {
Long userId = userPrincipal == null ? null : userPrincipal.getId();
return collectionQueryService.searchNearbyCollectionsByBirdName(
new SearchNearbyCollectionsCommand(
userId,
request.getLatitude(),
request.getLongitude(),
request.getRadiusMeters(),
request.getQuery(),
request.getLimit()
)
);
}

@GetMapping("/nearby")
@PermitAll
@Operation(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package org.devkor.apu.saerok_server.domain.collection.api.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@Schema(description = "새 이름으로 주위 컬렉션을 검색하는 요청 DTO")
public class SearchNearbyCollectionsRequest {

@NotNull(message = "위도를 입력해주세요.")
@DecimalMin(value = "-90.0", message = "위도는 -90 이상이어야 해요.")
@DecimalMax(value = "90.0", message = "위도는 90 이하여야 해요.")
@Schema(description = "검색 중심 위도 (-90~90)", example = "37.5665", requiredMode = Schema.RequiredMode.REQUIRED)
private Double latitude;

@NotNull(message = "경도를 입력해주세요.")
@DecimalMin(value = "-180.0", message = "경도는 -180 이상이어야 해요.")
@DecimalMax(value = "180.0", message = "경도는 180 이하여야 해요.")
@Schema(description = "검색 중심 경도 (-180~180)", example = "126.9780", requiredMode = Schema.RequiredMode.REQUIRED)
private Double longitude;

@NotBlank(message = "query를 입력해주세요.")
@Schema(description = "새 이름 검색어", example = "까치", requiredMode = Schema.RequiredMode.REQUIRED)
private String query;

@NotNull(message = "radiusMeters를 입력해주세요.")
@Positive(message = "radiusMeters는 0보다 커야 해요.")
@Schema(description = "최초 검색 반경 (m).", example = "10", requiredMode = Schema.RequiredMode.REQUIRED)
private Double radiusMeters;

@Positive(message = "limit은 1 이상의 양수여야 해요.")
@Max(value = 60, message = "limit은 60 이하여야 해요.")
@Schema(description = "최초 검색 결과 최대 개수 (생략 시 60, 최대 60). 검색 반경이 늘어날수록 반환 개수도 줄어듭니다.", example = "60")
private Integer limit;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package org.devkor.apu.saerok_server.domain.collection.api.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

@Data
@NoArgsConstructor
@Schema(description = "새 이름으로 검색한 주위 컬렉션 조회 응답 DTO")
public class SearchNearbyCollectionsResponse {

@Schema(description = "검색 결과 컬렉션 목록")
private List<GetNearbyCollectionsResponse.Item> items;

@Schema(
description = "최대 검색 반경까지 확장했지만 검색 결과가 없는지 여부",
example = "false"
)
private boolean noResults;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetCollectionEditDataResponse;
import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetNearbyCollectionsResponse;
import org.devkor.apu.saerok_server.domain.collection.api.dto.response.MyCollectionsResponse;
import org.devkor.apu.saerok_server.domain.collection.api.dto.response.SearchNearbyCollectionsResponse;
import org.devkor.apu.saerok_server.domain.collection.application.dto.GetCollectionEditDataCommand;
import org.devkor.apu.saerok_server.domain.collection.application.dto.GetNearbyCollectionsCommand;
import org.devkor.apu.saerok_server.domain.collection.application.dto.SearchNearbyCollectionsCommand;
import org.devkor.apu.saerok_server.domain.collection.application.helper.CollectionImageUrlService;
import org.devkor.apu.saerok_server.domain.collection.application.strategy.NearbyCollectionsStrategy;
import org.devkor.apu.saerok_server.domain.collection.core.entity.AccessLevelType;
Expand Down Expand Up @@ -39,6 +41,12 @@
@Transactional(readOnly = true)
public class CollectionQueryService {

private static final double BIRD_NAME_SEARCH_FALLBACK_RADIUS_METERS = 1_250;
private static final double MAX_BIRD_NAME_SEARCH_RADIUS_METERS = 150_000;
private static final int DEFAULT_BIRD_NAME_SEARCH_LIMIT = 60;
private static final int MAX_BIRD_NAME_SEARCH_LIMIT = 60;
private static final int MIN_BIRD_NAME_SEARCH_LIMIT = 10;

private final CollectionRepository collectionRepository;
private final CollectionImageRepository collectionImageRepository;
private final CollectionLikeRepository collectionLikeRepository;
Expand Down Expand Up @@ -185,6 +193,69 @@ public GetNearbyCollectionsResponse getNearbyCollections(GetNearbyCollectionsCom
return response;
}

response.setItems(toNearbyCollectionItems(collections, command.userId()));
return response;
}

public SearchNearbyCollectionsResponse searchNearbyCollectionsByBirdName(SearchNearbyCollectionsCommand command) {
if (command.userId() != null) {
userRepository.findById(command.userId()).orElseThrow(() -> new NotFoundException("유효하지 않은 사용자 id예요"));
}

// 1) 근거리 컬렉션 조회 (전략 기반)
Point refPoint = PointFactory.create(command.latitude(), command.longitude());
double radiusMeters = Math.min(command.initialRadiusMeters(), MAX_BIRD_NAME_SEARCH_RADIUS_METERS);
int attempt = 0;

while (true) {
List<UserBirdCollection> collections = collectionRepository.findNearbyByBirdName(
refPoint,
radiusMeters,
command.query().trim(),
command.userId(),
calculateBirdNameSearchLimit(command.limit(), attempt)
);

if (!collections.isEmpty()) {
SearchNearbyCollectionsResponse response = new SearchNearbyCollectionsResponse();
response.setItems(toNearbyCollectionItems(collections, command.userId()));
response.setNoResults(false);
return response;
}

if (radiusMeters >= MAX_BIRD_NAME_SEARCH_RADIUS_METERS) {
SearchNearbyCollectionsResponse response = new SearchNearbyCollectionsResponse();
response.setItems(List.of());
response.setNoResults(true);
return response;
}

radiusMeters = nextBirdNameSearchRadius(radiusMeters);
attempt++;
}
}

private double nextBirdNameSearchRadius(double currentRadiusMeters) {
if (currentRadiusMeters < BIRD_NAME_SEARCH_FALLBACK_RADIUS_METERS) {
return BIRD_NAME_SEARCH_FALLBACK_RADIUS_METERS;
}
return Math.min(currentRadiusMeters * 2, MAX_BIRD_NAME_SEARCH_RADIUS_METERS);
}

private int calculateBirdNameSearchLimit(Integer requestedLimit, int attempt) {
int initialLimit = Math.min(
requestedLimit == null ? DEFAULT_BIRD_NAME_SEARCH_LIMIT : requestedLimit,
MAX_BIRD_NAME_SEARCH_LIMIT
);
int minimumLimit = Math.min(initialLimit, MIN_BIRD_NAME_SEARCH_LIMIT);
return Math.max(minimumLimit, initialLimit / (1 << attempt));
}

private List<GetNearbyCollectionsResponse.Item> toNearbyCollectionItems(
List<UserBirdCollection> collections,
Long userId
) {

// 2) 연관(작성자/새) 엔티티를 미리 불러와(prefetch) LAZY N+1 차단
List<Long> collectionIds = collections.stream().map(UserBirdCollection::getId).toList();
collectionRepository.prefetchUserAndBirdByIds(collectionIds);
Expand All @@ -198,8 +269,8 @@ public GetNearbyCollectionsResponse getNearbyCollections(GetNearbyCollectionsCom
Map<Long, Long> commentCounts = collectionCommentRepository.countByCollectionIds(collectionIds);

// 5) 내가 좋아요 눌렀는지 배치 조회
Map<Long, Boolean> myLikeMap = (command.userId() != null)
? collectionLikeRepository.findLikeStatusByUserIdAndCollectionIds(command.userId(), collectionIds)
Map<Long, Boolean> myLikeMap = (userId != null)
? collectionLikeRepository.findLikeStatusByUserIdAndCollectionIds(userId, collectionIds)
: new LinkedHashMap<>();

// 6) 작성자 프로필 이미지 배치 조회
Expand All @@ -212,10 +283,10 @@ public GetNearbyCollectionsResponse getNearbyCollections(GetNearbyCollectionsCom
.map(c -> {
long likeCount = likeCounts.getOrDefault(c.getId(), 0L);
long commentCount = commentCounts.getOrDefault(c.getId(), 0L);
boolean isLikedByMe = command.userId() != null && myLikeMap.getOrDefault(c.getId(), false);
boolean isLikedByMe = 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());
boolean isMine = userId != null && userId.equals(c.getUser().getId());

return collectionWebMapper.toGetNearbyCollectionsResponseItem(
c,
Expand All @@ -231,7 +302,6 @@ public GetNearbyCollectionsResponse getNearbyCollections(GetNearbyCollectionsCom
})
.toList();

response.setItems(items);
return response;
return items;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.devkor.apu.saerok_server.domain.collection.application.dto;

public record SearchNearbyCollectionsCommand(
Long userId,
Double latitude,
Double longitude,
Double initialRadiusMeters,
String query,
Integer limit
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,53 @@ ORDER BY ST_Distance(
return query.getResultList();
}

/**
* 중심점 반경 안에서 새 한국어 이름으로 컬렉션을 부분 검색한다.
* tempBirdName은 아직 사용하지 않으므로 현재 검색 대상에 포함하지 않는다.
*/
@SuppressWarnings("unchecked")
public List<UserBirdCollection> findNearbyByBirdName(
Point ref,
double radiusMeters,
String query,
Long userId,
Integer limit
) {
String sql = """
SELECT c.*
FROM user_bird_collection c
JOIN bird b ON b.id = c.bird_id
WHERE ST_DWithin(
c.location::geography,
CAST(:refPoint AS geography),
:radius
)
AND b.korean_name LIKE :query
AND (
c.access_level = 'PUBLIC'
OR (CAST(:userId AS bigint) IS NOT NULL AND c.user_id = :userId)
)
AND (
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)
)
""";

var nativeQuery = em.createNativeQuery(sql, UserBirdCollection.class)
.setParameter("refPoint", ref)
.setParameter("radius", radiusMeters)
.setParameter("query", "%" + query + "%")
.setParameter("userId", userId);
if (limit != null) {
nativeQuery.setMaxResults(limit);
}
return nativeQuery.getResultList();
}

public long countNearbyCandidates(Point ref, double radiusMeters, Long userId, boolean isMineOnly) {

if (isMineOnly && userId != null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
CREATE INDEX idx_user_collection_location_geography
ON user_bird_collection USING GIST ((location::geography));
Loading
Loading