diff --git a/src/main/java/com/payper/server/map/controller/MapApi.java b/src/main/java/com/payper/server/map/controller/MapApi.java new file mode 100644 index 0000000..38acd1a --- /dev/null +++ b/src/main/java/com/payper/server/map/controller/MapApi.java @@ -0,0 +1,26 @@ +package com.payper.server.map.controller; + +import com.payper.server.global.response.ApiResponse; +import com.payper.server.map.dto.NearbySearchRequest; +import com.payper.server.map.dto.NearbySearchResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import org.springframework.http.ResponseEntity; + +@Tag(name = "지도", description = "사용자 위치 기반 가맹점 검색 API") +public interface MapApi { + + @Operation( + summary = "반경 내 Top10 가맹점 검색", + description = """ + 사용자 위치(위도, 경도)와 반경(km)을 기준으로 DB에 저장된 가맹점 지점 중 + Top10 가맹점에 해당하는 지점을 최대 10개 반환. + 반환 순서: Top10 우선순위 → 같은 가맹점 내 거리 오름차순. + Top10 가맹점이 DB에 없으면 결과에서 제외됨. + """, + security = {}) + ResponseEntity>> getNearbyTop10( + @Parameter(description = "검색 조건 (위도, 경도, 반경(km))", required = true) NearbySearchRequest request); +} diff --git a/src/main/java/com/payper/server/map/controller/MapController.java b/src/main/java/com/payper/server/map/controller/MapController.java new file mode 100644 index 0000000..0653608 --- /dev/null +++ b/src/main/java/com/payper/server/map/controller/MapController.java @@ -0,0 +1,31 @@ +package com.payper.server.map.controller; + +import com.payper.server.global.response.ApiResponse; +import com.payper.server.map.dto.NearbySearchRequest; +import com.payper.server.map.dto.NearbySearchResponse; +import com.payper.server.map.service.NearbyMerchantService; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/map") +@RequiredArgsConstructor +public class MapController implements MapApi { + + private final NearbyMerchantService nearbyMerchantService; + + @GetMapping("/nearby-top10") + public ResponseEntity>> getNearbyTop10( + @Valid @ModelAttribute NearbySearchRequest request) { + + List result = + nearbyMerchantService.searchNearbyTop10(request.latitude(), request.longitude(), request.radiusKm()); + return ResponseEntity.ok(ApiResponse.ok(result)); + } +} diff --git a/src/main/java/com/payper/server/map/dto/NearbySearchRequest.java b/src/main/java/com/payper/server/map/dto/NearbySearchRequest.java new file mode 100644 index 0000000..ab42238 --- /dev/null +++ b/src/main/java/com/payper/server/map/dto/NearbySearchRequest.java @@ -0,0 +1,18 @@ +package com.payper.server.map.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "반경 내 Top10 가맹점 검색 요청") +public record NearbySearchRequest( + @Schema(description = "사용자 위도", example = "37.5665") @NotNull + Double latitude, + + @Schema(description = "사용자 경도", example = "126.9780") @NotNull + Double longitude, + + @Schema(description = "검색 반경 (km, 0 초과)", example = "1.0") + @NotNull + @DecimalMin(value = "0.0", inclusive = false) + Double radiusKm) {} diff --git a/src/main/java/com/payper/server/map/dto/NearbySearchResponse.java b/src/main/java/com/payper/server/map/dto/NearbySearchResponse.java new file mode 100644 index 0000000..25ae3ad --- /dev/null +++ b/src/main/java/com/payper/server/map/dto/NearbySearchResponse.java @@ -0,0 +1,25 @@ +package com.payper.server.map.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public class NearbySearchResponse { + + @Schema(description = "반경 내 Top10 가맹점 지점 정보") + public record NearbyPlaceItem( + @Schema(description = "Top10 가맹점 우선순위 (1 = 최우선)", example = "1") + int merchantRank, + + @Schema(description = "가맹점명", example = "스타벅스") String merchantName, + + @Schema(description = "지점명", example = "스타벅스 강남점") + String placeName, + + @Schema(description = "지점 위도", example = "37.4979") + double latitude, + + @Schema(description = "지점 경도", example = "127.0276") + double longitude, + + @Schema(description = "사용자로부터의 거리 (km)", example = "0.35") + double distanceKm) {} +} diff --git a/src/main/java/com/payper/server/map/service/NearbyMerchantService.java b/src/main/java/com/payper/server/map/service/NearbyMerchantService.java new file mode 100644 index 0000000..b730465 --- /dev/null +++ b/src/main/java/com/payper/server/map/service/NearbyMerchantService.java @@ -0,0 +1,89 @@ +package com.payper.server.map.service; + +import com.payper.server.map.dto.NearbySearchResponse; +import com.payper.server.merchant.entity.MerchantLocation; +import com.payper.server.merchant.repository.MerchantLocationRepository; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class NearbyMerchantService { + + private static final int MAX_RESULTS = 10; + private static final double EARTH_RADIUS_KM = 6371.0; + + private static final List TOP10_MERCHANTS = + List.of("스타벅스", "맥도날드", "CGV", "올리브영", "다이소", "GS25", "CU", "이디야커피", "파리바게뜨", "교촌치킨"); + + private final MerchantLocationRepository merchantLocationRepository; + + public List searchNearbyTop10( + double userLat, double userLng, double radiusKm) { + + // 위도 1도는 약 111km, 경도 1도는 cos(위도) * 111km + double latChange = radiusKm / 111.0; + double lonChange = radiusKm / (111.0 * Math.cos(Math.toRadians(userLat))); + + double minLat = userLat - latChange; + double maxLat = userLat + latChange; + double minLon = userLng - lonChange; + double maxLon = userLng + lonChange; + + Map> byMerchant = + merchantLocationRepository + .findByMerchantNamesInBoundingBox(TOP10_MERCHANTS, minLat, maxLat, minLon, maxLon) + .stream() + .collect(Collectors.groupingBy(ml -> ml.getMerchant().getName())); + + List results = new ArrayList<>(); + + for (int i = 0; i < TOP10_MERCHANTS.size() && results.size() < MAX_RESULTS; i++) { + String merchantName = TOP10_MERCHANTS.get(i); + List locations = byMerchant.get(merchantName); + if (locations == null) continue; + + int rank = i + 1; + int remaining = MAX_RESULTS - results.size(); + + locations.stream() + .map(ml -> new Object() { + final MerchantLocation location = ml; + final double distance = haversine(userLat, userLng, ml.getLatitude(), ml.getLongitude()); + }) + .filter(item -> item.distance <= radiusKm) + .sorted(Comparator.comparingDouble(item -> item.distance)) + .limit(remaining) + .map(item -> new NearbySearchResponse.NearbyPlaceItem( + rank, + merchantName, + item.location.getPlaceName(), + item.location.getLatitude(), + item.location.getLongitude(), + item.distance)) + .forEach(results::add); + } + + return results; + } + + /** Haversine 공식으로 두 좌표 간 거리(km)를 계산한다. */ + private double haversine(double lat1, double lon1, double lat2, double lon2) { + double dLat = Math.toRadians(lat2 - lat1); + double dLon = Math.toRadians(lon2 - lon1); + double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(Math.toRadians(lat1)) + * Math.cos(Math.toRadians(lat2)) + * Math.sin(dLon / 2) + * Math.sin(dLon / 2); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return EARTH_RADIUS_KM * c; + } +} diff --git a/src/main/java/com/payper/server/merchant/entity/MerchantLocation.java b/src/main/java/com/payper/server/merchant/entity/MerchantLocation.java new file mode 100644 index 0000000..8d0884c --- /dev/null +++ b/src/main/java/com/payper/server/merchant/entity/MerchantLocation.java @@ -0,0 +1,39 @@ +package com.payper.server.merchant.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MerchantLocation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "merchant_id", nullable = false) + private Merchant merchant; + + /** 지점명 (예: 스타벅스 강남점) */ + @Column(nullable = false) + private String placeName; + + @Column(nullable = false) + private Double latitude; + + @Column(nullable = false) + private Double longitude; + + public static MerchantLocation create(Merchant merchant, String placeName, Double latitude, Double longitude) { + return MerchantLocation.builder() + .merchant(merchant) + .placeName(placeName) + .latitude(latitude) + .longitude(longitude) + .build(); + } +} diff --git a/src/main/java/com/payper/server/merchant/repository/MerchantLocationRepository.java b/src/main/java/com/payper/server/merchant/repository/MerchantLocationRepository.java new file mode 100644 index 0000000..c19cd5b --- /dev/null +++ b/src/main/java/com/payper/server/merchant/repository/MerchantLocationRepository.java @@ -0,0 +1,26 @@ +package com.payper.server.merchant.repository; + +import com.payper.server.merchant.entity.MerchantLocation; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface MerchantLocationRepository extends JpaRepository { + + @Query(""" + select ml from MerchantLocation ml + join fetch ml.merchant m + where m.name in :merchantNames + and ml.latitude between :minLat and :maxLat + and ml.longitude between :minLon and :maxLon +""") + List findByMerchantNamesInBoundingBox( + @Param("merchantNames") List merchantNames, + @Param("minLat") double minLat, + @Param("maxLat") double maxLat, + @Param("minLon") double minLon, + @Param("maxLon") double maxLon); +} diff --git a/src/main/java/com/payper/server/security/SecurityConfig.java b/src/main/java/com/payper/server/security/SecurityConfig.java index 8b971cc..950a36b 100644 --- a/src/main/java/com/payper/server/security/SecurityConfig.java +++ b/src/main/java/com/payper/server/security/SecurityConfig.java @@ -52,7 +52,9 @@ void init() { requestMatcher.matcher(HttpMethod.GET, "/api/v1/posts/**"), requestMatcher.matcher(HttpMethod.GET, "/api/v1/comments/*/replies"), requestMatcher.matcher(HttpMethod.GET, "/api/v1/merchants/**"), - requestMatcher.matcher(HttpMethod.GET, "/api/v1/categories/**")); + requestMatcher.matcher(HttpMethod.GET, "/api/v1/categories/**"), + requestMatcher.matcher(HttpMethod.GET, "/api/v1/map/**")); + // 인증이 필요한 요청 authenticatedRequestMatcher = new OrRequestMatcher( // requestMatcher.matcher("/**"),