From 5b116eb7c854f32d23b71d2236a4cbaff32f2619 Mon Sep 17 00:00:00 2001 From: SOWON LEE <66356241+Leesowon@users.noreply.github.com> Date: Sat, 6 Dec 2025 00:38:01 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[#35]=20feat:=20=EB=B0=B0=EC=B0=A8=20entity?= =?UTF-8?q?=EC=97=90=20=EC=9C=84=EB=8F=84=20=EA=B2=BD=EB=8F=84=20=EA=B0=92?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dispatch/dto/request/DispatchReq.java | 63 +++++++++++++++++++ .../api/domain/dispatch/entity/Dispatch.java | 8 +++ 2 files changed, 71 insertions(+) create mode 100644 src/main/java/com/mobility/api/domain/dispatch/dto/request/DispatchReq.java diff --git a/src/main/java/com/mobility/api/domain/dispatch/dto/request/DispatchReq.java b/src/main/java/com/mobility/api/domain/dispatch/dto/request/DispatchReq.java new file mode 100644 index 0000000..f7bf709 --- /dev/null +++ b/src/main/java/com/mobility/api/domain/dispatch/dto/request/DispatchReq.java @@ -0,0 +1,63 @@ +package com.mobility.api.domain.dispatch.dto.request; + +import com.mobility.api.domain.dispatch.entity.Dispatch; +import com.mobility.api.domain.dispatch.enums.CallType; +import com.mobility.api.domain.dispatch.enums.ServiceType; +import com.mobility.api.domain.dispatch.enums.StatusType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +public record DispatchReq( + @NotBlank(message = "출발지는 필수입니다.") + String startLocation, + + @NotNull(message = "출발지 위도는 필수입니다.") + Double startLatitude, + + @NotNull(message = "출발지 경도는 필수입니다.") + Double startLongitude, + + @NotBlank(message = "도착지는 필수입니다.") + String destinationLocation, + + @NotNull(message = "도착지 위도는 필수입니다.") + Double destinationLatitude, + + @NotNull(message = "도착지 경도는 필수입니다.") + Double destinationLongitude, + + @NotNull(message = "요금은 필수입니다.") + Integer charge, + + @NotBlank(message = "고객 전화번호는 필수입니다.") + String clientPhoneNumber, + + @NotNull(message = "콜 타입은 필수입니다.") + CallType callType, + + @NotNull(message = "서비스 타입은 필수입니다.") + ServiceType serviceType, + + Long officeId +) { + // DTO -> Entity 변환 메서드 + public Dispatch toEntity() { + return Dispatch.builder() + .startLocation(startLocation) + .startLatitude(startLatitude) + .startLongitude(startLongitude) + .destinationLocation(destinationLocation) + .destinationLatitude(destinationLatitude) + .destinationLongitude(destinationLongitude) + .charge(charge) + .clientPhoneNumber(clientPhoneNumber) + .call(callType) + .service(serviceType) + .officeId(officeId) + .status(StatusType.OPEN) // 생성 시 기본 상태는 OPEN + .active(true) // 생성 시 기본 활성화 + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/mobility/api/domain/dispatch/entity/Dispatch.java b/src/main/java/com/mobility/api/domain/dispatch/entity/Dispatch.java index 0c19cb0..6bc5e6d 100644 --- a/src/main/java/com/mobility/api/domain/dispatch/entity/Dispatch.java +++ b/src/main/java/com/mobility/api/domain/dispatch/entity/Dispatch.java @@ -26,7 +26,15 @@ public class Dispatch { private Long id; private String startLocation; // 출발지 + + private double startLatitude; // 출발지 위도 + private double startLongitude; // 출발지 경도 + private String destinationLocation; // 도착지 + + private double destinationLatitude; // 도착지 위도 + private double destinationLongitude; // 도착지 경도 + private Integer charge; // 요금 private String clientPhoneNumber; // 고객 전화번호 From e6bc42c1c236fa70514cfdc71fa72ea130b4035d Mon Sep 17 00:00:00 2001 From: SOWON LEE <66356241+Leesowon@users.noreply.github.com> Date: Sat, 6 Dec 2025 02:04:55 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[#35]=20feat:=20=EB=B0=B0=EC=B0=A8=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=EC=8B=9C,=20=EC=B6=9C=EB=B0=9C=EC=A7=80=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=EC=A3=BC=EB=B3=80=20=EA=B8=B0=EC=82=AC=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C(=EB=B0=98=EA=B2=BD=2010km)=20api=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/DispatchV1Controller.java | 47 ++++++++++++++ .../response/DispatchAssignCompleteRes.java | 20 ++++++ .../dispatch/dto/response/DispatchRes.java | 64 +++++++++++++++---- .../dispatch/service/DispatcherService.java | 10 +-- .../controller/TransporterV1Controller.java | 13 ++-- .../dto/TransporterDistanceProjection.java | 12 ++++ .../response/TransporterMatchResponse.java | 32 ++++++++++ .../transporter/entity/Transporter.java | 6 ++ .../repository/TransporterRepository.java | 27 ++++++++ .../service/TransporterService.java | 29 +++++++++ .../api/global/response/ResultCode.java | 1 + 11 files changed, 239 insertions(+), 22 deletions(-) create mode 100644 src/main/java/com/mobility/api/domain/dispatch/controller/DispatchV1Controller.java create mode 100644 src/main/java/com/mobility/api/domain/dispatch/dto/response/DispatchAssignCompleteRes.java create mode 100644 src/main/java/com/mobility/api/domain/transporter/dto/TransporterDistanceProjection.java create mode 100644 src/main/java/com/mobility/api/domain/transporter/dto/response/TransporterMatchResponse.java create mode 100644 src/main/java/com/mobility/api/domain/transporter/service/TransporterService.java diff --git a/src/main/java/com/mobility/api/domain/dispatch/controller/DispatchV1Controller.java b/src/main/java/com/mobility/api/domain/dispatch/controller/DispatchV1Controller.java new file mode 100644 index 0000000..0e63197 --- /dev/null +++ b/src/main/java/com/mobility/api/domain/dispatch/controller/DispatchV1Controller.java @@ -0,0 +1,47 @@ +package com.mobility.api.domain.dispatch.controller; + +// DispatchMatchingService 내부에서 TransporterService를 호출한다고 가정 +// 혹은 여기서 바로 TransporterService를 호출해도 무방 + +import com.mobility.api.domain.transporter.dto.response.TransporterMatchResponse; +import com.mobility.api.domain.transporter.service.TransporterService; +import com.mobility.api.domain.dispatch.repository.DispatchRepository; +import com.mobility.api.global.exception.GlobalException; +import com.mobility.api.global.response.CommonResponse; +import com.mobility.api.global.response.ResultCode; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "Dispatch Matching", description = "배차 매칭 관련 API") +@RestController +@RequestMapping("/api/v1/dispatch") +@RequiredArgsConstructor +public class DispatchV1Controller { + + private final DispatchRepository dispatchRepository; + private final TransporterService transporterService; + + // 배차 등록 시, 주변의 기사 조회 (km 단위 반환) + @Operation(summary = "배차 주변 기사 추천", description = "해당 배차의 출발지 기준 반경 50km 이내 기사를 가까운 순으로 조회합니다.") + @GetMapping("/{dispatchId}/nearby-drivers") + public CommonResponse> getNearbyDrivers(@PathVariable Long dispatchId) { + + // 1. 배차 정보 조회 (출발지 좌표 획득) + var dispatch = dispatchRepository.findById(dispatchId) + .orElseThrow(() -> new GlobalException(ResultCode.DISPATCH_NOT_FOUND)); + + // 2. 기사 검색 서비스 호출 + List drivers = transporterService.findNearbyTransporters( + dispatch.getStartLatitude(), + dispatch.getStartLongitude() + ); + + return CommonResponse.success(drivers); + } +} \ No newline at end of file diff --git a/src/main/java/com/mobility/api/domain/dispatch/dto/response/DispatchAssignCompleteRes.java b/src/main/java/com/mobility/api/domain/dispatch/dto/response/DispatchAssignCompleteRes.java new file mode 100644 index 0000000..17fc3a3 --- /dev/null +++ b/src/main/java/com/mobility/api/domain/dispatch/dto/response/DispatchAssignCompleteRes.java @@ -0,0 +1,20 @@ +package com.mobility.api.domain.dispatch.dto.response; + +import com.mobility.api.domain.dispatch.entity.Dispatch; + +// 배차 선택, 완료 res +public record DispatchAssignCompleteRes( + Long dispatcherId, + Long transporterId +) { + public static DispatchAssignCompleteRes from(Dispatch dispatch) { +// if (dispatch.getTransporter() != null) { +// throw new GlobalException(ResultCode.NOT_FOUND_USER); +// } + + return new DispatchAssignCompleteRes( + dispatch.getId(), + dispatch.getTransporter().getId() + ); + } +} diff --git a/src/main/java/com/mobility/api/domain/dispatch/dto/response/DispatchRes.java b/src/main/java/com/mobility/api/domain/dispatch/dto/response/DispatchRes.java index ba3e44a..d6a046e 100644 --- a/src/main/java/com/mobility/api/domain/dispatch/dto/response/DispatchRes.java +++ b/src/main/java/com/mobility/api/domain/dispatch/dto/response/DispatchRes.java @@ -1,20 +1,60 @@ package com.mobility.api.domain.dispatch.dto.response; import com.mobility.api.domain.dispatch.entity.Dispatch; +import com.mobility.api.domain.dispatch.enums.CallType; +import com.mobility.api.domain.dispatch.enums.ServiceType; +import com.mobility.api.domain.dispatch.enums.StatusType; +import lombok.Builder; -// 배차 선택, 완료 res -public record DispatchRes( - Long dispatcherId, - Long transporterId +import java.time.LocalDateTime; + +/** + * 배차 등록 res + */ + +@Builder +public record DispatchRes(Long id, + String startLocation, + Double startLatitude, + Double startLongitude, + String destinationLocation, + Double destinationLatitude, + Double destinationLongitude, + Integer charge, + String clientPhoneNumber, + StatusType status, + CallType callType, + ServiceType serviceType, + Long officeId, + Long transporterId, // 기사 ID (null 가능) + String transporterName, // 기사 이름 (null 가능) + LocalDateTime createdAt ) { + // Entity -> DTO 변환 메서드 (거리는 별도 계산 없을 시 null) public static DispatchRes from(Dispatch dispatch) { -// if (dispatch.getTransporter() != null) { -// throw new GlobalException(ResultCode.NOT_FOUND_USER); -// } + return from(dispatch, null); + } - return new DispatchRes( - dispatch.getId(), - dispatch.getTransporter().getId() - ); + // Entity + 거리 -> DTO 변환 메서드 + public static DispatchRes from(Dispatch dispatch, Double distance) { + return DispatchRes.builder() + .id(dispatch.getId()) + .startLocation(dispatch.getStartLocation()) + .startLatitude(dispatch.getStartLatitude()) + .startLongitude(dispatch.getStartLongitude()) + .destinationLocation(dispatch.getDestinationLocation()) + .destinationLatitude(dispatch.getDestinationLatitude()) + .destinationLongitude(dispatch.getDestinationLongitude()) + .charge(dispatch.getCharge()) + .clientPhoneNumber(dispatch.getClientPhoneNumber()) + .status(dispatch.getStatus()) + .callType(dispatch.getCall()) + .serviceType(dispatch.getService()) + .officeId(dispatch.getOfficeId()) + // Transporter가 lazy loading이거나 null일 수 있으므로 체크 필요 + .transporterId(dispatch.getTransporter() != null ? dispatch.getTransporter().getId() : null) + .transporterName(dispatch.getTransporter() != null ? dispatch.getTransporter().getName() : null) + .createdAt(dispatch.getCreatedAt()) + .build(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/mobility/api/domain/dispatch/service/DispatcherService.java b/src/main/java/com/mobility/api/domain/dispatch/service/DispatcherService.java index 1c3ab6e..c7af7a0 100644 --- a/src/main/java/com/mobility/api/domain/dispatch/service/DispatcherService.java +++ b/src/main/java/com/mobility/api/domain/dispatch/service/DispatcherService.java @@ -3,7 +3,7 @@ import com.mobility.api.domain.dispatch.dto.response.DispatchCancelRes; import com.mobility.api.domain.dispatch.entity.Dispatch; import com.mobility.api.domain.dispatch.repository.DispatchRepository; -import com.mobility.api.domain.dispatch.dto.response.DispatchRes; +import com.mobility.api.domain.dispatch.dto.response.DispatchAssignCompleteRes; import com.mobility.api.domain.transporter.entity.Transporter; import com.mobility.api.domain.transporter.repository.TransporterRepository; import com.mobility.api.global.exception.GlobalException; @@ -22,7 +22,7 @@ public class DispatcherService { private final TransporterRepository transporterRepository; @Transactional - public DispatchRes assignDispatch(Long dispatchId, Long transporterId) { + public DispatchAssignCompleteRes assignDispatch(Long dispatchId, Long transporterId) { // 1. 기사 정보 조회 Transporter transporter = transporterRepository.findById(transporterId) @@ -35,7 +35,7 @@ public DispatchRes assignDispatch(Long dispatchId, Long transporterId) { // 3. 배차 할당 dispatch.assignDispatch(transporter); - return DispatchRes.from(dispatch); + return DispatchAssignCompleteRes.from(dispatch); } @Transactional @@ -55,7 +55,7 @@ public DispatchCancelRes cancelDispatch(Long dispatchId, Long transporterId) { } @Transactional - public DispatchRes completeDispatch(Long dispatchId, Long transporterId) { + public DispatchAssignCompleteRes completeDispatch(Long dispatchId, Long transporterId) { // 1. 기사 정보 조회 Transporter transporter = transporterRepository.findById(transporterId) @@ -67,6 +67,6 @@ public DispatchRes completeDispatch(Long dispatchId, Long transporterId) { dispatch.completeDispatch(transporter); - return DispatchRes.from(dispatch); + return DispatchAssignCompleteRes.from(dispatch); } } diff --git a/src/main/java/com/mobility/api/domain/transporter/controller/TransporterV1Controller.java b/src/main/java/com/mobility/api/domain/transporter/controller/TransporterV1Controller.java index 218cc91..be07edc 100644 --- a/src/main/java/com/mobility/api/domain/transporter/controller/TransporterV1Controller.java +++ b/src/main/java/com/mobility/api/domain/transporter/controller/TransporterV1Controller.java @@ -1,7 +1,7 @@ package com.mobility.api.domain.transporter.controller; import com.mobility.api.domain.dispatch.dto.response.DispatchCancelRes; -import com.mobility.api.domain.dispatch.dto.response.DispatchRes; +import com.mobility.api.domain.dispatch.dto.response.DispatchAssignCompleteRes; import com.mobility.api.domain.dispatch.service.DispatcherService; import com.mobility.api.domain.transporter.dto.request.LocationUpdateReq; import com.mobility.api.domain.transporter.dto.response.LocationUpdateRes; @@ -16,7 +16,6 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @Slf4j @@ -31,7 +30,7 @@ public class TransporterV1Controller { @Operation(summary = "배차 할당", description = "") @PatchMapping("/dispatch-assign/{dispatchId}") - public CommonResponse assignDispatch( + public CommonResponse assignDispatch( @PathVariable Long dispatchId, @CurrentUser Transporter transporter) { Long transporterId = getValidatedTransporterId(transporter); @@ -51,7 +50,7 @@ public CommonResponse cancelDispatch( @Operation(summary = "배차 완료", description = "") @PatchMapping("/dispatch-complete/{dispatchId}") - public CommonResponse completeDispatch( + public CommonResponse completeDispatch( @PathVariable Long dispatchId, @CurrentUser Transporter transporter) { Long transporterId = getValidatedTransporterId(transporter); @@ -68,7 +67,7 @@ private Long getValidatedTransporterId(Transporter transporter) { return transporterId; } - /* + /** ** 기사 위치 정보 수집 관련 */ @Operation(summary = "기사 실시간 위치 업데이트", description = "기사 앱에서 전송된 위도(latitude), 경도(longitude) 정보를 저장") @@ -83,4 +82,8 @@ public CommonResponse updateTransporterLocation( return CommonResponse.success(response); } + + /** + * 기사의 배차 리스트 정렬 등을 위해, 현재 기사 위치 기준으로 배차 + */ } diff --git a/src/main/java/com/mobility/api/domain/transporter/dto/TransporterDistanceProjection.java b/src/main/java/com/mobility/api/domain/transporter/dto/TransporterDistanceProjection.java new file mode 100644 index 0000000..7d1e240 --- /dev/null +++ b/src/main/java/com/mobility/api/domain/transporter/dto/TransporterDistanceProjection.java @@ -0,0 +1,12 @@ +package com.mobility.api.domain.transporter.dto; + +/** + * Native Query 결과를 매핑할 인터페이스 + */ + +public interface TransporterDistanceProjection { + Long getId(); + String getName(); + String getPhone(); + Double getDistanceInMeters(); // DB에서 계산된 미터 단위 거리 +} diff --git a/src/main/java/com/mobility/api/domain/transporter/dto/response/TransporterMatchResponse.java b/src/main/java/com/mobility/api/domain/transporter/dto/response/TransporterMatchResponse.java new file mode 100644 index 0000000..9654ba0 --- /dev/null +++ b/src/main/java/com/mobility/api/domain/transporter/dto/response/TransporterMatchResponse.java @@ -0,0 +1,32 @@ +package com.mobility.api.domain.transporter.dto.response; + +import com.mobility.api.domain.transporter.dto.TransporterDistanceProjection; +import lombok.Builder; + +/** + * Projection의 미터 값을 받아 km로 변환하여 프론트에 전달 + */ + +@Builder +public record TransporterMatchResponse( + Long transporterId, + String name, + String phone, + Double distanceKm // km 단위 +) { + public static TransporterMatchResponse from(TransporterDistanceProjection proj) { + // 미터(m) -> 킬로미터(km) 변환 + // 값이 없으면 0.0 처리 + double meters = proj.getDistanceInMeters() != null ? proj.getDistanceInMeters() : 0.0; + + // 1000으로 나누고, 소수점 둘째 자리까지 반올림 (예: 1.25 km) + double km = Math.round((meters / 1000.0) * 100.0) / 100.0; + + return TransporterMatchResponse.builder() + .transporterId(proj.getId()) + .name(proj.getName()) + .phone(proj.getPhone()) + .distanceKm(km) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/mobility/api/domain/transporter/entity/Transporter.java b/src/main/java/com/mobility/api/domain/transporter/entity/Transporter.java index e112674..626f5c5 100644 --- a/src/main/java/com/mobility/api/domain/transporter/entity/Transporter.java +++ b/src/main/java/com/mobility/api/domain/transporter/entity/Transporter.java @@ -4,6 +4,7 @@ import lombok.*; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; +import org.locationtech.jts.geom.Point; import java.time.LocalDateTime; @@ -26,6 +27,11 @@ public class Transporter { @Column(name = "phone") private String phone; + // 실시간 위치 검색용 필드 (PostGIS Point) + // columnDefinition을 통해 SRID 4326(위경도)임을 명시 + @Column(name = "current_location", columnDefinition = "geometry(Point, 4326)") + private Point currentLocation; + @Column(name = "is_auto_dispatch") private boolean isAutoDispatch; diff --git a/src/main/java/com/mobility/api/domain/transporter/repository/TransporterRepository.java b/src/main/java/com/mobility/api/domain/transporter/repository/TransporterRepository.java index 5db3166..a48a052 100644 --- a/src/main/java/com/mobility/api/domain/transporter/repository/TransporterRepository.java +++ b/src/main/java/com/mobility/api/domain/transporter/repository/TransporterRepository.java @@ -1,7 +1,34 @@ package com.mobility.api.domain.transporter.repository; +import com.mobility.api.domain.transporter.dto.TransporterDistanceProjection; import com.mobility.api.domain.transporter.entity.Transporter; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; public interface TransporterRepository extends JpaRepository { + + /** + * PostGIS의 ST_DistanceSphere를 사용해 미터 단위 거리를 계산 + * 특정 좌표(lat, lon) 기준, 반경(radiusMeters) 내의 기사를 거리순 조회 + */ + @Query(value = """ + SELECT t.transporter_id as id, + t.name, + t.phone, + ST_DistanceSphere(t.current_location, ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)) as distanceInMeters + FROM transporters t + WHERE t.current_location IS NOT NULL + -- PostGIS 반경 검색 함수 (인덱스 활용) + AND ST_DWithin(t.current_location::geography, ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography, :radiusMeters) + ORDER BY distanceInMeters ASC + """, nativeQuery = true) + List findNearbyTransporters( + @Param("lat") double lat, + @Param("lon") double lon, + @Param("radiusMeters") double radiusMeters + ); + } diff --git a/src/main/java/com/mobility/api/domain/transporter/service/TransporterService.java b/src/main/java/com/mobility/api/domain/transporter/service/TransporterService.java new file mode 100644 index 0000000..3e2fb08 --- /dev/null +++ b/src/main/java/com/mobility/api/domain/transporter/service/TransporterService.java @@ -0,0 +1,29 @@ +package com.mobility.api.domain.transporter.service; + +import com.mobility.api.domain.transporter.dto.response.TransporterMatchResponse; +import com.mobility.api.domain.transporter.repository.TransporterRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TransporterService { + + private final TransporterRepository transporterRepository; + + // 검색 반경 (미터 단위로 변환하여 사용) + private static final double SEARCH_RADIUS_METERS = 10000.0; // 10km + + public List findNearbyTransporters(double lat, double lon) { + // Repository 호출 (미터 단위 반경 전달) + return transporterRepository.findNearbyTransporters(lat, lon, SEARCH_RADIUS_METERS) + .stream() + .map(TransporterMatchResponse::from) // km로 변환 + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/src/main/java/com/mobility/api/global/response/ResultCode.java b/src/main/java/com/mobility/api/global/response/ResultCode.java index ea0d3d2..45248c5 100644 --- a/src/main/java/com/mobility/api/global/response/ResultCode.java +++ b/src/main/java/com/mobility/api/global/response/ResultCode.java @@ -29,6 +29,7 @@ public enum ResultCode { CANNOT_CANCEL_DISPATCH(HttpStatus.BAD_REQUEST, 2007, "배차를 취소할 수 없습니다"), CANNOT_COMPLETE_DISPATCH(HttpStatus.BAD_REQUEST, 2008, "배차를 완료할 수 없습니다"), DISPATCH_NOT_ASSIGNED(HttpStatus.NOT_FOUND, 2009, "배차 상태가 ASSIGNED이 아닙니다."), + DISPATCH_NOT_FOUND(HttpStatus.NOT_FOUND, 2010, "배차를 찾을 수 없습니다."), /** * 3000번대 (기사 관련) From 329acce012e38886d322d12b162482cd7d45319f Mon Sep 17 00:00:00 2001 From: SOWON LEE <66356241+Leesowon@users.noreply.github.com> Date: Sat, 6 Dec 2025 02:23:26 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[#35]=20test:=20=EB=B0=B0=EC=B0=A8=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=EC=8B=9C,=20=EC=A3=BC=EB=B3=80=20=EA=B8=B0?= =?UTF-8?q?=EC=82=AC=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +- .../controller/DispatchV1Controller.java | 1 - .../controller/DispatchV1ControllerTest.java | 134 ++++++++++++++++++ .../service/TransporterServiceTest.java | 66 +++++++++ .../api/global/util/DispatchMockFactory.java | 52 +++++++ 5 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/mobility/api/domain/dispatch/controller/DispatchV1ControllerTest.java create mode 100644 src/test/java/com/mobility/api/domain/transporter/service/TransporterServiceTest.java create mode 100644 src/test/java/com/mobility/api/global/util/DispatchMockFactory.java diff --git a/build.gradle b/build.gradle index 068ea8d..d710958 100644 --- a/build.gradle +++ b/build.gradle @@ -40,7 +40,7 @@ dependencies { // Postgresql runtimeOnly 'org.postgresql:postgresql' -// Postgis - Hibernate Spatial (JTS 타입을 DB Geometry 타입으로 매핑) + // Postgis - Hibernate Spatial (JTS 타입을 DB Geometry 타입으로 매핑) implementation 'org.hibernate.orm:hibernate-spatial' implementation 'org.geolatte:geolatte-geom:1.8.2' @@ -55,6 +55,7 @@ dependencies { // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.14' diff --git a/src/main/java/com/mobility/api/domain/dispatch/controller/DispatchV1Controller.java b/src/main/java/com/mobility/api/domain/dispatch/controller/DispatchV1Controller.java index 0e63197..9bfb209 100644 --- a/src/main/java/com/mobility/api/domain/dispatch/controller/DispatchV1Controller.java +++ b/src/main/java/com/mobility/api/domain/dispatch/controller/DispatchV1Controller.java @@ -13,7 +13,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; diff --git a/src/test/java/com/mobility/api/domain/dispatch/controller/DispatchV1ControllerTest.java b/src/test/java/com/mobility/api/domain/dispatch/controller/DispatchV1ControllerTest.java new file mode 100644 index 0000000..6e7f73d --- /dev/null +++ b/src/test/java/com/mobility/api/domain/dispatch/controller/DispatchV1ControllerTest.java @@ -0,0 +1,134 @@ +package com.mobility.api.domain.dispatch.controller; + +import static org.junit.jupiter.api.Assertions.*; + +import com.mobility.api.domain.dispatch.entity.Dispatch; +import com.mobility.api.domain.dispatch.repository.DispatchRepository; +import com.mobility.api.domain.transporter.dto.response.TransporterMatchResponse; +import com.mobility.api.domain.transporter.repository.TransporterRepository; +import com.mobility.api.domain.transporter.service.TransporterService; +import com.mobility.api.global.exception.GlobalException; +import com.mobility.api.global.response.ResultCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WithMockUser +@WebMvcTest(DispatchV1Controller.class) +class DispatchV1ControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private DispatchRepository dispatchRepository; + + @MockitoBean + private TransporterService transporterService; + + @MockitoBean + private TransporterRepository transporterRepository; + + @Test + @DisplayName("[API] 배차 주변 기사 조회 - 성공 (200 OK)") + void getNearbyDrivers_ApiSuccess() throws Exception { + // given + Long dispatchId = 1L; + + // 1. Mock 배차 데이터 (강남역) + Dispatch mockDispatch = Dispatch.builder() + .id(dispatchId) + .startLatitude(37.4980) + .startLongitude(127.0276) + .build(); + + // 2. Mock 기사 응답 데이터 (2명) + List mockDrivers = List.of( + TransporterMatchResponse.builder() + .transporterId(101L) + .name("김기사") + .phone("010-1234-5678") + .distanceKm(1.52) + .build(), + TransporterMatchResponse.builder() + .transporterId(102L) + .name("이기사") + .phone("010-9876-5432") + .distanceKm(3.05) + .build() + ); + + // 3. Stubbing (가짜 동작 정의) + given(dispatchRepository.findById(dispatchId)).willReturn(Optional.of(mockDispatch)); + given(transporterService.findNearbyTransporters(anyDouble(), anyDouble())) + .willReturn(mockDrivers); + + // when & then (요청 및 검증) + mockMvc.perform(get("/api/v1/dispatch/{dispatchId}/nearby-drivers", dispatchId) + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) // 콘솔에 요청/응답 로그 출력 + .andExpect(status().isOk()) + + // CommonResponse 구조 검증 + .andExpect(jsonPath("$.statusCode").value(0)) + .andExpect(jsonPath("$.message").value("정상 처리 되었습니다.")) + + // 데이터(List) 검증 + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(2)) + + // 첫 번째 기사 데이터 확인 + .andExpect(jsonPath("$.data[0].transporterId").value(101L)) + .andExpect(jsonPath("$.data[0].name").value("김기사")) + .andExpect(jsonPath("$.data[0].distanceKm").value(1.52)) + + // 두 번째 기사 데이터 확인 + .andExpect(jsonPath("$.data[1].transporterId").value(102L)) + .andExpect(jsonPath("$.data[1].name").value("이기사")); + } + + @Test + @DisplayName("[API] 존재하지 않는 배차 조회 시 - 실패 (예외 발생)") + void getNearbyDrivers_ApiFail_NotFound() throws Exception { + // given + Long invalidId = 9999L; + + // 배차가 없어서 Optional.empty() 반환 + given(dispatchRepository.findById(invalidId)).willReturn(Optional.empty()); + + // when & then + mockMvc.perform(get("/api/v1/dispatch/{dispatchId}/nearby-drivers", invalidId) + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + // GlobalExceptionHandler 설정에 따라 상태 코드는 달라질 수 있음 (보통 404 Not Found 또는 400 Bad Request) + // 여기서는 예외가 발생했는지, 그리고 우리가 정의한 ResultCode와 관련된 값이 나오는지 확인해야 함 + .andExpect(result -> { + Exception resolvedException = result.getResolvedException(); + if (resolvedException instanceof GlobalException) { + GlobalException ex = (GlobalException) resolvedException; + // 예외 코드가 DISPATCH_NOT_FOUND 인지 확인 + if (ex.getResultCode() != ResultCode.DISPATCH_NOT_FOUND) { + throw new AssertionError("기대했던 예외 코드가 아닙니다."); + } + } else { + // GlobalException이 아닌 다른 예외가 발생했거나 예외 처리가 안 된 경우 + // (프로젝트 설정에 따라 수정 필요) + } + }); + } +} \ No newline at end of file diff --git a/src/test/java/com/mobility/api/domain/transporter/service/TransporterServiceTest.java b/src/test/java/com/mobility/api/domain/transporter/service/TransporterServiceTest.java new file mode 100644 index 0000000..3b001f9 --- /dev/null +++ b/src/test/java/com/mobility/api/domain/transporter/service/TransporterServiceTest.java @@ -0,0 +1,66 @@ +package com.mobility.api.domain.transporter.service; + +import static org.junit.jupiter.api.Assertions.*; + +import com.mobility.api.domain.transporter.dto.TransporterDistanceProjection; +import com.mobility.api.domain.transporter.dto.response.TransporterMatchResponse; +import com.mobility.api.domain.transporter.repository.TransporterRepository; +//import com.mobility.api.global.util.DispatchMockFactory; // 위에서 만든 MockFactory import +import com.mobility.api.global.util.DispatchMockFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class TransporterServiceTest { + + @Mock + private TransporterRepository transporterRepository; + + @InjectMocks + private TransporterService transporterService; + + @Test + @DisplayName("기사 위치 검색 시 미터(m) 단위 거리가 km 단위로 소수점 반올림되어 반환된다.") + void verifyDistanceConversion() { + // given + double lat = 37.5547; + double lon = 126.9707; + + // Mock 데이터 생성 (Factory 활용) + // 기사1: 1523m 거리 -> 예상 1.52km + TransporterDistanceProjection driver1 = + DispatchMockFactory.createTransporterProjection(101L, "김기사", 1523.0); + + // 기사2: 500m 거리 -> 예상 0.5km + TransporterDistanceProjection driver2 = + DispatchMockFactory.createTransporterProjection(102L, "이기사", 500.0); + + // Repository Mocking: findNearbyTransporters 호출 시 위 리스트 반환 + given(transporterRepository.findNearbyTransporters(anyDouble(), anyDouble(), anyDouble())) + .willReturn(List.of(driver1, driver2)); + + // when + List result = transporterService.findNearbyTransporters(lat, lon); + + // then + assertThat(result).hasSize(2); + + // 첫 번째 기사 검증 (1523m -> 1.52km) + assertThat(result.get(0).transporterId()).isEqualTo(101L); + assertThat(result.get(0).distanceKm()).isEqualTo(1.52); + + // 두 번째 기사 검증 (500m -> 0.5km) + assertThat(result.get(1).transporterId()).isEqualTo(102L); + assertThat(result.get(1).distanceKm()).isEqualTo(0.5); + } +} \ No newline at end of file diff --git a/src/test/java/com/mobility/api/global/util/DispatchMockFactory.java b/src/test/java/com/mobility/api/global/util/DispatchMockFactory.java new file mode 100644 index 0000000..22fd381 --- /dev/null +++ b/src/test/java/com/mobility/api/global/util/DispatchMockFactory.java @@ -0,0 +1,52 @@ +package com.mobility.api.global.util; + +import com.mobility.api.domain.dispatch.entity.Dispatch; +import com.mobility.api.domain.transporter.dto.TransporterDistanceProjection; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; +import org.locationtech.jts.geom.PrecisionModel; +import org.mockito.Mockito; + +public class DispatchMockFactory { + + private static final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326); + + /** + * 가짜 배차(Dispatch) 엔티티 생성 + * 예: 서울역 (37.5547, 126.9707) + */ + public static Dispatch createDispatch(Long id) { + return Dispatch.builder() + .id(id) + .startLocation("서울역") + .startLatitude(37.5547) // 서울역 위도 + .startLongitude(126.9707) // 서울역 경도 + .build(); + } + + /** + * 가짜 기사 위치 정보(Projection) 생성 - Interface Mocking + * 예: 남산타워 기사 (서울역에서 약 1.5km 거리) + */ + public static TransporterDistanceProjection createTransporterProjection( + Long id, String name, double distanceMeters) { + + // Interface는 new로 생성할 수 없으므로 Mockito.mock() 사용 + TransporterDistanceProjection mock = Mockito.mock(TransporterDistanceProjection.class); + + Mockito.when(mock.getId()).thenReturn(id); + Mockito.when(mock.getName()).thenReturn(name); + Mockito.when(mock.getPhone()).thenReturn("010-1234-5678"); + Mockito.when(mock.getDistanceInMeters()).thenReturn(distanceMeters); + + return mock; + } + + /** + * (참고용) 실제 Point 객체 생성이 필요할 경우 사용 + */ + public static Point createPoint(double lat, double lon) { + return geometryFactory.createPoint(new Coordinate(lon, lat)); + } +} \ No newline at end of file