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
26 changes: 26 additions & 0 deletions src/main/java/com/payper/server/map/controller/MapApi.java
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<List<NearbySearchResponse.NearbyPlaceItem>>> getNearbyTop10(
@Parameter(description = "검색 조건 (위도, 경도, 반경(km))", required = true) NearbySearchRequest request);
}
31 changes: 31 additions & 0 deletions src/main/java/com/payper/server/map/controller/MapController.java
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<List<NearbySearchResponse.NearbyPlaceItem>>> getNearbyTop10(
@Valid @ModelAttribute NearbySearchRequest request) {

List<NearbySearchResponse.NearbyPlaceItem> result =
nearbyMerchantService.searchNearbyTop10(request.latitude(), request.longitude(), request.radiusKm());
return ResponseEntity.ok(ApiResponse.ok(result));
}
}
18 changes: 18 additions & 0 deletions src/main/java/com/payper/server/map/dto/NearbySearchRequest.java
Original file line number Diff line number Diff line change
@@ -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) {}
25 changes: 25 additions & 0 deletions src/main/java/com/payper/server/map/dto/NearbySearchResponse.java
Original file line number Diff line number Diff line change
@@ -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) {}
}
Original file line number Diff line number Diff line change
@@ -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<String> TOP10_MERCHANTS =
List.of("스타벅스", "맥도날드", "CGV", "올리브영", "다이소", "GS25", "CU", "이디야커피", "파리바게뜨", "교촌치킨");

private final MerchantLocationRepository merchantLocationRepository;

public List<NearbySearchResponse.NearbyPlaceItem> 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<String, List<MerchantLocation>> byMerchant =
merchantLocationRepository
.findByMerchantNamesInBoundingBox(TOP10_MERCHANTS, minLat, maxLat, minLon, maxLon)
.stream()
.collect(Collectors.groupingBy(ml -> ml.getMerchant().getName()));
Comment thread
Answl marked this conversation as resolved.

List<NearbySearchResponse.NearbyPlaceItem> results = new ArrayList<>();

for (int i = 0; i < TOP10_MERCHANTS.size() && results.size() < MAX_RESULTS; i++) {
String merchantName = TOP10_MERCHANTS.get(i);
List<MerchantLocation> 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);
Comment thread
Answl marked this conversation as resolved.
}

return results;
Comment thread
Answl marked this conversation as resolved.
}

/** Haversine 공식으로 두 좌표 간 거리(km)를 계산한다. */
private double haversine(double lat1, double lon1, double lat2, double lon2) {
Comment thread
Answl marked this conversation as resolved.
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;
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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<MerchantLocation, Long> {

@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<MerchantLocation> findByMerchantNamesInBoundingBox(
@Param("merchantNames") List<String> merchantNames,
@Param("minLat") double minLat,
@Param("maxLat") double maxLat,
@Param("minLon") double minLon,
@Param("maxLon") double maxLon);
}
4 changes: 3 additions & 1 deletion src/main/java/com/payper/server/security/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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("/**"),
Expand Down