diff --git a/build.gradle b/build.gradle index 4f22375..b78931b 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,7 @@ 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-webflux' // Lombok compileOnly 'org.projectlombok:lombok' diff --git a/src/main/java/com/example/Centralthon/domain/route/algo/PathStitcher.java b/src/main/java/com/example/Centralthon/domain/route/algo/PathStitcher.java new file mode 100644 index 0000000..4116656 --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/route/algo/PathStitcher.java @@ -0,0 +1,43 @@ +package com.example.Centralthon.domain.route.algo; +import com.example.Centralthon.domain.route.exception.RouteSegmentMissingException; +import com.example.Centralthon.domain.route.model.*; +import com.example.Centralthon.domain.route.web.dto.LocationRes; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class PathStitcher { + + public static CombinedPath stitch(List order, Map segs) { + long distSum = 0, durSum = 0; + List merged = new ArrayList<>(); + + for (int t = 0; t < order.size() - 1; t++) { + int i = order.get(t), j = order.get(t + 1); + + PedSegment seg = segs.get(SegmentKey.of(i, j)); + if (seg == null) throw new RouteSegmentMissingException(); + + List p = seg.path(); + if (merged.isEmpty()) merged.addAll(p); + else { + if (!p.isEmpty() && !merged.isEmpty() + && equalsPoint(merged.get(merged.size()-1), p.get(0))) { + merged.addAll(p.subList(1, p.size())); + } else merged.addAll(p); + } + distSum += seg.distance(); + durSum += seg.duration(); + } + return new CombinedPath(distSum, durSum, merged); + } + + // 좌표 중복 여부 확인 + private static boolean equalsPoint(LocationRes a, LocationRes b) { + return Double.compare(a.lng(), b.lng()) == 0 && Double.compare(a.lat(), b.lat()) == 0; + } +} diff --git a/src/main/java/com/example/Centralthon/domain/route/algo/TspSolver.java b/src/main/java/com/example/Centralthon/domain/route/algo/TspSolver.java new file mode 100644 index 0000000..a759ed5 --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/route/algo/TspSolver.java @@ -0,0 +1,62 @@ +package com.example.Centralthon.domain.route.algo; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class TspSolver { + + public static List solveOpenTour(double[][] d) { + int k = d.length; + // 초기화 작업 + boolean[] used = new boolean[k]; + List path = new ArrayList<>(); + int cur = 0; + used[0] = true; + path.add(0); + + // 최근접 이웃 초기 해 구성 + for (int step = 1; step < k; step++) { + int best = -1; + double bestD = Double.POSITIVE_INFINITY; + for (int j = 1; j < k; j++) if (!used[j]) { + double cand = d[cur][j]; + if (cand < bestD) { + bestD = cand; + best = j; + } + } + used[best] = true; + path.add(best); + cur = best; + } + + return twoOpt(path, d); + } + + // 교차 제거를 통한 로컬 개선 + private static List twoOpt(List tour, double[][] d) { + boolean improved = true; + int n = tour.size(); + while (improved) { + improved = false; + for (int i = 1; i < n - 2; i++) { + for (int k = i + 1; k < n - 1; k++) { + double delta = + - d[tour.get(i - 1)][tour.get(i)] + - d[tour.get(k)][tour.get(k + 1)] + + d[tour.get(i - 1)][tour.get(k)] + + d[tour.get(i)][tour.get(k + 1)]; + if (delta < -1e-6) { + Collections.reverse(tour.subList(i, k + 1)); + improved = true; + } + } + } + } + return tour; + } +} diff --git a/src/main/java/com/example/Centralthon/domain/route/client/TmapPedestrianClient.java b/src/main/java/com/example/Centralthon/domain/route/client/TmapPedestrianClient.java new file mode 100644 index 0000000..acb06f3 --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/route/client/TmapPedestrianClient.java @@ -0,0 +1,41 @@ +package com.example.Centralthon.domain.route.client; + +import com.example.Centralthon.domain.route.model.PedSegment; +import com.example.Centralthon.domain.route.port.PedestrianRoutingPort; +import com.example.Centralthon.domain.route.web.dto.LocationRes; +import lombok.RequiredArgsConstructor; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class TmapPedestrianClient implements PedestrianRoutingPort { + private final WebClient tmapWebClient; + private final TmapPedestrianParser parser; + + @Override + public Mono fetchSegment(LocationRes a, LocationRes b) { + // 1. Tmap 보행자 경로 API 요청 바디 생성 + Map body = Map.of( + "startX", String.valueOf(a.lng()), + "startY", String.valueOf(a.lat()), + "endX", String.valueOf(b.lng()), + "endY", String.valueOf(b.lat()), + "reqCoordType", "WGS84GEO", + "resCoordType", "WGS84GEO", + "startName", "S", "endName", "E" + ); + + // 2. API 호출 → JSON 응답 수신 → parser로 PedSegment 변환 + return tmapWebClient.post() + .uri(ub -> ub.path("/tmap/routes/pedestrian").queryParam("version","1").build()) + .bodyValue(body) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .map(parser::parsePedestrian); + } +} diff --git a/src/main/java/com/example/Centralthon/domain/route/client/TmapPedestrianParser.java b/src/main/java/com/example/Centralthon/domain/route/client/TmapPedestrianParser.java new file mode 100644 index 0000000..5f112c5 --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/route/client/TmapPedestrianParser.java @@ -0,0 +1,79 @@ +package com.example.Centralthon.domain.route.client; + +import com.example.Centralthon.domain.route.model.PedSegment; +import com.example.Centralthon.domain.route.web.dto.LocationRes; +import com.example.Centralthon.global.util.geo.GeoUtils; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Component +public class TmapPedestrianParser { + public PedSegment parsePedestrian(Map pedRes) { + long totalDistance = 0L; + long totalDuration = 0L; + List path = new ArrayList<>(); + + List> features = + (List>) pedRes.getOrDefault("features", List.of()); + + // 1) 총거리/시간 추출 + for (Map f : features) { + Map props = (Map) 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; + } + + // 2) LineString 경로 좌표 이어붙이기 (중복점 제거) + for (Map f : features) { + Map geom = (Map) f.get("geometry"); + if (geom == null) continue; + String type = String.valueOf(geom.get("type")); + if (!"LineString".equalsIgnoreCase(type)) continue; + + List> coords = (List>) geom.get("coordinates"); + if (coords == null) continue; + + for (List xy : coords) { + if (xy.size() < 2) continue; + double x = xy.get(0).doubleValue(); // lon + double y = xy.get(1).doubleValue(); // lat + LocationRes p = new LocationRes(x, y); // (lng, lat) + + if (path.isEmpty()) { + path.add(p); + } else { + LocationRes last = path.get(path.size() - 1); + if (Double.compare(last.lng(), p.lng()) != 0 || Double.compare(last.lat(), p.lat()) != 0) { + path.add(p); + } + } + } + } + // 3) 총거리 누락 시 하버사인 합으로 보정 + if (totalDistance <= 0 && path.size() >= 2) { + totalDistance = Math.round(GeoUtils.sumHaversineMeters(path)); + } + + return new PedSegment(totalDistance, totalDuration, path); + } + + private static long asLong(Object v) { + if (v == null) return 0L; + if (v instanceof Number n) return n.longValue(); + if (v instanceof String s && !s.isBlank()) { + try { return Long.parseLong(s.trim()); } catch (NumberFormatException ignored) {} + } + return 0L; + } +} diff --git a/src/main/java/com/example/Centralthon/domain/route/exception/RouteErrorCode.java b/src/main/java/com/example/Centralthon/domain/route/exception/RouteErrorCode.java new file mode 100644 index 0000000..7a49b9a --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/route/exception/RouteErrorCode.java @@ -0,0 +1,16 @@ +package com.example.Centralthon.domain.route.exception; + +import com.example.Centralthon.global.response.code.BaseResponseCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum RouteErrorCode implements BaseResponseCode { + ROUTE_SEGMENT_MISSING("ROUTE_500_1", 500, "서버에서 세그먼트가 누락되었습니다."), + ROUTE_NOT_CREATED("ROUTE_500_2",500,"서버에서 보행자 경로를 생성하지 못했습니다."); + + private final String code; + private final int httpStatus; + private final String message; +} diff --git a/src/main/java/com/example/Centralthon/domain/route/exception/RouteNotCreatedException.java b/src/main/java/com/example/Centralthon/domain/route/exception/RouteNotCreatedException.java new file mode 100644 index 0000000..2975fea --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/route/exception/RouteNotCreatedException.java @@ -0,0 +1,9 @@ +package com.example.Centralthon.domain.route.exception; + +import com.example.Centralthon.global.exception.BaseException; + +public class RouteNotCreatedException extends BaseException { + public RouteNotCreatedException() { + super(RouteErrorCode.ROUTE_NOT_CREATED); + } +} diff --git a/src/main/java/com/example/Centralthon/domain/route/exception/RouteSegmentMissingException.java b/src/main/java/com/example/Centralthon/domain/route/exception/RouteSegmentMissingException.java new file mode 100644 index 0000000..4cf5159 --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/route/exception/RouteSegmentMissingException.java @@ -0,0 +1,9 @@ +package com.example.Centralthon.domain.route.exception; + +import com.example.Centralthon.global.exception.BaseException; + +public class RouteSegmentMissingException extends BaseException { + public RouteSegmentMissingException() { + super(RouteErrorCode.ROUTE_SEGMENT_MISSING); + } +} diff --git a/src/main/java/com/example/Centralthon/domain/route/model/CombinedPath.java b/src/main/java/com/example/Centralthon/domain/route/model/CombinedPath.java new file mode 100644 index 0000000..2016a14 --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/route/model/CombinedPath.java @@ -0,0 +1,11 @@ +package com.example.Centralthon.domain.route.model; + +import com.example.Centralthon.domain.route.web.dto.LocationRes; +import java.util.List; + +// 최종 합성 경로 (사용자 출발 → 경유지들 → 도착) +public record CombinedPath( + long distanceMeters, + long durationSeconds, + List path) { +} diff --git a/src/main/java/com/example/Centralthon/domain/route/model/PedMatrix.java b/src/main/java/com/example/Centralthon/domain/route/model/PedMatrix.java new file mode 100644 index 0000000..140c9ac --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/route/model/PedMatrix.java @@ -0,0 +1,8 @@ +package com.example.Centralthon.domain.route.model; +import java.util.Map; + +// 보행 경로 행렬 (Pedestrian Matrix) +public record PedMatrix( + double[][] distMatrix, // 비용(거리/시간) 테이블 + Map segments) { +} diff --git a/src/main/java/com/example/Centralthon/domain/route/model/PedSegment.java b/src/main/java/com/example/Centralthon/domain/route/model/PedSegment.java new file mode 100644 index 0000000..58bd748 --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/route/model/PedSegment.java @@ -0,0 +1,11 @@ +package com.example.Centralthon.domain.route.model; + +import com.example.Centralthon.domain.route.web.dto.LocationRes; +import java.util.List; + +// 보행 경로 세그먼트 (두 지점 A→B 사이) +public record PedSegment( + long distance, + long duration, + List path // A -> B까지의 보행 경로 좌표 리스트 +) {} diff --git a/src/main/java/com/example/Centralthon/domain/route/model/SegmentKey.java b/src/main/java/com/example/Centralthon/domain/route/model/SegmentKey.java new file mode 100644 index 0000000..aa7f130 --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/route/model/SegmentKey.java @@ -0,0 +1,18 @@ +package com.example.Centralthon.domain.route.model; + +// 경로 행렬의 키 (두 노드 간 구간을 고유하게 식별) +public record SegmentKey( + int i, + int j) { + public SegmentKey { + if (i > j) { + int tmp = i; + i = j; + j = tmp; + } + } + + public static SegmentKey of(int a, int b) { + return new SegmentKey(a, b); + } +} diff --git a/src/main/java/com/example/Centralthon/domain/route/port/PedestrianRoutingPort.java b/src/main/java/com/example/Centralthon/domain/route/port/PedestrianRoutingPort.java new file mode 100644 index 0000000..c8624e2 --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/route/port/PedestrianRoutingPort.java @@ -0,0 +1,9 @@ +package com.example.Centralthon.domain.route.port; + +import com.example.Centralthon.domain.route.model.PedSegment; +import com.example.Centralthon.domain.route.web.dto.LocationRes; +import reactor.core.publisher.Mono; + +public interface PedestrianRoutingPort { + Mono fetchSegment(LocationRes a, LocationRes b); +} diff --git a/src/main/java/com/example/Centralthon/domain/route/service/PedMatrixBuilder.java b/src/main/java/com/example/Centralthon/domain/route/service/PedMatrixBuilder.java new file mode 100644 index 0000000..350b85b --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/route/service/PedMatrixBuilder.java @@ -0,0 +1,95 @@ +package com.example.Centralthon.domain.route.service; + +import com.example.Centralthon.domain.route.exception.RouteNotCreatedException; +import com.example.Centralthon.domain.route.model.*; +import com.example.Centralthon.domain.route.port.PedestrianRoutingPort; +import com.example.Centralthon.domain.route.web.dto.LocationRes; +import com.example.Centralthon.global.util.geo.GeoUtils; +import io.netty.handler.timeout.ReadTimeoutException; +import lombok.RequiredArgsConstructor; +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.Mono; + +import java.time.Duration; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + +@Component +@RequiredArgsConstructor +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); + + public PedMatrix build(List nodes) { + // 전체 노드 수 (0 = 사용자, 1..n = 가게) + int k = nodes.size(); + + // 도보 거리 행렬 (대칭행렬) + double[][] dist = new double[k][k]; + + // (i,j) 쌍별 실제 보행자 경로 세그먼트 (거리/시간/좌표) + Map segs = new ConcurrentHashMap<>(); + + List> calls = new ArrayList<>(); + + // 폴백(하버사인)으로 만든 세그먼트 개수 + AtomicInteger fallbackCount = new AtomicInteger(0); + + for (int i = 0; i < k; i++) { + for (int j = i + 1; j < k; j++) { + // 람다 캡처 에러 방지: 루프 변수의 로컬 복사본 + final int ii = i, jj = j; + LocationRes a = nodes.get(ii); + LocationRes b = nodes.get(jj); + + Mono call = routingPort.fetchSegment(a, b) + .timeout(PER_CALL_TIMEOUT) + // 일시적 오류만 3회 재시도 + .retryWhen(reactor.util.retry.Retry + .fixedDelay(RETRIES, RETRY_DELAY) + .filter(this::isTransientError)) + // 실패 시 하버사인 폴백 + .onErrorResume(ex -> { + 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))); + }) + .doOnNext(seg -> { + SegmentKey key = SegmentKey.of(ii, jj); + segs.put(key, seg); + dist[ii][jj] = dist[jj][ii] = seg.distance(); + }) + .then(); + + calls.add(call); + } + } + Mono.when(calls).block(); + + // 모든 세그먼트가 폴백 시 예외 처리 + int totalCalls = calls.size(); + if (totalCalls > 0 && fallbackCount.get() == totalCalls) { + throw new RouteNotCreatedException(); + } + + return new PedMatrix(dist, segs); + } + + /** 타임아웃/네트워크/5xx 같은 일시적 오류만 재시도 대상으로 */ + private boolean isTransientError(Throwable t) { + if (t instanceof TimeoutException) return true; + if (t instanceof ReadTimeoutException) return true; + if (t instanceof WebClientRequestException) return true; + if (t instanceof WebClientResponseException w) { + return w.getStatusCode().is5xxServerError(); + } + return false; + } +} diff --git a/src/main/java/com/example/Centralthon/domain/route/service/RouteService.java b/src/main/java/com/example/Centralthon/domain/route/service/RouteService.java new file mode 100644 index 0000000..67e1e6d --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/route/service/RouteService.java @@ -0,0 +1,8 @@ +package com.example.Centralthon.domain.route.service; + +import com.example.Centralthon.domain.route.web.dto.RouteReq; +import com.example.Centralthon.domain.route.web.dto.RouteRes; + +public interface RouteService { + RouteRes findOptimalPath( RouteReq req); +} diff --git a/src/main/java/com/example/Centralthon/domain/route/service/RouteServiceImpl.java b/src/main/java/com/example/Centralthon/domain/route/service/RouteServiceImpl.java new file mode 100644 index 0000000..c97ab5c --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/route/service/RouteServiceImpl.java @@ -0,0 +1,65 @@ +package com.example.Centralthon.domain.route.service; + +import com.example.Centralthon.domain.route.algo.PathStitcher; +import com.example.Centralthon.domain.route.algo.TspSolver; +import com.example.Centralthon.domain.route.model.CombinedPath; +import com.example.Centralthon.domain.route.model.PedMatrix; +import com.example.Centralthon.domain.route.web.dto.LocationRes; +import com.example.Centralthon.domain.route.web.dto.RouteReq; +import com.example.Centralthon.domain.route.web.dto.RouteRes; +import com.example.Centralthon.domain.store.entity.Store; +import com.example.Centralthon.domain.store.exception.StoreNotFoundException; +import com.example.Centralthon.domain.store.repository.StoreRepository; +import com.example.Centralthon.global.util.geo.GeoUtils; +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + + +@Service +@RequiredArgsConstructor +public class RouteServiceImpl implements RouteService { + private final StoreRepository storeRepository; + private final PedMatrixBuilder pedMatrixBuilder; + + @Override + @Transactional(readOnly = true) + public RouteRes findOptimalPath(RouteReq req) { + // 0) 입력 검증 및 노드 생성 (0 = 사용자, 1..n = 가게) + List stores = storeRepository.findAllById(req.getStoreIds()); + if (stores.isEmpty()) throw new StoreNotFoundException(); + + List nodes = new ArrayList<>(); + nodes.add(new LocationRes(req.getUserLng(), req.getUserLat())); + nodes.addAll(stores.stream() + .map(s -> new LocationRes(s.getLongitude(), s.getLatitude())) + .toList()); + + // 1) 보행자 경로 행렬/세그먼트 사전 계산 + PedMatrix pm = pedMatrixBuilder.build(nodes); + + // 2) TSP 라우팅 + List order = TspSolver.solveOpenTour(pm.distMatrix()); + + // 3) 세그먼트 조합 → 전체 경로 좌표/거리/시간 합산 + CombinedPath combined = PathStitcher.stitch(order, pm.segments()); + + // 4) 좌표 리스트를 폴리라인 인코딩 + String polyline = combined.path().isEmpty() ? "" : GeoUtils.encodePolyline(combined.path()); + + // 5) storeId 순서 변환 + List idOrder = order.stream() + .filter(i -> i != 0) + .map(i -> stores.get(i - 1).getId()) + .toList(); + + return new RouteRes( + combined.distanceMeters(), + idOrder, + polyline + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/Centralthon/domain/route/web/controller/RouteApi.java b/src/main/java/com/example/Centralthon/domain/route/web/controller/RouteApi.java new file mode 100644 index 0000000..3550141 --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/route/web/controller/RouteApi.java @@ -0,0 +1,53 @@ +package com.example.Centralthon.domain.route.web.controller; + +import com.example.Centralthon.domain.route.web.dto.RouteReq; +import com.example.Centralthon.domain.route.web.dto.RouteRes; +import com.example.Centralthon.global.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "Routes", description = "최적 경로 API") +public interface RouteApi { + @Operation( + summary = "최적 경로 생성", + description = "사용자 위치와 경유지를 기반으로 최적 경로를 생성합니다. 경로는 폴리라인으로 인코딩되어 넘어갑니다." + ) + @ApiResponse( + responseCode = "200", + description = "최적 경로 생성 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + name = "SUCCESS_200", + value = """ + { + "timestamp": "2025-08-15 04:22:54", + "code": "SUCCESS_200", + "httpStatus": 200, + "message": "호출에 성공하였습니다.", + "data": { + "distanceMeters": 10423, + "optimizedStoreIds": [ + 5, + 2, + 1, + 4 + ], + "polyline": "gfkdFeuefW\\\\OZKj@INDF@DEN[B?Rg@BB`@ZPXDNNx@@L?PCPCLAHhAcARHN@EfAIl@ADY~ACLMx@@FB@t@LhB`@LFn@h@XLL@F@tAFT@BBN?B?LEXELCDAFCD?LAn@EJ??ABA@?DAP?HA@?DC@FNr@P|" + }, + "isSuccess": true + } + """ + ) + ) + ) + ResponseEntity> findOptimalPath(@Valid @RequestBody RouteReq req); +} diff --git a/src/main/java/com/example/Centralthon/domain/route/web/controller/RouteController.java b/src/main/java/com/example/Centralthon/domain/route/web/controller/RouteController.java new file mode 100644 index 0000000..6a0a40f --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/route/web/controller/RouteController.java @@ -0,0 +1,28 @@ +package com.example.Centralthon.domain.route.web.controller; + +import com.example.Centralthon.domain.route.service.RouteService; +import com.example.Centralthon.domain.route.web.dto.RouteReq; +import com.example.Centralthon.domain.route.web.dto.RouteRes; +import com.example.Centralthon.global.response.SuccessResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/routes") +@RequiredArgsConstructor +public class RouteController implements RouteApi { + private final RouteService routeService; + + @PostMapping("/directions") + @Override + public ResponseEntity> findOptimalPath(@Valid @RequestBody RouteReq req) { + RouteRes res = routeService.findOptimalPath(req); + return ResponseEntity.status(HttpStatus.OK).body(SuccessResponse.from(res)); + } +} diff --git a/src/main/java/com/example/Centralthon/domain/route/web/dto/LocationRes.java b/src/main/java/com/example/Centralthon/domain/route/web/dto/LocationRes.java new file mode 100644 index 0000000..3caf873 --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/route/web/dto/LocationRes.java @@ -0,0 +1,7 @@ +package com.example.Centralthon.domain.route.web.dto; + +public record LocationRes( + double lng, + double lat +) { +} diff --git a/src/main/java/com/example/Centralthon/domain/route/web/dto/RouteReq.java b/src/main/java/com/example/Centralthon/domain/route/web/dto/RouteReq.java new file mode 100644 index 0000000..578b74c --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/route/web/dto/RouteReq.java @@ -0,0 +1,21 @@ +package com.example.Centralthon.domain.route.web.dto; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; + +import java.util.List; + +@Getter +public class RouteReq{ + @NotNull(message = "userLat을 입력해주세요.") + private Double userLat; + + @NotNull(message = "userLng을 입력해주세요.") + private Double userLng; + + @NotEmpty(message = "storeIds 리스트가 비어있습니다.") + @Size(max = 8, message = "경유지는 최대 8개까지만 지정할 수 있습니다.") + private List storeIds; +} diff --git a/src/main/java/com/example/Centralthon/domain/route/web/dto/RouteRes.java b/src/main/java/com/example/Centralthon/domain/route/web/dto/RouteRes.java new file mode 100644 index 0000000..a7b03a9 --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/route/web/dto/RouteRes.java @@ -0,0 +1,8 @@ +package com.example.Centralthon.domain.route.web.dto; +import java.util.*; + +public record RouteRes( + long distanceMeters, + List optimizedStoreIds, + String polyline +){} diff --git a/src/main/java/com/example/Centralthon/global/config/TmapClientConfig.java b/src/main/java/com/example/Centralthon/global/config/TmapClientConfig.java new file mode 100644 index 0000000..d9996f8 --- /dev/null +++ b/src/main/java/com/example/Centralthon/global/config/TmapClientConfig.java @@ -0,0 +1,21 @@ +package com.example.Centralthon.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class TmapClientConfig { + @Bean + public WebClient tmapWebClient(WebClient.Builder builder, + @Value("${apiKey}") String appKey) { + return builder + .baseUrl("https://apis.openapi.sk.com") + .defaultHeader("appKey", appKey) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build(); + } +} diff --git a/src/main/java/com/example/Centralthon/global/util/geo/GeoUtils.java b/src/main/java/com/example/Centralthon/global/util/geo/GeoUtils.java index 69dc17e..f34c378 100644 --- a/src/main/java/com/example/Centralthon/global/util/geo/GeoUtils.java +++ b/src/main/java/com/example/Centralthon/global/util/geo/GeoUtils.java @@ -1,5 +1,9 @@ package com.example.Centralthon.global.util.geo; +import com.example.Centralthon.domain.route.web.dto.LocationRes; + +import java.util.List; + public class GeoUtils { private static final double R = 6371.0; // 지구 반경 (km) @@ -27,4 +31,57 @@ public static double calculateDistance(double lat1, double lng1, double lat2, do double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } + + // 간단한 하버사인 합산 + public static double sumHaversineMeters(List pts) { + double sum = 0.0; + for (int i = 1; i < pts.size(); i++) { + sum += calculateDistance( + pts.get(i - 1).lat(), pts.get(i - 1).lng(), + pts.get(i).lat(), pts.get(i).lng() + ); + } + return sum; + } + + // ====== Polyline Encode (Google format, precision 1e-5) ====== + public static String encodePolyline(List path) { + return encodePolyline(path, 1e5); + } + + public static String encodePolyline(List path, double precision) { + if (path == null || path.isEmpty()) return ""; + + long factor = Math.round(precision); + long lastLat = 0L; + long lastLng = 0L; + StringBuilder sb = new StringBuilder(); + + for (LocationRes p : path) { + // LocationRes는 (lng, lat) 순서 필드 — 인코딩은 (lat, lng) 순서로 + long lat = Math.round(p.lat() * factor); + long lng = Math.round(p.lng() * factor); + + long dLat = lat - lastLat; + long dLng = lng - lastLng; + + encodeValue(dLat, sb); + encodeValue(dLng, sb); + + lastLat = lat; + lastLng = lng; + } + return sb.toString(); + } + + private static void encodeValue(long v, StringBuilder out) { + // ZigZag + varint (Google polyline 규칙) + v = (v < 0) ? ~(v << 1) : (v << 1); + while (v >= 0x20) { + int chunk = (int)((0x20 | (v & 0x1F)) + 63); + out.append((char) chunk); + v >>= 5; + } + out.append((char)(v + 63)); + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a24dd91..e73e320 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -8,6 +8,9 @@ spring.datasource.url=${DATABASE_URL} spring.datasource.username=${DATABASE_USERNAME} spring.datasource.password=${DATABASE_PASSWORD} +# TmapAPI +apiKey=${API_KEY} + # JPA spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true