Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import com.example.Centralthon.global.external.ai.web.dto.GetTipReq;
import com.example.Centralthon.global.external.ai.web.dto.GetTipRes;
import com.example.Centralthon.global.response.SuccessResponse;
import com.example.Centralthon.global.validation.Latitude;
import com.example.Centralthon.global.validation.Longitude;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
Expand Down Expand Up @@ -56,9 +58,9 @@ public interface MenuApi {
)
ResponseEntity<SuccessResponse<List<NearbyMenusRes>>> nearbyMenus(
@Parameter(name = "lat", description = "사용자 위도", example = "37.468355", required = true)
@RequestParam("lat") Double lat,
@RequestParam("lat") @Latitude Double lat,
@Parameter(name = "lng", description = "사용자 경도", example = "127.039073", required = true)
@RequestParam("lng") Double lng);
@RequestParam("lng") @Longitude Double lng);

@Operation(
summary = "특정 메뉴 판매 가게 조회",
Expand Down Expand Up @@ -104,9 +106,9 @@ ResponseEntity<SuccessResponse<List<StoresByMenuRes>>> storesByMenu(
@Parameter(name = "name", description = "메뉴 이름(공백 포함하여 완전일치)", example = "두부 조림", required = true)
@RequestParam("name") String name,
@Parameter(name = "lat", description = "사용자 위도", example = "37.468355", required = true)
@RequestParam("lat") Double lat,
@RequestParam("lat") @Latitude Double lat,
@Parameter(name = "lng", description = "사용자 경도", example = "127.039073", required = true)
@RequestParam("lng") Double lng);
@RequestParam("lng") @Longitude Double lng);

@Operation(
summary = "메뉴 상세 조회",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import com.example.Centralthon.global.external.ai.web.dto.GetTipReq;
import com.example.Centralthon.global.external.ai.web.dto.GetTipRes;
import com.example.Centralthon.global.response.SuccessResponse;
import com.example.Centralthon.global.validation.Latitude;
import com.example.Centralthon.global.validation.Longitude;
import jakarta.validation.Valid;

import com.example.Centralthon.domain.menu.web.dto.NearbyMenusRes;
Expand All @@ -29,8 +31,8 @@ public class MenuController implements MenuApi {
@GetMapping("")
@Override
public ResponseEntity<SuccessResponse<List<NearbyMenusRes>>> nearbyMenus(
@RequestParam("lat") Double lat,
@RequestParam("lng") Double lng) {
@RequestParam("lat") @Latitude Double lat,
@RequestParam("lng") @Longitude Double lng) {

List<NearbyMenusRes> menus = menuService.nearbyMenus(lat,lng);

Expand All @@ -42,8 +44,8 @@ public ResponseEntity<SuccessResponse<List<NearbyMenusRes>>> nearbyMenus(
@Override
public ResponseEntity<SuccessResponse<List<StoresByMenuRes>>> storesByMenu(
@RequestParam("name") String name,
@RequestParam("lat") Double lat,
@RequestParam("lng") Double lng) {
@RequestParam("lat") @Latitude Double lat,
@RequestParam("lng") @Longitude Double lng) {

List<StoresByMenuRes> stores = menuService.storesByMenu(name,lat,lng);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import lombok.NoArgsConstructor;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;

Expand All @@ -23,6 +24,12 @@ public static CombinedPath stitch(List<Integer> order, Map<SegmentKey, PedSegmen
if (seg == null) throw new RouteSegmentMissingException();

List<LocationRes> p = seg.path();

if (i > j && p != null && p.size() >= 2) {
p = new ArrayList<>(p);
Collections.reverse(p);
}

if (merged.isEmpty()) merged.addAll(p);
else {
if (!p.isEmpty() && !merged.isEmpty()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import lombok.AccessLevel;
import lombok.NoArgsConstructor;

import java.util.stream.IntStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
Expand All @@ -11,6 +12,10 @@ public final class TspSolver {

public static List<Integer> solveOpenTour(double[][] d) {
int k = d.length;
if (k <= 1) {
return IntStream.range(0, k).boxed().toList(); // [ ] or [0]
}

// 초기화 작업
boolean[] used = new boolean[k];
List<Integer> path = new ArrayList<>();
Expand Down Expand Up @@ -43,6 +48,7 @@ private static List<Integer> twoOpt(List<Integer> tour, double[][] d) {
int n = tour.size();
while (improved) {
improved = false;
outer:
for (int i = 1; i < n - 2; i++) {
for (int k = i + 1; k < n - 1; k++) {
double delta =
Expand All @@ -53,6 +59,7 @@ private static List<Integer> twoOpt(List<Integer> tour, double[][] d) {
if (delta < -1e-6) {
Collections.reverse(tour.subList(i, k + 1));
improved = true;
break outer;
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ public class TmapPedestrianParser {
public PedSegment parsePedestrian(Map<String, Object> pedRes) {
long totalDistance = 0L;
long totalDuration = 0L;
boolean gotDistance = false;
boolean gotDuration = false;

List<LocationRes> path = new ArrayList<>();

List<Map<String, Object>> features =
Expand All @@ -22,20 +25,18 @@ public PedSegment parsePedestrian(Map<String, Object> pedRes) {
// 1) 총거리/시간 추출
for (Map<String, Object> f : features) {
Map<String, Object> props = (Map<String, Object>) f.get("properties");
if (props == null) continue;

Object d = props.get("totalDistance");
long candDist = asLong(d);
if (candDist > 0) totalDistance = candDist;

Object t = props.get("totalTime");
long candTime = asLong(t);
if (candTime > 0) totalDuration = candTime;
if (totalDistance > 0 && totalDuration > 0) break;
}
if(props != null){
if(!gotDistance){
long candDist = asLong(props.get("totalDistance"));
if (candDist > 0) { totalDistance = candDist; gotDistance = true; }
}
if(!gotDuration){
long candTime = asLong(props.get("totalTime"));
if (candTime > 0) { totalDuration = candTime; gotDuration = true; }
}
}

// 2) LineString 경로 좌표 이어붙이기 (중복점 제거)
for (Map<String, Object> f : features) {
// 2) LineString 경로 좌표 이어붙이기 (중복점 제거)
Map<String, Object> geom = (Map<String, Object>) f.get("geometry");
if (geom == null) continue;
String type = String.valueOf(geom.get("type"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@
import com.example.Centralthon.global.util.geo.GeoUtils;
import io.netty.handler.timeout.ReadTimeoutException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClientRequestException;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;

import java.time.Duration;
import java.util.*;
Expand All @@ -20,12 +24,21 @@

@Component
@RequiredArgsConstructor
@Slf4j
public class PedMatrixBuilder {
private final PedestrianRoutingPort routingPort;

private static final Duration PER_CALL_TIMEOUT = Duration.ofSeconds(3);
private static final int RETRIES = 3;
private static final Duration RETRY_DELAY = Duration.ofMillis(250);
@Value("${route.ped.per-call-timeout:3s}")
private Duration perCallTimeout;

@Value("${route.ped.retries:3}")
private int retries;

@Value("${route.ped.retry-delay:250ms}")
private Duration retryDelay;

@Value("${route.ped.concurrency:8}")
private int defaultConcurrency;

public PedMatrix build(List<LocationRes> nodes) {
// 전체 노드 수 (0 = 사용자, 1..n = 가게)
Expand All @@ -50,13 +63,14 @@ public PedMatrix build(List<LocationRes> nodes) {
LocationRes b = nodes.get(jj);

Mono<Void> call = routingPort.fetchSegment(a, b)
.timeout(PER_CALL_TIMEOUT)
.timeout(perCallTimeout)
// 일시적 오류만 3회 재시도
.retryWhen(reactor.util.retry.Retry
.fixedDelay(RETRIES, RETRY_DELAY)
.filter(this::isTransientError))
.retryWhen(Retry.fixedDelay(retries, retryDelay)
.filter(this::isTransientError)
.jitter(0.5))
// 실패 시 하버사인 폴백
.onErrorResume(ex -> {
log.warn("API 호출 실패, 원인 : {}",ex.getMessage());
fallbackCount.incrementAndGet();
double dMeter = GeoUtils.calculateDistance(a.lat(), a.lng(), b.lat(), b.lng());
return Mono.just(new PedSegment(Math.round(dMeter), 0L, List.of(a, b)));
Expand All @@ -71,7 +85,14 @@ public PedMatrix build(List<LocationRes> nodes) {
calls.add(call);
}
}
Mono.when(calls).block();
int pairs = calls.size();
int concurrency = Math.min(defaultConcurrency, Math.max(1, pairs));

// 동시에 최대 concurrency개만 실행
Flux.fromIterable(calls)
.flatMap(m -> m, concurrency)
.then()
.block();

// 모든 세그먼트가 폴백 시 예외 처리
int totalCalls = calls.size();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,16 @@ public RouteRes findOptimalPath(RouteReq req) {
List<Store> stores = storeRepository.findAllById(req.getStoreIds());
if (stores.isEmpty()) throw new StoreNotFoundException();

List<LocationRes> nodes = new ArrayList<>();
List<LocationRes> nodes = new ArrayList<>(stores.size() + 1);
List<Long> indexToStoreId = new ArrayList<>(stores.size() + 1);

nodes.add(new LocationRes(req.getUserLng(), req.getUserLat()));
nodes.addAll(stores.stream()
.map(s -> new LocationRes(s.getLongitude(), s.getLatitude()))
.toList());
indexToStoreId.add(null);

for (Store s : stores) {
nodes.add(new LocationRes(s.getLongitude(), s.getLatitude()));
indexToStoreId.add(s.getId());
}

// 1) 보행자 경로 행렬/세그먼트 사전 계산
PedMatrix pm = pedMatrixBuilder.build(nodes);
Expand All @@ -53,7 +58,7 @@ public RouteRes findOptimalPath(RouteReq req) {
// 5) storeId 순서 변환
List<Long> idOrder = order.stream()
.filter(i -> i != 0)
.map(i -> stores.get(i - 1).getId())
.map(indexToStoreId::get)
.toList();

return new RouteRes(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
package com.example.Centralthon.domain.route.web.dto;

import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.*;
import lombok.Getter;
import org.hibernate.validator.constraints.UniqueElements;

import java.util.List;

@Getter
public class RouteReq{
@DecimalMin(value = "-90.0", message = "위도는 -90 이상이어야 합니다.")
@DecimalMax(value = "90.0", message = "위도는 90 이하이어야 합니다.")
@NotNull(message = "userLat을 입력해주세요.")
private Double userLat;

@DecimalMin(value = "-180.0", message = "경도는 -180 이상이어야 합니다.")
@DecimalMax(value = "180.0", message = "경도는 180 이하이어야 합니다.")
@NotNull(message = "userLng을 입력해주세요.")
private Double userLng;

@NotEmpty(message = "storeIds 리스트가 비어있습니다.")
@Size(max = 8, message = "경유지는 최대 8개까지만 지정할 수 있습니다.")
@UniqueElements(message = "storeIds에 중복 값이 포함되어 있습니다.")
private List<Long> storeIds;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import com.example.Centralthon.domain.store.web.dto.NearbyStoresRes;
import com.example.Centralthon.domain.store.web.dto.StoreMenusRes;
import com.example.Centralthon.global.response.SuccessResponse;
import com.example.Centralthon.global.validation.Latitude;
import com.example.Centralthon.global.validation.Longitude;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
Expand Down Expand Up @@ -60,9 +62,9 @@ public interface StoreApi {
)
ResponseEntity<SuccessResponse<List<NearbyStoresRes>>> nearbyStores(
@Parameter(name = "lat", description = "사용자 위도", example = "37.468355", required = true)
@RequestParam("lat") Double lat,
@RequestParam("lat") @Latitude Double lat,
@Parameter(name = "lng", description = "사용자 경도", example = "127.039073", required = true)
@RequestParam("lng") Double lng);
@RequestParam("lng") @Longitude Double lng);

@Operation(
summary = "가게에서 판매 중인 메뉴 조회",
Expand Down Expand Up @@ -117,7 +119,7 @@ ResponseEntity<SuccessResponse<StoreMenusRes>> getStoreMenus(
@Parameter(name = "storeId", in = ParameterIn.PATH, description = "가게 ID", example = "1", required = true)
@PathVariable Long storeId,
@Parameter(name = "lat", in = ParameterIn.QUERY, description = "사용자 위도", example = "37.468355", required = true)
@RequestParam("lat") Double lat,
@RequestParam("lat") @Latitude Double lat,
@Parameter(name = "lng", in = ParameterIn.QUERY, description = "사용자 경도", example = "127.039073", required = true)
@RequestParam("lng") Double lng);
@RequestParam("lng") @Longitude Double lng);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import com.example.Centralthon.domain.store.web.dto.NearbyStoresRes;
import com.example.Centralthon.domain.store.web.dto.StoreMenusRes;
import com.example.Centralthon.global.response.SuccessResponse;
import com.example.Centralthon.global.validation.Latitude;
import com.example.Centralthon.global.validation.Longitude;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
Expand All @@ -22,8 +24,8 @@ public class StoreController implements StoreApi {
@GetMapping("")
@Override
public ResponseEntity<SuccessResponse<List<NearbyStoresRes>>> nearbyStores(
@RequestParam("lat") Double lat,
@RequestParam("lng") Double lng){
@RequestParam("lat") @Latitude Double lat,
@RequestParam("lng") @Longitude Double lng){

List<NearbyStoresRes> stores = storeService.nearbyStores(lat, lng);

Expand All @@ -35,8 +37,8 @@ public ResponseEntity<SuccessResponse<List<NearbyStoresRes>>> nearbyStores(
@Override
public ResponseEntity<SuccessResponse<StoreMenusRes>> getStoreMenus(
@PathVariable Long storeId,
@RequestParam("lat") Double lat,
@RequestParam("lng") Double lng){
@RequestParam("lat") @Latitude Double lat,
@RequestParam("lng") @Longitude Double lng){

StoreMenusRes menus = storeService.getStoreMenus(storeId, lat, lng);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@

import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
import org.springframework.web.servlet.NoHandlerFoundException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.util.List;


@RestControllerAdvice
Expand Down Expand Up @@ -106,6 +108,21 @@ public ResponseEntity<ErrorResponse<?>> handleMissingRequestParam(MissingServlet
return ResponseEntity.status(errorResponse.getHttpStatus()).body(errorResponse);
}

// @RequestParam 검증 실패 (Validation)
@ExceptionHandler(HandlerMethodValidationException.class)
public ResponseEntity<ErrorResponse<?>> handleHandlerMethodValidation(HandlerMethodValidationException e) {
log.error("HandlerMethodValidationException : {}", e.getMessage(), e);

List<String> names = e.getParameterValidationResults().stream()
.map(r -> r.getMethodParameter().getParameterName())
.distinct()
.toList();
String msg = "잘못된 요청 파라미터: " + String.join(", ", names);

ErrorResponse<?> errorResponse = ErrorResponse.of(ErrorResponseCode.INVALID_HTTP_MESSAGE_PARAMETER,msg);
return ResponseEntity.status(errorResponse.getHttpStatus()).body(errorResponse);
}

// 나머지 예외 처리
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse<?>> handleException(Exception e) {
Expand Down
Loading